简介:基于Kaggle平台电信客户流失数据集,利用生存分析方法构建客户流失预测模型,适合具备一定机器学习基础、希望进阶学习生存分析在商业场景中应用的数据科学学习者。分析内容以交互式文档为核心,完整呈现从数据探索、缺失值处理、特征编码到KM生存曲线绘制、Log-Rank检验以及Cox比例风险回归的建模全流程,每个环节均配有可运行代码与必要解释;同时提供独立脚本,便于在本地环境直接复现和扩展。压缩包内共7个文件,除核心分析代码与脚本外,还包含原始CSV数据集、Markdown说明文档、数据来源链接以及许可文件,目录清晰、用途明确。整个资源包仅186KB,下载与存储都非常轻量。目前已有567人学习浏览,适合希望借助真实电信业务数据快速理解生存分析原理、掌握流失预测方法的数据分析学习者。
1. 用生存分析做电信客户流失预测:多回答一个"什么时候走"
同样的Kaggle电信客户流失数据集,换一套建模思路,能比普通分类模型多回答一个问题。随机森林、XGBoost做流失预测,输出的是一张"谁可能走"的名单;而生存分析输出的是"他在第几个合同期走、用了多久开始想走、什么时间点干预性价比最高"。对于做续费运营和用户挽留的团队来说,后者才是真正能排进日历表的动作依据。这篇笔记从Kaggle平台下载电信客户流失数据集开始,用Python的lifelines库把生存分析完整跑一遍,覆盖数据处理、模型训练、评估和分层干预名单的生成,重点写清楚哪些参数能调、哪些坑我替你先踩过了。
2. 生存分析为什么适合流失预测:从"会不会走"到"多久会走"
先建立概念框架。流失预测本质上是一个"事件发生时间"(time-to-event)问题,生存分析恰好是专门处理这类问题的统计框架,它把"流失"重新定义为一种随在网时长变化的风险。
2.1 生存函数、风险函数和删失:换一套语言看用户生命周期
生存分析里有三个基础概念,理解它们之后再看数据会完全不同。
生存函数 S(t) 表示用户存活到时间 t 之后仍未流失的概率,这是一条从1开始、随时间单调下降的曲线。风险函数 h(t) 表示在存活到时刻 t 的条件下,下一瞬间发生流失的瞬时概率,它不一定是单调的,电信场景里往往在合约到期前后出现明显的峰值。
第三个概念是删失(censoring),这是生存分析和普通分类任务最本质的差异。在这个数据集中,Churn = 0 的用户并不是"永远不会流失",而是在观测窗口结束时仍然没有流失。他们的真实流失时间未知,只知道"至少存活了这么久"。这种类型叫右删失(right censoring),占了数据集的绝大多数。常规分类模型把这一类样本当作负样本丢掉时间信息,逻辑上是有损失的。
| 概念 | 数学符号 | 电信场景含义 |
|---|---|---|
| 生存函数 | S(t) | 用户在网超过 t 个月仍未流失的概率 |
| 风险函数 | h(t) | 在网第 t 个月的瞬时流失强度 |
| 右删失 | — | 观测期结束时用户仍在网,真实流失时间未知 |
流失预测换成生存分析的视角后,输入变成了 (时间 T, 事件标记 E),输出变成了整条生存曲线。这个结构上的变化,决定了你能回答的问题类型完全不同。
2.2 为什么选Cox比例风险模型而不是XGBoost
明确一下选型逻辑。XGBoost、LightGBM 做流失概率预测效果不差,但在"用户生命周期"这个语义下有两个短板。第一,它们输出的是静态概率,无法直接回答"第6个月风险最高"这类时间维度的结论;第二,删失样本在常规分类里很难优雅处理——直接丢掉会损失信息,标成负样本又引入偏差。
Cox比例风险模型(Cox Proportional Hazards Model)是生存分析领域最常用的半参数模型,它假设风险函数可以拆成基准风险 h0(t) 和协变量效应的乘积:
h(t|X) = h0(t) × exp(β₁X₁ + β₂X₂ + ...)
协变量部分不依赖时间,模型不关心基准风险的形状,因此拟合时不需要对生存时间分布做假设。这个"半参数"特性很关键——电信用户流失的时间分布往往不是简单的指数分布或Weibull分布,早期活跃流失和合约到期流失混合在一起,参数模型容易过度约束,Cox能避开这个坑。
它的输出有三个层次:每个特征的系数 β(对应风险比 exp(β)),每个样本的生存曲线预测,以及风险分数。这三个输出分别对应策略层、个体层和排序层,覆盖面足够广。
可解释性也是一个现实考量。流失预警系统通常需要向业务方解释"为什么给这批用户发优惠券",Cox模型的风险比可以直接说"月租每增加一档,流失风险提升多少倍",这是树模型和深度学习模型给不了的。但是Cox模型的前提是比例风险假设(PH假设),也就是协变量对风险的影响不随时间变化。如果某个特征明显违反这个假设,后文会给出具体的检测和修正方法。
2.3 lifelines和scikit-survival:两个库怎么选
Python生态里做生存分析,lifelines和scikit-survival是两个主流选择。
lifelines的使用体验最接近统计建模习惯,API设计偏R语言风格,内置CoxPHFitter、KaplanMeierFitter、NelsonAalenFitter,而且自带PH假设检验、残差诊断这些模型体检功能,做小型数据集探索和业务分析很顺手。
scikit-survival(sksurv)的优势在于和scikit-learn生态的集成,它的CoxnetSurvivalAnalysis支持L1/L2正则化,适合特征维度较高的场景。但它对数据格式要求较严苛——时间列必须是结构化数组np.array([(time, event)], dtype=[('time', float), ('event', bool)]),新手容易在这里踩坑。
我一般用lifelines做数据分析,因为电信客户流失数据集只有二十个左右的特征,不需要上正则化生存模型,lifelines的诊断工具更成熟。唯一要注意的是lifelines 0.27版本之后,CoxPHFitter的输入必须是干净的pandas DataFrame,梳理好的数据可以直接喂进去,不用额外指定时间列名和事件列名的类型。
3. 从Kaggle拉取电信客户流失数据集:注册、下载与字段盘点
很多人的第一道坎不是建模,而是怎么把数据从Kaggle上拿到本地。这里把完整流程和踩坑点一次说清。
3.1 Kaggle注册弹不出验证码:高频报错的处理办法
Kaggle注册时最常见的问题是验证码不显示,或者填写后提示 "captcha must be filled out"。这不是你操作的问题,多数情况下是浏览器对Google reCAPTCHA脚本的加载限制,或者网络环境本身对Google域名的访问不稳定。
遇到验证码弹窗不出来,先不要反复刷新页面。打开浏览器开发者工具(F12),切到网络面板,刷新注册页,看是否有指向www.google.com/recaptcha的请求被拦截或被挂起。确认是这类加载问题后,换一个浏览器内核差异较大的环境,比如Chrome不行就换Firefox,或者检查系统级代理设置。注册页面本身不需要额外插件,不要把时间浪费在找特殊脚本上。
如果你的网络环境访问Google验证码服务确实不稳定,还有一个折中方案:直接在Kaggle页面右上角选择"Continue with GitHub"或邮箱注册入口,邮箱注册流程有时候能绕过验证码强制校验。注册完先在个人主页确认邮箱验证通过,再进API设置页生成Token。
3.2 下载数据集:网页直下和API两种方式对比
Kaggle下载电信客户流失数据集,有两种方式,场景不同选法不同。
网页方式最直观:登录后搜索 "Telco Customer Churn",进入数据集页面,点右上角Download按钮,浏览器会开始下载一个约几百KB的压缩包。这个数据量不大,网页下载完全够用,不需要纠结API。
API方式适合你后续还有多数据集下载需求的情况。Kaggle官网账号设置里生成API Token(即kaggle.json),放到用户目录(Windows是C:\Users\你的用户名\.kaggle\kaggle.json),然后命令行执行:
pip install kaggle # 安装完成后,把kaggle.json放到 ~/.kaggle/ 目录,Windows路径同上 kaggle datasets download -d blastchar/telco-customer-churn # 解压 unzip telco-customer-churn.zip说明一下每个步骤的作用。pip install kaggle安装官方命令行工具,它会读取~/.kaggle/kaggle.json里的用户名和API Key来完成身份认证。datasets download -d指定数据集标识,blastchar/telco-customer-churn是这个数据集的规范路径格式,如果你在网页端打开数据集详情页,URL路径里的那串字符串就是它的唯一标识。
注意API下载默认不覆盖已存在的同名压缩包,重复执行时会看到403或者"already exists"类的提示,需要先删掉旧的zip再重新下载。另外kaggle.json文件的权限在Linux/Mac下要求是600(仅当前用户可读写),否则API会报认证错误,这一点文档里不会主动提醒。
3.3 字段盘点与关键的时间变量构造
下载完成后,通过pandas读入并检查结构:
import pandas as pd df = pd.read_csv("WA_Fn-UseC_-Telco-Customer-Churn.csv") print(df.shape) # 输出 (7043, 21) print(df.dtypes) print(df.head())这个数据集有7043条记录、21个字段。其中tenure字段是用户在网月数,Churn字段是是否流失标志,这两列是做生存分析最重要的输入——前者对应"事件时间T",后者对应"事件标记E"。
但这里有一个大多数教程没讲透的点:原始数据的Churn列是字符串"Yes"/"No",必须先转换成数值。更重要的是要意识到,数据集的观测窗口对每个用户并不同时结束,我们没有"合同到期时间"这类更细的日历时间,只能用tenure作为生存时间的近似。这在电信流失分析里是可以接受的常见近似做法,但后文会讲到它带来的一个隐蔽坑。
字段层面还需要做一次类型校正:TotalCharges列本身是数值型,但读入时如果有空值会被pandas识别为object类型。需要先处理掉空值(这个数据集只有个别行的TotalCharges为空,占比极小),再pd.to_numeric转换。MonthlyCharges和TotalCharges存在强相关,做Cox模型时建议保留MonthlyCharges,TotalCharges可以丢弃或做特征组合,两个都放进去会有共线性问题。
# 处理Churn标记:字符串转数值 df["Churn_Flag"] = df["Churn"].map({"Yes": 1, "No": 0}) # TotalCharges类型校正 df["TotalCharges"] = pd.to_numeric(df["TotalCharges"], errors="coerce") df = df.dropna(subset=["TotalCharges"]) # 时间列直接使用tenure,事件列使用Churn_Flag T = df["tenure"].values E = df["Churn_Flag"].valueserrors="coerce"参数会把无法解析的非数值串转为NaN,这一步宁可让数据少几行,也不要让模型吃掉脏数据。到这里数据准备工作就完成了,核心是把"是否流失"换成"(时间, 事件)"二元组。
4. 用lifelines跑通Cox流失预测:完整代码与参数解释
数据就绪后开始建模。先用Kaplan-Meier方法画整体生存曲线快速感知流失节奏,再训练Cox模型进入正式的流失预测流程。
4.1 Kaplan-Meier生存曲线:先看整体流失节奏
from lifelines import KaplanMeierFitter kmf = KaplanMeierFitter() kmf.fit(durations=T, event_observed=E, label="整体用户") kmf.plot_survival_function() # 查看中位生存时间 print(kmf.median_survival_time_)fit()接受最低限度的两个参数:durations是观测时间数组,event_observed是0/1标记数组。模型内部会维护一张生存概率随时间变化的表,曲线每一步下降的位置对应有用户在该时间点发生了流失。中位生存时间指生存概率降到50%的时刻,这个数据集中通常是几十个月的量级——先从这一步感受用户整体生命周期,再决定之后对时间窗口的分层尺度。
拿到整体曲线后,按合同类型分组比较是个能直接指导业务的切入点:
from lifelines import KaplanMeierFitter kmf_monthly = KaplanMeierFitter() kmf_one_year = KaplanMeierFitter() kmf_two_year = KaplanMeierFitter() mask_monthly = df["Contract"] == "Month-to-month" mask_one = df["Contract"] == "One year" mask_two = df["Contract"] == "Two year" kmf_monthly.fit(T[mask_monthly], E[mask_monthly], label="月付") kmf_one_year.fit(T[mask_one], E[mask_one], label="一年期") kmf_two_year.fit(T[mask_two], E[mask_two], label="两年期") kmf_monthly.plot_survival_function() kmf_one_year.plot_survival_function() kmf_two_year.plot_survival_function()三条曲线在平台上几乎分离,月付合同的生存曲线掉得最快,一年期居中,两年期最平缓。这一步的价值在于定性确认:合约长度是最强的流失分层变量之一,模型里如果没它或者它不显著,大概率是编码或数据处理出了问题。
4.2 Cox比例风险模型训练:核心参数与理解
from lifelines import CoxPHFitter # 准备建模特征:去掉文本标签列和强共线性特征 drop_cols = ["customerID", "Churn", "Churn_Flag", "TotalCharges"] df_model = df.drop(columns=drop_cols) # 有序多分类变量做数值映射 df_model["Contract"] = df_model["Contract"].map({ "Month-to-month": 1, "One year": 2, "Two year": 3 }) # 二元分类变量做0/1映射 binary_cols = ["Partner", "Dependents", "PhoneService", "PaperlessBilling"] for col in binary_cols: df_model[col] = df_model[col].map({"Yes": 1, "No": 0}) # 哑变量编码其余多分类变量 df_model = pd.get_dummies( df_model, columns=["InternetService", "PaymentMethod", "MultipleLines", "OnlineSecurity", "OnlineBackup", "DeviceProtection", "TechSupport", "StreamingTV", "StreamingMovies", "Gender"], drop_first=True ) # 训练Cox模型 cph = CoxPHFitter() cph.fit(df_model, duration_col="tenure", event_col="Churn_Flag", show_progress=True, step_size=0.5, penalizer=0.1) cph.print_summary()逐段解释这段代码的意图。
drop()删除的列里,customerID是纯标识符,Churn是流失标签原文,Churn_Flag是事件标记本身,三者都不能进特征矩阵。TotalCharges删除是因为与tenure和MonthlyCharges存在严重的线性相关,同时放进Cox模型会让系数标准误会膨胀,减弱可解释性。
Contract映射成1/2/3后,模型把合约时长当作连续风险因子处理。这里有个选择:如果担心效应不是线性递增,应该改用哑变量编码(三个合约类型取两个哑变量),让每个级别的风险独立估计。实际操作里我建议用哑变量,因为"月付→一年期→两年期"的风险不一定是等差增长的。
其余变量处理遵循一个原则:二元变量直接映射0/1,多分类变量用get_dummies且drop_first=True防止共线性陷阱。drop_first=True会为每个类别变量生成k-1列,本质是选择一个类别作参照系。
fit()里三个参数值得说。duration_col明确告诉模型哪一列是时间,event_col是事件标记。step_size默认是0.1,数据量较小或特征尺度差异大时调大一点能加快收敛,但太大容易跳过最优解。penalizer是L2正则强度,默认0,当特征间存在较强相关性或想压低系数波动时才需要开启。这个数据集特征不算多,penalizer=0.1的目的是把稀疏哑变量带来的过拟合压住。
print_summary()输出每个特征的coef、exp(coef)、标准误、z值和p值。exp(coef)大于1表示该特征增加流失风险,小于1表示保护效应。比如Contract:若系数为负,说明合同越长风险越低,这和KM曲线的观察一致。
4.3 评估:C-index与随时间变化的AUC
生存模型的评估不能直接用准确率,因为预测输出是生存曲线而非硬分类。最常用的判别指标是C-index(一致性指数),含义是:随机挑一对可比较的样本,模型判断出的风险排序和真实事件发生顺序一致的概率。C-index为1完美,0.5等于随机。
from lifelines.utils import concordance_index c_index = concordance_index( df_model["tenure"].values, -cph.predict_partial_hazard(df_model).values, df_model["Churn_Flag"].values ) print(f"C-index: {c_index:.4f}")这里细节在第二参数是predict_partial_hazard的负值。predict_partial_hazard返回的是模型输出的风险分数,风险分数越高表示越容易流失。而concordance_index默认认为"数值越大越不容易发生事件",所以要把风险分数取负再传入,顺序颠倒了会得到一个1减去真实值的错误结果。
C-index只评价排序质量,不评价概率校准。如果你要回答"预测在网第12个月的流失率是15%,这个数字准不准",需要看随时间变化的AUC(time-dependent AUC),lifelines里可以用lifelines.utils.survival_table_from_events配合scikit-learn的roc_auc_score自行计算,也可以换用sksurv的cumulative_dynamic_auc。对大多数业务决策来说,C-index在0.8以上就已经能支撑分层运营了,不必追求极端高值,排序能力够用、曲线可解释是关键。
5. 电信流失预测的典型坑:删失、零时、PH假设、业务解读
这一章是血泪经验汇总。现象、原因、解决三步写,有同样报错直接对照排查。
5.1 现象:模型跑出来C-index超过0.95,高到可疑
原因:这不是模型厉害,多半是时间列使用错误。tenure是合同期内累计在网月数,而Contract和部分消费特征本身和tenure高度纠缠。更常见的问题是,把TotalCharges留在特征里,它由MonthlyCharges × tenure构成,等于把一个线性派生的时间变量放进了模型,模型几乎只是靠数学变换重算了一遍时间,导致C-index虚高。
解决:TotalCharges这类由时间直接线性生成的列必须剔除。自查方法是删除该特征后重新训练,如果C-index从0.95掉到0.8左右,说明之前的"好效果"是泄漏而不是预测能力。判断有没有特征泄漏,除了值本身,还要看你手里有没有未来才能知道的信息。这个数据集的泄漏源就是TotalCharges,去掉它,模型才是在真正做预测。
5.2 现象:Cox模型输出警告"Binned fit did not converge"或系数全不显著
原因:特征里某个二元变量的类占比极度不平衡,或者多个哑变量之间存在近似线性依赖。OnlineSecurity、TechSupport这类字段"没有服务"的档位占比很高,哑变量生成后彼此相关性强,导致信息矩阵接近奇异。
解决:先跑一遍相关性矩阵,把相关系数超过0.8的特征删掉一组。然后检查print_summary()里的coef值:如果不显著的系数大多是稀疏哑变量,可以手工合并类别。比如把"No internet service"和"No"合并成一个"未开通或不使用"的语义,减少哑变量数。合并后重新训练,系数和标准误会健康很多。
5.3 现象:按预测风险分组画的生存曲线,在中途交叉了
原因:Cox模型的比例风险假设被违反。这个假设要求任何两个用户的风险比随时间保持恒定。电信场景里最常见的违反者是Contract——月付合约用户早期流失剧烈,存活下来的很快就变得稳定;而长期合约用户早期风险很低但合约到期前后上升。两条风险曲线随时间变化的方向不一致,生存曲线就会交叉。
解决:先做PH假设检验:
cph.check_assumptions(df_model, p_value_threshold=0.05)lifelines会报告哪些变量违反了假设。标准的处理方案是把变量按时间分层(strata):对于违反PH假设的特征,把它从协变量移到strata参数里,让模型为它的每个取值单独估计基准风险函数,而不再假设这个特征的风险比恒定。
cph = CoxPHFitter() cph.fit(df_model, duration_col="tenure", event_col="Churn_Flag", strata=["Contract"])分层后模型不再给出Contract的系数,但其余变量的解释基本不变。一般来说只要核心业务特征的风险比稳定,个别控制变量分层掉并不影响整体分析。
5.4 现象:KM曲线在时间点为0处直线下坠
原因:tenure为0的用户被当作事件直接纳入计算,但他们在观测窗口开始时就已流失,KM曲线在零点就会被拉低一段,失真明显。
解决:检查df["tenure"].min(),如果存在0值,判断这些用户的"第0月流失"是业务事实还是数据录入规则。如果是数据录入规则——比如入网登记和离网登记发生在同一个月,标记为0——把这些样本的tenure统一改成0.5或者其他微小正值,避免0参与时间比例运算。这个方法有争议,但实操中很常见,前提是你要能向业务方解释这个调整的含义,不解释清楚就是玄学。
5.5 现象:生存概率和流失概率总是对不上,业务质疑"你们预测说30%,实际只有12%在三个月内走了"
原因:生存概率S(t)描述的是"从入网开始存活超过t个月的概率",它是从入网那一刻起算的累计量。业务要的"未来3个月流失率",应该是在"当前已经存活到第12个月"的条件下,未来3个月内的流失条件概率:
P(第12~15月内流失 | 已存活到第12月) = 1 - S(15) / S(12)
不少初学者直接把1-S(12)当作"第12月流失率",这是概念错位。输出预测时必须先明确参考时间点,再换算成业务口径。
解决:写一个转换函数,输入当前存活时间和预测窗口长度,输出窗口内的条件流失概率:
import numpy as np def conditional_churn_probability(cph, df_row, current_t=12, window=3): # 取该用户的生存函数 surv = cph.predict_survival_function(df_row) times = surv.index.values # 找最接近的两个时间点的生存概率 s_current = np.interp(current_t, times, surv[0].values) s_future = np.interp(current_t + window, times, surv[0].values) return 1 - (s_future / s_current) prob = conditional_churn_probability(cph, df_model.iloc[[0]], 12, 3) print(f"未来3个月条件流失概率: {prob:.2%}")np.interp做线性插值,因为我们只需要近似值,不需要精确到月,插值精度足够。这段函数会成为一个标准化工具,后续所有给业务方的数据都要过这一层转换,避免口径争论。
6. 把生存曲线变成续费干预名单:分层阈值与校准验证
风险评估的最终产物是一张可执行的名单,不是一个C-index数字。以月付合约用户为例,在训练好的Cox模型上,对全体用户的生存曲线做预测,然后生成三个层面的动作。
第一层是短期风险名单。对每个用户取"当前在网月数 + 未来30天"的条件流失概率,把概率从高到低排序,超过某一阈值(比如20%)的进入高优先级唤醒名单,推送优惠券或专属客服。第二层是中期风险名单。取未来90天的条件流失概率,筛选出"当前稳定但90天内有风险"的用户,这批人适合安排合约升级引导。第三层是曲线形状分析。对每个用户预测的生存曲线做一阶差分,找到风险陡增的拐点月份,把干预动作提前到拐点之前一个月。
阈值怎么定:画出条件流失概率的分布直方图,找自然断档,或者直接按运营承接能力控制名单量。假设客服团队每天能触达200人,就把阈值调到名单人数恰好等于承接上限。
最后做一次校准验证。把测试集用户按预测概率分桶(比如0-10%、10-20%……),计算每个桶内实际发生流失的比例,画出校准曲线。如果实测值系统性高于预测值,说明模型过度自信,需要回去检查是否有什么高影响特征没编码完全;如果实测值低于预测值,可能删失处理过于激进,把本来会在窗口外流失的用户错误标记成了删失。
一个我自己沉淀下来的习惯:模型输出永远不直接给"流失概率",而是给"未来N天流失风险"和"风险等级"。前者是条件概率,后者是分层标签。这样做业务方不会拿生存函数去和当月实际流失率硬比,减少大量解释成本。这套流程从Kaggle数据下载到名单输出,在真实业务环境里大概率能直接复用,希望帮到你。
本文还有配套的精品资源,点击获取