☰
个人信用评估模型实战:从数据清洗到评分卡映射的完整链路
2026/10/2 14:01:51 网站建设 项目流程

简介:这份资源面向Python机器学习入门者与课程设计需求者,围绕个人信用评估与贷款违约预测任务,提供从数据处理到模型训练的完整实践方案。数据集选自阿里天池贷款违约预测比赛,总量超120万条、含47列变量,并划分训练集与测试集,适合练习特征工程、分类建模与结果评估。压缩包共82个文件,约5.16MB,以62张png可视化图表、13个py脚本为主,另含2个csv数据文件、1份docx设计报告及md说明等,覆盖数据检查、缺失填充、编码、降维、网格搜索与训练等环节。目前已有740人学习下载。读者可据此获得完整赛题实现思路、可运行源码与数据、相关性热力图和分布图等分析素材,以及设计报告参考,便于快速复现实验并完成课程设计或入门竞赛练习。

1. 从一份压缩包说起:个人信用评估模型到底在算什么

打开招聘网站搜“风控建模”,十有八九会看到“熟悉 Python、机器学习、个人信用评估”这一组关键词。很多人第一反应是去下载一份“Python通过机器学习实现对个人信用评估.zip”,解压之后发现里面无非是几个 .py 文件加一份 CSV,跑起来却处处报错。问题不在代码,而在于没搞清楚这个任务本身在算什么。

个人信用评估,本质是一个二分类问题:给定一个借款人的历史行为、资产、负债、履约记录等特征,预测他未来是否会发生违约。输出通常是一个 0 到 1 之间的概率值,业内叫 PD(Probability of Default)。这个概率再经过评分卡映射,就变成了我们熟悉的信用分。它和图像分类、文本分类最大的区别在于:样本极度不平衡、特征有强业务含义、模型可解释性往往比精度更重要。所以这份压缩包里的方案,核心不是把准确率刷到多高,而是把“数据清洗—特征工程—模型训练—评分映射—验证”这条链路跑通。适合刚入门机器学习、想找一个完整项目练手的人,也适合做风控业务、想自己动手复现一版基线模型的从业者。

2. 数据到手先别急着建模:信用数据的清洗与标签定义

2.1 先搞清楚标签怎么来的

信用评估数据集通常长这样:一行代表一个客户,一列是标签,其余是特征。标签列常见命名是SeriousDlqin2yrs、default、label之类,取值 0 或 1。0 代表正常履约,1 代表发生过严重逾期。这里有个血泪经验:很多公开数据集里的“违约”定义是“逾期 90 天以上”,而不是“逾期一天”。如果你拿到的数据里标签定义模糊,模型学出来的东西和业务想要的完全不是一回事。

另一个坑是时间窗口。信用评估必须明确“观察期”和“表现期”。观察期用来收集特征,表现期用来观察是否违约。比如用过去 12 个月的还款记录做特征,预测未来 6 个月是否违约。如果压缩包里的代码直接把所有数据混在一起训练测试,那这个模型上线就是灾难——因为它用未来的信息预测过去。

import pandas as pd import numpy as np # 读取数据,注意编码问题,信用数据常带中文列名 df = pd.read_csv('credit_data.csv', encoding='utf-8') # 查看标签分布,判断不平衡程度 print(df['label'].value_counts(normalize=True)) # 如果违约样本占比低于 5%,后续必须做重采样或调权重 # 常见做法:用 imblearn 的 SMOTE,或直接在模型里设 class_weight='balanced'

这段代码做了两件事:读数据、看标签分布。参数上,encoding要根据实际文件调整,中文环境常见gbk或utf-8-sig。normalize=True输出的是比例而不是计数,方便判断不平衡程度。如果违约样本只有 3%,你直接训练一个全预测 0 的模型也能有 97% 准确率,但这种模型毫无价值。所以下一步必须处理不平衡。

2.2 缺失值不是随便填的

信用数据里缺失值特别多,尤其是收入、负债这类敏感字段。很多人上来就fillna(0),这是典型的翻车操作。收入缺失填 0,等于告诉模型这个人没收入,但实际上可能是他没填。正确做法是分情况:连续型特征用中位数填充并加一个“是否缺失”的指示列;类别型特征把缺失单独归为一类。

# 对收入列:中位数填充 + 缺失指示列 df['MonthlyIncome_missing'] = df['MonthlyIncome'].isnull().astype(int) df['MonthlyIncome'] = df['MonthlyIncome'].fillna(df['MonthlyIncome'].median()) # 对类别列:缺失归为 'Unknown' df['EmploymentType'] = df['EmploymentType'].fillna('Unknown') # 检查还有没有残留缺失 print(df.isnull().sum().sum())

