1. 从“看单点”到“看趋势”:为什么我们需要滑动窗口

如果你处理过时间序列数据,比如股票价格、传感器读数、或者网站每天的访问量,你肯定遇到过这样的需求:想知道过去7天的平均销售额,或者计算最近5分钟内的温度移动平均值。这时候,如果你还在用循环去一个个切片、计算,那效率就太低了,代码也会变得臃肿不堪。

pandas 库里的 rolling() 函数,就是专门为解决这类“滑动窗口计算”而生的利器。它的核心思想非常直观:想象一个固定宽度的“窗口”,沿着你的数据(通常是按时间或索引顺序排列的)一步步滑动。在每一个停留的位置,窗口都框住一部分数据,然后对这个窗口内的数据进行某种聚合计算(比如求和、求平均、求标准差)。这个计算的结果,就作为新序列中对应位置的值。

举个例子,你有一列每日销售额 [100, 200, 150, 300, 250] ,你想计算3天的移动平均。那么 rolling(window=3) 就会创建宽度为3的窗口:

  • 窗口覆盖第1、2、3天时,计算 (100+200+150)/3 = 150 ,这个值放在结果序列的第3天位置。
  • 窗口滑动到覆盖第2、3、4天时,计算 (200+150+300)/3 ≈ 216.67 ,放在第4天位置。
  • 窗口继续滑动……

最终你得到的是一个平滑后的序列,能更好地反映趋势,而不是被某一天的异常值所干扰。这比单纯看每一天的孤立数字要有用得多。无论是金融领域的技术指标(如布林带、MACD),还是物联网中的异常检测,或是业务分析中的周期对比,滑动窗口都是基础中的基础。接下来,我会带你从最基础的用法开始,把 rolling() 这个工具彻底搞明白,让你在数据处理时能信手拈来。

2. 核心参数拆解:window、min_periods与center

要玩转 rolling() ,首先得理解它的几个核心参数。这些参数决定了窗口如何滑动、如何计算,是精准控制计算行为的关键。

2.1 window:窗口的大小与灵活性

window 参数是最核心的,它定义了窗口的大小。它可以是整数,也可以是偏移量字符串,这给了我们很大的灵活性。

整数窗口 :这是最常用的形式,表示窗口包含固定数量的观测值。

import pandas as pd
import numpy as np

# 创建一个简单的Series
s = pd.Series([1, 2, 3, 4, 5])
# 窗口大小为3
rolling_obj = s.rolling(window=3)
print(rolling_obj.mean())

输出会是:

0    NaN
1    NaN
2    2.0
3    3.0
4    4.0
dtype: float64

注意到前两个位置(索引0和1)的结果是 NaN 。这是因为当窗口起始位置在数据开头时,无法凑满3个值。这是默认行为,后面我们会用 min_periods 来控制它。

偏移量窗口(针对时间序列) :当你的数据索引是时间类型( DatetimeIndex , TimedeltaIndex 等)时, window 可以用字符串指定一个时间周期,比如 ‘3D’ (3天)、 ‘2H’ (2小时)、 ‘30T’ (30分钟)。这时,窗口大小不再是固定的行数,而是固定的时间跨度。 pandas 会自动根据索引的时间间隔来动态决定每个窗口包含哪些数据点,这对于不规则时间序列(比如股票tick数据)特别有用。

# 创建一个带有时间索引的Series
date_rng = pd.date_range(start='2023-01-01', end='2023-01-10', freq='D')
ts = pd.Series(range(10), index=date_rng)
# 计算3天的移动平均
rolling_mean = ts.rolling(window='3D').mean()
print(rolling_mean.head())

在这个例子中,每个窗口都会包含索引时间在窗口开始时间点之后3天内的所有数据行。

2.2 min_periods:处理窗口初期的计算规则

min_periods 参数解决了我们上面看到的 NaN 问题。它定义了窗口内至少需要有多少个非 NaN 值,才进行计算并输出结果;否则,结果就是 NaN 。它的默认值等于 window 参数。

  • 默认情况 min_periods = window 。这意味着窗口必须被完全填满(即窗口内所有位置都有数据)才进行计算。这就是为什么在序列开头会有 NaN
  • 自定义 min_periods :你可以将其设置为一个更小的数。例如, rolling(window=3, min_periods=1) 。这意味着只要窗口内有至少1个值,就进行计算。
    s = pd.Series([1, 2, 3, 4, 5])
    print(s.rolling(window=3, min_periods=1).mean())
    
    输出:
    0    1.0  # 窗口[1],有1个值,平均值是1
    1    1.5  # 窗口[1,2],平均值(1+2)/2=1.5
    2    2.0  # 窗口[1,2,3],平均值2.0
    3    3.0  # 窗口[2,3,4],平均值3.0
    4    4.0  # 窗口[3,4,5],平均值4.0
    dtype: float64
    
    这样,序列开头也能得到有意义的计算结果,非常适合你希望从第一个数据点就开始观察趋势的场景。

