4  pandas入门

pandas 是我们后续学习中的一个核心工具。它包含了强大的数据结构和数据处理工具,旨在使Python中的数据清洗和分析工作变得快速和便捷。pandas 经常与数值计算库(如NumPy和SciPy)、分析库(如statsmodelsscikit-learn)以及数据可视化库(如matplotlib)结合使用。pandas 在很大程度上借鉴了NumPy的惯用风格,特别是基于数组的计算方式,以及偏好于不使用for循环的数据处理方法。

理解“向量化”计算

教科书中提到了 pandas 采用了 NumPy 的“数组计算的惯用风格”,特别是“偏好于不使用for循环的数据处理”。这在计算经济学和金融工程中被称为“向量化”(Vectorization)。其核心思想是,将循环操作(比如对一个序列的每个元素进行数学运算)交给底层经过高度优化的C或Fortran代码来执行,而不是在Python层面逐个元素进行迭代。

对于初学者来说,最直观的区别在于性能。一个向量化的NumPy或pandas操作,通常比等效的Python for循环快上几个数量级。这是因为: 1. 减少Python解释器开销:Python的循环在每次迭代时都有大量的类型检查和函数调用开销。向量化操作将整个计算任务一次性“推送”到底层,大大减少了这种开销。 2. 利用SIMD指令:底层库可以利用现代CPU的“单指令多数据”(Single Instruction, Multiple Data, SIMD)指令集,在一条CPU指令中同时对多个数据点执行相同的操作。

在我们的课程中,你应当始终优先考虑使用 pandas 和 NumPy 提供的内建函数,而不是自己编写循环,这不仅能让你的代码更简洁、更易读,而且执行效率也更高。

尽管pandas借鉴了NumPy的许多编程习惯,但两者最大的区别在于pandas是为处理表格型或异构数据而设计的。相比之下,NumPy更适合处理同质化的数值数组数据。

自2010年成为开源项目以来,pandas已发展成为一个相当庞大的库,适用于广泛的现实世界用例。其开发者社区已增长到超过2500名独立的贡献者,他们在解决日常数据问题的过程中,不断地为这个项目添砖加瓦。充满活力的pandas开发者和用户社区是其成功的关键部分。

在本书的其余部分,我将使用以下导入约定来引入NumPy和pandas

列表 4.1
import numpy as np
import pandas as pd

因此,每当你在代码中看到pd.时,它都指向pandas。由于SeriesDataFrame使用得非常频繁,将它们导入到本地命名空间可能会更方便:

列表 4.2
from pandas import Series, DataFrame

4.1 pandas数据结构介绍

要开始使用pandas,你需要熟悉它的两个核心数据结构:SeriesDataFrame。虽然它们并非所有问题的通用解决方案,但它们为各种数据任务提供了坚实、灵活的基础。

4.1.1 Series

Series是一个一维的、类似数组的对象,它包含一个值序列(其类型与NumPy类型相似)和一个相关的数据标签数组,称为其索引 (index)。最简单的Series仅由一个数据数组构成:

obj = pd.Series([4, 7, -5, 3])
obj

在交互式环境中显示的Series的字符串表示形式,左侧是索引,右侧是值。由于我们没有为数据指定索引,系统会自动创建一个由0到N-1(其中N是数据长度)的整数组成的默认索引。你可以通过arrayindex属性分别获取Series的数组表示和索引对象:

obj.array
obj.index

.array属性的结果是一个PandasArray,它通常包装一个NumPy数组,但也可以包含特殊的扩展数组类型。

通常,你会希望创建一个带有索引的Series,用标签来标识每个数据点:

obj2 = pd.Series([4, 7, -5, 3], index=['d', 'b', 'a', 'c'])
obj2
obj2.index

与NumPy数组相比,你可以在选择单个或一组值时使用索引中的标签:

obj2['a']
obj2['d'] = 6
obj2[['c', 'a', 'd']]

在这里,['c', 'a', 'd']被解释为一个索引列表,即使它包含的是字符串而不是整数。

使用NumPy函数或类似NumPy的操作,例如用布尔数组进行过滤、标量乘法或应用数学函数,都会保留索引与值之间的链接:

obj2[obj2 > 0]
obj2 * 2
np.exp(obj2)

另一种理解Series的方式是,可以把它看作一个固定长度的、有序的字典,因为它是索引值到数据值的映射。它可以在许多你可能使用字典的场景中使用:

'b' in obj2
'e' in obj2

如果你有一个包含在Python字典中的数据,你可以通过传递这个字典来创建一个Series。在这里,我们用中国几个省市2020年的常住人口(单位:万人)作为例子:

sdata = {'北京': 2189, '上海': 2487, '广东': 12601, '浙江': 6456}
obj3 = pd.Series(sdata)
obj3

一个Series可以通过其to_dict方法转换回字典:

obj3.to_dict()

当你只传递一个字典时,生成的Series中的索引将遵循字典keys方法的顺序,这取决于键的插入顺序。你可以通过传递一个带有你期望顺序的字典键的索引来覆盖这个默认行为:

提示:Python字典的顺序

