对比学习金融表征:不用标签,也能把「同行业」和「跨行业」拉开
监督学习要标签,金融标签又贵又滞后。对比学习(Contrastive Learning)走另一条路:不预测「是什么」,只学「什么和什么像」——把同行业股票拉近、跨行业股票推远。本文用纯 numpy 从零实现 SimCLR 风格的 InfoNCE 预训练(两层 MLP 投影头,端到端训练),在 42 支股票 / 3 行业的合成数据上,把它和「直接用原始特征」做下游少标注探针对比:每行业只给 1~3 个标注时,对比表征把分类精度从 0.50/0.54/0.58 抬到 0.57/0.61/0.61,且增益随标注变多而收敛——正是「标签越稀缺越值得预训练」的教科书结论。附完整代码与五类真实陷阱(中阶)。
你手上 4000 支股票,每只每月一条 8 维特征。你想训个模型预测「次月 alpha」。但标签(次月真实 alpha)又贵又滞后——等你知道答案,行情早走了。
监督学习卡在这:标签不够,模型学歪。但仔细想,「哪些股票像」这件事,根本不需要标签——同行业、同风格、同风险因子的股票,它们的特征向量天然该离得近。对比学习(Contrastive Learning)就是把这条「什么样和什么样像」的先验,变成可训练的 pretext( pretext task),无标签地先学出一套好表征,再拿去下游任务上用少量标签微调。
结论先放这:在标签稀缺时,对比预训练确实能白捡精度。我们的合成数据(42 支股票 / 3 行业,原始特征里行业信号被强噪声淹没)上,下游「少标注探针」——每个行业只给 1~3 个标注、其余用类原型 + 最近邻分类——对比表征把精度从 0.498 / 0.538 / 0.576 抬到 0.574 / 0.614 / 0.614;而且增益随标注变多而收敛(+0.076 → +0.076 → +0.038):标签越少,预训练越值钱;标签多到一定程度,原始特征自己也能凑出来。这正是工业界「先大规模无标签预训练、再小样本微调」的逻辑根基。

1. 对比学习在优化什么:拉近「该像的」,推远「不像的」#
核心思想极简:构造「对」(pair),让正对(该像的)在表征空间靠近、负对(不像的)远离。
在金融里,「对」怎么定义?最自然的两种:
- 同行业 / 同板块 = 正对:两只银行股特征该接近;
- 跨行业 = 负对:银行股和半导体股该拉远。
SimCLR(Chen et al. 2020)的做法是:同一批样本过两次增强,得到两份视图,同一原始样本的两份视图互为正对,其余全当负对。我们这里没做数据增强(金融特征不好「增强」而不泄露),直接用同行业=正对、跨行业=负对这个结构化先验——本质上就是一种**有监督对比(Supervised Contrastive)**的简化版。
2. InfoNCE 损失:把「分类」变成「对比」#
正对拉近、负对推远,写成损失就是 InfoNCE(Oord et al. 2018):
分子是同一样本的正对相似度,分母是「它和所有样本(含负对)」的相似度之和, 是温度系数。最小化它 = 让正对相似度在全体里「脱颖而出」。
我们用纯 numpy 两层 MLP 当投影头(encoder 把 8 维原始特征压成 4 维表征),端到端用 InfoNCE 训练。关键是从零实现梯度——InfoNCE 对表征 的梯度有个干净闭式:
def info_nce(z):
sim = (z @ z.T) / tau
np.fill_diagonal(sim, -1e9) # 自己不算
loss = 0.0
gz = np.zeros_like(z)
for i in range(N):
p = np.exp(sim[i]); p /= p.sum() # softmax 行归一
G = p.copy(); G[pos_mask[i]] -= 1.0 # 正对位置减 1
# ∂L_i/∂z_i = (1/τ²) Σ_j G_ij z_j
gz[i] += (1.0 / tau ** 2) * (G @ z)
gz += (1.0 / tau ** 2) * np.outer(G, z[i])
loss += -np.log(p[pos_mask[i]].sum() + 1e-12)
return loss, gzpython然后反向过投影头(tanh 链路)更新权重。注意一个踩了大坑才懂的细节:梯度已经对所有样本求和,更新时不要再除以 N;早期版本多除了一次,学习率被吃掉 1000 多倍,训练肉眼看不出动——损失卡在 48.9 一动不动,调了半天才发现是这行 W2 -= lr * gW2 / N 的 /N。
3. 合成数据:标签稀缺 + 噪声淹没行业结构#
我们造一个对监督学习不友好、却恰好凸显对比价值的数据:
- 42 支股票、3 个行业,每只一条 8 维特征;
- 行业质心拉得很开,但每只股票叠了强噪声(信噪比约 0.7 : 1.1):
特征 = 0.7 × 行业质心 + N(0, 1.1); - 于是原始特征里行业结构被噪声严重淹没——这正是真实金融嵌入的样子(量价因子里行业信号本就混在噪声里)。
X = np.empty((N, DIM))
for i in range(N):
X[i] = 0.7 * cen[sec[i]] + rng.normal(0, 1.1, DIM)
# 注意:原始特征不预先 L2 归一化——归一化会把行业结构洗成无信号的单位向量,
# 让对比任务退化(这是另一个踩过的坑)。
pos_mask = (sec[:, None] == sec[None, :]) & ~np.eye(N, dtype=bool) # 同行业=正对python4. 训练结果:损失收敛,表征把行业「聚成团」#
端到端训练 600 轮,InfoNCE 损失从 48.95 收敛到 36.25(稳定下降,不是假收敛)。把学到的 4 维表征做 PCA 投影到 2 维,和原始特征对比:

- 左(原始特征):三个行业颜色完全混在一起——噪声把行业结构吃光了;
- 右(对比表征):同行业明显挤成三个紧实的团、彼此分开。
这就是对比学习的「免费午餐」:它没用过任何标签,只用了「同行业该像」这个结构先验,就把被噪声淹没的行业信息,在表征空间里重新显形。
5. 下游验证:少标注探针,对比表征白捡精度#
光「看着聚团」不够,要量化它有没有用。我们做少标注探针(few-shot probe):
- 每个行业只给
per ∈ {1, 2, 3}个标注样本; - 用这些标注算每个行业的类原型(均值向量);
- 其余样本用「最近原型」分类;
- 对比「直接用原始特征」vs「用对比表征」两种输入的精度。

| 每行业标注数 | 原始特征精度 | 对比表征精度 | 增益 |
|---|---|---|---|
| 1 个 | 0.498 | 0.574 | +0.076 |
| 2 个 | 0.538 | 0.614 | +0.076 |
| 3 个 | 0.576 | 0.614 | +0.038 |
这条曲线本身就是结论:标签越稀缺(1 个/行业),对比预训练增益越大;标签多到 3 个/行业,原始特征自己也能凑出 0.576,增益收窄到 +0.038。这恰是「大规模无标签预训练 + 小样本微调」范式成立的理论直觉——预训练的价值,在标签贵的角落最大。
6. 五类真实陷阱(不拆穿就是自欺)#
陷阱 1:正/负对定义错了,学出来的是垃圾。 对比学习 90% 的效果取决于「什么算正对」。用「随机两个样本当负对」在金融里会出事——两只同行业的股票可能被当成负对,模型被教去把它们推远。对策:正对用结构化的「同行业 / 同板块 / 同因子暴露」,别用随机增广(金融特征不好做不泄露的增广)。
陷阱 2:温度 τ 是隐形旋钮。 τ 太小→对比过尖锐、只盯着最难负对,训练不稳;τ 太大→所有样本差不多,梯度消失。本文用 τ=0.1,实际要网格扫一下。
陷阱 3:表征空间坍缩(collapse)。 最经典的死法:所有样本被映射到同一个点(或同一方向),相似度全 1,损失看着低实则啥也没学。本文靠「正对只减 1、分母含全部负对」的 InfoNCE 结构天然防坍缩;若上更复杂架构,要加投影头 + 停止梯度 + 负对队列等防坍缩手段。
陷阱 4:原始特征别乱归一化。 我们踩过的坑:把原始特征 L2 归一化后,所有行变成单位向量、行业结构被洗平,对比任务直接退化成「区分噪声方向」,训练损失卡死不动。原始特征保持原尺度,只归一化「学出来的投影 z」。
陷阱 5:分布外行业 / 新标的,表征未必靠谱。 对比预训练学的是「训练样本里的相似结构」。一只全新行业、或结构已漂移的老股票,它的表征可能落在奇怪的位置。生产里要定期用新数据重训投影头,别拿半年前的表征当永恒真理。
7. 小结:对比学习该怎么用#
对比学习不是「又一个深度学习花活」,它是在标签贵、标签少的金融场景里,把「无标签数据」榨出结构的正道:
- 它适合预训练阶段:海量无标签的量价 / 基本面 / 新闻嵌入,先无监督学出「什么和什么像」的表征;
- 它最适合下游标签稀缺的迁移:新市场、新标的、少标注的细分任务,拿预训练表征当起点,少样本就能起量;
- 它的命门是正对定义和防坍缩——这俩错了,剩下全是自欺。
一句话:对比学习不预测「它是什么」,只学「它和谁像」——而在金融里,「像谁」往往比「是什么」更早、更便宜、更可学。
代码与数据完全可复现:本文所有图由
gen_contrastive_finance.py生成(纯 numpy,无 torch/sklearn 依赖)。InfoNCE 损失 48.95→36.25、少标注探针精度 0.498/0.538/0.576 → 0.574/0.614/0.614,均直接打印在脚本输出里。正对用「同行业」结构化先验,原始特征不做 L2 归一化。