移动推荐算法竞赛实战:从数据切分到模型融合的完整链路 简介阿里移动推荐算法竞赛.zip 是一套面向人工智能、计算机科学与技术等专业学生的算法竞赛实战源码适合用作毕业设计、课程作业或推荐系统入门练习。资源围绕阿里移动推荐场景提供从样本处理到模型训练与预测的完整流程帮助读者理解推荐算法在真实赛题中的落地方式。压缩包共118个文件约636KB以csv数据文件、py脚本、cs与cpp源码为主辅以h头文件、bat批处理脚本及配置文件覆盖数据处理、神经网络实现与工程构建等环节。已有226人学习关注。下载后可先查阅README.md了解目录结构源码均经测试验证可正常运行读者能获得赛题方案参考、样本处理脚本、模型代码与排错思路便于在此基础上复现实验、修改参数并完成自己的推荐算法项目。1. 移动推荐赛题到底在考什么从一份离线日志说起移动推荐算法竞赛这个题目很多人第一眼会把它当成普通的推荐系统练手项目觉得无非是协同过滤加个排序。但真正跑过一遍的人会告诉你它和电商推荐、内容推荐最大的区别在于行为数据极度稀疏、时间窗口极短、且用户-商品交互几乎全是隐式反馈。你拿到的往往是一份按天切分的用户行为日志字段包括用户标识、商品标识、行为类型点击、收藏、加购、购买、时间戳和地理坐标。没有评分没有显式偏好只有“做了还是没做”。这个赛题适合两类人一是想从零搭一套推荐 pipeline 的工程师二是已经在做召回排序但没系统处理过时间衰减和稀疏性的人。它解决的核心问题是——在只有几天行为数据的前提下预测用户在未来某天会不会对某个商品产生购买行为。难点不在模型多深而在特征工程和负采样策略。下面我从数据切分、特征构造、模型选型到排错把这条链路拆开讲。2. 数据切分与负采样别让时间穿越毁掉你的离线评估2.1 为什么随机切分是第一个坑很多新手拿到日志后直接train_test_split(random_state42)然后得到一个 AUC 0.95 的漂亮结果上线就崩。原因很简单推荐系统的评估必须按时间切分。用户在第 5 天的行为不能用来预测第 3 天的结果否则模型学到的是“未来信息泄漏”。常见做法是取前 N-1 天的数据做训练第 N 天做验证。如果数据量够再往前推一天做测试。比如总共 5 天日志用第 1-3 天训练第 4 天验证调参第 5 天测试。这样切出来的负样本才接近线上真实分布。import pandas as pd # 假设日志字段: user_id, item_id, behavior_type, timestamp, geo df pd.read_csv(behavior_log.csv, parse_dates[timestamp]) # 按天切分不要随机 df[date] df[timestamp].dt.date train df[df[date] 2024-06-04] valid df[df[date] 2024-06-04] test df[df[date] 2024-06-05] # 正样本购买行为 train_pos train[train[behavior_type] buy][[user_id, item_id]].drop_duplicates() valid_pos valid[valid[behavior_type] buy][[user_id, item_id]].drop_duplicates()这段代码的关键在drop_duplicates。同一个用户同一天可能多次购买同一商品去重后才是真正的“用户-商品”正样本对。参数上date的边界要根据实际日志跨度调整一般留出至少一天做验证否则调参没有意义。2.2 负采样全量负样本为什么跑不动正样本可能只有几万条但用户-商品笛卡尔积是千万级。全量负采样不仅内存扛不住还会让正负比失衡到 1:1000 以上模型直接偏向预测负类。我一般用按用户分层采样对每个正样本用户从该用户未交互的商品池里随机抽 3-5 个作为负样本同时保证热门商品被抽到的概率略高模拟真实曝光偏差。import numpy as np def negative_sample(pos_df, all_items, user_item_set, ratio4): neg_rows [] for _, row in pos_df.iterrows(): u row[user_id] # 该用户已交互商品集合 interacted user_item_set.get(u, set()) candidates list(all_items - interacted) if len(candidates) 0: continue # 按热度加权采样热门商品权重高 weights np.array([item_pop[i] for i in candidates]) weights weights / weights.sum() sampled np.random.choice(candidates, sizemin(ratio, len(candidates)), replaceFalse, pweights) for i in sampled: neg_rows.append({user_id: u, item_id: i, label: 0}) neg_df pd.DataFrame(neg_rows) pos_df pos_df.copy() pos_df[label] 1 return pd.concat([pos_df, neg_df], ignore_indexTrue)ratio4是经验值正负比 1:4 在大多数稀疏场景下收敛稳定。item_pop是商品热度字典用训练集统计。注意replaceFalse同一个负样本不要重复抽。如果某个用户候选集为空比如他已经交互过所有商品直接跳过不要硬造负样本。2.3 时间衰减权重怎么加移动场景下用户三天前的点击和昨天的点击意义完全不同。我通常给每个行为加一个指数衰减权重w exp(-λ * Δt)其中 Δt 是行为时间到预测时间的天数λ 取 0.1-0.3。λ 越大越只看近期行为。这个权重不直接进负采样而是用在特征聚合阶段——比如统计用户对某品类的点击次数时用加权和代替简单计数。import math def time_decay_weight(delta_days, lam0.2): return math.exp(-lam * delta_days) # 示例用户对某品类的加权点击次数 user_cate_clicks {} for _, row in train[train[behavior_type] click].iterrows(): delta (predict_date - row[timestamp].date()).days w time_decay_weight(delta, lam0.2) key (row[user_id], row[cate_id]) user_cate_clicks[key] user_cate_clicks.get(key, 0) wλ 的选择要看业务节奏。如果商品更新快、用户兴趣漂移明显λ 调到 0.3如果品类稳定0.1 就够。这个参数没有理论最优只能靠验证集 AUC 来调。3. 特征工程把用户、商品、上下文拆成可计算的向量3.1 用户侧特征从行为频次到兴趣漂移用户特征分三层基础属性年龄、性别如果有、行为统计点击/收藏/加购/购买次数、兴趣演化最近一次行为距今天数、最近 3 天活跃天数。移动端还有一个容易被忽略的维度——地理活跃度。用户经常出现的坐标网格可以编码成类别特征反映其活动范围。def user_features(df, predict_date): feats {} for uid, group in df.groupby(user_id): feats[uid] { click_cnt: (group[behavior_type] click).sum(), buy_cnt: (group[behavior_type] buy).sum(), cart_cnt: (group[behavior_type] cart).sum(), fav_cnt: (group[behavior_type] fav).sum(), last_active_days: (predict_date - group[timestamp].max().date()).days, active_days_3: group[group[timestamp] predict_date - pd.Timedelta(days3)][date].nunique(), geo_grid: group[geo].mode()[0] if not group[geo].mode().empty else unknown } return featslast_active_days和active_days_3是强特征尤其对购买预测。geo_grid用众数填充缺失时给 unknown。注意不要用未来数据算这些统计量所有聚合必须限制在训练时间窗口内。3.2 商品侧与交叉特征热度、转化率、用户-品类偏好商品侧核心是热度和转化率。热度用曝光或点击次数转化率用购买/点击。交叉特征里最有效的是用户对品类的偏好分该用户在某品类的购买次数除以该品类总购买次数再乘以时间衰减。def item_features(df): feats {} for iid, group in df.groupby(item_id): clicks (group[behavior_type] click).sum() buys (group[behavior_type] buy).sum() feats[iid] { item_click: clicks, item_buy: buys, item_cvr: buys / (clicks 1), # 平滑 item_cate: group[cate_id].iloc[0] } return feats def cross_features(user_feats, item_feats, user_cate_buy): cross {} for uid in user_feats: for iid in item_feats: cate item_feats[iid][item_cate] cross[(uid, iid)] { user_cate_buy_ratio: user_cate_buy.get((uid, cate), 0), item_cvr: item_feats[iid][item_cvr], user_buy_cnt: user_feats[uid][buy_cnt] } return crossitem_cvr分母加 1 是拉普拉斯平滑避免除零。user_cate_buy_ratio需要提前统计用户在各品类的购买分布。交叉特征维度会爆炸实际训练时只对候选集正样本负采样计算不要全量笛卡尔积。3.3 特征拼接与归一化把上面三部分拼成一条向量数值特征做 log1p 或 min-max 归一化类别特征做 one-hot 或 embedding。我一般用sklearn的DictVectorizer快速拼或者手写 numpy 数组。注意树模型不需要归一化但线性模型和神经网络必须做。如果后面用 LightGBM数值特征保持原样即可类别特征直接传 category 类型。import lightgbm as lgb feature_cols [click_cnt, buy_cnt, cart_cnt, fav_cnt, last_active_days, active_days_3, item_click, item_buy, item_cvr, user_cate_buy_ratio] X_train train_samples[feature_cols] y_train train_samples[label] X_valid valid_samples[feature_cols] y_valid valid_samples[label] dtrain lgb.Dataset(X_train, y_train, categorical_feature[geo_grid]) dvalid lgb.Dataset(X_valid, y_valid, referencedtrain) params { objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 63, min_data_in_leaf: 50, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 5, verbose: -1 } model lgb.train(params, dtrain, num_boost_round500, valid_sets[dvalid], callbacks[lgb.early_stopping(50)])num_leaves63是起点数据量小就降到 31。min_data_in_leaf50防止过拟合。early_stopping(50)很关键移动推荐数据噪声大不加早停很容易在验证集上掉点。4. 召回与排序的衔接别把候选集当儿戏4.1 召回阶段ItemCF 和热度兜底排序模型再强召回没覆盖到正样本也是白搭。移动推荐场景下我一般用三路召回ItemCF基于物品相似度、用户历史购买品类下的热门商品、全局热度 TopN。ItemCF 的相似度用余弦基于用户-物品交互矩阵。from sklearn.metrics.pairwise import cosine_similarity from scipy.sparse import csr_matrix # 构建用户-物品交互矩阵购买行为 user_item train[train[behavior_type] buy].groupby( [user_id, item_id]).size().unstack(fill_value0) item_sim cosine_similarity(user_item.T) item_sim_df pd.DataFrame(item_sim, indexuser_item.columns, columnsuser_item.columns) def itemcf_recall(uid, user_item_df, item_sim_df, topk50): if uid not in user_item_df.index: return [] interacted user_item_df.loc[uid] interacted interacted[interacted 0].index.tolist() scores {} for iid in interacted: sim_items item_sim_df[iid].sort_values(ascendingFalse)[1:topk] for jid, sim in sim_items.items(): scores[jid] scores.get(jid, 0) sim return sorted(scores, keyscores.get, reverseTrue)[:topk]topk50是单路召回数量三路合并后去重大概 100-150 个候选。热度兜底保证新用户也有结果。注意 ItemCF 的相似度矩阵在物品数超过 10 万时会很占内存可以用稀疏矩阵或只保留 TopK 相似对。4.2 排序阶段LightGBM 二分类就够了候选集有了排序就是一个二分类问题。LightGBM 在这个赛题里几乎是最优解训练快、对稀疏特征友好、不需要太多调参。上面第 3 章的参数可以直接用。唯一要注意的是样本权重购买行为的正样本可以给更高权重比如 2.0让模型更关注转化。train_samples[weight] train_samples[label].apply(lambda x: 2.0 if x 1 else 1.0) dtrain lgb.Dataset(X_train, y_train, weighttrain_samples[weight], categorical_feature[geo_grid])权重不要设太高2.0 是经验值超过 5.0 会导致模型过度拟合正样本AUC 反而下降。4.3 评估指标AUC 之外还要看 TopN 命中率AUC 衡量全局排序能力但线上只看 TopN 推荐位。所以验证时除了 AUC还要算HitRateK对每个用户取排序 TopK看是否命中该用户当天购买的商品。def hit_rate_at_k(model, valid_samples, K10): valid_samples[score] model.predict(valid_samples[feature_cols]) hits 0 total 0 for uid, group in valid_samples.groupby(user_id): topk group.nlargest(K, score)[item_id].tolist() true_items group[group[label] 1][item_id].tolist() if any(i in topk for i in true_items): hits 1 total 1 return hits / total if total 0 else 0这个指标比 AUC 更贴近业务。如果 AUC 高但 HitRate10 低说明模型排序头部不准需要检查特征里是否有强区分度的交叉项。5. 避坑与排查那些让我重跑过三次的细节5.1 时间戳时区不一致导致特征全错现象训练集 AUC 正常验证集 AUC 掉到 0.5 左右。原因日志里的时间戳有的是 UTC有的是本地时间按天切分时边界错位导致验证集混入了训练集未来的行为。解决统一转成同一时区再切分。用pd.to_datetime(..., utcTrue).dt.tz_convert(Asia/Shanghai)然后取.dt.date。5.2 负采样时把验证集用户漏掉了现象验证集 HitRate10 为 0但 AUC 正常。原因负采样只对训练集用户做了验证集用户没有候选商品排序时直接空列表。解决验证集和测试集的候选集要单独生成用同样的召回逻辑不要复用训练集的负样本。5.3 类别特征直接传整数被树模型当数值切现象geo_grid 特征重要性很低但实际地理信息应该有用。原因LightGBM 默认把整数当数值特征切分时按大小排序而地理网格编号没有大小意义。解决在categorical_feature里显式声明或者提前做 one-hot。LightGBM 的 category 处理会自动做梯度统计比 one-hot 更省内存。5.4 早停轮数设太大导致过拟合现象验证集 AUC 在前 100 轮上升之后缓慢下降但 early_stopping 没触发。原因early_stopping(50)意味着 50 轮没提升才停但移动推荐数据噪声大50 轮足够模型记住噪声。解决改成early_stopping(20)同时把learning_rate降到 0.03让收敛更平滑。5.5 特征穿越用未来统计量算历史特征现象离线 AUC 0.9线上效果差。原因统计商品热度时用了全量日志包括验证集和测试集的时间段。解决所有统计量必须按时间窗口滚动计算训练集只能用训练时间之前的数据。写个assert检查每个特征的最大时间戳不超过切分点。6. 进阶技巧用贝叶斯平滑和模型融合再挤一点 AUC6.1 转化率特征的贝叶斯平滑商品转化率buy/click在点击少的时候波动极大一个点击一个购买就是 100% 转化这种特征进模型就是噪声。贝叶斯平滑用全局转化率做先验按点击量加权def bayesian_smooth(buys, clicks, global_cvr, alpha10): return (buys alpha * global_cvr) / (clicks alpha)alpha10表示当点击量达到 10 次时先验权重降到一半。点击越多越相信实际转化率。这个技巧在商品侧特征上通常能带来 0.005-0.01 的 AUC 提升。6.2 多模型融合LightGBM XGBoost 逻辑回归单模型调到头之后融合是性价比最高的提升方式。我一般用三个模型LightGBM 处理数值和类别特征XGBoost 做补充不同随机种子和特征采样逻辑回归对稀疏 one-hot 特征做线性拟合。融合方式用加权平均权重按验证集 AUC 分配。from sklearn.linear_model import LogisticRegression import xgboost as xgb # XGBoost xgb_model xgb.XGBClassifier(n_estimators300, max_depth6, learning_rate0.05, subsample0.8, colsample_bytree0.8, eval_metricauc) xgb_model.fit(X_train, y_train) # 逻辑回归需要 one-hot from sklearn.preprocessing import OneHotEncoder ohe OneHotEncoder(handle_unknownignore) X_train_ohe ohe.fit_transform(X_train) lr_model LogisticRegression(C0.1, max_iter1000) lr_model.fit(X_train_ohe, y_train) # 加权融合 pred_lgb model.predict(X_valid) pred_xgb xgb_model.predict_proba(X_valid)[:, 1] pred_lr lr_model.predict_proba(ohe.transform(X_valid))[:, 1] final_pred 0.5 * pred_lgb 0.3 * pred_xgb 0.2 * pred_lr权重 0.5/0.3/0.2 是经验值可以用验证集 AUC 做网格搜索。注意逻辑回归的C0.1是强正则移动推荐数据稀疏正则太弱会过拟合。6.3 线上验证A/B 测试的最小可行方案离线指标再好也要线上验证。如果没有真实流量可以做一个模拟 A/B把测试集按用户随机分两组一组用旧模型一组用新模型比较 HitRate10 和购买转化率的差异。样本量至少覆盖 1000 个用户否则差异不显著。import random random.seed(42) users valid_samples[user_id].unique().tolist() random.shuffle(users) group_a set(users[:len(users)//2]) group_b set(users[len(users)//2:]) hit_a hit_rate_at_k(model_old, valid_samples[valid_samples[user_id].isin(group_a)]) hit_b hit_rate_at_k(model_new, valid_samples[valid_samples[user_id].isin(group_b)]) print(fGroup A HitRate10: {hit_a:.4f}, Group B: {hit_b:.4f})如果 B 组比 A 组高 2 个百分点以上且样本量够就可以考虑上线。否则继续调特征。6.4 我踩过的最大的坑忽略地理特征的时效性地理网格特征在移动推荐里很有用但用户的坐标是随时间变化的。我早期直接把用户所有历史坐标的众数当特征结果模型学到的是“用户最常待的地方”而不是“用户购买时所在的地方”。后来改成购买行为发生时的坐标网格AUC 直接涨了 0.015。这个细节很少有人提但移动场景下位置和时间是强耦合的。做这个赛题我的习惯是每加一个特征就问自己这个特征在预测时刻真的能拿到吗如果答案有一丝犹豫就砍掉。离线 AUC 是虚的线上能用的特征才是真的。希望帮到你。本文还有配套的精品资源点击获取