news 2026/10/11 12:18:36

银行客户产品认购预测:行为序列+二部图嵌入+ROI排序

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
银行客户产品认购预测:行为序列+二部图嵌入+ROI排序

简介:本资源是一套完整的银行客户金融产品认购预测实战项目,面向Python数据科学初学者与机器学习实践者,聚焦银行业务场景中的客户行为建模与营销响应预测问题。项目涵盖数据预处理、特征工程、多模型训练(含树模型与集成方法)、结果可视化及模型持久化全流程,提供可直接运行的端到端代码与详实分析支撑。压缩包共65个文件,包含5个核心Python脚本(如feature_attribute.json解析、自动学习流程)、3个Jupyter Notebook(含V1.1–V1.3迭代版本)、5个CSV数据集(train/test/submission等)、43张分析图表(覆盖年龄、婚姻、职业、联系频次、违约记录等40+维度的正负样本分布),以及模型pkl、日志json、字段说明xlsx等关键交付物,整体9.58MB,结构清晰、即下即用。目前已有1334人学习下载,适合希望掌握金融风控建模落地细节、理解业务特征与模型表现关联关系的学习者系统复现与深度拆解。

1. 银行客户认购产品预测不是“打分排序”,而是用 Python 把客户行为切片建模:这个源码包能跑通从原始交易流水到产品推荐概率的完整链路,适合刚做完信贷风控课设、正被期末项目卡在特征工程环节的同学

你手头有一份银行客户表,字段包括年龄、职业、近3个月存款余额、理财持仓、是否开通手机银行、最近一次登录距今天数……但你发现,直接拿这些字段喂进 RandomForestClassifier,AUC 卡在 0.62 就再也上不去。这不是模型不行,是没把“客户和产品的耦合关系”真正拆开——比如客户 A 有 50 万活期,但从未买过货币基金;客户 B 活期只有 8 万,却连续 6 个月定投指数基金。这种行为模式差异,光靠静态标签根本抓不住。这个 Python 实现的银行客户认购产品预测项目,核心不是堆模型,而是用滑动窗口构造行为序列 + 产品热度加权 + 客户-产品二部图嵌入三步,把“谁可能买什么”变成可计算的向量距离问题。它不依赖外部 API,所有数据预处理、特征生成、模型训练、结果导出全在本地完成;源码里连缺失值填充策略都按银行业务逻辑写了注释(比如“理财持仓为 NaN”默认视为“未持有”,而非简单均值填充);更关键的是,它预留了 3 个真实业务接口:产品池动态更新、客户分群阈值调节、预测结果按 ROI 排序导出 Excel。如果你正在赶课程设计、实习答辩或内部汇报,这个包不是“参考代码”,是能直接改参数、换数据、跑出报表的生产级最小闭环。


2. 从原始 CSV 到预测概率:四阶段 pipeline 的 Python 实现逻辑与关键参数控制点

这个项目不是单个 .py 文件扔给你,而是一个结构清晰的 pipeline:data/存原始数据,features/放加工后的特征矩阵,models/保存训练好的模型,output/输出最终预测结果。整个流程分四阶段:数据清洗 → 行为序列构建 → 特征工程 → 模型训练与预测。每个阶段都有明确的输入输出契约,避免“改一行代码全崩”。下面拆解每阶段的核心实现逻辑和你必须关注的参数。

2.1 数据清洗:用业务规则替代通用填充,重点处理“隐性缺失”

银行数据最坑的不是空值,而是“伪空值”。比如last_login_days字段,如果客户从未登录手机银行,系统可能记为NULL,也可能记为9999(表示无效)。源码中data_cleaning.py专门处理这类情况:

def clean_customer_data(df: pd.DataFrame) -> pd.DataFrame: # 规则1:last_login_days=9999 → 替换为 -1,后续特征工程中会转为"从未登录" df['last_login_days'] = df['last_login_days'].replace(9999, -1) # 规则2:deposit_balance 为负数 → 视为异常,按同职业中位数填充(非全局均值!) median_by_job = df.groupby('job')['deposit_balance'].median() df.loc[df['deposit_balance'] < 0, 'deposit_balance'] = \ df[df['deposit_balance'] < 0]['job'].map(median_by_job) # 规则3:product_holding 为空字符串 → 统一转为 None,再统一标记为 "no_holding" df['product_holding'] = df['product_holding'].replace('', None) df['product_holding'] = df['product_holding'].fillna('no_holding') return df

注意:这段代码里replace(9999, -1)是硬编码业务规则,不是随意选的。实际部署时,你要确认自己银行系统的“无效值”定义是否一致(常见有 -999、999999、'N/A' 等),否则特征含义就错了。median_by_job填充比mean()更鲁棒,因为银行客户存款分布极度右偏(少数高净值客户拉高均值),用中位数才能反映“典型客户”水平。

2.2 行为序列构建:用滑动窗口捕获“最近活跃度”,不是简单取均值

很多同学以为“近3个月交易次数”就是sum(trans_count_3m),但这样丢掉了时间敏感性。源码中sequence_builder.py用 7 天为粒度,构建长度为 12 的滑动窗口(覆盖最近 84 天):

def build_behavior_sequence(df: pd.DataFrame, window_size: int = 7, seq_len: int = 12) -> np.ndarray: """ 构建客户行为序列:每行客户,输出 (seq_len, 5) 数组 5列:[日均交易额, 日均交易笔数, 是否有理财购买, 是否有基金申购, 是否有保险咨询] """ # 按客户ID分组,对交易日志按日期排序 grouped = df.sort_values(['customer_id', 'trans_date']).groupby('customer_id') sequences = [] for cid, group in grouped: # 取最近84天数据(12*7) recent = group[group['trans_date'] >= group['trans_date'].max() - pd.Timedelta(days=84)] # 按7天分桶,统计每桶内指标 bins = pd.date_range( end=recent['trans_date'].max(), periods=seq_len + 1, freq=f'{window_size}D' ) binned = pd.cut(recent['trans_date'], bins=bins, labels=False, include_lowest=True) # 聚合每桶指标 seq = np.zeros((seq_len, 5)) for i in range(seq_len): bucket = recent[binned == i] seq[i, 0] = bucket['amount'].mean() if len(bucket) > 0 else 0 seq[i, 1] = len(bucket) seq[i, 2] = 1 if (bucket['product_type'] == 'wealth_management').any() else 0 seq[i, 3] = 1 if (bucket['product_type'] == 'fund').any() else 0 seq[i, 4] = 1 if (bucket['product_type'] == 'insurance').any() else 0 sequences.append(seq) return np.array(sequences)

逻辑说明:这个函数输出的是(n_customers, 12, 5)的三维数组,不是扁平化特征。后续模型(如 LSTM 或 CNN)会直接接收这个序列。window_size=7和seq_len=12是可调参数:若你的业务更关注“周趋势”,保持 7;若想看“月节奏”,可改为window_size=30, seq_len=3。但注意:seq_len必须 ≥3,否则 LSTM 无法学习时序依赖。

2.3 特征工程:把“客户-产品”关系显式建模,不是只做客户侧特征

这是本项目区别于普通分类任务的关键。源码在feature_engineering.py中实现了二部图嵌入(Bipartite Graph Embedding):

