☰
模式识别课程设计:Python信用卡风控四套评级模型实战
2026/9/26 14:03:02 网站建设 项目流程

简介:本资源面向模式识别课程学习者和金融风控入门者,围绕银行信用卡场景设计了一套完整的风险评估模型方案,涵盖申请人评级、行为评级、收款(催收)评级与欺诈评级四个方向,帮助读者理解从数据到模型落地的全流程。压缩包共18个文件,约7.67MB,包含8个csv数据文件、4个Python源码、4个pkl模型文件,以及字体与说明文档,分别用于数据存储、模型训练、结果复现与运行说明。项目按数据预处理与分析、模型构建、模型评估与优化三阶段推进,涉及缺失值与异常值处理、特征选择、可视化、降维及随机森林等机器学习方法,并对准确度与稳定性进行交叉验证。已有130人学习,适合希望掌握风控建模思路、对照源码复现实验或完成课程设计的读者参考。

1. 模式识别课程设计里的信用卡风控:四套评级模型到底在评什么

银行信用卡业务里,风险从来不是一个单点问题。申请那一刻要判断这个人该不该发卡、给多少额度,这叫申请人评级;发卡之后要盯着他会不会逾期、什么时候会逾期,这叫行为评级;真逾期了要判断这笔账还能不能收回来、该投入多少催收资源,这叫收款评级(催收评级);还有一类人从一开始就是来骗钱的,申请材料、消费行为全是伪造的,这需要欺诈评级单独处理。模式识别课程把这四个场景打包成一个项目,用 Python 从数据到模型跑一遍,本质上是在训练一种能力:面对一个真实的业务问题,知道该选什么特征、用什么模型、怎么评估、怎么解释结果。这套东西适合正在做课程设计的学生,也适合想从零搭一套风控评分卡原型的工程师。热搜里“模式识别”“python”“风险评估模型”“申请人评级模型”“欺诈评级模型”这几个词,恰好对应了从特征工程到模型落地的完整链路。下面按实际动手的顺序拆开讲,不绕弯子。

2. 四套评级模型的数据底座:特征怎么选、标签怎么定

2.1 申请人评级与行为评级的特征差异

申请人评级发生在贷前,能拿到的只有申请表单和外部征信数据。常见特征包括年龄、收入、职业类型、学历、婚姻状况、居住时长、工作年限、已有信用卡数量、历史逾期次数、近半年查询次数。这些特征的特点是静态、稀疏、缺失率高。行为评级发生在贷后,数据来自账户交易流水和还款记录,特征变成动态的:近 3 个月平均使用额度、近 6 个月逾期天数、还款率、取现比例、消费商户类型分布。行为评级的标签通常是“未来 6 个月是否逾期 30 天以上”,申请人评级的标签是“放款后 12 个月内是否发生 90 天以上逾期”。

选特征时有一个血泪经验:不要直接把原始金额丢进模型。收入 5000 和 50000 的差异不是线性的,通常要做分箱或者取对数。申请人评级里“年龄”这个特征,22 岁和 45 岁的风险差异很大,但 45 岁和 48 岁几乎没区别,所以分箱比连续值更稳。

2.2 收款评级与欺诈评级的标签构造

收款评级(催收评级)的标签不是“是否逾期”,而是“逾期后回收比例”。常见做法是把回收金额除以欠款金额,分成高、中、低三档,或者直接做回归预测回收率。欺诈评级的标签最特殊:正样本极少,通常不到 1%。这时候不能用准确率评估,要看召回率和 AUC。欺诈特征包括:申请时间集中在凌晨、设备 ID 频繁更换、联系人号码重复、单位地址与居住地址距离异常、首笔交易金额接近额度上限。

import pandas as pd import numpy as np from sklearn.model_selection import train_test_split # 模拟申请人评级数据 np.random.seed(42) n = 5000 data = pd.DataFrame({ 'age': np.random.randint(20, 60, n), 'income': np.random.lognormal(8, 1, n), 'work_years': np.random.randint(0, 30, n), 'past_due_30d': np.random.poisson(0.3, n), 'query_6m': np.random.poisson(2, n), 'credit_count': np.random.randint(0, 10, n), }) # 标签:12个月内是否逾期90天以上 logit = -3 + 0.02*data['age'] - 0.0001*data['income'] + 0.5*data['past_due_30d'] + 0.3*data['query_6m'] prob = 1 / (1 + np.exp(-logit)) data['label'] = np.random.binomial(1, prob) # 分箱处理年龄 data['age_bin'] = pd.cut(data['age'], bins=[0, 25, 35, 45, 55, 100], labels=False) # 收入取对数 data['income_log'] = np.log1p(data['income']) X = data[['age_bin', 'income_log', 'work_years', 'past_due_30d', 'query_6m', 'credit_count']] y = data['label'] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, stratify=y, random_state=42) print(f"训练集正样本比例: {y_train.mean():.3f}")

