多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

电商广告推荐系统核心链路:召回、排序与Embedding实战解析

电商广告推荐系统核心链路:召回、排序与Embedding实战解析 简介一份基于阿里巴巴展示广告点击率数据集的 Python 电商广告推荐系统源码面向有一定 Python 基础、希望把推荐算法落地成完整工程的开发者和学习者。压缩包共13个文件以12个 Python 脚本为主另有1个 Markdown 说明文档整体仅21KB代码按离线召回、在线推荐、特征存储、品牌与品类打分等模块组织目录结构十分清晰便于快速定位学习。目前已有920人学习下载。项目内说明文档对原始样本与广告特征的字段做了说明可帮助理解用户、广告、品类、品牌等核心数据通过源码可以掌握 ALS 召回模型的落地写法、CTR 数据分析思路以及如何把离线特征写入 Redis 供线上推荐模块调用。对刚入门推荐系统、想复现电商广告点击率预估流程的人而言这是一份轻量但完整的实战范例也适合用作课程设计或面试项目参考。1. Python电商广告推荐系统源码先搞清楚这个 zip 里最值钱的部分在哪拿到一个“Python电商广告推荐系统源码.zip”最容易犯的错是一上来就找模型文件然后把 Item2Vec 或双塔训练跑通就觉得完事了。真在广告场景里待过的工程师都清楚这个 zip 里最值钱的部分是那条从广告日志到召回、精排、出价排序的完整链路而不是某一个单独的模型。用户点击一个商品广告后系统要在几十毫秒内完成候选压缩、CTR/CVR 预估、和广告主出价一起排序这一套流程在 Python 里每一步都有对应实现但每一步也都藏着坑。这篇文章就是顺着这个 zip 的常见内容拆开讲先立住广告推荐的理论前提再落到样本构造和可抄的训练代码最后把最容易翻车的五个问题和上线前的验证方法讲透。适合有点 Python 基础、手里正好有类似源码包、想把离线模型做成线上可用方案的从业者。2. 电商广告推荐为什么不能照搬内容推荐召回、排序和Embedding的分工2.1 广告推荐为什么不能照搬内容推荐三个本质差异做过内容推荐的人切换到广告推荐第一个不适应就是用户意图短促得多。刷信息流时用户有的是时间闲逛点不点一个视频取决于“感不感兴趣”但在电商广告场景里用户往往带着明确购买意图进来搜“蓝牙耳机”就是想买耳机搜不到合适的可能十秒后就走了。这意味着广告推荐要在更短的决策窗口里把“最可能被点击且广告主愿意付费”的商品排到前面。第二个差异是参与方的数量。内容推荐只需要对用户和内容两方建模广告推荐却必须把广告主也拉进目标函数。同样是曝光位A 广告主的出价是 0.5 元一次点击B 广告主出价 2 元一次点击即便系统预测用户点 B 的概率略低B 也可能因为出价高而赢得这个展位。这就要引入 eCPM 的概念按预估点击率 pCTR 乘以出价来排序而不是单纯按点击率排序。第三个差异是位置因素远比内容推荐强烈。同一个广告放在首屏和放在页面底部点击率可能差一个量级。如果模型把“位置好”学成了“这个广告好”那换个展位后预测就完全失真。所以后来成熟的方案都会把位置单列成特征甚至单独建模这在普通内容推荐里很少被提到。2.2 召回层的职责把百万级候选压到两三千广告系统的候选商品数量级通常在百万甚至千万精排模型不可能对全量商品做预估必须先做召回。召回的职责不是“找最准”而是“别漏掉”——哪怕多花点算力也不能让用户真正想点的商品压根没进候选集。最常用的三路召回在这个类型的源码包中几乎都能看到。第一路是 Item2Vec把用户点击序列当作句子、商品当作词喂给 Word2Vec训练出商品向量用用户最近点击的向量平均去检索相近商品。第二路是矩阵分解经典 ALS 或 BPR把 user-item 交互矩阵分解成低秩向量。第三路是双塔内积召回用户塔和广告塔分别输出向量用内积或余弦相似度做近邻搜索在线用 Faiss 这类向量检索引擎召回。三路召回往往还要做“多路合并”。我的习惯是每路设定配额比如 Item2Vec 出 30%、ALS 出 30%、双塔出 40%合并后去掉重复保证候选集的多样性。只留一路召回是广告推荐召回阶段最常见的贫血方案——模型再准漏掉的那批用户意图商品也补不回来。2.3 排序层的职责pCTR、pCVR 和 eCPM 出价排序召回把候选压到几千条后排序层要逐条做精细预估。正统的广告排序链路分三段粗排用轻量模型把几千条快速降到几百条精排用完整特征的大模型对几百条逐条打 pCTR如果广告主按转化付费还要再算 pCVR。精排之后不是直接按点击率降序输出而是按 eCPM 排序。这里要单独强调 eCPM 公式因为它是广告推荐区别于所有纯推荐系统的关键点。点击竞价模式下eCPM pCTR × bid转化竞价模式下eCPM pCTR × pCVR × bid。也就是说精排模型输出的是概率分数但真正排在前面的是“概率分数与广告主出价的乘积”。如果只把 pCTR 排序当成最终排序等于把广告主维度彻底丢掉了这在商业化场景里是不可接受的。2.4 为什么 Embedding 在这里是核心从 ID 向量到在线检索前面提到的三路召回本质上都是 Embedding 的产物。Item2Vec 输出商品向量ALS 输出用户向量和商品向量双塔输出的一侧是用户向量、另一侧是广告向量。理解了这点就能明白源码包里为什么往往有一段“训练向量 建索引 在线检索”的代码召回阶段根本不跑模型只是用训练好的向量去查最近邻。Embedding 的第二个价值是冷启动。新广告没有点击历史无法用协同过滤但它的 ID 向量可以先初始化为同类目商品的向量平均值或者用文本特征映射进向量空间。这样新广告即便没有曝光也能进候选。第三个价值是离线在线一致性向量可以在离线算好在线只做内积运算几十毫秒内返回结果。这比在线重算特征然后跑深度模型要快一个数量级。3. 广告日志怎么变成训练样本清洗、负采样和时间窗口3.1 先看日志长什么样EDR 里哪些字段值得进特征拿到源码包第一件事不是读模型代码而是找数据 schema。广告系统最常见的数据来源是 EDR 日志每条日志记录一次曝光事件主要字段通常包括 event_time、user_id、ad_id、item_id、position_id广告位、scene_id场景 ID、is_click是否点击、is_convert是否转化即下单或支付以及广告主侧字段 bid_price出价、creative_id素材 ID。是否要保留全部字段进特征取决于业务。我的经验是user_id、item_id、position_id 这三个 ID 类特征必须保留曝光和转化标签必留bid_price 只用于排序阶段不进 CTR 模型的预估值——因为出价是广告主策略和用户是否点击没有因果关系。creative_id 建议单独建 Embedding因为同一商品用不同创意图点击率差异很大。3.2 负采样比例怎么定曝光未点击和随机负样本要分开广告点击率通常在 0.5% 到 2% 之间正样本极少。如果把所有曝光未点击都当负样本正负比可以到 1:100 甚至更夸张模型会被负样本彻底淹没。常见的做法是保留全部正样本对负样本按比例采样整体控制在 1:5 到 1:10 之间。但负采样要区分两种来源选了曝光未点击的样本和随机从未曝光商品里抽的样本。曝光未点击代表“用户见过但没点”是真实的负反馈要多保留随机负样本代表“用户根本没见到这个商品”是人为制造的负样本要控制比例。如果随机负样本比例过高模型会学偏——它会把“这个商品没有曝光给用户”理解成“用户不喜欢这个商品”线上推出去后点击率反而下降。3.3 组装特征宽表小心时间穿越下面这段代码是这类源码包里最常见的样本构造逻辑把曝光日志和用户历史行为拼接成一条样本每行代表一个“用户-广告”二元组。import pandas as pd import numpy as np def build_samples(log_df, click_df, neg_sample_ratio8, seed42): # 正样本所有点击行为 pos log_df[log_df[is_click] 1].copy() # 曝光未点击的负样本 exp_neg log_df[(log_df[is_click] 0)].copy() # 随机负样本从未曝光集合里抽 all_items log_df[item_id].unique() rng np.random.default_rng(seed) exposed set(log_df[log_df[is_click] 1][item_id].unique()) random_negs [] for user_id in pos[user_id].unique(): # 每个正样本用户补 random_neg 条随机未曝光商品 candidates [i for i in all_items if i not in exposed] sampled rng.choice(candidates, sizemin(neg_sample_ratio, len(candidates)), replaceFalse) for item_id in sampled: random_negs.append({user_id: user_id, item_id: item_id, label: 0}) rand_neg_df pd.DataFrame(random_negs) # 合并取合理正负比 df pd.concat([pos, exp_neg, rand_neg_df], ignore_indexTrue) df[label] df[is_click].fillna(0).astype(int) # 特征用户在 7 天内对该商品的点击次数、对同类目点击次数 df[user_item_click_7d] df.apply( lambda r: click_df[(click_df[user_id] r[user_id]) (click_df[item_id] r[item_id]) (click_df[event_time] r[event_time]) # 时间窗口 (click_df[event_time] r[event_time] - 7 * 86400) ].shape[0], axis1) return df这段代码的关键在两处。时间窗口条件event_time r[event_time]是防“未来特征”的第一道防线——构造特征时只能用预测时刻之前发生的行为不能用之后的信息7 * 86400是 7 天窗口窗口越长特征越平滑但越滞后窗口越短越灵敏但容易稀疏。neg_sample_ratio控制负采样强度建议从 8 起步调参时观察线上点击率变化不要盲目加大。4. 可以直接抄进源码里的四段代码Item2Vec、ALS、LR 和双塔4.1 Item2Vec 召回20 分钟跑通的最小代码Item2Vec 是把用户的历史点击序列当成句子、商品当成词来训练。下面这段代码可以直接跑唯一的前提是sequences已经按用户和时间排好序。from gensim.models import Word2Vec # sequences 形如 [[item_1, item_3, ...], [item_2, item_5, ...]] # 每个元素是一个用户按时间排序的点击商品序列 model Word2Vec( sentencessequences, vector_size64, window5, min_count5, sg1, epochs10, workers4, ) model.wv.save(item_vectors.kv)sg1表示用 skip-gram对低频商品更友好window5意味着序列中前后 5 个商品互相成为训练对窗口越大越能捕捉“看完这个再看那个”的长距离关系但也更容易引入噪声min_count5过滤出现次数少于 5 次的商品避免冷门商品学出不可靠的向量vector_size64是最小可用维度在百万商品规模下通常要放到 128 到 256。训练完检查一下输出。打开item_vectors.kv找几个商品看看最近邻是否合理搜“iPhone 充电器”应该返回数据线、耳机这类配件如果返回的是完全不相关的类目多半是序列切分有问题——正样本序列里混入了太多噪声点击。4.2 用 implicit 做 ALS 召回三个必须调的参数矩阵分解是召回的老牌方法Python 里最省事的库是 implicit。ALS 不需要卷积或深度网络但它的参数直接影响召回质量。import implicit from implicit.als import AlternatingLeastSquares from scipy.sparse import coo_matrix # data: 每行是 user_idx, item_idx, weight # weight 用点击次数或置信度点击 1 次记 1购买记 2 mat coo_matrix((data[weight], (data[user_idx], data[item_idx]))) model AlternatingLeastSquares( factors64, regularization0.01, iterations20, random_state42, ) model.fit(mat) # 给 user_idx 召回 20 个商品 ids, scores model.recommend(user_idx, mat[user_idx], N20)三个重点参数factors是隐向量维度64 起步维度越大表达力越强但过拟合风险越高regularization是正则系数0.01 是个保守起点如果召回结果过于集中在热门商品可以调到 0.1 试iterations是交替最小二乘的迭代轮数20 轮够收敛继续加大收益很小但耗时翻倍。这个库有个隐含细节传给fit的稀疏矩阵会被当作“置信度矩阵”数值越大代表交互强度越高而不是简单二分类。所以构造矩阵时同一个 user-item 对如果发生过多次点击weight建议直接累加而不是只记 1 次。4.3 精排 LR广告位编号和出价特征一起进模型精排阶段不一定上来就上深度模型LR 依然是个很能打的基线。它的优势是可解释、不易过拟合、训练快适合先跑通全链路再替换成大模型。from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split # X 的列依次是user_emb(64维), item_emb(64维), # position_id, user_ctr_7d, item_ctr_7d, scene_id X_train, X_val, y_train, y_val train_test_split(X, y, test_size0.2, random_state42) lr LogisticRegression( C1.0, class_weightbalanced, solverliblinear, max_iter200, ) lr.fit(X_train, y_train)class_weightbalanced让 sklearn 自动按类别频率调整权重正样本少时直接在两三类正负比下训练模型几乎不会输出“点击”预测balanced 能立刻缓解这个问题C1.0是正则强度的倒数调小 C 会让权重更小、抗过拟合更强调大 C 则让模型更激进地拟合训练集solverliblinear在小批量稀疏特征上收敛快是处理广告日志这种高维稀疏数据最稳的选择。LR 的预测结果需要看分布直方图。正常情况是大多数样本的 pCTR 集中在 0.01 到 0.1只有少量高于 0.3如果预测分数出现大量接近 0 或接近 1 的情况说明特征里有泄漏或者正负样本切分有问题。4.4 双塔的简化版本内积和温度系数双塔结构如今是广告召回的主流方案因为在线检索可以把用户向量和广告向量分开算好再内积。import torch import torch.nn as nn class TwoTower(nn.Module): def __init__(self, n_user, n_item, dim64): super().__init__() self.user_emb nn.Embedding(n_user, dim) self.item_emb nn.Embedding(n_item, dim) self.user_layer nn.Linear(dim, dim) self.item_layer nn.Linear(dim, dim) def forward(self, user_ids, item_ids): u self.user_layer(self.user_emb(user_ids)) v self.item_layer(self.item_emb(item_ids)) return (u * v).sum(dim1) model TwoTower(n_user, n_item, dim64) # 训练策略正样本对 label1batch 内随机采其他物品做负样本 # 损失用 BCEWithLogits输出 logit 过 sigmoid 即为 pCTRdim64同样是向量维度self.user_layer和self.item_layer让两个塔不是单纯的 Embedding 查表而是各学一层非线性变换表达力更强代价是训练时间变长。用内积而不是余弦相似度内积保留向量的模长信息更接近点击率建模的原始需求如果换成余弦要额外加一个温度系数否则所有分数会被压缩到很窄的区间难以区分好坏样本。双塔最常见的误区是两个塔的输入特征不对称用户塔只用了 ID广告塔却塞了大量文本和价格特征。实践里要保证两个塔的特征复杂度大体匹配否则训练会偏向特征更丰富的那一侧。5. 电商广告推荐的五个翻车现场现象、原因和解决办法5.1 正样本比例失衡模型只会对热门广告位点头现象训练完成后打印预测分布发现模型对几乎所有样本都输出“不点击”哪怕在正样本集合上预测概率也只有 0.1 上下。线上 A/B 一看总体点击率没有提升热门展位反而掉了。原因正负样本比例 1:100 时LR 或神经网络不设置任何类别权重学到的决策边界几乎全是负样本的形态。更隐蔽的是模型学到了“position_id 越大越不点击”的假规律——因为大展位的曝光量大、负样本绝对数量多。解决先给正样本加权重class_weightbalanced是最快的干预更彻底的办法是把 position_id 单独作为偏置项加入模型输出pCTR sigmoid(user_item_score position_bias)线上排序时把 position_bias 置零让排序不依赖位置。这个“位置偏置校正”的做法在广告和搜索领域已经是标配源码包里如果没实现建议自己补上。5.2 离线 AUC 很漂亮线上点击率反而崩了现象离线测试 AUC 0.82团队信心满满上了全量第二天看线上点击率下降 15%。回看数据发现离线评估时窗口混用了未来数据。原因特征工程里用了“当天是否下单”“当天是否加购”之类的标签特征这些信息在训练时可用的实际预测时刻并不存在是典型的时间穿越。AUC 被这些未来信息抬得虚高线上完全没有对应特征模型自然失效。解决切数据集必须按时间切训练集和验证集以某个时间点为界验证集不能出现任何训练集时间之后的样本构造特征时强制要求event_time 预测时刻这样的窗口约束。我在每次特征上线前都会跑一遍“延迟回放”用几天前的特征数据模拟当天的预测对比预测值和实际值的差距这个验证方法能抓住大多数时间穿越问题。5.3 负采样过度离线 AUC 虚高线上召回质量下降现象把负采样比例调到 1:50 之后离线 AUC 仍有上涨但线上的点击率没有同步提升且广告主反馈曝光量下降。原因负采样比例过高模型学会了区分“真实曝光”和“人为采样”而不是区分“用户喜欢什么”和“用户不喜欢什么”。离线 AUC 只能说明模型能分开训练集里的正负样本但线上曝光分布和训练分布已经不一致。解决把曝光未点击的负样本全部保留只对随机负样本做采样且随机负样本不超过正样本的 5 倍。同时要看线上指标离线 AUC 只能用来做模型版本之间的对比不能作为放量依据。要根治线上线下不一致还要在训练时对采样后的样本做重要性加权。5.4 只看 AUC 不校准eCPM 排序被系统性带偏现象新模型的 AUC 比旧模型高 0.02替换上线后广告收入持平点击率也没提升。原因AUC 衡量的是“排序能力”不保证模型输出的概率是真实概率。比如模型对所有样本的 pCTR 都低估一倍排序顺序完全不变AUC 依然很高。但广告排序用的是 eCPM pCTR × bidpCTR 被系统性低估后低出价的优质广告会被错误地排到后面收入自然没变化。解决补做概率校准。把训练好的模型在时间窗口外的数据上预测画校准曲线看预测概率和实际点击率是否一致偏差大就用温度缩放或 Platt Scaling 校正。校准是广告推荐上线前必须做的步骤很多源码包里没有需要自己补。5.5 跑源码先翻车在环境Python 版本、NumPy 和内存现象import implicit报错、Word2Vec训练时内存暴涨、NumPy 版本冲突导致 scipy 调用失败。这类问题占了拿到新源码包后 70% 的调试时间。原因源码包可能是在 Python 3.8 NumPy 1.x 的环境下写的而你本机是 Python 3.11 NumPy 2.ximplicit 这类底层 C 库依赖版本锁得比较紧。加上商品向量维度高、日志量大内存不够就会在训练中途被杀进程。解决先为本项目单独建一个虚拟环境不要直接装进全局装依赖时优先看源码包里是否带 requirements.txt 或 environment.yml按锁定的版本装。如果 implicit 编译失败优先处理pip install implicit --no-cache-dir大概率能解决编译器找不到的问题内存爆炸则下调min_count或vector_size先跑通再逐步加大到生产规模。6. 上线前拿这三个指标自检召回覆盖率、NDCG 和校准曲线6.1 召回覆盖率看召回是否“只见热门”离线召回测试不能只看 hit rate还要看覆盖率——召回结果里究竟覆盖了多少不同的商品以及新商品有没有机会进入候选集。如果百万商品里召回的热门商品占了九成冷门商品几乎见不到说明召回模型被“马太效应”困住了广告主投放新商品的空间就没有了。我的习惯是把覆盖率作为召回阶段的第一自检指标低于 50% 就要调整负采样和热度的平衡。6.2 NDCG广告列表的质量不只是“点没点”点击和不点击是很粗的二分广告场景里转化价值不同。用户点了但没下单和点了直接支付对广告主的价值完全不同。NDCG 能处理这种分级转化算 1 分点击算 0.5 分单纯曝光算 0 分然后按位置折损。上线前我会对比新旧模型在 NDCG5 和 NDCG10 上的差异NDCG 持平而 AUC 提高说明提升在排序而不是在概率预测上。6.3 校准曲线和分数分布画两张图再做决定校准必须在上线前做下面这段代码画出校准曲线直接判断 pCTR 是否“说真话”。from sklearn.calibration import calibration_curve import matplotlib.pyplot as plt prob_true, prob_pred calibration_curve(y_true, y_score, n_bins10) plt.plot(prob_pred, prob_true, markero) plt.plot([0, 1], [0, 1], r--) plt.xlabel(predicted CTR) plt.ylabel(actual CTR) plt.show()如果点全部落在红色参考线上方模型在低估点击率落在下方则高估。排序顺序不受影响的“低偏差”可以勉强接受但幅度超过 20% 就严重影响 eCPM 排序。我上线前只看两张图一张校准曲线一张 pCTR 分布直方图。分布直方图能暴露 5.1 节说的“模型只预测一个常数”以及 5.2 节说的“分数极端化”这两张图都正常才敢放小流量 A/B。用这套方法做下来不敢说零踩坑但至少能保证拿到手的源码包在跑通之后不是“黑匣子”。这几年做的广告推荐项目里最深刻的教训就是模型只是链路里最小的一块召回覆盖率、位置偏置和概率校准才是真正决定线上效果的三块基石。希望帮到你。本文还有配套的精品资源点击获取
返回列表