1. 从“下一行减上一行”说起:为什么需要行间运算?
刚接触数据分析那会儿,我处理过一个看似简单的需求:计算每天销售额的日环比增长。数据表里,“销售额”这一列整整齐齐,我心想,这不就是拿今天的数减去昨天的数吗?于是吭哧吭哧写了个循环,一行行去减。结果程序跑得慢不说,还因为边界问题(比如第一天没有“昨天”)处理不当,搞出了不少错误。
后来我才知道,在Pandas的世界里,这种“行与行之间”的计算,有个专门的术语叫“行间运算”或“窗口运算”。它太常见了:计算股票价格的日涨跌幅、分析传感器数据的连续差值、统计用户访问量的日变化、甚至是像水文计算中求相邻时间点的体积变化……本质上,都是在处理当前行与它“邻居”行的关系。
如果你也曾经像我一样,为这种计算写过繁琐的循环或者复杂的索引,那今天这篇文章就是为你准备的。我要跟你分享两个Pandas里处理行间运算的“神器”:shift() 和 diff()。它们能让原本需要好几行、甚至十几行代码才能搞定的计算,变得清晰又高效。更重要的是,理解了它们,你就能以一种更“Pandas”的向量化思维来操作数据,告别低效的循环。
简单来说:
shift():负责“搬运”数据。它可以把整列数据向上或向下“推”几位,空出来的位置就用NaN(空值)填充。想象一下,你把一列数字抄到另一张纸上,但故意抄错了一行,这就是shift()干的事。这个“错位”恰恰是计算差值的关键。diff():负责“计算”差值。它直接计算当前行与前面某一行(默认是前一行)的差值,本质上就是当前行 - 前一行。它内部其实就调用了shift(),但把结果封装得更直接。
原始文章里那个“下一行减去上一行”的问题,用这两个函数都能轻松解决,但用法和结果稍有不同,这也是我们接下来要深入探讨的。我们先从最基础的用法开始,一步步揭开它们高效应用的面纱。
2. 核心函数深度解析:shift与diff的异同
别看shift()和diff()最终都能算出差值,但它们的“性格”和适用场景其实不太一样。理解清楚这一点,能让你在实战中少走弯路。
2.1 shift():数据搬运工,灵活但需手动计算
shift()函数就像一个勤恳的搬运工,它的核心工作就是沿着某个轴移动数据,不进行计算。它的灵活性很高,但需要你手动完成后续计算。
它的基本语法是:DataFrame.shift(periods=1, freq=None, axis=0, fill_value=None)
periods:移动的步数。正数表示向下/向右移动,负数表示向上/向左移动。这是最关键的参数。axis:移动的轴。0或'index'表示沿着行移动(上下移动),1或'columns'表示沿着列移动(左右移动)。行间运算我们主要用axis=0。fill_value:用于填充因移动而产生的空值的标量,默认是NaN。
经典场景:计算“下一行减上一行”
假设我们有一列股票收盘价 df['Close']:
import pandas as pd
df = pd.DataFrame({'Close': [100, 102, 101, 105, 108]})
我们想计算每日涨跌(今日收盘价 - 昨日收盘价)。用shift(1)可以把数据向下移动一行,这样“昨日收盘价”就和“今日收盘价”对齐了:
df['Prev_Close'] = df['Close'].shift(1) # 创建一列“前一日收盘价”
df['Change'] = df['Close'] - df['Prev_Close'] # 手动计算差值
print(df)
输出会是:
Close Prev_Close Change
0 100 NaN NaN
1 102 100.0 2.0
2 101 102.0 -1.0
3 105 101.0 4.0
4 108 105.0 3.0
看到了吗?第一行因为前面没有数据,所以Prev_Close是NaN,导致Change也是NaN。这是shift()计算差值时必然要处理的边界问题。
反向移动与“上一行减下一行”
原始文章里提到了shift(-1),这是向上移动一行。这有什么用呢?比如你想计算“今日收盘价 - 明日收盘价”(虽然这听起来有点怪,但某些预测或回溯分析中会用到):
df['Next_Close'] = df['Close'].shift(-1) # 创建一列“后一日收盘价”
df['Change_Reverse'] = df['Close'] - df['Next_Close']
print(df)
输出中,最后一行的Next_Close会是NaN。所以,shift()的方向决定了哪一行会出现NaN:shift(正数)导致开头出现NaN,shift(负数)导致结尾出现NaN。
2.2 diff():专业的差值计算器,开箱即用
如果说shift()是给你提供了原材料(错位的数据),那diff()就是直接给你成品(计算好的差值)。它是专门为计算差值而生的。
它的语法更简洁:DataFrame.diff(periods=1, axis=0)
periods:差值的阶数。默认为1,表示当前行减去前一行。如果设为2,就是当前行减去前两行。axis:同上。
直接计算差值 同样计算每日涨跌:
df['Change_diff'] = df['Close'].diff() # 默认 periods=1
print(df[['Close', 'Change_diff']])
输出:
Close Change_diff
0 100 NaN
1 102 2.0
2 101 -1.0
3 105 4.0
4 108 3.0
看,结果和用shift()手动计算的一模一样!df['Close'].diff()在内部等价于df['Close'] - df['Close'].shift(1)。diff()帮你省去了创建中间列和手动减法的步骤,代码更干净。
高阶差值计算
diff()的periods参数让它能轻松计算更复杂的差值。比如,你想看每周的变化(假设数据是日度的,计算当前日与7天前的差值):
df['Weekly_Change'] = df['Close'].diff(periods=7) # 如果数据足够长
这在分析具有周期性的数据(如周效应)时非常有用。
2.3 关键对比与选择建议
为了更直观,我把它们的核心区别总结成下表:
| 特性 | shift() | diff() |
|---|---|---|
| 核心功能 | 移动数据,产生错位 | 直接计算差值 |
| 输出 | 移动后的原数据 | 计算后的差值结果 |
| 灵活性 | 极高。可移动任意步长、任意方向,并可自定义填充值。 | 较高。主要专注于差值计算,也可指定阶数。 |
| 便捷性 | 需要后续手动计算(如减法)才能得到差值。 | 开箱即用,一步得到差值结果。 |
| NaN出现位置 | periods>0:前periods行为NaN。periods<0:后` | periods |
| 典型应用场景 | 1. 需要错位数据进行复杂公式计算(如原始文章中的体积公式)。 2. 构建滞后特征或领先特征用于机器学习。 3. 自定义填充移动产生的空值。 | 1. 快速计算简单行间差值(涨跌幅、变化量)。 2. 计算高阶差值(如周环比、月环比)。 3. 代码简洁性优先的场景。 |
我的经验是:如果你只是单纯地计算当前行与前一行(或前N行)的差值,直接用diff(),代码更简洁,意图更明确。如果你需要进行更复杂的、不仅仅是减法的行间运算(比如涉及乘除、开方等复合公式),或者你需要显式地得到错位后的数据列用于其他用途,那么选择shift(),它会给你更大的操作空间。原始文章里那个包含平方根和求和的复杂体积公式,就必须用shift()来分别获取相邻行的数据才能完成计算。
3. 实战进阶:搞定NaN值与复杂公式计算
知道了怎么用,接下来就是实战中最常遇到的“坑”:NaN值处理和复杂计算。这也是原始文章花费大量篇幅在解决的问题。
3.1 处理NaN值的几种策略
无论是shift()还是diff(),都会不可避免地产生NaN。直接忽略它们可能会导致后续计算错误或图表异常。我有几种常用的处理方式:
1. 填充固定值(最常用)
对于差值计算,通常开头的NaN是因为没有“前一个值”可比较。一种合理的处理方式是将其填充为0,表示“初始变化为0”。
df['Change'] = df['Close'].diff().fillna(0)
或者,在金融领域,有时会填充为前向或后向的有效值,但填充0在业务解释上通常最直接。
2. 使用eval()进行原地计算与填充
原始文章最后提到了df.eval(),这是一个非常高效的方法,特别是列很多、公式复杂时。它允许你用字符串表达式进行列运算,并且可以结合fillna()。
# 使用 eval 计算差值并就地填充
df.eval('Change = Close - Close.shift(1)', inplace=True)
df['Change'].fillna(0, inplace=True)
# 或者更紧凑的,虽然eval本身不直接支持链式fillna,但可以这样:
df['Change'] = df.eval('Close - Close.shift(1)').fillna(0)
eval()的优点是对于特别复杂的多列表达式,其执行效率往往比普通的列运算更高,因为它进行了优化。
3. 删除NaN行
如果数据量很大,且开头的几行NaN对分析无关紧要,可以直接删除。
df['Change'] = df['Close'].diff()
df_clean = df.dropna(subset=['Change']) # 删除Change列为NaN的行
但要注意,这会改变数据的索引和长度。
3.2 拆解复杂公式:以体积计算为例
原始文章里那个体积计算公式 ws = 1/3 * (h2-h1)/1000 * (a1 + a2 + sqrt(a1*a2)) 是一个绝佳案例。它完美展示了为什么有时必须用shift()而不是diff()。
这个公式需要同时用到相邻两行的h(高)和a(面积)。diff()只能算(h2-h1),但算不了(a1+a2+sqrt(a1*a2))。这时就得请出shift()来分别获取“上一行”的数据。
我们来一步步拆解,假设DataFrame df有height和area两列:
import pandas as pd
import numpy as np # 通常用numpy的sqrt,它支持数组运算
# 1. 计算高度差 (h2 - h1), 注意这里h2是当前行,h1是上一行
height_diff = df['height'].diff() / 1000 # 原始文章中还除了1000
# 2. 计算面积项 a1 + a2 + sqrt(a1*a2)
# a1 是当前行面积, a2 是上一行面积(通过shift(1)得到)
a_current = df['area']
a_prev = df['area'].shift(1) # 获取上一行的面积
# 使用numpy进行向量化计算,效率远高于apply
sqrt_term = np.sqrt(a_current * a_prev) # sqrt(a1*a2)
sum_term = a_current + a_prev # (a1+a2)
area_component = sum_term + sqrt_term
# 3. 组合公式计算体积变化
df['volume_change'] = (1.0/3.0) * height_diff * area_component
# 4. 处理NaN值(由于shift和diff产生在首行)
df['volume_change'].fillna(0, inplace=True)
你看,通过shift(1),我们轻松地拿到了a_prev(即a1),从而能计算复杂的面积组合项。如果强行用diff(),这一步根本无法完成。
性能提示:原始文章最初使用了.apply(lambda x: math.sqrt(x)),这在数据量大时会比较慢。我强烈推荐换成np.sqrt(),它是针对整个数组(Series)进行计算的向量化操作,速度会快上几个数量级。这是Pandas高效编程的一个关键技巧:尽量用NumPy函数或Pandas内置的向量化方法,避免使用apply,尤其是循环。
3.3 多列协同的行间运算
行间运算不局限于单列。有时我们需要根据多列的组合来进行计算。例如,计算“本日营收”与“上一日成本”的比值。
df['revenue_to_prev_cost'] = df['revenue'] / df['cost'].shift(1)
又或者,像原始文章后面用eval()计算多列之和那样:
df.eval('Total = A + B + C', inplace=True)
# 如果想计算当前行Total与上一行Total的差值
df['Total_Change'] = df['Total'].diff()
eval()语法在这里写起来非常直观,接近数学公式。
4. 性能优化与高阶技巧:让你的代码飞起来
当数据量达到百万、千万级别时,行间运算的效率就成了必须考虑的问题。这里分享几个我踩过坑后总结的优化技巧。
4.1 向量化优先,避免循环与apply
这一点再怎么强调都不为过。Pandas和NumPy的底层是C语言实现的,向量化操作是一次性对整个数组进行计算。而Python层面的循环或apply是逐元素操作,速度慢得多。
反面教材(慢):
# 使用循环(极其不推荐!)
changes = []
for i in range(1, len(df)):
changes.append(df.loc[i, 'Close'] - df.loc[i-1, 'Close'])
df['Change'] = [np.nan] + changes
# 使用apply(仍然较慢)
df['Change'] = df['Close'].rolling(2).apply(lambda x: x.iloc[1] - x.iloc[0] if len(x)==2 else np.nan)
最佳实践(快):
# 使用diff()或shift()向量化操作
df['Change'] = df['Close'].diff() # 最快最简洁
# 或
df['Change'] = df['Close'] - df['Close'].shift(1)
对于复杂公式,如之前的体积计算,也尽量将np.sqrt、np.log等NumPy函数作用于整个Series,而不是在apply里调用math.sqrt。
4.2 使用eval()和query()提升效率
对于涉及多列的重度计算,df.eval()是一个性能宝藏。它使用字符串表达式,Pandas会尝试将其编译成更高效的底层代码。
# 假设要计算一个复杂的衍生指标
# 普通方法:
df['complex_indicator'] = df['A'] * 0.3 + df['B'].shift(1) * 0.5 - df['C'].diff().abs()
# 使用eval(通常更快,尤其在大数据框时):
expression = 'A * 0.3 + B.shift(1) * 0.5 - (C - C.shift(1)).abs()'
df['complex_indicator'] = pd.eval(expression)
注意,pd.eval()或df.eval()支持大部分Pandas运算,包括shift()和diff()。当公式非常长或计算非常频繁时,性能提升会很明显。
4.3 与cumsum()等累积函数联用
原始文章提到了cumsum(),它和diff()是一对“逆运算”。diff()求差值,cumsum()求累积和。这在处理“变化量”和“累计总量”时特别有用。
经典场景:从每日变化还原累计值
假设我们只有每日的销售额变化daily_change(可能由diff()计算得出),想还原出每天的累计销售额:
df['cumulative_sales'] = df['daily_change'].cumsum()
因为diff()在开头留下了NaN,所以cumsum()的结果也会从第一个有效值开始累积。通常我们需要先给daily_change的第一个NaN填充一个初始值(比如0或首日销售额)。
反过来,从累计值计算每日变化:
df['daily_change'] = df['cumulative_sales'].diff()
这种diff() -> 处理 -> cumsum()的链条,在数据清洗、重构时间序列数据时非常常见。
4.4 处理大数据集的内存与速度考量
当DataFrame非常大时,即使向量化操作也可能消耗大量内存。因为shift()和diff()都会生成一个与原始列大小相同的新Series。
- 适时释放内存:如果只是中间步骤,考虑使用
del删除不再需要的中间列,或者使用df.assign()链式操作而不创建太多中间变量。 - 考虑数据类型:确保数值列使用的是
np.float32或np.int32等节省内存的类型,而不是默认的np.float64/np.int64,如果精度允许的话。 - 分块处理:对于极端大的数据,可能需要使用
df.iterrows()或df.itertuples()进行分块计算,但这是最后的手段,因为会牺牲向量化的速度优势。优先考虑升级硬件或使用Dask等并行计算库。
5. 真实案例综合演练:从数据清洗到分析报告
让我们用一个模拟的电商每日销售数据,把前面所有的技巧串起来,完成一个从数据预处理、特征工程到简单分析的小项目。
场景:我们有一份粗糙的销售日志,需要计算每日销售额的环比增长率、7日移动平均销售额,并找出销售额波动最大的日期。
步骤1:准备数据
import pandas as pd
import numpy as np
# 生成模拟数据
np.random.seed(42)
dates = pd.date_range('2023-01-01', periods=100, freq='D')
# 模拟销售额,带有一些趋势和噪声
sales = 1000 + np.cumsum(np.random.randn(100) * 50) + np.arange(100) * 2
df = pd.DataFrame({'date': dates, 'daily_sales': sales})
print(df.head())
步骤2:计算核心指标
# 1. 计算日环比变化(今日/昨日 - 1),用shift获取昨日销售额
df['sales_prev_day'] = df['daily_sales'].shift(1)
df['day_over_day_growth'] = (df['daily_sales'] / df['sales_prev_day']) - 1
# 2. 计算7日移动平均,这里用rolling窗口,但原理涉及“窗口”内的行间关系
df['sales_ma_7d'] = df['daily_sales'].rolling(window=7, min_periods=1).mean()
# 3. 计算销售额的日绝对变化(用diff最简单)
df['sales_change_abs'] = df['daily_sales'].diff()
# 4. 计算销售额的日变化百分比(同样用diff)
df['sales_change_pct'] = df['daily_sales'].pct_change() # pct_change()就是 (diff() / shift()),专门算百分比变化的
# 处理首行的NaN
df[['day_over_day_growth', 'sales_change_abs', 'sales_change_pct']] = df[['day_over_day_growth', 'sales_change_abs', 'sales_change_pct']].fillna(0)
# 移动平均第一行就是它自己,不用处理NaN
步骤3:分析与洞察
# 找出销售额增长最快和下降最快的一天
max_growth_day = df.loc[df['sales_change_pct'].idxmax(), 'date']
max_drop_day = df.loc[df['sales_change_pct'].idxmin(), 'date']
print(f"销售额增长最快的一天是:{max_growth_day.date()},增长率:{df['sales_change_pct'].max():.2%}")
print(f"销售额下降最快的一天是:{max_drop_day.date()},下降率:{df['sales_change_pct'].min():.2%}")
# 找出销售额绝对变化最大的一天
max_change_day = df.loc[df['sales_change_abs'].abs().idxmax(), 'date']
print(f"销售额波动(绝对值)最大的一天是:{max_change_day.date()},变化额:{df['sales_change_abs'].abs().max():.2f}")
步骤4:可视化(可选,但很直观)
import matplotlib.pyplot as plt
plt.figure(figsize=(12, 8))
plt.subplot(2, 1, 1)
plt.plot(df['date'], df['daily_sales'], label='Daily Sales')
plt.plot(df['date'], df['sales_ma_7d'], label='7D Moving Avg', color='orange', linewidth=2)
plt.title('Daily Sales and Trend')
plt.legend()
plt.subplot(2, 1, 2)
plt.bar(df['date'], df['sales_change_pct'] * 100, color='green', alpha=0.6)
plt.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
plt.title('Daily Sales Change Percentage (%)')
plt.tight_layout()
plt.show()
通过这个案例,你可以看到shift()、diff()以及相关的pct_change()、rolling()如何协同工作,将原始的日销售数据,转化出多个有业务洞察力的指标。整个过程没有使用任何显式循环,代码清晰且高效。记住,熟练运用这些行间运算函数,是成为Pandas数据处理高手的关键一步。刚开始可能需要多想一下该用shift还是diff,但多写几次,这种向量化的思维方式就会成为你的本能。
152



被折叠的 条评论
为什么被折叠?



