LightGBM自定义损失函数的正确写法

导语: 在LightGBM中可以通过自定义损失函数和评价函数来解决新问题, 但在自定义损失函数时可能会忽略一些细节,导致效果不佳,收敛速度减慢。本文的背景是在GitHub的一个issues中读到了关于自定义的和官方提供的logloss不能完全复现的解决过程,当时没有太在意这个细节, 近期又读到作者关于当前开源的FocalLoss有相同的问题说法, 本文主要是拾人牙慧,在理解后的基础上,补充点笔记,跟着大佬再走一遍流程,以便加深记忆.

本文首发于公众号、知乎专栏: 一直学习一直爽


根据导语中提及的 issues , 按图索骥找到作者MaxHalford的个人主页,里面包含了logloss复现过程和实现FocalLoss博客内容 Focal loss implementation for LightGBM, 点击这两个链接你可以知道所有的细节.

复现logloss不可不知道的细节

在这一节中,展现了复现logloss遇到的坑, 同时作者做事情的风格也很值得学习:对于不确定的事情, 寻找到合适的参照物,对发现不合理的细节,深入探索.

在复现过程时,作者预期按照官方文档自定义损失函数的教程,在设置完全相同的超参数和随机数情况下,模型迭代的次数,最终的logloss值要和官方的一致才合理,然而在AUC, logloss上两者相差了万7, 万1,这个看起来不太明显的差异的点,但是迭代次数从352增加到了874,收敛速度显著下降.

那么,哪些细节造成了这两种的差异呢?

  • 细节1: 自定义损失函数后,模型的输出不在是 [ 0 , 1 ] [0,1] [0,1]概率输出,而是sigmoid函数之前的输入值,即样本落在各颗树中对应叶子节点权重之和,因此在改变训练的损失函数时,评价函数也需要做相应的调整;
  • 细节2: 自定义损失函数后,LightGBM默认的boost_from_average=True失效,按照GBDT的框架,对于利用logloss来优化的二分类问题,样本的初始值为训练集标签的均值,在自定义损失函数后,系统无法获取到这个初始化值,导致收敛速度变慢;
  • 细节3: 对于细节2中提及的样本初始值问题,解决办法是在构建lgb.Dataset时,利用init_score参数手动完成,具体取值问题要结合优化算法来得到,后续会具体介绍;

下面代码中的数据来源于作者的博客, 通过代码的方式来展示上述差异,使得问题更清晰.

导入需要的package

import sys
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score, log_loss
import random
import lightgbm as lgb
import seaborn as sns
import warnings
from scipy import special, optimize


pd.set_option('display.max_rows', 500)
pd.options.display.float_format = '{:.06f}'.format
from IPython.core.display import display, HTML
display(HTML("<style>.container { width:80% !important; }</style>"))

warnings.filterwarnings('ignore')
%matplotlib inline
%load_ext autoreload
%autoreload 2

导入数据,并做简单的数据切分

# 看完本文,你肯定可以知道数据怎么下载
df = pd.read_csv('./creditcard.csv')
X = df.drop(columns='Class')
y = df['Class']

# 切分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
# 从训练集中, 切分出部分验证集
X_fit, X_val, y_fit, y_val = train_test_split(X_train, y_train, random_state=42)

模型训练V1:官方损失函数版本

fit = lgb.Dataset(X_fit, y_fit)
val = lgb.Dataset(X_val, y_val, reference=fit)

model = lgb.train(
    params={
        'learning_rate': 0.01,
        'objective': 'binary',
        'seed': 2021
    },
    train_set=fit,
    num_boost_round=10000,
    valid_sets=(fit, val),
    valid_names=('fit', 'val'),
    early_stopping_rounds=20,
    verbose_eval=100
)

y_pred = model.predict(X_test)

官方实现的损失函数训练日志如下图所示,注意打印出来的日志中的几个细节点:

  • 通过binary:BoostFromScore,计算出了样本的初始值,由于训练集中的p = np.mean(y_fit) = 0.001767,将其转换到sigmoid函数之前的输入,即 log ⁡ p 1 − p = − 6.336982 \log \frac{p}{1 - p} = -6.336982 log1pp=6.336982;
  • 由于是利用官方损失函数,那么y_pred = model.predict(X_test)取值为 [ 0 , 1 ] [0,1] [0,1]之间;
  • 模型训练了352轮,在测试集上AUC,logloss取值分别为: 0.97772, 0.00237
    在这里插入图片描述

