IV

IV:Python 与 C++ 算得不一样的地方

研究侧 pandas / NumPy 写的因子搬到 C++ 后对不上,常见原因有哪些:求和顺序、NaN、ddof、rolling、ewm、取整、整数除法、溢出、类型、排名、时间切分、编译选项;以及对不上时怎么按差值的样子定位。

本页目录
  • 研究和实盘要逐值对得上:研究员给的是 pandas 写的因子,实盘用 C++ 重写,同一份行情喂进去,两边每个值都要一样(或在约定的容差内)。
  • 对不上多半不是因子逻辑写错了:更常见的是两边的默认行为不同。比如 pandas 默认跳过 NaN,C++ 的算术不会;pandas 的 std 默认除以 n − 1,NumPy 默认除以 n。
  • 每一条都回答两个问题:两边默认各怎么做,C++ 怎么写才能和研究侧对上。

1. 数值本身:浮点加法不满足结合律

1.1 求和顺序

  1. 为什么加的顺序会改结果

    • 每做一次加法都要舍入一次:所以 $(a+b)+c$ 和 $a+(b+c)$ 的末位可能不同。
    • 顺序一变,末几位就跟着变:同样一列数,换个顺序加,结果的最后几位就可能不一样。
  2. 两边各是怎么加的

    怎么加
    NumPy np.sum成对求和:分块两两相加,误差比顺序累加小
    pandas rolling(...).sum() / .mean()窗口滑动时加新减旧,带误差补偿(Kahan 求和)
    C++ 手写循环从头到尾顺序累加;滚动窗口常写成 sum += x_new - x_old
    • 一次性求和通常只差末一两位:C++ 用一个循环从头加到尾,和 np.sum 的成对求和比,一般只在最后一两位上不同。
    • 滚动窗口的误差会越积越大:C++ 加新减旧时没有补偿,跑上一整天,误差一直累积,后面的值可能差得比末位还多。
  3. C++ 怎么和研究侧对上

    • 先和研究侧定好标准:是在容差内一致就行,还是要求逐位一致。这一步要先定,后面的写法都取决于它。
    • 容差内一致就按相对误差比:例如 $|a-b| \le 10^{-9}\max(|a|,|b|)$ 就算对上。
    • 逐位一致就得按同样的顺序加:一次性求和照成对求和的分块方式写;滚动窗口也加上 Kahan 补偿,或者每隔一段从头重算一次窗口和,把累积的误差清掉。

1.2 数学函数

  • exp、log、pow 的实现两边可能不同:NumPy 在支持 AVX-512 的机器上用自己的向量化实现,C++ 调的是 glibc 的实现,结果可能差一两个末位单位(ulp)。
  • 只有要求逐位一致时才看得到:差的量级很小,按容差比的话不会被发现。
  • 先查两边是不是同一台机器、同一个库:机器或库版本不同,这一类差别就可能冒出来。

2. 缺失值和窗口

2.1 NaN

  1. 两边默认怎么处理 NaN

    默认行为
    pandas sum / mean跳过 NaN(skipna=True);全是 NaN 时,sum 得 0,mean 得 NaN
    NumPy np.sum / np.meanNaN 会传染,结果是 NaN;要跳过得用 np.nansum / np.nanmean
    C++ 算术和 NumPy 一样,NaN 一路传下去
    C++ std::max(a, b)结果取决于参数顺序:std::max(NaN, 1.0) 得 NaN,std::max(1.0, NaN) 得 1.0
    • 例子:[1, NaN, 3] 求平均,pandas 跳过 NaN 得 $(1+3)/2=2$;C++ 直接 sum / n 得 NaN;跳过了 NaN 却仍除以 3,得 $4/3$。
  2. std::max 的结果为什么看参数顺序

    • 它只比一次大小:std::max(a, b) 只做一次 a < b,成立就返回 b,否则返回 a。
    • 和 NaN 比较永远是 false:所以只要有一个参数是 NaN,a < b 就不成立,它总是返回第一个参数。
    • 想忽略 NaN 就用 std::fmax:它碰到一个 NaN 时会返回另一个数。
  3. C++ 怎么对上 pandas

    • 显式写逻辑跳过 NaN:累加之前先判断 std::isnan(x),是 NaN 就不加。
    • 单独记下非 NaN 的个数:平均值除以这个个数,不除以窗口长度,否则就是上面 $4/3$ 那种错法。
    • 全是 NaN 时照 pandas 的约定输出:sum 给 0,mean 给 NaN。
    • C++ 的算术不会替你跳过 NaN,要对上 pandas,每一处都得显式写出来。

