移动推荐系统实战:基于Python与XGBoost的F1分数优化指南
2026/9/12 4:48:04 网站建设 项目流程

简介:一份面向阿里天池移动推荐算法竞赛的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", dtype=dtype_map) print(df.shape) print(df.dtypes) print(df.memory_usage(deep=True).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_value=0) user_beh.columns = ["view", "fav", "cart", "buy"] user_beh = user_beh.reset_index() print(user_beh.head())

逻辑说明:groupbyunstack会把四种行为变成四列,缺失值用 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(days=days) sub = source[source["date"] > cutoff] agg = sub.groupby(["user_id", "behavior_type"]).size().unstack(fill_value=0) agg.columns = [f"u_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_value=0) 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, on="user_id", how="left") X = X.merge(feat_d1, on="user_id", how="left") X = X.merge(feat_d3, on="user_id", how="left") X = X.merge(feat_d7, on="user_id", how="left") X = X.merge(item_hot, on="item_id", how="left") X = X.merge(cat_hot, on="item_category", how="left")

逻辑说明:合并时全部用how="left",以用户与商品对为准,防止右表把行数撑爆。缺失值会在模型训练前统一补 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"], how="left", indicator=True) neg = neg[neg["_merge"] == "left_only"].drop(columns="_merge") neg = neg.sample(frac=1.0, random_state=42) neg["label"] = 0 sample = pd.concat([pos, neg.sample(n=len(pos) * 8, random_state=42)], axis=0) 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(C=1.0, solver="liblinear", max_iter=100) lr.fit(X_fill, sample.set_index(["user_id", "item_id"]).loc[X.index]["label"])

逻辑说明:C=1.0是正则强度的倒数,C 越小正则越强。移动推荐特征普遍是计数型,量纲差异很大,逻辑回归对特征尺度敏感,正式跑之前最好再包一层 StandardScaler。基线模型不用追求精确,能看出特征集有没有明显问题就够了。

4.3 XGBoost 训练与早停

竞赛主力模型基本都落在 XGBoost 和 LightGBM 上。XGBoost 的优势在于能自动处理特征间的非线性组合,同时对稀疏计数特征比较友好。把数据和标签转成 DMatrix,用验证集做早停。

import xgboost as xgb dtrain = xgb.DMatrix(X_train[feature_cols], label=y_train) dvalid = xgb.DMatrix(X_valid[feature_cols], label=y_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_round=2000, evals=[(dvalid, "valid")], early_stopping_rounds=50, verbose_eval=50, )
参数常用范围作用
eta0.03 ~ 0.1学习率,越低越稳但越慢
max_depth5 ~ 7树深度,太深容易过拟合稀疏行为数据
subsample0.7 ~ 0.9每次迭代的行采样比例
colsample_bytree0.7 ~ 0.9每次迭代的列采样比例
min_child_weight1 ~ 5叶子节点最小样本权重和

逻辑说明:early_stopping_rounds=50表示验证集 logloss 连续 50 轮不下降就停止,防止 num_boost_round 设太大导致过拟合。eta调小后,通常需要更多轮数才能收敛,所以我把最大轮数放到了 2000,靠早停兜底。比赛里常见的一个动作是把eval_metric从 logloss 换成 auc,但本赛题最终指标是 F1,logloss 与 F1 不完全一致,所以更推荐在验证集上直接搜阈值。

4.4 特征重要性与冗余处理

训练完先看get_score,而不是急着调参。特征重要性会暴露你在特征工程阶段的错误理解,比如时间衰减特征全是 0,或者某个 count 特征反超了所有业务特征。

import operator importance = bst.get_score(importance_type="gain") sorted_imp = sorted(importance.items(), key=operator.itemgetter(1), reverse=True) 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, average="binary" ) 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", index=False)

逻辑说明:提交文件的格式必须和赛题要求的字段顺序完全一致,多一列或少一列都可能被判为格式错误。最后的best_th和融合权重,都只来自验证集,绝不在提交阶段回看测试集去调。保留住每次实验的阈值、权重和验证 F1,多试几组组合,通常能找到比单模型高 2 到 3 个百分点的提交配置。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询