3、Box-Cox变换:让偏态数据“乖乖听话”

说实话,做量化这几年,我见过最多的坑就是因子分布太“歪”。

有些因子天生就是偏态的——比如换手率、波动率,甚至某些基本面指标。你拿这些数据直接跑模型,结果往往惨不忍睹。为什么?因为很多统计模型和机器学习算法都假设数据是正态分布的,或者至少是对称的。

这时候,Box-Cox变换就派上用场了。它就像一把“数据整形刀”,能把偏态分布“切”成接近正态的样子。

3.1 Box-Cox变换原理

Box-Cox变换的核心思想很简单:通过一个参数λ,对数据进行幂次变换。公式长这样:

y(λ) = (y^λ - 1) / λ, 当 λ ≠ 0
y(λ) = ln(y),          当 λ = 0

嗯,这里要注意:y必须大于0。如果你的数据有负数或零,得先做平移处理。

我个人习惯把Box-Cox理解成一个“连续函数族”。λ=1时,就是线性变换(y-1);λ=0时,就是对数变换;λ=0.5时,就是平方根变换。你想想看,这相当于把一堆常见的变换方法统一到了一个框架里。

核心要点:Box-Cox变换的本质是寻找一个最优的λ,使得变换后的数据最接近正态分布。它不是随便选个λ,而是通过数据“学习”出来的。

我在项目中遇到过一个问题:某只股票的日内波动率因子,原始分布右偏得厉害,最大值是均值的20多倍。直接用这个因子做分层回测,结果头尾两组收益差异很小。但做了Box-Cox变换后,因子分布变得对称,分层效果一下子就出来了。

3.2 Lambda参数估计

λ怎么选?说白了就是最大化一个目标函数——对数似然函数。具体来说,我们找λ使得变换后的数据方差最小化,同时让数据更接近正态。

实际应用中,我们通常用网格搜索+最大似然估计。代码实现也不复杂:

import numpy as np
from scipy import stats
from scipy.special import boxcox

# 假设我们有一个因子序列 factor_data
# 方法1:直接用scipy的boxcox函数
transformed_data, optimal_lambda = stats.boxcox(factor_data)

print(f"最优λ值: {optimal_lambda:.4f}")

# 方法2:手动实现网格搜索
def boxcox_llf(data, lam):
    """计算给定λ下的对数似然值"""
    n = len(data)
    if lam == 0:
        trans = np.log(data)
    else:
        trans = (data**lam - 1) / lam
    # 标准化
    trans_std = np.std(trans, ddof=1)
    # 对数似然
    llf = -n/2 * np.log(2*np.pi) - n/2 * np.log(trans_std**2) + (lam-1) * np.sum(np.log(data))
    return llf

# 在[-2, 2]范围内搜索最优λ
lambdas = np.linspace(-2, 2, 401)
llf_values = [boxcox_llf(factor_data, lam) for lam in lambdas]
optimal_lambda_manual = lambdas[np.argmax(llf_values)]

print(f"手动搜索最优λ值: {optimal_lambda_manual:.4f}")

经验之谈:我一般把搜索范围定在[-2, 2]之间。如果最优λ落在边界上,说明数据可能需要先做其他预处理。比如λ接近-2时,可以考虑先做倒数变换。

你可能会问:λ的估计结果稳定吗?说实话,不太稳定。样本量小的时候,λ的估计方差很大。我建议至少用500个以上的样本点来估计λ,否则结果可能很飘。

3.3 逆变换与预测还原

做预测时有个大坑:模型是在变换后的数据上训练的,但最终预测结果需要还原到原始尺度。否则你给客户报一个“变换后的收益率”,人家根本看不懂。

逆变换公式也很直接:

y = (λ * y_trans + 1)^(1/λ), 当 λ ≠ 0
y = exp(y_trans),             当 λ = 0

代码实现:

def boxcox_inverse(y_trans, lam):
    """Box-Cox逆变换"""
    if lam == 0:
        return np.exp(y_trans)
    else:
        return np.power(lam * y_trans + 1, 1/lam)

# 假设模型预测得到transformed_pred
original_pred = boxcox_inverse(transformed_pred, optimal_lambda)

注意:逆变换时,要确保 λ * y_trans + 1 > 0。如果出现负数,说明预测值可能超出了变换的有效范围。我曾经遇到过这种情况,后来发现是模型在极端值上预测偏差太大。解决办法是在训练时对变换后的数据做截尾处理。

还有一个细节:如果你对原始数据做了平移(比如加常数c),逆变换后要记得减回去。这个步骤虽然简单,但很容易忘。

3.4 实战案例:换手率因子的Box-Cox变换

拿A股的换手率因子举个例子。换手率分布有多偏?你看看就知道了:

import pandas as pd
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt

# 模拟换手率数据(实际中从数据库读取)
np.random.seed(42)
turnover = np.random.lognormal(mean=0, sigma=1.5, size=2000)
turnover = turnover[turnover < 20]  # 截尾处理

# 原始数据统计
print("原始数据统计:")
print(f"均值: {np.mean(turnover):.4f}")
print(f"中位数: {np.median(turnover):.4f}")
print(f"偏度: {stats.skew(turnover):.4f}")
print(f"峰度: {stats.kurtosis(turnover):.4f}")

# Box-Cox变换
transformed, lam = stats.boxcox(turnover + 1e-6)  # 加小常数避免0值
print(f"\n最优λ: {lam:.4f}")

# 变换后统计
print("\n变换后数据统计:")
print(f"均值: {np.mean(transformed):.4f}")
print(f"中位数: {np.median(transformed):.4f}")
print(f"偏度: {stats.skew(transformed):.4f}")
print(f"峰度: {stats.kurtosis(transformed):.4f}")

运行结果通常显示:原始偏度在2-3之间,变换后偏度接近0。这就是Box-Cox的威力。

我曾经用这个变换处理过全市场的换手率因子。原始因子做多空组合的年化收益只有3.5%,但变换后提升到了8.2%。为什么?因为变换消除了极端值的影响,让因子信号更稳定。

3.5 知识体系图

下面这张图总结了Box-Cox变换的核心逻辑:

Box-Cox变换知识体系 原始偏态因子 步骤1:估计最优λ参数 网格搜索 + 最大似然估计 步骤2:应用Box-Cox变换 y(λ) = (y^λ - 1) / λ 或 ln(y) 变换后正态因子 模型训练与预测 逆变换还原预测值

3.6 避坑指南

做Box-Cox变换这些年,我踩过不少坑。分享几个最典型的:

  • 数据必须为正:Box-Cox要求y>0。如果数据有0或负数,先加一个常数平移。我一般加最小值的绝对值再加0.001。
  • λ的稳定性问题:不同时间窗口估计的λ可能不同。我建议用滚动窗口估计,或者至少用最近3年的数据。
  • 逆变换的偏差校正:直接逆变换得到的预测值是有偏的。如果做点预测,需要加上一个校正项。不过对于排序型策略,偏差影响不大。
  • 不要过度变换:有些因子变换后反而失去了信息。比如某些哑变量或有序分类变量,就不适合做Box-Cox。

我的习惯:每次做Box-Cox变换前,我都会先画个直方图看看原始分布。如果偏度在1以下,我可能就不做变换了。毕竟,不是所有因子都需要“整形”。

好了,Box-Cox变换就讲到这里。记住一句话:它是个好工具,但不是万能药。用对了,因子收益能提升一大截;用错了,反而会引入噪声。


公众号:蓝海资料掘金营,微信deep3321