注意 min_periods 的设置需要权衡。设为1虽然能获得更多结果,但在窗口初期,由于数据点少,计算出的统计量(如标准差)可能波动很大,不够稳定。根据你的分析目的选择合适的值。

2.3 center:结果的对齐方式

center 参数决定了计算出的结果值应该对齐到窗口的哪个位置。默认是 False

  • center=False (默认):结果对齐到窗口的 右边缘 。也就是说,用窗口内数据计算出的值,会被放在这组数据中 最后一个 数据点所在的位置。我们之前的例子都是这种模式。这很符合直觉:“过去3天的平均值”这个标签,理应贴在第3天上。
  • center=True :结果对齐到窗口的 中心 。计算出的值会被放在窗口覆盖范围的中间位置。这对于平滑数据、进行可视化尤其有用,可以避免相位滞后。
    s = pd.Series([1, 2, 3, 4, 5, 6])
    print(“右对齐 (center=False):”)
    print(s.rolling(window=3).mean())
    print(“\n中心对齐 (center=True):”)
    print(s.rolling(window=3, center=True).mean())
    
    输出:
    右对齐 (center=False):
    0    NaN
    1    NaN
    2    2.0  # 对应窗口[1,2,3]
    3    3.0  # 对应窗口[2,3,4]
    4    4.0  # 对应窗口[3,4,5]
    5    5.0  # 对应窗口[4,5,6]
    dtype: float64
    
    中心对齐 (center=True):
    0    NaN
    1    2.0  # 对应窗口[0,1,2] -> 中心是索引1
    2    3.0  # 对应窗口[1,2,3] -> 中心是索引2
    3    4.0  # 对应窗口[2,3,4] -> 中心是索引3
    4    5.0  # 对应窗口[3,4,5] -> 中心是索引4
    5    NaN
    dtype: float64
    
    可以看到,中心对齐后,序列两端的值变成了 NaN ,因为中心对齐的窗口在两端无法完整居中。但中间部分的结果值的位置发生了偏移,在平滑曲线时,这个曲线不会“落后”于原始数据。

3. 不只是求平均:丰富的内置聚合函数

调用 rolling() 会返回一个 Rolling 对象。这个对象本身并不计算,它像一个“计算蓝图”。你需要在这个对象上调用聚合方法,才能真正触发计算。 pandas 提供了几乎所有常见的统计函数。

3.1 基础统计函数

这些是最常用的,名字一看就懂:

  • .mean() : 平均值
  • .sum() : 求和
  • .std() : 标准差(默认分母是 n-1,即样本标准差)
  • .var() : 方差
  • .min() : 最小值
  • .max() : 最大值
  • .median() : 中位数
  • .quantile(q) : 分位数(需传入参数q,如0.5代表中位数)
df = pd.DataFrame({‘A’: [1, 2, 3, 4, 5],
                   ‘B’: [10, 20, 30, 40, 50]})
# 计算窗口为3的各种统计量
print(“移动和:\n”, df[‘A’].rolling(3).sum())
print(“\n移动标准差:\n”, df[‘A’].rolling(3).std())
print(“\n移动中位数:\n”, df[‘A’].rolling(3).median())

3.2 进阶与计数函数

  • .count() : 计算窗口内非 NaN 值的数量。这个函数不受 min_periods 影响,它总是返回实际的数量。
  • .cov(other) / .corr(other) : 计算当前序列与另一个序列 ( other ) 在滚动窗口内的协方差或相关系数。这在分析两个时间序列的联动关系时非常有用。
  • .apply(func, raw=False) : 万能方法,允许你传入一个自定义函数 func 来对窗口数据进行操作。 raw=True 时, func 接收一个 numpy 数组; raw=False 时,接收一个 pandas Series

3.3 在DataFrame上的应用

rolling() 可以应用于整个 DataFrame ,此时会对每一列 独立 进行滑动窗口计算。

df = pd.DataFrame(np.random.randn(10, 3), columns=[‘X’, ‘Y’, ‘Z’])
# 计算每列过去3行的移动平均
df_rolling_mean = df.rolling(window=3).mean()
print(df_rolling_mean.head())

