Skip to content

Vintage 分析——批次×账龄:暴露速度、违约高峰与客群分层 ​

本文为 AI 辅助创作,数据实测与结论推导由作者完成。

关键词:Vintage 分析,账龄,生存曲线,风险函数,右删失


先看一个现象:2018 年新发放贷款的违约率只有 1.79%,而 2015 年是 18%、2016 年是 15.7%——新贷款看起来永远比老贷款安全得多。

这背后不是客群质量的"革命性改善",而是账龄效应:贷款的风险是随发放时间逐步暴露的,新批次的违约还没来得及发生。如果不把账龄对齐,直接拿"当前违约率"跨批次比较,结论必然失真。

同一批 2018 年发放的贷款,如果按账龄对齐——都只看"发放后 6 个月内的表现"——违约率是 2.4%,和 2013、2016 年历史批次几乎一模一样。客群没有变好,只是这些贷款太年轻,还没来得及违约。

这篇用 Vintage 分析(账龄分析)把账龄效应拆开讲透:把每笔贷款放回它的时间轴,横着看违约怎么随账龄暴露,竖着看客群真实怎么变,最后用风险函数告诉你违约高峰到底在哪。


Vintage 表:把每笔贷款放回时间轴 ​

Vintage 分析的思想很简单:不要拿"今天的状态"比较不同时间发放的贷款,要拿"发放后第 N 个月的状态"比较。

做法:把贷款按发放季度分成 cohort(同批),对每一批,统计"发放后 3/6/12/18/24/36 个月内累计违约"的比例:

V(t)=D(t)N

违约时点用最后还款日近似(最后一次还款后不再还=违约,比真实核销日偏早约 4 个月,但所有批次偏差一致,跨批比较不受影响)。数据:LendingClub 225.7 万笔(2007-2018),剔除无信用分记录样本。

Vintage 分析表(行=发放季度,列=账龄,单位%):

cohortn3月6月12月18月24月36月
2008Q11,0131.43.810.214.518.722.8
2009Q18951.13.66.08.211.113.5
2010Q12,1720.82.24.66.38.311.0
2011Q14,1260.61.84.16.38.611.8
2012Q18,0760.82.15.58.411.215.0
2013Q122,7060.51.74.67.710.413.9
2014Q147,4100.51.64.57.910.914.9
2015Q184,2770.72.15.79.713.117.3
2016Q1133,8870.72.46.610.713.6—
2017Q196,7790.72.36.09.5——
2018Q1107,8640.82.4————

("—" = 该批贷款还没活到那个账龄,不能填——这是右删失,原理见下)

构造这张表的代码很直接(违约时点用最后还款日近似):

python
import duckdb, pandas as pd

df = duckdb.connect().execute("""
SELECT issue_d, last_pymnt_d, loan_status
FROM read_parquet('lendingclub.parquet') WHERE issue_d IS NOT NULL
""").df()

df["bad"] = df["loan_status"].str.contains("Charged Off|Default", na=False).astype(int)
df["issue"] = pd.to_datetime(df["issue_d"], format="%b-%Y")
df["cohort"] = df["issue"].dt.to_period("Q").astype(str)          # 发放季度
df["lastp"] = pd.to_datetime(df["last_pymnt_d"], format="%b-%Y", errors="coerce")
df["mtd"] = (df["lastp"].dt.to_period("M").astype(int)            # 违约账龄(月)
             - df["issue"].dt.to_period("M").astype(int)).clip(0, 99)

def vintage_table(df, cohorts, ages):
    rows = []
    for c in cohorts:
        sub = df[df["cohort"] == c]
        n = len(sub)
        rows.append([c, n] + [((sub["bad"] == 1) & (sub["mtd"] <= k)).sum() / n * 100
                              for k in ages])
    return pd.DataFrame(rows, columns=["cohort", "n"] + [f"{k}月" for k in ages])

print(vintage_table(df, ["2015Q1", "2018Q1"], [3, 6, 12]))

为什么"—"不能填?这是右删失(right censoring)。表里每个单元格的定义是"该批中发放后 t 个月内违约的比例",它要求批内每笔贷款都活过了 t 个月(违约或不违约都算"活过")。2016Q1 发放的贷款到数据截止只活了约 34 个月——36 月单元格里,那些还没违约的贷款未来可能违约,我们不知道答案。把"不知道"填成 0(没违约)会系统性低估;填成 1 会高估;所以银行标准做法是留空。等这批贷款全部到期(或观察满账龄),单元格自然就确定了。

