Pandas 的基本特性之一就是高性能的内存式数据连接(join)和合并(merge)操作。如果你有使用数据库的经验,那么对这类操作一定很熟悉。Pandas 的主接口是pd.merge
函数,下面让我们通过一些示例来介绍它的用法。
pd.merge()
实现的功能基于关系代数
(relational algebra)的一部分。关系代数是处理关系型数据的通用理论,绝大多数数据库的可用操作都以此为理论基础。关系代数方法论的强大之处在于,它提出的若干简单操作规则经过组合就可以为任意数据集构建十分复杂的操作。借助在数据库或程序里已经高效实现的基础操作规范,你可以完成许多非常复杂的操作。
Pandas 在pd.merge()
函数与 Series 和 DataFrame 的join()
方法里实现了这些基础操作规则。下面来看看如何用这些简单的规则连接不同数据源的数据。
import pandas as pd
import numpy as np
class display(object):
template = """
{0}
{1}
"""
def __init__(self, *args):
self.args = args
def _repr_html_(self):
return '\n'.join(self.template.format(a, eval(a)._repr_html_())
for a in self.args)
def __repr__(self):
return '\n\n'.join(a + '\n' + repr(eval(a))
for a in self.args)
pd.merge()
函数实现了三种数据连接的类型:一对一
、多对一
和多对多
。
与之前介绍的按列合并十分相似,如下面示例所示,有两个包含同一所公司员工不同信息的 DataFrame:
df1 = pd.DataFrame({'员工': ['Bob', 'Jake', 'Lisa', 'Sue'],
'部门': ['会计', '工程', '工程', '招聘']})
df2 = pd.DataFrame({'员工': ['Lisa', 'Bob', 'Jake', 'Sue'],
'入职日期': [2004, 2008, 2012, 2014]})
display('df1', 'df2')
df1
员工 | 部门 | |
---|---|---|
0 | Bob | 会计 |
1 | Jake | 工程 |
2 | Lisa | 工程 |
3 | Sue | 招聘 |
df2
员工 | 入职日期 | |
---|---|---|
0 | Lisa | 2004 |
1 | Bob | 2008 |
2 | Jake | 2012 |
3 | Sue | 2014 |
若想将这两个 DataFrame 合并成一个 DataFrame,可以用pd.merge()
函数实现:
df3 = pd.merge(df1, df2)
df3
员工 | 部门 | 入职日期 | |
---|---|---|---|
0 | Bob | 会计 | 2008 |
1 | Jake | 工程 | 2012 |
2 | Lisa | 工程 | 2004 |
3 | Sue | 招聘 | 2014 |
pd.merge()
方法会默认丢弃原来的行索引,不过也可以自定义。
多对一连接是指,在需要连接的两个列中,有一列的值有重复。通过多对一连接获得的结果 DataFrame 将会保留重复值。请看下面的例子:
df4 = pd.DataFrame({'部门': ['会计', '工程', '招聘'],
'领导': ['Carly', 'Guido', 'Steve']})
display('df3', 'df4', 'pd.merge(df3, df4)')
df3
员工 | 部门 | 入职日期 | |
---|---|---|---|
0 | Bob | 会计 | 2008 |
1 | Jake | 工程 | 2012 |
2 | Lisa | 工程 | 2004 |
3 | Sue | 招聘 | 2014 |
df4
部门 | 领导 | |
---|---|---|
0 | 会计 | Carly |
1 | 工程 | Guido |
2 | 招聘 | Steve |
pd.merge(df3, df4)
员工 | 部门 | 入职日期 | 领导 | |
---|---|---|---|---|
0 | Bob | 会计 | 2008 | Carly |
1 | Jake | 工程 | 2012 | Guido |
2 | Lisa | 工程 | 2004 | Guido |
3 | Sue | 招聘 | 2014 | Steve |
在结果 DataFrame 中多了一个“领导”列,里面会因为输入数据的对应关系而有所重复。
多对多连接是个有点复杂的概念,不过也可以理解。如果左右两个输入的共同列都包含重复值,那么合并的结果就是一种多对多连接。用一个例子来演示更容易理解。来看下面的例子,里面有一个 DataFrame 显示不同岗位员工的一种或多种能力。
通过多对多连接,就可以得知每位员工所具备的能力:
df5 = pd.DataFrame({'部门': ['会计', '会计', '工程', '工程', '招聘', '招聘'],
'技能': ['数学', '制表', '编码', 'Linux', '制表', '管理']})
display('df1', 'df5', "pd.merge(df1, df5)")
df1
员工 | 部门 | |
---|---|---|
0 | Bob | 会计 |
1 | Jake | 工程 |
2 | Lisa | 工程 |
3 | Sue | 招聘 |
df5
部门 | 技能 | |
---|---|---|
0 | 会计 | 数学 |
1 | 会计 | 制表 |
2 | 工程 | 编码 |
3 | 工程 | Linux |
4 | 招聘 | 制表 |
5 | 招聘 | 管理 |
pd.merge(df1, df5)
员工 | 部门 | 技能 | |
---|---|---|---|
0 | Bob | 会计 | 数学 |
1 | Bob | 会计 | 制表 |
2 | Jake | 工程 | 编码 |
3 | Jake | 工程 | Linux |
4 | Lisa | 工程 | 编码 |
5 | Lisa | 工程 | Linux |
6 | Sue | 招聘 | 制表 |
7 | Sue | 招聘 | 管理 |
这三种数据连接可以直接与其他 Pandas 工具组合使用,从而实现各种各样的功能。但是工作中的真实数据集往往不像示例中演示的那么干净、整洁。下面就来介绍pd.merge()
的一些功能,它们可以让你更好的应对数据连接中的问题。
我们已经知道pd.merge()
的默认行为:它会将两个输入的一个或多个共同列作为键进行合并。但是由于两个输入要合并的列通常都不是同名的,因此pd.merge()
提供了一些参数处理这个问题。
on
的用法最简单的方法就是直接将参数on
设置为一个列名称或者一个包含多列名称的列表:
display('df1', 'df2', "pd.merge(df1, df2, on='员工')")
df1
员工 | 部门 | |
---|---|---|
0 | Bob | 会计 |
1 | Jake | 工程 |
2 | Lisa | 工程 |
3 | Sue | 招聘 |
df2
员工 | 入职日期 | |
---|---|---|
0 | Lisa | 2004 |
1 | Bob | 2008 |
2 | Jake | 2012 |
3 | Sue | 2014 |
pd.merge(df1, df2, on='员工')
员工 | 部门 | 入职日期 | |
---|---|---|---|
0 | Bob | 会计 | 2008 |
1 | Jake | 工程 | 2012 |
2 | Lisa | 工程 | 2004 |
3 | Sue | 招聘 | 2014 |
left_on
与right_on
参数有时候你需要合并连个列名不同的数据集,例如前面的员工信息表中有一个字段不是“员工”
而是“姓名”
。在这种情况下,就可以用left_on
与right_on
参数来指定列名:
df3 = pd.DataFrame({'姓名': ['Bob', 'Jake', 'Lisa', 'Sue'],
'工资': [70000, 80000, 120000, 90000]})
display('df1', 'df3', 'pd.merge(df1, df3, left_on="员工", right_on="姓名")')
df1
员工 | 部门 | |
---|---|---|
0 | Bob | 会计 |
1 | Jake | 工程 |
2 | Lisa | 工程 |
3 | Sue | 招聘 |
df3
姓名 | 工资 | |
---|---|---|
0 | Bob | 70000 |
1 | Jake | 80000 |
2 | Lisa | 120000 |
3 | Sue | 90000 |
pd.merge(df1, df3, left_on="员工", right_on="姓名")
员工 | 部门 | 姓名 | 工资 | |
---|---|---|---|---|
0 | Bob | 会计 | Bob | 70000 |
1 | Jake | 工程 | Jake | 80000 |
2 | Lisa | 工程 | Lisa | 120000 |
3 | Sue | 招聘 | Sue | 90000 |
但是获取的结果中有一个多余的列,可以通过 DataFrame 的drop()
方法将这一列去掉:
pd.merge(df1, df3, left_on="员工", right_on="姓名").drop("姓名", axis=1)
员工 | 部门 | 工资 | |
---|---|---|---|
0 | Bob | 会计 | 70000 |
1 | Jake | 工程 | 80000 |
2 | Lisa | 工程 | 120000 |
3 | Sue | 招聘 | 90000 |
left_index
与right_index
参数除了合并列之外,你可能还需要合并索引:
df1a = df1.set_index('员工')
df2a = df2.set_index('员工')
display('df1a', 'df2a')
df1a
部门 | |
---|---|
员工 | |
Bob | 会计 |
Jake | 工程 |
Lisa | 工程 |
Sue | 招聘 |
df2a
入职日期 | |
---|---|
员工 | |
Lisa | 2004 |
Bob | 2008 |
Jake | 2012 |
Sue | 2014 |
# pd.merge(df1a, df2a) 这样合并会报错,报错如下(MergeError:没有要执行合并的通用列)
# MergeError: No common columns to perform merge on. Merge options: left_on=None, right_on=None, left_index=False, right_index=False
display('df1a', 'df2a', "pd.merge(df1a, df2a, left_index=True, right_index=True)")
df1a
部门 | |
---|---|
员工 | |
Bob | 会计 |
Jake | 工程 |
Lisa | 工程 |
Sue | 招聘 |
df2a
入职日期 | |
---|---|
员工 | |
Lisa | 2004 |
Bob | 2008 |
Jake | 2012 |
Sue | 2014 |
pd.merge(df1a, df2a, left_index=True, right_index=True)
部门 | 入职日期 | |
---|---|---|
员工 | ||
Bob | 会计 | 2008 |
Jake | 工程 | 2012 |
Lisa | 工程 | 2004 |
Sue | 招聘 | 2014 |
为了方便考虑,DataFrame 实现了join()
方法,它可以按照索引进行数据合并:
display('df1a', 'df2a', 'df1a.join(df2a)')
df1a
部门 | |
---|---|
员工 | |
Bob | 会计 |
Jake | 工程 |
Lisa | 工程 |
Sue | 招聘 |
df2a
入职日期 | |
---|---|
员工 | |
Lisa | 2004 |
Bob | 2008 |
Jake | 2012 |
Sue | 2014 |
df1a.join(df2a)
部门 | 入职日期 | |
---|---|---|
员工 | ||
Bob | 会计 | 2008 |
Jake | 工程 | 2012 |
Lisa | 工程 | 2004 |
Sue | 招聘 | 2014 |
如果想将索引与列混合使用,那么可以通过结合left_index
与right_on
,或者结合left_on
与right_index
来实现:
# df1a 的 索引,与 df3 的 “姓名” 列,数据相同
display('df1a', 'df3', "pd.merge(df1a, df3, left_index=True, right_on='姓名')")
df1a
部门 | |
---|---|
员工 | |
Bob | 会计 |
Jake | 工程 |
Lisa | 工程 |
Sue | 招聘 |
df3
姓名 | 工资 | |
---|---|---|
0 | Bob | 70000 |
1 | Jake | 80000 |
2 | Lisa | 120000 |
3 | Sue | 90000 |
pd.merge(df1a, df3, left_index=True, right_on='姓名')
部门 | 姓名 | 工资 | |
---|---|---|---|
0 | 会计 | Bob | 70000 |
1 | 工程 | Jake | 80000 |
2 | 工程 | Lisa | 120000 |
3 | 招聘 | Sue | 90000 |
通过前面的演示,我们总结出数据连接的一个重要条件:集合操作规则。
df6 = pd.DataFrame({'姓名': ['Peter', 'Paul', 'Mary'],
'食物': ['鱼', '大豆', '面包']},
columns=['姓名', '食物'])
df7 = pd.DataFrame({'姓名': ['Mary', 'Joseph'],
'饮品': ['葡萄酒', '啤酒']},
columns=['姓名', '饮品'])
display('df6', 'df7', 'pd.merge(df6, df7)')
df6
姓名 | 食物 | |
---|---|---|
0 | Peter | 鱼 |
1 | Paul | 大豆 |
2 | Mary | 面包 |
df7
姓名 | 饮品 | |
---|---|---|
0 | Mary | 葡萄酒 |
1 | Joseph | 啤酒 |
pd.merge(df6, df7)
姓名 | 食物 | 饮品 | |
---|---|---|---|
0 | Mary | 面包 | 葡萄酒 |
我们合并两个数据集,在“姓名”列中只有一个共同值:Mary。默认情况下,结果中只会包含两个输入集合的交集
,这种连接方式叫做内连接
(inner join)。我们可以用how
参数设置连接方式,默认值是'inner'
:
pd.merge(df6, df7, how='inner')
姓名 | 食物 | 饮品 | |
---|---|---|---|
0 | Mary | 面包 | 葡萄酒 |
how
参数支持的数据连接方式还有'outer'
、'left'
和'right'
。外连接
(outer join)返回两个输入列的并集,所有缺失值都用NaN
填充:
display('df6', 'df7', "pd.merge(df6, df7, how='outer')")
df6
姓名 | 食物 | |
---|---|---|
0 | Peter | 鱼 |
1 | Paul | 大豆 |
2 | Mary | 面包 |
df7
姓名 | 饮品 | |
---|---|---|
0 | Mary | 葡萄酒 |
1 | Joseph | 啤酒 |
pd.merge(df6, df7, how='outer')
姓名 | 食物 | 饮品 | |
---|---|---|---|
0 | Peter | 鱼 | NaN |
1 | Paul | 大豆 | NaN |
2 | Mary | 面包 | 葡萄酒 |
3 | Joseph | NaN | 啤酒 |
左连接
(left join)和右连接
(right join)返回的结果分别只包含左列和右列:
display('df6', 'df7', "pd.merge(df6, df7, how='left')")
df6
姓名 | 食物 | |
---|---|---|
0 | Peter | 鱼 |
1 | Paul | 大豆 |
2 | Mary | 面包 |
df7
姓名 | 饮品 | |
---|---|---|
0 | Mary | 葡萄酒 |
1 | Joseph | 啤酒 |
pd.merge(df6, df7, how='left')
姓名 | 食物 | 饮品 | |
---|---|---|---|
0 | Peter | 鱼 | NaN |
1 | Paul | 大豆 | NaN |
2 | Mary | 面包 | 葡萄酒 |
display('df6', 'df7', "pd.merge(df6, df7, how='right')")
df6
姓名 | 食物 | |
---|---|---|
0 | Peter | 鱼 |
1 | Paul | 大豆 |
2 | Mary | 面包 |
df7
姓名 | 饮品 | |
---|---|---|
0 | Mary | 葡萄酒 |
1 | Joseph | 啤酒 |
pd.merge(df6, df7, how='right')
姓名 | 食物 | 饮品 | |
---|---|---|---|
0 | Mary | 面包 | 葡萄酒 |
1 | Joseph | NaN | 啤酒 |
suffixes
参数df8 = pd.DataFrame({'姓名': ['Bob', 'Jake', 'Lisa', 'Sue'],
'等级': [1, 2, 3, 4]})
df9 = pd.DataFrame({'姓名': ['Bob', 'Jake', 'Lisa', 'Sue'],
'等级': [3, 1, 4, 2]})
display('df8', 'df9', 'pd.merge(df8, df9, on="姓名")')
df8
姓名 | 等级 | |
---|---|---|
0 | Bob | 1 |
1 | Jake | 2 |
2 | Lisa | 3 |
3 | Sue | 4 |
df9
姓名 | 等级 | |
---|---|---|
0 | Bob | 3 |
1 | Jake | 1 |
2 | Lisa | 4 |
3 | Sue | 2 |
pd.merge(df8, df9, on="姓名")
姓名 | 等级_x | 等级_y | |
---|---|---|---|
0 | Bob | 1 | 3 |
1 | Jake | 2 | 1 |
2 | Lisa | 3 | 4 |
3 | Sue | 4 | 2 |
由于输出结果中有两个重复的列名,因此pd.merge()
函数会自动为它们增加后缀_x
或_y
,当然也可以用通过suffixes
参数自定义后缀:
display('df8', 'df9', 'pd.merge(df8, df9, on="姓名", suffixes=["_L", "_R"])')
df8
姓名 | 等级 | |
---|---|---|
0 | Bob | 1 |
1 | Jake | 2 |
2 | Lisa | 3 |
3 | Sue | 4 |
df9
姓名 | 等级 | |
---|---|---|
0 | Bob | 3 |
1 | Jake | 1 |
2 | Lisa | 4 |
3 | Sue | 2 |
pd.merge(df8, df9, on="姓名", suffixes=["_L", "_R"])
姓名 | 等级_L | 等级_R | |
---|---|---|---|
0 | Bob | 1 | 3 |
1 | Jake | 2 | 1 |
2 | Lisa | 3 | 4 |
3 | Sue | 4 | 2 |
suffixes
参数同样适用于任何连接方式,即使有三个及三个以上的重复列名时也同样适用。
pop = pd.read_csv('data/state-population.csv') # 人口
areas = pd.read_csv('data/state-areas.csv') # 地区
abbrevs = pd.read_csv('data/state-abbrevs.csv') # 地区名称缩写
display('pop.head()', 'areas.head()', 'abbrevs.head()')
pop.head()
state/region | ages | year | population | |
---|---|---|---|---|
0 | AL | under18 | 2012 | 1117489.0 |
1 | AL | total | 2012 | 4817528.0 |
2 | AL | under18 | 2010 | 1130966.0 |
3 | AL | total | 2010 | 4785570.0 |
4 | AL | under18 | 2011 | 1125763.0 |
areas.head()
state | area (sq. mi) | |
---|---|---|
0 | Alabama | 52423 |
1 | Alaska | 656425 |
2 | Arizona | 114006 |
3 | Arkansas | 53182 |
4 | California | 163707 |
abbrevs.head()
state | abbreviation | |
---|---|---|
0 | Alabama | AL |
1 | Alaska | AK |
2 | Arizona | AZ |
3 | Arkansas | AR |
4 | California | CA |
有了数据,我们先完成一个比较简单的小指标:美国各州的人口密度排名:
merged = pd.merge(pop, abbrevs, how='outer', left_on='state/region', right_on="abbreviation")
merged = merged.drop("abbreviation", axis=1)
merged.head()
state/region | ages | year | population | state | |
---|---|---|---|---|---|
0 | AL | under18 | 2012 | 1117489.0 | Alabama |
1 | AL | total | 2012 | 4817528.0 | Alabama |
2 | AL | under18 | 2010 | 1130966.0 | Alabama |
3 | AL | total | 2010 | 4785570.0 | Alabama |
4 | AL | under18 | 2011 | 1125763.0 | Alabama |
merged.isnull().any()
state/region False
ages False
year False
population True
state True
dtype: bool
部分population
与state
有缺失值,让我们仔细看看那些数据!
merged[merged['population'].isnull()].head()
state/region | ages | year | population | state | |
---|---|---|---|---|---|
2448 | PR | under18 | 1990 | NaN | NaN |
2449 | PR | total | 1990 | NaN | NaN |
2450 | PR | total | 1991 | NaN | NaN |
2451 | PR | under18 | 1991 | NaN | NaN |
2452 | PR | total | 1993 | NaN | NaN |
然后我们看下有哪些州缩写没有数据:
merged.loc[merged['state'].isnull(), 'state/region'].unique()
array(['PR', 'USA'], dtype=object)
上面可以看到:人口数据中包含PR
和USA
,但是这两项没有出现在州名称缩写表中。
merged.loc[merged['state/region'] == 'PR', 'state'] = 'Puerto Rico'
merged.loc[merged['state/region'] == 'USA', 'state'] = 'United States'
merged.isnull().any()
state/region False
ages False
year False
population True
state False
dtype: bool
final = pd.merge(merged, areas, on='state', how='left')
final.head()
state/region | ages | year | population | state | area (sq. mi) | |
---|---|---|---|---|---|---|
0 | AL | under18 | 2012 | 1117489.0 | Alabama | 52423.0 |
1 | AL | total | 2012 | 4817528.0 | Alabama | 52423.0 |
2 | AL | under18 | 2010 | 1130966.0 | Alabama | 52423.0 |
3 | AL | total | 2010 | 4785570.0 | Alabama | 52423.0 |
4 | AL | under18 | 2011 | 1125763.0 | Alabama | 52423.0 |
# 检查 缺失值
final.isnull().any()
state/region False
ages False
year False
population True
state False
area (sq. mi) True
dtype: bool
# 查看 缺失值
# final.loc[final['area (sq. mi)'].isnull(), 'state'].unique() # 和下面同效
final[final['area (sq. mi)'].isnull()]['state'].unique()
array(['United States'], dtype=object)
上述缺失值是全国面积数据
,针对本案例非必要,可以删除:
# 删除 缺失值
final.dropna(inplace=True)
final.head()
state/region | ages | year | population | state | area (sq. mi) | |
---|---|---|---|---|---|---|
0 | AL | under18 | 2012 | 1117489.0 | Alabama | 52423.0 |
1 | AL | total | 2012 | 4817528.0 | Alabama | 52423.0 |
2 | AL | under18 | 2010 | 1130966.0 | Alabama | 52423.0 |
3 | AL | total | 2010 | 4785570.0 | Alabama | 52423.0 |
4 | AL | under18 | 2011 | 1125763.0 | Alabama | 52423.0 |
现在数据准备好了。
# data2010 = final.query("year == 2010 & ages == 'total'") # and 替换成 & 也可以
data2010 = final.query("year == 2010 and ages == 'total'")
data2010.head()
state/region | ages | year | population | state | area (sq. mi) | |
---|---|---|---|---|---|---|
3 | AL | total | 2010 | 4785570.0 | Alabama | 52423.0 |
91 | AK | total | 2010 | 713868.0 | Alaska | 656425.0 |
101 | AZ | total | 2010 | 6408790.0 | Arizona | 114006.0 |
189 | AR | total | 2010 | 2922280.0 | Arkansas | 53182.0 |
197 | CA | total | 2010 | 37333601.0 | California | 163707.0 |
优先对索引进行重置,然后在计算:
data2010.set_index('state', inplace=True)
density = data2010['population'] / data2010['area (sq. mi)']
density.head()
state
Alabama 91.287603
Alaska 1.087509
Arizona 56.214497
Arkansas 54.948667
California 228.051342
dtype: float64
# 排序
density.sort_values(ascending=False, inplace=True)
density.head()
state
District of Columbia 8898.897059
Puerto Rico 1058.665149
New Jersey 1009.253268
Rhode Island 681.339159
Connecticut 645.600649
dtype: float64
# 查看最后几名
density.tail()
state
South Dakota 10.583512
North Dakota 9.537565
Montana 6.736171
Wyoming 5.768079
Alaska 1.087509
dtype: float64
在对较大的数据进行分析时,一项基本的工作就是有效的数据累计(summarization):计算累计(aggregation)指标,如sum()
、mean()
、median()
、min()
和max()
,其中每一个指标都呈现了大数据集的特征。在这一节中,我们将探索 Pandas 的累计功能,从类似前面 NumPy 数组中的简单操作,到基于groupby
实现的复杂操作。
我们将通过 Seaborn 程序库用一份行星数据来进行演示,其中包含天文学家观测到的绕恒星运转的行星数据(通常简称为太阳系外行星或外行星)。行星数据可以直接通过 Seaborn 下载:
import seaborn as sns
# 如果此处下载失败,可以自行去github下载:https://github.com/mwaskom/seaborn-data;
# 然后解压将文件拖进我的电脑seaborn-data的空文件里即可。
planets = sns.load_dataset('planets')
planets.shape
(1035, 6)
planets.head()
method | number | orbital_period | mass | distance | year | |
---|---|---|---|---|---|---|
0 | Radial Velocity | 1 | 269.300 | 7.10 | 77.40 | 2006 |
1 | Radial Velocity | 1 | 874.774 | 2.21 | 56.95 | 2008 |
2 | Radial Velocity | 1 | 763.000 | 2.60 | 19.84 | 2011 |
3 | Radial Velocity | 1 | 326.030 | 19.40 | 110.62 | 2007 |
4 | Radial Velocity | 1 | 516.220 | 10.50 | 119.47 | 2009 |
这份数据包含了截至2014年已被发现的一千多颗外行星的资料。
之前我们介绍过 NumPy 数组的一些数据累计指标。与一维 NumPy 数组相同,Pandas 的 Series 的累计函数也会返回一个统计值:
rng = np.random.RandomState(42)
ser = pd.Series(rng.rand(5))
ser
0 0.374540
1 0.950714
2 0.731994
3 0.598658
4 0.156019
dtype: float64
print('sum :', ser.sum())
print('mean:', ser.mean())
sum : 2.811925491708157
mean: 0.5623850983416314
DataFrame 的累计函数默认对每列进行统计:
df = pd.DataFrame({'A': rng.rand(5),
'B': rng.rand(5)})
df
A | B | |
---|---|---|
0 | 0.155995 | 0.020584 |
1 | 0.058084 | 0.969910 |
2 | 0.866176 | 0.832443 |
3 | 0.601115 | 0.212339 |
4 | 0.708073 | 0.181825 |
print(f'列 mean:\n{df.mean()}')
print(f'行 mean:\n{df.mean(axis=1)}')
列 mean:
A 0.477888
B 0.443420
dtype: float64
行 mean:
0 0.088290
1 0.513997
2 0.849309
3 0.406727
4 0.444949
dtype: float64
Pandas 的 Series 和 DataFrame 支持所有之前学习的常用累计函数。另外,还有一个非常方便的describe()
方法可以计算每一列的若干常用统计值。让我们在行星数据上试验一下,首先丢弃有缺失值的行:
planets.dropna().describe()
number | orbital_period | mass | distance | year | |
---|---|---|---|---|---|
count | 498.00000 | 498.000000 | 498.000000 | 498.000000 | 498.000000 |
mean | 1.73494 | 835.778671 | 2.509320 | 52.068213 | 2007.377510 |
std | 1.17572 | 1469.128259 | 3.636274 | 46.596041 | 4.167284 |
min | 1.00000 | 1.328300 | 0.003600 | 1.350000 | 1989.000000 |
25% | 1.00000 | 38.272250 | 0.212500 | 24.497500 | 2005.000000 |
50% | 1.00000 | 357.000000 | 1.245000 | 39.940000 | 2009.000000 |
75% | 2.00000 | 999.600000 | 2.867500 | 59.332500 | 2011.000000 |
max | 6.00000 | 17337.500000 | 25.000000 | 354.000000 | 2014.000000 |
这是一种理解数据集所有统计属性的有效方法,例如,从年份year
列中可以看出,1989
年首次发现外行星,而且一半已知外行星都在2010
年及以后的年份被发现的。这主要得益于开普勒计划
–一个通过激光望远镜发现恒星周围椭圆运动轨道行星的太空计划。
指标 | 描述 |
---|---|
count() |
计数项 |
first() , last() |
第一项、最后一项 |
mean() , median() |
平均值、中位数 |
min() , max() |
最小值、最大值 |
std() , var() |
标准差、方差 |
mad() |
均值绝对方差 |
prod() |
所有项乘积 |
sum() |
所有项求和 |
DataFrame
和Series
对象支持以上所有方法。
简单的累计方法可以让我们对数据集有一个笼统的认识,但是我们经常还需要对某些标签或索引的局部进行累计分析,这时就需要用到groupby
了。虽然“分组”(group by)这个名字借用了SQL
数据库语言的命令,但其理念引用发明 R语言 frame 的 Hadley Wickham 的观点可能更合适:分割 split
、应用 apply
和组合 combine
。
一个经典分割 - 应用 - 组合操作示例如下图,其中“apply”的是一个求和函数。
该图清晰地描述了GroupBy
的过程。
分割
步骤将 DataFrame 按照指定的键分割成若干组。应用
步骤对每个组应用函数,通常是累计、转换或过滤函数。组合
步骤将每一组的结果合并成一个输出数组。虽然我们也可以通过前面介绍的一系列的掩码、累计和合并操作来实现,但是一直到中间分割过程不需要显式地暴露出来这一点十分重要。而且GroupBy
只需要一行代码,就可以计算每组的和、均值、计数、最小值及其其他累计值。GroupBy
的用处就是将这些步骤进行抽象:用户不需要知道底层如何计算,只要把操作看成一个整体就够用了。
用 Pandas 进行上图所示的计算作为具体的示例。从创建输入 DataFrame 开始:
df = pd.DataFrame({'key': list('ABCABC'),
'data': range(6)})
df
key | data | |
---|---|---|
0 | A | 0 |
1 | B | 1 |
2 | C | 2 |
3 | A | 3 |
4 | B | 4 |
5 | C | 5 |
df.groupby('key')
这里需要注意的是,返回值不是一个DataFrame
对象,而是一个DataFrameGroupBy
对象。这个对象的魔力在于,你可以将它看成一个特殊形式的DataFrame
,里面隐藏着若干组数据,但是在没有应用累计函数之前不会计算。这种“延迟计算”
(lazy evaluation)的方法使得大多数常见的累计操作可以通过一种对用户而言几乎透明的(感觉操作仿佛不存在)方式非常高效地实现。
为了得到这个结果,可以对DataFrameGroupBy
对象应用累计函数,它会完成相应的应用/组合步骤并生成结果:
df.groupby('key').sum()
data | |
---|---|
key | |
A | 3 |
B | 5 |
C | 7 |
GroupBy
对象是一种非常灵活的抽象类型。在大型场景中,你可以将它看成是 DataFrame 的集合,在底层解决所有问题。
GroupBy
中最重要的操作可能就是累计 aggregate
、过滤 filter
、 转换 transform
和应用 apply
了。
(1)按列取值。 GroupBy 对象与 DataFrame 一样,也支持按列取值,并返回一个修改过的 GroupBy 对象:
planets.groupby('method')
planets.groupby('method')['orbital_period']
这里从原来的 DataFrame 中取某个列名作为一个 Series 组。与 GroupBy 对象一样,知道我们运行累计函数,才会开始计算:
planets.groupby('method')['orbital_period'].median()
method
Astrometry 631.180000
Eclipse Timing Variations 4343.500000
Imaging 27500.000000
Microlensing 3300.000000
Orbital Brightness Modulation 0.342887
Pulsar Timing 66.541900
Pulsation Timing Variations 1170.000000
Radial Velocity 360.200000
Transit 5.714932
Transit Timing Variations 57.011000
Name: orbital_period, dtype: float64
(2)按组迭代。 GroupBy对象支持直接按组进行迭代,返回的每一组都是 Series 或 DataFrame:
for (method, group) in planets.groupby('method'):
print("{0:30s} shape={1}".format(method, group.shape))
Astrometry shape=(2, 6)
Eclipse Timing Variations shape=(9, 6)
Imaging shape=(38, 6)
Microlensing shape=(23, 6)
Orbital Brightness Modulation shape=(3, 6)
Pulsar Timing shape=(5, 6)
Pulsation Timing Variations shape=(1, 6)
Radial Velocity shape=(553, 6)
Transit shape=(397, 6)
Transit Timing Variations shape=(4, 6)
(3)调用方法。 借助 Python 类的魔力(@classmethod),可以让任何不由 GroupBy 对象直接实现的方法直接应用到每一组,无论是 DataFrame 还是 Series 对象都同样适用。例如,你可以用 DataFrame 的describe()
方法进行累计,对每一组数据进行描述性统计:
planets.groupby('method')['year'].describe()
count | mean | std | min | 25% | 50% | 75% | max | |
---|---|---|---|---|---|---|---|---|
method | ||||||||
Astrometry | 2.0 | 2011.500000 | 2.121320 | 2010.0 | 2010.75 | 2011.5 | 2012.25 | 2013.0 |
Eclipse Timing Variations | 9.0 | 2010.000000 | 1.414214 | 2008.0 | 2009.00 | 2010.0 | 2011.00 | 2012.0 |
Imaging | 38.0 | 2009.131579 | 2.781901 | 2004.0 | 2008.00 | 2009.0 | 2011.00 | 2013.0 |
Microlensing | 23.0 | 2009.782609 | 2.859697 | 2004.0 | 2008.00 | 2010.0 | 2012.00 | 2013.0 |
Orbital Brightness Modulation | 3.0 | 2011.666667 | 1.154701 | 2011.0 | 2011.00 | 2011.0 | 2012.00 | 2013.0 |
Pulsar Timing | 5.0 | 1998.400000 | 8.384510 | 1992.0 | 1992.00 | 1994.0 | 2003.00 | 2011.0 |
Pulsation Timing Variations | 1.0 | 2007.000000 | NaN | 2007.0 | 2007.00 | 2007.0 | 2007.00 | 2007.0 |
Radial Velocity | 553.0 | 2007.518987 | 4.249052 | 1989.0 | 2005.00 | 2009.0 | 2011.00 | 2014.0 |
Transit | 397.0 | 2011.236776 | 2.077867 | 2002.0 | 2010.00 | 2012.0 | 2013.00 | 2014.0 |
Transit Timing Variations | 4.0 | 2012.500000 | 1.290994 | 2011.0 | 2011.75 | 2012.5 | 2013.25 | 2014.0 |
这张表可以帮助我们对数据有更深的认识,例如大多数行星都是通过Radial Velocity
和Transit
方法发现的,而且后者在近十年变得越来越普遍(得益于更新、更精准的望远镜)。最新的Transit Timing Variations
和Orbital Brightness Modulation
方法在 2011年之后才有新的发现。
rng = np.random.RandomState(0)
df = pd.DataFrame({'key' : list('ABCABC'),
'data1': range(6),
'data2': rng.randint(0, 10, 6)})
df
key | data1 | data2 | |
---|---|---|---|
0 | A | 0 | 5 |
1 | B | 1 | 0 |
2 | C | 2 | 3 |
3 | A | 3 | 3 |
4 | B | 4 | 7 |
5 | C | 5 | 9 |
(1)累计。 我们目前比较熟悉的 GroupBy 累计方法只有sum()
和median()
之类的简单函数,但是aggregate()
其实可以支持更复杂的操作,例如字符串、函数或者函数列表,并且能一次性计算所有累计值:
df.groupby('key').aggregate(['min', np.median, max])
data1 | data2 | |||||
---|---|---|---|---|---|---|
min | median | max | min | median | max | |
key | ||||||
A | 0 | 1.5 | 3 | 3 | 4.0 | 5 |
B | 1 | 2.5 | 4 | 0 | 3.5 | 7 |
C | 2 | 3.5 | 5 | 3 | 6.0 | 9 |
另一种方法就是通过 Python 字典指定不同列需要累积的函数:
df.groupby('key').aggregate({'data1': min,
'data2': np.max})
data1 | data2 | |
---|---|---|
key | ||
A | 0 | 5 |
B | 1 | 7 |
C | 2 | 9 |
(2)过滤。 过滤操作可以让你按照分组的属性丢弃若干数据。例如,我们可能只需要保留标准差超过某个阈值的组:
def filter_func(x):
return x['data2'].std() > 4
display('df', "df.groupby('key').std()", "df.groupby('key').filter(filter_func)")
df
key | data1 | data2 | |
---|---|---|---|
0 | A | 0 | 5 |
1 | B | 1 | 0 |
2 | C | 2 | 3 |
3 | A | 3 | 3 |
4 | B | 4 | 7 |
5 | C | 5 | 9 |
df.groupby('key').std()
data1 | data2 | |
---|---|---|
key | ||
A | 2.12132 | 1.414214 |
B | 2.12132 | 4.949747 |
C | 2.12132 | 4.242641 |
df.groupby('key').filter(filter_func)
key | data1 | data2 | |
---|---|---|---|
1 | B | 1 | 0 |
2 | C | 2 | 3 |
4 | B | 4 | 7 |
5 | C | 5 | 9 |
filter()
函数会接收一个布尔值,表示每个组是否通过过滤。由于A组‘data2’列的标准差不大于4,所以被丢弃了。
(3)转换。 累计操作返回的是对组内全量数据缩减过的结果,而转换操作会返回一个新的全量数据。数据经过转换之后,其形状与原来的输入数据是一样的。常见的例子就是将每一组的样本数据减去各组的均值,实现数据标准化:
df.groupby('key').transform(lambda x: x - x.mean())
data1 | data2 | |
---|---|---|
0 | -1.5 | 1.0 |
1 | -1.5 | -3.5 |
2 | -1.5 | -3.0 |
3 | 1.5 | -1.0 |
4 | 1.5 | 3.5 |
5 | 1.5 | 3.0 |
(4)apply()方法。 apply()方法让你可以在每个组上应用任意方法。这个函数输入一个 DataFrame,返回一个 Pandas 对象或一个标量。组合操作会适应返回结果类型。
下面例子就是用apply()
方法将第一列数据以第二列的和作为基数进行标准化:
def norm_by_data2(x):
x['data1'] /= x['data2'].sum()
return x
display('df', "df.groupby('key').apply(norm_by_data2)")
df
key | data1 | data2 | |
---|---|---|---|
0 | A | 0 | 5 |
1 | B | 1 | 0 |
2 | C | 2 | 3 |
3 | A | 3 | 3 |
4 | B | 4 | 7 |
5 | C | 5 | 9 |
df.groupby('key').apply(norm_by_data2)
key | data1 | data2 | |
---|---|---|---|
0 | A | 0.000000 | 5 |
1 | B | 0.142857 | 0 |
2 | C | 0.166667 | 3 |
3 | A | 0.375000 | 3 |
4 | B | 0.571429 | 7 |
5 | C | 0.416667 | 9 |
前面介绍的简单例子一直在用列名分割 DataFrame。这只是众多分组操作中的一种,下面将继续介绍更多的分组方法。
(1)将 列表、数组、Series 或 索引 作为分组键。 分组件可以是长度与 DataFrame 匹配的任意 Series 或列表,例如:
L = [0, 1, 0, 1, 2, 0]
display('df', 'df.groupby(L).sum()')
df
key | data1 | data2 | |
---|---|---|---|
0 | A | 0 | 5 |
1 | B | 1 | 0 |
2 | C | 2 | 3 |
3 | A | 3 | 3 |
4 | B | 4 | 7 |
5 | C | 5 | 9 |
df.groupby(L).sum()
data1 | data2 | |
---|---|---|
0 | 7 | 17 |
1 | 4 | 3 |
2 | 4 | 7 |
# df.groupby(df['key']) == df.groupby('key')
display('df', "df.groupby(df['key']).sum()")
df
key | data1 | data2 | |
---|---|---|---|
0 | A | 0 | 5 |
1 | B | 1 | 0 |
2 | C | 2 | 3 |
3 | A | 3 | 3 |
4 | B | 4 | 7 |
5 | C | 5 | 9 |
df.groupby(df['key']).sum()
data1 | data2 | |
---|---|---|
key | ||
A | 3 | 8 |
B | 5 | 7 |
C | 7 | 12 |
(2)用 字典 或 Series 将索引映射到分组名称。 另一种的方法是提供一个字典,将索引映射到分组键:
df2 = df.set_index('key')
mapping = {'A': 'vowel', 'B': 'consonant', 'C': 'consonant'}
display('df2', 'df2.groupby(mapping).sum()')
df2
data1 | data2 | |
---|---|---|
key | ||
A | 0 | 5 |
B | 1 | 0 |
C | 2 | 3 |
A | 3 | 3 |
B | 4 | 7 |
C | 5 | 9 |
df2.groupby(mapping).sum()
data1 | data2 | |
---|---|---|
key | ||
consonant | 12 | 19 |
vowel | 3 | 8 |
(3)任意 Python 函数。 与前面的字典映射类似,你可以将任意 Python 函数传入groupby
,函数映射到索引,然后新的分组输出:
display('df2', 'df2.groupby(str.lower).mean()')
df2
data1 | data2 | |
---|---|---|
key | ||
A | 0 | 5 |
B | 1 | 0 |
C | 2 | 3 |
A | 3 | 3 |
B | 4 | 7 |
C | 5 | 9 |
df2.groupby(str.lower).mean()
data1 | data2 | |
---|---|---|
key | ||
a | 1.5 | 4.0 |
b | 2.5 | 3.5 |
c | 3.5 | 6.0 |
(4)多个有效键构成的列表。 刺猬,任意之前有效的键都可以组合起来进行分组,从而返回一个多级索引的分组结果:
df2.groupby([str.lower, mapping]).mean()
data1 | data2 | ||
---|---|---|---|
key | key | ||
a | vowel | 1.5 | 4.0 |
b | consonant | 2.5 | 3.5 |
c | consonant | 3.5 | 6.0 |
通过下例中的几行 Python 代码,我们就可以运用上述知识,获取不同方法和不同年份发现的行星数量:
decade = 10 * (planets['year'] // 10)
decade = decade.astype(str) + 's'
decade.name = 'decade'
planets.groupby(['method', decade])['number'].sum().unstack().fillna(0)
decade | 1980s | 1990s | 2000s | 2010s |
---|---|---|---|---|
method | ||||
Astrometry | 0.0 | 0.0 | 0.0 | 2.0 |
Eclipse Timing Variations | 0.0 | 0.0 | 5.0 | 10.0 |
Imaging | 0.0 | 0.0 | 29.0 | 21.0 |
Microlensing | 0.0 | 0.0 | 12.0 | 15.0 |
Orbital Brightness Modulation | 0.0 | 0.0 | 0.0 | 5.0 |
Pulsar Timing | 0.0 | 9.0 | 1.0 | 1.0 |
Pulsation Timing Variations | 0.0 | 0.0 | 1.0 | 0.0 |
Radial Velocity | 1.0 | 52.0 | 475.0 | 424.0 |
Transit | 0.0 | 0.0 | 64.0 | 712.0 |
Transit Timing Variations | 0.0 | 0.0 | 0.0 | 9.0 |
planets.groupby(decade)['year'].median()
decade
1980s 1989.0
1990s 1998.0
2000s 2007.0
2010s 2012.0
Name: year, dtype: float64
数据透视表
(pivot table)是一种类似的操作方法,常见于 Excel 与 类似的表格应用中。数据透视表将每一列数据作为输入,输出将数据不断细分成多个维度累计信息的二维数据表。人们有时容易弄混数据透视表
与GroupBy
,但我觉得数据透视表更像一种多维度的 GroupBy 累计操作
。也就是说,虽然你也可以分割 - 应用 - 组合,但是分割与组合不是发生在一维索引上,而是在二维网格上(行列同时分组)。
这一节的示例将采用泰坦尼克号
的乘客信息数据库来演示,可以在Seaborn
程序库(上面8.1讲了获取方式)获取:
titanic = sns.load_dataset('titanic')
titanic.rename(columns={'survived': '幸存', 'pclass': '船舱等级', 'sex': '性别', 'age': '年龄', 'sibsp': '旁系',
'parch': '直系', 'fare': '票价', 'embarked': '登船港口', 'class': '船舱分级', 'who': '男/女/小孩',
'adult_male': '成年男性', 'deck': '甲板', 'embark_town': '登船小镇',
'alive': '活着', 'alone': '独自'}, inplace=True)
titanic.head()
幸存 | 船舱等级 | 性别 | 年龄 | 旁系 | 直系 | 票价 | 登船港口 | 船舱分级 | 男/女/小孩 | 成年男性 | 甲板 | 登船小镇 | 活着 | 独自 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 3 | male | 22.0 | 1 | 0 | 7.2500 | S | Third | man | True | NaN | Southampton | no | False |
1 | 1 | 1 | female | 38.0 | 1 | 0 | 71.2833 | C | First | woman | False | C | Cherbourg | yes | False |
2 | 1 | 3 | female | 26.0 | 0 | 0 | 7.9250 | S | Third | woman | False | NaN | Southampton | yes | True |
3 | 1 | 1 | female | 35.0 | 1 | 0 | 53.1000 | S | First | woman | False | C | Southampton | yes | False |
4 | 0 | 3 | male | 35.0 | 0 | 0 | 8.0500 | S | Third | man | True | NaN | Southampton | no | True |
在研究这些数据之前,先将它们按照性别
、最终生还状态
或其他组合属性
进行分组。如果你看过前面的章节,你可能会用GroupBy
来实现,例如这样统计不同性别乘客的生还率:
titanic.groupby('男/女/小孩')['幸存'].mean()
男/女/小孩
child 0.590361
man 0.163873
woman 0.756458
Name: 幸存, dtype: float64
比如,我们想要观察不同性别与船舱等级的生还情况:
# titanic.groupby(['男/女/小孩', '船舱分级'])['幸存'].aggregate('mean').unstack() # 与下面等同
titanic.groupby(['男/女/小孩', '船舱分级'])['幸存'].mean().unstack()
船舱分级 | First | Second | Third |
---|---|---|---|
男/女/小孩 | |||
child | 0.833333 | 1.000000 | 0.431034 |
man | 0.352941 | 0.080808 | 0.119122 |
woman | 0.978022 | 0.909091 | 0.491228 |
虽然这样也可以清晰地观察数据,但是代码看上去有点复杂。尽管这个管道命令的每一步前面都介绍过,但是理解起来也不太容易。由于二维的GroupBy
应用场景非常普遍,因此 Pandas 提供了一个快捷方式pivot_table
来快速解决多维的累计分析任务。
用 DataFrame 的pivot_table
实现的效果等同于上面的管道命令:
titanic.pivot_table('幸存', index='男/女/小孩', columns='船舱分级')
船舱分级 | First | Second | Third |
---|---|---|---|
男/女/小孩 | |||
child | 0.833333 | 1.000000 | 0.431034 |
man | 0.352941 | 0.080808 | 0.119122 |
woman | 0.978022 | 0.909091 | 0.491228 |
与GroupBy
方法相比,这行代码可读性更强,而且取得的结果也一样。
与GroupBy
类似,数据透视表中的分组也可以通过各种参数指定多个等级。例如,我们可能想把年龄也加进去作为第三个维度,这就可以通过pd.cut
函数将年龄进行分段:
age = pd.cut(titanic['年龄'], [0, 18, 80])
titanic.pivot_table('幸存', index=['性别', age], columns='船舱分级')
船舱分级 | First | Second | Third | |
---|---|---|---|---|
性别 | 年龄 | |||
female | (0, 18] | 0.909091 | 1.000000 | 0.511628 |
(18, 80] | 0.972973 | 0.900000 | 0.423729 | |
male | (0, 18] | 0.800000 | 0.600000 | 0.215686 |
(18, 80] | 0.375000 | 0.071429 | 0.133663 |
fare = pd.qcut(titanic['票价'], 2) # 票价按照计数项等分为两份
titanic.pivot_table('幸存', index=['性别', age], columns=[fare, '船舱分级'])
票价 | (-0.001, 14.454] | (14.454, 512.329] | |||||
---|---|---|---|---|---|---|---|
船舱分级 | First | Second | Third | First | Second | Third | |
性别 | 年龄 | ||||||
female | (0, 18] | NaN | 1.000000 | 0.714286 | 0.909091 | 1.000000 | 0.318182 |
(18, 80] | NaN | 0.880000 | 0.444444 | 0.972973 | 0.914286 | 0.391304 | |
male | (0, 18] | NaN | 0.000000 | 0.260870 | 0.800000 | 0.818182 | 0.178571 |
(18, 80] | 0.0 | 0.098039 | 0.125000 | 0.391304 | 0.030303 | 0.192308 |
DataFrame 的pivot_table
方法(1.3.3版本)的完整签名如下:
@Substitution("data : DataFrame")
@Appender(_shared_docs["pivot_table"], indents=1)
def pivot_table(
data: DataFrame,
values=None,
index=None,
columns=None,
aggfunc: AggFuncType = "mean", # 累计函数类型,‘sum’或者 np.sum(),或者通过字典为不同的列指定不同的累计函数
fill_value=None, # 处理缺失值
margins=False, # 是否计算每组总数
dropna=True, # 处理缺失值
margins_name="All", # 每组总数名称
observed=False,
sort=True,
) -> DataFrame:
titanic.pivot_table(index='性别', columns='船舱分级', aggfunc={'幸存': sum, '票价': 'mean'})
幸存 | 票价 | |||||
---|---|---|---|---|---|---|
船舱分级 | First | Second | Third | First | Second | Third |
性别 | ||||||
female | 91 | 70 | 72 | 106.125798 | 21.970121 | 16.118810 |
male | 45 | 17 | 47 | 67.226127 | 19.741782 | 12.661633 |
需要注意的是,这里忽略了一个参数values
。当我们为aggfunc
指定映射关系的时候,待透视的数值就已经确定了。
当需要计算每一组的总数时,可以通过margins
参数来设置:
titanic.pivot_table('幸存', index='性别', columns='船舱分级', margins=True)
船舱分级 | First | Second | Third | All |
---|---|---|---|---|
性别 | ||||
female | 0.968085 | 0.921053 | 0.500000 | 0.742038 |
male | 0.368852 | 0.157407 | 0.135447 | 0.188908 |
All | 0.629630 | 0.472826 | 0.242363 | 0.383838 |
这样就可以获取 不同性别,不同船舱等级下的生还率。以及全部乘客的生还率为38%
。
数据下载:!curl -O https://raw.githubusercontent.com/jakevdp/data-CDCbirths/master/births.csv
import matplotlib.pyplot as plt
# 设置 Seaborn 风格
sns.set()
# 设置字体为黑体,解决Matplotlib中文乱码问题
plt.rcParams['font.sans-serif'] = ['PingFang HK']
# 解决Matplotlib坐标轴负号'-'显示为方块的问题
plt.rcParams['axes.unicode_minus']=False
births = pd.read_csv("./PythonDataScienceHandbook-master/notebooks/data/births.csv")
births.rename(columns={'year': '年', 'month': '月', 'day': '日', 'gender': '性别', 'births': '出生数'},
inplace=True)
# 性别:F -> 女;M -> 男
births.head()
年 | 月 | 日 | 性别 | 出生数 | |
---|---|---|---|---|---|
0 | 1969 | 1 | 1.0 | F | 4046 |
1 | 1969 | 1 | 1.0 | M | 4440 |
2 | 1969 | 1 | 2.0 | F | 4454 |
3 | 1969 | 1 | 2.0 | M | 4548 |
4 | 1969 | 1 | 3.0 | F | 4548 |
可以用一个数据透视表来探索这份数据。先增加一列表示不同年代,看看各个年代的男女出生比例:
births['年代'] = 10 * (births['年'] // 10)
births.pivot_table('出生数', index='年代', columns='性别', aggfunc=sum)
性别 | F | M |
---|---|---|
年代 | ||
1960 | 1753634 | 1846572 |
1970 | 16263075 | 17121550 |
1980 | 18310351 | 19243452 |
1990 | 19479454 | 20420553 |
2000 | 18229309 | 19106428 |
%matplotlib inline
births.pivot_table('出生数', index='年', columns='性别', aggfunc=sum).plot()
plt.ylabel('每年的出生总数')
Text(0, 0.5, '每年的出生总数')
虽然使用数据透视表不是必须的,但是通过 Pandas 的这个工具展现一些有趣的特征。我们必须对数据做一点儿清理工作,消除由于输错了日期而造成的异常点(如6月31号)或者是缺失值(如1999年6月)。消除这些异常的简单方法就是直接删除异常值,可以通过更稳定的sigma
消除法(sigma-clipping,按照正态分布标准差划定范围,SciPy中默认是四个标准差)操作来实现:
quartiles = np.percentile(births['出生数'], [25, 50, 75])
mu = quartiles[1]
sig = 0.74 * (quartiles[2] - quartiles[0])
sig
是样本均值的稳定性估计,其中0.74
是指标准正态分布的分位数间距。在qurey()
方法中用这个范围就可以将有效的生日数据筛选出来:
births = births.query('(出生数 > @mu - 5 * @sig) & (出生数 < @mu + 5 * @sig)')
然后,将日
列设置为整数。这列数据在筛选之前是字符串,因为数据集中有的列含有缺失值null
:
births['日'] = births['日'].astype(int)
现在就可以将年月日
组合起来创建一个日期索引了,这样就可以快速计算每一行是星期几:
# 用 年月日 创建一个日期索引
births.index = pd.to_datetime(10000 * births['年'] + 100 * births['月'] + births['日'], format='%Y%m%d')
births['星期'] = births.index.dayofweek
births.head()
年 | 月 | 日 | 性别 | 出生数 | 年代 | 星期 | |
---|---|---|---|---|---|---|---|
1969-01-01 | 1969 | 1 | 1 | F | 4046 | 1960 | 2 |
1969-01-01 | 1969 | 1 | 1 | M | 4440 | 1960 | 2 |
1969-01-02 | 1969 | 1 | 2 | F | 4454 | 1960 | 3 |
1969-01-02 | 1969 | 1 | 2 | M | 4548 | 1960 | 3 |
1969-01-03 | 1969 | 1 | 3 | F | 4548 | 1960 | 4 |
用这个索引可以画出不同年代不同星期的日均出生率:
births.pivot_table('出生数', index='星期', columns='年代', aggfunc='mean').plot()
# 先使用 set_xticks 固定刻度位置
plt.gca().set_xticks(plt.gca().get_xticks().tolist()[1:-1])
# 再进行标签 修改/添加 的操作。
# 该方法只能在使用 set_xticks 固定刻度位置之后使用。否则,标签可能会出现在意想不到的位置。
plt.gca().set_xticklabels(['周一', '周二', '周三', '周四', '周五', '周六', '周末'])
plt.ylabel('出生数/日(平均)')
Text(0, 0.5, '出生数/日(平均)')
由上图可知,周末的出生人数比工作日要低许多。另外,因为 CDC 只提供了 1989 年之前的数据,所以没有 20世纪90年代和 21世纪的数据。
另一个有趣的图标是画出各个年份
平均每天的出生人数,可以按照月和日两个维度分别对数据进行分组:
births_by_day = births.pivot_table('出生数', index=[births.index.month, births.index.day])
births_by_day.head()
出生数 | ||
---|---|---|
1 | 1 | 4009.225 |
2 | 4247.400 | |
3 | 4500.900 | |
4 | 4571.350 | |
5 | 4603.625 |
这是一个包含月日的多级索引。为了让数据可以用图形表示,我们可以虚构一个年份,与月日结合成新的索引(因为又2月29号,所以需要一个闰年):
births_by_day.index = [pd.datetime(2012, month, day) for (month, day) in births_by_day.index]
births_by_day.head()
/var/folders/rx/27fc03s525nbs15jphd750hm0000gn/T/ipykernel_1679/1737888214.py:1: FutureWarning: The pandas.datetime class is deprecated and will be removed from pandas in a future version. Import from datetime module instead.
births_by_day.index = [pd.datetime(2012, month, day) for (month, day) in births_by_day.index]
出生数 | |
---|---|
2012-01-01 | 4009.225 |
2012-01-02 | 4247.400 |
2012-01-03 | 4500.900 |
2012-01-04 | 4571.350 |
2012-01-05 | 4603.625 |
fig, ax = plt.subplots(figsize=(12, 4))
births_by_day.plot(ax = ax)
plt.gca().set_xticks(plt.gca().get_xticks().tolist()[:-1])
plt.gca().set_xticklabels(['一月', '二月', '三月', '四月', '五月', '六月',
'七月', '八月', '九月', '十月', '十一月', '十二月'])
[Text(15340, 0, '一月'),
Text(15371, 0, '二月'),
Text(15400, 0, '三月'),
Text(15431, 0, '四月'),
Text(15461, 0, '五月'),
Text(15492, 0, '六月'),
Text(15522, 0, '七月'),
Text(15553, 0, '八月'),
Text(15584, 0, '九月'),
Text(15614, 0, '十月'),
Text(15645, 0, '十一月'),
Text(15675, 0, '十二月')]
从上图我们可以明显看出,美国节假日的时候,出生人数急剧下降。这种现象可能是由于医院放假导致的接生数减少。。。而非某种自然生育的心理学效应。
使用 Python 的一个优势就是字符串处理起来比较容易。在此基础上创建的 Pandas 同样提供了一系列向量化字符串操作
(vectorized string operation),它们都是在处理(清洗)现实工作中的数据时不可或缺的功能。
这一节中,我们将介绍 Pandas 的字符串操作,学习如何用它们对一个从网络采集来的杂乱无章的数据进行局部处理。
向量化
操作简化了纯数值的数组操作语法 – 我们不需要再担心数组的长度或维度,只需要关心需要的操作。然而,由于 NumPy 并没有为字符串数组提供简单的接口,因此需要通过繁琐的for
循环来解决问题:
data = ['peter', 'Paul', 'MARY', 'gUIDO']
[s.capitalize() for s in data]
['Peter', 'Paul', 'Mary', 'Guido']
虽然这么做对于某些数据可能是有效的,但是假如数据中出现了缺失值
,那么这样做就会引起异常
,例如:
data = ['peter', 'Paul', None, 'MARY', 'gUIDO']
[s.capitalize() for s in data]
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
/03 第三章 Pandas 数据处理(下半章).ipynb Cell 151' in ()
1 data = ['peter', 'Paul', None, 'MARY', 'gUIDO']
----> 2 [s.capitalize() for s in data]
/03 第三章 Pandas 数据处理(下半章).ipynb Cell 151' in (.0)
1 data = ['peter', 'Paul', None, 'MARY', 'gUIDO']
----> 2 [s.capitalize() for s in data]
AttributeError: 'NoneType' object has no attribute 'capitalize'
|
Pandas 为包含字符串的 Series 和 Index 对象提供的str
属性堪称两全其美的方法,它既可以满足向量化字符串操作的需求,又可以正确地处理缺失值。例如,我们用前面的数据data
创建了一个 Pandas 的 Series:
names = pd.Series(data)
names.str.capitalize()
0 Peter
1 Paul
2 None
3 Mary
4 Guido
dtype: object
如果你熟悉 Python 的字符串方法的话,就会发现 Pandas 绝大多数的字符串语法都很直观,甚至可以了列成一个表格。这一节的示例将采用一些人名来演示:
monte = pd.Series(['Graham Chapman', 'John Cleese', 'Terry Gilliam',
'Eric Idle', 'Terry Jones', 'Michael Palin'])
几乎所有 Python 内置的字符串方法都被复制到 Pandas 的向量化字符串方法中。
len() |
lower() |
translate() |
islower() |
ljust() |
upper() |
startswith() |
isupper() |
rjust() |
find() |
endswith() |
isnumeric() |
center() |
rfind() |
isalnum() |
isdecimal() |
zfill() |
index() |
isalpha() |
split() |
strip() |
rindex() |
isdigit() |
rsplit() |
lstrip() |
swapcase() |
istitle() |
rpartition() |
需要注意:这些方法的返回值不同,例如lower()
方法返回一个字符串 Series。
monte.str.lower()
0 graham chapman
1 john cleese
2 terry gilliam
3 eric idle
4 terry jones
5 michael palin
dtype: object
但是有些方法返回数值:
monte.str.len()
0 14
1 11
2 13
3 9
4 11
5 13
dtype: int64
有些方法返回布尔值:
monte.str.startswith('T')
0 False
1 False
2 True
3 False
4 True
5 False
dtype: bool
有些方法返回列表或其他复合值:
monte.str.split()
0 [Graham, Chapman]
1 [John, Cleese]
2 [Terry, Gilliam]
3 [Eric, Idle]
4 [Terry, Jones]
5 [Michael, Palin]
dtype: object
还有一些支持正则表达式的方法可以用来处理每个字符串元素。下表的内容是 Pandas 向量化字符串方法根据 Python 标准库的re
模块函数实现的API
。
方法 | 描述 |
---|---|
match() |
对每个元素调用re.match() ,返回布尔值 |
extract() |
对每个元素调用re.match() ,返回匹配的字符串组 (groups) |
findall() |
对每个元素调用re.findall() |
replace() |
用正则模式替换字符串 |
contains() |
对每个元素调用re.search() ,返回布尔值 |
count() |
计算符合正则模式的字符串数量 |
split() |
等价于str.split() ,支持正则表达式 |
rsplit() |
等价于str.rsplit() ,支持正则表达式 |
通过这些方法,可以实现各种有趣的操作,例如可以提取元素前面的连续字母作为每个人的名字(first name):
monte.str.extract('([a-zA-Z]+)')
0 | |
---|---|
0 | Graham |
1 | John |
2 | Terry |
3 | Eric |
4 | Terry |
5 | Michael |
还能实现更复杂的操作,例如找出所有开头和结尾都是辅音字母的名字 – 这可以用正则表达式中的开始符合^
与结尾符号$
来实现:
monte.str.findall(r'^[^AEIOU].*[^aeiou]$')
0 [Graham Chapman]
1 []
2 [Terry Gilliam]
3 []
4 [Terry Jones]
5 [Michael Palin]
dtype: object
还有其他一些方法也可以实现方便的操作。
方法 | 描述 |
---|---|
get() |
获取元素索引位置上的值,索引从 0 开始 |
slice() |
对元素进行切片取值 |
slice_replace() |
对元素进行切片替换 |
cat() |
拼接字符串(此功能比较复杂,建议阅读文档) |
repeat() |
重复元素 |
normalize() |
将字符串转换为Unicode 规范形式 |
pad() |
在字符串的左边、右边或两边增加空格 |
wrap() |
将字符串按照指定的宽度换行 |
join() |
用分隔符连接Series 的每个元素 |
get_dummies() |
按照间隔符提取每个元素的dummy 变量,转换为独热 (one-hot)编码的DataFrame |
(1)向量化字符串的取值与切片操作。 这里需要特别指出的是,get()
与slice()
操作可以从每个字符串数组中获取向量化元素。通过 Python 的标准取值方法也可以取得同样的效果。
# monte.str.slice(0:3) # 与下面效果相同
monte.str[0:3]
0 Gra
1 Joh
2 Ter
3 Eri
4 Ter
5 Mic
dtype: object
# monte.str.split().str.get(-1) # 与下面效果相同
monte.str.split().str[-1]
0 Chapman
1 Cleese
2 Gilliam
3 Idle
4 Jones
5 Palin
dtype: object
(2)指标变量。 另一个需要花点时间解释的是get_dummies()
方法。当你的数据有一列包含了若干已被编码的指标时,这个方法就能派上用场了。例如,假设有一个包含了某种编码信息的数据集,如 A=出生在美国、B=出生在英国、C=喜欢奶酪、D=喜欢冰淇淋:
full_monte = pd.DataFrame({'姓名': monte,
'信息': ['B|C|D', 'B|D', 'A|C', 'B|D', 'B|C', 'B|C|D']})
full_monte
姓名 | 信息 | |
---|---|---|
0 | Graham Chapman | B|C|D |
1 | John Cleese | B|D |
2 | Terry Gilliam | A|C |
3 | Eric Idle | B|D |
4 | Terry Jones | B|C |
5 | Michael Palin | B|C|D |
get_dummies()
方法可以让你快速将这些指标变量分割成一个独热编码
的 DataFrame(每个元素都是 0 或 1):
full_monte['信息'].str.get_dummies('|')
A | B | C | D | |
---|---|---|---|---|
0 | 0 | 1 | 1 | 1 |
1 | 0 | 1 | 0 | 1 |
2 | 1 | 0 | 1 | 0 |
3 | 0 | 1 | 0 | 1 |
4 | 0 | 1 | 1 | 0 |
5 | 0 | 1 | 1 | 1 |
通过 Pandas 自带的这些字符串操作方法,你就可以建立一个功能无比强大的字符串处理程序来清洗自己的数据了。
前面介绍的这些向量化字符串操作方法非常适合用来处理现实中那些凌乱的数据。下面将通过一个从不同网站获取的公开食谱数据库的案例来进行演示。我们的目标是将这些食谱数据解析为食材列表,这样就可以根据现有的食材快速找到食谱。
获取数据:https://github.com/fictivekin/openrecipes
# 数据下载,Jupyter 直接运行,即可下载。
# !curl -O https://s3.amazonaws.com/openrecipes/20170107-061401-recipeitems.json.gz
# !gunzip 20170107-061401-recipeitems.json.gz
try:
recipes = pd.read_json('./recipeitems.json')
except ValueError as e:
print("ValueError:", e)
ValueError: Trailing data
这里直接读取会提示数据里有Trailing data
(数据断行)的ValueError
错误。原因好像是虽然文件中的每一行
都是一个有效的JSON
对象,但是全文却不是这样,我们来验证一下:
with open("./recipeitems.json") as f:
line = f.readline() # 读取一行
pd.read_json(line).shape
(2, 12)
那么我们就需要新建一个字符串,将所有行JSON
对象连接起来,然后再通过pd.read_json
读取全部数据:
with open("./recipeitems.json") as f:
data = [line.strip() for line in f] # 提取每一行
data_json = f"[{', '.join(data)}]" # 将所有数据连接,合成一个列表
recipes = pd.read_json(data_json)
recipes.rename(columns={'name': '菜名', 'ingredients': '食材', 'url': '网址', 'image': '图片',
'cookTime': '烹饪时长', 'source': '来源', 'recipeYield': '配方产量',
'datePublished': '发布日期', 'prepTime': '准备时长', 'description': '描述',
'totalTime': '总时长', 'creator': '创造者', 'recipeCategory': '分类',
'dateModified': '日期修改', 'recipeInstructions': '食谱说明'},
inplace=True)
recipes.shape
(173278, 17)
recipes.iloc[0] # 抽取一行看看
_id {'$oid': '5160756b96cc62079cc2db15'}
菜名 Drop Biscuits and Sausage Gravy
食材 Biscuits\n3 cups All-purpose Flour\n2 Tablespo...
网址 http://thepioneerwoman.com/cooking/2013/03/dro...
图片 http://static.thepioneerwoman.com/cooking/file...
ts {'$date': 1365276011104}
烹饪时长 PT30M
来源 thepioneerwoman
配方产量 12
发布日期 2013-03-11
准备时长 PT10M
描述 Late Saturday afternoon, after Marlboro Man ha...
总时长 NaN
创造者 NaN
分类 NaN
日期修改 NaN
食谱说明 NaN
Name: 0, dtype: object
我们可以观察到,上面食材
是字符串形式,我们需要从中抽取感兴趣的信息。下面仔细看这个字段:
recipes['食材'].str.len().describe()
count 173278.000000
mean 244.617343
std 146.704308
min 0.000000
25% 147.000000
50% 221.000000
75% 314.000000
max 9067.000000
Name: 食材, dtype: float64
食材
列表平均 245 个字符,最短为 0 个,最长的 9100 个字符!出于好奇心,来看看这个拥有最食材列表的究竟是那道菜:
# np.argmax(a):返回的是a中元素最大值所对应的索引值
recipes['菜名'][np.argmax(recipes['食材'].str.len())]
'Carrot Pineapple Spice & Brownie Layer Cake with Whipped Cream & Cream Cheese Frosting and Marzipan Carrots'
我们还可以在做一些累计探索,例如看看哪些食谱是早餐:
recipes['描述'].str.contains('[Bb]reakfast').sum()
3524
或者看看有多少食谱用到了肉桂(cinnamin)作为食材:
recipes['食材'].str.contains('[Cc]innamon').sum()
10526
还可以看看究竟有多少食谱里把肉桂写错成(cinamin)
recipes['食材'].str.contains('[Cc]inamon').sum()
11
如果用户提供一些食材,系统就会推荐使用了所有食材的食谱。说起来是容易,但是由于大量不规则数据的存在,这个任务变得十分复杂,例如并没有一个简单直接的方法可以从每一行数据中清理出一份干净的食材列表。因此,我们在这里简化处理:首先提供一些常见食材列表,然后通过简单搜索判断这些食材是否在食谱中。为了简化任务,这里只列举常用的香料和调味料:
spice_list = ['salt', 'pepper', 'oregano', 'sage', 'parsley',
'rosemary', 'tarragon', 'thyme', 'paprika', 'cumin']
现在就可以通过一个由True
或False
构成的布尔类型的 DataFrame 来判断食材是否出现在某个食谱中:
import re
# re.IGNORECASE:忽略大小写
spice_df = pd.DataFrame(
dict(# 食材,食谱中是否存在
(spice, recipes['食材'].str.contains(spice, re.IGNORECASE)) for spice in spice_list
)
)
spice_df.head()
salt | pepper | oregano | sage | parsley | rosemary | tarragon | thyme | paprika | cumin | |
---|---|---|---|---|---|---|---|---|---|---|
0 | False | False | False | True | False | False | False | False | False | False |
1 | False | False | False | False | False | False | False | False | False | False |
2 | True | True | False | False | False | False | False | False | False | True |
3 | False | False | False | False | False | False | False | False | False | False |
4 | False | False | False | False | False | False | False | False | False | False |
现在,来找一份使用了欧芹(parsley)
、辣椒粉(paprika)
和龙蒿叶(tarragon)
这三种食材的食谱:
selection = spice_df.query('parsley & paprika & tarragon') # 与下面同效
len(selection)
10
selection = spice_df[spice_df['parsley'] & spice_df['paprika'] & spice_df['tarragon']]
len(selection)
10
看看是那十个食谱:
recipes['菜名'][selection.index]
2069 All cremat with a Little Gem, dandelion and wa...
74964 Lobster with Thermidor butter
93768 Burton's Southern Fried Chicken with White Gravy
113926 Mijo's Slow Cooker Shredded Beef
137686 Asparagus Soup with Poached Eggs
140530 Fried Oyster Po’boys
158475 Lamb shank tagine with herb tabbouleh
158486 Southern fried chicken in buttermilk
163175 Fried Chicken Sliders with Pickles + Slaw
165243 Bar Tartine Cauliflower Salad
Name: 菜名, dtype: object
由于 Pandas 最初是为金融模型而创建的,因此它拥有一些功能非常强大的日期、时间、带时间索引数据的处理工具。本节将介绍的时间和日期数据主要包括三类。
时间戳
表示某个具体时间。时间间隔
与周期
表示开始时间点与结束时间点之前的时间长度。时间增量(time delta)
或持续时间(duration)
表示精确的时间长度。在 Python 标准库与第三方库中有许多可以表示日期、时间、时间增量和时间跨度的工具。尽管 Pandas 提供的时间序列工具更适合用来处理数据科学问题,但是了解 Pandas 与 Python 标准库以及第三方库中的其他时间序列工具之间的关联性将大有裨益。
datetime
与dateutil
Python 基本的日期与时间功能都在标准库的datetime
模块中。如果和第三方库dateutil
模块搭配使用,可以快速实现许多处理日期与时间的功能,例如,你可以用datetime
类型创建一个日期:
from datetime import datetime
datetime(year=2022, month=5, day=10)
datetime.datetime(2022, 5, 10, 0, 0)
from dateutil import parser
date = parser.parse('4th, July, 2015')
date
datetime.datetime(2015, 7, 4, 0, 0)
# 例如打印星期几
date.strftime('%A')
'Saturday'
在上面一行代码中,为了打印出是星期几,我们使用了一个标准字符串格式
(standard string format)
代码 | 含义 | 代码 | 含义 |
---|---|---|---|
%a |
星期几的简写;如 星期三为Web | %A |
星期几的全称;如 星期三为Wednesday |
%b |
月份的简写;如 4月份为Apr | %B |
月份的全称;如 4月份为April |
%c |
标准的日期的时间串;如 04/07/10 10:43:39 | %C |
年份的后两位数字 |
%d |
十进制表示的每月的第几天 | %D |
月/天/年 |
%e |
在两字符域中,十进制表示的每月的第几天 | %F |
年-月-日 |
%g |
年份的后两位数字,使用基于周的年 | %G |
年分,使用基于周的年 |
%h |
简写的月份名 | %H |
24小时制的小时 |
%I |
12小时制的小时 | %j |
十进制表示的每年的第几天 |
%m |
十进制表示的月份 | %M |
十时制表示的分钟数 |
%n |
新行符 | %p |
本地的AM或PM的等价显示 |
%r |
12小时的时间 | %R |
显示小时和分钟:hh:mm |
%S |
十进制的秒数 | %V |
每年的第几周,使用基于周的年 |
%t |
水平制表符 | %T |
显示时分秒:hh:mm:ss |
%u |
每周的第几天,星期一为第一天(值从0到6,星期一为0) | %U |
第年的第几周,把星期日做为第一天(值从0到53) |
%w |
十进制表示的星期几(值从0到6,星期天为0) | %W |
每年的第几周,把星期一做为第一天(值从0到53) |
%x |
标准的日期串 | %X |
标准的时间串 |
%y |
不带世纪的十进制年份(值从0到99) | %Y |
带世纪部分的十制年份 |
%z / %Z |
时区名称,如果不能得到时区名称则返回空字符。 | %% |
百分号 |
NumPy
的datetime64
类型Python 原生日期格式的性能弱点促使NumPy
团队为 NumPy 增加了自己的时间序列类型。datetime64
类型将日期编码为64位整数
,这样可以让日期数组非常紧凑(节省内存)。datetime64
需要在设置日期时确定具体的输入类型:
date = np.array('2022-05-10', dtype=np.datetime64)
date
array('2022-05-10', dtype='datetime64[D]')
但只要有了这个日期格式,就可以进行快速的向量化运算:
date + np.arange(12)
array(['2022-05-10', '2022-05-11', '2022-05-12', '2022-05-13',
'2022-05-14', '2022-05-15', '2022-05-16', '2022-05-17',
'2022-05-18', '2022-05-19', '2022-05-20', '2022-05-21'],
dtype='datetime64[D]')
因为 NumPy 的 datetime64 数组内元素的类型是统一的,所以这种数组的运算速度会比 Python 的 datetime 对象的运算速度快很多,尤其是在处理较大数组时。
datetime64
与timedelta64
对象的一个共同特点是,它们都是在基本时间单位
的基础上建立的。由于datetime64
对象是 64位 精度,所以可编码的时间范围可以是基本单位的 2 64 2^{64} 264 倍。也就是说,datetime64
在时间精度
与最大时间跨度
之间达成了一种平衡。
比如你想要一个时间纳秒(ns)级的时间精度,那么你就可以将时间编码到 0~ 2 64 2^{64} 264 纳秒 或 600年之内,NumPy 会自动判断输入时间需要使用的时间单位:
np.datetime64('2022-05-10')
numpy.datetime64('2022-05-10')
np.datetime64('2022-05-10 23:16')
numpy.datetime64('2022-05-10T23:16')
你可以通过各种格式的代码设置基本时间单位。例如:将时间单位设置为纳秒:
np.datetime64('2022-05-10 23:19:16.34', 'ns')
numpy.datetime64('2022-05-10T23:19:16.340000000')
NumPy 的datetime64
所有支持相对于绝对时间跨度的时间与日期单位格式代码表,如下:
代码 | 含义 | 时间跨度(相对) | 时间跨度(绝对) |
---|---|---|---|
Y |
年 Year | ± 9.2e18 年 | [9.2e18 BC, 9.2e18 AD] |
M |
月 Month | ± 7.6e17 年 | [7.6e17 BC, 7.6e17 AD] |
W |
周 Week | ± 1.7e17 年 | [1.7e17 BC, 1.7e17 AD] |
D |
日 Day | ± 2.5e16 年 | [2.5e16 BC, 2.5e16 AD] |
h |
时 Hour | ± 1.0e15 年 | [1.0e15 BC, 1.0e15 AD] |
m |
分 Minute | ± 1.7e13 年 | [1.7e13 BC, 1.7e13 AD] |
s |
秒 Second | ± 2.9e12 年 | [ 2.9e9 BC, 2.9e9 AD] |
ms |
毫秒 Millisecond | ± 2.9e9 年 | [ 2.9e6 BC, 2.9e6 AD] |
us |
微秒Microsecond | ± 2.9e6 年 | [290301 BC, 294241 AD] |
ns |
纳秒 Nanosecond | ± 292 年 | [ 1678 AD, 2262 AD] |
ps |
皮秒 Picosecond | ± 106 年 | [ 1969 AD, 1970 AD] |
fs |
飞秒 Femtosecond | ± 2.6 年 | [ 1969 AD, 1970 AD] |
as |
原秒 Attosecond | ± 9.2 年 | [ 1969 AD, 1970 AD] |
Pandas 所有关于日期与时间的处理方法全部都是通过Timestamp
对象实现的,它利用numpy.datetime64
的有效储存和向量化接口将datetime
和dateutil
的易用性有机结合起来。Pandas 通过一组Timestamp
对象就可以创建一个可以作为Series
或DataFrame
索引的DatetimeIndex
。
例如,可以用 Pandas 的方式演示前面介绍的日期和时间功能。我们可以灵活处理不同格式的日期与时间字符串,获取某一天是星期几:
date = pd.to_datetime('4th of July, 2015')
date
Timestamp('2015-07-04 00:00:00')
date.strftime('%A')
'Saturday'
date + pd.to_timedelta(np.arange(12), 'D')
DatetimeIndex(['2015-07-04', '2015-07-05', '2015-07-06', '2015-07-07',
'2015-07-08', '2015-07-09', '2015-07-10', '2015-07-11',
'2015-07-12', '2015-07-13', '2015-07-14', '2015-07-15'],
dtype='datetime64[ns]', freq=None)
Pandas 时间序列工具非常适合用来处理带时间戳的索引数据
。例如,我们可以通过一个时间索引数据创建一个 Series 对象:
index = pd.DatetimeIndex(['2021-04-11', '2021-05-11',
'2022-04-11', '2022-05-11'])
date = pd.Series([0, 1, 2, 3], index=index)
date
2021-04-11 0
2021-05-11 1
2022-04-11 2
2022-05-11 3
dtype: int64
有了一个带时间索引的 Series 之后,就能用它来演示之前介绍过的 Series 取值方法,可以直接用日期进行切片取值:
date['2021-04-10':'2022-04-12']
2021-04-11 0
2021-05-11 1
2022-04-11 2
dtype: int64
date['2022']
2022-04-11 2
2022-05-11 3
dtype: int64
本节将介绍 Pandas 用来处理时间序列的基础数据类型。
Timestamp
类型。它本质上是 Python 的原生datetime
类型的替代品
,但是在性能更好的numpy.datetime64
类型的基础上创建的。对应的索引数据结构是DatetimeIndex
。Period
类型。这是利用numpy.datetime64
类型将固定频率的时间间隔进行编码。对应的索引数据结构是PeriodIndex
。Timedelta
类型。Timedelta
是一种代替 Python 原生datetime.timedelta
类型的高性能数据结构,同样是基于numpy.datetime64
类型,对应的索引数据结构是TimedeltaIndex
。最基础的日期/时间对象是Timestamp
和DatetimeIndex
。这两种对象可以直接使用,最常用的方法就是pd.to_datetime()
函数,它可以解析许多日期与时间格式。对pd.to_datetime()
传递一个日期会返回一个Timestamp
类型,传递一个时间序列会返回一个DatetimeIndex
类型:
dates = pd.to_datetime([datetime(2015, 7, 3), '4th of July, 2015',
'2015-Jul-6', '07-07-2015', '20150708'])
dates
DatetimeIndex(['2015-07-03', '2015-07-04', '2015-07-06', '2015-07-07',
'2015-07-08'],
dtype='datetime64[ns]', freq=None)
任何DatetimeIndex
类型都可以通过to_period()
方法和一个频率代码
转换成PeriodIndex
类型,下面用D
将数据转换成单日的时间序列:
dates.to_period('D')
PeriodIndex(['2015-07-03', '2015-07-04', '2015-07-06', '2015-07-07',
'2015-07-08'],
dtype='period[D]')
dates - dates[0]
TimedeltaIndex(['0 days', '1 days', '3 days', '4 days', '5 days'], dtype='timedelta64[ns]', freq=None)
有规律的时间序列:pd.date_range()
为了更简洁地创建有规律的时间序列,Pandas 提供了一些方法:pd.date_range()
可以处理时间戳、pd.period_range()
可以处理周期、pd.timedelta_range()
可以处理时间间隔。我们已经介绍过,Python 的range()
和 Numpy的np.arange()
可以用起点、终点和步长(可选)创建一个序列。pd.date_range()
与之类似,通过开始日期、结束日期和频率代码(同样可选)创建一个有规律的日期序列,默认的频率是天:
pd.date_range('2022-05-08', '2022-05-15')
DatetimeIndex(['2022-05-08', '2022-05-09', '2022-05-10', '2022-05-11',
'2022-05-12', '2022-05-13', '2022-05-14', '2022-05-15'],
dtype='datetime64[ns]', freq='D')
此外,日期范围不一定非是开始时间与结束时间,也可以是开始时间与周期数periods
:
pd.date_range('2022-05-15', periods=8)
DatetimeIndex(['2022-05-15', '2022-05-16', '2022-05-17', '2022-05-18',
'2022-05-19', '2022-05-20', '2022-05-21', '2022-05-22'],
dtype='datetime64[ns]', freq='D')
可以通过freq
参数改变时间间隔了,默认值是D
,例如,可以创建一个按小时变化的时间戳:
pd.date_range('2022-05-15', periods=8, freq='H')
DatetimeIndex(['2022-05-15 00:00:00', '2022-05-15 01:00:00',
'2022-05-15 02:00:00', '2022-05-15 03:00:00',
'2022-05-15 04:00:00', '2022-05-15 05:00:00',
'2022-05-15 06:00:00', '2022-05-15 07:00:00'],
dtype='datetime64[ns]', freq='H')
如果要创建一个有规律的周期或时间间隔序列,有类似的函数pd.period_range()
和pd.timedelta_range()
。下面是一个以月为周期的示例:
pd.period_range('2022-05', periods=8, freq='M')
PeriodIndex(['2022-05', '2022-06', '2022-07', '2022-08', '2022-09', '2022-10',
'2022-11', '2022-12'],
dtype='period[M]')
pd.timedelta_range(0, periods=10, freq='H')
TimedeltaIndex(['0 days 00:00:00', '0 days 01:00:00', '0 days 02:00:00',
'0 days 03:00:00', '0 days 04:00:00', '0 days 05:00:00',
'0 days 06:00:00', '0 days 07:00:00', '0 days 08:00:00',
'0 days 09:00:00'],
dtype='timedelta64[ns]', freq='H')
Pandas 时间序列工具的基础是时间频率或偏移量(offset)代码。就像之前见过的D(day)
或H(hour)
代码,我们可以用这些代码设置任意需要的时间间隔。下表总结了主要的频率代码:
代码 | 描述 | 代码 | 描述 |
---|---|---|---|
D |
天 Calendar day | B |
天(工作日)Business day |
W |
周 Weekly | ||
MS |
月初 Month start | BMS |
月初(工作日)Business month start |
M |
月末 Month end | BM |
月末(工作日)Business month end |
QS |
季初 Quarter start | BQS |
季初(工作日)Business quarter start |
Q |
季末 Quarter end | BQ |
季末(工作日)Business quarter end |
AS |
年初 Year start | BAS |
年初(工作日)Business year start |
A |
年末 Year end | BA |
年末(工作日)Business year end |
H |
小时 Hours | BH |
小时(工作时间)Business hours |
T |
分钟 Minutes | ||
S |
秒 Seconds | ||
L |
毫秒 Milliseonds | ||
U |
微秒 Microseconds | ||
N |
纳秒 nanoseconds |
另外,你可以在频率代码后面加三位月份缩写字母来改变季、年频率的开始时间。
Q-JAN
、BQ-FEB
、QS-MAR
、BQS-APR
等。A-JAN
、BA-FEB
、AS-MAR
、BAS-APR
等。同理,也可以在后面加三位星期缩写字母来改变一周的开始时间。
W-SUN
、W-MON
、W-TUE
、W-WED
等。在这些代码的基础上,还可以将频率组合起来创建的新的周期。例如,可以用小时(H)和分钟(T)的组合起来实现2小时30分钟
:
pd.timedelta_range(0, periods=9, freq='2H30T')
TimedeltaIndex(['0 days 00:00:00', '0 days 02:30:00', '0 days 05:00:00',
'0 days 07:30:00', '0 days 10:00:00', '0 days 12:30:00',
'0 days 15:00:00', '0 days 17:30:00', '0 days 20:00:00'],
dtype='timedelta64[ns]', freq='150T')
所有的这些频率代码都对应 Pandas 时间序列的偏移量,具体内容可以在pd.tseries.offsets
模块中找到。例如,可以用下面的方法直接创建一个工作日偏移序列:
from pandas.tseries.offsets import BDay
pd.date_range('2022-05-15 23:08:46', periods=8, freq=BDay())
DatetimeIndex(['2022-05-16 23:08:46', '2022-05-17 23:08:46',
'2022-05-18 23:08:46', '2022-05-19 23:08:46',
'2022-05-20 23:08:46', '2022-05-23 23:08:46',
'2022-05-24 23:08:46', '2022-05-25 23:08:46'],
dtype='datetime64[ns]', freq='B')
用日期和时间直观地组织与获取数据是 Pandas 时间序列工具最重要的功能之一。Pandas 不仅支持普通索引功能(合并数据时自动索引对齐、直观的数据切片和取值方法等),还专为时间序列提供了额外的操作。
下面然我们用一些股票数据来演示这些功能。由于 Pandas 最初是为金融数据模型服务的,因此可以用它非常方便地获取金融数据。例如,pandas-datareader
程序包(可以用过conda install pandas-datareader -y
进程安装)知道如何从这些可用的数据源导入金融数据,包含Yahoo财经(2021-11-01日起,不在支持大陆)
、Google财经(新版不在支持)
和其他数据源(这里使用 Tiingo 获取数据,需要注册)
。下面来导入Google历史股票价格:
# 如果没有安装,可以直接使用下面命令在 Jupyter 中安装;-y 表示同意安装,不用手动输入
# ! conda install pandas-datareader -y
import pandas_datareader.data as p_data
# goog = p_data.DataReader('GOOG', start='2004', end='2021', data_source='google') # 已失效
# goog = p_data.DataReader('BAC', 'yahoo') # 雅虎 2021年11月1日起,停止大陆用户服务
# 改用 Tiingo 获取数据,api_key 需要去注册申请:https://api.tiingo.com/documentation/general/connecting
# 1.2节,点击按键 “Click here to see your API Token”
api_token = '4a84110800db2afd5f7e5e239ede34a182016c74'
# goog = p_data.get_data_tiingo('GS', api_key=api_token)
goog = p_data.get_data_tiingo('GS', '2009-05', '2021-05', api_key=api_token)
goog.head()
/Users/weiyi/opt/anaconda3/envs/20200304-Python数据科学/lib/python3.8/site-packages/pandas_datareader/tiingo.py:234: FutureWarning: In a future version of pandas all arguments of concat except for the argument 'objs' will be keyword-only.
return pd.concat(dfs, self._concat_axis)
close | high | low | open | volume | adjClose | adjHigh | adjLow | adjOpen | adjVolume | divCash | splitFactor | ||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
symbol | date | ||||||||||||
GS | 2009-05-01 00:00:00+00:00 | 127.08 | 130.27 | 126.16 | 127.90 | 17536300 | 104.836081 | 107.467707 | 104.077117 | 105.512549 | 17536300 | 0.0 | 1.0 |
2009-05-04 00:00:00+00:00 | 134.16 | 134.43 | 126.16 | 127.51 | 23434000 | 110.676807 | 110.899546 | 104.077117 | 105.190814 | 23434000 | 0.0 | 1.0 | |
2009-05-05 00:00:00+00:00 | 135.20 | 136.20 | 132.55 | 132.90 | 16811000 | 111.534767 | 112.359728 | 109.348619 | 109.637356 | 16811000 | 0.0 | 1.0 | |
2009-05-06 00:00:00+00:00 | 139.22 | 140.36 | 136.05 | 136.80 | 20439300 | 114.851111 | 115.791567 | 112.235984 | 112.854705 | 20439300 | 0.0 | 1.0 | |
2009-05-07 00:00:00+00:00 | 133.73 | 141.56 | 131.81 | 140.98 | 23188600 | 110.322074 | 116.781520 | 108.738148 | 116.303043 | 23188600 | 0.0 | 1.0 |
goog = goog['close'] # 处于简化的目的,这里只用`收盘价`
goog.index = pd.DatetimeIndex(goog.index.levels[1]) # 复合索引,只取日期
goog.plot()
处理时间序列数据时,经常需要按照新的频率(更高或更低的频率)对数据进行重新取样。你可以通过resample()
方法解决这个问题,或者用更简单的asfreq()
方法。这两个方法的主要差异在于,resample()
方法是以数据累计(data aggregation)
为基础,而asfreq()
方法是以数据选择(data selection)
为基础。
看到 Google 的收盘价之后,让我们用两种方法对数据进行向后取样(down-sample)
。这里用年末(‘BA’,最后一个工作日)对数据进行重新取样:
goog.plot(alpha=0.5, style='-')
goog.resample('BA').mean().plot(style=':')
goog.asfreq('BA').plot(style='--')
plt.legend(['input', 'resample', 'asfreq'], loc='upper left')
请注意这两种取样方法的差异:在每个数据点上
resample
反映的是上一年的平均值asfreq
反映的是上一年最后一个工作日的收盘价
在进行向前取样(up-sampling)
时,resample()
与asfreq()
的用法大体相同,不过重新取样有许多配置方式。操作时,两种方法都默认将向前取样作为缺失值处理,也就是说在里面填充NaN
。与前面介绍过的pd.fillna()
函数类似,asfreq()
有一个method
参数可以设置填充缺失值的方式。下面将对工作日数据按天进行重新取样(既包含周末),结果如下图:
fig, ax = plt.subplots(2, sharex=True)
data = goog.iloc[:10]
data.asfreq('D').plot(ax=ax[0], marker='o')
data.asfreq('D', method='bfill').plot(ax=ax[1], style='-o')
data.asfreq('D', method='ffill').plot(ax=ax[1], style='--o')
ax[1].legend(['向后取样', '向前取样'])
另一种常用的时间序列操作是对数据按时间进行迁移。Pandas 有两种解决这类问题的方法:shift()
和tshift()
。简单来说,shift()
就是迁移数据
,而tshift()
就是迁移索引
。两种方法都是按照频率代码进行迁移。
下面我们将用shift()
和tshift()
这两种方法让数据迁移 900天:
fig, ax = plt.subplots(3, sharey=True, figsize=(14, 12))
# 对数据应用时间频率,向后填充解决缺失值
goog = goog.asfreq('D', method='pad')
goog.plot(ax=ax[0])
goog.shift(900).plot(ax=ax[1])
goog.tshift(900).plot(ax=ax[2])
# 设置图例与标签
local_max = pd.to_datetime('2012-01-01')
offset = pd.Timedelta(900, 'D')
ax[0].legend(['input'], loc=2)
ax[0].get_xticklabels()[2].set(weight='heavy', color='red')
ax[0].axvline(local_max, alpha=0.3, color='red')
ax[1].legend(['shift(900)'], loc=2)
ax[1].get_xticklabels()[2].set(weight='heavy', color='red')
ax[1].axvline(local_max + offset, alpha=0.3, color='red')
ax[2].legend(['tshift(900)'], loc=2)
ax[2].get_xticklabels()[1].set(weight='heavy', color='red')
ax[2].axvline(local_max + offset, alpha=0.3, color='red')
/var/folders/rx/27fc03s525nbs15jphd750hm0000gn/T/ipykernel_1679/3443451037.py:8: FutureWarning: tshift is deprecated and will be removed in a future version. Please use shift instead.
goog.tshift(900).plot(ax=ax[2])
我们会发现,shift(900)
将数据
向前推进了 900天,这样图形中的一段就消失了(最左侧就会变成了缺失值),而tshift(900)
方法是将时间索引
向前推进了 900天。
这类迁移方法的常见使用场景就是计算数据在不同时间段的差异。例如,我们可以用迁移后的值来计算 Google股票一年的投资回报率:
ROI = 100 * (goog.tshift(-365) / goog - 1)
ROI.plot(figsize=(14, 4))
plt.ylabel('% 投资回报')
/var/folders/rx/27fc03s525nbs15jphd750hm0000gn/T/ipykernel_1679/3360961840.py:1: FutureWarning: tshift is deprecated and will be removed in a future version. Please use shift instead.
ROI = 100 * (goog.tshift(-365) / goog - 1)
Text(0, 0.5, '% 投资回报')
Pandas 处理时间序列数据的第三种操作就是移动统计值(rolling statistics)
。这些指标可以通过 Series 和 DataFrame 的rolling()
属性来实现,它会返回与groupby
操作类似的结果。移动视图(rolling view)
使得许多累计操作称为可能。
与groupby
操作一样,aggregate()
和apply()
方法都可以用来自定义自动计算。
例如,可以通过下面的代码获取 Google 股票收盘价的一年期移动平均值和标准差:
rolling = goog.rolling(365, center=True)
data = pd.DataFrame({'input': goog,
'一年期 移动平均值': rolling.mean(),
'一年期 标准差': rolling.std(),
})
ax = data.plot(style=['-', '--', ':'], figsize=(14, 4))
ax.lines[0].set_alpha(0.3)
下面来介绍一个比较复杂的时间序列数据,统计自2012年每天经过美国西雅图弗莱蒙特乔上的自行车的数量,数据由安装在桥东西两侧人行道的传感器采集。小时统计数据可以在 http://data.seattle.gov/ 下载,还有一个数据集的直接 下载链接。
# !curl -o FremontBridge.csv https://data.seattle.gov/api/views/65db-xm6k/rows.csv?accessType=DOWNLOAD
data = pd.read_csv('./FremontBridge.csv', index_col='Date', parse_dates=True)
data.head()
Fremont Bridge Total | Fremont Bridge East Sidewalk | Fremont Bridge West Sidewalk | |
---|---|---|---|
Date | |||
2019-11-01 00:00:00 | 12.0 | 7.0 | 5.0 |
2019-11-01 01:00:00 | 7.0 | 0.0 | 7.0 |
2019-11-01 02:00:00 | 1.0 | 0.0 | 1.0 |
2019-11-01 03:00:00 | 6.0 | 6.0 | 0.0 |
2019-11-01 04:00:00 | 6.0 | 5.0 | 1.0 |
# 缩短数据集列名
data.columns = ['Total', 'East', 'West']
data = data[['Total', 'West', 'East']]
data.dropna().describe()
Total | West | East | |
---|---|---|---|
count | 150134.000000 | 150134.000000 | 150134.000000 |
mean | 109.507420 | 59.866932 | 49.640488 |
std | 139.596963 | 86.696586 | 64.238184 |
min | 0.000000 | 0.000000 | 0.000000 |
25% | 14.000000 | 7.000000 | 6.000000 |
50% | 59.000000 | 30.000000 | 27.000000 |
75% | 144.000000 | 74.000000 | 67.000000 |
max | 1097.000000 | 850.000000 | 698.000000 |
通过可视化,我们可以对数据集有一些直观的认识。先为原始数据画图:
data.plot(figsize=(14, 5))
plt.ylabel('自行车数量/每小时')
Text(0, 0.5, '自行车数量/每小时')
上图显示的样本数据对我们来讲是在太多了,因此可以用过重新取样将数据转换成更大的颗粒度,比如按周累计:
weekly = data.resample('W').sum()
weekly.plot(style=[':', '--', '-'], figsize=(14, 5))
plt.ylabel('自行车数量/每周')
Text(0, 0.5, '自行车数量/每周')
这就显示出一些季节性的特征。夏天骑自行车的人比冬天多,而且某个季节中每一周的自行车数量也在变化(可能和天气有关)。
另外,我们还可以看到自从
新冠爆发(2020年)
之后,数量有明显的下降。
另一种对数据进行累计的简单方法是用pd.rolling_mean()
函数求移动平均值。下面将计算数据的 30天 移动平均值,并让图形在窗口居中显示(center=True
):
daily = data.resample('D').sum()
daily.rolling(30, center=True).mean().plot(style=[':', '--', '-'], figsize=(14, 5))
plt.ylabel('移动平均值/30天')
Text(0, 0.5, '移动平均值/30天')
monthly = daily.resample('M').mean()
monthly.plot(style=[':', '--', '-'], figsize=(14, 5))
plt.ylabel('平均值/每月')
Text(0, 0.5, '平均值/每月')
由于窗口太小,现在的图形还不太平滑。我们可以用另一个移动平均值的方法获得更平滑的图形,例如高斯分布时间窗口。下面的代码将设置窗口的宽度(50天)和窗口内高斯平滑的宽度(10天):
daily.rolling(50, center=True, win_type='gaussian').mean(std=10).plot(style=[':', '--', '-'], figsize=(14, 5))
虽然我们已经从上图的平滑数据图观察到了数据的总体趋势,但是它们还隐藏了一些有趣的特征。例如,我们可以观察单日内的小时均值流量,这可以通过GroupBy
操作来解决:
by_time = data.groupby(data.index.time).mean()
hourly_ticks = 4 * 60 * 60 * np.arange(6)
by_time.plot(xticks=hourly_ticks, style=[':', '--', '-'])
小时均值流量呈现十分明显的双峰分布特性,早间峰值在上午 8点,晚间峰值在下午 5点。这充分反映了过桥上下班往返自行车流量的特征。进一步分析会发现,桥西的高峰在早上(上班),而桥东的高峰在下午(下班)。
我们可能还会对周内每天的变化产生兴趣,这时依然可以通过一个简单的GroupBy
来实现:
by_weekday = data.groupby(data.index.weekday).mean()
by_weekday.index = ['周一', '周二', '周三', '周四', '周五', '周六', '周末']
by_weekday.plot(style=[':', '--', '-'])
看到这个特征之后,我们用一个复合GroupBy
来观察一周内工作日与双休日每小时的数据。用一个标签表示双休日和工作日的不同小时:
weekend = np.where(data.index.weekday < 5, '工作日', '双休日')
weekend
array(['工作日', '工作日', '工作日', ..., '双休日', '双休日', '双休日'], dtype='
by_time = data.groupby([weekend, data.index.time]).mean()
by_time.head()
Total | West | East | ||
---|---|---|---|---|
双休日 | 00:00:00 | 15.397536 | 9.055431 | 6.342105 |
01:00:00 | 8.878499 | 4.955767 | 3.922732 | |
02:00:00 | 5.757199 | 3.111801 | 2.645398 | |
03:00:00 | 3.325308 | 1.680291 | 1.645017 | |
04:00:00 | 3.678052 | 2.166853 | 1.511198 |
fig, ax = plt.subplots(1, 2, figsize=(14, 5))
by_time.loc['工作日'].plot(ax=ax[0], title='工作日',
xticks=hourly_ticks, style=[':', '--', '-'])
by_time.loc['双休日'].plot(ax=ax[1], title='双休日',
xticks=hourly_ticks, style=[':', '--', '-'])
还有很多有意思的数据可以继续挖掘!
eval()
与query()
前面的章节介绍过,Python 数据科学生态环境的强大力量建立在 NumPy 与 Pandas 的基础之上,并通过直接的语法将基本操作转成 C语言:在 NumPy 例是向量化/广播运算,在 Pandas 里是分组型运算。虽然这些抽象功能可以简洁高效地解决许多问题,但是它们经常需要创建临时中间变量,这样就会占用大量的计算时间与内存。
Pandas 从 0.13版 开始(2014年1月)就引入了实验性工具,让用户可以直接运行 C语言速度的操作,不需要十分费力地配置中间的数组。它们就是eval()
和query()
函数,都依赖与Numexpr
程序包。
eval()
与query()
的设置动机:复合代数式前面已经介绍过,NumPy 与 Pandas 都支持快速的向量化运算。例如,可以快速求和:
rng = np.random.RandomState(42)
x = rng.rand(int(1E6))
y = rng.rand(int(1E6))
%timeit x + y
1.39 ms ± 76.6 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
这样做要比普通的 Python 循环或列表综合要快很多:
%timeit np.fromiter((xi + yi for xi, yi in zip(x, y)), dtype=x.dtype, count=len(x))
162 ms ± 11.7 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
但是这种运算在处理复合代数式
问题时的效率比较低,例如:
mask = (x > 0.5) & (y < 0.5)
由于 NumPy 会计算每一个代数子式,因此这个计算过程等价于:
tmp1 = x > 0.5
tmp2 = y < 0.5
mask = tmp1 & tmp2
也就是说,每段中间过程都需要显式地分配内存。如果X数组
和Y数组
非常大,这么运算就会占用大量的时间和内存消耗。Numexpr
程序库可以让你在不为中间过程分配全部内存的前提下,完成元素到元素的复合代数式运算。虽然Numexpr
文档里提供了更详细的内容,但是简单点儿说,这个程序库其实就是一个 NumPy 风格的字符串代数式进行运算:
import numexpr
mask_numexpr = numexpr.evaluate('(x > 0.5) & (y < 0.5)')
np.allclose(mask, mask_numexpr)
True
这样做的好处是,由于 Numexpr 在计算代数式时不需要为临时数组分配全部内存,因此计算比 NumPy 更高效,尤其适合处理大型数组。马上要介绍的 Pandas 的eval()
与query()
工具其实也是基于 Numexpr 实现的。
pandas.eval()
实现高性能运算Pandas 的 eval() 函数用字符串代数式实现了 DataFrame 的高性能运算,例如:
nrows, ncols = 100000, 100
rng = np.random.RandomState(42)
df1, df2, df3, df4 = (pd.DataFrame(rng.rand(nrows, ncols))
for i in range(4))
%timeit df1 + df2 + df3 + df4
50.2 ms ± 1.26 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
%timeit pd.eval('df1 + df2 + df3 + df4')
25.4 ms ± 2.06 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
# eval() 比 普通方法 快一倍,结果也是相同的
np.allclose(df1 + df2 + df3 + df4,
pd.eval('df1 + df2 + df3 + df4'))
True
从 Pandas v0.16版开始,pd.eval() 就支持许多运算了。
df1, df2, df3, df4, df5 = (pd.DataFrame(rng.randint(0, 1000, (100, 3)))
for i in range(5))
result1 = -df1 * df2 / (df3 + df4) - df5
result2 = pd.eval('-df1 * df2 / (df3 + df4) - df5')
np.allclose(result1, result2)
True
result1 = (df1 < df2) & (df2 <= df3) & (df3 != df4)
result2 = pd.eval('df1 < df2 <= df3 != df4')
np.allclose(result1, result2)
True
result1 = (df1 < 0.5) & (df2 < 0.5) | (df3 < df4)
result2 = pd.eval('(df1 < 0.5) & (df2 < 0.5) | (df3 < df4)')
result3 = pd.eval('(df1 < 0.5) and (df2 < 0.5) or (df3 < df4)')
np.allclose(result1, result2), np.allclose(result1, result3)
(True, True)
obj.attr
语法获取对象属性,通过obj[index]
语法获取对象索引result1 = df2.T[0] + df3.iloc[1]
result2 = pd.eval('df2.T[0] + df3.iloc[1]')
np.allclose(result1, result2)
True
Numexpr
来实现。由于 pd.eval() 是 Pandas 的顶级函数,因此 DataFrame 有一个 eval() 方法可以做类似的运算。使用 eval() 方法的好处是可以借助列名称进行运算,示例如下:
df = pd.DataFrame(rng.rand(1000, 3), columns=list('ABC'))
df.head()
A | B | C | |
---|---|---|---|
0 | 0.375506 | 0.406939 | 0.069938 |
1 | 0.069087 | 0.235615 | 0.154374 |
2 | 0.677945 | 0.433839 | 0.652324 |
3 | 0.264038 | 0.808055 | 0.347197 |
4 | 0.589161 | 0.252418 | 0.557789 |
# 如果按照前面介绍的 pd.eval(),就可以通过下面的代数式计算:
result1 = (df.A + df.B) / (df.C - 1)
result2 = pd.eval('(df.A + df.B) / (df.C - 1)')
np.allclose(result1, result2)
True
而 DataFrame.eval() 方法可以通过列名实现简洁的代数式:
result3 = df.eval('(A + B) / (C - 1)')
np.allclose(result1, result3)
True
除了前面介绍的运算功能,DataFrame.eval() 还可以创建新的列。还用前面的 DataFrame 来演示,列名是 A、B、C,可用 df.eval() 创建一个新列 D,然后赋给它其他列计算得值:
df.eval('D = (A + B) / C', inplace=True)
df.head()
A | B | C | D | |
---|---|---|---|---|
0 | 0.375506 | 0.406939 | 0.069938 | 11.187620 |
1 | 0.069087 | 0.235615 | 0.154374 | 1.973796 |
2 | 0.677945 | 0.433839 | 0.652324 | 1.704344 |
3 | 0.264038 | 0.808055 | 0.347197 | 3.087857 |
4 | 0.589161 | 0.252418 | 0.557789 | 1.508776 |
还可以修改已有列:
df.eval('D = (A - B) / C', inplace=True)
df.head()
A | B | C | D | |
---|---|---|---|---|
0 | 0.375506 | 0.406939 | 0.069938 | -0.449425 |
1 | 0.069087 | 0.235615 | 0.154374 | -1.078728 |
2 | 0.677945 | 0.433839 | 0.652324 | 0.374209 |
3 | 0.264038 | 0.808055 | 0.347197 | -1.566886 |
4 | 0.589161 | 0.252418 | 0.557789 | 0.603708 |
DataFrame.eval() 方法还支持通过@符号
使用 Python 的局部变量:
column_mean = df.mean(1)
result1 = df.A + column_mean
result2 = df.eval('A + @column_mean')
np.allclose(result1, result2)
True
@符号
表示“这是一个变量名称而不是一个列名称”,从而让你灵活地用两个“命名空间”的资源(列名称的命名空间 和 Python对象的命名空间)计算代数式。需要注意的是,@符号
只能在 DataFrame.eval() 方法中使用,而不能在 pandas.eval() 函数中使用,因为 pandas.eval() 函数只能获取一个(Python)空间命名的内容。
DataFrame 基于字符串代数式的运算实现了另一个方法,被称为query()
:
result1 = df[(df.A < 0.5) & (df.B < 0.5)]
result2 = pd.eval('df[(df.A < 0.5) & (df.B < 0.5)]')
np.allclose(result1, result2)
True
和前面介绍的 DataFrame.eval() 一样,这是一个用 DataFrame 列创建的代数式,但是不能用 DataFrame.eval() 语法。不过,对于这种过滤运算,你可以用query()
方法:
result2 = df.query('A < 0.5 and B < 0.5')
np.allclose(result1, result2)
True
除了计算性能更优之外,这种方法的语法也比掩码代数式语法更好理解。需要注意的是,query()
方法也支持用@符号
引用局部变量:
Cmean = df.C.mean()
result1 = df[(df.A < Cmean) & (df.B < Cmean)]
result2 = df.query('A < @Cmean and B < @Cmean')
np.allclose(result1, result2)
True
在考虑要不要用这两个函数时,需要考虑两个方面:计算时间
和内存消耗
,而内存消耗是更重要的影响因素。就像前面介绍的那样,每个涉及 NumPy 数组或 Pandas 的 DataFrame 的复合代数式都会产生临时数组,例如:
x = df[(df.A < 0.5) & (df.B < 0.5)]
它基本等价于:
tmp1 = df.A < 0.5
tmp2 = df.B < 0.5
tmp3 = tmp1 & tmp2
x = df[tmp3]
如果临时 DataFrame 的内存需求比你的系统内存还大,那么最好还是使用eval()
和query()
代数式。你可以通过下面的方法大概估算一下变量的内存消耗:
df.values.nbytes
32000