用TensorFlow构建深度生存模型:债券违约预测中的右删失数据实践

用TensorFlow构建深度生存模型:债券违约预测中的右删失数据实践 简介这是一份面向金融分析师和机器学习研发人员的PDF技术资料聚焦债券违约预测中右删失数据难以处理的问题讲解如何借助TensorFlow构建生存分析模型系统覆盖数据预处理、模型构建、自定义损失函数、训练优化与评估等环节并以实际案例说明在投资决策、金融机构风控和监管政策中的应用价值。资源包共1个PDF文件大小仅1.84MB内容紧凑便于快速通读。其中包含从右删失数据处理方法如直接忽略、填补、基于生存模型处理到一致性指数、Brier分数、时间依赖AUC等评估指标的完整梳理还给出代码示例与章节式结构能帮助读者掌握将深度学习引入信用风险建模的完整思路。已有77人学习浏览适合具备一定编程基础、希望深入理解TensorFlow与生存分析结合的读者。1. 右删失数据为什么让传统分类模型失效债券违约预测里最容易被误处理的数据不是缺失值而是右删失样本。观察期结束时还没违约的债券不等于它永远不会违约只是你还没等到它出事的那一天。把这类样本直接删掉训练集里剩下的几乎全是暴雷债券模型会系统性高估违约率把它们当作未违约样本标成 0又会低估尾部风险模型学不到“时间”这个维度的信息。很多团队用 XGBoost、LightGBM 做二分类时都会踩这个坑。生存分析模型的解决思路是把“是否违约”和“违约时间”联合建模让删失样本以“截至观察点仍未违约”的形态参与训练而不是被迫二值化。本文用 TensorFlow 从零构建一个深度生存模型走通右删失数据从清洗、建模到评估的完整链路适合已经跑通过常规分类模型、想进一步处理时间信息的金融风控研发。整个方案参考了 DeepSurv 的思路用神经网络替换 Cox 比例风险模型里的线性部分保留右删失数据的似然表达。下文所有代码都可以在 TensorFlow 2.x 下直接执行建议先用 pip 完成 tensorflow 安装再对照逐步复现。2. 生存分析建模基础从生存函数到风险函数2.1 生存函数、风险函数与右删失数据的数学表述生存分析研究的是事件发生时间 T 的分布。两个核心函数贯穿整个建模过程。生存函数 S(t) 表示个体存活到时间 t 之后的概率即 T t 的概率。在债券场景里S(t) 就是债券在 t 时刻之后仍未违约的概率。S(t) 单调不增S(0) 1t 趋于无穷时 S(t) 趋于 0。风险函数 h(t) 表示已知个体活到 t 时刻的条件下在 t 时刻瞬间发生事件的概率密度数学上等于h(t) f(t) / S(t) -d(ln S(t)) / dth(t) 的取值范围是 [0, ∞)它刻画的是“当下这一刻的危险程度”而不是累积概率。累积风险函数 H(t) 是 h(t) 从 0 到 t 的积分H(t) -ln S(t)由此可得 S(t) exp(-H(t))。右删失样本的贡献需要同时用到这两个函数。设第 i 个样本的观测时间为 τᵢ事件标识为 δᵢδ 1 表示在 τᵢ 时刻违约δ 0 表示右删失其似然贡献为Lᵢ h(τᵢ)^δᵢ · S(τᵢ)当 δ 1似然是 τᵢ 处的密度 f(τᵢ) h(τᵢ)S(τᵢ)当 δ 0只知道它活过了 τᵢ似然就是 S(τᵢ)。这个统一表达式是生存分析处理删失数据的基础也是自定义损失函数的核心来源。传统分类模型的损失函数里没有“时间”维度自然无法表达这种似然结构。2.2 从 Cox 比例风险到深度生存网络Cox 比例风险模型假设风险函数可以分解为基准风险与协变量效应的乘积h(t | X) h₀(t) · exp(βᵀX)基准风险 h₀(t) 只与时间有关不随样本变化指数部分刻画协变量对风险的乘性影响。估计参数 β 时Cox 使用偏似然函数构造每个事件发生时刻的风险集只比较同一时刻“谁先出事”的排序信息从而绕开 h₀(t) 的具体形式。DeepSurv 的核心改动是把 βᵀX 换成神经网络输出 f_θ(X)h(t | X) h₀(t) · exp(f_θ(X))这样保留了 Cox 模型的偏似然框架同时获得非线性建模能力。债券违约里特征与风险的关系远非线性——资产负债率对违约风险的影响会随行业、宏观环境变化而改变线性 Cox 模型很难捕捉这种交互效应。神经网络正好补上这块短板。TensorFlow 实现这个模型的技术基点在于两点一是自动求导反向传播可以直接计算出 f_θ(X) 对每个参数的梯度无需手动推导偏似然的导数二是 Keras 的自定义损失函数机制负对数偏似然可以像 MSE 一样直接作为损失函数传入 model.compile()。2.3 TensorFlow 环境与张量机制开始建模前先确认环境。TensorFlow 2.x 默认启用 Eager Execution张量操作即时执行调试体验接近 NumPyimport tensorflow as tf import numpy as np # 创建一个标量、向量和矩阵三个张量验证基本维度 scalar tf.constant(1.0) vector tf.constant([1.0, 2.0, 3.0]) matrix tf.constant([[1.0, 2.0], [3.0, 4.0]]) print(标量维度:, scalar.ndim, 形状:, scalar.shape) print(向量维度:, vector.ndim, 形状:, vector.shape) print(矩阵维度:, matrix.ndim, 形状:, matrix.shape) # 模拟 5 条债券观测每行 [观测时间, 是否违约(1违约,0删失)] y_demo np.array([ [2.3, 1.0], [3.1, 0.0], [1.8, 1.0], [4.2, 0.0], [2.7, 0.0] ], dtypenp.float32) y_tensor tf.convert_to_tensor(y_demo) print(标签张量形状:, y_tensor.shape)代码里的tf.constant与tf.convert_to_tensor作用是把 NumPy 数组或 Python 数值转成 TensorFlow 张量。ndim和shape可以用来确认张量的维度和形状模型输入输出层的定义依赖这两个属性。y_demo的构造方式贯穿全流程第一列是观测时间第二列是事件标识。观测时间对应债券从纳入研究到违约或到观察截止之间的时长单位可以是月也可以是年在后续模型里需要保持统一。3. 删失时间变换与特征编码的数据预处理实践3.1 债券数据的清洗与异常值识别债券违约预测的数据通常来自金融终端、交易所披露和评级机构报告。拿到原始数据后第一件事不是建模而是把“时间”和“事件”两个字段整理干净。这里的核心是把原始数据整理成“观测时间 事件标识 特征矩阵”的三元组结构观测时间是 min(违约日期, 研究截止日期) 与该债券纳入研究起点日期的差值事件标识则看违约是否发生在研究截止之前。清洗时我一般按固定顺序处理。先剔除观测时间为负值或缺失的样本这类数据没有参与生存分析的意义再处理重复记录同一只债券在多个数据源里可能出现多次最后处理异常值债券发行规模、票面利率这类数值特征用均值加减 3 倍标准差识别离群点结合业务判断是数据错误还是真实的小概率事件。import pandas as pd import numpy as np # 读入债券数据假设已生成 default_time 和 default_flag 两列 df pd.read_csv(bond_data.csv) # 1. 剔除观测时间异常样本 df df[(df[default_time] 0) df[default_time].notna()] # 2. 去重同一债券同一观察期只保留一条 df df.drop_duplicates(subset[bond_code, start_date], keeplast) # 3. 用 3 倍标准差识别发行规模的异常值 mean_issue df[issue_size].mean() std_issue df[issue_size].std() df df[(df[issue_size] mean_issue - 3 * std_issue) (df[issue_size] mean_issue 3 * std_issue)] print(清洗后样本量:, len(df)) print(删失率: {:.2%}.format((df[default_flag] 0).mean()))这段代码的关键在drop_duplicates的subset参数。同一只债券如果多次出现在数据中最晚录入的记录往往包含最新的兑付状态keeplast正是保留状态最新的记录。删失率指的是右删失数据占全体样本的比例这个数字直接决定后续处理策略的选择删失率低于 10% 时可以谨慎尝试简单方法超过 30% 就必须用生存分析模型来消化删失信息。3.2 特征构造与标准化原始财务字段大多是绝对量比如总资产、总负债、营业收入直接喂给模型会引入规模效应。同类特征里量级大的天然占主导神经网络虽然能学非线性关系但预处理阶段做掉这部分工作收敛更快。常用的比率特征包括资产负债率总负债/总资产、流动比率流动资产/流动负债、利息保障倍数息税前利润/利息费用等。同时还需要检查时间相关的数据泄漏用于预测的特征必须严格早于观测起点比如用上一年年报数据预测当年违约风险而不是用当年同期数据。from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split # 构造比率特征 df[debt_to_asset] df[total_debt] / df[total_assets] df[current_ratio] df[current_assets] / df[current_liabilities] df[ebit_interest] df[ebit] / df[interest_expense] # 处理无穷值和缺失值 df.replace([np.inf, -np.inf], np.nan, inplaceTrue) df[ebit_interest] df[ebit_interest].fillna(df[ebit_interest].median()) # 特征列与标签列拆分 feature_cols [debt_to_asset, current_ratio, ebit_interest, coupon_rate, issue_size] X df[feature_cols].values y df[[default_time, default_flag]].values # 标准化均值 0标准差 1 scaler StandardScaler() X_scaled scaler.fit_transform(X) # 分割训练验证 / 测试 X_train_val, X_test, y_train_val, y_test train_test_split( X_scaled, y, test_size0.15, random_state42, stratifydf[default_flag] ) # 再从训练验证中拆出验证集 X_train, X_val, y_train, y_val train_test_split( X_train_val, y_train_val, test_size0.15, random_state42, stratifyy_train_val[:, 1] )标准化选择 StandardScaler 而不是 MinMaxScaler是因为神经网络输出端要计算 exp(risk)对输入尺度比较敏感。标准化让特征均值为 0、方差为 1配合零均值初始化可以让网络初始输出接近 0避免 exp 溢出。stratify参数的加入是为了让训练集、验证集、测试集里违约和删失样本的比例与全量数据保持一致特别是在违约样本本身稀少的情况下随机划分很可能把少数违约样本全分到某个子集里。3.3 右删失数据的处理策略对比处理策略做法适用场景主要问题直接忽略法只用 δ1 的违约样本训练删失率极低删失样本信息量小丢失大量未违约信息严重高估违约率填补法用均值或模型预测违约时间替代删失时间删失机制简单样本量有限引入估计误差破坏不确定性表达生存模型法删失样本以 S(τ) 贡献似然删失率高观察期不统一需要自定义损失函数实现成本较高直接忽略法和填补法的共同问题是把“未知”当成了“已知”。删失样本被填充一个违约时间后模型会把这个虚假时间当作真实观测来拟合相当于人为制造了一个并不存在的确定性。生存模型法在损失函数层面表达删失每个样本的贡献是精确的似然值不引入额外的不确定性假设。这也是本文选择 TensorFlow 自定义损失函数的原因Keras 的loss参数支持任意接受真实标签和预测值、返回标量张量的函数负对数偏似然可以无缝嵌入训练流程。4. TensorFlow 构造生存网络架构、损失与配置4.1 网络架构设计输入、隐藏与单节点输出输入层的维度由特征工程后的特征数量决定。假设经过筛选后保留 10 个特征输入层就用tf.keras.Input(shape(10,))。隐藏层按“宽到窄”堆叠第一层 64 个神经元、第二层 32 个神经元激活函数统一用 ReLU。层数不必太深债券违约数据通常只有几千到几万条样本两层全连接已经具备足够的非线性表达能力层数再加深容易在有限样本上过拟合。输出层需要特别说明。原方案里输出两个节点风险得分和生存函数估计值但更稳定的做法是只输出一个节点——风险得分 f_θ(X)生存函数在训练完成后用基准累积风险估计器事后推导。理由有两点一是生存函数受时间影响在输出层直接回归 S(t) 需要模型显式处理时间变量结构复杂且收敛不稳定二是 DeepSurv 验证过的做法是模型只负责学特征与风险的映射关系时间相关的部分交给非参数的 Breslow 估计器二者解耦后模型更简单评估指标也更好看。import tensorflow as tf from tensorflow.keras import layers, regularizers # 输入层 inputs tf.keras.Input(shape(X_train.shape[1],), namefeatures) # 隐藏层64 - 32 - 1ReLU 激活 L2 正则化 x layers.Dense(64, activationrelu, kernel_regularizerregularizers.l2(1e-4))(inputs) x layers.Dense(32, activationrelu, kernel_regularizerregularizers.l2(1e-4))(x) # 输出层单节点风险得分无激活函数 outputs layers.Dense(1, namerisk_score)(x) # 构建模型 model tf.keras.Model(inputsinputs, outputsoutputs) model.summary()输出层不加激活函数是因为偏似然损失内部要计算 exp(risk)线性输出允许模型自由学习任意实数值风险得分不需要压缩到 (0,1) 区间。kernel_regularizer给隐藏层加了 L2 正则化惩罚项是权重的平方和超参1e-4控制惩罚强度正则化能抑制隐藏层权重过大导致的过拟合这在样本量不大的债券数据集上效果明显。4.2 负对数偏似然损失函数的 TensorFlow 实现损失函数的设计目标对每个违约事件计算该时刻风险集中所有样本的风险得分指数和最大化违约样本相对于风险集的“相对风险”。TensorFlow 实现时关键在于按观测时间排序后做累计求和def neg_log_partial_likelihood(y_true, y_pred): # y_true: [观测时间, 事件标识] # y_pred: [风险得分] time y_true[:, 0] event y_true[:, 1] risk tf.squeeze(y_pred) # 按观测时间降序排序 order tf.argsort(time, directionDESCENDING, stableTrue) time_sorted tf.gather(time, order) event_sorted tf.gather(event, order) risk_sorted tf.gather(risk, order) # 累计 exp(risk)simga_j in R(t_i) exp(risk_j) exp_risk tf.exp(risk_sorted) cumsum_exp tf.cumsum(exp_risk) # 对数似然sum event_i * (risk_i - log(cumsum_exp_i)) log_ll tf.reduce_sum(event_sorted * (risk_sorted - tf.math.log(cumsum_exp))) return -log_lltf.argsort的directionDESCENDING让时间大的样本排前面。cumsum_exp在排序后的序列上做前缀和恰好表示“当前样本时间点之后仍在风险集中的所有样本的 exp(风险) 之和”。stableTrue的意义在于处理同时间多个事件的情况同一时刻多个违约样本应该共享同一个风险集稳定排序保证相同时间的样本相对顺序不被破坏避免排名不稳定导致梯度抖动。最终返回负对数似然因为优化器默认最小化损失。这段代码还有两个边界情况要处理tf.exp输入过大时可能溢出可以在训练前把标准化做好或者给 risk 加一个最大值裁剪。4.3 模型编译与超参配置编译阶段让模型知道用什么优化器和评估指标。评估指标里不能直接用 C-index因为 Keras 的compile阶段指标接收(y_true, y_pred)并且要参与批量计算C-index 在 batch 上算出来不稳定把 C-index 计算放到训练之外单独实现更可靠model.compile( optimizertf.keras.optimizers.Adam(learning_rate3e-4), lossneg_log_partial_likelihood, metrics[] )超参数推荐值说明隐藏层神经元64 / 32特征 10~20 个时足够特征更多可加一层激活函数ReLU隐藏层统一使用避免梯度消失优化器Adam自适应学习率金融数据上收敛稳定初始学习率3e-4过大容易 loss 爆炸过小收敛缓慢L2 正则系数1e-4抑制过拟合可按验证集损失微调批大小32兼顾梯度稳定性和训练速度训练轮数200配合早停按验证损失自动截断Adam 优化器是生存网络场景下的默认选择因为它对学习率不那么敏感基本不需要手工做学习率退火。初始学习率 3e-4 是一个相对保守的值金融数据噪声大学习率太高会让负对数偏似然在训练初期剧烈震荡。5. 模型训练、C-index 评估与时间依赖 AUC5.1 训练循环、早停与模型保存训练过程的关键不是把 loss 跑到最低而是监控验证集上的损失来防止过拟合。眼下的实操里负对数偏似然的值本身没有绝对意义只有相对比较才有参考价值所以训练时盯住验证集上的 loss 趋势from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau callbacks [ EarlyStopping( monitorval_loss, patience20, restore_best_weightsTrue ), ReduceLROnPlateau( monitorval_loss, factor0.5, patience8, min_lr1e-6 ), ModelCheckpoint( filepathbond_survival_model.keras, monitorval_loss, save_best_onlyTrue ) ] history model.fit( X_train, y_train, validation_data(X_val, y_val), epochs200, batch_size32, callbackscallbacks, verbose1 )patience20表示验证损失连续 20 个 epoch 不下降时停止训练restore_best_weightsTrue让模型回滚到验证损失最低的权重避免最后几个过拟合的 epoch 污染模型。ReduceLROnPlateau是训练停滞时的备用方案验证损失连续 8 个 epoch 不降学习率减半给训练过程第二次机会。这组回调组合在大多数债券数据上都能让训练在 60~100 个 epoch 内稳定收敛。5.2 一致性指数 C-index 的计算与解读C-index 衡量模型风险排序能力含义是随机抽取一对可比较样本模型给出的风险排序与真实结果一致的概率。可比较对的定义违约样本和另一个比它晚删失或晚违约的样本。0.5 相当于随机猜测0.7 以上才具备实际区分能力0.8 以上属于优秀的风险排序模型def concordance_index(time, event, risk): n len(time) concordant 0 comparable 0 for i in range(n): for j in range(n): # 跳过同一对和不可比较对 if i j: continue # i 比 j 更早违约两者可比较 if event[i] 1 and time[i] time[j]: comparable 1 if risk[i] risk[j]: concordant 1 elif risk[i] risk[j]: concordant 0.5 if comparable 0: return 0.0 return concordant / comparable # 在测试集上评估 risk_test model.predict(X_test).flatten() c_index concordance_index( y_test[:, 0], y_test[:, 1].astype(int), risk_test ) print(测试集 C-index:, c_index)上面是 O(n^2) 的朴素实现作为验证够用。样本量超过两三万时应该改成按时间排序后的线性扫描方式避免计算时间过长。C-index 值突降时优先检查测试集和训练集的特征分布是否偏移其次检查删失样本占比是否过高——删失率超过 70% 时可比较对大幅减少C-index 的置信区间会显著变宽。5.3 Brier 分数与时间依赖 AUC 的补充视角C-index 只关注排序不关心预测概率的绝对校准度。这在业务上有个问题模型可能给出很好的排序但违约概率的绝对值整体偏高或偏低。这时候需要 Brier 分数和时间依赖 AUC 作为补充。Brier 分数在生存分析里扩展为随时间变化的曲线。t 时刻的 Brier 分数定义为BS(t) (1/n) ∑ [ (Ŝ(t|Xᵢ) - 1{τᵢ t})² · Wᵢ(t) ]其中 Ŝ(t|Xᵢ) 是模型预测的生存函数值权重 Wᵢ(t) 处理删失用逆删失概率加权IPCW。Brier 分数越低越好0.25 以下算可接受0.16 以下属于优秀。时间依赖 AUC 是另一条视角每个时间点计算一次 AUC反映模型在“t 时刻之前违约 vs t 时刻之后仍生存”这个二分问题上的区分能力。与 C-index 只看排序不同时间依赖 AUC 能看到模型在不同时间尺度上的表现差异——有些模型在短期1 年内区分度好长期3 年以上衰减很快这对债券投资期限匹配很有参考价值。指标关注点取值范围业务含义C-index风险排序一致性0.5~1.0高风险样本是否真的更早违约Brier 分数概率校准度0~0.25 左右预测违约概率与实际情况的偏离程度时间依赖 AUC时点区分能力0.5~1.0不同时间窗口下模型还能不能分开好坏样本实际项目里这三个指标配合使用C-index 管排序Brier 管校准时间依赖 AUC 管时间维度上的稳定性。三个指标同时恶化优先检查特征里是否包含了未来信息单看 C-index 高但 Brier 差考虑是否为模型输出的风险分数做校准映射。6. 用生存曲线做违约风险排序的工程化细节6.1 从风险得分到个体生存曲线模型训练完成后输出的是风险得分 f_θ(X)要得到业务人员能直接看的违约概率或生存曲线还要补一步基准累积风险估计。常见做法是 Breslow 估计器训练集每个违约时刻 tᵢ 处的基准累积风险增量为ΔH₀(tᵢ) dᵢ / Σⱼ∈R(tᵢ) exp(f_θ(Xⱼ))其中 dᵢ 是 tᵢ 时刻的违约事件数R(tᵢ) 是该时刻的风险集。随后逐个时间点累加再结合每个测试样本的风险得分得到该样本的生存曲线 S(t|X) exp(-H₀(t) · exp(f_θ(X)))。这里的操作可以在测试集上直接实现不需要重新训练模型。6.2 风险分桶与组合筛选的实操拿到生存曲线后具体落地时我会按期限切分观察窗口。比如关注一年内的违约风险就对每个样本取 S(1年) 的值换算成一年内违约概率 1 - S(1年)再按这个概率降序排列把样本分成五档风险桶。对比不同桶的 Kaplan-Meier 实际违约曲线如果分档后桶间差异明显、桶内曲线平滑说明模型的排序能力在产品层面成立。每个阈值的选择要结合业务可承受的误杀率。风控场景常见做法是取样本总数的 10% 作为高危名单这个比例对应着一年内违约概率的某个具体分位数可以用测试集做网格搜索来决定。模型上线后还要定期重训因为债券发行人的财务数据按季度更新生存曲线的基准风险会随宏观信用环境漂移——一个常见做法是每个季度末用最新的财务数据重新预测一次生存曲线对比上季度的风险排序变化把排名跃升超过阈值的样本自动送入人工复核队列再配合实际违约案例回验模型分层是否出现偏移。这个“排序变化监控 定期重训”的流程比盯着单个指标调参更能满足业务端的持续性需求。本文还有配套的精品资源点击获取