模型训练V2:自定义损失函数-无初始化

在LightGBM中,自定义损失函数需要返回损失函数的一阶(grad)和二阶(hess)导数,对于logloss损失函数而言,其一阶和二阶导数在之前的文章中做过详细推导,这里直接把结果复制过来, L ( y , p ) = − y log ⁡ p − ( 1 − y ) log ⁡ ( 1 − p ) L(y,p) = -y \log p - (1-y) \log(1-p) L(y,p)=ylogp(1y)log(1p),特意强调一下,这里的 y y y的取值为 { 0 , 1 } \{0,1\} { 0,1},在不同的损失函数中 y y y的取值可能不一样,有些是 { − 1 , 1 } \{-1, 1\} { 1,1}:
g r a d = p − y h e s s = p ( 1 − p ) \begin{aligned} grad &= p - y \\ hess &= p(1-p) \end{aligned} gradhess=py=p(1p)

因此,可以通过以下函数来实现自定义的损失函数和评估函数:

def logloss_objective(preds, train_data):
    y = train_data.get_label()
    p = special.expit(preds)
    grad = p - y
    hess = p * (1 - p)
    return grad, hess

def logloss_metric(preds, train_data):
    y = train_data.get_label()
    p = special.expit(preds)

    ll = np.empty_like(p)
    pos = y == 1
    ll[pos] = np.log(p[pos])
    ll[~pos] = np.log(1 - p[~pos])

    is_higher_better = False
    return 'logloss', -ll.mean(), is_higher_better

lgb.train中通过参数fobjfeval来自定损失函数和评估函数,代码如下:

model = lgb.train(
    params={
        'learning_rate': 0.01,
        'seed':2021
    },
    train_set=fit,
    num_boost_round=10000,
    valid_sets=(fit, val),
    valid_names=('fit', 'val'),
    early_stopping_rounds=20,
    verbose_eval=100,
    fobj=logloss_objective,
    feval=logloss_metric
)

y_pred = special.expit(model.predict(X_test))

print()
print(f"Test's ROC AUC: {roc_auc_score(y_test, y_pred):.5f}")
print(f"Test's logloss: {log_loss(y_test, y_pred):.5f}")

训练日志如下图所示,注意打印出来的日志中的几个细节点:

  • 对于官方版本,这里无样本初始化过程;
  • 自定义损失函数,需要手动进行sigmoid函数变换:y_pred = special.expit(model.predict(X_test))
  • 模型训练了874轮,在测试集上AUC,logloss取值分别为: 0.97701, 0.00227, 与官方版本不一致,原因在细节2,细节3中已经解释,下面通过加入初始化来验证;
    在这里插入图片描述

模型训练V3:自定义损失函数-增加初始化步骤

lgb.Dataset中利用init_score参数来完成,由于是二分类任务,可以推导出只需要利用训练集标签的均值即可,推导过程如下:

step1: 假设训练集中有 N N N个样本,那么损失函数为 L ( y , p ) = ∑ i = 1 N [ − y i log ⁡ p − ( 1 − y i ) log ⁡ ( 1 − p ) ] L(y,p) = \sum_{i=1}^N [-y_i \log p - (1-y_i) \log(1-p) ] L(y,p)=i=1N[yilogp(1yi)log(1p)],同时,我们也知道其一阶导数(grad)为: d L d x = ∑ i = 1 N ( p − y i ) \frac{d L}{dx} = \sum_{i=1}^N (p - y_i) dxdL=i=1N(pyi);
step2:要使得到 L ( y , p ) L(y,p) L(y,p)的最小值,令一阶导数为0即可:
d L d x = ∑ i = 1 N ( p − y i ) = 0 N ∗ p = ∑ i = 1 N y i p = ∑ i = 1 N y i N \begin{aligned} \frac{d L}{dx} &= \sum_{i=1}^N (p - y_i) = 0 \\ N*p &= \sum_{i=1}^N y_i \\ p &= \frac{\sum_{i=1}^N y_i}{N} \end{aligned} dxdLNpp=i=1N(pyi)=0=i=1Nyi=Ni=1Nyi

通过函数实现:

def logloss_init_score(y):
    p = y.mean()
    p = np.clip(p, 1e-15, 1 - 1e-15)  # never hurts
    log_odds = np.log(p / (1 - p))
    return log_odds

