简介:这是一份面向计算机相关专业毕业设计及课程设计的机器学习项目资源,完整实现了银行客户认购定期存款产品的预测流程。项目以银行营销数据集为基础,涵盖数据探索可视化、特征工程、模型训练与结果提交等环节,适合需要从零搭建分类模型的本科学生或练习者。资源共66个文件,包含43张数据分布与正样本特征图、5个Python脚本、5个CSV数据文件、3个Jupyter Notebook以及训练好的pkl模型和JSON日志等,zip压缩包仅9.39MB,结构清晰便于按模块学习,其中可视化图表覆盖年龄、婚姻、联系方式等关键维度,方便理解数据分布。目前已有481人学习下载,经过严格调试可直接运行。使用该资源可掌握预处理管道构建、多版本模型迭代与结果对比的完整思路,模型文件与预测输出可直接用于毕设报告和答辩演示。
1. 银行客户认购产品预测这个项目,为什么值得用Python完整做一遍
银行客户认购产品预测是机器学习在金融营销里最常被拿来做练手的场景之一。一个完整的 Python 实现,通常包含客户档案、历史营销记录,以及“是否购买某个金融产品”的标签,训练一个分类模型,最后把模型文件拿去生成可拨打的名单。只要把目标字段从定期存款换成理财、贷款或保险,这套流程几乎可以复用。适合想从“调包”走向“完整项目”的数据分析师、金融从业者和课程设计的学生,也适合第一次面对不平衡样本和模型交付的人。
这类项目的门槛不在算法,而在数据和评估口径。数据里往往会有大量“未认购”样本,少数“认购”样本才是利润来源;简单用准确率会得到虚假信心。下面会一起处理标签定义、特征工程、模型选型和模型文件管理,并给出几段可以直接跑的Python代码。
2. 先把银行客户数据变成模型能吃的样本:标签、清洗与划分
2.1 经典客户营销表里,哪些字段是标签,哪些藏着未来信息
常见做法是,用银行电话营销的客户数据来演示,目标字段是客户是否认购定期存款。原始表一般长这样:age、job、marital、education、default、balance、housing、loan,这组字段描述客户基本财务状况;contact、day、month、duration、campaign、pdays、previous、poutcome 描述这次电话营销怎么打、前几次打得怎么样;最后一列 y 是标签,yes 代表认购。字段名可能随业务改,但逻辑一致。
拿到数据第一件事是看标签分布。用 pandas 读入并统计:
import pandas as pd df = pd.read_csv('bank.csv', sep=';') print(df.shape) print(df['y'].value_counts(normalize=True))逻辑说明:sep=';' 是因为很多银行数据集用分号而不是逗号。value_counts(normalize=True)给出各类占比,能立刻看清正样本是不是稀有类。我一般会在这一步顺便看df.info(),确认有没有缺失值、类型有没有被读错。
参数说明:如果你的数据文件是逗号分隔,把 sep 改成 ',' 就好。如果字段名带空格,可以先看前两行再决定是否要skipinitialspace=True。这一步不做模型参数,但决定后面的 Pipeline 能不能跑通。
真正容易翻车的是字段的“时序身份”。duration 是这次通话的持续秒数,它在通话结束前拿不到;如果模型要预测的是“营销时谁会买”,把 duration 丢进特征等于偷看答案。很多银行项目线上效果不如回测,问题就出在这种字段。我会先建立一个建模时点假设:只在客户成为本次营销名单时已知的信息才允许进特征,因此 duration 不作为常规特征使用;如果要保留,必须在特征工程里定义成“事后已知”并明确告诉业务方。
注意:duration 这类“事后才知道”的字段,和标签一样是未来信息。用它做回测能得到漂亮曲线,上线后业务方拿不到,预测就失真。
2.2 用 pandas 清洗并用分层抽样划分:代码与参数说明
接下来把标签转成 0/1,去掉无关主键,然后划分训练集和测试集。代码骨架如下:
# 标签从 yes/no 转成 1/0 df['target'] = (df['y'] == 'yes').astype(int) df = df.drop(columns=['y']) # 建模特征与标签分离 X = df.drop(columns=['target']) y = df['target'] # 分层抽样,保证训练集和测试集的正样本比例一致 from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) print(f"训练集正样本占比: {y_train.mean():.3f}") print(f"测试集正样本占比: {y_test.mean():.3f}")逻辑说明:stratify=y是必须写的参数。银行营销数据的认购比例往往只有 10% 到 15%,如果不分层,随机切分可能让测试集正样本只有几个,评估曲线全是锯齿。random_state=42固定随机种子,便于复现;不要每次跑得到不同结果再怀疑代码。
参数说明:test_size=0.2 表示留 20% 做最终评估。对于四万行样本已经够用;如果遇到百万级名单,可以保持 0.2 但提示自己,评估指标要按业务分层再看,后面会提到。
这里不急着把模型丢进来,先把正样本占比记下来。比例低不是错误,而是后面所有评估方式的前提:准确率在这里是陷阱,因为“全预测不认购”也有接近 90% 的准确率。真正要盯的是 AUC、召回率、精确率,以及业务侧更关心的“一通电话的转化成本”。我在实际项目中会先把基线结果打出来,用 DummyClassifier 判断特征到底有没有信息量:
from sklearn.dummy import DummyClassifier from sklearn.metrics import f1_score dummy = DummyClassifier(strategy='most_frequent') dummy.fit(X_train, y_train) y_pred = dummy.predict(X_test) print("dummy f1:", f1_score(y_test, y_pred))逻辑说明:strategy='most_frequent' 的 Dummy 会永远预测训练集中最多的类别,也就是“不认购”。如果某个特征工程后的模型 F1 还不如它,说明模型没有学到有意义的模式。因为无法输出概率,我不对 Dummy 计算 AUC,只用它作为最底线。之后上真实模型时再用 predict_proba。
这一步还应该处理重复行和明显缺失。常见做法是你自己写一个清洗函数:
# 去重、检查空值、丢弃无业务意义的流水号 df = df.drop_duplicates().reset_index(drop=True) print(df.isnull().sum().sum()) df = df.drop(columns=['id'], errors='ignore')逻辑说明:drop_duplicates()能清掉完全相同的营销记录,避免训练集和测试集里同时出现同一条数据,形成轻度数据泄露。isnull().sum().sum()只看全表缺失数,不够细,但足以决定下一步是否要接 SimpleImputer。
参数说明:errors='ignore' 让 drop 操作在列不存在时不报错,适合写通用脚本。实际银行数据里很多“缺失”并不是空值,而是用 -1、999、unknown 这种占位符,比如下面的 pdays,需要在特征工程里单独处理。
3. 银行客户认购特征工程:编码方式、业务衍生与管道固化
3.1 类别特征别盲目 One-Hot:职业、月份和教育水平怎么处理
银行数据里类别字段不少:job、marital、education、contact、month、poutcome。职业字段基数不小,如果贪图简单全部 One-Hot,特征会变得很宽;对逻辑回归来说可以接受,对随机森林和 LightGBM 来说,太多稀疏列反而稀释特征重要性。
通常我不直接对所有类别列 OneHot,而是分组处理:
- 低基数且无顺序的字段,如 contact,用 OneHot;
- 高基数字段,如 job,可以用 OneHot 加高频类别合并,或者改用目标编码;
- 有顺序语义的字段,如 education,可映射成学历等级;
- month 是周期变量,适合做 sin/cos 变换。
from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.compose import ColumnTransformer num_cols = ['age', 'balance', 'loan_num', 'campaign', 'pdays_recent'] cat_cols = ['job', 'marital', 'contact', 'poutcome'] preprocessor = ColumnTransformer([ ('num', StandardScaler(), num_cols), ('cat', OneHotEncoder(handle_unknown='ignore'), cat_cols) ])逻辑说明:ColumnTransformer 会分别对数值列和类别列做变换,之后再拼接成模型输入。handle_unknown='ignore'很关键,它解决的是未来新数据里出现训练集没见过的类别时,OneHot 不会报错而是全 0。银行名单会不断新增职业类型,这个参数能避免上线时崩掉。
参数说明:StandardScaler 是逻辑回归的必需品,对树模型不是必须;但放进去不会坏事。如果你的特征里包含大量 0 值或者长尾分布,可以先用FunctionTransformer(np.log1p)把正偏态数值特征压缩,再进 StandardScaler,这样对 balance、pdays 这类字段更稳。我在实际项目里习惯把loan_num这类衍生列先算好,再交给管线。
3.2 从历史营销次数和月份里构造业务特征:pdays 的 999 陷阱
银行营销数据里最容易误导模型的是 pdays。它表示这位客户上一次被营销后距离今天的天数,如果没有被营销过,数据里常常填 999。如果把 999 当普通数值交给模型,模型会学到“pdays=999 这个具体数”,而不是“从未联系过”这个状态。常见做法是拆成两个特征:是否从未联系过,以及距离天数。
import numpy as np # 先标记缺失和不适用 no_previous_contact = (df['pdays'] == 999).astype(int) # 再把999换成-1,让它和真实天数不在同一个量纲区间 pdays_recent = df['pdays'].replace(999, -1) df['no_previous_contact'] = no_previous_contact df['pdays_recent'] = pdays_recent逻辑说明:拆成两个特征后,逻辑回归可以学到“从未接触过会降低购买概率”,同时 pdays_recent 对接触过的人继续保留时间间隔信息。如果不拆,模型必须同时拟合“是否接触”和“接触多久”,信息容易纠缠。
参数说明:把 999 替换成 -1 是一个务实做法。它不假装模型理解缺失值,只是把不适用状态推到特征空间边缘。如果你用树模型,直接保留 999 也能跑,但解释性会变差;如果后续要出评分卡,尽量拆。
还可以把 campaign 和 previous 合起来看:campaign 是本次活动的第几次联系,previous 是活动前联系过多少次。对同一个客户联系太多次,通常是高拒绝率信号,但单纯放进两个数字不够直观,我会构造一个“联系密度”特征:
df['contact_pressure'] = df['campaign'] / (df['previous'] + 1)逻辑说明:除以历史次数加 1,避免除零。联系密度高表示本次营销推力过大,可能说明客户的兴趣在被消耗;这个特征在业务上可比单独 campaign 更容易解释。遇到线上效果不好时,这一列也可以直接用 SQL 统计回来,方便运营复核。
月份特征也值得处理。电话营销有明显的季节性,但直接把 'may' 映射成 5 会丢失周期关系。简单做法是保留原始类别交给 OneHot;更贴合业务的做法是做 sin/cos 周期编码:
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} month_num = df['month'].map(month_map).astype(float) df['month_sin'] = np.sin(2 * np.pi * month_num / 12) df['month_cos'] = np.cos(2 * np.pi * month_num / 12)逻辑说明:sin/cos 可以把 1 月和 12 月映射到相邻位置,避免线性编码带来的“12月比1月大”的假象。这类特征在树模型里不够直观,但对逻辑回归和评分卡场景很实用。
3.3 用 sklearn Pipeline 把预处理、训练、预测串成一条线
到这一步,特征已经有了几十列。最怕的是训练时处理一版特征,测试时再手工处理另一版,数量对不上。我一般会用 Pipeline 固定全流程,这样后续保存模型文件时,预处理器和模型会一起被持久化。
from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression num_cols = ['age', 'balance', 'campaign', 'pdays_recent', 'contact_pressure'] cat_cols = ['job', 'marital', 'education', 'contact', 'poutcome'] preprocessor = ColumnTransformer([ ('num', StandardScaler(), num_cols), ('cat', OneHotEncoder(handle_unknown='ignore'), cat_cols) ]) lr_pipeline = Pipeline([ ('prep', preprocessor), ('clf', LogisticRegression(max_iter=2000, class_weight='balanced')) ])逻辑说明:Pipeline 除了简化代码,还有一个关键好处是fit时只看到训练集,transform测试集时不会把测试集统计量泄漏进模型。StandardScaler 的均值和标准差都来自训练集,OneHot 的类别集合也来自训练集。这比手工在全体数据上做标准化更安全。
参数说明:class_weight='balanced' 会自动按样本比例放大少数类惩罚,逻辑回归用它比事后调阈值更省事。max_iter=2000 是因为 OneHot 后特征维度变大,默认 1000 偶尔不收敛。把prep和clf用双下划线分隔,后面做网格搜索时可以直接写clf__C这样的参数路径。
4. 候选模型怎么选、训练脚本怎么写、模型文件怎么存才不翻车
4.1 逻辑回归、随机森林、LightGBM 在银行认购场景的定位差异
银行客户认购预测很少有一上来就上深度模型的必要。以几万行结构化数据、几十个特征、以“可解释”和“可审计”为前提的场景,候选模型通常在这三个之间选。
逻辑回归的优点是系数直接对应业务含义,能解释“年龄每增加一岁,认购概率怎么变”。缺点是特征交互要靠手工做,线性边界不一定够。随机森林优点是可以自动抓到非线性关系,对异常值稳健;缺点是特征重要性不稳定,换一份数据排序会变,上级问起来不好交代。LightGBM 在近期项目里往往 AUC 最高,能处理高基数类别,但需要认真调参,过拟合后线上递减很快。
我通常用一份简单的对比表决定方向:
| 模型 | 适合情况 | 交付时要多做的事 |
|---|---|---|
| LogisticRegression | 需要评分卡、业务方要求可解释 | 标准化、分箱、系数产出 |
| RandomForest | 基线模型,快速验证特征有效性 | 检查随机种子、特征重要性 |
| LightGBM | 追求 AUC、特征多、样本足够 | 早停、NumLeaves 约束、版本管理 |
说明:这不是说 LightGBM 一定更好。银行场景经常要回答“为什么给这个客户打电话”,逻辑回归和评分卡更容易通过合规审查。如果时间只够跑一个模型,我会先用逻辑回归做一个精简单线,再快速看 LightGBM 有没有提升。
4.2 交叉验证下的训练骨架:网格搜索与参数怎么给
有了 Pipeline,训练脚本可以非常规整。以下是一个带网格搜索和 LightGBM 的骨架:
from sklearn.model_selection import StratifiedKFold, GridSearchCV from lightgbm import LGBMClassifier lgbm_pipeline = Pipeline([ ('prep', preprocessor), ('clf', LGBMClassifier( n_estimators=300, learning_rate=0.05, class_weight='balanced', random_state=42, verbosity=-1 )) ]) param_grid = { 'clf__num_leaves': [15, 31, 63], 'clf__min_child_samples': [20, 50, 100], 'clf__max_depth': [-1, 8, 12] } cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) grid = GridSearchCV( lgbm_pipeline, param_grid, cv=cv, scoring='roc_auc', n_jobs=-1, verbose=1 ) grid.fit(X_train, y_train) print(grid.best_params_)逻辑说明:StratifiedKFold 和前面 train_test_split 的理由一样,都是为了在每折里保持正样本比例。scoring='roc_auc' 是银行认购这类不平衡分类的首选评估,它只关心排序质量,不受 0.5 阈值影响。
参数说明:num_leaves 是 LightGBM 最需要控的过拟合旋钮,15 到 63 已经覆盖常见范围。min_child_samples 控制在 20 到 100,防止叶子节点只用几十个样本。max_depth=-1 表示不限制,但配合 num_leaves 不会太深。learning_rate=0.05 配 n_estimators=300 是一个保守起步;如果你用默认 0.1,建议把 n_estimators 降低,否则训练时间变长而且容易过拟合。
网格搜索跑完后,我会看一眼测试集 AUC:
from sklearn.metrics import roc_auc_score y_test_proba = grid.predict_proba(X_test)[:, 1] print("test auc:", roc_auc_score(y_test, y_test_proba))逻辑说明:predict_proba返回的是两个类别概率,取第二列是“认购”概率。评估只用测试集,不要用测试集参与调参。如果训练 AUC 很高而测试 AUC 明显下滑,优先回到 num_leaves 和 min_child_samples 去约束模型复杂度。
4.3 把模型文件、预处理器和特征列表一起保存
标题里既然有“模型文件”,这一节就特别值得说清楚。很多人只保存一个模型对象,结果新环境加载后报特征数量不一致,或者找不到类别编码中的某一列。正确做法是把整个 Pipeline 保存成一个 joblib 文件,特征逻辑跟着走。
import joblib import os os.makedirs('model', exist_ok=True) joblib.dump(grid.best_estimator_, 'model/bank_pipeline.joblib')逻辑说明:dump 的是整个 Pipeline 而不是里面某个分类器。这样加载后直接predict,不需要手工再拼一遍特征。模型文件大小通常几十到几百 MB,如果你发现文件大得离谱,检查是不是把训练 DataFrame 也放进了对象里。
loaded_pipeline = joblib.load('model/bank_pipeline.joblib') y_scores = loaded_pipeline.predict_proba(new_data)[:, 1]参数说明:joblib 比 pickle 更适合存 numpy 对象,而且它按数组分块压缩。要保证能 load,需要在同一套 Python 版本、scikit-learn 版本和 LightGBM 版本环境里。这个坑在银行客户预测项目里非常常见,下一章专门说。
5. 银行客户认购预测避坑:从数据泄露到阈值幻觉的5个真实踩坑记录
5.1 AUC 高但线上转化没涨:未来字段泄露与业务指标错配
现象:训练测试 AUC 都是 0.88,模型在回测里表现亮眼,但业务人员按预测名单打电话,转化率并没有提升。
原因:最常见的是把 duration 这种通话结束才有的字段放进了特征。回测时这些字段存在,但营销前根本拿不到,等于模型偷看了答案。另一个常见原因是用准确率做评估,AUC 高不代表你去掉的低分客户真的是低分。
解决:回到建模时点,把事后才知道的字段从特征里删除,或者明确标注为“通话后模型”。如果业务要事前名单,只能使用年龄、资产、历史接触次数这类历史数据。把 AUC 和“名单 Top10% 的转化率”一起看,比单看 AUC 更贴近银行营销目标。
5.2 预测结果全是“不认购”:类别不平衡与固定阈值
现象:模型训练正常,预测时发现概率超过 0.5 的样本几乎没有,业务拿到名单只有几十个人,根本没法外呼。原因:正样本占比只有 10% 左右,模型学习到的先验概率很低,默认阈值 0.5 对这类场景过高。
解决:调阈值,而不是改模型。对测试集算 precision_recall_curve,选择 F1 最高点或者业务成本最低点作为 cutoff。示例:
from sklearn.metrics import precision_recall_curve precision, recall, thresholds = precision_recall_curve( y_test, y_test_proba ) f1_scores = 2 * precision * recall / (precision + recall + 1e-9) best_idx = f1_scores.argmax() best_threshold = thresholds[best_idx] print("best threshold:", best_threshold)逻辑说明:precision_recall_curve返回随阈值变化的精确率和召回率,F1 最高点就是平衡两者的经验选择。实际业务里,如果外呼成本低而认购利润高,可以把阈值往下压,保证召回多数潜在客户;如果外呼容易引起投诉,就把阈值上调。
参数说明:1e-9只是防止除零。拿到 best_threshold 后,还要在业务样本上验证 Top 比例,比如 5% 或 10% 名单覆盖了多少正样本,不要只看 F1。y_test_proba必须来自测试集或线上验证集的模型输出,不能用训练集概率,否则阈值会被高估。
5.3 测试集特征数量报错:手工特征工程没有纳入管道
现象:训练时模型好好能跑,测试或新数据预测时报错,提示期待 X 个特征,实际输入 Y 个特征。原因:特征工程全部手工写,训练集多算了一列,测试时漏算或顺序不一致;或者 OneHot 编码时训练集和测试集类别不同,新类别变成新列。
解决:把预处理集中到 ColumnTransformer;新增特征也写成一个FunctionTransformer或普通函数放进 Pipeline。这样所有数据处理逻辑随模型文件一起保存,不会再出现“测试集少一列”的问题。调试时可以用loaded_pipeline[:-1].transform(new_data)检查转换后的特征形状。
5.4 新机器加载模型文件异常:环境版本不一致
现象:模型文件拷到另一台机器,joblib.load 后 predict 报错,说找不到 LightGBM 的某个类,或者 numpy 报错。原因:joblib 和 pickle 会把类名和模块路径记进文件;LightGBM、scikit-learn、numpy 版本不一致,序列化对象恢复不出来。
解决:先记版本再交付。在训练机执行:
pip freeze > requirements.txt然后新环境用同版安装。如果做不到完全同版本,至少保证 lightgbm 和 scikit-learn 大版本一致。我在项目里的习惯是训练完模型文件后,紧接着把 requirements.txt 也放进交付目录,并在记录里写明 Python 版本。血泪经验是,很多“模型文件加载失败”不是文件坏了,是环境变了。
5.5 目标编码做过全局均值:看起来正常,线上却失效
现象:用目标编码(比如把 job 映射成“该职业历史认购率”)后,AUC 明显提升;上线后预测分布整体偏移,评分失真。原因:在整个数据集上 compute 均值再去编码,编码值里混入了当前样本标签信息;切分训练测试后,测试集的目标均值被偷看。
解决:目标编码只能在训练集上 fit,再 transform 验证集和测试集;最好把它放进 KFold 内部,每个 fold 独立编码。如果不想自己写,可以用sklearn.preprocessing.TargetEncoder或 category_encoders 里的 TargetEncoder,但一定要确保它被包括在 Pipeline 中,而不是先编码再划分。判断标准很简单:改一行标签,编码值就变,说明它已经过拟合到标签上了。
6. 从预测概率到银行可用的认购名单:阈值、解释与模型文件的最后一公里
6.1 用业务成本曲线选阈值,而不是默认 0.5
最后一公里是把概率转成外呼名单。我一般会做一张成本和收益的小表,而不是直接取 0.5:
| 指标 | 计算方式 |
|---|---|
| 外呼成本 | 每通电话人力成本 |
| 单客户利润 | 客户认购产品的期望利润 |
| 名单规模 | 超过阈值的客户数 |
把y_test_proba按阈值切分,计算不同阈值下的总利润,选利润最大的阈值。这比 F1 更贴合银行场景,因为它把“漏掉客户”和“打错电话”的代价直接量化了。
6.2 交付目录和验证检查单
交付一个银行客户认购预测模型时,应该有模型文件、代码、环境依赖和说明一份目录。验证时我会按顺序检查:新数据里的类别是否会报错、pdays 是否还有 999 被当数值、模型文件加载后预测是否稳定、Top 名单的认购率是否显著高于整体。用加载后的 Pipeline 跑一遍完整 SQL 拉出的最新客户表,再和业务方认定的 top 特征做对照。
6.3 下一步从“预测谁买”扩展到“买后价值”
这个方向真正值钱的地方不在多提升一点 AUC,而在于把模型结果接到业务动作上。后续可以加一个成本敏感决策:高概率客户直接外呼,中概率客户走短信或 App 推送,低概率客户进入沉默观察。也可以从二分类升级成回归,预测客户购买后的资产贡献,让名单排序更贴近利润。我现在接手类似项目时,会先定阈值和收益口径,再开始建模,而不是先把模型调到最好看。希望这些经验能帮你把银行客户认购产品预测做成一个能交付、能解释、能持续迭代的小工程。
本文还有配套的精品资源,点击获取