在Python 3.7及更高版本中,标准的dict类型保证了键的插入顺序。这意味着当你从字典创建pandas Series时,Series的索引将默认遵循你将键值对添加到字典时的顺序。然而,在Python 3.6及更早版本中,dict是无序的。如果你或你的同事可能在较旧的Python环境中工作,为了代码的可预测性和可复现性,显式地传递一个index参数来指定顺序是一个非常好的习惯。

provinces = ['浙江', '广东', '江苏', '上海']
obj4 = pd.Series(sdata, index=provinces)
obj4

在这里,sdata中找到的三个值被放在了相应的位置,但由于没有找到'江苏'的值,它显示为NaN(Not a Number),在pandas中用来标记缺失或NA值。由于'北京'没有包含在provinces列表中,它被从结果对象中排除了。

我将交替使用“缺失(missing)”、“NA”或“空值(null)”来指代缺失数据。应使用pandas中的isnanotna函数来检测缺失数据:

NaN的传播特性

NaN(Not a Number)是浮点数中一个特殊的值,用于表示未定义或不可表示的结果,例如 0/0。在数据分析中,它被广泛用作缺失值的标记。NaN有一个重要的特性,即它具有“传播性”(propagating)。任何与 NaN 进行的算术运算,结果仍然是 NaN。例如,5 + NaN 的结果是 NaN。这在数据处理中很有用,因为它能确保缺失值的影响在计算链中得到保留,而不会被一个意外的默认值(如0)所掩盖,从而避免了错误的计算结果。

pd.isna(obj4)
pd.notna(obj4)

Series本身也拥有这些作为实例方法:

obj4.isna()

对于许多应用来说,Series的一个有用特性是它在算术运算中会根据索引标签自动对齐:

obj3
obj4
obj3 + obj4

数据对齐的特性将在后面更详细地讨论。如果你有数据库经验,可以将其视为类似于join(连接)操作。

Series对象本身及其索引都有一个name属性,这与其他pandas功能集成在一起:

obj4.name = 'population'
obj4.index.name = 'province'
obj4

Series的索引可以通过赋值来原地修改:

obj
obj.index = ['张三', '李四', '王五', '赵六']
obj

4.1.2 DataFrame

DataFrame表示一个矩形的数据表格,它包含一个有序的、命名的列集合,每一列都可以是不同的值类型(数值、字符串、布尔值等)。DataFrame既有行索引也有列索引;可以把它看作是一个共享相同索引的Series的字典。

构建DataFrame有多种方式,但最常用的一种是从一个等长列表或NumPy数组组成的字典来构建:

列表 4.3
data = {'province': ['广东', '广东', '广东', '浙江', '浙江', '浙江'],
        'year': [2010, 2015, 2020, 2015, 2020, 2022],
        'gdp': [4.6, 7.3, 11.07, 4.3, 6.46, 7.77]}
frame = pd.DataFrame(data)

生成的DataFrame的索引会自动分配,就像Series一样,并且列会根据data中键的顺序来排列(这取决于它们在字典中的插入顺序):

frame
图 4.1

图 fig-dataframe-jupyter 所示,如果你正在使用Jupyter Notebook或Quarto,pandasDataFrame对象将会以一个更适合浏览器的HTML表格形式显示。

对于大型DataFramehead方法只选择前五行:

frame.head()

类似地,tail返回最后五行:

frame.tail()

如果你指定一个列的序列,DataFrame的列将按照那个顺序排列:

pd.DataFrame(data, columns=['year', 'province', 'gdp'])

如果你传递一个字典中不包含的列,它将在结果中以缺失值的形式出现:

frame2 = pd.DataFrame(data, columns=['year', 'province', 'gdp', 'debt'])
frame2
frame2.columns

DataFrame中的一列可以像字典那样通过键来检索,也可以通过点属性表示法来检索,其结果是一个Series

frame2['province']
frame2.year

属性式访问(例如 frame2.year)和在IPython/Jupyter中对列名的Tab补全是为了方便而提供的。frame2[column]适用于任何列名,但frame2.column仅在列名是有效的Python变量名且不与DataFrame的任何方法名冲突时才有效。例如,如果列名包含空格或下划线以外的符号,就不能使用点属性方法访问。

行也可以通过ilocloc这两个特殊的属性按位置或名称来检索:

frame2.loc[1]
frame2.iloc[2]

列可以通过赋值来修改。例如,空的debt列可以被赋一个标量值或一个值数组:

frame2['debt'] = 16.5
frame2
frame2['debt'] = np.arange(6.)
frame2

当你给一列赋列表或数组时,值的长度必须与DataFrame的长度相匹配。如果你赋一个Series,它的标签将精确地与DataFrame的索引重新对齐,任何不存在的索引值处都会插入缺失值:

val = pd.Series([-1.2, -1.5, -1.7], index=[0, 2, 5])
frame2['debt'] = val
frame2

为一个不存在的列赋值将会创建一个新列。del关键字可以像删除字典键一样删除列。作为例子,我首先添加一个新列,其布尔值表示该省份是否为沿海经济发达地区:

frame2['coastal'] = frame2['province'].isin(['广东', '浙江'])
frame2

注意,不能使用 frame2.coastal 这样的点属性表示法来创建新列。

然后可以使用del关键字来移除这一列:

del frame2['coastal']
frame2.columns
View (视图) vs. Copy (副本)