逻辑说明:isnull().astype(int)生成 0/1 列,告诉模型“这个值原来是缺失的”,有时候缺失本身就有信息量。中位数比均值抗异常值,信用数据里收入动辄几百万的异常值很常见。最后一行检查确保没有漏网之鱼。参数上,中位数填充适用于偏态分布,如果特征接近正态分布,均值也可以,但信用数据几乎都是偏态的。

2.3 异常值处理:别用 3σ 一刀切

信用数据里的异常值往往不是错误,而是真实的高净值客户或极端违约者。用 3σ 原则直接删掉,可能把最有价值的样本删了。我一般用分位数截断:把超过 99% 分位和低于 1% 分位的值拉回边界。这样既限制了极端值对模型的干扰,又保留了样本。

# 对连续特征做分位数截断 for col in ['age', 'MonthlyIncome', 'DebtRatio']: lower = df[col].quantile(0.01) upper = df[col].quantile(0.99) df[col] = df[col].clip(lower, upper)

quantile(0.01)和quantile(0.99)分别取 1% 和 99% 分位点,clip把超出范围的值截断到边界。参数可以根据业务调整,如果数据量小,可以用 0.05 和 0.95。注意DebtRatio这个字段经常出现除以零导致的无穷大,截断前先用np.inf替换掉。

3. 特征工程决定上限:从原始字段到 WOE 编码

3.1 为什么信用评分偏爱 WOE

在信用评估领域,WOE(Weight of Evidence)编码是绕不开的。它把每个分箱的违约概率和正常概率做比,再取对数。好处是:把非线性关系变成线性、对异常值不敏感、每个箱子的 WOE 值直接反映风险方向。IV(Information Value)则用来筛选特征,一般 IV 小于 0.02 的字段直接扔掉,0.1 到 0.5 之间的最有价值。

def calc_woe_iv(df, feature, target): # 分箱:连续变量先等频分 5 箱 if df[feature].dtype != 'object': df['bin'] = pd.qcut(df[feature], q=5, duplicates='drop') else: df['bin'] = df[feature] # 统计每个箱的坏样本和好样本 grouped = df.groupby('bin')[target].agg(['count', 'sum']) grouped.columns = ['total', 'bad'] grouped['good'] = grouped['total'] - grouped['bad'] # 防止除零 grouped['bad'] = grouped['bad'].replace(0, 0.5) grouped['good'] = grouped['good'].replace(0, 0.5) # 计算 WOE 和 IV grouped['woe'] = np.log((grouped['bad'] / grouped['bad'].sum()) / (grouped['good'] / grouped['good'].sum())) grouped['iv'] = ((grouped['bad'] / grouped['bad'].sum()) - (grouped['good'] / grouped['good'].sum())) * grouped['woe'] iv_total = grouped['iv'].sum() return grouped, iv_total # 对每个特征计算 IV features = ['age', 'MonthlyIncome', 'DebtRatio', 'NumberOfOpenCreditLines'] iv_dict = {} for f in features: _, iv = calc_woe_iv(df, f, 'label') iv_dict[f] = iv print(f'{f}: IV = {iv:.4f}')

这段代码是 WOE 编码的核心。pd.qcut做等频分箱,保证每个箱样本量差不多。replace(0, 0.5)是防止某个箱没有坏样本导致 log 无穷大,这是行业惯例。WOE 公式里分子是坏样本占比,分母是好样本占比,取对数后,WOE 越大代表风险越高。IV 是 WOE 的加权和,用来衡量特征整体区分能力。参数上,分箱数一般 5 到 10,太少区分度不够,太多容易过拟合。如果某个特征的 IV 超过 0.5,要警惕数据泄露。

3.2 特征交叉:让模型看到组合风险

单看“年龄”和“收入”可能区分度一般,但“年轻且低收入”这个组合往往风险极高。信用评分里常用特征交叉来捕捉这种非线性关系。常见做法是把两个强特征的分箱编号相加或相乘,生成新特征。

# 先对年龄和收入做分箱编号 df['age_bin'] = pd.qcut(df['age'], q=5, labels=False) df['income_bin'] = pd.qcut(df['MonthlyIncome'], q=5, labels=False) # 交叉特征:分箱编号相加 df['age_income_combo'] = df['age_bin'] + df['income_bin'] # 再对这个组合特征算 IV,看是否比单特征高 _, iv_combo = calc_woe_iv(df, 'age_income_combo', 'label') print(f'交叉特征 IV = {iv_combo:.4f}')

labels=False返回整数编号而不是区间标签,方便做运算。相加是一种简单交叉,也可以相乘或拼接成字符串。判断标准是:交叉后的 IV 是否显著高于两个单特征。如果没提高,说明这两个特征之间没有强交互,加了反而增加维度。注意交叉特征容易过拟合,样本量少于 1 万时慎用。

