信贷资产质量监控中,Vintage 分析犹如风险管理的"体检表和时光望远镜",能够透过时间维度观察不同放款批次的生命周期表现(成熟期、变化规律等)。本文力求以通俗简洁的文风来介绍 Vintage 分析的概念、计算逻辑和业务应用,希望能对大家有所帮助。
目录
Part 1. 什么是 Vintage 分析?
Part 2. Vintage 分析的意义
Part 3. 脱敏数据解读
Part 4. Python 构建 Vintage 分析表
Part 5. 延展 & 注意事项
参考资料
版权声明
本文含7000字,建议阅读 8~12 分钟
同期群分析(Cohort Analysis),是一种上到互联网巨头做用户行为分析,下到地铁口出摊卖炒粉的阿姨阿叔,都能用到的接地气分析思路。
其核心是将用户按初始行为的发生时间划分为不同群组,追踪群体行为的长期演变规律。而 Vintage分析 则是同期群分析在金融风控领域的垂直应用,聚焦于资产质量与风险表现的动态评估。
先来看同期群分析的表现形式:
数据来源于笔者朋友在广东开的一家火锅烧烤餐厅,我把他每个月门店的新增客户数据进行记录,并统计他们在之后几个月份的再次光顾的情况:
再来看金融风控的垂直应用(Vintage):
数据来源于某银行 2024 年3月-9月的放款资产池,统计各批次贷款在放款后 1-10 个月内逾期>30天的金额占比:
仅知晓每个月用户行为的笼统数字(新增用户 or 放贷行为),对于拉长时间线去观察生命周期绝对是一头雾水,更别提识别早期风险信号或挖掘长期价值。
回到笔者朋友开的餐厅的新增用户同期群分析图:
追溯横纵向波动较大的月份背后的数据进一步探索后,发现
对应到 Vintage 分析,同样能起到深挖波动原因和验证策略的作用:
当然还可以继续深挖很多其他东西,但因为涉及的业务术语和知识点较多,留着以后的文章再讲。
本文用到的脱敏数据集如下(2024 年 1~6 月的贷款数据,节选前 5 行):
| 关于数据日期和类型的特别说明:
① 避免复杂的日期格式转换(如字符串转 datetime 对象)
② 便于快速计算时间周期(例如:(数据日期÷100 - 放款日期_年月)可直接得到贷款存续月份数)
③ 兼容不同系统导出的原始数据格式,减少预处理步骤 当然,
实际数据肯定不止这么几列,这里只是展示常规的 Vintage 分析所需的最关键的列。
读入数据后,以防万一最好检查一下数据日期和放款日期两者的关系是否正确(数据日期 ≥ 放款日期),有误的话可能就需要联系后台人员重新取数。
import pandas as pd
import numpy as np
df = pd.read_excel('脱敏数据.xlsx')
# 查看 放款日期 和 数据日期 的范围
print("放款日期范围:", df['放款日期_年月'].min(), "~", df['放款日期_年月'].max())
print("数据日期范围:", df['数据日期'].min(), "~", df['数据日期'].max())
# 检查 放款日期 和 数据日期 的关系
if (df['数据日期']/100 >= df['放款日期_年月']).sum() == df.shape[0]:
print('数据日期均大于等于放款日期,取数正确!')
# 输出结果 ------------------------------------------------------
## 放款日期范围: 202401 ~ 202406
## 数据日期范围: 20240131 ~ 20241231
## 数据日期均大于等于放款日期,取数正确!
再来看下 Vintage 分析表的最终形式:
我们需要统计各批次贷款在放款后 n 个月内逾期>30天的金额占比。
肯定有读者会觉得,这还不简单,直接根据放款日期和数据日期来分组,再转置(打横)一下,拼起来不就完事儿了??
# Step 1:各月放款总金额
putout_month_total = df.groupby('放款日期_年月')['放款金额'].sum() \
.reset_index(name='总放款金额')
# Step 2:逾期天数 30 天以上的余额总和,按照放款年月分组
df_over_30 = df[ df['当前逾期天数']>30 ]
df_result = df_over_30.groupby(['放款日期_年月', '数据日期'])['借据本金余额'].sum()
.reset_index(name='逾期金额总和')
# Step 3:按“放款日期_年月”关联两份数据
merged_df = pd.merge(df_result, putout_month_total,
on='放款日期_年月', how='left')
merged_df['逾期金额占比'] = (merged_df['逾期金额总和'] / merged_df['总放款金额'] * 100)
.round(2).astype(str) + '%' ## 计算逾期占比,并格式化为百分比字符串(保留两位小数)
# Step 4:生成宽表
# 生成宽表,保留原始日期作为列名
pivot_df = merged_df.pivot(
index='放款日期_年月',
columns='数据日期',
values='逾期金额占比'
)
pivot_df.columns.name = None
pivot_df = pivot_df.reset_index() # 将索引转换为普通列
# 按日期升序重新排列列
date_columns = sorted([col for col in pivot_df.columns if col != '放款日期_年月'], key=lambda x: int(x))
pivot_df = pivot_df[['放款日期_年月'] + date_columns]
确实,使用 groupby+pivot 直接生成宽表的这种做法的确很简洁明朗,但业务可读性弱:
1. 数据准备阶段:
- 提取所有唯一的放款月份并排序 → loan_months
- 获取最大数据日期,为放款月份~最大日期之间的循环最准备
→ max_data_date(保持整数格式如20241231)
2. 结果容器初始化:
- 创建空列表 result_list 存放最终结果
3. 遍历每个放款月份:
for loan_month in loan_months:
a. 过滤数据集:
仅保留当前放款月份的数据 → df_filtered
b. 初始化结果字典(用来存储 DataFrame):
results = {}
c. 日期格式转换:
将整数年月转换为月初日期对象(如202401→2024-01-01)
4. 计算 n 个月观察窗口(可人为设置):
for 偏移量 in 0到11:
a. 计算理论观察日期:
月初 + 偏移月数 → 月末最后一天(动态计算闰年等)
转换为与原数据一致的整数格式(如2024-02-28→20240228)
b. 有效性检查:
if 理论日期 > 最大数据日期:
标记为nan(数据尚未产生)
continue
c. 计算逾期金额:
过滤同时满足两个条件的数据:
- 数据日期 == 理论日期
- 当前逾期天数 > 30天
逾期率 = 逾期本金总额 / 放款总金额 *100%
d. 结果格式化:
保留两位小数并添加%符号(如25.37%)
5. 结果汇总:
将每个放款月份的结果字典存入result_list
最终转换为二维表格 → pd.DataFrame
觉得上面这段伪代码复杂的话,也可以用看下面这张图来辅助理解:
完整代码和结果如下:
loan_months = sorted(df['放款日期_年月'].unique().tolist())
# 确保 max_data_date 是整数(与原数据中的日期格式一致)
max_data_date = df['数据日期'].max() # 这里直接保留为整数,例如 20241231
result_list = []
for loan_month in loan_months:
# 过滤特定的放款日期
df_filtered = df[df["放款日期_年月"] == loan_month] # 确保列名和数据类型正确
# 计算 +0月、+1月、+2月的实际最后一天
results = {}
offset = list(range(12))
labels = [f"+{i}月" for i in range(12)] # "+0月", "+1月", ..., "+12月"
# 将月份转换为日期对象(月初)
month_start = pd.to_datetime(str(loan_month), format='%Y%m')
for offset, label in zip(offset, labels): ## zip 的作用是起到动态后移
# 动态计算月末日期
end_date = (month_start + pd.DateOffset(months=offset)).replace(day=1) + pd.offsets.MonthEnd(1)
# 转换为与原数据日期一致的整数格式(如20241031)
period_int = int(end_date.strftime('%Y%m%d'))
# 检查是否在数据范围内(比较整数)
if period_int <= max_data_date:
## 日期比较统一使用整数(如 20241031 和 20241231),直接比较大小。无需依赖 Timestamp 类型,避免混合类型比较错误
## filtered 记录了行为,可根据实际业务修改
filtered = df_filtered[
(df_filtered['数据日期'] == period_int) &
(df_filtered['当前逾期天数'] > 30)
]
results[label] = round(filtered['借据本金余额'].sum() / 10000, 2) / round(df_filtered['放款金额'].sum() / 10000, 2)
# 将计算结果转为百分比字符串(例如 0.85 → "85.00%")
results[label] = f"{round(results[label] * 100, 2)}%"
else:
results[label] = np.nan # 数据未到期
result_list.append({"放款日期": str(loan_month), **results})
final_result = pd.DataFrame(result_list)
本文案例的数据日期都是取月末,即计算每个放款月份在不同的月末观察窗口的逾期金额占比。这里有两个可以延展的地方:
# 动态计算月末日期
end_date = (month_start + pd.DateOffset(months=offset)).replace(day=1) + pd.offsets.MonthEnd(1)
# 修改成:
# 动态计算月中固定日期(例如每月15号)
end_date = (month_start + pd.DateOffset(months=offset)).replace(day=15)
分析视角还有很多,这里就不一一列举了。不同行为对应的代码改造也不难,都是在 Part 4 代码中计算动态时间窗口的小循环里面进行修改。组合不同行为的 Vintage 分析表,对理解风险全貌非常有帮助。
就像笔者帮朋友的餐厅做的同期群分析,如果组合留存率和客单价(即每位顾客在一次购买中平均消费的金额)这两个视角,能综合得到的信息就更多。
最终该餐厅的数据分析报告如下:
一、用户质量与促销活动的关联
促销用户的低质量特征:
二、留存率与客单价的正相关关系
结论: 自然增长或产品驱动的用户(如火锅季)留存率高且消费稳定,促销用户则两者均弱。
建议:
三、节假日活动的优化策略
建议:
转载分享,请在文章中注明作者和原文链接,感谢您对知识的尊重和对本文的肯定。
原文作者:萝卜(CSDN ID)
⚠️著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处,侵权转载将追究相关责任。