图神经网络 GNN 资产关系建模:把「谁和谁像」直接写进模型的结构里
传统因子模型把每只股票当成孤立样本,可现实里资产从来不是孤岛——同行业、供应链上下游、共同持仓,处处是关系。图神经网络(GNN)把资产建成一张图:节点是股票、边是关系,图卷积让每个节点在预测时「借用邻居的信息」。本文用纯 numpy 从零实现一层 GCN,在「3 行业 × 60 股、同行业由共同因子驱动」的自洽合成数据上,证明加了图结构的半监督分类准确率从 MLP 的 0.75 直接拉到 1.00,并诚实拆穿邻接矩阵怎么建、过平滑、图泄漏等五类真实陷阱(中阶)。
传统因子模型有个隐含假设:每只股票是独立同分布的一行样本。你把茅台、五粮液、宁德时代排成一张表,每行算各自的动量、估值、波动,然后丢进回归。模型眼里,茅台和五粮液的关系,跟茅台和宁德时代的关系,没有任何区别——都只是表里的两行而已。
但你我都知道这不对。茅台和五粮液会一起涨一起跌,因为它们共享同一个白酒行业因子;宁德时代和它的上游锂矿供应商会联动,因为供应链把它们的现金流绑在了一起。资产从来不是孤岛,它们嵌在一张密密麻麻的关系网里。
结论先放这:图神经网络(Graph Neural Network, GNN)的核心价值,就是把「谁和谁有关系」这件事直接写进模型的结构里。 它把资产建成一张图——节点是股票,边是关系(行业、相关、供应链、共同持仓都行)——然后用图卷积让每个节点在做预测时,主动去借用它邻居的信息。在本文的自洽合成数据上,一层最朴素的 GCN 把半监督行业分类的测试准确率从「只用节点特征的 MLP」的 0.75,直接拉到 1.00。下面从零实现,并诚实拆穿它最容易翻车的五个地方。

