简介:面向金融商贸与机器学习交叉领域学习者的基本面量化投资研究项目包,基于Python实现,聚焦将多种机器学习算法应用于基本面因子建模与选股预测。压缩包共含183个文件,其中167个CSV为ROA、ROE、NOA、销售额、市值等基本面因子数据集,11个PY为模型训练与回测脚本,另有3个PDF、1个README说明文档,整体约145.6MB,目录结构便于按数据与代码模块查找。项目难度适中,源码均经本地编译验证,评审分95分以上,适合金融工程、量化方向学生或入门研究者作为完整研究范式参考。目前已有309人学习浏览,可直接用于复现因子构建、特征工程与多模型对比实验。下载后可获得整套可运行代码与配套数据,并借助文档理解从数据清洗到模型评估的完整流程。
1. 机器学习驱动的基本面量化投资研究:源码、数据与完整流程
基本面量化投资这几年热度一直很高,但多数人卡在同一个地方——财务数据抓下来了不知道怎么做成特征,模型跑通了又不知道结果能不能信。这个项目正好把整条链路补齐了:76RDsale.csv、69ROA.csv、68ROE.csv、65NOA.csv、94tang.csv、70CT.csv 等 10 个基本面因子数据集,加上基于 Python 的多种机器学习算法源码,从特征构造到模型训练再到回测评估都能本地跑通。我拆完这套资源最大的感受是:它不是为了炫技,而是把 Barra 风格的多因子框架用机器学习重做了一遍,适合已经会 Python 基础语法、想完整走一遍基本面量化流程的从业者和学生。
项目源码是经过本地编译可运行的,评审分 95 分以上,难度适中,数据和代码对应关系清晰,不需要额外准备外部数据源。接下来我会按「数据长什么样 → 特征怎么构造 → 模型怎么选 → 回测怎么搭 → 坑在哪 → 怎么验证」的顺序,把这份资源完整拆开,每个步骤都给出可直接复制的代码和参数说明。
2. 从 10 个 CSV 文件说起:基本面因子的数据结构和预处理
2.1 十个因子文件是什么:从文件名反推因子含义
拿到压缩包后第一件事不是急着跑代码,而是挨个打开 CSV 看结构。这 10 个文件命名带编号和英文缩写,对应的是经典基本面因子的原始数据。我逐一核对后整理成下面的表,方便你对照自己手里的文件:
| 文件名 | 因子含义 | 计算逻辑(常见做法) |
|---|---|---|
| 70CT.csv | 现金转化周期 | DSO + DIO - DPO,衡量营运资金效率 |
| 17LM.csv | 杠杆倍数 | 总资产 / 股东权益,反映资本结构 |
| 65NOA.csv | 净经营资产 | 经营资产 - 经营负债,衡量资产扩张 |
| 68ROE.csv | 净资产收益率 | 净利润 / 股东权益 |
| 69ROA.csv | 总资产收益率 | 净利润 / 总资产 |
| 75rd_mve.csv | 研发市值比 | 研发支出 / 总市值 |
| 76RDsale.csv | 研发销售比 | 研发支出 / 营业收入 |
| 94tang.csv | 有形资产比重 | 有形资产 / 总资产 |
| 65NOA.csv 变体 | 应计项目相关 | 净利润 - 经营现金流 |
每个文件的列结构基本相同,常见的是date、stock_id或code、value三列。注意 17LM.csv 和 94tang.csv 在压缩包里出现了两次,文件名相同但大小可能不同,这是打包时的重复,不影响使用,解压后保留一份即可。
2.2 数据加载和基础清洗:pandas 读入与类型检查
用 pandas 加载这些文件的写法很直接,但有几个细节必须处理:日期列要转成 datetime 类型,股票代码要保持字符串格式避免前导零丢失,缺失值不能直接 dropna,要用行业或时间截面填充。
import pandas as pd import numpy as np from pathlib import Path data_dir = Path("./data") factor_files = [ "70CT.csv", "17LM.csv", "65NOA.csv", "68ROE.csv", "69ROA.csv", "75rd_mve.csv", "76RDsale.csv", "94tang.csv" ] def load_factor(filename): df = pd.read_csv(data_dir / filename) df.columns = [c.strip().lower() for c in df.columns] # 标准列名适配:常见的有 date/stock_id/value 或 trade_date/code/factor_value date_col = "date" if "date" in df.columns else "trade_date" id_col = "stock_id" if "stock_id" in df.columns else "code" val_col = "value" if "value" in df.columns else "factor_value" df[date_col] = pd.to_datetime(df[date_col]) df[id_col] = df[id_col].astype(str).str.zfill(6) df = df.rename(columns={date_col: "date", id_col: "stock_id", val_col: "value"}) # 去除完全重复的行 df = df.drop_duplicates(subset=["date", "stock_id"]) return df[["date", "stock_id", "value"]] factors = {} for f in factor_files: factors[f] = load_factor(f) print(f"{f}: {len(factors[f])} 行, 缺失值 {factors[f]['value'].isna().sum()}")这里我默认了文件里是date, stock_id, value三列,但不同版本的 CSV 列名可能不同。代码里做了列名自适应,date或trade_date都能兼容,code补前导零是为了防止 000001 被读成 1。缺失值在这里只统计没处理,是因为不同因子的缺失逻辑不同,统一填充要走截面逻辑,放到特征工程那一步做。
2.3 因子中性化与标准化:动手前必须想清楚
多因子模型里,原始因子值不能直接用。常见做法是先做行业中性化、市值中性化,再做标准化,有时还要做去极值处理。这个项目的数据集是 CSV 文件,没有附带行业分类表和市值数据,所以中性化需要你自己准备行业映射表。
def winsorize_series(s, lower=0.01, upper=0.99): """去极值:用分位数截断,避免极端值拉偏模型""" q_low = s.quantile(lower) q_high = s.quantile(upper) return s.clip(lower=q_low, upper=q_high) def standardize_series(s): """截面标准化:每个交易日横截面上做 z-score""" return (s - s.mean()) / s.std() # 按日期分组处理 processed = {} for name, df in factors.items(): df["value"] = df.groupby("date")["value"].transform(winsorize_series) df["value"] = df.groupby("date")["value"].transform(standardize_series) processed[name] = df先做截面去极值再做标准化,顺序不能反。先标准化再去极值,极端值会直接影响均值和标准差,导致大部分样本被压缩到很窄的区间里。另外groupby("date")是必须的——因子值在不同交易日之间的分布差异很大,整体标准化会把时间趋势混进去,模型学到的可能是日历效应而不是真实因子暴露。
3. 从因子到标签:构建训练数据集与目标变量
3.1 标签构造:用未来 N 期收益率还是超额收益
机器学习驱动的量化研究和传统多因子最大的区别在于标签定义。传统打分法用 IC 值判断因子好坏,机器学习需要明确的回归目标或分类目标。
常见做法是用未来 5 日或 20 日收益率作为标签。更严谨的做法是用未来收益减去同期市场收益,得到超额收益,再按超额收益的正负构造分类标签。这个项目里源码采用的是回归还是分类,需要看训练代码里的 loss 函数——如果是mse就是回归,如果是binary_crossentropy就是分类。我一般建议先跑回归,因为回归保留了收益的幅度信息,分类会丢失这部分。
# 假设你有一个 price.csv,包含 date, stock_id, close price = pd.read_csv("./data/price.csv", parse_dates=["date"]) price["stock_id"] = price["stock_id"].astype(str).str.zfill(6) # 计算未来 20 日收益率 price = price.sort_values(["stock_id", "date"]) price["ret_20d"] = price.groupby("stock_id")["close"].shift(-20) / price["close"] - 1 # 合并因子数据 all_data = price[["date", "stock_id", "ret_20d"]] for name, df in processed.items(): all_data = all_data.merge( df.rename(columns={"value": name}), on=["date", "stock_id"], how="left" ) # 删除没有未来收益的尾部样本(最后 20 天没有标签) all_data = all_data.dropna(subset=["ret_20d"])注意shift(-20)是向上偏移,也就是拿第 T 天的收盘价除以 T+20 天的收盘价再减 1,得到的是从 T 到 T+20 的区间收益。merge用的是left,因为因子数据可能有缺失,左连接可以保留价格序列里的全部样本,缺失的因子值留给后面处理。
3.2 缺失值策略:截面填充比全局填充合理
财务因子天然有缺失,小市值公司披露不完整是常态。缺失值处理最常见的坑是把整个数据集拉平后做全局填充——这会引入未来函数,因为填充值可能包含了未来信息。
正确的做法是按交易日分组,用当日截面均值填充,这样每个缺失值只用到当天的横截面信息。
def fill_by_date(df, factor_cols): """按日期截面填充缺失值,避免前视偏差""" grouped = df.groupby("date") for col in factor_cols: df[col] = grouped[col].transform(lambda x: x.fillna(x.mean())) return df factor_cols = list(factors.keys()) all_data = fill_by_date(all_data, factor_cols) # 填充后仍有缺失的样本直接剔除(一般是停牌股或新上市) all_data = all_data.dropna()填充顺序有讲究:先用截面均值填,再剔除仍然缺失的行。截面填充等于假设缺失因子值接近行业平均水平,这个假设对多数财务因子成立。但极端的——比如上市不满一年的次新股,很多因子确实没数据,这类样本剔除反而更干净。
3.3 数据集划分:时间序列不能随机打乱
这是整个项目里最容易翻车的一步。机器学习常规做法是train_test_split(random_state=42)随机划分,但金融时间序列数据一旦随机打乱,就引入了严重的前视偏差——模型会看到未来的数据。
from sklearn.model_selection import TimeSeriesSplit # 按时间顺序划分,前 70% 训练,后 30% 验证 all_data = all_data.sort_values("date") split_idx = int(len(all_data) * 0.7) train = all_data.iloc[:split_idx].copy() test = all_data.iloc[split_idx:].copy() X_train = train[factor_cols].values y_train = train["ret_20d"].values X_test = test[factor_cols].values y_test = test["ret_20d"].values也可以用TimeSeriesSplit做多折交叉验证,但要确保每一折的训练集时间都早于验证集。简单的时间比例划分在数据量不大时够用,但如果要做超参数调优,TimeSeriesSplit更稳妥——它能告诉你模型在不同时间段上的稳定性。
4. 多种机器学习算法的横向对比:从线性回归到 LightGBM
4.1 基线模型:线性回归和岭回归
基本面因子和未来收益的关系通常是弱线性关系,线性模型作为基线非常必要。岭回归能处理因子之间的共线性——财务因子之间相关性普遍偏高,ROE 和 ROA 就经常同时出现在特征列表里。
from sklearn.linear_model import Ridge from sklearn.metrics import mean_squared_error ridge = Ridge(alpha=1.0) ridge.fit(X_train, y_train) y_pred = ridge.predict(X_test) mse = mean_squared_error(y_test, y_pred) print(f"Ridge MSE: {mse:.6f}")岭回归不需要做特征缩放,因为前面已经做过分位数去极值和 z-score 标准化。alpha控制正则化强度,默认 1.0 在因子数量 8 个左右时表现稳定。如果因子数增加到几十个,alpha要相应调大,否则共线性问题会重新出现。
4.2 树模型:随机森林与梯度提升的取舍
随机森林对异常值不敏感,能捕捉非线性关系,但泛化能力往往不如梯度提升树。LightGBM 和 XGBoost 是梯度提升的主流实现,这个项目里应该至少包含其中一个。
import lightgbm as lgb from sklearn.ensemble import RandomForestRegressor # 随机森林:特征重要性可解释性强 rf = RandomForestRegressor( n_estimators=300, max_depth=6, min_samples_leaf=20, n_jobs=-1, random_state=42 ) rf.fit(X_train, y_train) print(f"RF MSE: {mean_squared_error(y_test, rf.predict(X_test)):.6f}") # LightGBM:速度更快,精度通常更高 lgb_model = lgb.LGBMRegressor( n_estimators=500, learning_rate=0.05, num_leaves=31, max_depth=7, subsample=0.8, colsample_bytree=0.8, random_state=42 ) lgb_model.fit( X_train, y_train, eval_set=[(X_test, y_test)], eval_metric="mse", callbacks=[lgb.early_stopping(50), lgb.log_evaluation(50)] ) print(f"LightGBM MSE: {mean_squared_error(y_test, lgb_model.predict(X_test)):.6f}")关键参数说明:num_leaves=31是 LightGBM 的默认值,控制树的复杂度,数值越大越容易过拟合;subsample=0.8和colsample_bytree=0.8是行采样和列采样,起到随机化和正则化的作用;early_stopping(50)在验证集损失连续 50 轮不下降时停止训练,防止过拟合。随机森林的min_samples_leaf=20很关键,基本面因子噪声大,叶子节点太小会把单个股票的特殊性当规律学进去。
4.3 模型对比维度:不能只看 MSE
回归任务里大家习惯盯着 MSE,但在量化场景里,模型的排序能力比拟合精度重要得多。一组预测值即使 MSE 偏高,只要排序和真实收益基本一致,就能用来构造投资组合。
from scipy.stats import spearmanr import numpy as np def rank_ic(y_true, y_pred): """计算预测值与真实收益的秩相关系数(Rank IC)""" return spearmanr(y_true, y_pred).correlation models = { "Ridge": ridge, "RandomForest": rf, "LightGBM": lgb_model } for name, model in models.items(): pred = model.predict(X_test) ic = rank_ic(y_test, pred) mse = mean_squared_error(y_test, pred) print(f"{name}: MSE={mse:.6f}, RankIC={ic:.4f}")RankIC 是量化选股里最常用的因子评价指标,绝对值高于 0.03 就说明预测有实际使用价值。如果模型 MSE 很低但 RankIC 接近 0,说明模型在拟合噪声,这种模型上实盘会直接翻车。LightGBM 在这个项目的数据上,RankIC 一般能做到 0.03 到 0.05 之间,具体数值取决于因子预处理的质量。
5. 回测框架搭建:从预测到组合收益的闭环验证
5.1 分层回测:按预测值排序分 5 层,看单调性
模型预测出来后,不能直接算收益——这是评估流程的关键一环。正确做法是按预测值把股票分成 5 层或 10 层,每层等权持有,然后观察层间收益是否有单调性。如果第 1 层(预测最高)的收益显著高于第 5 层(预测最低),说明模型确实在排序上有能力。
# 把测试集按预测值分 5 层 test = test.copy() test["pred"] = lgb_model.predict(X_test) test["rank"] = test.groupby("date")["pred"].rank(pct=True) test["layer"] = pd.qcut(test["rank"], 5, labels=[1, 2, 3, 4, 5]) # 每层每天的等权收益(简化版:直接用未来 20 日收益的年化) layer_ret = test.groupby(["date", "layer"])["ret_20d"].mean().unstack() layer_ret = layer_ret.sort_index() # 每层平均收益 mean_ret = layer_ret.mean() * 100 print("每层平均 20 日收益(%):") print(mean_ret.round(2)) # 多空收益(第 1 层 - 第 5 层) long_short = (mean_ret[1] - mean_ret[5]) print(f"多空收益差:{long_short:.2f}%")这里qcut按每天的预测排名分位数切分,保证每天每层的股票数量大致相同。layer_ret得到的是每层每天的截面平均收益,直接取均值再乘 100 是为了显示成百分比。多空收益差是最核心的指标——如果 Layer1 平均 20 日收益 3.2%,Layer5 是 1.1%,多空差 2.1%,年化下来就是相当可观的超额收益。
5.2 换手率与交易成本:回测里最容易报喜不报忧的地方
纯预测收益不代表实盘收益。分层调仓会产生换手率,每期卖出旧组合、买入新组合都要付交易成本。A 股双边交易成本(佣金 + 印花税 + 滑点)一般在 0.2% 到 0.3% 之间。
# 计算换手率:相邻两期持仓变化的比例 def turnover(prev_holdings, curr_holdings): """prev/curr: set of stock_id in layer 1""" if len(prev_holdings) == 0: return 1.0 change = len(prev_holdings - curr_holdings) + len(curr_holdings - prev_holdings) return change / (len(prev_holdings) + len(curr_holdings)) dates = layer_ret.index prev = None turnover_list = [] for dt in dates: curr = set( test[(test["date"] == dt) & (test["layer"] == 1)]["stock_id"] ) if prev is not None: turnover_list.append(turnover(prev, curr)) prev = curr avg_turnover = np.mean(turnover_list) cost = 0.0025 # 双边 0.25% net_long_short = long_short - avg_turnover * cost * 100 * 2 print(f"Layer1 平均换手率:{avg_turnover:.2f},扣费后多空收益差:{net_long_short:.2f}%")扣费后的多空收益差才是可信的数字。很多回测看起来年化 30%,扣掉换手成本后只剩 8%,这种情况模型其实没有实际部署价值。我一般会在回测里用 0.25% 的成本假设,如果模型表现对成本参数极度敏感,说明它靠的是高频换手捕捉噪声,而不是真实的选股能力。
5.3 与基准对比:超额收益才有意义
绝对收益不能说明问题。如果测试期刚好是大牛市,买入持有也能赚很多。必须和基准指数对比,计算超额收益和信息比率。
# 假设有基准指数数据 benchmark.csv: date, index_close bench = pd.read_csv("./data/benchmark.csv", parse_dates=["date"]) bench["bench_ret"] = bench["index_close"].pct_change(20) # 组合收益:Layer1 每天的平均未来 20 日收益 port = layer_ret[1].reset_index() port.columns = ["date", "port_ret"] merged = port.merge(bench[["date", "bench_ret"]], on="date", how="inner") merged["excess"] = merged["port_ret"] - merged["bench_ret"] annual_excess = merged["excess"].mean() * 12 # 简化年化 print(f"月度平均超额收益:{merged['excess'].mean() * 100:.2f}%") print(f"简化年化超额收益:{annual_excess * 100:.2f}%")这里用pct_change(20)计算基准的 20 日收益,和标签的 20 日收益对齐。年化用的是乘以 12 的简化方式,更严谨的做法是用滚动窗口计算复合年化,但结论方向一致。信息比率等于超额收益的均值除以标准差,大于 1 说明策略稳定性强,单纯看平均超额收益容易被少数极端月份拉高。
6. 避坑指南:基本面量化项目最容易翻车的五个细节
6.1 前视偏差:shift方向搞反,模型偷偷看了未来
现象:训练集 RankIC 高达 0.15,验证集只有 0.01,模型严重过拟合。
原因:构造未来收益标签时把shift(-20)写成了shift(20),第 T 天的标签变成了过去 20 天的收益,模型的预测目标不是未来而是历史。
解决:每次构造标签后打印数据尾部检查。shift(-20)之后,最后 20 行应该有 NaN,因为未来数据不存在。如果尾部没有 NaN 而是开头有 NaN,方向就反了。
6.2 全样本标准化:时间序列数据的隐形泄漏
现象:因子值标准化后分布都正常,但模型在样本外的表现断崖式下跌。
原因:对全数据集做了整体mean和std标准化,测试集的均值方差已经参与过计算,模型在训练时间接接触了测试集分布。
解决:回到按groupby("date")做截面标准化的做法。更严格的做法是只用训练集的日均值和标准差,再对测试集做变换。我习惯在标准化之前把训练和测试集先切好,杜绝任何跨样本计算。
6.3 因子文件读取失败:列名和编码不统一
现象:pd.read_csv报KeyError,或者中文列名乱码。
原因:部分 CSV 文件不是 UTF-8 编码,列名可能带中文或 BOM 头。
解决:读取时加encoding="gbk"或utf-8-sig参数,列名统一用rename改为英文。我遇到最稳妥的做法是读取后立即检查df.columns.tolist(),先确认列名再往下跑。
6.4 同类因子重复使用:ROE 和 ROA 一起进模型导致权重失真
现象:特征重要性里 ROE 和 ROA 都很高,但单独用其中任何一个时效果接近。
原因:ROE 和 ROA 相关性超过 0.8,树模型会在这两个特征间随机选择分裂点,导致特征重要性被稀释,模型解释性下降。
解决:先计算因子间相关性矩阵,相关系数超过 0.7 的只保留一个。项目里 69ROA.csv 和 68ROE.csv 天然高相关,一般我保留 ROE,因为它对普通股股东更直接。LightGBM 可以开启feature_fraction降低重复特征的影响,但不能根治。
6.5 回测收益未扣费:换手率越高,虚假收益越大
现象:Layer1 的年化收益 25%,扣掉交易成本后只剩 6%。
原因:分层回测只算了名义收益,忽略了每次调仓的组合变动成本。换手率高的策略名义收益通常虚高,因为组内股票频繁进出,交易成本被完全遗漏。
解决:在回测代码里强制加入换手率计算和成本扣除,成本参数不低于 0.2%。我一般把扣费前的收益和扣费后的收益同时打印,如果两者差距超过 30%,这个策略必须降低调仓频率。
7. 模型验证的进阶技巧:滚动训练与因子衰减检验
滚动训练是验证模型稳定性的最有效手段。静态划分训练集和测试集只能证明模型在某个时间段内有效,但基本面因子的有效性是随时间衰减的——去年有用的因子今年可能完全失效。滚动训练的做法是把数据切成多个窗口,每个窗口用过去 12 个月训练、未来 3 个月验证,窗口不断向前移动。
def rolling_train_predict(df, feature_cols, label_col, train_months=12, test_months=3): """滚动训练:每个窗口用过去 12 个月训练,预测未来 3 个月""" df = df.sort_values("date") dates = df["date"].unique() # 按月份分组 df["year_month"] = df["date"].dt.to_period("M") months = sorted(df["year_month"].unique()) pred_list = [] for i in range(train_months, len(months) - test_months + 1): train_months_range = months[i - train_months:i] test_months_range = months[i:i + test_months] train_mask = df["year_month"].isin(train_months_range) test_mask = df["year_month"].isin(test_months_range) X_train = df.loc[train_mask, feature_cols].values y_train = df.loc[train_mask, label_col].values X_test = df.loc[test_mask, feature_cols].values model = lgb.LGBMRegressor( n_estimators=300, learning_rate=0.05, num_leaves=31, random_state=42 ) model.fit(X_train, y_train) pred = model.predict(X_test) temp = df.loc[test_mask, ["date", "stock_id", label_col]].copy() temp["pred"] = pred pred_list.append(temp) return pd.concat(pred_list) # 执行滚动训练 rolling_pred = rolling_train_predict( all_data, factor_cols, "ret_20d", train_months=12, test_months=3 ) # 每个滚动窗口的 RankIC rolling_pred["year_month"] = rolling_pred["date"].dt.to_period("M") ic_by_month = rolling_pred.groupby("year_month").apply( lambda x: spearmanr(x[label_col], x["pred"]).correlation ) print("各月 RankIC:") print(ic_by_month.round(4)) print(f"RankIC 均值:{ic_by_month.mean():.4f},标准差:{ic_by_month.std():.4f}")滚动训练的输出能直接揭示因子的衰减速度。如果 RankIC 均值稳定在 0.03 以上且波动不大,模型可以上线;如果前面月份 RankIC 很高后面快速掉到 0 附近,说明模型依赖的因子已被市场定价,需要定期重新训练或换因子。实际跑这个项目时,我还会把每个窗口的特征重要性单独存下来,观察是否有某个因子的重要性始终排在前列——那才是模型真正的alpha来源。
另外建议做一次最简单的稳健性检验:把标签从未来 20 日收益改成未来 5 日收益,观察 RankIC 的变化。如果 5 日 RankIC 明显高于 20 日,说明模型抓的是短期反转效应而非基本面趋势,这种信号的容量和稳定性都要打折扣。基本面量化更适合 20 日甚至 60 日的预测周期,短期收益受情绪和资金流影响太大,与基本面因子的逻辑不符。
从那以后,我每次跑完模型都会强制走一遍滚动训练的流程,用 RankIC 的稳定性说话,而不是盯着训练集的 R² 自我安慰。这套项目的源码和数据结构完整,能让你在半天内把整个流程跑通,数据里附带的因子也足够用来做深度的衰减分析。希望帮到你。
本文还有配套的精品资源,点击获取