11 生存分析

生存分析

核心问题:如何分析”等待时间”?

生存分析(Survival Analysis)研究从某个起始时间到某个事件发生所经历的时间

  • 医学:患者确诊后的存活时间
  • 商业:客户首次购买后的流失时间
  • 工程:设备投产后的故障时间
  • 金融:上市公司从IPO到退市的存续时间

关键挑战:数据中存在删失(Censoring)——在观察结束时,事件尚未发生。

生存时间与删失时间

\(T\)真实事件时间(如退市时间),\(C\)删失时间(如研究截止时间)。

我们实际能观测到的是:

\[Y = \min(T, C)\]

以及事件指示变量

\[\delta = \begin{cases} 1, & T \leq C \quad (\text{事件已发生}) \\ 0, & T > C \quad (\text{被删失}) \end{cases}\]

直觉理解:假设你追踪了100家上市公司,5年后有10家退市(\(\delta=1\)),90家仍在交易(\(\delta=0\),被删失)。你不能忽略这90家——它们贡献了”至少存活5年”的信息。

删失类型:右删失、左删失与区间删失

类型 定义 金融场景示例
右删失 只知道 \(T > C\) 研究截止时公司仍未退市
左删失 只知道 \(T < L\) 只知道公司在数据收集前已违约
区间删失 只知道 \(T \in (L, R]\) 只知道违约发生在两次财报之间
  • 实际分析中,右删失最为常见
  • 独立删失假设:删失机制不能携带关于事件时间的信息

生存函数与Kaplan-Meier估计量

生存函数 \(S(t)\) 定义为事件时间超过 \(t\) 的概率:

\[S(t) = \Pr(T > t)\]

Kaplan-Meier 乘积极限估计量

\[\hat{S}(t) = \prod_{t_j \leq t} \left(1 - \frac{d_j}{n_j}\right)\]

  • \(t_j\):第 \(j\) 个事件发生时间
  • \(d_j\):在 \(t_j\) 时刻发生的事件数
  • \(n_j\):在 \(t_j\) 时刻仍处于”风险集”中的个体数

Kaplan-Meier 生存曲线

案例:A股上市公司退市生存分析

我们使用本地 stock_basic_data.h5 数据,分析A股上市公司从上市到退市的生存情况。

import scipy.integrate  # 导入scipy积分模块以添加向后兼容性补丁
scipy.integrate.trapz = scipy.integrate.trapezoid  # 为旧版lifelines添加trapz别名(scipy 1.14+已移除trapz)
import pandas as pd  # 导入pandas用于数据处理
import numpy as np  # 导入numpy用于数值计算
import matplotlib.pyplot as plt  # 导入matplotlib用于可视化
import os  # 导入os模块用于跨平台路径

plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 设置中文字体
plt.rcParams['axes.unicode_minus'] = False  # 解决负号显示问题

DATA_DIR = 'C:/qiufei/data' if os.name == 'nt' else '/home/ubuntu/r2_data_mount/qiufei/data'  # 跨平台数据路径

stock_basic_data = pd.read_hdf(os.path.join(DATA_DIR, 'stock/stock_basic_data.h5'))  # 读取上市公司基本信息
stock_basic_data['listed_date'] = pd.to_datetime(stock_basic_data['listed_date'], errors='coerce')  # 转换上市日期格式
stock_basic_data['de_listed_date'] = pd.to_datetime(stock_basic_data['de_listed_date'], errors='coerce')  # 转换退市日期格式
stock_basic_data = stock_basic_data.dropna(subset=['listed_date'])  # 去除缺少上市日期的无效记录

构建生存数据集

observation_end_date = pd.to_datetime('2024-01-01')  # 设定研究观测截止日期
company_survival_records = []  # 初始化列表存储生存记录

for _, company_row in stock_basic_data.iterrows():  # 遍历每家上市公司
    listed_date = company_row['listed_date']  # 获取上市日期
    delisted_date = company_row['de_listed_date']  # 获取退市日期
    if pd.isna(delisted_date) or delisted_date >= observation_end_date:  # 未退市视为删失
        duration_years = (observation_end_date - listed_date).days / 365.25  # 计算存续年数
        event_indicator = 0  # 标记为删失(未退市)
    else:  # 已退市视为事件发生
        duration_years = (delisted_date - listed_date).days / 365.25  # 计算存续年数
        event_indicator = 1  # 标记为事件(已退市)
    if duration_years > 0:  # 排除异常负值记录
        company_survival_records.append({  # 添加生存记录
            'order_book_id': company_row['order_book_id'],  # 股票代码
            'industry': company_row.get('citics_2019_l1_name', 'Unknown'),  # 中信一级行业
            'duration': duration_years,  # 存续时间(年)
            'event': event_indicator  # 事件标记
        })  # 完成记录添加

