☰
银行客户认购预测:Python机器学习全流程实战与避坑指南
2026/10/1 17:26:46 网站建设 项目流程

简介:这套项目包定位于银行客户产品认购预测场景,属于完整的Python机器学习入门到进阶实战资源,适合数据分析学习者、算法工程师以及银行营销建模相关从业者。项目以银行客户行为数据为基础,完整覆盖数据预处理、特征工程、多种分类算法对比训练、模型评估与结果导出流程,最终形成可用于精准营销决策的高准确率分类模型。资源共79个文件,压缩包约10.47MB,包含3个版本的Jupyter Notebook建模脚本、5个Python辅助脚本、训练集/测试集/提交结果CSV、特征说明Excel、预处理流水线与最优模型pkl,以及43张特征分布图,可支撑从数据探索到模型落地的全链路复现。目前已有42人学习下载。借助多版本Notebook可对比调优过程,图表与字段说明便于撰写分析报告,模型文件可直接迁移到类似金融数据集,同时附带备份文件与说明文档,便于追溯版本调整与工程组织方式。

1. 银行客户认购预测:一份能直接跑通的 Python 机器学习全流程源码

做银行零售业务的朋友应该都有体会:电话营销的响应率低得离谱,一百个客户里能有两三个愿意听你讲完产品就不错了。与其靠话术碰运气,不如用机器学习在拨号前先筛一遍名单。这份《银行客户产品认购预测系统源码》做的就是这件事——基于 Python 建模,根据客户的年龄、职业、负债情况、历史联系记录以及宏观利率指标,预测客户会不会认购产品。它不是教学 demo,而是带着完整数据预处理、特征工程、多模型对比和提交结果的一整套项目,训练集、测试集、字段说明、模型文件全在里面,适合想系统看一遍真实营销建模流程的人,也适合正在做金融风控或精准营销项目、需要一份参考实现的人。源码目录里的automatic_learning.py、category_bin.py、preprocessing_pipeline.pkl这些文件,基本就是建模全流程的骨架,照着拆能省不少事。

2. 先读数据与 EDA:从 36 张分布图里读出建模决策

2.1 字段结构:营销活动、客户画像与宏观指标三类特征

项目根目录下的字段说明.xlsx把每个字段的定义理得很清楚。这种数据集的字段可以分成三类:第一类是客户静态画像,包括年龄、职业、婚姻状况、教育水平、是否有房贷、是否有个人贷款、信用卡是否有违约记录;第二类是本次营销过程的动态信息,包括联系方式、联系的月份和星期几、上一次联系的时长、活动期间联系客户的次数、上次联系后的间隔天数、之前营销活动的结果;第三类是当时的宏观经济快照,包括就业变动率、消费者信心指数、消费者价格指数、银行同业拆借率 3 个月利率、雇员人数。

这三类特征在建模时的定位完全不一样。客户画像特征是稳定信号,一个人有没有房贷、教育水平高不高,短期内不会变;宏观特征是环境变量,影响的是某一批客户整体的购买意愿;营销过程特征是最容易被忽视但也最容易出问题的部分,尤其是“上一次联系的时长”这类字段,后面避坑章我会专门讲。

先看训练集的结构,用 Pandas 读一下:

import pandas as pd train = pd.read_csv("train.csv") print(train.shape) # 输出行数和列数,确认样本规模 print(train.dtypes) # 检查每个字段类型,判断哪些是类别、哪些是数值 print(train.head()) # 扫一眼前几行,确认是否有空值和异常表达

这里dtypes输出里,job、marital、education、contact、month这类字段肯定是 object 类型,后面要做类别编码;age、duration、campaign、euribor3m这些是数值类型,但要留意它们量纲差异很大,euribor3m是零点几到几的利率,duration可能是几十到几千秒,不归一化直接喂给线性模型会被大数值字段带偏。

2.2 EDA 图怎么读:正样本分布对比图到底在看什么

figures目录里几十张 png 是这个项目最值钱的部分之一,它把探索性分析的结论直接固化成图了,不用自己重新跑一遍。文件名有一个规律:前缀001_开头的是全量分布图,比如001_年龄分布.png、001_婚姻分布.png;002_开头的是连续型特征的分布,比如002_就业变动率分布.png、002_消费者信心指数分布.png;003_开头的是“正样本分布”,也就是只有目标y=1的那些客户在各特征上的分布,比如003_年龄正样本分布.png、003_联系时长正样本分布.png。

