ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

信用卡风险评估四模型:从申请评分到欺诈识别的Python实现

信用卡风险评估四模型:从申请评分到欺诈识别的Python实现

简介:面向模式识别课程与银行信贷风控场景,这套项目方案围绕申请人评级、行为评级、收款(催收)评级和欺诈评级四个任务,分别解决新客户违约可能性评估、历史行为信用走势预测、逾期后催收策略制定以及申请环节欺诈识别等问题;实现过程完整覆盖数据理解、缺失值与异常值处理、重复值处理、特征选择与可视化、降维处理、随机森林建模,以及基于交叉验证的准确度和稳定性评估,并给出优化建议,适合金融风控方向的高校学生或入门从业者用于课程设计、实验复现或业务参考。压缩包共18个文件,包含8个csv数据文件(原始数据与处理后样本)、4个Python脚本(四个评级模型分别实现)、4个pkl模型文件(训练好的模型参数)、1个ttf字体文件(用于图表中文显示)和1个Markdown说明文档(记录项目流程与结论),总大小仅7.67MB。目前已有130人学习下载,项目代码可直接运行,文档明确每个模型的目标与功能,读者可对照源码理解从数据预处理到模型评估的全链路,还能基于评估指标和改进建议继续调优,或将自有数据套入现有框架开展扩展实验。

1. 风险评估模型设计:为什么一张信用卡需要四个评级模型

做信用卡风控的同学应该都遇到过这种场景:同一个客户,申请进件的时候评分很高,发卡后前三个月还款也正常,结果第六个月突然连续逾期,催收团队打电话过去才发现人已经换了工作、联系方式全变。问题出在哪?出在只做了一个申请评分卡,没有把客户的完整生命周期拆开看。模式识别课程里最难啃的作业之一,就是让风险评估评级模型真正落地成可运行的 python 源码,而这份资源恰好就是一套完整的银行信用卡风险评估模型设计,把客户从申请、用卡、逾期到欺诈识别拆成四个模型:申请人评级模型、行为评级模型、收款评级模型(催收评级模型)和欺诈评级模型。每个模型有各自的数据输入、目标变量和业务动作,而不是一把抓进一个黑匣子里。对正在做课程设计、或者刚入行想看看风控建模全流程的人来说,这份资源的价值在于它把「数据预处理 → 模型构建 → 模型评估」走了一遍完整闭环,而且用的都是 python 生态里常见的库,拿到手就能改、能跑、能答辩。

2. 数据预处理与分析:先把原始字段焐热,再谈四个模型

2.1 理解数据:风险字段长什么样,目标变量怎么定

信用卡风险评估的第一步从来不是建模,而是把数据读进来、看清每一列是什么类型、缺失到什么程度、分布有多偏。项目里的原始数据存放在file_list和file_processed目录下,前者是未清洗的原始文件,后者是处理好的特征表。我拿到数据后第一件事就是跑info()和describe(),弄清楚每个字段是数值型还是类别型。比如申请人评级模型的输入通常是年龄、收入、职业、负债比、历史逾期次数;行为评级模型则需要还款记录、账单金额、逾期天数这类带时间窗口的字段。

import pandas as pd df = pd.read_csv('credit_application.csv') print(df.info()) print(df.describe(include='all'))

info()能直接看到每列的非空数量和数据类型,describe(include='all')会同时输出数值列的均值、分位数和类别列的频次、唯一值数量,方便我一眼定位到「哪些列有明显缺失」「哪些列是字符串但应该转数值」。这里有个经验:目标变量最好单独定义清楚,申请人评级模型的目标是「是否违约」,行为评级模型的目标是「未来 3 个月是否逾期」,收款评级模型的目标是「违约后的回收概率」,欺诈评级模型的目标是「是否欺诈」。这四件事用的标签完全不同,后面每个模型训练前要先确认y有没有取错。

2.2 缺失值、重复值与异常值处理:pandas 三板斧

