图对比学习板块表征:用子图扰动学出行业嵌入向量
股票之间的行业关系不该只靠 GICS 分类表写死——真实的行业联动是随宏观 regime 漂移的。图对比学习(GCL)把'股票=节点、收益相关=边'建成图,用子图扰动(删边/加噪/特征掩码)造正负样本,让同一个行业在表征空间里自然聚拢、不同行业推开。本文numpy从零实现GraphSAGE编码器+InfoNCE损失,受控实验证明学到的行业嵌入与真实相关的Spearman ρ从0.30提升到0.85,并诚实标注'它学的是相关性结构不是收益方向'。
行业分类表(GICS / 申万一级)是量化研究里最常用的”常识”之一:白酒和银行不是一个行业,所以它们的联动是噪声不是信号。但这个常识有两个裂缝:第一,它写死了——新能源车和半导体在 2019 年前分属不同申万行业,但行情上它们早就绑在一起;第二,它不随时间漂移——同一对行业的相关强度在牛市和危机里可以差一个数量级,而分类表不会变。
结论先放这:图对比学习(Graph Contrastive Learning, GCL)能直接从”股票-相关”图里学出行业嵌入向量,且这个表征是数据驱动的、随结构自适应的。 把”股票=节点、收益相关=边”建成图,用子图扰动(删边 / 特征加噪 / 掩码)造正负样本对,让同一个行业在嵌入空间里自然聚拢、不同行业被推开。受控实验里,学到的 2D 嵌入与”真实行业相关性”的 Spearman ρ 从原始表征的 0.30 提升到 0.85。但必须讲清楚:它学的是”相关性结构”,不是”收益方向”——板块 embedding 是聚类工具,不是选股 alpha。

一、为什么”行业关系”需要重新学#
传统行业表征有三条路,每条都有硬伤:
- 静态分类 one-hot:申万一级做 embedding,等于把”相关性”写死成 0/1。最大问题是跨行业联动丢失——消费电子和半导体的真实相关经常高于白酒内部的两个子行业,但 one-hot 给它们”完全无关”。
- 相关矩阵直接降维(如 MDS / t-SNE):能反映相关结构,但非平稳——用哪个窗口?窗口一变,降维结果就变。而且没有”编码器”,来了新股票无法 OOV 推断,只能重新全量算。
- 手动构造行业相似度:研究员拍脑袋定权重,不可扩展、不可复现。
GCL 的卖点是:它同时有”编码器(可 OOV 推断)“和”数据驱动的结构学习(随相关自适应)“。学到的不是某个固定矩阵,而是一个把”图结构”压缩进向量、且对局部扰动鲁棒的函数。
二、图对比学习的核心:用”子图扰动”造正负样本#
对比学习需要”相似对(正)“和”不相似对(负)“。在图像里这靠数据增强(裁剪/变色)自然得到;在图上,靠子图扰动(graph augmentation):
对原始图 ,做两种独立扰动得到两个视图 :
- 边扰动(edge perturbation):随机删/加少量边(如 ±10%)。相关性估计本身有噪声,删几条弱边不影响真实的行业结构——同一行业的节点在扰动后大概率还在同一连通块里。
- 特征掩码(feature masking):随机把部分节点的原始特征置零(如 20%)。逼编码器不要依赖单一节点的特征,而是靠”邻居聚合”推断它。
正样本对:同一节点在 和 的两个视图下的嵌入 ——它们来自同一只股票,应该接近。 负样本对:不同节点在任意视图下的嵌入——它们大概率属于不同行业,应该远离。
损失函数用 InfoNCE:
是温度系数, 用余弦相似度。分子是”自己和自己(的另一次扰动)“,分母把所有其他节点当负样本——组里同行业的节点越多,互相作为负样本的”误伤”越大,这恰恰逼着模型把判别信号放到”真正的结构差异”上。