company_survival_data = pd.DataFrame(company_survival_records)  # 转换为DataFrame
print(f'总样本量: {len(company_survival_data)}')  # 输出总样本量
print(f'退市公司数: {company_survival_data["event"].sum():.0f}')  # 输出退市数量
print(f'退市率: {company_survival_data["event"].mean():.2%}')  # 输出退市比例
总样本量: 5334
退市公司数: 237
退市率: 4.44%

A股整体退市生存曲线

Code
from lifelines import KaplanMeierFitter  # 导入Kaplan-Meier生存曲线估计器

kaplan_meier_fitter = KaplanMeierFitter()  # 实例化KM估计器
kaplan_meier_fitter.fit(  # 拟合整体生存曲线
    company_survival_data['duration'],  # 存续时间
    company_survival_data['event'],  # 事件标记
    label='A股上市公司整体'  # 图例标签
)  # 完成拟合

fig, ax = plt.subplots(figsize=(10, 6))  # 创建画布
kaplan_meier_fitter.plot(ax=ax)  # 绘制KM生存曲线
ax.set_title('A股上市公司退市生存曲线', fontsize=14)  # 设置图标题
ax.set_xlabel('上市年数', fontsize=12)  # 设置x轴标签
ax.set_ylabel('生存概率', fontsize=12)  # 设置y轴标签
ax.grid(True, alpha=0.3)  # 添加网格线
plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图形
Figure 1: A股上市公司整体退市生存曲线

按行业分组的生存曲线

Code
industry_counts = company_survival_data['industry'].value_counts()  # 统计各行业样本量
top_industry_categories = industry_counts.head(5).index.tolist()  # 选取前5大行业

fig, ax = plt.subplots(figsize=(10, 6))  # 创建画布

for ind in top_industry_categories:  # 遍历前5大行业
    mask = company_survival_data['industry'] == ind  # 筛选当前行业
    kaplan_meier_fitter.fit(  # 拟合当前行业KM曲线
        company_survival_data.loc[mask, 'duration'],  # 存续时间
        company_survival_data.loc[mask, 'event'],  # 事件标记
        label=ind  # 行业名称作为图例
    )  # 完成拟合
    kaplan_meier_fitter.plot(ax=ax)  # 绘制到同一画布

ax.set_title('不同行业A股的退市生存曲线比较', fontsize=14)  # 设置图标题
ax.set_xlabel('上市年数', fontsize=12)  # x轴标签
ax.grid(True, alpha=0.3)  # 添加网格线
plt.legend(prop={'family': 'Source Han Serif SC'})  # 设置图例中文字体
plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图形
Figure 2: 不同行业A股退市生存曲线比较

对数秩检验

对数秩检验:行业间差异是否显著?

仅靠”目测”曲线分离来下结论是危险的——我们需要对数秩检验(Log-Rank Test)

Code
from lifelines.statistics import logrank_test  # 导入对数秩检验

industry_first = top_industry_categories[0]  # 样本量最大的行业
industry_second = top_industry_categories[1]  # 样本量第二大的行业

mask_first = company_survival_data['industry'] == industry_first  # 第一行业掩码
mask_second = company_survival_data['industry'] == industry_second  # 第二行业掩码

rank_test_results = logrank_test(  # 执行对数秩检验
    company_survival_data.loc[mask_first, 'duration'],  # 行业1存续时间
    company_survival_data.loc[mask_second, 'duration'],  # 行业2存续时间
    company_survival_data.loc[mask_first, 'event'],  # 行业1事件标记
    company_survival_data.loc[mask_second, 'event']  # 行业2事件标记
)  # 完成检验

print(f'比较行业: {industry_first} vs {industry_second}')  # 输出比较的行业名
print(f'对数秩检验统计量: {rank_test_results.test_statistic:.4f}')  # 输出检验统计量
print(f'p值: {rank_test_results.p_value:.4f}')  # 输出p值
比较行业: 机械 vs 基础化工
对数秩检验统计量: 0.9421
p值: 0.3317
Figure 3