横着读:每一批贷款的违约时间线 ​

先看每一行怎么走。以 2015Q1 为例:3 个月违约率 0.7% → 6 个月 2.1% → 12 个月 5.7% → 24 个月 13.1% → 36 个月 17.3%。

违约不是均匀发生的,是"暴露"出来的:发放后前 3 个月几乎没人违约(0.7%),到 12 个月已经出来一半(5.7%/17.3%),24 个月后接近平台。把多批的曲线画在一起(账龄对齐),就是经典的 S 形 Vintage 曲线族:

注意图里越新的批次曲线越短(2017/2018 只画到 18/6 个月)——它们只是还没活到那个账龄,跟表现好坏没关系。这就是表格里"—"的意思:右删失。这批贷款里还没违约的那些,未来可能违约,只是我们还没等到。

竖着读:账龄对齐后的真实客群 ​

现在竖着看同一列——同账龄、跨批次。比如 12 月账龄:2008Q1 是 10.2%,2012Q1 是 5.5%,2015Q1 是 5.7%,2016Q1 是 6.6%。

账龄对齐后,客群质量是真实的:2015-2016 的 12 月违约率(5.7%/6.6%)明显高于 2012-2014(5.5%/4.6%/4.5%)——这几年 LendingClub 通过率放宽、客群下沉,真实违约率在上升。18 月账龄同样如此(2015Q1 9.7% vs 2013Q1 7.7%)。

这是 Vintage 表的核心价值:它把"批次差异"和"账龄效应"分开——同一列里只有批次差异,同一行里只有账龄效应。

核心现象:当前违约率被账龄压制 ​

回到开场那个 1.79%。把它放进坐标里看:

红色柱子是"当前违约率"(直接拿今天的贷款状态算)——2012 年 16.2%、2015 年 18.0%、2017 年暴跌到 8.8%、2018 年 1.8%,看起来客群质量发生了"革命性改善"。

蓝色柱子是账龄对齐后的 6 个月违约率——2012 年 2.1%、2015 年 2.1%、2017 年 2.3%、2018 年 2.4%。几乎一条水平线。

机制很清楚:当前违约率 = 真实违约率 × 已暴露比例。2018 年的贷款只暴露了不到一年(86% 还在贷),违约还没来得及发生;2012 年的贷款已经全部到期,违约全部暴露。红色柱子的"暴跌",是账龄效应压低了新批次的表观违约率,跟客群变好无关。

风险函数:违约高峰在发放后 11 个月 ​

Vintage 表是累计视角。换成风险函数(某账龄时刻的瞬时违约强度)看得更清楚:

h(t)=d(t)n(t)

风险集法:对每个账龄 t,风险集 R(t) = 账龄 ≥t 且尚未违约的贷款(已违约的退出、已到期/已还清的也退出——它们不再"有风险")。全部样本按月滚动,h(t) 就是在风险集上算"下一个月违约的比例":

python
def hazard_curve(df, max_age=60):
    """风险集法计算违约强度 h(t) = 账龄(t,t+1]违约数 / 风险集"""
    df["obs_m"] = np.minimum((cutoff - df["issue"]).dt.days / 30.44,
                             df["term_m"])          # 观察窗口: min(数据截止, 到期)
    h, n_risk = [], []
    for t in range(max_age):
        at_risk = ((df["obs_m"] >= t) & ((df["bad"] == 0) | (df["mtd"] > t))).sum()
        d = ((df["bad"] == 1) & (df["mtd"] > t) & (df["mtd"] <= t + 1)).sum()
        h.append(d / at_risk if at_risk > 0 else 0.0)
        n_risk.append(at_risk)
    return h

为什么用风险集做分母,不用 cohort 总数? 因为 h(t) 要回答的是"活到账龄 t 的贷款里,下一个月的违约概率"——这是条件概率。分母必须是在 t 时刻"还在场"的贷款(风险集),而不是最初的总数。用总数做分母会得到"累计违约率"(Vintage 曲线),那是另一回事:Vintage 曲线是累计的、无条件概率;风险函数是瞬时的、条件概率。两者互补:Vintage 看总量暴露,风险函数看暴露的节奏。