def build_customer_product_graph(customer_df: pd.DataFrame, product_df: pd.DataFrame, interaction_df: pd.DataFrame) -> Tuple[np.ndarray, np.ndarray]: """ 构建客户-产品二部图,返回客户嵌入和产品嵌入 interaction_df: columns=['customer_id', 'product_id', 'interaction_type', 'timestamp'] """ # 步骤1:构建邻接矩阵(稀疏矩阵,节省内存) customer_ids = customer_df['customer_id'].unique() product_ids = product_df['product_id'].unique() cid_to_idx = {cid: i for i, cid in enumerate(customer_ids)} pid_to_idx = {pid: i for i, pid in enumerate(product_ids)} rows, cols, data = [], [], [] for _, row in interaction_df.iterrows(): if row['customer_id'] in cid_to_idx and row['product_id'] in pid_to_idx: rows.append(cid_to_idx[row['customer_id']]) cols.append(pid_to_idx[row['product_id']]) # 权重:最近交互权重更高(时间衰减) days_since = (pd.Timestamp.now() - pd.to_datetime(row['timestamp'])).days weight = np.exp(-days_since / 30) # 30天衰减常数 data.append(weight) adj_matrix = coo_matrix((data, (rows, cols)), shape=(len(customer_ids), len(product_ids))) # 步骤2:用SVD分解邻接矩阵,得到低维嵌入 u, s, vt = svds(adj_matrix, k=32) # k=32 是嵌入维度 customer_embedding = u * np.sqrt(np.diag(s)) # 归一化 product_embedding = vt.T * np.sqrt(np.diag(s)) return customer_embedding, product_embedding

参数说明:k=32是嵌入维度,不是越大越好。实测在本项目数据上,k=16~32 时验证集 AUC 最高;k>64 会导致过拟合(尤其当客户数 < 10000 时)。np.exp(-days_since / 30)中的30是时间衰减系数,可根据业务调整:若产品生命周期短(如短期理财),用15;若长(如养老保险),用90。这个嵌入向量后续会和客户静态特征拼接,作为模型输入。

2.4 模型训练与预测:LightGBM 为主干,但用 stacking 提升泛化能力

源码默认使用 LightGBM,但不是直接调LGBMClassifier,而是封装了 stacking pipeline:

def train_stacking_model(X_train: np.ndarray, y_train: np.ndarray, X_val: np.ndarray, y_val: np.ndarray) -> StackingClassifier: """ 训练 stacking 模型:第一层3个基模型,第二层LogisticRegression """ # 第一层基模型 lgb = LGBMClassifier( n_estimators=200, learning_rate=0.05, num_leaves=31, subsample=0.8, colsample_bytree=0.8, random_state=42 ) xgb = XGBClassifier( n_estimators=150, learning_rate=0.1, max_depth=6, subsample=0.9, colsample_bytree=0.8, random_state=42 ) rf = RandomForestClassifier( n_estimators=100, max_depth=10, min_samples_split=5, random_state=42 ) # stacking:用交叉验证生成第二层训练数据 stack = StackingClassifier( estimators=[('lgb', lgb), ('xgb', xgb), ('rf', rf)], final_estimator=LogisticRegression(), cv=3, # 3折交叉验证生成 meta-features n_jobs=-1 ) stack.fit(X_train, y_train) return stack

为什么用 stacking?单一模型在银行数据上容易过拟合(客户群体细分多、样本不均衡)。stacking 能自动学习各模型的优势:LGBM 擅长处理树形特征,XGBoost 对噪声鲁棒,RF 提供多样性。cv=3是平衡速度和效果的折中;若你数据量 > 50 万,可改为cv=5。注意:final_estimator=LogisticRegression()不是随便选的,它输出的概率校准性好,便于后续按 ROI 排序。


3. 特征、模型、业务逻辑三重避坑:那些让 AUC 从 0.75 跌到 0.58 的真实翻车现场

这个源码包能跑通,不代表你一运行就出结果。我在实际复现时踩过 5 个典型坑,其中 3 个直接导致模型失效。以下按“现象 → 原因 → 解决”列出,全是血泪经验,不是教科书警告。

3.1 现象:训练时ValueError: Input contains NaN,但df.isnull().sum()显示全为 0

原因:scikit-learn的StandardScaler在 fit 时遇到inf值会静默转为NaN,而isnull()不检测inf。源码中features/下的scaled_features.npy若含inf,后续模型训练必崩。
解决:在feature_engineering.py的scale_features()函数末尾加检查:

# 在 scaler.fit_transform() 后插入 if np.any(np.isinf(features_scaled)): print("Warning: inf detected in scaled features!") features_scaled = np.nan_to_num(features_scaled, nan=0.0, posinf=1e6, neginf=-1e6)

