ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python机器学习实战:银行电话营销定期存款预测模型

Python机器学习实战:银行电话营销定期存款预测模型 简介这份资源面向希望进入金融风控与客户行为预测领域的数据科学学习者提供一套完整的银行客户认购产品预测实战方案。项目以Python为工具围绕客户年龄、职业、收入、历史营销记录等特征构建从数据清洗、特征工程到模型训练与评估的全流程帮助读者理解哪些因素驱动客户订阅金融产品。压缩包共68个文件、约9.4MB包含5个py脚本、3个ipynb笔记本、5个csv数据集、2个pkl模型文件以及43张png可视化图表和4个md说明文档覆盖数据探索、模型对比、结果输出等环节。已有674人学习下载。读者可获得可复现的源码与训练好的模型直接用于预测新客户认购概率同时通过字段说明、特征分布图和模型日志掌握逻辑回归、随机森林、XGBoost等算法的调优思路与评估方法适合作为课程设计或求职项目的参考案例。1. 银行电话营销名单里为什么 90% 的客户根本不会买银行零售条线每年花在电话营销上的成本不低但接通后真正认购定期存款的客户往往不到一成。问题不在话术而在名单——把同一套说辞砸给全部客户命中率必然被稀释。这个项目要解决的就是这件事用 Python 和机器学习基于银行历史营销数据预测哪些客户更可能认购定期存款产品把有限的外呼坐席压在概率最高的那批人身上。它属于典型的二分类预测问题输入是客户画像年龄、职业、婚姻、教育、账户状态房贷、违约记录、以及本次营销的接触信息接触方式、月份、通话时长、往期营销次数输出是 yes/no。适合三类人想找一个完整机器学习落地案例练手的 Python 学习者需要给营销系统加一层评分能力的银行数据从业者以及手上有一份业务数据、想知道从原始表到可上线模型要经过哪些环节的工程师。源码、数据集、模型文件三件套齐全意味着你能从读数据一路跑到加载模型做预测而不是停在调包那一行。2. 先看清数据长什么样字段、分布与目标变量2.1 银行营销数据集的字段构成与业务含义这份数据通常来自葡萄牙某银行的直接营销活动记录一行代表一次电话接触约四万五千条。字段可以分成四组来理解分组的意义在于后面做特征工程时不同组的处理方式完全不同。分组代表字段类型处理要点客户画像age, job, marital, education数值类别类别字段需编码job 有十几种取值账户状态default, housing, loan, balance类别数值default 极度不平衡需谨慎处理本次接触contact, month, day, duration类别数值duration 泄漏风险最高见第 5 章往期记录campaign, pdays, previous, poutcome数值类别pdays 的 -1 是特殊值不是缺失目标变量 y 只有两个取值 yes 和 no且 no 占绝大多数正样本比例通常在 11% 上下。这个比例决定了后面所有评估指标的选择——准确率在这里基本没有参考价值一个全预测 no 的模型也能拿到 88% 以上的准确率但它对业务毫无用处。提示拿到数据第一件事不是建模是把每个字段的业务含义问清楚。duration 是通话时长只有通话结束后才知道如果用它训练再拿去预测就是典型的标签泄漏。2.2 用 pandas 做一次完整的数据体检在写任何模型代码之前先跑一遍数据体检脚本把缺失、类型、分布、目标比例一次性看清楚。这一步省掉后面调参就是盲调。import pandas as pd import numpy as np # 读取数据分隔符是分号这是该数据集常见的存储格式 df pd.read_csv(bank-full.csv, sep;) # 1. 基本信息行列数、字段类型 print(shape:, df.shape) print(df.dtypes) # 2. 缺失值检查这份数据表面无缺失但存在伪装成取值的缺失 print(null count:\n, df.isnull().sum()) # 3. 目标变量分布确认不平衡程度 print(target ratio:\n, df[y].value_counts(normalizeTrue)) # 4. 数值字段描述统计重点看 pdays 的 -1 和 balance 的负值 print(df[[age, balance, duration, campaign, pdays, previous]].describe()) # 5. 类别字段的取值数量判断哪些需要合并稀有类别 for col in df.select_dtypes(includeobject).columns: print(col, df[col].nunique())这段脚本的逻辑是层层递进的先确认数据规模再排查缺失然后看目标比例决定评估策略最后通过描述统计和取值数量找出需要特殊处理的字段。参数上sep;必须写对否则整张表会读成一列value_counts(normalizeTrue)返回的是比例而非计数方便直接判断不平衡程度。跑完你会注意到几个关键点pdays 的中位数是 -1意思是从未被联系过这不是缺失值而是业务状态不能简单填充balance 存在负值代表客户透支是有效信息duration 的分布极度右偏少数超长通话拉高了均值。这些观察直接决定第 3 章特征工程怎么写。2.3 目标变量不平衡带来的评估陷阱正样本只有约 11%如果按常规做法用准确率选模型会选出全部预测为 no的废模型。正确做法是换指标用 ROC-AUC 看排序能力用 recall 和 precision 看具体业务取舍用 F1 做综合平衡。这里有个业务判断必须先做银行外呼的成本是坐席时间漏掉一个会买的客户假阴性和打扰一个不会买的客户假阳性代价不一样。如果坐席充足优先保 recall宁可多打几个也别漏如果坐席紧张优先保 precision把名单压到最短。这个取舍不是技术问题是业务问题建模前就要和业务方对齐否则后面调阈值没有依据。from sklearn.metrics import roc_auc_score, classification_report, confusion_matrix # 假设 y_true 是真实标签y_prob 是模型输出的正类概率 # 阈值 0.5 只是默认值实际应按业务取舍调整 y_pred (y_prob 0.5).astype(int) print(AUC:, roc_auc_score(y_true, y_prob)) print(confusion_matrix(y_true, y_pred)) print(classification_report(y_true, y_pred))AUC 衡量的是模型把正样本排在负样本前面的能力与阈值无关适合先做模型间横向对比。确定模型后再用混淆矩阵和分类报告看具体阈值下的表现。注意classification_report里的 support 列正样本那一行的 support 只有几千任何指标波动都会被放大交叉验证时要用分层抽样保证每折的正样本比例一致。3. 从原始表到模型输入特征工程与预处理流水线3.1 类别字段编码独热、序数还是目标编码这份数据里类别字段占了一半以上编码方式选错模型效果会差一大截。我的经验是按字段性质分三类处理。第一类是纯名义字段比如 job、marital、contact、poutcome取值之间没有大小关系用独热编码。但 job 有十二种取值独热后维度膨胀稀有类别如 unknown、student样本极少可以合并成 other 再编码。第二类是有序字段比如 educationprimary secondary tertiary用序数编码保留顺序信息比独热更省维度也更合理。month 虽然看着是类别但月份有天然顺序可以映射成 1 到 12 的数值让模型捕捉季节性。第三类是二值字段default、housing、loan 都是 yes/no直接映射成 0/1 即可不需要独热。from sklearn.preprocessing import OrdinalEncoder # 有序字段显式指定顺序避免编码器按字母序乱排 edu_order [[primary, secondary, tertiary]] ord_enc OrdinalEncoder(categoriesedu_order) df[education_ord] ord_enc.fit_transform(df[[education]]) # 二值字段yes/no 映射为 1/0 for col in [default, housing, loan, y]: df[col] df[col].map({yes: 1, no: 0}) # 月份映射为数值保留时间顺序 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)OrdinalEncoder的categories参数必须显式传入否则它会按字母顺序排成 primary、secondary、tertiary 之外的顺序把业务含义搞反。二值映射用map比LabelEncoder更可控因为你能明确知道哪个值变成了 1。3.2 数值字段的离散化与业务衍生特征原始数值字段直接丢给模型不是不行但加几个衍生特征往往能带来明显提升。这里说三个我验证过有效的。第一个是年龄分箱。age 从 18 到 95线性关系不明显青年、中年、老年的认购倾向差异很大。用业务经验切成 18-30、31-45、46-60、60 四段比原始连续值更稳。第二个是往期接触特征。pdays 为 -1 表示从未联系其余表示距上次联系的天数。可以拆成两个特征一个二值was_contacted_before一个数值days_since_last-1 的填成 0 或中位数。这样模型能分别学到是否联系过和联系间隔多久两个维度的信息。第三个是通话时长分箱。duration 右偏严重取对数后再分箱或者直接按分位数切。但记住第 5 章的警告这个特征在真实预测场景里用不了。# 年龄分箱 bins [17, 30, 45, 60, 100] labels [young, mid, senior, elder] df[age_group] pd.cut(df[age], binsbins, labelslabels) # 往期接触拆成两个特征 df[was_contacted_before] (df[pdays] ! -1).astype(int) df[days_since_last] df[pdays].where(df[pdays] ! -1, 0) # 通话时长取对数压缩右偏 df[log_duration] np.log1p(df[duration])pd.cut的bins左开右闭17 到 30 这一段包含 30 不包含 17边界要想清楚。np.log1p等价于 log(1x)避免 duration 为 0 时报错。这些衍生特征做完后原始字段可以保留也可以丢弃取决于模型类型——树模型对冗余特征不敏感线性模型则建议做特征选择。3.3 用 Pipeline 把预处理和模型绑在一起预处理步骤如果散落在脚本各处换一份数据或做交叉验证时极易出错。正确做法是用 sklearn 的 Pipeline 和 ColumnTransformer 把编码、缩放、模型串成一条流水线训练和预测走同一套逻辑。from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.ensemble import RandomForestClassifier # 按类型分组指定处理方式 num_cols [age, balance, campaign, previous, days_since_last, log_duration] cat_cols [job, marital, contact, poutcome, age_group] preprocessor ColumnTransformer([ (num, StandardScaler(), num_cols), (cat, OneHotEncoder(handle_unknownignore), cat_cols), ]) pipe Pipeline([ (prep, preprocessor), (clf, RandomForestClassifier(n_estimators300, random_state42)), ]) pipe.fit(X_train, y_train)ColumnTransformer的好处是每个字段走哪条处理路径一目了然handle_unknownignore保证预测时遇到训练集没见过的类别不会报错而是编码成全零。random_state42固定随机种子保证结果可复现。整条 pipeline 可以直接丢进cross_val_score不会出现数据泄漏因为缩放和编码只在训练折上 fit。4. 模型选型与调参从逻辑回归到 XGBoost 的实测对比4.1 为什么先跑逻辑回归做基线很多人一上来就上 XGBoost结果调了半天不知道提升来自模型还是来自特征。我的习惯是先跑一个逻辑回归基线它训练快、可解释、系数能直接看方向用来验证特征工程有没有做对。from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score, StratifiedKFold cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) lr_pipe Pipeline([ (prep, preprocessor), (clf, LogisticRegression(max_iter1000, class_weightbalanced)), ]) scores cross_val_score(lr_pipe, X_train, y_train, cvcv, scoringroc_auc) print(LR AUC: %.4f (/- %.4f) % (scores.mean(), scores.std()))class_weightbalanced让逻辑回归自动按类别频率反比加权缓解不平衡问题。max_iter1000是因为标准化后的数据收敛慢默认 100 次往往不收敛会报警告。StratifiedKFold保证每折正样本比例一致这对不平衡数据是必须的。基线 AUC 通常在 0.88 到 0.90 之间如果明显低于这个数先回头查特征工程别急着换模型。4.2 随机森林与 XGBoost 的参数怎么设树模型在这类表格数据上通常比线性模型强但参数不调好也白搭。随机森林重点调三个树的数量、最大深度、叶节点最小样本数。XGBoost 重点调学习率、树深度、正则项。from xgboost import XGBClassifier xgb_pipe Pipeline([ (prep, preprocessor), (clf, XGBClassifier( n_estimators500, learning_rate0.05, max_depth5, subsample0.8, colsample_bytree0.8, reg_lambda1.0, scale_pos_weight8, # 约等于负正样本比缓解不平衡 eval_metricauc, random_state42, )), ]) scores cross_val_score(xgb_pipe, X_train, y_train, cvcv, scoringroc_auc) print(XGB AUC: %.4f (/- %.4f) % (scores.mean(), scores.std()))参数逐个说learning_rate0.05配n_estimators500是稳妥组合学习率低就需要更多树但泛化更好max_depth5控制单棵树复杂度太深容易过拟合subsample和colsample_bytree都是 0.8引入随机性防过拟合reg_lambda是 L2 正则scale_pos_weight设成负正样本比约 8让正样本的损失权重更大。这套参数在验证集上 AUC 通常能到 0.92 以上。注意scale_pos_weight调太大会让 precision 暴跌模型变得过度激进。建议从样本比开始上下浮动试两三个值看业务指标而不是只看 AUC。4.3 用网格搜索找关键参数别全参数一起搜全参数网格搜索的组合数是乘积跑一晚上都跑不完。正确做法是挑两三个影响最大的参数做小网格其余用经验值固定。from sklearn.model_selection import GridSearchCV param_grid { clf__max_depth: [3, 5, 7], clf__learning_rate: [0.03, 0.05, 0.1], clf__n_estimators: [300, 500], } grid GridSearchCV( xgb_pipe, param_grid, cvcv, scoringroc_auc, n_jobs-1, verbose1, ) grid.fit(X_train, y_train) print(best params:, grid.best_params_) print(best AUC: %.4f % grid.best_score_)注意参数名要带clf__前缀因为它们在 pipeline 的第二步里。n_jobs-1用满所有 CPU 核verbose1打印进度。3×3×2 共 18 组每组 5 折总共 90 次训练XGBoost 在这个数据量下几分钟能跑完。搜完之后用grid.best_estimator_拿到最优模型直接保存。5. 避坑与排查这份数据里最容易翻车的五个地方5.1 把 duration 当特征线下 AUC 虚高线上崩盘现象交叉验证 AUC 高达 0.93模型上线后实际命中率却和随机差不多。原因duration 是通话时长只有通话结束才知道。用它训练模型学到的是通话久的人更容易买但预测时你根本不知道这通电话会打多久等于用未来信息预测现在。这是最典型的标签泄漏。解决训练阶段就把 duration 及其衍生特征全部剔除重新评估。剔除后 AUC 通常会掉到 0.88 到 0.90这才是真实水平。如果业务上确实想用通话行为只能做实时评分在通话过程中动态更新不能进离线训练集。5.2 pdays 的 -1 被当成缺失值填充现象用均值或中位数填充 pdays 后模型对从未联系过的客户判断失准。原因-1 在这份数据里是明确的业务状态表示此前从未被营销过不是数据缺失。填充成均值等于把新客户和老客户混为一谈。解决按 3.2 节的做法拆成was_contacted_before和days_since_last两个特征让模型分别学习。填充前一定要先看字段的业务文档别看到负数就条件反射当异常值处理。5.3 用准确率选模型选出全预测 no 的废模型现象某个模型准确率 0.89比另一个 0.87 的高但上线后一个客户都没识别出来。原因正样本只占 11%全预测 no 就有 89% 准确率。准确率在不平衡数据上完全失效。解决统一用 ROC-AUC 做模型选择用 recall、precision、F1 做业务评估。如果业务方只认一个数用 F1 或 AUC别用准确率。在代码里把scoringroc_auc写死避免有人手滑改成默认的 accuracy。5.4 交叉验证没分层某折正样本几乎为零现象五折交叉验证的 AUC 方差极大有的折 0.95有的折 0.75。原因用了普通 KFold随机切分时某折可能只分到极少数正样本指标自然不稳。解决一律用StratifiedKFold它保证每折的正负样本比例与整体一致。这个坑在小数据集上尤其明显四万多条看着不少但正样本只有五千切分不当很容易出问题。5.5 训练集和测试集用了不同的编码器现象离线评估很好保存模型后重新加载做预测报错说特征维度不匹配或类别未知。原因预处理和模型分开保存或者预测时重新 fit 了编码器导致类别映射和训练时不一致。解决把整个 pipeline含 ColumnTransformer 和编码器一起用 joblib 保存预测时加载同一个对象。OneHotEncoder(handle_unknownignore)也要加上防止新数据出现训练集没有的类别时直接崩。import joblib # 保存整个 pipeline不是只保存模型 joblib.dump(grid.best_estimator_, bank_model.pkl) # 加载后直接 predict预处理自动走同一套逻辑 model joblib.load(bank_model.pkl) y_prob model.predict_proba(X_new)[:, 1]6. 让模型真正能用阈值调优与评分卡落地模型训练完 AUC 0.92不代表就能直接上线。业务方要的不是概率是一份能直接外呼的名单这就涉及阈值怎么定、名单怎么排序、效果怎么验证。先说阈值。默认 0.5 只是数学上的中点不是业务最优。正确做法是画一条阈值-收益曲线横轴是阈值纵轴是不同阈值下的预期收益。假设每成功一单利润是 L每次外呼成本是 C那么预期收益 命中数 × L - 外呼总数 × C。把这条曲线画出来取收益最高的那个阈值。import numpy as np def profit_curve(y_true, y_prob, cost_per_call, profit_per_success): thresholds np.arange(0.05, 0.95, 0.01) results [] for t in thresholds: pred (y_prob t).astype(int) calls pred.sum() hits ((pred 1) (y_true 1)).sum() profit hits * profit_per_success - calls * cost_per_call results.append((t, calls, hits, profit)) return results # 假设单次外呼成本 5 元成功一单利润 200 元 curve profit_curve(y_test, y_prob, cost_per_call5, profit_per_success200) best max(curve, keylambda x: x[3]) print(最优阈值 %.2f外呼 %d 次命中 %d 单预期收益 %d 元 % best)这段代码的逻辑是把每个候选阈值下的外呼量、命中量、净收益都算出来取收益最大的。参数cost_per_call和profit_per_success必须找业务方要真实数字拍脑袋填会让整个分析失去意义。跑完你往往会发现最优阈值远低于 0.5比如 0.2 左右因为漏掉一个客户的损失比多打一通电话大。再说名单排序。业务方坐席有限不可能按阈值一刀切完就打完所以要按预测概率从高到低排优先打头部。可以做一个累计增益图cumulative gains chart看前 10%、前 20% 的名单能覆盖多少正样本。如果前 20% 的名单能覆盖 60% 以上的认购客户这个模型就值得上。# 按概率降序排列计算累计命中率 order np.argsort(-y_prob) y_sorted y_true[order] cum_hits np.cumsum(y_sorted) / y_sorted.sum() cum_pop np.arange(1, len(y_sorted)1) / len(y_sorted) # 前 20% 名单的覆盖率 idx np.searchsorted(cum_pop, 0.2) print(前 20%% 名单覆盖了 %.1f%% 的认购客户 % (cum_hits[idx] * 100))最后说验证。模型上线不是终点要留一批近期数据做时间外验证——用较早月份的数据训练用较晚月份的数据测试。如果时间外 AUC 比交叉验证低超过 0.03说明数据分布随时间漂移模型需要定期重训。我一般会设一个月度重训任务用最近半年的数据滚动训练保证模型跟得上客户行为变化。这套流程走下来从原始 CSV 到一份可外呼的评分名单核心工作量在特征工程和阈值调优模型本身反而是最标准化的部分。我自己踩过最深的坑就是一开始没剔 duration线下指标漂亮得不像话上线才发现全是幻觉。后来养成习惯任何特征进模型前先问一句预测那一刻这个值真的拿得到吗拿不到就删别犹豫。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进