这非常方便,一次操作就能得到所有指标的平滑序列。

实操心得 :虽然可以对整个 DataFrame 应用 rolling() ,但有时我们只关心某几列。直接对 DataFrame 切片后的列进行操作(如 df[[‘col1‘, ‘col2‘]].rolling(5).mean() )在性能上通常更好,代码意图也更清晰。

4. 实战演练:从数据清洗到特征工程

理解了基本用法,我们来看看 rolling() 在实际数据分析流水线中能扮演什么角色。它绝不仅仅是求个移动平均那么简单。

4.1 场景一:平滑数据与去除噪声

这是最经典的应用。传感器数据、用户活跃度、股价等都含有噪声。一个适中的移动平均可以有效地平滑曲线,让我们看清主要趋势。

# 假设我们有一组带有噪声的模拟温度数据
np.random.seed(42)
time = pd.date_range(‘2023-06-01’, periods=100, freq=‘H’)
temperature = 20 + 5 * np.sin(2 * np.pi * np.arange(100) / 24) + np.random.normal(0, 1, 100)
ts_temp = pd.Series(temperature, index=time)

# 计算6小时移动平均(平滑短期波动)
ts_smoothed = ts_temp.rolling(window=‘6H’, min_periods=1).mean()

# 可视化对比
import matplotlib.pyplot as plt
plt.figure(figsize=(12, 6))
plt.plot(ts_temp.index, ts_temp, label=‘原始数据 (带噪声)’, alpha=0.6)
plt.plot(ts_smoothed.index, ts_smoothed, label=‘6小时移动平均’, linewidth=2, color=‘red’)
plt.legend()
plt.title(‘滑动窗口平滑数据示例’)
plt.show()

通过调整 window 大小,你可以控制平滑的程度。窗口越大,曲线越平滑,但对快速变化的反应也越迟钝。

4.2 场景二:计算滚动统计量作为新特征

在机器学习中,我们经常需要基于历史行为创造新的特征。 rolling() 是特征工程的利器。

# 模拟用户每日交易金额
dates = pd.date_range(‘2023-01-01’, ‘2023-01-31’, freq=‘D’)
np.random.seed(123)
transactions = np.random.randint(50, 500, size=len(dates))
df_user = pd.DataFrame({‘date’: dates, ‘amount’: transactions}).set_index(‘date’)

# 创建滚动特征
df_user[‘rolling_3d_mean’] = df_user[‘amount’].rolling(window=3, min_periods=1).mean()
df_user[‘rolling_7d_std’] = df_user[‘amount’].rolling(window=7, min_periods=1).std() # 波动性
df_user[‘rolling_7d_sum’] = df_user[‘amount’].rolling(window=7, min_periods=1).sum() # 周累计
df_user[‘amount_ratio_to_7d_avg’] = df_user[‘amount’] / df_user[‘rolling_7d_mean’] # 与近期均值的比值

print(df_user.head(10))

这样,我们就为每一天的数据,都附上了“近期平均消费水平”、“消费波动情况”、“本周累计消费”等动态特征,这些特征往往比单纯的当日值更有预测力。

4.3 场景三:检测异常值

通过计算滚动均值与标准差,我们可以定义数据的“正常范围”,并将超出此范围的点视为潜在异常。

# 继续使用上面的温度数据
ts_temp = ts_temp.copy() # 避免修改原数据
# 计算滚动统计:过去24小时的均值和标准差
rolling_mean = ts_temp.rolling(window=‘24H’, min_periods=12).mean()
rolling_std = ts_temp.rolling(window=‘24H’, min_periods=12).std()

# 定义异常:偏离滚动均值超过3个滚动标准差
threshold = 3
ts_temp[‘upper_bound’] = rolling_mean + threshold * rolling_std
ts_temp[‘lower_bound’] = rolling_mean - threshold * rolling_std
ts_temp[‘is_outlier’] = (ts_temp[‘temperature’] > ts_temp[‘upper_bound’]) | (ts_temp[‘temperature’] < ts_temp[‘lower_bound’])

# 查看异常点
outliers = ts_temp[ts_temp[‘is_outlier’]]
print(f”检测到 {len(outliers)} 个潜在异常点”)
print(outliers[[‘temperature’, ‘upper_bound’, ‘lower_bound’]].head())

这种方法比用全局均值和标准差更合理,因为它考虑了数据的局部特征,能适应趋势和季节性的变化。