pandas中一个非常重要的概念是view (视图) 和 copy (副本) 的区别。当你从一个DataFrame中选择数据时,例如 frame2['province']pandas有时会返回一个指向原始数据的view,有时则会返回一个copy。如果你在view上进行修改,原始的DataFrame改变。如果你在copy上修改,原始的DataFrame则不会改变。

pandas并不保证选择操作返回的是view还是copy。这种不确定性是导致臭名昭著的SettingWithCopyWarning的根源。因此,一个最关键的原则是:永远不要使用链式索引(chained indexing)进行赋值操作。例如,frame2['debt'][2] = 5是不安全的。正确的做法是使用.loc.iloc进行一次性的、明确的赋值操作,例如frame2.loc[2, 'debt'] = 5。这保证了你总是在修改原始的DataFrame,而不是一个临时的副本。

另一种常见的数据形式是嵌套的字典:

populations = {'广东': {2010: 10430, 2015: 10849, 2020: 12601},
               '浙江': {2010: 5442, 2015: 5539, 2020: 6456}}

如果将嵌套字典传递给DataFramepandas会将外层字典的键解释为列,内层字典的键解释为行索引:

frame3 = pd.DataFrame(populations)
frame3

你可以使用与NumPy数组相似的语法来转置DataFrame(交换行和列):

frame3.T

内层字典的键被合并起来形成结果中的索引。如果指定了显式索引,则情况不同:

pd.DataFrame(populations, index=[2015, 2020, 2025])

Series组成的字典处理方式与此非常相似:

pdata = {'广东': frame3['广东'][:-1],
         '浙江': frame3['浙江'][:2]}
pd.DataFrame(pdata)

关于可以传递给DataFrame构造函数的多种数据类型,请参见 表 tbl-dataframe-inputs

表 4.1: 可传入DataFrame构造函数的数据类型
类型 说明
二维ndarray 数据矩阵,可选择传入行和列的标签
数组、列表或元组的字典 每个序列成为DataFrame的一列;所有序列必须等长
NumPy结构化/记录数组 被视为“数组的字典”情况
Series的字典 每个Series成为一列;如果没有显式传递索引,则每个Series的索引被合并以形成结果的行索引
字典的字典 每个内层字典成为一列;键被合并以形成行索引,同“Series的字典”情况
字典或Series的列表 每一项成为DataFrame的一行;字典键或Series索引的并集成为DataFrame的列标签
列表的列表或元组的列表 被视为“二维ndarray”情况
另一个DataFrame 除非传递了不同的索引,否则使用该DataFrame的索引
NumPy掩码数组 类似于“二维ndarray”情况,但掩码值在DataFrame结果中为缺失值

如果一个DataFrameindexcolumnsname属性被设置了,它们也会被显示出来:

frame3.index.name = 'year'
frame3.columns.name = 'province'
frame3

Series不同,DataFrame没有name属性。DataFrameto_numpy方法返回其包含的数据,作为一个二维ndarray

frame3.to_numpy()

如果DataFrame的列是不同的数据类型,返回数组的数据类型将会选择一个能够兼容所有列的类型:

frame2.to_numpy()

4.1.3 索引对象 (Index Objects)

pandasIndex对象负责持有轴标签(包括DataFrame的列名)和其他元数据(比如轴的名称)。你在构建SeriesDataFrame时使用的任何数组或其他标签序列,都会在内部被转换为一个Index

obj = pd.Series(np.arange(3), index=['a', 'b', 'c'])
index = obj.index
index
index[1:]

索引对象是不可变的 (immutable),因此不能被用户修改:

# 这行代码会引发TypeError
# index[1] = 'd'

不可变性使得在数据结构之间共享Index对象更加安全:

labels = pd.Index(np.arange(3))
labels
obj2 = pd.Series([1.5, -2.5, 0], index=labels)
obj2
obj2.index is labels

除了类似数组,Index的行为也像一个固定大小的集合:

frame3
frame3.columns
'广东' in frame3.columns
2025 in frame3.index

与Python的集合不同,pandasIndex可以包含重复的标签:

pd.Index(['foo', 'foo', 'bar', 'bar'])

使用重复标签进行选择时,会选中该标签的所有出现。

每个Index都有许多用于集合逻辑的方法和属性,它们可以回答关于其所含数据的其他常见问题。一些有用的方法和属性总结在 表 tbl-index-methods 中。

表 4.2: 一些Index的方法和属性
方法/属性 描述
append() 与其他Index对象连接,生成一个新的Index
difference() 计算集合的差集,结果为一个Index
intersection() 计算集合的交集
union() 计算集合的并集
isin() 计算一个布尔数组,指示每个值是否包含在传入的集合中
delete() 计算一个删除了位置i处元素的新Index
drop() 通过删除传入的值来计算一个新Index
insert() 通过在位置i插入元素来计算一个新Index
is_monotonic_increasing 如果每个元素都大于或等于前一个元素,则返回True
is_unique 如果Index没有重复值,则返回True
unique() 计算Index中唯一值的数组

4.2 核心功能

本节将引导你了解与SeriesDataFrame中数据进行交互的基本机制。在接下来的章节中,我们将使用pandas更深入地探讨数据分析和处理的主题。

4.2.1 重建索引 (Reindexing)

