ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

TensorFlow实现Weibull生存模型预测债券违约概率

TensorFlow实现Weibull生存模型预测债券违约概率

简介:本资源是一份面向金融风控工程师、量化分析师及具备TensorFlow基础的机器学习从业者的专业技术文档,聚焦债券违约预测中右删失数据建模这一关键难点。全文系统阐述如何基于TensorFlow构建生存分析模型,覆盖右删失数据特性解析、特征工程策略、自定义损失函数设计、模型架构搭建、训练优化(含早停与正则化)及多维评估(C-index、Brier分数、时变AUC),并结合真实债券市场案例展示从数据预处理到生存曲线解读的完整落地流程。资源为单文件PDF,共27页,大小1.84MB,内容结构严谨,含9大章节与30+子模块,涵盖引言、理论基础、代码实现要点、局限性分析与未来方向,技术深度与业务结合度兼备。目前已有77人学习下载,适合希望提升金融时序风险建模能力、掌握生存分析在信贷/债券领域实战应用的技术人员精读与复现。

1. 债券违约预测为什么非得用生存分析?——右删失数据不处理,模型再准也是玄学

你训练了一个LSTM+XGBoost的债券违约二分类模型,AUC做到0.89,回测三年表现亮眼。但一上线就翻车:2023年Q4某城投债突然展期,模型没预警;2024年Q1另一只债提前兑付,模型却打了高风险标签。问题不在特征工程,而在你根本没碰过“右删失”——那只还没违约、但已退出观察期的债,不是“安全”,而是“信息不全”。生存分析不是统计学选修课,是债券风控的硬门槛:它不强行把“未违约”等同于“永不会违约”,而是建模“从发行到违约的时间分布”,把“观察期内未违约但已退出(如提前兑付、被收购、数据缺失)”这类右删失样本当作部分信息纳入训练。TensorFlow原生不支持生存分析,但通过自定义损失函数+事件时间建模,能跑通Cox比例风险、Weibull加速失效等主流模型。本文聚焦一个可复现的最小闭环:用TensorFlow 2.x构建Weibull生存模型,处理真实债券池的右删失数据,输出违约概率曲线与12个月条件违约率(PD12)。适合有Python基础、跑过分类模型、但没接触过生存建模的风控/量化工程师——你不需要懂Kaplan-Meier推导,但必须知道tfp.distributions.Weibull怎么和删失标签对齐。


2. 为什么Weibull模型是债券场景的务实选择?——比Cox更可控,比Logistic更合理

2.1 生存分析三要素:时间、事件、删失,缺一不可

债券违约预测中,“时间”是发行日至违约日(单位:月);“事件”是是否发生实质违约(1=违约,0=未违约);“删失”分三类:

  • 右删失(Right-censoring):观察期末未违约,但债券已到期兑付/展期/被收购(占样本70%以上);
  • 左删失(Left-censoring):违约发生在观察期开始前(极少见,通常清洗掉);
  • 区间删失(Interval-censoring):只知道违约发生在某两个时间点之间(如审计报告披露“2023年Q3发生违约”,但无精确日)。

本文只处理右删失——这是债券数据最普遍、最需建模的类型。关键在于:传统二分类模型把右删失样本全标为label=0,等于假设“永远不违约”,而生存模型将其标记为censored=1,并保留其观察时长t_obs作为约束条件。例如:某债发行于2020-01-01,观察截止2024-06-30,期间未违约且未兑付 →t_obs=54个月, event=0, censored=1;若该债2022-06-30提前兑付 →t_obs=30个月, event=0, censored=1。二者在生存模型中贡献不同:前者说明“至少活了54个月”,后者说明“至少活了30个月”。

2.2 Weibull vs Cox:为什么放弃“黑匣子”Cox,选可解释的Weibull?

Cox模型虽无需假设基线风险函数,但存在三个债券场景硬伤:

  1. 无法外推预测:Cox输出的是风险比(Hazard Ratio),不能直接计算某债在未来12个月的违约概率;
  2. 协变量效应恒定假设不成立:债券信用风险随时间非线性演化(如地产债前3年平稳,第4年暴雷),Cox要求HR不随时间变,强行满足会牺牲精度;
  3. 无法处理时间依赖协变量:债券的财务指标(如EBITDA利息覆盖倍数)是时序更新的,Cox扩展版(time-dependent Cox)在TensorFlow中实现复杂且不稳定。

