news 2026/9/18 4:05:22

信贷风控Vintage、滚动率、迁移率:账龄分析与拨备SQL落地

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
信贷风控Vintage、滚动率、迁移率:账龄分析与拨备SQL落地

简介:这份PDF面向信贷风控建模人员、资产质量分析岗及金融风控学习者,系统梳理资产质量分析中绕不开的三块理论:账龄分析、滚动率分析与迁移率分析,帮助读者厘清概念、计算逻辑与业务应用之间的衔接关系。内容从基础指标切入,逐层展开MOB账龄、DPD逾期天数、M0至M7逾期期数等定义,再借助葡萄酒窖藏的类比讲清Vintage曲线的读图方法,包括判断资产质量、识别变化规律、确定账户成熟期与排查影响因素,并讨论观察点、观察期与表现期的取舍,以及订单口径与金额口径两种逾期率算法的差异。滚动率与迁移率部分进一步说明如何定义账户好坏程度、确定目标变量Y,为样本表现期的设定提供依据。资源为1个PDF文件,压缩包约1.55MB,篇幅紧凑、结构完整,适合逐节精读。目前已有1625人学习,可作为风控策略调整与建模准备的参考材料。

1. 同一批放款,为什么逾期率差了三倍

去年有朋友把两份资产质量报表拍在我桌上,问我为什么A产品放款半年逾期率不到1%,B产品放款半年逾期率接近4%,是不是B产品的风控团队水平太差。我把两份报表按放款月份对齐重排了一遍,结论是B产品的分母里混着大量还没走完账龄的账户。这不是风控水平问题,是账龄口径没对齐——把三个月大的账户和九个月大的账户放在同一个逾期率里比,就像拿婴儿的身高去比成年人的身高。

信贷风控里做资产质量分析,账龄(Vintage)、滚动率(Roll Rate)、迁移率(Flow Rate)这三件工具各管一段:Vintage管「什么时候成熟」,滚动率管「什么样算坏」,迁移率管「坏账一路怎么漏下来、最后要提多少拨备」。三者不是三选一,而是一条链上的三个环节。这篇就把这条链从头到尾拆开,从基础指标定义、SQL 实现、目标变量 Y 的确定,一直到拨备金额的计算。

2. MOB、DPD、M 三套账龄口径的 SQL 落地

2.1 三个基础指标的定义边界

做Vintage分析之前,先把三个指标的口径钉死,否则后面所有图表都是错的。

账龄 MOB(Month of Book)指资产放款月份与统计月份之间的间隔。MOB0 是放款日至当月月底,MOB1 是放款后第二个完整月份,依次递增。它的最大值取决于产品期限:12 期产品生命周期是 12 期,MOB 最大到 MOB12。举例,2019 年 11 月 13 日放款的订单,2019 年 11 月是 MOB0,2019 年 12 月是 MOB1。

逾期天数 DPD(Days Past Due)的常见定义是「实际还款日 − 应还款日次日」。若还款日是每月 8 号,9 号就是逾期第一天,客户 10 号还款则逾期 1 天。DPD30+ 表示逾期天数 ≥30 天的资产。

逾期期数 M用 M0 到 M7 划分状态:M0 当前未逾期(也写作 C,取自 Current),M1 逾期 1–30 日,M2 逾期 31–60 日,M3 逾期 61–90 日,M4 逾期 91–120 日,M5 逾期 121–150 日,M6 逾期 151–180 日,M7 逾期 180 日以上。

注意:DPD 和 M 经常被混用。DPD 是按天数的连续口径,M 是按账期的离散口径。建模定好坏用 M,因为催收和核销流程是按账期设计的;而监管报送里常见 DPD90+,两者在月边界上会差几天,做口径对照表时要专门核一遍。

2.2 用还款计划表算出 MOB 和当期逾期状态

数据源一般是客户还款计划表(repayment schedule),字段包括合同号、期次、应还款日、实际还款日、应还本金利息、已还金额。下面这段 SQL 是按月末快照算 MOB 和最坏逾期状态的核心逻辑:

-- 输入:loan(放款表)、repay(还款计划表)、snapshot_date(统计月末) -- 输出:每个合同在统计月的 MOB 与最坏逾期期数 M WITH base AS ( SELECT l.loan_id, l.disburse_date, -- MOB:放款月与统计月的月份差,放款当月为 0 TIMESTAMPDIFF(MONTH, DATE_TRUNC('month', l.disburse_date), DATE_TRUNC('month', :snapshot_date)) AS mob, r.due_date, r.actual_repay_date, -- 逾期天数:实际还款日(未还则取快照日)减去应还款日次日 DATEDIFF(DAY, DATEADD(DAY, 1, r.due_date), COALESCE(r.actual_repay_date, :snapshot_date)) AS dpd, r.should_principal + r.should_interest AS due_amt, r.paid_amt FROM loan l JOIN repay r ON l.loan_id = r.loan_id WHERE l.disburse_date <= :snapshot_date ) SELECT loan_id, mob, -- 把 DPD 映射为 M 状态:<=0 记 0,然后每 30 天升一级 GREATEST(0, CEIL(dp.overdue_dpd / 30.0)) AS m_state FROM ( SELECT loan_id, mob, MAX(CASE WHEN due_amt - paid_amt > 0 THEN dpd ELSE 0 END) AS overdue_dpd FROM base GROUP BY loan_id, mob ) dp;

逻辑说明:先按合同和期次算出每一期的逾期天数,再取合同在该 MOB 上的最坏值,最后按 30 天一档映射成 M 状态。参数说明::snapshot_date是统计截止日,决定曲线画到哪个 MOB;CEIL(dpd / 30.0)的除数要和业务口径一致,有的机构用 DPD30/60/90 的自然日边界,有的用账期边界,切换时改动这个除数即可。逾期天数小于等于 0 的用GREATEST(0, ...)兜底成 0,避免提前还款的负 DPD 污染统计。

2.3 快照表设计上的两个坑

第一坑是把 MOB 算成「放款后第几个月」而不是「放款所在月是 MOB0」,差一个月,整条曲线会整体右移,成熟期判断跟着错。第二坑是用当前余额反推历史逾期状态。还款计划表是逐渐被更新覆盖的,如果直接取现表做历史快照,已经结清的账户会全部显示为正常,逾期曲线会被系统性低估。常见做法是每天跑一次快照落分区表,按dt分区存下来,历史分析只读快照,不读变动表。

3. Vintage 曲线:从葡萄酒到资产成熟期判定

3.1 为什么按放款月份对齐才有意义

Vintage 一词来自葡萄酒业。每年采摘的葡萄受日照、气温、降水影响,最终酒质有差异;窖藏若干年后品质趋于稳定,这段年份数就是成熟期(maturity)。把入窖年份作为批次标签(Vintage 或 Cohort),每年抽样测量酒精浓度,就能画出浓度随窖藏时间变化的曲线。

映射到信贷:入窖年份对应放款月份,酒精浓度对应逾期率,窖藏年数对应 MOB。按 MOB 对齐后比较同一产品不同时期放款的资产,能回答四个问题:确定资产质量(曲线平缓后对应的逾期率)、分析变化规律(前几期上升快说明短期风险没抓住,往往是欺诈;全程缓慢上升说明信用风险识别能力不足)、确定账户成熟期(客户展现好坏的时间长度,即表现期)、分析影响因素(策略收紧放松、客群变化、市场环境)。

3.2 两种逾期率口径的差异

绘制 Vintage 曲线时,纵坐标逾期率有两种计算口径:

口径公式特点
订单口径逾期订单数 / 总放贷订单数计算简单,小额度订单与大额度订单权重相同
金额口径逾期金额 / 总放贷金额反映真实资金损失敞口,但受大额订单影响大

各家机构的定义存在差异,因此单看公开披露的 Vintage 曲线,有时并不能客观比较资产质量和风控水平。自己内部做分析时,建议两种口径都出,交叉验证。

3.3 从曲线读出六个结论

以某 12 期信贷产品 2018 年的 Vintage 曲线为例,可以读出这些信息:

  1. 账龄最长为 12 个月,说明产品期限是 12 期,MOB 最大到 MOB12。
  2. 2018 年 5 月放款的订单已走完生命周期,2018 年 6 月的没走完,说明数据统计时间是 2019 年 6 月初。
  3. MOB1、MOB2、MOB3 的逾期率都是 0,说明逾期指标是 M4+,前三个月不可能有人逾期超过 90 天。
  4. 2018 年 1 月到 12 月各批次的最终逾期率逐月下降,说明资产质量在提升。
  5. 2018 年 5 月相对 1–4 月大幅下降,说明该阶段策略有明显调整。
  6. 各月放款的 M4+ 在 9 个 MOB 后趋于稳定,说明账户成熟期是 9 个月。

