跳至正文
MODULE 05金融机器学习5 讲 · MACHINE LEARNING
LECTURE 19共 31 讲预计阅读 50 分钟

第 19 讲:表格级梯度提升树(LightGBM / CatBoost)实战 ​

本讲核心目标:
在现代多因子特征融合体系中,梯度提升决策树(GBDT,以 LightGBM 与 CatBoost 为代表)长年稳坐买方多因子量化的王者地位。深入理解为什么树模型在金融表格数据上显著击败全连接神经网络;严格推导普通**点回归目标(MSE/Huber)与截面排序学习(LambdaMART)的本质差异;深入剖析 NDCG@K 与虚拟梯度(λ-Gradient)**的数学原理;掌握买方针对低信噪比数据的极简防过拟合超参数体系。


1. 为什么 GBDT 是金融表格数据无可争议的“王者”? ​

在计算机视觉或 NLP 领域,深度学习统治一切;但在以横截面多因子为核心的金融表格数据(Tabular Data)中,基于树的集成算法(GBDT)在工业界实证中对深度神经网络呈现降维打击。

NeurIPS 2022 经典顶会论文 Why do tree-based models still outperform deep learning on typical tabular data?(Grinsztajn et al.)从理论上阐明了其核心归纳偏置(Inductive Bias):

树模型在金融表格数据中的天然优势
├── 1. 对单调变换与量纲完全免疫:
│     └── 基于分箱直方图 (Histogram) 寻找最优分裂点,不需要对连续变量做严格归一化
├── 2. 天然擅长拟合阶梯型非线性决策面:
│     └── 例如: 资产负债率超过 85% 违约风险暴增、PE 小于 0 发生质变,树模型通过单个阈值即可完美切分
├── 3. 对无用特征 (Uninformative Features) 具备极强的鲁棒性:
│     └── 神经网络会尝试为每一个特征分配权重并反向传播更新;而决策树可直接在根节点忽略无关噪声因子
└── 4. 坐标轴对齐的超平面分裂 (Axis-Aligned Split):
      └── 金融多因子之间多为正交或半独立的经济学维度,旋转不变性网络往往引入额外的拟合噪声

2. 目标函数选择:点回归(Pointwise)vs 排序学习(Listwise LambdaMART) ​

在构建 LightGBM 模型时,绝大多数初学者会默认设置 objective = "regression"(均方误差 MSE)。但这在以“选出 Top 10% 优质股票”为目标的量化选股中,存在根本性的目标错位!

2.1 点回归(MSE / Huber)的致命痛点 ​

LMSE(y,y^)=12N∑i=1N(yi−y^i)2
  • 误差均匀惩罚的陷阱:在全市场 5,000 只股票中,中间 80% 股票的未来收益差异极其微小且充满随机游走噪声;
  • MSE 逼迫决策树消耗大量分裂叶子节点,去精细化拟合中间垃圾样本的微小数值波动(例如究竟是 +0.2% 还是 +0.4%),而真正决定多头组合超额的 Top 10% 头部选股质量却被严重稀释!

2.2 排序学习(Listwise LambdaMART)的数学飞跃 ​

排序学习将每一个交易日看作一个检索 Query,该交易日内的 5,000 只股票看作待排序的候选文档(Documents)。

其优化的终极目标是归一化折损累积增益(Normalized Discounted Cumulative Gain, NDCG@K):

DCG@K=∑i=1K2reli−1log2⁡(i+1),NDCG@K=DCG@KIDCG@K

其中 reli 为股票 i 的收益相关度评级(如按未来收益率分成 0~4 档),i 为模型预测排序位次。

  • 分母 log2⁡(i+1) 的位置折损机制:排在第 1 名的股票获得最大权重,排在第 100 名之后的股票权重急剧衰减至近乎为零!
  • LambdaRank 虚拟梯度(λ-Gradient)数学推导:
    由于 NDCG 是非连续的阶梯排序函数、无法直接求导,微软学者 Christopher Burges 等提出了虚拟梯度 λij:
λij=−σ1+eσ(si−sj)|ΔNDCG|

其中:

  • si,sj 为模型对股票 i 和 j 的预测打分;
  • |ΔNDCG| 表示如果把股票 i 与股票 j 的预测排序位次对调,整个截面 NDCG 的变动差额!
LambdaMART 梯度的微观物理机制
├── 头部排序错位: 若把全市场第 1 名误判到了第 10 名,|ΔNDCG| 极其巨大,λ 梯度暴冲,驱动树全力纠错!
└── 尾部排序错位: 若把第 2,500 名和第 2,501 名颠倒,|ΔNDCG| 几乎严格为 0,树模型直接忽略跳过!
课程图示
可放大查看细节,点击百分比恢复原始大小。