Weibull模型则天然适配:

  • 它假设基线风险函数为h₀(t) = γ·t^(γ-1),其中形状参数γ控制风险曲线形态(γ<1:风险递减,如优质国企债;γ>1:风险递增,如高收益地产债);
  • 加入协变量后,尺度参数λ变为λ = exp(βᵀx),最终风险函数h(t|x) = γ·t^(γ-1)/exp(βᵀx),可直接积分得生存函数S(t|x) = exp(-(t/λ)^γ);
  • 关键优势:给定x(债券特征),能算出任意时间t的生存概率S(t|x),进而得条件违约概率P(T≤t+Δt | T>t, x) = 1 - S(t+Δt|x)/S(t|x)——这正是监管要求的PD12核心输出。

提示:Weibull不是万能解。若数据中大量早期违约(如发行后6个月内暴雷),γ估计易偏小,此时可切换为Log-Logistic模型(本方案附录提供切换代码)。但对国内城投债、产业债主流样本,Weibull收敛稳定、解释性强,是落地首选。

2.3 TensorFlow实现Weibull的底层逻辑:自定义损失函数才是关键

TensorFlow不内置生存分析层,但可通过tf.keras.losses.Loss继承实现Weibull负对数似然损失(NLL)。损失函数推导如下:

  • 对于事件样本(event=1):贡献密度函数f(t|x) = h(t|x)·S(t|x);
  • 对于右删失样本(censored=1):贡献生存函数S(t_obs|x)(因只知t ≥ t_obs);
  • 合并后,单样本损失为:
    loss_i = -[event_i * log(f(t_i|x)) + (1-event_i) * log(S(t_i|x))]

代入Weibull公式并化简,最终损失仅含γ(形状参数)、λ(尺度参数)及event、t_obs:

def weibull_nll_loss(y_true, y_pred): # y_true: shape=(batch, 2), y_true[:,0]=t_obs, y_true[:,1]=event (0 or 1) # y_pred: shape=(batch, 2), y_pred[:,0]=log_gamma, y_pred[:,1]=log_lambda t_obs = y_true[:, 0] event = y_true[:, 1] gamma = tf.exp(y_pred[:, 0]) # 形状参数γ > 0 lambda_ = tf.exp(y_pred[:, 1]) # 尺度参数λ > 0 # Weibull密度 f(t) = (γ/λ) * (t/λ)^(γ-1) * exp(-(t/λ)^γ) # Weibull生存 S(t) = exp(-(t/λ)^γ) log_f = tf.math.log(gamma) - tf.math.log(lambda_) \ + (gamma - 1) * tf.math.log(t_obs / lambda_) \ - tf.pow(t_obs / lambda_, gamma) log_S = -tf.pow(t_obs / lambda_, gamma) # 损失 = -[event*log_f + (1-event)*log_S] loss = -tf.where(event == 1, log_f, log_S) return tf.reduce_mean(loss)

注意:y_pred输出log_gamma和log_lambda而非原始值,是为了保证gamma>0, lambda>0,避免梯度爆炸。此损失函数可直接传入model.compile(loss=weibull_nll_loss),无需修改模型结构。


3. 从原始债券数据到可训练TensorFlow模型:四步清洗与特征工程

3.1 原始数据必备字段与清洗铁律

债券违约预测数据源通常来自Wind/CSMAR/企业财报,但必须包含以下5个字段才能构建生存分析数据集:

字段名类型说明清洗要求
bond_idstr债券唯一代码去重,确保无重复发行
issue_datedatetime发行日期统一转为YYYY-MM-DD格式
default_datedatetime违约日期(可为空)空值记为右删失,非空则计算t_obs
end_datedatetime观察截止日(如2024-06-30)所有样本统一此值
statusstr状态("default", "repaid", "extended", "acquired")"default"→event=1;其余→event=0, censored=1