违约高峰在发放后约 11 个月,h≈0.67%/月——不是"时间越长越危险",而是"发放后第一年是危险窗口"。这背后是信贷逻辑:真正的坏客户(欺诈、过度负债)会在 6-12 个月集中暴露(收入断流、多头借贷爆雷);能活过 24 个月的,是经过时间筛选的幸存者,违约强度反而下降。

这个形状对经营的含义是直接的:新批次发放后的 6-18 个月是最需要盯的窗口——催收资源、拨备计提、预警规则都应该向这个账龄段倾斜,而不是平均用力。

分层:不是所有客户都一个节奏 ​

Vintage 曲线是"平均",但客群内部差异巨大。按 fico 分层画 Kaplan-Meier 生存曲线(S(t)=发放后 t 个月仍未违约的比例):

S(t)=∏k≤t(1−dknk)

KM 的推导:生存函数 S(t)=P(T>t)。把"活过 t 个月"拆成"活过 1 月 且 活过 2 月 且 … 且 活过 t 月"——条件概率的乘积:

S(t)=∏k=1t(1−dknk)

其中 dk 是第 k 个月违约数,nk 是第 k 个月的风险集(含删失前仍在观察的贷款)。为什么必须用乘积而不是直接减:直接算"1 − 累计违约/总数"在删失存在时是错误的——删失的贷款(还清/未到期)没提供"不违约"的信息,只是"没来得及观察",把它们当"没违约"会系统性高估生存率。KM 用风险集把删失正确处理了:删失贷款退出后续乘积的分母,但不贡献分子。实现:

python
def kaplan_meier(sub, max_t=48):
    """KM 生存曲线: 事件=违约(mtd), 删失=未违约在 obs_m 处"""
    t_ev = sub.loc[sub["bad"] == 1, "mtd"].values
    t_cs = sub.loc[sub["bad"] == 0, "obs_m"].values
    surv = np.ones(max_t + 1)
    for t in range(1, max_t + 1):
        n = (t_ev >= t).sum() + (t_cs >= t).sum()      # 风险集
        d = ((t_ev >= t) & (t_ev < t + 1)).sum()       # 本月违约
        surv[t] = surv[t-1] * (1 - d / n) if n > 0 else surv[t-1]
    return surv

km_low  = kaplan_meier(df[df["fico"] < 650])
km_high = kaplan_meier(df[df["fico"] >= 700])
print(1 - km_low[24], 1 - km_high[24])   # 24月累计违约率

fico <650:24 个月累计违约率 26.8%;650-700:14.9%;700+:8.6%——低分客户不仅违约率高三倍,而且违约来得更快(曲线更陡)。Vintage 的平均曲线(~15%)是这三条线的混合:管理资产组合不能只看总量曲线,要分层看结构——低分段的账龄暴露速度和高分段完全不是一个节奏。

结论:Vintage 分析怎么落地 ​

把整条逻辑收起来:

当前违约率 = 真实违约率 × 已暴露比例。新贷款永远看起来更安全,因为它的违约还没暴露——表观违约率 = 真实违约率 × 已暴露比例。 Vintage 表把账龄效应和批次差异分开:横着读是暴露速度(S 形),竖着读是真实客群(2015-2016 确实在变差)。 风险函数告诉你违约高峰在发放后 11 个月,管理窗口应该聚焦 6-18 个月账龄。 分层生存曲线告诉你低分客户更快更狠,总量曲线掩盖结构。

所以读任何"新增贷款违约率"之前,先问一句:这批贷款发出来多久了?账龄对齐了没有?——这就是 Vintage 分析存在的理由。

边界声明:以上数字在 LendingClub 2,257,017 笔样本(剔除无信用分记录)上测得;违约时点用最后还款日近似(比真实核销日偏早约 4 个月,各批次偏差一致,跨批比较不受影响);数据截止 2018-12,"—"处为右删失未观察满账龄;不同客群/核销政策下具体数字会变,但"账龄效应 + 违约高峰 + 分层差异"三个规律不变。


关键要点:Vintage 表=批次×账龄矩阵,横读暴露速度、竖读真实客群;当前违约率被账龄压制(1.79% 假象 vs 账龄对齐 2.4%);风险函数违约高峰在发放后 11 个月;fico 分层 24 月违约率 26.8%/14.9%/8.6%。

代码与数据:本文所有代码可复现,数据来自 LendingClub 公开数据集(2007-2018Q4)。完整脚本留档在文章同目录 gen_figures.py。

关注公众号:QIAN数据