3.3 时间窗口特征:别用未来信息

如果你的数据里有时间戳,一定要构造时间窗口特征。比如“过去 6 个月逾期次数”“过去 3 个月查询次数”。但这里有个大坑:窗口的截止时间必须是观察期结束,不能用表现期的数据。很多压缩包里的代码直接对全量数据做滚动统计,这就是数据泄露。

# 假设数据按客户和时间排序 df = df.sort_values(['customer_id', 'apply_date']) # 构造过去 6 个月逾期次数,注意 shift 避免包含当前 df['past_6m_delinq'] = df.groupby('customer_id')['delinq_30d'].transform( lambda x: x.rolling(window=6, min_periods=1).sum().shift(1) )

rolling(window=6)做滚动窗口,shift(1)把当前时间点排除,确保只用历史信息。min_periods=1保证第一个月也有值。这个特征构造完,必须检查有没有 NaN 和无穷大,然后重新算 IV。

4. 模型训练与评估:逻辑回归还是 XGBoost

4.1 逻辑回归是信用评分的基准线

在信用评估领域,逻辑回归至今仍是主流,原因不是它准,而是它可解释。每个特征的系数直接对应风险权重,监管和业务都能看懂。而且经过 WOE 编码后,逻辑回归的线性假设基本成立。我一般先用逻辑回归跑一个基线,AUC 能到 0.75 以上就算合格。

from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score, classification_report # 准备特征和标签 feature_cols = ['age', 'MonthlyIncome', 'DebtRatio', 'age_income_combo'] X = df[feature_cols] y = df['label'] # 分层抽样,保证训练测试集标签比例一致 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) # 训练逻辑回归,设 class_weight 处理不平衡 lr = LogisticRegression(class_weight='balanced', max_iter=1000, random_state=42) lr.fit(X_train, y_train) # 预测概率和 AUC y_pred_proba = lr.predict_proba(X_test)[:, 1] auc = roc_auc_score(y_test, y_pred_proba) print(f'逻辑回归 AUC: {auc:.4f}') # 查看系数,正系数代表风险增加 coef_df = pd.DataFrame({'feature': feature_cols, 'coef': lr.coef_[0]}) print(coef_df.sort_values('coef', ascending=False))

stratify=y保证训练集和测试集的违约比例一致,避免随机切分导致测试集全是好样本。class_weight='balanced'自动给少数类更高权重,比手动调参省事。max_iter=1000是因为信用数据特征多,默认 100 次可能不收敛。AUC 是信用评分最常用的指标,因为它不受阈值影响。系数为正说明该特征增加违约风险,为负则相反。如果某个系数符号和业务常识相反,比如“收入越高违约越高”,那大概率是多重共线性,需要删特征。

4.2 XGBoost 用来找非线性关系

逻辑回归跑完,再用 XGBoost 跑一版,通常 AUC 能提高 2 到 5 个点。XGBoost 能自动捕捉特征交叉和非线性,但代价是可解释性下降。我一般用 SHAP 值来解释 XGBoost 的预测,这样既能享受高精度,又能给业务一个交代。

import xgboost as xgb from sklearn.metrics import roc_auc_score # 计算 scale_pos_weight 处理不平衡 scale = (y_train == 0).sum() / (y_train == 1).sum() xgb_model = xgb.XGBClassifier( n_estimators=200, max_depth=4, learning_rate=0.05, scale_pos_weight=scale, subsample=0.8, colsample_bytree=0.8, random_state=42, eval_metric='auc' ) xgb_model.fit(X_train, y_train) y_pred_xgb = xgb_model.predict_proba(X_test)[:, 1] print(f'XGBoost AUC: {roc_auc_score(y_test, y_pred_xgb):.4f}')

scale_pos_weight是 XGBoost 处理不平衡的关键参数,值等于负样本数除以正样本数。max_depth=4是信用评分常用的深度,太深容易过拟合,太浅学不到交互。learning_rate=0.05配合n_estimators=200是经典组合,如果 AUC 不够可以加到 500,但要注意早停。subsample和colsample_bytree都是 0.8,增加模型多样性防止过拟合。跑完对比两个模型的 AUC,如果 XGBoost 只高 1 个点以内,我建议用逻辑回归,因为可解释性在风控里值钱得多。

4.3 评估不能只看 AUC

AUC 衡量的是排序能力,但业务关心的是“如果我把阈值设在 0.5,会误杀多少好人,放过多少坏人”。所以还要看 KS 统计量和混淆矩阵。KS 值等于好样本和坏样本累积分布的最大差值,一般超过 0.3 就算不错。