2.2 ddof:方差的分母

默认分母
pandas std / var,包括 rolling(...).std()n − 1(ddof=1,样本标准差)
NumPy np.std / np.varn(ddof=0)
  • 研究员用的是除以 n − 1 的那个:研究员在 pandas 里调 std,默认得到的就是样本标准差。
  • 例子:[1, 2, 3] 的均值是 2,偏差平方和是 2,除以 3 得方差 0.667,除以 2 得 1。
  • 除错了分母,每个值都偏同一个比例:C++ 照教科书公式除以 n,窗口 20 时标准差差 $\sqrt{20/19}$ 倍,约 2.6%。差值是固定比例,一眼就能认出是 ddof。

2.3 rolling 的前几个值

  1. pandas 窗口没攒满时输出 NaN

    • 默认 min_periods=n:rolling(n) 的窗口里凑不满 n 个非 NaN 的值,就输出 NaN。
    • 所以开头前 n − 1 个都是 NaN:例如 rolling(3).mean() 作用在 [1, 2, 3, 4] 上,输出是 [NaN, NaN, 2, 3]。
    • 中间夹一个 NaN 也一样:这个 NaN 留在窗口里的 n 步,输出全是 NaN。
    • 按时间开的窗口规则不同:rolling("5min") 这类默认 min_periods=1,有一个值就出数。
  2. C++ 怎么对上

    • 自然写法从第一条就出值:同样的输入,C++ 会输出 [1, 1.5, 2, 3],前 n − 1 个对不上,窗口里有 NaN 时也对不上。
    • C++ 要维护窗口里非 NaN 的个数,个数不够 n 就输出 NaN。

2.4 ewm 的 adjust

  1. ewm 是什么,因子里为什么常用

    • ewm 是指数加权移动平均:和 rolling(n).mean() 一样是求平均,但不只看最近 n 个,而是看全部历史,最新的权重是 1,往前一个是 $1-\alpha$,再往前是 $(1-\alpha)^2$,越旧的权重越小。
    • 因子里常拿它平滑成交量、价差、收益这类噪声大的序列:近的数据说话更多;也没有 rolling 那种硬边界,一个大值不会在第 n + 1 步突然整个掉出窗口。
    • 它天生适合逐 tick 增量算:递推只要记住上一步的一个数,每来一条 O(1) 更新。
    • span 换算成 $\alpha$:$\alpha = 2/(\text{span}+1)$,span=10 时 $\alpha \approx 0.18$,大致相当于最近 10 条的平均。
    • adjust 管的只是开头:历史还短时,这个加权平均怎么算,下面两种算法就差在这里。
  2. pandas 有两种算法,默认的不是递推

    • adjust=True(默认)按全部历史加权:每一步都用到目前为止的全部数据,按权重 $(1-\alpha)^i$ 加权,再除以权重之和:
    $$y_t=\frac{\sum_{i=0}^{t}(1-\alpha)^i\,x_{t-i}}{\sum_{i=0}^{t}(1-\alpha)^i}$$
    • adjust=False 是递推:这也是 C++ 最自然的写法:
    $$y_t=(1-\alpha)\,y_{t-1}+\alpha\,x_t,\qquad y_0=x_0$$
  3. 两种算法差在开头

    • 例子:取 $\alpha=0.5$,数据是 [10, 0]。递推得 $y_1=0.5\times0+0.5\times10=5$;adjust=True 得 $y_1=\dfrac{1\times0+0.5\times10}{1+0.5}\approx3.33$。
    • 递推让第一个值的权重偏大:$y_0=x_0$ 相当于把还没出现的历史全当成 $x_0$;adjust=True 只按真实出现过的数据加权。
    • 往后差距越来越小:第一个值的权重按 $(1-\alpha)^t$ 衰减,权重之和趋近 $1/\alpha$,两种算法慢慢收敛到一起。
    • 大约 25 条以后两种算法就几乎一样:$\alpha \approx 0.18$ 时,$(1-\alpha)^{25} \approx 0.007$,第一个值的影响已不到 1%。因子如果每天开盘重新开始算,每天开头这一段都会对不上。
    • ewm 对不上,典型的样子是开头差得大、后面慢慢收敛。
  4. C++ 怎么对上 adjust=True

    • 分子和分母各自递推,每步相除:两者每步都先乘 $1-\alpha$,分子再加 $x_t$,分母再加 1,输出两者之比。这样仍是 O(1) 的增量计算。
    • span、halflife 换算 $\alpha$ 的公式照抄 pandas:例如 $\alpha = 2/(\text{span}+1)$,换算写错了,每个值都会对不上。

