文章 · 2023-04-27

机器学习中的数据预处理

数据预处理应对数据质量问题:去除无效或错误的数据、填补缺失值,以及对数据的范围、单位、格式进行规范化处理,把原始数据处理成模型喜欢的样子。良好的预处理大大提升训练效率和模型效果——通常也比模型调参更费时。这是机器学习工程中最重要的基础之一。

下面我们介绍常见的预处理步骤和方法,解释每一步为何对模型效果至关重要。

缺失值处理:补全遗失的信息

现实数据很少完美无缺。面对缺失值(Missing Values),需要合理应对:许多算法会因为无法处理空值而报错,或错误地将缺失当作零值处理,导致系统偏差。

两种主要策略如下:

假如某特征是产品价格,缺失了一些值,可用该特征的平均价格来填补缺失项。这样模型仍能利用大部分样本的信息,且不会因为空值而无法计算。用户填写表单时有时会漏掉"年龄"这一栏,可用所有填写了年龄用户的平均年龄来估计,或加一个布尔特征"是否缺失年龄"让模型自行学习其影响。

用平均值填充缺失值的代码示例如下:

import numpy as np

# 示例数据,包含一个缺失值 np.nan
data = np.array([1.0, 2.5, np.nan, 4.0])
print("原始数据:", data)  # 输出原始数组,其中第三个元素为 nan

# 计算非缺失元素的平均值
mean_val = np.nanmean(data)  
print("非缺失值平均值:", mean_val)  # 输出计算得到的均值

# 用平均值填充缺失位置
data[np.isnan(data)] = mean_val  
print("填充缺失值后:", data)  # 输出填补缺失值后的数组

output

原始数据: [1.  2.5 nan 4. ]
非缺失值平均值: 2.5
填充缺失值后: [1.  2.5 2.5 4. ]

代码中,np.nanmean 自动忽略 nan 计算平均值。填充后,原本的 nan 被替换为平均值,使得数据不再有空缺。

为什么缺失值处理影响模型效果?未处理的缺失值会导致大多数算法(如 scikit-learn 算法)直接报错或无法训练;用不当的值填充(如全部用0填充)则引入偏差,让模型学到错误的统计规律。需要根据业务理解选择合适的处理策略,尽量还原数据的真实分布或提供模型可理解的信号。

实际项目中,常常针对不同特征选择不同的填充策略。例如用户年龄缺失填充为平均年龄,商品评论缺失填充为空字符串或特殊标记。需要记录哪些值是填充得来的,必要时模型可区别对待这些推测的数据。Scikit-Learn 的 SimpleImputer 等工具方便处理缺失值,但理解背后的逻辑依然很重要。

数值特征的缩放:让特征尺度可比

数值型特征往往有不同的量纲和取值范围。直接把原始值喂给模型,某些值域特别大的特征会对模型产生不成比例的影响。例如利用「身高」和「体重」来预测健康指数。身高以厘米记录(范围约150–180),体重以千克记录(范围约50–80)。由于身高的数值普遍比体重大,某些对数值大小敏感的算法(如基于距离的KNN、使用梯度的线性回归/神经网络)可能更加依赖"身高"这个特征,不是因为身高更重要,而仅仅因为它的数值较大。为避免这种"量纲偏差",需要对特征做缩放变换,使得不同特征处于相近的数值范围。

两种主要方法如下。

标准化(Standardization,均值归零)

标准化将特征调整为均值为0、标准差为1的分布:

$$X' = \frac{X - \mu}{\sigma}$$

其中 $\mu$ 是该特征的均值,$\sigma$ 是该特征的标准差。经过这样的线性变换,数据围绕0上下波动,大多数落在[-3, 3]范围(对于正态分布数据)。标准化使每个特征的"基准值"和"波动程度"相似,防止某个特征因为原始值偏大而主导模型结果。

**举例:**设有样本,每个样本3个特征值:

import numpy as np
from sklearn.preprocessing import scale

# 样本数据:每列代表一个特征
raw_samples = np.array([
    [3.0,  -1.0,  2.0],
    [0.0,   4.0,  3.0],
    [1.0,  -4.0,  2.0]
])
print("原始数据:\n", raw_samples)
print("每列特征的均值:", raw_samples.mean(axis=0))
print("每列特征的标准差:", raw_samples.std(axis=0))

