首页 / 资讯中心 / 文章详情

银行客户认购预测实战:特征工程与模型pipeline完整拆解

银行客户认购预测实战:特征工程与模型pipeline完整拆解 ★ FEATURED ARTICLE
简介一套运用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数据集基本对齐但这份资源的关键在于它把字段解释和分布图都提前做好了不用你自己去猜“pdays999是不是表示从未联系过”。2.2 从分布图反推数据清洗策略项目里figures目录下几十张PNG分布图不是什么装饰品它们直接决定了预处理脚本里那些看起来“莫名其妙”的规则。我挑几个典型的说。看001_婚姻分布.png和001_教育水平分布.png婚姻状态有“unknown”教育水平里有“illiterate”“unknown”。这些类别值在建模时不能直接丢因为“unknown”可能代表系统没采集到不代表真实情况不存在。更合理的做法是合并成“其他”或单独保留一个类别让模型自己去学这个缺失模式的判别力。再看002_上一次与客户联系后的间隔天数分布.pngpdays字段绝大部分是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(pathfeature_attribute.json): with open(path, r, encodingutf-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_bins5): model DecisionTreeClassifier( max_leaf_nodesmax_bins, # 限制分箱数量宁少勿多 min_samples_leaf0.05, # 每箱至少5%样本防止过拟合 random_state42 ) 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_bins6) train_df[duration_bin] pd.cut(train_df[duration], bins[-np.inf] bins [np.inf], labelsFalse)这里max_bins5表示最多5个分箱min_samples_leaf0.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_unknownignore), categorical_features) ])观察项目里有StandardScaler层的可能性因为pipeline里同时存在多种算法对比场景。对于树模型标准化没有意义但如果是SVM或逻辑回归标准化又是必须的。一个成熟的pipeline会在预处理阶段只做分箱和编码把标准化放进具体模型自己的pipeline里这样才灵活。如果你自己改这个pkl建议保持“预处理只输出特征矩阵不做缩放”的思路避免锁定模型类型。使用pipeline的坑在于如果训练时对连续变量做了分箱分箱边界是保存在pipeline里的预测时直接用pipeline.transform(new_df)即可但如果你的新数据多了训练时没见过的类别OneHotEncoder必须设置handle_unknownignore否则直接报错。这一点在后续第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_pathbest_score_log.json): X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) models { rf: { model: RandomForestClassifier(random_state42), params: { n_estimators: [200, 500], max_depth: [6, 10, None], min_samples_leaf: [10, 50] } }, lgbm: { model: LGBMClassifier(random_state42, 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], scoringroc_auc, cv3, n_jobs-1) gs.fit(X_train, y_train) best_scores[fV1.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, encodingutf-8) as f: json.dump(best_scores, f, ensure_asciiFalse, indent2)这段代码里最关键的是stratifyy它保证切分训练集和验证集时正负样本比例和全量一致。如果不加这个参数对不均衡数据切分时很容易出现验证集里正样本只剩几十个的情况AUC波动极大。另外scoringroc_auc而不是accuracy这是银行营销预测的共识——因为准确率对不均衡数据没有区分度。你复现时可以把cv3改成cv5但训练时间会拉长很多。这个脚本在原始项目里可能还会把最优模型直接保存为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_pathfeature_attribute.json): import json with open(attr_path, r, encodingutf-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_weightbalanced或手动指定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_pathbest_model.pkl, preprocess_pathpreprocessing_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文件。银行场景里通常需要按概率降序并附带“建议营销策略”的注释字段。我一般会直接生成一个带分段标签的CSVimport 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, ascendingFalse) # 按概率分成五档方便后续按档位制定话术 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, indexFalse)pd.qcut在这里是按概率分位数分成5档而不是固定阈值。好处是每一次营销活动发出去的外呼量是动态的——如果本次客户池整体概率偏低A档的比例还是20%不会出现“没有一个人过0.7”的尴尬。分段之后业务方可以直接拿A档去外呼B档去发短信C档以后暂不触达。我个人的习惯是从V1.2那次复现之后才开始用这套固定pipeline概率分档的流程。之前总是图省事把训练代码复制一遍改成预测脚本结果每次复现都要重头调一遍预处理白白浪费半天时间。从那以后我不管项目大小都强制把预处理和模型保存成两个pkl文件预测脚本固定一个入口函数新的数据来了只换CSV路径不再动模型代码。希望这一套拆解能让你少走两天弯路照着V1.3那条路把预测流程跑通再按自己的场景去微调。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站