清洗铁律三条:

  1. 剔除状态模糊样本:如status="unknown"或default_date与status矛盾(如status="repaid"但default_date非空),直接丢弃;
  2. 统一时间粒度:所有日期转为datetime64[D],t_obs以月为单位(np.floor((end_date - issue_date).dt.days / 30.44)),避免日粒度导致小数误差;
  3. 处理“伪删失”:若status="repaid"但end_date - issue_date < 12个月,视为短期债,保留;若>120个月(10年),检查是否为永续债,永续债单独建模(本文不覆盖)。

3.2 特征构造:债券领域特有的3类时变特征

债券特征不能简单套用股票因子,需紧扣信用风险驱动逻辑:

  • 静态特征(Static):发行时即确定,不随时间变。如bond_type(利率债/信用债/ABS)、issuer_rating(发行主体评级)、guarantor(是否有担保)、coupon_rate(票面利率)。编码方式:issuer_rating用映射字典转数值(AAA=10, AA+=9, ..., D=0);类别变量用pd.get_dummies。
  • 动态财务特征(Dynamic Financial):每年更新一次,取最新年报数据。如debt_to_ebitda,cash_short_term_debt,roa。关键技巧:滞后一期——用T-1年财报预测T年风险,避免前瞻偏差。例如:2023年发行的债,用2022年财报数据。
  • 市场行为特征(Market Behavior):二级市场信号,高频更新。如yield_spread_1y(较国债利差)、bid_ask_spread(买卖价差)、volume_ratio(日成交额/债券余额)。采样规则:取发行后至观察截止日的均值+标准差,共6个衍生变量。

注意:不要直接用price或yield绝对值!债券价格受利率环境系统性影响,应使用相对指标(如利差、波动率)。实测显示,加入yield_spread_1y_std(利差标准差)比单纯用均值提升AUC 0.03。

3.3 构建生存分析标签:t_obs与event的生成脚本

以下脚本将原始DataFrame转为(t_obs, event)标签,严格遵循右删失定义:

import pandas as pd import numpy as np def build_survival_labels(df, end_date='2024-06-30'): """ df: 原始债券DataFrame,含issue_date, default_date, status end_date: 观察截止日,str格式 返回: DataFrame with columns ['t_obs', 'event'] """ df = df.copy() df['issue_date'] = pd.to_datetime(df['issue_date']) df['end_date'] = pd.to_datetime(end_date) # 计算观察时长(月) df['days_obs'] = (df['end_date'] - df['issue_date']).dt.days df['t_obs'] = np.floor(df['days_obs'] / 30.44).astype(int) # 转月,向下取整 # 定义event:1=违约,0=右删失 df['event'] = 0 # 条件1:default_date非空且早于end_date → 真实违约 valid_default = df['default_date'].notna() & (pd.to_datetime(df['default_date']) <= df['end_date']) df.loc[valid_default, 'event'] = 1 # 条件2:status为"default"且default_date为空 → 数据录入问题,按右删失处理(保守策略) df.loc[(df['status'] == 'default') & df['default_date'].isna(), 'event'] = 0 # 输出标签 labels = df[['t_obs', 'event']].copy() # 强制t_obs >= 1(发行当月即违约视为t_obs=1) labels['t_obs'] = labels['t_obs'].clip(lower=1) return labels # 使用示例 # raw_df = pd.read_csv('bonds_raw.csv') # labels = build_survival_labels(raw_df, end_date='2024-06-30') # print(labels.head())

逻辑说明:

  • t_obs最小为1,避免log(0)错误;
  • default_date为空但status=="default"时,视为数据缺失,按右删失处理(宁错杀不放过);
  • t_obs用floor而非round:若观察30.5个月,只能确认“活过30个月”,不能假设“活过31个月”。

4. 在TensorFlow中搭建Weibull生存模型:从数据加载到模型编译

4.1 数据预处理:标准化与删失样本加权

生存分析中,右删失样本量远超事件样本(典型比例10:1),直接训练会导致模型忽略违约信号。解决方案:对事件样本加权,权重=len(censored)/len(event)。同时,所有特征需标准化(非归一化),因Weibull的λ=exp(βᵀx)对输入尺度敏感:

from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split # 假设X为特征矩阵,y为labels DataFrame scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # 划分训练/验证集(按bond_id分层,避免同一发行人债券分散) train_idx, val_idx = train_test_split( np.arange(len(X)), test_size=0.2, stratify=y['event'], # 按event分层,保证验证集有足够违约样本 random_state=42 ) X_train, X_val = X_scaled[train_idx], X_scaled[val_idx] y_train, y_val = y.iloc[train_idx].values, y.iloc[val_idx].values # 计算事件样本权重 n_event = np.sum(y_train[:, 1]) n_censored = len(y_train) - n_event class_weight = {0: 1.0, 1: n_censored / n_event} # 右删失权重1,事件权重>1 print(f"训练集:{len(X_train)}样本,其中违约{int(n_event)}个,删失{n_censored}个") print(f"事件样本权重:{class_weight[1]:.2f}")

参数说明:

  • StandardScaler用fit_transform而非fit+transform,避免验证集泄露训练集统计量;
  • stratify=y['event']确保验证集包含足够违约样本(否则验证loss虚低);
  • 权重n_censored/n_event是经验阈值,若违约样本极少(<50),可升至2*n_censored/n_event。

4.2 构建双输出DNN模型:预测log_gamma与log_lambda

Weibull模型需同时输出log_gamma(形状)和log_lambda(尺度),因此网络最后一层为2节点。推荐结构:3层全连接(512→256→64),ReLU激活,Dropout=0.3防过拟合:

import tensorflow as tf from tensorflow import keras from tensorflow.keras import layers def build_weibull_model(input_dim, hidden_units=[512, 256, 64]): inputs = keras.Input(shape=(input_dim,)) x = inputs for i, units in enumerate(hidden_units): x = layers.Dense(units, activation='relu')(x) x = layers.Dropout(0.3)(x) # BatchNorm在生存分析中效果不稳定,故不用 # 输出层:2节点,无激活(直接输出log_gamma, log_lambda) outputs = layers.Dense(2, activation=None, name='weibull_outputs')(x) model = keras.Model(inputs=inputs, outputs=outputs) return model # 实例化模型 model = build_weibull_model(input_dim=X_train.shape[1]) model.summary() # 编译:使用自定义Weibull NLL损失 model.compile( optimizer=keras.optimizers.Adam(learning_rate=0.001), loss=weibull_nll_loss, metrics=[] # 生存分析不适用accuracy等分类指标 )

关键设计点:

  • 无BatchNorm:生存损失对归一化敏感,BN层在小批量下统计量不准,易导致gamma震荡;
  • 学习率0.001:Weibull参数对lr敏感,过大则gamma发散(>10),过小则收敛慢;
  • metrics留空:accuracy、AUC在此场景无意义,后续用concordance_index评估。

4.3 自定义回调:监控gamma稳定性与早停

Weibull的gamma参数必须>0且稳定(通常0.5~3.0),若训练中gamma持续>5或<0.1,说明模型崩溃。添加回调实时监控:

class GammaMonitor(keras.callbacks.Callback): def __init__(self, validation_data, patience=10): self.validation_data = validation_data self.patience = patience self.wait = 0 self.best_gamma = float('inf') self.best_weights = None def on_train_begin(self, logs=None): self.wait = 0 self.best_gamma = float('inf') def on_epoch_end(self, epoch, logs=None): # 获取验证集上gamma的均值 val_pred = self.model.predict(self.validation_data[0]) gamma_vals = np.exp(val_pred[:, 0]) # log_gamma → gamma mean_gamma = np.mean(gamma_vals) if not (0.3 <= mean_gamma <= 5.0): print(f"\n警告:epoch {epoch} 验证集gamma均值={mean_gamma:.3f},超出合理范围[0.3,5.0]") self.wait += 1 if self.wait >= self.patience: print("gamma异常,强制终止训练") self.model.stop_training = True else: self.wait = 0 if mean_gamma < self.best_gamma: self.best_gamma = mean_gamma self.best_weights = self.model.get_weights() # 使用回调 gamma_monitor = GammaMonitor(validation_data=(X_val, y_val), patience=15) early_stopping = keras.callbacks.EarlyStopping( monitor='loss', patience=20, restore_best_weights=True ) history = model.fit( X_train, y_train, batch_size=256, epochs=100, validation_data=(X_val, y_val), class_weight=class_weight, callbacks=[gamma_monitor, early_stopping], verbose=1 )

