简介这份资源是一套基于Kaggle电信客户流失数据集的生存分析实战项目适合数据科学、机器学习初学者及希望掌握客户流失预测方法的学习者。资源以客户是否流失为目标通过tenure使用月份数等特征运用生存分析方法建模并结合合同方式、月费用等字段进行多维探索。压缩包共7个文件包含Jupyter Notebook分析脚本、Python辅助代码、原始CSV数据表、说明文档及授权文件等整体仅186KB轻量易用便于快速复现分析过程。目前已有567人浏览学习内容上覆盖从数据理解、特征梳理到生存模型构建与结果解读的完整链路读者可直接参考Notebook中的分析思路结合自身数据练习。这份资料尤其适合用于理解生存分析在商业客户流失场景中的落地方式提升实际项目分析能力。1. 用生存分析做电信客户流失预测先回答“什么时候走”大部分做流失预测的人一上手就是逻辑回归、XGBoost把问题定义成“这个用户会不会流失”。但运营同学拿到这个名单后往往还会追问一句他大概什么时候会走如果预测出来十个高流失用户有两个其实还能再留一年你提前用大额优惠券去挽留反而是亏损。电信客户流失这个Kaggle数据集正好可以换一种思路来做把tenure字段当作生存时间把Churn当作事件发生用生存分析回答“用户能活多久”“哪些因素在加速死亡”。这套思路同样适用于会员到期、贷款违约、续费提醒只要你手里有“起始时间 结束时间 是否发生事件”三列数据。我这次在Kaggle上找到的是BlastChar的Telco Customer Churn数据集压缩包里是一个完整分析工程包含原始CSV、Jupyter Notebook、说明文件和License。下面我按自己复现的完整过程拆解一遍从数据清洗讲到KM曲线、log-rank检验再到Cox比例风险模型最后给出几条我踩过的坑和风险分层的进阶玩法。2. 数据准备与 KM 曲线从 tenure 到生存函数清洗的两个关键列2.1 从 Kaggle 下载与读入zip 里文件的作用压缩包解压之后核心文件只有两个WA_Fn-UseC_-Telco-Customer-Churn.csv是原始数据ipython 分析.ipynb是别人跑过的分析流程。我先说结论这份zip适合两类人一类是刚接触生存分析、想拿一份干净数据练手的学习者另一类是想参考别人怎么组织生存分析代码的从业者。数据本身是7043行、21列每条记录对应一个电信用户。import pandas as pd df pd.read_csv(WA_Fn-UseC_-Telco-Customer-Churn.csv) print(df.shape) print(df.dtypes) print(df.isnull().sum().sum())df.shape会返回(7043, 21)确认行数和列数。df.dtypes的目的不是看一眼就过而是要重点确认TotalCharges这一列的类型我下面会专门讲这个坑。df.isnull().sum().sum()是快速扫描全表空值总数这个数据集在多数列上没有空值但正好有一个列是特例。2.2 TotalCharges 类型修复与 tenure0 的取舍先说结论这个数据集里最坑的列是TotalCharges。它表面上是数值型但 Pandas 在读入时把它推断成了object类型因为里面混了空字符串。如果你直接拿去做训练CoxPHFitter会直接报类型错误而且df.describe()也完全不显示这一列的统计量。df[TotalCharges] pd.to_numeric(df[TotalCharges], errorscoerce) print(df[TotalCharges].dtype) print(df[df[TotalCharges].isnull()][tenure].value_counts())pd.to_numeric加errorscoerce的作用是把所有无法解析的值强制变成NaN而不是让程序中断。转换之后你再看这一列会发现有11个空值而这11个用户的 tenure 恰好全是0。这说明这批用户当期开卡、当期就没了缴费记录属于还没真正进入观察期。对于这11行常见做法是直接删除因为它们既没有生存时间也没有累积消费留着只会污染后面的 KM 曲线首点。tenure0 的问题还要多说一句。生存分析的起点是“进入观察”tenure 表示的是“已经使用了多少个月”从业务上理解一个 tenure0 的用户是在当月刚入网。如果把0当作生存时间放进 KM 曲线它会在时间轴上形成一个瞬间掉落的台阶导致整条曲线第一点就大幅下探。我一般建议直接删掉这11行让时间轴从1开始如果你想保留也可以把生存时间统一替换成tenure 1这两种做法在后续建模里结果差异不大。接下来是二值化 Churn 列。原始数据里它是Yes/No字符串而 lifelines 的event_observed参数要求传入0/1整数不转换的话拟合时会报“event column must be binary”这类错误。df[Churn] df[Churn].map({Yes: 1, No: 0}) df[gender] df[gender].map({Male: 0, Female: 1}) df df[df[TotalCharges].notna()].copy() print(df[Churn].value_counts())Churn1表示在观察期内发生了流失事件Churn0表示右删失也就是在数据收集截止时用户还在网。右删失这个概念是生存分析理解上的第一道坎没有流失不意味着他不会流失只是你还没看到他流失。传统二分类模型会把这一部分人一律当负样本而生存分析把这个信息拆成“时间 状态”信息量完全不同。2.3 画第一条 KM 曲线lifelines 五步出图前期清洗做完就可以画整个数据集的第一条生存曲线了。这一条曲线回答的问题是这批用户里随机挑一个人他能活过第t个月的概率是多少。from lifelines import KaplanMeierFitter import matplotlib.pyplot as plt kmf KaplanMeierFitter() kmf.fit( durationsdf[tenure], event_observeddf[Churn], labelAll Customers ) kmf.plot_survival_function() plt.title(KM Survival Curve - Telco Churn) plt.xlabel(Tenure (months)) plt.ylabel(Survival Probability) plt.show()KaplanMeierFitter是 lifelines 里最基础的估计器durations传入的就是生存时间列tenureevent_observed传入的是是否流失。label只是图例名。跑完这段代码你会得到一条从1.0逐渐下降的阶梯曲线下降的陡峭程度就是流失速度的直观体现。这条曲线整体下降很快前12个月掉得尤其明显到第70个月左右存活率只剩两成上下。这意味着这个数据集里的整体留存并不乐观也间接说明混在一起看曲线是看不出业务动作差异的必须分组拆开。关于kmf.fit之后的对象有一个常用属性值得记住print(kmf.median_survival_time_) print(kmf.survival_function_.head())median_survival_time_返回的是中位生存时间也就是存活率首次跌破50%的那个月份。survival_function_是一个 DataFrame每一行对应一个时间点上的存活率估计值你可以直接把它导出按月份生成留存表这在给运营写周报的时候可以直接用。3. 分组对比与 log-rank 检验合同类型到底差多远3.1 分组 KM 曲线三条合同曲线的差距整体曲线只能给你一个基准感真正有价值的信息藏在分组里。这个数据集里最天然的分组变量是Contract它有三个取值Month-to-month按月付、One year一年期、Two year两年期。我的经验是拿到生存数据第一件事不是急着上模型而是先按两三个业务变量画分组曲线。import matplotlib.pyplot as plt from lifelines import KaplanMeierFitter plt.figure(figsize(8, 5)) for contract in [Month-to-month, One year, Two year]: mask df[Contract] contract kmf KaplanMeierFitter() kmf.fit( durationsdf.loc[mask, tenure], event_observeddf.loc[mask, Churn], labelcontract ) kmf.plot_survival_function() plt.title(Survival Curves by Contract Type) plt.xlabel(Tenure (months)) plt.ylabel(Survival Probability) plt.legend() plt.show()这里用一个for循环分别对三个子集拟合 KM 曲线然后把三条曲线画在同一张图上。第一次跑完你就能直观看到按月付用户的曲线从第1个月就开始快速下滑存活率在10个月附近就已经跌破五成一年期用户曲线明显平缓两年期用户曲线几乎是条水平线72个月后仍有六成以上存活。三组曲线之间的间距非常大说明合同期限是流失预测里最强的一个单变量。3.2 log-rank 检验差异是不是统计显著肉眼看到差距还不够严谨。做数据分析会遇到一个问题三条曲线分开看有高有低但这个差距可能是随机波动。生存分析里对应的假设检验叫 log-rank 检验零假设是“两组生存曲线完全一致”。如果 p 值小于0.05就有理由拒绝零假设认为两组存在真实差异。from lifelines.statistics import logrank_test res_12 logrank_test( durations_Adf[df[Contract] Month-to-month][tenure], event_observed_Adf[df[Contract] Month-to-month][Churn], durations_Bdf[df[Contract] One year][tenure], event_observed_Bdf[df[Contract] One year][Churn] ) print(res_12.p_value)logrank_test接受两组各自的durations和event_observed返回结果里的p_value就是检验显著性。我第一次跑这个检验的时候p_value已经小到打印出来是1e-300这个量级也就是差异极其显著根本不可能是随机波动。要注意的是log-rank 检验适合处理两组数据如果分三组以上用pairwise_logrank_test两两比较否则分组多了容易出多重比较问题。3.3 从生存曲线反推运营动作中位生存时间生存曲线不是画出来好看的它直接对应运营动作。中位生存时间的意思是这一组用户里流失掉一半需要多少个月。按月付用户的中位生存时间大约在10个月左右这意味着如果你不干预这批用户在一年内就会走掉一半。运营在这里的动作应该是“在第三个月和第六个月设两个留存节点”而不是等到第10个月才想起来发优惠券。grouped_median df.groupby(Contract).apply( lambda x: KaplanMeierFitter().fit(x[tenure], x[Churn]).median_survival_time_ ) print(grouped_median)这段代码用groupby把三条子集的median_survival_time_一次性算出来。输出结果里Month-to-month那条通常是两位数的月份Two year那条返回的往往是inf或者NaN因为它的存活率从来没有跌到过50%整个观察期内都没流失一半人。遇到inf不要觉得是bug这正是“两年合约用户太难流失”的量化证据比任何描述性统计都有说服力。4. Cox 比例风险模型与避坑清单从单变量走向多变量4.1 Cox 模型的假设与输入为什么要做 PH 检验KM 曲线和 log-rank 检验本质上都是单变量分析适合探索数据不适合直接拿来预测。要同时考虑合同类型、月费、网龄、附加服务等多个特征就需要 Cox 比例风险模型。它的核心思想是不直接估计生存函数而是估计风险函数可以近似理解为“某个用户在第t个月流失的瞬时概率”。Cox 模型有一个很强的假设叫比例风险假设Proportional Hazards简称 PH 假设不同个体的风险函数成比例即某特征对风险的影响不随时间变化。比如“电子账单用户的风险是纸质账单用户的1.5倍”这个1.5倍在任何时间点都成立。如果这个假设不成立模型系数估计就会有偏。from lifelines import CoxPHFitter df_model df[[tenure, MonthlyCharges, Contract, PaymentMethod, gender, Churn]].copy() df_model[Contract] df_model[Contract].astype(category) df_model[PaymentMethod] df_model[PaymentMethod].astype(category) cph CoxPHFitter() cph.fit( df_model, duration_coltenure, event_colChurn, formula... )实际拟合代码我会在下一节给完整版这里先强调一个容易犯的错误CoxPHFitter的fit方法不要求你把分类变量手动做独热编码lifelines 会自动处理非数值列。你只需要保证字符型列是category类型模型就能正确识别。这也是为什么我在上一节刻意强调了TotalCharges必须转成数值——如果漏转它会连同其他object列一起被模型当作分类变量结果完全跑偏。4.2 实战拟合CoxPHFitter 的字段与输出解读from lifelines import CoxPHFitter df_model df[[tenure, MonthlyCharges, Contract, PaymentMethod, gender, Churn]].copy() df_model[Contract] df_model[Contract].astype(category) df_model[PaymentMethod] df_model[PaymentMethod].astype(category) cph CoxPHFitter() cph.fit( df_model, duration_coltenure, event_colChurn, show_progressTrue ) cph.print_summary()duration_col指定生存时间列这里仍然用tenureevent_col指定事件列用Churn。show_progressTrue会在控制台输出迭代收敛过程数据量如果只有七千行一两秒就能迭代完。print_summary()输出的表格里每一行是一个变量核心看三列coef、exp(coef)和p。exp(coef)大于1表示该特征增加流失风险小于1表示降低流失风险。Contract[T.One year]和Contract[T.Two year]的exp(coef)明显小于1说明长期合同的保护作用在控制其他变量后依然显著。gender的 p 值很大说明性别与流失没有统计显著关系可以直接从模型里去掉。输出里还有一个Concordance指标就是 C-index取值范围0.5到1代表模型区分能力的优劣。我这次跑出来的 C-index 在0.7左右对流失预测场景来说属于可用的水平但远不如医疗场景里那些动辄0.9的模型。原因在于电信流失数据本身噪声很大行为特征颗粒度也不够细这个值不用强求。4.3 避坑记录我踩过的五个坑坑一TotalCharges 没转数值模型直接报错。现象CoxPHFitter.fit报ValueError提示某一列无法处理。原因CSV 里 TotalCharges 有11个空字符串Pandas 把它整列推断为 object 类型。解决在清洗阶段就执行pd.to_numeric(df[TotalCharges], errorscoerce)然后删除空值行否则后面所有分析都会连环报错。坑二Churn 是字符串 Yes/No拟合时事件列报错。现象fit报 “event_observed must be binary” 或者结果里事件数永远是0。原因lifelines 要求事件列是0/1整数字符串类型不会被识别。解决df[Churn] df[Churn].map({Yes: 1, No: 0})清洗完确认一下value_counts()。坑三tenure 和 TotalCharges 高度共线系数乱跳。现象模型收敛了但tenure和TotalCharges的系数一正一负且 p 值都很显著。原因TotalCharges本质上是MonthlyCharges * tenure的累计值与tenure的相关性超过0.9两个变量同时进 Cox 模型会导致共线性问题系数解释完全失真。解决二选一我一般只保留tenure把TotalCharges作为描述性指标放在报表里不参与建模。坑四PH 假设检验不通过还硬解释系数。现象跑完cph.check_assumptions(df_model)控制台输出一堆误差表某个变量在各时间 bin 的p值都小于0.05甚至Contract这种分类变量也挂红灯。原因不同合同组的风险比实际上会随时间变化——一开始差距很大后面逐渐收敛——这本身就违反 PH 假设。解决方式有三种对违规变量做分层strata或者把变量改成时变协变量再或者直接不解释这个变量的系数只把它当控制变量。我通常先尝试分层分层之后的模型更稳定。cph.fit( df_model, duration_coltenure, event_colChurn, strata[Contract] ) cph.print_summary()strata[Contract]的含义是允许不同合同类型拥有各自的基准风险函数但不要求Contract本身产生一个全局系数。这样模型的 PH 假设压力会小很多同时Contract的效果仍然通过分层被部分保留了。如果你发现OnlineSecurity、TechSupport这类附加服务项也挂红灯同样可以把它们拆到strata里代价是模型解释性变差一点。坑五拿准确率评价生存模型。现象用 sklearn 的accuracy_score评估 Cox 模型发现只有75%觉得模型不行。原因生存分析建模的不是“是否流失”这个二分类结果而是“事件发生的时间分布”。有右删失样本存在时准确率本身就定义不清。解决改用concordance_index或 Brier Score 来评价或者直接把predict_partial_hazard输出的风险分数丢给roc_auc_score做单点的排序能力评估。5. 进阶技巧风险分层与个体生存曲线校准5.1 用 C-index 评估模型质量Cox 模型拟合完成后除了看print_summary里的 Concordance更独立的验证方法是把数据切成训练集和测试集在测试集上单独算 C-index。concordance_index的语义是随机抽一对用户模型判断“谁先流失”和实际情况一致的概率。from lifelines.utils import concordance_index from sklearn.model_selection import train_test_split train_df, test_df train_test_split(df_model, test_size0.2, random_state42) cph_train CoxPHFitter() cph_train.fit( train_df, duration_coltenure, event_colChurn ) cph_test CoxPHFitter() cph_test.fit( test_df, duration_coltenure, event_colChurn ) ci concordance_index( test_df[tenure], -cph_train.predict_partial_hazard(test_df), test_df[Churn] ) print(ci)concordance_index有三个参数第一个是真实生存时间第二个是预测风险分数第三个是事件状态。注意这里我在风险分数前加了负号因为predict_partial_hazard返回的是风险值越大代表越容易流失而concordance_index默认认为分数越高的应该活得更久所以需要取反。这是新手最容易搞反的地方。5.2 输出个体生存曲线与风险分层表模型不只是输出一个总分它还可以为每一个用户画一条专属生存曲线。这类曲线适合直接落到 CRM 系统里给客服人员看“这个用户未来六个月的存活概率”。sample df_model.iloc[:3] surv_funcs cph_train.predict_survival_function(sample, timesrange(0, 73)) surv_funcs.plot()predict_survival_function会返回一个 DataFrame每一列是一条生存曲线times参数控制你关心的月份范围。你可以把每条曲线最后几个月的存活率拉出来做成一张风险分层表存活率低于0.3的划成高风险0.3到0.6划成中风险高于0.6划成低风险然后按风险等级配置不同的挽留预算。我一般还会做一件事就是把预测风险分数predict_partial_hazard按五等分切段分别画 KM 曲线看高分段和低分段的实际流失曲线是否明显分开这一步能直观验证模型的排序能力是否真实落地。5.3 供参考的完整流程与验证清单经过前面几步整个项目从数据到结论的链路已经完整拉通先清洗TotalCharges和Churn再用 KM 曲线看整体存活率按Contract分组后跑 log-rank 检验确认差异显著最后用 Cox 模型纳入多变量并处理 PH 假设问题。整条链路里我最后习惯把生存曲线的预测结果和实际数据做一次回放比对拿前6个月的模型预测去对后6个月的真实流失记录看存活率有没有系统性偏差。如果偏差大多半是 PH 假设或者分类变量分层没处理干净。说一个我的个人习惯每次拿到这类生存数据第一件事永远是先画分组 KM 曲线再决定要不要上 Cox。曲线分组都拉不开的话模型再复杂也很难有突破。这个顺序帮我避免了很多次“模型调参两小时、业务价值为零”的尴尬。希望帮到你。本文还有配套的精品资源点击获取