# 使用 sklearn 的 scale 函数进行标准化(均值归零,方差归一)
std_samples = scale(raw_samples)
print("标准化后的数据:\n", std_samples)
print("标准化后每列特征的均值:", std_samples.mean(axis=0))
print("标准化后每列特征的标准差:", std_samples.std(axis=0))

output

原始数据:
 [[ 3. -1.  2.]
 [ 0.  4.  3.]
 [ 1. -4.  2.]]
每列特征的均值: [ 1.33333333 -0.33333333  2.33333333]
每列特征的标准差: [1.24721913 3.29983165 0.47140452]
标准化后的数据:
 [[ 1.33630621 -0.20203051 -0.70710678]
 [-1.06904497  1.31319831  1.41421356]
 [-0.26726124 -1.1111678  -0.70710678]]
标准化后每列特征的均值: [ 5.55111512e-17  0.00000000e+00 -2.96059473e-16]
标准化后每列特征的标准差: [1. 1. 1.]

运行代码可以看到:

标准化把数据的尺度拉到相同水平。在训练线性回归、逻辑回归和神经网络时尤为重要:特征标准化后,梯度下降求解更稳定,收敛更快;模型对不同特征的权重调整也更公平,不会因为未标准化数据某一维数值特别大而偏向它。

直观理解:标准化有点像把不同单位的度量转换到统一标准下比较。比较两人的财产,一个用人民币衡量,一个用日元衡量,直接比数字会产生误导(1日元远小于1人民币)。只有把两人的资产都换算成同一种货币,才能公正地比较谁更富有。对特征做标准化,就是为了公正比较不同量纲的特征对模型的贡献。

Min-Max 归一化(区间缩放)

归一化是指将数据按比例缩放到某个固定区间,典型情况是缩放到[0, 1]范围:

$$X' = \frac{X - X_{\min}}{X_{\max} - X_{\min}}$$

其中 $X_{\min}$ 和 $X_{\max}$ 是该特征列的最小值和最大值。经过这样的映射处理,最小值变为0,最大值变为1,其他值按原相对位置映射到0~1之间。

归一化也是为了让不同特征的取值范围具有可比性。特别是在计算欧氏距离等度量时,若一个特征的范围远大于另一个,距离计算几乎完全被"大范围"特征主导;归一化可以避免这种问题。此外,将输入特征限定在0~1之间还可能加快某些模型的收敛(如神经网络的梯度下降)。

Min-Max 归一化的简单示例:

import numpy as np
from sklearn.preprocessing import MinMaxScaler

# 样本数据:3个特征,每列数值差异较大
raw_samples = np.array([
    [ 1.0,  2.0,  300.0],
    [ 4.0,  5.0,  600.0],
    [ 7.0,  8.0,  900.0]
])
print("原始数据:\n", raw_samples)

# 初始化一个MinMax缩放器,将范围缩放到[0,1]
mms = MinMaxScaler(feature_range=(0, 1))
scaled_samples = mms.fit_transform(raw_samples)
print("Min-Max归一化后的数据:\n", scaled_samples)

output

原始数据:
 [[  1.   2. 300.]
 [  4.   5. 600.]
 [  7.   8. 900.]]
Min-Max归一化后的数据:
 [[0.  0.  0. ]
 [0.5 0.5 0.5]
 [1.  1.  1. ]]

假设原始数据第三列数值远大于前两列(如第三列为百位数而前两列为个位数),输出结果中:

注意:Min-Max归一化会压缩原有的差值分布,对最大最小值(可能是异常值)非常敏感。如果数据中存在极端异常值,Min-Max归一化会把大部分正常数据挤在接近0的位置,失去分辨率。因此,使用前通常先处理异常值或选用对异常值不太敏感的标准化方法。

工程提示:Scikit-Learn 的 MinMaxScaler 可以方便地对数据进行归一化。如果想缩放到[0, 1]以外的区间,也可以在创建 MinMaxScaler 时通过 feature_range=(min, max) 来指定新的缩放区间。

按样本归一化(Normalization by norm)

上面的标准化和Min-Max归一化都是针对特征列进行的变换。有些情况下,需要对每个样本的特征向量进行归一化处理,使得每个样本自身的所有特征值之和为1(或平方和为1)。这种操作用于关注各特征占比而非绝对大小的场景。