Cox 比例风险模型

Cox模型:量化风险因素的影响

Cox 比例风险模型将风险函数分解为两部分:

\[h(t|x_i) = h_0(t) \exp(x_i^T \beta)\]

  • \(h_0(t)\)基线风险函数(所有协变量为0时的基准风险)
  • \(\exp(x_i^T \beta)\):相对于基准的风险乘数

革命性设计:估计 \(\beta\)完全不需要假设 \(h_0(t)\) 的具体形式!

偏似然函数:基线风险函数的巧妙消去

补充说明:偏似然函数推导

在事件时刻 \(t_i\),个体 \(j\) 恰好发生事件的条件概率为:

\[\frac{h_0(t_i)\exp(x_j^T\beta)}{\sum_{k \in \mathcal{R}(t_i)} h_0(t_i)\exp(x_k^T\beta)} = \frac{\exp(x_j^T\beta)}{\sum_{k \in \mathcal{R}(t_i)}\exp(x_k^T\beta)}\]

\(h_0(t_i)\) 在分子分母中被完美消去!全样本偏似然函数:

\[L(\beta) = \prod_{i=1}^K \frac{\exp(x_{(i)}^T\beta)}{\sum_{k \in \mathcal{R}(t_i)}\exp(x_k^T\beta)}\]

准备Cox模型数据:加入财务风险指标

financial_statement_path = os.path.join(DATA_DIR, 'stock/financial_statement.h5')  # 财务报表路径
financial_statement_raw = pd.read_hdf(financial_statement_path)  # 读取财务报表数据

financial_statement_raw['report_period_end'] = pd.PeriodIndex(  # 将季度标识转换为报告期末日期
    financial_statement_raw['quarter'], freq='Q'  # 按季度频率
).to_timestamp(how='end')  # 取季度最后一天

financial_groups = financial_statement_raw.groupby('order_book_id')  # 按股票代码分组

提取上市初期杠杆率

initial_leverage_records = []  # 存储每家公司的初始杠杆率

for _, company_row in company_survival_data.iterrows():  # 遍历每家公司
    stock_id = company_row['order_book_id']  # 股票代码
    if stock_id not in financial_groups.groups:  # 无财报数据则跳过
        continue  # 跳过当前循环
    company_financials = financial_groups.get_group(stock_id).sort_values('report_period_end')  # 按日期排序
    basic_info = stock_basic_data[stock_basic_data['order_book_id'] == stock_id]  # 查找基本信息
    if len(basic_info) == 0:  # 无基本信息则跳过
        continue  # 跳过当前循环
    listed_date = pd.to_datetime(basic_info.iloc[0]['listed_date'])  # 上市日期
    post_listing = company_financials[company_financials['report_period_end'] > listed_date]  # 上市后的财报
    if len(post_listing) > 0:  # 存在上市后财报
        first_report = post_listing.iloc[0]  # 取第一份
        total_assets = first_report.get('total_assets', None)  # 总资产
        total_liab = first_report.get('total_liabilities', None)  # 总负债
        if pd.notna(total_assets) and total_assets > 0 and pd.notna(total_liab):  # 数据有效
            leverage = total_liab / total_assets  # 计算资产负债率
            initial_leverage_records.append({'order_book_id': stock_id, 'Initial_Leverage': leverage})  # 记录

initial_leverage_df = pd.DataFrame(initial_leverage_records)  # 转换为DataFrame
company_survival_data = company_survival_data.merge(initial_leverage_df, on='order_book_id', how='left')  # 合并杠杆率
median_leverage = company_survival_data['Initial_Leverage'].median()  # 计算中位数
company_survival_data['Initial_Leverage'] = company_survival_data['Initial_Leverage'].fillna(median_leverage)  # 填充缺失值

加入地区分类变量

province_mapping = stock_basic_data[['order_book_id', 'province']].drop_duplicates('order_book_id')  # 提取省份映射
company_survival_data = company_survival_data.merge(province_mapping, on='order_book_id', how='left')  # 合并省份

COASTAL_PROVINCES = ['广东省', '浙江省', '江苏省', '上海市', '北京市', '福建省', '山东省', '天津市']  # 沿海省份列表
company_survival_data['Region'] = company_survival_data['province'].apply(  # 生成地区分类
    lambda prov: 'Coastal' if prov in COASTAL_PROVINCES else 'Inland'  # 按省份判断沿海/内陆
)  # 完成分类

