简介:基于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 与 event
2.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天然就是 duration,Churn则是 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) # event:1=已流失,0=观察期内未流失 df["event"] = (df["Churn"] == "Yes").astype(int)这里最需要理解的是event=0的含义。普通二分类会把 Churn=No 当成“负样本”,但生存分析认为这些客户只是“还没流失”。假设观察期在第 73 个月截断,tenure=73 且 Churn=No 的客户,可能下个月就走,只是在数据采集那一刻还没发生。这种样本叫右删失(right censoring)。
lifelines 等库在估计生存函数时,会把这些右删失样本计入“截至该时点还活着的人数”,但不会把它们当成在那一天“死亡”。如果强行把删失样本当负样本丢给 XGBoost,时间信息会被压缩成“是否”两态,模型也就无法回答“多久之后流失”。这恰恰是用生存分析做流失预测和传统分类最本质的区别。
2.3 洗数据的三处硬伤:TotalCharges、tenure=0 与类别编码
电信客户流失数据集虽然干净,但有几个字段直接喂给生存分析模型会出问题。第一处是TotalCharges,它里面有一小部分空字符串,读进来后被识别成 object 类型,需要先转数值再处理缺失。第二处是tenure=0,这类客户要么是当月开通当月退订,要么是刚开通还没到账期,其TotalCharges必然为空。
# TotalCharges 转数值,空值来自 tenure 接近 0 的未出账客户 df["TotalCharges"] = pd.to_numeric(df["TotalCharges"], errors="coerce") df["TotalCharges"] = df["TotalCharges"].fillna(df["MonthlyCharges"]) # 检查 tenure=0 的分布 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 更符合“已经消费了一个月”的事实。tenure=0的样本总共只有十几个,直接剔除对整体结果影响可以忽略,但如果不剔除,KaplanMeierFitter在时间 0 点的生存概率会瞬间下跌,后续所有估计都会被这个伪事件干扰。
第三处硬伤是类别变量编码。lifelines 的 CoxPHFitter 要求协变量全是数值,所以需要把 Contract、InternetService、PaymentMethod 等字符型字段做独热编码。这里建议drop_first=True,避免多个虚拟变量之间产生完全共线性:
categorical_cols = [ "gender", "Partner", "Dependents", "PhoneService", "MultipleLines", "InternetService", "OnlineSecurity", "OnlineBackup", "DeviceProtection", "TechSupport", "StreamingTV", "StreamingMovies", "Contract", "PaperlessBilling", "PaymentMethod" ] df = pd.get_dummies(df, columns=categorical_cols, drop_first=True, dtype=int) # 列名里有空格会影响后续输出,统一替换成下划线 df.columns = df.columns.str.replace(" ", "_", regex=False) # 建模用的数据框只保留协变量 + 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_observed=df_model["event"]) kmf.plot_survival_function() print("整体中位在网月数:", kmf.median_survival_time_)fit的第一个参数是durations,也就是每个客户的 duration;event_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[f"Contract_{contract}"] == 1 kmf_tmp = KaplanMeierFitter() kmf_tmp.fit( df_model.loc[mask, "duration"], event_observed=df_model.loc[mask, "event"], label=contract ) kmf_tmp.plot_survival_function(ax=ax) plt.ylabel("在网留存概率 S(t)") plt.xlabel("在网月数") plt.show()注意这里df_model[f"Contract_{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_A=df_model.loc[mask_mtm, "event"], event_observed_B=df_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(penalizer=0.1) cph.fit( df_model, duration_col="duration", event_col="event", show_progress=True ) cph.print_summary(decimals=3)penalizer=0.1是 L2 正则的强度参数。电信流失数据经过独热编码后,某些列之间仍存在较强相关性,稍微加一点惩罚可以抑制系数爆炸,也让print_summary不出现共线性警告。duration_col和event_col分别指定时间轴和事件标记。print_summary会输出一张表,每行对应一个协变量,主要看三列:coef是 beta 的估计值,exp(coef)是风险比,p是显著性检验结果。
4.2 风险比 HR:Contract、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 # 检查 t=12 个月时预测概率的校准程度 survival_probability_calibration( cph, df_model, t0=12, event_observed="event" )校准曲线越贴近对角线,说明预测概率越可信。这一项在 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_col="duration", event_col="event", k=5, scoring_method="concordance_index" ) print("5折 C-index:", scores)如果业务上要求严格的时间外验证,更合理的做法是按进入观察期的时间切分数据,比如用前 70% 十个月内开始观察的客户做训练,后 30% 做验证。但电信客户流失数据集的 tenure 是客户自己的在网时长,不是观察期日历时间,做时间切分需要额外构造观察窗口,这里按下不表。
另外一件必须检查的事是比例风险假设。Cox 模型要求协变量对风险的影响随时间保持恒定,比如“月付合同的流失风险是年付合同的 3 倍”这个 3 倍关系不能在第 6 个月变成 2 倍、第 12 个月又变成 4 倍。lifelines 有现成的假设检验方法:
cph.check_assumptions(df_model, p_value_threshold=0.05)运行后如果输出大量警告,说明某些变量随时间变化明显。常见处理方式有两个:一是把违反假设的变量放进strata参数做分层;二是改用CoxTimeVaryingFitter建模时变协变量。对电信流失这个数据集,Contract有时会轻微违反比例风险假设,因为它对流失的影响确实在前期更强烈。如果只是想拿到一个可用风险分,penalizer=0.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", ascending=False) priority.to_csv("churn_priority_12m.csv", index=False) 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 的客户生命周期字段里,每次批跑后更新一次风险排序,就是一套不依赖外部平台的轻量流失预警系统。
本文还有配套的精品资源,点击获取