5. 避坑指南与性能优化

rolling() 用起来顺手,但也有一些细节不注意就会踩坑。下面是我在实际项目中总结的几个关键点。

5.1 缺失值(NaN)的连锁反应

rolling() 函数在计算时,默认会忽略窗口内的 NaN 值。但是, NaN 的存在会影响窗口内有效数据的数量,进而可能因为不满足 min_periods 而导致输出结果也为 NaN

常见坑点 :你的原始数据中间有缺失值,你用 rolling(window=5).mean() 想计算移动平均。在缺失值附近,即使时间窗口跨度够,但因为有效数据点不足5个,结果还是会变成 NaN ,这个 NaN 会随着计算向后传播。

s = pd.Series([1, 2, np.nan, 4, 5, 6, 7])
print(s.rolling(window=3).mean())

输出:

0    NaN
1    NaN
2    NaN  # 窗口[2, NaN, 4],有效点不足3个(默认min_periods=3)
3    NaN  # 窗口[NaN, 4, 5],有效点不足3个
4    5.0  # 窗口[4, 5, 6],平均值5.0
5    6.0  # 窗口[5, 6, 7],平均值6.0
dtype: float64

解决方案

  1. 数据预处理 :在应用 rolling() 之前,先处理缺失值。根据业务场景,可以用前向填充( .ffill() )、后向填充( .bfill() )或插值( .interpolate() )等方法。
  2. 调整 min_periods :如果你能接受在数据缺失时使用更少的数据点进行计算,可以设置 min_periods=1 。但需要明白这可能会在数据稀疏处引入噪声。
  3. 使用 .apply() 自定义 :在自定义函数里处理 NaN

5.2 时间序列索引与频率陷阱

当使用偏移量字符串(如 ‘3D’ )作为 window 时,前提是你的数据索引必须是时间类型,并且最好是 规则频率 的。如果索引不规则, pandas 仍然会计算,但你需要理解其行为:它会根据每个时间点,回溯 window 指定的时间长度,将落在这个时间区间内的所有点纳入窗口。这可能导致不同窗口包含的数据点数量差异很大。

建议 :对于不规则时间序列,使用偏移量窗口是合适的。但对于规则时间序列,使用整数窗口( window=3 )和偏移量窗口( window=‘3D’ )在数据点均匀的情况下结果一致,但整数窗口通常计算更快。

5.3 性能考量:向量化操作与避免循环

rolling().mean() , rolling().sum() 等内置聚合函数是高度优化的向量化操作,速度非常快。 绝对不要 自己写循环去实现滑动窗口计算。

性能瓶颈往往出现在使用 .apply() .apply(func) 会对每个窗口调用一次Python函数,如果数据量很大(几十万、百万行),这会非常慢。

# 慢:使用apply计算自定义的加权平均
def weighted_mean(series):
    weights = np.arange(1, len(series)+1) # 线性权重
    return np.average(series, weights=weights)

# 对于大数据量,这行会很慢
result_slow = large_series.rolling(10).apply(weighted_mean, raw=False)

优化策略

  1. 优先使用内置函数 :几乎所有常见统计都有内置函数,它们底层是C/C++/Cython实现的。
  2. 如果必须用 .apply()
    • 设置 raw=True 。这会将窗口数据作为 numpy 数组传递给函数,避免了 pandas Series 的构造开销,通常能快不少。
    • 审视你的自定义函数,看能否用 numpy 的向量化函数重写。
  3. 对于超大数据 :考虑使用更专业的库如 numba 来加速,或者将数据分块处理。

5.4 窗口类型扩展:expanding() 与 ewm()

rolling() 的兄弟函数也值得了解,它们解决了稍有不同的需求。

  • expanding() :扩展窗口。窗口从时间序列的起点开始,一直扩展到当前行。它计算的是“截至当前”的所有历史数据的聚合值(如累计和、累计平均)。 df.expanding().mean() 就等价于 df.cumsum() / (np.arange(len(df)) + 1)

    s = pd.Series([1, 2, 3, 4, 5])
    print(s.expanding().mean()) # 输出:1.0, 1.5, 2.0, 2.5, 3.0
    
  • .ewm() :指数加权移动。它给窗口内的数据赋予指数衰减的权重,越近的数据权重越高。它不像 rolling() 有一个清晰的窗口边界,而是用一个衰减因子( alpha span halflife )来控制“记忆”的长度。 ewm 对最近的变化更敏感,常用于金融分析。

    # span参数大致相当于平均窗口大小
    ewm_mean = s.ewm(span=3, adjust=False).mean()
    print(ewm_mean)
    

    选择 rolling 还是 ewm ,取决于你是想要一个绝对公平的近期窗口( rolling ),还是认为近期的数据理应拥有更大的话语权( ewm )。