这段代码做了三件事:生成模拟数据、构造标签、做基础特征变换。pd.cut把年龄切成五段,np.log1p处理收入的长尾分布。stratify=y保证训练集和测试集的正负样本比例一致,这在风控场景里很关键,因为正样本本来就少,随机切分可能导致测试集里一个正样本都没有。参数bins的边界不是拍脑袋定的,通常要看业务含义:25 岁以下刚毕业,35 到 45 岁收入稳定,55 岁以上临近退休。实际项目中这些边界要用卡方分箱或者决策树分箱自动找。

2.3 缺失值处理与样本不平衡的实操选择

申请人数据里“工作年限”缺失很常见,因为自由职业者不填。处理方式有三种:填中位数、填一个特殊值(比如 -1)、或者用模型预测。我一般会先填 -1 并加一个“是否缺失”的指示列,让模型自己学。行为评级里“近 6 个月逾期天数”缺失,往往意味着这个人没有信用卡,这时候填 0 是合理的,但欺诈评级里缺失可能本身就是信号。

样本不平衡方面,欺诈评级正样本可能只有 0.5%。不要一上来就 SMOTE,先试试class_weight='balanced'。如果还不行,再用欠采样加集成。收款评级做回归时,回收率分布通常双峰:要么全收回来,要么一分收不回,这时候用分位数回归比普通线性回归更合适。

3. 用 Python 把四套模型跑通:从逻辑回归到 XGBoost 的选型与调参

3.1 申请人评级:逻辑回归评分卡为什么还是首选

申请人评级模型在银行里必须可解释,监管要求你能说清楚为什么拒绝一个人。逻辑回归的系数直接对应 WOE(证据权重),每个特征的分箱对应一个分数,最后加起来就是总分。XGBoost 虽然 AUC 高,但解释性差,通常只用来做特征筛选或者和逻辑回归做融合。

from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, classification_report from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline pipe = Pipeline([ ('scaler', StandardScaler()), ('clf', LogisticRegression(class_weight='balanced', max_iter=1000, C=0.5)) ]) pipe.fit(X_train, y_train) y_prob = pipe.predict_proba(X_test)[:, 1] print(f"AUC: {roc_auc_score(y_test, y_prob):.4f}") print(classification_report(y_test, (y_prob > 0.5).astype(int)))

class_weight='balanced'让正样本权重自动调高,C=0.5是正则化强度的倒数,越小正则越强。风控场景里我一般会把 C 设在 0.1 到 1 之间,防止过拟合。StandardScaler对逻辑回归不是必须的,但加了之后系数大小可以直接比较,方便做特征重要性排序。AUC 在 0.75 以上算可用,0.8 以上算不错。如果 AUC 只有 0.6,先检查标签定义是不是有问题,再看特征有没有穿越(用了未来信息)。

3.2 行为评级:时间窗口滚动特征与 XGBoost 调参

行为评级的特征需要按时间窗口滚动计算。比如“近 3 个月平均逾期天数”,要用groupby加rolling。这里有个坑:滚动窗口不能跨用户,必须按用户 ID 分组后再滚。

import xgboost as xgb from sklearn.metrics import roc_auc_score # 模拟行为数据 behavior = pd.DataFrame({ 'user_id': np.repeat(range(500), 12), 'month': np.tile(range(12), 500), 'overdue_days': np.random.poisson(1, 6000), 'repay_ratio': np.random.beta(5, 2, 6000), }) # 按用户滚动计算近3个月平均逾期 behavior['overdue_3m'] = behavior.groupby('user_id')['overdue_days'].transform( lambda x: x.rolling(3, min_periods=1).mean() ) behavior['repay_3m'] = behavior.groupby('user_id')['repay_ratio'].transform( lambda x: x.rolling(3, min_periods=1).mean() ) # 取每个用户最后一个月作为样本 last = behavior.groupby('user_id').tail(1).copy() last['label'] = (last['overdue_3m'] > 2).astype(int) Xb = last[['overdue_3m', 'repay_3m']] yb = last['label'] Xb_train, Xb_test, yb_train, yb_test = train_test_split(Xb, yb, test_size=0.3, stratify=yb, random_state=42) model = xgb.XGBClassifier( n_estimators=200, max_depth=4, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, scale_pos_weight=3, eval_metric='auc', use_label_encoder=False ) model.fit(Xb_train, yb_train) print(f"行为评级 AUC: {roc_auc_score(yb_test, model.predict_proba(Xb_test)[:,1]):.4f}")