# 创建行业分组(取前5大行业)
company_survival_data['Industry_Group'] = company_survival_data['industry'].where(  # 条件赋值
    company_survival_data['industry'].isin(top_industry_categories), other='Other'  # 非前5行业标记为Other
)  # 完成行业分组

print(f'有效样本量: {len(company_survival_data)}')  # 输出样本量
print(f'沿海公司占比: {(company_survival_data["Region"]=="Coastal").mean():.1%}')  # 输出沿海占比
有效样本量: 5334
沿海公司占比: 68.9%

拟合Cox比例风险模型

from lifelines import CoxPHFitter  # 导入Cox模型拟合器

cox_model_data = company_survival_data[['duration', 'event', 'Industry_Group', 'Region', 'Initial_Leverage']].copy()  # 选取建模变量
cox_model_data['Initial_Leverage'] = cox_model_data['Initial_Leverage'].fillna(  # 填充杠杆率缺失
    cox_model_data['Initial_Leverage'].median() if cox_model_data['Initial_Leverage'].median() == cox_model_data['Initial_Leverage'].median() else 0.5  # 若中位数为NaN则用0.5
)  # 完成填充
cox_model_data['Industry_Group'] = cox_model_data['Industry_Group'].fillna('Other')  # 填充行业缺失
cox_model_data['Region'] = cox_model_data['Region'].fillna('Inland')  # 填充地区缺失
cox_model_data = pd.get_dummies(cox_model_data, drop_first=True, dtype=float)  # 转换为哑变量
cox_model_data = cox_model_data.dropna(subset=['duration', 'event'])  # 删除关键列缺失行

cox_proportional_hazards_model = CoxPHFitter(penalizer=0.1)  # 添加L2正则化防止矩阵奇异
cox_proportional_hazards_model.fit(cox_model_data, duration_col='duration', event_col='event')  # 拟合Cox模型
cox_proportional_hazards_model.print_summary()  # 输出模型完整回归结果
Table 1: Cox比例风险模型回归结果
model lifelines.CoxPHFitter
duration col 'duration'
event col 'event'
penalizer 0.1
l1 ratio 0.0
baseline estimation breslow
number of observations 5334
number of events observed 237
partial log-likelihood -1728.45
time fit was run 2026-03-12 11:35:47 UTC
coef exp(coef) se(coef) coef lower 95% coef upper 95% exp(coef) lower 95% exp(coef) upper 95% cmp to z p -log2(p)
Initial_Leverage 0.07 1.08 0.02 0.02 0.12 1.02 1.13 0.00 2.97 <0.005 8.38
Industry_Group_医药 -0.32 0.73 0.13 -0.58 -0.06 0.56 0.95 0.00 -2.37 0.02 5.83
Industry_Group_基础化工 -0.30 0.74 0.13 -0.56 -0.04 0.57 0.96 0.00 -2.24 0.02 5.33
Industry_Group_机械 -0.20 0.82 0.12 -0.45 0.04 0.64 1.04 0.00 -1.63 0.10 3.29
Industry_Group_电子 -0.19 0.83 0.14 -0.47 0.09 0.62 1.09 0.00 -1.34 0.18 2.48
Industry_Group_计算机 -0.22 0.80 0.16 -0.53 0.10 0.59 1.11 0.00 -1.34 0.18 2.47
Region_Inland 0.15 1.17 0.08 0.00 0.30 1.00 1.35 0.00 2.02 0.04 4.52

Concordance 0.66
Partial AIC 3470.89
log-likelihood ratio test 25.86 on 7 df
-log2(p) of ll-ratio test 10.87

Cox模型结果:森林图

Code
fig, ax = plt.subplots(figsize=(10, 6))  # 创建画布
cox_proportional_hazards_model.plot(ax=ax)  # 绘制系数森林图
ax.set_title('Cox模型: 财务困境风险因子', fontsize=14)  # 设置标题
ax.grid(True, alpha=0.3)  # 添加网格线
plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图形
Figure 4: 财务困境风险因子森林图

关键发现解读

