机器学习中的数据预处理
数据预处理的重要性
现实中的数据常常充满各种“杂质”和不一致之处,例如: • 缺失值:有的数据样本某些特征项为空缺(就像问卷有的人漏答了一题)。 • 异常值/错误数据:有的值明显不合理,比如人的身高出现了负数,或者原本应该填写年龄却填入了电话号码。 • 尺度不统一:不同特征的取值范围差异巨大,比如一个特征是用户年龄(几十的量级),另一个是收入(上万的量级);又或者相同的度量单位混用,例如身高有的记录用米,有的用厘米。 • 数据格式不一致:类别型数据可能以文字表示(“男”/“女”)或数字编码表示(0/1),需要统一处理才能供模型使用。
数据预处理的目的就是应对这些问题,具体包括:去除无效或错误的数据、填补缺失值,以及对数据的范围、单位、格式进行规范化处理,把原始数据处理成模型喜欢的样子。良好的预处理能大大提升模型训练的效率和效果。这部分内容往往比模型调参更费时,但也是机器学习工程中最重要的基础之一。
下面,我们分别介绍常见的数据预处理步骤和方法,并解释每一步为何对模型效果至关重要。
缺失值处理:补全遗失的信息
现实数据很少完美无缺。面对缺失值(Missing Values),我们需要合理应对,否则很多算法会因为无法处理空值而报错,或者错误地将缺失当作零值处理,导致偏差。
常见的缺失值处理方法有: • 删除法:直接丢弃含有缺失值的样本或特征。如果缺失值非常多且无法可靠填补,或该样本本身无意义,可以选择删掉。但删除可能丢弃有用信息,需慎重。 • 填充法:用一个合理的值替换缺失值。简单常用的策略包括用平均值或中位数填充数值型特征的空缺,用众数(出现频率最高的值)填充分类特征的空缺,或者使用固定值(如0或“未知”)标记缺失。填充值应尽量反映数据的总体趋势,避免引入明显偏差。
举个例子,假如某一特征是产品价格,缺失了一些值,我们可以用该特征的平均价格来填补缺失项。这样模型仍能利用大部分样本的信息,且不会因为空值而无法计算。再比如用户填写表单时有时会漏掉“年龄”这一栏,我们可以用所有填写了年龄用户的平均年龄来估计,或者干脆加一个布尔特征“是否缺失年龄”让模型自行学习其影响。
下面用简短的代码演示如何用平均值填充缺失值。我们构造一个简单的数组,其中包含缺失值 np.nan,然后计算均值进行替换:
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 被替换为了平均值,使得数据不再有空缺。
为什么缺失值处理影响模型效果? 一方面,缺失值如果不处理,很多模型算法(比如大部分的 sklearn 算法)会直接报错或无法训练;另一方面,用不当的值填充(比如全部用0填充)可能引入偏差,让模型学到错误的统计规律。因此,我们需要根据业务理解选择合适的处理策略,尽量还原数据的真实分布或提供模型可理解的信号。
在实际项目中,常常需要针对不同特征选择不同的缺失值填充策略。例如,用户年龄缺失可以填充为平均年龄,商品评论缺失可以填充为空字符串或特殊标记。同时要注意记录哪些值是填充得来的,必要时模型可以区别对待这些推测的数据。Scikit-Learn 提供了 SimpleImputer 等工具类方便地处理缺失值,但理解背后的逻辑依然很重要。
数值特征的缩放:让特征尺度可比
数值型特征往往有不同的量纲和取值范围。如果直接把原始值喂给模型,某些值域特别大的特征会对模型产生不成比例的影响。例如,我们要利用「身高」和「体重」两个特征来预测某人的健康指数。假设身高以厘米记录(范围约150180),体重以千克记录(范围约5080),由于身高的数值普遍比体重大一倍左右,某些对数值大小敏感的算法(比如基于距离的KNN、使用梯度的线性回归/神经网络)就可能更加依赖“身高”这个特征,不是因为身高更重要,而仅仅因为它的数值较大。为避免这种“量纲偏差”,我们需要对特征做缩放变换,使得不同特征处于相近的数值范围。
常用的数值特征缩放方法包括标准化和归一化两大类,下面分别介绍。
标准化(Standardization,均值归零)
标准化旨在将特征数据调整为均值为0、标准差为1的分布。转换公式是对每个特征列执行:
其中 是该特征的均值, 是该特征的标准差。经过这样的线性变换,所有数据都围绕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.]
运行上述代码,我们可以看到: • 原始数据每列的均值可能不是0,标准差各不相同。 • 标准化转换后,输出的 std_samples 每列均值接近0,标准差接近1(由于浮点误差可能不是完全0和1,但非常接近)。
通过标准化,数据的尺度被拉到相同水平。这在训练诸如线性回归、逻辑回归和神经网络时尤为重要:特征标准化后,梯度下降求解更稳定,收敛更快;模型对不同特征的权重调整也更公平,不会因为未标准化数据某一维数值特别大而偏向它。
生活类比: 标准化有点像把不同单位的度量转换到统一标准下比较。想象比较两个人的财产,一个用人民币衡量,一个用日元衡量,直接比数字会产生误导(因为1日元远小于1人民币)。只有把两人的资产都换算成同一种货币,才能公正地比较谁更富有。对特征做标准化,就是为了公正比较不同量纲的特征对模型的贡献。
Min-Max 归一化(区间缩放)
归一化通常是指将数据按比例缩放到某个固定区间,典型情况下是缩放到[0, 1]范围(也称Min-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. ]]
假设原始数据第三列数值远大于前两列(如上例第三列为百位量级而前两列为个位数),则输出结果中: • 每列的最小值都被转化为0,最大值转化为1; • 其他值按比例缩放,例如原始中第三列600介于最小300和最大900正中间,因此归一化后为0.5;同样地,原始第二列5在2到8区间中也大约居中,对应归一化结果约0.5。
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](各元素即为原值占总和的比例,检查可得 )。这种预处理在需要比较组成成分而非绝对值大小的任务中(如文本单词频率向量归一化)非常有用。
二值化:简单粗暴的阈值过滤
有些情况下,我们关心的不是特征的具体值,而是它是否超过某个阈值。二值化(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}(由于第三列有4种取值,其独热编码其实应占4列,但这里由于原始样本未出现某种取值,OneHotEncoder自动按实际出现的类别数编码)。打印结果可以看到每行有且仅有9个值中的几个为1,其余为0。例如第一行原始数据 [1, 3, 2] 编码后可能变成 [1,0, 1,0,0, 1,0,0,0],对应含义是:第一列取值1(编码为1,0),第二列取值3(编码为1,0,0),第三列取值2(编码为1,0,0,0)。使用 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 可以确认编码无误地还原回了原始字符串数组。
标签编码简单直接,但对于无序类别特征应慎用,避免让模型误解数值之间不存在的大小关系;独热编码更安全通用,但会增加维度。视具体情况选择合适的编码方式是特征工程的一部分。
总结
数据预处理是机器学习过程中不可或缺的一步。通过清洗无效数据、合理填补缺失、规范特征尺度以及正确编码类别信息,我们为模型提供了一个健康干净的数据集。正如盖房子要打好地基一样,充分的数据预处理能让后续的模型训练事半功倍,模型的稳定性和精度都会有明显提升。在实际工程中,不同数据集和任务可能需要不同的预处理策略,但核心思想都是为了让数据更好地表达问题、符合模型假设。
脱敏说明:本文所有出现的表名、字段名、接口地址、变量名、IP地址及示例数据等均非真实, 仅用于阐述技术思路与实现步骤,示例代码亦非公司真实代码。 示例方案亦非公司真实完整方案,仅为本人记忆总结,用于技术学习探讨。
• 文中所示任何标识符并不对应实际生产环境中的名称或编号。
• 示例 SQL、脚本、代码及数据等均为演示用途,不含真实业务数据,也不具备直接运行或复现的完整上下文。
• 读者若需在实际项目中参考本文方案,请结合自身业务场景及数据安全规范,使用符合内部命名和权限控制的配置。版权声明:本文版权归原作者所有,未经作者事先书面许可,任何单位或个人不得以任何方式复制、转载、摘编或用于商业用途。
• 若需非商业性引用或转载本文内容,请务必注明出处并保持内容完整。
• 对因商业使用、篡改或不当引用本文内容所产生的法律纠纷,作者保留追究法律责任的权利。
Copyright © 1989–Present Ge Yuxu. All Rights Reserved.