读图的方法是对比:先把003_职业正样本分布.png和001_职业分布.png放在一起看,如果正样本里“retired”占比明显高于全量里的占比,说明退休人群的认购倾向偏高,这个字段就是有区分度的信号。再看003_是否有房贷正样本分布.png,如果正样本里无房贷的比例显著高于全量,说明房贷状态对购买意愿有负向影响。这类“正样本分布 vs 全量分布”的对比,是判断类别特征有没有用的最快方式。

数值特征看的是形态。002_年龄分布.png如果是右偏的,建模时可以考虑做 log 变换;002_银行同业拆借率3个月利率分布.png如果正样本集中在某个区间,说明利率水平对认购有区间效应,后续做特征工程时可以按分箱处理,而不是直接丢给模型。

2.3 目标不平衡是第一个要正视的问题

001_目标分布.png告诉你训练集里正样本占比通常只有 10% 上下。这个比例对建模策略的影响很大:直接拿准确率当评估指标会得到“全都预测不买”这种假模型,而银行营销场景更关心的是“在限定电话量的前提下,尽量把会买的人捞出来”。所以后面的模型评估必须围绕 AUC、召回率、F1 这类指标转,不能只看准确率,这点在第四章会展开。

3. 特征工程与预处理管线:category_bin.py 与 preprocessing_pipeline.pkl

3.1 类别字段的二元化思路:不是所有模型都吃 One-Hot

category_bin.py这个脚本名字很有意思,“bin”表示二元化。常见做法是把多类别字段压缩成少数几个有业务含义的二值特征。比如marital字段,按“是否已婚”拆一个特征;housing和loan本身就是 yes/no,映射成 1/0 即可;poutcome这种多类别字段,可以把“之前营销成功”映射成 1,其余映射成 0。这么做的直接原因是项目里会跑树模型(从tree_transform_bin.py这个文件名也能看出来),而树模型做分裂时不擅长处理高基数 One-Hot 展开后的大量稀疏列。

下面是用映射表做二元化的核心逻辑:

import pandas as pd def category_bin(df, mapping_dict): """ 按字段的二元化映射表做转换 mapping_dict 示例: {"marital": {"married": 1, "single": 0, "divorced": 0}, "housing": {"yes": 1, "no": 0}} """ df = df.copy() for col, mapping in mapping_dict.items(): if col in df.columns: # 用 map 做值替换,未命中的类别填 -1,防止训练集和测试集类别不一致时报错 df[col] = df[col].map(mapping).fillna(-1).astype(int) return df mapping = { "marital": {"married": 1, "single": 0, "divorced": 0}, "housing": {"yes": 1, "no": 0}, "loan": {"yes": 1, "no": 0}, "default": {"yes": 1, "no": 0} } train_bin = category_bin(train, mapping) print(train_bin[list(mapping.keys())].head())

这段代码里最容易被忽略的是fillna(-1)。它解决的是训练集里某个类别在测试集里不存在的情况。比如训练集里marital有divorced,但测试集来了一条unknown,map之后会变成 NaN,如果不兜底,后续直接astype(int)就会报错。给一个 -1 的取值,等于告诉模型“这是一个没见过的状态”,比报错强。

3.2 预处理管线:为什么一定要把标准化和编码串成 Pipeline

项目里preprocessing_pipeline.pkl是直接用joblib.dump保存的完整 sklearn Pipeline。它的作用是把“缺失值填充、数值标准化、类别编码”这三步打包成一个对象,训练时只 fit 一次,预测时直接 transform,保证训练和预测走的是同一套逻辑。这个设计非常关键,很多人建模时预处理代码写到函数里,训练时跑一遍、预测时再复制粘贴一遍,一旦两边代码改了不一致,线上预测效果就会莫名其妙变差。

管线构建的常见做法是:

from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.impute import SimpleImputer numeric_features = ["age", "campaign", "duration", "euribor3m"] categorical_features = ["job", "education", "contact", "month"] numeric_transformer = Pipeline(steps=[ ("imputer", SimpleImputer(strategy="median")), # 数值字段用中位数填充,抗离群点 ("scaler", StandardScaler()) # 标准化,让均值0方差1 ]) categorical_transformer = Pipeline(steps=[ ("imputer", SimpleImputer(strategy="most_frequent")), # 类别字段用众数填充 ("onehot", OneHotEncoder(handle_unknown="ignore")) # 忽略未知类别,防止预测报错 ]) preprocessor = ColumnTransformer(transformers=[ ("num", numeric_transformer, numeric_features), ("cat", categorical_transformer, categorical_features) ]) pipeline = Pipeline(steps=[("preprocess", preprocessor)]) pipeline.fit(train[numeric_features + categorical_features])

参数上注意两个点。SimpleImputer的median策略对偏态分布的数值字段比mean稳,像duration这种长尾分布,均值会被极值拉高,填充出来的值反而失真。OneHotEncoder的handle_unknown="ignore"一定要设,否则线上预测时测试集出现一个新职业类别,整个 Pipeline 直接抛异常。这是最容易翻车的地方,后面避坑章会再提一次。

3.3 feature_attribute.json:用配置驱动整个特征流程

feature_attribute.json这个文件的价值在于它把“哪个字段是数值、哪个字段是类别、哪个是目标变量”从代码里抽出来了。这样训练脚本、预测脚本、自动化脚本都读同一个配置文件,改字段不用改代码,只改 JSON。常见结构是:

{ "numerical": ["age", "campaign", "duration", "prev_contact_days"], "categorical": ["job", "marital", "education", "contact", "month"], "target": "y", "drop": ["user_id"] }

有了这个配置,automatic_learning.py在训练时可以直接按 key 读字段列表去构造 ColumnTransformer,不用每个脚本硬编码一次字段名。这个习惯建议保持,尤其在项目版本迭代过程中,V1.1 到 V1.3 字段若发生了变化,改 JSON 比改三处代码靠谱得多。

4. 自动学习与模型调参:automatic_learning.py 怎么选出 best_model

4.1 训练脚本的整体骨架:先固定评估策略

automatic_learning.py是整份资源里最值得读的一个脚本,因为它把“自动学习”做成了可重复的流程:加载数据 → 预处理 → K 折交叉验证 → 多模型评估 → 保存最优模型和分数记录。它不只是一个训练脚本,更像是一个建模实验管理工具。

第一步是先指定评估策略。由于正样本只有 10% 左右,直接随机划分训练集和验证集,验证集里可能一两条正样本都没有。项目里用的是分层 K 折交叉验证,确保每一折里正样本比例都和全量一致:

from sklearn.model_selection import StratifiedKFold skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) for fold, (train_idx, valid_idx) in enumerate(skf.split(X, y)): X_train, X_valid = X.iloc[train_idx], X.iloc[valid_idx] y_train, y_valid = y.iloc[train_idx], y.iloc[valid_idx] # 每一折独立做预处理和训练

n_splits=5是营销场景下比较常用的折数,样本量不大时 5 折比 10 折更稳,每折的验证集足够大,评估指标的方差也小一些。random_state=42固定下来,是为了让实验结果可复现,不然每次跑出来的分数都不一样,没法判断模型改动到底有没有效果。

4.2 多模型对比:不要只用一种算法定生死

项目摘要里提到“采用多种机器学习算法进行对比实验”,这在automatic_learning.py里体现为对多个分类器的统一封装。常见做法是定义一个模型候选集,逐个跑 K 折交叉验证,收集每一折的 AUC,最后比较均值。

from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier from sklearn.metrics import roc_auc_score import joblib models = { "lr": LogisticRegression(max_iter=1000), "rf": RandomForestClassifier(n_estimators=300, random_state=42), "gbdt": GradientBoostingClassifier(random_state=42) } best_score = 0 best_model = None for name, model in models.items(): auc_scores = [] for fold, (train_idx, valid_idx) in enumerate(skf.split(X, y)): X_train, X_valid = X.iloc[train_idx], X.iloc[valid_idx] y_train, y_valid = y.iloc[train_idx], y.iloc[valid_idx] model.fit(X_train, y_train) auc = roc_auc_score(y_valid, model.predict_proba(X_valid)[:, 1]) auc_scores.append(auc) avg_auc = sum(auc_scores) / len(auc_scores) print(f"{name}: {avg_auc:.4f}") if avg_auc > best_score: best_score = avg_auc best_model = model joblib.dump(best_model, "models/best_model.pkl")

注意这里比较的是predict_proba出来的概率,不是predict出来的 0/1 标签。AUC 本身就是基于排序的指标,必须用概率算。模型里LogisticRegression设置了max_iter=1000,是因为默认的 100 次迭代在特征较多时可能不收敛,日志里会出现ConvergenceWarning,这个属于最常见的警告之一。

4.3 评估指标:为什么银行营销场景必须盯 AUC 和 Recall

银行电话营销的成本结构决定了评估指标的选取:每通电话的呼叫成本固定,一个认购客户带来的收益远高于单通电话成本。所以建模目标不是“整体预测准确率高”,而是在尽量少的电话里覆盖尽量多的实际认购客户。这对应两个指标:AUC 衡量模型对正负样本的排序能力,值越高说明“打给高概率客户”的策略越有效;Recall 衡量“实际会买的客户里,模型捞出来了多少”,在限定电话量的前提下,Recall 比 Precision 更贴近业务诉求。

如果模型只输出最终类别,项目里的submission.csv相关性分析就没法做。好在best_score_log.json里记录的也是 AUC 这类分数,说明作者一开始就用了正确的评估视角。

4.4 版本迭代逻辑:V1.1 到 V1.3 改了什么东西

notebook目录里有认购产品_V1.1.ipynb、认购产品_V1.2.ipynb、认购产品_V1.3.ipynb三个版本,results目录下对应submission_v1.3.csv。三版迭代最能说明建模过程的演进。V1.1 通常是最粗糙的一版,可能只做了 One-Hot 编码加逻辑回归;V1.2 加入了更多特征工程,比如对连续变量分箱、对类别变量做目标编码;V1.3 把预处理流程固定成了 Pipeline,并且调整了模型超参数。

对比这几个版本时,除了看分数变化,更重要的是看结构变化。把 V1.1 到 V1.3 的 notebook 从 EDA 到建模的代码 diff 一下,能看到作者在每个版本里动了哪些特征、加了哪些样本处理逻辑。这比直接看最终版收获大,因为你能看到“为什么上一个版本分数上不去”。best_score_log.json里会记录每个版本的最优分数,可以拿来和best_model.pkl核对,确认当前最优模型对应的是哪一个预处理版本,模型和管线不匹配会让预测结果完全错误。

5. 避坑指南:翻车率最高的五个地方

5.1 data leakage:duration 字段让分数虚高

现象:模型 AUC 跑出 0.95 以上,但心里总觉得不对劲,这分数高得不真实。

原因:duration字段是“上一次联系的时长”,只有电话已经打出去了才会有这个值。营销预测场景里,你不可能还没拨号就知道这通电话会打多久。竞赛数据集里这个字段明明存在,但真实业务里它属于“未来才知道的信息”,拿它建模等于作弊。

解决:把duration从特征里直接删掉。如果不舍得,至少要做一个“有 duration 和无 duration 两套模型”的对比实验,验证一下没有这个字段时模型的真实水平。这个也是业界公认的 Bank Marketing 数据集水榜重灾区。

5.2 测试集出现没见过类别值,预测阶段直接抛异常

现象:训练和调参都正常,一跑test.csv的预测脚本就报ValueError: Found unknown categories。

原因:训练集里job字段没有"student"这个取值,但测试集里有,OneHotEncoder默认遇到新类别就报错。

解决:如上面 3.2 里写的,给OneHotEncoder设置handle_unknown="ignore"。如果是自己写的映射函数,用fillna(-1)兜底。这个坑在银行场景特别容易遇到,因为职业、教育水平这类字段的口径在不同批次数据里经常对不齐。

5.3 交叉验证每折重新 fit 预处理,导致验证集信息泄漏

现象:交叉验证的 AUC 很高,但用独立测试集评估时分数掉了一大截。

原因:有人在 K 折循环之前就把全量数据做了标准化或编码,相当于验证集的数据分布信息已经被“看”过了,这叫数据泄漏。标准化用的是全量的均值和方差,验证集的信息自然被编码进了训练过程。

解决:预处理必须在每一折内部完成,先切分训练集和验证集,再在训练集上fit预处理器,然后transform验证集。用 Pipeline 的好处就在于它强制把流程绑定到模型上,fit 和 transform 不会错位。如果不用 Pipeline,每折循环里都要手动先fit再transform。

5.4 submission.csv 格式不对导致评分失败

现象:本地跑得好好的,一提交却发现评分脚本报错,或者得分为 0。

原因:submission.csv通常要求两列,一列是客户 ID,一列是预测结果。有人直接导出了带索引的 DataFrame,有人把id列弄丢了,有人提交的是 0/1 标签,但评分方要求的是概率值。

解决:写出提交文件前,先print(submission.head())确认列名和内容。客户 ID 列直接来自test.csv,不要自己重新生成索引;预测列建议输出概率而不是硬标签,概率可以灵活调整阈值,标签一旦写死就丢掉了排序信息。生产习惯是先保存成两列无索引的 CSV,再重新读一次确认格式。

5.5 .zbak 文件混淆:备份当成正式文件加载

现象:加载preprocessing_pipeline.pkl.zbak和best_model.pkl后预测结果全是同一个值,检查半天发现是 pkl 文件本身就不同。

原因:项目里所有脚本和模型都有.zbak后缀的备份文件,它们是之前的旧版本。如果把.zbak当成有效文件加载,管线版本和模型版本不匹配,特征顺序可能都不一样,结果自然不对。

解决:加载模型时只看models目录下的无后缀文件,.zbak一律不碰。best_score_log.json里记录了对应版本号,加载完模型后打印一下特征数或模型类型做个 sanity check。这个血泪经验提醒我,每个项目都要在模型文件旁边放一个说明字段,注明训练时间和对应代码版本。

6. 从复现到落地:用 best_model.pkl 接一个可复用的预测入口

6.1 加载管线与模型,封装成 predict 函数

项目的最终产物是models/best_model.pkl和models/preprocessing_pipeline.pkl,这两个文件必须成对使用:先transform,再预测。不要只加载模型,以为模型是独立存在的——它前面所有预处理步骤都跑在管线里,脱离管线直接预测会导致字段顺序错乱甚至维度不匹配。

import joblib import pandas as pd model = joblib.load("models/best_model.pkl") preprocessor = joblib.load("models/preprocessing_pipeline.pkl") def predict_proba(test_df): X_processed = preprocessor.transform(test_df) return model.predict_proba(X_processed)[:, 1]

这一段代码量不多,但有一个关键点:preprocessor.transform用的是transform而不是fit_transform,因为预处理器的参数已经在训练时确定好了,预测阶段不能重新学习。很多人习惯性地写fit_transform,在预测阶段就会让管线重新拟合,结果等于用训练数据重新训练了一遍预处理,线上预测自然变差。

6.2 生成提交文件时的两个细节

拿到测试集后,生成提交结果要注意两点。第一,id列先抽出来单独保存,预处理时把它 drop 掉,因为模型不需要把 ID 作为特征;第二,预测结果建议输出概率,这样后续可以按不同的阈值调整营销名单规模。

test = pd.read_csv("test.csv") test_ids = test["id"] # 先保存ID列,后续要原样写回提交文件 proba = predict_proba(test.drop(columns=["id"])) submission = pd.DataFrame({"id": test_ids, "y": proba}) submission.to_csv("results/submission_v1.3.csv", index=False) # 校验一下输出 print(submission.head()) print(submission["y"].describe())

y列的分布打印出来看一眼,如果概率值全部集中在 0.1 以下,说明模型输出的不是概率,或者预处理管线没加载对。正常情况概率分布应该有一定梯度,均值大概在正样本比例附近。

6.3 没有测试集时怎么办

项目目录里虽然带了test.csv,但实际做模型评估时,我更建议把train.csv自己切一块出来当测试集,专门留到最后用。这样能看到模型在完全没参与训练的数据上的真实表现。automatic_learning.py里已经有交叉验证,但交叉验证的分数是多次平均,会低估模型在单批数据上的波动,留出法测试集更接近真实线上表现。留出比例 20% 比较常见,同样要做分层抽样,保证测试集里正样本比例和训练集一致。

从那以后,我每次拿到这类营销预测项目,都会先检查一遍train.csv和test.csv的列名是否对齐,再用固定 seed 跑一遍全流程,最后核对提交文件的列名和概率分布。这一步看起来琐碎,省掉它大概率要返工。希望你也能把这份源码里最实用的部分拆走,改造成自己手边能直接用的项目。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询