逻辑说明:

  • gamma_monitor每轮检查验证集gamma均值,超界则计数,连续15轮异常则停训;
  • early_stopping监控loss而非val_loss,因生存损失在验证集波动大,loss更稳定;
  • restore_best_weights=True确保返回最优权重,而非最后权重。

5. 避坑指南:Weibull生存模型在债券场景的5个血泪教训

5.1 现象:训练loss下降但验证集gamma持续>10,模型输出全是NaN

原因:log_gamma输出未约束,梯度爆炸导致gamma=exp(log_gamma)溢出。常见于初始权重过大或学习率过高。
解决:在模型输出层后加tf.clip_by_value裁剪:

outputs = layers.Dense(2, activation=None)(x) outputs = tf.clip_by_value(outputs, -5.0, 5.0) # log_gamma ∈ [-5,5] → gamma ∈ [0.007,148]

5.2 现象:验证集loss稳定在0.8,但所有样本预测S(t=12|x)≈0.99,无区分度

原因:特征未标准化,λ=exp(βᵀx)中βᵀx过大,导致S(t)=exp(-(t/λ)^γ)≈1。
解决:强制使用StandardScaler,并在build_weibull_model中添加检查:

# 训练前验证 assert np.abs(np.mean(X_train)) < 0.1 and np.std(X_train) > 0.5, "特征未标准化!"

5.3 现象:t_obs为0的样本导致log(t_obs)报错

原因:清洗时未处理issue_date==end_date的极端情况(如当日发行当日违约)。
解决:在build_survival_labels中增加兜底:

df['t_obs'] = df['t_obs'].clip(lower=1) # 已有,但需确认执行 # 额外检查 if (df['t_obs'] == 0).any(): df.loc[df['t_obs'] == 0, 't_obs'] = 1

5.4 现象:模型对高评级债券(AAA)预测S(t=60|x)=0.999,但实际违约率0.02%

原因:事件样本太少,模型学不到高评级风险模式。
解决:对高评级子集(AAA/AA+)单独采样,使n_event≥20;或引入预训练:先用全部债券训练,再用高评级样本微调最后两层。

5.5 现象:concordance_index=0.55,低于逻辑回归基线

原因:特征工程失效,或删失权重设置不当。
排查步骤:

  1. 用sklearn.linear_model.LogisticRegression跑二分类(event为label),C-index应>0.65;
  2. 若LR也低,说明特征无效,回溯财务指标计算逻辑;
  3. 若LR高而Weibull低,检查class_weight是否误设为{0:1,1:1}(未加权);
  4. 最后检查weibull_nll_loss中tf.where条件是否写反(event==1对应log_f)。

6. 模型交付:如何输出监管认可的PD12与违约概率曲线?

6.1 计算单只债券的PD12:从生存函数到条件违约概率

Weibull模型输出log_gamma和log_lambda,需转换为gamma和lambda,再计算S(t|x)。PD12定义为:未来12个月内违约的概率,即P(T ≤ t+12 | T > t, x)。推导如下:

  • S(t|x) = exp(-(t/λ)^γ)
  • PD12(t|x) = 1 - S(t+12|x)/S(t|x) = 1 - exp(-[(t+12)/λ]^γ + [t/λ]^γ)

代码实现:

def predict_pd12(model, X, t_current=0, gamma_scale=1.0): """ 预测债券在当前持有t_current月后的PD12 model: 训练好的Weibull模型 X: 特征矩阵 (n_samples, n_features) t_current: 当前已持有月数(发行后t_current个月) gamma_scale: gamma缩放系数(调试用,默认1.0) 返回: PD12数组 (n_samples,) """ pred = model.predict(X) # shape=(n,2) log_gamma, log_lambda = pred[:, 0], pred[:, 1] gamma = np.exp(log_gamma) * gamma_scale lambda_ = np.exp(log_lambda) # 计算S(t_current|x)和S(t_current+12|x) t1 = t_current t2 = t_current + 12 S_t1 = np.exp(-(t1 / lambda_) ** gamma) S_t2 = np.exp(-(t2 / lambda_) ** gamma) # PD12 = 1 - S_t2/S_t1 pd12 = 1 - S_t2 / (S_t1 + 1e-8) # +1e-8防除零 return np.clip(pd12, 0, 1) # 保证[0,1] # 示例:预测新债券池的PD12 # X_new = scaler.transform(X_new_raw) # 先标准化 # pd12_scores = predict_pd12(model, X_new, t_current=12) # 持有12个月后