6. 综合案例:分析股票价格波动

让我们用一个更综合的例子,把前面讲的知识点串起来。假设我们有一支股票的日度收盘价数据,我们想计算几个常见的技术指标。

# 1. 准备数据(这里用模拟数据)
np.random.seed(2024)
dates = pd.date_range(‘2023-01-01’, ‘2023-12-31’, freq=‘B’) # 工作日
price = 100 + np.cumsum(np.random.randn(len(dates)) * 0.5) # 随机游走模拟股价
df_stock = pd.DataFrame({‘Close’: price}, index=dates)

# 2. 计算简单移动平均线 (SMA)
df_stock[‘SMA_10’] = df_stock[‘Close’].rolling(window=10, min_periods=5).mean()
df_stock[‘SMA_30’] = df_stock[‘Close’].rolling(window=30, min_periods=15).mean()

# 3. 计算布林带 (Bollinger Bands)
window_bb = 20
df_stock[‘SMA_20’] = df_stock[‘Close’].rolling(window=window_bb).mean()
df_stock[‘STD_20’] = df_stock[‘Close’].rolling(window=window_bb).std()
df_stock[‘Upper_Band’] = df_stock[‘SMA_20’] + 2 * df_stock[‘STD_20’]
df_stock[‘Lower_Band’] = df_stock[‘SMA_20’] - 2 * df_stock[‘STD_20’]

# 4. 计算每日收益率和滚动波动率(年化)
df_stock[‘Daily_Return’] = df_stock[‘Close’].pct_change()
df_stock[‘Volatility_30d’] = df_stock[‘Daily_Return’].rolling(window=30).std() * np.sqrt(252) # 年化

# 5. 识别价格突破布林带上轨的事件(潜在超买信号)
df_stock[‘Break_Above_Upper’] = df_stock[‘Close’] > df_stock[‘Upper_Band’]

# 查看结果
print(df_stock[[‘Close’, ‘SMA_10’, ‘SMA_30’, ‘Upper_Band’, ‘Lower_Band’, ‘Volatility_30d’, ‘Break_Above_Upper’]].tail())

# 6. 简单可视化
import matplotlib.pyplot as plt
plt.figure(figsize=(14, 8))
plt.plot(df_stock.index, df_stock[‘Close’], label=‘Close Price’, alpha=0.7, linewidth=1)
plt.plot(df_stock.index, df_stock[‘SMA_10’], label=‘SMA 10’, alpha=0.8)
plt.plot(df_stock.index, df_stock[‘SMA_30’], label=‘SMA 30’, alpha=0.8)
plt.plot(df_stock.index, df_stock[‘Upper_Band’], label=‘Upper Bollinger Band’, linestyle=‘--’, alpha=0.6, color=‘gray’)
plt.plot(df_stock.index, df_stock[‘Lower_Band’], label=‘Lower Bollinger Band’, linestyle=‘--’, alpha=0.6, color=‘gray’)
plt.fill_between(df_stock.index, df_stock[‘Lower_Band’], df_stock[‘Upper_Band’], color=‘gray’, alpha=0.1)
plt.scatter(df_stock.index[df_stock[‘Break_Above_Upper’]], df_stock[‘Close’][df_stock[‘Break_Above_Upper’]], color=‘red’, s=20, label=‘Break Above Upper’, zorder=5)
plt.legend()
plt.title(‘Stock Price Analysis with Rolling Statistics’)
plt.show()

通过这个案例,你可以看到,仅仅使用 rolling() 函数及其衍生出的几个滚动统计量,我们就从原始的价格序列中,构建出了趋势线(SMA)、波动通道(布林带)、风险指标(波动率)和交易信号(突破事件)。这就是滑动窗口分析在实战中的强大之处——它将静态的数据点,转化为了动态的、富含信息的特征序列。

最后再分享一个小技巧:当你对一组数据应用了复杂的 rolling() 计算链后,如果后续需要多次使用这个结果,记得用 .copy() 将其保存到一个新变量,或者用 .assign() 直接赋值给原 DataFrame 的新列。避免重复计算,尤其是在数据量大的时候,这是一个很好的习惯。 rolling() 是一个惰性操作,每次调用 .mean() .std() 都会重新计算一次。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