资讯动态

客户流失预测实战:数据理解与特征编码全攻略

发布时间:2026/9/28 22:55:17 来源:尧图企业网站定制
客户流失预测是机器学习在业务侧最经典的落地场景之一。不管你是做电信、银行、SaaS还是电商只要业务有存量用户就绕不开“哪些用户快要走了”这个问题。我这次想分享的是一个完整的客户流失预测项目的上半部分项目背景梳理和特征编码这部分内容往往被很多人跳过但恰恰是决定模型上限的地方。这个系列我计划分成两篇来写。上篇就是你现在看到的聚焦“项目背景编码方案”从业务问题定义、数据理解一直讲到各种编码方法的选型和实操。下篇再讲建模调参、结果评估和上线部署。如果你正处于“会调包但不太清楚项目从头到尾怎么串起来”的阶段这篇应该能帮你把很多模糊的环节想明白。1. 项目背景先搞清楚业务到底要什么1.1 流失预测的本质是“存量经营”先说一句大实话很多入门者做流失预测上来就找数据集、跑模型却忽略了最重要的一步——理解业务。客户流失预测本质上不是一个“分类问题”而是一个“挽留决策支持问题”。你的模型输出谁要流失最终是要配合运营动作的比如送优惠券、专属客服回访、调整套餐等。我习惯在动手前先问自己三个问题预测出来之后运营团队能做什么如果不能对应到具体动作预测再准也没价值。预测的时间窗口是多长是预测未来一个月流失还是未来三个月这决定了标签怎么做。模型误判的代价是什么把“不会流失”的客户误判为“会流失”成本是营销资源浪费反过来漏判成本是客户真的走了。以电信行业为例获取一个新用户的成本通常是维护一个存量用户的5到8倍某些高端套餐客户甚至能到10倍以上。这也是为什么几乎所有运营商都在建流失预警体系。金融、SaaS订阅制行业同理MRR月度经常性收入的稳定性直接取决于流失率。这段理解题做扎实了后面所有技术选型都会顺理成章。比如评估指标选AUC还是选召回率特征工程里某些字段要不要处理都会因为业务目标不同而导向不同方案。1.2 项目目标与评估指标的约定定义问题的时候必须把“预测目标”讲清楚。我这次用的是经典的电信客户流失数据集目标字段是Churn表示该客户是否在观察期内终止服务。这个数据集字段不算多但非常典型几乎涵盖了流失预测里会遇到的所有特征类型数值型连续变量tenure在网时长、MonthlyCharges月费、TotalCharges总费用二分类变量gender性别、Partner是否有伴侣、Dependents是否有家属、PhoneService是否开通电话服务多分类变量Contract合约类型、PaymentMethod支付方式、InternetService互联网服务类型其他服务类字段OnlineSecurity、OnlineBackup、DeviceProtection、TechSupport、StreamingTV、StreamingMovies等数据集总共7043行每行是一个客户。流失客户占比约26.5%左右是一个典型的非均衡分类场景。评估指标上我的建议是不要只盯accuracy。因为非均衡数据下哪怕你预测所有人都不流失准确率也有73%左右但这个模型毫无价值。这个项目里我更看重AUC衡量排序能力适合评估模型整体区分度不受阈值影响召回率在给定精确率约束下看能捞回多少真正流失的客户业务模拟收益这才是终极指标。比如预测出1000个高流失风险客户运营投入挽留成本最终实际挽回了多少ARPU每用户平均收入在实际项目中我会和业务方拉一个简单的收益表挽留成本、成功挽留概率、客户生命周期价值然后反推模型需要在哪个召回率/精确率水平上才划算。这一步别省它能帮你在后续调参时避免陷入纯指标游戏中。2. 数据理解与探索性分析2.1 数据字段的业务含义这个数据集里的每个字段背后都有业务逻辑理解这些逻辑比直接跑模型有用得多。我逐个翻译一下tenure用户在网月数。这是一个非常有区分度的字段。通常来说在网时间越长越不容易流失因为用户已经形成了使用习惯切换成本高。Contract签约方式分为按月签约、一年合约、两年合约。长期合约客户流失率天然低于短期合约这个字段几乎可以视为流失的“风向标”。MonthlyCharges月费。这里的逻辑有点反直觉并不是月费越高流失越高而是要看用户对价格的敏感度通常需要和其他特征组合才有意义。TotalCharges累计费用。它和tenure、MonthlyCharges之间存在线性关系TotalCharges约等于tenure乘以MonthlyCharges使用时要注意多重共线性。PaymentMethod支付方式。银行自动扣款的用户流失率低于手动支付因为自动扣款降低了用户“主动中断”的门槛。TechSupport、OnlineSecurity等增值服务订购了更多增值服务的用户相当于在产品上绑定了更多东西流失率通常更低。如果你只看字段名就傻乎乎地全塞进模型也能跑出一个差不多的分数但你在做特征工程时会缺乏方向感。反过来当你理解了每个字段的业务含义你就能主动构造出更有解释性的特征比如把“用户是否同时开通了多项服务”做成一个计数特征。2.2 数据质量检查清单拿到数据后第一步不是EDA画图而是检查数据质量。我有一套固定流程几乎每个项目都用检查缺失值。这个数据集原本比较干净但TotalCharges字段在读取时可能因为含空格被解析成object类型需要先转成数值型不能直接进模型。检查唯一值数量和数据类型。用df.nunique()过一遍找出那些唯一值数量异常少的列它们大概率是分类变量。检查目标变量分布。确认正负样本比例这决定了后续要不要做采样处理。检查取值是否合法。比如tenure不可能为负数MonthlyCharges不会为0除非是特殊优惠出现这些情况往往是数据导入问题或上游ETL问题。这段我踩过很多坑。最典型的是某次项目中把“Unknown”值直接当成正常分类处理结果线上推理时业务方说“这类用户根本没有这个服务”导致特征分布偏移。所以每看到一个特殊取值都要和业务确认这是什么含义——是确实不存在还是数据缺失还是系统默认值。三者处理方式完全不同。2.3 核心变量与流失的关系简单看一下关键字段对流失的影响能帮你建立直觉合约类型按月签约用户流失率显著高于一年期和两年期差距通常在20个百分点以上。在网时长前6个月是流失高危期。新用户还没形成使用粘性最容易因为一次不好的体验就走。服务订阅数量订阅服务越多流失率越低。这些观察不需要太复杂的分析直接做分组统计或者画个柱状图就能看到趋势。它们最大的作用是帮你验证特征工程的方向比如构造“是否为新用户tenure6个月”这类衍生特征往往比模型自己从原始数据里学要更稳。3. 特征编码实战这一步决定了模型的上限3.1 为什么机器学习模型不能直接吃字符串这是本文的核心部分。几乎所有主流机器学习库的输入都是数值矩阵字符串类型的分类特征必须转换成数值后才能喂给模型。但不正确的编码方式会带来两个问题一是破坏类别之间的有序关系二是引入虚假的数值含义。我用一个生活化的例子来解释。假设分类字段是“支付方式”取值有“银行卡自动扣款”“信用卡自动扣款”“电子支票”“邮寄支票”。这4个类别之间没有大小关系你不能说“银行卡0、信用卡1、电子支票2”就表示电子支票是银行卡的2倍。这种数值化方式会让模型学到根本不存在的大小关系特别是对线性模型和树模型都会造成干扰。更麻烦的是不同模型对编码的容忍度不一样树模型XGBoost、LightGBM、随机森林对无序类别做简单的标签编码问题不大因为树模型是单个特征做切分不关心特征间的相对大小。线性模型逻辑回归、SVM和基于距离的模型KNN必须使用能正确表达类别差异的编码不然就是灾难。神经网络虽然也能吃标签编码但通常会配合Embedding层这属于另一个层面的话题。所以编码方案的选择不是“哪个准”而是“针对你的模型选哪个”。3.2 四种主流编码方法对比我把实际工作中最常用的4种编码方法梳理成一个对比表方便你对照选择。编码方法核心思想适用模型优点缺点Label Encoding标签编码给每个类别分配一个整数树模型简单高效不增加维度引入大小关系线性模型不可用One-Hot Encoding独热编码每个类别生成一个0/1列线性模型、神经网络、树模型无大小关系信息无损类别多时维度爆炸稀疏Ordinal Encoding序数编码按业务含义给有排序关系的类别编码任何模型保留顺序信息维度不增加要求类别真的有排序关系Target Encoding目标编码用类别对应的目标变量均值替代类别树模型、线性模型均可捕捉类别与目标的强关联维度不增容易过拟合和标签泄漏需交叉验证每个方法展开说一下Label Encoding最简单的方式sklearn.preprocessing.LabelEncoder一行搞定。对于树模型它其实和Ordinal Encoding是同一个东西。我见过很多入门项目直接用它处理所有分类变量在LightGBM里也能跑但如果同一个字段类别数很多且基数不平衡树模型的切分点选择会偏向类别数多的取值。这个影响通常可控但让所有无序类别共用这个编码方式不够严谨。One-Hot Encoding最“正统”的无序类别编码方式。pd.get_dummies()或者sklearn.preprocessing.OneHotEncoder都能做。它的代价是维度过大比如一个类别数200的字段会生成200列特征空间膨胀后训练速度变慢而且稀疏特征会让某些模型效果变差。所以使用前先看类别数超过一定阈值我一般定在2050之间看任务场景就需要考虑其他编码手段。Ordinal Encoding这个容易被忽略但它非常有用。适用于分类取值的类别之间有明确顺序的场景。比如说合约类型按月签约、一年合约、两年合约天然就是递增关系合约时长越长、客户粘性越大编码成0、1、2就合理。再比如客户满意度极不满意、不满意、一般、满意、非常满意这明显是15的等级关系。实际工作中遇到这种字段千万不要无脑One-Hot那就是把有用信息扔掉了。Target Encoding这是很多竞赛选手偏爱的编码方式在业务项目里用得好也很强大。核心做法是对每个类别计算该类别下目标变量比如是否流失的均值用这个均值替代原始类别。比如“支付方式银行卡自动扣款”的流失率是15%“支付方式邮寄支票”的流失率是45%那这两个类别就分别编码成0.15和0.45。它能非常紧凑地捕捉类别和目标之间的关系但是风险也大如果某个类别在训练集里样本少它的编码值会有很大噪声而且如果直接用全量数据的均值去编码再拿去训练会发生标签泄漏。正确做法是嵌套交叉验证或者使用平滑公式。我在后面的实操部分会给出具体代码。3.3 这个项目的编码方案到底怎么选回到我们这个电信客户流失项目我把数据里的分类字段分成三组来处理第一组有序分类字段用Ordinal Encoding。这里最典型的就是Contract合约类型按月签约、一年合约、两年合约按0、1、2编码。第二组无序多分类字段用One-Hot Encoding比如InternetServiceDSL、光纤、无、PaymentMethod4种支付方式。类别数都不多One-Hot完全能驾驭。第三组二分类字段有两种做法。一种是映射成0/1比如gender的Female和Male映射为0和1。另一种是保留为原始字符串让LightGBM原生处理分类特征。如果你的环境用的是LightGBM 3.0以上版本直接指定categorical_feature参数就行省心又高效。这里我额外多说一句TotalCharges这个字段虽然是数值型但它和tenure高度线性相关。要不要把它去掉我的建议是保留但要意识到模型会同时在两个特征上学到时间维度信息。如果后续做特征重要性分析发现它俩排名都很高也不奇怪。当然如果你有强迫症可以把TotalCharges替换成“平均每月费用”即TotalCharges除以tenure这个衍生特征有时反而更有解释力。4. 实操过程与核心环节实现4.1 环境准备与数据读取我建议用Python 3.9环境核心依赖是pandas、numpy、scikit-learn、LightGBM。直接上代码import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import LabelEncoder, OneHotEncoder, OrdinalEncoder from sklearn.compose import ColumnTransformer df pd.read_csv(telco_customer_churn.csv) # 先看一眼整体情况 print(df.shape) print(df.dtypes.value_counts()) print(df.isnull().sum().sum()) # TotalCharges列有问题需要清洗 df[TotalCharges] pd.to_numeric(df[TotalCharges], errorscoerce) df df.dropna(subset[TotalCharges])这里有一个关键细节pd.to_numeric遇到空字符串时会转成NaN所以要加errorscoerce然后再把含NaN的行删掉。在实际项目中出现空字符串往往是因为该客户从未被计费这类行要么删除要么单独作为一个“新客户”标记取决于业务口径。目标字段处理我单独列一下df[Churn] df[Churn].map({Yes: 1, No: 0})简单直接没有任何歧义。不要用LabelEncoder去处理这个二分类字符串因为Churn的类别含义就是二元的映射成0/1足够了。4.2 编码实操分门别类处理先把字段分好组这是编码前最重要的一步组织工作# 分类字段分组 ordinal_cols [Contract] # 合约类型有序 onehot_cols [InternetService, PaymentMethod, MultipleLines, OnlineSecurity, OnlineBackup, DeviceProtection, TechSupport, StreamingTV, StreamingMovies] binary_cols [gender, Partner, Dependents, PhoneService] # 数值字段 numeric_cols [tenure, MonthlyCharges, TotalCharges]你会注意到我把OnlineSecurity这些原本是“Yes/No/No internet service”的三分类字段归到了OneHot组。这里有个细节值得讲这类字段的取值里“No internet service”和“No”在语义上都表示“没有”但一个是“因为没开通互联网所以没有”另一个是“开通了互联网但没买这个安全服务”。直接合并成同一个类别会丢失信息保留三个类别更合理。用One-Hot编码正好可以区分开。然后是编码逻辑。我用ColumnTransformer把三类编码统一在一个流水线里这样后面做交叉验证和线上推理都不会出纰漏from sklearn.pipeline import Pipeline preprocessor ColumnTransformer( transformers[ (num, passthrough, numeric_cols), (ord, OrdinalEncoder(), ordinal_cols), (onehot, OneHotEncoder(handle_unknownignore), onehot_cols), (bin, passthrough, binary_cols) # 二分类先用passthrough稍后手动映射 ]) # 二分类字段直接映射更直观 for col in binary_cols: df[col] df[col].map({Yes: 1, No: 0, Female: 0, Male: 1, No phone service: 0})这里handle_unknownignore非常重要。它保证线上推理时如果出现训练集里没见过的类别不会被报错打断。在真实项目里新类别出现是常态比如支付方式新增了一种渠道你要是没有设置这个参数线上服务直接崩。4.3 目标编码的进阶做法如果有些字段类别数比较多One-Hot会显得笨重。这时候可以用Target Encoding。但直接写均值会过拟合我提供一个带平滑的版本def target_encoding_with_smooth(series, target, alpha20): 带平滑的目标编码避免小样本类别的噪声。 alpha越大编码值越向全局均值收缩。 global_mean target.mean() agg series.groupby(series).agg([sum, count]) smooth_mean (agg[sum] alpha * global_mean) / (agg[count] alpha) return series.map(smooth_mean)这个公式的本质是“借力”当某个类别的样本量足够大时它的编码值基本等于该类别的实际流失率当样本量很小比如只有3个样本且全部流失时编码值不会直接等于100%而是向整体均值收缩。这个技巧能有效避免小类别把模型带偏。但在完整项目里我不会直接用全量数据来算这个编码而是放到交叉验证的每一折内部去计算。否则就是泄露。一个标准做法是from sklearn.model_selection import StratifiedKFold def target_encode_cv(df, features, target, n_folds5, alpha20): df df.copy() skf StratifiedKFold(n_splitsn_folds, shuffleTrue, random_state42) for col in features: encoded pd.Series(np.nan, indexdf.index) for tr_idx, va_idx in skf.split(df, df[target]): tr_mean df.iloc[tr_idx][target].mean() agg df.iloc[tr_idx].groupby(df.iloc[tr_idx][col])[target].agg([sum, count]) smooth (agg[sum] alpha * tr_mean) / (agg[count] alpha) encoded.iloc[va_idx] df.iloc[va_idx][col].map(smooth) df[col _target] encoded return df这段代码的逻辑是每一折只用训练部分的数据来计算各类别的平滑编码然后映射到验证部分。这样验证部分的目标值不会渗透进编码计算信号是干净的。这个写法第一次看可能有点绕但它是避免标签泄漏的保命操作强烈建议你直接抄下来用。以这个电信数据集为例PaymentMethod会被我谨慎地做Target Encoding还是One-Hot我的判断是One-Hot就够了因为类别只有4个。只有当类别数大于10的时候我才优先考虑Target Encoding或者其他降维编码方式。编码不是越花哨越好够用就好。4.4 训练集划分与冻结编码做完后的下一步是划分训练集和测试集。这步看起来简单但有一些细节必须注意X df.drop(Churn, axis1) y df[Churn] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, stratifyy, random_state42 )stratifyy的意思是保持训练集和测试集中流失客户比例一致。如果省掉这一步万一随机划分出来的测试集流失率只有15%而训练集流失率是30%你的模型评估结果就会失真。特别是非均衡场景分层抽样是必须的。划分完数据之后要“冻结”测试集。什么意思就是测试集只在最终评估时用一次不要反复拿它来调参否则你对测试集的拟合会逐渐渗进模型选择过程最终测试分数虚高。所有调参、特征筛选都在训练集上做必要时再切出一部分做验证集。5. 常见问题与排查技巧实录5.1 编码以后维度爆炸怎么办有读者之前问我做One-Hot之后特征从十几个变成一千多个训练变得很慢。这种情况通常是分类字段基数太大。我的处理思路是分层次降维优先用类别频率编码Frequency Encoding把低频类别合并成一个“其他”类再送入One-Hot。实现很简单def cap_frequent_categories(series, threshold50): counts series.value_counts() rare counts[counts threshold].index if len(rare) 0: return series return series.where(~series.isin(rare), otherOTHER)这个操作的业务含义是出现次数极少的类别本身就不具备统计意义模型很难从几十个样本中学到稳定规律与其让它们撑大维度不如合并。这也符合奥卡姆剃刀原则。5.2 训练集和测试集编码不一致这是个很隐蔽的坑。如果你在训练集上做One-Hot得到10列然后直接用pd.get_dummies(X_test)测试集可能只有9列因为某个类别在测试集里没出现。列数对不上模型直接报错。更隐蔽的是Target Encoding的场景如果你在训练集上算好了映射字典测试集出现了一个训练集里没有的新类别map函数会返回NaN模型推理时要么报错要么得到一个无意义的默认值。解决办法有两条一是所有编码器都在训练集上fit然后transform测试集这也是sklearn Pipeline所推荐的做法二是对稀有类别统一归入一个“unknown”取值并为它单独分配一个编码值。我之前给的ColumnTransformer方案天然规避了这个问题因为fit/transform的边界清晰。5.3 标签泄漏最容易出现在哪里最后单独强调一次目标编码的泄漏问题。很多新手做Target Encoding时直接在整个数据集上算每个类别的目标均值然后再交叉验证。这个流程看似没问题但实际上验证集的目标信息已经在编码阶段被看到了模型评估结果会虚高不少。我见过有人测试集AUC做到0.92换成严格交叉验证后只有0.84差距非常明显。判断是否泄漏有一个简单原则任何特征的计算过程只要使用了当前样本的目标值或当前样本所在评估折的目标值就是泄漏。解决方案就是我上面给的target_encode_cv写法或者使用现成的库比如category_encoders它对这种场景提供了封装。5.4 关于数值型特征要不要标准化很多入门者一看到特征就想着标准化但树模型其实对尺度不敏感。我用LightGBM时从不做标准化它每棵树的切分都基于特征值排序绝对数值大小没有意义。但如果后续要换逻辑回归或者神经网络那就必须做标准化否则高量级的特征会主导梯度。我的建议是把标准化放到Pipeline里但通过参数控制是否启用建模阶段先跑一个没标准化的LightGBM基线再尝试其他方案。写在最后的一个经验编码这块在整个项目里看起来最不起眼但它直接决定了模型能不能学到真实信号。我踩过的最大一个坑就是早期做项目时把所有分类变量一律One-Hot导致一个基数100多的字段硬生生拓宽了特征维度模型效果反而不如做Label Encoding的树模型版本。后来我养成了习惯每拿到一个特征先问三个问题——这个特征有没有顺序类别基数有多大对业务意味着什么搞清楚之后才决定编码方式。编码做完、训练集划分好这个项目的“地基”就算打完了。下一篇我会讲特征筛选策略、LightGBM模型的参数调优、类别不平衡的处理以及最终怎么用业务视角去评估这个模型是否真的能帮运营团队少丢客户。你先把手里的编码环节跑通有任何问题欢迎在评论区或者对应社区交流我也很好奇你们在实际项目中用哪些编码方案处理过大基数分类特征。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