用Python构建客户流失预测模型:从特征工程到上线监控
2026/9/17 14:04:54 网站建设 项目流程

简介:这是一份用于Python大数据分析课程期末实验参考的完整报告,以电信运营商客户流失预测为案例,面向初学者展示从数据导入、清洗、可视化到机器学习建模的完整流程。报告中不仅包含实验目的、环境配置与字段说明,还详细列出了随机森林、支持向量机、逻辑回归、KNN、朴素贝叶斯等算法的对比评估思路,并附有可复现的代码命令与结果解读,方便读者对照练习或改造用于自己的课题。包体为单个doc文档,体积仅1.42MB,下载后可直接打开阅读,无需额外解压或配置环境,适合期末报告撰写、课程设计参考以及入门机器学习实践。目前已有1460人学习/下载,内容源自山东财经大学课程实验报告,结构清晰、步骤完整,将数据处理与模型评价指标等关键环节都做了梳理,能帮助初学者快速理解客户流失预测的基本方法。若需要一份可以直接借鉴的实验报告范本,这份资源可以作为不错的起点。

1. 用Python建立客户流失预测模型:不是只追准确率的建模流程

“测试集AUC 0.93,运营看完只说了一句‘不好用’就下线了”——流失预测项目最常见的结局就是这样。客户流失预测模型本质上是一套用历史行为判断未来留存概率的决策引擎,输出不只是分数,而是一份能直接指导“给谁发券、什么时候发、用什么理由发”的挽救清单。我会按实际做这类项目的顺序处理四件事:先钉死流失口径,再做Python特征工程,然后训练对比模型、评估并输出可执行名单,最后落到上线监控上。整套流程用Python闭环,从数据清洗到模型部署都在同一个语言环境里跑通,这也是客户流失预测最主流的工程选型。适合数据分析师、算法工程师,以及想在团队里搭一条基线方案的Tech Lead;python入门阶段的同学也可以边跑边补基础。

2. 数据准备与特征工程:用Python构造不会泄漏的流失特征

2.1 先用SQL把流失口径钉死再谈建模

流失预测建模的第一步往往不是打开编辑器写Python,而是把一个基本问题回答清楚:什么样的用户算“流失”。同一个用户,订阅到期30天没续费算流失,还是连续60天不登录就算流失,对应到模型里是完全不同的目标函数,后面的特征、样本、评估全部会跟着变。口径定错,模型再漂亮也落不了地。

常见做法是按业务形态分:订阅制产品看“订单到期加宽限期”,比如会员到期后14天内没再付费就标记为流失;免费产品看“连续N天无活跃行为”,N天取业务上最长的一次自然使用周期,通常是7到30天。还有团队会把流失和“注销/卸载”分开,但注销事件太少且不可逆,放进模型里通常学不到有效信号。下面这张表是我在项目里经常贴着用的口径对照:

流失口径定义适用业务标签时点
订阅到期未续费订单到期 + 宽限期未付费订阅制SaaS到期日后第14天
行为沉默连续30天无活跃免费App/社区行为窗口结束次日
混合口径到期未续且无活跃订阅+免费混合产品两者判定日的较晚者

标签时点通常设为观测窗口结束日的第二天。比如行为窗口是5月1日到5月31日,那标签就看6月1日这个时间点用户是否已经进入沉默状态。这样模型学到的是:用前30天的行为预测未来30天会不会流失。

口径定完后,把标签落在SQL里。订阅制场景的典型写法是从订阅流水里取每个用户最近一次有效订阅的结束时间,再跟标签时点比较:

WITH last_sub AS ( SELECT user_id, MAX(sub_end_date) AS max_end_date FROM subscription_log WHERE status = 'active' GROUP BY user_id ) SELECT user_id, CASE WHEN max_end_date < '2024-06-01' THEN 1 ELSE 0 END AS churn_label FROM last_sub;

这段SQL先用MAX(sub_end_date)取出每个用户最近一段有效订阅的结束时间,再判断它是否早于标签时点2024-06-01。注意这里要拿max_end_date跟观测窗口的截止日比较,不要用数据库的CURRENT_DATE,否则标签会随系统时间漂移,导致训练样本每天都在变。SQL跑出来的结果集应该只有user_id和churn_label两个字段,前者是主键,后者是0/1标签,给后面的Python特征工程当监督信号。

2.2 用Python构造流失特征:RFM加趋势变化率