重新训练:

fit = lgb.Dataset(X_fit, y_fit, init_score=np.full_like(y_fit, logloss_init_score(y_fit), dtype=float))
val = lgb.Dataset(X_val, y_val, reference=fit,init_score=np.full_like(y_val, logloss_init_score(y_val), dtype=float))

model = lgb.train(
    params={
        'learning_rate': 0.01,
        'seed': 2021
    },
    train_set=fit,
    num_boost_round=10000,
    valid_sets=(fit, val),
    valid_names=('fit', 'val'),
    early_stopping_rounds=20,
    verbose_eval=100,
    fobj=logloss_objective,
    feval=logloss_metric
)

y_pred = special.expit(logloss_init_score(y_fit) + model.predict(X_test))
print()
print(f"Test's ROC AUC: {roc_auc_score(y_test, y_pred):.5f}")
print(f"Test's logloss: {log_loss(y_test, y_pred):.5f}")

通过打印出来的训练过程日志可以发现,其结果与官方自带版本一致了!在这里插入图片描述

自定义损失函数的框架

在开始写自定义损失函数之前,需要先明确损失的类型,确定是训练的损失函数,还是用于early stop的评估函数,下面的步骤是针对训练损失函数而言的:
step 1: 定义出新的损失函数;
step 2: 写出损失函数关于输入的一阶导数和二阶导数形式;
step 3: 根据LightGBM的规范,分别写出训练损失函数和评估函数,如果训练和评估都使用相同的评估函数,那么自定义损失函数后模型的输出已经发生改变,评估函数也需要重写;
step 4: 寻找一个样本初始化值,该值对任何样本来说取值都是一样的,为常数,因此只需要满足使得训练损失函数一阶导数为0即可,如果手动计算麻烦,可以通过scipy的优化器来求解,该求解过程仅涉及到真实的标签和自定义的损失函数形式;
step 5: 在测试集中预测时,将上述初始值和样本落在对应的叶子节点上权重求和,利用sigmoid函数进行转换即可

实战:FocalLoss的实现过程

有了上述复现logloss的知识,我们就可以来实现一下FocalLoss了.FocalLoss是在论文Focal Loss for Dense Object Detection提出的,我们不需要去了解其中大量关于CV中目标检测的相关背景,只需要知道该损失函数要解决的问题是:重新分配正负样本的权重,使得模型对于训练得好的简单样本权重降低,困难样本权重增加,这就是其名字中Focal的来意,模型重点关注困难样本.

定义

对于logloss或者分类模型中二分类交叉熵损失函数:
C E ( p , y ) = { − log ⁡ ( p ) , y = 1 − log ⁡ ( 1

### 创建自定义 MSE 损失函数 为了在 LightGBM 中实现自定义的均方误差 (MSE) 损失函数,可以利用 `lightgbm.LGBMRegressor` 或者 `lgb.train()` 方法中的 `custom_loss_` 参数来指定自定义的目标函数。下面展示了一个具体的例子: #### 自定义目标函数的要求 自定义目标函数应当返回两个列表:梯度和海森矩阵(Hessian)。对于回归问题来说,这两个量分别对应于预测值与真实标签之间的差值及其平方。 ```python import numpy as np import lightgbm as lgb from sklearn.datasets import make_regression from sklearn.model_selection import train_test_split def custom_mse(y_true, y_pred): """计算并返回自定义的MSE损失""" grad = y_pred - y_true # 计算一阶导数即残差 hess = np.ones_like(grad) # 对于MSE而言,二阶导数恒等于1 return 'mse_custom', grad, hess # 构建数据集 X, y = make_regression(n_samples=1000, n_features=20, noise=0.1) train_x, test_x, train_y, test_y = train_test_split( X, y, test_size=0.2) # 将训练数据转换成Dataset对象 lgb_train = lgb.Dataset(train_x, label=train_y) params = { "objective": "regression", "metric": {"rmse"}, } evals_result = {} bst = lgb.train(params, lgb_train, valid_sets=[lgb_train], fobj=custom_mse, # 使用自定义loss function evals_result=evals_result, verbose_eval=False) print('Training finished.') ``` 此代码片段展示了如何通过传递给 `fobj` 参数来自定义目标函数的方式,在 LightGBM 的训练过程中应用自定义的 MSE 函数[^1]。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值