☰
基金相关性预测实战:从特征工程到LightGBM建模
2026/10/22 10:14:56 网站建设 项目流程

简介:这是一份完整的机器学习课程大作业资源,对应CCF-BDCI赛题中的基金相关性预测任务,适合课程设计、期末大作业或想要系统学习竞赛方案的新手与进阶学习者。资源内包含可直接运行的Python源代码、预测结果CSV、技术报告docx、答辩PPT与README说明,共5个文件,压缩包仅681KB,轻量且易于部署。代码带有详细注释,从数据预处理、特征工程、模型训练到相关性预测与结果输出均有完整实现,便于理解赛题思路并迁移到其他金融数据任务。技术报告系统梳理了赛题分析、方案设计、实验对比和结论,答辩PPT可直接用于期末汇报;README则给出快速复现步骤,帮助初学者按流程跑通项目。目前已有267人学习下载,作为高分离线课程设计参考,能够显著节省从零搭建的时间和精力。

1. 这门大作业为什么选“基金相关性预测”:从赛题到课程交付

如果机器学习课程大作业只要求“跑通一个模型”,基金相关性预测并不是最省力的题目。它难在:要预测的不是涨跌方向,而是一堆基金彼此之间的相关系数矩阵;评价指标也不看准确率,而是看预测矩阵和真实矩阵的数值差。但正因为难,它把特征工程、时序交叉验证、矩阵约束和答辩表达全部串起来了,一份作业能同时覆盖源代码、文档说明、技术报告和答辩ppt,性价比其实很高。

这个赛题的数据通常是若干只开放式基金的净值序列,输出是未来一段时间内各基金日收益率的相关系数矩阵。比较常见的落地方式是把矩阵预测拆成“基金对”的成对回归,再用 LightGBM 这类树模型学习,最后把预测值还原成对称矩阵。适合谁:想用一份完整作业同时体现数据处理、建模、调参和工程交付能力的学习者。下面这几章按照我自己做这个题目的顺序展开:先拆赛题,再做特征和基线,然后讲验证和调参,最后专门列避坑清单和答辩组织思路。

2. 先把赛题拆明白:预测目标、标签构造与数据准备

拿到赛题数据后的前三天,我没急着写模型,而是在倒腾净值文件和数据对齐。原因很简单:相关性预测的标签不是现成的,它需要你从收益率序列里自己算出来;算错一步,后面所有模型都在学一个错误的目标。

2.1 相关性预测的本质:不是在猜涨跌,是在猜矩阵

这题的目标是:给定截至某个日期的基金净值历史,预测未来一段时间内各基金日收益率之间的相关系数矩阵。也就是说,假设有 N 只基金,输出是 N×N 的矩阵,第 i 行第 j 列表示基金 i 和基金 j 在未来 L 个交易日收益率序列的 Pearson 相关系数。矩阵是对称的,对角元素全是 1。

为什么这个任务比预测涨跌更有意思?因为单只基金收益率的可预测性很弱,但“相关性”有明显的聚集效应:市场波动大的时候,股票型基金之间的相关性会系统性上升;震荡市里相关性又会下降。模型要学的不是“哪只基金涨”,而是“哪些基金在未来会一起涨跌”。这个目标本身更容易从历史里提取信号,也更容易在答辩时讲清楚业务含义。

另一个关键点是:相关性矩阵是结构化输出,100 只基金的矩阵有 4950 个上三角元素,普通分类回归的框架不能直接套。常见做法是把它转成成对回归问题,每一行样本对应一对基金,特征是这对基金在历史截面上的成对统计量,标签是它们未来的相关系数。这样就把矩阵预测降维成一个标准回归任务,LightGBM、XGBoost 都能直接处理。

2.2 从净值序列到训练样本:窗口滑动与标签构造

数据最常见的原始格式是长表:fund_id、date、nav(单位净值)。有的赛题会额外给累计净值或者复权因子,有的只给单位净值。第一步永远是把单位净值转成日收益率,并且按基金分组处理:

import pandas as pd import numpy as np df = pd.read_csv('fund_navs.csv', parse_dates=['date']) df = df.sort_values(['fund_id', 'date']).reset_index(drop=True) # 用复权净值算收益。如果没有复权净值,优先用累计净值;单位净值会因分红除权产生假跌。 df['ret'] = df.groupby('fund_id')['adj_nav'].pct_change() df = df.dropna(subset=['ret']) # 透视成 日期 x 基金ID 的收益率矩阵,后面所有相关矩阵都从这个矩阵算 ret_pivot = df.pivot_table(index='date', columns='fund_id', values='ret') # 缩尾处理:单只基金异常净值波动(如大额赎回导致净值跳涨)会拉爆相关系数 ret_pivot = ret_pivot.clip(lower=-0.1, upper=0.1)

逻辑说明:pct_change 算的是相邻交易日的简单收益率,之所以按基金分组做,是因为不同基金上市日期不同,整体 shift 会错位。clip 到 ±10% 不是必须的,但基金偶尔会出现单日 20% 以上的异常波动,这类点对 Pearson 相关系数的影响极大,属于典型的“一个样本毁掉一个窗口”。

接下来构造训练样本。对每个时间截面 t,取它之前 hist_len 天的收益率作为特征区间,取它之后 pred_len 天的收益率作为标签区间,计算标签区间的相关矩阵:

def make_window_samples(ret, hist_len=60, pred_len=5, step=5): dates = [] labels = [] n = len(ret) for t in range(hist_len, n - pred_len, step): hist = ret.iloc[t - hist_len:t] fut = ret.iloc[t:t + pred_len] # 未来区间存在缺失的日期直接跳过;缺失的原因一般是基金新成立或停牌 if fut.isnull().values.any(): continue if hist.isnull().values.any(): continue dates.append(ret.index[t]) labels.append(fut.corr().values) return dates, np.array(labels)

参数说明:hist_len=60 是因为相关性的估计需要至少一两个月的日收益率样本才算稳定,30 天以内噪声太大;pred_len=5 对应未来一周(5 个交易日),这是赛题最常见的预测跨度,如果要求预测未来 10 天就改成 10;step=5 控制样本间隔,间隔太小会导致相邻样本高度重叠,模型评估时虚高,后面避坑部分会单独展开。

2.3 数据缺失与复权:一开始就要处理的两个坑

净值数据和股价数据有个很大的不同:开放式基金没有停牌一说,但不同基金的成立时间、分红折算、大额赎回处理都会影响净值序列的完整性。我在这题上吃过的第一个亏是没看复权口径,直接用单位净值算收益,结果某只基金在分红日前后收益率出现了一条 -15% 的假阴线,导致它和其他所有基金的相关性被系统性拉低。

处理办法是用复权净值。如果官方数据只给了单位净值,一个常用于课程作业的近似是用累计净值代替,或者自己做前复权:把分红日的价格缺口按分红比例补回去。另一个容易忽略的点是对齐日期。不同基金在某些日期可能没有净值记录,pivot 之后会留下 NaN,常见的处理是前向填充:

# 前向填充:用上一个有效值填补缺失日,模拟“净值延续” ret_aligned = ret_pivot.ffill() # 但成立之前的日期不能用未来数据填充,成立前保持 NaN,后续建模时做列过滤 ret_aligned = ret_aligned[ret_aligned.index >= '2018-01-01'] # 过滤上市太晚、有效样本不足的基金 valid_cols = ret_aligned.notnull().sum() ret_aligned = ret_aligned[valid_cols[valid_cols >= 400].index]

逻辑说明:ffill 处理的是“某天没更新净值”的情况,它只用了过去信息,不算泄漏。真正会泄漏的是 bfill(后向填充),它用未来某天的净值去填补前一天的缺失,相当于把未来的部分信息搬到了历史里,模型在训练时会看到一个被“剧透”过的历史截面。另外,基金成立前的缺失不能用任何方式填充,要么把起始时间整体往后推,要么直接丢弃那些上市晚于样本起点的基金。

3. 特征工程与基线模型:从历史相关性到可学习的输入

模型部分我建议分两条线走:先做一个完全不学习的基线,再上 LightGBM。基线的作用不是为了得分,是为了给所有后续模型的改进幅度定一个标尺。这题的基线其实非常强,因为相关性本身就有持续性。

