远思为径,码动价值。欢迎来到远思码技术实验室的最新技术实践记录。
上一篇文章的策略是"人工写规则",参数换了就要重跑一遍。机器学习选股的思路不同:把多个因子交给模型,让它自己学加权方式。
听起来很美,但实验室第一次做的时候,IC 跑到了 0.15(这个数字在 A 股日频里强得不像真的),回测年化 60%。查了两天才发现——标签用到了未来 5 天的收盘价,而特征里的换手率是"未来 5 天平均换手"。这不是机器学习,这是拿着答案考试。
这篇把正确的流程完整走一遍,重点标出那些"看起来没问题、其实在泄露"的环节。
一、问题定义:截面回归,不是时间序列预测
先说清楚我们在预测什么:
对每个交易日 t,输入当期所有股票的因子截面,输出每只股票未来 5 个交易日收益的横截面排名。
为什么是排名而不是绝对收益?因为 A 股有系统性涨跌(beta),模型容易把"市场要涨"学成"所有股票都好"。去掉截面均值之后,模型只能学相对强弱,这也和最终"买前 N 只"的决策方式一致。
# ml/labels.py
import numpy as np
import pandas as pd
def forward_return(close: pd.DataFrame, horizon: int = 5) -> pd.DataFrame:
"""未来 horizon 日收益。注意:这里的时间箭头是指向未来的,永远只能用于标签。"""
return close.shift(-horizon) / close - 1
def cross_section_rank(y: pd.DataFrame) -> pd.DataFrame:
"""截面排序归一化到 [-1, 1],比 z-score 更抗极端值。"""
return y.rank(axis=1, pct=True) * 2 - 1
二、特征:因子池 + 截面处理
特征直接复用因子工程里落库的 factor_value 表,再补几个:
| 组别 | 特征 | 说明 |
|---|---|---|
| 动量 | mom_5 / mom_20 / mom_60 / mom_120 | 多尺度动量 |
| 反转 | rev_5 / rev_20 | 短期反转 |
| 波动 | vol_20 / vol_60 / max_dd_60 | 风险特征 |
| 流动性 | turn_20 / amount_20 / amihud | 换手与冲击成本代理 |
| 估值 | pe_ttm / pb / ps_ttm | 财务因子(低频,按季更新) |
| 行业 | industry | 类别特征,LightGBM 原生支持 |
关键纪律:所有特征都必须在截面上做稳健标准化,并且标准化只用当日截面信息(不用全样本均值),这样在任何时点重算都一致。
# ml/features.py
from __future__ import annotations
import numpy as np
import pandas as pd
def load_factor_panel(factor_names: list[str], start: str, end: str) -> pd.DataFrame:
"""从 factor_value 取宽表并堆成 (trade_date, symbol) 长表。"""
from sqlalchemy import create_engine
engine = create_engine("postgresql+psycopg2://quant:quant@127.0.0.1:5432/quant")
sql = """
SELECT trade_date, symbol, factor_name, z
FROM factor_value
WHERE factor_name = ANY(%(names)s)
AND trade_date BETWEEN %(start)s AND %(end)s
"""
df = pd.read_sql(sql, engine, params={"names": factor_names, "start": start, "end": end})
return df.pivot_table(index=["trade_date", "symbol"], columns="factor_name", values="z")
def add_industry_and_label(panel: pd.DataFrame, close: pd.DataFrame,
industry: pd.DataFrame, horizon: int = 5) -> pd.DataFrame:
"""拼上行业标签与截面排序标签,并丢弃最后 horizon 天(没有未来收益)。"""
y = cross_section_rank(forward_return(close, horizon))
long = y.stack().rename("label").reset_index()
long.columns = ["trade_date", "symbol", "label"]
ind = industry.stack().rename("industry").reset_index()
ind.columns = ["trade_date", "symbol", "industry"]
out = panel.reset_index().merge(long, on=["trade_date", "symbol"], how="inner")
out = out.merge(ind, on=["trade_date", "symbol"], how="left")
# 行业标签在每个截面内部统一成 category,未知记为 "UNKNOWN"
out["industry"] = out["industry"].fillna("UNKNOWN").astype("category")
return out.dropna(subset=["label"])
三、时序切分:purged 与 embargo
这是整篇文章最重要的部分。机器学习课教你 K-Fold 随机切分,在量化里这是灾难:
- 标签是未来 5 日收益,t 日的样本和 t+3 日的样本标签区间重叠 → 训练集和验证集"共享答案";
- 相邻交易日的特征高度自相关 → 模型只要记住"这几天",就能在验证集上蒙对。
实验室用的是按时间切分的 purged walk-forward + embargo:
# ml/split.py
from __future__ import annotations
import pandas as pd
def purged_time_split(dates: pd.Series, n_splits: int = 5, embargo: int = 5) -> list[tuple]:
"""按时间切成 n_splits 段,训练集与验证集之间留 embargo 天缓冲。
返回 [(train_mask, valid_mask), ...],mask 是与 dates 同长度的布尔数组。
"""
uniq = sorted(dates.unique())
fold = len(uniq) // (n_splits + 1)
idx = pd.Series(range(len(uniq)), index=uniq)
splits = []
for k in range(1, n_splits + 1):
train_end = k * fold
valid_start = train_end + embargo
valid_end = valid_start + fold
if valid_end > len(uniq):
break
train_days = set(uniq[:train_end])
valid_days = set(uniq[valid_start:valid_end])
splits.append((dates.isin(train_days), dates.isin(valid_days)))
return splits
embargo=5 的含义:训练集最后 5 天的样本,其标签区间与验证集第一天重叠,必须整体切除。少了这一步,验证集 IC 能凭空高 30%。
四、训练:LightGBM
# ml/train.py
from __future__ import annotations
import lightgbm as lgb
import numpy as np
import pandas as pd
FEATURES = ["mom_5", "mom_20", "mom_60", "mom_120", "rev_5", "rev_20",
"vol_20", "vol_60", "max_dd_60", "turn_20", "amount_20", "amihud"]
PARAMS = dict(
objective="regression_l2",
metric="l2",
learning_rate=0.03,
num_leaves=15, # 别太大,截面样本噪声很重
min_data_in_leaf=200,
feature_fraction=0.75,
bagging_fraction=0.8,
bagging_freq=1,
lambda_l2=5.0, # 强正则,防止记住某几天
max_depth=5,
verbosity=-1,
seed=42,
num_threads=8,
)
def train_model(df: pd.DataFrame, train_mask, valid_mask) -> lgb.Booster:
X_tr = df.loc[train_mask, FEATURES + ["industry"]]
y_tr = df.loc[train_mask, "label"].to_numpy()
X_va = df.loc[valid_mask, FEATURES + ["industry"]]
y_va = df.loc[valid_mask, "label"].to_numpy()
dtrain = lgb.Dataset(X_tr, label=y_tr, categorical_feature=["industry"], free_raw_data=False)
dvalid = lgb.Dataset(X_va, label=y_va, categorical_feature=["industry"], reference=dtrain)
booster = lgb.train(
PARAMS,
dtrain,
num_boost_round=2000,
valid_sets=[dtrain, dvalid],
valid_names=["train", "valid"],
callbacks=[lgb.early_stopping(100, verbose=False), lgb.log_evaluation(0)],
)
return booster
几个参数选择的理由,比参数本身更值得记:
num_leaves=15 + max_depth=5 + lambda_l2=5:截面数据的信噪比极低(IC 0.03 就算不错),复杂模型几乎必然过拟合到某几段行情;min_data_in_leaf=200:一天全市场几千只股票,叶子太小就等于记住个股;early_stopping只看验证集,测试集从头到尾不参与训练决策。
五、评价:不看 R²,只看 IC 与分层
回归任务的 R² 在这里毫无意义(0.01 也算"显著")。真正的评价指标还是那三个:
# ml/evaluate.py
import numpy as np
import pandas as pd
from scipy.stats import spearmanr
def predict_panel(booster, df: pd.DataFrame) -> pd.Series:
pred = booster.predict(df[FEATURES + ["industry"]])
return pd.Series(pred, index=df.index)
def ic_by_date(df: pd.DataFrame, pred: pd.Series, label_col: str = "label") -> pd.Series:
tmp = df.assign(pred=pred)
ic = tmp.groupby("trade_date").apply(
lambda g: spearmanr(g["pred"], g[label_col]).statistic
if len(g) >= 50 else np.nan,
include_groups=False,
)
return ic.dropna()
def ic_stats(ic: pd.Series) -> dict:
return {
"IC均值": round(float(ic.mean()), 4),
"ICIR": round(float(ic.mean() / ic.std(ddof=1)), 3),
"IC>0占比": round(float((ic > 0).mean()), 3),
"IC衰减(后20%)": round(float(ic.iloc[-len(ic) // 5:].mean()), 4),
}
def long_short_backtest(df: pd.DataFrame, pred: pd.Series, top_pct: float = 0.1) -> pd.Series:
"""按预测分位做多空组合,返回每日收益(未计成本)。"""
tmp = df.assign(pred=pred)
daily = []
for date, g in tmp.groupby("trade_date"):
n = max(10, int(len(g) * top_pct))
g = g.sort_values("pred", ascending=False)
daily.append((date, g.head(n)["label"].mean() - g.tail(n)["label"].mean()))
s = pd.Series(dict(daily)).sort_index()
return s
实验室的实测水平(示例,2018-2025 的日频、5 日持有):
| 指标 | 数值 | 评价 |
|---|---|---|
| RankIC 均值 | 0.042 | 可用(> 0.03) |
| ICIR | 0.31 | 弱但稳定 |
| IC > 0 占比 | 0.58 | 尚可 |
| 多空年化(未计成本) | 16.4% | 与 IC 量级相符 |
| 多空最大回撤 | -12.7% | 可接受 |
注意这里的 IC 只有 0.042,远低于我第一版的 0.15。0.15 不是好消息,是报警信号。
六、过拟合诊断:三个必查项
6.1 特征重要性是否稳定
同一套流程在 2020-2022 和 2023-2025 两段数据上分别训练,如果两次的 top 特征完全不同,说明模型学的是噪声。
def top_features(booster, k: int = 6) -> list[str]:
imp = booster.feature_importance(importance_type="gain")
names = booster.feature_name()
order = np.argsort(imp)[::-1][:k]
return [names[i] for i in order]
实验室两次训练的 top 特征都是 rev_5 / mom_20 / turn_20 / vol_20,只有排序在变——这才叫稳定。
6.2 分年度 IC 是否衰减
by_year = ic.groupby(ic.index.str[:4]).agg(["mean", "count"])
如果 2024 年之后 IC 突然接近 0,那不是"市场变了",更可能是因子被交易过度拥挤了。任何因子都有生命周期,要接受它。
6.3 SHAP 方向是否反常识
import shap
def explain_one_day(booster, df: pd.DataFrame, date: str, top_n: int = 5) -> pd.DataFrame:
day = df[df["trade_date"] == date]
explainer = shap.TreeExplainer(booster)
sv = explainer.shap_values(day[FEATURES + ["industry"]])
shap_df = pd.DataFrame(sv, columns=FEATURES + ["industry"], index=day.index)
return shap_df.abs().mean().sort_values(ascending=False).head(top_n)
如果 SHAP 告诉你"换手率越低越好",而你去查因子定义发现写反了符号——这类 bug 在 IC 评价里看不出来,但会在实盘里原形毕露。
七、从预测到组合:别忽略这一步
模型输出的是相对分数,不是权重。从分数到组合中间还有几件事:
- 截断极值:按预测分位取前 10%,而不是简单取 top 50 只(防止小盘股霸榜);
- 行业约束:行业偏离不超过 3%,否则模型会集中押注某几个行业;
- 权重平滑:用分数排名加权(
w ∝ rank),比等权更能利用信息,但换手更高; - 在池内选股:只在沪深 300 / 中证 500 成分股里选,避免流动性黑洞。
这些环节对最终净值的影响,往往比换一个更强的模型更大。
八、踩坑清单
- 标签泄露:
forward_return只能用于y,如果它不小心进入特征表(比如按 symbol merge 时列名撞车),IC 会好到不真实。 - 复权口径不一致:特征用前复权、标签用未复权,除权日会凭空出现 -30% 的标签。
- 行业分类会变:申万行业每年调整,历史行业标签要用当期分类,不能用今天的分类回填。
- 停牌股:停牌期间没有任何成交,标签却是"未来 5 日收益",必须按可交易性把样本剔除,否则模型会学到"停牌股复牌后大涨"的伪规律。
groupby.apply的include_groups:pandas 2.2 之后行为变了,老代码会静默丢列,IC 计算直接错位。
结语
LightGBM 这条路的上限是清楚的:它能把多个弱因子合成一个稍强的信号,但不会凭空创造 alpha。IC 从单因子的 0.03 提到 0.042,靠的是模型对非线性与交互的捕捉,以及行业信息的利用。
如果还想往上走,就要引入序列结构——这正是下一篇的主题:用 TFT 与 ST-GNN 捕捉时序与截面图结构。那条路的坑,比 LightGBM 多得多。
远思为径,码动价值。模型不会让你赚钱,只会放大你流程里的漏洞。