
简介这份资源是一套基于Python的银行客户金融产品认购预测机器学习项目适合机器学习初学者和金融行业数据分析人员学习参考。项目围绕银行精准营销场景从数据预处理、特征工程到模型训练与评估提供了完整的代码实现和项目文档。资源包共79个文件以Python脚本、Jupyter Notebook、CSV数据文件、PNG可视化图表以及训练好的pkl模型文件为主整体压缩包约10.47MB目录结构清晰便于系统学习。目前已有42人学习使用。通过该项目可以掌握二分类建模的完整流程理解多种机器学习算法的对比与选择方法同时资源附带大量特征分布可视化图表和数据说明文档能够帮助读者深入理解银行客户数据的分析思路并基于训练好的模型快速开展扩展实验。1. 银行客户认购预测系统这个项目到底在解决什么银行电话营销的场景里坐席每天要拨打几百通电话但真正愿意认购定期存款、理财产品、基金的用户往往只有个位数百分比。把一份几千条客户名单按谁最可能认购排序让坐席先打高意向客户这就是客户产品认购预测系统的核心价值。它本质上是一个二分类机器学习项目输入客户的年龄、职业、婚姻状况、存款余额、历史营销接触记录等字段输出该客户是否会认购某款金融产品。这类项目之所以适合做入门到进阶的完整实战是因为它身上的机器学习要素非常齐全真实结构化数据、类别特征与数值特征混合、样本类别不平衡、时序泄漏风险、模型可解释性要求。无论是课程设计、毕业设计还是想系统走一遍机器学习项目流程的从业者用它来练手和沉淀一套可复用的训练预测源码都是比较划算的投入。2. 数据准备从银行原始营销记录到干净的建模数据集2.1 字段解读与目标变量定义常见做法是使用公开的银行营销数据集它的字段定义和真实银行CRM导出的营销记录高度一致。项目源码里通常包含一份形如bank.csv或bank-additional-full.csv的训练数据包含十六个左右的输入字段和一个目标字段。建模前第一件事是搞清楚每个字段的业务含义而不是急着写代码。字段名类型含义与建模价值age数值客户年龄与风险偏好和产品接受度相关job类别职业类型如管理、蓝领、退休、学生等marital类别婚姻状况离异/已婚/单身default类别是否有信用违约记录balance数值账户平均余额认购能力的重要信号housing类别是否有房贷影响可投资资金loan类别是否有个人贷款contact类别联系方式手机/座机/未知day / month数值/类别上次联系日期部分场景按周期特征使用duration数值最后一次通话时长秒与认购强相关但要小心泄漏campaign数值本次营销活动中联系该客户的次数pdays数值上次营销距今天数999表示从未联系过previous数值此前营销活动中联系该客户的次数poutcome类别上次营销结果成功/失败/不存在y类别目标变量是否已认购定期存款目标变量y在原始数据里是yes/no字符串建模时需要映射成1/0。duration这个字段是项目里最典型的双刃剑通话时长越长用户大概率已经在与坐席深入沟通认购率自然高但预测阶段拿不到未拨打电话的时长必须单独讨论处理策略后面第四章展开。2.2 加载数据与样本构造的代码实现拿到源码包后数据加载部分通常是这样的写法import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import LabelEncoder # 原始CSV以分号分隔部分字段含引号需要指定分隔符 df pd.read_csv(bank.csv, sep;, quotechar) # 目标变量映射yes - 1no - 0 df[y] df[y].map({yes: 1, no: 0}) # 标记未知值便于后续统一处理 for col in [job, marital, education, default, housing, loan, contact]: df[col] df[col].replace(unknown, UNKNOWN) print(数据集形状, df.shape) print(认购率, df[y].mean())逻辑说明sep;是因为这份数据不是标准逗号分隔直接read_csv不指定分隔符会把整行读成单列。replace(unknown, UNKNOWN)是把缺失值和业务上的未知值统一成一个显式类别避免建模时被当作有效字符串或空值误处理。参数说明这里建议先不 dropunknown而是保留成单独类别。很多新手上来就删缺失值结果覆盖率高的字段被削掉大量样本排序模型在真实名单上失效。df[y].mean()是快速看类别分布的手段正常数值在 0.1 上下如果高于 0.3先怀疑是不是样本选择偏了。2.3 数据切分的时序边界shuffle 前的常见坑切分训练集和测试集时一个高频问题是直接train_test_split(df, test_size0.2, random_state42)这在大多数学术 demo 里没问题但营销数据的采集是随时间推进的同一客户可能在几个月内被多次营销联系。如果不加约束直接随机切分会造成训练集里出现和测试集几乎同批次的客户测试精度虚高上线后模型表现明显缩水。train_df, test_df train_test_split( df, test_size0.2, stratifydf[y], random_state42 ) print(训练集认购率, train_df[y].mean()) print(测试集认购率, test_df[y].mean())逻辑说明stratifydf[y]保证切分后训练与测试集的正负样本比例与原数据一致。对这类不平衡的二分类任务不做分层采样极可能测试集全是负样本连混淆矩阵都画不出来。参数说明random_state固定后每次跑结果一致调参时可复现。如果你的业务数据带时间戳字段切分时应该按时间截断比如前 80% 时间段的记录做训练后 20% 做验证这才是模拟真实上线的正确姿势。3. 特征工程从业务字段到模型输入的关键转换3.1 类别变量编码从 LabelEncoder 到 One-Hot 的选择逻辑银行营销数据里大部分字段是类别型。新手最常犯的错误是给所有类别字段都套上LabelEncoder把职业直接编码成 0、1、2、3。模型会把这些数字当成有序数值导致蓝领1、退休2这样的伪顺序干扰树模型的分裂逻辑回归更是会直接学出荒谬的权重。正确处理方式取决于字段是否有天然顺序。education可以按学历高低映射成有序数值但job、marital、contact只能用 One-Hot 编码或 Target Encoding。源码里常见的做法是用pd.get_dummies或sklearn的OneHotEncoder处理无顺序类别用OrdinalEncoder处理后少数有序字段。from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder # 有序字段按教育程度映射 education_order [UNKNOWN, primary, secondary, tertiary] df[education_level] pd.Categorical( df[education], categorieseducation_order, orderedTrue ).codes # 无顺序类别字段统一one-hot categorical_cols [job, marital, default, housing, loan, contact, poutcome] df_encoded pd.get_dummies(df, columnscategorical_cols, drop_firstTrue) print(one-hot之后字段数, df_encoded.shape[1])逻辑说明drop_firstTrue会删掉每个类别变量的第一个取值避免出现 dummy variable trap尤其是后面接线性模型时多列完全共线会导致系数不稳定。参数说明pd.Categorical的categories顺序就是编码顺序未知值排在 0 位不和后面的有序值混淆。codes得到的是连续整数但注意负数 -1 代表在 categories 中找不到的取值建模前要检查有没有 -1 泄漏进来。3.2 数值特征处理与时长字段的边界问题数值字段至少要看一眼分布。balance和duration是明显右偏分布多数客户存款余额在几千到几万区间个别客户有几十万存款直接送进模型会让线性模型被极端值带偏。日志变换是这一类字段最通用的处理方式但也别只盯着单变量变换。duration字段是绕不开的重点。它在训练集里与标签的关联强到离谱训练时把它放进去AUC 能直接拉到 0.9 以上看起来很爽但这是一份虚假繁荣。真实营销场景里要在通话前预估客户是否会认购而通话时长只有挂电话之后才知道。如果源码把duration直接放进特征建议理解它的业务含义后手动剔除。import numpy as np # 记录原始duration但不进特征 df[duration_sec] df[duration] # 对右偏字段做log1p变换 df[balance_log] np.log1p(df[balance]) df[previous_log] np.log1p(df[previous]) # 把pdays中的999替换为代表“从未联系过”的标记值 df[pdays_flag] (df[pdays] 999).astype(int) df[pdays_valid] df[pdays].replace(999, -1) # 剔除泄漏字段后的完整特征列表 exclude_cols [y, duration, duration_sec] feature_cols [c for c in df_encoded.columns if c not in exclude_cols]逻辑说明log1p是log(1x)对含有 0 的字段友好不做1直接取 log 会出现负无穷。pdays999是业务上定义的从未联系过把它替换成 -1 然后再加一个标志位字段相当于同时告诉模型这个值是缺失的和数值上它落在什么位置。参数说明这里的关键决策是剔除了duration。如果你只是想课程设计拿高分保留它确实能涨指标但面试或答辩环节一旦被问这个特征在实际预测时拿不到你怎么处理就很被动。完整源码里一般有两种方案要么剔除要么只在特征重要性分析阶段单独观察它不建议直接进最终模型。3.3 特征工程完整封装与交叉验证中的一致性特征工程写成一堆散落的 pandas 操作调参时前端变量一变后面跟着出错。建议把编码、变换、剔除逻辑统一封装成一个函数或一个 sklearnPipeline。这样交叉验证的每一折都只基于训练数据拟合编码器避免验证集信息提前泄漏进变换参数。from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline num_cols [age, balance, campaign, pdays, previous] cat_cols [job, marital, education, default, housing, loan, contact, poutcome] preprocessor ColumnTransformer( transformers[ (num, Pipeline([ (log, FunctionTransformer(np.log1p, validateTrue)), (scaler, StandardScaler()) ]), num_cols), (cat, OneHotEncoder(handle_unknownignore), cat_cols) ]) X df.drop(columns[y, duration]) y df[y] model Pipeline(steps[ (preprocess, preprocessor), (classifier, LogisticRegression(max_iter1000)) ])逻辑说明ColumnTransformer把数值和类别特征分开处理数值做 log 变换和标准化类别做 one-hot。handle_unknownignore保证训练时没出现的类别在预测阶段不会报错这个对上线环境尤其重要真实名单总会出现新职业或新联系人渠道。参数说明FunctionTransformer(np.log1p, validateTrue)直接复用np.log1p不需要单独写自定义函数。StandardScaler对逻辑回归这类基于距离的模型是必选项对树模型影响不大但统一放进 pipeline 里不需要为每个模型单独调整。4. 模型选型与训练逻辑回归、随机森林与GBDT的对比落点4.1 三个模型的适用边界银行信贷与营销场景有监管和可解释性压力模型选型天然倾向于逻辑回归。逻辑回归权重直接对应每个特征的贡献方向和强度业务人员拿着系数表可以讲清楚为什么这个客户被排到前面。缺点是特征交互需要手动构造而客户行为里恰恰存在大量交互比如高余额 无贷款 长期未联系的组合信号。随机森林是课程设计和快速落地的稳妥选择。它对异常值不敏感类别特征处理容忍度高调参空间相对简单基本不会出现过拟合到没法看的情况。缺点是预测结果不好解释树模型内部的决策路径太长没法直接给运营一个清晰的排序理由。GBDT如 XGBoost、LightGBM在结构化数据上的精度普遍强于前两者能自动发掘特征交互对不平衡样本的处理也有原生支持。但配套而来的问题是参数组合更多、训练时间更长、模型文件更大。对银行客户认购预测这个任务数据量通常在几万条级别GBDT 的优势不至于碾压却能让最终指标高出几个百分点。4.2 基准训练与交叉验证代码不管最终选哪个模型第一步都是先跑一组朴素基准用默认参数交叉验证把 AUC 基线记下来。没有基线后面调参都是在碰运气。from sklearn.model_selection import cross_val_score from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import roc_auc_score, accuracy_score # 简单基线逻辑回归 lr Pipeline(steps[ (preprocess, preprocessor), (classifier, LogisticRegression(max_iter1000)) ]) scores_lr cross_val_score( lr, X, y, cv5, scoringroc_auc, n_jobs-1 ) print(逻辑回归 AUC, scores_lr.mean(), /-, scores_lr.std()) # 随机森林基线 rf Pipeline(steps[ (preprocess, preprocessor), (classifier, RandomForestClassifier(n_estimators300, random_state42)) ]) scores_rf cross_val_score( rf, X, y, cv5, scoringroc_auc, n_jobs-1 ) print(随机森林 AUC, scores_rf.mean(), /-, scores_rf.std())逻辑说明这套代码同时跑了逻辑回归和随机森林的 5 折交叉验证scoringroc_auc表示评估指标用的是 AUC 而不是准确率。类别不平衡场景下准确率没有参考意义全部预测为不认购也能拿到 90% 的准确率AUC 才能真实反映模型区分认购与非认购客户的能力。参数说明n_jobs-1让交叉验证并行跑满所有 CPU 核数据量小的时候体现不出差异但到了真实全量名单上会节省大量时间。random_state42固定随机森林的采样过程保证多次运行结果一致。4.3 参数怎么调网格搜索的实用参数表调参只盯三个方向防止过拟合的正则化强度、控制模型复杂度的树深度、应对类别不平衡的样本权重。把候选值压缩到 3~4 个组合数控制在十几组以内几分钟跑完一轮。模型关键参数候选值调整方向逻辑回归C0.01 / 0.1 / 1.0越小正则越强防止系数膨胀逻辑回归class_weightNone / balanced平衡让少数类权重增大缓解类别不平衡随机森林n_estimators200 / 300 / 500越大越稳定边际收益递减随机森林max_depth5 / 8 / None限制深度防止把训练集细节全背下来随机森林min_samples_leaf5 / 20 / 50越大模型越保守泛化更好随机森林class_weightNone / balanced与逻辑回归同理GBDTn_estimators100 / 300配合 early_stopping 使用GBDTlearning_rate0.01 / 0.05 / 0.1越小越稳但要更多树GBDTmax_depth3 / 5 / 7弱学习器深度通常不超过 7from sklearn.model_selection import GridSearchCV param_grid { classifier__n_estimators: [200, 400], classifier__max_depth: [5, 8], classifier__min_samples_leaf: [10, 50], classifier__class_weight: [balanced, None] } grid GridSearchCV( rf, param_grid, scoringroc_auc, cv5, n_jobs-1, verbose1 ) grid.fit(X, y) print(最优参数, grid.best_params_) print(最优AUC, grid.best_score_)逻辑说明classifier__前缀是因为整个模型是Pipeline结构参数名要带上步骤名否则GridSearchCV找不到对应参数。class_weightbalanced会自动把少数类的权重按样本比例调高对银行营销这类认购率只有 10% 上下的任务基本是必开选项。参数说明cv5表示内部网格搜索再做 5 折交叉验证外层交叉验证如果再设 5 折总计算量是 5 乘 5 乘参数组合数数据量不大可以接受数据量大时建议把cv降到 3。verbose1在终端输出进度参数组合多的时候能直观看到跑到哪一步。5. 避坑类别不平衡、过拟合与时序泄漏的排查记录5.1 准确率 99% 的假象类别不平衡的经典翻车现象训练完成后打印accuracy得到 0.90 以上看起来模型很强但业务上一点用都没有坐席按这个名单打电话转化率并没有提升。原因数据里认购客户占比约 11%负样本占 89%。模型只要把所有样本都预测成不认购准确率就是 89%在分类阈值默认 0.5 的情况下模型收敛后倾向把所有样本概率压到 0.5 以下导致少数类几乎一个都捞不出来。解决把评估指标从accuracy换成roc_auc、recall、precisiontop20%这类对排序能力敏感的指标。训练层面开class_weightbalanced或者用SMOTE做过采样但更推荐先调权重再考虑采样权重不影响样本分布不容易引入额外噪声。5.2 duration 字段放进模型后 AUC 虚高 0.9 以上现象特征里包含duration时交叉验证 AUC 高达 0.92去掉这个字段后直接掉到 0.78怀疑模型变差了。原因duration与目标变量之间存在业务上的因果倒置。通话持续 300 秒以上的客户大概率已经在和坐席深入沟通产品细节认购概率天然高。但这个特征只有在通话结束后才能获得预测场景根本拿不到属于典型的数据泄漏。解决把duration从特征列表剔除。如果项目要求必须分析它的影响可以单独做一个实验记录含 duration 的 AUC 上限和不含 duration 的可用 AUC并明确说明线上预测只用后者。这是答辩和面试时最容易被追问的一个点提前想好口径会从容很多。5.3 在验证集上反复调参过拟合现象交叉验证分数始终在 0.78 到 0.79 之间横跳某一次调参后突然到 0.82但换了随机种子后分数又回到 0.78以为是参数的功劳其实是运气。原因在固定的测试集上反复看结果并据此调参验证集信息会逐步泄漏进模型选择过程。尤其数据量只有几千条时随机切分带来的波动足够掩盖真实的参数差异。解决每一轮调参只在训练集上做交叉验证测试集最后只碰一次。更稳妥的做法是把数据切成三段训练、验证、测试。训练集调权重验证集选模型和阈值测试集做最终评估。如果项目时间紧至少把random_state换几个值都跑一遍观察 AUC 标准差是否小于 0.01。5.4 模型文件保存与 Python 版本兼容问题现象模型训练完用pickle.dump保存换一台机器加载时报ModuleNotFoundError或直接报版本不兼容重装环境后预测结果还变了。原因pickle保存的模型依赖原始类定义sklearn版本升级后部分类的内部结构发生变化旧模型加载时会失败或行为不一致。另一个常见坑是Pipeline里包含自定义函数时换环境后函数路径对不上反序列化失败。解决统一用joblib.dump和joblib.load替代pickle操作模型文件同时对包含自定义函数的Pipeline把函数单独放进一个.py文件里部署环境导入同一路径的模块。保存模型时顺手把sklearn.__version__和pandas.__version__记录到requirements.txt踩过这个坑之后我已经养成了每次训练完先打印环境信息的习惯。import joblib from sklearn import __version__ as sklearn_version import pandas as pd joblib.dump(grid.best_estimator_, bank_model.joblib) # 记录环境版本避免换机器后黑匣子式报错 with open(model_meta.txt, w, encodingutf-8) as f: f.write(fsklearn_version{sklearn_version}\n) f.write(fpandas_version{pd.__version__}\n)6. 从准确率到决策收益阈值调优与模型部署验证分类模型的默认阈值 0.5 在这里并不合适。当正样本只有 10% 出头时模型输出的预测概率普遍偏低直接按 0.5 切片会导致几乎所有客户都被判定为不认购。实际业务上银行要的不是绝对预测正确而是给坐席一份高意向名单。调整阈值的逻辑是把排序前 20% 或前 30% 的客户筛出来优先外呼哪怕误判一些负样本只要名单里的认购密度比随机名单高这个模型就有业务价值。from sklearn.metrics import precision_recall_curve # 用测试集预测概率 y_pred_proba grid.best_estimator_.predict_proba(X_test)[:, 1] precisions, recalls, thresholds precision_recall_curve(y_test, y_pred_proba) # 找到满足召回率60%的最大精度阈值 valid_idx [i for i, r in enumerate(recalls) if r 0.6] best_idx max(valid_idx, keylambda i: precisions[i]) best_threshold thresholds[best_idx] print(目标召回率0.6下的最佳阈值, round(best_threshold, 4)) print(对应精度, round(precisions[best_idx], 4))逻辑说明precision_recall_curve返回每个可能阈值下的精度和召回率。这里遍历所有满足召回率不低于 0.6 的阈值选精度最高的那个。含义是模型捞出了 60% 以上真正会认购的客户同时名单里一半以上是有效客户坐席外呼效率显著高于随机打名单。参数说明召回率目标设多高完全取决于业务成本。如果一次外呼电话成本高把目标降到 0.4 换取更高精度如果产品本身利润空间大就把目标抬到 0.7 以上多覆盖一些潜在客户。这个值不要照抄要按银行的单客营销成本和产品收益反推。阈值确定后可以用一小段代码模拟排序名单的效果把测试集按预测概率降序排列取前 20% 客户统计真实认购率和全量认购率对比这个比值就是常见的提升度。提升度在 2 以上说明模型有实用价值低于 1.5就需要回到特征工程找问题。部署阶段项目源码里最常见的形态是把训练好的Pipeline模型通过一个轻量 HTTP 接口暴露出来。请求进来时传入客户字段接口返回预测概率和名单排序建议。模型推理不涉及频繁更新用Flask或FastAPI封装几行代码就够用不需要上完整的机器学习平台。from flask import Flask, request, jsonify import joblib import pandas as pd app Flask(__name__) model joblib.load(bank_model.joblib) app.route(/predict, methods[POST]) def predict(): data request.get_json(forceTrue) df_input pd.DataFrame([data]) # 对应特征工程部分保持处理逻辑一致 proba model.predict_proba(df_input)[0][1] return jsonify({subscribe_probability: round(proba, 4)}) if __name__ __main__: app.run(host0.0.0.0, port5000)逻辑说明joblib.load加载的是GridSearchCV选出的最优Pipeline输入一个 JSON 对象就能输出预测概率。重点是这里的df_input列名必须和训练时的X完全一致否则Pipeline里的ColumnTransformer会报特征缺失错误。参数说明forceTrue表示强制按 JSON 解析请求体实际生产环境建议去掉让接口在请求格式错误时返回可读的 400 错误方便联调排错。模型上线后我一般会在测试集上留一份预测结果与线上请求抽样比对概率分布漂移超过 0.1 就触发重新训练流程。到这一步整个项目的源码路径基本完整了原始数据加载、特征工程、模型训练、阈值调优、轻量部署验证。回头看最值得反复打磨的还是那段特征工程和阈值选择逻辑模型算法本身反而是相对标准化的环节。建议拿到源码后不要急着跑通全部代码先把duration泄漏、类别不平衡、验证集污染这三个问题在代码里找到对应处理位置能说清楚这三个地方才是真的把这个项目吃透了。希望这篇笔记能帮你在自己的环境里复现出靠谱的基线结果。本文还有配套的精品资源点击获取