风险因子 方向 经济解释
初始杠杆率 风险↑ 高负债企业在下行期更易触发退市条件
医药行业 风险↓ 刚性需求和政策扶持提供保护
内陆地区 风险↑ 融资环境和市场化程度的劣势
  • 杠杆率每增加1个单位,退市风险增加约8%
  • 内陆公司退市风险比沿海高约17%

风险函数

风险函数的定义

风险函数(Hazard Function)衡量在给定已存活到时间 \(t\) 时的瞬时风险率

\[h(t) = \lim_{\Delta t \to 0} \frac{\Pr(t < T \leq t + \Delta t \mid T > t)}{\Delta t}\]

风险函数与生存函数的关系

\[S(t) = \exp\left(-\int_0^t h(u)\,du\right)\]

函数 回答的问题
生存函数 \(S(t)\) 存活到时间 \(t\) 的概率是多少?
风险函数 \(h(t)\) 如果已活到时间 \(t\),下一瞬间发生事件的概率强度?

不同杠杆率分组的风险函数

Code
from lifelines import NelsonAalenFitter  # 导入Nelson-Aalen累积风险估计器

company_survival_data['leverage_group'] = pd.cut(  # 按杠杆率分组
    company_survival_data['Initial_Leverage'],  # 杠杆率变量
    bins=[0, 0.3, 0.6, float('inf')],  # 分组边界
    labels=['低杠杆(0-30%)', '中杠杆(30-60%)', '高杠杆(>60%)']  # 分组标签
)  # 完成分组

fig, ax = plt.subplots(figsize=(10, 6))  # 创建画布
nelson_aalen_fitter = NelsonAalenFitter()  # 实例化NA估计器

for leverage_group in company_survival_data['leverage_group'].dropna().unique():  # 遍历各分组
    mask = company_survival_data['leverage_group'] == leverage_group  # 筛选当前分组
    if mask.sum() < 2:  # 样本不足则跳过
        continue  # 跳过
    nelson_aalen_fitter.fit(  # 拟合累积风险
        company_survival_data.loc[mask, 'duration'],  # 存续时间
        company_survival_data.loc[mask, 'event']  # 事件标记
    )  # 完成拟合
    cumulative_hazard = nelson_aalen_fitter.cumulative_hazard_  # 获取累积风险
    hazard_estimate = cumulative_hazard.diff().fillna(0)  # 差分得到风险函数估计
    hazard_estimate.plot(ax=ax, label=leverage_group)  # 绘制到画布

ax.set_title('不同初始杠杆率分组的退市风险函数', fontsize=14)  # 设置标题
ax.set_xlabel('生存时间(年)', fontsize=12)  # x轴标签
ax.set_ylabel('风险函数 h(t)', fontsize=12)  # y轴标签
ax.grid(True, alpha=0.3)  # 添加网格线
plt.legend(title='杠杆率分组', prop={'family': 'Source Han Serif SC'})  # 图例
plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图形
Figure 5: 不同初始杠杆率分组的风险函数估计

Cox模型的正则化

Lasso正则化:自动变量筛选

当特征数量较多时,使用L1(Lasso)正则化自动筛选重要变量。

验证思路:在真实数据中注入5个纯噪声特征,观察正则化路径中信号与噪声的分离。

from sklearn.preprocessing import StandardScaler  # 导入标准化工具

np.random.seed(1)  # 固定随机种子
for i in range(5):  # 注入5个噪声特征
    cox_model_data[f'Noise_{i}'] = np.random.normal(0, 1, len(cox_model_data))  # 标准正态随机噪声

predictor_matrix = cox_model_data.drop(['duration', 'event'], axis=1)  # 提取协变量矩阵
predictor_matrix = predictor_matrix.select_dtypes(include=[np.number])  # 仅保留数值列

scaler = StandardScaler()  # 实例化标准化器
scaled_predictor_matrix = scaler.fit_transform(predictor_matrix)  # 标准化特征
scaled_predictor_df = pd.DataFrame(scaled_predictor_matrix, columns=predictor_matrix.columns)  # 恢复列名
scaled_predictor_df['duration'] = cox_model_data['duration'].values  # 加回存续时间
scaled_predictor_df['event'] = cox_model_data['event'].values  # 加回事件标记

Lasso正则化路径

Code
penalty_parameters = np.logspace(-4, 0, 20)  # 生成20个惩罚参数
coefficient_path = []  # 记录系数变化路径
successful_penalties = []  # 记录成功的惩罚参数

