矩形数据 (Rectangular Data)
数据科学家经常处理存储在表格中的数据。本章将介绍 Dataframe,这是表示数据表最广泛使用的方法之一。我们还将介绍 pandas,这是Python中用于处理 Dataframe 的标准库。
1. 使用 pandas 处理 Dataframe
以下是一个包含流行犬种信息的 Dataframe 示例:
| breed (索引) | grooming | food_cost | kids | size |
|---|---|---|---|---|
| Labrador Retriever | weekly | 466.0 | high | medium |
| German Shepherd | weekly | 466.0 | medium | large |
| Beagle | daily | 324.0 | high | small |
| Golden Retriever | weekly | 466.0 | high | medium |
| Yorkshire Terrier | daily | 324.0 | low | small |
| Bulldog | weekly | 466.0 | medium | medium |
| Boxer | weekly | 466.0 | high | medium |
在 Dataframe 中,每一行代表一条记录——在这个例子中,是一个特定的犬种。每一列代表记录的一个特征——例如,grooming 列表示每种犬种需要梳理毛发的频率。
Dataframe 的行和列都有标签 (Labels)。例如,这个 Dataframe 有一个名为 grooming 的列和一个名为 Labrador Retriever 的行。Dataframe 的列和行是有序的——我们可以称 Labrador Retriever 行为 Dataframe 的第一行。
在同一列中,数据具有相同的类型。例如,food_cost(食物成本)包含数字,而 size(体型)包含类别。但在同一行中,数据类型可以不同。正因为这些特性,Dataframe 支持各种有用的操作。
术语说明:特征 vs 变量 / 数据类型
数据科学家经常与不同背景的人合作,他们可能使用不同的术语:
- 特征 vs 变量:计算机科学家通常称 Dataframe 的列为特征 (features),而统计学家通常称之为变量 (variables)。
- 数据类型 (Data types):人们有时用同一个词指代稍微不同的概念。
- 编程视角:指计算机内部如何存储数据(例如,
size列在 Python 中是字符串string类型)。 - 统计视角:指数据的统计属性(例如,
size列的类型是有序分类数据 (ordered categorical data),即 ordinal data)。我们在探索性数据分析 (EDA) 部分会更详细地讨论这种区别。
- 编程视角:指计算机内部如何存储数据(例如,
在本章中,我们将介绍使用 pandas 进行常见的 Dataframe 操作。数据科学家在 Python 中处理 Dataframe 时主要使用 pandas 库。
- 首先,我们将解释 pandas 提供的核心对象:DataFrame 和 Series 类。
- 然后,我们将展示如何使用 pandas 执行常见的数据操作任务,如切片 (slicing)、过滤 (filtering)、排序 (sorting)、分组 (grouping) 和 连接 (joining)。
2. Dataframe 子集 (Subsetting)
本节介绍获取 Dataframe 子集的操作。当数据科学家第一次读取 Dataframe 时,他们通常希望提取出计划使用的特定数据子集。例如,数据科学可能会从拥有数百个列的 Dataframe 中切分出 10 个相关特征。或者,他们可能会过滤 Dataframe 以删除数据不完整的行。在本章的其余部分,我们将使用婴儿名字的 Dataframe 演示 Dataframe 操作。
2.1 数据范围与问题 (Data Scope and Question)
有一篇2021年的《纽约时报》文章谈到了哈里王子和梅根·马克尔为他们的新生女儿取了一个独特的选择:莉莉贝特 (Lilibet)。文章采访了婴儿名字专家 Pamela Redmond,她谈到了人们给孩子取名的一些有趣趋势。例如,她说以字母 L 开头的名字近年来变得非常流行,而以字母 J 开头的名字在 1970 年代和 1980 年代最为流行。这些说法能数据中反映出来吗?我们可以使用 pandas 来找出答案。
首先,导入 pandas 库,通常简写为 pd:
import pandas as pd
我们有一个名为 babynames.csv 的 CSV 文件,其中包含婴儿名字的数据。我们使用 pd.read_csv 函数将文件读取为 pandas.DataFrame 对象:
baby = pd.read_csv('babynames.csv')
baby
(显示部分数据)
| Name | Sex | Count | Year | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 2020719 | Verona | F | 5 | 1880 |
| 2020720 | Vertie | F | 5 | 1880 |
| 2020721 | Wilma | F | 5 | 1880 |
2020722 rows × 4 columns
baby 表中的数据来自美国社会保障局 (SSA),该机构出于出生证明的目的记录婴儿的姓名和出生性别。SSA 在其网站上提供了婴儿名字数据。我们已经将此数据加载到 baby 表中。
SSA 网站有一个页面更详细地描述了这些数据。我们不会在本章深入探讨数据的局限性,但我们会指出网站上这些相关信息:
所有名字都来自 1879 年以后发生在美国的出生社会保障卡申请。请注意,许多 1937 年以前出生的人从未申请过社会保障卡,因此他们的名字不包含在我们的数据中。对于其他申请过的人,我们的记录可能没有显示出生地,此时他们的名字也不包含在我们的数据中。
所有数据均来自截至 2021 年 3 月社会保障卡申请记录的 100%样本。
同样需要指出的是,在撰写本文时,SSA 数据集仅提供男性 (M) 和女性 (F) 的二元选项。我们希望未来像这样的国家数据集能提供更具包容性的选项。
2.2 Dataframe 与 索引 (Dataframes and Indices)
让我们更详细地检查 baby Dataframe。Dataframe 有行和列。每一行和每一列都有一个标签。
默认情况下,pandas 将行标签分配为从 0 开始递增的数字。在这种情况下,行标签为 0 且列标签为 Name 的位置的数据是 'Liam'。
Dataframe 也可以使用字符串作为行标签。比如之前的狗的数据:
| breed (索引) | grooming | food_cost | kids | size |
|---|---|---|---|---|
| Labrador Retriever | weekly | 466.0 | high | medium |
| German Shepherd | weekly | 466.0 | medium | large |
| ... | ... | ... | ... | ... |
行标签有一个特殊的名称。我们称之为 Dataframe 的 索引 (Index),pandas 将行标签存储在一个特殊的 pd.Index 对象中。我们不会讨论 pd.Index 对象,因为直接操作索引本身并不常见。现在,重要的是要记住,即使索引看起来像一列数据,但索引实际上代表行标签,而不是数据。例如,狗品种的 Dataframe 有四列数据,而不是五列,因为索引不算作数据列。
2.3 切片 (Slicing)
切片是从另一个 Dataframe 中提取行或列的子集并创建一个新 Dataframe 的操作。这就好比切西红柿——切片既可以是垂直的也可以是水平的。要在 pandas 中对 Dataframe 进行切片,我们使用 .loc 和 .iloc 属性。让我们从 .loc 开始。
.loc 允许我们使用标签来选择行和列。例如,要获取标签为 1 的行和标签为 Name 的列的数据:
# 第一个参数是行标签
# ↓
baby.loc[1, 'Name']
# ↑
# 第二个参数是列标签
# 输出: 'Noah'
注意
请注意,.loc 需要方括号 [];运行 baby.loc(1, 'Name') 会导致错误。
要切分出多行或多列,我们可以使用 Python 切片语法代替单个值:
baby.loc[0:3, 'Name':'Count']
| Name | Sex | Count | |
|---|---|---|---|
| 0 | Liam | M | 19659 |
| 1 | Noah | M | 18252 |
| 2 | Oliver | M | 14147 |
| 3 | Elijah | M | 13034 |
要获取一整列数据,我们可以传递一个空切片 : 作为第一个参数:
baby.loc[:, 'Count']
0 19659
1 18252
2 14147
...
2020719 5
2020720 5
2020721 5
Name: Count, Length: 2020722, dtype: int64
注意,输出看起来不像 Dataframe,实际上也不是。从 Dataframe 中选出单行或单列会产生一个 pd.Series 对象:
counts = baby.loc[:, 'Count']
counts.__class__.__name__
# 输出: 'Series'
pd.Series 对象和 pd.DataFrame 对象有什么区别?本质上,pd.DataFrame 是二维的——它有行和列,代表数据表。pd.Series 是一维的——它代表数据列表。pd.Series 和 pd.DataFrame 对象有许多共同的方法,但它们确实代表两个不同的东西。混淆这两者会导致错误和困惑。
要选择 Dataframe 的特定列,请将列表传递给 .loc:
# 这是一个只包含 Name 和 Year 列的 Dataframe
baby.loc[:, ['Name', 'Year']]
选择列非常常见,因此有一个简写方式:
# baby.loc[:, 'Name'] 的简写
baby['Name']
# baby.loc[:, ['Name', 'Count']] 的简写
baby[['Name', 'Count']]
使用 .iloc 进行切片类似于 .loc,不同之处在于 .iloc 使用行和列的位置 (integer position) 而不是标签。当 Dataframe 索引为字符串时,区分 .iloc 和 .loc 最容易。让我们看看狗品种的数据:
dogs = pd.read_csv('dogs.csv', index_col='breed')
要按位置获取前三行和前两列,使用 .iloc:
dogs.iloc[0:3, 0:2]
同样的操作如果用 .loc,则需要使用 Dataframe 的标签:
dogs.loc['Labrador Retriever':'Beagle', 'grooming':'food_cost']
2.4 过滤行 (Filtering Rows)
到目前为止,我们已经展示了如何使用 .loc 和 .iloc 使用标签和位置来切分 Dataframe。
然而,数据科学家经常想要过滤行——他们想要根据某些条件获取行的子集。例如,我们想找出 2020 年最流行的婴儿名字。为此,我们可以过滤行,只保留 Year 为 2020 的行。
为了过滤,我们首先要检查 Year 列中的每个值是否等于 2020。这可以通过切分出该列并进行布尔比较来实现(这类似于我们在 numpy 数组中所做的):
# 获取 Year 数据的 Series
baby['Year']
# 与 2020 比较
baby['Year'] == 2020
0 True
1 True
2 True
...
2020719 False
2020720 False
2020721 False
Name: Year, Length: 2020722, dtype: bool
注意,对 Series 进行布尔比较会得到一个布尔值的 Series。这几乎等同于写一个循环,但布尔比较更容易编写,执行速度也快得多。
现在,我们告诉 pandas 只保留比较结果为 True 的行:
# 将布尔 Series 传递给 .loc 只保留 Series 中为 True 的行
baby.loc[baby['Year'] == 2020, :]
过滤也有简写形式。这会计算出与上述代码片段相同的表,而不使用 .loc:
baby[baby['Year'] == 2020]
最后,为了找出 2020 年最常见的名字,我们将 Dataframe 按 Count 降序排序:
# 将长表达式用括号括起来,可以方便地换行,提高可读性
(baby[baby['Year'] == 2020]
.sort_values('Count', ascending=False)
.head(7) # 取前 7 行
)
| Name | Sex | Count | Year | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 13911 | Emma | F | 15581 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| 13912 | Ava | F | 13084 | 2020 |
| 3 | Elijah | M | 13034 | 2020 |
| 13913 | Charlotte | F | 13003 | 2020 |
我们看到 Liam、Noah 和 Emma 是 2020 年最受欢迎的婴儿名字。
2.5 案例:Luna 这个名字是什么时候变流行的? (Example: How Recently has Luna Become a Popular Name?)
《纽约时报》的文章提到,Luna 这个名字在 2000 年之前几乎不存在,但后来逐渐成为非常受女孩欢迎的名字。Luna 到底是什么时候开始流行的?我们可以使用切片和过滤来检查这一点。在处理数据操作任务时,我们建议将问题分解为更小的步骤。例如:
- 过滤:只保留
Name列为 'Luna' 的行。 - 过滤:只保留
Sex列为 'F' 的行。 - 切片:只保留
Count和Year列。
现在将每个步骤转换为代码:
luna = baby[baby['Name'] == 'Luna'] # [1]
luna = luna[luna['Sex'] == 'F'] # [2]
luna = luna[['Count', 'Year']] # [3]
luna
(结果显示大量数据)
在本书中,我们使用一个名为 plotly 的库进行绘图。您可以参考第 11 章了解更多绘图知识。现在,我们使用 px.line() 制作一个简单的折线图:
import plotly.express as px
px.line(luna, x='Year', y='Count', width=350, height=250)
正如文章所说,Luna 直到 2000 年左右才开始流行。换句话说,如果有人告诉你由于她的名字叫 Luna,你甚至不需要关于她的任何其他信息就可以很好地猜出她的年龄!
纯粹为了好玩,这是名字 Siri 的同样图表:
# 使用 .query 类似于使用 .loc 加上布尔序列
# query() 在过滤方面有更多限制,但作为简写很方便
siri = (baby.query('Name == "Siri"')
.query('Sex == "F"'))
px.line(siri, x='Year', y='Count', width=350, height=250)
为什么 2010 年后流行度会突然下降?嗯,Siri 恰好是苹果语音助手的名字,于 2011 年推出。让我们画一条 2011 年的线来看看……
fig = px.line(siri, x="Year", y="Count", width=350, height=250)
fig.add_vline(
x=2011, line_color="red", line_dash="dashdot", line_width=4, opacity=0.7
)
fig.show()
看起来父母们不希望他们的孩子在别人对着手机喊“嘿 Siri”时感到困惑。
在本节中,我们介绍了 pandas 中的 Dataframe。我们涵盖了数据科学家对 Dataframe 进行子集化的常见方法——使用标签切片和使用布尔条件过滤。
3. 聚合行 (Aggregating)
本节介绍 Dataframe 的行聚合操作。数据科学家通过聚合行来生成数据摘要。例如,可以将包含每日销售额的数据集聚合为月度销售额。本节介绍了分组 (grouping) 和透视 (pivoting),这是聚合数据的两种常见操作。
3.1 基础分组聚合 (Basic Group-Aggregate)
假设我们要计算数据中记录的所有出生婴儿总数。这只是 Count 列的总和:
baby['Count'].sum()
# 输出: 352554503
如果我们想回答一个更有趣的问题:美国的出生人数是否随时间呈上升趋势?这就需要按年份对 Count 列求和。换句话说,我们将数据按 Year 分组,然后对每组内的 Count 值求和。这在 pandas 中称为分组后聚合:
baby.groupby('Year')['Count'].sum()
Year
1880 194419
1881 185772
...
2019 3437438
2020 3287724
Name: Count, Length: 141, dtype: int64
结果是一个 pd.Series,其索引包含唯一的年份值。现在我们可以绘制随时间变化的计数图:
counts_by_year = baby.groupby('Year')['Count'].sum().reset_index()
px.line(counts_by_year, x='Year', y='Count', width=350, height=250)
pandas 分组的基本配方:
(baby # 1. Dataframe
.groupby('Year') # 2. 分组列
['Count'] # 3. 聚合列
.sum() # 4. 聚合方式
)
3.1.1 案例:使用 .value_counts()
一个常见的任务是计算列中每个唯一项出现的次数。例如,统计某个班级中每个名字出现的次数。
虽然我们可以使用 groupby + size() 来实现,但 pandas 提供了一个简写方法 .value_counts():
# 计算名字出现的频率
classroom['name'].value_counts()
默认情况下,.value_counts() 会按数量从高到低排序,非常方便查看最常见和最少见的值。
3.2 多列分组 (Grouping on Multiple Columns)
我们可以将多个列作为列表传递给 .groupby,以便同时按多个列分组。例如,按年份和性别分组,查看随时间推移出生的男婴和女婴数量:
counts_by_year_and_sex = (baby
.groupby(['Year', 'Sex']) # 传入列名列表
['Count']
.sum()
)
结果是一个具有多级索引 (multilevel index) 的 Series。多级索引处理起来有点棘手,通常我们可以使用 .reset_index() 将其变回普通的 Dataframe:
counts_by_year_and_sex.reset_index()
| Year | Sex | Count | |
|---|---|---|---|
| 0 | 1880 | F | 83929 |
| 1 | 1880 | M | 110490 |
| ... | ... | ... | ... |
| 281 | 2020 | M | 1706423 |
3.3 自定义聚合函数
除了 .sum(),pandas 还提供其他聚合函数,如 .mean()、.size()、.max() 等。
如果内置函数不满足需求,我们可以使用 .agg(fn) 定义自定义聚合。
例如,计算每个组的最大值和最小值之差(极差):
def data_range(counts):
return counts.max() - counts.min()
(baby
.groupby('Year')
['Count']
.agg(data_range) # 使用自定义函数聚合
)
或者计算每年唯一名字的数量:
def count_unique(s):
return len(s.unique())
unique_names_by_year = (baby
.groupby('Year')
['Name']
.agg(count_unique)
)
3.4 透视表 (Pivoting)
透视表 (Pivoting) 是处理两个分组列聚合结果的另一种便捷方式。
之前按年份和性别分组的结果是一个长表。我们可以使用 pd.pivot_table 将其转换为宽表,其中一个分组变量(如性别)变成列:
mf_pivot = pd.pivot_table(
baby,
index='Year', # 新索引的列
columns='Sex', # 新列头的列
values='Count', # 用于聚合值的列
aggfunc=sum) # 聚合函数
mf_pivot
| Sex | F | M |
|---|---|---|
| Year | ||
| 1880 | 83929 | 110490 |
| 1881 | 85034 | 100738 |
| ... | ... | ... |
| 2020 | 1581301 | 1706423 |
透视表中的数据值与 .groupby() 生成的表相同,只是排列方式不同。透视表非常适合快速汇总两个属性的数据。
px.line() 函数对透视表的支持也很好,它会自动为表中的每一列数据绘制一条线:
fig = px.line(mf_pivot, width=350, height=250)
在本节中,我们介绍了在 pandas 中聚合数据的常见方法:使用 .groupby() 或 pd.pivot_table()。在下一节中,我们将解释如何连接 (join) Dataframe。
4. 连接 Dataframe (Joining)
数据科学家经常需要将两个或多个 Dataframe 连接在一起,以便关联不同 Dataframe 中的数据值。例如,一家在线书店可能有一个 Dataframe 记录每位用户订购的书籍,另一个 Dataframe 记录每本书的类型。通过连接这两个 Dataframe,数据科学家可以查看每位用户偏好的书籍类型。
我们将继续使用婴儿名字数据。我们将使用连接 (joins) 来检验《纽约时报》文章中提到的一些关于婴儿名字的趋势。文章提到某些类别的名字随着时间的推移变得越来越流行或不流行。例如,文章指出像 Julius 和 Cassius 这样的神话名字变得流行,而像 Susan 和 Debbie 这样的婴儿潮一代名字变得不流行。这些类别的流行度随时间发生了怎样的变化?
我们将《纽约时报》文章中的名字和类别放入一个小的 Dataframe 中:
nyt = pd.read_csv('nyt_names.csv')
nyt
| nyt_name | category | |
|---|---|---|
| 0 | Lucifer | forbidden |
| 1 | Lilith | forbidden |
| 2 | Danger | forbidden |
| ... | ... | ... |
| 20 | Venus | celestial |
| 21 | Celestia | celestial |
| 22 | Skye | celestial |
23 rows × 2 columns
为了了解这些名字类别的流行程度,我们将 nyt Dataframe 与 baby Dataframe 连接起来,以获取 baby 表中的名字计数:
baby = pd.read_csv('babynames.csv')
baby
(显示部分 baby 表数据)
直观地说,我们可以想象遍历 baby 表中的每一行,并询问:这个名字是否在 nyt 表中?如果是,则将 category 列的值添加到该行。这就是连接的基本思想。让我们先看几个较小 Dataframe 的例子。
4.1 内连接 (Inner Joins)
为了更清楚地观察连接表时发生的情况,我们首先创建 baby 和 nyt 表的较小版本:
# 创建示例小 Dataframe
nyt_small = pd.DataFrame({
'nyt_name': ['Karen', 'Julius', 'Freya'],
'category': ['boomer', 'mythology', 'mythology']
})
baby_small = pd.DataFrame({
'Name': ['Noah', 'Julius', 'Karen', 'Karen', 'Noah'],
'Sex': ['M', 'M', 'M', 'F', 'F'],
'Count': [18252, 960, 6, 325, 305],
'Year': [2020, 2020, 2020, 2020, 2020]
})
要在 pandas 中连接表格,我们将使用 .merge() 方法:
baby_small.merge(nyt_small,
left_on='Name', # 左表用于匹配的列
right_on='nyt_name') # 右表用于匹配的列
| Name | Sex | Count | Year | nyt_name | category | |
|---|---|---|---|---|---|---|
| 0 | Julius | M | 960 | 2020 | Julius | mythology |
| 1 | Karen | M | 6 | 2020 | Karen | boomer |
| 2 | Karen | F | 325 | 2020 | Karen | boomer |
请注意,新表包含了 baby_small 和 nyt_small 表的所有列。名字为 Noah 的行消失了。剩下的行都有来自 nyt_small 的匹配类别。
注意
读者也应该知道 pandas 有一个 .join() 方法用于连接两个 Dataframe。但是,.merge() 方法在连接 Dataframe 方面具有更大的灵活性,这就是为什么我们专注于使用 .merge()。我们鼓励读者查阅 pandas 文档,了解两者之间的确切区别。
当我们连接两个表时,我们告诉 pandas 每个表中要用于进行连接的列(left_on 和 right_on 参数)。pandas 在连接列中的值匹配时将行匹配在一起。
默认情况下,pandas 执行内连接 (inner join)。如果任一表中有在另一个表中没有匹配项的行,pandas 会从结果中丢弃这些行。在这种情况下,baby_small 中的 Noah 行在 nyt_small 中没有匹配项,因此它们被丢弃了。同样,nyt_small 中的 Freya 行在 baby_small 中没有匹配项,因此也被丢弃了。只有在两个表中都有匹配项的行才会保留在最终结果中。
4.2 左连接、右连接和外连接 (Left, Right, and Outer Joins)
有时我们希望保留没有匹配项的行,而不是完全丢弃它们。还有其他类型的连接——左连接 (Left)、右连接 (Right) 和外连接 (Outer)——即使没有匹配项,它们也会保留行。
在左连接中,左表中没有匹配项的行会保留在最终结果中(通常填充 NaN)。要在 pandas 中执行左连接,请在调用 .merge() 时使用 how='left':
baby_small.merge(nyt_small,
left_on='Name',
right_on='nyt_name',
how='left') # 左连接代替内连接
| Name | Sex | Count | Year | nyt_name | category | |
|---|---|---|---|---|---|---|
| 0 | Noah | M | 18252 | 2020 | NaN | NaN |
| 1 | Julius | M | 960 | 2020 | Julius | mythology |
| 2 | Karen | M | 6 | 2020 | Karen | boomer |
| 3 | Karen | F | 325 | 2020 | Karen | boomer |
| 4 | Noah | F | 305 | 2020 | NaN | NaN |
注意,Noah 行保留在最终表中。由于这些行在 nyt_small Dataframe 中没有匹配项,连接操作会在 nyt_name 和 category 列中留下 NaN 值。另外注意,nyt_small 中的 Freya 行仍然被丢弃了。
右连接的工作方式类似于左连接,只是保留右表中不匹配的行,而不是左表:
baby_small.merge(nyt_small,
left_on='Name',
right_on='nyt_name',
how='right')
| Name | Sex | Count | Year | nyt_name | category | |
|---|---|---|---|---|---|---|
| 0 | Karen | M | 6.0 | 2020.0 | Karen | boomer |
| 1 | Karen | F | 325.0 | 2020.0 | Karen | boomer |
| 2 | Julius | M | 960.0 | 2020.0 | Julius | mythology |
| 3 | NaN | NaN | NaN | NaN | Freya | mythology |
最后,外连接即使没有匹配项也会保留两个表中的行:
baby_small.merge(nyt_small,
left_on='Name',
right_on='nyt_name',
how='outer')
| Name | Sex | Count | Year | nyt_name | category | |
|---|---|---|---|---|---|---|
| 0 | Noah | M | 18252.0 | 2020.0 | NaN | NaN |
| 1 | Noah | F | 305.0 | 2020.0 | NaN | NaN |
| 2 | Julius | M | 960.0 | 2020.0 | Julius | mythology |
| 3 | Karen | M | 6.0 | 2020.0 | Karen | boomer |
| 4 | Karen | F | 325.0 | 2020.0 | Karen | boomer |
| 5 | NaN | NaN | NaN | NaN | Freya | mythology |
4.3 案例:纽约时报名字类别的流行度 (Example: Popularity of NYT Name Categories)
现在让我们回到完整的 Dataframe baby 和 nyt:
# .head() 切出前几行 - 方便节省空间
baby.head(2)
# 显示 baby 前两行...
nyt.head(2)
# 显示 nyt 前两行...
我们要了解 nyt 中名字类别的流行度随时间的变化。为了回答这个问题:
- 将
baby与nyt进行内连接。 - 按
category和Year对表进行分组。 - 使用总和 (sum) 聚合计数:
cate_counts = (
baby.merge(nyt, left_on='Name', right_on='nyt_name') # [1]
.groupby(['category', 'Year']) # [2]
['Count'] # [3]
.sum() # [3]
.reset_index()
)
cate_counts
| category | Year | Count | |
|---|---|---|---|
| 0 | boomer | 1880 | 292 |
| 1 | boomer | 1881 | 298 |
| ... | ... | ... | ... |
| 649 | mythology | 2020 | 3489 |
现在我们可以绘制 boomer(婴儿潮一代)名字和 mythology(神话)名字的流行度:
# 筛选出 boomer 和 mythology 类别进行绘图
cate_subset = cate_counts[cate_counts['category'].isin(['boomer', 'mythology'])]
px.line(cate_subset, x='Year', y='Count', color='category', width=350, height=250)
正如《纽约时报》文章所称,自 2000 年以来,婴儿潮一代的名字已变得不再流行,而神话名字则变得更加流行。
我们还可以一次绘制所有类别的流行度。看看下面的图表,看看它们是否支持《纽约时报》文章中的说法:
# 绘制所有类别的流行度
px.line(cate_counts, x='Year', y='Count', color='category', width=350, height=250)
在本节中,我们介绍了 Dataframe 的连接操作。将 Dataframe 连接在一起时,我们使用 .merge() 函数匹配行。连接 Dataframe 时,考虑连接类型(内连接、左连接、右连接或外连接)非常重要。
5. 数据变换 (Transforming)
数据科学家经常变换 Dataframe 的列,即需要以相同的方式更改特征中的每个值。例如,如果一个特征包含以英尺为单位的身高,数据科学家可能希望将其变换为厘米。在本节中,我们将介绍 apply,这是一个使用用户定义函数变换数据列的操作。
baby = pd.read_csv('babynames.csv')
baby
(显示部分数据)
在《纽约时报》的婴儿名字文章中,专家 Pamela 提到以字母 L 或 K 开头的名字在 2000 年后变得流行。另一方面,以字母 J 开头的名字在 1970 年代和 1980 年代达到顶峰,此后流行度下降。我们可以使用 baby 数据集来验证这些说法。
我们使用以下步骤来解决这个问题:
- 将
Name列变换为一个新列,其中包含Name中每个值的首字母。 - 按首字母和年份对 Dataframe 进行分组。
- 通过求和聚合名字计数。
为了完成第一步,我们将对 Name 列应用一个函数。
5.1 使用 Apply
pd.Series 对象包含一个 .apply() 方法,该方法接收一个函数并将其应用于 Series 中的每个值。例如,要查找每个名字的长度,我们可以应用 len 函数:
names = baby['Name']
names.apply(len)
# 0 4
# 1 4
# 2 6
# ...
# Name: Name, Length: 2020722, dtype: int64
要提取每个名字的首字母,我们定义一个自定义函数并将其传递给 .apply():
# 函数的参数是序列中的单个值
def first_letter(string):
return string[0]
names.apply(first_letter)
# 0 L
# 1 N
# ...
# 2020721 W
# Name: Name, Length: 2020722, dtype: object
使用 .apply() 类似于使用 for 循环。上面的代码大致相当于:
result = []
for name in names:
result.append(first_letter(name))
现在我们可以将首字母分配给 Dataframe 中的一个新列:
# 使用 .assign 创建新列
letters = baby.assign(Firsts=names.apply(first_letter))
letters
| Name | Sex | Count | Year | Firsts | |
|---|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 | L |
| 1 | Noah | M | 18252 | 2020 | N |
| ... | ... | ... | ... | ... | ... |
| 2020721 | Wilma | F | 5 | 1880 | W |
注意
要在一个 Dataframe 中创建一个新列,你可能还会遇到这种语法:
baby['Firsts'] = names.apply(first_letter)
这会通过添加一个名为 Firsts 的新列来修改 (mutate) baby 表。在前面的代码中,我们使用了 .assign(),它不会修改 baby 表本身;而是创建一个新的 Dataframe。修改 Dataframe 并没有错,但这可能是 Bug 的常见来源。正因为如此,我们在本书中主要使用 .assign()。
5.2 案例:"L" 开头名字的流行度 (Popularity of “L” Names)
现在我们可以使用 letters Dataframe 来查看首字母随时间的流行度:
letter_counts = (letters
.groupby(['Firsts', 'Year'])
['Count']
.sum()
.reset_index()
)
letter_counts
| Firsts | Year | Count | |
|---|---|---|---|
| 0 | A | 1880 | 16740 |
| 1 | A | 1881 | 16257 |
| ... | ... | ... | ... |
| 3640 | Z | 2020 | 54011 |
现在绘制以 "L" 开头的名字流行度:
fig = px.line(letter_counts.loc[letter_counts['Firsts'] == 'L'],
x='Year', y='Count', title='Popularity of "L" names',
width=350, height=250)
fig.update_layout(margin=dict(t=30))
该图显示,“L”名字在 1960 年代很流行,在随后的几十年中有所下降,但自 2000 年以来确实重新流行起来。
那 "J" 名字呢?
fig = px.line(letter_counts.loc[letter_counts['Firsts'] == 'J'],
x='Year', y='Count', title='Popularity of "J" names',
width=350, height=250)
fig.update_layout(margin=dict(t=30))
《纽约时报》的文章称,“J”名字在 1970 年代和 80 年代很流行。图表吻合,并显示自 2000 年以来它们已变得不那么流行。
5.3 Apply 的代价 (The Price of Apply)
.apply() 的强大之处在于它的灵活性——你可以用任何接收单个数据值并输出单个数据值的函数来调用它。
然而,这种灵活性是有代价的。使用 .apply() 可能会很慢,因为 pandas 无法优化任意函数。例如,对于数值计算,使用 .apply() 比直接在 pd.Series 对象上使用向量化操作要慢得多:
%%timeit
# 使用向量化运算符计算年代
baby['Year'] // 10 * 10
# 9.66 ms ± 755 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%%timeit
def decade(yr):
return yr // 10 * 10
# 使用 apply 计算年代
baby['Year'].apply(decade)
# 658 ms ± 49.6 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
使用 .apply() 的版本慢了 30 倍以上!特别是对于数值操作,我们建议直接对 pd.Series 对象进行操作,利用其向量化特性。
在本节中,我们介绍了数据变换。为了变换 Dataframe 中的值,我们通常使用 .apply() 和 .assign() 函数。在下一节中,我们将比较 Dataframe 与表示和操作数据表的其他方式。
6. Dataframe 与其他数据表示形式的区别 (How Are Dataframes Different?)
Dataframe 只是表示存储在表中的数据的一种方式。在实践中,数据科学家会遇到许多其他类型的数据表,如电子表格 (spreadsheets)、矩阵 (matrices) 和关系 (relations)。在本节中,我们将对比 Dataframe 与其他表示形式,解释为什么 Dataframe 在数据分析中如此广泛使用。我们还将指出其他表示形式可能更合适的情况。
6.1 Dataframe 与 电子表格 (Dataframes and Spreadsheets)
电子表格是计算机应用程序,用户可以在网格中输入数据并使用公式进行计算。今天一个众所周知的例子是 Microsoft Excel,尽管电子表格的历史至少可以追溯到 1979 年的 VisiCalc。电子表格使查看和直接操作数据变得容易,因为电子表格公式可以在数据更改时自动重新计算结果。相比之下,当数据集更新时,Dataframe 代码通常需要手动重新运行。这些特性使得电子表格非常受欢迎——根据 2005 年的估计,有超过 5500 万电子表格用户,而行业中只有 300 万专业程序员。
Dataframe 相比电子表格有几个关键优势。在像 Jupyter 这样的计算笔记本中编写 Dataframe 代码自然会产生数据血缘 (data lineage)。打开笔记本的人可以看到笔记本的输入文件以及数据是如何被更改的。电子表格不会使数据血缘可见;如果有人手动编辑单元格中的数据值,未来的用户很难知道哪些值被手动编辑过以及是如何编辑的。Dataframe 可以处理比电子表格更大的数据集,用户还可以使用分布式编程工具来处理很难加载到电子表格中的海量数据集。
6.2 Dataframe 与 矩阵 (Dataframes and Matrices)
矩阵是主要用于线性代数运算的二维数据数组。
矩阵是由它们允许的运算符定义的数学对象。例如,矩阵可以相加或相乘。矩阵也有转置。这些运算符具有数据科学家在统计建模中依赖的非常有用的属性。
矩阵和 Dataframe 之间的一个重要区别是,当矩阵被视为数学对象时,它们通常只能包含数字。另一方面,Dataframe 还可以包含其他类型的数据,如文本。这使得 Dataframe 在加载和处理可能包含各种数据类型的原始数据时更有用。在实践中,数据科学家经常将数据加载到 Dataframe 中,然后将数据处理成矩阵形式。在本书中,我们将通常使用 Dataframe 进行探索性数据分析和数据清洗,然后将数据处理成矩阵用于机器学习模型。
注意
数据科学家不仅将矩阵称为数学对象,也称为程序对象。例如,R 编程语言有一个矩阵对象,而在 Python 中我们可以使用二维 numpy 数组表示矩阵。Python 和 R 中实现的矩阵可以包含除数字以外的其他数据类型,但这样做会失去数学属性。这又是不同领域使用相同术语指代不同事物的另一个例子。
6.3 Dataframe 与 关系 (Dataframes and Relations)
关系是数据库系统(尤其是像 SQLite 和 PostgreSQL 这样的 SQL 系统)中使用的数据表表示形式。(我们将在 SQL 章节中介绍关系和 SQL)。关系与 Dataframe 有许多相似之处;两者都使用行表示记录,用列表示特征。两者都有列名,并且列内的数据具有相同的类型。
Dataframe 的一个关键优势是,它们在加载阶段更加灵活。很多时候,原始数据并不是以可以直接放入关系的方便格式出现的。在这些情况下,数据科学家使用 Dataframe 加载和处理数据,因为 Dataframe 在这方面更灵活。通常,数据科学家会将原始数据加载到 Dataframe 中,然后将数据处理成可以轻松存储到关系中的格式。
关系相比 Dataframe 的一个关键优势是,关系被像 PostgreSQL 这样的关系数据库系统使用,这些系统具有非常有用的数据存储和管理功能。考虑一家运营大型社交媒体网站的公司的数据科学家。数据库可能包含的数据太大,无法一次全部读入 pandas Dataframe;相反,数据科学家使用 SQL 查询来子集化和聚合数据,因为数据库系统更有能力处理大型数据集。此外,网站用户通过发帖、上传图片和编辑个人资料不断更新他们的数据。在这里,数据库系统让数据科学家重用现有的 SQL 查询来用最新数据更新他们的分析,而不必重复下载大型 CSV 文件。