数据预处理阶段最容易翻车的不是算法,而是数据质量。缺失值方面,数值列我一般用中位数填充,不用均值,因为收入、逾期金额这类字段往往有长尾,均值会被极端值拉偏。类别列用众数填充,但众数也可能有多个,所以要取第一个。重复值直接用drop_duplicates()删掉,但要小心:同一客户在不同时间点的行为记录可能是合法的重复,不能一刀切,这时候必须按customer_id + 时间戳来判重。异常值用 IQR(四分位距)法检测,超过上下限的样本通常是录入错误或者极端客户,先剔除再建模。

# 数值列:中位数填充 num_cols = df.select_dtypes(include=['int64', 'float64']).columns df[num_cols] = df[num_cols].fillna(df[num_cols].median()) # 类别列:众数填充,取第一个 cat_cols = df.select_dtypes(include=['object']).columns df[cat_cols] = df[cat_cols].fillna(df[cat_cols].mode().iloc[0]) # 重复值删除 df = df.drop_duplicates() # 异常值检测与剔除(IQR 法) Q1 = df[num_cols].quantile(0.25) Q3 = df[num_cols].quantile(0.75) IQR = Q3 - Q1 df = df[~((df[num_cols] < (Q1 - 1.5 * IQR)) | (df[num_cols] > (Q3 + 1.5 * IQR))).any(axis=1)]

这里有几个参数可以调:IQR 的倍数 1.5 是默认标准,如果业务上认为极端客户也有价值,可以放宽到 3;但如果你做的是欺诈模型,异常值反而可能是欺诈信号,这时候更推荐用「分位数截断」而不是「剔除」。逻辑很简单:申请人评级模型里,IQR 剔除的是中间群体之外的极端申请人,这些人在业务上本来就不该获批;但欺诈模型里,异常行为恰恰是建模信号,所以预处理策略必须跟着模型目标走,不能一套逻辑通吃所有模型。

2.3 特征选择与可视化:SimHei.ttf 解决中文乱码,SelectKBest 筛特征

数据清洗完了,接下来要决定哪些列进模型。项目里带了一个SimHei.ttf字体文件,这就是给 matplotlib 可视化用的——很多同学在自己的电脑上画图中文变方块,就是因为系统字体里没有中文字体,matplotlib 默认调不到。正确做法是用FontProperties显式指定字体路径,项目里这个文件就放在根目录,直接引用。

import matplotlib.pyplot as plt from matplotlib.font_manager import FontProperties font = FontProperties(fname='SimHei.ttf') plt.rcParams['font.family'] = font.get_name() # 特征选择 from sklearn.feature_selection import SelectKBest, chi2 X = df.drop('target', axis=1) y = df['target'] # chi2 要求特征非负,先做 MinMax 或离散化 from sklearn.preprocessing import MinMaxScaler X_scaled = MinMaxScaler().fit_transform(X) selector = SelectKBest(chi2, k=10) X_selected = selector.fit_transform(X_scaled, y) print('selected feature indices:', selector.get_support(indices=True))

SelectKBest的k参数控制保留几个特征,这里取 10 是看数据量拍的经验值;chi2适合类别型特征,如果全是数值特征,用f_classif会更稳。特征选择的目的不是提高精度,而是减少后面模型训练时的噪音,特别是行为评级模型里如果按时间窗口展开特征,维度可能到几十上百,先筛一轮能明显加速交叉验证。做完这一步,file_processed目录下应该多出几个处理干净的 CSV,后面四个模型都从这份数据上取数。

3. 申请人评级模型与行为评级模型:随机森林与降维的组合

3.1 申请人评级模型:用随机森林给新客户打信用分

申请人评级模型要回答的问题是:一个新客户来申请信用卡,批不批?目标变量是历史样本里「发卡后 12 个月内是否发生违约」。这种问题优先选随机森林,因为它在小样本、高维、非线性关系下表现稳定,能输出违约概率,还自带特征重要性,课程设计里展示「哪些字段对评分影响最大」非常方便。项目里applicant_rating.py就是用随机森林做的,我把训练流程拆成标准四步:切分、标准化、训练、评估。

from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X_train, X_test, y_train, y_test = train_test_split( X_selected, y, test_size=0.2, random_state=42 ) scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) model = RandomForestClassifier( n_estimators=100, max_depth=5, random_state=42 ) model.fit(X_train_scaled, y_train) print('train acc:', model.score(X_train_scaled, y_train)) print('test acc:', model.score(X_test_scaled, y_test))

