TensorFlow实现Weibull生存模型预测债券违约概率
简介本资源是一份面向金融风控工程师、量化分析师及具备TensorFlow基础的机器学习从业者的专业技术文档聚焦债券违约预测中右删失数据建模这一关键难点。全文系统阐述如何基于TensorFlow构建生存分析模型覆盖右删失数据特性解析、特征工程策略、自定义损失函数设计、模型架构搭建、训练优化含早停与正则化及多维评估C-index、Brier分数、时变AUC并结合真实债券市场案例展示从数据预处理到生存曲线解读的完整落地流程。资源为单文件PDF共27页大小1.84MB内容结构严谨含9大章节与30子模块涵盖引言、理论基础、代码实现要点、局限性分析与未来方向技术深度与业务结合度兼备。目前已有77人学习下载适合希望提升金融时序风险建模能力、掌握生存分析在信贷/债券领域实战应用的技术人员精读与复现。1. 债券违约预测为什么非得用生存分析——右删失数据不处理模型再准也是玄学你训练了一个LSTMXGBoost的债券违约二分类模型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发生违约”但无精确日。本文只处理右删失——这是债券数据最普遍、最需建模的类型。关键在于传统二分类模型把右删失样本全标为label0等于假设“永远不违约”而生存模型将其标记为censored1并保留其观察时长t_obs作为约束条件。例如某债发行于2020-01-01观察截止2024-06-30期间未违约且未兑付 →t_obs54个月, event0, censored1若该债2022-06-30提前兑付 →t_obs30个月, event0, censored1。二者在生存模型中贡献不同前者说明“至少活了54个月”后者说明“至少活了30个月”。2.2 Weibull vs Cox为什么放弃“黑匣子”Cox选可解释的WeibullCox模型虽无需假设基线风险函数但存在三个债券场景硬伤无法外推预测Cox输出的是风险比Hazard Ratio不能直接计算某债在未来12个月的违约概率协变量效应恒定假设不成立债券信用风险随时间非线性演化如地产债前3年平稳第4年暴雷Cox要求HR不随时间变强行满足会牺牲精度无法处理时间依赖协变量债券的财务指标如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 | Tt, 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。损失函数推导如下对于事件样本event1贡献密度函数f(t|x) h(t|x)·S(t|x)对于右删失样本censored1贡献生存函数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_obsdef 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而非原始值是为了保证gamma0, lambda0避免梯度爆炸。此损失函数可直接传入model.compile(lossweibull_nll_loss)无需修改模型结构。3. 从原始债券数据到可训练TensorFlow模型四步清洗与特征工程3.1 原始数据必备字段与清洗铁律债券违约预测数据源通常来自Wind/CSMAR/企业财报但必须包含以下5个字段才能构建生存分析数据集字段名类型说明清洗要求bond_idstr债券唯一代码去重确保无重复发行issue_datedatetime发行日期统一转为YYYY-MM-DD格式default_datedatetime违约日期可为空空值记为右删失非空则计算t_obsend_datedatetime观察截止日如2024-06-30所有样本统一此值statusstr状态default, repaid, extended, acquireddefault→event1其余→event0, censored1清洗铁律三条剔除状态模糊样本如statusunknown或default_date与status矛盾如statusrepaid但default_date非空直接丢弃统一时间粒度所有日期转为datetime64[D]t_obs以月为单位np.floor((end_date - issue_date).dt.days / 30.44)避免日粒度导致小数误差处理“伪删失”若statusrepaid但end_date - issue_date 12个月视为短期债保留若120个月10年检查是否为永续债永续债单独建模本文不覆盖。3.2 特征构造债券领域特有的3类时变特征债券特征不能简单套用股票因子需紧扣信用风险驱动逻辑静态特征Static发行时即确定不随时间变。如bond_type利率债/信用债/ABS、issuer_rating发行主体评级、guarantor是否有担保、coupon_rate票面利率。编码方式issuer_rating用映射字典转数值AAA10, AA9, ..., D0类别变量用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_date2024-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) # 转月向下取整 # 定义event1违约0右删失 df[event] 0 # 条件1default_date非空且早于end_date → 真实违约 valid_default df[default_date].notna() (pd.to_datetime(df[default_date]) df[end_date]) df.loc[valid_default, event] 1 # 条件2status为default且default_date为空 → 数据录入问题按右删失处理保守策略 df.loc[(df[status] default) df[default_date].isna(), event] 0 # 输出标签 labels df[[t_obs, event]].copy() # 强制t_obs 1发行当月即违约视为t_obs1 labels[t_obs] labels[t_obs].clip(lower1) return labels # 使用示例 # raw_df pd.read_csv(bonds_raw.csv) # labels build_survival_labels(raw_df, end_date2024-06-30) # print(labels.head())逻辑说明t_obs最小为1避免log(0)错误default_date为空但statusdefault时视为数据缺失按右删失处理宁错杀不放过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_size0.2, stratifyy[event], # 按event分层保证验证集有足够违约样本 random_state42 ) 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而非fittransform避免验证集泄露训练集统计量stratifyy[event]确保验证集包含足够违约样本否则验证loss虚低权重n_censored/n_event是经验阈值若违约样本极少50可升至2*n_censored/n_event。4.2 构建双输出DNN模型预测log_gamma与log_lambdaWeibull模型需同时输出log_gamma形状和log_lambda尺度因此网络最后一层为2节点。推荐结构3层全连接512→256→64ReLU激活Dropout0.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, activationrelu)(x) x layers.Dropout(0.3)(x) # BatchNorm在生存分析中效果不稳定故不用 # 输出层2节点无激活直接输出log_gamma, log_lambda outputs layers.Dense(2, activationNone, nameweibull_outputs)(x) model keras.Model(inputsinputs, outputsoutputs) return model # 实例化模型 model build_weibull_model(input_dimX_train.shape[1]) model.summary() # 编译使用自定义Weibull NLL损失 model.compile( optimizerkeras.optimizers.Adam(learning_rate0.001), lossweibull_nll_loss, metrics[] # 生存分析不适用accuracy等分类指标 )关键设计点无BatchNorm生存损失对归一化敏感BN层在小批量下统计量不准易导致gamma震荡学习率0.001Weibull参数对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, patience10): 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, logsNone): self.wait 0 self.best_gamma float(inf) def on_epoch_end(self, epoch, logsNone): # 获取验证集上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), patience15) early_stopping keras.callbacks.EarlyStopping( monitorloss, patience20, restore_best_weightsTrue ) history model.fit( X_train, y_train, batch_size256, epochs100, validation_data(X_val, y_val), class_weightclass_weight, callbacks[gamma_monitor, early_stopping], verbose1 )逻辑说明gamma_monitor每轮检查验证集gamma均值超界则计数连续15轮异常则停训early_stopping监控loss而非val_loss因生存损失在验证集波动大loss更稳定restore_best_weightsTrue确保返回最优权重而非最后权重。5. 避坑指南Weibull生存模型在债券场景的5个血泪教训5.1 现象训练loss下降但验证集gamma持续10模型输出全是NaN原因log_gamma输出未约束梯度爆炸导致gammaexp(log_gamma)溢出。常见于初始权重过大或学习率过高。解决在模型输出层后加tf.clip_by_value裁剪outputs layers.Dense(2, activationNone)(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(t12|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_dateend_date的极端情况如当日发行当日违约。解决在build_survival_labels中增加兜底df[t_obs] df[t_obs].clip(lower1) # 已有但需确认执行 # 额外检查 if (df[t_obs] 0).any(): df.loc[df[t_obs] 0, t_obs] 15.4 现象模型对高评级债券AAA预测S(t60|x)0.999但实际违约率0.02%原因事件样本太少模型学不到高评级风险模式。解决对高评级子集AAA/AA单独采样使n_event≥20或引入预训练先用全部债券训练再用高评级样本微调最后两层。5.5 现象concordance_index0.55低于逻辑回归基线原因特征工程失效或删失权重设置不当。排查步骤用sklearn.linear_model.LogisticRegression跑二分类event为labelC-index应0.65若LR也低说明特征无效回溯财务指标计算逻辑若LR高而Weibull低检查class_weight是否误设为{0:1,1:1}未加权最后检查weibull_nll_loss中tf.where条件是否写反event1对应log_f。6. 模型交付如何输出监管认可的PD12与违约概率曲线6.1 计算单只债券的PD12从生存函数到条件违约概率Weibull模型输出log_gamma和log_lambda需转换为gamma和lambda再计算S(t|x)。PD12定义为未来12个月内违约的概率即P(T ≤ t12 | T t, x)。推导如下S(t|x) exp(-(t/λ)^γ)PD12(t|x) 1 - S(t12|x)/S(t|x) 1 - exp(-[(t12)/λ]^γ [t/λ]^γ)代码实现def predict_pd12(model, X, t_current0, gamma_scale1.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_current12|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_current12) # 持有12个月后参数说明t_current是业务关键参数对新发债设为0对存量债按实际持有月数填gamma_scale用于微调若发现模型低估早期风险γ偏小可设为1.2np.clip防止浮点误差导致PD120或1。6.2 绘制违约概率曲线识别风险拐点单只债券的S(t|x)随时间衰减但不同债券衰减形态不同。绘制曲线可识别风险拐点如γ1的债券在t36个月后风险陡增import matplotlib.pyplot as plt def plot_survival_curve(model, X_single, bond_id, max_t120): 绘制单只债券生存曲线 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-, linewidth2, labelf{bond_id} Survival Curve) plt.axhline(y0.5, colorr, linestyle--, alpha0.7, label50% Survival) # 标注中位生存时间S(t)0.5时的t median_t int(lambda_ * (np.log(2))**(1/gamma)) plt.axvline(xmedian_t, colorg, linestyle:, alpha0.8, labelfMedian t{median_t}m) plt.xlabel(Months since Issue) plt.ylabel(Survival Probability S(t)) plt.title(fSurvival Curve for {bond_id}) plt.legend() plt.grid(True, alpha0.3) plt.show() # 使用示例 # plot_survival_curve(model, X_val[0:1], bond_id123456.SZ)业务价值中位生存时间median_t是直观风控阈值median_t24的债券需重点盯防曲线凹凸性反映风险模式γ1曲线凸→ 风险递减如政策支持债γ1曲线凹→ 风险递增如高杠杆地产债。6.3 模型验证Concordance Index与校准度双指标监管要求模型既排序准谁更可能先违约又概率准PD125%的债券实际违约率确为5%。双指标验证指标计算方法合格线说明Concordance Index (C-index)对所有可比样本对(i,j)若t_it_j且event_i1则预测risk_irisk_j的比例≥0.70排序能力类似AUCCalibration Slope对PD12分10组每组实际违约率vs预测PD12均值的线性回归斜率0.8~1.2概率校准斜率1最准代码实现C-index使用lifelines.utils.concordance_indexfrom lifelines.utils import concordance_index # 预测风险分数Weibull中常用-log(S(t12|x))作为风险分 def get_risk_score(model, X, t12): 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(fValidation C-index: {c_index:.3f})我带团队落地这个方案时最大的后悔药是没在第一版就做校准检验——模型C-index0.75但Calibration Slope0.4意味着PD1210%的债券实际违约率仅4%。后来发现是删失权重过高把事件样本“吹胀”了调低权重至n_censored/(2*n_event)后Slope升至0.92。生存模型不是调完loss就结束概率校准才是风控落地的生死线。希望帮到你。本文还有配套的精品资源点击获取