简介:这份PDF资料聚焦信贷风控资产质量分析,面向风控建模、策略分析及信贷数据挖掘从业者,系统梳理账龄分析(Vintage)、滚动率分析与迁移率分析三大理论。内容从MOB、DPD、M0—M7等基础指标讲起,结合葡萄酒Vintage曲线类比,说明如何判断资产质量、账户成熟期与表现期,并借助滚动率与迁移率确定目标变量Y、观察不同逾期状态间的转化规律。资源为1个PDF文件,压缩包约1.55MB,篇幅紧凑,适合碎片时间查阅。目前已有1625人学习,可作为风控模型与反欺诈场景下的案头参考。读者能获得从概念口径到计算逻辑、再到业务应用的完整梳理,包括逾期率订单口径与金额口径差异、Vintage曲线解读要点,以及策略收紧、客群变化等因素对资产质量的影响分析思路,便于快速建立分析框架并落地到实际报表与模型变量定义中。
1. 为什么存量 90+ 的指标漂亮,新客 Vintage 却已经烂了
季度风险复盘会上经常出现一个反直觉的场面:资产端报表里 90+ 逾期率连着三个月往下走,管理层据此判断资产质量在改善,可把新放款月份的账龄曲线单独拉出来一看,新客的 M3 表现其实一轮比一轮差。原因不复杂,存量口径是一个混合池,历史放款的优质老客还在正常还款,把新客的劣变稀释掉了,指标下降只是结构变化带来的错觉。
要看清真实风险,得用三把尺度完全不同的尺子。Vintage 按放款月份切片,看同一批客户随账龄增长而劣变的速度,回答「哪一批客群变坏了」;滚动率盯住某个账龄档位,看这一个月里有多少客户滑向更差的档位,回答「坏在哪个环节」;迁移率把所有这些档位之间的流转关系组织成一个矩阵,用矩阵幂运算外推未来几个月的损失,回答「接下来要准备多少钱」。做资产质量监控、贷中策略调优、拨备测算的人基本绕不开这三张表,它们也是数据口径最容易被做错的地方。
2. Vintage 曲线怎么切:放款月、MOB 账龄与累计逾期率的计算口径
2.1 MOB 账龄的定义与三个必须先固定的口径
MOB 是 Month on Book,放款当月记 MOB0 还是 MOB1,团队里必须有唯一答案。常见做法是把放款日所在的自然月当 MOB0,放款之后第一个完整自然月记 MOB1,按月末快照折算月份差。如果一部分报表按日折算、一部分按自然月折算,两条曲线叠在一起会出现半个月的错位,这不是风险变化,是口径打架。
比 MOB 更容易出错的是下面三个口径,做 Vintage 之前先写进数据字典:
| 口径项 | 常见取值 | 选错后的典型后果 |
|---|---|---|
| 风险定义 | DPD≥1 / DPD≥30 / DPD≥90 | 曲线整体抬高或压低,跨部门对不上数 |
| 状态范围 | 含核销 / 剔除核销 / 含已结清 | 尾部账龄段被核销订单压低,越到后面越失真 |
| 分母基数 | 放款户数 / 放款金额 / 期初在贷余额 | 户数口径与金额口径走势背离,无法归因 |
我在实际项目里更倾向户数与金额两条曲线都出一版:户数口径反映客群质量,金额口径反映资金敞口,两者背离往往意味着大额客户和小额客户的风险分层不同,这本身就是策略线索。
2.2 从日快照生成 MOB 逾期宽表的 SQL
原始数据一般是借据放款表和每日快照表,先用月末快照把借据打平到「借据 × MOB」粒度,再按 Vintage 聚合。
-- 1) 放款底表:确定每笔借据的 Vintage 月份 WITH loan_base AS ( SELECT loan_id, cust_id, disburse_date, disburse_amt, trunc(disburse_date, 'MM') AS vintage_month FROM dwd_loan_disburse WHERE biz_type = 'CASH' -- 只取现金分期,混入消费分期会污染曲线 AND disburse_date >= '2024-01-01' ), -- 2) 月末快照:只保留每月最后一天,避免同月多条快照重复计数 snap AS ( SELECT s.loan_id, s.overdue_days, trunc(s.snapshot_date, 'MM') AS stat_month, months_between(trunc(s.snapshot_date, 'MM'), trunc(l.disburse_date, 'MM')) AS mob FROM dwd_loan_daily_snapshot s JOIN loan_base l ON s.loan_id = l.loan_id WHERE s.snapshot_date = last_day(s.snapshot_date) -- 月末口径 ), -- 3) 打平到 借据 × MOB,取该账龄月内的最大逾期天数 mob_panel AS ( SELECT loan_id, vintage_month, mob, max(overdue_days) AS max_dpd FROM snap GROUP BY loan_id, vintage_month, mob ) -- 4) 按 Vintage × MOB 聚合累计逾期率 SELECT vintage_month, mob, count(DISTINCT loan_id) AS book_cnt, count(DISTINCT CASE WHEN max_dpd >= 30 THEN loan_id END) AS dpd30_cnt, count(DISTINCT CASE WHEN max_dpd >= 90 THEN loan_id END) AS dpd90_cnt, round(count(DISTINCT CASE WHEN max_dpd >= 30 THEN loan_id END) / count(DISTINCT loan_id), 4) AS cum_dpd30_rate FROM mob_panel GROUP BY vintage_month, mob ORDER BY vintage_month, mob;last_day(s.snapshot_date)保证每个自然月只取一条快照,这是整个口径里最容易被忽略的一行;如果快照表本身已经只存月末,这行可以去掉,但加上不亏。max(overdue_days)取的是账龄月内的峰值,得到的才是「累计」逾期率,如果改成取月末当天值,曲线会因为客户临时还清而上下抖动。count(DISTINCT loan_id)保证分母是户数而不是快照行数,这一点在快照表存在重复写入时必须显式写出来。
提示:
months_between在部分引擎里返回小数,先trunc到自然月再相减,能避免 MOB 出现 0.97、1.03 这种值。
2.3 用 Python 画曲线并标出未成熟账龄
Vintage 曲线最大的阅读陷阱是把还没跑完的账龄段也画成实线。放款月加 MOB 超过当前账月的点,样本没走完,只能画虚线或者干脆截断。
import pandas as pd import matplotlib.pyplot as plt CUT_MOB = 12 df = pd.read_sql(VINTAGE_SQL, conn) pivot = (df[df['mob'] <= CUT_MOB] .pivot(index='mob', columns='vintage_month', values='cum_dpd30_rate') .sort_index()) # 计算每个 Vintage 的已成熟最大 MOB cur = pd.Period(pd.Timestamp.today(), freq='M') mature_max = {c: (cur - pd.Period(c, freq='M')).n for c in pivot.columns} fig, ax = plt.subplots(figsize=(10, 6)) for col in pivot.columns: series = pivot[col] m = mature_max[col] ax.plot(series.index[series.index <= m], series[series.index <= m], marker='o', label=str(col)) ax.set_xlabel('MOB') ax.set_ylabel('累计 DPD30 率') ax.set_xticks(range(0, CUT_MOB + 1)) ax.legend(fontsize=8, ncol=2) plt.tight_layout()mature_max用自然月相减得到每个放款月理论上能观察到多少期账龄,超过这个值的点直接不画,比画虚线更不容易被误读。图的横轴是 MOB,纵轴是累计逾期率,正常形态是前期快速抬升、MOB6 之后趋缓;如果新一批曲线在 MOB3 之前就明显高于历史批次,说明前端准入或授信额度出了问题,此时应该马上去看滚动率里 C→M1 那一格有没有同步跳涨。
3. 滚动率怎么算:从 C 到 M1 的分母口径与月末快照对齐
3.1 风险档位划分与两种分母
滚动率描述的是相邻两期之间的状态流转,先把 DPD 映射成离散档位,再算从档位 A 迁到档位 B 的比例。
| 档位 | DPD 区间 | 说明 |
|---|---|---|
| C | 0 | 正常,含未到期 |
| M1 | 1–30 | 早期逾期,策略干预窗口 |
| M2 | 31–60 | 中期,回收率开始下降 |
| M3 | 61–90 | 后期,委外节点 |
| M4+ | 90 以上 | 实质不良 |
分母有两种主流做法:一种是期初处于该档位的全部在贷账户,另一种是期初处于该档位且在下一期仍有应还款项的账户。前者简单,但会把下一期已经提前结清的客户算进分母,导致滚动率被系统性低估;后者更贴近真实劣变概率,代价是需要多关联一张还款计划表。我一般做监控用前者、做模型和拨备用后者,并在指标口径说明里写清楚用的是哪一种。
3.2 按账龄对齐的滚动率 SQL
用月末快照自连接,把上个月的状态和下个月的状态拼到同一行,再做行内归一化。
WITH stage_month AS ( SELECT loan_id, trunc(snapshot_date, 'MM') AS stat_month, CASE WHEN overdue_days = 0 THEN 'C' WHEN overdue_days BETWEEN 1 AND 30 THEN 'M1' WHEN overdue_days BETWEEN 31 AND 60 THEN 'M2' WHEN overdue_days BETWEEN 61 AND 90 THEN 'M3' ELSE 'M4_PLUS' END AS risk_stage FROM dwd_loan_daily_snapshot WHERE snapshot_date = last_day(snapshot_date) AND loan_status <> 'SETTLED' -- 已结清订单退出分母 AND loan_status <> 'WRITE_OFF' -- 核销单独建状态,不要在这里吞掉 ), pair AS ( SELECT a.stat_month, a.risk_stage AS from_stage, coalesce(b.risk_stage, 'SETTLED') AS to_stage FROM stage_month a LEFT JOIN stage_month b ON a.loan_id = b.loan_id AND b.stat_month = add_months(a.stat_month, 1) ) SELECT stat_month, from_stage, to_stage, count(*) AS cnt, round(count(*) / sum(count(*)) OVER (PARTITION BY stat_month, from_stage), 4) AS roll_rate FROM pair GROUP BY stat_month, from_stage, to_stage ORDER BY stat_month, from_stage, to_stage;LEFT JOIN加coalesce是关键:下个月已经结清的客户不该从分母里消失,而应该落到SETTLED这个目标状态,这样每一行from_stage的滚动率之和才等于 1。add_months(a.stat_month, 1)用的是自然月对齐,如果业务是双周还款,这里要换成按账期序号对齐,否则跨月边界会错配。滚动率看的是相邻两期,所以快照表只要保证月末唯一,不需要额外处理账龄。
跑完之后重点看两行:C→M1 反映前端客群和首期还款能力,M1→M2 反映催收触达效果。C→M1 稳定而 M1→M2 上升,通常是催收资源或话术出了问题;两个一起涨,就要回到 Vintage 看是不是某一批渠道客群整体变差。
3.3 金额口径与户数口径差在哪
户数口径把每个账户权重设为 1,金额口径用期初应还本金或期末剩余本金做权重。两者背离时信息量很大:户数滚动率高、金额滚动率低,说明滑落的主要是小额多笔客户;反过来则意味着几笔大额客户出了问题,单笔损失就能吃掉整月的利润。
实现上把 SQL 里的count(*)换成sum(overdue_principal)即可,但要注意金额口径下SETTLED状态的权重应当是 0 而不是本金,否则结清客户会虚增分母。实务里我会同时输出两张表,并且在看板上用同一坐标轴对比,背离超过 20% 就单独归因。
4. 迁移率与迁移矩阵:把滚动率拼成马尔可夫链做损失外推
4.1 迁移矩阵的构造规则与吸收态
滚动率是单格数据,迁移率是把所有格子拼成一张方阵,行是期初状态、列是期末状态,每行之和为 1。矩阵里必须显式保留SETTLED和WRITE_OFF两个吸收态,一旦进入就不会再流出,否则核销订单会在后续月份反复参与滚动,把整张矩阵的尾部概率算高。
import numpy as np import pandas as pd order = ['C', 'M1', 'M2', 'M3', 'M4_PLUS', 'SETTLED', 'WRITE_OFF'] roll = pd.read_sql(ROLL_SQL, conn) # 按 from_stage 求各期滚动率的均值,得到一张静态矩阵 mat = (roll.pivot_table(index='from_stage', columns='to_stage', values='roll_rate', aggfunc='mean') .reindex(index=order, columns=order) .fillna(0.0)) # 吸收态行归一化为自身,防止历史数据里出现流出 for s in ['SETTLED', 'WRITE_OFF']: mat.loc[s, :] = 0.0 mat.loc[s, s] = 1.0 # 重新归一化,消除四舍五入带来的行和偏差 mat = mat.div(mat.sum(axis=1), axis=0) print(mat.round(4))吸收态处理是这一步的分水岭。见过不少实现把核销订单直接从样本里删掉,结果 M4+ 的滚动率被低估,因为最坏的那批客户根本没进分母。
4.2 用矩阵幂运算预测未来损失
有了静态矩阵,把当前各档位占比作为初始分布,逐期做向量乘矩阵,就能得到未来 N 期的状态分布和累积损失率。
P = mat.values cur_dist = pd.read_sql(CURRENT_DIST_SQL, conn) # 当月末各档位户数占比 state = np.zeros(len(order)) for k, v in zip(cur_dist['risk_stage'], cur_dist['ratio']): if k in order: state[order.index(k)] = v state = state / state.sum() rows = [] for n in range(1, 7): s = state @ np.linalg.matrix_power(P, n) rows.append({'future_month': n, 'bad_share': s[order.index('M4_PLUS')] + s[order.index('WRITE_OFF')], 'settled_share': s[order.index('SETTLED')]}) print(pd.DataFrame(rows).round(4))bad_share是未来第 n 个月末处于 M4+ 或已核销的期望占比,把六个月的增量差分一下再乘上平均损失率,就是滚动口径下的预期信用损失,可以直接进拨备模型。和传统账龄分析法相比,矩阵外推的好处是能把当前结构变化立刻传导到未来,而不是等三个月后报表上才看出来。
4.3 矩阵预测失真的四个常见来源
| 失真来源 | 表现 | 处理方式 |
|---|---|---|
| 状态口径混用 | 期初用月初、期末用月末,对角线虚高 | 统一月末快照,或统一用账期序号 |
| 群体异质性 | 新客和老客共用一套矩阵,新客风险被平滑 | 按渠道、客群、期次分群建矩阵 |
| 矩阵非齐次 | 季末冲量后一期滚动率整体跳变 | 用近 6 期加权,越近权重越高 |
| 吸收态缺失 | 核销订单回流,尾部概率虚高 | 单列 WRITE_OFF 并锁定为吸收态 |
分群粒度不是越细越好。分到每个渠道每个月只有几十个样本时,矩阵会充满 0 和 1,预测出来的损失率反而比不分群更离谱。经验阈值是每一行的分母不低于 300 户,低于这个数就向上合并一层。
5. 三张表联动的参数调优与监控落点
三张表不是并列关系,是一条排查链路。Vintage 先告诉你「哪一批变差了」,滚动率接着定位「环节在哪一格」,迁移矩阵最后量化「未来要准备多少拨备」。这个顺序反过来用会白费很多时间,比如一上来就调矩阵,最后发现根源是某个月改过一次授信额度规则。
落地时有几个参数值得固定下来并写进监控脚本。第一是滚动率基线窗口,用近 6 个月的中位数而不是均值,避免单月异常把基线抬起来;第二是矩阵更新频率,按月更新、按季重估分群;第三是成熟度阈值,Vintage 只看已跑完 MOB6 的批次,未成熟的批次单独标记不参与归因。
滚动率环比跳变的告警可以用稳健 z 分数,比直接比较阈值更抗异常值:
import numpy as np import pandas as pd hist = roll_c_m1.set_index('stat_month')['roll_rate'].sort_index() med = hist.rolling(6).median().shift(1) # 前 6 期中位数作基线 mad = (hist - med).abs().rolling(6).median().shift(1) # MAD 抗异常 robust_z = 0.6745 * (hist - med) / mad.replace(0, np.nan) alert = hist[(robust_z > 3.5) & (hist > 0.02)] # 双重条件:统计显著 + 绝对水平 print(alert)0.6745是 MAD 到标准差的换算系数,3.5是告警阈值,0.02是绝对水平地板,防止在滚动率本来就只有千分之几的 C→M1 上频繁误报。三个参数里真正需要按业务调整的是最后那个地板值,它决定了告警是灵敏还是迟钝,建议先用历史数据回测三个月再定。
最后补一个容易被忽略的细节:每次改完口径,把旧口径的 Vintage 曲线和滚动率表各存一份快照,标注变更日期。风控指标的连续性比单月的精确度更重要,半年后回头看曲线上的断点,能省掉大量「这个月到底发生了什么」的排查时间。
本文还有配套的精品资源,点击获取