基于XGBoost的O2O优惠券使用预测系统设计与实现
2026/9/14 14:25:29 网站建设 项目流程

简介:面向计算机相关专业学生的一套基于XGBoost的O2O优惠券使用预测分析系统设计与实现源码及文档说明,可作为毕业设计、期末大作业或课程设计的高分参考。系统围绕优惠券核销预测场景,覆盖数据预处理、特征工程、模型训练与结果分析等环节,并配套清晰的设计文档,便于理解项目链路并完成本地部署。压缩包共2025个文件,大小71.63MB,其中Markdown文档超过1300份,适合查看需求说明、接口设计与实验记录;JavaScript、Java、XML/JSON配置及少量Python脚本则涵盖前端页面、系统配置与模型相关模块,整体文件结构较为清晰,可按目录检索学习。当前已有152人浏览学习。项目代码经过调试并附有注释,部署简便,能直观呈现从数据到预测结果的完整流程,是一份兼具工程实现与文档支撑的实用参考。

1. O2O优惠券使用预测:先想清楚要预测什么

O2O优惠券使用预测要回答的问题很具体:用户领了一张券,在过期前会不会用。结果决定运营给谁发券、发多少、有效期设几天。真实数据里正样本(领券后核销)通常只有20%左右,无脑预测“不核销”就拿到八成准确率,所以准确率在这里没有意义。技术上它是二分类问题,XGBoost最擅长吃的就是领券记录、满减规则、商户信息这类表格数据。标题里的“系统设计”是把训练脚本扩展成完整链路:特征工程、离线训练、在线预测、结果展示。下面按特征怎么造、模型怎么调、系统怎么拆、坑怎么排的顺序展开,答辩高频考点数据泄漏和样本不平衡放在对应章节重点处理。

2. 特征工程是主战场:领券记录怎么变成XGBoost的输入

表格型数据的预测里,模型能挖的上限由特征决定。XGBoost能自动处理缺失值、做非线性分裂,但没法替你想出“用户历史核销率”这种有业务含义的字段。这一章按特征来源分组讲,每组给可执行代码,换字段名就能跑。

2.1 原始数据长什么样:领券、商户与核销

先看数据形态。这类预测任务通常涉及四类信息:领券动作本身、券的规则、商户位置、用户历史。字段设计没有标准答案,但以下结构覆盖了绝大多数公开数据集和业务表的公共部分。

数据表关键字段说明
领券记录user_id, coupon_id, date_received, date_consumeddate_consumed为空表示未核销
优惠券信息coupon_id, discount_rule(如“满20减5”), expire_days有效期通常为固定天数
商户信息merchant_id, latitude, longitude经纬度可能是加密坐标
用户行为user_id, 历史消费次数/金额可选的用户侧补充特征

读取和标签构造是第一步,也是后面所有统计特征的地基:

import pandas as pd coupon = pd.read_csv('data/coupon.csv', parse_dates=['date_received', 'date_consumed']) coupon = coupon[(coupon['date_received'] < coupon['date_consumed']) | coupon['date_consumed'].isna()] coupon['label'] = coupon['date_consumed'].notna().astype(int)

逻辑说明:标签不是简单看 date_consumed 是否为空,还要排除消费时间早于领券时间的脏数据。这类脏记录在真实业务里不少见,比如用户核销了但上报时间错乱,不删掉会污染标签。label 的语义定义为“领券后在有效期内核销”,这个定义后面所有评估都要保持一致。

2.2 距离特征:经纬度换成球面距离

O2O场景里,用户和商户的经纬度是加密过的坐标,但加密只影响绝对位置,不影响相对距离计算,所以直接用 Haversine 公式算球面距离:

import numpy as np def haversine(lat1, lon1, lat2, lon2): # 输入经纬度,输出单位:米 R = 6371000 p1, p2 = np.radians(lat1), np.radians(lat2) dp = np.radians(lat2 - lat1) dl = np.radians(lon2 - lon1) a = np.sin(dp / 2) ** 2 + np.cos(p1) * np.cos(p2) * np.sin(dl / 2) ** 2 return 2 * R * np.arcsin(np.sqrt(a)) coupon['distance'] = haversine( coupon['merchant_lat'], coupon['merchant_lon'], coupon['user_lat'], coupon['user_lon'] )