for lambda_val in penalty_parameters:  # 遍历每个惩罚参数
    try:  # 捕获可能的收敛错误
        penalized_model = CoxPHFitter(penalizer=lambda_val, l1_ratio=1.0)  # Lasso正则化
        penalized_model.fit(scaled_predictor_df, duration_col='duration', event_col='event')  # 拟合模型
        coefficient_path.append(penalized_model.params_.values)  # 记录系数
        successful_penalties.append(lambda_val)  # 记录成功参数
    except Exception:  # 收敛失败则跳过
        continue  # 跳过

penalty_parameters = np.array(successful_penalties)  # 转为数组
coefficient_path = np.array(coefficient_path)  # 转为二维数组

fig, ax = plt.subplots(figsize=(12, 7))  # 创建画布
for i, col in enumerate(predictor_matrix.columns):  # 遍历每个特征
    ax.plot(penalty_parameters, coefficient_path[:, i], label=col)  # 绘制系数路径

ax.set_xscale('log')  # x轴对数刻度
ax.set_xlabel('Lambda (正则化参数)', fontsize=12)  # x轴标签
ax.set_ylabel('系数值', fontsize=12)  # y轴标签
ax.set_title('Cox模型的Lasso正则化路径', fontsize=14)  # 标题
ax.grid(True, alpha=0.3)  # 网格线
plt.legend(prop={'family': 'Source Han Serif SC'}, fontsize=8)  # 图例
plt.tight_layout()  # 调整布局
plt.show()  # 显示
Figure 6: Cox模型的Lasso正则化路径

生存树与模型评估

生存树:自动发现非线性关系

生存树不需要线性假设,可以自动发现变量间的交互效应

from sksurv.tree import SurvivalTree  # 导入生存树模型
from sksurv.util import Surv  # 导入生存数据结构化工具
from sklearn.inspection import permutation_importance  # 导入置换重要性

survival_targets = Surv.from_arrays(  # 构造生存目标
    event=cox_model_data['event'].astype(bool),  # 事件标记
    time=cox_model_data['duration']  # 存续时间
)  # 完成构造

tree_predictors = cox_model_data.drop(['duration', 'event'], axis=1)  # 特征矩阵

survival_tree_model = SurvivalTree(max_depth=3, min_samples_split=10, min_samples_leaf=5, random_state=42)  # 限制树深防止过拟合
survival_tree_model.fit(tree_predictors, survival_targets)  # 拟合生存树

perm_result = permutation_importance(  # 计算置换重要性
    survival_tree_model, tree_predictors, survival_targets, n_repeats=10, random_state=42  # 重复10次
)  # 完成计算
feature_importance_df = pd.DataFrame({  # 构建重要性表
    'feature': tree_predictors.columns, 'importance': perm_result.importances_mean  # 特征名和重要性
}).sort_values('importance', ascending=False)  # 按重要性降序排列

生存树特征重要性

Code
fig, ax = plt.subplots(figsize=(10, 6))  # 创建画布
top_features = feature_importance_df.head(10)  # 取前10重要特征
ax.barh(range(len(top_features)), top_features['importance'])  # 水平条形图
ax.set_yticks(range(len(top_features)))  # y轴刻度
ax.set_yticklabels(top_features['feature'])  # y轴标签
ax.set_xlabel('特征重要性', fontsize=12)  # x轴标签
ax.set_title('生存树:特征重要性排名', fontsize=14)  # 标题
ax.grid(True, alpha=0.3, axis='x')  # x方向网格线
plt.tight_layout()  # 调整布局
plt.show()  # 显示
Figure 7: 生存树特征重要性排名

C-index:模型评估指标

C-index(一致性指数):评估模型预测”谁先发生事件”的能力。

Table 2: 模型C-index评估
from lifelines.utils import concordance_index  # 导入一致性指数

predicted_risk = cox_proportional_hazards_model.predict_partial_hazard(cox_model_data)  # Cox模型风险预测
cox_c_index = concordance_index(  # 计算C-index
    cox_model_data['duration'], -predicted_risk, cox_model_data['event']  # 风险取负(风险高→生存短)
)  # 完成计算

print(f'Cox比例风险模型 C-index: {cox_c_index:.4f}')  # 输出C-index
Cox比例风险模型 C-index: 0.6565
C-index 范围 含义
0.5 随机猜测(抛硬币)
0.6-0.7 中等预测能力
0.7-0.8 较强预测能力
>0.8 强预测能力

