简介这套项目面向高校毕业设计、课程设计与期末大作业场景是一个基于XGBoost的O2O优惠券使用预测分析系统。项目围绕优惠券领取、核销与用户消费行为数据完成特征工程、模型训练与结果分析并将算法能力封装为可操作的完整界面读者可由此掌握数据分析到系统交付的全链路。压缩包共2025个文件、约71.63MBMarkdown文档1375个对应项目说明与部署指引402个JavaScript、47个Java及4个Python文件组成前后端与算法模块XML、JSON配置文件和HTML页面进一步完善系统结构。代码注释详细、运行路径清晰简单部署即可使用且经过严格调试界面完整、功能齐备适合需要快速完成高分毕设或系统练手的Python方向学习者。目前已有152人学习浏览。1. 基于XGBoost的O2O优惠券预测这道题不是评分题是样本切分题O2O优惠券使用预测这几年在数据类毕业设计里出现频率很高核心任务很直白给一批用户领到的优惠券预测用户会不会在有效期内去门店核销。很多人拿到数据集第一反应是直接训练一个XGBoost二分类AUC跑出0.85以上就觉得自己做完了但答辩时被问“你的测试集是怎么切出来的”“有没有用到未来信息”往往会卡壳。这恰恰说明这个项目真正的难点不在模型而在样本构建和评估口径上——优惠券核销是一个典型的随时间衰减、行为依赖强的场景随机切分训练集会让离线指标看起来漂亮却经不起业务推敲。这篇文章会从数据切分、标签定义、特征工程、模型调参到上线前的坑完整拆一遍这条落地路径适合正在做相关毕设、或者想在企业营销系统里复现这套流程的人直接参考。2. 先拆数据再谈模型优惠券预测的样本窗口、标签定义与评估口径2.1 为什么随机切分会让你的离线AUC变成黑匣子刚上手的人最容易犯的错误是把所有用户领券记录混在一起用sklearn的train_test_split按比例随机切。表面看训练集和验证集分布很一致逻辑上却漏掉了两个关键前提第一用户在一个时间段内的行为会互相影响同一个用户在训练集里出现过他在验证集里出现的记录已经不再是“未知样本”模型相当于见过这个人第二优惠券活动往往有强时效性某个大促期间的核销率远高于日常随机切分等于把大促样本均匀撒进了训练集和验证集模型学到的其实是“这段时间大家都在疯狂用券”而不是“什么样的用户会被什么样的券打动”。我做这类项目时的习惯是严格按时间切把用户领券时间戳排序前70%的时间段做训练中间15%做验证最后15%做测试。这样训练集里永远不会出现验证集时间之后的特征信息。这里有一个容易忽略的点按时间切分依然要保证同一个用户不会同时落在训练集和验证集否则模型还是会“记住”这个人。实操上可以用“用户维度分桶”再加“时间截断”双重限制比如先给每个用户按照他第一次领券时间排个序号再在全局时间线上切一刀两边都用——但最简单可靠的做法是直接用领券记录的发生时间排序然后按比例截断并额外检查两个集合的用户ID是否有重叠有重叠就往前调整切割点直到不重叠为止。import pandas as pd from datetime import datetime df pd.read_csv(coupon_data.csv, parse_dates[领券时间]) # 按领券时间全局排序再按7:1.5:1.5切分 df df.sort_values(领券时间).reset_index(dropTrue) train_size int(len(df) * 0.70) valid_size int(len(df) * 0.15) train df.iloc[:train_size] valid df.iloc[train_size:train_size valid_size] test df.iloc[train_size valid_size:] # 检查泄漏用户ID是否交叉 overlap set(train[user_id]) set(valid[user_id]) set(test[user_id]) print(三段用户重叠数:, len(overlap))这段代码的关键不是切片本身而是最后的那个重叠检查。如果发现重叠用户数很大说明同一用户的多张券被分到了不同集合模型在验证集上等于开卷考试AUC会虚高。真实的业务做法是要么按用户维度分层抽样保证每个用户只出现在一个集合要么对时间窗口重叠的部分直接丢弃。对毕设来说按用户去重之后再按时间切是最稳妥的方案代码改动不大但答辩时你能说清楚为什么这么切。2.2 标签定义用“核销”做正样本还是用“在规定时间内核销”O2O数据集里每条样本通常是一次领券行为字段包括用户ID、商户ID、优惠券满减条件、优惠券类型、距离、领券时间等。标签的常规定义是该券最终有没有被核销。但这里有两个容易踩的深坑。第一个是核销时间问题——用户领了券可能在第3天才用如果数据集的统计截止时间离领券时间不足一个核销周期标注为“未核销”的样本里其实混入了“我还没用但迟早会用”的样本。所以严格做法是给每个样本设置一个观察窗口比如领券后15天内发生核销记为正样本超过15天记负样本且这个窗口必须小于数据集的尾部留白时间。第二个是“领取后立即使用”和“快过期才用”是两种完全不同的决策行为如果业务上需要区分可以把正样本再细分出一个“及时核销”标签但作为单手标项目我建议保持二分类把时间敏感特征放在特征工程里解决不要一开始就把目标复杂化。标签定义直接影响模型上限。一个常见的反面案例是某同学把“领券后核销”定义为正样本但忽略了数据集里有一部分用户领券之后马上又被商户退款回收这种“假核销”会让模型学到很多噪声。如果你手里的数据有核销金额、核销时间、或者核销后是否有后续消费记录应该先做一轮清洗核销金额为0的删掉核销时间早于领券时间的删掉同一张券ID出现多条核销记录的保留第一条。清洗规则要写清楚放到论文的数据预处理部分这是加分项。2.3 离线评估指标为什么这里只看AUC不够优惠券预测的正负样本比例通常很悬殊一般在5%到20%之间取决于活动力度和商户品类。这种情况下准确率没有意义——全部预测为负样本准确率也能到85%。常见的选择是AUC它不依赖阈值能衡量模型把正样本排到负样本前面的能力。但AUC也有盲区它看的是整体排序质量不关心预测值本身离真实概率有多远。业务上真正关心的是“我从预测概率最高的那一批用户里圈出1万人做推送这一万人的核销率是多少”所以除了AUC我还建议再加两个指标PrecisionTopK和实际核销率提升倍数。评估时有一个容易翻车的点验证集里不能包含那些“领券后当天就用掉”的极短周期样本因为它们特征太强模型只要学到“当天领当天用的人大概率还用”TopK就会永远命中这些人覆盖不到沉默用户。我在实践里的做法是评估时把负样本窗口内的行为特征尽量藏起来只保留领券时刻之前的信息这样TopK才有业务意义。下表是一个受用的评估口径参考。指标计算方式用途AUC所有样本概率排名的ROC面积看整体排序能力不依赖阈值PrecisionTopKTopK预测中真实核销的比例看圈人能力业务直接可用核销率提升倍数模型TopK核销率 / 全量用户平均核销率判断营销投放收益这三个指标放在论文的实验部分可以让评审一眼看出你不是只堆了一个模型而是真的在解决“给谁发券”的问题。3. O2O优惠券特征工程把满减门槛和到店距离翻译成模型听得懂的话3.1 特征体系整体结构用户侧、商户侧、券侧、场景侧四类XGBoost对特征工程的要求不像深度学习那样需要做复杂表示学习但它非常吃“业务推导能力”。一套完整的优惠券预测特征体系我通常会拆成四块用户侧、商户侧、券本身、用户与商户的交叉关系。用户侧包括用户历史领券次数、核销次数、核销率、最近一次核销距今的天数、用户在多少类商户下过单等。商户侧包括商户平均客单价、商户被领券总量、商户券核销率、商户品类餐饮、娱乐、超市等。券本身包括满减门槛满50减10里的50、优惠金额、折扣率、券类型折扣券、代金券、兑换券。交叉关系是这套体系里最值钱的部分——用户离商户多远、用户历史上在该商户的核销记录、该满减门槛是否超出用户历史客单价等。为什么交叉关系值钱因为优惠券的本质是“用确定性折扣换不确定性消费”用户决策的核心是“我愿不愿意为了省这点钱跑到这家店”。一个常去该商户、距离又近的用户核销概率天然高一个从没去过、距离5公里外、满减门槛又高的冷启动用户就算券面很诱人也不一定行动。所以特征不是越多越好而是越能还原用户决策过程越好。3.2 三个必做的交叉特征距离、满减门槛与用户历史客单价比值第一个必做特征是和距离直接相关的用户到商户的距离在数据里通常直接给了一个数值字段但裸距离不够要做成相对特征。比如用户历史去过的商户平均距离是多少当前这个距离是高于还是低于用户习惯。第二个必做特征是“满减门槛 / 用户历史平均客单价”乘出来的比值能表达“这张券的满减条件对这个人来说是否容易达到”。如果用户平时客单价是40元券是满120减50他大概率不会为了用券硬凑120如果用户历史客单价就是150元这张券几乎是白送的。第三个必做特征是“折扣力度”也就是优惠金额占满减门槛的比例满50减20的折扣比是0.4满100减20的折扣比是0.2前者更能刺激冲动消费。import numpy as np # 用户历史客单价预计算结果按user_id分组 user_avg_price df.groupby(user_id)[订单金额].mean().rename(user_avg_price) # 满减门槛拆出满多少、减多少 df[门槛金额] df[满减规则].str.split(减).str[0].str.replace(满, ).astype(float) df[优惠金额] df[满减规则].str.split(减).str[-1].astype(float) df[折扣率] df[优惠金额] / df[门槛金额] # 与用户历史客单价交叉 df df.merge(user_avg_price, onuser_id, howleft) df[门槛与客单价比值] df[门槛金额] / df[user_avg_price] # 距离相对化距离 / 用户历史平均到店距离 user_avg_distance df.groupby(user_id)[距离].mean().rename(user_avg_distance) df df.merge(user_avg_distance, onuser_id, howleft) df[距离与习惯比值] df[距离] / (df[user_avg_distance] 1e-6)一定要注意的是任何“用用户整个历史算出来的均值”都会引入未来信息。严格的做法是只在训练集和验证集内部计算用户均值测试集不能参与计算。所以上面的代码你可以在训练集跑完、把统计数据存下来再对验证集和测试集做transform而不是直接用全量df直接算。一个简单可行的替代方案用训练集算好用户均值映射到验证集和测试集映射不到的用全局均值兜底。这样虽然有一点点偏差但至少测试集是干净的。3.3 时间窗口聚合特征领券之前的行为才有意义模型要学的不是“这个用户过去半年很活跃”而是“这个用户最近一周是不是对优惠券敏感”。所以时间窗口聚合特征是XGBoost版图里绝对不能省的部分。我常用的窗口是过去3天、7天、14天、30天分别统计领券次数、核销次数、未核销次数、核销率四项。窗口太长特征会被长期行为平均掉窗口太短稀疏度太高很多用户是0。14天是一个比较折中的窗口适合大部分O2O场景。构造这个特征时最容易犯的错是使用全局数据统计导致某一行的特征包含了“用户领这张券之后的行为”这就是典型的标签泄露。正确姿势是对每一行样本往前找该用户在这个时间点之前的领券记录做聚合。实现上如果数据量不大可以用双重循环配合布尔切片但效率太低更工程化的做法是按用户分组后对时间做滑动窗口。def create_window_features(df, user_coluser_id, time_col领券时间, label是否核销): df df.sort_values([user_col, time_col]).reset_index(dropTrue) result [] for _, grp in df.groupby(user_col): grp grp.sort_values(time_col) # 只使用当前行之前的样本做窗口统计 windows {3d: 3D, 7d: 7D, 14d: 14D} for win_name, delta in windows.items(): stats [] for idx in range(len(grp)): cur_time grp.iloc[idx][time_col] prev grp.loc[grp[time_col] cur_time] if len(prev) 0: prev prev[prev[time_col] cur_time - pd.Timedelta(delta)] count len(prev) rate prev[label].mean() if count 0 else 0 stats.append((count, rate)) grp[f{win_name}_领券数] [s[0] for s in stats] grp[f{win_name}_核销率] [s[1] for s in stats] result.append(grp) return pd.concat(result, ignore_indexTrue)这个脚本的优点是好理解缺点是速度慢几百万行数据会跑很久。实际项目里我会先用pandas的groupby加shift构造截至当前行为止的累计次数再通过差分得到窗口计数速度会快几个数量级。但对毕设演示来说可读性更重要上面的代码能帮你检查逻辑跑通了再优化。窗口特征的核心检验标准是同一用户在验证集里的窗口统计不能有任何一笔来自训练集时间之后的记录。4. XGBoost训练与调参从基线模型到能拿得出手的三组参数组合4.1 先把基线跑通默认参数下的效果与问题定位拿到特征集后第一步不是调参而是用XGBoost的默认参数跑出一个baseline然后看三个东西AUC、日志损失、特征重要性排序的前10个。默认参数的learning_rate是0.3max_depth是6n_estimators默认100棵。直接跑通常会有一个可接受的AUC但会伴随两个问题一是训练集AUC接近0.99验证集AUC正常说明模型过拟合因为O2O数据里用户稀疏特征太多树深度学过头了二是特征重要性排序里距离和门槛比值类特征应该排前面如果排第一的是用户ID说明特征工程阶段没有处理好高基数特征。我先说一下常见做法用户ID这类高基数标识特征直接喂给XGBoost是坏消息树会按用户ID疯狂分裂得到一个对训练集记忆完美、对验证集毫无泛化能力的模型。处理方式是不直接使用用户ID字段而是使用用户ID统计出来的行为特征领券次数、核销率等。如果确实要保留个体差异可以用类别编码后放进模型但要把max_depth调小、再加早停才能压住过拟合。baseline阶段不要追求高AUC追求的是快速确认特征没有漏、标签没有错、切分没有泄漏。如果baseline的AUC低于0.6先回头查特征而不是调参。4.2 不均衡样本的应对scale_pos_weight与采样策略怎么选正负样本比例悬殊时XGBoost默认会倾向于把所有样本预测为负样本因为这样能让logloss最小。三种常见处理手段是调scale_pos_weight、负样本欠采样、正样本过采样或SMOTE。我的建议顺序是先调scale_pos_weight它是XGBoost原生支持的不均衡处理参数公式是负样本数/正样本数。如果数据集负样本是8万、正样本是2万比值就是4直接设scale_pos_weight4一次性到位。然后看验证集AUC有没有变化再看预测概率的分布有没有偏移。欠采样是另一条路把负样本砍到和正样本接近的数量可以显著加快训练速度但会丢失大量用户行为分布信息尤其会伤害那些“领了很多券但从不核销”的用户特征。SMOTE在这类表结构数据上的效果不稳定因为优惠券特征的分布是高度稀疏且有业务边界的插值生成的样本会出现“满减门槛和用户客单价不匹配”的不合理组合。真实场景里我更常用的是scale_pos_weight early stopping的组合训练快也不引入伪造样本。如果你的数据量很大且时间充裕可以做一个消融实验只调权重 vs 只做欠采样 vs 两者结合把三个AUC对比放进论文里这是很好的实验章节素材。import xgboost as xgb from sklearn.model_selection import StratifiedKFold # 假设train和valid已经按时间切好 X_train train_feature.drop(columns[label]) y_train train_feature[label] X_valid valid_feature.drop(columns[label]) y_valid valid_feature[label] scale_pos_weight (y_train 0).sum() / (y_train 1).sum() print(scale_pos_weight:, scale_pos_weight) params { objective: binary:logistic, eval_metric: auc, eta: 0.1, max_depth: 5, min_child_weight: 3, subsample: 0.8, colsample_bytree: 0.8, scale_pos_weight: scale_pos_weight, seed: 42 } dtrain xgb.DMatrix(X_train, labely_train) dvalid xgb.DMatrix(X_valid, labely_valid) bst xgb.train( params, dtrain, num_boost_round2000, evals[(dvalid, valid)], early_stopping_rounds100, verbose_eval50 )你看这个配置里有几个关键点。eta设置成0.1是为了配合early_stopping让最优轮次出现在400到800之间太大会在100轮内收敛然后过拟合。min_child_weight设成3是为了压制那些只在训练集里出现、覆盖极少的叶子节点。subsample和colsample_bytree都是0.8属于稳妥选择既能轻微缓解过拟合又不会因为采样太狠导致每棵树学不到足够信息。scale_pos_weight是这里唯一的“业务参数”必须按数据实际比例来不能固定在某个值。4.3 调参顺序与三组可复用参数组合XGBoost参数不算少但真正需要动的就那几个。我习惯的调参顺序是固定eta为0.1先用默认的max_depth6跑一遍记录最优迭代轮数然后调max_depth从3到7遍历配合min_child_weight从1到5观察AUC变化之后调subsample和colsample_bytree在0.6到0.9之间网格搜索最后回到eta尝试降到0.05看最优轮数是否变多、AUC是否微升。这一步看起来机械实际上是在帮你理解“模型复杂度来自哪里”。树深了模型能捕捉更细的交互但O2O特征里很多交互是噪声比如某商户某个时段某个距离的组合根本没有几条数据树深了就会在这些细分组合上过拟合。min_child_weight的作用就是告诉树别太细样本量不够的叶子直接不要。真实经验是max_depth4到6之间往往是甜区超过7基本都会在验证集上衰减min_child_weight设3以上对稀疏特征有明显的防过拟合作用。参数保守组合均衡组合激进组合eta0.050.10.2max_depth456min_child_weight531subsample0.70.80.9colsample_bytree0.70.80.9early_stopping_rounds10010050激进组合适合特征质量很高、噪声很少的数据收敛快但风险大保守组合适合特征稀疏、正样本很少的数据稳定但可能欠拟合。我一般从均衡组合出发根据baseline的过拟合程度向两端调。调参的终点不是验证集AUC最大化而是训练集和验证集之间的差距控制在可接受范围。如果你的训练AUC是0.95、验证AUC是0.72说明严重过拟合如果训练AUC是0.74、验证AUC是0.73这个模型才是真正学到了泛化的规律。调参还有一个容易被忽略的点对验证集的预测概率分布要可视化看是不是过度集中在0.1到0.3区间。这往往意味着模型被scale_pos_weight压得太狠正样本的预测概率整体降低后续做TopK圈人时会出现阈值难以选择的问题。这种情况下可以把scale_pos_weight往下降一些接受AUC的轻微回退换来更合理的概率尺度。概率尺度对真实业务很重要因为投放系统要的是一个可以解释的分数不是黑匣子里的排序。5. 优惠券预测系统落地的避坑记录从离线AUC到真实效果的几个翻车点5.1 离线AUC很高上线后核销率不见涨大概率是特征泄露现象验证集AUC达到0.90以上团队兴冲冲压测结果投放一轮下来核销率和随机选择用户没什么区别。排查思路不是先看模型而是先怀疑特征里混进了“未来信息”。常见泄露源有三个一是构造时间窗口特征时用了全量数据导致某条样本的窗口统计里包含了自己核销后的行为二是特征工程里直接加入了“券核销日期”或“距过期天数”这两个字段只有在核销发生后才知道三是把样本里的商户ID直接做LabelEncoder后喂给模型高基数特征加上树模型会强行记住商户维度的核销规律而这些规律在冷启动商户上完全不适用。解决方式是做一次特征回溯验证把明显带未来信息的特征删掉再训练一次如果AUC从0.90降到0.75恭喜说明之前确实是在作弊。下降之后才是模型真实能力再做后续优化才有意义。答辩时主动讲这个剪枝过程比藏着掖着要加分得多。5.2 正负样本比例调了scale_pos_weight预测概率却整体漂移现象scale_pos_weight从1调到5之后验证集AUC确实提高了两个点但预测概率整体变低正样本均值只有0.2左右。阈值无论设在0.3还是0.5圈出来的人要么太少要么精确率崩盘。原因是scale_pos_weight本质上是在训练时加大正样本的梯度权重让模型更关注正样本但它并不会校准预测概率——模型输出的概率不再是真实概率的估计而是被权重扭曲过的分值。解决方法是要么放弃概率解释只用排序位置做投放阈值按TopK比例切要么在训练完之后做一次概率校准。XGBoost自带的base_score调整能部分解决但更靠谱的是用Isotonic Regression或Platt Scaling对验证集的预测分数做一次映射让输出恢复到可解释的概率尺度。实操里我用的是sklearn的CalibratedClassifierCV包一层XGBoost但注意做校准的样本一定要是模型未见过的验证集否则校准过程本身也在过拟合。5.3 模型在测试集上表现稳定但部署后遇到节假日效果崩了现象三月份训练的模型到了五一假期前预测效果明显变差TopK核销率下降节日专项券的预测尤为离谱。原因是O2O场景的时效性极强节假日期间用户领券意愿、到店距离容忍度、满减门槛敏感度都会发生整体平移。三月份训练的数据里周末效应和假期效应被当作噪声压掉了模型没有学到“节日里用户更愿意走远一点去核销”的规律。这属于典型的分布漂移。解决方式是第一特征里必须加入节假日标记至少区分周末、普通工作日、法定节假日、大促预热期第二训练时可以适当对节假日样本加权因为节假日本身是营销的核心场景样本量少但业务价值高第三部署之后要建立周级的AUC监控发现连续一周验证集AUC低于离线AUC基线5个点以上就要触发重新训练。对毕设来说把这个问题在论文的讨论章节里讲清楚再给出一个“时间衰减重训策略”会比只展示一个静态模型更有深度。5.4 不均衡处理里做欠采样结果训练集AUC高、验证集AUC正常但业务不可用现象负样本欠采样后训练集和验证集AUC都很漂亮但投放时发现圈定的TopK用户里大面积是不活跃的老客新客覆盖率为零。原因是欠采样把不活跃用户的行为特征稀释了模型学到的偏好规律偏向活跃用户而活跃用户恰恰是营销活动里最容易自然核销的一批人模型并没有带来增量。解决方式是欠采样时不要均匀随机砍负样本而是按用户活跃度分层采样每层保留一定比例让模型依然能见到不同活跃度的用户群体。但更推荐的做法还是用scale_pos_weight因为它的信息损失最小。这一条是我做这类项目时踩得最深的一个坑。当时的解决方案是改回scale_pos_weight然后额外构造了一个“用户活跃度分桶”特征一共分5桶把活跃用户和不活跃用户分开建模。效果立竿见影不活跃用户的核销率提升比之前高了近一倍。这个经验说明不均衡处理的本质不是让模型多学正样本而是让模型在少数学到正样本的同时不丢掉多数类里的结构化信息。6. 把模型输出变成可用的投放策略阈值之外还要看成本与频次控制模型训练完成不等于系统落地完成。最后一个环节是把XGBoost预测的连续概率转化为实际投放动作这里有两个关键决策要做对。第一个是阈值选择。不要机械地取0.5因为概率分布本来就是偏的。正确做法是画出不同阈值下的Precision和Recall曲线结合营销成本定阈值——核销带来的毛利是50元一条推送的成本是0.1元那就让Precision稍微低一点换取更大覆盖率反过来如果推送渠道成本高比如短信渠道每条0.05元且有频次限制就要提高阈值压低圈人量。我用过一个简单省事的策略不对全量用户定统一阈值而是按TopK固定比例圈人比如只投放预测概率最高的前5%用户。这种方式天然避开了概率尺度问题运营也容易理解。第二个是频次控制。同一个用户在30天内被圈进投放太多次会明显产生券疲劳核销率递减。特征工程里可以加一个“近30天被投放次数”的时间窗口特征但投放系统侧更应该加一条硬规则同一用户两次推送间隔不少于15天。这条规则对效果的贡献有时候比模型调参还明显。写论文时可以把阈值-成本曲线放在系统设计里这是一个差异化亮点。另外还有一个小技巧值得单独说在最终输出的预测分上叠加一个“临期加权”。用户领的券如果剩余有效期只剩3天核销概率会有一次显著抬升这个规律XGBoost通常学得不够连续因为训练数据里临期样本占比不高。我习惯在模型输出后做规则修正剩余有效期小于3天且用户历史核销率大于0.3的概率乘以1.2这会小幅提升TopK圈的核销命中率。代价是AUC轻微下降但业务收益更明显。如果你正在做这个方向的毕设建议把整套系统的模块拆清楚数据清洗与样本构建、特征工程、XGBoost训练与调参、规则修正、效果评估。每个模块对应一个章节实验部分放三组对比——baseline、调参后、加规则后这样的论文结构是完整且有说服力的。答辩大概率会追问样本切分和特征泄露前文第2章和第5章的内容足够帮你顶住。这个方向本身非常值得投入O2O场景里优惠券是核销率提升最直接的抓手换个数据集、换个商户品类整套流程能直接复用。手工调参确实有点玄学但先把切分、标签和窗口特征这三件事做扎实后面的每一步都会顺畅很多希望帮到你。本文还有配套的精品资源点击获取