机器学习中的数据预处理
数据预处理的重要性
现实中的数据常常充满各种“杂质”和不一致之处,例如: • 缺失值:有的数据样本某些特征项为空缺(就像问卷有的人漏答了一题)。 • 异常值/错误数据:有的值明显不合理,比如人的身高出现了负数,或者原本应该填写年龄却填入了电话号码。 • 尺度不统一:不同特征的取值范围差异巨大,比如一个特征是用户年龄(几十的量级),另一个是收入(上万的量级);又或者相同的度量单位混用,例如身高有的记录用米,有的用厘米。 • 数据格式不一致:类别型数据可能以文字表示(“男”/“女”)或数字编码表示(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、脚本、代码及数据等均为演示用途,不含真实业务数据,也不具备直接运行或复现的完整上下文。
• 读者若需在实际项目中参考本文方案,请结合自身业务场景及数据安全规范,使用符合内部命名和权限控制的配置。Data Desensitization Notice: All table names, field names, API endpoints, variable names, IP addresses, and sample data appearing in this article are fictitious and intended solely to illustrate technical concepts and implementation steps. The sample code is not actual company code. The proposed solutions are not complete or actual company solutions but are summarized from the author's memory for technical learning and discussion.
• Any identifiers shown in the text do not correspond to names or numbers in any actual production environment.
• Sample SQL, scripts, code, and data are for demonstration purposes only, do not contain real business data, and lack the full context required for direct execution or reproduction.
• Readers who wish to reference the solutions in this article for actual projects should adapt them to their own business scenarios and data security standards, using configurations that comply with internal naming and access control policies.版权声明:本文版权归原作者所有,未经作者事先书面许可,任何单位或个人不得以任何方式复制、转载、摘编或用于商业用途。
• 若需非商业性引用或转载本文内容,请务必注明出处并保持内容完整。
• 对因商业使用、篡改或不当引用本文内容所产生的法律纠纷,作者保留追究法律责任的权利。Copyright Notice: The copyright of this article belongs to the original author. Without prior written permission from the author, no entity or individual may copy, reproduce, excerpt, or use it for commercial purposes in any way.
• For non-commercial citation or reproduction of this content, attribution must be given, and the integrity of the content must be maintained.
• The author reserves the right to pursue legal action against any legal disputes arising from the commercial use, alteration, or improper citation of this article's content.Copyright © 1989–Present Ge Yuxu. All Rights Reserved.