简介:点击率(CTR)预估是在线广告排序与推荐系统的核心环节,其准确性直接影响广告收益和用户体验。传统逻辑回归与FM模型依赖大量人工特征工程,难以捕捉特征间的高阶组合与用户行为的时序依赖。深度学习模型如CNN擅长提取局部组合特征,LSTM则擅长建模点击序列的长期依赖,但单独使用均存在短板,CNN+LSTM混合模型通过卷积核滑动提取高影响力特征,再以LSTM按时间步建模,在Avazu公开数据集上将AUC提升至0.772、log loss降至0.371。该技术适用于广告竞价排序、推荐系统和用户行为建模等大规模稀疏特征场景,为工程化部署和调参提供了可复现的实践路径。本文从模型原理出发,结合数据预处理、参数配置与线上落地细节,系统解析了这一混合架构在提升CTR预估精度上的实际价值。
1. 搜索广告排序为什么卡在CTR预估上
广告排序的收益公式里,竞价是明牌,质量度是暗牌。质量度最常用的量化指标就是点击率(CTR)。CTR预估准不准,直接决定广告排序是“展示给对的人”还是“白白消耗曝光”。传统逻辑回归、FM这类模型在特征工程上非常烧人,且很难抓住特征之间的组合与用户行为的时序依赖。这篇基于Avazu公开数据集的实验方案,把卷积神经网络和LSTM串成一个混合模型:CNN先用卷积核滑动提取局部高影响力特征,LSTM再按时间步做分类预测,把AUC从单一LSTM的0.723拉到0.772,log loss降到0.371。这个思路对做广告排序、推荐系统、用户行为建模的工程师都很有参考价值,尤其适合特征规模大、需要兼顾局部交叉和长期依赖的场景。
2. 从LR到CNN+LSTM:CTR预估模型选型与网络设计
2.1 广告点击率预测的标准流程
不管用哪种模型,CTR预估的骨架都是三件事:特征提取、模型训练、用预测结果排序。论文里把流程拆成三步:第一步,从原始广告日志里做特征处理,获取影响力高的特征,划分训练集和测试集;第二步,把训练集喂给模型,用优化方法迭代更新权值;第三步,用测试集做预测,平台按预测点击率从高到低排序,决定广告展示区域。
这个流程和工业界常见的在线广告系统是一致的。离线阶段只解决“模型怎么学”的问题,在线阶段才是真正的广告排序服务。需要注意的是,特征提取不是简单的清洗,而是要对连续特征做归一化、对类别特征做编码,还要过滤掉曝光量过少的稀疏特征。论文里强调人工提取特征费时费力,这正是深度学习介入的原因:卷积核在滑动过程中自动做局部特征组合,省掉大量手工交叉特征的工作。
下面用一段伪代码描述数据流转,对应论文中的流程:
原始日志 -> 特征处理 -> [训练集, 测试集]
训练集 -> 模型训练(CNN提取特征 -> LSTM分类) -> 模型文件
测试集 -> 模型推理 -> 预测CTR -> 广告排序
这段流程看着简单,真正落地时坑很多。比如训练集和测试集的切分比例、正负样本比例是否和线上一致,都会直接影响AUC的可信度。论文专门提到抽取数据时保持正样本比例与原训练数据接近,就是为了避免样本选择偏差。
2.2 为什么单独用CNN或LSTM都不够
先看CNN。卷积神经网络擅长提取局部特征,卷积核在一个小感知域内滑动,能够发现相邻特征之间的相关性。但广告特征不是图像像素,它的“局部性”更多体现在特征组合上,比如用户性别和广告类目同时出现时对点击的影响。CNN可以捕捉这种组合,却很难建模用户行为的先后顺序,比如用户先搜索“机票”再点“酒店”这种时序关联。百度的FNN、谷歌的Wide&Deep和PNN都是这个方向的代表,思路都是在Embedding层后做特征交叉,而这篇论文选的是CNN做局部交叉,LSTM做时序分类。
再看LSTM。LSTM的优势是门控机制,输入门、输出门和记忆状态让信息可以在长序列中传递,非常适合点击序列、浏览序列这类时间依赖。但广告特征往往是高维稀疏的One-Hot向量,直接把这种输入塞给LSTM,不仅训练慢,而且LSTM很难从稀疏特征里找到高阶组合。单独使用LSTM时,AUC只有0.723,低于CNN的0.731,也侧面说明了这个短板。
所以论文把两者串行组合:CNN先做特征提取和降维,把高维稀疏输入压缩成有表达能力的特征向量;LSTM再基于这个特征序列做分类。这比在同一个网络里做并联或者简单堆叠更直观,也更容易调参。关键点在于CNN的输出维度要和LSTM的输入维度对齐,论文里CNN输出向量维度设为256,正好作为LSTM每个时间步的输入特征维度。
| 模型 | 优势 | 短板 |
|---|---|---|
| CNN | 自动提取局部特征组合,减少人工特征工程 | 难以建模用户行为时序依赖 |
| LSTM | 门控机制处理长期依赖,适合点击序列 | 高维稀疏特征下训练效率低,特征交叉能力弱 |
| CNN+LSTM | CNN降维后可让LSTM聚焦时序分类 | 网络层数多,训练耗时更长 |
2.3 混合模型结构设计与特征流向
从论文图3可以看出,整个网络分为输入层、卷积池化层、LSTM层和输出层。输入层先把原始特征排重、清理,然后做独立One-Hot编码,映射到t维空间,通过索引值生成新特征。这里的t维空间可以理解成编码后的稀疏向量维度。
卷积层对输入特征做卷积和池化。论文算法描述里的关键操作为:
y = ReLU(x * w + b)
y = maxpooling(y * w + b)
第一行是卷积操作,ReLU作为激活函数;第二行是最大池化,移动步长设置为4。池化不是简单地把特征图变小,它能让模型对特征位置不那么敏感,同时保留最显著的特征值。之后就进入LSTM部分。
LSTM的输入是CNN输出的特征向量序列,每个时间步对应一个特征向量。LSTM内部通过输入门、遗忘门和输出门控制信息流动,最终输出ht。ht再进入sigmoid函数,输出0到1之间的概率p,这个p就是预测的点击率。整个正向传播过程可以用下面的PyTorch风格代码示意:
import torch
import torch.nn as nn
class CnnLstmCTR(nn.Module):
def __init__(self, input_dim, cnn_out_dim=256,
lstm_hidden=128, lstm_layers=3, kernel_size=5):
super().__init__()
self.cnn = nn.Sequential(
nn.Conv1d(in_channels=input_dim, out_channels=32,
kernel_size=kernel_size, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool1d(kernel_size=4, stride=4)
)
self.proj = nn.Linear(32, cnn_out_dim)
self.lstm = nn.LSTM(input_size=cnn_out_dim, hidden_size=lstm_hidden,
num_layers=lstm_layers, batch_first=True)
self.fc = nn.Linear(lstm_hidden, 1)
self.sigmoid = nn.Sigmoid()
def forward(self, x):
# x: (batch, seq_len, input_dim)
x = x.permute(0, 2, 1) # (batch, input_dim, seq_len)
cnn_out = self.cnn(x) # (batch, 32, seq_len//4)
cnn_out = cnn_out.permute(0, 2, 1) # (batch, seq_len//4, 32)
cnn_out = self.proj(cnn_out) # (batch, seq_len//4, 256)
lstm_out, _ = self.lstm(cnn_out) # (batch, seq_len//4, 128)
last = lstm_out[:, -1, :]
p = self.sigmoid(self.fc(last))
return p
这里把 input_dim 作为Conv1d的输入通道数,所以每个时间步可以是标量特征,也可以是多维向量。 cnn_out 通道数32表示CNN卷积核个数,池化后序列长度变为四分之一。 proj 把32通道映射到256维,对应论文中CNN输出向量维度。LSTM层的 lstm_hidden 设为128, num_layers 为3,对应论文核心参数表中的LSTM隐藏层节点数和层数。需要留意的是,如果直接把20维特征当作20个时间步,每个时间步只有1维,池化步长4会让序列长度从20降到5,信息压缩较狠;工程上更常见的做法是先对特征做Embedding,再以Embedding维度作为通道维输入卷积层。
3. 复现卷积-LSTM组合模型:核心参数、训练代码与调参实验
3.1 数据预处理与特征编码
论文实验数据来自Kaggle比赛中Avazu公司2014年公开的数据集,抽取了100万条样本作为训练集。每条样本有20个维度,包含10个连续特征(其中9个已脱敏)和9个类别特征,样本标签是0或1,分别代表负样本和正样本。实际下载数据后会发现Avazu原始数据里有更多字段,比如用户ID、广告ID、应用ID、设备类型等,需要先做筛选。
常用的预处理步骤是:对类别特征做标签编码或One-Hot编码,对连续特征做归一化或GBDT分箱。论文里提到输入层“把原始特征排重、数据清理后开始独立向量编码”,也就是每个类别特征独立做One-Hot,然后在特征维度上拼接。这样做的问题是维度会非常高,100万条数据里仅设备型号就可能上千维。所以在工程复现时,我一般会先对低频类别做过滤,只保留出现次数超过阈值的取值,其余的归到“其他”类。
下面代码演示One-Hot编码的基本做法:
import pandas as pd
from sklearn.preprocessing import LabelEncoder
# 假设df是原始数据,categorical_cols是类别特征列
min_count = 20
for col in categorical_cols:
df[col] = df[col].astype(str)
# 过滤低频值,出现次数小于min_count的归为'OTHER'
vc = df[col].value_counts()
df.loc[df[col].isin(vc[vc < min_count].index), col] = 'OTHER'
le = LabelEncoder()
df[col] = le.fit_transform(df[col])
# 之后可以用pd.get_dummies生成One-Hot向量,或保存映射关系在模型输入层查表
X = pd.get_dummies(df[categorical_cols]).values.astype('float32')
这段代码先统一转成字符串,再按出现次数过滤低频类别,然后用LabelEncoder转为整数索引。 get_dummies 会把每个不同取值展开成一列,形成稀疏的0/1矩阵。实际训练时不需要把One-Hot后的完整矩阵放在内存里,更常见的做法是把整数索引直接传给Embedding层查表,这边为了对齐论文的One-Hot描述,先用 get_dummies 演示。 min_count 需要根据数据量调整,通常取5到20之间;设得太大,高频特征也会被归到“其他”,设得太小,稀疏噪声会保留下来。
3.2 模型定义与训练代码
论文原始实验环境是Caffe2,Python 2.7。现在复现这个结构,更顺手的是PyTorch或TensorFlow。下面给出一个和论文参数对齐的PyTorch训练脚本骨架:
import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.metrics import roc_auc_score, log_loss
# 复用2.3的CnnLstmCTR,input_dim=1表示每个时间步是标量特征
model = CnnLstmCTR(input_dim=1, cnn_out_dim=256,
lstm_hidden=128, lstm_layers=3, kernel_size=5)
optimizer = optim.RMSprop(model.parameters(), lr=0.002)
criterion = nn.BCELoss()
for epoch in range(30):
model.train()
for batch_x, batch_y in train_loader:
optimizer.zero_grad()
pred = model(batch_x)
loss = criterion(pred, batch_y.unsqueeze(1))
loss.backward()
optimizer.step()
model.eval()
with torch.no_grad():
val_pred = model(val_x).cpu().numpy().ravel()
auc = roc_auc_score(val_y, val_pred)
ll = log_loss(val_y, val_pred)
print(f"epoch {epoch+1}: AUC={auc:.4f}, logloss={ll:.4f}")
这里用 BCELoss 作为二分类损失函数,优化器选RMSprop,学习率0.002,都和论文核心参数表一致。RMSProp对RNN这类网络比较友好,能自适应调整每个参数的学习率,在广告CTR这类非平稳目标上也比SGD稳定。 input_dim=1 意味着把每条样本的20维特征当作20个时间步,每个时间步是1维标量;如果特征经过One-Hot后变成高维向量,需要把 input_dim 改为编码后的维度。 batch_x 的形状是 (batch, seq_len, input_dim) , batch_y 的形状是 (batch, 1) ,训练循环里的 unsqueeze(1) 是为了对齐BCELoss的输入形状。
3.3 关键参数表与调参实验解读
论文做了四组对照实验:LSTM层数、LSTM隐藏层节点数、学习率、优化方法。图4的结果显示,在测试范围内,LSTM层数从1增加到3时AUC持续上升;隐藏层节点数从32增加到128时AUC也上升;学习率从0.01到0.001之间有一段最优区间;优化方法对比中RMSProp优于Adam和其他方法。最终确定的核心参数如下:
| 参数 | 值 | 说明 |
|---|---|---|
| CNN卷积核个数 | 32 | 提取32种局部特征模式 |
| CNN卷积核大小 | 5 | 每次滑动覆盖5个连续特征位置 |
| CNN移动步长 | 1 | 卷积时不跳过位置 |
| CNN输出向量维度 | 256 | 池化后再映射到256维,作为LSTM输入 |
| LSTM层数 | 3 | 加深网络提升时序建模能力 |
| LSTM隐藏层节点数 | 128 | 每层记忆单元数量 |
| 优化方法 | RMSProp | 自适应学习率 |
| 模型学习率 | 0.002 | 初始学习率 |
调参时要注意图4里的横轴含义。LSTM层数实验是单独改变层数,隐藏层节点实验是单独改变节点数,不存在联合调参。实际复现时,我建议先用小数据集跑通训练,再在20%的数据上做粗调,最后全量数据微调。学习率预热和衰减也很重要,RMSProp默认参数下学习率0.002在训练后期可能震荡,可以在第20个epoch后把学习率降低一半。
3.4 评估指标AUC与LogLoss
论文用了两个指标:AUC和LogLoss。AUC是ROC曲线下的面积,取值范围0到1,越大说明模型把正样本排在负样本前面的能力越强。LogLoss衡量预测概率和真实标签之间的交叉熵损失,越小越好。两者要配合看,AUC关注排序,LogLoss关注校准度。有时候AUC高但LogLoss也高,说明模型排序准但概率值偏差大,线上排序还能用,但做竞价出价时就有问题。
评估代码在3.2里已经出现。补充一个细节:LogLoss对预测概率的绝对值敏感,sigmoid输出不能直接用0或1截断,否则log(0)会变成无穷大。实践里会对预测值做clip,比如限制在0.001到0.999之间。AUC则对概率绝对值不敏感,只看相对顺序。这就是为什么论文同时报告两个指标,它们从不同角度约束模型。
4. 从论文实验到线上排序:效果对比与工程化落地细节
4.1 六种模型对比结果再分析
论文在同一特征处理环境下对比了LR、FM、NN、CNN、LSTM和CNN+LSTM六种模型,训练集与测试集按8:1切割,迭代30次取最优结果。实验结果如表所示:
| 预测模型 | AUC | log loss | 消耗时间/s |
|---|---|---|---|
| LR | 0.659 | 0.421 | 172.82 |
| FM | 0.696 | 0.385 | 217.69 |
| NN | 0.711 | 0.467 | 19832 |
| CNN | 0.731 | 0.395 | 596.43 |
| LSTM | 0.723 | 0.382 | 1217.32 |
| CNN+LSTM | 0.772 | 0.371 | 1339.21 |
这个表有几个值得注意的地方。第一,LR和FM这类浅层模型在第9次迭代左右就开始收敛,但AUC上限明显更低。第二,NN虽然也是深度网络,但log loss高达0.467,AUC也只是0.711,说明普通的全连接网络在广告特征上的拟合能力并不理想。第三,CNN+LSTM相比CNN,AUC提高约1.38个百分点,相比LSTM提高约2.61个百分点,提升幅度不算小。代价是训练时间比CNN多出约两倍,这是堆叠模型深度的正常成本。
我还注意到NN的训练时间长达19832秒,比其他模型高一个数量级,这大概率是网络结构设计不合理或者没有做足够的正则化导致的。论文没有展开NN的具体结构,但至少给了一个反面案例:深度模型不是越深越好,结构设计失误会同时牺牲效果和效率。
4.2 训练时长与资源消耗
论文实验环境是Centos7.5、NVIDIA 1080Ti GPU、128GB内存、12TB硬盘、Python2.7和Caffe2框架。这套配置在2018年前后属于主流深度学习工作站,放到现在来看,1080Ti的算力已经不如入门级显卡,但在这个数据规模和网络复杂度下跑30个epoch,CNN+LSTM用了1339秒,也就是22分钟左右。这说明模型的计算瓶颈主要在LSTM的串行依赖上,CNN部分因为卷积和池化可以并行,反而很快。
如果要在更大规模数据上训练,比如千万级样本,直接照搬这个结构会非常慢。常见优化手段包括:用GPU训练时把LSTM层数减小到2层,或者换用GRU替代LSTM;对CNN输出的特征向量做LayerNorm,帮助LSTM收敛;数据加载时使用TFRecord或Parquet格式,避免IO瓶颈。论文提到的Caffe2在当年很流行,现在团队接手新项目基本都用PyTorch或TensorFlow,只需要保留模型结构定义和参数设置,损失函数和优化器都能平滑迁移。
4.3 线上排序服务如何用预测值
离线模型训练完,线上排序要做的不是简单地把pCTR当分数,而是结合广告竞价计算最终的排序分。常见做法是:
def ad_rank_score(pctr, bid, quality_factor=1.0):
# pctr: 模型预测的点击率
# bid: 广告主出价
# quality_factor: 质量度修正系数,比如广告落地页体验分
return pctr * bid * quality_factor
这个排序分越高的广告,展示位置越靠前。这里有个关键点:pCTR是0到1之间的概率,出价可能是几毛钱,乘积之后数值很小,但并不影响排序,因为排序只关心相对大小。如果平台需要预估广告收益,可能还会乘以一个价值系数,比如点击后的转化率。论文里提到CTR是实时竞价算法RTB的重要因素,就是因为竞价过程需要实时计算每个广告的期望收益,CTR是期望收益的核心因子。
工程上,线上推理服务通常把训练好的模型导出为ONNX或TorchScript格式,用GPU或CPU做batch推理。特征拼接、归一化等操作需要在线上用同一套代码复现,否则会出现训练和推理特征不一致的问题,这是广告排序项目最常见的“线上效果比离线差一大截”的原因。
5. 踩坑记录与参数优化技巧
5.1 特征排重与One-Hot维度爆炸
论文输入层强调“原始特征排重”,实际操作中经常会忽略这一点。广告日志里的用户ID、广告ID可能多次重复出现,如果不排重,One-Hot编码会产生大量无意义的重复维度,让CNN和LSTM都很难收敛。我一般在数据处理阶段对类别特征做哈希映射到固定长度桶,比如10万桶,而不是直接展开全部取值。这能控制特征空间大小,减少内存占用。哈希冲突的影响在广告特征里通常很小,但需要固定哈希种子,保证训练和推理一致。
5.2 卷积核大小与序列长度的匹配
论文把卷积核大小设为5,移动步长为1,池化步长4。这个池化步长对序列长度有硬性要求,序列长度必须是4的倍数,否则 MaxPool1d 会对不满一个窗口的数据丢弃。我在复现时发现,如果把20维特征直接当作序列长度,池化后长度变成5,后续LSTM时间步只有5个,信息压缩太狠。更好的做法是先对连续特征和类别特征分别处理,再把特征维度拼成更大的序列长度,比如每个特征独立作为一个时间步,序列长度等于特征数,池化步长设为2或4时能保留更多信息。建议先用一个小测试集打印每一层输出的shape,避免维度不匹配。
5.3 学习率调度与早停
RMSProp优化器在0.002学习率下训练初期很稳,但到第20轮之后AUC可能开始在小范围内震荡。常见做法是引入学习率调度,比如每隔10个epoch乘以0.5。另外,论文迭代30次后取最优AUC,这个“最优”需要靠验证集早停来捕捉。如果训练集和验证集的正样本比例不一致,AUC会虚高,所以要像论文一样保证两者正样本比例接近。
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)
for epoch in range(30):
train_one_epoch()
scheduler.step()
if val_auc > best_auc:
best_auc = val_auc
torch.save(model.state_dict(), "best_model.pt")
这段代码的 StepLR 每10个epoch把学习率减半,同时只在验证AUC提升时保存模型,避免把最后震荡期的参数当成最优模型。这个技巧在广告CTR训练里很实用,能稳定复现论文中“取最优AUC值”的效果。
373




被折叠的 条评论
为什么被折叠?



