移动推荐系统实战:基于Python与XGBoost的F1分数优化指南 简介一份面向阿里天池移动推荐算法竞赛的Python实现与源码解析资源定位在帮助有机器学习基础的初学者快速上手推荐算法实战也可作为计算机、人工智能专业毕业课题的参考项目。内容覆盖数据加载与预处理、数据集划分、特征构造、逻辑回归/GBDT/XGBoost等模型训练以及基于OnSpark的分布式特征生成与预测流程能够清晰呈现从原始数据清洗到模型训练评估的完整链路。资源共31个文件以19个Python脚本为主体另含Markdown说明文档、txt特征列表及若干备份文件压缩包仅154KB轻量便于对照阅读。通过源码注释和清晰的目录结构可快速定位数据预处理、特征工程、模型训练与预测等关键模块节省自行摸索时间。目前已有65人学习适合需要参考赛题代码结构、复用特征工程思路或快速搭建同类竞赛Baseline的读者。1. 移动推荐竞赛在预测什么源码该从哪里下手移动推荐竞赛一般指天池上 IJCAI-15 这场经典赛拿来用户近一个月在移动端的浏览、收藏、加购、购买四类行为预测用户在 12 月 18 日会不会购买指定商品。赛题最值得玩味的地方不是模型而是评估口径。比赛不用准确率用 F1这意味着模型输出的概率没有一个天然的 0.5 门限必须回到验证集上做阈值搜索才能把精确率和召回率同时拉起来。整套 Python 源码的骨架从最朴素的逻辑回归到后面普遍采用的 XGBoost主线都逃不开四个动作构造正负样本、抽取行为特征、训练得分、搜索阈值。我建议拿到源码先不急着看模型把文件按数据层、特征层、模型层和提交层拆开后面的代码也都按这个分层来组织。这样无论是复现成绩还是迁移到自己的推荐场景都只改局部不用动全局。2. 天池移动推荐数据的 Python 加载与时间窗口切分2.1 原始字段与 Pandas 读取时的内存优化天池移动推荐竞赛的数据格式在不同衍生版本里略有差别最常见的是 CSV 文件里给六个字段用户、商品、类目、行为类型、品类、时间。单看字段数量会觉得简单但真实训练集往往有几千万行不做类型优化直接pd.read_csv()读进来的内存占用会非常难看。第一步先把每一列的 dtype 钉死尤其是行为类型这种只有几个取值的列int8就够了。字段建议 dtype说明user_idint32用户唯一标识item_idint32商品唯一标识cat_idint32商品所属类目behavior_typeint81 浏览 / 2 收藏 / 3 加购 / 4 购买item_categoryint32商品品类timeint6410 位时间戳如 2014121721 表示 2014-12-17 21:00读取代码如下import pandas as pd import numpy as np dtype_map { user_id: int32, item_id: int32, cat_id: int32, behavior_type: int8, item_category: int32, time: int64, } df pd.read_csv(data/user.csv, dtypedtype_map) print(df.shape) print(df.dtypes) print(df.memory_usage(deepTrue).sum() / 1024 / 1024, MB)逻辑说明Pandas 默认会把数值列推断成 int64四类行为列本身只有 1 到 4 四个值int64 纯属浪费。把 user_id、item_id 这类标识列压到 int32把 behavior_type 压到 int8几千万行数据能省下一半以上的内存。处理这类竞赛数据时内存占用不只是一个数字它直接决定你能不能在同一台机器上同时保留原始表、聚合表和训练特征矩阵。先压缩再做后续合并能省掉很多“内存爆炸后重启”的时间。2.2 时间解析与验证集切分time 字段如果是 10 位数字前 8 位是日期后 2 位是小时。拆分之后要立刻把“预测日”这个概念固定下来任务预测的是 12 月 18 日购买我们在本地验证时要把 12 月 17 日当作虚拟预测日特征只允许使用 17 日之前的行为训练目标则取 17 日的购买记录。from datetime import date df[dt] pd.to_datetime(df[time], format%Y%m%d%H) df[date] df[dt].dt.date df[hour] df[dt].dt.hour valid_date date(2014, 12, 17) train df[df[date] valid_date].copy() valid df[df[date] valid_date].copy()逻辑说明这行切分是整个源码里最容易踩坑的地方。如果把 17 日的行为也并进特征聚合那么训练时模型已经看到了“验证日当天的浏览、加购”验证分数会虚高等真正提交到 18 日预测时立刻现原形。我一般会再补一句保护性检查确认valid里的每条买卖行为都没有参与特征统计。训练窗口拉到 12 月 1 日还是 12 月 15 日可以后面用验证集去试但切分边界要固定。2.3 多行为类型的宽表化四类行为不能只当成一个整体去看因为浏览、收藏、加购、购买在移动端漏斗里的位置完全不同。一个常见做法是按用户聚合出行为宽表作为最基础的基线特征。user_beh df.groupby([user_id, behavior_type]).size().unstack(fill_value0) user_beh.columns [view, fav, cart, buy] user_beh user_beh.reset_index() print(user_beh.head())逻辑说明groupby后unstack会把四种行为变成四列缺失值用 0 填平。这里的 buy 列是过去整段训练期的累计购买次数它不是目标标签而是“这个用户历史上有多爱买”的画像特征。真正的标签要在构建训练样本时单独定义不能直接把这一列当 y 用。很多入门代码把这两者混在一起F1 分数会失真得离谱。3. 移动推荐特征工程用户行为聚合、时间衰减与物品热度3.1 用户维度行为聚合过去 1/3/7 天窗口用户行为特征的核心是窗口。窗口太短数据稀疏窗口太长噪声进来反而稀释了近期意图。我会同时保留 1 天、3 天、7 天三个尺度让模型自己去决定该信哪一层。def user_window_feature(source, days): cutoff source[date].max() - pd.Timedelta(daysdays) sub source[source[date] cutoff] agg sub.groupby([user_id, behavior_type]).size().unstack(fill_value0) agg.columns [fu_b{c}_d{days} for c in agg.columns] return agg.reset_index() feat_d1 user_window_feature(train, 1) feat_d3 user_window_feature(train, 3) feat_d7 user_window_feature(train, 7)逻辑说明这段函数把行为在用户维度上按天数窗口切开列名如u_b4_d3表示“该用户最近 3 天的购买次数”u_b1_d7表示“该用户最近 7 天的浏览次数”。注意我传的是train不是全量df这样就保证了验证集数据不会泄漏进特征。不同窗口之间的差异本身也是信号如果 d7 的加购次数很高但 d1 很低说明用户近期活跃度在下降。3.2 时间衰减加权特征固定窗口的问题是第 6 天前的行为和昨天发生的行为权重相同这不符合移动用户的兴趣变化节奏。常见做法是引入指数衰减权重离预测日越近的记录权重越高。衰减系数 lambda 需要反复试我一般从 0.35 起步往 0.2 和 0.5 两个方向各探一轮。train train.copy() age_days (train[date].max() - train[date]).dt.days train[decay_w] np.exp(-0.35 * age_days.astype(int)) decay_feat train.groupby([user_id, behavior_type]).apply( lambda x: (x[decay_w]).sum() ).unstack(fill_value0) decay_feat.columns [u_decay_view, u_decay_fav, u_decay_cart, u_decay_buy] decay_feat decay_feat.reset_index()逻辑说明age_days是每条行为距特征截止日的天数越早的记录天数越大指数函数算出来的权重越小。这样“昨天加购”对模型的作用远大于“十天前加购”符合移动端冲动消费的直觉。lambda 的取值直接用验证集 F1 来挑选不要凭感觉定。3.3 物品与品类维度的热度特征只看用户侧特征模型分不清一个商品是爆款还是长尾。需要把商品维度的热度、品类维度的热度也灌进去否则热门商品天然占劣势。item_hot train.groupby(item_id)[behavior_type].agg([sum, count]) item_hot.columns [item_bhv_sum, item_bhv_cnt] cat_hot train.groupby(item_category)[behavior_type].agg([sum, count]) cat_hot.columns [cat_bhv_sum, cat_bhv_cnt]逻辑说明item_bhv_sum是所有行为类型的分值加总买、加购、收藏、浏览都累计进来item_bhv_cnt则是行为数量。两个字段配合能反映商品的平均行为深度比如 sum 高但 cnt 低说明这个商品每次行为都离购买更近。实际竞赛里通常还会把buy / view这种转化率特征加进来效果比单纯计数值更稳。3.4 把所有特征合并成训练矩阵特征工程最后一步是把多个来源的特征拼到一张主体表上。主体表由(user_id, item_id)构成后续模型才能同时读入用户侧和商品侧信息。X valid[[user_id, item_id]].drop_duplicates() X X.merge(user_beh, onuser_id, howleft) X X.merge(feat_d1, onuser_id, howleft) X X.merge(feat_d3, onuser_id, howleft) X X.merge(feat_d7, onuser_id, howleft) X X.merge(item_hot, onitem_id, howleft) X X.merge(cat_hot, onitem_category, howleft)逻辑说明合并时全部用howleft以用户与商品对为准防止右表把行数撑爆。缺失值会在模型训练前统一补 0因为merge后左表查不到右表特征时Pandas 默认填 NaN。跑模型前检查一下X.isnull().sum()如果某列缺失比例超过一半要回到上一步看是不是窗口开太短导致稀疏。4. 源码主流程负采样、逻辑回归基线与 XGBoost 训练4.1 训练样本的负采样策略竞赛数据里用户对商品“购买”的比例极低直接拿全量数据训练二分类正负比可能到 1:200。这时候模型学到的全是“不买”召回率几乎为零。竞赛里普遍的做法是把负样本采样到正样本的 5 到 10 倍保留相对合理的分布。pos valid[valid[behavior_type] 4][[user_id, item_id]].drop_duplicates() pos[label] 1 cand valid[[user_id, item_id]].drop_duplicates() neg cand.merge(pos, on[user_id, item_id], howleft, indicatorTrue) neg neg[neg[_merge] left_only].drop(columns_merge) neg neg.sample(frac1.0, random_state42) neg[label] 0 sample pd.concat([pos, neg.sample(nlen(pos) * 8, random_state42)], axis0) print(sample[label].value_counts())逻辑说明正样本是 17 日真实发生购买的(user, item)对负样本从 17 日出现但没有购买的对里抽。抽样比设为 8 倍而不是 1:200 的原始比例。这里的random_state必须固定否则每次跑的负样本不同模型分数会跟着波动。负采样比例可以先试 5、8、10看验证 F1 的峰值落在哪。4.2 逻辑回归基线逻辑回归在这个赛题里并不是拿来冲榜的它的价值是提供一个稳定的分数底座跑得快、可解释、能验证特征是否有效。特征必须先填 NaN 再喂进模型。feature_cols [c for c in X.columns if c not in (user_id, item_id, label)] X_fill X[feature_cols].fillna(0) from sklearn.linear_model import LogisticRegression lr LogisticRegression(C1.0, solverliblinear, max_iter100) lr.fit(X_fill, sample.set_index([user_id, item_id]).loc[X.index][label])逻辑说明C1.0是正则强度的倒数C 越小正则越强。移动推荐特征普遍是计数型量纲差异很大逻辑回归对特征尺度敏感正式跑之前最好再包一层 StandardScaler。基线模型不用追求精确能看出特征集有没有明显问题就够了。4.3 XGBoost 训练与早停竞赛主力模型基本都落在 XGBoost 和 LightGBM 上。XGBoost 的优势在于能自动处理特征间的非线性组合同时对稀疏计数特征比较友好。把数据和标签转成 DMatrix用验证集做早停。import xgboost as xgb dtrain xgb.DMatrix(X_train[feature_cols], labely_train) dvalid xgb.DMatrix(X_valid[feature_cols], labely_valid) params { objective: binary:logistic, eval_metric: logloss, eta: 0.05, max_depth: 6, subsample: 0.8, colsample_bytree: 0.8, min_child_weight: 1, } bst xgb.train( params, dtrain, num_boost_round2000, evals[(dvalid, valid)], early_stopping_rounds50, verbose_eval50, )参数常用范围作用eta0.03 ~ 0.1学习率越低越稳但越慢max_depth5 ~ 7树深度太深容易过拟合稀疏行为数据subsample0.7 ~ 0.9每次迭代的行采样比例colsample_bytree0.7 ~ 0.9每次迭代的列采样比例min_child_weight1 ~ 5叶子节点最小样本权重和逻辑说明early_stopping_rounds50表示验证集 logloss 连续 50 轮不下降就停止防止 num_boost_round 设太大导致过拟合。eta调小后通常需要更多轮数才能收敛所以我把最大轮数放到了 2000靠早停兜底。比赛里常见的一个动作是把eval_metric从 logloss 换成 auc但本赛题最终指标是 F1logloss 与 F1 不完全一致所以更推荐在验证集上直接搜阈值。4.4 特征重要性与冗余处理训练完先看get_score而不是急着调参。特征重要性会暴露你在特征工程阶段的错误理解比如时间衰减特征全是 0或者某个 count 特征反超了所有业务特征。import operator importance bst.get_score(importance_typegain) sorted_imp sorted(importance.items(), keyoperator.itemgetter(1), reverseTrue) for k, v in sorted_imp[:15]: print(k, v)逻辑说明gain类型的重要性反映的是特征在每棵树上带来平均收益比默认的 weight 更有参考价值。如果发现用户侧特征包揽前五说明商品和品类侧特征做得太弱如果某几个窗口特征重要性接近零就可以考虑从特征表里删掉降低训练噪音。5. 拉高 F1 的实测技巧阈值扫描与双模型融合5.1 在验证集上做全量阈值搜索模型输出的是购买概率要变成最终的 0/1 标签需要找到一个阈值。阈值不是拍脑袋定的要在验证集上把 0.05 到 0.95 这一段全部扫一遍取 F1 最高的点。from sklearn.metrics import precision_recall_fscore_support val_prob bst.predict(xgb.DMatrix(X_valid[feature_cols])) best_th, best_f1 0, 0 for thr in np.arange(0.05, 0.95, 0.01): pred (val_prob thr).astype(int) p, r, f1, _ precision_recall_fscore_support( y_valid, pred, averagebinary ) if f1 best_f1: best_f1, best_th f1, thr print(best_f1:, best_f1, best_th:, best_th)逻辑说明F1 是精确率和召回率的调和平均阈值调高精确率上升但召回率下降阈值调低则相反。你可以在输出里同时打印精确率和召回率看阈值偏移的方向。竞赛中常见的误差是把训练集上的最优阈值直接用在测试集上正确做法是坚持用验证集选阈值。5.2 逻辑回归与 XGBoost 的概率融合单一模型在稀疏行为数据上容易抖把逻辑回归和 XGBoost 的预测概率按比例融合是竞赛源码里最常见的提分动作。两者误差模式不同融合后 F1 往往比单独跑 XGBoost 高出一截。lr_prob lr.predict_proba(X_valid[feature_cols])[:, 1] xgb_prob bst.predict(xgb.DMatrix(X_valid[feature_cols])) final_prob 0.4 * lr_prob 0.6 * xgb_prob逻辑说明融合权重 4:6 只是一个常见起点实际操作时扫 0.3 到 0.7 之间的步长。每换一档权重都要重新做一次阈值扫描因为它们本质上是一个联动参数。如果两个模型分数差距过大融合前先做 min-max 归一化避免量纲把权重压废。5.3 提交阶段的最终输出格式线上提交一般只需要输出每个预测对是否购买的标签把预测流程完整串起来test build_candidate_pairs() test build_features(test) test_prob bst.predict(xgb.DMatrix(test[feature_cols])) test_label (test_prob best_th).astype(int) submission test[[user_id, item_id]].copy() submission[predict_label] test_label submission.to_csv(submission.csv, indexFalse)逻辑说明提交文件的格式必须和赛题要求的字段顺序完全一致多一列或少一列都可能被判为格式错误。最后的best_th和融合权重都只来自验证集绝不在提交阶段回看测试集去调。保留住每次实验的阈值、权重和验证 F1多试几组组合通常能找到比单模型高 2 到 3 个百分点的提交配置。本文还有配套的精品资源点击获取