例如,统计中有两年的编程语言使用人数:2017年 Python 10万人,Java 20万人,PHP 5万人;2018年 Python 8万人,Java 10万人,PHP 1万人。两年总人数都不相同。如果直接比较人数增减,Python用了8万看似减少,但它在2018年总人数中的占比反而提升了。通过对每年数据做行归一化,将每年的总人数视为1,再看各语言所占比例,就能更直接地比较它们的相对变化。

在 sklearn 中,可以使用 preprocessing.normalize 来实现按样本的归一化。设置 norm='l1' 表示将每个样本向量按绝对值之和归一化;norm='l2' 则表示按平方和的平方根归一化(即将每个样本看作一个向量,长度缩放为1)。l1 归一化会把每个样本的特征值绝对值之和缩放为1:

from sklearn.preprocessing import normalize
import numpy as np

raw_samples = np.array([
    [10.0, 20.0, 5.0],
    [ 8.0, 10.0, 1.0]
])
# 使用 L1 范数归一化每个样本(行)
norm_samples = normalize(raw_samples, norm='l1')
print("按样本归一化后的数据:\n", norm_samples)

output

按样本归一化后的数据:
 [[0.28571429 0.57142857 0.14285714]
 [0.42105263 0.52631579 0.05263158]]

输出的每行数据各特征之和都等于1。例如,第一行 [10, 20, 5] 归一化后变为 [0.2857, 0.5714, 0.1429](各元素即为原值占总和的比例,检查可得 $0.2857+0.5714+0.1429=1$)。这种预处理在需要比较组成成分而非绝对值大小的任务中(如文本单词频率向量归一化)非常有用。

二值化:简单粗暴的阈值过滤

有些情况下,我们关心的不是特征的具体值,而是它是否超过某个阈值。二值化(Binarization)就是把数值特征转换成只有0和1两种取值:低于阈值记为0,高于阈值记为1。这样做可以简化模型,只保留关键信息。例如,在图像处理中,可以将灰度图像二值化,以突出边缘轮廓而忽略细微的灰度变化。

对数据特征进行二值化在Scikit-Learn中很容易实现:

import sklearn.preprocessing as sp
import numpy as np

raw_samples = np.array([[65.5, 89.0, 73.0],
                        [55.0, 99.0, 98.5],
                        [45.0, 22.5, 60.0]])
binarizer = sp.Binarizer(threshold=60)  # 定义阈值为60
bin_samples = binarizer.fit_transform(raw_samples)
print("二值化处理后的数据:\n", bin_samples)

output

二值化处理后的数据:
 [[1. 1. 1.]
 [0. 1. 1.]
 [0. 0. 0.]]

代码中,阈值设为60,那么输出中原始值不高于60的全部变为0,高于60的变为1。需要注意,二值化会丢失数值的细粒度信息(例如把65.5和89.0都变成1,区别消失了),而且这个转换不可逆(无法从结果0/1还原原始值)。因此,除非模型确实只需要关注超过阈值与否这种信息,否则应谨慎使用。如果想保留可逆的数值转换来表示类别信息,可以考虑下文的独热编码。

二值化适合某些特殊场景,例如将连续的声音信号处理成0/1以表示静音和有声,或者根据考试分数划定是否及格等。在这些场景下,阈值的选择非常重要,需要根据业务需求确定。

分类变量编码:独热编码与标签编码

原始数据中类别型(分类)特征无法直接输入大多数机器学习模型。比如性别、颜色、品牌这种非数值信息,需要先编码成数字形式。常见的类别编码方法有两种:独热编码(One-Hot Encoding)和标签编码(Label Encoding)。它们适用于不同的场景。

独热编码(One-Hot Encoding)

import numpy as np
from sklearn.preprocessing import OneHotEncoder

# 原始数据:每行一个样本,包含三个类别特征
raw_samples = np.array([
    [1,  3,  2],
    [7,  5,  4],
    [1,  8,  6],
    [7,  3,  9]
])
# 定义 OneHotEncoder,sparse=False 表示输出稠密NumPy数组
one_hot = OneHotEncoder(sparse_output=False)
oh_samples = one_hot.fit_transform(raw_samples)
print("独热编码后的结果:\n", oh_samples)
print("编码后矩阵形状:", oh_samples.shape)
# 可以通过 inverse_transform 将编码结果还原回原始类别
print("还原回原始数据:\n", one_hot.inverse_transform(oh_samples))