rolling(3, min_periods=1)表示窗口大小 3,最少 1 个数据就计算。scale_pos_weight=3是负样本数除以正样本数的近似值,用来处理不平衡。max_depth=4是风控场景的常用值,树太深容易记住噪声。subsample和colsample_bytree都设 0.8,增加随机性防止过拟合。如果 AUC 在训练集上 0.95、测试集上 0.7,那就是过拟合了,把max_depth降到 3,n_estimators降到 100 再试。

3.3 收款评级与欺诈评级的模型差异

收款评级如果做回归预测回收率,用XGBRegressor,损失函数选reg:squarederror或者reg:quantileerror。欺诈评级用XGBClassifier,但评估指标要看aucpr(PR 曲线下面积),因为正样本太少,ROC 曲线会过于乐观。

from sklearn.metrics import average_precision_score # 欺诈评级模拟:正样本比例约1% fraud = pd.DataFrame({ 'device_change': np.random.poisson(0.5, 10000), 'night_apply': np.random.binomial(1, 0.1, 10000), 'contact_repeat': np.random.poisson(0.2, 10000), 'amount_ratio': np.random.beta(2, 5, 10000), }) fraud['label'] = ((fraud['device_change'] > 2) & (fraud['night_apply'] == 1)).astype(int) Xf = fraud[['device_change', 'night_apply', 'contact_repeat', 'amount_ratio']] yf = fraud['label'] Xf_train, Xf_test, yf_train, yf_test = train_test_split(Xf, yf, test_size=0.3, stratify=yf, random_state=42) fraud_model = xgb.XGBClassifier( n_estimators=300, max_depth=3, learning_rate=0.03, scale_pos_weight=50, eval_metric='aucpr', use_label_encoder=False ) fraud_model.fit(Xf_train, yf_train) yf_prob = fraud_model.predict_proba(Xf_test)[:, 1] print(f"欺诈评级 PR-AUC: {average_precision_score(yf_test, yf_prob):.4f}")

scale_pos_weight=50是因为正样本大约 2%,负正比约 50:1。eval_metric='aucpr'让模型在训练时直接优化 PR 曲线。max_depth=3比行为评级更浅,因为欺诈特征少,树深了容易过拟合。PR-AUC 在 0.3 以上就值得进一步调,0.5 以上算不错。如果 PR-AUC 只有 0.1,先检查正样本定义是不是太宽泛,把“欺诈”定义收紧一点再试。

4. 避坑与排查:四套模型落地时最容易翻车的五个地方

4.1 标签穿越:用了未来才知道的信息

现象:模型在测试集上 AUC 0.95,上线后效果暴跌到 0.6。原因:特征里混入了标签发生之后的数据。比如预测“未来 6 个月是否逾期”,但特征里用了“近 6 个月逾期天数”,这个特征在预测时点根本拿不到。解决:画一张时间轴,把每个特征的“可获取时间”标出来,只保留预测时点之前能拿到的特征。申请人评级里“历史逾期次数”可以用,但“本次申请后的查询次数”不能用。

4.2 分箱边界过拟合

现象:训练集上每个分箱的坏样本率单调,测试集上乱跳。原因:分箱边界是在全量数据上找的,包含了测试集信息。解决:分箱只在训练集上做,然后应用到测试集。用optbinning库或者手写卡方分箱,确保边界来自训练集。如果某个分箱在测试集上样本数少于 5%,合并到相邻箱。

4.3 样本权重设置错误

现象:逻辑回归的截距项巨大,预测概率全部偏向负样本。原因:class_weight='balanced'和手动sample_weight同时用了,权重被乘了两次。解决:二选一。如果用class_weight,就不要在fit里再传sample_weight。另外,评分卡通常要把概率校准到真实坏样本率,CalibratedClassifierCV可以做这件事。

4.4 滚动窗口跨用户

现象:行为评级的滚动特征在用户边界处出现异常值。原因:rolling没有按用户分组,把上一个用户的最后几个月和下一个用户的前几个月混在一起算了。解决:先groupby('user_id')再transform,或者用groupby.rolling。检查方法:取一个用户的前 3 个月数据,手动算平均值,和代码结果对比。