第 3 条是新手最容易忽略的:曲线的形状本身就在告诉你指标口径。如果纵坐标是 M3+ 而 MOB1、MOB2 全是 0,说明指标其实设成了 M4+;看到前几个 MOB 全零,先反查指标定义,别急着下「资产质量极好」的结论。

3.4 表现期的权衡

为什么要确定表现期?表现期越长,信用风险暴露越彻底,但观察期离当前越远,用来提取样本特征的历史数据越陈旧,建模样本和未来样本的差异越大。表现期越短,风险暴露不完全,好处是能用上更近的样本。

对 12 期产品,理论上用户还清全部款项后才是绝对好客户,否则只能说「目前为止是好客户」。所以需要一个刚好能覆盖足够多坏客户的表现期,这个长度不是拍脑袋定的,要由 Vintage 曲线配合滚动率分析一起定。

4. 滚动率矩阵定坏客户,Vintage 定表现期

4.1 滚动率分析的六步法

滚动率分析考察某个观察点之前一段时间(观察期)的最坏状态,向观察点之后一段时间(表现期)的最坏状态的变化。标准操作步骤:

  1. 确定数据源,一般用还款计划表。
  2. 选观察点,统计客户在观察期(如过去 6 个月)的最长逾期期数,按最坏状态分层:C、M1、M2、M3、M4+。
  3. 以观察点为起点,统计客户在表现期(如未来 6 个月)的最长逾期期数,同样分层。
  4. 交叉统计每个格子的客户数。
  5. 统计每个格子的客户占比。
  6. 为了排除单次观察点的随机影响,选多个观察点重复上述过程。

假设选 2018 年 6 月 30 日为观察点,取 10,000 个客户做交叉统计,得到滚动率矩阵。用代码实现的核心逻辑:

import pandas as pd # df: 每行一个客户,含 obs_max_m(观察期最坏状态)、perf_max_m(表现期最坏状态) # 状态映射为数值:C=0, M1=1, M2=2, M3=3, M4+=4 def roll_rate_matrix(df, obs_col='obs_max_m', perf_col='perf_max_m'): # 交叉计数 cnt = pd.crosstab(df[obs_col], df[perf_col]) # 按观察期状态求行占比,即每一层的去向分布 ratio = cnt.div(cnt.sum(axis=1), axis=0).round(4) # 从良率:从当前状态回到 C 的比例 cured = ratio.get(0, pd.Series(dtype=float)) # 恶化率:向更差状态迁移的比例 matrix = ratio.copy() matrix['cured_rate'] = cured return cnt, matrix cnt, ratio = roll_rate_matrix(df) print(ratio)

逻辑说明:pd.crosstab做二维频数统计,div(..., axis=0)转成行占比,每一行代表观察期处于某状态的客户在表现期的去向分布。参数说明:obs_colperf_col的分层标签必须先统一映射成可排序的数值,否则交叉表的行列顺序会乱;观察期和表现期长度应保持一致,常见是各取 6 个月。

4.2 从矩阵读好坏分界线

观察滚动率矩阵可以发现:M0 客户未来 6 个月有 96% 保持正常,4% 恶化到 M1 和 M2;M1 客户有 81% 回到正常(从良率 81%),7% 恶化,13% 保持 M1;M2 从良率 23%,39% 恶化到 M3 和 M4+;M3 从良率 14.7%,60.7% 恶化到 M4+;M4+ 从良率仅 4%,80% 继续保持。

M4+ 的从良率只有 4%,几乎不会回头,这就是好坏分界线。为了让模型区分能力更强,把好坏界限设得尽量清晰,于是定义:坏用户 = 逾期状态 M4+(逾期超过 90 天)。

4.3 为什么还需要 Vintage 定表现期

滚动率分析定了「坏到什么程度算坏」,但没定「观察多久」。对 12 期产品,有些账户在 MOB1–MOB4 就达到 M4+,有些要到后几期才达到。目标是抓住尽可能多的坏客户。