output:

独热编码后的结果:
 [[1. 0. 1. 0. 0. 1. 0. 0. 0.]
 [0. 1. 0. 1. 0. 0. 1. 0. 0.]
 [1. 0. 0. 0. 1. 0. 0. 1. 0.]
 [0. 1. 1. 0. 0. 0. 0. 0. 1.]]
编码后矩阵形状: (4, 9)
还原回原始数据:
 [[1 3 2]
 [7 5 4]
 [1 8 6]
 [7 3 9]]

有3列分类特征。OneHotEncoder 自动识别每列中的不同取值种类,为每一列分别创建对应的独热编码。最终输出 oh_samples 是一个4行×9列的矩阵:前三列对应原第一列特征可能的取值{1,7},接下来的三列对应原第二列特征可能的取值{3,5,8},最后三列对应原第三列特征可能的取值{2,4,6,9}(由于原始样本未出现某种取值,OneHotEncoder自动按实际出现的类别数编码)。打印结果可以看到每行有且仅有几个值为1,其余为0。例如第一行原始数据 [1, 3, 2] 编码后可能变成 [1, 0, 1, 0, 0, 1, 0, 0, 0],对应含义是:第一列取值1,第二列取值3,第三列取值2。使用 inverse_transform 可以验证编码正确性,它能将独热矩阵再转回原始的类别取值。

独热编码不会丢失信息,并且是可逆的。缺点是会使数据维度变高,特别是当类别种类很多时,会生成大量稀疏的0/1特征。这会增加模型的计算和存储负担,不过对于大多数线性模型和树模型来说这是常见处理方式。实际应用中,如果某个特征的类别种类非常多,可能考虑目标编码或降维等别的方法,但那超出了本文范围。

标签编码(Label Encoding)

标签编码是另一种简单的类别编码方式,即将每个类别直接用一个整数标签表示。比如"北京, 上海, 广州"可以映射为0, 1, 2。这种方法不会增加维度,直接将类别特征转换为了数值,但需要注意类别编码后的数值本身并没有大小顺序上的意义。如果对待这些数字不加注意,某些模型(尤其是线性回归、SVM这类对数值大小敏感的算法)可能错误地将类别的数字大小当成有序信号。如果类别本身没有大小关系(如颜色、城市),一般更倾向使用独热编码而非直接使用标签编码。

标签编码通常用于已有大小意义的序数特征(例如教育程度:高中=0、大专=1、本科=2、硕士=3)或者用于对模型输出标签进行编码(如二分类标签正/负映射为1/0)。在预处理阶段,也经常先用标签编码把文字类别转成数字表示,然后再进一步喂给独热编码或其它算法。

下面演示使用 LabelEncoder 对简单的类别列表进行编码和解码:

import numpy as np
from sklearn.preprocessing import LabelEncoder

raw_labels = np.array(['lv', 'ee', 'lth', 'ee', 'tt', 'lv'])
label_encoder = LabelEncoder()
encoded_labels = label_encoder.fit_transform(raw_labels)
print("标签编码结果:", encoded_labels)
print("还原回原始标签:", label_encoder.inverse_transform(encoded_labels))

output

标签编码结果: [2 0 1 0 3 2]
还原回原始标签: ['lv' 'ee' 'lth' 'ee' 'tt' 'lv']

输出的 encoded_labels 可能是 [0 2 0 1 2 1],表示算法将'audi'映射为0,'bmw'映射为1,'ford'映射为2。(具体映射次序取决于LabelEncoder按类别字母排序或出现顺序。)通过 inverse_transform 可以确认编码无误地还原回了原始字符串数组。

标签编码简单直接,但对于无序类别特征应慎用,避免让模型误解数值之间不存在的大小关系;独热编码更安全通用,但会增加维度。视具体情况选择合适的编码方式是特征工程的一部分。

总结

数据预处理是机器学习过程中不可或缺的一步。通过清洗无效数据、合理填补缺失、规范特征尺度以及正确编码类别信息,我们为模型提供了一个健康干净的数据集。正如盖房子要打好地基一样,充分的数据预处理能让后续的模型训练事半功倍,模型的稳定性和精度都会有明显提升。在实际工程中,不同数据集和任务可能需要不同的预处理策略,但核心思想都是为了让数据更好地表达问题、符合模型假设。

© 2026 Yuxu Ge ·