LightGBM 截面选股:把因子流水线变成预测模型

从规则策略转到机器学习选股:用 LightGBM 做 A 股截面收益预测,讲清特征构造、标签设计、purged 时序切分与 embargo、IC 评价和过拟合诊断,重点放在最容易泄露未来信息的那几个环节。

远思为径,码动价值。欢迎来到远思码技术实验室的最新技术实践记录。

上一篇文章的策略是"人工写规则",参数换了就要重跑一遍。机器学习选股的思路不同:把多个因子交给模型,让它自己学加权方式。

听起来很美,但实验室第一次做的时候,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 随机切分,在量化里这是灾难:

  1. 标签是未来 5 日收益,t 日的样本和 t+3 日的样本标签区间重叠 → 训练集和验证集"共享答案";
  2. 相邻交易日的特征高度自相关 → 模型只要记住"这几天",就能在验证集上蒙对。

实验室用的是按时间切分的 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)
ICIR0.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 评价里看不出来,但会在实盘里原形毕露。

七、从预测到组合:别忽略这一步

模型输出的是相对分数,不是权重。从分数到组合中间还有几件事:

  1. 截断极值:按预测分位取前 10%,而不是简单取 top 50 只(防止小盘股霸榜);
  2. 行业约束:行业偏离不超过 3%,否则模型会集中押注某几个行业;
  3. 权重平滑:用分数排名加权(w ∝ rank),比等权更能利用信息,但换手更高;
  4. 在池内选股:只在沪深 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 多得多。

远思为径,码动价值。模型不会让你赚钱,只会放大你流程里的漏洞。