为什么不用欧氏距离:经纬度是球面坐标,直接用平面公式算出的数值没有物理含义。距离特征不要只给原始数值,我一般再分箱一次:0到0.5公里、0.5到1公里、1到3公里、3公里以上。对核销行为来说,3公里和5公里的区别远小于“在楼下”和“要坐地铁”的区别,分箱让模型更容易学到这个拐点。另外,距离缺失是很正常的,别用 0 填充,缺失就让它缺失,XGBoost 会自己学一个方向。

2.3 满减规则:从字符串里把折扣率抠出来

“满20减5”这种字符串直接喂给模型没有意义。需要拆成三个字段:满的金额、减的金额、折扣率。其中折扣率比绝对金额更有业务解释力,满100减20和满20减5都是打八折,但后者带来的进店动力完全不一样。

import re def parse_discount(rule): # 从 "满20减5" 中提取满额和减额 nums = re.findall(r'(\d+\.?\d*)', rule) if len(nums) < 2: return np.nan, np.nan full, minus = float(nums[0]), float(nums[1]) return full, round(1 - minus / full, 4) coupon['full_amount'], coupon['discount_rate'] = zip( *coupon['discount_rule'].map(parse_discount) )

参数说明:full_amount 表示要凑到的最低消费,discount_rate 表示实际折扣比例,数值越小优惠力度越大。有些券是“折扣券”而不是“满减券”,比如“8.5折”,这时正则解析出来的只有一个数字,需要单独走一个分支。一个容易被忽略的点是“满”和“减”对模型的作用方向不同:满额高会提高门槛降低核销率,减额高会增加诱惑提升核销率,所以不要把这两个数做差合并成一个特征。

2.4 用户历史行为特征:先排序再统计,不然就泄漏

用户历史核销率通常是最有区分度的特征。构造它最大的坑是数据泄漏:如果用全量数据算用户核销率,相当于把未来信息带进了当前样本,训练时 AUC 虚高,上线后立刻崩。正确做法是排序后做累计统计,并且把当前样本自己排除掉:

coupon = coupon.sort_values(['user_id', 'date_received']) coupon['user_cnt_cum'] = coupon.groupby('user_id').cumcount() coupon['_tmp'] = coupon.groupby('user_id')['label'].cumsum() coupon['user_consume_cum'] = coupon.groupby('user_id')['_tmp'].shift(1) coupon['user_consume_rate'] = coupon['user_consume_cum'] / coupon['user_cnt_cum'] coupon = coupon.drop(columns=['_tmp'])

逻辑说明:cumcount 是组内计数,从 0 开始,天然表示“这个用户之前领过几张券”;cumsum 是累计核销数,shift(1) 把当前样本的核销结果从累计值里剔除。这样算出来的 user_consume_rate 只包含该用户过去的行为,没有泄漏。第一条记录的 rate 是 NaN,不需要填充,XGBoost 原生支持缺失值。商户侧和券类型侧的历史核销率用同样方法构造,只需要把 groupby 的 key 换掉。

2.5 时间特征与缺失值处理

领券时间是强信号。周末领的券因为消费场景多,核销率整体偏高;领券日到过期日的剩余天数越少,用户越急着用掉。这两个特征成本低、收益明显:

coupon['receive_weekday'] = coupon['date_received'].dt.weekday coupon['expire_days'] = (coupon['date_expire'] - coupon['date_received']).dt.days

注意:缺失值和 0 是两种完全不同的语义。用户没到店(distance 缺失)和用户就在店门口(distance 为 0)在模型里必须被区分开,不要用 fillna(0) 糊弄。XGBoost 对缺失值的处理是自动学习“缺失时往左走还是往右走”,这个能力在用户行为数据上非常有用,因为很多用户只有一两次领券记录,历史特征天然缺失。

3. XGBoost二分类建模:参数、训练与特征重要性

特征做完,进入建模。这一章的重点不是“跑通”,而是让模型在验证集上可复现、可解释、可调优。

3.1 为什么选 XGBoost而不是线性模型或LightGBM

选型要能说出理由,这部分也是论文文献综述的素材。Logistic Regression 胜在可解释,但需要手动做特征交叉,面对缺失值还要写一堆填充策略;LightGBM 训练更快、内存占用更低,但在样本量几万到几十万、特征几十个的这个量级上优势不明显,而且小样本下更容易过拟合。XGBoost 的增益计算更保守,正则项更强,对弱特征更鲁棒,和毕设场景的样本量匹配度最好。

