简介:这是一份可直接运行部署的基于XGBoost的O2O优惠券使用预测分析系统毕业设计源码,面向计算机、大数据等相关专业的本科生与研究生,主要解决毕业设计选题难、系统不完整、代码无注释等痛点。项目围绕优惠券核销预测完整场景展开,涵盖数据清洗、特征构建、XGBoost模型训练与评估、可视化展示等环节,功能完善、界面简洁,代码注释详尽,即使基础薄弱的新手也能快速上手并二次改造。资源压缩包为zip格式,共2025个文件,大小约71.63MB。文件类型较丰富:1375个md文件承载项目文档、设计与实现说明;402个js文件与47个java文件支撑前端交互与后端逻辑;51个json与90个xml作为数据配置与接口描述;另有少量py文件用于算法脚本。目录结构清晰,便于按模块检索学习。目前已有152人学习下载。下载后除了完整源码与文档说明,还能获得经过严格调试、确保可运行的整套项目配置,直接用于毕业设计展示或期末答辩。该项目为个人手打高分项目,功能完善且界面美观,对理解XGBoost算法在O2O优惠券预测业务中的真实落地具有很好的参考价值。
1. 基于XGBoost的O2O优惠券使用预测到底在解什么题
一个O2O平台每天发出几十万张优惠券,用户领了不用,平台亏的是真金白银;用户想用的时候没券,流失的又是回头客。基于XGBoost的O2O优惠券使用预测分析,就是用用户历史领券、核销、距离、商户热度这些特征,提前判断一张已经被领取的优惠券会不会被真的用掉。我前后在两个模拟项目里用同一套思路落地:先定义标签,再做特征,最后调XGBoost,整套流程走完后离线AUC能做到0.80左右。这篇笔记适合正在做毕业设计、或者想快速搭一套可演示的优惠券预测系统的人。不需要从零啃论文,按数据到特征、训练到接口的顺序走完,你就有一套能讲清原理、能展示效果、也扛得住答辩追问的完整系统。
2. 数据和标签:先回答“使用”怎么定义,再谈模型
很多人拿到数据就急着训练,结果模型指标很好看,答辩时被问一句“你的正样本是怎么定义的”就卡住。O2O优惠券预测的第一步不是建模,而是把“用户会不会使用优惠券”这件事用数据精确表达出来。
2.1 原始数据集一般长什么样:四类表和一个关键陷阱
常见的O2O优惠券公开数据集或模拟项目里,原始数据通常拆成四张表:用户信息表、商户信息表、优惠券信息表、领券与核销记录表。真实业务里还可能叠加消费流水表,但毕设项目一般到记录表就够用了。
| 表 | 典型字段 | 在预测里的作用 |
|---|---|---|
| 用户信息 | 用户ID、城市、性别、注册日期 | 构造用户活跃度、新老客特征 |
| 商户信息 | 商户ID、商户类别、经纬度或距离 | 构造商户热度、品类偏好 |
| 优惠券信息 | 券ID、面值、满减门槛、有效期起止 | 构造折扣率、满减门槛特征 |
| 领券核销记录 | 用户ID、券ID、商户ID、领券日期、消费日期 | 主表,标签和大部分特征都从这里来 |
关键陷阱是领券记录表里大量消费日期为空。空值代表用户领了券但没在有效期内消费,这是天然的负样本。有些同学会把空值行直接删掉,这一删,正负样本比例就完全失真了,后面所有评估指标都失去意义。
另一个容易忽略的点是距离字段。O2O场景里用户和商户之间的距离是强特征,但很多记录里距离是缺失的。常见做法是把缺失距离单独编码成一个值,而不是填平均值,因为缺失本身可能就代表“距离太远无法计算”。
2.2 标签构造与正负样本切分:三种口径会影响整个评估
标签定义的核心就一句话:用户在优惠券有效期内完成了核销,记为正样本。但展开之后有三个口径必须统一。
第一种口径只看消费日期是否非空,不校验有效期。这种定义简单,但会把“领券后过了有效期才消费”的样本也标成正样本,业务上说不通。第二种口径要求消费日期在领券日期之后、有效期结束之前,这是多数项目和竞赛采用的标准定义。第三种口径更严格,要求消费日期必须在领券当日之后,把“领完立刻用”的情况单独处理,因为这类样本没有预测价值——用户已经完成转化了。
真实数据里还有一个边界情况:同一个人同一张券领了多次。按记录粒度建模的话,重复领取记录应该合并或者去重,否则同一张券重复计数会让验证集和训练集之间出现重叠。我在第一个模拟项目里没处理这个,导致验证集里混入了和训练集完全相同的记录,AUC虚高了大概0.03。
正负样本比例也是一个重要参考。公开的O2O优惠券数据里,负样本数量通常是正样本的5到10倍。如果看到比例接近1比1,先别高兴,要么是数据被采样过,要么是标签定义把太多负样本划成了正样本。
import pandas as pd import numpy as np df = pd.read_csv('coupon_records.csv') df['Date_received'] = pd.to_datetime(df['Date_received']) df['Date'] = pd.to_datetime(df['Date'], errors='coerce') df['Date_end'] = pd.to_datetime(df['Date_end']) # 标准口径:消费日期非空,且在领券日之后、有效期结束之前(含当日) df['label'] = 0 mask = ( df['Date'].notna() & (df['Date'] >= df['Date_received']) & (df['Date'] <= df['Date_end']) ) df.loc[mask, 'label'] = 1 # 看一眼正负样本分布,心里有个底 print(df['label'].value_counts(normalize=True))errors='coerce'会把无法解析的日期转成NaT,避免程序直接崩掉。Date_end如果原始数据里没有,可以用Date_received + 有效天数构造。标签构造之后一定要输出分布比例并留存截图,毕设文档里写数据预处理时这一张图很有说服力。
2.3 预处理与特征矩阵输出:日期解析和内存压缩
标签定好之后,剩下的预处理工作有三个重点:日期解析、缺失值处理、内存压缩。日期解析方面,原始数据里的日期常常是字符串或者时间戳,统一用pd.to_datetime转成datetime类型,后续才能做差值计算。缺失值处理方面,距离缺失填-1,城市缺失填“未知”字符串,数值型字段缺失先填-1再看特征重要性。这里不要填中位数或均值,树模型对具体数值不敏感,-1作为一个独立取值反而能让模型学会“这个样本距离未知”这一信息。
内存压缩是个容易被忽视的工程点。原始记录可能上百万行,如果不做类型压缩,特征工程阶段频繁groupby会让内存占用翻几倍。常见做法是把用户ID、商户ID、券ID这类高基数ID转成category类型,数值字段能降int64就降int32甚至int8。这一步做完,数据框的内存占用通常能降一半以上,训练时的磁盘和内存开销也小很多。
3. 特征工程:XGBoost的效果上限是特征喂出来的
XGBoost经常被称作表格数据上的大杀器,但它不是万能的。同样的模型,特征工程做到位和随便扔几个原始字段进去,AUC能差0.1以上。在O2O优惠券预测里,特征要分三层做:用户侧、商户与券侧、交叉统计侧。
3.1 用户侧特征:历史核销率和时间衰减
用户侧的核心逻辑是:历史爱用券的人,未来更可能用券。所以第一批特征就是用户维度的历史统计量:领券总次数、核销总次数、历史核销率、平均核销间隔、最近一次领券距离预测日期的天数。
这里有一个关键的时间衰减问题。很多同学的统计口径是“全量历史”,也就是用整个数据集去算用户的核销率。这在离线训练里会引入数据穿越:训练样本的用户特征里包含了这个用户在未来才发生的行为。正确做法是,每个样本只使用该样本领券日期之前的历史窗口做统计。我一般是按30天、60天、90天三个窗口分别聚合,窗口越短,特征越贴近用户当前状态,但数据也越稀疏。
3.2 商户侧与券特征:折扣率、距离、满减门槛
商户侧特征里,最基本的是商户历史被核销的优惠券数量、商户核销率、商户平均客单价。这些特征刻画的是“这家店的活动到底受不受欢迎”。实操中发现,商户核销率比商户销量更重要——销量高的店可能是自然流量,核销率高的店才是真正的券驱动型商户。
券本身的特征也有讲究。面值、满减门槛、折扣率这三个要分开构造。折扣率建议统一算成用户实际支付比例,比如满100减30,用户实付70,折扣率就是0.7。这个数字比面值更能刻画优惠力度,因为同样减30,对满50的订单和满200的订单驱动效果完全不同。
距离字段在O2O场景里是一个强特征,而且是非线性关系:用户愿意为优惠券跑的路程是有限的,超过某段距离后核销率断崖式下降。直接用原始距离数值喂给模型,树模型也能学出这个阈值,但更稳妥的做法是额外构造一个“距离是否超过3公里”的0/1特征,相当于帮模型把先验知识提前写好。
3.3 交叉特征:用户×商户、用户×券的统计量
单维特征做完,模型的AUC大概能到0.72左右,撑起高分的是交叉特征。最常用的三个交叉维度是用户×商户、用户×券类别、商户×券类别。逻辑很直白:一个用户对某家店的历史偏好、对某类优惠券的接受度,往往比全局统计更个性化。
比如“某个用户在某个商户领过几次券、核销过几次”是强特征,但这个组合基数很高,如果用户只有一次记录,统计值就是1和0,没有区分度。所以交叉特征通常会配合一个“组合出现次数”特征一起用,次数低于阈值的样本,交叉统计量要当成缺失处理,否则高基数组合的稀疏性会给模型带来噪声。
交叉特征的构造思路和用户侧类似,但要注意计算量。用户×商户的全量组合可能几百万行,groupby本身不慢,慢的是merge回主表。一般做法是先按主表的user_id和merchant_id聚合,再把结果merge回去,不要先笛卡尔积再统计。
3.4 特征工程代码落地:一个groupby模板吃遍多数统计特征
# 用户侧统计特征模板 user_feat = df.groupby('user_id').agg( user_coupon_cnt=('label', 'count'), user_use_cnt=('label', 'sum'), user_use_rate=('label', 'mean'), user_avg_distance=('distance', 'mean') ).reset_index() # 注意:上面的统计用了全量数据,仅作为快速验证 # 正式训练时务必加时间过滤,只统计样本领券日之前的行为 # 交叉特征:用户 × 商户 user_merchant_feat = df.groupby(['user_id', 'merchant_id']).agg( um_cnt=('label', 'count'), um_use_cnt=('label', 'sum') ).reset_index() um_use_rate = um_use_cnt / um_cnt # 合并回主表 train = df.merge(user_feat, on='user_id', how='left') train = train.merge(user_merchant_feat, on=['user_id', 'merchant_id'], how='left')agg里的label列是第2章构造的标签,count统计领券次数,sum统计核销次数,mean就是核销率。交叉特征的um_cnt一定要保留,后面筛特征时用得上。这段代码最大的坑是不加时间过滤,如果直接跑,你的用户特征里混入了未来信息,后续所有实验结论都要打折扣。
特征落地的顺序建议是:先跑一个只有用户侧和商户侧特征的baseline,记录AUC;再加上交叉特征跑第二版,看AUC增量。如果交叉特征带来的提升不超过0.02,优先怀疑高基数稀疏性,而不是急着加更多组合特征。
4. XGBoost建模与系统实现:从训练脚本到预测接口
特征工程做完,进入模型环节。这一章会讲清楚为什么选XGBoost、怎么切分数据、参数怎么设,以及系统最后的接口长什么样。这也是毕设文档里最占篇幅、最能体现工程能力的一部分。
4.1 为什么选XGBoost而不是逻辑回归或深度学习
逻辑回归在O2O优惠券预测里也能用,上限在0.70到0.75左右,但要做到这个水平,特征工程的工作量要大得多,而且还得做特征标准化、非线性特征手工构造。深度学习模型在这类表格数据上并不天然占优,需要海量数据训练Embedding,用一个几十万行的数据集做深度模型,很容易过拟合,答辩时解释训练过程也更吃力。
XGBoost之所以是首选,有三个实际理由。第一,它对特征尺度不敏感,不必做标准化,数值型和类别型可以混着喂。第二,它原生处理缺失值,-1这类填充值也能被树分裂自动利用。第三,它能输出特征重要性,毕设文档里放一张特征重要性柱状图,比放十张训练曲线更有说服力。
在落地层面,XGBoost的训练和推理速度也够快,单机跑完百万行数据加几百个特征完全没压力。如果你要做的系统还要支持在线预测,XGBoost的模型文件就几个MB,加载到内存里做实时打分没有任何性能问题。
4.2 时间切分与early stopping:先防过拟合再谈调参
模型训练的第一个决策不是调参,而是数据怎么划分。随机划分在这里是禁忌,因为用户的领券行为有强烈的时间相关性,随机划分会用未来数据训练、用过去数据验证,得到的是一个虚高的AUC。正确做法是按领券日期排序,前80%的时间作为训练集,后20%作为验证集。
切分之后,先跑一个XGBoost默认参数的baseline,配合eval_metric='auc'和early_stopping_rounds确定合适迭代轮数。early stopping的原理是:每训练一轮,模型都计算验证集AUC,如果连续多轮没有提升,就停止训练并回滚到最优轮数的模型。这个机制比手工固定num_boost_round可靠得多,因为最优轮数受特征工程质量影响很大,不同版本的特征可能差出一倍。
4.3 核心参数速查与推荐区间
XGBoost参数不少,但真正需要手调的其实就六个。下面这张表是多次实验下来的推荐区间,毕设答辩时把这些参数为什么这样设讲清楚,比念一遍官方文档分数高得多。
| 参数 | 作用 | 推荐区间 | 备注 |
|---|---|---|---|
| eta | 学习率,每棵树贡献的收缩步长 | 0.01 - 0.1 | 越小越稳但需要更多轮 |
| max_depth | 单棵树最大深度 | 3 - 6 | 超过6基本就是过拟合 |
| min_child_weight | 叶子节点最小样本权重 | 1 - 10 | 调大能明显防过拟合 |
| subsample | 每棵树随机采样的行比例 | 0.7 - 0.9 | 防止单棵树过度依赖局部样本 |
| colsample_bytree | 每棵树随机采样的列比例 | 0.7 - 0.9 | 增加特征多样性 |
| scale_pos_weight | 正负样本权重调节 | 1 到 负样本数/正样本数 | 不平衡场景下可以尝试 |
scale_pos_weight是O2O场景特别值得试的参数。当负样本是正样本5倍以上时,把scale_pos_weight设成负样本除以正样本的比例,会让模型更关注正样本。但这不一定是好事,我习惯把AUC和实际业务指标放一起看,如果调完正样本召回率上去了但整体AUC掉了,还是要以业务目标为准。
4.4 训练脚本、网格搜索与特征重要性输出
import xgboost as xgb from sklearn.model_selection import GridSearchCV dtrain = xgb.DMatrix(X_train, label=y_train) dvalid = xgb.DMatrix(X_valid, label=y_valid) params = { 'objective': 'binary:logistic', 'eval_metric': 'auc', 'eta': 0.05, 'max_depth': 5, 'min_child_weight': 5, 'subsample': 0.8, 'colsample_bytree': 0.8, 'scale_pos_weight': 1.0, 'seed': 42 } bst = xgb.train( params, dtrain, num_boost_round=2000, evals=[(dvalid, 'valid')], early_stopping_rounds=100, verbose_eval=50 ) # 输出特征重要性,后面做筛选和文档配图都用它 importance = bst.get_score(importance_type='gain')binary:logistic输出的是预测概率,而不是0/1类别,这样后续可以自己选阈值,也方便做分档分析。verbose_eval=50表示每50轮打印一次验证集AUC,训练过程一目了然。get_score(importance_type='gain')里的gain是特征被用于分裂时带来的平均增益,比默认的weight更有业务解释性。
网格搜索这里不推荐全套参数一起搜,维度爆炸而且慢。我的习惯是分开两轮:第一轮固定eta=0.1,搜索max_depth和min_child_weight;第二轮固定这两个值,搜索subsample和colsample_bytree。每轮用GridSearchCV跑5折交叉验证,记录最优参数,再带着最优参数跑一次完整训练。
4.5 系统模块划分与预测接口实现
毕设里的系统设计不必追求微服务架构,模块划分清楚就好。我一般把系统拆成三块:特征工程模块负责从原始数据生成特征矩阵;训练模块负责模型训练、参数搜索、模型保存;预测模块负责接收前端请求,加载模型并返回预测结果。
import joblib import xgboost as xgb from flask import Flask, request, jsonify app = Flask(__name__) bst = xgb.Booster() bst.load_model('coupon_model.model') @app.route('/predict', methods=['POST']) def predict(): data = request.get_json() feature_row = data['features'] # 特征顺序要和训练时一致 drow = xgb.DMatrix([feature_row]) prob = bst.predict(drow)[0] return jsonify({'use_probability': float(prob)})这里最容易翻车的点是特征顺序。训练时特征矩阵的列顺序和预测时必须完全一致,否则模型输出的概率就是错的。我踩过一次这个坑,排查了很久才发现是训练前多了一列ID特征没去掉,预测时却传了。为了避免这类问题,训练结束保存模型的同时,把特征列名列表一起用joblib保存下来,预测接口里先校验列名一致再构建DMatrix。
5. 避坑与常见问题:高AUC模型一样会在真实场景翻车
这一章全是血泪经验。我在第一版系统里踩过好几个坑,每一个都让指标虚高或者模型不可用。写在这里,希望你不用再走一遍。
5.1 现象:离线AUC 0.85,线上效果纹丝不动
第一版系统用随机划分训练,AUC高达0.85,当时觉得稳了。上线测试时发现,预测结果和随机猜没什么区别。原因定位很快:随机划分导致同一用户的不同记录同时出现在训练集和验证集里,模型学会了“记住用户”,而不是“理解用户行为”。解决方法是改成时间切分,以领券日期为界划分训练和验证集合。从那以后,我所有时序预测项目默认都用时间切分,不再用随机划分。
5.2 现象:正样本特征越加越准,模型却越来越虚
做特征时顺手加了一个“用户领券后7天内是否活跃”的特征,AUC从0.78涨到0.83。但仔细一想就发现问题:这个特征用到了领券之后的行为数据,而预测目标本身就是判断用户领券后会不会核销,这相当于偷看了答案。这个特征在训练集里有效,在真实预测场景里根本不存在。解决方法是给所有特征打上时间标签,凡是特征窗口晚于样本领券日期的,一律剔除。做特征工程之前先列一张“特征时间口径表”,能把大部分数据泄漏问题挡在门外。
5.3 现象:特征重要性第一名业务上说不通
有一版模型的特征重要性第一名是“用户ID”,gain值远超其他特征。用户ID是典型的高基数类别特征,模型可以直接用它把样本拆到一个个独立叶子节点上,训练集拟合得很好,但泛化能力为零。解决方法是把高基数ID类特征要么拆成哈希特征,要么干脆不进模型,只保留它们的统计聚合结果。判断标准很简单:如果这个特征的值在现实中每来一个新用户都会变,它就不适合直接作为模型输入。
5.4 现象:预测分数整体偏高,阈值怎么调都不对
训练时用scale_pos_weight=5.0把正样本权重调高,发现预测概率整体往0.8以上飘,不管阈值设多少,要么误报太多要么漏报太多。原因是scale_pos_weight改变了模型输出的概率分布,模型输出的分数不再接近真实核销率。解决方法是把scale_pos_weight设回1.0,用subsample和min_child_weight处理不平衡,或者保留这个参数但意识到分数是排序分,不是概率,后续要做概率校准再用。
5.5 现象:多次训练结果不一致,答辩被追问
同一份数据,同一个参数,两次训练出的AUC差了0.01,特征重要性排名也在变化。原因是树模型训练里有随机采样,不固定随机种子,结果必然有波动。解决方法是训练参数里固定seed,同时在代码里设置全局随机种子。如果发现固定种子后仍有波动,检查是否有并发训练环境或者数据读取顺序不稳定。这一条在毕设文档的实验复现章节里尤其重要,复现不了结果的实验在答辩时很吃亏。
6. 效果验证与进阶:不只看AUC,用分档表和概率校准让模型可解释
模型训练完不是终点。真正让这套系统从“能跑”变成“能讲清楚”的,是最后一公里:怎么验证、怎么解释、怎么把模型输出变得可用。
6.1 特征重要性的三种读法:weight、gain与cover
weight统计的是特征被用于分裂的次数,容易偏向高基数特征,只能看个大概;gain统计的是特征分裂带来的平均增益,解释性最好,通常用它做特征筛选和文档配图;cover统计的是特征分裂覆盖的样本比例,适合判断特征的真实覆盖范围。我在项目里习惯把三列重要性并排列成一张表,再标注“筛选后保留”还是“剔除”,这个表放在毕设文档的实验分析里非常加分。
6.2 概率校准:xgb预测分数不等于真实核销率
XGBoost输出的分数在0到1之间,但它不是严格意义上的概率。同一个0.7,可能对应真实核销率0.6,也可能对应0.8。如果系统只做排序,比如选出分数最高的前20%用户发更大面额的券,那不需要校准。但如果要预估ROI,比如告诉运营这笔补贴预计能带来多少核销,就必须做概率校准。常见做法是用sklearn.isotonic做等渗回归校准,校准之后再验证各分段的平均预测值和真实核销率是否接近。
6.3 一个很实用的验证技巧:按预测分位分档看单调性
AUC只能说明排序能力,不能说明预测值在不同区间是否均匀。更直观的验证方式是把验证集按预测分数排序,均分为10档,统计每一档的真实核销率,然后看这个序列是否从左到右单调递增且跨度明显。如果后三档的真实核销率比前七档高出一大截,说明模型把高概率人群和低概率人群分得很开,这个模型是有业务价值的;如果中间出现倒挂,说明某些区间的预测分数失真,需要进一步排查。
我现在每次跑完模型都先输出这张分档表,再决定要不要继续调参,而不是只盯AUC数字。这套系统的完整流程跑下来,训练、评估、解释都有了,毕设文档的三章核心内容也就齐了。希望你少踩我踩过的那些坑,希望帮到你。
本文还有配套的精品资源,点击获取