生存分析在电信客户流失预测中的实战:从KM曲线到Cox与随机生存森林
简介一份基于Kaggle电信客户流失数据集、利用生存分析进行流失预测的项目资源适合数据分析师和机器学习初学者深入理解客户流失建模。资源围绕真实业务场景包含客户编号、性别、月费用、合同方式等丰富字段覆盖数据清洗、生存曲线估计、Cox回归建模等核心环节可帮助读者掌握生存分析在客户留存中的实际应用。压缩包共7个文件包含交互式分析脚本、Python辅助代码、原始CSV数据、说明文档及许可证文件整体大小仅186KB结构清晰便于按需查看。目前已有567人学习下载。读者可参照其中完整分析流程直接复用代码与思路结合原数据集进行动手实践是学习生存分析与流失预测的实用参考。1. 为什么电信客户流失预测该换用生存分析用二分类模型做电信客户流失预测最多给你一个“会跑 / 不会跑”的概率但运营在续约节点上真正问的是“他第 12 个月还在不在”。Kaggle 电信客户流失数据集自带 tenure在网月数和 Churn 标签天然适合改造成生存分析把 tenure 当作观察时长、Churn 当作事件计算的是“某个时间点前不流失的概率”以及哪些因素让流失风险上升。相比单纯分类生存分析能输出风险排序 生存概率曲线 任意观察窗口的留存率比“流失概率 0.7”这类单一数字更容易接到续约动作和预警名单。这篇文章讲怎么用 lifelines 和 scikit-survival 把这个数据集做成一个可评估、可解释、可运营落地的最小生存分析流失预警方案新手能照步骤跑通熟手可以重点看删失处理、比例风险假设这些容易翻车的地方。2. 把电信流失数据组织成生存数据时间、删失与字段清洗2.1 先看懂原始字段哪个是时间哪个是事件哪个才是特征这个公开数据集有 21 列行数大概七千多一点流失标签在四分之一左右。customerID 是记录编号不能当特征tenure 列记录客户已在网月数它就是生存分析里的 durationChurn 列记录 Yes / No对应 event。要注意的是Churn 为 No 不代表“永远不会流失”只代表在数据截断时点他还活着所以应当作右删失处理而不是把它当负样本丢掉。其余列如 Contract、InternetService、OnlineSecurity、OnlineBackup、DeviceProtection、TechSupport、PaymentMethod、MonthlyCharges是特征。TotalCharges 是累计费用读进来时常是字符串因为文件里有空白必须做数值化。先看一下字段在生存分析里的角色划分这是建模前最值得做的一次全局对齐原始字段生存分析角色处理方式tenureduration数值列直接使用Churnevent映射成 0 / 1TotalCharges累计费用特征先转数值处理空值Contract / TechSupport / OnlineSecurity 等分类特征one-hot 后进入模型customerID编号不参与训练代码块按这个顺序走import pandas as pd df pd.read_csv(telco-customer-churn.csv) # 改成你实际下载路径 print(df.shape) print(df.dtypes) print(df[Churn].value_counts()) print(df[TotalCharges].isna().sum())跑完先看三个输出行数列数、每列类型、流失标签分布。如果 TotalCharges 类型不是 float 而是 object就说明文件里的空值让 pandas 自动推断成了字符串必须先清洗Churn 的 Yes / No 分布确认一下避免后面事件列方向搞反。这些检查 30 秒能做完跳过的话后面全是类型报错。2.2 TotalCharges 清洗与事件二元化清洗和事件编码可以一次做完。TotalCharges 出现空值的原因基本是未出账的新号码行数占比非常小直接删除即可也可以用 0 填充但对这里的影响差别很小。Churn 映射成 0 / 1 之后lifelines 和 sksurv 都能直接吃。df[TotalCharges] pd.to_numeric(df[TotalCharges], errorscoerce) df df.dropna(subset[TotalCharges]).copy() df[Churn_bin] (df[Churn] Yes).astype(int) print(流失比例:, df[Churn_bin].mean().round(4)) print(tenure 范围:, df[tenure].min(), df[tenure].max()) print( 前十个月客户中流失占比:, df.loc[df[tenure] 10, Churn_bin].mean().round(3), )这里有两个关键参数。第一个是to_numeric里的errorscoerce遇到不可解析的值直接置为 NaN而不是抛异常中断脚本。第二个是dropna(subset[TotalCharges])删掉的是只有 TotalCharges 为空的少数行不影响整体分布。最后那段按 tenure 分段看流失占比是生存分析里的一个小习惯早期流失是否集中会直接影响后面选观察窗口。2.3 先切分再构造生存标签避免信息泄漏很多人在完整 DataFrame 上先做特征衍生、再划分训练测试集这在普通分类里风险没那么大但在生存分析里duration 和 event 的构造顺序一旦混乱泄漏就很难查。我通常的做法是先 train_test_split再把特征和生存目标分头构造。另一个要注意的是 lifelines 和 sksurv 的输入格式不同lifelines 用 DataFrame 指定列名就行sksurv 要一个结构化数组。from sklearn.model_selection import train_test_split from sksurv.util import Surv df_train, df_test train_test_split( df, test_size0.3, random_state42, stratifydf[Churn_bin] ) # sksurv 需要的结构化生存数组第一个参数是事件列第二个是时间列 y_surv_train Surv.from_dataframe(Churn_bin, tenure, df_train) y_surv_test Surv.from_dataframe(Churn_bin, tenure, df_test) # lifelines 直接用原始列名这里抽成 numpy 数组备用 y_bin_train df_train[Churn_bin].values y_bin_test df_test[Churn_bin].values print(训练集流失比例:, y_bin_train.mean().round(4))Surv.from_dataframe的返回值是一个结构化数组第一列是事件指示符第二列是观测时间顺序不能反。这里random_state42固定随机种子stratifydf[Churn_bin]保证训练测试两边的流失比例一致。值得留意的是切分要在构造 y 之前完成一旦你在完整 DataFrame 上算过衍生列测试集信息已经被训练阶段碰过了后面评估出来的 C-index 变得乐观而且难以排查。3. 从 Kaplan-Meier 到 Cox 与随机生存森林三个模型怎么做3.1 Kaplan-Meier先看分层生存曲线再做模型Kaplan-Meier 是最能快速建立直觉的模型。它不假设分布直接用每个流失时间点重建留存概率右删失数据天然能处理。第一步先画整体曲线看早期流失的形状。from lifelines import KaplanMeierFitter import matplotlib.pyplot as plt kmf KaplanMeierFitter() kmf.fit(df_train[tenure], event_observedy_bin_train, label训练集总体) fig, ax plt.subplots(figsize(8, 5)) kmf.plot_survival_function(axax) plt.xlabel(在网月数) plt.ylabel(仍留在网的概率) plt.title(电信客户总体留存曲线) plt.show()fit(durations, event_observed)里第一个参数是 tenure第二个是 0/1 事件列。lifelines 内部会把 event_observed 为 0 的样本按右删失处理只保留他已知的存活片段不会当成“事件未发生且永远不流失”。整体曲线通常呈现前陡后缓前面几个月下滑快后面趋于平缓这说明早期流失集中续约动作的时间窗口应该往前放。分层看更有意义。按 Contract 分组画曲线是判断“哪类客户差异最明显”的最快方式fig, ax plt.subplots(figsize(8, 5)) for label, group in df_train.groupby(Contract): kmf.fit(group[tenure], event_observedgroup[Churn_bin], labellabel) kmf.plot_survival_function(axax) plt.xlabel(在网月数) plt.ylabel(仍留在网的概率) plt.title(按合约类型分层的留存曲线) plt.show()Month-to-month 的曲线会明显更早下坠而 Two year 的曲线长期在高位。这类图给业务侧讲的时候比回归系数直观得多也方便直接引出后面的 Cox 建模分组差异是真实存在的但 K-M 只能看分组没法同时容纳十几个变量。3.2 Cox 比例风险回归风险比、p 值与公式K-M 能拉开差异但要回答“差异来自哪里、每个因素贡献多少”就得做回归。Cox 比例风险模型在电信流失场景里仍是结果最易读的模型核心输出是风险比 exp(coef)含义是“某个特征每变化一个单位流失风险是原来的几倍”没有二分类那种 threshold 的争论。from lifelines import CoxPHFitter cph_cols [ tenure, Churn_bin, Contract, TechSupport, InternetService, OnlineSecurity, MonthlyCharges, ] cph_df_train df_train[cph_cols].copy() cph_df_test df_test[cph_cols].copy() cph CoxPHFitter() cph.fit(cph_df_train, duration_coltenure, event_colChurn_bin, show_progressTrue) cph.print_summary()字符串列 lifelines 会自动 one-hot不用手动处理。duration_col指定观察时长event_col指定是否流失show_progressTrue只是方便看到迭代过程数据量小也可以不开。看输出时要养成看三样东西的习惯coef 的正负、exp(coef) 的大小、p 值是否小于 0.05。coef 大于 0 表示风险更高exp(coef) 大于 1 表示流失风险放大。按常见结果Month-to-month 的 exp(coef) 会明显大于 1意思是月付客户的流失风险是两年期客户的数倍TechSupport_Yes 的 exp(coef) 小于 1说明有技术支持会降低风险。画期望留存曲线也是 Cox 的一个加分项cph.plot_partial_effects_on_outcome( Contract, values[Month-to-month, One year, Two year], ) plt.xlabel(在网月数) plt.ylabel(预期留存概率) plt.show()这个图展示的是“其他变量取均值时的留存曲线”它和 K-M 的区别是K-M 只看单一分组真实数据Cox 这是模型预测。两者放在一起可以互相验证判断模型是否明显扭曲现实。3.3 随机生存森林不用比例风险假设的排名模型Cox 好用但比例风险假设不是总能满足。随机生存森林这类非线性模型的优势是不需要 PH 假设能抓交互输出风险分直接用于排序。scikit-survival 里调用很轻量。from sksurv.ensemble import RandomSurvivalForest feature_cols [ gender, SeniorCitizen, Partner, Dependents, InternetService, OnlineSecurity, OnlineBackup, DeviceProtection, TechSupport, Contract, PaymentMethod, MonthlyCharges, ] X_train pd.get_dummies(df_train[feature_cols], drop_firstTrue) X_test pd.get_dummies(df_test[feature_cols], drop_firstTrue) rsf RandomSurvivalForest( n_estimators200, min_samples_leaf30, max_featuressqrt, random_state42, ) rsf.fit(X_train, y_surv_train) print(训练集 C-index:, rsf.score(X_train, y_surv_train).round(4)) print(测试集 C-index:, rsf.score(X_test, y_surv_test).round(4))rsf.score内置了 Harrell 一致性指数也就是 C-index随机生存森林在这个数据集上的排序能力通常和 Cox 相当或略优。参数方面n_estimators在这个数据量下 150 到 300 足够太大只增加耗时min_samples_leaf是控制过拟合的主力公开数据集上 20 到 50 是常见的比较稳的区间max_featuressqrt降低树间相关性比默认全特征更稳。如果训练 C-index 比测试高出 0.05 以上优先调大min_samples_leaf而不是增加树的数量。用pd.get_dummies(X, drop_firstTrue)而不是完整 one-hot可以防止特征列之间出现完全共线性对树模型影响不大但这里保持与 Cox 一致的习惯。4. 生存模型评估与预警名单C-index、Brier 分数与落地输出4.1 C-index排序能力的第一指标C-index 衡量的是“模型给的风险分数和真实流失时间顺序一致的程度”。0.5 等于随机0.8 已经是很强的排序能力。它最特殊的地方是能处理右删失未流失客户的真实流失时间未知但 C-index 仍然能在“已知顺序的样本对”上做统计不需要把他们排除掉。from sksurv.metrics import concordance_index_censored cph_risk_test cph.predict_partial_hazard( cph_df_test.drop(columns[tenure, Churn_bin]) ) c_index_cph concordance_index_censored( y_bin_test, df_test[tenure].values, cph_risk_test.values )[0] rsf_risk_test rsf.predict(X_test) c_index_rsf concordance_index_censored( y_bin_test, df_test[tenure].values, rsf_risk_test )[0] print(Cox C-index:, round(c_index_cph, 4)) print(RSF C-index:, round(c_index_rsf, 4))concordance_index_censored的参数顺序是事件指示、事件时间、风险分不要倒。predict_partial_hazard返回的是偏风险值越大表示流失风险越高RSF 的predict返回的风险分也是同样方向所以两个分数可以直接用于同一个评估函数。如果 Cox 和 RSF 的 C-index 差距不大优先上解释性更好的 Cox如果差距明显那就说明数据里存在 Cox 抓不住的非线性关系RSF 值得上线。4.2 Brier 分数校准质量而不是只看排序C-index 管排序不管概率是否准确。假设模型给所有人的风险分都整体偏高排序可能依然不错但业务侧想要的是“第 12 个月流失概率 30% 的客户”这类绝对概率。Brier 分数衡量预测生存概率和真实状态之间的距离越接近 0 越好它是校准能力的主要参考。import numpy as np from sksurv.metrics import integrated_brier_score times np.arange(1, 73, 6) surv_matrix rsf.predict_survival_function(X_test, timestimes) brier integrated_brier_score( y_surv_train, y_surv_test, surv_matrix, times ) print(Integrated Brier Score:, round(brier, 4))predict_survival_function(X_test, timestimes)返回一个形状为 (样本数, 时间点数) 的矩阵每一列是对应时点的留存概率integrated_brier_score要拿到同一组时间点才能做积分。这个值需要找一个基准来对比最粗的基准是全体客户的平均留存率曲线如果模型 Brier 分数比那个基准还高说明概率预测没有信息量。不同版本的 scikit-survival 里这个接口有细微差别如果times参数报错改成return_arrayTrue再把模型的事件时间点对应传进去思路是一样的。4.3 从模型输出到预警名单生存分析落地时业务侧最喜欢问的问题是“谁该被优先联系提前多久”。我会把模型输出整理成一个带风险分和留存概率的名单表这比直接给模型接口更让运营容易接手。pred_df X_test.copy() pred_df[客户编号] df_test[customerID].values pred_df[tenure] df_test[tenure].values pred_df[churn] y_bin_test pred_df[risk_score] rsf_risk_test surv_12m rsf.predict_survival_function(X_test, timesnp.array([12])) pred_df[surv_12m] surv_12m[:, 0] top100 pred_df.nlargest(100, risk_score) print(top100[[客户编号, tenure, churn, risk_score, surv_12m]].head(10))这里有两个细节。一是predict_survival_function即使只传一个时间点返回的仍然是二维数组所以取列要写[:, 0]。二是nlargest(100, risk_score)取的是风险分最高的 100 个客户他们的共同特征是在模型眼中“离开时间越来越近”。surv_12m 则给运营一个额外信息这批客户如果什么都不做12 个月后留存概率大概是多少。这个名单本身可以直接导成 Excel 或者 CSV列名用业务能看懂的字段后续续约动作就按这个顺序往下排。5. 生存分析流失预测避坑5 个高频翻车点与修正5.1 TotalCharges 读进来变成 object回归直接崩溃现象df.dtypes显示 TotalCharges 是 object用 sklearn 的模型或者 lifelines 时报“无法把字符串转成 float”有时候不报错但算出来的 mean 是空值。原因CSV 文件里存在空字符串pandas 推断整列为字符串后续任何数值运算都被静默破坏。解决加载后立刻执行pd.to_numeric(df[TotalCharges], errorscoerce)再把全为 NaN 的行删掉或补 0。这个动作放最前面比后面所有特征工程都优先因为一旦带着字符串列往下走错误出现的位置往往离源头很远排查成本极高。5.2 把 tenure 放进特征集C-index 虚高到不正常现象第一次跑模型C-index 高到接近 1训练测试都极好看起来像是找到了完美模型。原因tenure 是生存分析里的 duration 列也就是目标变量的一部分。把它作为特征喂给模型等于让模型用标签本身去预测标签属于典型的标签泄漏。解决从feature_cols里拿掉 tenure也拿掉所有由 tenure 派生的列比如 tenure 的桶、tenure 取整、tenure 除以 12 这类衍生字段。凡是“时间长度”类变量都要问一句它是不是就是 duration 的另一种写法。5.3 Cox 比例风险假设没做检验系数可能失真现象Cox 模型跑通之后调用cph.check_assumptions(cph_df_train, p_value_threshold0.05)输出里一片警告大多数变量的 Schoenfeld 检验 p 值小于 0.05。原因Cox 假设风险比在时间轴上恒定但月付、年付、两年期的客户其流失风险曲线形状差异很大全球回归系数在时间上做了平均自然不满足假设。解决最直接的办法是分层把合同类型放进去让基础风险率随合同类型变化cph.fit(cph_df_train, duration_coltenure, event_colChurn_bin, strata[Contract])strata[Contract]允许不同合同类型拥有不同的基线风险函数而不是强迫它们共享一条基线。如果分层之后检验仍然不满足就换随机生存森林或者 AFT 模型。这里不要因为“Cox 很经典”就忽略检验生成模型时这步是必选项不是加分项。5.4 把 C-index 当“命中率”模型和运营都在继续拔高预期现象有人看到 C-index 0.8 就说预测准确率 80%实际拿预警名单去对真实流失情况发现命中率差很多。原因C-index 只衡量排序一致性不衡量绝对预测是否准确。它在类别不平衡得情况下尤其容易被误读——大部分样本的风险分数值集中少数极端值就能把分数拉高。解决把 C-index 定位成“排序能力”把 Brier 分数定位成“概率校准”对外沟通用“危险客户排序质量”之类的说法不用“预测准确率 X 成”。最重要的是在预警名单里定期回收命中情况统计实际流失客户中有多大比例出现在名单里这个叫召回比只看 C-index 更接近业务真实感受。5.5 把右删失当事件对未流失客户硬打 1现象为了统一评估口径有人把 Churn0 的客户 tenure 也当作“事件发生的时间”结果留存曲线明显向下偏移Cox 风险比混乱。原因Churn0 表示观察结束时客户仍然在网这是一个右删失观测不是一个“在 tenure 月那天流失”的事件。把它当事件处理等于假设每个在网客户都在自己 tenure 月的那一天必然流失这个假设显然不成立。解决event 列只填“已经流失”的 1duration 列存“观察到该状态时的月数”。用Surv.from_dataframe时第一个参数是事件列第二个是时间列y_surv Surv.from_dataframe(Churn_bin, tenure, df)这里最容易犯的错是把两个列名顺序写反文档里这类报错通常很隐蔽可能直到要评估 Brier 分数时才暴露所以构造完之后打印前 20 条y_surv用肉眼确认一下事件列和时间列没有调换。6. 风险分层与预警时效把模型输出做成续约动作生存分析相对二分类的优势是能把“时间”直接带进输出。最终落地我一般做两件事分层给标签、按留存概率给提前量。第一步是风险分层。纯风险分直接看月付和两年期的客户基线差异太大会导致阈值错位用同一个分位点切两年期客户很难进高危名单但他们在两年里的任何一个时点都可能流失。所以我按合同类型分组再切四分位pred_df[contract] df_test[Contract].values pred_df[risk_group] ( pred_df .groupby(contract)[risk_score] .transform(lambda x: pd.qcut(x, 4, labels[低, 中, 高, 极高])) ) surv_6m rsf.predict_survival_function(X_test, timesnp.array([6])) pred_df[surv_6m] surv_6m[:, 0] recall_list pred_df[(pred_df[surv_6m] 0.75) (pred_df[churn] 0)] print(recall_list[[客户编号, contract, tenure, surv_6m, risk_group]].head())pd.qcut按单个合同组内部分位数切分避免了跨合同比分的失真surv_6m则筛选出“6 个月留存概率低于 0.75 但当前还没流失”的客户这部分才是真正值得提前介入的名单。两套标准叠加后输出的是一个可以直接匹配续约任务的表单合同类型、在网月数、6 个月留存概率、风险分组。这个方向值不值得投入我的判断是值得。如果团队正打算从“哪些客户会走”升级到“什么时候该出手”生存分析是从方法到评估都更完整的一套方案不需要砍掉原有二分类模型只需要在它旁边增加一条时间维度的输出。我最初做的时候最大的失误恰恰不是模型复杂度不足而是没有先把 tenure 和 Churn 之间的删失关系对运营讲清楚导致模型结果被当成普通分类概率使用后来把时间窗口和风险分组直接放进名单、让业务按名单执行续约反馈就完全不一样了。建议你们也这样收口模型最后交付的不是一张概率表而是一份有时间、有排序、有动作提示的客户名单。希望帮到你。本文还有配套的精品资源点击获取