拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

微信大数据挑战赛2021实战:用户行为预测与特征工程全解析

微信大数据挑战赛2021实战:用户行为预测与特征工程全解析 简介2021微信大数据挑战赛WBDC参赛代码与方案整理面向对点击率预测、多目标行为建模感兴趣的算法学习者。赛题要求基于用户与feed信息预测读评论、点赞、收藏、转发等7类行为本质是点击率预估任务资源重点解决了item冷启动与用户共现性两大难点采用word2vec预训练等手段提升精度作者团队最终在复赛B榜取得约0.700的成绩。包体共19个文件以Python源码7个py为主辅以4个pyc缓存、3个Shell脚本、3个txt说明和2个Markdown文档整体仅51KB代码轻量、结构清晰便于直接阅读与复现。目前已有53人学习下载。通过这份资料可以了解到完整的数据处理、模型构建、训练与inference流程以及针对冷启动特征的实战处理思路适合想要参加推荐/广告类算法比赛或研究多目标预估的读者参考。1. 微信大数据挑战赛2021.zip一个压缩包背后是完整的用户行为预测赛题当你从竞赛平台或个人网盘下载到“微信大数据挑战赛2021.zip”这个文件时第一反应应该是这到底是一个赛题数据包还是某位选手的代码存档常见的情况是前者——它打包了赛题说明、训练数据、测试数据、评价脚本和一个基线代码目录。这类比赛的典型任务是给定微信生态内的用户行为日志或小程序访问记录预测用户的后续动作点击、转化、停留时长。对于想接触真实业务数据的从业者来说这个包的价值不在“微信”两个字本身而在于它包含了一套完整的、干净的、可以直接复现的推荐/预估任务链路。这个赛题方向适合两类读者一是准备数据科学竞赛入门、但不想从 Kaggle 的英文赛题起步的人二是想在简历里补一段“用户行为预测 特征工程 排序模型”经历的在职数据工程师。后续所有内容都围绕一个目标展开把 zip 里那份原始数据变成一份你可以讲清楚、能复现、分数可信任的完整方案。2. 解包与数据摸查先把 zip 里的家底数清楚2.1 用命令行解压并核对文件清单拿到 zip 第一步不是双击解压而是先看压缩包内部结构避免把一大堆 JSON 文件直接灌进当前目录。在 Linux 或 macOS 下我习惯这样做unzip -l WeChat_Challenge_2021.zip | head -50-l列出压缩包内的文件清单head -50只看前 50 行这样能快速判断压缩包有没有目录层级是否包含 readme、数据目录、代码目录。如果输出显示所有文件都平铺在一个层级建议先建目录再解压mkdir -p ./wechat_2021 unzip WeChat_Challenge_2021.zip -d ./wechat_2021这里-d指定目标目录防止解压出来的数据文件混进你现有的项目目录后面清洗时误被 glob 匹配到。Windows 上如果没有 unzip 命令用 7-Zip 的7z x WeChat_Challenge_2021.zip -o.\wechat_2021效果一致。解压后先看 readme 或赛题说明确认三件事预测目标是分类还是回归、评价指标是什么、训练集和测试集的时间切分点在哪。这三个信息决定了后续所有特征工程和模型选型的方向。2.2 评估数据规模与格式JSON 日志还是宽表微信大数据挑战赛2021 的数据通常是用户行为日志每条记录包含用户 ID、行为类型、时间戳、页面/物品 ID 等字段。常见格式有两种一种是以user_log为目录名、每天一个 JSON 文件另一种是直接给 CSV 宽表。先看文件头确认格式head -100 ./wechat_2021/data/user_log_train.json如果能看到一行行的 JSON 对象后续读取用 pandas 的json.loads逐行解析。这里有一个常见的低级错误pd.read_json直接读整个文件遇到多行 JSON 时会报错因为 DataFrame 期望的是单个 JSON 数组或记录式 JSON。逐行解析的代码这样写import json from pathlib import Path import pandas as pd rows [] for line in Path(./wechat_2021/data/user_log_train.json).open(r, encodingutf-8): line line.strip() if not line: continue rows.append(json.loads(line)) df pd.DataFrame(rows) print(df.shape) print(df.dtypes)逻辑说明逐行读文件、strip 去掉空行和首尾空白、json.loads把每行转成 dict最后统一转 DataFrame。这样即使某些行格式损坏也只会中断在那一行而不会整个文件读不了。参数说明如果你确认文件是单行 JSON 数组可以直接pd.read_json(path, linesFalse)但日志型数据十有八九是 JSON Lines每行一个 JSON所以lines参数不要乱省。拿到 DataFrame 后立刻做三件事看df.info()里各列的非空数量确认缺失比例看df[user_id].nunique()和df[item_id].nunique()判断用户和物品的覆盖度看时间戳范围df[ts].min(), df[ts].max()确认和赛题说明里的时间切分是否一致。2.3 行为日志的人工探查先写统计别急着建模很多人在这一步直接开始拼特征结果后面发现 user_id 和 item_id 的编码方式是字符串直接groupby没问题但做 embedding 需要重新编号。我一般先在探查阶段把数据压缩到可交互的规模按 user_id 排序统计每个用户的行为数、行为类型分布、活跃天数输出到一个临时 CSV 里用来“找感觉”。import pandas as pd df[date] pd.to_datetime(df[ts], units).dt.date user_stat df.groupby(user_id).agg( behavior_count(ts, count), active_days(date, nunique), type_count(action_type, nunique), ).reset_index() print(user_stat.describe()) user_stat.to_csv(./user_stat_explore.csv, indexFalse)这里units表示时间戳单位是秒如果你的日志是毫秒时间戳改成unitms否则日期会偏移到 1970 年附近这是最容易忽略的坑。active_days用nunique计算去重活跃天数它和behavior_count的比值能粗略反映用户活跃强度后续做频率特征时可以作为分母。这样一轮摸查下来你应该能回答三个问题数据量级在几十万行还是几千万行特征空间是偏用户侧还是偏物品侧赛题是让你预测“会不会发生”还是“发生多少次”这三个答案直接决定你打算用 LightGBM 还是用序列模型。3. 特征工程从行为日志里挖出用户意图3.1 时间窗口与滑窗统计一个完整可跑的基线特征特征工程这一步的目标不是炫技而是先把一套“时间窗口统计特征”跑通让 AUC 达到一个不丢人的基线。核心思想是对每个用户在历史行为序列上按不同的时间窗口1天、3天、7天分别计算行为数、类型数、活跃天数、点击率然后拼到训练样本上。下面以“预测用户次日是否转化”为例给出可复现的代码骨架import pandas as pd import numpy as np def build_window_features(df, windows[1, 3, 7]): feat_list [] for w in windows: # 只取预测日之前 w 天内的行为 cut_off df[date] df[date].max() - pd.Timedelta(daysw) part df[cut_off] tmp part.groupby(user_id).agg( **{fcnt_{w}d: (ts, count), factive_days_{w}d: (date, nunique), ftype_cnt_{w}d: (action_type, nunique)} ).reset_index() feat_list.append(tmp) from functools import reduce res reduce(lambda left, right: pd.merge(left, right, onuser_id, howouter), feat_list) return res feat build_window_features(df, windows[1, 3, 7]) print(feat.head())代码里的**{...}是给agg传动态列名的写法如果不用这种方式就得写agg(cnt_1d(ts, count))然后三行重复代码动态生成列名在窗口很多时会省很多事。howouter保证新出现的行为量少的用户也能留存在特征表里避免后续 join 掉样本。窗口数量建议先用 [1, 3, 7]因为这三个窗口在业务上分别代表“昨日刚发生”“短周期习惯”“一周规律”覆盖了主流转化信号。参数说明windows是一个纯业务参数没有必须设成多少的硬规则。如果你的预测目标跨度是 7 天窗口就往上提到 [7, 14, 30]如果赛题里日志只有 10 天那 30 天窗口就没有意义。特征工程永远跟着预测目标的时间尺度走不是窗口越大越有用。3.2 行为序列的编码把“顺序”也变成特征滑窗统计把行为压成了计数但丢失了顺序信息。一个用户昨天“浏览→点击→购买”和“购买→浏览→点击”这两个序列的意图完全不同。常见做法是提取“最后一个行为类型”和“倒数第二个行为类型”作为类别特征。代码实现时用groupbytail非常直接df_sorted df.sort_values([user_id, ts]) last_action_df ( df_sorted.groupby(user_id) .tail(2) .assign(seq_ranklambda x: x.groupby(user_id).cumcount(ascendingFalse)) .pivot(indexuser_id, columnsseq_rank, valuesaction_type) .rename(columns{0: last_action, 1: second_last_action}) .reset_index() )seq_rank用groupby(user_id).cumcount(ascendingFalse)实现倒序排列排名 0 是最近一次行为排名 1 是上一次行为。tail(2)取每个用户最后两行再 pivot 成宽表。这样得到的last_action和second_last_action直接作为类别特征喂给树模型。注意一个边界条件如果某个用户只有一条行为记录tail(2)只能取到一行pivot 后second_last_action会是 NaN。对于缺失值先观察数量再决定策略如果缺失比例低于 5%LightGBM 原生支持在分裂时处理缺失值可以直接保留如果缺失比例高说明这个特征本身覆盖度不足可以考虑换一种序列编码方式比如用“最近一次行为距离预测日的时间差”替代。3.3 正负样本构造这是最容易翻车的环节赛题如果给的是“行为日志 一个回放窗口”你需要自己构造训练样本。常见做法是对每个用户在 T 日内是否有目标行为比如点击某个指定页面作为 label特征只使用 T 日之前的数据。这个“T 日之前”的切分是最容易造成数据泄露的地方。label_date df[date].max() - pd.Timedelta(days1) # 正样本在 label_date 当天有 click 行为的用户 pos ( df[(df[date] label_date) (df[action_type] click)] .groupby(user_id).size().reset_index(namelabel_cnt) ) pos[label] 1 # 负样本在 label_date 当天出现过但没有 click 的用户 active_not_click ( df[df[date] label_date] .groupby(user_id)[action_type].apply(lambda x: int((x click).sum() 0)) .reset_index(namelabel) ) # 特征只用 label_date 之前的数据 feat build_window_features(df[df[date] label_date], windows[1, 3, 7])逻辑说明先把用户按“当天是否有目标行为”分成正负样本再把窗口统计限制在date label_date保证特征和 label 严格时间隔离。不要先算全量特征再在拼接时按日期过滤因为窗口统计里一旦混入了 label 当天的行为AUC 会虚高到 0.9 以上线上直接打回原形。这种情况在很多比赛复盘里叫“穿越特征”属于作弊特征之一。参数说明label_date选择max(date) - 1是因为要留出最后一天做测试集。如果你的赛题明确给了测试集日期就按它的切分来不要自己另搞一套。另外注意正负样本的构造范围负样本限定在“当天出现过”的用户里而不是全量用户否则会引入大量不活跃用户让模型学到“活跃度”而不是“转化意图”。3.4 用户与物品 id 的类别编码树模型可以直接吃字符串类别特征但有些场景下你需要把 id 映射成数值以便训练 embedding 或做矩阵分解。常见做法是统一编码df[user_id_code] df[user_id].astype(category).cat.codes df[item_id_code] df[item_id].astype(category).cat.codes注意astype(category)之后.cat.codes得到的编码顺序是按照类别在数据中出现的顺序不是按照业务含义所以这套编码只用于模型内部计算不要试图从编码倒推用户关系。如果用户量很大百万级树模型直接吃原始字符串会更方便不需要单独编码。4. 模型与评价指标LightGBM 撑起评分AUC 要看得懂4.1 评价指标的细节性差异AUC 和 GAUC 不是一回事微信大数据挑战赛2021 这类用户行为预测赛题官方评价指标往往不是全局 AUC而是基于用户分组的 AUC 均值即 GAUC。简单说AUC 把所有样本混在一起算GAUC 先按 user_id 分组分别算 AUC再按每个用户的样本数加权平均。两者差别很大全局 AUC 容易被“高活跃用户”左右GAUC 更关心每个用户内部的排序能力。所以你在模型调参时要用和官方一致的评价函数做早停否则会出现本地验证分数涨但官方分数跌的诡异情况。如果你拿到的 zip 里带官方评分脚本直接读它确认指标含义。下面是一个与 GAUC 等价的实现很多队伍最后用的都是这个from sklearn.metrics import roc_auc_score import numpy as np import pandas as pd def gauc(y_true, y_pred, users): df pd.DataFrame({y_true: y_true, y_pred: y_pred, user: users}) scores [] weights [] for _, grp in df.groupby(user): if grp[y_true].nunique() 2: continue # 该用户全是同一类别跳过 scores.append(roc_auc_score(grp[y_true], grp[y_pred])) weights.append(len(grp)) if not scores: return float(nan) return np.average(scores, weightsweights)实现里有两处细节容易踩坑一是每个用户组内如果只有一个类别roc_auc_score会报错所以直接跳过二是权重用len(grp)还是grp[y_true].sum()官方定义差异很大读 zip 里的 eval 脚本为准。如果你的评估脚本和你本地 GAUC 实现不一致最坏情况是本地验证和在线评分完全对不上所以这个函数值得单独用一个文件存起来每次训练结束都用它算一次。4.2 从特征表到训练样本合表、切分、训练一轮特征表建好后和 label 表 merge再喂 LightGBM。这里完整的代码骨架如下import lightgbm as lgb from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score train_df feature_table.merge(label_table, onuser_id, howinner) # 用 5 折交叉验证保证每折正负样本比例一致 skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) feature_cols sorted([c for c in train_df.columns if c ! label]) for fold, (tr_idx, va_idx) in enumerate(skf.split(train_df, train_df[label])): tr train_df.iloc[tr_idx] va train_df.iloc[va_idx] dtrain lgb.Dataset(tr[feature_cols], labeltr[label]) dvalid lgb.Dataset(va[feature_cols], labelva[label], referencedtrain) params { objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 31, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, min_data_in_leaf: 100, verbose: -1, seed: 42, } model lgb.train( params, dtrain, num_boost_round2000, valid_sets[dvalid], callbacks[lgb.early_stopping(100), lgb.log_evaluation(200)], ) va_pred model.predict(va[feature_cols], num_iterationmodel.best_iteration) print(ffold {fold}: AUC {roc_auc_score(va[label], va_pred):.5f})参数说明逐个讲。num_leaves31是 LightGBM 默认值如果你的特征数很多超过 200 个可以升到 63 或 127但要注意过拟合时训练 AUC 和验证 AUC 差值会拉大这个差值超过 0.05 就要降。feature_fraction0.8和bagging_fraction0.8是给模型加随机性多折结果会更稳。min_data_in_leaf100防止叶子节点样本太少导致某个特征在个别叶子上的统计没有意义这在用户行为数据里尤其重要因为长尾用户很多。early_stopping(100)表示验证集 AUC 连续 100 轮不提升就停止训练log_evaluation(200)每 200 轮打印一次日志避免训练日志刷屏。如果你打印出来发现 90% 的轮次 AUC 卡在一个值不动说明特征已经提取得差不多再叠模型结构收益有限该回头补特征。4.3 树模型之外什么时候值得试序列模型LightGBM 对这类“多用户、多行为、少特征语义”的任务通常已经能到很高的分数但有几种情况值得升级到序列模型第一log 里行为序列长度差异非常大从 1 到 1000树模型对长度只能做截断或比例特征处理不了动态长度第二官方指标是 GAUC对用户内部排序要求高GRU/Transformer 这类对序列语义建模的模型在组内排序上通常更强第三你有充足的 GPU 时间和比较大的数据集几百万行日志可以让深度模型吃饱。坦白说用深度模型处理这类任务的工程成本不止翻倍需要重新设计数据管道、训练时间长、预测代码要处理不定长序列。如果这是你第一次打这个赛题我建议把 LightGBM 方案做到极致特征到几十个、多折验证稳定之后用深度模型作为另一个模型做集成而不是直接替换。4.4 类别特征的入模方式用户行为数据里除了数值统计特征还有不少类别特征比如 action_type、last_action、item_id。LightGBM 对类别特征的官方推荐方式是直接声明为 categoricalcategorical_cols [action_type, last_action] dtrain lgb.Dataset(tr[feature_cols], labeltr[label], categorical_featurecategorical_cols)这里categorical_feature参数传入列表LightGBM 内部会做分裂时的类别最优切分比独热编码更高效。但注意一个坑categorical_feature里的值必须在训练集和验证集中保持一致如果验证集出现了训练集没有的类别LightGBM 会警告并把这些值归为缺失。如果你的用户行为日志里某个类别只在验证集出现比如新的 action_type建议直接把编码方式改为频率特征或 embedding而不是强行让树模型吃原始类别值。5. 踩坑与常见问题排查从解压到提分的五个反面教材5.1 zip 伪加密导致解压失败现象unzip 提示需要密码或 7-Zip 显示“加密头”。实际上这个压缩包只是把 zip 的加密标志位改了文件内容并未真正加密。原因很多竞赛数据包上传前经过加密压缩但年代久远或工具链不一致导致压缩包的通用标志位被置位。也有作者手动修改加密位防止误传的。解决在 Linux 下用zip -s 0查看或直接尝试7z x解压很多伪加密包能被 7-Zip 无视标志位直接解出来。如果还是不行用zipdetails检查一个个 entry 的通用位标志找到第 0 位是否为 1手动改回 0 后重新压缩。这个操作属于货真价实的“血泪经验”——我第一次拿到这种包时差点给发件人发邮件要密码后来发现是伪加密浪费了半天。5.2 时间戳单位不一致导致日期错位现象特征里active_days算出来只有 1970-01-01 一天或者所有用户活跃天数都异常小。原因日志里的ts可能部分是秒级、部分是毫秒级混在一起没法直接pd.to_datetime(df[ts], units)。这个情况在拼接多个来源的文件时经常出现。解决先看df[ts].astype(str).str.len().value_counts()统计时间戳位数如果是 10 位和 13 位混在一起写一个条件判断超过 10 位就除以 1000 再转换。加一个中间列保留原始值用于复核比直接原地改安全。def normalize_ts(ts): s str(ts).strip() if len(s) 13: return int(s) // 1000 if len(s) 10: return int(s) raise ValueError(funexpected ts: {s}) df[ts_norm] df[ts].map(normalize_ts) df[date] pd.to_datetime(df[ts_norm], units).dt.date5.3 数据泄露让离线 AUC 虚高到 0.95现象训练时 AUC 0.95提交线上分数远低于验证差 0.05 以上。原因最典型的一个是特征工程用了未来信息。比如计算用户历史行为次数时没有按日期过滤把预测日当天的行为也统计进去了。第二个是标准化时用了全量数据的均值和方差把测试集信息“偷渡”到了训练阶段。解决特征构建函数里强制要求一个max_date参数所有窗口计算都基于df[df[date] max_date]。归一化或标准化操作全部在交叉验证的每一折内重算严禁在训练集上 fit 后直接 transform 验证集。这一点可以直接写进代码规范里每个入参人员都得执行def build_features(df, max_date): window_data df[df[date] max_date] # ... 窗口统计基于 window_data而不是 df另一个隐蔽的泄露来源是目标编码。给类别特征做统计编码时必须在折内对训练集计算均值/频数再映射到验证集不能全量数据算好直接切分。常见做法是把user_id的历史频率特征也放进build_features里一起按时间截止而不是事后从头表里 join。5.4 用户长尾导致 GAUC 权重失衡现象本地 GAUC 提不上去细看发现大部分用户只有一两条行为每个用户组内样本太少AUC 要么是 1.0 要么是 0.0加权后极不稳定。原因行为数据天然是偏态分布头部用户贡献大量日志尾部用户贡献极少。GAUC 在尾部用户组上没有任何区分度权重却依然计入。解决一是把只有一条行为的用户从验证集里剔除并单独统计最终给线上的说明里提到“仅在行为数2的用户上评估”二是在特征层面加重“历史行为数”这类全局特征的比重让尾部用户也能借助群体统计获得排序。注意不要在评估时偷偷删掉尾部用户来刷分这属于自欺欺人的操作线上分数不会配合你。5.5 LightGBM 训练报 feature names mismatch现象训练跑得好好的折与折之间突然报“Training data feature names mismatch”或ValueError。原因交叉验证时某一折的验证集来自train_df.iloc[va_idx]和训练集的列顺序不一致切分后列顺序可能被重置LightGBM 对列顺序敏感。解决在构造 Dataset 之前统一做一次列顺序对齐。最稳妥的方式是在合表后把特征列名sorted(feature_cols)固定下来切分时保证tr[feature_cols]这样顺序始终一致。如果你是直接在 DataFrame 上训练这个错很少出现如果你用了split返回的 numpy 数组就要注意列顺序问题feature_cols sorted([c for c in train_df.columns if c ! label]) tr_x tr[feature_cols] va_x va[feature_cols] # 再传入 lgb.Dataset5.6 评测脚本不一致导致本地分数失真现象本地用 sklearn 的roc_auc_score计算 Auc 是 0.82提交线上却只有 0.76且多次提交波动不大排除了随机性。原因赛题官网评测往往带一个“分组截断”逻辑比如对每个用户只取预测概率最高的前 N 个样本计算命中率而不是全量样本的 AUC。你的评测函数和官方不一致导致本地优化方向和线上目标偏离。解决把 zip 里的官方评测脚本原样跑通不要把roc_auc_score当唯一标准。如果脚本是 Python 文件调试模式跑一遍确认它对空分组、单类别分组的处理逻辑再在本地复刻一个 miniapi 版本。如果脚本是二进制或者独立可执行文件可以把预测结果存成官方要求的格式用 subprocess 调起评测脚本把输出解析出来写进实验记录。这个步骤虽然绕但能保证你做的一切调参都在“正确的坐标系”里。6. 进阶用法把本地验证做厚再用特征重要性决定下一步走到这一步模型已经能跑通分数也稳定在一个水平剩下的工作全部围绕一件事可信的本地验证框架。首先是多折一致性检查你训练时应该把 5 折的得分方差打出来如果 5 折 AUC 方差大于 0.01说明你的特征或样本随机性太大此时去调参意义也不大。建议把每折预测结果全部合并再算一次整体 GAUC这个值和每折均值之间如果差异明显往往意味着某些用户组预测波动大需要回到特征层面修补。其次用特征重要性做下一步决策。LightGBM 训练完后model.feature_importance(gain)返回各特征的分裂增益总和。对它排序如果前 10 个特征贡献了 90% 以上的重要性可以尝试删掉后 30 个特征再跑一轮验证如果删掉后验证 AUC 几乎不变说明你的特征有一批“陪跑”字段去掉后模型更轻、线上推理更快。这个操作虽然朴素但比盲目堆特征有效率得多我一直用它来筛选冗余特征importance pd.DataFrame({ feature: model.feature_name(), gain: model.feature_importance(gain), }).sort_values(gain, ascendingFalse) print(importance.head(20))最后一个小习惯值得所有人复制每次调参和特征变更把当时的验证分数、模型参数、特征列表写进一个 CSV 记录。这个文件到赛题后期就是你的“后悔药”。我见过太多组调了十几次参数最后根本不知道哪个版本支撑了当前最高分只能靠聊天记录里翻找。这个 CSV 结构很简单一行一次 experiment列是 date、feature_version、params_version、gauc_fold_mean、gauc_fold_std、线上分如果有。同样一份数据反复实验时这个文件能帮你省下至少一个晚上的时间。希望这些踩坑记录和验证习惯能帮到你让你拿到“微信大数据挑战赛2021.zip”之后少走点我走过的弯路。本文还有配套的精品资源点击获取
返回列表