Python机器学习实战:银行客户认购产品预测模型全流程
简介这份资源是面向数据科学与金融风控方向学习者的机器学习实战项目包围绕银行客户是否认购定期产品这一典型分类问题展开适合具备Python基础、希望积累完整项目经验的高校学生与初级数据从业者。包内共68个文件以43张png可视化图表、5个csv数据集、5个py脚本、3个ipynb笔记本及2个pkl模型文件为主另含md说明文档与json日志压缩包约9.4MB。项目覆盖数据清洗、类别编码、特征工程、多模型训练与交叉验证调优并保存了预处理流水线与最优模型可直接复现预测流程。图表涵盖年龄、职业、婚姻、联系时长等维度的正负样本分布便于理解特征与认购行为的关系。目前已有674人学习下载可作为金融场景机器学习入门的完整参考案例。1. 银行客户认购产品预测从一份数据集到能上线的模型文件银行电话营销场景里客户认购定期存款产品的转化率通常只有个位数百分比这意味着外呼团队每打一百通电话可能只有几个人真正下单。把「谁会认购」提前筛出来直接决定了外呼名单的质量和人力成本。这个项目要解决的就是这件事用 Python 和机器学习基于银行历史营销数据训练一个二分类模型预测客户是否会认购产品并交付可复用的源码、数据集和模型文件三件套。适合有 Python 基础、想完整走一遍「数据清洗 → 特征工程 → 模型训练 → 评估 → 模型持久化」流程的从业者也适合需要给业务方一个可解释预测结果的算法同学。数据集本身是经典的银行营销结构化数据字段包含年龄、职业、婚姻、教育、账户余额、联系次数、上次联系间隔等标签是客户是否认购。整套流程不依赖深度学习框架一台普通笔记本就能跑完重点在于把每一步的参数和坑讲清楚让你拿到源码后能改、能调、能复现。2. 数据先看懂字段含义、分布陷阱与清洗顺序2.1 银行营销数据集的字段拆解与标签定义拿到数据集第一件事不是急着fit而是把每个字段的业务含义和取值分布过一遍。常见做法是先看列名和dtype再逐列统计唯一值和缺失情况。银行营销数据一般分几类字段客户属性age、job、marital、education、账户与历史balance、housing、loan、default、本次营销上下文contact、month、day、duration、campaign、pdays、previous、poutcome以及标签 yyes/no。其中 duration 是通话时长这个字段有个致命问题它在预测时是事后才知道的真实外呼前拿不到所以训练时如果把它当特征离线指标会虚高上线就翻车。我一般会先把 duration 单独拎出来做分析但最终模型里要么剔除要么只做参考。标签分布通常严重不平衡yes 占比可能只有 10% 上下。这直接影响后面评估指标的选择accuracy 在这里基本没有参考价值得看 recall、F1 和 AUC。下面这段代码做的是加载数据、看结构、看标签比例是每次开新项目我都会跑的第一段。import pandas as pd import numpy as np # 加载数据集分隔符按实际文件调整常见是分号 df pd.read_csv(bank_marketing.csv, sep;) # 看整体结构和前几行 print(df.shape) print(df.dtypes) print(df.head()) # 标签分布判断不平衡程度 print(df[y].value_counts(normalizeTrue)) # 数值列描述统计重点看 balance、duration、campaign 的极值 print(df.describe().T[[mean, std, min, max]])逻辑说明sep;是因为这类数据集常以分号分隔如果读进来只有一列八成是分隔符错了。value_counts(normalizeTrue)直接给出标签占比决定后面要不要做重采样或调class_weight。describe()重点看 max 和 minbalance 可能有负值透支duration 可能有极端长尾这些都会影响后续处理。参数说明read_csv的sep必须和文件实际分隔符一致如果字段里有引号包裹的字符串加quoting3或quotechar。describe()默认只统计数值列类别列要看唯一值得单独用nunique()。2.2 缺失值、异常值与类别编码的处理顺序清洗顺序错了后面全白做。我的习惯是先处理缺失再处理异常最后做编码。缺失值先看是哪种缺失数值列用中位数填充比均值稳因为 balance 这类字段有极端值均值会被拉偏。类别列如果缺失少直接填众数缺失多考虑单独设一个 unknown 类别因为银行数据里 unknown 本身可能携带信息。异常值不要无脑删。balance 的负值和极大值可能是真实的高净值或透支客户删了反而丢信息。我的做法是对树模型不做处理对线性模型才做截断或分箱。类别编码上job、marital、education 这些无序类别用 One-Hot但要注意维度膨胀month 是有序的可以映射成 1-12 的数值比 One-Hot 更省维度也更有语义。from sklearn.preprocessing import LabelEncoder # 数值列缺失用中位数填充 num_cols df.select_dtypes(include[np.number]).columns df[num_cols] df[num_cols].fillna(df[num_cols].median()) # 类别列缺失填 unknown cat_cols df.select_dtypes(include[object]).columns.drop(y) for col in cat_cols: df[col] df[col].fillna(unknown) # month 映射成数值保留顺序信息 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) # 标签编码成 0/1 df[y] df[y].map({no:0, yes:1}) # 剩余无序类别做 One-Hot df pd.get_dummies(df, columns[c for c in cat_cols if c ! month], drop_firstTrue)逻辑说明先数值后类别避免填充时把类别列当数值处理。month_num单独映射是因为月份有天然顺序One-Hot 会丢掉这个信息。drop_firstTrue是为了避免 One-Hot 后的多重共线性对树模型影响不大但对逻辑回归很重要。参数说明fillna用中位数是抗异常值的默认选择如果某列缺失超过 30%建议先看这列的业务价值再决定填还是删。get_dummies的drop_first按模型类型决定树模型可以设 False 保留全部。提示清洗完一定要重新跑一次df.isnull().sum()和df.shape确认没有漏网的缺失和维度爆炸。3. 特征工程与模型选型为什么树模型在这类数据上更稳3.1 从原始字段到可用特征的构造思路原始字段直接喂给模型不是不行但构造几个衍生特征往往能明显提升效果。银行营销场景里我常做这几类一是联系强度类比如campaign本次联系次数和previous之前联系次数相加得到总联系次数二是时间间隔类pdays表示上次联系距今天数-1 表示没联系过可以转成是否联系过的二值特征加实际间隔三是账户健康度balance 结合 housing、loan 可以粗略刻画负债情况。这些衍生特征的价值在于把业务逻辑显式地告诉模型。树模型虽然能自己学交互但显式构造能降低学习难度尤其在样本量不大时更明显。构造完记得做一次特征重要性初筛把重要性接近 0 的字段去掉减少噪声。# 总联系次数 df[total_contacts] df[campaign] df[previous] # pdays 转成是否联系过 实际间隔 df[was_contacted] (df[pdays] ! -1).astype(int) df[pdays_clean] df[pdays].replace(-1, np.nan) df[pdays_clean] df[pdays_clean].fillna(df[pdays_clean].median()) # 负债标记 df[has_debt] ((df[housing] yes) | (df[loan] yes)).astype(int) # 去掉原始 pdays 和 duration如果决定不用 df df.drop(columns[pdays, duration], errorsignore)逻辑说明was_contacted把 -1 这个特殊值转成有意义的二值信号pdays_clean保留真实间隔。has_debt把两个负债字段合并成一个强信号。duration剔除是因为它在线下预测时不可得留着就是自欺欺人。参数说明replace(-1, np.nan)后再填中位数是为了不让 -1 污染间隔分布如果业务上 -1 占比很高was_contacted这个特征的重要性会很高属于正常现象。3.2 逻辑回归、随机森林与 XGBoost 的对比选型模型选型上逻辑回归可解释性最强系数直接反映特征影响方向但在这类有大量类别特征和交互的数据上效果通常不如树模型。随机森林稳、抗过拟合、对参数不敏感适合作为 baseline。XGBoost 在结构化数据上往往是效果最好的支持自定义损失、能处理不平衡、有内置特征重要性是这类项目的常见首选。我的做法是三个都跑用同一套交叉验证对比 AUC 和 recall。逻辑回归作为可解释性兜底随机森林作为稳定 baselineXGBoost 作为效果上限。如果业务方要求解释每个客户的拒绝原因逻辑回归或加 SHAP 的树模型更合适如果只看排序效果XGBoost 直接上。from sklearn.model_selection import train_test_split, cross_val_score from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from xgboost import XGBClassifier X df.drop(columns[y]) y df[y] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, stratifyy, random_state42) models { lr: LogisticRegression(max_iter1000, class_weightbalanced), rf: RandomForestClassifier(n_estimators300, class_weightbalanced, random_state42), xgb: XGBClassifier(n_estimators300, learning_rate0.05, max_depth5, scale_pos_weight(y_train0).sum()/(y_train1).sum(), eval_metricauc, random_state42) } for name, model in models.items(): scores cross_val_score(model, X_train, y_train, cv5, scoringroc_auc) print(name, scores.mean().round(4), scores.std().round(4))逻辑说明stratifyy保证训练测试集标签比例一致避免小类在某一侧过少。class_weightbalanced和scale_pos_weight都是应对不平衡的手段前者按类别频率自动加权后者显式给正样本加权。cross_val_score用 AUC 而不是 accuracy是因为不平衡数据下 accuracy 会骗人。参数说明max_iter1000是逻辑回归防不收敛的保险n_estimators300是随机森林和 XGBoost 的常用起点再大收益递减learning_rate0.05配max_depth5是 XGBoost 在中小数据上的稳妥组合学习率低一点、树浅一点泛化更好。注意scale_pos_weight不要设得过大否则 recall 上去了但 precision 崩了外呼名单会充满误报业务方会骂人。4. 训练、调参与模型持久化把模型文件真正用起来4.1 用 GridSearchCV 做一轮有节制的调参调参不是网格越密越好参数组合爆炸会让训练时间失控。我的习惯是先粗后细第一轮用较宽的步长定位大致区间第二轮在最优附近细化。XGBoost 重点调max_depth、learning_rate、n_estimators、subsample、colsample_bytree这几个min_child_weight和gamma作为防过拟合的辅助。随机森林重点调n_estimators和max_depthmax_features影响也大。评估指标锁定 AUC 或 F1不要用 accuracy。交叉验证折数 5 折够用数据量小可以上 10 折但时间会翻倍。调参完一定要在独立测试集上再验一次避免调参过拟合验证集。from sklearn.model_selection import GridSearchCV param_grid { max_depth: [3, 5, 7], learning_rate: [0.03, 0.05, 0.1], n_estimators: [200, 300, 500], subsample: [0.8, 1.0], colsample_bytree: [0.8, 1.0] } xgb XGBClassifier(scale_pos_weight(y_train0).sum()/(y_train1).sum(), eval_metricauc, random_state42) grid GridSearchCV(xgb, param_grid, cv5, scoringroc_auc, n_jobs-1, verbose1) grid.fit(X_train, y_train) print(grid.best_params_) print(grid.best_score_)逻辑说明n_jobs-1用满 CPU 核数加速verbose1能看到进度。scoringroc_auc保证选出的参数是对排序能力最优的。best_params_拿到后建议再手动跑一次完整训练确认测试集表现。参数说明max_depth超过 7 在中小数据上很容易过拟合learning_rate和n_estimators要配合学习率低就多树学习率高就少树subsample和colsample_bytree小于 1 能增加随机性、抗过拟合。4.2 模型文件保存、加载与推理接口封装模型训练完不保存等于白做。常见做法是用joblib或pickle保存模型对象但要注意保存的不只是模型还有特征列顺序、编码映射、阈值。我一般会把模型和一份feature_columns.json一起存推理时按同样顺序对齐列否则predict会报维度错或静默出错。推理接口封装成函数输入原始字段字典内部走一遍和训练一致的清洗和编码再调模型输出概率。这样业务方调用时不用关心预处理细节。阈值不要固定 0.5按业务对 recall 和 precision 的偏好调比如外呼成本低就调低阈值多捞人。import joblib import json # 保存模型和特征列 joblib.dump(grid.best_estimator_, bank_model.pkl) with open(feature_columns.json, w) as f: json.dump(list(X_train.columns), f) # 加载并推理 model joblib.load(bank_model.pkl) with open(feature_columns.json) as f: cols json.load(f) def predict_one(raw: dict, threshold0.4): row pd.DataFrame([raw]) # 这里复用训练时的清洗和编码逻辑 row preprocess(row) row row.reindex(columnscols, fill_value0) prob model.predict_proba(row)[0, 1] return {prob: float(prob), label: int(prob threshold)} print(predict_one({...}))逻辑说明reindex(columnscols, fill_value0)是关键保证推理时列顺序和训练完全一致缺失的 One-Hot 列补 0。threshold0.4是示例实际按业务调。返回概率而不是直接返回标签方便业务方自己设阈值。参数说明joblib比pickle对 numpy 数组更高效模型大时优先用。feature_columns.json必须和模型同版本保存改特征后要重新生成。阈值调整建议画 PR 曲线看不同阈值下 recall 和 precision 的权衡再定。提示模型文件上线前用一批历史数据做一次离线回放对比预测认购名单和实际认购名单的重合度确认没有预处理不一致的问题。5. 避坑与排查这类项目最容易翻车的五个地方5.1 数据泄漏duration 和 pdays 的隐形陷阱现象离线 AUC 高到 0.95 以上上线后效果断崖式下跌。原因duration是通话结束后才知道的字段训练时当特征用模型学到了「通话久就认购」这种事后信息线上预测时拿不到自然失效。pdays的 -1 如果没处理模型可能把 -1 当成一个普通数值学到错误的大小关系。解决训练前明确哪些字段在线下预测时可得duration直接剔除pdays拆成was_contacted和清洗后的间隔避免 -1 污染。5.2 标签不平衡导致的指标幻觉现象accuracy 有 0.88看着不错但 recall 只有 0.2模型几乎不预测正类。原因正类占比低模型全预测负类就能拿到高 accuracy这是不平衡数据的经典陷阱。解决评估指标换成 AUC、F1、recall训练时用class_weight或scale_pos_weight必要时对正类过采样或对负类欠采样但要注意过采样可能过拟合SMOTE 在类别特征多时效果不稳定。5.3 训练与推理的预处理不一致现象离线测试正常线上推理报维度错或概率分布明显异常。原因训练时用了 One-Hot推理时字段顺序、类别取值和训练不一致或者get_dummies在单条数据上生成的列和训练集不同。解决把预处理逻辑封装成函数训练和推理共用保存feature_columns.json推理时reindex对齐类别编码用固定的映射字典不要依赖运行时数据。5.4 阈值固定 0.5 导致业务不可用现象模型 AUC 不错但按 0.5 阈值筛出的名单要么太少要么误报太多。原因0.5 是数学默认值不是业务最优值。外呼成本低、客户打扰成本低时应该调低阈值多捞人反之调高。解决画 PR 曲线结合业务方对 recall 和 precision 的要求定阈值把阈值做成可配置参数不要写死在代码里。5.5 特征重要性误读与过度删特征现象按特征重要性删了一批字段后模型效果反而下降。原因树模型的特征重要性对相关特征会分摊权重两个相关特征各自重要性都不高但删掉一个另一个也失效另外重要性高不代表因果只代表模型依赖。解决删特征前先看相关性矩阵成组保留或成组删用 SHAP 看单样本解释比全局重要性更可靠每次删特征后重新交叉验证用数据说话。6. 进阶技巧用 SHAP 把黑匣子模型讲给业务方听模型效果再好业务方看不懂就不会用。树模型尤其容易被当成黑匣子这时候 SHAP 是最实用的解释工具。它基于博弈论能给每个样本的每个特征算出一个贡献值正负代表推动预测方向绝对值代表影响大小。对银行营销场景你可以直接告诉业务方「这个客户被预测为会认购主要因为上次联系成功过、账户余额高、联系次数适中」这种解释比一个概率数字有说服力得多。安装和基础用法很简单pip install shap之后用TreeExplainer包住训练好的 XGBoost 或随机森林模型传入测试集就能出图。下面这段代码输出全局特征重要性和单样本解释。import shap # 用树模型解释器 explainer shap.TreeExplainer(model) shap_values explainer.shap_values(X_test) # 全局重要性图保存成图片给业务方 shap.summary_plot(shap_values, X_test, showFalse) # 单个样本解释 sample_idx 0 shap.force_plot(explainer.expected_value, shap_values[sample_idx], X_test.iloc[sample_idx], matplotlibTrue)逻辑说明TreeExplainer对树模型是精确计算速度快。summary_plot给出全局特征影响点的颜色代表特征值高低能看出「余额高推高预测概率」这类方向性结论。force_plot针对单个客户红色推高、蓝色拉低业务方一眼能看懂为什么这个客户被选中。参数说明shap_values对二分类返回的是正类贡献多分类要按类别取。summary_plot的showFalse是为了在脚本里保存图片配合plt.savefig使用。数据量大时shap_values计算会慢可以抽样几百条算全局趋势足够。我自己的习惯是每次交付模型时附一张 SHAP 全局图加三个典型客户的解释业务方接受度明显比只给 AUC 高。还有一点血泪经验SHAP 解释的是模型行为不是因果关系别对业务方说「因为余额高所以一定认购」要说「模型认为余额高这个信号推高了认购概率」措辞上留余地后面才不会被打脸。模型上线后定期用新数据重跑 SHAP看特征影响有没有漂移比只看 AUC 更能提前发现数据分布变化。这套流程跑通一次后面换数据集、换标签、换模型都能复用值得花时间把每个环节的参数和边界摸清楚。希望帮到你。本文还有配套的精品资源点击获取