YAOTU INSIGHTS

离散变量编码三件套:因子化、One-Hot与哑变量实战解析

离散变量编码三件套:因子化、One-Hot与哑变量实战解析
做数据分析这几年几乎每个项目都会碰到一类让人头疼的字段里面存的全是文本。地区、渠道、颜色、等级、支付方式……这些在建模时不能直接喂给模型必须先用某种方式转成数字。这个转换过程就是我们常说的离散变量处理。但很多刚入行的朋友在这里踩坑最多LabelEncoder、factorize、get_dummies、OneHotEncoder全试了一遍结果模型效果忽好忽坏也不知道为什么。这篇文章就围绕离散变量处理中的三件套——因子化、One-Hot、哑变量展开把它们的原理、代码、适用场景和坑一次讲透。适合刚接触特征工程、或者已经写过代码但总觉得哪里不对劲的同学。看完你至少能搞清楚什么时候只用整数编码就行什么时候必须拆成多列拆完之后为什么经常要删掉一列。1. 先别急着写代码离散变量到底在讲什么很多教程一上来就扔代码但我建议你先花两分钟理解一下离散变量本身。离散变量就是取值有限、可列举的变量比如商品类目、性别、所在区域。它和连续变量价格、年龄、收入最本质的区别是离散变量没有内在的“度量单位”不能直接比较大小。1.1 分清“有序”和“无序”是第一步离散变量内部还要再分两类有序离散变量取值之间存在顺序关系。比如会员等级普通、银卡、金卡、学历本科、硕士、博士、满意度差、一般、好大小关系是成立的差值却不成立。无序离散变量取值之间不存在顺序关系。比如颜色、城市、支付方式谁排在前面完全无所谓更谈不上谁大谁小。我在实际项目里见过一个典型错误有人把“颜色”直接用整数编码红1、蓝2、绿3然后丢给线性回归模型。模型学到的关系是“颜色每增加1目标值下降多少”这个结论完全是无意义的。所以判断有序还是无序直接决定了你后续选哪种编码方式。1.2 为什么模型“读不懂”文本大部分机器学习模型底层都是数学运算。线性回归要算权重和偏置逻辑回归要算概率KNN要算距离神经网络要做矩阵乘法。你给模型一个包含字符串的字段它根本没法参与计算。就算一些树模型内部能处理类别底层还是会把类别转成某种数值或分裂规则。所以“编码”这个动作的本质是在保留原始类别语义的前提下把类别转化为数值结构。转化得好模型能学到类别与目标之间的规律转化得不好轻则无效重则引入虚假关系把整个模型带偏。1.3 编码前必须做的三个检查我自己的标准流程是先检查三件事再动手编码能省掉后面一大半的坑查类型用df.dtypes确认哪些列真的是离散类别而不是被误读成对象的数值列。查基数和分布用df[col].nunique()看类别个数用value_counts(normalizeTrue)看频率分布低频类别往往需要合并。查缺失类别列的缺失值不能被编码函数自动处理缺失值要单独填充一个“缺失”类别或者先丢弃。import pandas as pd df pd.DataFrame({ region: [A区, B区, C区, A区, None], channel: [线上, 线下, 线上, 线下, 线上] }) print(df.dtypes) print(df[region].nunique()) print(df[region].value_counts(dropnaFalse))输出里你会看到region列有4个值包含缺失这个信息就足够帮你决定是填充、合并还是保留为独立类别。2. 因子化最基础的整数映射方案因子化也叫标签编码、整数编码做法非常简单把每个不同的类别值映射成一个从0开始的整数。名字上可能有LabelEncoder、factorize之分但思路完全一致——查表替换。2.1 用 pandas.factorize 快速实现pd.factorize()是Pandas自带的方法用起来极其顺手codes, uniques pd.factorize(df[region]) print(codes) # [0 1 2 0 -1] print(uniques) # Index([A区, B区, C区], dtypeobject) df[region_factor] codes注意两点编码顺序按“出现顺序”分配所以A区得到0、B区得到1、C区得到2缺失值默认被编码为-1这个特性很实用省得你再单独处理NaN。2.2 用 sklearn.LabelEncoder 做工程集成如果你的特征工程后面要接Scikit-learn的Pipeline用LabelEncoder更合适from sklearn.preprocessing import LabelEncoder le LabelEncoder() df[channel_label] le.fit_transform(df[channel]) print(le.classes_) # [线下 线上]LabelEncoder按类别的字典序排序而不是出现顺序。这个细节在离线训练和在线预测时尤其重要只要两边都用同一个LabelEncoder对象就不会出现顺序错乱。2.3 因子化适合哪些场景存储优化把一列长字符串变成整数内存占用大幅下降特别是category类型配合使用效果更好。有序类别会员等级、学历这种本身有大小关系的变量整数映射能保留顺序信息。树模型决策树、随机森林、梯度提升树对整数编码不敏感直接用因子化类别往往效果不错因为树模型做的是分裂而不是计算数值距离。但要注意无序类别千万别直接因子化后喂给线性模型或距离类模型。因为整数1、2、3之间天然带了“距离”和“大小”模型会误以为相邻类别更相似。我在项目里排查过一个异常某分类特征被LabelEncoder编码后逻辑回归系数出现了一个奇怪的单调趋势实际上只是编码顺序和排序巧合造成的伪规律。方法是否保留顺序是否产生新列适合模型factorize / LabelEncoder是否原列替换为整数树模型、有序类别One-Hot否是拆成N列线性模型、距离模型、神经网络哑变量否是拆成N-1列带截距项的线性/统计模型3. One-Hot 与哑变量拆开看差别One-Hot和哑变量经常被混提因为它们做出来的表几乎一样每个类别占一列取值只有0或1。但两者的细微差别直接影响回归模型的稳定性。3.1 One-Hot编码到底做了什么假设region有4个类别A区、B区、C区、D区。One-Hot会生成4列每一列对应“是否属于该类别”df_oh pd.get_dummies(df, columns[region], prefixregion) print(df_oh.head())输出大概是这样region_A区region_B区region_C区region_D区1000010000101000编码后的矩阵是一个稀疏矩阵大多数位置是0只有极少数位置是1。从信息角度看每个样本只需要一个“1”就能表达它属于哪个类别所以其实有一列是不必要的这就引出了哑变量。3.2 哑变量删掉一列的原因哑变量编码在One-Hot的基础上删掉一列比如删除region_D区只保留前3列。这样做不是省内存而是为了解决统计模型里的完全多重共线性。线性回归里如果包含截距项模型形式是y b0 b1 * x1 b2 * x2 b3 * x3 b4 * x4当x1到x4是一组One-Hot列时无论样本属于哪个类别这四个值的和恒等于1。这意味着x4 1 - x1 - x2 - x3截距项和四个变量之间出现了严格的线性关系模型无法唯一估计参数结果就是系数不稳定、方差膨胀。这也就是常说的虚拟变量陷阱。Pandas里处理很简单df_dummy pd.get_dummies(df, columns[region], prefixregion, drop_firstTrue)Sklearn的OneHotEncoder也提供了对应参数from sklearn.preprocessing import OneHotEncoder enc OneHotEncoder(dropfirst, sparse_outputFalse) encoded enc.fit_transform(df[[region]])实测下来做线性回归、逻辑回归这类带截距模型时我基本都开drop_firstTrue。只有在纯树模型或神经网络里删不删影响才没那么大。3.3 两种编码的优缺点对照优点不引入虚假顺序每个类别独立成一个特征模型可以学习类别各自的效应解释性强看系数就知道哪个类别对目标影响大。缺点高基数类别会导致特征维度爆炸每一列都是高度稀疏的0/1占用内存样本量不足时容易带来稀疏性问题模型可能过拟合。举个我踩过的例子某项目里一个城市字段有300多个类别直接get_dummies后训练集瞬间多出300多列。当时觉得没问题结果KNN模型训练慢到无法接受模型效果也低于直接做目标编码的版本。从那以后我只要看到类别基数超过20就先停下来想想是不是必须用One-Hot。4. 成型前的关键决策基数、缺失值和低频类别编码方式不是拍脑袋选的有一类前置判断能在编码前把问题简化一大半。这个环节往往被教程忽略却是实际项目中最花时间的地方。4.1 类别基数决定方案基数Cardinality就是列中不同取值的个数。我一般这么判断基数 ≤ 5放心用One-Hot或哑变量。基数 6 ~ 20One-Hot可用但要关注低频类别是否过拟合。基数 50不建议直接One-Hot优先考虑合并低频类别、目标编码或其他降维方法。高基数问题的本质是每个类别分到的样本太少One-Hot后每个特征都只有极少数非零样本模型很难学到稳定系数。比如某字段有200个类别其中一半只出现过1次那这些“一次类别”不仅没用还会引入噪声。4.2 低频类别合并成“其他”操作上先用value_counts统计频率然后设定一个阈值低于阈值的统一替换成“其他”counts df[city].value_counts(ascendingFalse) rare_cities counts[counts 50].index df[city_grouped] df[city].replace(rare_cities, 其他) print(df[city_grouped].value_counts(dropnaFalse))阈值取多少没有固定标准我通常会看两个东西一是样本总量二是这个类别本身的业务含义。如果某类别本身有特殊业务含义比如“未知”或“未填写”不管频率多低都保留如果就是某个冷门城市且数量很少合并成“其他”能明显提升编码稳定性。4.3 缺失值先想清楚再填缺失值在离散编码里经常被单独当成一个新类别。因为“缺失”本身可能是有业务含义的——比如用户没填写年龄段也许说明这是一个非核心注册渠道来的用户。df[region_filled] df[region].fillna(Missing) df_encoded pd.get_dummies(df, columns[region_filled], drop_firstTrue)如果缺失比例很低直接填充成“其他”也行如果缺失比例很高比如超过30%单独保留“缺失”类别的效果往往更好。不要把缺失值直接丢给get_dummies因为get_dummies默认会跳过NaN等于隐式地把缺失样本“隐藏”了后续预测时如果新数据里有NaN你根本没地方查它去了哪一列。4.4 顺序类别用有序编码更合适对于教育程度、会员等级这种有明确顺序的变量One-Hot会丢失顺序信息纯整数编码又会让线性模型误认为数值间距相等。折中方案是手动指定映射表保留顺序但不强行等距edu_map {本科: 1, 硕士: 2, 博士: 3, 其他: 0} df[edu_level] df[education].map(edu_map)这个方案不完美但在业务解释上通常比One-Hot更直观也比盲目因子化少一点数学上的伪距离。5. 实战操作从原始数据到可直接建模的编码结果理论说完了下面我把一套完整流程走一遍。以一份简化版用户订单数据为例目标是预测用户是否复购。5.1 构建示例数据import pandas as pd import numpy as np data { user_id: range(101, 111), region: [A区, B区, C区, A区, D区, B区, C区, E区, A区, B区], channel: [线上, 线下, 线上, 线上, 线下, 线下, 线上, 线下, 线上, 线上], level: [普通, 银卡, 金卡, 普通, 银卡, 金卡, 普通, 银卡, 银卡, 普通], first_month_order: [3, 6, 9, 2, 5, 7, 4, 8, 6, 3], repurchase: [0, 1, 1, 0, 0, 1, 0, 1, 1, 0] } df pd.DataFrame(data)其中region是无序类别channel是二分类别level是有序类别first_month_order是数值特征repurchase是目标。5.2 Sklearn 方式ColumnTransformer 一键整合用Sklearn处理编码特征的最大好处是和Pipeline集成能避免训练集和测试集编码不一致的问题from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split X df[[region, channel, level, first_month_order]] y df[repurchase] X_train, X_test, y_train, y_test train_test_split(X, y, test_size0.3, random_state42) # 无序类别用OneHot并丢第一列 preprocessor ColumnTransformer( transformers[ (region_oh, OneHotEncoder(dropfirst, handle_unknownignore, sparse_outputFalse), [region]), (channel_oh, OneHotEncoder(dropfirst, handle_unknownignore, sparse_outputFalse), [channel]), (level_ord, OrdinalEncoder(categories[[普通, 银卡, 金卡]]), [level]) ], remainderpassthrough ) pipe Pipeline(steps[ (prep, preprocessor), (clf, LogisticRegression(max_iter500)) ]) pipe.fit(X_train, y_train) print(pipe.score(X_test, y_test))这里每个步骤都值得解释一下dropfirst删掉每个类别字段的第一列避免虚拟变量陷阱。handle_unknownignore测试集或未来线上数据里如果出现没见过的类别不会被报错编码结果里该样本对应的所有新类别列都是0。这个参数在真实业务里几乎必须开。OrdinalEncoder(categories[...])手动指定顺序避免Sklearn按字典序生成的顺序不符合业务含义。remainderpassthrough没指定编码的数值列原样通过。5.3 Pandas 方式get_dummies 方便但要对齐如果你只是做快速探索分析get_dummies是最高效的X_encoded pd.get_dummies( X, columns[region, channel], drop_firstTrue, dummy_naFalse ) X_encoded[level] X[level].map({普通: 0, 银卡: 1, 金卡: 2})但用get_dummies有一个非常隐蔽的坑训练集和测试集类别不一致时列名和列数会不一样。比如训练集里region有A区、B区、C区测试集里多了一个F区两个get_dummies生成的列完全不同模型预测时直接对齐失败。我踩过这个坑后养成了一个固定习惯——编码后立刻对测试集做reindex对齐train_dummy pd.get_dummies(X_train, columns[region, channel], drop_firstTrue) test_dummy pd.get_dummies(X_test, columns[region, channel], drop_firstTrue) test_dummy test_dummy.reindex(columnstrain_dummy.columns, fill_value0)这样即使测试集出现了训练集没有的类别也会被填充成0等于自动进入“未知类别”状态。5.4 完整流程的取舍建议如果你的特征工程需要反复调试、上线上模型强烈建议用Pipeline ColumnTransformer。整个预处理逻辑固化成一个对象训练集、测试集、线上预测时用同一个transform逻辑不会出现“训练阶段A编码、预测阶段B编码”的错位问题。如果你是做一次性数据分析、快速出图get_dummies更轻量。实际项目里我通常混合用探索阶段用get_dummies方便看数据正式训练和上线阶段切换到OneHotEncoder Pipeline。曾经有个项目在探索阶段发现get_dummies结果很好部署时改成OneHotEncoder后分数掉了0.02查了很久才发现是两个阶段对未知类别的处理策略不一致导致的。6. 常见问题与排查实录这部分是踩坑合集我把这几年在离散变量编码上遇到最多的问题列出来附带排查思路方便你直接对号入座。6.1 编码后内存暴涨怎么办特征维度爆炸是One-Hot最常见的副作用。排查方法很直接看data_memory_usage()和编码前后的列数变化。encoded_df pd.get_dummies(df, columns[region_city_large]) print(encoded_df.shape) print(encoded_df.memory_usage(deepTrue))如果列数超过几千优先考虑把低频类别合并掉改用稀疏存储。OneHotEncoder本身就支持稀疏矩阵这是它比get_dummies更适合大数据的核心原因。新版Sklearn里把sparse_outputFalse会直接输出稠密数组反而是自己把稀疏优势扔掉了。建议在线性模型和神经网络场景里直接把稀疏矩阵喂给模型训练速度和内存占用都能好不少。6.2 模型效果反而变差了怎么排查最常遇到的情况是编码前模型还有0.75的AUCOne-Hot后变成0.70。这时候我会依次检查是不是把有序类别当成无序做了One-Hot。比如“学历”被拆成5列模型学不到“本科到硕士的递进关系”效果反而下降。是不是新生成的列里有大量稀疏特征。类别基数过高、低频类别过多模型容易在这些噪声列上过拟合。是不是编码后又做了标准化。One-Hot列全部是0/1对树模型无所谓但某些线性模型再做标准化反而破坏稀疏结构。排查方法很简单把编码方案换回因子化或有序编码对比模型效果。如果因子化更好大概率是你的类别本身包含顺序信息或者高基数导致One-Hot维度太碎。6.3 回归模型报出“完全共线性”警告如果你把One-Hot列全部塞进带截距项的回归模型可能会出现设计矩阵秩不足的警告或者系数非常不稳定。这就是前面说的虚拟变量陷阱。解决方案很简单drop_firstTrue或者手动删掉任意一列。判断共线性除了看警告还可以看方差膨胀因子VIF。VIF超过10基本说明该列和其他列高度相关。但对One-Hot列来说VIF高不一定全是坏事重点还是看模型系数是否稳定。6.4 测试/线上数据出现没见过的新类别这是上线阶段最容易炸的坑。用pd.get_dummies的朋友尤其容易中招训练时没见过的新类别在测试阶段凭空多出一列模型特征数量对不上。庆幸的是OneHotEncoder的handle_unknownignore能一键解决新类别不会被单独编码而是全部映射成全0向量。如果你用的get_dummies就要用上一节说的reindex对齐来模拟这个效果。不过还要提醒一句handle_unknownignore只解决“不会报错”的问题不保证“效果好”。如果新类别占比很高意味着大量样本在模型看来都属于“未知状态”模型自然难以预测。更靠谱的做法还是在离线阶段就收集到足够全面的类别列表或者主动对低频类别做合并。个人经验补充做离散变量编码这几年我最大的体会是不要在第一步就急着选工具先想清楚类别本身的性质。拿到一个字段先问三个问题它有序吗基数大吗缺失值多吗这三个问题有答案之后用哪种编码几乎是顺理成章的事。另外分享一个小技巧对于高基数无序类别除了One-Hot你还可以考虑目标编码Target Encoding——用类别对应的目标变量均值来编码。这种方案在树模型和神经网络里经常表现不错但要注意做交叉验证内部的编码否则容易严重过拟合。具体实现方式以后有机会再单独写一篇。先把因子化、One-Hot、哑变量这三个基础编码吃透你已经可以处理绝大多数业务表里的离散字段了。