3. 买方级防过拟合超参数控制体系 ​

金融数据的信噪比极低(SNR 通常小于 0.05),严禁使用互联网搜索推荐的通用树模型参数!量化机构的“防过拟合保命参数清单”如下:

参数名称机构推荐安全范围默认库参数调优物理含义与买方心法
max_depth3 ~ 5-1 (无限制)强制使用极浅的弱学习器,杜绝深层树死记硬背历史噪声
num_leaves8 ~ 2031叶子节点上限,在浅层树下需满足 ≤2max_depth
min_child_samples100 ~ 50020单叶子最小样本行数。防止树针对某几只妖股单独开辟专有分裂路径
feature_fraction0.50 ~ 0.701.0每次分裂随机抽样因子特征,破坏特征强共线性
bagging_fraction0.60 ~ 0.801.0样本行随机下采样,注入时序抗震泛化性
learning_rate0.01 ~ 0.030.1慢工出细活,结合 early_stopping 防止过快冲过极值点
reg_alpha (L1)0.5 ~ 2.00.0叶子权重绝对值惩罚,引入稀疏性
reg_lambda (L2)2.0 ~ 10.00.0叶子权重平方惩罚,平滑预测打分截面分布

4. 滚动重训架构(Rolling Walk-Forward Retraining) ​

金融市场具有显著的非平稳性(Non-stationarity)与机制漂移(Regime Shift)。2021 年之前有效的大盘价值白马逻辑,在 2023 年会被微盘小市值量化彻底颠覆。

量化机构通常采用**按月滚动训练(Rolling Window)**机制:

课程图示
可放大查看细节,点击百分比恢复原始大小。

5. 动手实操:LightGBM LambdaMART 截面排序器实战 ​

下面的 Python 代码严格遵循机构级买方规范,基于 LightGBM 原生 Group 排序模式,使用带 Purging 隔离的数据集训练 LambdaMART 排序模型。

在项目根目录运行 PYTHONPATH=tutorial python3 -m examples.lecture_19_lightgbm_catboost_ranking。依赖安装、共享模块和数据契约见项目说明。

py
"""
教学用 A 股 LightGBM 截面排序器 (AshareLightGBMRanker)
支持:LambdaMART 排序学习与鲁棒 Huber 回归目标、日期 Group 自动聚合、标签区间检查与早停验证
"""

from typing import Dict, List, Literal, Optional, Tuple
import numpy as np
import polars as pl
import lightgbm as lgb
from examples.ml_utils import demo_dates, purged_holdout


