Vintage、滚动率与迁移矩阵:信贷风控资产质量监控实战
2026/9/18 1:36:49 网站建设 项目流程

简介:这份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 区间说明
C0正常,含未到期
M11–30早期逾期,策略干预窗口
M231–60中期,回收率开始下降
M361–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 JOINcoalesce是关键:下个月已经结清的客户不该从分母里消失,而应该落到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。矩阵里必须显式保留SETTLEDWRITE_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 曲线和滚动率表各存一份快照,标注变更日期。风控指标的连续性比单月的精确度更重要,半年后回头看曲线上的断点,能省掉大量「这个月到底发生了什么」的排查时间。

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

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

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

立即咨询