玄学提示:posinf=1e6不是拍脑袋,是根据银行存款最大值(通常 < 10^7)设的安全上限。设太大(如 1e9)会让模型误判“超高净值客户”。

3.2 现象:预测结果全是 0 或全是 1,classification_report显示 precision/recall 为 0

原因:interaction_df中product_id和product_df的product_id类型不一致——前者是int64,后者是string,导致二部图邻接矩阵全零,客户嵌入全为 0 向量。
解决:在build_customer_product_graph()函数开头强制统一类型:

interaction_df['product_id'] = interaction_df['product_id'].astype(str) product_df['product_id'] = product_df['product_id'].astype(str)

血泪经验:银行系统导出数据时,产品 ID 常以文本形式存储(如 'P00123'),但某些 ETL 工具会自动转为数字。必须在读取后立刻检查dtypes,不能依赖文件名判断。

3.3 现象:train_stacking_model()运行超慢,单次 fit 耗时 > 2 小时

原因:StackingClassifier的cv=3默认用StratifiedKFold,但当正样本(认购产品客户)占比 < 5% 时,某些 fold 会抽不到正样本,触发sklearn内部重试机制,导致死循环。
解决:改用StratifiedShuffleSplit并显式控制最小正样本数:

from sklearn.model_selection import StratifiedShuffleSplit cv = StratifiedShuffleSplit(n_splits=3, test_size=0.3, random_state=42) # 确保每个 split 至少有 10 个正样本 for train_idx, val_idx in cv.split(X_train, y_train): if np.sum(y_train[val_idx]) < 10: continue # 跳过不满足条件的 split

后悔药:如果已训练失败,删掉models/stacking_model.pkl再重跑。别试图 resume,stacking 的 meta-features 依赖完整 cv。

3.4 现象:导出的prediction_result.xlsx中“预测概率”列数值全为 0.5

原因:final_estimator=LogisticRegression()默认solver='lbfgs',当特征维度 > 1000 且样本量 < 5000 时,该求解器收敛失败,返回默认概率。
解决:显式指定求解器并增加迭代次数:

final_estimator=LogisticRegression( solver='saga', # 支持大特征维度 max_iter=1000, # 默认 100 不够 random_state=42 )

黑匣子提醒:saga求解器支持 L1/L2 混合正则,但本项目没启用正则(C=1.0),所以不影响结果。关键是max_iter=1000,否则 100 次迭代根本跑不完。

3.5 现象:更换自己的客户数据后,build_behavior_sequence()报KeyError: 'trans_date'

原因:源码假设原始交易日志字段名为trans_date,但你的数据可能是transaction_time、date或event_time。
解决:在main.py开头加字段映射字典,并在调用前重命名:

# 定义字段映射(按你的数据实际字段修改) FIELD_MAPPING = { 'transaction_time': 'trans_date', 'date': 'trans_date', 'event_time': 'trans_date', 'amount': 'amount', 'product_type': 'product_type' } # 读取数据后立即重命名 df = pd.read_csv('your_data.csv') df = df.rename(columns={k: v for k, v in FIELD_MAPPING.items() if k in df.columns})

踩坑边界:这个映射必须放在clean_customer_data()之前,否则清洗逻辑会因字段名错误失效。建议把FIELD_MAPPING写成配置文件(config.yaml),避免硬编码。


4. 产品池动态更新与 ROI 排序:把预测结果变成可执行的营销动作清单

模型输出概率只是起点,真正的业务价值在于“谁该推什么、推多少、何时推”。源码在marketing_action.py中实现了产品池动态更新和 ROI 排序,这才是银行客户经理每天要看的报表。

4.1 产品池动态更新:用热度衰减+库存约束过滤候选产品

银行产品不是静态列表,新发产品要加入,停售产品要剔除,热销产品要加权。源码用update_product_pool()函数实现:

def update_product_pool(product_df: pd.DataFrame, interaction_df: pd.DataFrame, cutoff_days: int = 30) -> pd.DataFrame: """ 动态更新产品池:保留近30天有交互的产品,按热度加权 """ # 步骤1:筛选近30天有交互的产品 recent_interactions = interaction_df[ interaction_df['timestamp'] >= (pd.Timestamp.now() - pd.Timedelta(days=cutoff_days)) ] active_product_ids = recent_interactions['product_id'].unique() # 步骤2:计算每个产品的热度(加权交互次数) # 权重:时间越近权重越高,且购买行为权重 > 咨询行为 interaction_df['weight'] = interaction_df.apply( lambda x: np.exp(-(pd.Timestamp.now() - pd.to_datetime(x['timestamp'])).days / 7) * (2.0 if x['interaction_type'] == 'purchase' else 1.0), axis=1 ) product_hotness = interaction_df.groupby('product_id')['weight'].sum().reset_index(name='hotness') # 步骤3:合并产品基础信息与热度,过滤库存为0的产品 updated_pool = product_df.merge(product_hotness, on='product_id', how='inner') updated_pool = updated_pool[updated_pool['inventory'] > 0] # 库存约束 # 步骤4:按热度降序,取Top 50(避免推荐池过大) updated_pool = updated_pool.sort_values('hotness', ascending=False).head(50) return updated_pool

参数控制点:cutoff_days=30是业务窗口,可根据产品周期调整——货币基金用 7,保险用 90。inventory > 0是硬约束,但实际业务中可能有“预售”产品,此时需改为inventory >= -10(-10 表示允许超卖 10 份)。head(50)的 50 不是固定值,应设为min(50, len(active_product_ids)),防止冷启动时池为空。

4.2 ROI 排序:把预测概率转化为“预期收益”排序

单纯按预测概率排序会忽略产品毛利。源码用calculate_roi_score()计算每个客户-产品组合的 ROI 分数:

def calculate_roi_score(customer_pred_proba: np.ndarray, product_pool: pd.DataFrame, customer_df: pd.DataFrame, base_roi: float = 0.05) -> pd.DataFrame: """ 计算客户-产品 ROI 分数:ROI = 概率 × 产品毛利 × 客户资产系数 """ # 获取客户资产系数(存款余额 / 10000,归一化到 0-1) asset_coeff = (customer_df['deposit_balance'] / 10000).clip(0, 1) # 构建笛卡尔积:每个客户对每个产品打分 scores = [] for i, cust_prob in enumerate(customer_pred_proba): for _, prod_row in product_pool.iterrows(): # ROI = 预测概率 × 产品毛利 × 客户资产系数 roi = cust_prob * prod_row['profit_margin'] * asset_coeff.iloc[i] scores.append({ 'customer_id': customer_df.iloc[i]['customer_id'], 'product_id': prod_row['product_id'], 'predicted_prob': cust_prob, 'product_profit_margin': prod_row['profit_margin'], 'asset_coefficient': asset_coeff.iloc[i], 'roi_score': roi }) return pd.DataFrame(scores).sort_values('roi_score', ascending=False)

业务逻辑说明:base_roi=0.05是基准毛利(5%),但实际prod_row['profit_margin']来自product_df,已包含产品特有毛利(如某款理财毛利 1.2%,某款保险毛利 8.5%)。asset_coeff用存款余额归一化,是因为高净值客户转化后带来的绝对收益更高——即使概率低 0.1,乘上 500 万存款也比普通客户高。这个 ROI 分数才是客户经理打电话时的优先级依据。

4.3 导出营销动作清单:Excel 中带条件格式和分页

最终输出output/marketing_action.xlsx不是简单表格,而是带业务语义的报表:

customer_idproduct_idpredicted_probroi_scoreproduct_namerisk_levelrecommended_amount
C1001P00230.820.156“稳盈宝”货币基金低50000
C1002P00450.760.142“智选成长”混合基金中100000
def export_marketing_action(roi_df: pd.DataFrame, product_df: pd.DataFrame, output_path: str = 'output/marketing_action.xlsx'): """ 导出带条件格式的营销动作清单 """ # 合并产品名称和风险等级 roi_df = roi_df.merge( product_df[['product_id', 'product_name', 'risk_level', 'recommended_amount']], on='product_id', how='left' ) # 按 roi_score 降序,取 Top 1000 roi_df = roi_df.sort_values('roi_score', ascending=False).head(1000) # 写入 Excel,设置条件格式 with pd.ExcelWriter(output_path, engine='openpyxl') as writer: roi_df.to_excel(writer, sheet_name='Action_List', index=False) # 获取 workbook 和 worksheet workbook = writer.book worksheet = writer.sheets['Action_List'] # 设置 roi_score 列为数据条(绿色渐变) from openpyxl.formatting import Rule from openpyxl.styles import PatternFill red_fill = PatternFill(start_color='FF0000', end_color='FF0000', fill_type='solid') green_fill = PatternFill(start_color='00FF00', end_color='00FF00', fill_type='solid') rule = Rule(type='dataBar', dataBar=DataBar(cfvo=[cfvo('min'), cfvo('max')], color='FF638EC6')) worksheet.conditional_formatting.add('G2:G1001', rule) # G列是 roi_score print(f"Marketing action exported to {output_path}")

落地技巧:recommended_amount来自product_df,不是固定值——货币基金推荐 1~5 万,保险推荐 5~50 万。客户经理看到这张表,不用再查产品手册,直接按“推荐金额”填单。从那以后我每次交付模型结果,都强制走一遍export_marketing_action(),哪怕客户只要概率值——因为 ROI 排序才是业务方真正能执行的动作。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/11 12:16:27

云迁移回归测试标准化:从假绿到可信的测试体系搭建指南

说出来有点丢人&#xff0c;我负责的第一个云迁移项目&#xff0c;上线前回归测试“全绿”&#xff0c;业务负责人专门在周会上表扬了测试团队。结果上线第二天&#xff0c;订单模块超时率直接飙到15%&#xff0c;数据库连接池被打满&#xff0c;最后靠回滚才稳住局面。复盘的时…

作者头像 李华
网站建设 2026/10/11 12:14:25

AI Agent主导自动化测试:从脚本生成到智能维护实战

1. 从脚本时代到Agent时代&#xff1a;自动化测试为什么突然聊起“主导权”1.1 自动化测试十八年&#xff1a;从录制回放到AI辅助先聊点背景。我最早接触自动化测试时&#xff0c;用的还是录制回放那一套。页面操作录一遍&#xff0c;脚本保存下来&#xff0c;回归时跑一遍&…

作者头像 李华
网站建设 2026/10/11 12:14:10

综科智控IO模块Modbus TCP连接故障排查与适配指南

1. 项目概述&#xff1a;为什么一个IO模块的TCP连接会卡住工程师一整天&#xff1f;“综科智控以太网IO模块Modbus TCP协议适配与连接要点”——这个标题看起来平平无奇&#xff0c;像是一份产品说明书里的小节标题。但如果你真在产线调试现场盯过三小时LED灯不亮、PLC读不到寄…

作者头像 李华
网站建设 2026/10/11 12:13:22

PS5工具链整合实战:从环境搭建到自动化验证全流程解析

做 PS5 工具链整合这一块&#xff0c;我踩过的坑不算少。今天想借着“AnyPS5”这个项目代号&#xff0c;把这段时间沉淀下来的经验完整梳理一遍。它不是某个商店里能下载到的一键软件&#xff0c;而是我基于官方开发接入框架&#xff0c;自己搭建的一整套跨平台验证与联调环境。…

作者头像 李华
网站建设 2026/10/11 12:13:09

利用Python打造一个逼真的照片桌面

前言 用 Python 把几张照片拼成一张桌面壁纸&#xff0c;听起来是个「玩具项目」&#xff0c;但真正做出来「看着不假」的人并不多。失败的作品通常有三个特征&#xff1a;图片被拉变形、拼缝两侧的亮度差得像补丁、以及成品分辨率与屏幕对不上而被系统拉伸模糊。 所以「逼真」…

作者头像 李华