
简介面向2021年微信大数据挑战赛的多目标预测解决方案专为参赛选手、推荐系统及用户行为分析研究者设计。资源聚焦微信视频号场景下的七类用户互动行为读评论、点赞、点击头像、收藏、转发、发表评论、关注基于行为数据构建点击率预测模型兼顾多目标训练的工程实现与基线参考。压缩包共19个文件、约84KB以Python源码为主7个py附带Shell脚本3个sh完成训练与推理另有Markdown/TXT说明文档、pyc编译文件及docx附赠材料结构与目录清晰便于快速定位。内容涵盖赛题思路、数据说明、模型代码和运行脚本读者可按流程复现预测任务也可借鉴其多任务特征设计与评估方法进行二次开发。该资料已有87人学习对备战数据竞赛和落地视频号互动预测具有直接参考价值。1. 微信大数据挑战赛的多目标预测七个行为目标不能拆成七个独立模型用户点开一个视频号作品可能在几十秒内产生读评论、点赞、点击头像、收藏、转发、发表评论、关注七种行为。2021年微信大数据挑战赛要解决的就是给出一条视频曝光样本在用户行为发生之前同时预测这七种行为各自的发生概率。这不是七次点击率预测而是一次多目标预测——七种行为共享同一条用户行为数据、同一个视频上下文却有着完全不同的稀疏程度和业务含义。对做推荐系统、广告CTR预估、用户增长的人来说这类“一个输入、多路输出”的场景极其常见信息流里的点赞/评论/转发按钮是同一批候选短视频场景下的复看/追更/付费也指向同一批内容。我最初按单目标思路训练了七个独立模型结果正样本稀疏的目标严重欠拟合特征重复计算浪费了大量时间后来改成共享底座的多目标模型才稳定下来。这篇按我做过同类任务的经验把标签构造、特征工程、模型选型、训练验证和踩坑点完整过一遍让新手能对着复现熟手能直接抄参数。2. 标签构造与数据切分把七种用户行为变成模型能学的目标矩阵2.1 七种行为的时序依赖决定了标签不能平铺七种行为不是平等的并列关系。用户先看到视频才可能读评论进入评论页后点赞、点击头像、转发这些动作才有发生的土壤点击头像往往是关注的前置信号但点了头像不代表一定会关注。构造标签矩阵时我习惯把这个行为链拆成两层理解曝光级动作样本本身出现就有判断价值读评论、点赞、点击头像深层动作依赖更多上下文正例更稀疏收藏、转发、发表评论、关注这里有个容易被忽略的点读评论的标签怎么定义。用户进入评论页就置1还是停留超过某个时长才置1按比赛数据的事件定义我以“该行为事件是否发生”作为标注准则即行为事件出现就置1未出现置0。这样能最大程度避免人工阈值引入噪声也让七个目标在统计口径上保持一致。如果你自己拿业务日志做类似任务建议先统一这个口径否则会做出“看起来是七分类、实际上标签语义各自为政”的模型后期排查根本无从下手。2.2 用 pivot_table 把行为流折叠成七列 0/1 标签原始日志通常长这样一个用户对一条视频产生多个动作每条动作一行记录带有行为类型和时间戳。要变成模型输入得先把这些行级事件折叠成样本级标签。我的做法是用pivot_table按曝光样本聚合行为类型做列事件计数做值再裁剪成 0/1import pandas as pd def build_label_matrix(event_log, exposure_id_colexposure_id, action_colaction): 把行为事件日志折叠成样本级多目标标签矩阵。 event_log 每一行 一个用户在某时刻对某视频产生的一次行为。 actions [ read_comment, like, click_avatar, collect, forward, comment, follow ] # 一个曝光样本上同一行为可能出现多次连点两次赞clip上限为1 label_df pd.pivot_table( event_log, indexexposure_id_col, columnsaction_col, aggfuncsize, fill_value0 ).clip(upper1) # 补齐缺失的行为列避免某些行为在局部样本上全为空 for col in actions: if col not in label_df.columns: label_df[col] 0 return label_df[actions].reset_index()这段代码里最关键的是aggfuncsize配合clip(upper1)前者统计同一个曝光样本上某行为事件发生的次数后者把多次事件折叠成“是否发生”的布尔标签。fill_value0保证没有出现过的行为补零避免稀疏矩阵里出现 NaN。参数说明曝光样本 id 的粒度决定了整个任务的语义。如果你的曝光 id 定义成“推送一次”那么用户反复看到同一视频会是多个样本模型学到的是“这次推送用户买不买账”如果按 uservideodate 聚合就丢失了“第几次推送”的信息模型会误以为用户对同一视频的所有曝光一视同仁。比赛数据给的是曝光级别样本我保留原始曝光 id 不做聚合把频次信息另算成特征。2.3 时间窗口切分为什么随机划分会让验证集失效比赛评测的是模型在“未来时刻”的预测能力。如果随机按行切分训练集和验证集同一条用户历史行为里的一部分出现在训练集、另一部分出现在验证集模型等于偷看了未来验证 AUC 虚高到 0.95 以上换成按时间切分立刻掉到 0.85 左右。这个落差就是信息泄漏的量级。常见做法是严格按时间序切分最后一天做验证集之前的数据做训练集。同时再做一次用户维度校验确保验证集里的样本发生在训练集对应的行为之后。cut_date 2021-05-30 train_df df[df[date] cut_date] val_df df[df[date] cut_date] # 用户横跨两个集合是正常的老用户又回来了 # 但特征构造时只能用预测时刻之前的状态。 train_users set(train_df[user_id]) cross_users val_df[val_df[user_id].isin(train_users)] print(f横跨用户占比: {len(cross_users) / len(val_df):.2%})关键点在于用户出现在两边并不算泄漏真正会泄漏的是特征构造时把该用户后知的信息比如验证集日期的统计量算进了训练样本。所以特征工程和样本切分必须绑定同一个时间锚点——所有统计特征统一以样本行为发生时刻为截止点绝不能用全量数据算。这段检查代码的作用只是让你心里有数验证集里有多少老用户、多少冷启动用户这直接影响你对模型表现的归因。2.4 正例率差异悬殊loss 权重必须分目标设置我打印过比赛数据的正例率分布大致是读评论 3% 左右、点赞 5% 左右、点击头像 3%、收藏 1.4%、转发 0.6%、发表评论 0.8%、关注 1.1%。看到这个分布我就知道转发和评论这种千分之级别的目标如果直接用 BCEWithLogitsLoss模型大概率全程输出 0——负样本太占优正例的梯度被淹没。处理办法是给每个目标单独设pos_weight用反频次作为初始值再乘一个 0.5~0.8 的缩放系数防止稀疏目标权重过大导致训练震荡import torch.nn as nn label_rate torch.tensor([0.032, 0.051, 0.030, 0.014, 0.006, 0.008, 0.011]) pos_weight ((1 - label_rate) / label_rate) * 0.7 criterion nn.BCEWithLogitsLoss(pos_weightpos_weight)BCEWithLogitsLoss原生支持pos_weight它会放大正样本的梯度贡献效果等价于给每个目标单独做正负样本加权又比手动采样稳定。反频次算出来的值在转发目标上可能高达 150直接套会震荡所以乘了 0.7。跑完第一个 epoch观察每个目标的 loss 是否都在下降如果某个目标 loss 纹丝不动说明权重还是不够逐步往上加。3. 点击率预测模型的特征工程用户行为数据怎么变成输入向量3.1 用户侧、视频侧、交互侧三类特征的分工这类任务的输入数据通常分三块。用户侧是性别、年龄、注册时长、活跃时段等静态画像加上他最近看过或交互过的视频痕迹视频侧是视频类别搞笑、知识、影视剪辑等、时长、是否原创、作者 id交互侧是用户与视频作者之间的关系、用户历史上对同作者视频的行为、当前时间上下文。真正决定七种行为预测质量的是交互侧特征——尤其是用户对同一个作者的历史行为、用户对同一类视频的历史点击率。统计特征在比赛里的权重往往高于 embedding 特征因为它们直接把“这个人喜不喜欢这个作者”的结论喂给模型省去了从行为序列里隐式学习的成本。我的特征表大致长这样特征组特征示例缺失值处理用户侧性别、年龄、活跃小时段、注册天数中位数填充视频侧视频类别 id、时长、作者 id、发布时间0 填充交互侧用户对同作者历史点赞率、对同类视频历史转发率0 填充 冷启动标志位交互侧特征全部要求“时点一致”——只在预测时刻之前累计否则就会滑进 3.3 说的泄漏坑。3.2 行为序列 embedding截断长度、时间衰减和空序列兜底序列特征是比赛里的大杀器把用户最近的 N 个行为按时间排序每个行为编码成“行为类型 物品 id”的组合 token构成一条行为序列。预测时只取预测时刻之前的行为绝不取之后。def build_action_seq(events, max_len20): # 按时间升序排列只保留最近 max_len 条行为 events events.sort_values(time).tail(max_len) seq [] for _, e in events.iterrows(): # 行为类型和视频id组合成token再一起embedding seq.append(f{e[action_type]}_{e[video_id]}) return seq这里max_len我一般从 20 起步行为日志密集时可以取到 30。太短丢信息太长让前端序列模型收敛变慢。行为类型和视频 id 联合编码后统一进 embedding 层顺序信息靠自带的序列位置保存。如果用户没有任何历史行为序列为空我会塞一个专门的[PAD]token 并把序列长度置 0让模型把冷启动用户识别成一种独立状态——这比直接填零更稳。时间衰减是另一个值得做的点在序列编码时给越靠近当前时刻的行为越高的权重。做法不复杂在 Attention 里直接加一个按时间间隔计算的偏置项即可不必改模型的整体结构。效果上它对读评论、点赞这类即时反馈行为提升最明显对收藏这种延迟行为帮助不大。3.3 滑窗统计特征不要在特征里偷看未来高频操作是给每个目标构造历史频次特征例如过去 7 天里用户对同作者视频的点赞率、过去 30 天的转发率。这里最容易翻车的就是滑窗对齐——必须只用滑窗内的事件滑窗截止时刻不能晚于预测时刻。而且当前样本自己的行为不能被计入统计不然就等于把标签告诉了模型。def add_window_stats(label_df, targetlike, window_days7): key_cols [user_id, author_id] sorted_df label_df.sort_values(date) stat ( sorted_df .groupby(key_cols)[target] .rolling(window_days, min_periods1) .mean() .shift(1) # 关键把当前样本自身排除在统计外 ) return statshift(1)就是常说的后悔药——不加它当前样本自己的行为被计入了历史统计验证集上的表现会严重虚高加了它特征才是真正的“预测时刻之前”状态。rolling窗口取 7 天或 30 天各有取舍窗口小响应快但稀疏窗口大平滑但迟钝。我一般两组都算——7 天做短期行为浓度30 天做稳定偏好。注意shift(1)是按行在排序后的数据上移动一位它排除了当前行但当同一时刻一个样本产生多个行为时同一样本内其他行为仍可能混进统计。要彻底干净需要按曝光样本 id 分组单独排除本样本的所有行而不是简单 shift。我在正式版本里用的是分组后取滑窗内其他样本的均值成本高一些但保险。3.4 大 id 表怎么压统计降维替代全量 embedding第一版特征我把所有离散 id 直接丢进 embedding效果很差——用户 id 和视频 id 的交叉维度上亿训练慢且严重过拟合。后来参照比赛常用做法把大 id 降维成邻域统计向量用户侧用其历史行为的统计过去 7 天交互视频类目的熵、平均行为间隔、活跃峰值时段视频侧用作者的历史平均互动率和视频的平均完播时长。这套降维让模型参数从亿级降到千万级七个目标一起训练时收敛明显加快。embedding 层只留给视频 id 和用户 id 的交叉特征并且用哈希桶压缩到几百万维度。结果证明统计特征承接了大部分个性化信号embedding 只是在它们之上做残差补充。4. 多目标模型架构怎么选Share-Bottom 多塔到 PLE选型与参数4.1 七个独立模型的问题参数爆炸与稀疏目标欠拟合第一直觉是给七种行为各训一个二分类模型最后把分数拼起来。实际做下来会撞上两堵墙转发目标只有千分之几正例单独训练时正样本不够模型只能记住少数高转发用户泛化几乎为零而点赞和读评论数据充足学到的表示又不被稀疏目标共享。七个模型变成七个大小不齐的黑匣子到线上决策时各自打分谁都用不好。多目标预测的核心价值在于共享底座底层的用户表示、视频表示、交互表示是一体的只是输出层分开。这样转发、关注这类稀疏目标可以借力读评论、点赞学到的稠密表示模型参数总量也小得多。做比赛时我先用单目标模型拿到每个目标的上界参考再切多目标模型对比结果多目标在稀疏目标上的提升有 2~3 个点稠密目标不掉点综合收益非常明显。4.2 Share-Bottom 多塔先跑通这个可解释的基线第一版多目标模型我建议从 Share-Bottom 开始不做花活。结构是特征层 → embedding concat → 若干层共享 MLP → 七条独立的塔分支 → 7 个 logit 输出。PyTorch 实现大概这样import torch.nn as nn class ShareBottomMultiTask(nn.Module): def __init__(self, num_features, hidden_units(512, 128), num_tasks7): super().__init__() # 共享底座所有任务共用这一段表示学习 self.shared_mlp nn.Sequential( nn.Linear(num_features, hidden_units[0]), nn.BatchNorm1d(hidden_units[0]), nn.ReLU(), nn.Dropout(0.2), nn.Linear(hidden_units[0], hidden_units[1]), nn.ReLU(), ) # 七个独立的输出塔每个塔只有一层线性层 self.towers nn.ModuleList([ nn.Linear(hidden_units[1], 1) for _ in range(num_tasks) ]) def forward(self, features): shared self.shared_mlp(features) logits [tower(shared) for tower in self.towers] return torch.cat(logits, dim-1)共享 MLP 的输出被七个塔共用梯度从七个方向回流到共享层让共享表示学到“对多个任务都有用”的特征组合。每个塔只有一个线性层参数少不容易过拟合BatchNorm 放在共享层内部而不是塔上因为塔参数本来就少再加 BN 会让稀疏目标更学不动。参数建议hidden_units(512, 128)是常见起步配置特征维度 200~300 时够用特征上到千维时第一层换成 1024。Dropout 放在共享层、概率 0.2 左右塔上不放。训练时对七个 logit 分别算 BCE loss再求和反传。4.3 PLE 作为进阶门控专家如何缓解目标跷跷板Share-Bottom 的短板是共享层被迫向七个目标的平均方向妥协稠密目标读评论、点赞效果好稀疏目标转发、关注效果拉胯——典型的跷跷板效应。PLEProgressive Layered Extraction在共享层旁边加一组专家网络和一个门控让每个目标自己选择“用共享知识还是用专属知识”。PLE 块的核心是门控加权每个任务的门控对共享专家池的输出做 softmax 加权不同目标可以从同一批专家里挑出适合自己的子集。代码如下import torch import torch.nn as nn import torch.nn.functional as F class PLEBlock(nn.Module): def __init__(self, input_dim, expert_num4, task_num7, hidden_dim128): super().__init__() # 共享专家池 self.experts nn.ModuleList([ nn.Sequential(nn.Linear(input_dim, hidden_dim), nn.ReLU()) for _ in range(expert_num) ]) # 每个任务独立的门控网络 self.gates nn.ModuleList([ nn.Sequential(nn.Linear(input_dim, expert_num), nn.Softmax(dim-1)) for _ in range(task_num) ]) def forward(self, x): # 所有专家分别计算 expert_outs torch.stack([e(x) for e in self.experts], dim1) gate_outputs [] for gate in self.gates: gate_weight gate(x).unsqueeze(-1) # (b, expert_num, 1) weighted (expert_outs * gate_weight).sum(dim1) gate_outputs.append(weighted) return torch.stack(gate_outputs, dim1) # (b, task_num, hidden_dim)逻辑说明experts是共享专家池每个专家做一个非线性映射gates为每个目标单独学一组权重选择性地组合专家输出。第 i 个任务的 gate 输入是同一份特征 x但输出权重完全属于任务 i因此转发目标可以学着只从读评论专家的输出里提取自己需要的信号。参数说明expert_num取 4 比较稳3 个偶尔表达力不足6 个以上每个专家学得碎片化且训练变慢hidden_dim取 128~256。PLE 替换 Share-Bottom 的共享 MLP 输出接七个塔之前先过 PLE 块。代价是训练时间涨 30% 左右但对比赛离线验证场景完全付得起。就我的经验PLE 相比 Share-Bottom 的提升通常在 0.5~1 个点以内胜在稳定——翻转和震荡的情况少。策略上先 Share-Bottom 跑通拿基线再换 PLE 追求上限不要一上来就上复杂结构。4.4 训练配置与防塔同化为什么七个塔会退化成同一个模型多目标任务一个隐蔽的坑是塔同化七个塔初始权重一样、loss 权重接近共享层梯度主导训练收敛后七路 logit 高度相关塔形同虚设。我加过约束让塔保持差异每个塔用不同的随机种子初始化对塔的输出做轻量正交正则计算七路 logit 的协方差矩阵把非对角线元素的绝对值均值乘一个小系数加入总 losslogits model(features) # 正交正则鼓励七路输出去相关 cov torch.cov(logits.T) off_diag cov - torch.diag(torch.diag(cov)) ortho_reg off_diag.abs().mean() * 0.01 total_loss task_loss ortho_regtorch.cov计算七路 logit 的协方差off_diag提取非对角线元素乘 0.01 的系数加入总 loss。这个值要小否则会过度压低模型表达能力。加上之后七路 logit 的相关系数从 0.98 降到 0.75 左右稀疏目标的 AUC 涨了约 1 个点。学习率配置上我用 Adam共享层学习率 1e-3塔的学习率 3e-3——塔参数少需要更大的步伐才能在共享表示上做出差异化。Batch size 1024跑 10 个 epoch第二个 epoch 后开始用验证集早停。5. 七目标预测高频避坑泄漏、不平衡、跷跷板的排错记录5.1 特征泄漏验证集 AUC 虚高到 0.97换时间切分直接腰斩现象线下验证集 AUC 一度冲到 0.97看起来完美得可疑。 原因构造用户行为统计特征时用了全量数据的全局统计量把“用户是否关注该作者”直接做成特征——而关注本身就是要预测的七个目标之一。特征和标签是同一件事模型直接读答案。 解决把所有待预测行为本身从特征工程里彻底剔除统计量一律只用预测时刻之前的数据加上shift(1)排除当前样本验证集改按时间边界切分。改完后 AUC 掉到 0.86这个数字才是可信的。这个坑在比赛里特别容易踩因为很多选手习惯用全量数据算均值根本没意识到时间顺序的重要性。5.2 正例率千分之几的目标全程输出 0现象forward 和 comment 两个目标的 logit 一开始就是很大的负值训练好几轮后正例预测几乎全是 0。 原因BCE loss 里负样本占绝对多数模型发现“全预测负例”的 loss 已经很低正例信号被淹没共享层只能学到一个平均表示稀疏目标的塔从平均表示里挖不到任何信号。 解决给稀疏目标加pos_weight同时把共享层和塔的学习率分开。调完以后观察正例召回率forward 从 0% 涨到 20% 左右再配合阈值调优最终 AUC 能逼近稠密目标的 98%。注意pos_weight不是越大越好超过某个值后模型开始狂报正例P/R 曲线整体漂移我用的是在验证集上二分搜索最优权重。5.3 多目标 loss 加权玄学调一个目标掉另一个现象把点赞 loss 权重从 1 调到 1.5点赞确实涨了 0.3 个点但读评论掉了 0.4 个点。 原因两类目标共享表示加权变化相当于改变共享层的梯度方向被调高权重的目标会挤压另一个目标的优化空间——这就是跷跷板效应。 解决先固定权重跑一组完整实验记录七个目标的 AUC 矩阵。如果所有目标同涨说明没有冲突继续调权如果有涨有跌停止手工调参换 PLE 或为目标单独设专家。调参时每轮只动一个权重记录完整表格不要同时调两个——否则你永远不知道是哪个动作起了作用。5.4 新用户和新视频特征全空预测完全失效现象验证集里有一批 user_id 从未在训练集出现模型给他们的所有目标都打出很低的分。 原因新用户没有历史行为滑窗统计特征全为 NaN序列特征为空模型能用的只有静态画像——典型冷启动问题。 解决所有统计特征统一填 0 或全局均值再加一个“该用户历史行为数是否为零”的桶特征把冷启动识别成一种可训练的状态。视频侧同理新视频没有互动数据时用作者历史均值兜底。这个操作会损失一点老用户精度但换来验证集整体分数的稳定对比赛排名是净收益。5.5 七个塔输出同化相关系数 0.98现象训练收敛后读评论和转发两个目标的 logit 相关系数高达 0.98七路分数几乎一样。 原因塔初始化相同、loss 权重接近、共享层梯度主导目标专用塔学的只是共享输出的小扰动。 解决每个塔用不同随机种子初始化加入输出协方差的正交正则项详见 4.4。改完后相关系数降到 0.75转发目标 AUC 提升 1 个点。每次改模型结构后我都习惯打印七路 logit 的相关系数矩阵这个数字比 AUC 更能暴露多目标模型的结构问题。6. 收尾技巧用验证集把七路分数合并成可解释的决策比赛最终评测通常不给单一 AUC而是给七路目标各自的 Auc 再加权求和或者要求按排序质量打分。最后一步我习惯做的是把七个 logit 的 sigmoid 概率画出来手动设定每个目标的决策阈值把“七路概率”降维成“强互动 / 弱互动 / 仅浏览”三档再针对每档做后续策略。# 七个目标的概率输出preds shape (n_samples, 7) proba torch.sigmoid(torch.from_numpy(preds)).numpy() # 每个目标在验证集上的正例率作为阈值初值 valid_rate np.array([0.032, 0.051, 0.030, 0.014, 0.006, 0.008, 0.011]) threshold valid_rate.copy() # 留一法对每个目标单独找使 F1 最大的阈值 for i in range(7): best_f1, best_thr 0, valid_rate[i] for thr in np.linspace(0.01 * valid_rate[i], 3 * valid_rate[i], 50): pred proba[:, i] thr f1 compute_f1(val_label[:, i], pred) if f1 best_f1: best_f1, best_thr f1, thr threshold[i] best_thr # 强互动 至少有两个目标的概率超过阈值 strong_click (proba threshold).sum(axis1) 2阈值不能直接用正例率——模型输出的概率整体偏低直接把正例率当阈值会把太多样本判成正例。我一般按每个目标扫 F1 的平衡点关注这种千分之级别的目标最优阈值通常要到正例率的 2~3 倍。验证时画一遍每个目标在验证集上的精确率和召回率随阈值变化的曲线取平衡点附近的值再按比赛加权公式算总得分选最优阈值组合。做多目标预测最怕的不是模型不够深而是自己骗自己——特征泄漏、窗口对齐、塔同化这三个问题解决掉排名基本就定型了。我后来再看这类赛题的方案最值得借鉴的不是某个具体网络结构而是“把七种行为当成一件事来做用一套模型服务多个业务目标”的思路。这个复盘希望对你在视频号、信息流或短视频社区的多目标预测上能帮上忙。本文还有配套的精品资源点击获取