3. 取整和整数

3.1 四舍五入

  1. 正好在 .5 时两边取的方向不同

    结果
    Python round、np.round、pandas .round取偶数:round(2.5) 是 2,round(3.5) 是 4
    C++ std::round远离 0:std::round(2.5) 是 3,std::round(-2.5) 是 −3
  2. 二进制表示本身也会改变取整的结果

    • 2.675 存不准:它在二进制里存的是 2.67499999…,所以 Python round(2.675, 2) 得 2.67,不是 2.68。
    • C++ 也有同样的问题:std::round(x * 100) / 100 里乘 100 这一步也会舍入,结果不一定和 Python 一样。
    • 价格这类本来就是定点的数,一直用放大后的整数存,不在浮点上取整。

3.2 整数除法和取模

  1. 两边的规则

    7 / 2-7 // 2 或 -7 / 2-7 % 2
    Python3.5(/ 是真除法)−4(// 向负无穷取整)1(符号跟除数)
    C++(两个整数)3(整数除法)−3(向 0 截断)−1(符号跟被除数)
  2. 最常犯的是两类错

    • C++ 两个整数相除会丢掉小数:例如成交量除以笔数求平均每笔量,Python 的 / 得小数,C++ 两边都是整数时直接截断。这是最常见的一类。
    • 负数时取整方向不同:算时间桶编号时最容易遇到,例如时间减去基准时刻得到负数(集合竞价在 9:30 之前),Python 的 // 和 C++ 的 / 给出的桶号差 1。

3.3 溢出

  • Python 的整数不会溢出:位数不够会自动变长。
  • NumPy、pandas 的 int64 会溢出而且不报错:超出范围直接回绕。
  • C++ 有符号整数溢出是未定义行为:编译器可能按“不会溢出”来优化,结果不可预测。
  • 最容易撞上的是金额:价格放大 10000 倍存成整数,100 元就是 1,000,000,再乘 100 万股是 $10^{12}$,远超 int32 的上限(约 $2.1\times10^9$)。
  • 价×量一律用 int64 或 double,并且先转类型再相乘。否则乘法本身已经在 int32 里溢出了,转换来不及救。

3.4 类型

  • 带 NaN 的整数列在 pandas 里会变成 float64:除非用可空整数类型 Int64,所以研究侧的“成交量”可能其实是浮点;超过 $2^{53}$ 的整数转成 float64 还会丢精度。
  • float32 只有约 7 位有效数字:C++ 为了省内存用 float 存价格或因子值,和 pandas 的 float64 比,从第 7、8 位开始就不同了。

4. 排名和时间

4.1 rank

  1. pandas 默认给并列的取平均名次

    • 截面因子常用排名:对全市场股票排名,再归一化到 0–1。
    • 同值用 method="average":并列的取它们名次的平均,例如两只并列第 3,都记 3.5。
    • NaN 不参与排名:结果也是 NaN(na_option="keep")。
  2. C++ 的自然写法按下标给名次

    • 排序后按位置编号:这相当于 method="first",同值的按出现顺序排成 3、4。
    • 同值并不少见:停牌股的收益都是 0,涨停股的收益相同,名次就在这些股票上对不上。
  3. C++ 怎么对上

    • 给同值的一段赋平均名次:排序后找出值相同的一段,每只都记这段名次的平均。
    • pct=True 除的是非 NaN 的个数:不是股票总数,有停牌、缺数据的股票时两者不同。

4.2 时间切分和前视

  • bar 的边界和标签要照研究侧来:区间是左闭右开还是左开右闭,标签用起点还是终点。pandas resample 按分钟切时默认左闭右开、标签在左;研究侧可能改过参数,要照着它的来。
  • bar 什么时候算结束,两边可能不同:研究侧用 shift(1) 保证 t 时刻只用到 t 之前的数据;C++ 逐条收数据,只有下一条数据的时间越过边界、或者定时器到点时,才知道一根 bar 结束了。触发时机定得不一样,同一根 bar 用到的数据就不一样。
  • 午休和集合竞价要单独约定:11:30–13:00 中间没有数据,13:00 那根 bar 跟谁连在一起;9:25 集合竞价的成交归入哪根 bar,两边都要按同一个规则。
  • 时序对齐的更多内容:整理在 A 股业务知识 第 7 节。

5. 编译选项:同一份 C++ 代码,换个选项结果就变

  1. 三个选项各会不会改结果

    选项会不会改浮点结果为什么
    -O2 → -O3一般不会只要不放开结合律,编译器不能为了向量化改变加法顺序
    -march=native会打开 FMA 指令后,GCC(默认的 gnu++ 方言)和 Clang 会把 a * b + c 合成一条乘加,中间结果不再单独舍入,末位就变了
    -ffast-math会,而且可能更糟放开结合律;假定没有 NaN 和无穷大;把非常小的数(次正规数)当成 0
  2. 因子代码里不能开 -ffast-math

    • 它会删掉 NaN 判断:它假定没有 NaN,std::isnan(x) 可能被直接优化成 false,第 2 节里所有跳过 NaN 的写法都会悄悄失效。
    • 它会重排求和:放开结合律后,求和顺序会被向量化重排,第 1 节的问题又回来了。
  3. 想要 -march=native 又不要 FMA

    • 加上 -ffp-contract=off:这样能用上 -march=native 的其他指令,但编译器不再把乘和加合成一条 FMA。
    • 研究侧也要定死实现:NumPy 内部也有自己的向量化实现,逐位一致的前提是两边都定死实现方式,不只管 C++ 这一边。
  4. 构建配置要统一

    • 例子:回测框架用 -O2、实盘用 -O3 -march=native,同一份因子代码就可能在末位上对不上。
    • 研究侧和实盘侧的编译选项要一致,并写进构建配置。

6. 对不上时:先看差值的样子

  1. 按差值的形状定位原因

    • 不要一条条看:逐值比完以后,先看差值在时间上怎么分布,形状通常直接指向原因。
    差值的样子先查什么
    整列错开一格(C++ 的第 t 个等于 pandas 的第 t + 1 个)时间对齐、shift、bar 的边界和标签
    只有开头几个不对,后面都对min_periods;ewm 的 adjust;初始值
    开头差得大,往后越来越小ewm 的 adjust=True 和递推写法的差别
    越往后差得越大滚动窗口加新减旧,浮点误差在累积
    每个值都差同一个比例ddof;单位或放大倍数(价格放大了 10000 倍还是 100 倍)
    零星几个点差很多NaN 的处理;停牌、涨跌停;同值排名;整数除法截断
    全部只差末一两位求和顺序、FMA、数学函数实现;按约定的容差判过
  2. 从源头避免:两边跑同一份实现

    • 把 C++ 实现暴露给 Python:同一份 C++ 因子代码用 pybind11 暴露给研究侧,研究和实盘跑的是同一个实现,一致性从源头上保证。
    • 代价是研究员迭代变慢:改因子要改 C++、重新编译。
    • 所以通常只对稳定下来的因子这样做。