class AshareLightGBMRanker:
    """
    A 股截面多因子 LightGBM 学习器
    """

    def __init__(
        self,
        objective: Literal["lambdarank", "regression"] = "lambdarank",
        learning_rate: float = 0.03,
        num_leaves: int = 15,
        max_depth: int = 4,
        min_child_samples: int = 100,
        feature_fraction: float = 0.70,
        bagging_fraction: float = 0.80,
        reg_alpha: float = 1.0,
        reg_lambda: float = 5.0,
        n_estimators: int = 500,
        random_state: int = 42
    ):
        self.objective = objective
        self.params = {
            "boosting_type": "gbdt",
            "objective": objective if objective != "regression" else "huber",
            "metric": "ndcg" if objective == "lambdarank" else "l2",
            "learning_rate": learning_rate,
            "num_leaves": num_leaves,
            "max_depth": max_depth,
            "min_child_samples": min_child_samples,
            "feature_fraction": feature_fraction,
            "bagging_fraction": bagging_fraction,
            "bagging_freq": 1,
            "reg_alpha": reg_alpha,
            "reg_lambda": reg_lambda,
            "random_state": random_state,
            "n_jobs": 2,
            "verbose": -1
        }
        if objective == "lambdarank":
            # 关注 Top 20 / Top 50 选股排序质量
            self.params["eval_at"] = [20, 50]

        self.n_estimators = n_estimators
        self.model: Optional[lgb.Booster] = None
        self.feature_names: List[str] = []

    def _prepare_lgb_dataset(
        self,
        df: pl.DataFrame,
        feature_cols: List[str],
        label_col: str,
        date_col: str = "trade_date"
    ) -> Tuple[lgb.Dataset, np.ndarray]:
        """
        构造 LightGBM 数据集并按日期提取 Group 切片大小
        """
        # 必须确保数据严格按日期排序以构造连续 Group
        sorted_df = df.sort(date_col)
        
        # 提取 Group 大小 (每日股票数)
        group_counts = (
            sorted_df.group_by(date_col, maintain_order=True)
            .agg(pl.len().alias("count"))["count"]
            .to_numpy()
        )

        X = sorted_df.select(feature_cols).to_numpy()

        if self.objective == "lambdarank":
            # LambdaMART 要求标签为非负整数相关度等级 (0, 1, 2, 3, 4)
            # 每天在截面上将未来收益等分映射为 5 个离散档位
            ranked_label = (
                (sorted_df.select(
                    ((pl.col(label_col).rank().over(date_col) - 1.0) /
                     pl.len().over(date_col) * 5.0).floor().cast(pl.Int32)
                )).to_series().clip(0, 4).to_numpy()
            )
            y = ranked_label
            dataset = lgb.Dataset(X, label=y, group=group_counts, feature_name=feature_cols)
        else:
            y = sorted_df[label_col].to_numpy()
            dataset = lgb.Dataset(X, label=y, feature_name=feature_cols)

        return dataset, y

    def fit(
        self,
        train_df: pl.DataFrame,
        val_df: pl.DataFrame,
        feature_cols: List[str],
        label_col: str,
        date_col: str = "trade_date",
        early_stopping_rounds: int = 30,
        label_end_col: str = "label_end_date",
        available_at=None
    ):
        """
        执行带验证集 Early Stopping 的模型训练
        """
        if train_df.is_empty() or val_df.is_empty() or label_end_col not in train_df.columns or label_end_col not in val_df.columns:
            raise ValueError("训练/验证集不能为空,且必须提供 label_end_date")
        if available_at is None:
            raise ValueError("必须提供模型可用日期 available_at")
        for frame in (train_df, val_df):
            if any(frame[c].null_count() for c in (date_col, label_col, label_end_col)) or frame.filter(~pl.col(label_col).is_finite()).height:
                raise ValueError("训练标签必须有限且已提供结算日期")
            if frame.filter(pl.col(label_end_col) < pl.col(date_col)).height:
                raise ValueError("标签结算日期不能早于特征日期")
        if train_df[date_col].max() >= val_df[date_col].min() or train_df[label_end_col].max() >= val_df[date_col].min():
            raise ValueError("训练标签与验证区间重叠,请先执行 Purging")
        if val_df[label_end_col].max() >= available_at:
            raise ValueError("早停验证标签在模型可用日期之前尚未成熟")
        self.feature_names = feature_cols
        self.available_at = available_at

        train_ds, _ = self._prepare_lgb_dataset(train_df, feature_cols, label_col, date_col)
        val_ds, _ = self._prepare_lgb_dataset(val_df, feature_cols, label_col, date_col)

        callbacks = [
            lgb.early_stopping(stopping_rounds=early_stopping_rounds, verbose=False),
            lgb.log_evaluation(period=0) # 静默模式
        ]

        self.model = lgb.train(
            self.params,
            train_set=train_ds,
            num_boost_round=self.n_estimators,
            valid_sets=[val_ds],
            callbacks=callbacks
        )

    def predict(self, df: pl.DataFrame) -> np.ndarray:
        """
        使用训练好的模型预测截面打分
        """
        assert self.model is not None, "模型尚未训练,请先调用 fit()!"
        X = df.select(self.feature_names).to_numpy()
        return self.model.predict(X, num_iteration=self.model.best_iteration)

    def get_feature_importance(self) -> pl.DataFrame:
        """
        提取特征分裂收益 (Gain) 重要性
        """
        assert self.model is not None, "模型尚未训练!"
        gains = self.model.feature_importance(importance_type="gain")
        splits = self.model.feature_importance(importance_type="split")
        
        return pl.DataFrame({
            "feature": self.feature_names,
            "gain_importance": gains,
            "split_count": splits
        }).sort("gain_importance", descending=True)


