Vintage、滚动率与迁移矩阵:信贷风控资产质量监控实战 简介这份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(indexmob, columnsvintage_month, valuescum_dpd30_rate) .sort_index()) # 计算每个 Vintage 的已成熟最大 MOB cur pd.Period(pd.Timestamp.today(), freqM) mature_max {c: (cur - pd.Period(c, freqM)).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], markero, labelstr(col)) ax.set_xlabel(MOB) ax.set_ylabel(累计 DPD30 率) ax.set_xticks(range(0, CUT_MOB 1)) ax.legend(fontsize8, ncol2) 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后期委外节点M490 以上实质不良分母有两种主流做法一种是期初处于该档位的全部在贷账户另一种是期初处于该档位且在下一期仍有应还款项的账户。前者简单但会把下一期已经提前结清的客户算进分母导致滚动率被系统性低估后者更贴近真实劣变概率代价是需要多关联一张还款计划表。我一般做监控用前者、做模型和拨备用后者并在指标口径说明里写清楚用的是哪一种。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(indexfrom_stage, columnsto_stage, valuesroll_rate, aggfuncmean) .reindex(indexorder, columnsorder) .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(axis1), axis0) 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 曲线和滚动率表各存一份快照标注变更日期。风控指标的连续性比单月的精确度更重要半年后回头看曲线上的断点能省掉大量「这个月到底发生了什么」的排查时间。本文还有配套的精品资源点击获取