让一个模型同时学会多种任务——从短视频推荐到电商转化预测
MMoE(Multi-gate Mixture-of-Experts,多门控混合专家网络)就是为这类多任务学习问题设计的架构。它通过共享专家网络、为不同任务设置独立门控,实现任务间的知识共享与差异化学习。
本文结合短视频推荐和电商转化预测两个典型案例,介绍 MMoE 的原理、PyTorch 实践方法,以及它在工程落地时需要注意的问题。
一、MMoE 究竟解决什么问题?
MMoE 来源于 2018 年 KDD 论文《Modeling Task Relationships in Multi-task Learning with Multi-gate Mixture-of-Experts》,最初主要面向多任务推荐系统。
理解 MMoE,先要理解传统多任务学习的困境。
假设某个短视频推荐系统有两个任务:
- 任务 A:预测用户是否看完视频。
- 任务 B:预测用户是否点赞视频。
这两个任务有一定相关性,但并不完全一致。一个用户可能认真看完一段视频,却并不喜欢它;也可能因为内容很有趣,刚看几秒就点赞。
传统 Shared-Bottom 多任务学习架构会让两个任务共用底层特征提取网络,再分别输出预测结果。
问题是,共享网络的参数同时受到两个任务的梯度影响。当任务目标存在冲突时,某个任务的优化可能损害另一个任务,这就是所谓的负迁移(Negative Transfer)。
MMoE 的核心思路是:共享多个专家网络,但让每个任务自己决定如何组合这些专家。
MMoE 的三个核心组件
输入特征 X
|
+---------+---------+
| | |
Expert 1 Expert 2 Expert 3
| | |
+---------+---------+
|
+---------+---------+
| |
Gate A Gate B
| |
加权组合专家 加权组合专家
| |
Tower A Tower B
| |
完播概率 点赞概率
Expert(专家网络):多个可训练的神经网络,共同提取输入特征。它们并不是人为指定的“完播专家”或“点赞专家”,而是在联合训练中学习不同的特征表示。
Gate(门控网络):每个任务拥有自己的 Gate,根据输入特征动态计算不同专家的权重。
Tower(任务塔):接收门控加权后的特征,进一步学习该任务特有的规律,最终输出预测结果。
以某个用户样本为例,假设三个专家的门控权重为:
| 专家 | 完播任务 Gate | 点赞任务 Gate |
|---|---|---|
| Expert 1 | 0.6 | 0.1 |
| Expert 2 | 0.3 | 0.2 |
| Expert 3 | 0.1 | 0.7 |
这是一组示意权重,并非实验数据。
可以看到,虽然两个任务使用相同的专家网络,但它们关注的专家不同,而且这些权重还可以随输入样本变化。
其数学表达式为:
[
h_k(x)=\sum_{i=1}^{N}g_{k,i}(x)E_i(x)
]
其中:
- (E_i(x)):第 (i) 个专家网络输出的特征。
- (g_{k,i}(x)):任务 (k) 分配给专家 (i) 的门控权重。
- (h_k(x)):任务 (k) 最终获得的混合特征。
门控权重通常通过 Softmax 计算,并满足同一任务下所有专家权重之和为 1。
需要注意,MMoE 与大语言模型中用于条件计算的稀疏 MoE 并不完全相同。标准 MMoE 通常会计算全部专家,再进行加权求和。它主要解决任务之间的信息共享问题,并不天然具有稀疏 MoE 的计算节省优势。
二、实践案例一:短视频推荐——同时预测完播与点赞
1. 业务场景
这是一个能够找到公开代码和样例数据的典型 MMoE 应用。
开源项目 DeepCTR-Torch 提供了基于 ByteRec 样例数据的多任务学习演示,同时预测:
finish:用户是否完播视频。like:用户是否点赞视频。
原始数据中还包括用户 ID、视频 ID、作者 ID、城市、设备、视频时长等特征。
过去可能需要两个独立模型分别预测完播与点赞。现在可以让它们共享 MMoE 的专家网络,同时保留各自的任务塔。
2. 环境与数据准备
安装依赖:
pip install deepctr-torch torch pandas scikit-learn
公开项目:
https://github.com/shenweichen/DeepCTR-Torch
进入项目的 docs/source/Examples.md,找到 MultiTask Learning:MMOE,下载对应的 byterec_sample.txt 样例数据。
3. Python 实践
下面使用 DeepCTR-Torch 的 MMoE 模型。为避免测试集信息进入训练阶段,类别编码和数值归一化仅在训练数据上拟合。
import pandas as pd
from sklearn.preprocessing import MinMaxScaler
from sklearn.metrics import roc_auc_score
from deepctr_torch.inputs import SparseFeat, DenseFeat
from deepctr_torch.models import MMOE
# 1. 读取数据
columns = [
"uid", "user_city", "item_id", "author_id",
"item_city", "channel", "finish", "like",
"music_id", "device", "time", "duration_time"
]
df = pd.read_csv(
"byterec_sample.txt",
sep="\t",
header=None,
names=columns
)
# 2. 按时间划分训练集和测试集
df = df.sort_values("time").reset_index(drop=True)
boundary = int(len(df) * 0.8)
train = df.iloc[:boundary].copy()
test = df.iloc[boundary:].copy()
sparse = [
"uid", "user_city", "item_id", "author_id",
"item_city", "channel", "music_id", "device"
]
dense = ["duration_time"]
targets = ["finish", "like"]
# 3. 类别特征编码,测试集未知类别编码为 0
feature_columns = []
for col in sparse:
train[col] = train[col].fillna("UNK").astype(str)
vocabulary = {
value: i + 1
for i, value in enumerate(train[col].unique())
}
test[col] = (
test[col].fillna("UNK").astype(str)
.map(vocabulary).fillna(0).astype("int64")
)
train[col] = train[col].map(vocabulary).astype("int64")
feature_columns.append(
SparseFeat(col, len(vocabulary) + 1, embedding_dim=4)
)
# 4. 数值特征处理
for col in dense:
train[col] = pd.to_numeric(train[col], errors="coerce")
test[col] = pd.to_numeric(test[col], errors="coerce")
median = train[col].median()
train[col] = train[col].fillna(median)
test[col] = test[col].fillna(median)
scaler = MinMaxScaler()
train[dense] = scaler.fit_transform(train[dense])
test[dense] = scaler.transform(test[dense])
feature_columns += [DenseFeat(col, 1) for col in dense]
train_x = {c: train[c].values for c in sparse + dense}
test_x = {c: test[c].values for c in sparse + dense}
# 5. 创建 MMoE 模型
model = MMOE(
dnn_feature_columns=feature_columns,
num_experts=4,
expert_dnn_hidden_units=(64, 32),
tower_dnn_hidden_units=(16,),
task_types=("binary", "binary"),
task_names=("finish", "like"),
device="cpu"
)
model.compile(
"adam",
loss=["binary_crossentropy", "binary_crossentropy"]
)
# 6. 模型训练
model.fit(
train_x,
train[targets].values,
batch_size=256,
epochs=5,
validation_split=0.1
)
# 7. 分别评价两个任务
pred = model.predict(test_x, batch_size=256)
for i, name in enumerate(targets):
if test[name].nunique() == 2:
auc = roc_auc_score(test[name], pred[:, i])
print(f"{name} AUC: {auc:.4f}")
这段代码最值得注意的是:task_types 定义了两个二分类任务,而 task_names 分别指定完播和点赞。
虽然两个任务使用同一批输入特征,但模型会学习不同的 Gate 和 Tower。
最后获得两个预测概率,可以分别用于推荐排序策略。
在实际业务中,假设两个任务同样重要,可以先使用:
[
L=L_{\text{finish}}+L_{\text{like}}
]
如果业务更重视完播,则可以调整为:
[
L=0.7L_{\text{finish}}+0.3L_{\text{like}}
]
这里的权重仅作示例。实际调整时,需要考虑任务损失的数值尺度,不能将权重直接等同于业务重要性的百分比。
4. 为什么要用 MMoE?
完播和点赞任务具有共享信息,例如用户兴趣、视频内容和作者特征。但二者也具有不同规律。
MMoE 能够同时学习共享表示与任务专属的特征组合。
是否优于两个独立模型,需要在同一数据划分、相近训练预算和统一评价指标下进行实验验证,不能仅凭架构判断。
三、实践案例二:电商推荐——同时预测点击率与购买率
1. 业务场景
第二个案例换成电商商品推荐。
假设平台希望预测两个目标:
- CTR:商品被曝光后,用户点击它的概率。
- CTCVR:商品被曝光后,在归因窗口内产生购买的概率。
注意,这里的第二个任务并不是“点击之后的条件购买概率”,而是曝光到购买的联合转化概率。
这是两个相关但不同的任务。
对于价格较高的商品,用户可能愿意点击查看,但最终购买的可能性较低;对于刚需商品,点击概率和购买概率可能呈现另一种关系。
如果只预测 CTR,推荐系统容易偏向吸引眼球但缺少购买价值的商品。
因此,我们可以尝试同时预测点击与购买。
2. 从零实现简化版 MMoE
为了理解其内部结构,这次不调用封装好的 MMoE,而是直接使用 PyTorch。
安装依赖:
pip install torch numpy scikit-learn
以下代码使用模拟数据,展示完整的专家网络、门控网络、任务塔和训练流程。模拟数据用于验证架构与运行逻辑,不代表真实电商平台的效果。
import numpy as np
import torch
from torch import nn
from torch.nn import functional as F
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score
torch.manual_seed(42)
rng = np.random.default_rng(42)
# 1. 构造模拟数据
n = 8000
x = rng.normal(size=(n, 4)).astype("float32")
sigmoid = lambda z: 1 / (1 + np.exp(-z))
# 模拟点击概率与点击后的购买概率
p_click = sigmoid(
0.9*x[:, 0] - 0.6*x[:, 1]
+ 0.3*x[:, 2] + 0.4*x[:, 3]
)
p_buy_given_click = sigmoid(
1.1*x[:, 0] - 0.9*x[:, 1]
+ 0.8*x[:, 2] - 1.0
)
click = rng.binomial(1, p_click)
buy = click * rng.binomial(1, p_buy_given_click)
y = np.stack([click, buy], axis=1).astype("float32")
x_train, x_test, y_train, y_test = train_test_split(
x, y, test_size=0.2, random_state=42
)
# 2. 定义 MMoE
class MMoE(nn.Module):
def __init__(self, input_dim, num_experts=4, hidden=32):
super().__init__()
# 共享专家网络
self.experts = nn.ModuleList([
nn.Sequential(
nn.Linear(input_dim, hidden),
nn.ReLU()
)
for _ in range(num_experts)
])
# 每个任务独立的 Gate
self.gates = nn.ModuleList([
nn.Linear(input_dim, num_experts)
for _ in range(2)
])
# 每个任务独立的 Tower
self.towers = nn.ModuleList([
nn.Linear(hidden, 1)
for _ in range(2)
])
def forward(self, x):
expert_out = torch.stack(
[expert(x) for expert in self.experts],
dim=1
)
outputs = []
for gate, tower in zip(self.gates, self.towers):
weight = torch.softmax(gate(x), dim=1)
mixed = (
expert_out * weight.unsqueeze(-1)
).sum(dim=1)
outputs.append(tower(mixed))
return torch.cat(outputs, dim=1)
# 3. 训练
model = MMoE(input_dim=4)
optimizer = torch.optim.Adam(model.parameters(), lr=0.002)
X = torch.tensor(x_train)
Y = torch.tensor(y_train)
for epoch in range(20):
for ids in torch.randperm(len(X)).split(256):
logits = model(X[ids])
loss = F.binary_cross_entropy_with_logits(
logits, Y[ids]
)
optimizer.zero_grad()
loss.backward()
optimizer.step()
# 4. 测试
model.eval()
with torch.no_grad():
pred = model(torch.tensor(x_test)).sigmoid().numpy()
for i, name in enumerate(["CTR", "CTCVR"]):
auc = roc_auc_score(y_test[:, i], pred[:, i])
print(f"{name} AUC: {auc:.4f}")
3. 如何理解结果?
输出会分别显示 CTR 和 CTCVR 的 AUC。
AUC 衡量模型将正样本排在负样本前面的能力。AUC 越高,通常说明模型的区分能力越强。
上述合成数据具有预先设定的规律,因此模型相对容易学到有效的预测关系。
真正有意义的不是获得某个漂亮的 AUC 数字,而是比较三种方案:
- 两个完全独立的单任务模型。
- 一个 Shared-Bottom 多任务模型。
- 一个 MMoE 多任务模型。
如果 MMoE 在两个任务上都能取得更好的泛化表现,就说明它的共享机制确实产生了价值。
这里还有一个容易忽略的问题:虽然训练数据规定购买必须发生在点击之后,但上述两个输出头彼此独立,并不保证预测值始终满足 (P(\text{购买})\leq P(\text{点击}))。
如果业务目标是严谨地估计点击后转化率,可以进一步研究 ESMM,通过概率链式关系建模 CTR、CTCVR 与 CVR,而不是直接把两个 MMoE 输出相除就当作可靠的 CVR。
四、MMoE 的主要参数如何选择?
MMoE 的调参重点通常不是单纯增加网络深度,而是确定需要多少专家、每个专家的表达能力,以及多个任务之间如何平衡。
| 参数 | 作用 | 实践建议 |
|---|---|---|
num_experts |
共享专家数量 | 可以从 4 个开始,与 2、8 个对比 |
| Expert hidden units | 专家网络宽度和深度 | 数据较少时避免过大的网络 |
| Tower hidden units | 任务专属网络结构 | 可根据任务复杂度分别设计 |
| Gate network | 专家权重分配 | 先采用简单线性 Gate 作为基线 |
| Task loss weights | 多任务损失权重 | 结合业务目标与梯度规模调整 |
| Learning rate | 参数更新步长 | Adam 优化器可从 0.001 附近尝试 |
实践中还建议监控不同任务的训练损失、验证集指标,以及 Gate 的专家权重分布。
如果多个专家始终获得近似相同的权重,可能说明门控没有形成明显的选择性;如果几乎全部权重长期集中于单个专家,则需要分析是否存在专家使用不均衡。
不过,权重分布本身并不能证明模型训练成功或失败,最终仍以验证集和线上业务效果为准。
五、MMoE、Shared-Bottom、PLE,到底应该怎么选?
| 模型 | 主要特点 | 适合场景 |
|---|---|---|
| Shared-Bottom | 完全共享底层特征,结构简单 | 任务关系密切、数据量有限 |
| MMoE | 多个共享专家,每个任务独立门控 | 任务相关但存在一定差异 |
| PLE | 显式区分共享专家与任务专属专家 | 任务冲突明显、需要更强隔离 |
MMoE 在 Shared-Bottom 与 PLE 之间提供了一种灵活的折中方案。
但必须明确:MMoE 能够缓解负迁移,并不意味着一定能够消除负迁移。多个任务仍然会共同更新共享专家,任务损失权重和数据质量依然可能影响模型效果。
六、最后总结
MMoE 是一种面向多任务学习的神经网络架构,其核心价值在于:通过共享专家网络实现知识复用,再通过任务专属门控实现有区别的特征选择。
短视频推荐中的完播、点赞,电商推荐中的点击、购买,都是典型的应用方向。
从工程角度看,真正需要解决的是三个问题:
- 哪些任务适合联合建模,哪些任务存在明显冲突?
- 共享专家是否带来了超越独立模型的实际收益?
- 不同任务的损失、指标和业务目标如何平衡?
学会搭建 MMoE 只是第一步。只有在同一数据集上完成单任务模型、Shared-Bottom 与 MMoE 的对照实验,才能判断这种架构是否值得投入生产。
如果希望进一步深入,可以继续研究 PLE(渐进式分层提取)、**ESMM(全空间多任务模型)**和 CGC(定制门控控制)。它们分别从任务知识隔离、转化链路建模和专家结构设计等方向,对多任务学习做出了进一步拓展。
- 点赞
- 收藏
- 关注作者
评论(0)