4.5 欺诈评级的评估指标选错

现象:欺诈模型准确率 99%,但一个欺诈都没抓到。原因:正样本只有 1%,全预测为负样本就有 99% 准确率。解决:看召回率和 PR-AUC。业务上通常要求召回率在 30% 到 50% 之间,同时精确率不能太低,否则人工审核量爆炸。可以画 PR 曲线,找业务能接受的阈值点。

5. 从课程设计到可复现的原型:验证模型稳定性的三个技巧

5.1 用 PSI 监控特征分布漂移

PSI(群体稳定性指标)是风控里最常用的稳定性监控指标。计算方式:把训练集和测试集(或上线后的新数据)按同一个分箱规则分箱,算每个箱的样本占比差异,再求和。PSI 小于 0.1 表示稳定,0.1 到 0.25 表示轻微漂移,大于 0.25 表示需要重新训练。

def calculate_psi(expected, actual, bins=10): # expected: 训练集特征值, actual: 测试集特征值 breakpoints = np.percentile(expected, np.linspace(0, 100, bins+1)) breakpoints[0] = -np.inf breakpoints[-1] = np.inf expected_counts = np.histogram(expected, bins=breakpoints)[0] / len(expected) actual_counts = np.histogram(actual, bins=breakpoints)[0] / len(actual) # 避免除零 expected_counts = np.where(expected_counts == 0, 0.0001, expected_counts) actual_counts = np.where(actual_counts == 0, 0.0001, actual_counts) psi = np.sum((expected_counts - actual_counts) * np.log(expected_counts / actual_counts)) return psi # 对每个特征算 PSI for col in X_train.columns: psi_val = calculate_psi(X_train[col].values, X_test[col].values) print(f"{col}: PSI = {psi_val:.4f}")

np.percentile用训练集的分位数做切分点,保证分箱规则来自训练集。np.histogram统计每个箱的样本数。np.where把 0 替换成 0.0001 防止 log 报错。PSI 大于 0.25 的特征要重点看,可能是业务变化导致,也可能是数据采集出了问题。

5.2 交叉验证的折数选择与时间序列切分

风控数据有时间顺序,不能用随机 K 折。要用TimeSeriesSplit,保证训练集在测试集之前。折数一般选 5,如果数据量少于 10000 条,选 3 折。每折的 AUC 标准差如果大于 0.05,说明模型不稳定,需要检查特征或者增加数据。

from sklearn.model_selection import TimeSeriesSplit, cross_val_score tscv = TimeSeriesSplit(n_splits=5) scores = cross_val_score(pipe, X, y, cv=tscv, scoring='roc_auc') print(f"时间序列交叉验证 AUC: {scores.mean():.4f} ± {scores.std():.4f}")

TimeSeriesSplit按时间顺序切分,第一折用前 20% 训练、后 20% 测试,第二折用前 40% 训练、后 20% 测试,以此类推。scores.std()大于 0.05 就要警惕。如果标准差很大,先看是不是某个时间段的正样本特别少。

5.3 评分卡刻度转换与业务阈值设定

逻辑回归输出的是概率,业务上要转成 300 到 850 的分数。转换公式:score = offset + factor * ln(odds),其中odds = p / (1-p)。通常设定基准分 600 对应 odds 为 1:1,每增加 20 分 odds 翻倍。这样算出来 factor = 20 / ln(2) ≈ 28.85,offset = 600 - 28.85 * ln(1) = 600。

def prob_to_score(prob, base_score=600, base_odds=1, pdo=20): factor = pdo / np.log(2) offset = base_score - factor * np.log(base_odds) odds = prob / (1 - prob) return offset + factor * np.log(odds) # 对测试集样本打分 scores = prob_to_score(y_prob) print(f"分数范围: {scores.min():.0f} - {scores.max():.0f}") print(f"分数均值: {scores.mean():.0f}")

pdo=20表示每 20 分 odds 翻倍。base_odds=1表示基准分对应好坏比 1:1。实际业务里基准分和 pdo 要根据通过率来调。如果通过率要求 70%,就把阈值设在分数分布的第 30 百分位。这套转换让业务方容易理解:分数越高风险越低。

做课程设计的时候,我习惯先把四套模型的 AUC 和 PSI 都跑出来,再挑一个特征做完整的评分卡转换。这样既能看到模型效果,又能理解业务落地时分数是怎么用的。希望帮到你。

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

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

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

立即咨询