3.1 四类特征:收益、波动、历史相关、基金属性

既然目标是预测“一对基金未来一起波动的程度”,特征的单位就不能是单只基金,而是基金对。也就是说,每个样本代表一对基金在一个历史截面上的状态。常见做法是先给每只基金计算截面特征,再在基金对上做差值或交叉运算。

我一般把特征分成四类。第一类是收益统计特征,比如过去 60 日平均收益率、收益率的偏度和峰度,这类特征在基金对上的表达是两个基金统计量的绝对差或比例差。第二类是波动特征,包括收益率标准差、下行波动率、最大回撤,波动率差值决定了两只基金对同一风险的暴露是否一致。第三类是历史相关特征,直接计算过去 60 天两只基金的滚动相关系数,以及滚动相关的变化趋势,这类特征是最重要的。

第四类是基金属性特征。如果数据里给了基金类型,那就构造一个是否同类型的布尔特征;如果给了规模或成立日期,规模差和成立时间差也可以加进去。属性特征本身预测力不强,但它能帮树模型做条件切分:同类型基金的相关性高,不同类型基金的相关性低,模型会从这一刀开始划分。

def build_pair_features(ret, date_idx, hist_len=60): hist = ret.loc[:date_idx].iloc[-hist_len:] funds = ret.columns.tolist() rows = [] for i in range(len(funds)): for j in range(i + 1, len(funds)): fi, fj = funds[i], funds[j] ri, rj = hist[fi], hist[fj] # 跳过有效数据不足的基金对 if ri.notnull().sum() < 30 or rj.notnull().sum() < 30: continue rows.append({ 'fund_i': fi, 'fund_j': fj, 'mean_diff': abs(ri.mean() - rj.mean()), 'std_diff': abs(ri.std() - rj.std()), 'skew_diff': abs(ri.skew() - rj.skew()), 'hist_corr': ri.corr(rj), 'corr_trend': ri.iloc[-20:].corr(rj.iloc[-20:]) - ri.iloc[-60:-40].corr(rj.iloc[-60:-40]), 'type_same': int(fund_type[fi] == fund_type[fj]), }) return pd.DataFrame(rows)

逻辑说明:hist_corr 是核心特征,它直接度量两只基金最近一起波动的程度;corr_trend 度量这个相关性的方向是增强还是减弱,这在市场风格切换时尤其有用。注意 ri 和 rj 都是 Series,corr 会在任意一个序列有 NaN 时输出 NaN,所以前面做 notnull 过滤是必须的,否则这一对样本就会被直接丢弃。

3.2 基线模型:不带任何学习的历史相关平移

基线思路一句话:假设未来的相关性和最近一个窗口的相关性相同。用过去 60 天的相关矩阵直接作为未来 5 天的预测。这个基线在课程作业里通常能拿到一个不算丢人的分数,因为相关性受到的冲击衰减比收益率慢得多。

def baseline_hist_corr(ret, date_idx, hist_len=60): hist = ret.loc[:date_idx].iloc[-hist_len:] return hist.corr().values

逻辑说明:这个方法有四两拨千斤的地方,它天然对称、对角恒为 1,而且不需要任何训练。它的上限也很明显:市场如果突然从震荡切换到单边下跌,历史相关矩阵会系统性低估未来的相关性,因为危机时刻所有风险资产的相关性会向 1 收敛。后续 LightGBM 学到的最重要的修正之一,就是用市场波动特征去放大或缩小这个基线值。

基线一定要在本地验证集上先跑一遍,然后把它的误差记录下来。之后每次模型改进,只看相对基线的误差下降幅度。如果树模型加了一堆特征还不如基线,说明特征或标签构造有问题,没必要继续调参。

3.3 用 LightGBM 做成对回归:把矩阵摊平成样本

基线确定后,正式模型我用的是 LightGBM 的成对回归。做法是把所有历史截面的基金对样本堆成一个训练表,每行是一对基金在一个截面的特征,标签是这对基金在对应未来窗口的真实相关系数。预测阶段,对最后一个截面的所有基金对做预测,再把预测值填回矩阵上三角。