注意test_size=0.2是说留 20% 的样本做测试集,random_state=42是随机种子,固定下来才能让结果可复现。标准化在这里其实不是必需的——树模型不依赖特征尺度,但如果你后面接 PCA 降维,就一定要先标准化。max_depth=5是我用来防过拟合的,默认的不剪枝树在风控场景里很容易把训练集 AUC 做到 1.0,但实际线上效果一塌糊涂,所以树深度一定要控制。训练结束后,可以打印model.feature_importances_看每个特征的重要性排序,申请人评级模型里通常收入、负债比、征信查询次数这几个排在最前面。

3.2 行为评级模型:时序行为特征怎么构造

行为评级模型和申请人评级模型最大的区别是特征构造方式。申请模型用的是静态的「截面数据」,行为模型用的是客户过去 6 到 12 个月的行为序列。原始还款记录是一行行的事件,不能直接丢给训练器,必须先按客户聚合出滚动窗口特征。常见做法是:最近 3 个月还款次数、最近 6 个月最大逾期天数、最近 3 个月账单使用率均值、最近 1 个月最低还款比例。这些特征用groupby + rolling就能算出来。

# 假设 df_behavior 是还款记录,每行是一笔账单 df_behavior = df_behavior.sort_values(['customer_id', 'bill_month']) # 最近 3 个月还款次数 df_behavior['recent_3m_pay_count'] = ( df_behavior.groupby('customer_id')['payment_flag'] .rolling(3, min_periods=1).sum() .reset_index(level=0, drop=True) ) # 最近 6 个月最大逾期天数 df_behavior['max_overdue_days_6m'] = ( df_behavior.groupby('customer_id')['overdue_days'] .rolling(6, min_periods=1).max() .reset_index(level=0, drop=True) ) # 聚合到客户维度,一个客户一条样本 customer_feat = df_behavior.groupby('customer_id').agg({ 'recent_3m_pay_count': 'last', 'max_overdue_days_6m': 'last', 'bill_amount': 'mean', 'payment_amount': 'sum', }).reset_index()

rolling(3)里的窗口大小是个关键参数:3 个月能捕捉短期还款习惯,6 个月能捕捉中期趋势,更长窗口会丢失时效性。min_periods=1表示窗口内至少要有 1 条记录才计算,避免前几个月没有数据时直接把结果变成 NaN。这一段的坑我后面会单独讲——很多人直接把原始还款记录丢进随机森林,训练集和测试集里的同一个客户样本相互关联,造成数据泄漏,AUC 虚高到 0.99 但线上完全不能用。

3.3 降维:PCA 在高维特征上的作用

行为评级模型聚合出几十维特征后,如果还想加入更多衍生变量,维度会迅速膨胀。项目摘要里明确提到了降维处理,我在实际跑的时候用的是 PCA。树模型其实不怕高维,但 PCA 可以帮你去掉冗余特征,让模型训练更快,而且能画「累计方差贡献率」曲线,正好是课程设计里一个很好的可视化素材。

from sklearn.decomposition import PCA import numpy as np pca = PCA(n_components=20, random_state=42) X_pca = pca.fit_transform(X_train_scaled) X_test_pca = pca.transform(X_test_scaled) print('累计方差贡献率:', np.sum(pca.explained_variance_ratio_)) print('各主成分贡献率:', pca.explained_variance_ratio_)

n_components我取 20 是因为看到前 20 个主成分能解释大约 85% 到 90% 的方差,这个值不是固定的,你可以用循环尝试 10、20、30,找到一个拐点。注意一点:PCA 会破坏特征可解释性,如果你答辩时需要展示「哪个原始特征最重要」,就不要对整个训练集做 PCA,而是先用随机森林的特征重要性筛一轮,再用 PCA 压缩剩余特征。项目里python_mindspore目录的存在说明这份设计最初是基于 MindSpore 框架写的,但核心算法依然是随机森林加降维这套经典组合,我在复现时直接改成了 scikit-learn 版本,代码更短、调试更顺。

3.4 模型评估:交叉验证与 ROC

