资讯动态

R语言评分卡实战:从数据清洗到PSI监控的互联网金融风控模型

发布时间:2026/10/22 18:22:55 来源:尧图企业网站定制
简介这份资源是面向数据挖掘学习者与互联网金融风控从业者的课程资料包聚焦如何用R语言对海量互金数据做深入分析并构建有效风控模型适合具备一定统计与编程基础、希望提升实战能力的中高级学员。包内共4个文件包含R程序源代码、R历史记录、PDF讲义与PPT课件分别用于动手实践、复现操作过程以及系统梳理知识点压缩包约10.15MB轻量便于下载学习。课程内容覆盖数据导入清洗与转换、特征工程、信用评分卡与逻辑回归、决策树、随机森林、支持向量机等模型构建以及交叉验证、网格搜索调参和AUC、ROC曲线等评估指标并配有从数据清洗到建模的完整实战演练。目前已有266人学习读者可借助源码与课件快速理解互金风控建模全流程积累可迁移到实际业务中的分析与排错经验。1. 互联网金融风控模型到底在挖什么从一份课程资料说起很多人第一次接触「大数据挖掘之互联网金融风控模型」这个方向是被一份打包资料吸引进来的——里面通常有 R 语言程序源代码、PPT 课件还有一份 .rar 压缩包。但真正打开之后最常见的困惑不是代码跑不起来而是不知道这些代码在业务上到底对应什么动作。互联网金融风控模型的核心其实就三件事判断这笔借款该不该放、额度给多少、利率定多高。数据挖掘在这里的角色是把历史借贷行为、还款表现、多头借贷记录这些原始字段变成能排序的分数。这套东西适合谁一是刚转行做风控建模、手里只有 R 基础的数据分析人员二是做信贷产品、需要看懂模型输出但不想被算法黑匣子绕晕的产品和运营三是想拿一套完整案例练手的在校学生。它解决的不是「预测未来」这种玄学问题而是把「这个人过去的行为像不像会逾期的人」变成一个可复现的流程。下面我按一份典型课程资料的结构把从数据到评分卡的路径拆开讲代码用 R参数和坑都标清楚。2. 用 R 把原始借贷流水变成建模宽表字段清洗与标签定义2.1 先搞清楚哪些字段能进模型哪些必须扔拿到一份互联网金融风控的原始数据第一眼看到的往往是几十个字段用户 ID、申请时间、借款金额、期限、利率、还款状态、历史逾期次数、近 30 天申请次数等等。不是所有字段都能直接喂给模型。我一般会先做三件事把主键和时间戳单独拎出来、把标签列锁定、把明显属于「事后信息」的字段剔除。所谓事后信息就是放款之后才知道的字段比如「实际还款日期」「催收记录」。这些字段如果在训练集里出现模型离线指标会好得离谱上线就翻车。常见做法是只保留申请时点及之前能拿到的变量。下面这段 R 代码做的是读入数据、剔除事后字段、并把字符型日期转成标准格式。# 读入原始借贷流水假设为 csv 格式 raw - read.csv(loan_raw.csv, stringsAsFactors FALSE, encoding UTF-8) # 事后字段清单这些字段放款后才有训练阶段必须剔除 post_loan_cols - c(repay_date, collection_flag, settle_amount) # 只保留申请时点可见的字段 model_data - raw[, !(names(raw) %in% post_loan_cols)] # 日期字段统一转换避免后续计算逾期天数时格式错乱 model_data$apply_time - as.POSIXct(model_data$apply_time, format %Y-%m-%d %H:%M:%S) # 检查缺失率超过 60% 的字段先标记出来 missing_rate - sapply(model_data, function(x) sum(is.na(x)) / length(x)) high_missing - names(missing_rate[missing_rate 0.6]) print(high_missing)这段代码的逻辑很直白先做字段级过滤再做格式统一最后用缺失率筛一遍。参数上encoding UTF-8是为了防止中文列名乱码缺失率阈值 0.6 不是死规定业务上如果某个字段很重要但缺失高可以保留并单独做缺失值填充而不是一刀切删掉。2.2 标签怎么定义直接决定模型是「能用」还是「白做」风控模型的标签通常定义为「未来某段时间内是否逾期超过 X 天」。这里有两个参数必须写死观察窗口和表现窗口。观察窗口是拿多久的行为数据来预测表现窗口是放款后多久看结果。常见组合是观察窗口 6 个月、表现窗口 6 个月也就是用前 6 个月的申请和行为预测放款后 6 个月内会不会出现 M1逾期 1 期以上。# 定义表现窗口放款后 180 天内是否出现逾期超过 30 天 model_data$label - ifelse( !is.na(model_data$overdue_days) model_data$overdue_days 30, 1, 0 ) # 剔除表现期不足的样本避免标签不成熟 model_data - model_data[model_data$observation_days 180, ] # 查看好坏样本比例 table(model_data$label) prop.table(table(model_data$label))这里最容易踩的坑是「标签不成熟」有些样本放款才两个月还没到表现窗口结束就被当成好样本结果模型学到的全是噪声。所以observation_days 180这个过滤条件不能省。另外好坏样本比例如果低于 1:20后续要做采样或调权重否则模型会倾向于全预测为好样本。2.3 宽表落地前把类别变量和缺失值处理干净R 里跑逻辑回归或评分卡类别变量不能直接扔进去。常见做法是对高基数类别变量做 WOE 编码对低基数做哑变量。缺失值方面数值型用中位数填充并加一个缺失指示列类别型把缺失单独当成一类。下面这段代码演示一个简化版的处理流程。# 数值型缺失用中位数填充并生成缺失指示列 num_cols - c(age, income, apply_cnt_30d) for (col in num_cols) { na_flag - is.na(model_data[[col]]) model_data[[paste0(col, _is_na)]] - as.integer(na_flag) model_data[[col]][na_flag] - median(model_data[[col]], na.rm TRUE) } # 类别型缺失统一填 MISSING cat_cols - c(city_level, education, marital_status) for (col in cat_cols) { model_data[[col]][is.na(model_data[[col]])] - MISSING } # 保存宽表供后续分箱和建模使用 write.csv(model_data, model_wide_table.csv, row.names FALSE)参数说明中位数填充比均值填充更稳因为收入这类字段常有极端值加缺失指示列是为了让模型知道这个值原本是缺失的而不是真实的中位数。类别型填MISSING而不是众数是因为缺失本身可能携带信息比如不愿意填学历的人风险表现往往和填了的人不一样。3. 评分卡建模全流程分箱、WOE、逻辑回归与分数转换3.1 分箱不是随便切先看单调性再定切点变量分箱是评分卡最耗时的一步。常见做法有等频分箱、等距分箱和决策树分箱。我一般先用等频分箱跑一版再看每个箱的坏样本率是否单调。如果不单调就手动合并相邻箱。R 里可以用smbinning包做初步分箱也可以用下面这段自定义函数快速看分布。# 自定义等频分箱函数返回每个箱的样本量和坏样本率 equal_freq_bin - function(df, var, label, bins 5) { breaks - quantile(df[[var]], probs seq(0, 1, length.out bins 1), na.rm TRUE) breaks - unique(breaks) # 防止重复切点 df$bin - cut(df[[var]], breaks breaks, include.lowest TRUE) result - aggregate(df[[label]], by list(df$bin), FUN function(x) { c(count length(x), bad_rate mean(x)) }) result } # 对年龄字段做 5 等频分箱 bin_result - equal_freq_bin(model_data, age, label, bins 5) print(bin_result)逻辑说明quantile按分位点切保证每箱样本量接近cut生成箱标签aggregate统计每箱的样本数和坏样本率。参数bins 5是经验值样本量小的时候可以降到 3样本量大可以升到 10。关键看坏样本率是否随箱号递增或递减如果中间某箱突然跳变就要考虑合并。3.2 WOE 和 IV 的计算以及怎么用 IV 筛变量WOEWeight of Evidence是把每个箱的坏样本率转换成对数值IVInformation Value是 WOE 的加权和。IV 用来衡量变量的预测能力通常 IV 小于 0.02 的变量直接扔掉0.02 到 0.1 弱0.1 到 0.3 中等大于 0.3 要警惕过拟合。下面代码计算 WOE 和 IV。# 计算每个箱的 WOE 和 IV calc_woe_iv - function(df, var, label) { df$bin - as.factor(df[[var]]) total_bad - sum(df[[label]] 1) total_good - sum(df[[label]] 0) stats - do.call(rbind, lapply(levels(df$bin), function(b) { sub - df[df$bin b, ] bad - sum(sub[[label]] 1) good - sum(sub[[label]] 0) woe - log((bad / total_bad) / (good / total_good)) iv - (bad / total_bad - good / total_good) * woe data.frame(bin b, bad bad, good good, woe woe, iv iv) })) stats } # 假设 age_bin 是已经分好箱的变量 woe_table - calc_woe_iv(model_data, age_bin, label) print(woe_table) print(paste(IV , sum(woe_table$iv)))参数说明total_bad和total_good是全样本的好坏数量woe公式里分子是箱内坏样本占比分母是箱内好样本占比iv是每个箱的贡献求和得到变量总 IV。注意如果某个箱的好样本或坏样本为 0WOE 会变成无穷大实际处理时要把这个箱和相邻箱合并。3.3 逻辑回归建模与分数转换把概率变成 300 到 850 的评分变量筛完之后用逻辑回归拟合。R 里用glm函数family 设为 binomial。拟合完不能直接上线要把预测概率转换成标准评分。常见公式是score A - B * log(odds)其中A和B由基准分和 PDOPoints to Double the Odds决定。# 逻辑回归建模假设已选入 5 个变量 final_vars - c(age_bin_woe, income_bin_woe, apply_cnt_30d_woe, overdue_cnt_hist_woe, city_level_woe) formula - as.formula(paste(label ~, paste(final_vars, collapse ))) lr_model - glm(formula, data model_data, family binomial(link logit)) # 分数转换基准分 600PDO 50基准 odds 为 1:1 A - 600 50 * log(1) / log(2) B - 50 / log(2) # 计算每个样本的评分 model_data$prob - predict(lr_model, type response) model_data$odds - model_data$prob / (1 - model_data$prob) model_data$score - round(A - B * log(model_data$odds)) # 查看评分分布 summary(model_data$score)逻辑说明glm拟合逻辑回归predict输出概率A和B的推导基于「odds 翻倍时分数下降 PDO」这个约定。参数上基准分 600 和 PDO 50 是行业常见起点实际项目里要根据通过率和坏账率反推。评分越高代表风险越低通常 600 以上算较好客户550 以下要人工复核。4. 模型验证与监控AUC、KS 和 PSI 到底怎么看4.1 AUC 和 KS 的计算以及为什么不能只看 AUCAUC 衡量的是模型把好样本排在坏样本前面的能力KS 衡量的是好坏样本累计分布的最大差值。两个指标要一起看AUC 高但 KS 低说明模型整体排序可以但区分度不够尖锐。下面代码用pROC包计算 AUC 和 KS。library(pROC) # 计算 AUC roc_obj - roc(model_data$label, model_data$prob) auc_value - auc(roc_obj) print(paste(AUC , round(auc_value, 4))) # 计算 KS ks_value - max(roc_obj$sensitivities roc_obj$specificities - 1) print(paste(KS , round(ks_value, 4)))参数说明roc的第一个参数是真实标签第二个是预测概率sensitivities和specificities是 ROC 曲线上的灵敏度和特异度。经验上AUC 低于 0.6 的模型基本不能用KS 低于 0.2 说明区分度不足。但这两个指标都是在训练集或测试集上算的上线后要看 PSI。4.2 PSI 监控模型上线后有没有「漂移」PSIPopulation Stability Index用来比较训练集和当前样本的分数分布差异。PSI 小于 0.1 说明稳定0.1 到 0.25 要关注大于 0.25 说明分布明显漂移模型需要重新训练。下面代码计算两个时间段的 PSI。# 计算 PSIexpected 是训练集分数分布actual 是当前样本分布 calc_psi - function(expected, actual, bins 10) { breaks - quantile(expected, probs seq(0, 1, length.out bins 1), na.rm TRUE) breaks - unique(breaks) exp_pct - table(cut(expected, breaks, include.lowest TRUE)) / length(expected) act_pct - table(cut(actual, breaks, include.lowest TRUE)) / length(actual) psi - sum((act_pct - exp_pct) * log(act_pct / exp_pct)) psi } # 假设 train_score 和 oot_score 是两个时间段的评分 psi_value - calc_psi(train_score, oot_score) print(paste(PSI , round(psi_value, 4)))逻辑说明先按训练集分数切分箱再分别统计训练集和当前样本落在每个箱的比例最后按 PSI 公式求和。参数bins 10是常用值样本量小的时候可以降到 5。注意如果某个箱在 actual 里占比为 0log 会出问题实际代码里要加一个极小值保护。4.3 跨时间验证OOT 样本比随机切分更接近真实上线很多课程资料只做随机切分训练集和测试集但风控模型必须做跨时间验证。常见做法是按申请时间切分前 8 个月做训练后 2 个月做 OOTOut of Time测试。下面代码演示按时间切分并分别计算 AUC。# 按申请时间排序 model_data - model_data[order(model_data$apply_time), ] # 前 80% 做训练后 20% 做 OOT cut_point - floor(nrow(model_data) * 0.8) train_data - model_data[1:cut_point, ] oot_data - model_data[(cut_point 1):nrow(model_data), ] # 在训练集上建模在 OOT 上验证 lr_train - glm(formula, data train_data, family binomial(link logit)) oot_data$prob - predict(lr_train, newdata oot_data, type response) # 计算 OOT 的 AUC roc_oot - roc(oot_data$label, oot_data$prob) print(paste(OOT AUC , round(auc(roc_oot), 4)))参数说明cut_point按 80% 切分是常见做法但更严格的做法是按固定时间点切比如用 1 到 8 月训练9 到 10 月验证。OOT AUC 通常比随机测试集低 0.02 到 0.05如果低太多说明模型对时间不稳定要检查变量里有没有随时间漂移严重的字段。5. 避坑与排查R 语言风控建模里最容易翻车的 5 个地方5.1 现象模型 AUC 0.85上线后坏账率反而升高原因训练集里混入了事后字段或者标签定义时用了放款后的信息。比如「催收次数」这种字段只有逾期的人才有模型学到的是「有催收记录的人会逾期」而不是「会逾期的人有催收记录」。解决把所有字段按「申请时点是否可见」过一遍凡是放款后产生的字段一律剔除。可以用下面这段代码做字段审计。# 字段审计列出所有字段名人工标记是否申请时可见 field_audit - data.frame( field names(raw), visible_at_apply NA # 人工填写 TRUE/FALSE ) # 导出后逐字段确认FALSE 的字段从模型数据中删除 write.csv(field_audit, field_audit.csv, row.names FALSE)5.2 现象WOE 计算时出现 Inf 或 NaN原因某个分箱里好样本或坏样本数量为 0导致 log 里出现 0 或无穷大。解决在计算 WOE 前检查每个箱的好坏样本数如果小于 5就和相邻箱合并。下面代码做合并前的检查。# 检查每个箱的好坏样本数 bin_check - aggregate(model_data$label, by list(model_data$age_bin), FUN function(x) { c(bad sum(x 1), good sum(x 0)) }) print(bin_check) # 如果某个箱 bad 或 good 小于 5手动调整分箱切点5.3 现象逻辑回归系数符号和业务常识相反原因变量之间存在多重共线性或者 WOE 编码时把方向搞反了。比如收入越高应该风险越低但系数是正的。解决先算变量之间的相关系数矩阵把相关系数大于 0.7 的变量挑出来只保留 IV 更高的那个。然后检查 WOE 编码的方向确保坏样本率高的箱 WOE 也高。# 计算变量间相关系数 cor_matrix - cor(model_data[, final_vars], use pairwise.complete.obs) # 找出相关系数大于 0.7 的变量对 high_cor - which(abs(cor_matrix) 0.7 upper.tri(cor_matrix), arr.ind TRUE) print(high_cor)5.4 现象评分分布集中在 600 附近区分度不够原因分数转换时 A 和 B 的参数设得不对或者模型本身区分度不足。解决先看 AUC 和 KS如果 AUC 正常但分数集中调整 PDO 和基准分。PDO 越小分数分布越分散。可以画评分分布直方图确认。# 画评分分布直方图 hist(model_data$score, breaks 50, main Score Distribution, xlab Score, col lightblue) # 如果分布太集中把 PDO 从 50 降到 20 或 305.5 现象OOT 样本上 PSI 超过 0.25但 AUC 没降原因分数分布漂移但排序能力还在可能是某个变量的分布变了但变量本身的区分度没变。解决逐变量计算 PSI找出漂移最严重的变量判断是业务变化还是数据采集问题。如果某个变量 PSI 持续偏高考虑在下一版模型里替换掉。# 逐变量 PSI 计算 var_psi - sapply(final_vars, function(v) { calc_psi(train_data[[v]], oot_data[[v]]) }) print(sort(var_psi, decreasing TRUE))6. 把课程资料变成自己的东西从跑通代码到改一版能用的评分卡一份打包资料最大的价值不是代码本身而是它提供了一条从原始数据到评分卡的完整链路。但直接跑通只能算「看过」真正变成自己的东西得做三件事换数据、改参数、加监控。换数据是指找一份自己的借贷流水哪怕只有几千条按第 2 章的流程重新走一遍字段清洗和标签定义。这一步会逼着你面对真实数据里的各种脏情况比如同一个用户多条申请记录、时间戳格式不统一、标签列有缺失。改参数是指把分箱数量、IV 阈值、PDO 这些参数按自己的数据分布调一遍观察 AUC 和 KS 的变化。加监控是指把第 4 章的 PSI 计算封装成一个函数每月跑一次看分数分布有没有漂移。下面这段代码把前面几章的核心步骤串成一个可复用的函数框架方便你替换数据后直接跑。# 可复用的评分卡建模框架 build_scorecard - function(data, label_col, candidate_vars, bins 5) { # 1. 分箱和 WOE 计算 woe_tables - list() for (v in candidate_vars) { data[[paste0(v, _bin)]] - equal_freq_bin(data, v, label_col, bins)$bin woe_tables[[v]] - calc_woe_iv(data, paste0(v, _bin), label_col) } # 2. 按 IV 筛选变量 iv_values - sapply(woe_tables, function(x) sum(x$iv)) selected_vars - names(iv_values[iv_values 0.02]) # 3. 逻辑回归建模 formula - as.formula(paste(label_col, ~, paste(selected_vars, collapse ))) model - glm(formula, data data, family binomial(link logit)) # 4. 分数转换 A - 600 50 * log(1) / log(2) B - 50 / log(2) data$prob - predict(model, type response) data$score - round(A - B * log(data$prob / (1 - data$prob))) # 5. 返回模型、评分和 IV 表 list(model model, data data, iv iv_values, woe woe_tables) } # 使用示例 result - build_scorecard(model_data, label, c(age, income, apply_cnt_30d)) print(result$iv)这个框架里candidate_vars是候选变量列表bins是分箱数量。实际使用时先把所有可能用到的变量列进去让 IV 筛选自动淘汰弱变量。注意equal_freq_bin和calc_woe_iv需要提前定义就是第 3 章里的那两个函数。最后说一个我自己的习惯每次跑完模型先把评分分布、AUC、KS、PSI 四个数记在一张表里再开始调参。调参过程中只改一个参数改完重新记录。这样即使某次调参翻车了也能快速回退到上一版。风控建模没有一步到位的玄学靠的就是这种笨办法一点点逼近可用状态。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