第六章:项目实战之推荐/广告系统
第三部分:精排算法
第二节:精排算法模型精讲: DNN、deepFM、ESMM、PLE、MMOE算法精讲与实现
精排工程实战:从训练到部署(含代码、蒸馏与部署优化)
一、工程目标与总体流程
目标:在保证排序质量的前提下,把精排模型训练好、压缩好、以低延迟高吞吐率安全地上线到推荐系统中,做到稳定、可监控和可回滚。
整体流程:
离线数据准备 → 特征清洗与特征流水线
模型训练(DNN / DeepFM / ESMM / MMoE / PLE)→ 指标评估(AUC / NDCG / Gain)
模型压缩/蒸馏(若有需求)
导出(ONNX / SavedModel / TorchScript)
离线检验(一致性 test) → 构建推理镜像
服务部署(TF-Serving / TorchServe / Triton / Custom gRPC)
在线监控(延迟、QPS、A/B、指标回归检测)
下面把关键环节逐一拆清楚。
二、关键工程细节(一边做一边注意)
2.1 特征工程(必须把握的几点)
Embedding id 映射稳定:训练/推理必须共享同一 id map(user/item/feature→int)。上线前把映射导出并版本化。
稀疏/稠密分离:稀疏(categorical)用 embedding,稠密(numerical)做均值方差归一化(或 log/clip)。
时间窗口切分:训练集/验证集/测试集按时间切(避免信息泄露)。
线上特征一致性:线上特征计算逻辑必须与训练时一模一样(尤其是统计类特征如 user_ctr)。
缺失/新值处理:为未知 id 预留
<UNK>embedding,避免线上崩溃。Feature Store 或者 Feature Server:工程化建议用 Feature Store(离线批与在线特征一致性)。
2.2 训练实践
Batch size 与 lr 关系:增大 batch 需要调 lr 或用学习率热身(warmup)。
样本均衡:对于极度不平衡的转化(CVR),用重采样或加权 loss(或用 ESMM)。
Early stopping + best checkpoint:按 AUC / NDCG / business metric 早停并保存最优模型。
多卡训练:推荐使用分布式(DDP)或 TF MirroredStrategy,保证 batch 内负样本充足(对对比学习重要)。
2.3 离线评估
离线指标:AUC(CTR/CVR),LogLoss,NDCG@K(排序相关),Precision@K / Recall@K。
在线预估增益:用模拟器/离线回放估算 GMV 等经济指标。
置信区间与显著性检验:上线前做 AB 显著性测试规划。
三、PyTorch 完整可运行训练 + 导出示例(以 PLE/多任务为例 — 精排主流)
我给你一个简化但可直接跑起的 PyTorch 实例(包含模型、训练 loop、保存 embedding / 导出 TorchScript)。模型用 PLE 单层版本,两个任务(ctr & cvr)。数据用随机模拟器,便于跑通。
注意:实际工程请把
feature_size/field_num/dense_dim替换成你的真实值,并把数据管道换成你的 Feature Store 输出。
3.1 代码:PLE 模型(PyTorch)
# ple_model.py
import torch
import torch.nn as nn
import torch.nn.functional as F
class Expert(nn.Module):
def __init__(self, input_dim, hidden_dim):
super().__init__()
self.net = nn.Sequential(
nn.Linear(input_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU()
)
def forward(self, x):
return self.net(x)
class Gate(nn.Module):
def __init__(self, input_dim, n_experts):
super().__init__()
self.fc = nn.Linear(input_dim, n_experts)
def forward(self, x):
return torch.softmax(self.fc(x), dim=1) # [B, n_experts]
class PLELayer(nn.Module):
def __init__(self, input_dim, n_shared, n_task, n_experts_each, hidden_dim):
super().__init__()
self.shared_experts = nn.ModuleList([Expert(input_dim, hidden_dim) for _ in range(n_shared)])
self.task_experts = nn.ModuleList([nn.ModuleList([Expert(input_dim, hidden_dim) for _ in range(n_experts_each)]) for _ in range(n_task)])
self.gates_shared = nn.ModuleList([Gate(input_dim, n_shared + n_experts_each) for _ in range(n_task)]) # each task gate mixes shared + private
self.n_shared = n_shared
self.n_experts_each = n_experts_each
def forward(self, x):
# shared expert outputs: [B, n_shared, H]
shared_outs = torch.stack([e(x) for e in self.shared_experts], dim=1)
task_outs = []
for t in range(len(self.task_experts)):
private_outs = torch.stack([e(x) for e in self.task_experts[t]], dim=1) # [B, n_priv, H]
# concat shared + private along expert dim
concat = torch.cat([shared_outs, private_outs], dim=1) # [B, n_shared+n_priv, H]
gate = self.gates_shared[t](x).unsqueeze(2) # [B, n_experts, 1]
gated = (concat * gate).sum(dim=1) # [B, H]
task_outs.append(gated)
return task_outs # list of [B, H] per task
class PLEModel(nn.Module):
def __init__(self, field_num, vocab_size, emb_dim, dense_dim, hidden_dim=128):
super().__init__()
self.field_num = field_num
self.emb = nn.Embedding(vocab_size, emb_dim)
# simple embedding sum pooling to build input
input_dim = emb_dim * field_num + dense_dim
self.fc_in = nn.Linear(input_dim, hidden_dim)
# one PLE layer
self.ple = PLELayer(input_dim=hidden_dim, n_shared=2, n_task=2, n_experts_each=2, hidden_dim=hidden_dim)
# task towers
self.tower_ctr = nn.Sequential(nn.Linear(hidden_dim, hidden_dim//2), nn.ReLU(), nn.Linear(hidden_dim//2,1))
self.tower_cvr = nn.Sequential(nn.Linear(hidden_dim, hidden_dim//2), nn.ReLU(), nn.Linear(hidden_dim//2,1))
def forward(self, x_sparse, x_dense):
# x_sparse shape: [B, field_num]
emb = self.emb(x_sparse).view(x_sparse.size(0), -1) # [B, field*E]
x = torch.cat([emb, x_dense], dim=1)
x = F.relu(self.fc_in(x))
task_reprs = self.ple(x) # [ctr_repr, cvr_repr]
ctr_logit = self.tower_ctr(task_reprs[0]).squeeze(1)
cvr_logit = self.tower_cvr(task_reprs[1]).squeeze(1)
ctr_prob = torch.sigmoid(ctr_logit)
ctcvr_prob = torch.sigmoid(ctr_logit) * torch.sigmoid(cvr_logit) # optionally compute ctcvr
return ctr_prob, ctcvr_prob, torch.sigmoid(cvr_logit)
3.2 训练 loop 与保存模型
# train.py
import torch
from torch.utils.data import DataLoader, Dataset
import torch.optim as optim
import numpy as np
from ple_model import PLEModel
class DummyDataset(Dataset):
def __init__(self, n, field_num, vocab_size, dense_dim):
self.n=n; self.field_num=field_num; self.vocab=vocab_size; self.dense=dense_dim
def __len__(self):
return self.n
def __getitem__(self, idx):
x_sparse = np.random.randint(0, self.vocab, size=(self.field_num,))
x_dense = np.random.randn(self.dense).astype(np.float32)
click = np.random.randint(0,2)
conv = click * (np.random.rand() < 0.1) # conversion only if clicked
return x_sparse, x_dense, click, conv
def collate_fn(batch):
xs = torch.tensor([b[0] for b in batch], dtype=torch.long)
xd = torch.tensor([b[1] for b in batch], dtype=torch.float)
click = torch.tensor([b[2] for b in batch], dtype=torch.float)
conv = torch.tensor([b[3] for b in batch], dtype=torch.float)
return xs, xd, click, conv
# hyperparams
field_num=10; vocab=10000; emb_dim=8; dense_dim=4
dataset=DummyDataset(20000, field_num, vocab, dense_dim)
loader=DataLoader(dataset, batch_size=256, collate_fn=collate_fn, shuffle=True)
model=PLEModel(field_num, vocab, emb_dim, dense_dim).cuda()
opt=optim.Adam(model.parameters(), lr=1e-3)
bce=nn.BCELoss()
for epoch in range(5):
for xs, xd, click, conv in loader:
xs=xs.cuda(); xd=xd.cuda(); click=click.cuda(); conv=conv.cuda()
ctr, ctcvr, cvr = model(xs, xd)
loss_ctr = bce(ctr, click)
loss_ctcvr = bce(ctcvr, conv)
loss = loss_ctr + loss_ctcvr
opt.zero_grad(); loss.backward(); opt.step()
print("epoch", epoch, "loss", loss.item())
# save
torch.save(model.state_dict(), "ple_model.pth")
# export torchscript (for serving)
example_sparse = torch.randint(0, vocab, (1, field_num)).cuda()
example_dense = torch.randn(1, dense_dim).cuda()
traced = torch.jit.trace(model, (example_sparse, example_dense))
traced.save("ple_model_trace.pt")
注:上面代码为教学用最简版本;生产中 embedding & feature pipeline 要替换真实输入流(Feature Store)。
四、模型蒸馏与压缩(如何在保证效果的前提下降低延迟/内存)
当模型在精排阶段过大或因成本问题需要减小延迟,可采用蒸馏与量化技术。工程通用流程:
离线训练 Teacher(大模型):PLE 或大型 Transformer 排序器,性能最好。
训练 Student(小模型):小 DNN / Light-GBDT / Tiny PLE。用 Teacher 的 logits 或中间表示进行蒸馏。
蒸馏 Loss = α * task_loss + β * distill_loss,distill_loss 用 MSE 或 KLDivergence。
后处理量化:INT8 量化(TensorRT / ONNX Runtime)或动态范围量化(Tensorrt/TF-TRT)。
结构裁剪:剪掉冗余层/专家(MMoE/PLE 专家剪枝)。
工程化自动化:把蒸馏、量化融合到 CI/CD。
4.1 具体蒸馏示例(PyTorch)
# teacher produces ctr logits; we train a smaller student to match
teacher = load_teacher() # pre-trained
student = StudentModel(...)
for batch in loader:
xs, xd, click, conv = batch
with torch.no_grad():
t_ctr, _, _ = teacher(xs, xd) # teacher ctr prob
t_logit = torch.log(t_ctr.clamp(1e-6,1-1e-6) / (1-t_ctr.clamp(1e-6,1-1e-6)))
s_ctr, s_ctcvr, s_cvr = student(xs, xd)
# distill loss on logits
distill_loss = F.mse_loss(torch.log(s_ctr/(1-s_ctr)), t_logit)
label_loss = bce(s_ctr, click)
loss = 0.5*label_loss + 0.5*distill_loss
...
4.2 量化与 TensorRT
ONNX 导出后用 TensorRT 做 INT8 量化(需校准集),能显著降低延迟并提升吞吐。
ONNX Runtime + Quantization (QOperator) 支持 CPU/GPU 推理量化。
五、模型导出与 Serving(工程化细则)
5.1 导出格式建议
PyTorch:TorchScript (
torch.jit.trace/script) 或导出 ONNX(注意 ops兼容)。TensorFlow:SavedModel(TF-Serving友好)或转换为 TensorRT(TF-TRT)。
通用建议:线上使用
traced或 ONNX(方便转TensorRT),并在预生产做一致性校验。
5.2 推荐的 Serving 方案(权衡)
低延迟高 QPS:Triton Inference Server(支持 TensorRT/ONNX/torchscript)。
通用/易迭代:TF-Serving(Keras SavedModel),或 TorchServe(PyTorch)。
向量/ANN 服务:Milvus / Faiss + 自研向量检索服务(gRPC)作为召回,精排服务接收 TopK 进行打分。
5.3 常见工程优化技巧
Batching:把 1 个请求合并成小 batch 推理(提高 GPU利用率),但注意尾延迟和冷启动。
Async pipeline:召回 + 粗排 + 精排分开服务,各自扩容,精排只负责 TopK。
Warmup:模型上线前用历史流量做 warmup,填充缓存和 JIT 编译。
Cache:热门用户/热门 item embedding 缓存,或对用户近期请求缓存 TopK。
Feature prefetch:把特征预取到内存中,减少线上计算开销。
六、线上监控与回滚策略(工程里很重要的一环)
6.1 指标监控
业务指标:CTR/CVR/GMV/Revenue per Mille (RPM) / ARPU。
模型指标:loss、AUC、prediction distribution(分布漂移检测)。
系统指标:latency P50/P95/P99、QPS、CPU/GPU/Memory 使用。
服务可靠性:error rate、timeout数、容器重启次数。
6.2 异常检测与回滚
阈值报警:指标回退阈值(如 CTR 下跌 > 2% 发警)。
自动回滚:流量切分(10% → 50% → 100%)机制,一旦风险,自动回滚到上一个稳定版本。
Shadow Test:先做影子流量(Shadow)比对输出,不影响线上用户。
七、A/B 测试设计(上线验证模型价值)
目标:验证是否提升长期价值(不仅短期 CTR)。
指标层级:分 primary / secondary / guardrail(例如 primary: GMV, secondary: CTR, guardrail: user retention)。
实验周期:电商类建议 1–2 周;视频类可短一些,但要覆盖周周期行为。
样本量计算:计算显著检测需要的流量(基于 baseline 转化率和最小效果检测量)。
八、常见坑与工程经验总结(给新人和工程师的清单)
训练 / 线上特征不一致导致上线性能回退 —— 最常见原因。使用 Feature Store 并版本化特征。
未处理新 id(OOV) —— 线上会崩。务必把
<UNK>embedding 与 fallback 逻辑做好。Embedding 映射不同步 —— embedding 索引/映射需一并发布与备份。
没有做延迟与吞吐压测 —— 推理延迟没测过会滑到生产事故。
A/B 指标选择错误 —— 只看 CTR 很容易“骗点击”。把长期指标放首位。
模型冷启动 —— 新模型刚上线,需 warmup/分流逐步放量。
过度蒸馏导致效果退化 —— 蒸馏超参(α, β)需要 grid search 与小规模在线校验。
九、总结(工程路线图)
离线训练优先保证数据一致性与时间切分;模型选择根据业务复杂度(DNN→DeepFM→PLE)。
若对延迟与成本高度敏感,先尝试蒸馏 + INT8 量化 + Triton/TensorRT 部署。
上线要做 Shadow → 小流量灰度 → 分阶段増量 → 全量。
严格监控业务 & 模型 & 系统指标,自动化回滚保障安全。
浙公网安备 33010602011771号