ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

银行客户认购预测全流程:从特征工程到数据泄漏避坑实战

银行客户认购预测全流程:从特征工程到数据泄漏避坑实战 简介面向高校计算机相关专业正在准备毕业设计的学生也适合需要完整机器学习项目练手的初中级学习者这份源码包围绕银行客户认购产品预测这一典型二分类任务完整覆盖数据探索、特征工程、模型训练、效果评估与结果可视化流程。压缩包共65个文件包含5个Python脚本、3个Jupyter Notebook、2个序列化模型文件pkl、CSV/XLSX数据集与JSON参数配置另有43张分布分析图便于直观把握特征规律整体约9.58MB。项目经导师指导并严格调试能直接作为毕业设计、课程设计或期末大作业使用除完整源码外还打包了预处理管道、自动调参脚本和多版本Notebook分析过程方便对照复现调参思路、学习二分类建模与GridSearch等常规技巧。目前已有573人学习下载适合需要从数据清洗到模型落地全程参考的学习者尤其是希望高效完成毕设并体现工作量的人群。1. 用 Python 做基于机器学习的银行客户认购产品预测项目最容易翻车的不是模型而是那一列叫 duration 的字段它代表营销电话的通话时长电话都打完了才知道。拿它去预测客户会不会认购定期产品训练集 AUC 能做到 0.92 以上换一批新客户马上崩掉。这个项目本质是二分类问题根据银行营销电话记录预测客户是否购买定期存款产品。适合两类人——正在做机器学习课设或毕业设计的学生以及刚入行想跑通完整机器学习应用流程的初级算法工程师。这篇笔记按真实落地顺序来写先拆数据集和标签分布再做特征工程然后训练与调参最后集中讲我踩过的坑。你能照着复现也能在答辩时解释清楚每个选择。整套源码通常由数据清洗、特征工程、模型训练、评估脚本四部分组成而第一步永远不是跑模型是体检数据。2. 银行营销数据集拆解字段表、正负样本与不平衡度检查2.1 先看懂字段字典哪些能留下哪些是陷阱以 UCI Bank Marketing 这类公开银行营销数据集为蓝本原始数据是 CSV 格式分号分隔一行一条电话营销记录。字段类型含义建模时的处理意见age数值客户年龄保留可做分箱job分类职业类型OneHot 编码marital分类婚姻状态OneHot 编码education分类教育程度注意 unknown 值聚合到 otherdefault二分类是否有信用违约类别极不平衡保留balance数值账户余额保留可衍生余额/年龄housing二分类是否有房贷与 loan 组合成交叉特征loan二分类是否有其他贷款与 housing 组合成交叉特征contact分类联系方式OneHot 编码day / month数值/分类上次联系日期拆出星期几、月初月末duration数值通话时长训练时必须剔除泄漏高风险campaign数值本次营销联系次数保留是行为强信号pdays数值上次营销距今天数缺失用 -1 表示单独建模previous数值之前联系次数保留统计历史互动强度poutcome分类上次营销结果OneHot 编码y二分类是否认购定期产品目标变量yes/no最坑的是 duration。很多教程把它当普通特征AUC 立刻虚高到 0.9 以上。但实际业务场景是电话还没打你根本不知道这次通话能聊多久。所以它在训练集里再漂亮也不能用后面会单独讲。2.2 用两行代码检查正负样本比例11% 不是玄学拿到数据先别建模先看目标变量分布。银行客户认购定期产品的比例通常在 10%15%这决定了后面所有评估指标的选择。import pandas as pd import matplotlib.pyplot as plt import seaborn as sns df pd.read_csv(bank.csv, sep;) print(f数据集大小: {df.shape}) print(df[y].value_counts()) print(f正样本占比: {df[y].value_counts(normalizeTrue)[yes]:.2%}) fig, ax plt.subplots(figsize(8, 4)) sns.countplot(datadf, xy, huey, legendFalse) plt.title(目标变量 y 的分布) plt.show()这段代码里的sep;是银行公开数据常见的分隔符不是逗号normalizeTrue直接输出占比不用自己除。正样本只有 11% 意味着哪怕模型把所有客户都预测成“不买”准确率也有 89%但这模型没有任何业务价值。我一般会再打印一个交叉表看不同职业和婚姻状态下的认购比例这一步能快速感知哪些字段跟目标变量相关。print(pd.crosstab(df[job], df[y], normalizeindex))2.3 数据清洗的边界缺失值、噪声数据与不该删的记录pdays 字段用 -1 表示“客户之前没被联系过”。这不是脏数据是一个有业务含义的状态不能直接删。previous 为 0 和 pdays 为 -1 是同一件事的两个侧面都代表“新客户”。常见做法是把它们合并成一个contacted_before二值特征保留“从未联系过”这一信息。education 字段里有 unknown 值别直接 dropna。银行记录里 unknown 量大删掉会改变样本分布聚合到 other 更稳。balance 字段可能出现负值这是透支不代表数据错误不要当成噪声清洗掉。duration 字段不删行只从特征里剔除。清洗阶段保留它是为了后面做数据探查和对照实验训练阶段从 X 里去列这才是正确处理。3. 特征工程落地编码约定、时间字段拆分与业务交叉特征3.1 编码方案OneHot 优先于 LabelEncoder避免隐含有序性职业、婚姻状态、教育程度这类分类特征最常见的错误是用LabelEncoder一次性编码成 0、1、2这会引入本来不存在的顺序关系。正确做法是对非有序分类做 OneHot。cat_cols [job, marital, education, default, housing, loan, contact, poutcome] df pd.get_dummies(df, columnscat_cols, drop_firstTrue) print(df.shape)drop_firstTrue是为了去掉每个分类变量的第一个哑变量防止出现完全共线性逻辑回归对这种共线性特别敏感。编码之后 DataFrame 列数会明显变多比如 job 这一个字段就可能拆成十几列。对高基数分类特征比如 contact 这类只有两个取值的字段OneHot 完全够用。如果某个字段取值超过 20 种我一般改用 target encoding也就是用该类别的历史正样本占比替换原始值。但 target encoding 容易过拟合必须配合交叉验证使用毕业设计里不建议优先尝试。3.2 把 day 和 month 转成可用特征星期几、月初月末、季度节奏day 是数字但它不是连续数值是日期的一部分。month 是名称字符串也不能直接喂给模型。银行营销有明显的月度节奏常见做法是拆出星期几、是否工作日、是否月初、是否季末。import numpy as np month_map { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 } df[month_num] df[month].map(month_map) df[is_month_start] df[day].isin([1, 2, 3]).astype(int) df[is_quarter_end] df[month_num].isin([3, 6, 9, 12]).astype(int) df[day_of_week] (df[day] 1) % 7这里的is_quarter_end是季末冲刺信号银行季末冲存款是普遍现象。day_of_week是线性映射实际效果不强但聊胜于无。做完之后用 groupby 看一眼正样本率随这些特征的变化确认方向符合业务直觉。print(df.groupby(is_quarter_end)[y].agg([mean, count]))注意目标变量 y 在 groupby 里需要先映射成 0/1均值才是正样本占比。这一步的作用不是提升 AUC而是给答辩准备业务解释季末联系客户认购意愿确实更高。3.3 业务交叉特征怎么构造贷款组合、历史互动与消费能力交叉特征是这个项目能拉开差距的地方。单看 balance 意义有限把 balance 和 age 组合成“余额/年龄”代表单位年龄的财富积累把 housing 和 loan 组合代表客户是否有双重负债压力。df[balance_per_age] df[balance] / df[age].clip(lower1) df[debt_pressure] (df[housing_yes] df[loan_yes]) if housing_yes in df.columns else 0 df[contacted_before] (df[pdays] ! -1).astype(int) df[contact_intensity] df[previous] df[campaign] df[pdays_log] np.log1p(df[pdays].clip(lower0))np.log1p对 pdays 做对数压缩是因为 pdays 分布右偏直接喂给逻辑回归会被极端值带偏。debt_pressure如果字段名不存在先打印df.columns确认因为 get_dummies 输出列名依赖于原始分类取值。接触强度contact_intensity是“历史联系次数 本次联系次数”业务逻辑很直接联系次数越多客户被骚扰得越烦越不愿意买。这个特征对随机森林和梯度提升都有明显贡献。特征工程的最终检查点把y映射成 0/1删除duration、y、day、month这些原始列剩下的才是训练特征集。我感觉这个项目的特征做到 3050 个就够用了再往上加维度模型提升有限噪声反而更多。4. 训练与调参三个机器学习模型对比和 AUC 落地4.1 为什么用逻辑回归打底强基线模型的意义在银行客户认购预测里逻辑回归永远是第一个模型。原因有三它对小数据集稳定特征系数可以直接解释配合class_weightbalanced能处理类别不平衡。不要一上来就上 XGBoost先把基线跑出来。from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score target df[y].map({yes: 1, no: 0}) features df.drop(columns[y, duration, day, month]) X_train, X_test, y_train, y_test train_test_split( features, target, test_size0.2, stratifytarget, random_state42 ) lr LogisticRegression(max_iter2000, class_weightbalanced, C0.1) lr.fit(X_train, y_train) y_prob lr.predict_proba(X_test)[:, 1] print(fLogisticRegression AUC: {roc_auc_score(y_test, y_prob):.4f})train_test_split里stratifytarget保证训练集和测试集的正样本比例一致这对不平衡数据至关重要。class_weightbalanced让模型自动放大少数类权重。C 是正则化强度的倒数默认 C1 在这类数据上偏松我先试 0.1再看验证集微调。逻辑回归的 AUC 一般在 0.700.75 之间这是合理的强基线。如果连这个区间都达不到优先回头检查特征工程不要调模型。4.2 随机森林的边界类别不平衡、剪枝参数与降采样随机森林对不平衡数据比线性模型稳定但要注意剪枝。默认参数下每棵树都长到完全纯训练集的 AUC 接近 1验证集表现反而差。from sklearn.ensemble import RandomForestClassifier rf RandomForestClassifier( n_estimators400, max_depth10, min_samples_leaf20, class_weightbalanced, random_state42, n_jobs-1 ) rf.fit(X_train, y_train) y_prob_rf rf.predict_proba(X_test)[:, 1] print(fRandomForest AUC: {roc_auc_score(y_test, y_prob_rf):.4f}) print(pd.Series(rf.feature_importances_, indexfeatures.columns).nlargest(10))max_depth10限制了单棵树的深度min_samples_leaf20强制每个叶子至少有 20 个样本这两个参数是防过拟合的关键。n_estimators400不要更大树的数量到 300 之后收益递减只会增加训练时间。特征重要性排序能告诉哪些特征真正有用这在答辩时最有说服力。随机森林在这类数据上 AUC 约 0.750.80比逻辑回归高一截主要来自对非线性交互的捕捉比如 balance_per_age 和 debt_pressure 的组合效应。4.3 梯度提升怎么调才不过拟合早停与验证集梯度提升树的默认参数容易过拟合尤其在这个只有几千条样本的数据集上。直接用HistGradientBoostingClassifier用内置 early stopping 自动决定迭代轮数。from sklearn.ensemble import HistGradientBoostingClassifier hgb HistGradientBoostingClassifier( max_iter300, learning_rate0.05, max_depth5, validation_fraction0.2, early_stoppingTrue, class_weightbalanced, random_state42 ) hgb.fit(X_train, y_train) y_prob_hgb hgb.predict_proba(X_test)[:, 1] print(fHistGradientBoosting AUC: {roc_auc_score(y_test, y_prob_hgb):.4f})early_stoppingTrue会在验证集指标连续不再改善时停止训练validation_fraction0.2从训练集里切出 20% 做监控。learning_rate0.05配合max_iter300留出了足够的迭代空间又不会一跑到底。梯度提升的 AUC 通常在 0.80 左右是三个模型里最强的。4.4 评估指标选择为什么准确率在这里是错误答案正样本只有 11% 时准确率毫无意义。一个全预测 no 的模型准确率 89%但一个客户都捞不回来。这里要看三个指标ROC-AUC、召回率、以及按预测概率排序后的提升度。from sklearn.metrics import classification_report, recall_score for name, y_prob_ in [(LR, y_prob), (RF, y_prob_rf), (HGB, y_prob_hgb)]: y_pred (y_prob_ 0.3).astype(int) print(f{name} Recall0.3: {recall_score(y_test, y_pred):.4f})阈值从默认 0.5 降到 0.3 是处理不平衡的常见技巧因为正样本占比本来就只有 11%模型输出的概率普遍偏低。评估主线是 AUC辅线是降阈值后的召回率准确率只做参考。如果最终业务场景是电话营销更关心的是“名单里捞出来多少真正会买的客户”召回率比精确率重要。5. 避坑清单与常见排查泄漏、编码顺序和过采样误用5.1 泄漏duration 字段把 AUC“喂”到了 0.92现象训练集 AUC 高达 0.92换一批新数据 AUC 掉到 0.70模型表现完全不符合预期。原因duration 是通话时长通话结束才知道。它跟“客户是否同意购买”是同一时间点产生的信息拿它当特征等于先看答案再做题。这类数据泄漏在特征相关性热力图里非常明显duration 与 y 的相关系数远高于其他所有特征。解决建模特征里剔除 duration。如果一定要保留通话相关信号只能用标记为“预测时长”的代理特征比如客户平均历史通话时长而不是本次时长。5.2 编码顺序LabelEncoder 让逻辑回归系数跳得像玄学现象job、education 用 LabelEncoder 编码后逻辑回归某个系数大得异常另一个系数正负号与业务直觉相反。原因LabelEncoder 把“管理岗、蓝领、退休”等类别强制排成 0、1、2模型误以为存在线性顺序。管理岗比退休岗“大”了 3这种顺序在业务上不存在。解决职业、婚姻状态等无序分类一律用 OneHot。对无序分类做 LabelEncoder等于主动给模型灌噪声后面调参怎么调都救不回来。5.3 过采样SMOTE 用在切分之前验证集全废了现象用 SMOTE 对整个数据集过采样后再切分交叉验证 AUC 虚高 0.050.1且模型上线后落差极大。原因SMOTE 生成的正样本是插值合成的如果先过采样再切分这些合成样本及其近邻会同时出现在训练集和测试集里测试集不再干净。解决SMOTE 只能在训练折内部拟合测试集保持真实分布。from imblearn.pipeline import Pipeline from imblearn.over_sampling import SMOTE pipe Pipeline([ (smote, SMOTE(random_state42)), (rf, RandomForestClassifier(max_depth10, class_weightbalanced, random_state42)) ]) pipe.fit(X_train, y_train) y_prob_pipe pipe.predict_proba(X_test)[:, 1] print(fPipeline RF with SMOTE AUC: {roc_auc_score(y_test, y_prob_pipe):.4f})Pipeline 会把 SMOTE 放在每一折交叉验证内部执行避免信息泄漏。如果做手动交叉验证注意别把 SMOTE 放在外层循环之前。5.4 指标只看准确率会把“全预测 no”当成好模型现象模型报告准确率 89%但正样本一个都没捞到营销名单毫无效果。原因类别不平衡下全预测多数类就能拿到高准确率。这是这个项目里最常见的误判也是答辩时导师最喜欢问的点。解决主线指标改成 ROC-AUC业务指标改成按概率降序排序后取前 20% 名单的召回率。AUC 不看阈值能客观反映模型排序能力召回率反映名单里真正捞到多少正样本。5.5 噪声数据pdays-1 被删掉导致信息丢失现象对 pdays 做缺失值处理时直接删除所有 -1 的行样本量少了一半剩余样本的正样本比例也变了模型参数全乱。原因pdays-1 在数据集里不是缺失值而是“客户从未被联系过”的明确业务状态。把它当脏数据清理等于把新客户这类重要人群整体丢弃。解决保留原值构造contacted_before(pdays ! -1).astype(int)再把 pdays 用clip(lower0)后取对数。这样模型既能区分新老客户又能利用联系间隔信息。6. 按概率排序做营销名单增益曲线与成本线模型调完不是终点毕业设计答辩时最能加分的是“这个模型怎么用在业务里”。常见做法是把测试集的预测概率降序排列按概率分成十档统计每档的正样本占比画出累计增益曲线。results pd.DataFrame({ y_true: y_test.values, y_prob: y_prob_hgb }).sort_values(y_prob, ascendingFalse).reset_index(dropTrue) results[decile] pd.qcut(results[y_prob], 10, labelsFalse) gain results.groupby(decile)[y_true].agg([sum, count]) gain[positive_rate] gain[sum] / gain[count] print(gain)如果第一档正样本占比 30%而全量平均只有 11%说明模型把最可能认购的客户排在了前面。把概率最高的前 20% 客户拿出来做营销名单规模缩小 80%能覆盖整体正样本量的 50% 以上这个数字才是业务价值。还可以算出单客营销成本如果每次电话成本是 2 元前 20% 名单里每 100 个客户能捞回 30 个有效客户比全量打电话划算得多。我在做过的一次营销名单复盘里发现真正让名单质量提升的不是换更强的模型而是把 duration 剔除干净、把 pdays 当成业务状态而不是缺失值。这两件事决定了模型上限。增益曲线我建议直接画出来放进论文里能直观说明模型的营销价值。希望帮到你。本文还有配套的精品资源点击获取
返回列表