申请人评级模型和行为评级模型都属于二分类问题,直接看 accuracy 会被不平衡样本骗。违约客户通常只占 5%~10%,只要把所有人都预测成正常还款,准确率也能到 90% 以上,所以一定要用 AUC(ROC 曲线下面积)来评估。AUC 的含义是「随机抽一个违约客户和一个正常客户,模型能正确把违约客户排在前面的概率」,0.5 是瞎猜,0.7 勉强可用,0.8 以上才谈得上业务应用。

from sklearn.model_selection import cross_val_score from sklearn.metrics import roc_auc_score, roc_curve cv_scores = cross_val_score(model, X_train_scaled, y_train, cv=5, scoring='roc_auc') print('CV AUC: %.4f (+/- %.4f)' % (cv_scores.mean(), cv_scores.std())) y_pred_prob = model.predict_proba(X_test_scaled)[:, 1] print('Test AUC:', roc_auc_score(y_test, y_pred_prob))

cv=5是五折交叉验证,把训练集切成 5 份,轮流用 4 份训练、1 份验证,这样评估出来的 AUC 比单次切分更稳定。roc_auc_score接收的是predict_proba输出的第二列——也就是正类的概率,不是predict的硬分类结果。这里还要注意,交叉验证必须在数据预处理(比如填充、标准化)之后做,而且如果用了 SMOTE 这类过采样,一定要在每一折内部、划分完训练集后再做,否则验证集里会混入合成样本,评估结果虚高。这套流程跑完,申请人评级模型和行为评级模型的 AUC 基本能稳定在 0.75 到 0.85 之间,后续的调参再往上提。

4. 收款评级模型与欺诈评级模型:催收顺序与反欺诈怎么落地

4.1 收款评级模型(催收评级):分级催收策略如何设定

收款评级模型又叫催收评级模型,它解决的不是「批不批卡」,而是「客户已经出现逾期,按什么顺序去催、用什么力度的方式去催」。项目里Collection_rating.py做的事就是预测客户的违约概率,然后把概率映射到催收策略上:预测概率超过 0.8 的客户,直接走委外或法务;0.6~0.8 的,电话催收;0.3~0.6 的,短信提醒;小于 0.3 的,等下一个账单周期再看。这个映射用pd.cut就能做。

import pandas as pd # y_pred_prob 是模型输出的违约概率,假设已经预测出来了 bins = [0, 0.3, 0.6, 0.8, 1.0] labels = ['低风险', '中低风险', '中高风险', '高风险'] df_collection['催收等级'] = pd.cut(y_pred_prob, bins=bins, labels=labels) # 查看各等级客户占比 print(df_collection['催收等级'].value_counts())

bins的切分点是业务经验值,不是算法算出来的,实际业务里会根据催收成本和回收金额做损失矩阵优化。如果你想把阈值定得更科学,可以按「催收成本 = 触达成本 + 人力成本,回收收益 = 预测回收金额 × 概率」来算期望收益,在期望收益最高点找最优切分。收款评级模型的训练标签通常是「逾期后 90 天内是否回收成功」,注意这和历史逾期标签不同——同样是违约客户,有人催得回来,有人催不回来,模型要学的是回收概率,不是违约概率。

4.2 欺诈评级模型:不平衡样本下的欺诈识别

欺诈评级模型是四个模型里最特殊的一个,因为欺诈样本在真实业务里占比极低,可能千分之一都不到。直接喂随机森林的话,模型会学成「把所有客户都预测成非欺诈」,因为这样准确率已经高达 99.9%。解决思路有两个:一是用class_weight='balanced',让少数类的错误惩罚权重自动加大;二是用 SMOTE 合成少数类样本,把欺诈样本量补上来。项目里Fraud_rating.py的做法是两种都试,我一般建议先试class_weight,它能避免 SMOTE 可能引入的合成噪音。

from sklearn.ensemble import RandomForestClassifier # 方案一:通过类别权重处理不平衡 model_fraud = RandomForestClassifier( n_estimators=100, class_weight='balanced', random_state=42 ) model_fraud.fit(X_train, y_train_fraud) # 方案二:SMOTE 过采样 from imblearn.over_sampling import SMOTE smote = SMOTE(random_state=42, sampling_strategy='auto') X_res, y_res = smote.fit_resample(X_train, y_train_fraud) print('过采样后欺诈样本数量:', y_res.sum())