做法是以 MOB1 为起点,把前 N 个 MOB 作为窗口,滑窗统计坏客户率,得到 Vintage 数据表并绘图。当 N 取到 9 时,曲线基本走平,说明经过 9 期几乎能抓住所有坏客户。于是目标变量 Y 定义为:

标签定义
Bad账户经过 9 期表现期后,逾期状态达到 M4+
Good经过 9 期表现期,未达到 M4+
Intermediate未进入 9 期表现期,账户尚未成熟,无法定义好坏,即不定样本

提示:Intermediate 样本不要简单丢弃或强行归为 Good,这会引入标签噪声。常见做法是剔除,或者在样本量紧张时先只用于训练、不用于评估。

5. 迁移率链路与拨备金额的精算

5.1 迁移率的分母是上月末余额

迁移率刻画客户贷款账户在整个生命周期中的流转轨迹,定义是前一期逾期余额向下期逾期余额的转化率,常写作 M0→M1、M4→M5:

  • M0→M1 = 当月进入 M1 的贷款余额 / 上月末 M0 的贷款余额
  • M2→M3 = 当月进入 M3 的贷款余额 / 上月末 M2 的贷款余额

关键在分母:是上月末该状态的余额,不是当月总余额。分母用错,迁移率会整体失真。

5.2 从 M0 到 M7 的恶化路径

用数值案例展示这条链路:1 月末正常 M0 资产是起点;2 月末有部分 M0 恶化为 M1;3 月末部分 M1 恶化为 M2;4 月末部分 M2 恶化为 M3;5 月末部分 M4 恶化为 M5,此时已过催收黄金期(90 天以内);6 月末部分 M5 恶化为 M6,可能采用了委外催收、司法手段,效果显著;7 月末部分 M6 恶化为 M7,此时视为不良资产,打包转卖给第三方回收部分损失。

从正常资产迁移至 M7 需要经过 7 次迁移。据此定义:

  • 毛坏账损失率 = M0→M1→…→M7 各段迁移率连乘
  • 净坏账损失率 = 毛坏账损失率 − 不良资产外卖回收率

计算毛损失率和净损失率的代码如下:

import numpy as np # 各段迁移率:正常余额逐级恶化到 M7 的转化比例 trans = np.array([0.05, 0.85, 0.80, 0.75, 0.90, 0.70, 0.60]) # 从当前状态直接穿透到 M7 的累计损失率(按链路上游逐段相乘) gross_loss = np.cumprod(trans[::-1])[::-1] / trans # M7 不良资产的平均回收率 recovery_rate = 0.20 net_loss = np.clip(gross_loss - recovery_rate, 0, None) for i, (g, n) in enumerate(zip(gross_loss, net_loss)): print(f"M{i}->M7 毛损失率 {g:.2%} 净损失率 {n:.2%}")

逻辑说明:np.cumprod从链尾向前做累乘,得到每一级到 M7 的累计穿透率,再除以本级迁移率还原成从本级出发的毛损失率。参数说明:trans的顺序是从 M0→M1 到 M6→M7,写反方向会得到完全不同的结果;recovery_rate取行业平均的 M7 回收比例,不同机构差异很大,要用自己历史数据估。

5.3 拨备率与期望损失的计算

迁移率的一个重要用途是算拨备金额,也就是坏账准备金。有了上面各状态的净损失率表,就能算当月应计拨备:

  • 当月应计拨备额 = SUM(净坏账损失率 × 月末应收账款余额)
  • 拨备率 = 当月应计拨备额 / 总资产金额

拨备率越低越好。拨备率越高,说明风险越大、损失越大、利润越小。计算逻辑如下:

-- 输入:月末各逾期状态的应收账款余额表 balance(m_state, eom_balance) -- 输入:净损失率表 loss_rate(m_state, net_loss_rate) SELECT SUM(b.eom_balance * l.net_loss_rate) AS provision_amt, SUM(b.eom_balance * l.net_loss_rate) / SUM(b.eom_balance) AS provision_rate FROM balance b JOIN loss_rate l ON b.m_state = l.m_state;

逻辑说明:按每个逾期状态的月末余额乘以对应的净损失率,加总得到应计拨备,再除以总资产得拨备率。参数说明:loss_rate表要定期用最新迁移率刷新,否则拨备会偏离实际风险;m_state需要覆盖全部正常与逾期状态,漏掉 M0 会计入零损失而低估拨备。

