简介:这是一份面向金融分析师和机器学习研发人员的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) = 1,t 趋于无穷时 S(t) 趋于 0。风险函数 h(t) 表示已知个体活到 t 时刻的条件下,在 t 时刻瞬间发生事件的概率密度,数学上等于
h(t) = f(t) / S(t) = -d(ln S(t)) / dt
h(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,张量操作即时执行,调试体验接近 NumPy:
import 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] ], dtype=np.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'], keep='last') # 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参数。同一只债券如果多次出现在数据中,最晚录入的记录往往包含最新的兑付状态,keep='last'正是保留状态最新的记录。删失率指的是右删失数据占全体样本的比例,这个数字直接决定后续处理策略的选择:删失率低于 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, inplace=True) 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_size=0.15, random_state=42, stratify=df['default_flag'] ) # 再从训练+验证中拆出验证集 X_train, X_val, y_train, y_val = train_test_split( X_train_val, y_train_val, test_size=0.15, random_state=42, stratify=y_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],), name='features') # 隐藏层:64 -> 32 -> 1,ReLU 激活 + L2 正则化 x = layers.Dense(64, activation='relu', kernel_regularizer=regularizers.l2(1e-4))(inputs) x = layers.Dense(32, activation='relu', kernel_regularizer=regularizers.l2(1e-4))(x) # 输出层:单节点风险得分,无激活函数 outputs = layers.Dense(1, name='risk_score')(x) # 构建模型 model = tf.keras.Model(inputs=inputs, outputs=outputs) 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, direction='DESCENDING', stable=True) 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的direction='DESCENDING'让时间大的样本排前面。cumsum_exp在排序后的序列上做前缀和,恰好表示“当前样本时间点之后仍在风险集中的所有样本的 exp(风险) 之和”。stable=True的意义在于处理同时间多个事件的情况:同一时刻多个违约样本应该共享同一个风险集,稳定排序保证相同时间的样本相对顺序不被破坏,避免排名不稳定导致梯度抖动。最终返回负对数似然,因为优化器默认最小化损失。这段代码还有两个边界情况要处理:tf.exp输入过大时可能溢出,可以在训练前把标准化做好,或者给 risk 加一个最大值裁剪。
4.3 模型编译与超参配置
编译阶段让模型知道用什么优化器和评估指标。评估指标里不能直接用 C-index,因为 Keras 的compile阶段指标接收(y_true, y_pred)并且要参与批量计算,C-index 在 batch 上算出来不稳定,把 C-index 计算放到训练之外单独实现更可靠:
model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=3e-4), loss=neg_log_partial_likelihood, metrics=[] )| 超参数 | 推荐值 | 说明 |
|---|---|---|
| 隐藏层神经元 | 64 / 32 | 特征 10~20 个时足够,特征更多可加一层 |
| 激活函数 | ReLU | 隐藏层统一使用,避免梯度消失 |
| 优化器 | Adam | 自适应学习率,金融数据上收敛稳定 |
| 初始学习率 | 3e-4 | 过大容易 loss 爆炸,过小收敛缓慢 |
| L2 正则系数 | 1e-4 | 抑制过拟合,可按验证集损失微调 |
| 批大小 | 32 | 兼顾梯度稳定性和训练速度 |
| 训练轮数 | 200 | 配合早停,按验证损失自动截断 |
Adam 优化器是生存网络场景下的默认选择,因为它对学习率不那么敏感,基本不需要手工做学习率退火。初始学习率 3e-4 是一个相对保守的值,金融数据噪声大,学习率太高会让负对数偏似然在训练初期剧烈震荡。
5. 模型训练、C-index 评估与时间依赖 AUC
5.1 训练循环、早停与模型保存
训练过程的关键不是把 loss 跑到最低,而是监控验证集上的损失来防止过拟合。眼下的实操里,负对数偏似然的值本身没有绝对意义,只有相对比较才有参考价值,所以训练时盯住验证集上的 loss 趋势:
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau callbacks = [ EarlyStopping( monitor='val_loss', patience=20, restore_best_weights=True ), ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=8, min_lr=1e-6 ), ModelCheckpoint( filepath='bond_survival_model.keras', monitor='val_loss', save_best_only=True ) ] history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=200, batch_size=32, callbacks=callbacks, verbose=1 )patience=20表示验证损失连续 20 个 epoch 不下降时停止训练,restore_best_weights=True让模型回滚到验证损失最低的权重,避免最后几个过拟合的 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% 作为高危名单,这个比例对应着一年内违约概率的某个具体分位数,可以用测试集做网格搜索来决定。模型上线后还要定期重训,因为债券发行人的财务数据按季度更新,生存曲线的基准风险会随宏观信用环境漂移——一个常见做法是每个季度末用最新的财务数据重新预测一次生存曲线,对比上季度的风险排序变化,把排名跃升超过阈值的样本自动送入人工复核队列,再配合实际违约案例回验模型分层是否出现偏移。这个“排序变化监控 + 定期重训”的流程,比盯着单个指标调参更能满足业务端的持续性需求。
本文还有配套的精品资源,点击获取