4、因子计算框架:使用Pandas进行向量化计算,避免循环

做因子挖掘,最核心的痛点是啥?

速度。说白了就是计算效率。

我刚入行那会儿,写过一段纯Python循环算因子的代码。跑一个简单的5日移动平均,3000只股票,回测3年数据,愣是跑了快两个小时。我当时还觉得挺正常,直到被组长叫去谈话:「你这代码,跑完一轮,隔壁量化组的午饭都吃完了。」

从那以后,我彻底明白了——在因子计算的世界里,循环就是敌人,向量化才是朋友

4.1 为什么循环是性能杀手?

Python的循环慢,不是因为你写得不好。而是因为Python解释器每执行一行循环体,都要做类型检查、内存分配、引用计数更新……这些开销在单次操作中微乎其微,但乘以几百万、几千万次,就成了灾难。

举个例子,计算一个简单的滚动均值:

# 慢到想哭的写法
def slow_rolling_mean(series, window=5):
    result = []
    for i in range(len(series)):
        if i < window - 1:
            result.append(np.nan)
        else:
            result.append(np.mean(series[i-window+1:i+1]))
    return result

这段代码,逻辑上完全正确。但如果你对全市场5000只股票、每只1000个交易日的数据跑一遍……嗯,我建议你先泡杯咖啡,然后去睡一觉。

核心认知: 循环让CPU在Python解释器和底层C库之间反复横跳,每次跳转都有开销。向量化则是一次性把整个数组交给底层C/Fortran库处理,中间没有解释器干预。

4.2 Pandas向量化:一行代码顶一万行循环

Pandas的向量化操作,底层用的是NumPy的C语言实现。你写一行 df['close'].rolling(5).mean(),背后是高度优化的C代码在跑。

同样的滚动均值,向量化写法:

# 快到飞起的写法
fast_result = df['close'].rolling(window=5).mean()

就这一行。我实测过,同样100万行数据,循环版本耗时约47秒,向量化版本0.03秒。差了三个数量级。你想想看,这差距意味着什么?意味着别人还在等结果的时候,你已经迭代了三个因子思路了。

4.3 常用向量化操作速查

我个人习惯把常用的向量化操作整理成一张速查表,方便团队新人快速上手。这里分享给你:

操作类型 循环写法(不推荐) 向量化写法(推荐)
滚动均值 for i in range(n): mean(data[i-w:i]) series.rolling(w).mean()
滚动标准差 for i in range(n): std(data[i-w:i]) series.rolling(w).std()
条件赋值 if cond: result[i]=a else: result[i]=b np.where(cond, a, b)
分组计算 for stock in stocks: ... df.groupby('stock').transform(...)
滞后/移位 result[i] = data[i-1] series.shift(1)
累计求和 for i in range(n): s += data[i] series.cumsum()
我的小技巧: 每次写因子计算前,先问自己三个问题——「这个操作能不能用rolling?能不能用groupby?能不能用shift?」如果三个都是否,再考虑写循环。大多数情况下,至少有一个答案是肯定的。

4.4 实战:用向量化构建一个动量因子

光说不练假把式。我们拿一个真实的因子来练手——动量因子,也就是过去N天的累计收益率。

先看循环版本:

def momentum_loop(df, window=20):
    # df是包含多只股票的DataFrame,已按股票和时间排序
    stocks = df['stock'].unique()
    result = []
    for stock in stocks:
        sub = df[df['stock'] == stock].copy()
        prices = sub['close'].values
        for i in range(len(prices)):
            if i < window:
                result.append(np.nan)
            else:
                ret = (prices[i] - prices[i-window]) / prices[i-window]
                result.append(ret)
    df['momentum'] = result
    return df

这段代码,我当年写的时候觉得挺优雅。直到数据量上来,才发现它有多笨重——外层循环遍历股票,内层循环遍历时间,双重循环下来,复杂度是O(N×M)。

再看向量化版本:

def momentum_vectorized(df, window=20):
    # 按股票分组,每组内计算向量化动量
    df['momentum'] = (
        df.groupby('stock')['close']
        .transform(lambda x: x.pct_change(window))
    )
    return df

就一行。真的就一行。

groupby('stock') 把数据按股票切分成组,transform 对每组独立应用 pct_change(window),后者直接计算 (当前价格 - N天前价格) / N天前价格。整个过程全部在C层面完成。

性能对比(实测数据):
数据量:5000只股票 × 1000个交易日 = 500万行
循环版本:约 3 分 42 秒
向量化版本:约 0.8 秒
提速:约 277 倍

4.5 避坑指南:向量化不是万能药

我曾经踩过一个坑,觉得所有操作都能向量化。结果遇到一个需要逐行判断「如果当前价格突破过去20日最高价,则标记为1,否则为0」的逻辑。我硬是用 rollingshift 拼凑了一个极其复杂的表达式,跑出来结果还是错的。

后来我学乖了——有些逻辑确实不适合纯向量化。比如:

  • 需要逐行依赖前一行计算结果的(如递归计算)
  • 条件分支极其复杂且依赖历史状态的
  • 需要跨多列进行非线性组合的

遇到这些情况,我的建议是:先用向量化处理80%的数据,剩下20%用 applynumba 加速。别为了追求纯向量化而写出难以维护的「天书代码」。

注意: apply 虽然比纯循环快,但本质上还是逐行调用Python函数。如果数据量极大,建议用 numbacython 编译加速。这部分我们后面的章节会详细讲。

4.6 本章知识体系

下面这张图,是我自己总结的因子计算框架决策流程。每次写新因子前,我都会过一遍这个流程:

因子计算框架决策流程 开始写因子计算 能否用rolling/ groupby/shift? 直接用Pandas 向量化函数 是否依赖 历史状态? 用numba/cython 加速循环 用apply 逐行处理 完成因子计算

这个流程的核心逻辑很简单:优先向量化,不行再降级。80%的因子都可以用纯向量化搞定,剩下20%用apply或numba。别一上来就写循环,也别为了向量化而写出没人能看懂的代码。

4.7 本章小结

嗯,我们来捋一下这章的核心要点:

  • 循环是性能杀手,Python解释器的开销在数据量大时会被放大到不可接受
  • Pandas向量化操作底层是C语言实现,速度比循环快几百倍
  • 常用武器rollinggroupbyshiftpct_changecumsumnp.where
  • 不是所有问题都能向量化,遇到递归依赖或复杂条件分支,可以降级使用apply或numba
  • 先想再写:每次写因子前,先问自己三个问题,能向量化就别写循环

我记得有一次,团队里新来的同事用循环写了一个波动率因子,跑了一个多小时没出结果。我过去帮他改成向量化版本,30秒跑完。他当时看我的眼神,就像看到了魔术师。其实哪有什么魔术,不过是选对了工具而已。

下一章,我们会深入讲因子去噪与标准化——因子算出来了,但里面全是噪声和异常值,怎么处理?到时候见。


蓝海数据掘金营,专注资料整理