简介:这份资源面向金融风控方向的学生与开发者,提供一套基于机器学习的Python大数据风控建模实战项目,可直接用于毕业设计、期末大作业或课程设计。项目围绕信贷违约预测等典型场景展开,涵盖数据清洗、特征工程、模型训练与评估的完整流程,代码注释详尽,新手也能读懂。压缩包共106个文件,约20.13MB,其中40个py脚本承载核心建模逻辑,16个csv与19个pkl分别存放原始数据与训练好的模型文件,另有3个ipynb便于分步复现,20张png展示可视化结果,并附文档说明与依赖whl包,部署后即可运行。目前已有1159人学习下载,作者自述为手打98分项目。读者可从中获得一套结构完整的风控建模方案、可复用的特征处理与模型调优思路,以及清晰的目录组织,便于快速理解业务与算法结合方式,也适合作为二次开发与排错参考。
1. 从一份风控源码说起:Python 金融大数据建模到底在解决什么
金融风控建模这件事,很多人第一次接触是从一份「源代码+文档说明」的压缩包开始的。解压之后看到一堆.py文件、几个.csv样本、一份 README,心里第一反应往往是:这玩意儿能跑起来吗?跑起来之后又能说明什么?我当年也是这么入坑的,后来在消费金融和信贷场景里做了几年模型,才慢慢理解这套东西真正的价值不在代码本身,而在于它把「数据 → 特征 → 模型 → 决策」这条链路完整地摆在你面前。
Python 在这个领域几乎是默认选项,原因很实际:pandas 处理宽表够快,scikit-learn 把逻辑回归、XGBoost、LightGBM 这些风控主力模型封装得足够顺手,评分卡相关的分箱、WOE、IV 计算也有成熟实现。金融大数据的特点是样本量大、特征维度高、正负样本极度不平衡,同时还要满足可解释性和监管要求,这就决定了风控建模和一般机器学习任务在流程上有明显差异。
这份实战方案适合三类人:想从零搭一套信贷评分卡的新手、手里有数据但不知道怎么规范流程的从业者、以及需要快速验证某个特征或模型是否值得上线的工程师。接下来我会按真实落地顺序,把数据准备、特征工程、模型训练、评估上线这几段拆开讲清楚,中间穿插能直接抄的代码和参数说明。
2. 数据准备与特征工程:把原始宽表变成模型能吃的矩阵
2.1 风控数据的典型结构和读取方式
金融风控拿到的原始数据,通常是「一行为一个客户、一列为一个字段」的宽表,字段大致分几类:身份类(年龄、学历、婚姻)、申请类(申请金额、期限)、行为类(近 6 个月查询次数、逾期次数)、外部类(征信评分、多头借贷标识)。标签一般是二分类的label,1 代表坏客户,0 代表好客户。
读取的时候有几个细节要注意。第一,编码问题,很多业务系统导出的 CSV 是 GBK,直接pd.read_csv会报 UnicodeDecodeError。第二,缺失值表示方式不统一,有的用空字符串,有的用 -999,有的用 NULL,读进来之后要统一成np.nan。第三,日期字段要显式解析,否则后面算账龄、账期会出错。
import pandas as pd import numpy as np # 读取时统一处理编码和缺失值标识 df = pd.read_csv( "loan_data.csv", encoding="gbk", # 业务系统常见编码,UTF-8 报错时换这个 na_values=["", "NULL", "-999", "null"], # 统一缺失值 parse_dates=["apply_date", "first_loan_date"] # 显式解析日期 ) # 标签分布检查,风控里坏样本通常只占 1%~10% print(df["label"].value_counts(normalize=True)) # 字段类型概览,object 类型要重点关注 print(df.dtypes.value_counts())这段代码的关键在na_values和parse_dates两个参数。na_values把业务系统里各种「伪缺失」统一成 NaN,避免后面做统计时把 -999 当成真实数值算进均值。parse_dates让 pandas 直接转成 datetime,省去后面手动pd.to_datetime的步骤。标签分布那行一定要看,如果坏样本比例低于 1%,后面训练时就得考虑采样策略,否则模型会倾向于全预测为好客户。
2.2 缺失值处理和异常值截断的实操参数
风控数据里缺失值不是随机出现的,往往本身就有信息量。比如「近 3 个月查询次数」缺失,可能意味着这个客户根本没有征信记录,属于「白户」,风险特征和普通缺失完全不同。所以我的习惯是:先算缺失率,再决定处理方式。
缺失率低于 5% 的字段,直接用中位数或众数填充;5% 到 30% 之间的,填充的同时加一个「是否缺失」的指示列;超过 30% 的,除非业务上明确重要,否则直接丢掉。异常值方面,连续变量用分位数截断,一般卡在 1% 和 99% 分位,避免极端值把 WOE 分箱带偏。
def handle_missing_and_outlier(df, cols, missing_threshold=0.3): """缺失值填充 + 异常值截断""" drop_cols = [] for col in cols: miss_rate = df[col].isnull().mean() if miss_rate > missing_threshold: drop_cols.append(col) continue # 缺失指示列,保留缺失本身的信息 if miss_rate > 0.05: df[f"{col}_is_missing"] = df[col].isnull().astype(int) # 数值型用中位数,类别型用众数 if df[col].dtype in ["float64", "int64"]: fill_value = df[col].median() # 1% 和 99% 分位截断 lower, upper = df[col].quantile([0.01, 0.99]) df[col] = df[col].clip(lower, upper) else: fill_value = df[col].mode()[0] df[col] = df[col].fillna(fill_value) return df.drop(columns=drop_cols) df = handle_missing_and_outlier(df, numeric_cols + category_cols)missing_threshold设 0.3 是个经验值,业务上如果某个字段特别重要可以放宽到 0.5。clip那两行是防止极端值影响后续分箱,注意截断要在填充之前做,否则中位数会被极端值拉偏。缺失指示列看起来简单,但在评分卡里经常能贡献不错的 IV 值,别省这一步。
2.3 WOE 分箱和 IV 筛选:评分卡的核心工序
WOE(Weight of Evidence)和 IV(Information Value)是风控特征工程的标志性工序。WOE 衡量的是「某个分箱里坏样本占比和好样本占比的差异」,IV 则是 WOE 的加权和,用来判断一个特征整体有没有区分能力。IV 的经验阈值:小于 0.02 基本没用,0.02 到 0.1 弱,0.1 到 0.3 中等,0.3 到 0.5 强,超过 0.5 要警惕数据泄漏。
分箱方式有等频、等距、决策树分箱几种。实操里我一般先用等频分箱粗分,再根据单调性手动合并。单调性很重要,比如「逾期次数」的 WOE 应该随次数增加而单调上升,如果出现波动,说明分箱不合理或者数据有问题。
def calc_woe_iv(df, feature, target, bins=10): """等频分箱计算 WOE 和 IV""" # 等频分箱,重复值多时用 rank 避免报错 df["bin"] = pd.qcut(df[feature].rank(method="first"), bins, duplicates="drop") grouped = df.groupby("bin")[target].agg(["count", "sum"]) grouped.columns = ["total", "bad"] grouped["good"] = grouped["total"] - grouped["bad"] # 防止某箱好或坏样本为 0 导致除零 grouped["bad"] = grouped["bad"].replace(0, 0.5) grouped["good"] = grouped["good"].replace(0, 0.5) grouped["bad_rate"] = grouped["bad"] / grouped["bad"].sum() grouped["good_rate"] = grouped["good"] / grouped["good"].sum() grouped["woe"] = np.log(grouped["bad_rate"] / grouped["good_rate"]) grouped["iv"] = (grouped["bad_rate"] - grouped["good_rate"]) * grouped["woe"] return grouped, grouped["iv"].sum() woe_table, iv_value = calc_woe_iv(df, "query_count_3m", "label") print(f"IV = {iv_value:.4f}")replace(0, 0.5)这步是血泪经验,某箱里坏样本为 0 时np.log会算出无穷大,整个 IV 就废了。用 0.5 做平滑是行业常见做法。pd.qcut加rank是为了处理大量重复值的情况,直接 qcut 会报「Bin edges must be unique」。算完 IV 之后,一般保留 IV 大于 0.02 的特征进入模型,同时人工检查 WOE 的单调性。
3. 模型训练与调参:逻辑回归和树模型的取舍
3.1 为什么评分卡首选逻辑回归
在强监管的信贷场景里,逻辑回归至今仍是评分卡的主力,原因不是它效果最好,而是它可解释。每个特征的系数直接对应「这个特征每变化一个单位,坏账几率变化多少倍」,业务和监管都能看懂。树模型虽然 AUC 通常高几个点,但解释性差,很多场景下不被接受。
逻辑回归在风控里通常配合 WOE 转换使用,把每个特征的分箱 WOE 值作为输入,这样系数更稳定,也避免了原始值量纲差异带来的问题。正则化一般选 L2,C参数控制正则强度,C 越小正则越强。样本不平衡用class_weight="balanced"让模型自动加权。
from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score # 假设 X 已经完成 WOE 转换 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y # stratify 保证训练测试集坏样本比例一致 ) lr = LogisticRegression( penalty="l2", C=1.0, # 正则强度,越小越强,常用 0.1~10 之间调 class_weight="balanced", # 自动处理样本不平衡 solver="lbfgs", max_iter=1000 ) lr.fit(X_train, y_train) pred = lr.predict_proba(X_test)[:, 1] print(f"AUC = {roc_auc_score(y_test, pred):.4f}")stratify=y这个参数在小样本或坏样本极少时特别重要,不加的话可能测试集里坏样本只有个位数,AUC 波动会很大。C的调参建议用交叉验证,范围从 0.01 到 10 扫一遍。max_iter设 1000 是为了避免 lbfgs 不收敛的警告,如果还报 ConvergenceWarning,说明特征共线性严重,得先做 VIF 筛选。
3.2 XGBoost 和 LightGBM 在风控里的参数起点
树模型在风控里主要用在两个地方:一是作为逻辑回归的补充模型做模型融合,二是用在反欺诈这种对解释性要求没那么高的场景。XGBoost 和 LightGBM 我都用过,数据量在百万级以下时两者差距不大,超过千万行 LightGBM 的速度优势明显。
参数方面,风控场景有几个和通用机器学习不同的地方。第一,max_depth不要设太深,一般 3 到 6,太深容易过拟合而且解释性更差。第二,scale_pos_weight设成负样本数除以正样本数,处理不平衡。第三,subsample和colsample_bytree设 0.7 到 0.9 之间,增加泛化能力。
import lightgbm as lgb params = { "objective": "binary", "metric": "auc", "max_depth": 5, # 风控里不宜过深 "num_leaves": 31, # 控制复杂度,一般 2^max_depth 以内 "learning_rate": 0.05, # 小学习率配多轮迭代 "scale_pos_weight": (y_train == 0).sum() / (y_train == 1).sum(), "subsample": 0.8, "colsample_bytree": 0.8, "min_child_samples": 50, # 叶子最小样本数,防止过拟合 "reg_alpha": 0.1, # L1 正则 "reg_lambda": 1.0 # L2 正则 } dtrain = lgb.Dataset(X_train, label=y_train) dvalid = lgb.Dataset(X_test, label=y_test, reference=dtrain) model = lgb.train( params, dtrain, num_boost_round=500, valid_sets=[dvalid], callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)] )early_stopping(50)表示验证集 AUC 连续 50 轮不提升就停,这是防止过拟合最实用的手段。min_child_samples设 50 是经验值,样本量小可以降到 20,样本量大可以提到 100。scale_pos_weight那行直接算比例,不用手动填。训练完记得看特征重要性,如果某个特征重要性异常高,要怀疑是不是泄漏了标签信息。
3.3 模型融合和分数校准
单一模型往往不够稳,实操里常用逻辑回归加树模型做融合。最简单的方式是加权平均,权重按验证集 AUC 分配。更严谨的做法是用 stacking,把两个模型的预测值作为新特征再训一个逻辑回归。
分数校准是另一个容易被忽略的环节。树模型输出的概率往往偏高或偏低,不能直接当违约概率用。常用方法是 Platt Scaling 或 Isotonic Regression,把预测分数映射到真实概率。评分卡场景下还要把概率转成 300 到 850 的分数,公式是score = offset + factor * ln(odds),其中factor = pdo / ln(2),pdo 是「分数翻倍所需的 odds 变化」,一般设 20 或 50。
from sklearn.isotonic import IsotonicRegression # 用验证集做概率校准 iso = IsotonicRegression(out_of_bounds="clip") iso.fit(pred_valid, y_valid) calibrated = iso.predict(pred_test) # 概率转评分卡分数 import numpy as np pdo = 50 factor = pdo / np.log(2) offset = 500 # 基准分 odds = (1 - calibrated) / calibrated # 好坏人比 score = offset + factor * np.log(odds) print(f"分数范围: {score.min():.0f} ~ {score.max():.0f}")out_of_bounds="clip"防止测试集出现训练集没见过的概率值时校准失效。分数转换那几行里,offset和factor要根据业务定,基准分 500、pdo 50 是常见配置。算出来的分数要检查分布,正常应该集中在 500 到 700 之间,如果大量堆在两端,说明模型区分度有问题。
4. 避坑与排查:风控建模里最容易翻车的五个地方
4.1 时间穿越导致 AUC 虚高
现象:离线 AUC 跑到 0.85 以上,上线后 KS 只有 0.2,模型几乎失效。
原因:特征里混入了未来信息。比如用「当前逾期天数」预测「是否违约」,但当前逾期天数本身就是违约之后才知道的。或者做时间切分时,训练集用了未来数据做统计。
解决:所有特征必须确认在预测时点可得。时间切分要严格按申请日期,训练集用早期数据,测试集用后期数据,中间留一段观察期。做特征统计时,均值、分位数这些只能用训练集算,不能全量算完再切。
4.2 样本定义不一致导致标签噪声
现象:模型训练时 AUC 正常,但业务反馈「预测坏客户里很多其实是好客户」。
原因:坏样本定义不统一。有的按「逾期 30 天以上」,有的按「逾期 90 天以上」,混在一起训练标签就乱了。观察期和表现期没对齐也是常见问题。
解决:建模前先和业务确认坏样本定义,写进文档。观察期(申请到放款)和表现期(放款到判定好坏)要明确,一般表现期取 6 到 12 个月。不同定义的数据不要混在一起训。
4.3 WOE 分箱在新样本上映射失败
现象:训练时 WOE 转换正常,上线后新样本分箱报错或映射到错误区间。
原因:分箱边界是用训练集算的,新样本的取值可能落在边界外,或者训练集某个箱在新样本里没有样本。
解决:把分箱边界持久化保存,上线时用同一套边界做pd.cut。边界外的值统一归到最近的一箱。WOE 映射表也要保存,新样本按边界找对应 WOE 值,找不到就填 0 或训练集的均值。
4.4 特征共线性让逻辑回归系数失真
现象:逻辑回归系数符号和业务常识相反,比如「收入越高违约率越高」。
原因:特征之间高度相关,比如「月收入」和「年收入」同时入模,系数会互相抵消甚至翻转。
解决:入模前算 VIF,超过 10 的考虑剔除。或者先做相关性矩阵,相关系数超过 0.7 的只保留一个。WOE 转换本身能缓解一部分共线性,但不能完全消除。
4.5 模型监控缺失导致效果衰减无人知
现象:模型上线半年后 KS 从 0.4 掉到 0.15,但没人发现,直到坏账率飙升才排查。
原因:没有做 PSI 监控。PSI 衡量的是当前样本分布和训练样本分布的差异,超过 0.25 说明分布漂移严重,模型需要重新训练。
解决:上线后每月算一次 PSI,特征层面和分数层面都要算。PSI 超过 0.1 关注,超过 0.25 预警。同时监控 KS 和坏账率,三者结合判断模型是否失效。
def calc_psi(expected, actual, bins=10): """计算 PSI,expected 是训练集分布,actual 是当前样本""" breakpoints = np.percentile(expected, np.linspace(0, 100, bins + 1)) breakpoints[0], breakpoints[-1] = -np.inf, np.inf expected_perc = np.histogram(expected, breakpoints)[0] / len(expected) actual_perc = np.histogram(actual, breakpoints)[0] / len(actual) # 防止除零 expected_perc = np.where(expected_perc == 0, 0.0001, expected_perc) actual_perc = np.where(actual_perc == 0, 0.0001, actual_perc) return np.sum((actual_perc - expected_perc) * np.log(actual_perc / expected_perc))np.percentile那行用训练集算分位点,保证两批样本用同一套边界。除零保护是必须的,某个区间没样本时 PSI 会算出 inf。这个函数建议封装成工具,每月跑一次,结果存表,画趋势图。
5. 从离线到上线:评分卡部署和效果验证的几个关键动作
模型训完只是开始,真正决定价值的是上线之后的表现。我一般会把部署拆成三块:分数计算服务、监控看板、重训触发机制。
分数计算服务最简单的做法是把 WOE 映射表和模型系数导出成 JSON,线上用 Python 或 Java 加载后按公式算分。注意线上环境不一定有 pandas,所以 WOE 映射要用纯字典实现,别依赖pd.cut。模型系数和 WOE 表要版本化管理,每次重训生成新版本,方便回滚。
监控看板至少要有四个指标:每日申请量、分数分布、PSI、KS。分数分布看是否偏移,PSI 看特征漂移,KS 看区分度。KS 的计算用上线后积累的带表现标签样本,一般要等 3 到 6 个月才有足够样本。
重训触发条件我设三条:PSI 连续两个月超过 0.25、KS 下降超过 30%、业务侧反馈明显异常。满足任意一条就启动重训流程。重训不是全量重来,而是先做特征稳定性分析,看是哪些特征漂移了,再决定是替换特征还是调整分箱。
import json # 导出 WOE 映射表和模型系数,供线上使用 woe_map = {} for feature in features: woe_map[feature] = { "bins": bin_edges[feature], # 分箱边界 "woe": woe_values[feature] # 每个箱对应的 WOE } model_artifact = { "version": "20240101", "features": features, "woe_map": woe_map, "coef": lr.coef_[0].tolist(), "intercept": lr.intercept_[0], "score_offset": 500, "score_factor": 50 / np.log(2) } with open("scorecard_v20240101.json", "w") as f: json.dump(model_artifact, f, ensure_ascii=False, indent=2)导出成 JSON 的好处是跨语言、易版本管理。version字段用日期,方便追溯。线上加载后,对每个特征先按bins找区间,再取对应woe,乘系数求和加截距,最后按score_offset和score_factor转成分数。这套流程跑通之后,重训就是替换 JSON 文件的事,风险可控。
最后说个我自己的习惯:每次模型上线前,我会手动挑 20 个样本,从原始数据开始一步步算到最终分数,和线上服务的结果对一遍。这个动作看起来笨,但帮我抓到过好几次 WOE 映射错位和系数符号反了的问题。模型这东西,离线指标再好看,上线对不上就是白搭。希望帮到你。
本文还有配套的精品资源,点击获取