导语: 在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 log1−pp=−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−(1−y)log(1−p),特意强调一下,这里的 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=p−y=p(1−p)
因此,可以通过以下函数来实现自定义的损失函数和评估函数:
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
中通过参数fobj
和feval
来自定损失函数和评估函数,代码如下:
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−(1−yi)log(1−p)],同时,我们也知道其一阶导数(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(p−yi);
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} dxdLN∗pp=i=1∑N(p−yi)=0=i=1∑Nyi=N∑i=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