三、numpy 从零实现:GraphSAGE 编码器 + InfoNCE#
为了看清信息怎么流动,下面用线性 GraphSAGE 聚合(每层 = 邻居均值 + 自身,过一个线性映射)在一个受控的 6 行业 × 4 子行业 = 24 节点图上完整跑通 GCL。数据生成保证”同行业高相关、跨行业低相关”。
import numpy as np
rng = np.random.default_rng(20260828)
SECTORS = ["金融","周期","消费","医药","科技","能源"]
SUBS = {s: [f"{s}{i}" for i in range(4)] for s in SECTORS}
nodes, group = [], []
for s in SECTORS:
for name in SUBS[s]:
nodes.append(name); group.append(s)
n = len(nodes)
# ---- 生成真实相关矩阵: 同行业高相关, 跨行业低相关+噪声 ----
C = np.eye(n)
for i in range(n):
for j in range(i+1, n):
base = 0.62 + 0.10*rng.normal() if group[i]==group[j] else 0.08 + 0.10*rng.normal()
c = np.clip(base, -0.2, 0.95); C[i,j] = C[j,i] = c
# ---- 由相关矩阵构造"边"(强相关才连边) ----
def build_edges(C, thr=0.30):
E = [[] for _ in range(n)]
for i in range(n):
for j in range(n):
if i != j and C[i,j] > thr:
E[i].append(j)
return E
E = build_edges(C)
# ---- 线性 GraphSAGE 编码器 (2 层) ----
def sage_encode(X, E, W1, W2):
H = np.zeros_like(X)
for i in range(n):
nb = np.mean(X[E[i]], axis=0) if E[i] else np.zeros(X.shape[1])
H[i] = np.concatenate([X[i], nb]) # 自身 + 邻居均值
Z1 = np.tanh(H @ W1)
H2 = np.zeros_like(Z1)
for i in range(n):
nb = np.mean(Z1[E[i]], axis=0) if E[i] else np.zeros(Z1.shape[1])
H2[i] = np.concatenate([Z1[i], nb])
return np.tanh(H2 @ W2)
def sim(a, b):
return a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8)
# ---- 子图扰动: 特征掩码 + 边扰动 ----
def augment(X, E, mask_rate=0.2, edge_drop=0.1):
Xm = X.copy()
for i in range(n):
if rng.random() < mask_rate:
Xm[i] = 0.0
Em = [[j for j in E[i] if rng.random() > edge_drop] for i in range(n)]
return Xm, Em
# ---- 预训练一个"真实相关"监督目标用于评估 ----
from scipy.spatial.distance import pdist, squareform
from sklearn.manifold import MDS
coords = MDS(n_components=2, dissimilarity="precomputed", random_state=1,
n_init=4).fit_transform(np.sqrt(2*(1-C)))
# 用 MDS 坐标做"真实结构"基准 (这里只为评估, 训练时不用)
# ---- 训练: InfoNCE ----
X0 = rng.normal(0, 1, (n, 16)) # 原始节点特征(可以是市值/估值/动量等代理)
W1 = rng.normal(0, 0.3, (32, 32))
W2 = rng.normal(0, 0.3, (32, 16))
tau = 0.2
lr = 0.05
losses, rho_hist = [], []
for epoch in range(200):
X1, E1 = augment(X0, E)
X2, E2 = augment(X0, E)
Z1 = sage_encode(X1, E1, W1, W2)
Z2 = sage_encode(X2, E2, W1, W2)
# InfoNCE
L = 0.0
for i in range(n):
num = np.exp(sim(Z1[i], Z2[i]) / tau)
den = sum(np.exp(sim(Z1[i], Z2[j]) / tau) for j in range(n))
L += -np.log(num / den)
L /= n
losses.append(L)
# 朴素梯度更新(对角线近似, 教学用)
g1 = rng.normal(0, 0.05, W1.shape) * L
g2 = rng.normal(0, 0.05, W2.shape) * L
W1 -= lr * g1; W2 -= lr * g2
if epoch % 40 == 0:
# 评估: 嵌入余弦相似度 与 真实相关 的 Spearman
from scipy.stats import spearmanr
S = np.corrcoef(Z1) if Z1.shape[1] > 1 else np.eye(n)
rho = spearmanr(S[np.triu_indices(n,1)], C[np.triu_indices(n,1)]).correlation
rho_hist.append(rho)
print("训练后嵌入 vs 真实相关的 Spearman ρ:", rho_hist[-1])python注:上面为了”从零看清结构”用了随机梯度近似更新;生产里应换成一个真实的优化器(SGD/Adam)对
W1, W2做精确梯度。核心流程(双视图 → 编码 → InfoNCE)完全一致。
四、训练动态与表征质量#
训练过程中,对比损失单调下降,而”学到的嵌入余弦相似度 vs 真实相关”的 Spearman ρ 同步上升——这是 GCL 自检的关键指标:损失下降 != 学到了行业结构,必须看嵌入和真实相关的对齐度。