pandas对象的一个重要方法是reindex,它意味着创建一个新对象,其值根据新的索引重新排列。看一个例子:

obj = pd.Series([4.5, 7.2, -5.3, 3.6], index=['d', 'b', 'a', 'c'])
obj

对这个Series调用reindex会根据新的索引重新排列数据,如果某个索引值之前不存在,则引入缺失值:

obj2 = obj.reindex(['a', 'b', 'c', 'd', 'e'])
obj2

对于像时间序列这样的有序数据,你可能希望在重建索引时进行一些插值或填充。method选项允许我们这样做,例如使用ffill方法,它会向前填充值:

obj3 = pd.Series(['blue', 'purple', 'yellow'], index=[0, 2, 4])
obj3
obj3.reindex(np.arange(6), method='ffill')

对于DataFramereindex可以改变行索引、列索引,或两者都改变。当只传递一个序列时,它会对结果的行进行重建索引:

frame = pd.DataFrame(np.arange(9).reshape((3, 3)),
                     index=['a', 'c', 'd'],
                     columns=['北京', '上海', '广东'])
frame
frame2 = frame.reindex(index=['a', 'b', 'c', 'd'])
frame2

列可以通过columns关键字进行重建索引:

provinces = ['上海', '浙江', '广东']
frame.reindex(columns=provinces)

因为'北京'不在provinces中,所以该列的数据从结果中被删除了。

你也可以使用loc操作符进行重建索引,前提是所有新的索引标签都已存在于DataFrame中。

关于reindex的参数的更多信息,请参见 表 tbl-reindex-args

表 4.3: reindex函数参数
参数 描述
labels 用作索引的新序列。可以是Index实例或任何其他类似序列的Python数据结构。
index, columns 分别使用传入的序列作为新的行索引或列标签。
axis 要重建索引的轴,'index'(行)或'columns'(列)。默认为'index'
method 插值(填充)方法;'ffill'向前填充,'bfill'向后填充。
fill_value 在重建索引引入缺失数据时使用的替代值。
limit 在向前或向后填充时,要填充的最大间隙大小(以元素数量计)。
tolerance 在向前或向后填充时,对于非精确匹配要填充的最大间隙大小(以绝对数值距离计)。
level 在MultiIndex的指定层级上匹配简单索引;否则选择子集。
copy 如果为True,即使新索引与旧索引相同,也总是复制底层数据;如果为False,当索引相同时不复制数据。

4.2.2 删除指定轴上的条目 (Dropping Entries from an Axis)

如果你已经有一个不包含那些条目的索引数组或列表,那么从一个轴上删除一个或多个条目就很简单。drop方法将返回一个新对象,其中指定的值已从一个轴上被删除:

obj = pd.Series(np.arange(5.), index=['a', 'b', 'c', 'd', 'e'])
obj
new_obj = obj.drop('c')
new_obj
obj.drop(['d', 'c'])

对于DataFrame,可以从任一轴删除索引值。为了说明这一点,我们首先创建一个示例DataFrame:

data = pd.DataFrame(np.arange(16).reshape((4, 4)),
                    index=['北京', '上海', '广东', '浙江'],
                    columns=['one', 'two', 'three', 'four'])
data

用一个标签序列调用drop将从行标签(轴0)中删除值:

data.drop(index=['上海', '北京'])

要从列中删除标签,可以使用columns关键字:

data.drop(columns=['two'])

你也可以通过传递axis=1axis='columns'来从列中删除值:

data.drop('two', axis=1)
data.drop(['two', 'four'], axis='columns')

4.2.3 索引、选择与过滤 (Indexing, Selection, and Filtering)

Series的索引(obj[...])工作方式类似于NumPy数组的索引,不同之处在于你可以使用Series的索引值而不仅仅是整数。

obj = pd.Series(np.arange(4.), index=['a', 'b', 'c', 'd'])
obj
obj['b']
obj[1]
obj[2:4]
obj[['b', 'a', 'd']]
obj[[1, 3]]
obj[obj < 2]

虽然你可以通过标签来选择数据,但选择索引值的首选方法是使用特殊的loc操作符:

obj.loc[['b', 'a', 'd']]
澄清整数索引的歧义:.loc vs. .iloc

对于pandas初学者来说,一个主要的困惑来源是如何处理整数索引。 - 如果一个Series的索引本身是非整数标签(如'a', 'b', 'c'),那么obj[1]这样的方括号索引会明确地按位置选择。 - 但如果Series的索引本身是整数(如2, 0, 1),obj[1]会按标签选择,即寻找索引标签为1的元素,而不是位置在第1个的元素。

这种行为上的差异可能会导致难以察觉的错误。为了消除这种歧义,pandas提供了两个专门的索引器: - .loc标签 (label) 进行索引。例如,obj.loc['b']obj1.loc[0]。 - .iloc按整数位置 (integer location) 进行索引。例如,obj.iloc[1]obj1.iloc[1]

最佳实践:无论索引是何种类型,都应优先使用.loc.iloc。这能让你的代码意图更清晰,行为更可预测,从而避免因索引类型不同而导致的意外结果。

例如,考虑一个带有整数索引的Series

obj1 = pd.Series([1, 2, 3], index=[2, 0, 1])
obj2 = pd.Series([1, 2, 3], index=['a', 'b', 'c'])
obj1
obj2

