简介:这套客户留存分析与流失预测系统是一份面向电信、保险等行业的客户流失分析实战项目,适合数据科学、人工智能相关专业的毕业设计或课程作业。项目围绕生存分析模型与随机森林分类器展开,既能刻画客户流失概率随时间变化的动态趋势,又可计算客户生命周期价值,并借助Flask Web应用实现模型部署与交互式预测。资源包共62个文件,约19.89MB,主要包括3个Jupyter Notebook分析建模脚本、模型文件pkl/bz2、Flask入口py、HTML前端页面、Procfile部署配置及49张可视化图表,结构清晰,便于对照学习。当前已有163人学习浏览。下载后可通过README快速上手,在真实数据集上复现探索性数据分析、生存曲线绘制、特征重要性解释与流失预测全流程,也可基于模型文件直接搭建Web演示,对理解客户留存分析落地方法有较高参考价值。
1. 用生存分析看客户留存:为什么不能只看单月流失率
单月流失率2%看似健康,但按这个速率,一年后留存率不足80%,三年后只剩一半。流失从来不是匀速发生的:新客户头三个月最容易走,合约到期前后又会涌出一波。普通分类模型能回答“谁会流失”,回答不了“什么时候流失”,而留存策略恰恰需要时间维度。
这个项目把留存拆成两层:先做生存分析,画出留存曲线、风险函数,估算客户生命周期价值(LTV);再做随机森林,预测每个客户是否流失;最后用Flask把模型打包成可视化系统。适合电信、保险等订阅制业务,也适合把分类概率与客户价值结合的运营场景。下文按生存分析建模、随机森林预测、Flask部署、落地验证拆解,使用的库是lifelines、scikit-learn和shap,可以直接在自己的Notebook里跑通。
2. 生存分析建模:KM曲线、风险函数与LTV计算
2.1 Kaplan-Meier曲线:处理删失数据的留存统计
在客户留存数据里,有一类样本必须特殊处理:观察期结束时还没有流失的客户。如果你简单地把他们标记为“未流失”,就丢失了“他们未来可能流失”的信息;如果把他们从样本里删掉,又会低估高留存客户的比例。生存分析用删失机制解决这个问题——没有被观察到事件发生的样本,会在它们的观测时长处提供部分信息。
Kaplan-Meier(KM)估计是最常用的非参数留存曲线。它按事件发生的每个时间点重新计算“存活概率”,公式上等价于把所有时间点的条件留存概率连乘。在lifelines里实现只需要两行:
from lifelines import KaplanMeierFitter kmf = KaplanMeierFitter() # durations:客户在网月数;event_observed:1表示流失,0表示删失 kmf.fit(durations=data['tenure'], event_observed=data['Churn']) kmf.plot_survival_function()这里durations要传入每个客户的观察时长,event_observed是二值事件标注。kmf.survival_function_会输出一张表,每一行是某个时间点的留存概率估计值。项目里的SurvivalCurve.png和tenure-churn.png,就是把整体和分组的KM曲线画出来后按业务解读用的。
分组对比在留存分析里更有价值。比如按Contract分成按月、一年期、两年期三组,分别拟合KM曲线,能立刻看到长期合同客户的留存平台明显更高。代码如下:
fig, ax = plt.subplots(figsize=(8, 5)) for contract in data['Contract'].unique(): mask = data['Contract'] == contract kmf.fit(data.loc[mask, 'tenure'], event_observed=data.loc[mask, 'Churn'], label=contract) kmf.plot_survival_function(ax=ax)输出结果通常符合直觉:合同越长,中位存活时间越靠后。真正值得关注的是曲线下降最陡的阶段,比如按月客户第3个月附近留存率快速下滑,意味着这个时间点前应该安排客户成功干预。
2.2 风险函数:找到流失最集中的时间窗
KM曲线看的是“存活下来的概率”,风险函数看的是“在某个时刻存活的前提下,紧接着流失的瞬时速率”。直觉上,如果风险在某个月份突然升高,说明那是一个高危险窗口。对于电信数据,典型的高风险窗口是试用期结束和合约到期前后。
lifelines的NelsonAalenFitter可以估计累积风险:
from lifelines import NelsonAalenFitter naf = NelsonAalenFitter() naf.fit(data['tenure'], event_observed=data['Churn']) naf.plot_cumulative_hazard()plot_cumulative_hazard()画的是累积风险曲线,它的斜率就是实际风险。斜率越大,该时点附近流失越快。项目中的hazard.png就是这份输出。解读时不要只看绝对值,而要看斜率变化:某一段突然变陡,意味着“熬过前几个月的客户在某一时间又开始加速流失”,常见原因是自动续费设置或者竞对促销窗口。
2.3 从生存曲线推算客户生命周期价值
有了整体生存曲线,LTV可以按“每月平均收入 × 期望在网月数”估算。期望在网月数在离散时间下就是生存概率曲线的积分。数学上,随机变量T的期望E[T] = ∫S(t)dt,积分从0到无穷,实践中取到观察期结束即可。用数值积分求解:
import numpy as np surv_probs = kmf.survival_function_.iloc[:, 0].values # 每个时间点的存活概率 expected_months = np.trapz(surv_probs, dx=1) # 按月积分,近似期望在网月数 arpu = data['MonthlyCharges'].mean() ltv = arpu * expected_months print(f"Expected months: {expected_months:.1f}, LTV: {arpu * expected_months:.2f}")np.trapz用梯形法做离散积分,dx=1表示每个月一个间隔。由于KM曲线在后期会趋于一个平台,截断到最大观察月数会低估一点期望月数,但在留存分析里通常足够了。更严谨的做法是用指数分布延拓尾部,对订阅业务影响很小。
把整体LTV拆到用户群维度,就得到了运营预算分配的依据。我一般会做一个表格,把不同合约类型的中位存活月数和留存策略列在一起:
| 合约类型 | 中位存活月数 | 风险特征 | 建议动作 |
|---|---|---|---|
| 按月 | 观察到的输出值约几个月 | 前3个月风险陡增 | 首月结束前做回访,推送季度折扣 |
| 一年期 | 明显高于按月 | 到期前2个月风险上升 | 提前60天推荐续约方案 |
| 两年期 | 最高 | 基期风险低,到期后可能有波动 | 用权益包锁定续费 |
这里的数值会因数据集不同而不同,但分析流程是通用的:先看生存曲线,再找风险陡增窗口,最后用该窗口设计干预路径。
2.4 生存分析模型的边界
生存分析擅长回答“整体留存如何随时间变化”,却不擅长回答“某个具体客户为什么流失”。它的输出是群体层次的概率,没有利用个体特征做差异化判断。要解决这个问题,需要引入机器学习分类模型,这也正是项目里随机森林模型存在的理由。
3. 随机森林流失预测:特征、训练与可解释性
3.1 特征工程:从原始字段到可入模数据
原始数据包含大量类别特征:InternetService有DSL/光纤/无,Contract有按月/一年/两年,PaymentMethod有四种。直接将字符串喂给随机森林不行,需要先做编码。常见的做法是用pandas的get_dummies做独热编码,或者用LabelEncoder做有序编码。对于PaymentMethod这种没有顺序关系的类别,我倾向用one-hot:
cat_cols = ['InternetService', 'Contract', 'PaymentMethod', 'OnlineSecurity', 'OnlineBackup', 'TechSupport'] data_encoded = pd.get_dummies(data, columns=cat_cols, drop_first=True)注意drop_first=True会删除每个类别的第一列,避免多重共线性。随机森林对共线性不敏感,但保留它能让特征矩阵更小,同时保持树节点分裂时的信息增益一致。
数值型特征里,tenure和TotalCharges存在较强相关,因为总费用是从在网月数和月费累加出来的。直接把两者都放进去,树模型通常还能工作,但会出现特征重要性在两个变量间来回分配,影响解释。我一般把tenure分为几个区间,比如0-6、7-12、13-24、25-36、37-48、49+,让模型感知到“新客户”和“老客户”的非线性差异:
data['tenure_group'] = pd.cut(data['tenure'], bins=[0, 6, 12, 24, 36, 48, 100], labels=['0-6','7-12','13-24','25-36','37-48','49+'])pd.cut边界选择要结合业务经验:电信客户通常以半年、一年、两年为续费节点,边界落在这些位置能让分箱有业务含义。除了tenure_group,还可以构造“客户是否同时订购了多项服务”的交叉特征。但要注意,特征不是越多越好,树模型在高维稀疏特征上同样会过拟合。
3.2 随机森林参数选择与训练
随机森林的本质是Bagging加随机特征子集。它比单棵决策树稳定,比梯度提升树训练快,对类别不平衡也有一定容忍力。在流失预测场景,正负样本通常不平衡,我一般会设置class_weight='balanced'来让少数类的误判有更高代价。
下面是一组在Jupyter Notebook里可以直接跑的配置:
from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split X = data_encoded.drop('Churn', axis=1) y = data_encoded['Churn'] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) model = RandomForestClassifier( n_estimators=300, max_depth=8, min_samples_leaf=20, class_weight='balanced', random_state=42 ) model.fit(X_train, y_train)参数选择上,n_estimators=300可以在性能和稳定性之间取得平衡;max_depth=8限制树深度,防止对噪声和高维独热特征过拟合;min_samples_leaf=20强制叶节点至少包含20个样本,在流失率20%的数据里,这个值让叶节点的流失比例估计相对稳定。这些参数不一定全局最优,但作为起点很合适,调参时优先调整max_depth和min_samples_leaf,n_estimators收益递减很快。
| 参数 | 推荐值 | 作用 |
|---|---|---|
| n_estimators | 300 | 增加树数量,降低方差;超过300后边际收益很小 |
| max_depth | 8 | 限制树深度,防止对高维独热特征过拟合 |
| min_samples_leaf | 20 | 叶节点最小样本数,让概率估计更平滑 |
| class_weight | balanced | 按类别频率反向加权,缓解正负样本不平衡 |
训练完之后,把模型保存下来,部署阶段直接加载:
import joblib joblib.dump(model, 'model.pkl')同时保存一个等比例的预测概率结果,用于后续画提升曲线。项目根目录里的model.pkl就是这一步产物。
3.3 SHAP:从整体重要性到个体解释
随机森林自带的feature_importances_只能告诉用户“tenure和Contract很重要”,但重要性和方向绑在一起时才有业务价值。SHAP通过博弈论中的Shapley值,给每个样本的每个特征分配一个贡献值。贡献值为正表示推动流失概率上升,负值表示抑制流失。
项目里的explainer.bz2是压缩后的TreeExplainer。加载后直接对测试集解释:
import bz2 import joblib import shap model = joblib.load('model.pkl') with bz2.BZ2File('explainer.bz2', 'rb') as f: explainer = joblib.load(f) shap_values = explainer.shap_values(X_test) shap.summary_plot(shap_values, X_test)由于是二分类,shap_values是一个二维数组,维度对应样本和特征。summary_plot会把所有样本的点画在一张图上,红色表示特征值高,蓝色表示特征值低。以tenure为例,通常能看到低tenure样本的红点集中在Shapley值为正的一侧,说明在网时间短是流失信号。
如果还想看单个特征对流失概率的边际效应,可以用偏依赖图。项目里的pdp_tenure.png和pdp_contract.png就是这类图:
from sklearn.inspection import PartialDependenceDisplay PartialDependenceDisplay.from_estimator(model, X_train, ['tenure'], grid_resolution=30)偏依赖图把其他特征求平均后,画出目标特征变化时预测概率的走势。它可以验证业务直觉:tenure在0-6个月时流失概率陡降,之后趋于平缓。这一步对抗业务方质疑“模型就是黑盒”很有效。
3.4 二分类概率与生存分析输出怎么配合
随机森林输出的是一个静态概率P(churn),生存分析输出的是S(t)。在实践中,我会用随机森林选人,用生存曲线定时间。比如高流失概率的客户里,优先处理那些生存曲线在3个月内下坠最快的群组,而不是对所有高概率客户一视同仁地发优惠券。这个组合是后面Flask系统里最值得展示的部分。
4. Flask部署:把模型包成可交互的留存分析系统
4.1 项目结构与模型加载方式
解压zip后,项目目录结构如下:
Customer-Survival-Analysis-and-Churn-Prediction-master/ ├── app.py ├── README.md ├── requirements.txt ├── model.pkl ├── survivemodel.pkl ├── explainer.bz2 ├── templates/ │ └── index.html └── static/ └── images/ # 存放所有分析图app.py是Flask入口,templates/index.html是前端页面,static/images里放的是SurvivalCurve.png、shap.png、pdp_tenure.png等图表。部署前先在项目根目录安装依赖:
pip install -r requirements.txt如果本机还没有这些库,requirements.txt里通常包含flask、lifelines、scikit-learn、shap、joblib。加载模型时不能直接pickle.load(model.pkl),因为里面是sklearn的RandomForestClassifier对象,用joblib更安全:
import joblib import bz2 from flask import Flask, request, jsonify, render_template model = joblib.load('model.pkl') with bz2.BZ2File('explainer.bz2', 'rb') as f: explainer = joblib.load(f)bz2.BZ2File负责解压,外层的joblib.load负责反序列化。如果explainer.bz2是用shap.save保存的,应该用shap.load或者joblib.load都可以,取决于写文件时的方式。最稳妥的办法是看一眼模型文件头几个字节,但实际操作中按README来就好。
4.2 构建/predict预测接口
Flask应用只需要提供两个路由:/渲染网页,/predict接收表单数据并返回流失概率。这里最容易被忽略的一点是:表单传进来的原始字段要经过和训练时完全相同的预处理,才能保证特征矩阵列顺序一致。
app = Flask(__name__) @app.route('/', methods=['GET']) def index(): return render_template('index.html') @app.route('/predict', methods=['POST']) def predict(): form = request.form # 构造与训练时一致的特征向量 sample = { 'tenure': int(form.get('tenure', 1)), 'MonthlyCharges': float(form.get('monthly_charges', 0)), 'TotalCharges': float(form.get('total_charges', 0)), 'Contract_Month-to-month': 1 if form.get('contract') == 'Month-to-month' else 0, 'Contract_One year': 1 if form.get('contract') == 'One year' else 0, } model_cols = joblib.load('columns.pkl') sample_df = pd.DataFrame([sample]) X = sample_df.reindex(columns=model_cols, fill_value=0) prob = model.predict_proba(X)[:, 1][0] return jsonify({'churn_probability': round(prob, 4)})代码里先创建一个dict,再转DataFrame,最后用reindex按训练时的列顺序对齐,缺失列填0。columns.pkl是训练完成后用joblib.dump(X_train.columns.tolist(), 'columns.pkl')保存的,部署时再加载。predict_proba返回的是二维数组,第二列是正类的概率,取[:, 1][0]即可。返回JSON而不是渲染模板,方便前端异步刷新。
4.3 前端模板怎么承载可视化图
index.html的核心排版是左侧表单、右侧结果区,下面平铺图片。图片路径直接指向static目录:
<img src="{{ url_for('static', filename='images/survival.png') }}" class="chart"> <img src="{{ url_for('static', filename='images/shap.png') }}" class="chart">Flask的url_for('static', ...)会自动映射到static文件夹,不要写死成/static/images/...,否则项目迁移到子路径时图片会掉。用户点击预测后,前端用fetch把表单POST到/predict,拿回概率后更新页面上的数字,同时把生存曲线、SHAP图展示在下方。整个交互体验就是:输入客户信息,输出流失概率和可视化的模型解释。
启动应用:
python app.py默认端口是5000,浏览器访问http://127.0.0.1:5000。如果端口被占用,可以修改app.py的最后一行app.run(port=5001)来切换。还有一点需要提醒:从zip解压后,不要直接在压缩包预览里运行,先把目录完整解压到本地,否则相对路径会指向错误。
提示:部署时先确认当前工作目录是项目根目录,再执行python app.py。直接双击app.py可能导致相对路径错误。
4.4 部署中容易踩的三个坑
第一个坑是特征顺序不一致。训练时get_dummies产生的列顺序依赖当时的DataFrame列顺序,部署时如果手工构造特征dict漏掉某些列,predict_proba会报错或者静默错位。解决方法是保存X_train.columns并在加载后调用reindex(columns=model_cols, fill_value=0)。第二个坑是shap版本不兼容,不同版本的TreeExplainer序列化格式有变化,最好在训练和部署环境锁定shap版本。第三个坑是没有设置静态资源缓存策略,图片文件较大时,每次刷新都重新加载,视觉上很慢。可以给Flask挂一个send_file缓存头,但对内网演示系统来说不是必选项。
5. 落地验证:把概率切分和存活曲线合成运营动作
5.1 按时间切分验证集,防止数据泄漏
很多流失预测项目会用随机切分train_test_split,这在订阅数据里并不安全。原因是观察期内的客户特征可能是期末快照,如果同一个客户在时间上前后矛盾,模型会学到“未来信息”。更稳妥的验证方式是按月份切分:用第1到第12个月的客户训练,用第13个月以后的客户验证。实际操作时,给数据加一个月份字段,然后:
train = data[data['signup_month'] < 12] test = data[(data['signup_month'] >= 12) & (data['signup_month'] < 18)]这样训练集和测试集在时间上完全隔离,评估出的AUC才是对未来真实表现的近似。如果原始数据没有月份字段,可以用tenure和观察截止时间倒推一个近似签约批次。
5.2 用提升曲线选择触达名单
流失预测模型的输出概率本身不是决策,真正落地时要考虑成本约束:预算只够覆盖10%或20%的客户。此时应该看提升曲线,按预测概率排序,取前20%名单,检查这20%目标客户覆盖了实际流失案例的多少比例。
from scipy.stats import rankdata prob = model.predict_proba(X_test)[:, 1] rank = rankdata(-prob) / len(prob) # 从高到低排名,归一化 top20 = rank <= 0.2 recall_at_20 = y_test[top20].mean() / y_test.mean() print(f"Top 20% recall ratio: {recall_at_20:.2f}")如果recall ratio接近2.5,说明前20%名单里流失密度是整体的2.5倍,这个名单就比随机抽取强很多。模型产出最后落到运营端时,我会再叠加一层LTV:对高流失概率的客户,按LTV从高到低分配触达顺序。具体动作结合生存曲线的风险窗口——如果客户在3个月内有明显风险上升趋势,就在第2个月末发送发票提醒或赠送权益。
5.3 一个可落地的优先级公式
把三个指标合成单一评分:priority = churn_prob * normalized_ltv * hazard_multiplier。hazard_multiplier是生存曲线在下一个时间窗内的风险增量,例如1 + (累积风险[t+3] - 累积风险[t])。这样既识别了“会不会流失”,又识别了“值不值得救”和“什么时间救”。这个公式可以写成一个简单的Flask接口,直接返回给运营系统使用。
本文还有配套的精品资源,点击获取