标签就绪后开始做特征工程。核心目标是把用户的历史行为压缩成一张宽表:一行是一个用户,一列是一个统计特征。RFM(最近一次消费、消费频次、消费金额)是起点,但它只描述了“状态”,描述不了“趋势”。流失信号往往是趋势性的:一个用户近30天支付金额比前30天掉了一半,比绝对值低更值得警惕。所以常见做法是在RFM之外额外构造窗口间变化率特征。

import pandas as pd label_date = pd.Timestamp('2024-06-01') user_log = pd.read_csv('user_behavior_log.csv', parse_dates=['event_time']) feature_df = user_log.groupby('user_id').apply( lambda df: pd.Series({ # 距标签时点多少天没活跃,数值越大风险越高 'last_active_days': (label_date - df['event_time'].max()).days, # 近7天活跃天数,用于捕捉近期活跃度 'active_days_7d': (df['event_time'] >= label_date - pd.Timedelta(days=7)).sum(), # 近30天支付金额,排除退款记录只算正交易 'pay_amount_30d': df.loc[df['event_type'] == 'pay', 'amount'].sum(), # 近7天/近30天行为频次比,逼近0代表活跃度正在萎缩 'action_cnt_7d_ratio': ( (df['event_time'] >= label_date - pd.Timedelta(days=7)).sum() / max((df['event_time'] >= label_date - pd.Timedelta(days=30)).sum(), 1) ) }) ).reset_index()

这段代码里label_date是特征观测截止日,所有行为统计必须发生在它之前,否则会把未来信息带进历史特征。last_active_days是距标签时点多少天没活跃,0天说明用户当天还在活跃,属于“还在用但快流失”的临界状态,需要结合付费特征一起看。action_cnt_7d_ratio是近7天行为次数除近30天行为次数,比值接近0说明活跃度在快速萎缩,这个趋势特征比绝对值更早暴露风险。分母加max(..., 1)是为了防止除零。

跑代码前先确认数据集的event_time都早于label_date,最省事的做法是在vscode python环境配置里把notebook跑起来,提前对event_time做一次过滤。特征列建议全部用数值型,缺失统一填0,树模型能处理NaN但在同一个环境里填0更直观。python数据分析与可视化阶段如果发现某列分布极不均匀,比如99%的用户没有支付行为,可以先把金额分箱再喂给模型,而不是让原始金额直接进逻辑回归。特征做完后顺手看一眼相关性矩阵,高度相关的特征保留一个即可,减轻共线性对逻辑回归的影响。

2.3 时间穿越泄漏:流失预测翻车的第一大原因

特征泄漏在流失预测里最常见的形态就是时间穿越。典型场景有两个:一是把“退款申请时间”算进特征,但退款动作其实发生在标签时点之后;二是对全量数据做标准化后再切训练集和测试集,验证集的信息通过缩放参数混进了训练过程。前者让AUC虚高,后者让上线效果断崖式下跌。

避免泄漏我有两条硬规则。第一,所有特征统一用label_date作为as-of日期,代码里禁止出现pd.Timestamp.now()。第二,先切分后处理,归一化、缺失值填充只在训练集上fit,再对验证集和测试集transform

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_val_scaled = scaler.transform(X_val) X_test_scaled = scaler.transform(X_test)

这里fit_transform只对训练集调用一次,验证集和测试集只用transform,保证验证集处于“未来不可见”状态,模拟上线后的新数据。很多python基础语法没问题的人在流失项目里翻车,就是因为在全量X上调用了fit_transform,训练集AUC高得离谱,上线后立刻打回原形。

泄漏检查还有个实用技巧:训练完模型后看特征重要性排序,如果某个特征重要性高到不合理,比如占比超过0.3,可以怀疑它泄露了标签。去查这个特征的取值时间分布,通常能抓到问题。业务侧也可以用最简单的常识判断:一个特征如果运营根本解释不了它为什么对流失有效,就先拿掉再跑一遍对比。

3. 模型训练与调参:从逻辑回归到LightGBM的对比基线

3.1 时间切分验证集:流失场景下不用随机抽样

流失预测不能用train_test_split(random_state=42)直接随机切。因为流失标签依赖时间,随机切分会把同一时间段的数据同时放进训练集和验证集,模型相当于“偷看”了同一时期的行为模式,验证AUC虚高。我一般按时间切:前70%到80%的时间窗口做训练,后20%到30%做验证。

具体操作时用用户最近一次活跃时间排序,再切分:

sample = feature_df.merge(label_df, on='user_id') sample = sample.sort_values('last_active_days', ascending=False) cut_idx = int(len(sample) * 0.8) train_df = sample.iloc[:cut_idx] val_df = sample.iloc[cut_idx:] X_train, y_train = train_df.drop(columns=['user_id', 'churn_label']), train_df['churn_label'] X_val, y_val = val_df.drop(columns=['user_id', 'churn_label']), val_df['churn_label']

这里last_active_days表示离标签时点多少天没活跃,值越大说明活跃时间越早,所以降序切分后前80%样本在时间上更接近标签时点。这种切法保证训练集只见过更早的数据,验证集模拟的是“未来一个月”的预测任务,跟线上打分场景一致。

时间切分也有代价:如果业务刚起步、历史数据不足12个月,强行按时间切会让训练样本太少。这时退一步的做法是用最近6个月数据做时间序列交叉验证,比如把6个月切成4个滑动fold,每轮用前3个月训练、后1个月验证,把4个AUC取平均。这个方法会多跑几轮,但在小数据集上比单次时间切分更稳。

3.2 逻辑回归基线:class_weight和标准化是必调项

第一个模型建议跑逻辑回归。它表达力不如树模型,但在早期阶段有两个价值:一是给后面的复杂模型提供一个可对比的baseline;二是系数可以直接给业务看,方便判断特征方向是否符合直觉。比如pay_amount_30d的系数为负,说明支付金额越高流失风险越低,这跟业务常识一致,系数方向反了就要回头查数据。

逻辑回归对特征尺度敏感,一定要先做标准化。直接看代码:

from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline pipe_lr = make_pipeline( StandardScaler(), LogisticRegression( class_weight='balanced', # 按类别数量自动加权,缓解样本不平衡 max_iter=1000, # 给求解器充分迭代空间 random_state=42 ) ) pipe_lr.fit(X_train, y_train)

class_weight='balanced'是根据训练集里正负样本数量自动加权,相当于把类别不平衡当成先验信息;流失用户通常只占5%到20%,不处理的话模型会把所有人都预测成“不流失”,AUC再高也没有业务价值。max_iter=1000是因为流失场景特征动辄几十维,默认的100次经常收敛不完全,调高后训练时间几乎没变化。

跑完后记录验证集AUC,用roc_auc_score(y_val, pipe_lr.predict_proba(X_val)[:, 1])计算。逻辑回归的baseline区间一般在0.72到0.82,如果远低于0.7,先回头检查特征而不是换模型。这里踩过的坑是:标准化前忘记处理极端离群值,某个用户消费金额特别大,把整列缩放压扁了,AUC直接掉到0.6。遇到这种情况可以先做log1p变换再标准化。

3.3 LightGBM主力模型:scale_pos_weight和早停是核心参数

树模型是流失预测的主力。LightGBM比XGBoost训练更快、内存占用更小,在百万级用户量下仍然跑得动,我一般直接用LGBMClassifier。其中两个参数必须调:scale_pos_weight和早停轮数。

import lightgbm as lgb # 负样本数量 / 正样本数量,比如流失率10%时这个值约等于9 pos_weight = (y_train == 0).sum() / (y_train == 1).sum() lgb_model = lgb.LGBMClassifier( n_estimators=2000, # 树的数量上限,靠早停控制实际值 learning_rate=0.05, # 步长,调小要配合加大n_estimators num_leaves=31, # 单棵树叶子数,过大容易过拟合 max_depth=5, # 限制深度,配合num_leaves使用 subsample=0.8, # 每轮随机采样80%的行 colsample_bytree=0.8, # 每棵树的特征采样比例 scale_pos_weight=pos_weight, # 正样本梯度放大倍数 random_state=42 ) lgb_model.fit( X_train, y_train, eval_set=[(X_val, y_val)], # 早停必须用验证集 eval_metric='auc', callbacks=[lgb.early_stopping(100), lgb.log_evaluation(100)] )

scale_pos_weight取负样本数除正样本数,比如流失率10%,这个值就是9,它把正样本的梯度放大9倍,效果等价于类别加权但不改变样本分布。n_estimators=2000配合early_stopping(100)的意思是先给足树的数量上限,然后在验证集AUC连续100轮不再提升时自动截断,避免过拟合。subsample=0.8colsample_bytree=0.8分别做行采样和列采样,提升泛化能力。

这里容易忽略的是:eval_set必须放时间切分出来的验证集,不能用训练集做早停,否则早停几乎不触发,模型只是在拟合训练集噪声。训练结束后用lgb_model.best_iteration_看实际用了几棵树,如果小于500说明模型比较容易收敛,可以适当加大learning_rate到0.1加快后续实验迭代。

特征重要性方面,树模型的feature_importances_基于分裂增益累计,会偏向取值多的连续特征。如果你发现数值型特征霸榜但业务解释不通,可以用sklearn.inspection.permutation_importance做一次置换重要性复核,两次排序差异大的特征要重点关注数据质量。

3.4 类别不平衡:用样本权重,别急着上SMOTE

很多python数据分析团队一看到流失率只有8%,第一反应是做SMOTE过采样。但SMOTE在流失预测里的问题很明显:它基于KNN在特征空间插值造新样本,高维稀疏特征下很容易造出“四不像”样本,模型学到的分布是假的,上线后面对真实分布立刻失效。

更好的做法是把类别不平衡当作业务先验处理。逻辑回归用class_weight='balanced',LightGBM用scale_pos_weight,本质都是调整损失函数的权重,而不是修改数据分布。如果调完权重后召回率仍然太低,优先降低分类阈值而不是堆采样。

模型优点缺点适用阶段
逻辑回归可解释、训练快、稳定表达力弱、需人工特征基线和业务走查
LightGBM非线性、支持缺失值、效率高需要调参、易在小样本过拟合主力上线模型
XGBoost生态成熟、效果稳定训练更慢、参数更多做第二对比模型

我一般会同时跑LightGBM和XGBoost,哪个验证集AUC高就上哪个。两者差值通常在0.01以内,这时候不纠结模型,而是把精力放到特征和阈值上。如果你还在python入门阶段,先跑通逻辑回归管线,再替换成LightGBM,改动量很小。分类阈值的选择要看业务成本:运营人手紧就提高阈值只捞高危用户,预算充足就降低阈值扩大触达面,这跟模型本身是两回事。

4. 模型评估与业务解读:输出运营能直接抄的挽救清单

4.1 别只看AUC:算一下top 10%的流失覆盖率

AUC衡量模型对所有样本的排序能力,但业务真正关心的是:如果我只处理前10%的用户,能覆盖多少将要流失的人。前者是统计指标,后者是运营指标。线上预算有限,运营不可能给全量用户发挽回策略,所以评估阶段我习惯把AUC和lift曲线一起看。

提升度的计算逻辑不复杂:按预测概率降序排列,看累计前百分之多少的用户覆盖了百分之多少的真实流失用户。用Python手算也就几行:

import numpy as np churn_rate_total = y_val.mean() df_eval = pd.DataFrame({'churn': y_val, 'prob': y_prob}) df_eval = df_eval.sort_values('prob', ascending=False).reset_index(drop=True) df_eval['cum_churn'] = df_eval['churn'].cumsum() df_eval['cum_churn_rate'] = df_eval['cum_churn'] / df_eval['churn'].sum() # 累积流失覆盖率 df_eval['depth'] = (np.arange(len(df_eval)) + 1) / len(df_eval) top10_coverage = df_eval.loc[df_eval['depth'] <= 0.1, 'cum_churn_rate'].iloc[-1] print(f'top 10% 覆盖流失用户比例: {top10_coverage:.2%}')

如果模型排序有效,top 10%应该能覆盖30%到50%的真实流失用户;覆盖比例低于25%说明模型排序能力弱,业务按名单投放的意义不大。观察整条cum_churn_rate曲线,曲线越早抬升,说明高分段的用户越集中地流失。

这里还可以补充一个Precision@K指标,K取每周运营能触达的用户数上限。比如运营一周最多联系5000人,那就取概率最高的5000人,算里面实际流失用户的比例。这个指标比AUC更贴近投放预算约束,建议每次实验都记录。

4.2 用SHAP值给每个用户生成“挽留理由”

运营拿到的名单不能只有一行“流失概率0.87”,否则他不知道该给这个用户发什么。常见做法是用SHAP给每个用户算特征贡献,把贡献最大的两三个特征翻译成业务话术。

import shap explainer = shap.TreeExplainer(lgb_model) shap_values = explainer.shap_values(X_val) def top_reasons(user_idx, top_n=3): vals = shap_values[user_idx] top_idx = np.argsort(-np.abs(vals))[:top_n] # 按贡献绝对值取前top_n个特征 return [(X_val.columns[i], round(vals[i], 4)) for i in top_idx] for idx in range(3): print('user:', df_eval['user_id'][idx], 'reasons:', top_reasons(idx))

shap.TreeExplainer对LightGBM是原生支持,输出每个特征对样本预测的贡献值,正负代表方向。比如last_active_days贡献为正,说明距上次活跃越久越容易流失;pay_amount_30d贡献为负,说明近30天消费越多越不容易流失。把这两条组合起来,运营就能写出“您已经28天没有登录,最近消费金额比之前下降了60%”这类针对性挽留文案,而不是群发同一张优惠券。

SHAP计算量不小,线上实时打分时不建议逐样本跑,批量离线算好存库就行。另外SHAP值在特征高度相关时会分散贡献,解释话术尽量选业务上站得住的top2特征,不要强行解释所有特征。

4.3 分群成本收益测算:把分数切成高、中、低三档

有了概率分,下一步是帮运营定预算。直接把连续分数切成三档,对应不同的触达方式和成本。切分阈值参考业务痛感来定:流失概率大于0.7的属于高危,0.4到0.7属于中危,低于0.4的低危不触达。

风险档流失概率区间触达方式参考挽回率单客成本
高危>0.7电话回访 + 人工15% - 25%8元
中危0.4 - 0.7优惠券 + PUSH8% - 12%2元
低危<0.4不触达-0元

测算逻辑是:高危档即使挽回率低,因为单客价值高,ROI通常仍然为正;中危档靠量取胜。比如1000个高危用户,挽回率20%,单个用户月均价值50元,挽回收益就是1000×20%×50 = 10000元,触达成本1000×8=8000元,净赚2000元。每个团队的数字不一样,但测算框架是通用的。分档阈值不建议用验证集上的最优分类阈值直接代替,因为业务成本和用户价值才是决定分档的因素。阈值确定后,每一档名单都要能回溯到原始特征,方便运营抽查。

5. 模型上线与监控:写个Python脚本每周自动跑一遍

5.1 批处理脚本:把模型封装成定时任务

到上线阶段,模型不再是notebook里的训练过程,而是每周固定产出一批流失预警名单。常见做法是把特征查询和预测逻辑封装成一个函数,用cron或Airflow调度。跑批脚本的核心是保持特征口径和训练时一致,特别是label_date要动态取当前周的截止日。

def run_weekly_churn_score(): features = load_features_from_db(as_of_date=week_end_date) score = lgb_model.predict_proba(features)[:, 1] result = pd.DataFrame({ 'user_id': features['user_id'], 'churn_score': score, 'score_date': week_end_date }) # 覆盖式写回预警表,保留最新一期名单 result.to_sql('churn_warning_weekly', engine, if_exists='replace', index=False)

load_features_from_db里直接用训练时同一条SQL,只是参数换成最新日期;to_sql把名单写回业务库,运营就能从看板里拿到分档名单。python环境配置里先确认能把lightgbmpandassqlalchemy装在同一个小环境,线上跑批尽量用训练时锁定的版本,避免库升级带来行为差异。

5.2 模型漂移监控:用PSI判断要不要重训

模型上线后不是一劳永逸。用户行为会变,流失口径可能调整,模型分也会跟着漂。我习惯监控两个东西:预测分数分布的PSI,以及特征重要性的变化。PSI是常用的稳定性指标,计算公式是把当前分数分布和训练集分数分布做分箱对比:

def compute_psi(expected, actual, bins=10): expected_hist, edges = np.histogram(expected, bins=bins, range=(0, 1)) actual_hist, _ = np.histogram(actual, bins=edges) psi = 0 for e, a in zip(expected_hist, actual_hist): e_pct = e / expected_hist.sum() a_pct = a / actual_hist.sum() if e_pct > 0 and a_pct > 0: # 逐箱累计差异,数值越大漂移越严重 psi += (a_pct - e_pct) * np.log(a_pct / e_pct) return psi

PSI小于0.1说明分布稳定,0.1到0.25说明有轻微漂移,大于0.25基本可以判定模型失效,需要重新训练。实际项目里用户行为随季节波动很大,电商大促前后PSI会明显升高,所以判断阈值不要只看单周数字,最好跟去年同期的基线对比。

5.3 用分层对照验证模型带来的增量挽回

最后一个技巧留给业务价值的验证。模型名单发给运营后,要证明是模型带来的挽回,而不是用户本来就不会走。做法是每期按预测分数分层,每层内随机分触达组和对照组,触达组发挽回策略,对照组不发,几周后比较两组的实际流失率差。

test_plan = pd.DataFrame({ 'user_id': user_ids, 'churn_score': scores, # 随机分实验组和对照组,各占50% 'group': np.random.choice(['treatment', 'control'], size=len(scores), p=[0.5, 0.5]) }) test_plan.to_sql('churn_test_plan', engine, if_exists='replace', index=False)

这个对照实验跑三期之后,就能在每一档算出差值挽回率。如果高危档的触达组比对照组流失率低了5个百分点,说明模型筛选和策略共同生效;如果两个组没有显著差异,问题出在策略而不是模型分。到这一步,模型价值才真正闭环。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询