# ================= 单元测试与排序模型实战 =================
if __name__ == "__main__":
    np.random.seed(42)
    n_days = 200
    n_stocks = 300

    calendar = demo_dates(n_days + 6, "2024-01-02")
    dates = calendar[:n_days]
    symbols = [f"{s:06d}.SH" for s in range(n_stocks)]
    features = [f"factor_{k}" for k in range(8)]

    records = []
    # 模拟多因子非线性市场:
    # factor_0 和 factor_1 存在强交互非线性效应,factor_2 具有阶梯截断
    for d_idx, d in enumerate(dates):
        f_mat = np.random.normal(0, 1.0, (n_stocks, len(features)))
        
        # 真实的收益生成方程 (包含非线性与交互)
        # y = 0.003 * f0 + 0.005 * f1 * (f0 > 0.5) + noise
        y = (
            0.004 * f_mat[:, 0] +
            0.006 * f_mat[:, 1] * (f_mat[:, 0] > 0.5).astype(float) -
            0.003 * (f_mat[:, 2] ** 2) +
            np.random.normal(0, 0.02, n_stocks)
        )

        for s_idx, s in enumerate(symbols):
            row = {
                "trade_date": d,
                "symbol": s,
                "ret_fwd_5d": y[s_idx],
                "label_end_date": calendar[d_idx + 5]
            }
            for f_idx, f_name in enumerate(features):
                row[f_name] = f_mat[s_idx, f_idx]
            records.append(row)

    full_df = pl.DataFrame(records)

    # 验证用于早停,不作为最终样本外业绩;按标签结束日清洗训练边界
    unique_dates = full_df["trade_date"].unique().sort().to_list()
    split_date = unique_dates[150]

    available_at = calendar[-1]
    train_data, val_data = purged_holdout(full_df, split_date, dates[-1], available_at)

    print(f"训练集规模: {train_data.height:,} 行 | 验证集规模: {val_data.height:,} 行")

    # 1. 训练 LambdaMART 排序学习器
    print("\n>>> 开始训练 LightGBM LambdaMART 截面排序器...")
    ranker = AshareLightGBMRanker(
        objective="lambdarank",
        learning_rate=0.03,
        num_leaves=15,
        max_depth=4,
        min_child_samples=50
    )
    ranker.fit(train_data, val_data, feature_cols=features, label_col="ret_fwd_5d", available_at=available_at)

    # 2. 验证集推理与 Rank IC 评估
    val_preds = ranker.predict(val_data)
    val_with_pred = val_data.with_columns(pl.Series("pred_score", val_preds))

    # 计算每日验证集的 Rank IC
    ranked_val = val_with_pred.with_columns([
        pl.col("pred_score").rank().over("trade_date").alias("p_rank"),
        pl.col("ret_fwd_5d").rank().over("trade_date").alias("y_rank")
    ])
    daily_ic = ranked_val.group_by("trade_date").agg(pl.corr("p_rank", "y_rank").alias("rank_ic"))
    mean_val_ic = float(daily_ic["rank_ic"].mean())

    print(f"✅ 验证集平均 Rank IC: {mean_val_ic:8.4f} (树模型最佳迭代步数: {ranker.model.best_iteration})")

    # 3. 查看特征重要性
    print("\n>>> 模型特征分裂增益 (Gain) 排名:")
    print(ranker.get_feature_importance())

核心运行输出与特征增益剖析: ​

训练集规模: 43,500 行 | 验证集规模: 14,700 行

>>> 开始训练 LightGBM LambdaMART 截面排序器...
✅ 验证集平均 Rank IC:   0.2398 (树模型最佳迭代步数: 173)

>>> 模型特征分裂增益 (Gain) 排名:
shape: (8, 3)
┌──────────┬─────────────────┬─────────────┐
│ feature  ┆ gain_importance ┆ split_count │
│ ---      ┆ ---             ┆ ---         │
│ str      ┆ f64             ┆ i32         │
╞══════════╪═════════════════╪═════════════╡
│ factor_0 ┆ 2832.001484     ┆ 354         │
│ factor_1 ┆ 2229.821462     ┆ 347         │
│ factor_2 ┆ 396.601158      ┆ 359         │
│ factor_3 ┆ 66.493592       ┆ 177         │
│ factor_4 ┆ 54.932583       ┆ 169         │
│ factor_7 ┆ 54.615559       ┆ 173         │
│ factor_6 ┆ 42.035421       ┆ 117         │
│ factor_5 ┆ 37.808619       ┆ 154         │
└──────────┴─────────────────┴─────────────┘
  1. 高稳健验证集 Rank IC:
    • 树模型在迭代 173 步时触发早停,验证集平均 Rank IC 达到 0.2398,展现了极其强大的多因子非线性特征合成能力;
  2. 多因子非线性层级浮现:
    • factor_0 与 factor_1 的分裂增益(Gain)占据绝对统治地位,而弱相关因子被排挤在下层节点,实现了自适应加权。

6. 本讲小结与课后动手作业 ​

核心知识图谱梳理: ​

  1. GBDT 的表格统治力:对非线性、单调变换与连续尺度异质性的天然免疫,是金融表格数据建模的首选;
  2. LambdaMART 优于 MSE:通过 log2⁡(i+1) 位置折损与 |ΔNDCG| 虚拟梯度,集中模型火力纠正头部 Top 10% 股票排序;
  3. 严格超参数约束:极浅树深(3~5)、大样本叶子(100~500)与高正则惩罚,是抵抗金融低信噪比过拟合的核心底牌。

课后动手实训: ​

实操任务:

  1. 打开 tutorial/examples/lecture_19_lightgbm_catboost_ranking.py。
  2. 将目标函数模式从 objective="lambdarank" 切换为 objective="regression"(均方误差 MSE),保持其他超参数完全一致。
  3. 重新运行训练,对比两种模式下验证集的平均 Rank IC 与前 10% 优质股票的命中率差异。

QUANTML · Quantitative Research Handbook