1. 为什么数值运算值得单独拎出来讲
很多人学 Pandas 的时候,路径都差不多:先学怎么读 CSV,再学怎么选列选行,然后就开始跑业务逻辑了。结果一到需要做批量加减乘除、条件赋值、分组统计的时候,代码就开始变得又臭又长。我见过不少人的 DataFrame 里跑出来一堆 object 类型的列,做加法直接字符串拼接,做除法报 TypeError,然后就开始怀疑人生。
Pandas 的数值运算方法,说白了就是让你在 DataFrame 和 Series 上做数学操作时,不用写循环、不用逐行 apply、不用把数据倒腾到 NumPy 再倒回来。它解决的核心问题是:让列级别的批量计算变得像 Excel 一样直观,但比 Excel 快几个数量级,而且可复现、可版本管理。
这篇文章适合谁看?如果你已经会用 Pandas 读数据、选数据,但每次做计算都要查文档、每次遇到 NaN 就头疼、每次看到SettingWithCopyWarning就烦躁,那这篇就是写给你的。我会把数值运算的常见方法、参数背后的逻辑、实际踩过的坑,以及一些不太出现在官方文档里的经验,全部摊开来讲。
2. 数值运算的基础骨架与核心方法
2.1 算术运算:加减乘除不只是符号那么简单
Pandas 的算术运算分两个层面:Series 与标量、DataFrame 与标量、Series 与 Series、DataFrame 与 DataFrame。最常用的就是前两种,因为实际业务里大量场景是“给某一列统一加个值”“把某列单位从元转成万元”。
直接上代码:
import pandas as pd import numpy as np df = pd.DataFrame({ '单价': [10.5, 20.0, 15.8, 8.9], '数量': [100, 200, 150, 300], '折扣': [0.9, 0.85, 0.95, 1.0] }) # 标量运算 df['总价'] = df['单价'] * df['数量'] * df['折扣']这段代码看起来简单,但有几个细节值得说。第一,df['单价'] * df['数量']是 Series 对 Series 的运算,Pandas 会自动按索引对齐。如果两个 Series 的索引不一致,结果里会出现 NaN。这是很多人第一次踩的坑:从两个不同来源取的列,索引一个是 0-99,一个是 1-100,乘出来全是 NaN,还找不到原因。
第二,标量运算不会改变原 DataFrame,除非你赋值回去。df['单价'] + 1只是返回一个新的 Series,原数据不动。这个设计是好的,避免了意外修改,但新手经常忘了赋值,然后纳闷为什么数据没变。
第三,除法要特别注意除零。Pandas 里整数除以零不会报错,会返回inf或-inf,而0/0返回NaN。如果你后续要做聚合,inf会污染结果。我的习惯是在除法之后加一句df.replace([np.inf, -np.inf], np.nan, inplace=True),把无穷大统一转成 NaN,后续处理更干净。
2.2 比较运算与布尔索引:筛选的本质是数值比较
比较运算返回的是布尔 Series,这是布尔索引的基础。但很多人不知道的是,Pandas 的比较运算在遇到 NaN 时,结果永远是 False。这意味着df[df['列'] > 0]会自动排除 NaN 行,这通常是想要的行为,但如果你没意识到,就会觉得“怎么少了几行”。
# 多条件筛选,注意括号不能省 result = df[(df['单价'] > 10) & (df['数量'] >= 150)]这里&和and的区别是高频错误点。and是 Python 关键字,只能用于标量布尔值,用在 Series 上会报ValueError。必须用&、|、~,而且每个条件都要用括号包起来,因为位运算符的优先级高于比较运算符。
还有一个实用技巧:df['列'].between(a, b)等价于(df['列'] >= a) & (df['列'] <= b),但写起来更短,可读性更好。isin([...])用于多值匹配,比写一堆|干净得多。
2.3 统计运算:describe 只是起点
df.describe()是很多人第一个学会的统计方法,但它默认只统计数值列,而且只给 count、mean、std、min、25%、50%、75%、max。实际业务里,我们经常需要偏度、峰度、中位数绝对偏差、分位数自定义。
# 自定义分位数 df['单价'].quantile([0.1, 0.5, 0.9]) # 偏度和峰度 df['单价'].skew() df['单价'].kurt() # 累计统计 df['累计销量'] = df['数量'].cumsum() df['累计最大值'] = df['数量'].cummax()cumsum、cummax、cummin、cumprod这几个累计函数在时间序列场景里非常有用。比如算累计收益、累计最高点回撤,都是一行搞定。但要注意,cumsum默认不跳过 NaN,遇到 NaN 之后的结果全是 NaN。如果数据有缺失,先fillna(0)或者用skipna参数(部分函数支持)。
idxmin和idxmax返回的是索引而不是值,这个在找“哪个产品销量最高”时特别方便。但如果有多个相同最大值,只返回第一个。想要所有最大值的位置,得用df[df['列'] == df['列'].max()].index。
3. 对齐机制与缺失值处理:数值运算的暗礁区
3.1 索引对齐:Pandas 最强大也最容易被忽视的设计
Pandas 的运算默认按索引对齐,这是它和 NumPy 最本质的区别。NumPy 数组相加是位置对应,Pandas 是标签对应。这个设计在数据合并时非常有用,但在简单运算时经常带来意外。
s1 = pd.Series([1, 2, 3], index=['a', 'b', 'c']) s2 = pd.Series([10, 20, 30], index=['b', 'c', 'd']) s1 + s2 # a NaN # b 12.0 # c 23.0 # d NaN结果里 a 和 d 都是 NaN,因为对方没有对应的索引。如果你想要的是位置相加,应该用.values转成 NumPy 数组再运算,或者用add方法并指定fill_value。
s1.add(s2, fill_value=0) # a 1.0 # b 12.0 # c 23.0 # d 30.0add、sub、mul、div、floordiv、mod、pow这些方法都支持fill_value参数,这是处理索引不完全重叠时的标准做法。我个人的经验是:只要两个 Series 的索引不是明确一致的,就用带fill_value的方法,不要用运算符。这样代码意图更清晰,也避免了后续调试时才发现 NaN 的尴尬。
3.2 NaN 的传播与跳过策略
NaN 在数值运算里会传播:任何涉及 NaN 的算术运算结果都是 NaN。这是 IEEE 754 标准的行为,Pandas 遵循这个标准。但统计函数默认跳过 NaN,这是 Pandas 自己的设计选择。
s = pd.Series([1, 2, np.nan, 4]) s.sum() # 7.0,跳过 NaN s.mean() # 2.333...,跳过 NaN s.sum(skipna=False) # NaN,不跳过这个差异导致一个常见困惑:为什么s + 1里 NaN 还是 NaN,但s.sum()就把 NaN 忽略了?因为算术运算是逐元素的,统计函数是聚合的。理解这一点,很多“奇怪”的行为就说得通了。
填充 NaN 的常用方法:
| 方法 | 适用场景 | 注意点 |
|---|---|---|
fillna(0) | 计数、求和场景 | 会改变均值 |
fillna(method='ffill') | 时间序列 | 需要数据按时间排序 |
fillna(df.mean()) | 近似正态分布 | 会缩小方差 |
interpolate() | 连续变化的数据 | 默认线性插值 |
注意:
fillna默认返回新对象,不修改原数据。如果确定要原地修改,加inplace=True,但我不推荐这么做,因为链式调用时容易出问题。
3.3 类型转换:object 转数值的坑
从 CSV 读进来的数据,如果列里有任何非数字字符,整列就是 object 类型。这时候做数值运算,轻则结果错误,重则直接报错。
df['价格'] = pd.to_numeric(df['价格'], errors='coerce')errors='coerce'会把无法转换的值变成 NaN,这是最安全的做法。errors='ignore'会保持原样,但那样你还是在 object 类型上运算,问题没解决。errors='raise'直接报错,适合数据质量有保证的场景。
转换之后,建议检查一下有多少 NaN:
print(df['价格'].isna().sum())如果 NaN 比例很高,说明数据源有问题,需要回去查原始数据,而不是硬着头皮往下算。
4. 分组运算与窗口计算:从聚合到变换
4.1 groupby 后的数值运算:agg、transform、apply 怎么选
groupby之后的数值运算是 Pandas 最核心的功能之一,但三个方法的区别经常让人困惑。
agg:每组返回一个标量,结果行数等于组数transform:每组返回与组内行数相同的序列,结果行数等于原数据行数apply:最灵活,但最慢,能返回任意形状
df = pd.DataFrame({ '部门': ['A', 'A', 'B', 'B', 'B'], '销售额': [100, 200, 150, 300, 250] }) # agg:每个部门一个总和 df.groupby('部门')['销售额'].agg('sum') # transform:每行对应自己部门的均值 df['部门均值'] = df.groupby('部门')['销售额'].transform('mean') # apply:自定义函数 df.groupby('部门')['销售额'].apply(lambda x: x.max() - x.min())实际业务里,transform被低估了。比如算“每行占本部门的比例”,用transform一行搞定:
df['占比'] = df['销售额'] / df.groupby('部门')['销售额'].transform('sum')如果用agg再 merge 回去,代码量翻倍,还容易出错。我的原则是:能 transform 就不 merge,能 agg 就不 apply。
4.2 窗口计算:rolling 和 expanding
时间序列场景里,移动平均、移动求和是刚需。rolling提供固定窗口,expanding提供从开头到当前的累计窗口。
s = pd.Series([1, 2, 3, 4, 5]) s.rolling(window=3).mean() # 0 NaN # 1 NaN # 2 2.0 # 3 3.0 # 4 4.0 s.expanding().mean() # 0 1.0 # 1 1.5 # 2 2.0 # 3 2.5 # 4 3.0rolling的前window-1个值是 NaN,因为窗口不满。如果不想看到 NaN,可以加min_periods=1,这样窗口内至少有一个值就计算。
s.rolling(window=3, min_periods=1).mean() # 0 1.0 # 1 1.5 # 2 2.0 # 3 3.0 # 4 4.0这个参数在实际做报表时很有用,因为业务方通常不想看到开头的空白。
实操心得:
rolling的window参数可以是整数,也可以是时间偏移字符串(如'3D'、'7D')。用时间偏移时,索引必须是 DatetimeIndex,而且窗口是按时间长度算的,不是按行数。这个在数据时间间隔不均匀时特别重要。
4.3 分位数与排名:rank 的多种策略
rank方法在算排名时非常方便,但method参数决定了相同值怎么处理。
| method | 相同值的排名 | 示例(值:10, 20, 20, 30) |
|---|---|---|
| average | 平均排名 | 1, 2.5, 2.5, 4 |
| min | 最小排名 | 1, 2, 2, 4 |
| max | 最大排名 | 1, 3, 3, 4 |
| first | 按出现顺序 | 1, 2, 3, 4 |
| dense | 密集排名 | 1, 2, 2, 3 |
默认是average,但业务上经常需要min或dense。比如算销售排名,相同销售额通常给相同名次,下一个名次跳号,那就用min;如果不跳号,用dense。
df['排名'] = df['销售额'].rank(method='min', ascending=False)ascending=False表示大的排前面,这个在算“前 N 名”时很常用。
5. 常见问题与排查技巧实录
5.1 数值运算报错速查表
| 报错信息 | 常见原因 | 解决方法 |
|---|---|---|
TypeError: unsupported operand type(s) | 列是 object 类型 | pd.to_numeric(errors='coerce') |
ValueError: The truth value of a Series is ambiguous | 用了and/or | 改用&/` |
SettingWithCopyWarning | 链式索引后赋值 | 用.loc或先.copy() |
| 结果全是 NaN | 索引不对齐 | 检查索引,用add(fill_value=0) |
| 结果出现 inf | 除零 | replace([np.inf, -np.inf], np.nan) |
KeyError | 列名拼写错误或空格 | df.columns.tolist()检查 |
5.2 性能优化的几个实操经验
第一,避免逐行 apply。df.apply(lambda row: ..., axis=1)在数据量超过几万行时慢得离谱。能用向量化运算就用向量化,能用transform就不用apply。
第二,慎用inplace=True。虽然看起来省内存,但实际上 Pandas 很多时候还是创建了新对象,而且inplace会破坏链式调用,让代码更难读。我现在的习惯是全部用赋值,不写inplace。
第三,大文件读取时指定 dtype。如果知道某列是整数,读的时候直接dtype={'列名': 'int32'},比默认的int64省一半内存。浮点数用float32也是同理,但要注意精度损失。
第四,用eval和query加速复杂表达式。Pandas 的eval底层用 NumExpr,在大数据量下比普通运算快。但可读性会下降,建议只在性能瓶颈时用。
# 普通写法 df['结果'] = df['A'] + df['B'] * df['C'] # eval 写法 df.eval('结果 = A + B * C', inplace=True)5.3 几个容易忽略的细节
df.round(2)返回新 DataFrame,不修改原数据。而且round用的是银行家舍入(四舍六入五成双),不是传统的四舍五入。如果需要传统舍入,得用np.floor(x + 0.5)或decimal模块。
df.clip(lower, upper)用于截断异常值,比写df[df['列'] > upper] = upper干净得多。做数据清洗时非常实用。
df.abs()返回绝对值,df.cumsum()返回累计和,这些都是一行搞定的事,不需要写循环。
df.diff()算差分,默认是后一行减前一行。做同比环比时,df['列'].diff(periods=12)可以算年度差分(月度数据)。
6. 从数值运算到实际业务:一个完整的计算链路
假设我们有一份销售明细,需要算每个产品的:总销售额、平均折扣、销售额排名、累计销售额、环比增长率。这个链路几乎用到了前面所有知识点。
# 模拟数据 df = pd.DataFrame({ '产品': ['A', 'A', 'B', 'B', 'C', 'C'], '月份': [1, 2, 1, 2, 1, 2], '单价': [10, 10, 20, 18, 15, 15], '数量': [100, 120, 50, 60, 80, 90], '折扣': [0.9, 0.85, 1.0, 0.95, 0.9, 0.88] }) # 1. 算销售额 df['销售额'] = df['单价'] * df['数量'] * df['折扣'] # 2. 按产品聚合 product_stats = df.groupby('产品').agg( 总销售额=('销售额', 'sum'), 平均折扣=('折扣', 'mean') ).reset_index() # 3. 排名 product_stats['销售额排名'] = product_stats['总销售额'].rank( method='min', ascending=False ) # 4. 累计销售额(按月份排序后) df = df.sort_values(['产品', '月份']) df['累计销售额'] = df.groupby('产品')['销售额'].cumsum() # 5. 环比增长率 df['环比增长率'] = df.groupby('产品')['销售额'].pct_change() print(product_stats) print(df)这段代码里,pct_change()是算环比的标准方法,等价于(当前 - 上一期) / 上一期。但要注意,每组的第一行结果是 NaN,因为前面没有数据。如果业务上要求第一行填 0,得手动处理。
groupby之后sort_values再cumsum的顺序不能反。如果先cumsum再排序,累计值就乱了。这个顺序问题在实际写代码时经常搞错,建议养成“先排序、再分组、后累计”的习惯。
7. 一些不太出现在文档里的经验
Pandas 的数值运算方法看起来简单,但真正用好需要理解它背后的索引对齐、NaN 传播、类型系统这三套机制。我见过太多人卡在“为什么结果不对”上,最后发现是索引没对齐或者类型是 object。
我的建议是:每次做完数值运算,养成三个检查习惯。第一,df.dtypes看类型对不对;第二,df.isna().sum()看有没有意外的 NaN;第三,df.describe()看数值范围合不合理。这三个检查花不了十秒钟,但能省下大量调试时间。
另外,不要害怕写多行代码。Pandas 的链式调用很优雅,但调试时很痛苦。我现在的习惯是每个关键步骤赋值给一个新变量,虽然代码长一点,但出问题时能快速定位是哪一步的问题。等逻辑稳定了,再考虑合并成链式调用。
最后分享一个小技巧:pd.set_option('display.max_columns', None)和pd.set_option('display.float_format', '{:.2f}'.format)这两个设置能让 DataFrame 的打印输出更易读,尤其是列多的时候。这个不算数值运算本身,但能让你在调试数值问题时看得更清楚。