尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

用生存分析重做电信客户流失预测:从KM曲线到Cox模型

发布时间:2026/9/26 6:28:21

资讯中心
01
ARTICLE

用生存分析重做电信客户流失预测:从KM曲线到Cox模型

用生存分析重做电信客户流失预测:从KM曲线到Cox模型
简介来自Kaggle公开电信客户流失数据集的生存分析实战资源面向数据挖掘初学者与金融/电信风控从业者解决客户流失预测与挽留时机识别问题。资源围绕Telco Customer Churn数据完整演示从客户编号、性别、合约方式到月费用等20个字段的探索并运用生存分析建模流失风险提供可运行的Jupyter Notebook与配套Python脚本帮助读者理解生存曲线、风险函数在客户生命周期管理中的应用。压缩包共7个文件包含ipynb分析文档、py脚本、CSV数据、说明txt、README及LICENSE等整体仅186KB轻量便携。已有567人学习下载。资源内含原始CSV数据可配合分析笔记对照实践适合希望掌握基于生存分析的流失预测完整流程、并将模型迁移到自身业务数据的读者。1. 电信流失预测为什么值得用生存分析重做一遍同样一个客户逻辑回归告诉我他有 35% 的概率流失XGBoost 给出的数字也差不多。可运营同学追过来一句那他大概还能留几个月哪些客户是未来 30 天最危险的我一时答不上来因为分类模型给的是概率不是时间。kaggle 平台上的电信客户流失数据集Telco Customer Churn被很多人拿来练二分类但用生存分析做流失预测才是把问题问到了根上客户流失本质是一个什么时候离开的事件生存分析天然输出存活曲线和风险比能把会不会走升级成多久后走、谁先走。这篇文章就把这条技术路线完整拆开适合已经跑过一遍分类模型、想换视角的从业者。2. 把流失数据改造成生存数据tenure 做时间、Churn 做事件、TotalCharges 修复2.1 先分清分类任务和生存任务同一张表两种用法电信客户流失数据集最常见的姿势是读进来把 Churn 列变成 0/1丢进 LightGBM输出 AUC。这个流程没有错但它默认了所有客户处在同一个时间截面丢失了客户在网多久这个信息。生存分析要回答的是两个问题客户在任意时间点 t 还活着的概率是多少也就是 S(t)以及哪些因素让某类客户的风险更高也就是风险比 HR。在动手写代码之前先把数据集里的角色分清楚这块数据集正好有三个字段对应生存分析的三个要素。tenure 是客户已经在网的月数直接作为生存时间这个角色Churn 是观察窗口内是否发生了流失事件作为事件指示器Yes1No0其余像 Contract、MonthlyCharges、InternetService 这些全部作为协变量特征。值得强调的是ChurnNo 在生存分析里不叫没流失叫删失意思是客户在观察期结束时还没离开但你不知道他下个月会不会走。这一字之差决定了后面所有模型计算的底层逻辑。2.2 数据清洗TotalCharges 的 object 类型和空值是第一个翻车点先别急着构造生存数据很多人第一步就翻车出在 TotalCharges 这一列。这份 CSV 里 TotalCharges 读进来是 object 类型而不是 float原因是有些行的单元格是空的。直接df.dtypes看的时候它显示 object你拿去做数值计算lifelines 立刻抛异常错误信息可能让你摸不着头脑。更隐蔽的是那些空值本身不是随机缺失。翻一下数据会发现TotalCharges 为空的行tenure 基本都是 0说明这些客户刚签约还没产生过账单是真 0而不是缺数据。处理方式不是用均值填充而是把字符串转成数值再把少量空值直接删除。这段代码是最小可复现的清洗流程import pandas as pd import numpy as np from lifelines import KaplanMeierFitter from lifelines import CoxPHFitter from lifelines.statistics import logrank_test # 读取 kaggle 电信客户流失数据集 df pd.read_csv(telco_churn.csv) # TotalCharges 读进来是字符串空单元格会变成 NaN df[TotalCharges] pd.to_numeric(df[TotalCharges], errorscoerce) # 空值集中在 tenure0 的新客户身上直接删掉 df df.dropna(subset[TotalCharges]).copy() # 构造生存分析的三个核心列 df[tenure_months] df[tenure].astype(int) # 生存时间月在网时长 df[event_churn] (df[Churn] Yes).astype(int) # 事件是否流失这段代码做了三件事。第一件是把 TotalCharges 强制转数值errorscoerce让无法解析的值变成 NaN这样后面能用dropna统一清理第二件是按 TotalCharges 删除空行电信流失数据里这些行通常是新签约客户第三件是构造生存分析必需的 duration 列和 event 列。tenure_months是作为时间轴用的event_churn必须是 0/1 整数lifelines 的 Cox 模型如果发现事件列是字符串会直接报错这是个很容易踩的细节。2.3 构造生存数据后的自检删失比例和特征列清单清洗完后不要急着建模先做一个简单的体检。用df[event_churn].mean()看流失事件比例这份数据集常见结果在 26% 左右剩下约 74% 是删失样本。如果删失比例接近 99%说明你的观察窗口太短后面生存曲线会极度乐观反过来删失比例很低说明数据集本身选取的就是高流失人群结论不能外推到全部用户。删失比例的另一个用途是校准预期。二分法里 26% 的正样本占比会让人对 AUC 有某种预期但生存分析里的评判基准不是 AUC而是 C-index 和生存曲线的置信区间。它们的数值逻辑完全不同C-index 在 0.8 左右已经算很好的模型而在二分类里 AUC 0.8 只能算中等水平。如果拿二分类的尺子评价生存模型你会误判自己的成果。提示tenure和构造出的tenure_months是同一列千万别在建模时把原始tenure再当特征丢进模型否则会造成信息泄漏。这个坑在避坑章节会展开。3. 用 KM 生存曲线看整体与分群流失三个必读时间点与 log-rank 检验3.1 整体生存曲线先回答客户平均能留多久做完数据准备后的第一件事不是上 Cox而是先画 Kaplan-Meier 生存曲线也就是 KM 曲线。KM 是最朴素的非参数估计它不需要任何分布假设只依赖每个时间点上还有多少人在观察中、又有多少人流失来递推存活概率。# 初始化 KM 模型并拟合整体生存函数 kmf KaplanMeierFitter() kmf.fit(durationsdf[tenure_months], event_observeddf[event_churn]) # 看某些关键时间点的存活概率 for t in [6, 12, 24, 36]: surv kmf.predict(t) print(f{t} 个月存活概率: {surv:.3f}) # 中位生存时间存活概率降到 50% 的时间点 print(f中位生存时间: {kmf.median_survival_time_})拟合之后要会读结果不要只画完图就完了。三个关键时间点必看第 6 个月的存活概率对应新客户是否熬过半年第 12 个月多数运营商把这里当作续约节点第 24 个月通常对应两年合约的边界。电信数据集跑下来整体曲线通常呈现头 12 个月陡降之后趋缓的形状12 个月存活概率往往在 75% 上下24 个月后明显下滑。曲线形状本身就是信息斜率陡峭的区间就是流失高发期也意味着干预资源应该主要砸在这个时间窗口。median_survival_time_要小心解读。如果整体存活概率没有降到 50%这个值会返回 inf说明一半客户活过了数据集的可观测范围。这时候不要写成客户平均寿命无限大应该说在观察窗口内中位生存时间尚未达到。电信客户流失数据集里通常不至于 inf但分组后短合约组和中长期合约组的差异会非常大这就引出了下一层分析。3.2 按合同类型分组KM 曲线让差异一目了然电信流失数据里最经典的分组变量是 Contract。把这列分成 Month-to-month、One year、Two year 三组分别拟合 KM 曲线你能直观看到短期合约客户的生存曲线掉得有多快。分组拟合的代码不复杂关键是画图前先把三组数据筛干净。# 按合同类型分三组分别拟合并绘图 fig, ax plt.subplots(figsize(8, 6)) for label in [Month-to-month, One year, Two year]: mask df[Contract] label kmf_group KaplanMeierFitter() kmf_group.fit(df.loc[mask, tenure_months], event_observeddf.loc[mask, event_churn], labellabel) kmf_group.survival_function_.plot(axax) ax.set_ylabel(存活概率) ax.set_xlabel(在网月数) ax.set_title(不同合同类型的 KM 生存曲线)label参数用来区分曲线lifelines 会把 label 存进结果对象的元数据里画图时图例自动就是它。survival_function_返回一个 DataFrame索引是时间点值是存活概率直接用 pandas 的 plot 就能画出来。读这种分组曲线有一个技巧看曲线分叉的起点。如果两条曲线在最初几个月几乎重合、后面才分开说明差异是慢慢累积的如果一开始就分叉说明合同类型在早期就产生强筛选效应。电信客户流失数据里Month-to-month 组在 12 个月左右就掉到 60% 附近而 Two year 组往往还能维持在 90% 以上这个差异量级几乎可以断定合同类型是最强的单一风险因子。落运营动作时这类客户需要的不是挽留策略而是签约时的门槛设计。3.3 log-rank 检验差异是不是随机波动分组曲线肉眼看着差异很大但严谨的落地流程要求给出统计检验否则汇报时一句这是不是巧合就能让你卡住。log-rank 检验是生存分析里最常用的组间比较方法零假设是两条生存曲线没有差异p 值小于 0.05 就说明分组确实有效。# 将合同类型粗分为短期和长期两组做 log-rank 检验 short_term df[df[Contract] Month-to-month] long_term df[df[Contract] ! Month-to-month] results logrank_test( short_term[tenure_months], long_term[tenure_months], short_term[event_churn], long_term[event_churn] ) print(flog-rank p 值: {results.p_value:.4f})logrank_test接收四个等长序列两组的生存时间、两组的事件指示器。它内部会构造每个时间点的观察流失数和期望流失数汇总成卡方统计量。输出 p 值如果是 0.0000 这种极端小的数字报告里写 p0.001 即可不要写精确的 0.0000显得不够严谨。多分组场景下直接用lifelines.statistics.multivariate_logrank_test一次比较所有合同类型比两两比较更稳妥避免多重检验带来的假阳性。做完显著检验之后还要看效应量也就是 KM 曲线之间的实际距离。统计显著在几千样本上很容易达到但运营资源有限你得知道组间差异在业务上够不够大够大才值得为它设计一套不同的干预策略。4. 用 Cox 比例风险模型做流失预测从单因素到多因素4.1 模型选型为什么电信流失预测常用 Cox 而不是 LogisticKM 曲线只能告诉我们哪类人风险高但运营要的是一个能对个体打分的模型哪些客户排在最前面、风险由哪些因素驱动。Cox 比例风险模型是这步的标准选择它属于半参数模型不假设基准风险函数的具体形状只假设每个人的风险是基准风险乘以一个常数系数这个假设带来两个好处一是能处理删失数据二是能同时容纳连续变量和分类变量直接输出风险比 HR。对比一下逻辑回归它也能做流失预测但它把在网 10 个月的客户和在网 60 个月的客户放在同一个概率公式里丢失了时间维度对比随机生存森林效果可能不错但解释性差而且调参成本高。Cox 模型在电信流失场景里是性价比最高的起点原因就是它能给出可解释的 HR还能在后续接上生存曲线预测。如果数据量够大、特征复杂再上随机生存森林也不迟但第一版基线用 Cox 最稳。4.2 CoxPHFitter 的输入要求和特征预处理使用 lifelines 的CoxPHFitter之前有几条硬性约束需要理解。第一duration 列必须全部大于 0如果有些行 tenure 等于 0 且 event 等于 1模型会无法处理第二event 列必须是 0/1 整数不能是yes/no字符串第三所有需要参与建模的特征里不能有 NaN分类变量不能以字符串形式直接丢进去需要编码。# 复制一份用于建模去掉冗余原始列避免泄漏 df_model df.drop(columns[customerID, Churn, tenure]).copy() # 将多分类字符列转成 pandas 的分类类型便于 lifelines 自动编码 cat_cols df_model.select_dtypes(include[object]).columns for col in cat_cols: df_model[col] df_model[col].astype(category) # 对数值列做标准化消除量纲差异对正则化的影响 from sklearn.preprocessing import StandardScaler num_cols [MonthlyCharges, TotalCharges] scaler StandardScaler() df_model[num_cols] scaler.fit_transform(df_model[num_cols]) print(df_model.dtypes)这段代码有四个关键决策。去掉customerID是因为它只是编号去掉Churn是因为它本身就是事件列留在特征里等于作弊去掉tenure是因为它已经被用作时间列再作为特征会造成逻辑混乱。这里把事件信息和时间信息同时从特征表里剥掉是生存分析特征工程里最容易忽略的一步。把字符串列统一转成 category是让 lifelines 在拟合时自动对分类变量做编码它底层会用类似 one-hot 的方式展开但你不需要手动pd.get_dummies()省掉很多拼接列的麻烦。对MonthlyCharges和TotalCharges做标准化也很重要Cox 模型使用偏似然估计量纲大的变量会让系数估计不稳定特别是后面加正则化项时你会发现尺度不统一的特征惩罚力度完全不同。4.3 建第一版多因素 Cox 模型看系数、HR 与置信区间预处理完毕就可以拟合第一版 Cox 模型。CoxPHFitter的接口设计得很顺手一次fit就能完成系数估计、标准误计算和置信区间输出。# 初始化 Cox 模型加一个小惩罚防止数值过拟合 cph CoxPHFitter(penalizer0.01) cph.fit(df_model, duration_coltenure_months, event_colevent_churn, formulaC(Contract) C(PaymentMethod) C(InternetService) C(OnlineSecurity) C(TechSupport) MonthlyCharges TotalCharges SeniorCitizen C(Partner), show_progressTrue) # 输出系数表 cph.print_summary()formula参数用 R 风格公式写法C(Contract)表示把 Contract 当分类变量编码。不写 formula 直接传入 category 列也行但显式写出来后哪些特征进了模型一目了然排查问题也方便。penalizer0.01是岭回归式的 L2 惩罚能让系数估计更稳定尤其当特征之间存在相关的时候。print_summary()输出的表里重点看coef、exp(coef)、p和coef lower 95%/coef upper 95%这几列coef是风险对数比exp(coef)就是 HR大于 1 表示该变量增加流失风险小于 1 表示保护因素。从电信数据集跑出来的典型结果符合业务直觉Contract 的 Month-to-month 系数显著为正意味着短期合约客户的风险远高于长期合约OnlineSecurity 和 TechSupport 这类增值服务的系数通常为负说明服务绑定降低了流失概率SeniorCitizen 的系数一般偏正但 p 值经常模糊不清。拿到表后的正确姿势是先把 p 值大于 0.05 的特征摘出来不要急着删结合后面的模型对比决定去留。4.4 特征筛选与正则化p 值不是唯一的裁判只用 p 值筛特征很容易翻车因为特征之间可能存在共线性让单个系数的标准误变大p 值虚高。我习惯的做法是分三步走。第一步先各自跑一遍单因素 Cox把只有单因素显著、进多因素后变不显著的特征标记出来这通常说明它的效应被其他特征吸收了。第二步看 VIF 检查共线性比如MonthlyCharges和TotalCharges天然高度相关同时放进去会让系数解释不可靠可以只保留一个。第三步依赖 C-index 做最终判定。# 评估当前模型的区分度 print(fC-index: {cph.concordance_index_:.4f})C-index 是生存分析里的分对概率随机抽一对客户模型能把实际更早流失的那个人排到前面去的概率。0.5 等于瞎猜0.7-0.8 在电信流失数据里是常见区间。看完 C-index 后可以试着删掉几个 p 值不显著的特征再重新拟合如果 C-index 几乎没有下降说明这些变量是噪声可以放心去掉如果明显下降说明它们对排序有贡献即使单个系数不显著也值得保留常见原因就是共线性或交互效应。注意Cox 模型的 C-index 和二分类的 AUC 不是一回事前者是排序一致性后者是正负样本可分性。两个数字没有可比性你只需要关注它相对自身的变化。5. 生存分析落地避坑5 个会让结果翻车的问题5.1 TotalCharges 空值的假空与真缺失现象处理 TotalCharges 时直接把所有空值填充为列均值然后跑模型发现 C-index 高得不正常。 原因这份数据的 TotalCharges 空值集中在 tenure0 的新客户身上均值填充等于往这些行里塞了一个老客户的平均消费把时间信息注入了特征列副作用是模型学会了利用这个伪造值做判断。 解决先用df.loc[df[TotalCharges].isnull(), [tenure, Churn, MonthlyCharges, TotalCharges]].head()看一下空值行分布确认是 tenure 等于 0 后直接删除或用 0 填充不要用均值。判断标准很简单做任何缺失值处理前先去看缺失值所在行的其他字段数据天生就不告诉你缺了它告诉你这里本来就没有。5.2 数据泄漏把事件列又当特征又当标签现象模型 C-index 冲到 0.99print_summary 里某个变量的 p 值小到离谱。 原因建模表里漏删了原始Churn列或者自己造的派生特征里间接包含了未来信息比如直接用df[Churn] Yes构造了一个辅助列却没删原来的标签列。 解决在df_model df.drop(columns[customerID, Churn, tenure])之后做一次df_model.columns的清单检查。规则是事件列、时间列、唯一 ID 列这三类都不能出现在特征矩阵里。另一个容易漏的是未来信息型特征比如用全量数据计算的客户平均消费额填到每一行里这在时间序列视角上看也属于泄漏。5.3 PH 假设不满足时的补救现象cph.check_assumptions(df_model, p_value_threshold0.05)抛出一长串警告某几个特征被判定违反比例风险假设。 原因Cox 模型假设协变量的风险效应不随时间变化也就是任何时候风险比是恒定的。但电信流失里不一定是这样某些变量的影响会在特定时间段显现。 解决第一步看违反假设的变量是分类还是连续。分类变量可以按它的类别分层在模型里做分层 Cox连续变量可以考虑加时间交互项。实操上最简单是先看check_assumptions输出的建议然后决定是分层还是丢弃。注意不必追求所有假设都被满足如果是辅助控制变量违反对预测影响不大但如果是核心解释变量必须处理。5.4 删失的误解客户没流失不代表没出事现象把 ChurnNo 的客户当永久忠诚用户在报告里写这类客户不会流失。 原因混淆了在观察窗口内未发生事件和永远不发生事件。电信数据的观察窗口是一段有限时间窗口结束后客户可能下个月就流失。 解决报告里统一措辞删失客户只能说在观察期间未流失不能说不会流失。生存曲线末端如果拖着一条长尾巴那是删失客户比例高的表现不是业务好转的信号。汇报时把删失比例写进附录让听的人知道结论的适用范围。5.5 中位生存时间 inf 时的解读现象kmf.median_survival_time_返回 inf输出结果时不知道怎么写。 原因数据集的观察窗口太短存活概率始终没有跌破 50%导致中位生存时间不存在。 解决不要硬编一个数字。改看特定时间点的存活概率比如第 12 个月存活概率为 87%或者改看 x% 分位生存时间lifelines 里可以通过kmf.percentile(p)拿到任意分位数的生存时间。这个习惯在分组对比时尤其有用两组可能一组有中位生存时间另一组没有强行比较中位数会得出荒谬的业务结论。6. 让预测变成运营动作个体风险分层与生存概率输出6.1 用 partial hazard 给客户分险级Cox 模型拟合完成后最有价值的落地动作不是输出一个概率而是给每个客户算出一个风险评分partial hazard再把整个客户群分成低、中、高风险三层。分层后的运营动作完全不同低风险组保持正常维系中风险组推送产品权益高风险组安排人工外呼。# 给每个客户算风险评分并分为三层 df_model[risk_score] cph.predict_partial_hazard(df_model) df_model[risk_group] pd.qcut(df_model[risk_score], q3, labels[low, mid, high]) # 看每个风险组的平均生存曲线 fig, ax plt.subplots(figsize(8, 6)) for label in [low, mid, high]: mask df_model[risk_group] label kmf_group KaplanMeierFitter() kmf_group.fit(df_model.loc[mask, tenure_months], event_observeddf_model.loc[mask, event_churn], labelfrisk_{label}) kmf_group.survival_function_.plot(axax)pd.qcut按分位数三等分保证每层客户数量大致相同避免出现某一层人数过少导致统计不稳。画出三条生存曲线后你会看到高风险组曲线明显低于其他两组这就是模型价值的直观证据风险分层与真实生存结果是对齐的。6.2 给单个客户画生存曲线面对一个具体客户运营想知道的是他未来 12 个月还能留多久。predict_survival_function能给出个体级别的生存曲线把这条曲线的 6 个月和 12 个月截点值取出来就能转成自然语言这个客户 6 个月存活概率 78%12 个月存活概率 52%。# 以第 5 个客户为例画个体生存曲线 customer_index 5 surv_func cph.predict_survival_function(df_model.iloc[[customer_index]]) surv_func.T.plot() # 转置后画出该客户的生存曲线 # 取关键时间点的存活概率 for t in [6, 12]: print(f该客户在 {t} 个月存活概率: {surv_func.loc[t].values[0]:.3f})predict_survival_function返回的 DataFrame 里行是时间点列是客户编号所以画图前要转置。关键时间的概率可以直接用loc[t]索引。这类个体曲线对客服话术设计很有价值比单纯说您有流失风险多了时间维度话术可以变成我们想在第 6 个月前为您升级服务。6.3 把生存概率落成决策表模型最终要交给业务系统不是留在 notebook 里。我习惯的做法是把风险评分、风险分组、6 个月和 12 个月存活概率合成一张宽表输出成 CSV 或写回数据库。字段包括客户 ID、风险分层、risk_score、surv_6m、surv_12m。运营系统按风险分层做触发规则高分组自动进入外呼名单中等分组进入短信触达池低分组不打扰。做完这张表后再按周回看客户的实际流失情况持续校验阈值的合理性——分位数切分是静态的业务变化后阈值要跟着调。我现在做这类任务已经养成了固定习惯先看删失比例再画 KM然后才上 Cox最后一定会留一个风险分组的可视化作为交付物。这五个步骤走完模型再黑盒也不会让人心里发虚。希望这条从 kaggle 数据集到可落地风险分层的路线能帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。