在机器学习项目中,数据处理往往占据整个流程的大半时间。很多初学者拿到一份数据集后,最先遇到的问题不是模型选型,而是“这份数据里有数值列,又有文本分类列,到底该怎么统一处理”?如果直接把类别列删除,会丢失大量信息;如果硬把类别列转成数字,又会引入错误的顺序关系;如果全部做 One-Hot 编码,遇到高基数类别时又会造成维度爆炸。
本文要讨论的正是这个主题:处理混合变量。所谓混合变量,指的是同一份数据集中同时包含数值型特征(Numerical Features)和类别型特征(Categorical Features)。我们将围绕特征工程的完整流程,梳理从数据预览、缺失值处理、编码方式选择,到构建机器学习流水线的全过程,并提供一个基于 Python 和 scikit-learn 的可直接运行的实战案例。
无论你是正在学习机器学习基础课程,还是准备参加数据分析面试,或者正在做实际项目的特征工程,这篇文章都值得收藏备用。
1. 什么是混合变量,为什么需要特别处理
先看一个最直观的示例。假设我们在做一个“员工薪资预测”任务,原始数据可能长这样:
| 员工ID | 年龄 | 部门 | 学历 | 工作年限 | 薪资 |
|---|---|---|---|---|---|
| A001 | 29 | 技术部 | 本科 | 3 | 15000 |
| A002 | 34 | 市场部 | 硕士 | 8 | 21000 |
| A003 | 26 | 运营部 | 专科 | 2 | 9000 |
| A004 | 41 | 技术部 | 博士 | 15 | 40000 |
这份数据中,年龄、工作年限、薪资是数值型特征,部门、学历是类别型特征。像这样同时包含数值变量和类别变量的数据集,就是典型的混合变量数据集。
1.1 为什么不能直接丢给模型
大部分机器学习算法,例如线性回归、逻辑回归、支持向量机、神经网络,在底层做矩阵运算时要求输入是数值类型。如果你把部门这一列直接以字符串形式交给模型,模型根本无法计算。即使某些树模型(如决策树)能处理 pandas 中的 category 类型,在实际工程中,我们仍然建议统一转换成数值形式,原因有三:
- 提升特征表达能力,方便进行特征组合;
- 统一接口,便于在不同模型之间切换对比;
- 避免不同框架对类别特征的处理逻辑不一致。
1.2 类别型特征的两大类型
在动手编码之前,必须先区分类别特征是有序还是无序。
无序类别(Nominal):类别之间没有大小关系,例如颜色(红色、绿色、蓝色),部门(技术部、市场部、运营部)。这类特征在编码时不能赋予数字顺序,否则模型会认为 2 > 1 > 0。
有序类别(Ordinal):类别之间存在天然的等级关系,例如学历(专科 < 本科 < 硕士 < 博士),评价等级(差 < 中 < 好 < 优)。这类特征可以映射为有序整数,但要确保映射方向正确。
这一点在实际操作中非常容易忽略。拿学历这一列来说,如果直接使用LabelEncoder或pandas.factorize,它会按照拼音或字母顺序编码,比如“专科=0,博士=1,本科=2,硕士=3”,这个顺序完全不符合真实语义,会给模型引入错误先验。
2. 环境准备与版本说明
本文的示例代码基于以下环境,你用类似版本即可,不需要完全一致:
| 工具 / 库 | 版本建议 |
|---|---|
| Python | 3.8 及以上 |
| pandas | 1.5.0 以上 |
| numpy | 1.21.0 以上 |
| scikit-learn | 1.2.0 以上 |
| category_encoders | 2.5.0(可选) |
| matplotlib | 3.6.0(用于可视化) |
如果你的环境尚未安装这些库,可以执行:
pip install pandas numpy scikit-learn matplotlib如果希望使用目标编码等高级编码方式,还需要安装:
pip install category_encoders这里要提醒一点:不同版本的 scikit-learn 在 API 上会有细微差异。例如OneHotEncoder的handle_unknown参数、ColumnTransformer的命名规范等,不同版本略有不同。本文示例以 scikit-learn 1.2.x 为基准,如果你使用的是 1.0 或更早版本,建议先升级。
3. 核心处理方法拆解
在正式写代码之前,我们先从原理上把处理方法讲清楚。
3.1 数值型特征的常规处理
数值型特征通常不需要做编码,但需要做:
- 缺失值处理:数值列可以使用均值、中位数或模型预测填补;
- 量纲统一:使用标准化或归一化,让不同量纲的特征处于同一尺度;
- 异常值处理:对明显偏离正常范围的数据进行截断或删除;
- 分布偏态处理:对长尾分布的特征做对数变换或 Box-Cox 变换。
需要注意的是,树模型对特征尺度不敏感,所以随机森林、XGBoost 等模型可以不进行标准化;但线性模型、KNN、SVM 和神经网络对尺度非常敏感,标准化几乎是必须的。
3.2 无序类别特征的 One-Hot 编码
One-Hot 编码是处理无序类别最经典的方式。假设部门有三个取值,编码后的效果如下:
| 部门 | 部门_技术部 | 部门_市场部 | 部门_运营部 |
|---|---|---|---|
| 技术部 | 1 | 0 | 0 |
| 市场部 | 0 | 1 | 0 |
| 运营部 | 0 | 0 | 1 |
在 sklearn 中,可以使用OneHotEncoder,它默认生成稀疏矩阵,节省内存。实际使用中还会遇到“测试集出现训练集中未出现过的类别”的问题,这可以通过设置handle_unknown='ignore'来解决。
One-Hot 的缺点也很明显:当类别基数(Cardinality)很高时,特征维度会急剧膨胀。例如某个“城市”列有 1000 个取值,One-Hot 之后会增加 999 列,这会导致内存消耗巨大,同时很多线性模型会产生过拟合。
3.3 有序类别特征的 Ordinal 编码
对于有顺序的类别变量,我们可以手动指定映射关系。重点在于“手动指定”,而不是让工具自动编码。
education_mapping = { '专科': 0, '本科': 1, '硕士': 2, '博士': 3 }这样编码后的数值是有实际意义的,模型能够学到“学历越高,薪资越高”这样单调的关系。
3.4 高基数类别特征的目标编码与频率编码
当类别特征取值非常多时,One-Hot 编码不再合适。这时常见的替代方案有:目标编码(Target Encoding)和频率编码(Frequency Encoding)。
目标编码:用该类别下目标变量的均值来替换原始类别。例如“部门_技术部”这一类别下的平均薪资是 25000,那么所有技术部的样本就用 25000 来编码该特征。目标编码的优点是能压缩维度且携带目标信息,但缺点也明显——非常容易过拟合,必须配合交叉验证或平滑(Smoothing)使用。
频率编码:用类别出现的频次或频率替换原始类别。例如“技术部”出现 200 次,“市场部”出现 80 次,则分别用 200 和 80 编码。频率编码不会引入目标泄漏,但会丢失部分类别语义。
4. 完整实战案例:员工薪资预测中的混合变量处理
接下来我们用一个完整的示例,演示如何将上面的方法组合起来。为了让示例更直观,我们会生成一份模拟数据集,然后通过 scikit-learn 的ColumnTransformer和Pipeline构建一套可复用的特征工程流程。
4.1 创建项目结构与模拟数据
首先创建项目目录:
mixed_variable_demo/ ├── data_processing.py ├── train_model.py └── requirements.txt我们先编写一个数据生成脚本,模拟一份包含混合变量的员工数据集。
# 文件路径:data_processing.py import numpy as np import pandas as pd np.random.seed(42) n_samples = 1000 # 数值型特征 age = np.random.randint(22, 60, n_samples) years_at_company = np.random.randint(0, 20, n_samples) hours_per_week = np.random.randint(30, 60, n_samples) # 类别型特征 departments = np.random.choice(['技术部', '市场部', '运营部', '销售部', '财务部'], n_samples) education_levels = np.random.choice(['专科', '本科', '硕士', '博士'], n_samples, p=[0.2, 0.4, 0.3, 0.1]) cities = np.random.choice(['北京', '上海', '广州', '深圳', '杭州', '成都', '武汉', '南京'], n_samples) # 生成目标变量:薪资,与年龄、工龄、学历、城市有一定关系 salary = ( 5000 + age * 150 + years_at_company * 800 + hours_per_week * 50 + np.where(education_levels == '本科', 2000, 0) + np.where(education_levels == '硕士', 5000, 0) + np.where(education_levels == '博士', 9000, 0) + np.where(np.isin(cities, ['北京', '上海']), 3000, 0) + np.random.normal(0, 1000, n_samples) ) # 构造 DataFrame df = pd.DataFrame({ 'age': age, 'years_at_company': years_at_company, 'hours_per_week': hours_per_week, 'department': departments, 'education': education_levels, 'city': cities, 'salary': salary }) # 人为添加缺失值 df.loc[::50, 'age'] = np.nan df.loc[::70, 'department'] = np.nan print(df.head()) print("\nDataFrame Info:") print(df.info())运行这个脚本,你会看到生成的模拟数据集。age和department列被人为加入了缺失值,方便演示处理流程。
4.2 划分训练集和测试集
在特征工程中,最忌讳的是用全量数据做编码或填补,然后再划分训练测试集。正确做法是先划分,再用训练集统计数据去转换验证集和测试集。
from sklearn.model_selection import train_test_split X = df.drop('salary', axis=1) y = df['salary'] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) print(f"训练集样本数: {X_train.shape[0]}") print(f"测试集样本数: {X_test.shape[0]}")4.3 使用 ColumnTransformer 构建特征处理流水线
ColumnTransformer是 sklearn 中处理混合变量的核心工具。它允许你对不同列分别应用不同的变换,然后再将结果拼接起来。
我们定义三套处理规则:
- 数值列:中位数填补 + 标准化;
- 无序类别列:众数填补 + One-Hot 编码;
- 有序类别列:缺失值标记 + 自定义映射编码。
from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler, OneHotEncoder, FunctionTransformer from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline import pandas as pd # 自定义有序编码函数 def ordinal_encode(X, mapping_dict): X = X.copy() for col, mapping in mapping_dict.items(): X[col] = X[col].map(mapping).astype(float) return X education_mapping = { '专科': 0, '本科': 1, '硕士': 2, '博士': 3 } # 定义列 numeric_cols = ['age', 'years_at_company', 'hours_per_week'] nominal_cols = ['department', 'city'] ordinal_cols = ['education'] # 数值列处理 numeric_transformer = Pipeline(steps=[ ('imputer', SimpleImputer(strategy='median')), ('scaler', StandardScaler()) ]) # 无序类别处理 nominal_transformer = Pipeline(steps=[ ('imputer', SimpleImputer(strategy='most_frequent')), ('onehot', OneHotEncoder(handle_unknown='ignore')) ]) # 有序类别处理 ordinal_transformer = Pipeline(steps=[ ('ordinal', FunctionTransformer( lambda X: ordinal_encode(X, {'education': education_mapping}), validate=False )) ]) # 组合成预处理流程 preprocessor = ColumnTransformer( transformers=[ ('num', numeric_transformer, numeric_cols), ('nom', nominal_transformer, nominal_cols), ('ord', ordinal_transformer, ordinal_cols) ], remainder='drop' ) # 演示:直接调用 preprocessor 处理训练集 X_train_transformed = preprocessor.fit_transform(X_train, y_train) print("处理后的训练集形状:", X_train_transformed.shape) print("特征名:", preprocessor.get_feature_names_out())4.4 在流水线中加入回归模型
特征是处理完了,但我们最终目标是预测薪资。为了确保预处理和模型训练在同一个流程中,我们将预处理器和回归模型一起放进Pipeline。
from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_squared_error, r2_score model = Pipeline(steps=[ ('preprocessor', preprocessor), ('regressor', RandomForestRegressor(n_estimators=200, random_state=42)) ]) # 训练 model.fit(X_train, y_train) # 预测 y_pred = model.predict(X_test) # 评估 rmse = mean_squared_error(y_test, y_pred, squared=False) r2 = r2_score(y_test, y_pred) print(f"RMSE: {rmse:.2f}") print(f"R²: {r2:.4f}")这段代码同时完成了特征处理和模型训练。你不需要手动去对齐列名,因为ColumnTransformer已经处理好了拼接逻辑。
4.5 结果说明
运行上面的代码,你大概率会得到一个不错的 R² 值,因为模拟数据本身带有明显的线性关系。但重点不在于分数多高,而在于整个流程是闭环的:
- 缺失值被正确填补(用训练集的统计量);
- 数值特征被标准化;
- 无序类别特征被 One-Hot 编码;
- 有序类别特征按照自定义顺序映射;
- 所有变换在 Pipeline 中一起执行,避免数据泄漏。
5. 常见问题与排查思路
在实际做特征工程时,下面几个问题是高频出现的。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
ValueError: could not convert string to float | 数据中仍有字符串列未被编码 | 检查 ColumnTransformer 的列配置,确认所有非数值列都被覆盖 |
| 测试集出现训练集没有的类别 | 数据分布变化或数据划分不合理 | OneHotEncoder 设置handle_unknown='ignore',或重新划分数据 |
| One-Hot 后维度爆炸,内存不足 | 高基数类别未做降维处理 | 改用目标编码、频率编码,或先对类别做分组聚合 |
| 模型分数很高但线上效果差 | 目标编码或缺失值填补使用了全量数据 | 确保所有转换都在训练集上 fit,在测试集上只 transform |
| 有序类别映射方向错误 | 使用 LabelEncoder 自动编码 | 手动指定映射字典,确保映射符合业务语义 |
| 流水线中包含不同特征类型时列名不对 | ColumnTransformer 的列选择使用了错误类型 | 使用remainder='passthrough'调试,查看拼接后的列名 |
5.1 类别编码顺序错误的排查示例
这是一个非常隐蔽的问题。如果你在FunctionTransformer中定义了有序映射,但传入的数据列类型是 object(字符串),而映射键也是字符串,那么 map 操作能正常进行。但如果原始数据中education列存在缺失值NaN,map之后会保留NaN,后续模型无法处理。
解决方式是在定义FunctionTransformer之前,先对education列做缺失值填充:
ordinal_transformer = Pipeline(steps=[ ('imputer', SimpleImputer(strategy='most_frequent')), ('ordinal', FunctionTransformer( lambda X: ordinal_encode(X, {'education': education_mapping}), validate=False )) ])5.2 数据泄漏问题
数据泄漏是特征工程中最严重的问题之一。常见的情况是:先用全量数据做过 One-Hot 编码,再划分训练集和测试集,这样测试集的信息已经影响了编码映射。正确做法一定是先 split,再 fit transform。
6. 最佳实践与工程建议
如果你的特征工程要用于真实项目,下面这些建议会很有价值。
6.1 始终使用 Pipeline
不要手动去写“先填补再编码再训练”的脚本。手动流程很容易在测试集上漏掉某一步转换,或者不小心使用了全量统计量。使用Pipeline和ColumnTransformer可以把所有步骤封装起来,训练和推理时执行完全一致的逻辑。
6.2 区分模型类型
- 线性模型、KNN、SVM、神经网络:数值特征必须标准化,类别特征建议 One-Hot 或目标编码;
- 树模型(决策树、随机森林、XGBoost、LightGBM):特征尺度不重要,但高基数类别特征 One-Hot 会降低训练速度,推荐使用有序编码或目标编码。
6.3 保留原始数据副本
特征工程过程中,你可能会尝试多种编码方式。建议保留一份原始数据的只读副本,每次尝试从原始数据重新生成新特征,而不是在已经被修改的 DataFrame 上继续操作。这样能避免调试时数据被“污染”。
6.4 注意目标编码的过拟合风险
目标编码使用目标变量均值作为特征值,本质上是把目标信息“泄入”了特征中。如果不加约束,训练集 R² 会虚高,但线上效果会明显下滑。两种常用缓解措施:
- 使用 K 折交叉验证,在每个折内分别计算目标编码均值;
- 使用平滑系数,让样本量少的类别向全局均值靠拢。
6.5 新类别处理策略
上线后真实业务会出现训练集中从未见过的类别。建议在编码环节明确处理策略:
- One-Hot:设置
handle_unknown='ignore',让未知类别全部取 0; - 目标编码:将未知类别映射为全局目标均值;
- 频率编码:将未知类别映射为 0 或最小值。
6.6 关注特征的可解释性
在工业落地时,模型的可解释性与模型精度同样重要。One-Hot 编码后的特征名非常直观,方便观察每个类别的贡献度。目标编码的特征可能带来精度提升,但解释起来会比较困难。你需要根据项目需求做取舍。
7. 总结与下一步学习路线
本文围绕“处理混合变量”这一核心主题,梳理了机器学习特征工程中的基础方法和完整实践。回顾一下关键知识点:
- 混合变量 = 数值型特征 + 类别型特征;
- 类别特征需要先区分有序和无序,再选择合适的编码方式;
- 最简单稳妥的组合是:数值列标准化 + 无序类别 One-Hot + 有序类别自定义映射;
- 高基数类别可尝试目标编码或频率编码,但要防范过拟合和数据泄漏;
- 使用
ColumnTransformer和Pipeline可以让特征处理流程规范、统一、可复用。
接下来你可以继续学习的方向包括:
- 特征选择:在处理完几百维特征后,如何用互信息、方差阈值、递归特征消除等方法筛选重要特征;
- 特征构造:如何基于现有特征做多项式组合、分箱、时间窗口聚合;
- 非线性编码:学习嵌入编码(Embedding Encoding)等深度学习方法在类别特征上的应用;
- 自动化特征工程:了解 Featuretools 等库如何自动生成特征。
如果你正在学习机器学习课程,或者准备参加面试,建议亲自动手跑一遍本文的代码,然后把数据集替换成真实业务数据,看处理流程能否顺利迁移。动手永远是掌握特征工程最快的方式。
如果这篇文章对你有帮助,也欢迎收藏备用,后续我会持续分享更多机器学习和数据挖掘相关的实战教程。