一、为什么因子模型需要「图」#
先想清楚一个问题:关系信息到底值不值钱?
假设你要预测一只小盘股下个月的收益,但它自己的历史数据又短又噪。如果你知道「它和另外 20 只已经充分研究过的股票高度联动」,那这 20 只股票的信息,其实就是这只小盘股的免费先验。这正是量化里”用行业均值填补个股缺失""用同行相对估值做锚”这类操作的本质——你一直在用关系,只是过去用得很粗糙(简单取均值)。
GNN 把这件事做成了可学习、可端到端优化的模块。它不再是”取行业均值”这种拍脑袋的聚合,而是让模型自己学”该从哪个邻居借多少信息”。
一张资产图由两部分组成:
- 节点特征矩阵 : 只股票,每只 个特征(动量、波动、估值、beta……)
- 邻接矩阵 : 表示股票 和 有关系(相关超阈值 / 同行业 / 供应链相连)
图卷积做的事,一句话概括:每个节点的新表征 = 它自己和所有邻居的特征的加权平均,再过一个可学习的线性变换。
二、一层 GCN 的数学:就三步#
Kipf & Welling (2017) 的 GCN 传播公式看着吓人,其实拆开只有三步:
第一步:加自环。 。为什么?因为聚合邻居时,节点自己的信息也不能丢,加个单位阵 就等于”把自己也算作自己的邻居”。
第二步:对称归一化。 ,其中 是度矩阵(对角线是每个节点的邻居数)。这一步至关重要——不归一化的话,度数高的节点(连了 50 条边)聚合出来的值会比度数低的节点(连了 2 条边)大一个数量级,模型会被”社交达人”节点带偏。对称归一化让每条边的权重变成 ,度高的节点被适当压制。
第三步:线性变换 + 激活。 完成了”邻居聚合”,右乘 做特征变换(把 维映射到你要的输出维度),最后过一个非线性 。
关键洞察: 这个操作,就是”每个节点用归一化权重把邻居的特征平均过来”。 图卷积的全部魔法,都在这个矩阵乘法里。
三、纯 numpy 从零实现#
我们造一个自洽的合成世界:60 只股票分属 3 个行业,同行业股票的日收益由共同的行业因子驱动。这样,用相关阈值建出来的图,天然就该呈现”同行业内部密集连边、跨行业稀疏”的块结构。任务是半监督行业分类:只给每个行业 4 个标注节点,看模型能不能把剩下的 48 只股票正确归类。
import numpy as np
rng = np.random.default_rng(20260722)
# ---------- 合成资产关系图 ----------
N, G, T = 60, 3, 500 # 60 股 × 3 行业 × 500 日
sector = np.repeat(np.arange(G), N // G) # 每股所属行业
sector_factor = rng.normal(0, 1.0, (G, T)) * 0.012 # 行业共同因子
idio = rng.normal(0, 1.0, (N, T)) * 0.010 # 个股特质
beta = 0.6 + 0.5 * rng.random(N) # 对行业因子的暴露
ret = beta[:, None] * sector_factor[sector] + idio # 日收益 (N,T)
# ---------- 用相关阈值建邻接矩阵 ----------
C = np.corrcoef(ret)
np.fill_diagonal(C, 0.0)
A = (C > 0.30).astype(float) # 相关 > 0.3 就连边python注意这里的因果链:先有共同因子 → 同行业收益联动 → 相关高 → 建出边。这是自洽合成的关键,图结构不是我们手动画上去的,而是从数据生成过程里”长”出来的。
接着实现归一化邻接和一层 GCN 前向:
# ---------- 对称归一化邻接 ----------
Ah = A + np.eye(N) # 加自环
d = Ah.sum(1)
Dinv = np.diag(1.0 / np.sqrt(d))
Ahat = Dinv @ Ah @ Dinv # D^-1/2 (A+I) D^-1/2
# ---------- 节点特征: 8 个滚动统计量 ----------
feat_raw = np.stack([
ret[:, -20:].mean(1), ret[:, -20:].std(1),
ret[:, -60:].mean(1), ret[:, -60:].std(1),
beta, (ret > 0).mean(1),
np.abs(ret).mean(1), C.mean(1)
], axis=1)
feat = (feat_raw - feat_raw.mean(0)) / (feat_raw.std(0) + 1e-9)
F = feat.shape[1]
def softmax(Z):
Z = Z - Z.max(1, keepdims=True)
e = np.exp(Z)
return e / e.sum(1, keepdims=True)python训练部分——半监督,只在标注节点上算梯度,但前向传播用到了整张图(每个节点都聚合了邻居,包括未标注的邻居):
# ---------- 半监督训练: 每行业只标 4 个节点 ----------
labels = sector
train_mask = np.zeros(N, bool)
for g in range(G):
idx = np.where(sector == g)[0]
train_mask[rng.choice(idx, 4, replace=False)] = True
W = rng.normal(0, 0.3, (F, G))
XW_input = Ahat @ feat # 图卷积的聚合项(可预计算)
lr = 0.5
for it in range(200):
P = softmax(XW_input @ W)
Y = np.eye(G)[labels]
grad = (P - Y) * train_mask[:, None] / train_mask.sum() # 只在标注点回传
W -= lr * (XW_input.T @ grad)
pred = (XW_input @ W).argmax(1)
gcn_acc = (pred[~train_mask] == labels[~train_mask]).mean()python对照组是不用图的 MLP:一模一样的特征、一模一样的训练循环,唯一区别是把 XW_input = Ahat @ feat 换成 feat——即去掉图卷积那步聚合。
四、结果:图结构值 25 个百分点#
跑完打印的数字:
节点 60 | 边(单向) 1140 | 平均度 19.0
边落在同行业内的比例: 100.00% (随机基线≈32.20%)
GCN(用图) 测试集行业分类准确率: 1.000
MLP(不用图) 测试集行业分类准确率: 0.750plaintext三个数字,每个都说明一件事:
边落在同行业内的比例 100%。 相关阈值建图,把同行业的联动关系几乎完美地抓成了边——随机情况下同行业边只该占 32%,实际 100%,说明”共同因子 → 高相关”这条链条极其干净。这是合成数据的理想情况,真实市场会脏得多(见陷阱一)。
GCN 1.00 vs MLP 0.75。 这是全文最关键的对比。两个模型用的特征完全相同,唯一区别是 GCN 多做了 那步邻居聚合。就靠这步,测试准确率提升了 25 个百分点。原因很直白:一个未标注节点,只要它的邻居里有标注节点,图卷积就能把标注信息”传染”过来。 而 MLP 只能靠这个节点自己那 8 个特征硬判,在半监督(标注极少)的场景下必然吃亏。

从收敛曲线看,GCN 不仅终点更高,收敛也更快更稳——因为图平滑相当于给优化加了一个强先验(相邻节点表征应该接近),缩小了假设空间。
再看邻接矩阵本身的结构:

把节点按行业重排后,邻接矩阵呈现教科书般的块对角结构——三个行业各自内部密集连边,跨块几乎空白。这张图直观解释了为什么 GCN 有效:图卷积的信息流动被牢牢限制在行业块内部,等于自动实现了”行业中性”的信息聚合。
五、五类真实陷阱(务必读完再上手)#
合成数据上的 1.00 准确率很漂亮,但真实市场会用五种方式教你做人:
陷阱一:邻接矩阵怎么建,决定了模型的天花板。 本文用”相关 > 0.3”建图,在合成数据上干净得不真实。真实相关矩阵充满噪声, 只股票估 个相关系数,样本量根本不够,估出来的图一半是噪声边。阈值高了图太稀疏(信息传不动),低了图太稠密(所有节点糊成一团,等于没建图)。实务里更稳的是用先验图(行业分类、供应链、共同持仓),而不是纯数据驱动的相关图。
陷阱二:过平滑(over-smoothing)。 GCN 每多一层,节点就多聚合一圈邻居。层数一深,所有节点的表征会收敛到同一个值——整张图糊成一片,节点之间再也分不开。这就是为什么实务里 GCN 很少超过 2-3 层。别以为堆层数能提升效果,图神经网络在这点上和 CNN 完全相反。
陷阱三:图泄漏(look-ahead via graph)。 这是量化里最致命的。如果你用全样本的相关矩阵建图,那么 时刻的节点表征就偷看了 之后的相关信息——回测会好得离谱,实盘瞬间打回原形。正确做法:邻接矩阵必须只用截止到 的数据滚动重建,和所有因子一样严守时间因果。本文合成数据没有时间维度上的预测任务,所以用了全样本图,真实回测绝不能这么干。
陷阱四:图的时变性。 资产关系不是常数。牛市里所有股票齐涨(相关趋近 1,图变成全连接,等于没信息);危机里跨行业相关飙升(行业块结构瓦解)。一张静态图假设关系永恒,这在 regime 切换时会系统性失效。严肃的做法是用动态图(时变邻接)或滚动重估,但这会大幅增加过拟合风险。
陷阱五:半监督的标注质量。 本文的”标注”是行业标签,干净无争议。但真实任务里如果你的标注是”未来收益是否为正”这类含大量噪声的标签,图卷积会把错误标注也一起传染给邻居——GCN 放大信号的能力,反过来也会放大标注噪声。图结构是杠杆,既能放大 alpha,也能放大你的错误。
六、总结#
GNN 在量化里的定位,不是又一个更强的黑箱,而是一种把领域先验(谁和谁有关系)注入模型结构的方式。它最适合的场景是:单个资产数据稀疏、但资产之间关系明确且稳定(比如用供应链图做产业链联动预测、用共同持仓图做风格传染分析)。
本文的 1.00 vs 0.75 是合成数据的理想上界,真实收益要打很大折扣。但那个 25 个百分点的差距背后的道理是真的:当你的样本孤立且稀缺时,关系就是免费的信息。 GNN 只是把这句话,写成了可以端到端优化的矩阵乘法。
上手顺序建议:先用最保守的先验图(行业分类)+ 一层 GCN 跑通,确认图确实带来增量,再考虑相关图、动态图这些更花哨也更容易过拟合的东西。记住陷阱三——图泄漏是这条路上最深的坑,绝大多数”GNN 选股神效”的回测,都栽在这里。