from sklearn.metrics import confusion_matrix # KS 计算 def calc_ks(y_true, y_pred_proba): fpr, tpr, _ = roc_curve(y_true, y_pred_proba) return max(tpr - fpr) ks = calc_ks(y_test, y_pred_proba) print(f'KS = {ks:.4f}') # 按 0.5 阈值看混淆矩阵 y_pred_label = (y_pred_proba >= 0.5).astype(int) print(confusion_matrix(y_test, y_pred_label))

roc_curve返回假正率和真正率,tpr - fpr的最大值就是 KS。混淆矩阵里,左上和右下是对角线,代表预测正确。如果假阴性(把坏人预测成好人)特别多,就要降低阈值。参数上,阈值不是固定 0.5,要根据业务对坏账的容忍度调整。通常风控会选一个让 KS 最大的阈值。

5. 避坑与排查:信用评估模型最常见的五个翻车点

5.1 数据泄露:AUC 高得离谱

现象:训练集 AUC 0.99,测试集 AUC 0.6,或者交叉验证每一折都接近 1。原因:特征里包含了标签信息,或者用了未来数据。常见的是把“是否违约”相关的字段(如“催收次数”)当特征,或者时间窗口没做 shift。解决:逐个特征检查与标签的相关性,相关系数超过 0.8 的直接删。时间特征必须确认截止时间在观察期内。用TimeSeriesSplit代替随机切分。

5.2 样本不平衡导致模型全预测好人

现象:混淆矩阵里坏人一个都没抓到,AUC 看着还行但 KS 很低。原因:违约样本太少,模型学会了“全猜 0”就能获得高准确率。解决:逻辑回归设class_weight='balanced',XGBoost 设scale_pos_weight。或者用 SMOTE 过采样,但注意 SMOTE 只能在训练集做,测试集保持原始分布。

5.3 WOE 编码后新数据出现未知分箱

现象:训练好的模型上线后,新客户的特征值落在训练集没见过的分箱里,WOE 映射报错。原因:分箱边界是基于训练集定的,新数据可能超出范围。解决:保存分箱边界,新数据用pd.cut而不是pd.qcut,超出边界的归到最近箱。或者把分箱函数封装成类,训练时 fit,预测时 transform。

5.4 多重共线性让系数符号反了

现象:逻辑回归系数里,“收入”的系数是正的,意味着收入越高违约越高,和常识相反。原因:特征之间高度相关,比如“收入”和“负债”相关系数 0.9,模型系数不稳定。解决:计算 VIF(方差膨胀因子),超过 10 的删掉。或者用 L1 正则化自动做特征选择。WOE 编码后共线性会减轻,但还是要检查。

5.5 模型上线后效果衰减

现象:模型刚上线 AUC 0.78,三个月后降到 0.65。原因:客群变化、经济环境变化导致数据分布漂移。解决:每月监控 PSI(Population Stability Index),超过 0.25 就要重新训练。同时保留一个规则模型做兜底,防止模型完全失效。

6. 从模型到评分卡:把概率变成 300 到 850 的分数

模型输出的概率不能直接给业务用,业务要的是分数。评分卡映射的公式是:score = A - B * ln(odds),其中odds是坏样本概率除以好样本概率。A 和 B 通过设定基准分和 PDO(Points to Double the Odds)来定。比如基准分 600,PDO 50,意味着 odds 翻倍,分数降 50。

import numpy as np # 设定基准分和 PDO base_score = 600 base_odds = 50 # 基准 odds pdo = 50 # 计算 A 和 B B = pdo / np.log(2) A = base_score + B * np.log(base_odds) def prob_to_score(prob): # prob 是违约概率,odds = prob / (1 - prob) odds = prob / (1 - prob) score = A - B * np.log(odds) return round(score) # 测试几个概率 for p in [0.01, 0.05, 0.1, 0.3, 0.5]: print(f'违约概率 {p:.2f} -> 分数 {prob_to_score(p)}')

B = pdo / ln(2)是固定公式,A根据基准分反推。prob_to_score里先算 odds,再代入线性公式。分数越高代表风险越低。参数上,基准分和 PDO 是业务定的,常见基准分 600、PDO 50,也有用 500 和 20 的。映射完要检查分数分布,确保大部分客户落在 500 到 700 之间,否则基准分设错了。

最后说一个我自己的习惯:每次跑完模型,我都会把特征重要性、WOE 表、评分卡映射表存成三个 CSV,连同随机种子一起打包。下次再跑,先对比这三个文件有没有变化。如果特征重要性排序变了,说明数据分布动了,模型该重新训练了。这个习惯帮我省了无数次后悔药。希望帮到你。

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

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

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

立即咨询