5.4 三件工具的职责分工

收口一下三者的分工:滚动率分析用于定义客户好坏程度,Vintage 分析用于确定合适的表现期,迁移率分析用于计算损失率和拨备。目标变量的确定流程是先滚动率定 M4+ 为坏客户,再 Vintage 定 9 期为表现期,最后迁移率换成金额口径算拨备。

注意:迁移率用金额口径,滚动率常用订单口径,两者的分母不同。做拨备时不能直接用滚动率矩阵的占比,必须换成余额。

6. 口径漂移的排查清单

上面所有分析都建立在快照一致的前提下。实际做的时候,最常见的不是算法错,而是口径在跑的过程中漂了。这里给一份我自己常用的排查清单。

第一步:核对 MOB 计算的分界。取一笔已知放款日的历史订单,手工算一遍 MOB,和快照表比对。放款日当月是 MOB0,不是 MOB1,这是最高频的错。

第二步:核对 M 状态的映射除数。用CEIL(dpd / 30.0)和 DPD30/60/90 自然日边界,在月末附近会差一个档位。抽三个月末的边界样本(逾期 29/30/31 天、59/60/61 天)比对状态归属。

第三步:核对快照是否是分区落表的。直接查变动表做历史分析,已结清账户会全部显示为正常。检查快照表有没有dt分区,分区数是否与统计月份数一致。

第四步:核对迁移率分母的时点。分母必须是上月末余额。用两个相邻月末跑一遍,如果迁移率超过 100%,多半是分母用了当月数。

第五步:核对拨备率的刷新时间。净损失率表如果三个月没更新,拨备金额会系统性偏离。用一个固定日期的余额重算拨备,看和当月的差值。

# 快照表分区完整性检查:期望分区数 vs 实际分区数 expected=$(seq -w 1 12 | wc -l) actual=$(ls /data/snapshot/loan_state/ | grep -c '^dt=') echo "期望分区: ${expected} 实际分区: ${actual}" [ "$expected" -eq "$actual" ] || echo "存在缺失的快照月份,历史曲线会断档"

最后给一个技巧:把 MOB、M 状态、余额三个字段做成一张宽表,每月快照落一份,所有 Vintage、滚动率、迁移率分析都从这张宽表出发。口径只在一个地方定义一次,后面所有指标都从这里派生,比每个分析各写一套 SQL 要稳得多。宽表字段不用多,够用就行,多出来的字段反而容易在口径变更时漏改。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 4:04:44

用Go实现递归冒泡排序:原理、源码与性能分析

先聊个有意思的事&#xff1a;冒泡排序几乎是每个人学算法的第一堂课&#xff0c;教科书上写的版本基本都是两层循环套着走。可一旦面试官问“能不能用递归实现一个冒泡排序”&#xff0c;不少人就卡住了。其实递归冒泡排序&#xff08;recursive bubble sort&#xff09;本身没…

作者头像 李华
网站建设 2026/9/18 4:03:57

嵌入式AI编程:让大模型真正跑在STM32资源约束下

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 4:03:20

React FiberRoot源码解析:核心属性与调度机制详解

1. FiberRoot 到底在 React 生态里扮演什么角色打开 React 源码&#xff0c;在react-reconciler包里翻ReactFiberRoot.js&#xff0c;第一眼看到FiberRootNode这个构造函数时&#xff0c;你可能会有点懵。它上面挂了一批属性&#xff1a;tag、containerInfo、current、pendingC…

作者头像 李华
网站建设 2026/9/18 4:03:11

MiroFish全链路追踪实战:从排障泥潭到轻量级分布式追踪系统落地

从微服务排障的泥潭里爬出来&#xff0c;我越来越觉得“全链路追踪”不是可选项&#xff0c;而是标配。今天想聊聊我最近在用的一个轻量级开源工具MiroFish&#xff0c;它解决的就是分布式环境下一根请求线头找不到、问题定位全靠猜的顽疾。全文不讲虚的&#xff0c;就是一次真…

作者头像 李华
网站建设 2026/9/18 4:03:06

Stolz定理:离散极限计算的核心工具与差分思想

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华