模型优势在这个场景的短板
Logistic Regression可解释、训练快需要手动做特征交叉,缺失值要专门处理
XGBoost自带缺失值处理、正则化、可并行参数空间大,特征很多时内存开销高
LightGBM训练快、内存占用低小样本上更容易过拟合,直方图分箱损失精度

树模型天然做非线性切分,不需要像线性模型那样手工构造交叉特征,这就是 XGBoost 在这个任务里最大的省心之处。

3.2 核心参数表与调参顺序

XGBoost 参数多,但真正影响这个场景的只有下面几个。固定其他参数,逐个调,比一次性 GridSearch 全部参数快得多,也稳得多。

参数建议范围作用与调参逻辑
max_depth4 到 8特征数一般不超过 50,深度 6 够用,太深会过拟合
learning_rate0.02 到 0.1学习率越低,需要越多轮数;先 0.1 起步,最后再降
subsample0.7 到 0.9每棵树的行采样比例,控制过拟合
colsample_bytree0.7 到 0.9每棵树的特征采样比例,增加多样性
min_child_weight3 到 7叶子节点最小样本权重,调大让树更保守
scale_pos_weight负样本数 / 正样本数处理正负样本不平衡,约 3 到 5
eval_metricauc不平衡场景下用准确率没有意义

调参顺序固定成一套流程:先固定 learning_rate=0.1,粗调 max_depth 和 min_child_weight,再调两个 sampling 参数,最后把 learning_rate 降到 0.02 并增大轮数用早停收尾。不要在第一步就做全参数随机搜索,搜索空间太大,而且很容易在验证集上过拟合。

3.3 训练代码与早停策略

import xgboost as xgb from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val = train_test_split( features, labels, test_size=0.2, shuffle=False ) dtrain = xgb.DMatrix(X_train, label=y_train) dval = xgb.DMatrix(X_val, label=y_val) params = { 'objective': 'binary:logistic', 'max_depth': 6, 'learning_rate': 0.05, 'subsample': 0.8, 'colsample_bytree': 0.8, 'min_child_weight': 5, 'scale_pos_weight': int((len(labels) - labels.sum()) / labels.sum()), 'eval_metric': 'auc', 'nthread': 8, 'seed': 42 } bst = xgb.train( params, dtrain, num_boost_round=2000, evals=[(dtrain, 'train'), (dval, 'val')], early_stopping_rounds=50, verbose_eval=50 )

几个容易被忽略的点:train_test_split 一定要关掉 shuffle,这些数据有强时间相关性,随机打乱等于把未来信息混进训练集,验证集 AUC 会虚高。scale_pos_weight 按正负样本比例直接算,不用手动试,它等价于给少数类的损失函数加权。early_stopping_rounds 监听 val_auc,连续 50 轮不涨就停,所以 num_boost_round 设 2000 不会白跑。如果训练集和验证集 AUC 差距越拉越大,首先回来调 max_depth 和 subsample,不要靠堆数据解决。

3.4 用特征重要性复核特征工程

训练完先看特征重要性,用 gain 而不是默认的 weight:

importance = bst.get_score(importance_type='gain') for feat, gain in sorted(importance.items(), key=lambda x: x[1], reverse=True)[:10]: print(feat, round(gain, 2))

gain 表示该特征在所有分裂中带来的平均增益,能反映特征对预测的实际贡献。我一般拿这个列表反查特征工程:如果距离特征没进前 10,先怀疑经纬度字段是不是对错了;如果折扣率比满额和减额都低,回去看解析逻辑有没有把规则串了。特征重要性不是“特征有没有用”的最终结论,但它是排查工程错误的最快路径。

4. 预测系统设计与实现:训练脚本如何变成可用服务

标题里的“系统设计与实现”,落到源码层面就是把这个 Notebook 里的流程拆成模块,让训练、预测、展示各司其职。毕设源码的价值主要体现在这一层的结构上。

4.1 系统模块划分与数据流

工程上我习惯拆四个模块,依赖关系单向,谁都不许反向引用:

模块职责关键产物
data_loader读取原始数据、清洗、构造标签干净的 DataFrame
features所有特征构造函数特征矩阵
trainer模型训练、交叉验证、保存model.json + 特征列清单
api在线预测、批量预测REST 接口 / 批量脚本