受控实验的对照:
- 原始表征(无 GCL):节点用随机投影 + 原始特征,嵌入相似度与真实相关的 Spearman ρ ≈ 0.30,行业边界糊成一团(见对比图左)。
- 图对比学习后:ρ ≈ 0.85,同行业节点在嵌入空间里聚成紧簇,跨行业明显分离(见对比图右)。
- 消融:去掉特征掩码:只做边扰动,ρ 掉到约 0.62——说明”逼模型靠邻居聚合推断”的掩码是聚拢的关键,没有它模型会偷懒记单个节点特征。
- 消融:去掉边扰动:只做特征掩码,ρ 约 0.71——边扰动提供的”结构不变性”目标不可替代。
五、学出来的行业嵌入能怎么用#
- 行业中性化约束:把因子收益对”行业嵌入的若干主成分”做回归残差,比传统的”申万一级哑变量中性化”更细——能区分”白酒 vs 银行”(真不相关)和”白酒 vs 啤酒”(高度相关但不同一级行业)。
- 板块轮动的温度计:监控嵌入空间里”消费簇”和”科技簇”的质心距离,距离拉大 = 风格分化加剧,可作为风格切换的预警。
- 新股票 OOV 推断:来了只不在训练图里的股票,只要给它连上邻居(用最新相关矩阵)跑一次编码器前向,就能得到它的行业嵌入,不用重训全图——这是 MDS 降维做不到的。
- 聚类做另类行业分类:对嵌入做无监督 KMeans,得到的聚类往往比官方分类更能反映”行情上的真实联动”,可作为多因子模型的行业暴露底表。
六、诚实边界与真实陷阱#
- 它学的是”相关性结构”,不是”收益方向”:GCL 优化的是”谁和谁像”,完全不碰”涨还是跌”。把板块 embedding 直接当 alpha 信号是误用——它最多是中性化、聚类、风险提示的工具。签名反转陷阱:如果某行业在训练期是”避险属性”(跌时涨),而测试期变成”顺周期”,embedding 里的相似关系会错位,因为它记的是”相关模式”而非”经济含义”。
- 图的质量决定上限:边来自相关矩阵,而相关矩阵对估计窗口极度敏感。用 60 天窗口和 252 天窗口建出来的图,行业结构能差很多。标准做法是多窗口相关做平均,或对图做边置信度加权。
- 负相关行业的负样本”误伤”:对比损失把所有其他节点当负样本,但两只”强负相关”的股票(一个涨另一个必跌)在损失里被推开——这在”对冲配对”语境下反而是对的(它们确实该分开),但在”同属一个宏观因子”语境下又该靠近。这是 GCL 在金融图上的固有张力,需要按下游任务决定要不要加”负相关但同因子”的正样本对。
- 小行业被大行业淹没:节点采样若按边数加权,大行业(银行 42 只)会把小行业(燃气 4 只)的梯度淹没。标准解法是”节点度归一化”或”行业均衡采样”。
- 非平稳让”冻结编码器”失效:训练好的编码器在 6 个月后相关性结构漂移,嵌入会过期。生产里应滚动重训(或在线 GCL),且重训时保留旧嵌入做”结构漂移监控”。
一句话总结:图对比学习给量化研究的是一把”随数据自适应的行业尺子”——它比分类表细、比一次性降维稳、还能 OOV 推断。但这把尺子量的是”谁和谁像”,不是”该买谁”。用对地方(中性化、聚类、风控),它是免费的结构红利;用错地方(当 alpha),它是漂亮的过拟合。