用生存分析与Cox模型预测电信客户流失:从KM曲线到风险分落地

发布时间:2026/9/14 4:09:37
用生存分析与Cox模型预测电信客户流失:从KM曲线到风险分落地 简介基于Kaggle电信客户流失数据集利用生存分析进行客户流失预测案例适合数据科学初学者和从业者理解用户生命周期建模与常见二分类思路不同这里从生存时间角度刻画流失风险更能体现用户随时可能流失的动态过程。项目中完整覆盖从数据字段梳理、特征构造到风险建模的分析链路结合tenure、合同类型、月费用等变量呈现生存分析在运营场景中的应用方式。分析过程完整记录在ipynb笔记本中py脚本补充模块化函数README提供使用说明csv原始数据可直接加载复现全包共7个文件压缩后仅186KB规模轻量、结构清晰。目前已有566人学习下载适合希望获得真实案例参考的读者。拿到后可按Notebook顺序逐步运行结合脚本和说明快速复现流失预测流程再针对自身业务调整特征完成迁移应用。1. 用生存分析做流失预测先回答“什么时候走”再决定“怎么挽留”运营一个三百万用户的电信套餐最头疼的不是“谁这个月要跑”而是“哪些人未来三个月最大概率跑”——二者的区别在于时间。Kaggle 上最常被拿来做流失预测的电信客户流失数据集Telco Customer Churn天生就是一份生存分析样本tenure 记录每个客户在网多少个月Churn 记录他是否已流失而流失预测的难点恰恰又在于大部分客户在观察期内还没走。生存分析处理的就是这类“事件是否发生 多久发生”的数据它把未流失用户当作右删失样本而不是简单打上 0 标签比起二分类模型多保留了一层时间信息。这篇文章就把这个数据集用生存分析整个跑一遍从下载、清洗、KM 曲线到 Cox 模型最后给出一个可以直接落到挽留运营的风险分输出。不管你是在 Kaggle 上交一份 Notebook还是想把这套方法带进用户增长团队照着做都能跑通。2. 电信客户流失数据集准备从 Kaggle 下载到定义 duration 与 event2.1 用 Kaggle CLI 下载电信客户流失数据集Kaggle 上这类公开数据集最省事的获取方式是命令行 API前提是你已经在 Kaggle 账号里生成了 API token并把它放在~/.kaggle/kaggle.json。我一般先把工作目录建好再执行以下命令mkdir -p telco_survival cd telco_survival # 下载电信客户流失数据集压缩包 kaggle datasets download -d blastchar/telco-customer-churn # 解压得到 Telco-Customer-Churn.csv unzip -o telco-customer-churn.zip ls -lh Telco-Customer-Churn.csv-d后面跟的是数据集在 Kaggle 上的标识blastchar/telco-customer-churn是这类教程里出现频率最高的电信流失数据源。如果本机没配 kaggle.json也可以直接在数据集页面点 Download拿到压缩包再解压效果一样。下载完成后先不要急着建模打开 CSV 确认一下列名和行数这一步能避免后边所有代码因为字段名对不上而报错。这份数据一共 7043 行、21 列量级不大pandas 直接读入内存没有任何压力。它适合做生存分析入门练习是因为里面同时包含了时间轴、事件标记和一堆可能影响流失的协变量不需要额外拼接其他数据源。2.2 把 Churn 变成事件标记右删失是生存分析的关键生存分析的数据结构比普通监督学习多两个固定字段duration表示“从进入观察到事件发生或观察结束的时间”event表示“观察期内事件有没有发生”。在电信流失场景里tenure天然就是 durationChurn则是 event 的来源。import pandas as pd df pd.read_csv(Telco-Customer-Churn.csv) print(df.shape) # (7043, 21) print(df[tenure].describe()) # duration在网月数作为生存分析的时间轴 df[duration] df[tenure].astype(int) # event1已流失0观察期内未流失 df[event] (df[Churn] Yes).astype(int)这里最需要理解的是event0的含义。普通二分类会把 ChurnNo 当成“负样本”但生存分析认为这些客户只是“还没流失”。假设观察期在第 73 个月截断tenure73 且 ChurnNo 的客户可能下个月就走只是在数据采集那一刻还没发生。这种样本叫右删失right censoring。lifelines 等库在估计生存函数时会把这些右删失样本计入“截至该时点还活着的人数”但不会把它们当成在那一天“死亡”。如果强行把删失样本当负样本丢给 XGBoost时间信息会被压缩成“是否”两态模型也就无法回答“多久之后流失”。这恰恰是用生存分析做流失预测和传统分类最本质的区别。2.3 洗数据的三处硬伤TotalCharges、tenure0 与类别编码电信客户流失数据集虽然干净但有几个字段直接喂给生存分析模型会出问题。第一处是TotalCharges它里面有一小部分空字符串读进来后被识别成 object 类型需要先转数值再处理缺失。第二处是tenure0这类客户要么是当月开通当月退订要么是刚开通还没到账期其TotalCharges必然为空。# TotalCharges 转数值空值来自 tenure 接近 0 的未出账客户 df[TotalCharges] pd.to_numeric(df[TotalCharges], errorscoerce) df[TotalCharges] df[TotalCharges].fillna(df[MonthlyCharges]) # 检查 tenure0 的分布 zero_tenure df[df[duration] 0] print(zero_tenure[event].value_counts()) # 生存分析要求 duration 0否则 KM 曲线和风险集定义会出现边界问题 df df[df[duration] 0].copy()fillna(df[MonthlyCharges])的意思是未出账客户的总费用近似为当月月费总比填 0 更符合“已经消费了一个月”的事实。tenure0的样本总共只有十几个直接剔除对整体结果影响可以忽略但如果不剔除KaplanMeierFitter在时间 0 点的生存概率会瞬间下跌后续所有估计都会被这个伪事件干扰。第三处硬伤是类别变量编码。lifelines 的 CoxPHFitter 要求协变量全是数值所以需要把 Contract、InternetService、PaymentMethod 等字符型字段做独热编码。这里建议drop_firstTrue避免多个虚拟变量之间产生完全共线性categorical_cols [ gender, Partner, Dependents, PhoneService, MultipleLines, InternetService, OnlineSecurity, OnlineBackup, DeviceProtection, TechSupport, StreamingTV, StreamingMovies, Contract, PaperlessBilling, PaymentMethod ] df pd.get_dummies(df, columnscategorical_cols, drop_firstTrue, dtypeint) # 列名里有空格会影响后续输出统一替换成下划线 df.columns df.columns.str.replace( , _, regexFalse) # 建模用的数据框只保留协变量 duration event feature_cols [c for c in df.columns if c not in [customerID, Churn, tenure]] df_model df[feature_cols].copy() print(df_model.shape)get_dummies之后Contract_Month-to-month这一列实际含义是“是否月付合同”Contract_Two_year是不是被 drop 掉取决于drop_first的顺序。后面 Cox 模型输出的风险比全部是相对被 drop 掉的基准组而言比如“月付合同相对一年合同的流失风险是几倍”。这一点在下文解读结果时非常重要不要只看 p 值就下结论。3. Kaplan-Meier 生存曲线流失的时间分布怎么画出来3.1 S(t) 的含义从历史概率推导未来流失占比生存分析里最基础的输出是生存函数 S(t)它表示“客户在 t 时刻仍然没有流失的概率”。注意这里“生存”这个词在电信场景下的语义转换客户好好待在网就是生存客户取消套餐就是事件发生。所以 S(t) 实际描述的是“在网留存率随时间衰减的曲线”。Kaplan-Meier 估计是一种非参数方法它不假设 S(t) 服从某种固定分布而是在每个发生事件的时点上用“当前风险集里还剩下的人数”作为分母计算该时点的条件流失概率再把这些条件概率连乘得到累计生存概率。它的核心公式写出来就是S(t) Π (1 - d_i / n_i)其中n_i是时点t_i之前仍然在网的人数d_i是该时点流失的人数。因为涉及到连乘删失样本会从后续所有时点的n_i里消失但它不会改变它被删失之前那些时点的分母。这就是右删失被正确处理的方式。那么中位流失时间也很好理解当 S(t) 首次小于等于 0.5 时对应的最小 t表示“一半客户在这个时点之前已经流失”。这个指标比“平均流失时间”更稳健因为流失时间的右尾通常很长均值容易被少数超长在网客户拉高。3.2 用 lifelines 跑通 KM 曲线和中位流失时间lifelines 是 Python 生态里最顺手的生存分析库它把 KM 估计封装成了几行代码。先看整体流失曲线from lifelines import KaplanMeierFitter kmf KaplanMeierFitter() kmf.fit(df_model[duration], event_observeddf_model[event]) kmf.plot_survival_function() print(整体中位在网月数, kmf.median_survival_time_)fit的第一个参数是durations也就是每个客户的 durationevent_observed是前面构造的 0/1 事件标记。kmf.median_survival_time_返回的是中位生存时间在电信数据里就是“一半客户流失时对应的在网月数”。跑完你会发现整体曲线从 1 开始随着月份增加阶梯式下降曲线尾部因为风险集人数越来越少台阶会越来越密置信区间也会变宽。这里有个常见误解kmf.predict(t)返回的是“在 t 时刻仍然在网的概率”不是“流失概率”。如果业务上要的是“未来流失概率”要用1 - S(t)。很多从二分类转过来的同学会在这地方犯迷糊后面第 5 章讲风险分输出时还会再强调一次。3.3 按 Contract 分层Log-rank 检验验证差异是否显著整体 KM 曲线只能回答“全部客户流失有多快”但流失预测更关心不同客群的曲线差异。最直观的做法是按 Contract 字段分层画三条曲线import matplotlib.pyplot as plt from lifelines import KaplanMeierFitter fig, ax plt.subplots(figsize(8, 6)) for contract in [Month-to-month, One year, Two year]: mask df_model[fContract_{contract}] 1 kmf_tmp KaplanMeierFitter() kmf_tmp.fit( df_model.loc[mask, duration], event_observeddf_model.loc[mask, event], labelcontract ) kmf_tmp.plot_survival_function(axax) plt.ylabel(在网留存概率 S(t)) plt.xlabel(在网月数) plt.show()注意这里df_model[fContract_{contract}]是独热编码后的列经过列名空格替换实际列名是Contract_Month-to-month这种带下划线的形式。运行后可以看到月付合同的曲线明显更低更陡说明这类客户“走得早且走得多”。光看图不够还需要一个统计检验来支撑“差异显著”这个结论。Log-rank 检验是生存分析里最常用的组间比较方法它逐时点比较两组的实际事件数和期望事件数最终给出一个卡方统计量和 p 值。对月付合同和一年合同做两两比较from lifelines.statistics import logrank_test mask_mtm df_model[Contract_Month-to-month] 1 mask_oy df_model[Contract_One_year] 1 result logrank_test( df_model.loc[mask_mtm, duration], df_model.loc[mask_oy, duration], event_observed_Adf_model.loc[mask_mtm, event], event_observed_Bdf_model.loc[mask_oy, event] ) print(p-value:, result.p_value)Log-rank 检验的零假设是两组的生存函数相同。p 值远小于 0.05 就拒绝零假设说明合同类型对流失时间的影响不是随机波动。这个检验在写 Kaggle Notebook 时是很好的加分点因为它把“看图说话”升级成了“统计显著”。但要注意Log-rank 只适合分组比较放进多个连续变量就无能为力下一步需要上 Cox 回归。4. Cox 比例风险模型把流失影响因素和风险分一起算出来4.1 CoxPHFitter 的最小拟合代码与 print_summary 解读KM 曲线把流失时间分布讲清楚了但它回答不了“哪些因素在影响流失速度”。Cox 比例风险模型是这一步的标准选择。它的基本形式是h(t|X) h0(t) * exp(beta1*X1 beta2*X2 ...)左边是“客户在 t 时刻的瞬时流失风险”h0(t)是基准风险函数右边的指数部分把所有协变量对风险的倍率关系压缩成一组系数。模型不估计h0(t)的具体形状只估计系数 beta所以它属于半参数模型。优势在于你不用赌生存时间服从指数分布还是 Weibull 分布劣势在于它假设协变量对风险的影响是倍数式的也就是比例风险假设。在电信客户流失数据集上拟合 Cox 模型代码非常短from lifelines import CoxPHFitter cph CoxPHFitter(penalizer0.1) cph.fit( df_model, duration_colduration, event_colevent, show_progressTrue ) cph.print_summary(decimals3)penalizer0.1是 L2 正则的强度参数。电信流失数据经过独热编码后某些列之间仍存在较强相关性稍微加一点惩罚可以抑制系数爆炸也让print_summary不出现共线性警告。duration_col和event_col分别指定时间轴和事件标记。print_summary会输出一张表每行对应一个协变量主要看三列coef是 beta 的估计值exp(coef)是风险比p是显著性检验结果。4.2 风险比 HRContract、InternetService、tenure 各影响多少风险比Hazard Ratio是 Cox 模型输出里最有业务解释价值的量。exp(coef) 1.5表示该变量每增加一个单位流失风险变为原来的 1.5 倍exp(coef) 1则是保护因素。特别注意分类变量经过独热编码后“增加一个单位”的意思是“从基准组切换到该组”。在电信客户流失数据集的常见复现结果里Contract_Month-to-month的exp(coef)通常在 3 上下意思是月付合同的流失风险大约是一年合同客户的 3 倍Contract_Two_year的系数则显著小于 1说明长期合同对留存有强保护作用。InternetService_Fiber_optic的exp(coef)通常大于 1光纤用户反而更容易流失——这不是光纤不好而是光纤套餐价格更高用户对账单更敏感。这类反直觉发现正是生存分析模型的价值它把时间维度纳入之后风险比解释的其实是“流失速度”而不是“最终是否流失”。输出结果里第二行是显著性检验p 值大于 0.05 的变量说明其风险比和 1 没有显著差异可以考虑从模型里剔除。不要看着coef的绝对值排序变量重要性正确做法是比较exp(coef)与 1 的距离同时结合置信区间宽度一起判断。4.3 验证生存预测C-index 和校准曲线模型拟合完之后不能只看风险比是否符合直觉还要回答“这个模型预测得准不准”。生存分析里最常用的判别指标是 C-index可以理解为考虑了删失的 AUC。它的含义是随机抽一对可比较客户模型给风险更高那位打的风险分也更高这个顺序一致性占比就是 C-index。from lifelines.utils import concordance_index # predict_partial_hazard 输出的是风险分越大代表流失风险越高 risk_scores cph.predict_partial_hazard(df_model) # lifelines 的 concordance_index 期望越大的 score 对应越早的事件 # 按风险分方向评估取值越接近 1 越好 c_index concordance_index( df_model[duration], risk_scores, df_model[event] ) print(C-index:, c_index)电信流失数据集上单用原始特征构建的 Cox 模型 C-index 通常在 0.8 左右这已经是不错的结果。但要注意C-index 只衡量排序对不对不衡量预测的风险概率绝对值准不准。也就是“前 10% 的客户确实比后 10% 更容易流失”可以得高分但“模型说 12 个月流失率 40%实际只有 15%”这种系统性偏差 C-index 看不出来。校准问题需要用校准曲线检查。常见做法是把预测的生存概率分箱再和实际 KM 估计对比。lifelines 提供了现成接口from lifelines.calibration import survival_probability_calibration # 检查 t12 个月时预测概率的校准程度 survival_probability_calibration( cph, df_model, t012, event_observedevent )校准曲线越贴近对角线说明预测概率越可信。这一项在 Kaggle 提交里容易出彩因为在大多数人只报 C-index 或 AUC 时你能同时展示校准能力会让整个分析看起来更完整。t0是评估时点这里选 12 个月对应“未来一年流失概率”这种业务语言如果你的挽留动作是季度性的也可以改成 3 或 6。4.4 交叉验证与比例风险假设的边界交叉验证在生存分析里比普通分类要麻烦不能直接train_test_split后按随机种子打乱数据。因为生存数据里有删失和时间结构随机划分会破坏“风险集”的完整性。lifelines 提供了k_fold_cross_validation函数但它默认也是随机折适合用来估计 C-index 的稳定性from lifelines.utils import k_fold_cross_validation scores k_fold_cross_validation( cph, df_model, duration_colduration, event_colevent, k5, scoring_methodconcordance_index ) print(5折 C-index, scores)如果业务上要求严格的时间外验证更合理的做法是按进入观察期的时间切分数据比如用前 70% 十个月内开始观察的客户做训练后 30% 做验证。但电信客户流失数据集的 tenure 是客户自己的在网时长不是观察期日历时间做时间切分需要额外构造观察窗口这里按下不表。另外一件必须检查的事是比例风险假设。Cox 模型要求协变量对风险的影响随时间保持恒定比如“月付合同的流失风险是年付合同的 3 倍”这个 3 倍关系不能在第 6 个月变成 2 倍、第 12 个月又变成 4 倍。lifelines 有现成的假设检验方法cph.check_assumptions(df_model, p_value_threshold0.05)运行后如果输出大量警告说明某些变量随时间变化明显。常见处理方式有两个一是把违反假设的变量放进strata参数做分层二是改用CoxTimeVaryingFitter建模时变协变量。对电信流失这个数据集Contract有时会轻微违反比例风险假设因为它对流失的影响确实在前期更强烈。如果只是想拿到一个可用风险分penalizer0.1加上strata已经足够稳健不必把模型搞得过度复杂。5. 把生存分析结果变成挽留动作12 个月流失风险分与排序输出生存分析模型跑完最终要落到业务动作上。最实用的技巧是把 Cox 模型的输出转换成“未来 12 个月流失风险分”再按分数排序输出给 CRM 系统。注意这里的概率转换方向predict_survival_function返回的是“仍然存活”的概率也就是继续在网概率用 1 减去它才是流失概率。# 预测每个客户未来 12 个月的生存概率 surv cph.predict_survival_function(df_model) # surv 的行索引是时间点取第 12 个月所在的列 surv_12m surv.loc[12] # 1 - S(12) 12个月内累计流失概率 df_model[churn_risk_12m] (1 - surv_12m).round(4) # 方便业务方定位客户 df_model[customerID] df[customerID] # 按流失风险降序输出前 10% 就是最该优先干预的名单 priority df_model[[customerID, churn_risk_12m, duration, event]] \ .sort_values(churn_risk_12m, ascendingFalse) priority.to_csv(churn_priority_12m.csv, indexFalse) print(priority.head(20))这里有一个小坑surv.loc[12]要求时间轴上有 12 这个索引。如果数据里的 duration 没有恰好等于 12 的观测点索引会跳过 12。更稳妥的方式是用surv.T[12]或者cph.predict_survival_function(df_model, times[12])surv_12m cph.predict_survival_function(df_model, times[12]).T.iloc[:, 0] df_model[churn_risk_12m] (1 - surv_12m)第二种写法明确指定只预测时间点 12不依赖原始数据里是否正好存在 12 个月的观测值。拿到排序结果后运营动作通常按风险分层执行风险分超过 0.8 的客户走“高优先级挽留”看是否在合约到期前一个月推送续约优惠风险分在 0.5 到 0.8 之间的客户走“标准触达”发账单提醒或套餐降级推荐低于 0.5 的客户不进干预名单。这个分层阈值不是模型算出来的而是根据挽留成本和高价值客户定义来定的。最后可以把普通分类和生存分析的输出做一个对照传统二分类模型给出的是“流不流失”而 Cox 模型给出的是“未来多久流失”。同样是churn_risk_12m排第一名的客户前者只知道他危险后者还能告诉你“按照当前特征他的预期剩余在网时间不足两个月”。这个信息差直接决定能出什么样的挽留策略——是发一张小额优惠券还是直接派资深客服一对一沟通。把predict_survival_function输出接到 CRM 的客户生命周期字段里每次批跑后更新一次风险排序就是一套不依赖外部平台的轻量流失预警系统。本文还有配套的精品资源点击获取