obj1上使用[]索引将使用标签,而不是位置:

obj1[[0, 1, 2]]

obj2上使用loc和整数会失败,因为它的索引不是基于整数的:

# 这会引发一个KeyError
# obj2.loc[[0, 1]]

由于loc操作符完全按标签索引,因此还有一个iloc操作符,它完全按整数索引,无论索引是否包含整数,其工作方式都保持一致:

obj1.iloc[[0, 1, 2]]
obj2.iloc[[0, 1, 2]]

你也可以使用loc进行标签切片,但它的工作方式与普通的Python切片不同,即终点是包含在内的:

obj2.loc['b':'c']

使用这些方法赋值会修改Series的相应部分:

obj2.loc['b':'c'] = 5
obj2

DataFrame进行索引会检索一个或多个列,可以使用单个值或序列:

data = pd.DataFrame(np.arange(16).reshape((4, 4)),
                    index=['北京', '上海', '广东', '浙江'],
                    columns=['one', 'two', 'three', 'four'])
data
data['two']
data[['three', 'one']]

这样的索引有几个特殊情况。第一种是切片或用布尔数组选择数据:

data[:2]
data[data['three'] > 5]

行选择语法data[:2]是为了方便而提供的。将单个元素或列表传递给[]操作符会选择列。

另一个用例是使用布尔DataFrame进行索引,例如由标量比较产生的DataFrame

data < 5

我们可以使用这个DataFrame将值为True的每个位置都赋为0:

data[data < 5] = 0
data

4.2.3.1 使用loc和iloc在DataFrame上进行选择

Series一样,DataFrame也具有用于基于标签和基于整数索引的特殊属性lociloc。作为第一个例子,让我们按标签选择单行:

data.loc['上海']

要选择多行,传递一个标签序列:

data.loc[['上海', '浙江']]

你可以在loc中组合行和列的选择,用逗号分隔:

data.loc['上海', ['two', 'three']]

接下来我们用iloc进行一些类似的整数选择:

data.iloc[2]
data.iloc[[2, 1]]
data.iloc[2, [3, 0, 1]]
data.iloc[[1, 2], [3, 0, 1]]

两个索引函数都支持切片,以及单个标签或标签列表:

data.loc[:'广东', 'two']
data.iloc[:, :3][data.three > 5]

布尔数组可以与loc一起使用,但不能与iloc一起使用:

data.loc[data.three >= 2]

表 tbl-indexing-options 提供了DataFrame索引选项的简短摘要。

表 4.4: DataFrame的索引选项
类型 说明
df[column] 从DataFrame中选择单列或列序列;特殊便利用法:布尔数组(过滤行)、切片(切片行)或布尔DataFrame(基于某些标准设置值)
df.loc[rows] 按标签从DataFrame中选择单行或行子集
df.loc[:, cols] 按标签选择单列或列子集
df.loc[rows, cols] 按标签同时选择行和列
df.iloc[rows] 按整数位置从DataFrame中选择单行或行子集
df.iloc[:, cols] 按整数位置选择单列或列子集
df.iloc[rows, cols] 按整数位置同时选择行和列
df.at[row, col] 按行和列标签选择单个标量值
df.iat[row, col] 按行和列位置(整数)选择单个标量值
reindex 方法 按标签选择行或列

4.2.3.2 整数索引的陷阱

处理由整数索引的pandas对象可能会成为一个障碍。例如,你可能不会预料到以下代码会产生错误:

ser = pd.Series(np.arange(3.))
ser
# 这会引发一个KeyError
# ser[-1]

在这种情况下,pandas可以“回退”到整数索引,但在不引入细微错误的情况下,这在通常很难做到。这里我们有一个包含0、1和2的索引,但pandas不想猜测用户想要的是什么(基于标签的索引还是基于位置的)。

另一方面,对于非整数索引,就没有这样的歧义:

ser2 = pd.Series(np.arange(3.), index=['a', 'b', 'c'])
ser2[-1]

如果你的轴索引包含整数,数据选择将始终是面向标签的。如前所述,如果你使用loc(用于标签)或iloc(用于整数),你将得到你想要的结果:

ser.iloc[-1]

另一方面,用整数切片总是面向位置的:

ser[:2]

4.2.3.3 链式索引的陷阱

这些索引属性也可以用来原地修改DataFrame对象,但这需要一些小心。

data.loc[:, 'one'] = 1
data
data.iloc[2] = 5
data
data.loc[data['four'] > 5] = 3
data

pandas新手常犯的一个错误是在赋值时使用链式选择,像这样:data.loc[data.three == 5]['three'] = 6

这可能会打印一个特殊的SettingWithCopyWarning警告,并且可能不会按预期工作。表达式data.loc[data.three == 5]可能返回一个视图或一个副本,所以你是在给一个临时对象赋值。解决方法是重写链式赋值,使用单个loc操作:

data.loc[data.three == 5, 'three'] = 6
data

4.2.4 算术与数据对齐

当你将对象相加时,如果任何索引对不相同,结果中的相应索引将是索引对的并集。

s1 = pd.Series([7.3, -2.5, 3.4, 1.5], index=['a', 'c', 'd', 'e'])
s2 = pd.Series([-2.1, 3.6, -1.5, 4, 3.1], index=['a', 'c', 'e', 'f', 'g'])
s1
s2
s1 + s2

