风控报表实战:用Python代码拆解Vintage、迁徙率与滚动率

张开发
2026/4/21 4:11:41 15 分钟阅读

分享文章

风控报表实战:用Python代码拆解Vintage、迁徙率与滚动率
1. 风控三大报表的核心价值第一次接触风控报表时我被各种专业术语搞得晕头转向。直到真正用Python处理过几百万条信贷数据后才发现Vintage、迁徙率和滚动率就像三把不同的手术刀各自擅长解剖不同维度的风险特征。Vintage报表最像时间胶囊它能锁定每个放款月份的资产质量。我习惯把它想象成酿酒过程——2023年1月放的款就像当年酿的酒随着时间推移这批酒的品质逾期率变化会被完整记录下来。这种纵向追踪特别适合观察不同时期风控策略的效果比如去年双十一大促期间放宽了审批标准对应的Vintage曲线就会明显上扬。迁徙率报表则是逾期路径追踪器。去年处理过一个案例某消费贷产品M1-M2迁徙率突然从50%飙升到80%排查发现是第三方支付通道变更导致还款提醒失效。这种报表能清晰展示客户从正常还款到最终坏账的完整路径就像看疾病从轻症发展到重症的过程。滚动率报表我常用作风险传染预警器。通过热力图能直观看到客户逾期状态的跳跃式恶化比如直接从M0跳到M3的异常模式。有次发现某个渠道的客户集中出现这种跳跃后来证实是渠道商在帮客户养征信的欺诈行为。2. Vintage报表的Python实战2.1 数据准备与清洗先看一个真实的脏数据案例某消费金融公司提供的原始数据包含137个字段但实际需要的只有necessary_columns [ loan_id, # 贷款编号 issue_date, # 放款日期 term, # 贷款期数 due_date, # 应还款日 pay_date, # 实际还款日 principal, # 应还本金 paid_principal # 实还本金 ]处理日期字段时最容易踩坑。有次我直接用了pd.to_datetime转换结果发现系统自动把2023-02-30这种非法日期转成了2023-03-02导致后续计算全部错误。正确做法是def safe_date_parse(date_str): try: return pd.to_datetime(date_str, errorscoerce) except: return pd.NaT df[due_date] df[due_date].apply(safe_date_parse)2.2 MOB(账龄)计算MOB(Month on Book)计算有个隐藏陷阱——自然月vs周期月。比如1月31日放款的12期贷款如果用自然月计算2月只有28天就会导致账龄错位。我的解决方案是from dateutil.relativedelta import relativedelta def calculate_mob(row, obs_date): mob_list [] current_date row[issue_date] while current_date obs_date: mob_list.append(current_date) current_date relativedelta(months1) return mob_list obs_date pd.to_datetime(2023-12-31) # 当前观察日 df[mob_dates] df.apply(lambda x: calculate_mob(x, obs_date), axis1)2.3 逾期判定逻辑逾期天数计算要考虑四种特殊情况提前还款pay_date due_date按时还款pay_date due_date逾期后还款due_date pay_date mob_date始终未还pay_date is NaT对应的Python实现def get_overdue_days(row, mob_date): if pd.isna(row[due_date]): return 0 if pd.isna(row[pay_date]): # 未还款 return (mob_date - row[due_date]).days elif row[pay_date] row[due_date]: # 提前还款 return 0 else: # 逾期还款 return (row[pay_date] - row[due_date]).days # 计算每个MOB月的逾期状态 for mob in range(1, 13): mob_date df[issue_date] pd.DateOffset(monthsmob) df[fmob_{mob}_status] df.apply( lambda x: M2 if get_overdue_days(x, mob_date) 30 else M0, axis1 )2.4 Vintage矩阵构建用pivot_table构建Vintage矩阵时要注意处理未到期的MOB显示为NAvintage_table pd.pivot_table( df, indexpd.to_datetime(df[issue_date]).dt.to_period(M), columnsmob, valuesis_m2_plus, aggfunclambda x: x.sum() / len(x) ) # 美化输出 vintage_table.style.format({:.2%})\ .background_gradient(cmapReds)\ .set_caption(Vintage Analysis (M2逾期率))3. 迁徙率报表开发指南3.1 状态转移矩阵迁徙率的核心是计算状态转移概率。我曾用马尔可夫链的思路重构过传统计算方法def build_migration_matrix(df): status_map {M0:0, M1:1, M2:2, M3:3} transition_counts np.zeros((4,4)) for loan_id, loan_df in df.groupby(loan_id): loan_df loan_df.sort_values(report_date) prev_status None for _, row in loan_df.iterrows(): curr_status status_map[row[status]] if prev_status is not None: transition_counts[prev_status][curr_status] 1 prev_status curr_status # 归一化得到概率 row_sums transition_counts.sum(axis1, keepdimsTrue) return transition_counts / np.where(row_sums0, 1, row_sums)3.2 可视化技巧用桑基图展示迁徙路径效果惊人。Plotly的实现示例import plotly.graph_objects as go fig go.Figure(go.Sankey( nodedict( label[M0, M1, M2, M3, 已结清], colorblue ), linkdict( source[0,0,1,1,2,2,3,3], # 起始状态索引 target[1,4,2,4,3,4,4,4], # 目标状态索引 value[1000,8000,500,950,300,650,200,800] # 迁徙数量 ) )) fig.update_layout(title_text逾期状态迁徙路径)3.3 业务应用案例去年我们通过迁徙率发现一个异常现象M2→M3的迁徙率在工作日高达75%而在周末骤降到40%。深入分析后发现是催收团队的工作模式导致的——周末只进行自动语音提醒而工作日才有人工催收。调整催收策略后整体坏账率下降了15%。4. 滚动率分析进阶实战4.1 滚动率特殊处理处理跳跃逾期时需要特别小心。比如客户直接从M0跳到M3可能是数据质量问题也可能是欺诈信号。我的处理逻辑def detect_skip_overdue(loan_df): status_sequence loan_df[status].values max_status 0 anomalies [] for i, status in enumerate(status_sequence): curr_level int(status[1]) if status[1].isdigit() else 3 if curr_level max_status 1: # 出现跳跃 anomalies.append(i) max_status max(max_status, curr_level) return anomalies4.2 热力图优化matplotlib默认的热力图不够直观我通常做三点优化添加逾期状态标注使用发散色系突出异常值增加数字标注import seaborn as sns plt.figure(figsize(12,8)) ax sns.heatmap( roll_rate_table, cmapRdYlGn_r, annotTrue, fmt.1%, linewidths.5, vmin0, vmax0.3, center0.15 ) # 添加MOB标记 ax.set_xticklabels([fMOB{i1} for i in range(12)]) ax.set_yticklabels([M0→M1,M1→M2,M2→M3,M3→M4]) plt.title(滚动率热力图 (2023年Q4))4.3 性能优化技巧处理千万级数据时传统的逐行计算方式会非常慢。我总结的优化方案使用向量化计算替代循环对日期字段预先做数值化处理使用Dask处理超出内存的数据# 向量化计算示例 df[mob] ((obs_date - df[issue_date]).dt.days // 30).clip(1,12) df[is_overdue] (df[pay_date] df[due_date]).astype(int) # 使用numpy的bincount加速统计 mob_bins np.bincount(df[mob], minlength13)[1:] overdue_counts np.bincount(df[df[is_overdue]1][mob], minlength13)[1:] vintage_rates overdue_counts / mob_bins5. 报表自动化与监控实际业务中需要定期生成这些报表我搭建的自动化系统包含三个关键组件数据质量检查模块每次运行前自动检测缺失值、异常值def data_quality_check(df): checks { missing_values: df.isnull().mean(), date_consistency: (df[pay_date] df[issue_date]).mean(), amount_sanity: (df[paid_principal] df[principal]*1.5).mean() } if any(v 0.05 for v in checks[missing_values]): raise ValueError(数据缺失值超过5%)异常波动预警对比历史数据自动识别异常波动def detect_anomalies(current, history, threshold2): z_scores (current - history.mean()) / history.std() return z_scores.abs() threshold自动报告生成使用Jinja2模板生成HTML报告from jinja2 import Environment, FileSystemLoader env Environment(loaderFileSystemLoader(templates)) template env.get_template(report_template.html) html_output template.render( vintage_tablevintage_table, migration_plotmigration_fig.to_html(), roll_rate_heatmapheatmap_fig.to_html() )这套系统上线后原本需要3天手动完成的风控分析工作现在2小时内就能自动完成且准确率从85%提升到99.5%。最关键的是当某个渠道的迁徙率突然异常时系统能在次日就发出预警比原来人工发现平均提前了17天。

更多文章