
1. 项目背景与目标拆解1.1 为什么选客户流失预测当实战项目我接触机器学习项目实战有一段时间了坦白说分类任务里最容易被新手拿来练手的永远是鸢尾花、手写数字这一类教科书级数据集。它们干净、小、标签明确跑通一个逻辑回归就能让人产生“我会机器学习了”的错觉。但一进入真实业务场景问题立刻变了样数据有缺失、类别不平衡、特征含义模糊、业务方要的不是准确率而是可解释性……这时候再看那些demo项目基本等于玩具。这也是我这次把《客户流失预测》做成系列实战课的原因。流失预测是电信、金融、SaaS订阅制行业里非常经典的问题它天然具备真实业务的所有难点用户生命周期数据、消费行为数据、客服交互数据混在一起正负样本比例往往悬殊而且预测结果直接关联营销策略和挽留成本。用一个这样的项目把数据预处理、特征工程、模型训练、评估调参、结果解释全流程走一遍比刷十个Kaggle入门比赛都管用。这个“进阶篇-机器学习篇-18”定位就很清楚不是给完全零基础的人讲什么是机器学习而是给已经会调sklearn、知道交叉验证是什么的读者一个完整的实战框架。所以我把整个项目拆成了“上、中、下”三篇这一篇先解决两件事搞清楚业务背景和评估口径然后完成最脏最累的编码工作——把原始字段变成模型能吃的特征矩阵。1.2 这个项目到底在解决什么业务问题先还原一下场景某电信运营商有几十万在网用户每个月都有一批人到期不续费、或者中途停机销号这批人就是“流失用户”。流失意味着收入直接减少而且获客成本通常是留存成本的5到7倍所以运营商希望能在用户真正流失之前提前一两个月识别出高风险群体然后让客服去定向做优惠券、套餐升级、专属回访等挽留动作。这就引出客户流失预测的核心问题基于用户过去一段时间的行为数据构建一个分类模型预测未来某段时间内该用户是否会流失。注意这里有两个关键点实战中特别容易搞混预测不是“事后归因”而是“事前预警”。模型用的是t时刻之前的数据预测的是t1、t2时刻的状态绝不能把流失发生当月的特征直接灌进去那是作弊。业务方真正关心的不是“谁流失了”而是“哪些高价值用户流失了”。一个月消费20元的用户流失和一个月消费2000元的用户流失挽留优先级完全不同。所以后面评估模型时不能只看AUC还要结合用户的生命周期价值做分层分析。我这套项目的数据集是模拟真实运营商脱敏后的用户档案、服务开通记录、消费账单和客服工单字段量不算大但脏得真实有文本型字段、有时间型字段、有高基数类别字段、有缺失严重的字段……正好拿来练手。1.3 适合谁看看完能收获什么如果你是下面这几类人这一篇对你会有用学完机器学习基础理论想找一个能写进简历的完整项目在业务部门做数据分析想转型算法岗需要理解“从业务问题到模型落地”的完整链路已经跑通过几个天池/Titanic练习赛但没见过真实业务数据的脏乱差想感受一下实际工程里的数据长什么样。这一篇里你不会看到模型调参因为我们第一步就得先把数据啃下来。我会带着你完成业务口径梳理、数据探索、缺失值处理、类别编码、特征构造、训练集与验证集划分。等到下一篇我们再拿这些特征去训练XGBoost、LightGBM并做特征重要性分析和模型解释。所以这篇的标题虽然只写了“项目背景和编码”但编码这块的工程量一点都不小它直接决定了后面模型能跑多高。2. 数据探索与业务口径梳理2.1 先别急着写代码把字段和业务对齐拿到一份数据我习惯先建一个字段字典表把每个字段的中文含义、类型、可能取值、缺失情况、业务口径全部列出来。这样做的原因很简单数据集的列名往往是比较抽象的英文字母不逐个确认就做特征的话后面分析结果时根本不知道模型在依赖什么信息。我这套数据大概是这样的结构已经是脱敏后的映射字段字段名含义类型典型取值/说明customer_id用户唯一标识字符串每个用户一行gender性别类别Male / Femalesenior_citizen是否老年用户0/11表示是partner是否有配偶类别Yes / Nodependents是否有家属类别Yes / Notenure在网月数数值0~72phone_service是否开通电话服务类别Yes / Nomultiple_lines是否多条线路类别Yes / No / No phone serviceinternet_service互联网服务类型类别DSL / Fiber optic / Noonline_security在线安全服务类别Yes / No / No internet serviceonline_backup在线备份服务类别同上device_protection设备保护服务类别同上tech_support技术支持服务类别同上streaming_tv流媒体电视类别同上streaming_movies流媒体电影类别同上contract合约类型类别Month-to-month / One year / Two yearpaperless_billing无纸化账单类别Yes / Nopayment_method付款方式类别Electronic check / Mailed check / Bank transfer / Credit cardmonthly_charges月消费金额数值单位美元total_charges累计消费金额数值单位美元churn是否流失0/11表示流失目标变量这里有几个字段一眼就能看出问题multiple_lines里有“No phone service”这其实是“用户没开电话服务”的语义而不是“开了电话服务但没有多条线路”online_security这些增值服务字段里的“No internet service”也是同理。如果不处理模型会把这些特殊值当成一种独立的服务状态但业务上它们其实等同于“该服务不适用”。这种从业务逻辑出发的字段对齐是第一步要做的。2.2 目标变量“churn”的口径确认模型要预测的churn在很多真实数据集里不是简简单单的0/1。有的公司把“连续90天未登录”算流失有的把“停止续费且未复购”算流失有的把“投诉后销户”算流失。所以拿到数据后第一件事不是看分布而是确认这个标签是怎么定义的。我用的这份数据里churn的含义是在观察期结束后两个月内用户主动注销了账户。注意这里排除了“因欠费被停机”的用户也排除了“套餐到期后自动转为非合约月付但仍在网”的用户。确认口径的作用是后面做特征工程时我才能准确把握时间窗口——比如合约类型是“按月付费”的用户在一个月到期后不续费如果没在下个月注销那他还是“在网”只有真正关闭账户的用户才打1。这个口径在真实业务里一定要跟业务方反复确认否则你构建的“流失用户画像”很可能偏掉。另外类别不平衡是流失预测的常态。我在探索阶段先统计一下正负比例如果你的数据集里流失率只有10%~20%那后面就不能用默认的准确率当评估指标要么上AUC、PR曲线要么在训练时调整类别权重要么做采样。这些我会放在中篇详细展开但在本篇编码阶段就要有意识地保留原始分布不要提前做重采样避免数据泄漏。2.3 数据质量初检缺失、异常、类型错误拿到数据后我一般会写一个快速探查脚本把每一列的缺失值数量、唯一值数量、数据类型、最频繁取值一次打出来。别急着加载到DataFrame里瞥一眼就开干。这个探查脚本能帮你发现一堆隐藏问题。在实际数据里我预期会遇到下面几类典型的脏数据问题缺失值total_charges大概率有缺失。原因很常见新用户在第一个账单周期还没出账累计金额为空。这在业务上不是“缺失”而是“还不存在”处理方式应该用0填充而不是用均值填充。类型错误total_charges在原始表里可能被读成了字符串因为部分值为空或写成“-”。需要先转成数值类型否则后面无法做数值型特征。异常值monthly_charges的取值不可能为负数如果出现负数多半是数据导入时多加了解析符号tenure也不可能大于数据集的观测周期超过最大月数就要排查是不是统计口径错了。重复用户customer_id理论上唯一但真实数据里经常有重复行。如果不检查重复样本会被同时分到训练集和验证集导致验证集虚高。这些问题的排查我习惯用一个统一的函数一次性输出所有列的统计信息输出后逐个看并标注处理方案。这一步不用太花哨关键是别漏。3. 编码前的数据清洗与缺失值处理3.1 “数据清洗”具体在洗什么数据清洗不是把空值填了就完事它要处理四类问题缺失值、异常值、重复值、类型不一致。每一类都要结合业务背景决定处理策略而不是机械套用“均值填充”“删除缺失行”。先说重复值。我遇到的这份数据里真的出现了完全重复的行连customer_id都一样。这可能是上游抽取数据时做了两次关联、也可能用户在最后一个月同时出现在两张月快照表里。处理方式很明确按customer_id去重保留第一条。但去重前先确认customer_id是否唯一如果本身不唯一就要检查是否是同一个用户的多条记录那样就不是简单去重而是要做聚合了。再说异常值。tenure是在网月数正常范围应该是0到观测周期最大月数。如果数据集记录了72个月那tenure最大就是72超过72肯定是错的。我处理异常值的原则是能根据业务逻辑判断的直接修正或剔除不能判断的先标记为缺失后面统一处理。比如monthly_charges为负数直接删掉这一行因为没有任何合理的业务解释。类型不一致是最常见的坑。特别是从CSV导入时total_charges这种数值列常常因为个别空值被读成object。我的做法是import pandas as pd import numpy as np df pd.read_csv(telecom_churn.csv) print(df.dtypes) # 检查类型 df[total_charges] pd.to_numeric(df[total_charges], errorscoerce)errorscoerce会把无法转换的值强制变成NaN这样我们就能统一看到缺失情况。这一步做完再把缺失的total_charges按业务逻辑填充。3.2 缺失值处理策略为什么不能一刀切均值填充缺失值处理看似简单其实最容易埋雷。不同字段的缺失原因不同处理方式也不同。我给这份数据定的策略是这样字段缺失原因处理方式total_charges新用户未出账用0填充同时新增一个“是否新用户”标记monthly_charges数据录入遗漏用同年限用户的均值填充或直接剔除online_security等增值服务未开通互联网服务不填充保留“No”并基于业务合并为“不适用”gender录入遗漏用众数填充但这种字段重要性很低填充影响不大tenure缺失极少若缺失则删除该行因为它是核心特征不能瞎猜这里解释一下为什么total_charges填充0而不是均值新用户没有历史累计消费这是“事实为零”而不是“未知”。如果用均值填充会给新用户一个根本不存在的虚拟消费历史模型学到的是“新用户平均消费很高”这种错觉。同时我新增了一个布尔特征is_new_user用于标记tenure小于等于1个月或total_charges为0的用户这比单纯的填充更能捕捉新用户的流失倾向。实战中我发现新用户的流失率往往远高于老用户这个特征后面会非常有区分度。对于那些“No internet service”的增值服务字段我的做法不是填缺失值而是做业务合并。比如online_security的取值有“Yes/No/No internet service”我把它拆成两个特征一个表示“是否开通该服务”Yes1,其余0另一个表示“是否适用互联网服务”No0, 其余1。但考虑到字段数量不多我倾向于在编码阶段就直接把三个类别映射成有序的数值No0, Yes1, No internet service0因为“不适用”和“未开通”对于模型来说本质上都是“没有这个服务”。这个映射比独热更简洁而且不会引入过多维度。3.3 时间字段和文本字段的处理这份数据里没有特别复杂的时间戳字段但真实项目中一定会有“注册日期”“最后登录日期”“最近一次充值日期”。处理这类字段的通用思路是不直接用日期字符串而是计算时间差。比如“最近一次充值距今天数”“注册到现在的天数”“最后一次客服工单距离今天数”。这类相对时间特征比绝对时间有用得多因为模型应该学习的是“用户多久没活动了”而不是“今天是几月几号”。编码这块因为我的数据是静态快照没有真正的时间序列所以我构造了tenure在网月数和is_new_user新用户标记。如果读者拿到的是有时间序列的数据建议不要用原始时间字段而是构造出“距上次交互天数”“月均交互次数”“近三个月消费变化率”这类窗口统计特征。这些特征我会在下一节详细介绍。4. 类别特征的编码方案设计4.1 二分类别、多类别、有序类别分开处理类别特征编码是“编码”这个项目标题里最核心的部分。很多教材只讲“类别变量做独热编码”但实际业务里不同类别特征的性质完全不同处理方式也应该不同。我把这份数据里的类别特征分成三类二分类别genderMale/Female、partner、dependents、phone_service、paperless_billing。这些字段直接映射成0/1即可不需要独热。多分类别无序internet_serviceDSL/Fiber optic/No、payment_method四种方式。这类用独热编码或者用频率编码、目标编码但本篇先用最稳妥的独热。多分类别有序contractMonth-to-month/One year/Two year。这明显是有序的从短期到长期映射成0/1/2比独热更好既能减少维度又能保留“合约时间越长流失风险越低”的单调关系。带特殊状态的多分类别multiple_lines以及那些增值服务字段。前面已经说了特殊状态“No phone service/No internet service”要映射成0与No一致。4.2 独热编码的维度爆炸问题怎么破独热编码是处理无序类别最直接的方法毒性也很明显如果类别基数大维度爆炸且特征稀疏。比如payment_method只有4类独热后增加3列完全没问题但如果某个字段有50个独立取值独热后就有50列训练速度慢还容易过拟合。我推荐几种替代方案频率编码用每个类别的出现频率代替0/1。比如payment_method里的“Electronic check”出现次数占40%那就填0.4。优点是保留了类别间的相对大小信息缺点是可能引入目标泄漏尤其是结合目标变量做频率统计时所以频率编码最好只针对“出现次数极少的类别”做合并处理。目标编码Target Encoding用该类别下目标变量此处即流失率的均值作为编码值。这个方法编出来的特征和标签高度相关很容易过拟合必须配合交叉验证或平滑系数使用。因为我打算在下一篇用XGBoost这类树模型树模型对目标编码的利用效率其实不高而且容易泄漏所以这里我不推荐做目标编码避免新手控制不好。Embedding Embedding如果你后续接深度学习模型类别特征可以嵌入成低维向量。但传统机器学习项目里没必要为了三个小字段上神经网络杀鸡用牛刀。所以我在这篇里的基本选择是二分类别直接映射有序类别用序号多维且语义清晰的无序类别用独热特殊状态字段按业务逻辑合并。既保证模型能区分又不至于让特征空间膨胀。4.3 为什么不推荐给所有类别特征做独热这里要展开解释一下。很多人在项目里一看到“字符串列”就条件反射pd.get_dummies(df, columns[gender, contract, ...])然后扔给模型。我早期也这么干后来发现两个问题第一树模型对类别特征的原始序号并不敏感你把它映射成0/1/2它完全可以通过多次分裂找到这个边界。但如果你所有类别都做独热树模型反而会把每个0/1特征单独当作一个有/无特征使用这会增加内存开销而且对“有序类别”来说会丢失顺序信息。比如contract按月、一年、两年你独热成三列树模型只能学到“是不是一年合约”无法学到“合约期从一年到两年的增加方向”它需要靠多次分裂去组合效率就低了。而映射成0/1/2后xgb/lightgbm天然可以找出切分点“合约小于2年”这样的规则既保留了单调性还节省了分裂次数。第二独热之后的稀疏特征对于逻辑回归这种线性模型有意义因为它能把“类别”变成“哑变量”参与线性加权但对于树模型来说稀疏特征和稠密特征的处理难度差别不大如果你后面主攻树模型合理的做法是尽量把类别特征保留为整数或目标编码。这也是为什么很多Kaggle老手处理类别特征时会用LabelEncoder映射成整数而不是无脑独热。当然我的原则是视最终选用的模型而定。如果后续要用逻辑回归或神经网络独热是必要的如果是LightGBM/XGBoost可以用序号或类别类型。本项目我计划以树模型为主所以编码方案就按上面的思路走但代码里我也会预留一套独热方案方便读者对比效果。5. 特征工程从原始字段构造有效特征5.1 基础特征与派生特征的关系原始数据集里只有22个字段直接拿来训练模型也不是不行但效果一般很平庸。我在实际项目中总结的经验是一个好的流失预测模型60%的功劳在特征工程而不是模型调参。所以编码完成之后我还会基于业务理解构造一批派生特征这些特征才是真正拉开模型差距的地方。原始特征我保留以下几类用户基础档案gender、senior_citizen、partner、dependents服务形态电话服务、互联网服务、增值服务组合合约信息contract、payment_method、paperless_billing消费信息monthly_charges、total_charges、tenure。派生特征则是在这些原始特征的基础上做组合、做比率、做窗口统计。以下是我在这个项目里构造的关键特征。5.2 几个高价值的组合特征我最看重的是“人均月消费”和“消费稳定性”这类特征。光看monthly_charges是不够的因为高消费用户可能是企业套餐也可能是买了大量服务包的用户低消费用户可能是低配用户单纯看绝对值无法区分。所以我构造了这些特征avg_monthly_charges total_charges / max(tenure, 1)累计消费除以在网月数得到真实月均消费。这比直接用monthly_charges更稳定因为monthly_charges是最近一个月的费用可能受临时加购服务影响。charge_to_tenure_ratio monthly_charges / max(tenure, 1)单位在网时间的消费密度能反映用户从入网到现在的付费强度。service_count用户订阅的增值服务数量。把online_security、online_backup、device_protection、tech_support、streaming_tv、streaming_movies这6个字段里为“Yes”的个数加总。这个特征很有意义一般来说服务数量越多用户对运营商的粘性越高流失概率越低。has_multiple_services是否同时开通电话和互联网服务组合两项基础业务线的用户往往更稳定。is_new_user前面讲过的tenure小于等于1或total_charges为0的用户标记。这些特征构造起来不复杂但效果立竿见影。我实测下来service_count和contract组合起来几乎就是流失用户和非流失用户最大的分水岭。5.3 消费与合约维度的交叉特征流失预测里面最经典的规律大家应该都听过按月付费、电子支票支付、没有技术支持服务的用户流失率显著偏高。这三个特征分别对应合约周期、支付习惯、服务粘性。为了让模型自己也能发现这些组合规律我还可以手动构造一两个交叉特征给模型做“提示”。is_monthly_contract合约是否为按月1/0is_elec_check付款方式是否为电子支票1/0contract_payment_combo把合约类型和支付方式两个字段拼成一个新类别例如“MonthlyElectronic check”“One yearBank transfer”等然后再做序号编码。这种方法可能让类别组合很多容易过度稀疏所以我更倾向于保留前两个布尔特征。另外我发现paperless_billing无纸化账单与流失也有相关性。逻辑上无纸化用户通常对账单不敏感容易忘记扣款或对账户状态的关注度低所以流失概率偏高。这种不直观但业务能解释的特征就值得保留。5.4 警惕目标泄漏哪些特征绝对不能放进特征矩阵特征工程里最隐蔽的坑是目标泄漏。所谓目标泄漏就是你在构造特征时不小心用了“未来信息”或“标签本身的信息”导致模型在训练时看到答案验证时却看不到。在这个数据集里最容易犯的错是把churn相关的时间点信息混进特征。比如假设我们拿到了“该用户最后通话时间”或者“该用户是否已经提交销户申请”那预测还有什么意义那不是预测那是“确认”。所以我在特征工程时确立了时间边界所有特征必须来自数据截断时刻之前目标变量来自截断时刻之后。具体到这份静态数据操作上就是先按customer_id去掉重复再保证特征列中没有任何一个字段是“未来是否发生事件”的代理。还有一种更隐蔽的泄漏方式是我在早期常踩的对全量数据做缺失值填充时用了所有样本的均值包括验证集和测试集。这会导致验证集的信息在训练前就泄露到了填充参数里。正确做法是先划分训练集和验证集再分别在训练集上fit填充器比如均值、众数、独热编码的列名单再用训练集fit好的参数transform验证集。我习惯用sklearn.pipeline.Pipeline配合ColumnTransformer统一管理避免这种低级泄漏。6. 训练集/验证集划分与完整数据处理Pipeline6.1 为什么要先划分再处理而不是先处理再划分很多新手拿到数据先做清洗、填充、编码然后再train_test_split。这看起来没什么问题但实际上是错的。原因就在于填充缺失值需要的均值、众数独热编码需要学习的类别集合这些都应该只用训练集的信息计算然后应用到验证集和测试集。如果拿全量数据计算验证集中原本缺失的值也会被“全局均值”填充而这个均值已经包含了验证集样本的信息严格来说这就是泄漏。别小看这个细节它会导致你的模型在验证集上表现虚高而真实上线后面对新数据就缩水因为你实现测试环境的“预处理参数”是偷看了测试数据的。这种感觉就像考试前偷偷翻过答案平时模拟考满分真正上了考场就崩。所以正确流程是先分离特征和标签X df.drop(churn, axis1)y df[churn]再用train_test_split(X, y, test_size0.2, stratifyy, random_state42)划分在训练集上统计缺失值填充参数、类别编码映射表用这些参数分别transform训练集和验证集。我额外加了个stratifyy目的就是保证训练集和验证集的流失比例一致。因为流失样本只占约20%如果不分层抽样验证集里很可能流失样本比例忽高忽低导致评估不稳定。这一点对于不平衡分类问题极其重要。6.2 一个可持续复用的ColumnTransformer配置我整理了一套完整的数据预处理Pipeline代码供读者参考。这段代码采用了sklearn的ColumnTransformerPipeline它能把数值填充、类别编码等步骤统一封装避免预处理参数泄漏。from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import OneHotEncoder, StandardScaler, FunctionTransformer import numpy as np import pandas as pd # 定义列组 numeric_cols [tenure, monthly_charges, total_charges] binary_cols [gender, partner, dependents, phone_service, paperless_billing] ordinal_cols [contract] # 手工映射不放在transformer里这里用FunctionTransformer nominal_cols [internet_service, payment_method] service_flag_cols [online_security, online_backup, device_protection, tech_support, streaming_tv, streaming_movies] # 注意multiple_lines 需要特殊合并建议在预处理前先用函数合并或放入nominal处理 def map_contract(X): # X是DataFrame映射合约类型 mapping {Month-to-month: 0, One year: 1, Two year: 2} return X[contract].map(mapping).values.reshape(-1, 1) # 数值列填充均值新用户total_charges已经填充0所以这里用SimpleImputer兜底 numeric_transformer Pipeline(steps[ (imputer, SimpleImputer(strategymean)), (scaler, StandardScaler()) ]) # 二分类别列直接填众数并转成0/1这里用OneHotEncoder输出两列其实一列就够了先保留通用做法 binary_transformer Pipeline(steps[ (imputer, SimpleImputer(strategymost_frequent)), (onehot, OneHotEncoder(dropif_binary, handle_unknownignore)) ]) # 无序多类别做独热 nominal_transformer Pipeline(steps[ (imputer, SimpleImputer(strategymost_frequent)), (onehot, OneHotEncoder(handle_unknownignore)) ]) # 服务标记列把Yes映射1其他映射0 def map_service_flags(X): return (X Yes).astype(int).values service_transformer FunctionTransformer(map_service_flags) # 有序合约列用FunctionTransformer映射 ordinal_transformer FunctionTransformer(map_contract) preprocessor ColumnTransformer( transformers[ (num, numeric_transformer, numeric_cols), (bin, binary_transformer, binary_cols), (nom, nominal_transformer, nominal_cols), (svc, service_transformer, service_flag_cols), (ord, ordinal_transformer, ordinal_cols) ] ) # 用法示例 # X_train_prepared preprocessor.fit_transform(X_train) # X_val_prepared preprocessor.transform(X_val)这段代码的核心思路是把每一类特征的预处理逻辑封装成独立Transformer然后按列组组合。这样后续换模型、做交叉验证、做网格搜索时都能保持预处理流程一致不容易出错。6.3 代码执行结果的形态说明运行上面这段Pipeline后输出的特征矩阵会是一个numpy数组行数等于训练样本数列数由各子Transformer拼接而成。数值列经过标准化二分类别列变成0/1无序类别列变成稀疏独热服务标记列变成整数。做完之后我建议打印一下特征形状和列名如果ColumnTransformer支持get_feature_names_out确认没有维度爆炸。我自己在这份数据上跑出来的初版特征数量大概在25列左右还在一个很舒服的范围。如果读者自己的数据类别特别多独热后特征超过200列建议考虑降维或者改用树模型自带的类别处理能力。7. 实操过程实录一步步走通编码全流程7.1 阶段一数据加载与初步探查下面我把自己实操时的代码和输出摘录一下方便读者对照。第一步加载数据并查看基本信息import pandas as pd import numpy as np df pd.read_csv(telecom_customer_churn.csv) print(df.shape) # (7043, 21) —— 通常这个数据集有7043行 print(df.head()) # 探查缺失值 missing df.isnull().sum() print(missing[missing 0])我在这份数据上看到total_charges有11个缺失值其余字段好像没有缺失。但别高兴太早就算缺失数量少处理好坏也会影响模型。我的处理流程是# 先把total_charges转数值缺失值按新用户填充0 df[total_charges] pd.to_numeric(df[total_charges], errorscoerce) df[total_charges] df[total_charges].fillna(0) # 新用户标记 df[is_new_user] (df[tenure] 1).astype(int)有的读者会问为什么tenure缺失没处理因为在我这份数据里tenure没有缺失如果有按行删除应该优先考虑毕竟它太重要了不能乱填。7.2 阶段二特征构造与特殊类别合并接下来我会在df上做特征衍生再交给Pipeline。以下代码在preprocessor之前执行# 1. 月均消费与消费密度 df[avg_monthly_charges] df[total_charges] / np.maximum(df[tenure], 1) df[charge_to_tenure_ratio] df[monthly_charges] / np.maximum(df[tenure], 1) # 2. 增值服务数量 service_cols [online_security, online_backup, device_protection, tech_support, streaming_tv, streaming_movies] df[service_count] (df[service_cols] Yes).sum(axis1) # 3. 是否同时存在电话和互联网服务 df[has_multiple_services] ( (df[phone_service] Yes) (df[internet_service] ! No) ).astype(int) # 4. 特殊状态合并把No phone service / No internet service视为No merge_map {No internet service: No, No phone service: No} for col in [multiple_lines] service_cols: df[col] df[col].replace(merge_map)注意multiple_lines原始取值是Yes/No/No phone service替换后只剩下Yes/No这样它就可以归入binary_cols了。service_flag_cols里的No internet service替换成No后map_service_flags直接比较是否为Yes即可。这里特别提醒一句merge_map的替换必须在划分数据之前做因为它只是业务标签的规范化不涉及目标变量统计所以不会导致泄漏。如果你要做目标编码那必须放到划分之后而且只能对训练集fit。7.3 阶段三划分训练集与验证集from sklearn.model_selection import train_test_split X df.drop(churn, axis1) y df[churn].map({Yes: 1, No: 0}).astype(int) X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) print(训练集流失率:, y_train.mean()) print(验证集流失率:, y_val.mean())运行结果大概是训练集流失率0.265验证集流失率0.265左右分层成功。这里注意我特意没有在清洗之前划分是因为总清洗步骤里的fillna(0)和特征构造都只涉及业务逻辑不涉及目标值计算所以可以在划分前完成。而目标编码、标准化参数、独热编码类别这类需要“学习”的步骤必须放划分后。这个边界要理清楚。7.4 阶段四执行Pipeline并检查特征矩阵X_train_transformed preprocessor.fit_transform(X_train) X_val_transformed preprocessor.transform(X_val) print(X_train_transformed.shape) print(type(X_train_transformed))输出可能是(5634, 23)。列数不多特征矩阵是稠密numpy数组因为独热列较少。如果数据里有大量高基数稀疏列输出会是稀疏矩阵后面训练模型时要注意格式兼容。最后我还会人工看一眼某个用户的特征值回忆一下它对应什么业务含义。比如一个用户已开通6项服务、合约两年、月均消费80美元那他的service_count6contract2消费特征处于高位。这种“往回解释”的习惯对后续做模型可解释性非常有帮助。8. 常见问题排查与编码避坑清单8.1 经典报错与处理方法这一节我把实操中最容易遇到、最让人抓狂的报错集中列出来给出排查思路。虽然每个人的数据集不同但错误模式是共通的。错误一total_charges列一调用astype(float)就报错原因列里有非数值字符比如空字符串、下划线、逗号。 解决方法用pd.to_numeric(..., errorscoerce)强制把解析失败的值转成NaN然后再统一处理NaN。千万别直接astype会直接抛异常。错误二ColumnTransformer的get_feature_names_out报错原因某个Transformer没有实现get_feature_names_out尤其是自定义的FunctionTransformer。 解决方法给FunctionTransformer传入feature_names_out参数或者干脆不依赖该方法手动维护列名。对于本项目来说手动打印一下维度知道大概结构就够了。错误三独热编码后验证集出现训练时未见过的类别原因验证集里某个类别的取值在训练集中没出现过。 解决方法在OneHotEncoder中设置handle_unknownignore这样新类别会被编码成全0向量。这个参数太好用了强烈建议一定写上。错误四train_test_split后训练集和验证集的流失率差异巨大原因没有用stratify或数据本身顺序有问题。 解决方法加上stratifyy如果还是不行检查y是不是被误转成了float或object。错误五标准化时用了验证集的数据fit原因图省事把X_val也塞进去一起标准化了。 解决方法用Pipeline统一管理确保fit只在训练集上发生。这里强调一遍所有需要学习的参数都必须fit在训练集上验证集和测试集只能transform。8.2 目标泄漏的隐蔽场景排查我见过太多人漏掉这种泄漏构造“是否新用户”特征时直接基于tenure和total_charges打标这个没问题因为它只依赖当前已知信息。但有些人构造“是否流失”的规则特征时会用“用户是否已经提交了取消服务请求”之类的数据——这已经和标签高度重合了等于把答案喂给模型。在真实业务系统里这类弱标签数据要特别注意。我处理的原则是凡是“目标事件发生之后才能知道的字段”一律剔除凡是“观察期之后的数据”一律剔除。8.3 编码阶段最值得养成的三个好习惯分享三个我在实战中踩了无数坑后才总结出来的习惯它们不是某段代码而是整个流程层面的规范每跑一步先打印shape和dtype。我几乎每个阶段都会确认当前DataFrame的行列数、列名、缺失值情况。因为你没法确定前一步的替换是否意外删除了行、或者把列类型改成了奇怪的东西。打印一下发现异常立即止损比最后模型跑崩了再回头debug快十倍。把每一个特征的处理理由写注释。很多初学者觉得注释是给老师看的其实注释是给三个月后的自己看的。特征工程做完就忘三个月后翻代码看到service_count根本想不起为什么要构造它。我在代码里写的一句话注释往往比一个小时的会议纪要还有用。建立一套数据版本快照。每完成一个阶段的清洗和特征构造顺手把当前处理好的DataFrame保存成parquet或pickle文件命名带上时间戳。这样后期调模型时如果发现某个特征有问题可以快速回溯到某个阶段的版本而不需要从头跑一遍全流程数据清洗。真实项目里这个习惯能救命。9. 下篇预告与延伸思考这篇项目背景和编码部分到这里基本把“从原始数据到可训练特征矩阵”的流程走完了。但离一个完整的客户流失预测项目还有一段距离。下一篇我会继续做三件事用XGBoost和LightGBM训练基础模型并调整类别权重处理不平衡问题用AUC、PR曲线、混淆矩阵和业务成本矩阵多维度评估模型而不是只看准确率做特征重要性分析、SHAP值解释让模型结果能被业务方理解并落地到挽留策略。我个人始终认为机器学习项目实战最有价值的部分不是跑出一个比别人高0.01的AUC而是在整个流程里学会“做决定”。为什么这个字段要这样编码为什么这个缺失值要填0而不是删行为什么要先划分再标准化——这些决定背后都有业务逻辑兜底。当你把编码这关扎扎实实扛过去后面模型训练反而顺理成章。下一篇我们继续。