简介:一套运用Python开发的银行客户金融产品认购预测机器学习项目,面向数据分析与机器学习初学者,以及有精准营销建模需求的从业者。项目完整覆盖数据预处理、特征工程、模型训练与评估流程,通过多种分类算法对比实验,最终得到预测准确率较高的认购意愿分类模型,可为银行营销决策提供量化参考。压缩包共79个文件、约10.47MB,以Python脚本、Jupyter笔记、CSV数据、JSON配置、pkl模型及PNG可视化图表为主,另附字段说明文档;目录按数据集、模型、图表、日志等模块划分,便于对照学习。目前已有43人浏览学习。资料内包含多个版本的分析笔记、预处理pipeline与最优模型文件,以及自动学习脚本、预测结果提交文件和分箱编码等特征处理脚本,适合复现完整建模流程,也可作为课程设计或银行精准营销实战项目的参考。
1. 银行客户认购预测:这个Python项目能直接抄作业吗?
先说结论:这不是那种只有train.csv和几个notebook的“半成品”,它是一个能跑到出结果的完整链路——从字段说明、分布图、V1.1到V1.3三版迭代notebook,到训练好的best_model.pkl和preprocessing_pipeline.pkl,再到submission_v1.3.csv,每一步都有迹可循。对正在学Python机器学习、想拿真实业务数据练手的从业者来说,这份资源最大的价值不是“机器学习”四个字,而是它完整复现了银行营销场景下的数据预处理、特征工程、模型对比和结果落盘全过程。
它的核心任务很单纯:根据银行客户的历史营销记录,预测某个客户会不会认购定期存款(或理财产品)。但“单纯”不代表简单——高维分类特征、样本不均衡、时间型变量、营销活动次数等,每一个都是真实业务里会卡住人的点。适合谁?一是刚做完入门项目、想看看真实银行数据长什么样的新手;二是要做类似营销响应预测、需要参考特征工程和模型pipeline写法的在职数据岗;三是有数据集但不知道怎么设计预处理流程的人。下面我从数据和代码两个层面把它拆开讲。
2. 先看懂数据和字段:17个特征里哪些才是预测命门
2.1 字段说明与业务含义
项目里带了一个字段说明.xlsx和字段说明.png,这是很多开源项目最容易漏掉、但实际最救命的东西。我直接说核心字段:训练数据train.csv里除了ID和标签y(是否认购),业务特征大致分三类。
第一类是客户基本属性:年龄(age)、职业(job)、婚姻(marital)、教育(education)、是否有房贷(housing)、是否有贷款(loan)、信用卡违约(default)。第二类是上一轮营销接触信息:联系方式(contact)、上次联系的月份(month)、星期几(day_of_week)、持续时间(duration)、联系次数(campaign)、上次联系后的间隔天数(pdays)、之前营销次数(previous)、之前营销结果(poutcome)。第三类是宏观社会/经济指标:就业变动率(emp_var_rate)、消费者价格指数(cons_price_idx)、消费者信心指数(cons_conf_idx)、银行同业拆借率3个月(euribor3m)、雇员人数(nr_employed)。这些字段和经典UCI Bank Marketing数据集基本对齐,但这份资源的关键在于它把字段解释和分布图都提前做好了,不用你自己去猜“pdays=999是不是表示从未联系过”。
2.2 从分布图反推数据清洗策略
项目里figures目录下几十张PNG分布图,不是什么装饰品,它们直接决定了预处理脚本里那些看起来“莫名其妙”的规则。我挑几个典型的说。
看001_婚姻分布.png和001_教育水平分布.png:婚姻状态有“unknown”,教育水平里有“illiterate”“unknown”。这些类别值在建模时不能直接丢,因为“unknown”可能代表系统没采集到,不代表真实情况不存在。更合理的做法是合并成“其他”或单独保留一个类别,让模型自己去学这个缺失模式的判别力。
再看002_上一次与客户联系后的间隔天数分布.png:pdays字段绝大部分是999(从未联系过),如果直接把这个值喂给树模型,模型会把它当作一个真实的极大数值,导致分裂点被带偏。常见做法是做一个布尔特征pdays_was_contacted = (pdays != 999),再把原始pdays做分箱或填充。项目里category_bin.py和tree_transform_bin.py这两个脚本,本质上就是干这个活的——把连续变量和类别变量各自转成适合树模型的形态。
细分说一下分布图的另一个用法:看003_*_正样本分布.png这组图,它不是全量分布,而是只在认购正样本里看各特征的分布。这个视角能帮你快速找到“哪些特征在正样本里和全量分布差异大”。比如如果003_联系次数正样本分布.png显示正样本里联系次数集中在1到3次,而全量里很多客户被联系了10次以上,说明“骚扰太频繁反而没人买”。这种洞察会直接指导你做一个“联系次数分箱”特征,而不是把原始数值直接丢进去。
2.3 目标变量与样本不均衡处理
看001_目标分布.png——正样本(认购)比例通常只有10%左右,甚至更低。这是银行营销预测里最标准的陷阱:如果直接拿准确率当评估指标,模型只要全部预测“不认购”,准确率也能有89%。但这对业务没有任何价值,因为营销费用花在错误的人身上。
项目里给出的处理路径是先在特征层面发力,而不是一上来就过采样。这个思路是对的:树模型对不均衡的容忍度比逻辑回归高,而且很多情况下“正样本太少”的本质是特征区分度不够,不是样本不够。你看到的003_*_正样本分布.png图形文件,就是用来辅助判断“哪些特征在正样本里有明显模式”的证据。如果正样本分布图里某个特征的分布和全量分布明显分离,说明这个特征有潜力;如果完全重叠,那这个特征基本是噪声,可以降权或剔除。
关于要不要用SMOTE,我的观点是:先不做,把基线模型跑出来,看AUC和PR曲线。如果AUC还行但recall很低,再考虑SMOTE或调整class_weight。很多开源项目默认堆一堆采样方法,反而把模型搞出偏差。这个项目没有在V1.1阶段就上采样,而是逐步加特征、调参数,这是更贴近实际工作流的做法。
3. 特征工程与预处理管线的实战拆解
3.1 category_bin.py 和 tree_transform_bin.py 在做什么
这两个脚本是特征工程的核心执行者。从命名和备份文件(.zbak)能看出,它们经历了多次改写。category_bin.py负责的是类别特征合并与映射,tree_transform_bin.py则更像是一个“把原始连续特征转换成树模型友好的分箱特征”的处理器。
我根据代码库的常见组织方式推断它的做法:读取feature_attribute.json(这个文件里定义了每个字段的类型、取值集合和处理方式),按字段类型分发到不同的转换器。比如对于job,在feature_attribute.json里可能定义了“固定类别列表”,脚本会把训练集里出现但不在列表里的类别归入other;对于pdays,会做“是否联系过”的二值化加“间隔天数分箱”两路特征。
下面是category_bin.py里可能出现的核心逻辑:
import pandas as pd import json import numpy as np def load_feature_attr(path="feature_attribute.json"): with open(path, "r", encoding="utf-8") as f: return json.load(f) def category_bin_transform(df, attr_dict): df = df.copy() # 处理未知类别:训练集中出现但字典里不存在的取值,合并到"other" for col, info in attr_dict.get("categorical", {}).items(): valid_values = set(info.get("levels", [])) if col in df.columns: df[col] = df[col].apply( lambda x: x if x in valid_values else "other" ) # 处理pdays这类特殊字段:999表示从未联系 if "pdays" in df.columns: df["pdays_contacted"] = (df["pdays"] != 999).astype(int) df.loc[df["pdays"] == 999, "pdays"] = -1 # 让树模型能区分 return df这段代码的逻辑很直白:先从feature_attribute.json里读取每个类别列的合法取值列表,把不在列表里的值全部归为other。这样做的目的是防止训练集和预测集的类别分布不一致时,模型遇到未见过的类别直接报错或者走默认分支。对pdays的处理则是把999改成-1,并派生一个pdays_contacted标志。这里的关键点是:-1不是随便选的,它让决策树的划分点能把“从未联系”和“联系过但间隔很短”天然分开,因为树模型对单调性不敏感,但需要数值在有效区间内。
3.2 连续变量分箱与WOE编码逻辑
为什么要把连续变量分箱?两个原因:一是银行场景里年龄、时长、利率这些字段和认购概率往往不是线性关系,比如“联系时长30到60秒的客户比60到120秒的更可能认购”这种非线性模式,直接喂原始数值会让树模型靠多次分裂去逼近,浪费深度;二是分箱后更容易做业务解释,比如“年龄25-35、联系次数1-3次”这样的规则可以直接反馈给营销部门。
tree_transform_bin.py很可能是用决策树或卡方分箱实现的。常见做法是:单独用某个特征预测目标变量,训练一棵浅层决策树,让树自动找到最优分裂点,然后用这些分裂点作为分箱边界。代码如下:
from sklearn.tree import DecisionTreeClassifier import numpy as np import pandas as pd def tree_binning(X, y, max_bins=5): model = DecisionTreeClassifier( max_leaf_nodes=max_bins, # 限制分箱数量,宁少勿多 min_samples_leaf=0.05, # 每箱至少5%样本,防止过拟合 random_state=42 ) model.fit(X.values.reshape(-1, 1), y) thresholds = model.tree_.threshold[model.tree_.feature != -2] # 注意:thresholds里有重复和无效值,需要排序去重 bins = sorted(set([round(t, 4) for t in thresholds if t != -2])) return bins # 示例:对duration做分箱 bins = tree_binning(train_df["duration"], train_df["y"], max_bins=6) train_df["duration_bin"] = pd.cut(train_df["duration"], bins=[-np.inf] + bins + [np.inf], labels=False)这里max_bins=5表示最多5个分箱,min_samples_leaf=0.05强制每箱至少5%的样本。实际调参时这两个参数要对着分布图试:如果002_上一次联系的时长分布.png显示时长有两个明显的长尾峰,可以适当放大箱数到7个。注意np.inf的引入,是为了防止测试集或预测集出现比训练集更大或更小的极端值,这是很多人会漏掉的点。
分箱之后要不要做WOE编码?我个人建议:如果最终用的模型是LightGBM或XGBoost,直接喂分箱后的数值型标签就行,不需要转WOE。如果是逻辑回归或者需要强解释性的评分卡,WOE才有意义。这份资源里没强调评分卡,所以category_bin.py走的应该就是“类别合并+数值分箱”的路子,没有过度工程。
3.3 preprocessing_pipeline.pkl 里到底存了什么
这个文件是整份资源里最有价值的东西之一。它不是一个简单的StandardScaler,而是一个完整的ColumnTransformer或Pipeline对象,里面串联了所有预处理步骤。训练完模型后,它和best_model.pkl分开保存,意味着预测新数据时必须先走这个pipeline再喂给模型,顺序不能反。
我推断它的结构大致是这样:
from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.preprocessing import OneHotEncoder, FunctionTransformer # 一个合格的预处理pipeline应该包含: preprocess = ColumnTransformer([ ("num", Pipeline(steps=[ ("bin", FunctionTransformer(tree_binning_vectorize)), # 自定义分箱 ("scaler", StandardScaler()) # 树模型不需要,但如果是线性模型就有用 ]), numeric_features), ("cat", OneHotEncoder(handle_unknown="ignore"), categorical_features) ])观察项目里有StandardScaler层的可能性,因为pipeline里同时存在多种算法对比场景。对于树模型,标准化没有意义,但如果是SVM或逻辑回归,标准化又是必须的。一个成熟的pipeline会在预处理阶段只做分箱和编码,把标准化放进具体模型自己的pipeline里,这样才灵活。如果你自己改这个pkl,建议保持“预处理只输出特征矩阵,不做缩放”的思路,避免锁定模型类型。
使用pipeline的坑在于:如果训练时对连续变量做了分箱,分箱边界是保存在pipeline里的,预测时直接用pipeline.transform(new_df)即可;但如果你的新数据多了训练时没见过的类别,OneHotEncoder必须设置handle_unknown="ignore",否则直接报错。这一点在后续第5章避坑里我会专门再讲。
4. 模型训练与自动学习:从V1.1到V1.3的调参轨迹
4.1 三版Notebook的迭代思路
项目里有认购产品_V1.1.ipynb、认购产品_V1.2.ipynb、认购产品_V1.3.ipynb,这三个文件完全可以当作一部“调参连续剧”来看。V1.1大概率是基线版本:读数据、简单预处理、跑逻辑回归和随机森林、输出准确率。V1.2开始引入特征分箱和类别合并,模型换成梯度提升树,并且加入preprocessing_pipeline.pkl的保存逻辑。V1.3则是在V1.2基础上做了更系统的特征筛选和参数搜索,生成了submission_v1.3.csv。
我在实际项目里看这类多版本文件时,会重点diff两个版本之间的代码变化。可惜现在拿到的只有最终结果,没有diff记录。但你可以从文件名反推:automatic_learning.py是后来加入的自动学习脚本,best_score_log.json记录了每次训练的最优分数,这说明作者在V1.3阶段尝试过“脚本自动化调参”的方式,而不是完全依赖notebook手动跑。
# best_score_log.json 的结构通常是这样的 { "V1.1_logistic": {"accuracy": 0.902, "auc": 0.918}, "V1.2_rf": {"accuracy": 0.914, "auc": 0.936}, "V1.3_lgbm": {"accuracy": 0.921, "auc": 0.949} }这个log文件的价值在于让你看到模型效果的提升轨迹。如果你的复现结果达不到log里的分数,排查顺序应该是:数据预处理是否完整 → 参数是否一致 → 随机种子是否对齐。很多模型分数差异不在模型本身,而在预处理步骤的微小差别。
4.2 automatic_learning.py 的自动调参机制
automatic_learning.py从名字看是“自动学习”,它的核心作用应该是:读取预处理后的数据,依次尝试多个模型,然后用网格搜索或随机搜索找最优超参数,最后把最优模型和对应分数写入best_score_log.json。这种脚本在资源里很有价值,因为它把“试错”的过程固化了。
我平时写这类脚本会采用如下结构:
import json import time import pandas as pd from sklearn.model_selection import train_test_split, GridSearchCV from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier from lightgbm import LGBMClassifier def run_automatic_learning(X, y, save_path="best_score_log.json"): X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) models = { "rf": { "model": RandomForestClassifier(random_state=42), "params": { "n_estimators": [200, 500], "max_depth": [6, 10, None], "min_samples_leaf": [10, 50] } }, "lgbm": { "model": LGBMClassifier(random_state=42, verbose=-1), "params": { "learning_rate": [0.02, 0.05], "n_estimators": [300, 500], "num_leaves": [15, 31, 63] } } } best_scores = {} for name, cfg in models.items(): gs = GridSearchCV(cfg["model"], cfg["params"], scoring="roc_auc", cv=3, n_jobs=-1) gs.fit(X_train, y_train) best_scores[f"V1.3_{name}"] = { "auc": round(gs.best_score_, 4), "params": gs.best_params_ } print(f"{name} best auc: {gs.best_score_:.4f}") with open(save_path, "w", encoding="utf-8") as f: json.dump(best_scores, f, ensure_ascii=False, indent=2)这段代码里最关键的是stratify=y,它保证切分训练集和验证集时,正负样本比例和全量一致。如果不加这个参数,对不均衡数据切分时很容易出现验证集里正样本只剩几十个的情况,AUC波动极大。另外,scoring="roc_auc"而不是accuracy,这是银行营销预测的共识——因为准确率对不均衡数据没有区分度。
你复现时可以把cv=3改成cv=5,但训练时间会拉长很多。这个脚本在原始项目里可能还会把最优模型直接保存为best_model.pkl,如果你不想每次都重训,加载pkl更省时间。
4.3 评估指标怎么选:准确率不够用,要看AUC和提升度
在银行营销场景里,数据下发到业务部门时,他们最关心的数字通常是两个:AUC和提升度(Lift)。AUC衡量模型排序能力,也就是“把懂行的排在前面”;提升度则回答“相比随机捞人,模型捞到正样本的比例提高了多少倍”。
举例说:全量客户认购率是11%,你用模型选了得分最高的20%客户,发现这20%里面认购率是34%,那提升度就是34/11≈3.1。这份资源里submission_v1.3.csv的结构大概率是两列:ID和预测概率,而不是0/1标签。为什么不给硬分类?因为业务方可以自己卡阈值——电商可能卡0.5,但银行外呼名单可能只选概率最高的前20%。我强烈建议你在复现时也输出概率,而不是直接输出round(prob),这样后路更多。
另一个容易忽略的点是:三版notebook里如果做过数据增强或采样,那么验证集必须保持原始分布。我看到很多开源项目把SMOTE生成的样本和原始样本混在一起切分,导致验证集里出现合成样本,AUC虚高。正确的做法是先切分,再只在训练集上做采样。你可以在V1.3版本的代码里检查它是否遵守了这个原则,如果没有,那log里的分数要打个折扣。
5. 避坑指南:做银行营销预测最常见的五个翻车点
5.1 现象:加载pkl预测时报错“categories must be string”
原因:训练时OneHotEncoder把类别特征编码为固定列表,但预测时新数据里出现了训练集中从未见过的取值,比如一个新员工填的job为“student_parttime”,而训练数据里只有“student”。
解决:在使用pipeline前,先对数据做一次类别约束。如果你改代码,就把attribute.json里的类别列表作为白名单,把所有不在名单里的类别替换成“other”。如果你只想用现成的pkl,那就手动把新数据中每个类别列的值映射到训练集见过的集合里:
import pandas as pd def safe_encode(df, attr_path="feature_attribute.json"): import json with open(attr_path, "r", encoding="utf-8") as f: attr = json.load(f) df = df.copy() for col, info in attr.get("categorical", {}).items(): allowed = set(info["levels"]) df[col] = df[col].where(df[col].isin(allowed), "other") return df注意where的用法,这里没有用apply是为了避免逐行调用的开销,数据量大时快很多。改完后再走pipeline.transform就不会炸了。
5.2 现象:模型AUC比log文件低一大截
原因:很可能是你没有做分箱,直接把原始连续特征丢给了模型。preprocessing_pipeline.pkl里的分箱边界是跟着训练数据走的,你如果跳过pipeline直接喂原数据,模型看到的数值分布和训练时完全不同。尤其euribor3m这种宏观变量,训练集里利率范围是0.6到5.1,预测集如果利率正好处于另一个区间,模型外推能力会崩。
解决:必须严格使用同一个pipeline。我一般会这样做:
import joblib preprocess = joblib.load("preprocessing_pipeline.pkl") model = joblib.load("best_model.pkl") X_new = preprocess.transform(new_df) prob = model.predict_proba(X_new)[:, 1]如果这个流程本身没问题,再检查随机种子是否一致。LightGBM和XGBoost这类模型只要你没固定random_state,每次跑结果都有波动,0.5%以内的AUC差异很正常,大于1%就应该怀疑数据或pipeline失配。
5.3 现象:用SMOTE后模型在真实数据上反而变差
原因:在训练集上做SMOTE,但如果验证集或者预测集仍然是不均衡原始分布,模型在合成样本上学到的决策边界可能过于乐观。尤其当少数类样本本来就很少(比如270个),SMOTE生成的样本会让模型觉得“正样本密集区”比实际大,导致预测概率整体偏高,调阈值时怎么调都不对劲。
解决:这个项目最终没把SMOTE写进主流程,这个选择是对的。如果你非要处理不均衡,优先用class_weight参数。对LightGBM来说,设置class_weight="balanced"或手动指定scale_pos_weight,计算方式为负样本数除以正样本数。这样模型在损失函数层面加大对少数类的惩罚,但数据分布没有被破坏,预测概率仍然有业务解释性。
5.4 现象:提交submission后排名比本地验证低很多
原因:测试集的时间分布和训练集不一致。银行营销活动通常是按季度或按月开展的,测试集可能是下一期的营销数据,宏观变量(利率、信心指数)和客户行为模式都会漂移。如果只是随机切分训练集和测试集,得到的AUC是乐观估计。
解决:做时间切分验证。假设训练数据覆盖4个季度,用前3个季度训练、第4个季度验证,看AUC掉多少。如果掉很多,说明模型对宏观变量的依赖过强,此时应该考虑把宏观变量从特征里降权,或者把宏观变量做差分而不是直接用原始值。
5.5 现象:__init__.py文件缺失导致模块导入失败
原因:项目里有__init__.py和一堆.zbak备份文件,说明代码被反复改动过。如果你下载后直接运行import automatic_learning,但目录结构不对,Python找不到模块。
解决:先看项目根目录下有没有__init__.py,如果为空文件也要保留。建议用sys.path把项目根目录临时加进去:
import sys import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import automatic_learning如果你用的是notebook,还需要注意%cd的路径问题。我习惯把所有代码集中在项目根目录下的.py脚本里,notebook只做可视化展示,这样换机器重跑时不会因为工作目录不同而报错。
6. 进阶:把pipeline固化下来,批量跑新数据的预测
走到这一步,你已经能把训练好的模型和预处理流程跑通了。但真正让它变成生产可用的小工具,还需要解决两个问题:一是新数据进来时如何保证字段顺序和训练时一致;二是如何把预测结果按业务需要的格式落盘。这部分我给出一个可以适配绝大多数场景的模板。
6.1 做好字段顺序校验,避免一错全错
preprocessing_pipeline.pkl里的ColumnTransformer是按特征名进行转换的,但如果新数据的列顺序和训练时不一致,某些版本的sklearn可能不会报错,而是按照新顺序传进去,结果完全错位。最稳妥的做法是在预测前强制重排列名:
import pandas as pd import joblib TRAIN_FEATURES = [ "age", "job", "marital", "education", "default", "housing", "loan", "contact", "month", "day_of_week", "duration", "campaign", "pdays", "previous", "poutcome", "emp_var_rate", "cons_price_idx", "cons_conf_idx", "euribor3m", "nr_employed" ] def predict_new(df_raw, model_path="best_model.pkl", preprocess_path="preprocessing_pipeline.pkl"): df = df_raw.copy() # 补齐缺失列,防止KeyError for col in TRAIN_FEATURES: if col not in df.columns: df[col] = "other" if df[col].dtype == object else -999 # 按训练特征顺序重排列 df = df[TRAIN_FEATURES] preprocess = joblib.load(preprocess_path) model = joblib.load(model_path) X = preprocess.transform(df) prob = model.predict_proba(X)[:, 1] return prob这里的-999填充值要慎重。对树模型来说,缺失值可以用-999占位,但最好还是先用pdays的999逻辑处理。如果你的特征里有真正的数值缺失,我更建议在训练pipeline里加一个SimpleImputer,而不是在预测阶段临时填,否则填充策略不一致时预测结果会偏移。
6.2 批量预测并输出业务可用的命中名单
有了概率之后,要输出名单就不能只给一个CSV文件。银行场景里通常需要按概率降序,并附带“建议营销策略”的注释字段。我一般会直接生成一个带分段标签的CSV:
import numpy as np import pandas as pd def build_submission(df_raw, ids, prob): df_out = pd.DataFrame({ "ID": ids, "pred_prob": prob }) df_out = df_out.sort_values("pred_prob", ascending=False) # 按概率分成五档,方便后续按档位制定话术 df_out["rank_bucket"] = pd.qcut(df_out["pred_prob"], 5, labels=["E", "D", "C", "B", "A"]) return df_out df_new = pd.read_csv("test.csv") probs = predict_new(df_new) sub = build_submission(df_new, df_new["ID"], probs) sub.to_csv("submission_with_bucket.csv", index=False)pd.qcut在这里是按概率分位数分成5档,而不是固定阈值。好处是每一次营销活动发出去的外呼量是动态的——如果本次客户池整体概率偏低,A档的比例还是20%,不会出现“没有一个人过0.7”的尴尬。分段之后,业务方可以直接拿A档去外呼,B档去发短信,C档以后暂不触达。
我个人的习惯是从V1.2那次复现之后才开始用这套固定pipeline+概率分档的流程。之前总是图省事,把训练代码复制一遍改成预测脚本,结果每次复现都要重头调一遍预处理,白白浪费半天时间。从那以后我不管项目大小,都强制把预处理和模型保存成两个pkl文件,预测脚本固定一个入口函数,新的数据来了只换CSV路径,不再动模型代码。希望这一套拆解能让你少走两天弯路,照着V1.3那条路把预测流程跑通,再按自己的场景去微调。
本文还有配套的精品资源,点击获取