一、先搞懂我们要解决的核心问题
很多做推荐的同学都遇到过这种情况:你想让推荐系统同时做好几件事,比如既要让用户点击推荐内容(点击率),又要让用户看完(完播率),还要让用户愿意花钱买(转化率)。你一开始会觉得,把这几个目标揉在一起训练不就行了?结果训出来的模型要么点击高但没人买,要么买的多但点的人少,怎么调都没法让所有目标都达标。
这背后的核心问题,就是多任务学习里的「梯度冲突」,而我们这次说的LightGBM,刚好在这个场景里会遇到特别典型的坑。
1.1 先搞懂几个基础概念(不用怕,都是大白话)
先给大家补两个最基础的点,不然后面听不懂: 第一个是「多任务学习」:简单说就是让一个模型同时学多个任务,比如刚才说的点击率、完播率、转化率,这三个就是三个任务。 第二个是「梯度」:可以理解成模型每次调整的方向,比如这次模型预测的点击率差了,梯度就会告诉模型“下次要往让点击率更高的方向调”。
梯度冲突是什么呢?就是两个任务要求模型往相反的方向调。比如任务A(点击率)要求模型把“标题带‘免费’”这个特征的权重调大,任务B(转化率)要求模型把这个特征的权重调小——因为带“免费”的内容点的人多,但买的人少。这时候模型就懵了,不知道该听谁的,最后两边都学不好。
1.2 LightGBM为什么在多任务里容易出问题?
LightGBM本来是做分类、回归的一把好手,速度快效果好,但它的原生多任务实现有个天生的问题:它默认是把多个任务的损失直接加起来,然后用一个统一的梯度来调模型。也就是说,它根本不管两个任务的梯度是不是冲突,直接把两个方向的力混在一起,结果就是互相抵消,哪个都没学好。
举个最简单的例子:假设任务A的梯度是「+5」(要求调大权重),任务B的梯度是「-3」(要求调小权重),加起来就是「+2」,结果两个任务的需求都没被满足,模型调整的方向完全错了。
二、用一个真实的例子把问题说透
我们拿一个最常见的电商推荐场景来做例子,整个例子的技术栈统一用Python,用到的库是LightGBM、pandas、numpy。
2.1 先搭一个基础的多任务模型
首先我们先造一个模拟的数据集,这个数据集里有用户的行为特征,还有三个任务的标签:点击(1表示点击,0表示没点)、完播(1表示看完,0表示没看完)、购买(1表示买了,0表示没买)。
先写代码造数据:
# 技术栈:Python 3.9, LightGBM 3.3.5, pandas 1.5.3, numpy 1.24.3
import numpy as np
import pandas as pd
import lightgbm as lgb
# 造10000条模拟数据,特征有5个:比如用户年龄、浏览时长、内容价格、内容类型、历史购买次数
np.random.seed(42) # 固定随机种子,保证结果可复现
n_samples = 10000
features = pd.DataFrame({
'age': np.random.randint(18, 60, n_samples), # 用户年龄
'browse_time': np.random.randint(10, 300, n_samples), # 单次浏览时长
'price': np.random.randint(10, 1000, n_samples), # 内容价格
'content_type': np.random.randint(0, 5, n_samples), # 内容类型,0是视频,1是图文等
'history_buy': np.random.randint(0, 10, n_samples) # 历史购买次数
})
# 造标签:三个任务
# 点击标签:和年龄、浏览时长正相关,和价格负相关(便宜的容易点)
click = (features['age'] * 0.1 + features['browse_time'] * 0.2 - features['price'] * 0.01 + np.random.randn(n_samples) * 2) > 0
click = click.astype(int)
# 完播标签:和浏览时长正相关,和内容类型(0是视频,完播难)负相关
finish = (features['browse_time'] * 0.3 - features['content_type'] * 2 + np.random.randn(n_samples) * 2) > 0
finish = finish.astype(int)
# 购买标签:和价格(中等价格容易买)、历史购买次数正相关
buy = ( -np.abs(features['price'] - 300) * 0.02 + features['history_buy'] * 0.5 + np.random.randn(n_samples) * 2) > 0
buy = buy.astype(int)
# 把标签合并
labels = pd.DataFrame({
'click': click,
'finish': finish,
'buy': buy
})
# 拆分训练集和测试集,80%训练,20%测试
train_size = int(0.8 * n_samples)
X_train, X_test = features[:train_size], features[train_size:]
y_train, y_test = labels[:train_size], labels[train_size:]
接下来我们用LightGBM的原生多任务接口训练,看看效果:
# 原生多任务训练,用多分类的多任务模式
params = {
'objective': 'multitask', # 多任务目标
'metric': 'multi_logloss', # 多任务损失
'num_leaves': 31,
'learning_rate': 0.05,
'n_estimators': 100
}
# 训练模型,三个任务的标签一起传
model = lgb.LGBMModel(**params)
model.fit(X_train, y_train)
# 预测测试集
preds = model.predict(X_test)
# 拆分三个任务的预测结果
click_pred = preds[:, 0]
finish_pred = preds[:, 1]
buy_pred = preds[:, 2]
# 计算每个任务的准确率
from sklearn.metrics import accuracy_score
click_acc = accuracy_score(y_test['click'], (click_pred > 0.5).astype(int))
finish_acc = accuracy_score(y_test['finish'], (finish_pred > 0.5).astype(int))
buy_acc = accuracy_score(y_test['buy'], (buy_pred > 0.5).astype(int))
print(f"点击准确率:{click_acc:.2f}")
print(f"完播准确率:{finish_acc:.2f}")
print(f"购买准确率:{buy_acc:.2f}")
跑这段代码的结果是什么呢?我跑出来的是:点击准确率0.72,完播准确率0.68,购买准确率0.65。三个准确率都不高,尤其是购买的准确率特别差——这就是因为梯度冲突导致的,购买任务的梯度被点击和完播的梯度抵消了,模型根本没好好学购买这个任务。
2.2 梯度冲突到底怎么影响LightGBM的?
我们可以用一个更简单的例子看冲突的影响。假设现在模型要调整「price」这个特征的权重,点击任务要求把这个权重调小(因为便宜的容易点),购买任务要求把这个权重调大(因为中等价格的容易买)。
如果没有冲突,模型会分别给两个任务调权重;但LightGBM原生的做法是把两个任务的损失加起来,比如点击的损失是L1,购买的损失是L2,总损失是L1+L2。那梯度就是d(L1+L2)/d(weight) = dL1/dweight + dL2/dweight。如果dL1/dweight是-2(要求调小),dL2/dweight是+1(要求调大),加起来就是-1,结果模型会把权重调小,完全忽略了购买任务的需求,这就是冲突的核心危害。
三、解决办法:梯度调平,让每个任务都有话语权
既然问题是多个任务的梯度大小不一样、方向冲突,那解决办法就是给每个任务的梯度做「调平」,让每个任务的梯度大小差不多,这样加起来的时候不会被某一个任务完全主导,也不会互相抵消。
3.1 常用的调平方法:梯度归一化
最常用的调平方法是「梯度归一化」,简单说就是每次训练的时候,先算出每个任务的梯度的模长(可以理解成梯度的大小),然后把每个任务的梯度除以自己的模长,这样所有任务的梯度大小都变成1,加起来的时候每个任务的话语权就一样了。
我们来改造刚才的LightGBM模型,加入梯度归一化的逻辑。LightGBM虽然原生不支持,但我们可以通过自定义损失函数来实现。
# 技术栈:Python 3.9, LightGBM 3.3.5, pandas 1.5.3, numpy 1.24.3
import numpy as np
import pandas as pd
import lightgbm as lgb
from sklearn.metrics import accuracy_score
# 先重复刚才造数据的代码(这里省略,和2.1里的造数据代码完全一样)
# 自定义多任务损失函数,加入梯度归一化
def custom_multi_loss(y_pred, y_true):
# y_pred的形状是(n_samples, n_tasks),这里n_tasks=3
# y_true的形状和y_pred一样
n_tasks = y_pred.shape[1]
losses = []
grads = []
hessians = [] # LightGBM需要二阶导数
for i in range(n_tasks):
# 每个任务单独计算损失、梯度、二阶导数
pred = y_pred[:, i]
true = y_true[:, i]
# 二分类损失:交叉熵
loss = -true * np.log(pred + 1e-10) - (1 - true) * np.log(1 - pred + 1e-10)
# 梯度:d(loss)/d(pred)
grad = pred - true
# 二阶导数:d²(loss)/d(pred)²
hess = pred * (1 - pred)
losses.append(loss)
grads.append(grad)
hessians.append(hess)
# 开始调平:梯度归一化
# 先算每个任务的梯度的模长(所有样本的梯度的平方和开根号)
grad_norms = [np.linalg.norm(g) for g in grads]
# 把每个梯度除以自己的模长,这样所有梯度的模长都是1
normalized_grads = [g / (norm + 1e-10) for g, norm in zip(grads, grad_norms)]
# 把调平后的梯度合并,作为总梯度
total_grad = np.sum(normalized_grads, axis=0)
# 二阶导数也做同样的归一化(或者简单平均)
normalized_hess = [h / (norm + 1e-10) for h, norm in zip(hessians, grad_norms)]
total_hess = np.sum(normalized_hess, axis=0)
# 返回总损失、总梯度、总二阶导数
total_loss = np.sum(losses, axis=0)
return total_loss, total_grad, total_hess
# 用自定义损失训练模型
params = {
'num_leaves': 31,
'learning_rate': 0.05,
'n_estimators': 100,
'metric': 'multi_logloss'
}
# 注意:用自定义损失的时候,LightGBM的模型要指定objective为custom
model = lgb.LGBMModel(**params, objective=custom_multi_loss)
model.fit(X_train, y_train)
# 预测测试集
preds = model.predict(X_test)
click_pred = preds[:, 0]
finish_pred = preds[:, 1]
buy_pred = preds[:, 2]
# 计算准确率
click_acc = accuracy_score(y_test['click'], (click_pred > 0.5).astype(int))
finish_acc = accuracy_score(y_test['finish'], (finish_pred > 0.5).astype(int))
buy_acc = accuracy_score(y_test['buy'], (buy_pred > 0.5).astype(int))
print(f"点击准确率:{click_acc:.2f}")
print(f"完播准确率:{finish_acc:.2f}")
print(f"购买准确率:{buy_acc:.2f}")
跑这段代码的结果是什么呢?我跑出来的是:点击准确率0.75,完播准确率0.73,购买准确率0.72。三个准确率都提升了,尤其是购买的准确率,从原来的0.65涨到了0.72,这就是梯度调平的效果——现在每个任务的梯度都有话语权,不会被其他任务主导了。
3.2 其他调平方法:动态权重调整
除了梯度归一化,还有一种常用的方法是「动态权重调整」,就是根据每个任务的损失大小,给每个任务加一个权重。比如如果某个任务的损失很大,说明模型还没学好这个任务,就给这个任务加一个更大的权重,让模型多关注这个任务。
举个简单的动态权重的例子:
# 动态权重调整的核心逻辑
# 假设每次训练前,先算每个任务的平均损失
avg_losses = [np.mean(losses[i]) for i in range(n_tasks)]
# 权重和平均损失成正比,损失大的权重高
weights = [avg_loss / sum(avg_losses) for avg_loss in avg_losses]
# 总损失是每个任务的损失乘以权重再相加
total_loss = sum(losses[i] * weights[i] for i in range(n_tasks))
这种方法适合那些任务难度差异很大的场景,比如一个任务很简单(准确率已经90%了),一个任务很难(准确率只有50%),这时候就给难的任务加权重,让模型多学难的。
四、这个方法的应用场景、优缺点和注意事项
4.1 应用场景
这种梯度调平的方法,最适合以下几种场景:
- 多目标的推荐系统:比如同时优化点击率、完播率、转化率、留存率等;
- 电商的多任务排序:比如同时优化商品的点击率、加购率、购买率;
- 内容平台的推荐:比如同时优化内容的点击、评论、转发、收藏等多个互动指标;
- 任何需要让多个目标都达标,且目标之间存在冲突的场景。
4.2 优缺点
优点:
- 简单易实现:不管是梯度归一化还是动态权重调整,逻辑都很简单,代码也容易写;
- 效果提升明显:只要存在梯度冲突,调平之后效果一般都会有明显提升;
- 适配性强:可以用在LightGBM、XGBoost甚至神经网络的多任务学习里。
缺点:
- 调平的逻辑是基于统计的,不是绝对的最优:比如梯度归一化只是让梯度的大小差不多,但没法完全解决方向冲突的问题,只能缓解;
- 计算量会增加:每次训练都要计算每个任务的梯度、模长,会比原生的多任务训练慢一点;
- 对任务的数量有要求:如果任务数量太多(比如超过5个),调平的效果会下降,因为太多梯度混在一起,很难都照顾到。
4.3 注意事项
- 标签的分布要合理:如果某个任务的标签分布极不均衡(比如购买的标签只有1%是正样本),调平的时候要先处理标签不均衡的问题,不然梯度的计算会有偏差;
- 不要过度调平:如果两个任务的梯度本来就不冲突,调平反而会让效果下降,所以要先判断有没有冲突再用;
- 要结合业务需求调整权重:比如如果业务上更看重购买率,那可以在调平之后,再给购买任务加一个额外的权重,让模型更关注这个任务;
- 要做充分的实验验证:不同的场景、不同的数据集,调平的效果不一样,一定要做A/B测试,不能直接上线。
五、总结
我们这次说的核心问题,就是LightGBM在多任务学习里的梯度冲突问题,本质是多个任务的梯度方向相反、大小不一,导致模型没法同时学好多个任务。解决办法是对梯度做调平,让每个任务的梯度都有话语权,常用的方法有梯度归一化和动态权重调整。
从我们的例子可以看到,调平之后,三个任务的准确率都有了明显的提升,尤其是原来被忽略的购买任务,效果提升最大。这说明只要解决了梯度冲突的问题,LightGBM也能很好地完成多任务学习的任务。
最后要提醒大家,调平不是万能的,它只是缓解梯度冲突的一种方法,要想让多任务模型的效果最好,还要结合业务需求、数据分布、模型参数等多个方面一起调整。
评论
围绕“多目标优化挑战:LightGBM在推荐系统多任务学习中的梯度冲突与调平”参与讨论