sampling_strategy='auto'的意思是让少数类过采样到和多数类一样多,如果你不想完全均衡,可以设置成0.5,表示过采样后少数类是多数类的一半。这里有一个非常重要的原则:SMOTE 只能在训练集上做,而且要在交叉验证的每一折内部分别做,不能在train_test_split之前对整个数据集做。否则训练集和验证集里会出现重复或合成的样本,验证 AUC 虚高,等你把模型部署上线,真实欺诈概率分布一变,立刻打回原形。欺诈模型的特征和其他模型也不一样,除了客户基本信息,还会用设备号、IP 地址、下单时段、关联账户数量等强欺诈信号,这部分在项目数据集里如果字段不够,可以自己构造简单的交叉特征,比如「同一设备关联账户数」和「同一 IP 关联账户数」。

4.3 四个模型的工程组织:从源文件看整体结构

项目根目录下放了applicant_rating.py、Behavior_rating.py、Collection_rating.py、Fraud_rating.py四个训练脚本,外加model目录存放训练好的模型文件、file_processed目录存放处理后的特征数据。这个划分很清晰,每个模型一个脚本,独立训练、独立评估,最后在答辩时按「申请 → 行为 → 催收 → 欺诈」四条线分别讲,逻辑非常顺。我自己复现的时候做了一个小改动:把数据加载、缺失值填充、特征选择这些公共函数抽到一个common.py里,四个脚本调用同一个函数,避免修改一处逻辑要同步改四个文件。

# common.py import pandas as pd def load_and_clean(path): df = pd.read_csv(path) # 这里放你的缺失值、重复值、异常值处理逻辑 return df def get_feature_and_target(df, target_col, feature_cols): X = df[feature_cols] y = df[target_col] return X, y

这样组织的好处是,后续做参数调优时只需要改common.py或者某个模型脚本里的参数,不用动其他文件。另一个经验是:所有随机种子统一放在一个配置文件里,比如SEED = 42,保证每次运行结果一致。课程设计答辩时老师问「为什么结果可复现」,你直接说「我固定了随机种子」,比解释一堆原理更让人信服。

5. 避坑与常见问题:跑通这套源码的四个翻车现场

5.1 SimHei.ttf 中文乱码

现象:用 matplotlib 画特征分布图、相关性热力图时,所有中文标签显示成方块,英文和数字正常。

原因:matplotlib 默认字体 fallback 到 DejaVu Sans,不支持中文。项目里的SimHei.ttf在根目录,但你没有把它传给 matplotlib 的字体管理器,matplotlib 根本不知道有这个字体文件存在。

解决:显式指定字体路径,而不是修改全局配置。用FontProperties(fname='SimHei.ttf')创建字体对象,传给每个需要显示中文的文本元素,或者更省事的是在脚本开头用plt.rcParams['font.family'] = font.get_name()直接设成全局。如果加载后发现字形还是不对,检查一下文件路径是相对路径还是绝对路径,最好用os.path.join(os.path.dirname(__file__), 'SimHei.ttf')拼出完整路径。

5.2 pandas 版本差异导致 fillna 行为变化

现象:运行df.fillna(method='ffill')时出现 FutureWarning,或者结果和项目 README 里描述的不一致。

原因:项目是基于某个 pandas 版本写的,而新版本 pandas 已经移除了method参数,推荐直接调用df.ffill()或df.bfill()。同一份源码在不同环境下跑,预处理结果可能不一样,这是课程设计复现时最常见的坑。

解决:把df.fillna(method='ffill')直接改成df.ffill(),把bfill同理。如果你想统一版本,可以在requirements.txt里固定pandas==1.5.3这样的版本号。我个人的习惯是用pd.isna()检查缺失值,而不是依赖具体填充参数,因为不同版本的默认行为可能有偏差。

5.3 随机森林输入包含字符串特征直接报错

现象:模型fit的时候抛ValueError: could not convert string to float,或者一直跑不进去。