from lightgbm import LGBMRegressor, early_stopping, log_evaluation model = LGBMRegressor( n_estimators=800, learning_rate=0.05, num_leaves=31, colsample_bytree=0.8, subsample=0.8, subsample_freq=1, reg_lambda=5.0, random_state=42, ) model.fit( X_train, y_train, eval_set=[(X_valid, y_valid)], callbacks=[early_stopping(50), log_evaluation(100)], )

参数说明:n_estimators=800 配合 early_stopping,实际树数由验证集决定,不要关掉早停硬跑完;learning_rate=0.05 是精度和训练速度的平衡点,低于 0.02 训练时间受不了,高于 0.1 容易过拟合;num_leaves=31 对应深度大约 4-5 层,相关矩阵这种标签比较平滑,不需要太深的树;reg_lambda=5.0 是金融数据的习惯设置,这类数据噪声大,L2 正则能明显降低验证集误差的抖动。

预测和矩阵还原的代码要单独封装,因为还原这一步最容易被忽略的是顺序。训练时按 (i, j) 双层循环生成样本,预测后也必须按同样的顺序把值填回矩阵:

def pred_to_matrix(model, feat_df, funds): n = len(funds) mat = np.eye(n) pred = model.predict(feat_df) k = 0 for i in range(n): for j in range(i + 1, n): mat[i, j] = pred[k] mat[j, i] = pred[k] k += 1 return mat

逻辑说明:np.eye(n) 先把对角置 1,然后只填上三角,同时写入下三角保证对称。如果样本生成时过滤了数据不足的基金对,这一步的下标对齐会错位,所以生成样本和预测还原必须共用同一套过滤逻辑,建议封装成一个函数,而不是在训练和预测各写一遍。

4. 训练赛跑分与验证:指标、时序切分与必调参数

训练赛模式最烦人的地方是:提交一次的结果只能看到分数,看不到中间过程。如果本地验证和线上评分逻辑不一致,你会在反复提交中耗尽时间。所以这一章把指标、切分和参数一次说清。

4.1 评估指标:Frobenius 误差与相关系数分不开

先明确评分指标。常用的有两个方向:一个是预测矩阵与真实矩阵的 Frobenius 范数误差,另一个是矩阵元素级的平均绝对误差。Frobenius 误差本质上是把所有矩阵元素的平方差加起来再开方,它对大误差元素更敏感,更适合衡量“整体矩阵是否接近”:

def frob_error(pred_mat, true_mat): return np.sqrt(((pred_mat - true_mat) ** 2).sum()) def report_metrics(pred_mat, true_mat): frob = frob_error(pred_mat, true_mat) mae = np.abs(pred_mat - true_mat).mean() # 只取非对角元素,因为对角恒为1,不参与真实比较 off = ~np.eye(pred_mat.shape[0], dtype=bool) corr_score = np.corrcoef(pred_mat[off], true_mat[off])[0, 1] return {'frobenius': frob, 'mae': mae, 'corr_of_offdiag': corr_score}

逻辑说明:为什么要同时看三个数?因为 Frobenius 误差和 MAE 反映的是数值接近程度,但数值差 0.01 和差 0.03 的矩阵,排名可能差很多;而相关系数反映的是预测趋势是否和真实趋势一致,它只看形状不看绝对数值。一个预测矩阵可能相关系数很高但整体偏差很大,这种情况说明模型学到了相关性的波动方向,但系统性高估或低估了相关水平,需要检查特征里是否缺了市场波动类变量。

4.2 时序交叉验证:普通 KFold 在这里会给出虚假高分

这是整个作业里最值得多花时间的一步。基金相关性预测是严格的时间序列任务,样本之间存在时间顺序,直接用 sklearn 的 KFold 做随机切分会把未来样本混进训练集,模型在验证集上的分数会好看得离谱,一上训练赛就原形毕露。

正确的做法是用 TimeSeriesSplit,或者自己按日期手动切分:

from sklearn.model_selection import TimeSeriesSplit # gap=5 表示训练集末尾和验证集开头间隔5个交易日,避免窗口重叠 tscv = TimeSeriesSplit(n_splits=5, gap=5) for fold, (train_idx, valid_idx) in enumerate(tscv.split(X)): X_tr, X_va = X.iloc[train_idx], X.iloc[valid_idx] y_tr, y_va = y[train_idx], y[valid_idx] model.fit( X_tr, y_tr, eval_set=[(X_va, y_va)], callbacks=[early_stopping(50)], )

