在LightGBM中构建自定义多分类损失函数的技术咨询
问题概述
基于给定的F1赛事数据集,使用LightGBM实现多分类任务,以Win_odds_1~Win_odds_4为特征预测赛事胜者。需自定义包含对数似然项+正则项的损失函数(正则系数λ=1),其中$o_{i,k}$代表样本$i$对应类别$k$的Win_odds。当前核心难点:
- 无法推导并编码该损失的梯度与海森矩阵
- 目标为一维标签,LightGBM API无法直接向损失函数传递Win_odds这类额外数据
数据集与宽格式转换
长格式数据集
import pandas as pd import numpy as np import lightgbm as lgb data = { "Race_ID": [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4], "Racer_Number": [1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4], "Win_odds": [2.3, 4.3, 1.3, 5.0, 3.4, 1.1, 2.3, 2.0, 7.4, 3.1, 1.8, 6.0, 2.4, 5.2, 3.5, 3.0, 1.4, 3.2, 2.5, 5], "Rank": [3, 2, 1, 4, 2, 1, 4, 3, 4, 2, 1, 3, 4, 2, 3, 1, 1, 2, 3, 4] } df_long = pd.DataFrame(data)
转换为宽格式(单条记录对应一场赛事)
df_wide = df_long.pivot( index="Race_ID", columns="Racer_Number", values=["Win_odds","Rank"] ) df_wide.columns = [f"{col[0]}_{col[1]}" for col in df_wide.columns] df_wide = df_wide.reset_index() # 目标变量:取Rank最小的车手索引(0~3对应车手1~4) df_wide['target'] = df_wide.apply(lambda row: np.argmin(row['Rank_1':'Rank_4']) , axis=1)
自定义损失函数定义
损失函数的通用数学形式($N$为样本数,$K$为类别数,$y_i$为样本$i$的真实类别,$p_{i,k}$为模型预测的类别$k$概率):
$$
L = -\frac{1}{N}\sum_{i=1}^N \left[ \log(p_{i,y_i}) - \sum_{k=1}^K (p_{i,k} - \frac{1}{o_{i,k}})^2 \right]
$$
其中:
- 第一项$\log(p_{i,y_i})$是标准多分类对数似然项,衡量预测概率与真实标签的匹配度
- 第二项$\sum_{k=1}^K (p_{i,k} - \frac{1}{o_{i,k}})^2$是正则项,约束预测概率接近Win_odds的倒数(即隐含的胜率预期)
梯度与海森矩阵推导
LightGBM的自定义损失需要对每个样本的每个类别计算梯度(一阶导)和海森(二阶导)。模型输出为logit值,需先通过softmax转换为概率$p_{i,k} = \frac{e{z_{i,k}}}{\sum_{m=1}K e^{z_{i,m}}}$($z_{i,k}$为logit)。
梯度推导
对单个样本$i$的类别$j$,梯度为损失函数对$z_{i,j}$的一阶导:
$$
g_{i,j} = \mathbb{I}(y_i=j) - p_{i,j} + 2p_{i,j}\left[ p_{i,j}(1-2/o_{i,j}) + \sum_{k≠j} p_{i,k}/o_{i,k} - \sum_{k=1}^K p_{i,k}^2 \right]
$$
其中$\mathbb{I}(y_i=j)$是指示函数,真实类别为$j$时取1,否则取0。
海森矩阵推导
二阶导(海森)为:
$$
\begin{align*}
h_{i,j} &= -p_{i,j}(1-p_{i,j}) + 2\left[ (1-2p_{i,j})A + p_{i,j}(B - C) - 2p_{i,j}^2(1-p_{i,j}) \right] \
A &= p_{i,j}(1-2/o_{i,j}) + \sum_{k≠j} p_{i,k}/o_{i,k} - \sum_{k=1}^K p_{i,k}^2 \
B &= (1-2/o_{i,j})(1-2p_{i,j}) \
C &= \sum_{k=1}^K p_{i,k}/o_{i,k}
\end{align*}
$$
解决方案实现
1. 数据预处理(提取关键矩阵)
# 特征矩阵(Win_odds_1~4) X = df_wide[['Win_odds_1', 'Win_odds_2', 'Win_odds_3', 'Win_odds_4']].values # 目标标签(0~3) y = df_wide['target'].values # 预计算1/o_{i,k}矩阵,用于正则项 inv_odds_matrix = 1 / X
2. 闭包形式的自定义损失函数(解决额外数据传递问题)
通过闭包捕获inv_odds_matrix,让LightGBM的损失函数可以访问该额外数据:
def custom_multiclass_loss(inv_odds, lambda_reg=1): def loss(preds, train_data): labels = train_data.get_label() num_samples = preds.shape[0] num_classes = preds.shape[1] # 将logit转换为概率(softmax) exp_preds = np.exp(preds) p = exp_preds / np.sum(exp_preds, axis=1, keepdims=True) grad = np.zeros_like(preds) hess = np.zeros_like(preds) for i in range(num_samples): y_i = int(labels[i]) p_i = p[i] inv_o_i = inv_odds[i] # 预计算全局项,减少重复计算 sum_p_sq = np.sum(p_i ** 2) sum_p_inv_o = np.sum(p_i * inv_o_i) for j in range(num_classes): # 计算梯度 indicator = 1 if j == y_i else 0 term_a = indicator - p_i[j] term_b = p_i[j] * ( p_i[j]*(1 - 2*inv_o_i[j]) + (sum_p_inv_o - p_i[j]*inv_o_i[j]) - sum_p_sq ) grad[i,j] = term_a + 2 * lambda_reg * term_b # 计算海森 A = p_i[j]*(1 - 2*inv_o_i[j]) + (sum_p_inv_o - p_i[j]*inv_o_i[j]) - sum_p_sq B = (1 - 2*inv_o_i[j])*(1 - 2*p_i[j]) C = sum_p_inv_o d_term_b = (1 - 2*p_i[j])*A + p_i[j]*(B - C) - 2*p_i[j]**2*(1 - p_i[j]) hess[i,j] = -p_i[j]*(1 - p_i[j]) + 2 * lambda_reg * d_term_b return grad, hess return loss
3. 训练模型
# 创建LightGBM数据集 train_data = lgb.Dataset(X, label=y) # 模型参数 params = { 'objective': None, # 禁用内置目标,使用自定义损失 'metric': 'None', # 禁用内置评估指标 'num_class': 4, 'boosting_type': 'gbdt', 'learning_rate': 0.1, 'verbose': 1 } # 初始化自定义损失函数 my_loss = custom_multiclass_loss(inv_odds_matrix, lambda_reg=1) # 训练模型 model = lgb.train(params, train_data, num_boost_round=100, fobj=my_loss)
关键注意事项
- 闭包的使用:通过外层函数传递额外数据(如
inv_odds_matrix),避免了LightGBM API无法直接传递参数的限制。 - 计算效率:预计算
sum_p_sq和sum_p_inv_o等全局项,减少循环内的重复计算,提升训练速度。 - 多分类适配:LightGBM的多分类任务中,
preds是(num_samples, num_classes)形状的数组,梯度和海森矩阵需保持相同形状。
内容的提问来源于stack exchange,提问作者Ishigami