原因:随机森林底层基于 CART 树,每个节点做数值比较,不能直接处理字符串。很多人读了 CSV 后忘了把性别、职业这类列做编码,直接塞进训练器。

解决:先检查X.dtypes,把所有 object 类型列做编码。有序类别(如学历)用LabelEncoder,无序类别(如职业)用pd.get_dummies或OneHotEncoder。注意独热编码后特征维度会膨胀,特别是职业这种类别数很多的,编码完可以再用SelectKBest筛一遍。另外,pd.get_dummies会让训练集和测试集的特征列不一致,记得用fit_transform和transform分开处理,或者reindex对齐列名。

5.4 行为评级模型的数据泄漏

现象:行为评级模型的 AUC 高达 0.99,测试集表现也好,但拿新的一个月数据验证时,效果断崖式下跌。

原因:把「未来信息」放进了特征里。比如用payment_flag的滚动求和预测下个月是否逾期,但payment_flag本身就是这个月的还款结果,已经包含了下个月的违约信息。或者同一个客户的时序样本被切进训练集和测试集,模型直接认出了这个客户,而不是学到了违约规律。

解决:严格定义特征窗口和目标时间窗。比如要预测第 12 个月的逾期状态,只能用第 1 到第 11 个月的数据做特征。所有滚动特征计算要提前在时间维度上 shift 一个月,然后按客户聚合。切分数据时用「按客户切分」而不是「按行切分」,保证同一个客户的所有样本只出现在训练集或只出现在测试集里。我习惯在训练前打印出特征的时间范围,确认特征最大月份 < 目标月份,这一步能挡住大部分数据泄漏。

6. 进阶:把四个模型串成一个风控流程,并用 PSI 验证稳定性

四个模型单独跑通只是第一步,真正的价值在于串成一条流程:新客户申请时先用申请人评级模型和欺诈评级模型同时打分,欺诈分高的直接拒绝;正常批卡的客户进入行为评级模型的监控名单,一旦行为分下滑立刻触发预警;如果客户逾期,收款评级模型给出催收等级,分配不同力度的话务资源。

from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.ensemble import RandomForestClassifier # 将标准化和模型封装成 pipeline,方便复用和保存 pipeline = Pipeline([ ('scaler', StandardScaler()), ('classifier', RandomForestClassifier(n_estimators=100, random_state=42)) ]) pipeline.fit(X_train, y_train) # 保存模型 import joblib joblib.dump(pipeline, 'model/applicant_model.pkl')

Pipeline的价值在于把预处理和模型绑定,新数据进来只需要调用pipeline.predict_proba,不用再单独做标准化。模型保存用joblib.dump,加载用joblib.load,这是 scikit-learn 官方推荐的方式,比 pickle 更适合 numpy 数组。

模型上线前还有一个容易被忽略的验证:PSI(群体稳定性指标)。风控模型训练时的分布和上线后的真实分布往往有差异,PSI 就是用来量化这种差异的。经验阈值是小于 0.1 表示稳定,0.1~0.25 表示有轻度漂移需要关注,大于 0.25 表示模型已经明显失效。

def calculate_psi(expected, actual, bins=10): # 将两个分布切分到相同分位点 breakpoints = pd.qcut(expected, q=bins, retbins=True)[1] expected_dist = pd.cut(expected, bins=breakpoints, include_lowest=True).value_counts().sort_index() / len(expected) actual_dist = pd.cut(actual, bins=breakpoints, include_lowest=True).value_counts().sort_index() / len(actual) psi = ((actual_dist - expected_dist) * np.log(actual_dist / expected_dist)).sum() return psi

bins=10表示把得分分成 10 段,分别计算训练样本和线上样本在各段的占比差异。实践中我每个月都会用这个函数跑一遍四个模型的得分,哪个模型的 PSI 超了 0.25 就重训哪个。从那以后我每次做完一套风控建模,都会强制走一遍「数据清洗 → 特征时间窗检查 → 类别编码 → 交叉验证 → PSI 验证」这五步,尤其是时间窗检查,已经变成肌肉记忆了。这份资源的四个模型正好覆盖了这条流程的每一个环节,值得下载下来逐个脚本跑通,再替换成你自己的数据。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进