在 R 中的探索性相关分析
无痛且友好的 tidyverse 相关分析,使用 rstatix
·
关注 发表在 Towards Data Science · 7 分钟阅读 · 2023 年 5 月 5 日
–
图片由 Armand Khoury 提供,来源于 Unsplash
相关分析是探索两个或更多变量之间关系的最基本且基础的方法之一。你可能已经使用 R 进行了某些相关分析,结果可能看起来像这样:
cor_results <- cor.test(my_data$x, my_data$y,
method = "pearson")
cor_results
输出可能如下所示:
这是使用基本 R 对你预先选择的两个变量进行简单相关分析的方法。
但如果你真的不知道自己在寻找什么怎么办?如果你仅仅是在进行一些探索性数据分析的阶段,你可能不知道自己感兴趣的变量是什么,或者可能想要寻找关联的地方。在这种情况下,能够选择一个感兴趣的变量,然后对比包含多个、甚至数百个变量的数据集,以找出进一步分析的良好起点,可能会很有帮助。由于rstatix包的开发者kassambara的帮助,现在有一种快速且相对无痛的方法来做到这一点。
获取数据
例如,我将使用来自世界银行的世界发展指标(WDI)数据集的数据——这是一个关于全球发展指标的开放访问数据存储库。我们可以从上述链接的网站访问 WDI,但也有一个适用于此的 R 包——
install.packages("WDI")
library(WDI)
可以使用 WDI()函数从 WDI 中导入特定的数据系列,但由于我们感兴趣的是涵盖大量变量之间可能关系的探索性分析,我将批量下载整个数据库……
bulk <- WDIbulk(timeout = 600)
假设我们有兴趣尝试找出与贸易量更多的国家(相对于其经济规模)可能相关的其他国家特征,并且我们也对 2020 年的数据感兴趣。
一旦我们确定了正确的变量(这里我将使用贸易占 GDP 的百分比),我们需要对数据进行一些清理。我们将创建一个可以过滤的年度系列列表,然后应用另一个过滤步骤,以确保我们只使用在分析中有足够观察值的变量(在下面的示例中,任意设置为 n>100)。
# Create a filtered set with only annual variables
filtered <- bulk$Series %>% filter(Periodicity == "Annual")
# Create a list of variables to correlate against trade levels
bulk$Data %>%
filter(Indicator.Code %in% c(filtered$Series.Code)) %>%
filter(year == 2020) %>%
group_by(Indicator.Code) %>%
filter(!is.na(value)) %>%
count() %>%
arrange(n) %>%
filter(n>100) -> vars_list
运行分析
现在我们有一个变量列表——大约 790 个——来查看哪些可能与我们的贸易水平变量相关。手动运行这些,或者用 R 的 cor.test()循环处理,将需要永远的时间。这时 rstatix 中的 cor_test()函数就显得非常重要——它运行得相当快,相关分析的输出被转储到 tibble 格式中(便于进一步的操作和分析),并且这些函数支持管道操作,意味着我们可以将过滤、变换和执行步骤组合到一个管道框架中,也可以为 rstatix 的分组输出组合变量输入(稍后我们将查看一些例子)。
因此,为了运行分析:
# Because WDI contains regional data as well, we'll create a list that only has country codes, and filter our input based on that list
countries <- bulk$Country %>% filter(!Region == "") %>% as_tibble()
bulk$Data %>%
filter(Indicator.Code %in% c(vars_list$Indicator.Code)) %>%
filter(year == 2020) %>%
filter(Country.Code %in% c(countries$Country.Code)) %>%
select(-Indicator.Name) %>%
pivot_wider(names_from = Indicator.Code,
values_from = value) %>%
cor_test(NE.TRD.GNFS.ZS,
method = "pearson",
use = "complete.obs") -> results
results
这会填充一个 tidy 的 tibble,其中包含变量配对、相关系数(r)、t 统计量、置信水平(p)以及低和高置信度估计。对于我们上面的示例运行,它看起来是这样的:
因为输出是一个 tibble,我们可以按照我们想要的方式对其进行排序和分解。让我们用变量名称和描述制作一个关键字,将其加入到我们的输出数据中,过滤掉只有在 p > 0.05 水平上显著的变量对,然后查看哪个变量具有最高的 r 值:
indicator_explanation <- bulk$Series %>% select(Series.Code, Indicator.Name, Short.definition) %>% as_tibble()
results %>%
left_join(indicator_explanation, c("var2" = "Series.Code")) %>%
arrange(desc(cor)) %>%
filter(p<0.05) %>%
View()
一些相关性最高的变量不会令人惊讶——例如,整体贸易在各国之间与服务贸易和商品贸易高度正相关。其他的可能更为意外——比如贸易水平与一个国家作为总资本形成百分比的官方发展援助(援助资金)(通常用作援助“依赖性”指标——上图中的底部行)之间的中等高正相关(r = 0.43)。
分组分析
那么,如果我们想深入研究这种关系呢?例如——如果我们查看 2020 年以外的其他年份,这种关系是否仍然强烈?这时,cor_test() 的管道友好特性再次显得非常有用。
让我们过滤掉初始数据,只包括我们感兴趣的两个指标,然后按年份对数据进行分组,再将其传递到 cor_test() 中:
bulk$Data %>%
filter(Indicator.Code %in% c("NE.TRD.GNFS.ZS", "DT.ODA.ODAT.GI.ZS")) %>%
filter(Country.Code %in% c(countries$Country.Code)) %>%
select(-Indicator.Name) %>%
filter(year<2021) %>%
pivot_wider(names_from = Indicator.Code,
values_from = value) %>%
group_by(year) %>%
cor_test(NE.TRD.GNFS.ZS, DT.ODA.ODAT.GI.ZS,
method = "pearson",
use = "complete.obs") -> results_time
这将给我们提供每年观察到的两个变量之间的相关性数据(我筛选了数据,只包括 2021 年之前的年份,因为 ODA 数据仅到 2020 年为止)。而且由于相关性数据以整洁的方式存储,我们可以轻松地运行附加代码来可视化我们的结果:
results_time %>%
mutate(`Significant?` = if_else(p<0.05, "Yes", "No")) %>%
ggplot(aes(x = year, y = cor)) +
geom_hline(yintercept = 0,
linetype = "dashed") +
geom_line() +
ylab("cor (r)") +
geom_point(aes(color = `Significant?`)) +
theme_minimal()
在这里我们可以看到,历史上这两个变量之间几乎没有任何关系(除了偶尔几年的弱负相关),但在过去几年中,相关性呈现出显著且正向的趋势:
那这意味着什么呢?就贸易和援助之间的任何潜在问题而言——我们需要做更多的研究。毕竟,C相关性并不意味着因果关系,但这是一个很好的假设生成器——接受援助的国家是否变得越来越注重贸易?还是援助分配的模式转向更倾向于那些贸易更多的国家?这些都是我们可以探索的新方向。这些快速的相关性分析可以成为趋势分析或信号发现的一个非常有用的工具——而拥有一个友好的 tidyverse 方法来完成这项工作确实避免了潜在的麻烦。
就我们快速、轻松地进行一些有用的探索性分析的能力而言,我们可以看到 rstatix 是一个有用的附加包。然而,rstatix 中的 cor_test() 也有一些缺点 —
-
与“correlation”包中提供的更多方法相比,你只能使用 Pearson (r*)*、Spearman (ρ) 和 Kendall (τ) 相关性方法。不过,这些方法对于普通用户来说是最常见的,应该足以满足基本分析需求。
-
置信区间仅在 Pearson’s r 的输出中报告。这意味着如果需要 Spearman’s rho 或 Kendall’s tau 的置信区间,则需要额外的代码。
-
样本大小和自由度未被报告,这可能会让用户感到烦恼,例如当用户的目标是基于不同分组的段落开发多个报告时。
但这些通常不适用于普通用户。此外,除了 cor_test() 外,rstatix 还提供了大量其他函数用于各种统计测试和程序,下次你需要进行一些探索性统计分析时,绝对值得深入了解一下这些函数——为开发者点赞。
注意:想更深入了解 rstatix 与其他 R 中相关性分析包之间的差异,感兴趣的读者可以查看: https://www.r-bloggers.com/2021/01/correlation-analysis-in-r-part-2-performing-and-reporting-correlation-analysis/
喜欢这个故事吗?关注我在 Medium 上,或者在LinkedIn或Twitter上与我联系。
Google Sheets 中的探索性数据分析
原文:
towardsdatascience.com/exploratory-data-analysis-in-google-sheets-5df4d0e4d2dd
比较 Google Sheets 和 Pandas 方法
·发布于 Towards Data Science ·8 分钟阅读·2023 年 7 月 14 日
–
图片由作者生成
使用现代工具如 Pandas 或 Jupyter 处理数据总是很愉快。但让我们想象一下,如果一个同事或朋友要求进行数据分析,但他或她不是技术人员,不使用 Python 或 Jupyter,也没有 Tableau、Power BI 或其他花哨(但遗憾的是不免费的)服务的账户。在这种情况下,使用 Google Sheets 处理数据可以是一个不错的变通方法,原因有几个:
-
Google 在全球范围内使用;在撰写本文时,已有超过 18 亿用户拥有 Google 账户。现在几乎每个人都有 Google 账户,文档共享将变得非常容易。
-
Google 的生态系统是安全可靠的。它支持双重身份验证和现代安全标准,即使是私人数据集也可以在有限的人群中共享。
-
最后但同样重要的是,这个解决方案是免费的,不需要额外的费用。作为额外的好处,Google Sheets 在浏览器中运行,不需要安装任何软件,并且可以在 Windows、Linux、OSX 或甚至智能手机等任何平台上使用。
在本文中,我将使用 Pandas 进行基本的探索性数据分析,然后我们将重复这一过程在 Google Sheets 中,看看效果如何。
数据来源
为了增加趣味性,我们来使用一个真实的数据集。我们将制作一个计算太阳能电池板生成的能量的工具。为此,我将使用 PVGIS(欧洲委员会光伏地理信息系统)数据,可以通过 这个 URL 免费访问(CC BY 4.0 许可):
PVGIS 接口,图片由作者生成
使用这个页面,我们可以下载太阳辐射数据,从而计算能量生成。正如截图所示,我们可以选择不同年份和不同地点的小时数据。下载数据后,让我们在 Pandas 中使用它。
Pandas 中的 EDA
让我们从 Pandas 中的探索性数据分析(EDA)开始。使用熟悉的工具总是更容易,这也能让我们验证结果。首先,让我们加载数据集:
import pandas as pd
import datetime
df_eu = pd.read_csv("EUTimeseries_53.087_5.859_SA2_60deg_120deg_2020_2020.csv",
skiprows=8).dropna()
display(df_eu)
代码不言自明。CSV 文件的开头有评论和空行,因此我使用了“skiprows=8”来跳过不需要的数据;这就是读取文件所需的唯一“调整”。
输出如下:
太阳辐射数据集,图片由作者提供
我们有 8784 行,代表每小时收集的数据。根据文档,“G(i)”是以瓦特/平方米为单位的太阳辐射;其他参数,如风速或温度,对我们的任务没有用。时间戳不是标准的,让我们将字符串值转换为日期和时间对象。我还需要将“G(i)”值从字符串转换为浮点数:
def str_to_date(d: str):
""" Convert string to datetime object """
try:
return datetime.datetime.strptime(d, '%Y%m%d:%H%M')
except:
return None
def str_to_float(f: str):
""" Convert string value to float """
try:
return float(f)
except:
return None
df_eu['time'] = df_eu['time'].map(str_to_date)
df_eu['G(i)'] = df_eu['G(i)'].map(str_to_float)
现在我们可以进行所需的计算。数据集包含以瓦特每平方米为单位的太阳辐射数据。数据是以每小时间隔收集的,我们只需将值除以 1000 就能将瓦特转换为千瓦时(kWh)。为了得到最终的千瓦时输出,我们还需要知道太阳能板的数量及每个面板的尺寸和效率(这些数据可以在太阳能板的数据表中找到):
panels_amount = 1
panel_size_m2 = 2.5
panel_efficiency = 0.18
df_eu["kWh"] = panels_amount * panel_size_m2 * panel_efficiency * df_eu['G(i)'] / 1000
现在我们可以进行一些数据探索。让我们找出每天的太阳能发电量,例如,夏天的六月一号。我将使用 Bokeh Python 库来绘制结果:
from bokeh.io import show, output_notebook
from bokeh.models import ColumnDataSource
from bokeh.plotting import figure
output_notebook()
df_day = df_eu[df_eu['time'].dt.date == datetime.date(2020, 6, 1)]
source = ColumnDataSource(df_day)
p = figure(width=1600, height=600, x_axis_type='datetime',
title="Solar Panels Generation Per Day, kWh")
p.vbar(x='time', top='kWh', width=datetime.timedelta(minutes=50), source=source)
p.xgrid.grid_line_color = None
p.xaxis.ticker.desired_num_ticks = 12
p.y_range.start = 0
p.y_range.end = 0.4
show(p)
输出如下:
六月的每日太阳能板发电量,图片由作者提供
计算总发电量也很简单:
print("Total, kWh:", df_day["kWh"].sum())
> Total, kWh: 1.5560864999999997
我们的太阳能板在六月每天产生了 1.56 千瓦时。相比之下,同一面板在十二月的发电量要低得多:
十二月的每日太阳能板发电量,图片由作者提供
让我们查看每年的发电量并计算总输出。为此,我将按月对数据框进行分组:
df_eu["month"] = df_eu["time"].dt.month
df_eu_month = df_eu[["month", "kWh"]].groupby(["month"], as_index=False).sum()
display(df_eu_month.style.hide(axis="index"))
输出如下:
按月分组的太阳能板发电量,图片由作者提供
作为最后一步,让我们查看图表:
source = ColumnDataSource(data=dict(months=df_eu_month["month"],
values=df_eu_month["kWh"]))
p = figure(width=1600, height=600,
title="Solar Panels Generation Per Year, kWh")
p.vbar(x='months', top='values', width=0.95, source=source)
p.xgrid.grid_line_color = None
p.xaxis.ticker.desired_num_ticks = 12
show(p)
输出:
每年太阳能板发电量,图片由作者提供
正如我们所见,冬季和夏季月份之间存在显著差异。
最后,让我们计算全年总电力生成量:
print("Total, kWh:", df_eu["kWh"].sum())
> Total, kWh: 335.38783499999994
我们的 2.5 平方米太阳能板总共产生了 335 千瓦时电力。
现在,让我们完成 Python 编码,看看如何在 Google Sheets 中实现相同的操作。
Google Sheets
一般来说,我们的数据处理流程将与在 Pandas 中的处理方式相同。我们需要加载数据集,转换列值,过滤和分组值以查看结果。实际上,当我们知道自己想做什么时,我们可以以“跨平台”的方式思考,这使得处理变得更加容易。
首先,让我们加载数据集。我在 Google Sheets 中创建了一个包含两个标签页的文档,“Source”和“Calculation”,并将 CSV 文件导入了“Source”标签页:
Google Sheets 中的数据集,图像由作者提供
现在我们需要按月份对值进行分组。让我们提取月份值从时间戳列。在 Pandas 中,我们是这样做的:
df_eu["time"] = df_eu['time'].map(str_to_date)
df_eu["month"] = df_eu["time"].dt.month
在 Google Sheets 中,我在 G10 单元格中添加了这个公式:
=ArrayFormula(MID(A10:A8793, 5, 2))
在这里,G10 是第一个单元格,结果将位于此处,A10:A8793 是我们的时间戳数据。“MID”函数从字符串中提取月份(“20200101:0011” 是非标准时间戳,使用子字符串更容易),而“ArrayFormula”方法会将这个函数自动应用到整个表格中。输入公式后,Google Sheets 会自动为我们创建一个新列。
同样,让我们从“G(i)”创建一个“kWh”列。在 Pandas 中,我们是这样做的:
df_eu["kWh"] = df_eu['G(i)'] / 1000
在 Google Sheets 中,它的工作方式几乎相同。我在 H10 单元格中添加了这个公式:
=ArrayFormula(B10:B8793/1000)
作为最后的预处理步骤,让我们将*“Month”*和“*kWh”*的名称输入为标题。结果应该是这样的:
在 Google Sheets 中添加的列,图像由作者提供
我们有一个“月份”列,现在我们可以按月份分组数据。在 Pandas 中称为“groupby”的操作,在 Google Sheets 中可以使用“数据透视表”来完成。让我们创建一个新的数据透视表,并将“Source!A9:H8793”作为数据源。在这里,“Source”是第一个标签页的名称,A9:H8793 是我们的数据。表格编辑器将自动检测列名,我们可以选择“Month”作为“行”并将“kWh”作为“值”:
按月份分组的 kWh 值,图像由作者提供
结果显示在截图中。实际上,它与我们在 Pandas 中做的非常接近:
df_eu_month = df_eu[["month", "kWh"]].groupby(["month"], as_index=False).sum()
我们的数据集已经准备好了;让我们进行所需的计算。在“Calculation”标签页中,我将使用前面三个单元格作为太阳能电池板的“变量”(B1 单元格),每个面板的尺寸(B2 单元格)和面板效率(B3 单元格)。然后我可以通过添加一个公式来计算总生成量:
=ArrayFormula(B1*B2*B3*Source!K11:K22)
“Source!” 是我们第一个包含数据源的标签页的链接,K11–K22 是按月份分组的数据所在的单元格。我们的新结果将放在 B7–B18 单元格中,我们还可以计算总生成量:
=SUM(B7:B18)
添加额外的标签和图表很容易;这不需要任何公式,我将在这里跳过这一部分。我们最终按月份分组的生成数据应该是这样的:
太阳能电池板每年的发电量,作者提供的图片
显然,结果必须与我们在 Pandas 中得到的结果相同;否则,就有问题。Google Sheets 的用户界面是互动的;我们可以更改太阳能电池板的数量或面板效率,Google Sheets 将自动重新计算所有结果。
我们最后的数据探索步骤是获取特定日期的电力生成。为此,我将把所需日期放入“A24”单元格,并使用两个单元格来显示结果。第一个单元格将包含时间,第二个单元格将包含能量值:
=FILTER(MID(Source!A10:A8793, 10, 4), SEARCH(A24, Source!A10:A8793))
=FILTER(B1*B2*B3*Source!H10:H8793, SEARCH(A24, Source!A10:A8793))
在这里,*SEARCH(A24, Source!A10:A8793)*是应用于源表的过滤器;第一个公式用于获取一天中的时间,第二个公式用于计算 kWh 的能量。可以选择添加标签和摘要。最终页面可能如下所示:
结论
在这篇文章中,我们在 Google Sheets 中导入了太阳能发电数据集,并能够计算和可视化不同的参数,如每月或特定日期的太阳能电池板发电量。这个表格是互动的,任何没有技术或编程技能的人都可以使用。最后但同样重要的是,这个解决方案没有成本,文档可以安全地与任何拥有 Google 账户的人分享。
显然,社区里对“Excel 中的数据科学”有很多玩笑,我并不鼓励任何人将 Google Sheets 作为主要的生产工具。但对于需要与他人共享结果或制作简单数据处理界面的简单场景,它可以是一个不错的补充。正如我们所见,基本操作如制作图表、数据分组或提取子字符串效果良好。
感谢阅读。如果你喜欢这个故事,欢迎订阅Medium,你将收到我新文章发布的通知,并且可以完全访问其他作者的成千上万的故事。
探索性数据分析:揭示数据集中的故事
原文:
towardsdatascience.com/exploratory-data-analysis-unraveling-the-story-within-your-dataset-6a8b1acdde
探索数据的秘密艺术——理解、清理和揭示数据集中的隐藏见解
Deepak Chopra | Talking Data Science
·发表于 Towards Data Science ·8 分钟阅读·2023 年 7 月 6 日
–
由 Andrew Neel 在 Unsplash 上拍摄的照片
作为数据爱好者,探索一个新的数据集是一项激动人心的工作。它让我们深入了解数据,并为成功分析奠定基础。对一个新数据集有一个良好的感觉并不总是容易的,需要时间。然而,一个好的、彻底的探索性数据分析(EDA)可以帮助你更好地理解你的数据集,感受数据之间的连接,以及需要做什么来正确处理你的数据集。
事实上,你可能会把 80% 的时间花在数据准备和探索上,只有 20% 用于实际的数据建模。对于其他类型的分析,探索可能会占用你更多的时间。
**什么是探索性数据分析。
探索性数据分析,简单来说,就是探索数据的艺术。 这是从不同角度调查数据的过程,以增强你的理解,探索模式,建立变量之间的关系,并在必要时增强数据本身。
就像和你的数据集去‘盲目’约会一样,坐在这个神秘的数字和文本集合对面,渴望在开始一段严肃的关系之前理解它。就像盲目约会一样,EDA 允许你揭示数据集的隐藏面貌。你观察模式,检测异常值,探索细微差别,然后再做出任何重大承诺。这完全是关于了解和建立信任,与数字建立稳固的基础,确保在得出结论之前你是稳固的。
我们都经历过;无论是有意还是无意,深入统计工具或筛选报告——我们都曾在某个时点探索过某种数据!
**为什么。
作为分析师和数据科学家,我们应该最好地理解数据。当涉及到理解和解释数据时,我们必须成为专家。无论是机器学习模型、实验框架还是简单的分析——结果取决于数据的质量。
记住,垃圾进,垃圾出!!
EDA 使数据分析师和科学家能够探索、理解和从数据中提取有意义的见解。就在你认为一切都已弄清楚时,数据集却给你来了个意外。你发现了缺失值、不一致性和混乱的数据。这就像发现你的约会对象有一只秘密的宠物短吻鳄或一系列独角兽雕像。探索性数据分析为你提供了清理混乱和理解一切的工具。
——这就像给你的数据集一个大改造,将它从杂乱无章变成一个光彩夺目的伙伴。
最终,探索性数据分析的核心在于深入了解你的数据,在过程中享受乐趣,并为进一步分析奠定坚实基础。所以戴上你的侦探帽,和你的数据集一起踏上这段激动人心的冒险之旅。谁知道呢,你可能会发现隐藏的宝藏甚至是真爱!
**如何。
探索性数据分析,顾名思义,就是对数据进行探索的分析。它包含了多个组件;这些组件并非所有时候都是必需的,也并非所有组件都有同等重要性。以下,我将根据我的经验列出一些组件。
请注意,这绝不是详尽无遗的列表,而是一个指导框架。
1. 理解数据的现状。
你不知道自己不知道什么——但你可以探索!
首先要做的是感受数据——查看数据条目,观察列值。你有多少行,多少列。
-
一个零售数据集可能会告诉你——X 先生在 2023 年 8 月 1 日访问了 2000 号商店,并购买了一罐可乐和一包沃克脆片
-
一个社交媒体数据集可能会告诉你——Y 女士在 6 月 3 日早上 09:00 登录社交网站,浏览了 A、B 和 C 板块,搜索了她的朋友 A 先生,然后在 20 分钟后注销。
了解你拥有的数据的业务背景,了解数据的来源和收集机制是有益的;例如,调查数据与数字收集数据等。
2. 深入探讨变量
变量是数据集的“语言”,它们在不断与你交流。你只需要提出正确的问题,并仔细倾听。
→ 要问的问题:: - 变量的含义是什么?
-
这些变量是连续的还是分类的?… 是否有固有的顺序?
-
变量可能取什么值?
→ 行动::
-
对于连续变量——使用直方图、箱线图检查分布,并仔细研究均值、中位数、标准差等。
-
对于分类/有序变量——找出它们的唯一值,并进行频率表检查最常见/最少见的值。
你可能无法理解所有变量、标签和数值——但尽量获取尽可能多的信息
3. 查找数据中的模式/关系
通过 EDA,你可以发现数据中的模式、趋势和关系。
→ 需要问的问题:: *- 你是否对变量之间的关系有任何先前的假设/假设?
-
某些变量之间有业务上的关联理由吗?
-
变量是否遵循特定的分布?*
数据可视化技术、总结和相关性分析有助于揭示初看不明显的隐藏模式。理解这些模式可以为决策制定或假设生成提供有价值的见解。
→ 行动:: 思考双变量视觉分析。
-
对于连续变量——使用散点图、创建相关矩阵/热图等。
-
对于混合连续变量和有序/分类变量——考虑绘制条形图或饼图,并创建经典的列联表以可视化共现情况。
EDA(探索性数据分析)允许你验证统计假设,例如正态性、线性或独立性,以进行分析或数据建模。
4. 检测异常。
这是你成为数据上的福尔摩斯并寻找任何异常的机会!问问自己::
- 数据集中是否有重复条目?
重复项是指多次表示相同样本点的条目。在大多数情况下,重复项没有用处,因为它们不会提供任何额外的信息。它们可能是错误的结果,并且可能会干扰你的均值、中位数和其他统计数据。
→ 与你的利益相关者确认,并从数据中删除这些错误。
- 分类变量的标记错误?
查找分类变量的唯一值并创建频率图。查找拼写错误和可能表示相似事物的标签?
- 是否有变量缺失值?
这可能发生在数值和分类变量中。检查是否
-
是否有在很多变量(列)中缺失值的行? 这意味着有些数据点在大多数列中都是空白的 → 它们的用处不大,我们可能需要删除这些行。
-
是否有在多行中缺失值的变量(或列)? 这意味着有些变量在大多数数据点中没有值/标签 → 它们对我们的理解贡献不大,我们可能需要删除这些变量。
→行动::
计算所有变量的 NULL 或缺失值的比例。超过 15%-20%的变量应引起你的怀疑。
过滤掉某列中缺失值的行,并检查其余列的情况。是否大多数列一起有缺失值?…是否有模式?
- 我的数据集中是否存在异常值?
异常值检测是关于识别那些不符合常规的数据点。你可能会看到某些数值变量的非常高或极低的值,或者分类变量的高频/低频。
-
看似异常值的可能是数据错误。 虽然异常值是对于给定特征分布来说不寻常的数据点,但不需要的条目或记录错误是那些本来不应该存在的样本。
-
看似异常值的可能只是异常值。 在其他情况下,我们可能只是有一些极端值的数据点,并且背后有完全合理的解释。
→行动步骤::
研究直方图、散点图和频率条形图,以了解是否有一些数据点与其余数据点相距较远。思考:
这些值是否可能是真的,并且符合这些极端值?
对于这些极端值是否有业务上的理由或解释?
这些在后续阶段会对你的分析有价值吗?
5. 数据清洗。
数据清洗指的是从数据集中移除不需要的变量和值,并消除其中的任何不规则性。这些异常可能会不成比例地扭曲数据,从而对我们从该数据集中得出的分析结果产生不利影响。
记住:垃圾进,垃圾出。
- 纠正你的数据。
-
删除任何你发现的重复条目、缺失值和异常值——这些都没有为你的数据集增加价值。去除不必要的行/列。
-
纠正数据中你观察到的任何拼写错误或标签错误。
-
你发现的任何没有增加数据价值的数据错误也需要被移除。
- 截断异常值或保持现状。
- 在一些数据建模场景中,我们可能需要对异常值进行截断。截断通常在高端的第 99/95 百分位或低端的第 1/5 百分位进行。
- 处理缺失值。
我们通常会丢弃那些在变量中有很多缺失值的数据点(行)。同样,我们会丢弃那些在大量数据点中有缺失值的变量(列)。
如果有一些缺失值,我们可以考虑填补这些空缺,或者保持现状。
-
对于有缺失值的连续变量,我们可以通过使用均值或中位数(可能在特定分层中)来填补这些缺失值。
-
对于分类缺失值,我们可能会分配最常用的“类别”或创建一个新的“未定义”类别。
- 数据丰富化。
根据未来分析的需要,你可以向数据集中添加更多的特征(变量);例如(但不限于)
-
创建指示某事物存在或不存在的二元变量。
-
通过使用 IF-THEN-ELSE 子句创建额外的标签/类别。
-
根据未来分析的需求来缩放或编码你的变量。
-
结合两个或多个变量——使用各种数学函数,如求和、差异、均值、对数以及其他许多变换。
总结
EDA 使数据科学家能够发现有价值的见解,解决数据质量问题,并为进一步的分析和建模奠定坚实的基础。它确保数据分析的结果是可靠、准确且具有影响力的。
EDA 的关键组件:
-
了解数据的来源和“含义”。
-
了解所有变量及其分布、标签/类别。
-
寻找变量之间的模式/关系,以验证任何先前的假设或假定。
-
发现任何异常——数据错误、离群值、缺失值。
-
数据清理——删除或修正任何数据错误/异常,处理离群值,填补缺失值(如有需要),缩放/变换现有变量,并创建额外的衍生变量,丰富你的数据集,以便后续分析。
连接、学习与成长 …
如果你喜欢这篇文章并对类似内容感兴趣,可以在 Medium、LinkedIn、与我 1:1 联系、加入我的邮件列表 上关注我,(如果你还没有的话),快来成为 Medium 家庭的成员,以获取数千篇有用的文章。(如果你使用以上链接,我将获得你会员费用的 ~50%)
… 继续学习,继续成长!
探索性数据分析:我们对 YouTube 频道了解多少(第一部分)
原文:
towardsdatascience.com/exploratory-data-analysis-what-do-we-know-about-youtube-channels-3688c5cbc438
使用 Pandas 和 YouTube 数据 API 获取统计见解
·发表于Towards Data Science ·20 分钟阅读·2023 年 10 月 28 日
–
照片由 Glenn Carstens-Peters 拍摄,Unsplash
如今,活跃的 YouTube 用户超过 27 亿,对于很多人来说,YouTube 不仅仅是娱乐,更是重要的收入来源。但它是如何运作的呢?不同的 YouTube 频道可以获得多少观看次数或订阅者?借助 Python、Pandas 和 YouTube 数据 API,我们可以获得一些有趣的见解。
方法论
本文将分为几个部分:
-
使用 YouTube 数据 API。通过这个 API,我们将能够获取不同搜索请求的 YouTube 频道列表。对于每个频道,我们将获得有关视频数量、观看次数和订阅者的信息。
-
获取我们感兴趣的频道列表。这只能完成一次。
-
收集频道数据。为了获得统计见解,我们需要在一段时间内收集数据。
-
数据分析。
不再赘述,让我们开始吧。
1. YouTube 数据 API
首先,对于所有对从大型网络如 YouTube 收集数据感兴趣的人来说,有一个好消息:YouTube API 是免费的,我们无需支付费用。要开始使用这个 API,我们需要两个步骤:
- 打开
console.cloud.google.com
并创建一个新项目。我之前在那里有一个旧项目,但在一段时间不活动后,它的所有 API 限制都被重置为零,我找不到重置的方法。因此,创建一个新项目更为简单。
Google Cloud Console,图片由作者提供
- 前往“API 和服务”并启用“YouTube 数据 API”。打开 API,进入“凭据”并创建一个 API 密钥。如果一切设置正确,配额页面将如下所示:
YouTube API 配额,作者提供的图片
就这样;之后,我们可以开始发起 API 请求以获取 YouTube 数据。至于限制,免费配额为每天 10,000 次查询。计算这个配额有点复杂,因为它基于“内部”YouTube 查询,而不仅仅是 API 调用的数量。搜索请求是“重”的,例如,获取关于“智能手机评测”这一短语的 500 个频道的列表将消耗约 7,000 个“单位”。因此,我们每天只能用一个 API 密钥进行一次这样的搜索。但免费层允许我们拥有12 个项目,每个项目有单独的配额。所以任务比较简单,但我们仍需要将请求数量合理限制在一定范围内。
数据收集管道将包括两种类型的 API 调用:
-
首先,我们将创建一个关于不同主题的 YouTube 频道列表。这只需要做一次。
-
其次,我们可以获取每个频道的观看次数和订阅者数。我将使用 Apache Airflow 来运行这个任务,至少运行一周,每天两次。
2. 获取 YouTube 频道
在第一步中,我们启用了 YouTube API。现在,让我们创建一个我们感兴趣的频道列表。为了进行搜索,我将使用 python-youtube 库的 search_by_keywords
方法。作为示例,查询“猫”的输出如下所示:
{
"kind": "youtube#searchListResponse",
"etag": "h_RGyvb98m0yrxBgG0Q21J0ch94",
"nextPageToken": "CAIQAA",
"regionCode": "UK",
"pageInfo": {
"totalResults": 19544,
"resultsPerPage": 10
},
"items": [
{
"kind": "youtube#searchResult",
"etag": "N6_OLAdw4hCq2.....",
"id": {
"kind": "youtube#channel",
"channelId": "UCoV0b7wU....."
},
"snippet": {
"publishedAt": "2016-11-07T04:54:33Z",
"channelId": "UCoV0b7....",
"title": "1 stoner 3 cats",
"description": "MUST BE 18 OR OLDER FOR THIS CHANNEL...",
"thumbnails": {
"default": {
"url": "https://yt3.ggpht.com/ytc/APkrFKZKfv..."
},
"medium": {
"url": "https://yt3.ggpht.com/ytc/APkrFKZKfv..."
},
"high": {
"url": "https://yt3.ggpht.com/ytc/APkrFKZKfvuGIwwg..."
}
},
"channelTitle": "1 stoner 3 cats",
"liveBroadcastContent": "upcoming",
"publishTime": "2016-11-07T04:54:33Z"
}
},
...
],
"prevPageToken": null
}
在这里,我们关注 title
、channelId
和 publishedAt
参数。我们还可以看到 totalResults
值,这个值等于 19544。不过,遗憾的是,YouTube API 是为终端用户而设计的,而不是为了分析。我们不能获取所有关于“猫”的 YouTube 频道;这个 API 仅返回由 YouTube 推荐系统生成的 400-500 个频道的列表。
我们可以使用一个简单的程序,该程序针对特定短语进行 YouTube 查询并将结果保存到 CSV 文件中:
import datetime
import logging
from pyyoutube import Api # pip3 install python-youtube
def save_log(log_filename: str, s_data: str):
""" Save string to the log file """
with open(log_filename, "a", encoding="utf-8") as log_out:
log_out.write(s_data + "\n")
def search_by_keywords(api: Api, search_str: str, page_token: str):
""" Get YouTube channels list for a search phrase """
count = 10
limit = 25000
parts = ["snippet"]
res = api.search_by_keywords(q=search_str, limit=limit, count=count,
region_code="UK",
relevance_language="en",
search_type="channel",
order="title",
page_token=page_token, parts=parts,
return_json=True)
return res
def get_channels(api: Api, search_str: str):
""" Get YouTube channels list and save results in CSV file """
time_str = datetime.datetime.now().strftime('%Y-%m-%d-%H-%M-%S')
log_file = f"{search_str.replace(' ', '-')}-{time_str}.csv"
logging.debug(f"Log file name: {log_file}")
save_log(log_file, "channelId;publishedAt;title")
res = search_by_keywords(api, search_str, page_token=None)
next_page_token = res["nextPageToken"]
num_items = 0
while next_page_token is not None:
for item in res['items']:
title = item['snippet']['title'].replace(";", " ").replace(" ", " ")
description = item['snippet']['description'].replace(";", " ").replace(" ", " ")
log_str = f"{item['id']['channelId']};{item['snippet']['publishedAt']};{title} {description}"
logging.debug(log_str)
save_log(log_file, log_str)
num_items += 1
next_page_token = res["nextPageToken"]
logging.debug(f"{num_items} items saved to {log_file}")
res = search_by_keywords(api, search_str, page_token=next_page_token)
next_page_token = res["nextPageToken"]
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG,
format='[%(asctime)-15s] %(message)s')
key1 = "XXXXX"
youtube_api = Api(api_key=key1)
get_channels(youtube_api, search_str="cats")
作为输出,我们将获得如下所示的 CSV 文件:
channelId;publishedAt;title
UCoV0b7wUJ2...;2016-11-07T04:54:33Z;1 stoner 3 cats MUST BE ...
UCbm5zxzNPh...;2013-08-07T12:34:48Z;10 Cats ...
UCWflB-GzVa...;2013-09-25T10:39:41Z;13 Cats - Topic ...
UCiNQyjPsO9-c2C7eOGZhYXg;2023-10-09T22:51:37Z;2 CATS NO RULES ...
现在,我们可以使用不同的查询进行搜索。这只能做一次;频道 ID 不会改变。为了本文的目的,我使用了这些查询:
-
“猫”
-
“Dogs”
-
“化妆教程”
-
“摄影”
-
“智能手机评测”
-
“街头摄影”
结果是,我在 CSV 文件中保存了一个频道列表(每个查询大约 500 条记录),总共有大约 3000 个 YouTube 频道。
3. 获取频道详细信息
下一步,我们需要获取每个频道的统计数据。为此,我将使用相同 python-youtube 库中的 get_channel_info
方法:
def get_channel_info(api: Any,
file_out: str,
channel_id: str,
channel_title: str) -> int:
""" Get YouTube channel statistics """
res = api.get_channel_info(channel_id=channel_id, parts=["statistics"], return_json=True)
n_count = 0
if "items" in res:
time_str = datetime.datetime.now().strftime('%Y-%m-%d-%H-%M-%S')
for item in res["items"]:
ch_id = item["id"]
statistics = item["statistics"]
views = statistics["viewCount"]
subscribers = statistics["subscriberCount"]
videos = statistics["videoCount"]
s_out = f"{time_str};{ch_id};{channel_title};{views};{subscribers};{videos}"
logging.debug(f"Saving: {s_out}")
save_log(file_out, s_out)
n_count += 1
return n_count
可以这样使用这种方法:
api = Api(api_key="...")
get_channel_info(api, "cats_09_24.csv",
channel_id="UCbm5zxzNPh...",
channel_title="CATS NO RULES Its a Cats Life")
作为输出,我们将获得一个包含所需值的 CSV 文件:
timestamp;channelId;title;views;subscribers;videos
2023-10-09-19-42-19;UCoV0b7wUJ2...;1 stoner 3 cats MUST BE ...;14;2;6
2023-10-09-19-42-19;UCbm5zxzNPh...;CATS NO RULES Its a Cats Life;24;5;3
数据收集 现在,我们知道如何获取 YouTube 频道列表以及如何获取频道详细信息,例如观看次数和订阅者数量。但查看这些值的动态和它们如何随时间变化是很有趣的。YouTube 有一个单独的Analytics API,可以用于报告。然而,正如 API 文档中所写,“授权请求的用户必须是频道的拥有者”,因此对我们的任务来说是无用的。我们唯一的方法是收集一段时间的数据;1-2 周看起来是一个很好的时间段。
数据收集可以通过不同方式进行,我决定使用Apache Airflow,并在我的树莓派上安装了它。事实证明,树莓派是一个出色的数据科学工具,用于数据收集,我已经在几个爱好项目中使用过它。这台$50 的单板计算机仅消耗 2W 功率,静音,没有风扇,并且在 4 核 CPU 上运行完整的 Ubuntu。Raspbian OS 的配置细节超出了本文的范围;有兴趣的读者可以阅读我之前的 TDS 文章:
## 在树莓派上使用 Apache Airflow 进行数据收集
一台树莓派就是你所需要的一切
towardsdatascience.com
4. 探索性数据分析
预处理
最后,我们即将进入本文的有趣部分:让我们看看从收集的数据中可以获得什么样的见解。我将使用 Pandas 进行数据处理,使用 Matplotlib 和 Seaborn 绘制图表。
首先,让我们加载之前收集的数据。文件可以使用scp
命令从树莓派中复制(这里,10.14.24.168是设备地址,“pi”是标准的 Raspbian 用户名):
scp pi@10.14.24.168:/home/pi/airflow/data/*.csv data
Apache Airflow 每天执行代码两次,每次运行后保存一个带时间戳的 CSV 文件。一周后,我得到了一堆大约 80K 记录的 CSV 文件。让我们加载所有文件,并将它们合并到 Pandas 数据框中:
import glob
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
channel_files = glob.glob("data/channel*.csv")
channels_data = []
for file_in in channel_files:
channels_data.append(pd.read_csv(file_in, delimiter=';',
parse_dates=['timestamp'],
date_format="%Y-%m-%d-%H-%M-%S"))
df_channels = pd.concat(channels_data)
结果如下所示:
带有时间序列数据的数据框,图片由作者提供
作为提醒,在文章开头,我还收集了用于不同搜索请求的频道列表(“智能手机”、“猫”、“狗”等)。让我们将这个列表加载到第二个数据框中:
def load_channels(files: List, subject: str) -> pd.DataFrame:
""" Load and combine dataframe from several files """
dataframes = []
for csv in files:
df = pd.read_csv(csv, delimiter=";", parse_dates=["publishedAt"])
df["subject"] = subject
dataframes.append(df)
return pd.concat(dataframes).drop_duplicates(subset=["channelId"])
smartphones = load_channels(["smartphone-channels.csv"], subject="Smartphones")
dogs = load_channels(["dogs-channels.csv"], subject="Dogs")
cats = load_channels(["cats-channels.csv"], subject="Cats")
...
channels_all = pd.concat([smartphones, makeup, photography,
streetphotography, cats,
dogs]).drop_duplicates(subset=["channelId"])
加载频道列表可以实现自动化,但我只有 6 个类别,所以直接硬编码它们非常简单。我还添加了一个“subject”列来保存类别名称(重要的是要提到,“subject”不是由频道拥有者给出的“官方”频道类别,而是在搜索请求中使用的名称)。
此时,我们有两个 Pandas 数据框:一个包含基本频道数据(ID、标题和创建日期),另一个包含时间序列数据,包括观看次数、视频数量和订阅者数。让我们将这两个数据框 合并 在一起,以 channelId
作为键:
df_channels = df_channels.merge(
channels_all[["channelId", "publishedAt", "subject"]],
on=['channelId'],
how='left')
现在,我们准备开始有趣的部分了!让我们用 Seaborn 和 Matplotlib 可视化不同类型的数据。
4.1 观看次数和订阅者数量 作为热身,让我们按观看次数排序 YouTube 频道:
df_channels_ = df_channels.drop_duplicates(subset=["channelId"]).sort_values(by=['views'], ascending=False).copy()
df_channels_["views"] = df_channels_["views"].apply(lambda val: f"{val:,.0f}")
df_channels_["subscribers"] = df_channels_["subscribers"].apply(lambda val: f"{val:,.0f}")
display(df_channels_)
结果如下:
YouTube 频道,按观看次数排序,作者提供的图片
我们可以看到数值之间的差异非常大。列表中的顶级频道拥有数十亿的观看次数和数百万的订阅者。实际的数字大到我不得不在列中添加了上千个“,”分隔符!
题外话,我为什么不使用 Pandas Styler 对象呢?确实,这样写代码很简单:
display(df_channels_.style.format(thousands="."))
结果显示在小型数据框上效果很好。但至少在 Visual Studio Code 中,改变样式后,数据框不再显示为头、尾和“…”了,Visual Studio 总是显示所有 3030 行。如果有人知道解决方案,请在下面的评论中写出来。
看到数据框很不错,但图形形式会更清晰。让我们使用条形图绘制 观看次数:
decimation = 10
df_channels__ = df_channels_.reset_index(drop=True).iloc[::decimation, :]
sns.set(rc={'figure.figsize': (18, 6)})
sns.set_style("whitegrid")
fig, ax = plt.subplots()
sns.barplot(df_channels__, x=df_channels__.index, y="views", width=0.9, ax=ax)
ax.set(title='YouTube channels views',
xticks=range(0, df_channels__.shape[0], 50),
ylim=(0, None),
xlabel='Channel №',
ylabel='Views Total')
ax.ticklabel_format(style='plain', axis="y")
ax.yaxis.set_major_formatter(FuncFormatter(lambda x, p: format(int(x), ',')))
sns.despine(left=True)
plt.show()
绘图很简单,但需要一些小调整。再次,我使用了 FuncFormatter
添加“,”千位分隔符;否则,数字太大,不便于阅读。我还添加了 decimation=10
参数来减少数据框中的记录数;否则,垂直条形图太小了。尽管如此,我们可以看到该区域几乎是空的:
显然,通过使用 ylim
参数很容易调整纵坐标刻度,但我特意将其保留为这样,以便读者能够看到“顶级”频道与“其他”频道之间的真实差异。分布非常偏斜。几个顶级频道的观看次数达到数十亿,而与之相比,其他频道几乎不可见。在我列出的约 3000 个频道中,前 5%的频道拥有 95%的总观看次数。
我们还可以绘制 订阅者数量,其形状与之前的图表相同:
让我们使用 百分位数 获取更准确的数据:
display(df_channels_[["views", "subscribers"]].quantile(
[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]
).style.format("{:,.0f}"))
输出看起来是这样的:
四分位数数据,作者提供的图片
50 百分位数(或 0.5 分位数)是一个数字,显示了 50%的所有值低于该数字。例如,所有订阅者值的 50 百分位数仅为 16。这意味着尽管顶端的数字如同 Googleplex 一般,列表中 50%的频道订阅者数少于 16!这可能令人惊讶,但我们可以通过按订阅者数量对数据框进行排序并查看中间值来轻松验证这一点:
df_channels_ = df_channels.drop_duplicates(subset=["channelId"]).sort_values(by=['subscribers'], ascending=False).reset_index(drop=True)
display(df_channels_[df_channels_.shape[0]//2:])
结果确认了上表的正确性:
数据框的中间部分,作者提供的图片
所有这些数值可以让我们对预期的观看次数和订阅者数量有一个大致的了解。但是在这里,我只分析了我收集的 3030 个频道。我们能得到一个YouTube 频道总数,比如说,1 百万和 10 万订阅者的频道总数吗?我没有找到答案,这可能是 YouTube 的秘密,就像 Tinder 上男女用户的真实比例一样;)显然,YouTube 推荐系统有一个将“顶级”频道和“其他”频道混合在搜索结果中的算法,给新手提供了被观众看到的机会。
4.2 每注册日期的订阅者数量 了解某个 YouTube 频道是否有 1,000,000 次观看或订阅者很有趣,但频道主们多快能达到这个值呢?在 YouTube Data API 中,每个频道都有一个“publishedAt”参数,代表频道的创建日期。我们无法获取特定频道的历史数据,但我们可以通过散点图比较不同创建日期的频道。我还将用不同颜色区分不同类别,并添加平均线。
upper_limit = 1_000_000
df_channels_ = df_channels.drop_duplicates(subset=["channelId"]).copy()
df_channels_["subscribers_clipped"] = df_channels["subscribers"].clip(upper=upper_limit)
sns.set(rc={'figure.figsize': (18, 8)})
sns.set_style("white")
palette = sns.color_palette("bright")
fig, ax = plt.subplots()
# Add scatter plot and average lines
for ind, subj_str in enumerate(df_channels_["subject"].unique()):
df_subj = df_channels_[df_channels_["subject"] == subj_str]
# Draw scatter plot
markers = ["o" , "s" , "p" , "h"]
sns.scatterplot(data=df_subj, x="publishedAt", y="subscribers_clipped",
color=palette[ind],
marker=markers[ind % len(markers)],
label=subj_str,
ax=ax)
# Draw average
col_avg = df_subj["subscribers"].mean()
linestyles = ["--", ":", "-."]
linestyle = linestyles[ind % len(linestyles)]
ax.axhline(col_avg, color=palette[ind], label=subj_str + " Avg", linestyle=linestyle, linewidth=1.0, alpha=0.6)
ax.set(title='Channel Subscribers',
xlabel='Registration Date',
ylabel='Subscribers',
ylim=(0, upper_limit)
)
ax.ticklabel_format(style='plain', axis="y")
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=12))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
ax.yaxis.set_major_formatter(FuncFormatter(lambda x, p: format(int(x), ',')))
ax.spines['top'].set_color('#EEEEEE')
ax.spines['right'].set_color('#EEEEEE')
ax.spines['bottom'].set_color('#888888')
ax.spines['left'].set_color('#888888')
plt.legend(loc='upper right')
plt.show()
相比于之前的柱状图,结果提供了更多的信息:
订阅者数量分布,作者提供的图片
100 万订阅者是许多 YouTube 频道的一个“标志性”数值,我将这个值设置为图表的裁剪限制。我们可以看到,我列表中“最年轻”的 YouTube 频道在 2022 年初达到了这一点,所以频道主们花了将近两年时间才做到这一点(此分析在 2023 年底进行)。与此同时,还有一些“老”频道,甚至在 2010 年之前创建的,今天仍然没有达到 10 万订阅者。
关于平均值,它们也很有趣。我们可以看到,订阅“智能手机”相关频道的人更多,第二大热门类别是“化妆”。让我们再“放大”一下图表:
订阅者数量分布,作者提供的图片
在这里,我们可以看到,“猫”和“狗”类别的受欢迎程度平均要低得多(几乎低 10 倍)。“摄影”和“街头摄影”类别更加小众,即使获得 10 万订阅者对这些频道来说也可能是一个具有挑战性的目标。
4.3 每个视频的订阅者数量 这个问题对于那些想要开始自己 YouTube 频道的人可能很有趣。应该发布多少视频才能获得一定数量的观看或订阅者?我们知道每个频道的视频和订阅者数量,可以通过使用一个散点图来找到答案。我还会使用线性回归模型来绘制平均线:
from sklearn.linear_model import LinearRegression
import numpy as np
df_channels_ = df_channels.drop_duplicates(subset=["channelId"]).copy()
upper_limit = 100_000
right_limit = 1000
sns.set(rc={'figure.figsize': (18, 8)})
sns.set_style("white")
num_subjects = df_channels_["subject"].nunique()
palette = sns.color_palette("bright")
fig, ax = plt.subplots()
for ind, subj_str in enumerate(df_channels_["subject"].unique()):
# Filter by subject
df_subj = df_channels_[df_channels_["subject"] == subj_str].sort_values(by=['subscribers'], ascending=False)
# Draw scatter plot
markers = ["o" , "s" , "p" , "h"]
sns.scatterplot(data=df_subj, x="videos", y="subscribers",
color=palette[ind],
# palette=[palette[ind],
# hue="subject",
marker=markers[ind % len(markers)],
label=subj_str,
ax=ax)
# Make linear interpolation
df_subj = df_subj[10:] # Optional: remove top channels to exclude "outliers"
values_x = df_subj["videos"].to_numpy().reshape((-1, 1))
values_y = df_subj["subscribers"].to_numpy()
model = LinearRegression().fit(values_x, values_y)
x_val = np.array([0, right_limit])
y_val = model.predict(x_val.reshape((-1, 1)))
# Draw
linestyles = ["--", ":", "-."]
ax.axline((x_val[0], y_val[0]), (x_val[1], y_val[1]),
linestyle=linestyles[ind % 3], linewidth=1,
color=palette[ind], alpha=0.5,
label=subj_str + " Avg")
ax.set(title='YouTube Subscribers',
xlabel='Videos In Channel',
ylabel='Subscribers',
xlim=(0, right_limit),
ylim=(0, upper_limit)
)
ax.yaxis.set_major_formatter(FuncFormatter(lambda x, p: format(int(x), ',')))
ax.spines['top'].set_color('#EEEEEE')
ax.spines['right'].set_color('#EEEEEE')
ax.spines['bottom'].set_color('#888888')
ax.spines['left'].set_color('#888888')
plt.legend(loc='upper right')
plt.show()
在这里,我将值限制为 100,000 个订阅者和 1,000 个视频。我还从线性插值中排除了前 10 个频道,以使平均结果更具现实性。
输出结果如下:
订阅者数量在 0–100K 范围内,图像由作者提供
我们再次看到,“化妆”和“智能手机”频道每个视频获得的订阅者最多。“猫”和“狗”的平均线几乎是水平的。这是怎么回事?首先,正如我们在前一张图片中看到的,这一类别的订阅者平均数量通常较低。其次,我猜测更多的人发布关于猫和狗的视频,分布更为倾斜。
那么分布的顶部怎么样?好吧,那里有足够的频道,订阅者超过 1M,视频少于 1000 个:
订阅者数量在 0–10M 范围内,图像由作者提供
我认为这些是配备高端电影设备的专业工作室,预算也相当高。那分布的低部分怎么样?让我们看看另一张图:
订阅者数量在最低范围内,图像由作者提供
我惊讶于看到一些 YouTube 频道有 1,000–5,000 个视频,但只有 10–50 个订阅者。结果是,这些频道很可能是由机器人自动生成的;它们只有播放列表,没有视频,大多数没有观看量,也没有订阅者。这些频道的目的是什么?我不知道。一些其他频道属于真实的人,看到有人发布了超过 1000 个视频,每个视频每年只有 10–20 次观看,这有点令人遗憾。
4.4 频道动态 — 每日观看量 正如我们所知,使用公开的 YouTube API,我们只能获得当前时刻的观看量和订阅者数量,只有频道所有者才能获得历史数据。作为替代方案,我使用 Raspberry Pi 和 Apache Airflow 收集了一周的数据。现在,是时候看看我们能得到什么了。
在这种情况下,处理起来有点棘手。我需要获取每个频道的数据,按时间戳排序,并计算差值:
channels_data = []
channel_id = ...
df_channel_data = df_channels[df_channels["channelId"] == channel_id][["timestamp", "views", "subscribers", "videos"]].sort_values(by=['timestamp'], ascending=True).reset_index(drop=True).copy()
df_first_row = df_channel_data.iloc[[0]].values[0]
df_channel_data = df_channel_data.apply(lambda row: row - df_first_row, axis=1)
df_channel_data["channelId"] = channel_id
df_channel_data["days_diff"] = df_channel_data["timestamp"].map(lambda x: x.total_seconds()/(24*60*60), na_action=None)
df_channel_data[subj_str] = subj_str
channels_data.append(df_channel_data)
在这里,我使用apply
方法计算数据框中第一个值和其他值之间的差异。然后,我可以使用lineplot
绘制数据:
sns.lineplot(data=pd.concat(channels_data),
x="days_diff", y="views",
hue="channelId", palette=palette, linestyle=linestyle,
legend=False)
(完整代码更长,为了清晰起见,我只保留了必要部分)
如我们所知,分布是倾斜的。前 50 个频道的结果如下:
每周前 50 个频道观看次数,作者图片
如我们所见,顶级频道每天的观看次数可以超过几百万!
分布右侧情况如何?总的来说,我收集了 3030 个频道,这是右侧 1000 个频道的相同图表:
每周 1000 个 YouTube 频道观看次数,作者图片
这里的结果远不如预期。一些频道每周获得 50-100 次新观看,但大多数频道仅获得 10-20 次观看。YouTube 搜索限制在大约 500 个项目,但我可以猜测大多数 YouTube 用户从未滚动超过前 1-2 页。
4.5 渠道动态 — 每日订阅者数 让我们来看看订阅者数量的变化。代码是一样的,只不过我使用了“订阅者”列而不是“观看次数”。
结果很有趣。首先,让我们看看我列表中的前 50 个频道:
每周新频道订阅者,作者图片
如我们所见,顶级频道每天可以获得几千个新订阅者!在分布的右侧,结果再次不那么令人兴奋,但仍然很有趣:
每周新频道订阅者,作者图片
其中一个频道“突然”每天获得 100 个订阅者,但这一数值没有再增加。也许频道主支付了推广费用,或者其中一个视频突然走红——谁知道呢?其他频道每周仅获得 5-10 个新订阅者。
4.6 渠道动态 — 每日视频数 了解不同频道每天发布多少视频也很有趣。我们可以使用相同的代码轻松找到答案。首先,让我们看看前 50 个频道的新视频数量:
每日新视频,作者图片
这里是我列表右侧的 1000 个频道:
每日新视频,作者图片
有趣的是,数字并没有大幅度不同。但顶级频道显然发布的视频较少,他们显然更注重质量而非数量。他们每周只能制作一个视频,每个视频可能拥有超过 100 万的观看次数。然而,有些 YouTube 频道总共有超过 5000 个视频;他们每天发布几个视频。无论如何,这些频道都没有跻身前列,这很值得思考。
“意大利面图”可以展示我们一个大致趋势,但很难从中读取具体值。为了获得更精确的数据,我们可以为前 50 个频道绘制直方图:
每周新视频,作者图片
我们可以看到,有些频道每天发布多个视频,但大多数顶级频道每周只制作一个或更少的视频。显然,没有一种适用于所有类型的通用公式,关于猫的视频或智能手机或相机评论的视频可能需要完全不同的准备时间。欢迎读者按不同类别过滤频道,并自行做更详细的分析。
5. 奖励:异常检测
最后,对于耐心阅读到这里的读者,赠送一个小奖励。让我们应用异常检测算法,看看是否能找到一些不寻常的 YouTube 频道。我将使用无监督的IsolationForest算法。该算法本身基于二叉决策树。在每一步,树通过随机特征和随机阈值进行分支,直到每个点完全孤立或达到最大深度。之后,根据达到该点所需的树深度,为每个点分配“异常分数”。
我将使用每个视频的观看次数和订阅者数量作为度量标准。我还将contamination
值设置为 0.05;这是我们期望的异常比例。
from sklearn.ensemble import IsolationForest
df_channels_ = df_channels.sort_values(by=['videos'], ascending=False).drop_duplicates(subset=["channelId"]).copy().reset_index(drop=True).copy()
df_channels_ = df_channels_[df_channels_["videos"] > 10]
df_channels_["subscribers_per_video"] = df_channels_["subscribers"]/df_channels_["videos"]
df_channels_["views_per_video"] = df_channels_["views"]/df_channels_["videos"]
df_channels_[["subscribers_per_video", "views_per_video"]] = df_channels_[["subscribers_per_video", "views_per_video"]].apply(pd.to_numeric)
X = df_channels_[["subscribers_per_video", "views_per_video"]]
model = IsolationForest(contamination=0.05, random_state=42).fit(X)
df_channels_['anomaly_scores'] = model.decision_function(X)
df_channels_['anomaly'] = model.predict(X)
# Anomaly: Outlier (-1) or an inlier (1)
# Anomaly_scores: The lower the score, the more abnormal is the sample
display(df_channels_.sort_values(by=['anomaly_scores'], ascending=True)[:30])
我们来按异常分数对频道进行排序。结果如下所示:
在我们“异常评分”的第一位,我们看到一个来自“猫”类别的频道,这个频道确实每个视频的订阅者数量很高。我看了这个频道;虽然我不是猫视频的粉丝,但从技术上讲它确实做得很好。这也是我第一次看到一个有 193M 观看次数的视频(我必须承认,没有哪个关于数学或机器学习的视频能达到这一点;)。在我的“评分”中的第二个频道是关于化妆的。我对这一领域绝对不是专家,本来打算跳过它,但其中一个视频引起了我的注意。作者请 ChatGPT 写化妆步骤。我从未考虑过使用 AI 来化妆,尽管看到 AI 如何影响我们生活的越来越多领域还是很有趣。
有时很容易猜测为什么某个项目具有高异常评分,但如果特征数量很大,这可能会变得复杂。在这种情况下,我们可以使用SHAP库来可视化结果:
import shap
X = df_channels_[["subscribers_per_video", "views_per_video"]]
y_pred = model.predict(X)
explainer = shap.Explainer(model.predict, X)
shap_values = explainer(X)
shap.initjs()
explainer
方法使用Shapley 值来解释不同的机器学习模型,并且也可以与IsolationForest
一起使用。初始化后,我们可以检查列表中的不同项目。让我们检查第一个:
shap.plots.waterfall(shap_values[786])
结果如下所示:
Shapley 解释器结果,作者提供的图片
在另一个例子中,views_per_video
参数看起来正常,但subscribers_per_video
值很高:
Shapley Explainer 结果,图片由作者提供
在这种情况下,我们可以看到两个指标都异常高。
结论
在本文中,我解释了如何使用 YouTube 数据 API 和 python-youtube 库获取 YouTube 频道数据。这些数据允许我们对不同类别进行 YouTube 搜索请求,并获得有关 YouTube 频道的有趣统计见解。
我想每个读者今天或昨天至少看过一个 YouTube 视频。根据 demandsage.com,YouTube 是仅次于 Google 的第二大搜索引擎,2023 年有 27 亿活跃用户。它是我们现代社会的一部分,也是日常生活的一部分。因此,从文化和研究的角度来看,了解哪些类别最受欢迎以及不同频道可以获得多少观看次数和订阅者是很有趣的。在本文中,我使用了像“猫”或“狗”这样的“中性”类别,但同样的方法可以用于收集关于政治、战争、医学、阴谋论或其他任何话题的数据。最后但同样重要的是,对许多内容创作者来说,YouTube 是一个重要的收入来源,了解不同类别能获得多少观看次数或订阅者可能至关重要。因此,我鼓励你作为读者,对你感兴趣的话题进行相同的测试。无论如何,统计学是一门 关于我们的 科学。
在故事的第二部分,我将重点关注单个视频。我们将查看不同 YouTube 频道发布视频的频率,以及这些视频能获得多少观看次数:
## 探索性数据分析:我们对 YouTube 频道了解多少(第二部分)
使用 Pandas 和 YouTube 数据 API 获取统计见解
towardsdatascience.com
对社会数据分析感兴趣的人也欢迎阅读其他文章:
如果你喜欢这个故事,欢迎订阅Medium,你将会在我的新文章发布时收到通知,并且可以全面访问其他作者的成千上万篇故事。如果你想获取这篇文章以及我下一篇文章的完整源代码,欢迎访问我的Patreon 页面。
感谢阅读。
探索性数据分析:我们对 YouTube 频道了解了什么(第二部分)
使用 Pandas 和 YouTube Data API 获取统计见解
·发表于 Towards Data Science ·阅读时间 14 分钟·2023 年 11 月 24 日
–
图片来源:Souvik Banerjee,Unsplash
在第一部分中,我从大约 3000 个 YouTube 频道收集了统计数据,并获得了一些有趣的见解。在这一部分,我将进一步深入,从通用的“频道”层面到个别的“视频”层面。我将展示如何收集 YouTube 视频的数据以及我们可以获得什么样的见解。
方法论
为了收集 YouTube 视频的数据,我们需要执行几个步骤:
-
获取 YouTube Data API 的凭证。它是免费的,API 每天 10,000 次请求的限制足够满足我们的任务需求。
-
找到几个我们想要分析的 YouTube 频道。
-
编写一些 Python 代码来获取所选频道的最新视频及其统计数据。YouTube 分析功能仅对频道所有者开放,我们只能获取当前时刻的数据。但我们可以运行代码一段时间。在我的案例中,我使用 Apache Airflow 和 Raspberry Pi 收集了三周的数据。
-
执行数据分析。我将使用 Pandas、Matplotlib 和 Seaborn 来完成这项工作。
获取 YouTube API 凭证和配置 Apache AirFlow 的过程在我之前的文章中有描述,我建议读者暂停阅读本篇文章,先阅读那部分内容:
## 探索性数据分析:我们对 YouTube 频道了解了什么
使用 Pandas 和 YouTube Data API 获取统计见解
towardsdatascience.com
现在,让我们开始吧。
1. 获取数据
要获取有关 YouTube 视频的信息,我将使用一个python-youtube库。令人惊讶的是,没有现成的方法可以从特定频道获取视频列表,我们需要自己实现。
首先,我们需要调用get_channel_info
方法,它顾名思义,将返回有关频道的基本信息。
from pyyoutube import Api
def get_channel_info(api: Api, channel_id: str) -> Tuple[str, str, str]:
""" Get info about the channel. Return values: title, uploads, subscribers """
channel_info = api.get_channel_info(channel_id=channel_id, parts=["snippet", "statistics", "contentDetails"], return_json=True)
if len(channel_info["items"]) > 0:
item = channel_info["items"][0]
title = item["snippet"]["title"]
uploads = item["contentDetails"]["relatedPlaylists"]["uploads"]
subscribers = item["statistics"]["subscriberCount"]
return title, uploads, subscribers
logging.warning(f"get_channel_info::warning cannot get data for the channel {channel_id}")
return None, None, None
api = Api(api_key="...")
get_channel_info(api, channel_id="...")
输出如下:
"items": [
{
"id": "UCBJycsmd...",
"snippet": {
"title": "Mar...",
"description": "MKBH...",
"publishedAt": "2008-03-21T15:25:54Z",
"contentDetails": {
"relatedPlaylists": {
"likes": "",
"uploads": "UUBJy..."
}
},
"statistics": {
"viewCount": "3845139099",
"subscriberCount": "17800000",
"hiddenSubscriberCount": false,
"videoCount": "1602"
}
}
]
在这里,我们有一个statistics
部分,包含频道的视频数量、观看次数和订阅者数。第二部分是contentDetails
;这是我们需要的,因为它包含“uploads”列表的 ID。正如我们所见,频道上传的视频作为“虚拟”播放列表进行存储,这让我有些惊讶。
之后,我们需要调用get_playlist_items
方法,它会返回所需播放列表中的视频列表。
def get_playlist_items(api: Api, playlist_id: str, limit: int) -> List[Tuple[str, str]]:
""" Get video IDs for a playlist """
videos = []
playlist_items = api.get_playlist_items(playlist_id=playlist_id, count=10, limit=10, parts=["contentDetails"], return_json=True)
next_page_token = playlist_items["nextPageToken"]
while next_page_token is not None:
for video in playlist_items["items"]:
video_id = video["contentDetails"]["videoId"]
video_published_at = video["contentDetails"]["videoPublishedAt"]
# views, likes, comments = get_video_by_id(api, video_id)
videos.append([video_id, video_published_at])
next_page_token = playlist_items["nextPageToken"]
playlist_items = api.get_playlist_items(playlist_id=playlist_id, count=10, limit=10,
parts=["contentDetails"], return_json=True,
page_token=next_page_token)
if len(videos) >= limit:
break
return videos
输出如下:
"items": [
{
"kind": "youtube#playlistItem",
"etag": "tmSJMm9_KwkNTPkpdspUkQiQtuA",
"id": "VVVCSnljc21kdXZZRU...",
"contentDetails": {
"videoId": "Ks_7TmG...",
"videoPublishedAt": "2023-10-28T13:09:50Z"
}
},
...
]
在这里,我们需要videoId
和videoPublishedAt
字段。
只有在这一步,拥有视频 ID 列表后,我们才能找到每个视频的观看次数、点赞数和评论数:
def get_video_by_id(api: Api, video_id: str) -> Tuple[str, str, str]:
""" Get video details by id """
video_info = api.get_video_by_id(video_id=video_id, parts=["statistics"], return_json=True)
if len(video_info["items"]) > 0:
item = video_info["items"][0]
views = item["statistics"]["viewCount"]
likes = item["statistics"]["likeCount"]
comments = item["statistics"]["commentCount"]
return views, likes, comments
return None, None, None
作为最终步骤,我创建了一个将所有这些部分组合在一起的方法:
def get_channel_videos(api: Api, channel_id: str, limit: int) -> List:
""" Get videos for the channel """
videos_data = []
title, uploads, subscribers = get_channel_info(api, channel_id)
if title is not None and uploads is not None:
title_ = title.replace(";", ",")
videos = get_playlist_items(api, uploads, limit)
for video_id, video_published_at in videos:
views, likes, comments = get_video_by_id(api, video_id)
videos_data.append((channel_id, title_, subscribers, video_id, video_published_at, views, likes, comments))
return videos_data
limit
变量对调试很有帮助;它允许我们减少每个查询的请求数量,避免超过 API 配额限制。
如前所述,只有频道所有者才能获取历史和分析数据;我们只能获得当前时刻可用的数据。但我们可以定期请求数据(视频数量及其观看次数、点赞数和评论数)。使用Apache Airflow在树莓派上运行,我让这段代码运行了三周。每 3 小时执行一次请求,每次请求的输出都保存为 CSV 文件(更多详细信息和 DAG 示例见第一部分)。现在让我们看看能得到什么样的见解。
2. ETL(提取、转换、加载)
和往常一样,在使用数据进行分析之前,我们需要将其转换为方便的形式。我们的 ETL 过程非常简单。从 Apache AirFlow 任务中,我获得了大量的 CSV 文件。让我们加载这些文件并将它们合并成一个数据集:
import pandas as pd
import glob
channel_files = glob.glob("data/video*.csv")
channels_data = []
for file_in in channel_files:
channels_data.append(pd.read_csv(file_in, delimiter=";",
parse_dates=["timestamp"],
date_format="%Y-%m-%d-%H-%M-%S"))
df_channels = pd.concat(channels_data)
让我们检查一个视频的结果:
display(df_channels.query('videoId == "8J...4"').sort_values(by=["timestamp"], ascending=True))
输出如下:
示例数据框,图片由作者提供
每行包含一个时间戳、视频 ID、视频发布时间,以及在数据收集时的观看次数、点赞数和评论数。我们可以看到,视频8J…4
于 2023 年 10 月 27 日 19:00 发布。在我观察开始时,它已经有 514,948 次观看,而在数据框的末尾,观看次数增加到了 978,573 次。
现在,我们准备开始一些有趣的操作。
3. 数据分析
3.1 观看次数 作为热身,我们先展示每个视频的观看次数。我将只使用最近两个月内制作的视频。
channel_id = "UCu..."
df_channel = df_channels[df_channels["channelId"] == channel_id]
df_channel = df_channel.sort_values(by=['timestamp'], ascending=True)
# Videos published within interval
days_display = 2*31
start_date = df_channel["timestamp"].max() - pd.Timedelta(days=days_display)
end_date = df_channel["timestamp"].max()
df_channel = df_channel[(df_channel["videoPublishedAt"] >= start_date) &
(df_channel["videoPublishedAt"] < end_date)]
我每 3 小时收集一次频道数据,因此只需要最后的时间戳:
step_size = 3
interval_start = df_channel["timestamp"].max() - pd.Timedelta(hours=step_size)
interval_end = df_channel["timestamp"].max()
df_interval = df_channel[(df_channel["timestamp"] >= interval_start) &
(df_channel["timestamp"] < interval_end)]
df_interval = df_interval.drop_duplicates(subset=["videoId"])
v_days = df_interval["videoPublishedAt"].values
v_views = df_interval["viewCount"].values
让我们使用 Matplotlib 绘制条形图:
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
fig, ax = plt.subplots(figsize=(16, 4))
cmap = plt.get_cmap("Purples")
views_max = 3_000_000
views_avg = df_channel.drop_duplicates(subset=["videoId"], keep="last")["viewCount"].median() # Median value
rescale = lambda y: 0.5 + 0.5 * y / views_max
# Bar chart
ax.bar(v_days, v_views,
color=cmap(rescale(v_views)),
width=pd.Timedelta(hours=12))
# Add horizontal median line
ax.axhline(y=views_avg, alpha=0.2, linestyle="dotted")
trans = ax.get_yaxis_transform()
ax.text(0, views_avg, " Median ", color="gray", alpha=0.5, transform=trans, ha="left", va="bottom")
# Title
subscribers = df_channel.iloc[[0]]["subscribers"].values[0]
title_str = f"YouTube Channel, {subscribers/1_000_000:.1f}M subscribers"
# Adjust axis
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d/%m"))
ax.yaxis.set_major_formatter(FuncFormatter(lambda x, p: format(int(x), ",")))
ax.xaxis.set_major_locator(mdates.WeekdayLocator(byweekday=mdates.SU))
ax.set(title=title_str,
xlabel="Video Publication Date",
ylabel="Views",
xlim=(start_date, end_date),
ylim=(0, views_max))
plt.tight_layout()
plt.show()
在这里,我使用ax.bar
绘制条形图,并用rescale
函数调整条形的颜色。水平中位线有助于查看观看次数是否高于或低于平均水平。
首先,让我们看看一个拥有 2390 万订阅者的频道,它发布“化妆”类别的视频:
最近 2 个月的观看次数,作者提供的图片
结果很有趣。我们可以看到每个视频的观看次数基本一致,中位数大约是 100 万次。这个数字多吗?这个值显然很大。但频道几乎有 2400 万订阅者,这些订阅者应该对这些内容感兴趣,并在发布新视频时收到通知。我认为 1/24 的比例看起来并不大;也许人们订阅了但很快失去了兴趣?
另一个有趣的“异常”引起了我的注意。有时,我将显示间隔设置为 1 年,这时可以看到观看次数大幅增加:
每年观看次数,作者提供的图片
显然,作者发布了大量的“短视频”,在那段时间内获得了许多(300 万到 1000 万)观看次数。后来发生了什么?也许频道的主编换了?也许制作“短视频”不再盈利?我不知道。可能可以在网页浏览器中观看所有视频并尝试找出原因,但这绝对超出了这个测试的范围,而且我也不是化妆方面的专家。
作为另一个例子,让我们看看另一个拥有 1780 万订阅者的频道,这个频道制作小工具评测:
观看次数,作者提供的图片
我不知道这些结果是否与内容有关(“小工具评测”和“化妆”自然面向不同的受众),但与第一个频道相比,这个频道每个视频的中位观看次数要高得多。
现在让我们看看受众较小的频道能获得多少观看次数。这个与小工具相关的频道拥有130 万订阅者:
观看次数,作者提供的图片
差异是显著的。一个拥有 17.8M 订阅者的频道每个视频大约获得 300 万次观看,而一个拥有 1.3M 订阅者的频道仅获得“仅”30 万次观看。为了比较,下一个与摄影相关的频道拥有115K 受众:
观看次数,图片来自作者
在这种情况下,频道的每个视频平均观看次数为 25K。
显然,视频不仅向订阅者展示,还通过 YouTube 推荐系统展示给任何人。真实的比例是什么?我们不知道。从柱状图来看,我可以猜测只有大约 20%的订阅者是“活跃的”。其他人可能很久以前订阅了,已经对内容不再感兴趣。这是有道理的;例如,如果我要买一台笔记本电脑,我可以订阅一个硬件评测频道,但在购买后我可能就不再感兴趣了。
3.2 观看次数动态 我们能够看到每个视频的观看次数,但视频获取这些观看次数的速度有多快?我们已经有了一个 Matplotlib 柱状图;让我们对它进行动画处理!只有频道所有者可以访问历史数据,但我在三周内进行了请求,我们可以轻松地看到这些值在这个时间间隔内是如何变化的。为此,我们只需更新图表:
import matplotlib.animation as animation
def animate_bar(frame_num: int):
""" Update graph values according to frame number """
interval_start = df_channel["timestamp"].min() + pd.Timedelta(hours=step_size*frame_num)
interval_end = df_channel["timestamp"].min() + pd.Timedelta(hours=step_size*(frame_num+1))
day_str = interval_start.strftime('%d/%m/%Y %H:00')
days, views = get_views_per_interval(df_channel, interval_start, interval_end)
print(f"Processing {day_str}: {views.shape[0]} items")
bar = ax.bar(days, views,
color=cmap(rescale(views)),
width=pd.Timedelta(hours=bar_width))
day_vline.set_xdata([interval_start])
ax.set(title=f"{title_str}: {day_str}")
return bar,
step_size = 3
num_frames = (df_channel["timestamp"].max() - df_channel["timestamp"].min())//pd.Timedelta(hours=step_size)
anim = animation.FuncAnimation(fig, animate_bar, repeat=True, frames=num_frames)
writer = animation.PillowWriter(fps=5)
anim.save("output.gif", writer=writer)
在这里,我创建了一个FuncAnimation
对象,其中animate_bar
函数作为参数传递。这个函数会自动调用不同的帧编号;在这个函数内部,我创建了一个新的柱状图并更新了标题。我还添加了一条垂直线,代表当前日期。
输出结果如下:
3 周内的观看次数,图片来自作者
从这个动画中,我们可以看到一个新视频显然在第一周内获得了至少 70%的观看次数。旧视频也会获得一些观看次数,但这个过程要慢得多。
但也可能会有例外。在下一个例子中,一个频道的每个视频的中位数观看次数为 90K,但其中一个视频可能变得病毒式传播,被大量分享,并在 2 到 3 周内获得了大约一百万次观看:
3 周内的观看次数,图片来自作者
3.3 观看次数分布 在观看了柱状图后,我问自己一个问题:观看次数的分布是否正常?显然,有些视频的观看次数比其他视频多,但这种情况有多一致?使用 Seaborn 的histplot
方法很容易找到答案。
import seaborn as sns
channel_id = "UCu..."
df_channel = df_channels[df_channels["channelId"] == channel_id]
display(df_channel.drop_duplicates(subset=["videoId"]))
step_size = 3
interval_start = df_channel["timestamp"].max() - pd.Timedelta(hours=step_size)
interval_end = df_channel["timestamp"].max()
df_interval = df_channel[(df_channel["timestamp"] >= interval_start) & (df_channel["timestamp"] < interval_end)].drop_duplicates(subset=["videoId"])
# Title
subscribers = df_channel.iloc[[0]]["subscribers"].values[0]
title_str = f"YouTube Channel, {subscribers/1_000_000:.1f}M subscribers"
# Median
views_avg = df_channel["viewCount"].median()
# Draw
fig, ax = plt.subplots(figsize=(12, 5))
sns.set_style("white")
sns.histplot(data=df_interval, x="viewCount", stat="percent", bins=50)
ax.set(title=title_str,
xlabel="Views Per Video",
ylabel="Percentage",
xlim=(0, None),
ylim=(0, 18)
)
ax.axvline(x=views_avg, alpha=0.2, linestyle="dotted")
ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: format(int(x), ',')))
plt.tight_layout()
plt.show()
对于这个测试,我将 500 作为 API 请求的视频限制。一个“电子产品评测”类别频道的结果如下:
观看次数分布,图片来自作者
1780 万订阅者是一个大数字。这个频道绝对是顶级之一,正如我们所见,它产生的结果或多或少是一致的。分布看起来正常,但略有偏斜。该图表的中位值是每个视频 380 万次观看,但一些视频的观看次数超过了 1000 万次,且 500 个视频中只有 3 个超过了 2000 万次。
在其他订阅者较少的频道中也可以看到类似的模式,但在这种情况下,分布更加偏斜:
观看次数分布,作者提供的图片
这些数据可能需要更详细的分析。例如,结果显示“正常视频”和“短视频”可以有截然不同的观看次数,理想情况下应该分开分析。
3.4 附加内容:单个视频的观看次数 这篇文章已经很长了,我会给那些耐心读到这一步的读者一个附加内容。在 3.2 中,我做了动画,显示大多数视频在发布后很快获得了大量观看(顺便提一下,这对于 TDS 和 Medium 文章也是如此)。我们能更详细地看到这个过程吗?实际上可以。我在几周内收集了数据,期间有足够的视频发布。找到最新的视频很简单,因为我们有一个videoPublishedAt
参数:
# Find the newest videos for a specific channel
df_channel = df_channels[df_channels["channelId"] == "UCB..."]
num_videos = 5
df_videos = df_channel.drop_duplicates(subset=["videoId"]).sort_values(by=["videoPublishedAt"], ascending=False)
提醒一下,特定视频的数据如下:
数据框示例,作者提供的图片
然后,我“标准化”了这些数据:我的目标是显示从发布时间开始的观看次数,我将其视为“0”:
def get_normalized_views(df_channel: pd.DataFrame, video_id: str) -> pd.DataFrame:
""" Get relative views for a specific video """
df_video = df_channel[df_channel["videoId"] == video_id].sort_values(by=['timestamp'], ascending=True)
# Insert empty row with zero values at the beginning
video_pub_time = df_video.iloc[[0]]["videoPublishedAt"].values[0]
start_row = {'videoPublishedAt': video_pub_time,
'timestamp': video_pub_time,
'viewCount': 0, 'likeCount': 0, 'commentCount': 0}
df_first_row = pd.DataFrame(start_row, index=[0])
df_video_data = df_video[df_first_row.columns]
df_video_data = pd.concat([df_first_row, df_video_data], ignore_index=True)
# Make timestamps data relative, starting from publication time
df_first_row = df_video_data.iloc[[0]].values[0]
df_video_data = df_video_data.apply(lambda row: row - df_first_row, axis=1)
df_video_data["daysDiff"] = df_video_data["timestamp"].map(lambda x: x.total_seconds()/(24*60*60), na_action=None)
return df_video_data
这里,我还将时间戳转换为从发布时间开始的天数,以使图表更方便阅读。
现在,我们可以使用 Matplotlib 绘制图表:
fig, ax = plt.subplots(figsize=(10, 6))
# Title
subscribers = df_channel.iloc[[0]]["subscribers"].values[0]
title_str = f"YouTube Channel with {subscribers/1_000_000:.1f}M Subscribers, Video Views"
# Videos data
for p in range(num_videos):
video_id = df_videos.iloc[[p]]["videoId"].values[0]
df_video_data = get_normalized_views(df_channel, video_id)
plt.plot(df_video_data["daysDiff"], df_video_data["viewCount"])
# Params
ax.set(title=title_str,
xlabel="Days Since Publication",
ylabel="Views",
xlim=(0, None),
ylim=(0, None))
ax.yaxis.set_major_formatter(FuncFormatter(lambda x, p: format(int(x), ',')))
ax.tick_params(axis='x', rotation=0)
plt.tight_layout()
plt.show()
结果看起来是这样的:
每个视频的观看次数,作者提供的图片
这里,线的长度不同,因为视频的发布时间不同。最早的视频发布于将近两周前,而最新的视频发布于数据收集前的两天。
从这张图表中,我有两个有趣的观察。
首先,至少对于这个频道而言,我的假设是正确的,这些视频在发布后立即获得了最多的观看次数。更重要的是,曲线(例如红色和绿色的曲线)几乎是相同的。
其次,细心的读者可能会看到两个明显的组——前两个视频获得了大约 300 万次观看,而另外三个视频显然获得了大约 50 万次观看。确实,这些视频不同。顶部的线代表“正常”视频,底部的线代表“YouTube Shorts”。显然,至少对这个频道而言,观众对“短视频”的兴趣较低。
但显然,结果可能有所不同。首先,一些视频可能会变得更受欢迎甚至成为病毒视频;它们可以获得更多的观看次数:
每个视频的观看次数,图像作者提供
其次,内容本身也很重要。例如,关于小工具的评测在“新鲜”时通常最有趣,但关于健康、关系、运动、化妆或任何类似主题的视频对观众的长期价值可能更高。最后但同样重要的是,这些特定频道拥有大量订阅者,视频在发布后很快就会获得许多观看次数。对于“新手”而言,结果可能会有所不同,大多数新频道的观众可能来自 YouTube 推荐系统或搜索结果。因此,我只能建议读者自行研究,选择一个大致符合他们想了解内容的 YouTube 频道。
结论
在这篇文章中,我展示了如何收集和分析有关不同 YouTube 频道和视频的数据。在第一部分,我重点关注了诸如每个频道的观看次数等一般属性。在这一部分,我关注了单个视频。我们能够看到不同频道上新视频发布的频率、它们可以获得的观看次数以及这一过程的速度。这种分析通常仅对频道所有者开放,但借助 YouTube 数据 API,我们可以以高精度免费收集数据。这不仅对那些希望开设新频道的人感兴趣,也从文化和统计的角度来看非常有意义。
显然,YouTube 是一个庞大的流媒体平台,拥有数百万个频道和数十亿个视频。关于猫、数学问题或笔记本电脑评测的视频可以获得截然不同的观看次数、点赞数和评论数。因此,我鼓励读者对他们感兴趣的频道进行自己的测试。在这篇文章中,我只关注了观看次数,但评论数或点赞数也可以通过相同的方式进行分析(顺便提一下,我们可以通过 API 获取点赞数,但 YouTube 从 2021 年开始移除了公众对不喜欢数的访问)。
在下一部分也是最后一部分中,我将关注 YouTube “Shorts”。这些类型的视频显示在一个单独的 YouTube 页面上,该页面具有不同的 UI,并且观看次数或点赞数可能会有显著差异。敬请关注。
对社交数据分析感兴趣的人也欢迎阅读其他文章:
-
探索性数据分析:我们对 YouTube 频道了解多少(第一部分)
如果你喜欢这个故事,欢迎订阅 Medium,这样你将会收到我新文章发布的通知,并且可以全面访问其他作者的数千篇故事。本文的完整源代码和 Jupyter notebook 也可以在我的Patreon 页面找到。
感谢阅读。
探索 Pydantic V2 的增强数据验证功能
原文:
towardsdatascience.com/explore-pydantic-v2s-enhanced-data-validation-capabilities-792a3353ec5
了解 Pydantic V2 的新功能和语法
·发布于 Towards Data Science ·7 min read·2023 年 10 月 25 日
–
图片由 jackmac34 在 Pixabay 提供
数据验证是数据工程和软件开发领域中稳健应用的基石。确保数据的清洁性和准确性不仅对应用的可靠性至关重要,也对用户体验有很大影响。
Pydantic 是 Python 中使用最广泛的数据验证库。Pydantic 最新版本(V2)的核心已经用 Rust 重新编写,相比于之前的版本性能大大提升。此外,在功能方面也有一些重大改进,例如支持严格模式、无模型验证、模型命名空间清理等。
本文将深入探讨 Pydantic 强大数据验证功能的最新特性和增强性能,为开发者提供一个全面的数据处理工具集。
准备工作
要跟随本文中的示例,您应该安装现代版本的 Python(≥ 3.10)和最新版本的 Pydantic V2。建议使用 conda 虚拟环境来管理不同版本的 Python 和库:
conda create -n pydantic2 python=3.11
conda activate pydantic2
pip install -U pydantic
基本用法
通常使用 Pydantic 时,我们需要先通过模型定义数据的模式,这些模型只是继承自 BaseModel
的类。在这些模型中,每个字段的数据类型由类型提示定义。
from pydantic import BaseModel
class ComputerModel(BaseModel):
brand: str
cpu: str
storage: int
ssd: bool = True
要使用此模型进行验证,我们可以通过传递每个字段的值来创建一个实例:
input_dict = {"brand": "HP", "cpu": "Intel i7 1265U", "storage": "256"}
computer = ComputerModel(**input_dict)
print(computer)
# brand='HP' cpu='Intel i7 1265U' storage=256 ssd=True
storage
字符串数据会被强制转换为模型中定义的整数。
为了演示的简便性,我们在本文中仅使用两个字段,即brand
和storage
,这可以轻松扩展到其他字段。
# Basic model used in this post
from pydantic import BaseModel
class ComputerModel(BaseModel):
brand: str
storage: int
直接验证数据
在上面的示例中,为数据验证创建了一个 Pydantic 模型的实例。在 Pydantic V2 中,我们还可以直接使用model_validate()
和model_validate_json()
来验证字典或 JSON 数据:
ComputerModel.model_validate({"brand": "HP", "storage": "256"})
# ComputerModel(brand='HP', storage=256)
import json
input_json = json.dumps({"brand": "HP", "storage": "256"})
ComputerModel.model_validate_json(input_json)
# ComputerModel(brand='HP', storage=256)
在 Pydantic V2 中,所有模型的方法都以model_
开头,因此字段名称不允许以model_
开头。然而,如果需要,可以使用字段别名。
在严格模式下验证数据
默认情况下,严格模式是关闭的,这意味着数据类型会被强制转换(如果可能的话)。例如,在上述示例中,storage
字段的类型从str
被强制转换为int
。我们可以禁用严格模式,这样所有字段的数据类型必须完全匹配:
ComputerModel.model_validate({"brand": "HP", "storage": "256"}, strict=True)
# ValidationError: 1 validation error for ComputerModel
# storage
# Input should be a valid integer [type=int_type, input_value='256', input_type=str]
我们还可以在模型的字段级别设置严格模式,这样我们在验证步骤中就不需要指定它:
from pydantic import Field
class ComputerModelStrict(BaseModel):
brand: str
storage: int = Field(strict=True)
ComputerModelStrict.model_validate({"brand": "HP", "storage": "256"})
# ValidationError: 1 validation error for ComputerModel
# storage
# Input should be a valid integer [type=int_type, input_value='256', input_type=str]
使用model_config
配置模型
在 Pydantic V2 中,为了指定模型的配置,我们可以将类属性model_config
设置为一个字典,该字典包含将用于配置的键/值对。通常,我们通过一个称为ConfigDict
的特殊字典来做到这一点,它是一个用于配置 Pydantic 行为的TypedDict
。
例如,我们可以在模型级别设置strict
模式,而不是在字段级别,如上所示:
from pydantic import BaseModel, ConfigDict
class ComputerModelStrict(BaseModel):
model_config = ConfigDict(strict=True, str_min_length=2)
brand: str
storage: int
ComputerModelStrict.model_validate({"brand": "HP", "storage": "256"})
# ValidationError: 1 validation error for ComputerModel
# storage
# Input should be a valid integer [type=int_type, input_value='256', input_type=str]
ComputerModelStrict.model_validate({'brand': 'X', 'storage': 256})
# ValidationError: 1 validation error for ComputerModelStrict
# brand
# String should have at least 2 characters [type=string_too_short, input_value='X', input_type=str]
我们还指定了字符串字段的最小长度为 2,因此像X
这样的品牌将被拒绝。
使用typing.Annotated
来处理字段
不必将Field
值分配给字段以指定字段的行为,也可以使用类型提示typing.Annotated
来完成:
from typing import Annotated
class ComputerModelStrict(BaseModel):
brand: str
storage: Annotated[int, Field(strict=True, gt=0)]
ComputerModelStrict.model_validate({'brand': 'HP', 'storage': '256'})
# ValidationError: 1 validation error for ComputerModel
# storage
# Input should be a valid integer [type=int_type, input_value='256', input_type=str]
ComputerModelStrict.model_validate({'brand': 'HP', 'storage': 0})
# ValidationError: 1 validation error for ComputerModelStrict
# storage
# Input should be greater than 0 [type=greater_than, input_value=0, input_type=int]
使用Annotated
时,传递的第一个类型参数(这里是int
)是实际类型,其余的是其他工具(这里是 Pydantic)的元数据。元数据可以包含任何内容,如何使用由其他工具决定。
具有动态默认值的字段
我们可以为字段设置动态默认值,这样它可以自动生成,并且每个模型实例可能不同。例如,我们可以将当前时间戳设置为模型的创建时间,并为其设置唯一的 ID。这可以通过使用default_factory
来完成,它接受一个工厂函数作为输入。
from datetime import datetime
from typing import Annotated
from uuid import UUID, uuid4
from pydantic import BaseModel, Field
class ComputerModel(BaseModel):
uid: UUID = Field(default_factory=uuid4)
brand: str
storage: int
created: datetime = Field(default_factory=datetime.utcnow)
ComputerModel.model_validate({'brand': 'HP', 'storage': 256})
# ComputerModel(uid=UUID('81474288-f691-4e37-b5e3-d28f0656d972'), brand='HP', storage=256, created=datetime.datetime(2023, 9, 29, 0, 5, 2, 958755))
这表明uid
和created
字段是自动创建的,并且每个模型将会不同。
字段和模型验证器
类似于在字段上应用严格模式,我们可以使用Annotated
语法为字段应用自定义验证器。让我们添加一个自定义验证器,检查storage
是否是有效值:
from typing import Annotated
from pydantic.functional_validators import AfterValidator
def check_storage(storage: int):
allowed = (128, 256, 512, 1000, 1024, 2000, 2048)
if storage not in allowed:
raise ValueError(f"Invalid storage, storage must be one of {allowed}")
return storage
class ComputerModel(BaseModel):
brand: str
storage: Annotated[int, AfterValidator(check_storage)]
ComputerModel.model_validate({'brand': 'HP', 'storage': 256})
# ComputerModel(brand='HP', storage=256)
ComputerModel.model_validate({'brand': 'HP', 'storage': 250})
# ValidationError: 1 validation error for ComputerModel
# storage
# Value error, Invalid storage, storage must be one of (128, 256, 512, 1000, 1024, 2000, 2048) [type=value_error, input_value=250, input_type=int]
AfterValidator
表示验证将在 Pydantic 的内部验证逻辑之后应用。它相当于使用@field_validator()
装饰器的after
模式,如下所示。
请注意,验证代码不应抛出ValidationError
本身,而应抛出ValueError
或AssertionError
(或其子类),这些异常将被捕获并用于填充ValidationError
。
我们还可以使用@field_validator()
装饰器为字段应用自定义验证器:
from typing import Annotated
from pydantic import BaseModel, Field, field_validator
from pydantic.functional_validators import AfterValidator
class ComputerModel(BaseModel):
brand: str
storage: int
@field_validator('storage', mode='after')
@classmethod
def check_storage(cls, storage: int):
allowed = (128, 256, 512, 1000, 1024, 2000, 2048)
if storage not in allowed:
raise ValueError(f"Invalid storage, storage must be one of {allowed}")
return storage
使用@field_validator()
的效果应与上述Annotated
语法完全相同。每种语法都有其优缺点:
-
使用
Annotated
可以更轻松地重用自定义验证函数。 -
使用
field_validator
我们可以更轻松地将相同的验证函数应用于多个字段。
因此,你需要根据具体的实际使用案例来决定使用哪种语法。
我们还可以使用@model_validator()
将自定义验证器应用于整个模型。在这种情况下,我们可以访问所有字段的数据。例如,假设如果品牌是“Apple”,则存储必须至少为 256GB。
from __future__ import annotations
from typing import Annotated
from pydantic import BaseModel, Field, model_validator
from pydantic.functional_validators import AfterValidator
class ComputerModel(BaseModel):
brand: str
storage: int
@model_validator(mode='after')
def check_brand_storage(self) -> ComputerModel:
if self.brand.upper() == 'APPLE' and self.storage < 256:
raise ValueError("For Apple, the storage must be at least 256GB.")
return self
ComputerModel.model_validate({'brand': 'Apple', 'storage': 256})
# ComputerModel(brand='HP', storage=256)
ComputerModel.model_validate({'brand': 'Apple', 'storage': 128})
# ValidationError: 1 validation error for ComputerModel
# Value error, For Apple, the storage must be at least 256GB. [type=value_error, input_value={'brand': 'Apple', 'storage': 128}, input_type=dict]
请注意,@model_validator()
是一个实例方法装饰器,而不是像@field_validator()
那样的类方法装饰器。
转储或序列化
我们可以使用[model_dump](https://docs.pydantic.dev/latest/concepts/serialization/#modelmodel_dump)()
将 Pydantic 模型的实例转换为包含实例值的字典。我们将使用文章开头介绍的更详细的模型来演示序列化:
from pydantic import BaseModel
class ComputerModel(BaseModel):
brand: str
cpu: str
storage: int
ssd: bool = True
input_dict = {"brand": "HP", "cpu": "Intel i7 1265U", "storage": "256"}
model = ComputerModel(**input_dict)
output_dict_default = model.model_dump()
# {'brand': 'HP', 'cpu': 'Intel i7 1265U', 'storage': 256, 'ssd': True}
output_dict_no_unset = model.model_dump(exclude_unset=True)
# {'brand': 'HP', 'cpu': 'Intel i7 1265U', 'storage': 256}
output_dict_included = model.model_dump(include={'brand', 'storage'})
# {'brand': 'HP', 'storage': 256}
output_dict_excluded = model.model_dump(exclude={'cpu'})
# {'brand': 'HP', 'storage': 256, 'ssd': True}
Pydantic 可以将许多常用类型序列化为 JSON,这些类型否则会与简单的json.dumps()
不兼容(例如datetime
、date
或UUID
)。如果需要,我们还可以使用[@field_serializer](http://twitter.com/field_serializer)()
装饰器自定义字段的序列化方式。例如,我们可以在转储时将品牌转换为大写。
from pydantic import BaseModel, field_serializer
class ComputerModel(BaseModel):
brand: str
cpu: str
storage: int
ssd: bool = True
@field_serializer('brand')
def serialize_dt(self, brand: str, _info):
return brand.upper()
input_dict = {"brand": "Apple", "cpu": "M1", "storage": "512"}
model = ComputerModel(**input_dict)
# {'brand': 'APPLE', 'cpu': 'M1', 'storage': 512, 'ssd': True}
请注意,_info
表示 Pydantic 自动提供的元数据。
在这篇文章中,我们介绍了如何使用最新版本的 Pydantic(V2)进行数据验证。在这个版本中引入了许多语法变化和新特性,这些在官方文档中可能会显得非常冗长和复杂。幸运的是,我们在日常工作中只使用了一小部分功能,并且大多数功能在这篇文章中通过简单的示例进行了介绍,这些示例可以为开发者提供全面的数据处理工具集。
相关帖子
探索语料库中的语义关系与嵌入模型
·
关注 发表在 Towards Data Science ·10 min 阅读·2023 年 11 月 24 日
–
最近,我与一些同学和学者讨论了他们研究兴趣涉及自由形式文本分析的话题。遗憾的是,获得对书面自然语言的有意义的见解绝非易事。密切阅读当然是一个选择,但你理想中会希望通过更宏观的分析/量化视角来看待文本数据。更不用说在大数据时代,密切阅读往往不可行。
到目前为止,我最喜欢在语料库上进行探索性数据分析的方法是主题模型,我已经写过多篇文章讨论如何以尽可能少的痛苦方式进行这项工作。尽管主题模型非常棒,但它们并不是所有文本任务的最佳方法。
嵌入是文本数据的数值表示,已经成为文本语义查询的经典方法。在这篇文章中,我们将探讨如何使用嵌入来分析文本数据的一些方法。
使用词嵌入捕捉概念之间的关系
词嵌入模型是一组以无监督方式学习术语潜在向量表示的方法。当从自然语言中学习词嵌入时,实际上是获得了一个嵌入空间中的语义关系图。
词嵌入通常在大型语料库上进行训练,以便捕捉人类语言中的一般词对词关系。这很有用,因为可以将关于语言的一般知识注入到特定应用的模型中。这也被称为迁移学习,并且在机器学习中一直是一个热门话题。
如果我们不想将一般知识转移到特定模型中,而是希望得到一个较小语料库的语义特定方面的映射,该怎么办呢?假设我们有一个来自论坛的评论语料库,我们想探索其中可以发现哪些关联关系。
一种方法是从头开始训练一个词嵌入模型,而不是使用已经为我们预训练的模型。在这个例子中,我将使用 20Newsgroups 数据集作为语料库,我们将在其中探索语义关系。
训练模型
现在让我们从一个词嵌入模型开始。你可能对 Word2Vec 有所了解,它是普及静态词嵌入在研究和实践中的方法。另一方面,由斯坦福大学的团队开发的 GloVe 在大多数情况下似乎是一种更好的方法,我的经验表明,它提供了更高质量的嵌入,特别是在较小的语料库上。
不幸的是,GloVe 在 Gensim 中没有实现,但幸运的是,我为原始 GloVe 代码制作了一个完全兼容 Gensim 的接口,我们将使用它来训练模型。
让我们安装 gensim、glovpy 和 scikit-learn,以便我们可以获取 20Newsgroups 以及 embedding-explorer:
pip install glovpy gensim scikit-learn
我们首先需要加载数据集,并对其进行标记化,为此我们将使用 gensim 内置的标记化工具。我们还将过滤掉停用词,因为它们对当前任务没有任何有意义的信息。
from gensim.utils import tokenize
from sklearn.datasets import fetch_20newsgroups
from sklearn.feature_extraction.text import ENGLISH_STOP_WORDS
def clean_tokenize(text: str) -> list[str]:
"""This function tokenizes texts and removes stop words from them"""
tokens = tokenize(text, lower=True, deacc=True)
tokens = [token for token in tokens if token not in ENGLISH_STOP_WORDS]
return tokens
# Loading the dataset
dataset = fetch_20newsgroups(
remove=("headers", "footers", "quotes"), categories=["sci.med"]
)
newsgroups = dataset.data
# Tokenizing the dataset
tokenized_corpus = [clean_tokenize(text) for text in newsgroups]
之后,我们可以在标记化的语料库上轻松训练一个 GloVe 模型。
from glovpy import GloVe
# Training word embeddings
model = GloVe(vector_size=25)
model.train(tokenized_corpus)
我们已经可以查询这个词嵌入模型了,举个例子,让我们检查一下哪些十个词最接近“child”。
model.wv.most_similar("child")
==============================
+------------+----------+
| age | 0.849304 |
| consistent | 0.844267 |
| adult | 0.805101 |
| range | 0.800615 |
| year | 0.798799 |
| hand | 0.792965 |
| children | 0.792113 |
| use | 0.789804 |
| restraint | 0.773764 |
| belt | 0.77003 |
+------------+----------+
调查,可视化!
不过,单独调查每个词与其他词的关系很快会变得乏味。理想情况下,我们也希望可视化关系,甚至可能得到一些网络。
幸运的是,embedding-explorer 包可以帮助我们,我也开发了这个包。在计算人文学科中,我们经常使用词嵌入模型及其建立的语义网络,而 embedding-explorer 帮助我们以互动和可视化的方式探索这些网络。该包包含多个互动网页应用,我们首先来看看“网络探索器”。
这个应用的想法是,嵌入模型中的概念自然形成某种网络结构。相关性强的词有强链接,而其他词可能没有。在应用中,您可以基于指定的一组种子词和两个自由联想级别构建概念图。
在每个关联级别,我们从嵌入模型中找出与已有词汇最接近的五个词,并将其添加到我们的网络中,与其关联的词相连。连接的强度由嵌入空间中概念的余弦距离决定。这类网络在我或我的同事进行的多个研究项目中都证明了其有用性。
让我们启动我们的词嵌入模型应用。
from embedding_explorer import show_network_explorer
vocabulary = model.wv.index_to_key
embeddings = model.wv.vectors
show_network_explorer(vocabulary, embeddings=embeddings)
这将打开一个浏览器窗口,您可以自由探索语料库中的语义关系。这里是我查看围绕“jesus”,“science”和“religion”这些词汇形成的网络的截图。
探索我们 GloVe 模型中的语义关系
例如,我们可以看到,人们在线讨论这些话题时,似乎暗示了宗教和科学通过政治、社会和哲学相关联,这非常有道理。观察到教育在科学和宗教之间,虽然明显更接近科学,这也很有趣。这将值得进一步探讨。
N-grams 与句子变换器的网络
那么,如果我们不仅想查看词级别的关系,还想查看短语或句子呢?
我的建议是使用 N-grams。N-grams 本质上就是文本中连续的 N 个术语。例如,在句子“I love my little cute dog”中,我们将得到 4-grams:“I love my little”,“love my little cute”和“my little cute dog”。现在的问题是,我们如何学习 N-grams 的良好语义表示?
技术上,你仍然可以通过将短语或句子视为一个标记来使用 GloVe,但有一个问题。由于 N-grams 的多样性随着 N 的增加而急剧增加,某些 N-grams 可能只出现一两次,我们可能无法学习到良好的表示。
取短语中词嵌入的平均值怎么样?这可能会有很大帮助,但问题是我们完全丧失了关于不同词的重要性、它们在句子中的顺序以及所有上下文信息。
解决此问题的方案是使用句子变换器,这些深度神经语言模型产生具有上下文敏感性的文本表示。它们已经超越了所有其他方法好几年,并成为了嵌入文本的行业标准。现在训练这样的模型需要大量的数据,我们手头没有,但幸运的是,我们可以使用一些优秀的预训练模型。
N-gram 提取
首先,让我们从语料库中提取 N-gram。我选择了四元组,但你可以选择任何你喜欢的数量。我们将使用 scikit-learn 的 CountVectorizer 来完成这项工作。
from sklearn.feature_extraction.text import CountVectorizer
# First we train a model on the corpus that learns all 4-grams
# We will only take the 4000 most frequent ones into account for now,
# But you can freely experiment with this
feature_extractor = CountVectorizer(ngram_range=(4,4), max_features=4000)
feature_extractor.fit(newsgroups)
# Then we get the vectorizer's vocabulary
four_grams = feature_extractor.get_feature_names_out()
嵌入模型
我们需要一个嵌入模型来表示文本。正如我之前所说,我们将使用一个预训练模型。我选择了all-MiniLM-L6-v2,因为它非常稳定、广泛使用且相当小巧,因此即使在你的个人电脑上也能顺畅运行。
我们将使用另一个包,embetter,以便可以以与 scikit-learn 兼容的方式使用句子变换器。
pip install embetter[text]
我们可以像这样在 Python 中加载模型:
from embetter.text import SentenceEncoder
encoder = SentenceEncoder("all-MiniLM-L6-v2")
探索!
然后,我们可以将模型和 N-gram 加载到 embedding-explorer 中。
from embedding_explorer import show_network_explorer
show_network_explorer(four_grams, vectorizer=encoder)
请注意,这允许我们指定任何任意种子,而不仅仅是我们四元组词汇表中的种子。这里是我输入两个句子的截图,并查看从它们周围的四元组构建了什么样的网络。
探索语料库中的短语和句子
有趣的是再次观察哪些短语处于中间位置。看起来法律和历史在这里充当了宗教和科学之间的某种连接。
使用文档嵌入研究语料库级语义结构
我们现在已经在单词和短语级别查看了我们的语料库,并观察了它们中自然出现的语义结构。
如果我们想要了解在文档级别发生了什么呢?哪些文档彼此接近,出现了什么样的群体?
请注意,一个自然的解决方案是主题建模,如果你还没有尝试过,应该看看。在本文中,我们将探索与此任务相关的其他概念化方法。
文档表示
和以前一样,我们需要考虑如何表示单个文档,以便捕捉它们的语义内容。
更传统的机器学习实践通常使用词袋模型表示或训练 Doc2Vec 模型。这些都是很好的选择(你可以并且应该尝试这些方法),但它们同样缺乏文本的上下文理解。由于我们语料库中的文本不算太长,我们仍然可以使用句子变换器进行嵌入。让我们继续使用我们为短语使用的相同嵌入模型。
投影与聚类
探索文档的语义表示的一种自然方式是将它们投影到较低维度的空间(通常是 2D),并使用这些投影来可视化文档。我们还可以查看文档在某些聚类方法下的聚类情况。
现在这一切都很棒,但投影、降维和聚类方法的领域如此广泛,以至于我常常会想:“如果我使用其他方法,结果会有实质性不同吗?” 为了应对这个问题,我在嵌入探索中添加了另一个应用程序,你可以自由快速地探索不同方法下的可视化效果。
这是我们的工作流程:
1. 我们可能会在继续之前减少嵌入的维度。你可以选择各种降维方法,或者关闭它。
2. 我们想将嵌入投影到 2D 空间中,以便我们可以对其进行可视化。
3. 我们可能想要对嵌入进行聚类,以查看哪些文档被归为一组。
嵌入探索中的聚类与投影工作流
现在,在进行此操作时,我们还需要了解一些关于文档的外部信息(文本内容、标题等),否则我们没有太多可解释的内容。
让我们创建一个包含以下列的数据框:
1. 每个文档的前 400 个字符,以便我们可以了解文本的内容。
2. 文本的长度,以便我们可以在可视化中查看哪些文本较长,哪些较短。
3. 它们在数据集中来源的组。
import pandas as pd
import numpy as np
# Extracting text lengths in number of characters.
lengths = [len(text) for text in corpus]
# Extracting first 400 characters from each text.
text_starts = [text[:400] for text in corpus]
# Extracting the group each text belongs to
# Sklearn gives the labels back as integers, we have to map them back to
# the actual textual label.
group_labels = np.array(dataset.target_names)[dataset.target]
# We build a dataframe with the available metadata
metadata = pd.DataFrame(dict(length=lengths, text=text_starts, group=group_labels))
然后我们可以启动应用程序,传递元数据,以便我们可以悬停并查看有关文档的信息。
from embedding_explorer import show_clustering
show_clustering(
newsgroups,
vectorizer=encoder,
metadata=metadata,
hover_name="group", # Title of hover box is going to be the group
hover_data=["text", "length"] # We would also like to see these on hover
)
当应用程序启动时,你会首先看到这个屏幕:
聚类应用中的选项
运行聚类后,你将能够查看按聚类着色的所有文档的地图。你可以悬停在点上以查看文档的元数据……
聚类应用截图
并且在底部你甚至可以选择点的颜色、标签和大小。
带有文档大小的聚类
总结
对文本数据的探索性分析是困难的。我们已经研究了几种使用最先进的机器学习技术进行互动调查的方法。我希望本文讨论的方法以及 embedding-explorer Python 包对你未来的研究/工作有所帮助。
和平 ✌️
((本文中的所有图片均来自 embedding-explorer 的文档,由作者制作))