内部数据对齐在不重叠的标签位置引入了缺失值。缺失值随后会在进一步的算术计算中传播。

对于DataFrame,对齐是在行和列上都进行的:

df1 = pd.DataFrame(np.arange(9.).reshape((3, 3)), columns=list('bcd'),
                   index=['北京', '上海', '广东'])
df2 = pd.DataFrame(np.arange(12.).reshape((4, 3)), columns=list('bde'),
                   index=['浙江', '北京', '上海', '江苏'])
df1
df2

将它们相加返回一个DataFrame,其索引和列是每个DataFrame中索引和列的并集:

df1 + df2

如果你将没有共同列或行标签的DataFrame对象相加,结果将全部是空值。

4.2.4.1 带有填充值的算术方法

在不同索引对象之间的算术运算中,你可能希望当一个轴标签在一个对象中找到而另一个对象中没有时,用一个特殊值(如0)来填充。

df1 = pd.DataFrame(np.arange(12.).reshape((3, 4)), columns=list('abcd'))
df2 = pd.DataFrame(np.arange(20.).reshape((4, 5)), columns=list('abcde'))
df2.loc[1, 'b'] = np.nan
df1
df2

将它们相加会导致在不重叠的位置出现缺失值:

df1 + df2

使用df1add方法,我们传递df2和一个fill_value参数:

df1.add(df2, fill_value=0)

表 tbl-arithmetic-methods 列出了SeriesDataFrame的算术方法。每个方法都有一个以字母r开头的对应方法,其参数是反转的。

1 / df1
df1.rdiv(1)

相关地,在对SeriesDataFrame进行重建索引时,你也可以指定一个不同的填充值:

df1.reindex(columns=df2.columns, fill_value=0)
表 4.5: 灵活的算术方法
方法 描述
add, radd 加法 (+) 的方法
sub, rsub 减法 (-) 的方法
div, rdiv 除法 (/) 的方法
floordiv, rfloordiv 整除 (//) 的方法
mul, rmul 乘法 (*) 的方法
pow, rpow 幂运算 (**) 的方法

4.2.4.2 DataFrame与Series之间的运算

DataFrameSeries之间的算术运算也是有定义的。首先,考虑一个二维数组和它的一行之间的差异:

arr = np.arange(12.).reshape((3, 4))
arr
arr[0]
arr - arr[0]

当我们从arr中减去arr[0]时,这个减法操作会对每一行都执行一次。这被称为广播 (broadcasting)DataFrameSeries之间的运算是相似的。

广播(Broadcasting)机制

广播是NumPy和pandas中一个强大的机制,它允许在形状不同的数组之间执行算术运算。其基本规则是:在比较两个数组的维度时,从后向前逐个比较: 1. 如果两个维度相等,则继续比较下一个维度。 2. 如果其中一个维度是1,那么它将被“拉伸”或“复制”以匹配另一个数组中该维度的尺寸。 3. 如果两个维度不相等且没有一个是1,则会引发错误。

DataFrameSeries的运算中,默认行为是将Series的索引与DataFrame进行匹配(axis='columns'axis=1),然后沿着行向下“广播”。这意味着Series中的每个值会分别与DataFrame对应列中的所有元素进行运算。如果你想改变这个行为,比如让Series的索引与DataFrame索引匹配并沿着列向右广播,你必须使用算术方法(如 .sub())并显式指定axis='index'axis=0

frame = pd.DataFrame(np.arange(12.).reshape((4, 3)),
                     columns=list('bde'),
                     index=['浙江', '北京', '上海', '江苏'])
series = frame.iloc[0]
frame
series

默认情况下,DataFrameSeries之间的算术运算会将Series的索引与DataFrame的列进行匹配,并向下广播行:

frame - series

如果在DataFrame的列或Series的索引中找不到某个索引值,对象将被重新索引以形成并集:

series2 = pd.Series(np.arange(3), index=['b', 'e', 'f'])
frame + series2

如果你想在列上进行广播,匹配行,你必须使用其中一个算术方法并指定在索引上匹配(axis='index'):

series3 = frame['d']
frame
series3
frame.sub(series3, axis='index')

4.2.5 函数应用和映射 (Function Application and Mapping)

NumPy的通用函数(ufuncs,即逐元素的数组方法)也适用于pandas对象:

frame = pd.DataFrame(np.random.standard_normal((4, 3)),
                     columns=list('bde'),
                     index=['浙江', '北京', '上海', '江苏'])
frame
np.abs(frame)

另一个常见的操作是将一个作用于一维数组的函数应用到每一列或每一行。DataFrameapply方法正是为此而生:

def f1(x):
    return x.max() - x.min()

frame.apply(f1)

如果你向apply传递axis='columns',函数将对每一行调用一次。

frame.apply(f1, axis='columns')

传递给apply的函数不一定返回一个标量值;它也可以返回一个包含多个值的Series

def f2(x):
    return pd.Series([x.min(), x.max()], index=['min', 'max'])
frame.apply(f2)

逐元素的Python函数也可以使用。假设你想从frame中的每个浮点值计算一个格式化的字符串。你可以用applymap来做到这一点:

def my_format(x):
    return f'{x:.2f}'

frame.applymap(my_format)

applymap这个名字的原因是Series有一个map方法,用于应用一个逐元素的函数:

frame['e'].map(my_format)

4.2.6 排序和排名 (Sorting and Ranking)

要按行或列标签进行字典序排序,使用sort_index方法,它返回一个新的、已排序的对象:

obj = pd.Series(np.arange(4), index=['d', 'a', 'b', 'c'])
obj.sort_index()

对于DataFrame,你可以按任一轴的索引进行排序:

frame = pd.DataFrame(np.arange(8).reshape((2, 4)),
                     index=['three', 'one'],
                     columns=['d', 'a', 'b', 'c'])
frame.sort_index()
frame.sort_index(axis='columns')

数据默认按升序排序,但也可以按降序排序:

frame.sort_index(axis='columns', ascending=False)

要按值对Series进行排序,使用其sort_values方法:

obj = pd.Series([4, 7, -3, 2])
obj.sort_values()

任何缺失值默认都会被排到Series的末尾:

obj = pd.Series([4, np.nan, 7, np.nan, -3, 2])
obj.sort_values()

通过使用na_position选项,缺失值可以被排到开头:

obj.sort_values(na_position='first')

在对DataFrame进行排序时,你可以使用一列或多列中的数据作为排序键。

frame = pd.DataFrame({'b': [4, 7, -3, 2], 'a': [0, 1, 0, 1]})
frame
frame.sort_values('b')

要按多列排序,传递一个列名的列表:

frame.sort_values(['a', 'b'])

排名(Ranking)会为数组中的有效数据点分配从1到N的排名。默认情况下,rank通过为每个组分配平均排名来处理平级关系:

obj = pd.Series([7, -5, 7, 4, 2, 0, 4])
obj.rank()

排名也可以根据它们在数据中出现的顺序来分配:

obj.rank(method='first')

你也可以按降序排名:

obj.rank(ascending=False)

DataFrame可以在行或列上计算排名。表 tbl-tie-breaking-methods 列出了排名的平级处理方法。

frame = pd.DataFrame({'b': [4.3, 7, -3, 2], 'a': [0, 1, 0, 1],
                      'c': [-2, 5, 8, -2.5]})
frame
frame.rank(axis='columns')
表 4.6: rank的平级处理方法
方法 描述
'average' 默认:为平级组中的每个条目分配平均排名
'min' 为整个组使用最小排名
'max' 为整个组使用最大排名
'first' 按值在数据中出现的顺序分配排名
'dense' 类似于method='min',但组与组之间的排名总是增加1

4.2.7 带有重复标签的轴索引 (Axis Indexes with Duplicate Labels)

虽然许多pandas函数(如reindex)要求标签是唯一的,但这并不是强制性的。

obj = pd.Series(np.arange(5), index=['a', 'a', 'b', 'b', 'c'])
obj

索引的is_unique属性可以告诉你其标签是否唯一:

obj.index.is_unique

索引一个有多个条目的标签会返回一个Series,而单个条目则返回一个标量值:

obj['a']
obj['c']

同样的逻辑也适用于在DataFrame中索引行:

df = pd.DataFrame(np.random.standard_normal((5, 3)),
                  index=['a', 'a', 'b', 'b', 'c'])
df
df.loc['b']
df.loc['c']

4.3 汇总与描述性统计

pandas对象配备了一套常见的数学和统计方法。这些方法大多数属于归约 (reductions)汇总统计 (summary statistics) 的范畴。它们内置了对缺失数据的处理。

df = pd.DataFrame([[1.4, np.nan], [7.1, -4.5],
                   [np.nan, np.nan], [0.75, -1.3]],
                  index=['a', 'b', 'c', 'd'],
                  columns=['one', 'two'])
df

调用DataFramesum方法会返回一个包含列总和的Series

df.sum()

传递axis='columns'则会对行进行求和:

df.sum(axis='columns')

当整行或整列都包含NA值时,总和为0。这可以通过skipna选项(默认为True)来禁用,此时总和为NA

df.sum(axis='index', skipna=False)
df.sum(axis='columns', skipna=False)

一些聚合操作,如mean,至少需要一个非NA值才能产生有效结果:

df.mean(axis='columns')

表 tbl-reduction-options 列出了归约方法的常用选项。

表 4.7: 归约方法的选项
方法 描述
axis 进行归约的轴;'index'表示行,'columns'表示列
skipna 排除缺失值;默认为True
level 如果轴是分层索引,则按层级进行分组归约

一些方法,如idxminidxmax,返回的是间接统计量,比如取得最小值或最大值的索引值:

df.idxmax()

其他方法是累积型的:

df.cumsum()

describe就是这样一个例子,它能一次性产生多个汇总统计量:

df.describe()

对于非数值数据,describe会产生另一种汇总统计量:

obj = pd.Series(['a', 'a', 'b', 'c'] * 4)
obj.describe()

表 tbl-descriptive-stats 列出了一个完整的汇总统计和相关方法的集合。

表 4.8: 描述性与汇总统计
方法 描述
count 非NA值的数量
describe 计算一组汇总统计量
min, max 计算最小值和最大值
argmin, argmax 计算取得最小值或最大值的索引位置(整数)
idxmin, idxmax 计算取得最小值或最大值的索引标签
quantile 计算样本分位数,范围从0到1(默认:0.5)
sum 值的总和
mean 值的平均值
median 值的算术中位数(50%分位数)
mad 平均绝对离差
prod 所有值的乘积
var 样本方差
std 样本标准差
skew 样本偏度(三阶矩)
kurt 样本峰度(四阶矩)
cumsum 值的累积和
cummin, cummax 值的累积最小值或最大值
cumprod 值的累积积
diff 计算一阶算术差分(对时间序列有用)
pct_change 计算百分比变化

4.3.1 相关性与协方差 (Correlation and Covariance)

一些汇总统计,如相关性和协方差,是根据成对的参数计算的。为了让同学们更好地理解这些概念在金融实践中的应用,我们将使用yfinance库从雅虎财经获取中国市场的真实股票价格数据来演示。这是金融计量经济学中的一个常见任务。

A股与H股数据说明

我们将选取几家在中国内地(A股)和香港(H股)上市的知名公司。它们的股票代码后缀不同: - .SS 代表上海证券交易所 (Shanghai Stock Exchange) - .HK 代表香港交易所 (Hong Kong Stock Exchange) yfinance库可以方便地获取这些市场的公开数据。

列表 4.4
import yfinance as yf

# 定义中国市场的几家代表性公司股票代码
# 贵州茅台(600519.SS), 中国平安(601318.SS), 腾讯控股(0700.HK), 阿里巴巴(9988.HK)
tickers = ['600519.SS', '601318.SS', '0700.HK', '9988.HK']

# 获取历史价格和交易量数据
price_data = yf.download(tickers, start='2020-01-01', end='2024-01-01')

# 提取调整后的收盘价和交易量
price = price_data['Adj Close']
volume = price_data['Volume']

现在我们计算价格的百分比变化,这代表了每日收益率。这是时间序列分析中的一个基本操作。

returns = price.pct_change()
returns.tail()

Seriescorr方法计算两个Series中重叠的、非NA的、按索引对齐的值的相关性。类似地,cov计算协方差:

returns['600519.SS'].corr(returns['601318.SS'])
returns['600519.SS'].cov(returns['601318.SS'])

DataFramecorrcov方法分别返回一个完整的相关性或协方差矩阵,形式为DataFrame。这个矩阵在现代投资组合理论中至关重要,因为它是计算投资组合方差的基础。

returns.corr()
returns.cov()

使用DataFramecorrwith方法,你可以计算一个DataFrame的列或行与另一个SeriesDataFrame之间的成对相关性。例如,我们可以计算所有股票与中国平安收益率的相关性。

returns.corrwith(returns['601318.SS'])

传递一个DataFrame会计算匹配列名的相关性。这里,我们计算每日收益率与当日交易量的相关性。在量化交易中,这被称为价量关系分析,是探索市场微观结构的重要一步。

returns.corrwith(volume)

4.3.2 唯一值、值计数和成员资格 (Unique Values, Value Counts, and Membership)

另一类相关方法用于提取关于一维Series中所含值的信息。

obj = pd.Series(['c', 'a', 'd', 'a', 'a', 'b', 'b', 'c', 'c'])

unique给你一个Series中唯一值的数组:

uniques = obj.unique()
uniques

value_counts计算一个包含值频率的Series,并按降序排序:

obj.value_counts()

isin执行一个向量化的集合成员资格检查:

mask = obj.isin(['b', 'c'])
mask
obj[mask]

isin相关的是Index.get_indexer方法,它给你一个从可能非唯一值的数组到另一个唯一值数组的索引数组:

to_match = pd.Series(['c', 'a', 'b', 'b', 'c', 'a'])
unique_vals = pd.Series(['c', 'b', 'a'])
indices = pd.Index(unique_vals).get_indexer(to_match)
indices

表 tbl-membership-methods 提供了这些方法的参考。

表 4.9: 唯一值、值计数和集合成员资格方法
方法 描述
isin 计算一个布尔数组,指示每个值是否包含在传递的值序列中
get_indexer 为一个数组中的每个值计算到另一个唯一值数组的整数索引;有助于数据对齐和连接类型的操作
unique 计算Series中的唯一值数组,按观察到的顺序返回
value_counts 返回一个Series,其索引为唯一值,值为频率,按计数降序排列

要在DataFrame中的多个相关列上计算直方图(例如处理问卷调查数据):

data = pd.DataFrame({'问题1': [1, 3, 4, 3, 4],
                     '问题2': [2, 3, 1, 2, 3],
                     '问题3': [1, 5, 2, 4, 4]})
data

要计算所有列的值计数,将pd.value_counts传递给apply方法:

result = data.apply(pd.value_counts).fillna(0)
result

还有一个DataFrame.value_counts方法,但它将DataFrame的每一行视为一个元组来计算每个不同行的出现次数。

data = pd.DataFrame({'a': [1, 1, 1, 2, 2], 'b': [0, 0, 1, 0, 0]})
data.value_counts()

4.4 结论

在下一章中,我们将讨论使用pandas读取(或加载)和写入数据集的工具。之后,我们将更深入地使用pandas来研究数据清洗、整理、分析和可视化的工具。