数据流是:用户领券动作写入领券表,api 模块收到预测请求,features 模块实时构造特征,Booster 加载模型文件输出核销概率,运营按概率决定是否发券、发多大面额。这里的关键约束是 api 只能依赖 features 和 trainer 产出的模型文件,不能自己重新实现一份特征逻辑。

4.2 训练与推理共用的特征模块

最常见的问题:训练时特征代码写在 Notebook 里,线上预测时重新写一份,两边特征对不齐。比如训练时用全量用户核销率,线上只能用历史核销率,模型表现直接崩掉。解决办法是强制把特征工程收敛成一个模块:

# features/build_features.py FEATURE_COLUMNS = ['distance', 'discount_rate', 'user_consume_rate', ...] def build_features(df): df = add_distance_feature(df) df = parse_discount_rule(df) df = build_user_history(df) df = add_time_features(df) return df[FEATURE_COLUMNS]

训练脚本和预测服务都 import 这个函数,FEATURE_COLUMNS 在训练时导出成 features.json 存到模型文件旁边。预测请求进来时先按这个清单约束列顺序,再做一次缺失值检查,确保 DMatrix 的输入和训练时完全一致。

4.3 在线预测API与批量预测

预测服务提供两个通路:在线 API 处理单条请求,批量脚本处理离线人群。在线 API 用 Flask 写最小实现:

from flask import Flask, request, jsonify import xgboost as xgb import pandas as pd from features.build_features import build_features app = Flask(__name__) bst = xgb.Booster() bst.load_model('models/coupon_model.json') @app.route('/api/predict', methods=['POST']) def predict(): payload = request.get_json() df = pd.DataFrame([payload]) feat = build_features(df) d = xgb.DMatrix(feat) prob = float(bst.predict(d)[0]) return jsonify({'consume_prob': prob})

为什么用 Booster.load_model 而不是重新训练:模型文件是训练产物,服务启动时加载一次就行,和训练脚本解耦。为什么返回概率而不是 0/1:运营要根据概率分档决策,概率大于 0.7 发大额券、0.4 到 0.7 发小额券、低于 0.4 不发,二值化会把排序能力丢掉。批量预测脚本结构类似,只是读整张表、写回带概率列的结果表,用于每晚圈选第二天的推送名单。

4.4 结果落库与文档说明

预测结果至少需要三张表:predict_result 存 user_id、coupon_id、predict_prob、predict_date;send_log 存发券记录;consume_result 后续回填核销结果。把预测概率和实际核销关联,才能持续算准确率和覆盖率,这也是毕设论文里“系统运行效果”的数据来源。模型文件用 json 格式保存而不是 pkl,跨环境部署更省事。文档部分除了环境依赖,把特征定义和每张表的字段说明单独写一份,答辩时老师拿到就能跑通,比堆一堆截图有用得多。

5. 验证指标与排错:别让AUC骗了你

5.1 三个必查的验证点

模型上线前,我习惯在 AUC 之外再做三个检查。第一,排序单调性:把预测概率按十分位切成 10 组,每组的实际核销率应该大致单调递增。如果第 8 组核销率比第 6 组还低,说明某些样本吃到了未来的特征,先回去查特征统计的窗口。第二,时间外验证:用前 30 天训练、后 7 天验证,不要随机切分,随机切分的 AUC 通常比时间外验证高 0.03 到 0.05,这个差异就是数据泄漏的量级。第三,概率校准:预测概率的均值应该和实际正样本率接近,如果模型输出均值 0.5 而实际只有 0.2,检查 scale_pos_weight 是不是设得过大。

检查项做法异常信号
排序单调性按预测概率十分位分组高概率组核销率回落
时间外验证按日期切分训练/验证集时间外 AUC 明显低于随机切分
概率校准对比预测均值与实际正样本率预测均值远大于实际核销率

5.2 线上落地的一个技巧:按期望价值排序

模型输出概率本身不能直接用来发券决策。一张满 100 减 20 的券核销概率 0.5,一张满 20 减 5 的券核销概率 0.8,前者期望收益是 0.5×20=10 元,后者是 0.8×5=4 元,应该优先发前者。实际做法是给每张券算一个期望核销价值:核销概率 × 券面金额,运营按这个值排序决定发放顺序。这一步把预测系统从“能预测”推向“能决策”,也是论文里应用价值部分最好写的落点。

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

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

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

立即咨询