halo 的技术博客

返回

做量化的人迟早会撞上同一堵墙:回测漂亮的因子模型,一上实盘就拉胯。原因往往不是信号没用,而是模型在「训练集里把噪声也背下来了」——它学的是这段历史里偶然成立的局部规律,而不是真正稳健的因子结构。

传统机器学习靠海量标注数据撑着泛化,可量化领域没有这个奢侈:你只有几千个交易日、几百只股票,标签(下期涨跌)还被交易成本、停牌、幸存者偏差反复污染。样本越少、标签越脏,模型越容易「背答案」而不是「学规律」。

这条墙有三道裂缝:样本量不足导致的过拟合特征空间单调导致的脆弱决策边界市场环境漂移导致的分布失配。本文用两套从零实现的模型,逐一演示两种补墙技术——合成数据增强对抗训练——它们一个管「泛化」、一个管「鲁棒」,正好补上两道最深的裂缝。

小样本下三类模型的决策边界对比

一、为什么因子模型特别需要数据增强#

在图像领域,把一张猫旋转 5°、调亮 10%,它还是猫——增强样本天然成立。但在因子模型里,「增强」必须尊重金融数据的物理结构,否则就是在制造未来函数(look-ahead)或虚假相关性。这正是很多人对数据增强望而却步的原因。

可问题在于,如果你什么都不做,模型会在小样本上迅速过拟合。我们用一个受控实验把这件事摆到台面上:构造一个含真实信号(8 个量价因子的线性组合)但带 22% 标签噪声的合成数据集,只给模型 100 个训练样本,其余 4000 个留作测试。

用一个 2 隐层 MLP(64×32,tanh)训练后,基线模型在训练集上准确率 0.870,测试集只有 0.620——典型的过拟合:它把训练标签里的噪声也记下来了。这 gap 就是我们要填的坑。

二、合成数据增强:给输入加「受控噪声」#

最直接的增强是对特征加高斯噪声:在训练时对每个样本 x 扰动成 x + εε ~ N(0, σ²)。这迫使模型不能依赖某个特征的精确数值,而是学到「在邻域内都成立」的规律。本质上,它是一种比 L2 更主动的正则化。

在我们的实验里,增强把测试集 AUC 从 0.658 提到 0.673,决策边界也明显更平滑(见上图中间一列)。噪声强度 σ 是道权衡题:σ 太小没正则效果,σ 太大把信号也洗掉了——经验上取特征标准差的 5%~15% 起步,再用验证集调。

除了加噪,实务里还有两类增强值得提一句:

  • SMOTE 式样本混合:在特征空间对少数类近邻做线性插值 x_new = λx_i + (1-λ)x_j,适合涨跌极度不平衡的标签;
  • 截面 shuffle / bootstrap:对同日的横截面样本做有放回重采样,制造「另类历史路径」,但务必保证不跨时间泄露。

当标签的涨跌极度不平衡(比如暴雷样本极少)时,光加噪还不够,可以用 SMOTE 式混合在特征空间造少数类近邻,把决策边界主动拉向少数类一侧:

def smote(X, y, k=5, n_new=50):
    Xpos = X[y == 1]
    out = []
    for _ in range(n_new):
        i = rng.integers(0, len(Xpos)); j = rng.integers(0, len(Xpos))
        lam = rng.uniform(0, 1)
        out.append(lam * Xpos[i] + (1 - lam) * Xpos[j])  # 同标签近邻间线性插值
    return np.vstack([X, np.array(out)]), np.concatenate([y, np.ones(n_new)])
python

这一步缓解类别失衡带来的过拟合偏向。关键是插值只在同标签近邻间做,绝不跨时间、绝不跨标签,避免造出自相矛盾的样本。

三、对抗训练:在最坏方向上练肌肉#

数据增强让边界更平滑,但它改善的是「平均情况」。如果你想防的是定向攻击或极端行情(比如某只股票被错标、某个因子被瞬间拉爆),需要的是对抗训练(Adversarial Training)

核心思想来自 Goodfellow 的 FGSM:对输入施加一个「让模型最容易犯错」的微小扰动 δ = ε·sign(∇_x L),然后在「原样本 + 对抗样本」上联合训练。模型被迫在扰动邻域内都做对,于是它的有效鲁棒半径被撑大。

线性模型,这件事有解析的最美形式:损失对输入的梯度就是 ∇_x L = (p-y)·w,方向恒为 sign(w)。所以对抗样本就是 x_adv = x + ε·sign(w),训练时把这个样本混进来即可:

def train_logreg(X, y, adv=False, eps=0.4, epochs=300, lr=0.05, lam=1e-3):
    n, d = X.shape; w = np.zeros(d); b = 0.0; y = y.reshape(-1, 1)
    for _ in range(epochs):
        p = 1 / (1 + np.exp(-(X @ w + b)))[:, None]
        gw = (X.T @ (p - y)).ravel() / n + 2 * lam * w
        if adv:                                        # FGSM:沿 sign(w) 推一步
            xa = np.clip(X + eps * np.sign(w), -5, 5)
            pa = 1 / (1 + np.exp(-(xa @ w + b)))[:, None]
            gw = (gw + (xa.T @ (pa - y)).ravel() / n + 2 * lam * w) / 2
        w = w - lr * gw; b = b - lr * (p - y).mean()
    return w, b
python

对深度网络(如上面的 MLP),FGSM 的梯度要逐层反传:

# MLP 的 FGSM 输入梯度(省略前向细节,g1 为隐层一阶梯度)
gx = g1 * (1 - a1 ** 2) @ P["W1"].T        # dL/dx 的形状与 X 相同
xa = np.clip(X + eps * np.sign(gx), -5, 5) # 对抗样本
python

我们的白盒测试很说明问题:用每个模型自己的梯度造 FGSM 对抗样本去攻击测试集,在扰动幅度 ε=0.4 时——

模型干净准确率ε=0.4 攻击下准确率
基线逻辑回归0.6310.569
+对抗训练0.6180.609

对抗训练牺牲了约 1.3 个点的干净准确率(它变得”保守”),却把对抗攻击下的准确率从 0.569 拉到 0.609。这就是它存在的意义:用一点平均性能,换来分布尾部的安全性

白盒 FGSM 对抗攻击下三类模型的鲁棒性对比

四、学习曲线:增强让数据更”经用”#

另一视角是看「要用多少数据才能训好」。下图横轴是训练样本数,纵轴是测试准确率。增强和对抗训练的曲线更早爬升、也更平——意味着在样本稀缺时,它们能用更少的数据达到相近的泛化水平。这对因子模型至关重要:你多一个季度的样本往往要等一年,而增强让你”现有的数据更经用”。

不同训练样本量下的测试准确率(学习曲线)

五、真实陷阱:这些坑会让增强变成毒药#

  1. 增强样本里的未来函数:对时间序列加噪声必须只在「截面/特征维度」上做,绝对不能在时间维度上 shuffle 或插值——那等于把未来信息喂给过去,回测必暴利、实盘必腰斩。
  2. 增强幅度不校验σε 过大,等于在训练一个”看不清信号”的模型。务必用样本外验证集调,而不是训练集。
  3. 对抗训练只防它见过的攻击:FGSM 训出来的鲁棒性,主要覆盖 FGSM 这类一阶攻击;遇到更强的 PGD(多步迭代)攻击仍可能破防。真要上强度,得用 PGD 做对抗样本。
  4. 标签噪声不是增强能救的:本文 22% 标签噪声是”可学习的软噪声”,增强有效;但如果是系统性标注错误(如复权错误、停牌日误标),增强只会让模型更自信地错。
  5. 计算成本:对抗训练每个 batch 要多做一次前向+反向来造样本,约 2 倍算力。深度网络上建议用 FGSM 而非 PGD 做日常训练,PGD 留给最终压力测试。

六、落地建议#

  • 小样本 + 高标签噪声的因子模型,优先上高斯噪声增强,成本低、收益稳,先把过拟合压下去;
  • 如果你的信号要用于杠杆产品、期权对冲或极端行情风控,再把对抗训练加进 pipeline,用 FGSM 日常、PGD 做定期体检;
  • 增强/对抗的超参(σε)一律用滚动窗口的样本外集调,且每次改数据频率都要重调;
  • 永远保留一条未增强的基线做对照——如果增强后样本外反而掉,说明你的增强假设(“邻域仍同分布”)在当下市场不成立,别硬上。

合成数据增强与对抗训练不是银弹,它们解决的是「模型在已知分布内更稳、在微小扰动下不崩」。真正跨市场的泛化,最终还是回到因子经济学含义与严谨的样本外验证上——这两个技术,是让你那点宝贵的样本数据”物尽其用”的放大器,不是替代研究的捷径。

合成数据增强与对抗训练:提升因子模型的泛化边界
https://blog.halo26812.eu.org/blog/synthetic-data-augmentation-quant
Author halo
Published at 2026年7月11日
版权声明 CC BY-NC-SA 4.0
Comment seems to stuck. Try to refresh?✨