参数说明:n_splits=5 表示把时间切成 5 段做滚动验证;gap=5 是容易被忽略的参数,它控制训练集最后一个样本和验证集第一个样本之间的间隔。为什么需要这个间隔?因为如果预测窗口是 5 天,训练集末尾的样本已经包含了未来 5 天的信息,而这些信息和验证集开头样本的标签区间有重叠,gap 就是为了把这个重叠切掉。没有 gap 的时序验证,本质上还是在“偷偷看未来”。

4.3 必调参数与训练赛提速:把树模型调到舒服的位置

这题的数据量不大,N 只基金的成对样本量是 O(N² × T),几十只基金、几百个交易日就足够训练了,所以参数调整的重点不是提速而是稳定。我一般按三个顺序调:

第一步固定 learning_rate=0.05 和 early_stopping,先看默认 num_leaves 下的验证误差;第二步调 num_leaves,从 15 到 127 按倍数试,相关性预测的最佳值通常在 31-63 之间,太大容易记住单只基金对的噪声;第三步调 reg_lambda,从 0.1 到 20 做网格搜索,这个参数对金融噪声的抑制效果比调特征重要性还明显。

from sklearn.model_selection import GridSearchCV from lightgbm import LGBMRegressor param_grid = { 'num_leaves': [15, 31, 63], 'reg_lambda': [0.1, 1.0, 5.0, 10.0], } base = LGBMRegressor( n_estimators=300, learning_rate=0.05, colsample_bytree=0.8, subsample=0.8, )

逻辑说明:grid search 这里只用来粗筛,不要让它自己跑太多树。粗筛后固定一组参数重新训练到早停,得到的就是最终模型。另外,训练赛提交前一定要把特征重要性和验证曲线留档,答辩时老师大概率会问“你怎么知道模型没有过拟合”,早停曲线就是最直接的证据。

5. 避坑清单:基金相关性预测作业里最常见的 5 个翻车点

这一章是血泪经验。以下五条几乎每一届做这个方向的同学都会踩到至少两条,我自己也一条条填过坑。

5.1 复权口径不一致:分红除权让收益曲线突然“跳水”

现象:某只基金在分红日前后出现异常的 -15% 收益率;和它相关性较高的基金全部被牵连,模型在验证集上的相关性预测误差明显偏大。

原因:直接用单位净值算收益,没有处理分红除权。单位净值在分红日会下调,这个下调不是真实亏损,却在收益率序列里形成一根假阴线。

解决:用复权净值或累计净值计算收益。只有单位净值时,需要根据分红公告做前复权,或者在数据整理阶段把分红日当天的收益率置为 0 并做说明。答辩时这道题几乎是必问题,提前处理等于提前过关。

5.2 缺失值填充泄漏未来:一个 fillna 放错位置就废了

现象:本地验证误差很低,但用同样的代码在训练赛提交后分数明显变差,差到和本地不一致。

原因:缺失值用了 bfill 填充,用后一天的净值补了前一天的缺失。训练时这个“未来信息”被模型学到,本地验证集里的未来区间也被同样处理了,所以看起来没问题;真实预测时没有未来可用。

解决:只用 ffill 前向填充,并且成立之前的缺失保持 NaN。更稳妥的做法是把所有填充逻辑封装在同一个函数里,训练、验证、预测三条路径都走同一个函数,杜绝“训练时一套,预测时另一套”。

5.3 预测矩阵没做对称化:上下三角对不上,指标直接崩

现象:模型预测结果在成对回归阶段看起来没问题,但还原成矩阵后 Frobenius 误差比基线还差。

原因:模型分别预测了 (i, j) 和 (j, i),两个预测值不完全相等。如果直接把预测结果堆成一个矩阵而不做对称化,矩阵不对称,和真实对称矩阵比较时误差自然偏大。

解决:填矩阵时只填上三角,下三角用上三角的转置回填,再手动把对角设为 1:

M_sym = 0.5 * (M_raw + M_raw.T) np.fill_diagonal(M_sym, 1.0)

