简介:这份资源面向希望进入金融风控与客户行为预测领域的数据科学学习者,提供一套完整的银行客户认购产品预测实战方案。项目以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(normalize=True)) # 4. 数值字段描述统计,重点看 pdays 的 -1 和 balance 的负值 print(df[["age", "balance", "duration", "campaign", "pdays", "previous"]].describe()) # 5. 类别字段的取值数量,判断哪些需要合并稀有类别 for col in df.select_dtypes(include="object").columns: print(col, df[col].nunique())这段脚本的逻辑是层层递进的:先确认数据规模,再排查缺失,然后看目标比例决定评估策略,最后通过描述统计和取值数量找出需要特殊处理的字段。参数上,sep=";"必须写对,否则整张表会读成一列;value_counts(normalize=True)返回的是比例而非计数,方便直接判断不平衡程度。
跑完你会注意到几个关键点: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 再编码。
第二类是有序字段,比如 education(primary < 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(categories=edu_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"], bins=bins, labels=labels) # 往期接触拆成两个特征 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(1+x),避免 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_unknown="ignore"), cat_cols), ]) pipe = Pipeline([ ("prep", preprocessor), ("clf", RandomForestClassifier(n_estimators=300, random_state=42)), ]) pipe.fit(X_train, y_train)ColumnTransformer的好处是每个字段走哪条处理路径一目了然,handle_unknown="ignore"保证预测时遇到训练集没见过的类别不会报错,而是编码成全零。random_state=42固定随机种子,保证结果可复现。整条 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_splits=5, shuffle=True, random_state=42) lr_pipe = Pipeline([ ("prep", preprocessor), ("clf", LogisticRegression(max_iter=1000, class_weight="balanced")), ]) scores = cross_val_score(lr_pipe, X_train, y_train, cv=cv, scoring="roc_auc") print("LR AUC: %.4f (+/- %.4f)" % (scores.mean(), scores.std()))class_weight="balanced"让逻辑回归自动按类别频率反比加权,缓解不平衡问题。max_iter=1000是因为标准化后的数据收敛慢,默认 100 次往往不收敛会报警告。StratifiedKFold保证每折正样本比例一致,这对不平衡数据是必须的。基线 AUC 通常在 0.88 到 0.90 之间,如果明显低于这个数,先回头查特征工程,别急着换模型。
4.2 随机森林与 XGBoost 的参数怎么设
树模型在这类表格数据上通常比线性模型强,但参数不调好也白搭。随机森林重点调三个:树的数量、最大深度、叶节点最小样本数。XGBoost 重点调学习率、树深度、正则项。
from xgboost import XGBClassifier xgb_pipe = Pipeline([ ("prep", preprocessor), ("clf", XGBClassifier( n_estimators=500, learning_rate=0.05, max_depth=5, subsample=0.8, colsample_bytree=0.8, reg_lambda=1.0, scale_pos_weight=8, # 约等于负正样本比,缓解不平衡 eval_metric="auc", random_state=42, )), ]) scores = cross_val_score(xgb_pipe, X_train, y_train, cv=cv, scoring="roc_auc") print("XGB AUC: %.4f (+/- %.4f)" % (scores.mean(), scores.std()))参数逐个说:learning_rate=0.05配n_estimators=500是稳妥组合,学习率低就需要更多树,但泛化更好;max_depth=5控制单棵树复杂度,太深容易过拟合;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, cv=cv, scoring="roc_auc", n_jobs=-1, verbose=1, ) 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 核,verbose=1打印进度。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,别用准确率。在代码里把scoring="roc_auc"写死,避免有人手滑改成默认的 accuracy。
5.4 交叉验证没分层,某折正样本几乎为零
现象:五折交叉验证的 AUC 方差极大,有的折 0.95,有的折 0.75。
原因:用了普通 KFold,随机切分时某折可能只分到极少数正样本,指标自然不稳。
解决:一律用StratifiedKFold,它保证每折的正负样本比例与整体一致。这个坑在小数据集上尤其明显,四万多条看着不少,但正样本只有五千,切分不当很容易出问题。
5.5 训练集和测试集用了不同的编码器
现象:离线评估很好,保存模型后重新加载做预测,报错说特征维度不匹配或类别未知。
原因:预处理和模型分开保存,或者预测时重新 fit 了编码器,导致类别映射和训练时不一致。
解决:把整个 pipeline(含 ColumnTransformer 和编码器)一起用 joblib 保存,预测时加载同一个对象。OneHotEncoder(handle_unknown="ignore")也要加上,防止新数据出现训练集没有的类别时直接崩。
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_call=5, profit_per_success=200) best = max(curve, key=lambda 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,线下指标漂亮得不像话,上线才发现全是幻觉。后来养成习惯,任何特征进模型前先问一句:预测那一刻,这个值真的拿得到吗?拿不到就删,别犹豫。希望帮到你。
本文还有配套的精品资源,点击获取