参数说明:

  • t_current是业务关键参数:对新发债设为0;对存量债按实际持有月数填;
  • gamma_scale用于微调:若发现模型低估早期风险(γ偏小),可设为1.2;
  • np.clip防止浮点误差导致PD12<0或>1。

6.2 绘制违约概率曲线:识别风险拐点

单只债券的S(t|x)随时间衰减,但不同债券衰减形态不同。绘制曲线可识别风险拐点(如γ>1的债券在t=36个月后风险陡增):

import matplotlib.pyplot as plt def plot_survival_curve(model, X_single, bond_id, max_t=120): """ 绘制单只债券生存曲线 X_single: shape=(1, n_features) max_t: 最大绘图时间(月) """ pred = model.predict(X_single) gamma = np.exp(pred[0, 0]) lambda_ = np.exp(pred[0, 1]) t_grid = np.arange(1, max_t + 1) S_t = np.exp(-(t_grid / lambda_) ** gamma) plt.figure(figsize=(10, 6)) plt.plot(t_grid, S_t, 'b-', linewidth=2, label=f'{bond_id} Survival Curve') plt.axhline(y=0.5, color='r', linestyle='--', alpha=0.7, label='50% Survival') # 标注中位生存时间(S(t)=0.5时的t) median_t = int(lambda_ * (np.log(2))**(1/gamma)) plt.axvline(x=median_t, color='g', linestyle=':', alpha=0.8, label=f'Median t={median_t}m') plt.xlabel('Months since Issue') plt.ylabel('Survival Probability S(t)') plt.title(f'Survival Curve for {bond_id}') plt.legend() plt.grid(True, alpha=0.3) plt.show() # 使用示例 # plot_survival_curve(model, X_val[0:1], bond_id='123456.SZ')

业务价值:

  • 中位生存时间median_t是直观风控阈值,median_t<24的债券需重点盯防;
  • 曲线凹凸性反映风险模式:γ<1(曲线凸)→ 风险递减(如政策支持债);γ>1(曲线凹)→ 风险递增(如高杠杆地产债)。

6.3 模型验证:Concordance Index与校准度双指标

监管要求模型既排序准(谁更可能先违约),又概率准(PD12=5%的债券,实际违约率确为5%)。双指标验证:

指标计算方法合格线说明
Concordance Index (C-index)对所有可比样本对(i,j),若t_i<t_j且event_i=1,则预测risk_i>risk_j的比例≥0.70排序能力,类似AUC
Calibration Slope对PD12分10组,每组实际违约率vs预测PD12均值的线性回归斜率0.8~1.2概率校准,斜率=1最准

代码实现C-index(使用lifelines.utils.concordance_index):

from lifelines.utils import concordance_index # 预测风险分数:Weibull中常用`-log(S(t=12|x))`作为风险分 def get_risk_score(model, X, t=12): pred = model.predict(X) gamma = np.exp(pred[:, 0]) lambda_ = np.exp(pred[:, 1]) # S(12|x) = exp(-(12/lambda)^gamma) # 风险分 = -log(S) = (12/lambda)^gamma risk = (t / lambda_) ** gamma return risk risk_scores = get_risk_score(model, X_val) c_index = concordance_index(y_val[:, 0], -risk_scores, y_val[:, 1]) print(f"Validation C-index: {c_index:.3f}")

我带团队落地这个方案时,最大的后悔药是没在第一版就做校准检验——模型C-index=0.75,但Calibration Slope=0.4,意味着PD12=10%的债券实际违约率仅4%。后来发现是删失权重过高,把事件样本“吹胀”了,调低权重至n_censored/(2*n_event)后,Slope升至0.92。生存模型不是调完loss就结束,概率校准才是风控落地的生死线。希望帮到你。

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

返回列表