提示:如果训练时每个基金对只生成一次样本,这一步不会出问题;如果重复生成了双向样本,取平均再填也是可以的。

5.4 相关系数矩阵不正定:负特征值在等着你

现象:预测矩阵看起来正常,但用它做后续的组合配置演示时,出现方差为负、权重为负等明显不合理的结果。

原因:模型逐对预测相关系数,每个元素的误差独立,拼接出来的矩阵只能保证对称,不能保证半正定。真实相关系数矩阵一定半正定,预测矩阵不满足这个性质,说明某些特征值小于 0。

解决:做一次特征值截断,把负特征值归零再归一化对角:

eigvals, eigvecs = np.linalg.eigh(M_sym) eigvals = np.clip(eigvals, 0, None) M_pd = eigvecs @ np.diag(eigvals) @ eigvecs.T # 归一化对角,保证元素在[-1,1]范围 D = np.sqrt(np.diag(M_pd)) M_pd = M_pd / np.outer(D, D)

逻辑说明:这个操作在数学上是把预测矩阵投影到半正定锥上,它会让矩阵元素值发生微小变化,但整体误差通常只会变好不会变坏。答辩时如果讲到后续应用,这段代码就是“矩阵约束”这一页的支撑材料。

5.5 本地高分、训练赛掉队:滚动窗口重叠带来的虚假提升

现象:本地时序交叉验证误差稳定下降,模型加特征后分数持续提升,但训练赛成绩几乎不变,甚至下降。

原因:样本构造时 step 太小,相邻样本的历史区间和标签区间大量重叠,训练集和验证集之间存在信息重叠,验证误差被低估。特征越多,模型越容易记住这些重叠信息,本地虚高就越严重。

解决:把 step 调整到不小于 pred_len,比如预测 5 天就设 step=5;验证时保持 gap=5。另一个容易被忽略的点是:任何基于全量数据的标准化、缩尾、缺失率过滤,都必须只在训练段上计算参数,再应用到验证段,不能拿全量统计量去预处理。

6. 把代码变成可答辩的交付:文档、技术报告与 PPT 的组织思路

训练赛分数再好,如果交付物一团乱,课程大作业的评分也不会高。我的习惯是把整个工程当成一个可以复现的实验室项目来组织,而不是把一堆 notebook 丢进压缩包。

project/ ├── README.md # 运行说明:环境、数据路径、复现命令 ├── config.yaml # 所有参数集中管理,禁止散落在代码里 ├── data/ │ ├── raw/ # 原始净值数据 │ └── processed/ # 收益率矩阵、特征表 ├── src/ │ ├── data.py # 数据加载、复权、缺失处理 │ ├── features.py # 成对特征生成 │ ├── baseline.py # 历史相关基线 │ ├── train.py # LightGBM 训练与调参 │ └── evaluate.py # Frobenius / MAE / 对称化 / 半正定修正 └── reports/ ├── technical_report.md └── slides/ # 答辩 PPT 源文件

技术报告不要写成代码注释的堆叠,围绕四个问题组织:任务怎么定义、标签怎么构造、模型怎么选择、结果怎么验证。每一节配一张表或一张图:标签构造示意图、特征重要性图、时序交叉验证分割图、基线 VS 模型误差对比表。图比字值钱,尤其是特征重要性图,老师一眼就能看出你的特征构造是有逻辑的还是硬凑的。

答辩 PPT 控制在 12 到 15 页:第 1 页问题定义,第 2 页数据样例,第 3 页标签构造,第 4 页基线结果,第 5-6 页特征工程,第 7-8 页模型与调参,第 9-10 页评估与误差分析,第 11 页提交分数,第 12 页失败案例和反思。最后一页放“下一步改进方向”比放“谢谢”更有用,因为答辩提问环节大概率从这里开始。

我自己的习惯是:把所有能够固化下来的判断标准写进 README,包括“模型必须打败基线”“本地验证误差和训练赛分数偏差超过 20% 就回查泄漏”“矩阵必须对称且半正定”这三条。这样即使隔两周再打开代码,也能在半小时内复现全部结果。这个习惯帮我少走了很多弯路,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询