案例:股价回撤恢复时间

生存分析的金融应用:回撤恢复时间

将生存分析应用于股价回撤恢复时间(Time to Recovery):

  • 事件:股价创新高(解套/恢复)
  • 时间:从上一次创新高到下一次创新高
  • 删失:截至目前仍未创新高(仍在套牢中)

分析海康威视(002415.XSHE)的历史回撤恢复情况。

识别回撤恢复周期

stock_price_history = pd.read_hdf(  # 读取后复权股价数据
    os.path.join(DATA_DIR, 'stock/stock_price_post_adjusted.h5')  # 后复权数据路径
).reset_index()  # 重置MultiIndex

haikang_data = stock_price_history[stock_price_history['order_book_id'] == '002415.XSHE'].copy()  # 筛选海康威视
haikang_data = haikang_data.sort_values('date').set_index('date')  # 按日期排序
closing_prices = haikang_data['close']  # 提取收盘价

running_max = closing_prices.cummax()  # 计算历史累计最高价
is_new_high = (closing_prices >= running_max)  # 标记创新高日期
new_high_dates = closing_prices.index[is_new_high]  # 提取新高日期

recovery_durations = []  # 存储回撤持续天数
recovery_events = []  # 存储事件标记

if len(new_high_dates) > 1:  # 至少需要两个新高
    for i in range(len(new_high_dates) - 1):  # 遍历相邻新高
        duration_days = (new_high_dates[i+1] - new_high_dates[i]).days  # 间隔天数
        if duration_days > 5:  # 仅保留显著回撤
            recovery_durations.append(duration_days)  # 记录天数
            recovery_events.append(1)  # 已恢复
    last_gap = (closing_prices.index[-1] - new_high_dates[-1]).days  # 最后一段
    if last_gap > 5:  # 当前仍在回撤中
        recovery_durations.append(last_gap)  # 记录天数
        recovery_events.append(0)  # 删失(未恢复)

drawdown_data = pd.DataFrame({'duration': recovery_durations, 'event': recovery_events})  # 构建数据框
print(f'识别到 {len(drawdown_data)} 次显著回撤周期')  # 输出总数
识别到 38 次显著回撤周期

回撤恢复时间的KM生存曲线

Code
km_recovery = KaplanMeierFitter()  # 实例化KM估计器
km_recovery.fit(drawdown_data['duration'], drawdown_data['event'], label='回撤恢复')  # 拟合生存曲线

fig, ax = plt.subplots(figsize=(10, 6))  # 创建画布
km_recovery.plot(ax=ax)  # 绘制生存曲线

median_recovery = km_recovery.median_survival_time_  # 获取中位恢复时间
plt.axvline(median_recovery, color='r', linestyle='--', label=f'中位恢复时间: {median_recovery:.0f}天')  # 参考线

ax.set_title('海康威视:股价回撤恢复时间生存曲线', fontsize=14)  # 标题
ax.set_xlabel('天数', fontsize=12)  # x轴标签
ax.set_ylabel('未恢复概率', fontsize=12)  # y轴标签
ax.grid(True, alpha=0.3)  # 网格线
plt.legend(prop={'family': 'Source Han Serif SC'})  # 图例
plt.tight_layout()  # 调整布局
plt.show()  # 显示

prob_30d = km_recovery.predict(30)  # 30天未恢复概率
prob_180d = km_recovery.predict(180)  # 180天未恢复概率
print(f'30天内无法恢复的概率: {prob_30d:.1%}')  # 输出30天概率
print(f'半年(180天)内无法恢复的概率: {prob_180d:.1%}')  # 输出180天概率
Figure 8: 海康威视股价回撤恢复时间的生存分析
30天内无法恢复的概率: 44.7%
半年(180天)内无法恢复的概率: 13.2%

本章小结

生存分析方法体系总结

方法 类型 核心用途
Kaplan-Meier 非参数 估计生存曲线
对数秩检验 假设检验 比较两组/多组生存曲线
Cox 比例风险 半参数 量化协变量对风险的影响
生存树 非参数 发现非线性关系和交互效应
C-index 评估指标 衡量模型预测排序能力

关键假设

  • 独立删失:删失机制不携带事件信息
  • 比例风险(Cox模型):风险比不随时间变化