数据预处理:从原始数据到可用特征的完整指南
核心结论:现实世界的数据往往包含缺失值、异常值、非数值类型和巨大的量纲差异,若不经过系统的预处理,模型训练的效果将大打折扣——“Garbage in, garbage out”决定了数据质量直接约束模型性能的上限。
核心要点
- 数据预处理是机器学习流程中至关重要的一环,现实数据远不如课程 Demo 中的数据“干净”。
- 常用 Python 工具包括:Pandas(二维表格数据处理)、Matplotlib(基础可视化)、Seaborn(高级可视化)、Scikit-learn(机器学习标准库,包含丰富的预处理工具和算法实现)。
- 数据预处理前必须先划分数据集,且只能在训练集上进行学习(fit),对训练集和测试集进行转换(transform)。
- 数据分析探索(EDA)帮助我们在预处理前全面认识数据问题,指导后续处理的决策。
- 预处理的核心操作包括:数据清洗(重复值、缺失值、异常值)、特征编码(文本转数值)、特征构造与删除、特征缩放。
- 使用 Pipeline 可将多个预处理步骤自动化串联,降低人为出错风险。
数据与工具概述
为什么需要数据预处理
前面课程介绍的线性回归模型和梯度下降算法,在 Demo 中表现良好,是因为演示数据非常干净。但现实世界中的数据通常存在以下典型问题:
- 异常值(Outliers) :显著偏离其他观测值的极端值
- 缺失值(Missing Values) :部分特征或样本存在空缺
- 非数值类型:包含文本、类别等无法直接参与数学运算的数据
- 量纲差异巨大:例如房价是百万级,房间个数是个位数,参数间差异过大导致模型训练困难
处理原则
在算法界有一句至理名言:
“Garbage in, garbage out.”
即输入的数据质量极低,训练出的模型质量必然也极低。
四个核心工具
| 工具 | 功能定位 |
|---|---|
| Pandas | 处理二维数据(类似 Excel 表格),提供各种常见的快捷数据处理操作 |
| Matplotlib | Python 最基础的数据可视化库 |
| Seaborn | 基于 Matplotlib 的高级可视化库,语法更简洁,图表更美观 |
| Scikit-learn | 机器学习标准库,包含丰富的预处理工具和常用算法实现 |
准备测试数据:泰坦尼克号数据集
数据集说明
课程使用开源的 Titanic 生存预测数据集作为测试数据。可以从网络下载,虽然 Scikit-learn 内置了该数据集,但已做预处理,为了学习完整的预处理流程,应尽可能使用原始数据。
核心代码操作:
df = pandas.read_csv("从网络下载数据的URL") # 下载并加载到内存
df.shape # 查看数据格式
df.head() # 查看前五条数据
df.info() # 查看数据类型与缺失情况数据概览
- 数据集形状为 891 行 × 12 列,即 11 个特征 + 1 个标签
- 特征包括 Name、性别、年龄等字段
- 前 5 行数据中 Index 从 0 开始计数
缺失值初步分析(df.info() 结果)
| 字段 | 非空数量 | 缺失情况 |
|---|---|---|
| 大部分字段 | 891(全部) | 无缺失 |
| Age | 714 | 缺约 177 条 |
| Cabin | 204 | 缺约 687 条(约 77%) |
| Embarked | 889 | 缺 2 条 |
此外,多个字段是 str 类型而非 int 或 float 类型,需要在后续进行数值化处理。
数据集划分:训练集、验证集与测试集
三个数据集的作用
| 数据集 | 作用 |
|---|---|
| 训练集 | 训练模型,学习数据中的规律 |
| 验证集 | 调整超参数,评估模型在未见过的数据上的表现 |
| 测试集 | 模拟真实世界数据,最终评估模型的泛化能力 |
为什么必须先划分再预处理
这是初学者最容易犯的错误——上来对所有数据做预处理后才划分数据集。
原因:如果在预处理过程中加入了主观认知(如填充、缩放等),预测结果只能代表模型对自己修改过的数据预测准确,不能代表它对真实世界数据的泛化能力。预处理后数据被修改过,这种情况称为数据泄露(Data Leakage)。
正确做法:
- 对训练集:进行预处理操作(学习参数)
- 对测试集:只做转换,不可学习其中的分布信息
划分方法与代码
使用 Scikit-learn 中的 train_test_split 函数,按 80% 训练 / 20% 测试进行划分:
X = df.drop("Survived", axis=1) # 所有特征
y = df["Survived"] # 标签:是否存活
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)关于 `stratify` 参数:如果纯随机抽取 80%/20%,Y 值(标签)比例可能无法保持一致。设置 stratify 后,系统会先学习数据标签分布,在划分时尽可能保持训练集和测试集的标签比例与原数据一致。测试结果显示,划分后的训练集和测试集存活率均维持在 38% 左右,这种划分方式让模型训练更加真实可信。
数据探索分析(EDA)
EDA 的定义与作用
当拿到真实世界的数据时,往往不知道需要做哪些处理,也不知道存在多少异常值或缺失值。EDA(Exploratory Data Analysis,探索性数据分析) 就像对数据做一次全面的体检——发现存在问题之后,再有针对性地进行数据预处理。
EDA 核心操作
1. 查看数据类型与缺失情况
使用 df.info(),可以发现:
- 有些特征存在大量缺失
- 有些特征是文本类型
2. 缺失值统计
X_train.isnull().sum() # 绝对值统计
X_train.isnull().mean() # 缺失率| 特征 | 缺失绝对值 | 缺失率 |
|---|---|---|
| Age | 约 20% | 约 20% |
| Cabin | 约 70% | 约 77% |
| Embarked | 2 | 0.3% |
3. 数值特征统计:describe()
输出包括:计数(count)、均值(mean)、标准差(std)、最小值、25/50/75 分位数、最大值。
分位数 vs 均值:分位数比平均值大部分时候更可靠。例如“姚明和潘长江平均身高是 1.8 米”并不能代表大部分人的身高;而百分位数(如 75 分位)能更准确反映整体分布情况。
关键发现:Fare 最大值为 512,最小值为 0,差距过大,后续必然需要进行缩放。
4. 可视化分析
使用 Matplotlib 和 Seaborn 绘制多维度图表,探索数据规律:
- 存活率分布:存活率看起来比死亡率低,大部分人不幸遇难
- 年龄分布:20-40 岁之间人数最多,年龄大的和年龄小的也有分布
- 票价分布:存在严重的右偏(尾巴在右边),个别极高票价拉长尾部,对模型训练极不友好
- 性别与存活率:女性存活率远高于男性——尽管男性身体更强壮,但可能发挥了“高风亮节”
- 舱位等级与存活率:一等舱存活率 > 二等舱 > 三等舱——有钱人更容易活下来
5. 相关热力图
通过相关性热力图,观察各属性与存活率(Survived)的相关性:
| 特征 | 与存活率的相关性 |
|---|---|
| Fare | 正相关(票价越高越相关) |
| Age | 相关性不大 |
| Siblings(亲属数) | 影响不大(负相关) |
| Pclass | 负相关(一等舱数值小但存活率高) |
热力图对设置初始超参数有重要的指导意义。
EDA 的关键发现与预处理指导
| EDA 发现 | 预处理策略 |
|---|---|
| 女性存活率远高于男性 | 性别是强特征,需编码为数值 |
| 一等舱存活率最高 | Pclass 是强特征,需保留 |
| Age 缺失约 20%,分布略有偏态 | 用中位数填充(不用均值) |
| Cabin 缺失约 77% | 几乎无用,可将其删除或改造 |
| Fare 严重右偏 | 需做对数变换或截断处理 |
| 存在明确无用的特征 | 删除或提取关键信息 |
数据预处理方法与步骤
一、数据清洗
1. 处理重复值
拿到数据后,首先剔除重复值。操作步骤:
X_train = X_train.drop_duplicates()
y_train = y_train.loc[X_train.index] # 同步删除对应的标签行注意:特征和标签一一对应,依靠索引(序号)关联。从 X_train 中删除行后,必须同步从 y_train 中删除对应行,做强制同步操作。
2. 处理缺失值
缺失值的处理方法取决于缺失比例和数据特征:
| 缺失率 | 处理策略 | 适用条件与原因 |
|---|---|---|
| < 5% 且随机缺失 | 直接删除对应行 | 缺失极少、无规律可循,简单处理 |
| > 70% | 直接删除该列 | 特征信息量极少,保留无意义 |
| 介于中间 | 填充 | 数据来之不易,不宜轻易删除 |
重要考量:不要轻易删除整行数据。例如一个有 250 个特征的数据集,因为一个特征缺失就删掉一整行,其他 249 个特征的信息就被浪费了。
填充方法:
- 数值类型特征:用中位数(而非均值)填充。原因:中位数对极值不敏感,在数据偏态时更加稳健。
- 类别型特征(如性别、颜色等以文本形式出现的特征):用众数填充(出现次数最高的值)。例如 300 条数据中 200 条是红色,就把缺失值填充为红色。
- 补充方案:新增
unknown类别。某些情况下缺失不代表获取出错,而是该样本本身就没有此信息,此时应根据业务判断,不用于众数填充,而是新增一种类型。
代码实现(使用 SimpleImputer) :
from sklearn.impute import SimpleImputer
# 中位数填充(例如 Age)
imputer_age = SimpleImputer(strategy="median")
imputer_age.fit(X_train[["Age"]]) # 只在训练集学习
X_train["Age"] = imputer_age.transform(X_train[["Age"]])
X_test["Age"] = imputer_age.transform(X_test[["Age"]])
# 众数填充(例如 Embarked)
imputer_emb = SimpleImputer(strategy="most_frequent")
imputer_emb.fit(X_train[["Embarked"]]) # 只在训练集学习
X_train["Embarked"] = imputer_emb.transform(X_train[["Embarked"]])
X_test["Embarked"] = imputer_emb.transform(X_test[["Embarked"]])特别注意:学习(fit)时只学习训练集的数据分布;转换(transform)时使用训练集学习到的参数对训练集和测试集都进行转换。绝不能在测试集上 fit,否则相当于作弊。
Cabin 特征处理:缺失 77%,几乎无用。可将该列改造为一个新的二值列 Cabin_known(是否拥有客舱信息),用 0/1 表示,再删除原始列。
3. 处理异常值
定义:显著偏离其他观测值的极端值(极大或极小)。
识别方法:通过箱线图(Boxplot)可视化观察。
- Age 集中在 20-40 岁之间,但 60-80 岁仍有不少离群点(高龄乘客是真实存在的)
- Fare 极度右偏,大部分票价很低,极少数极高价格拉偏分布,若不处理会让模型过度关注高票价
- 亲属个数大部分为 0 或 1,少数较大,可能真实也可能是异常值
统计学处理:截断(Clipping)策略
设置一个合理区间,如果值小于下界则设为下界值,大于上界则设为上界值:
Q1 = data["Fare"].quantile(0.25) # 25 分位数
Q3 = data["Fare"].quantile(0.75) # 75 分位数
IQR = Q3 - Q1 # 四分位距
lower = max(Q1 - 1.5 * IQR, 0) # 至少为 0(票价不能为负)
upper = Q3 + 1.5 * IQR
# 找出极端值
outliers = data[(data["Fare"] < lower) | (data["Fare"] > upper)]找出极值后做截断处理,将其限制在合理区间内。对训练集和测试集都做相同处理,不简单粗暴删除——数据来之不易,应尽量避免删除。
二、特征编码(文本转数值)
编码的目的与核心区别
将文本类特征映射为数值类特征,模型才能进行数学运算(线性回归的本质就是数学运算)。不同编码方式的核心区别在于:是否保留特征中暗含的顺序关系,或是否引入数值大小含义。
三种编码方式对比
| 编码方式 | 原理 | 适用场景 | 优势 | 局限 |
|---|---|---|---|---|
| 序数编码(Ordinal Encoding) | 类别映射为整数,严格保留顺序 | 有序类别特征(如学历:小学<初中<高中;客户满意度:1-5 星) | 模型能学到顺序关系 | 不能用于无序类别,会引入错误的大小关系 |
| 独热编码(One-Hot Encoding) | 每个类别变为独立一列,用 0/1 表示 | 无序类别特征(颜色、城市、性别等) | 完全消除顺序假设,只表示“有无” | 显著增加列数(特征维度),500 个类别会新增 499 列,可能使模型训练复杂化 |
| 标签编码(Label Encoding) | 文本映射为数字,不保证顺序 | 二分类特征(如性别)、标签列、树模型 | 省空间(仅一列) | 严禁在线性模型中使用;不保证数值意义 |
为什么不能用 1、2、3 编码无序类别? 如果用 1、2、3 表示红、绿、蓝,模型会错误地认为蓝 = 红 × 3,或蓝色最大、红色最小。颜色之间没有顺序关系,这种编码会带来严重误导。
关于 One-Hot 的列数扩展问题:如果类别数达到 500 个,删除一列后新增 500 列,模型训练会变得非常复杂。后续课程将介绍 Embedding 方法解决此类问题。
标签编码注意事项:
- 二分类特征(性别男/女)可以用,相当于独热但更省空间
- 分类任务的标签列(最终预测结果)可以用,因为许多算法要求输出数字
- 树模型对数值不敏感,可以使用
- 严禁在线性模型和多分类无序特征中使用
- 有顺序特征在线性模型中用序数编码;无顺序特征用独热编码或更高级的 Embedding
代码示例:
from sklearn.preprocessing import OrdinalEncoder, OneHotEncoder, LabelEncoder
# 序数编码
ordinal_encoder = OrdinalEncoder()
ordinal_encoder.fit_transform(data[["quality"]])
# 独热编码
onehot_encoder = OneHotEncoder()
encoded = onehot_encoder.fit_transform(data[["embarked"]])
# 标签编码(fit_transform 一步完成学习和转换)
label_encoder = LabelEncoder()
label_encoder.fit_transform(data["gender"])说明:fit_transform 方法把“学习”和“转换”两步合并为一步完成。
三、特征构造与删除
目的
原始数据只是“原材料”,需要基于对业务背景的理解来挖掘数据背后真正的规律,把杂乱的原始记录转化为模型更容易理解的高质量特征。总原则:去粗取精。
具体操作
- 特征合并:比如将多个与亲属相关的特征合并为“家庭规模”:
- 将
SibSp(兄弟姐妹/配偶数量)和Parch(父母/子女数量)合并为FamilySize - 如果
FamilySize == 1,标记为IsAlone(独自一人),可能影响逃生策略
- 提取关键信息:姓名中的头衔(Mr、Mrs、Master 等)可以反映社会地位或特殊群体身份,可能影响逃生策略。把
Lady、Sir等贵族头衔单独提取出来。
- 删除无用特征:对缺失值太多、格式混乱、或明显会导致数据泄露的列直接删除。
代码逻辑示意:
# 构造家庭规模
data["FamilySize"] = data["SibSp"] + data["Parch"] + 1
# 标记独自一人
data["IsAlone"] = (data["FamilySize"] == 1).astype(int)
# 从姓名中提取头衔
data["Title"] = data["Name"].str.extract("...", expand=False)
# 删除无用特征
data = data.drop(columns=["Name", "Ticket", "Cabin", "SibSp", "Parch"])经过特征构造与删除后,数据集更加精简且富含信息量,模型训练更加高效。
四、特征缩放
为什么需要特征缩放
数据集中部分特征量纲差异可能极大:Age 范围 0-80,Fare 范围 0-100+,亲属数 0-8。如果不缩放:
- 模型会错误地认为数值大的特征更重要,赋予过大的权重
- 梯度下降更新参数时反复震荡,需要极多迭代次数才能找到最优解
- 甚至可能最终无法收敛
注:前面课程讲梯度下降时把房间面积从“平方米”改成“百平米”,就是最简单的数据缩放操作。
将不同列的数据缩放到同一维度,模型处理会更加精准。
两种主要缩放方法对比
| 方法 | 原理 | 输出范围 | 适用场景 | 局限 |
|---|---|---|---|---|
| 标准化(Standardization) | 转换为均值为 0、标准差为 1 的分布 | 不强制限定区间,可为负数 | 数据近似正态分布时效果极好;线性模型、逻辑回归等 | 受异常值影响大(需先处理异常值) |
| 归一化(Normalization) | 线性变换到固定区间(通常 0-1) | 严格限定 [0, 1],无负数 | 图像处理、神经网络输入(像素天然 0-255 分布);配合 Sigmoid 激活函数输出 0-1 | 受异常值影响大,离群点会破坏数据分布 |
标准化公式:\( z = \frac{x - \mu}{\sigma} \)
归一化公式:通常为 \( x' = \frac{x - x_{min}}{x_{max} - x_{min}} \)
代码示例(标准化) :
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
scaler.fit(X_train[["Age", "Fare"]]) # 只在训练集学习
X_train_scaled = scaler.transform(X_train[["Age", "Fare"]])
X_test_scaled = scaler.transform(X_test[["Age", "Fare"]])标准化后检查训练集均值和标准差:均值接近 0(如 \(-10^{-17}\)),标准差接近 1,说明数据已适合模型训练。
实践建议:由于标准化受异常值影响,应在缩放之前先用前面介绍的方法处理异常值,整体会更稳定。
五、Pipeline 自动化封装
为什么需要 Pipeline
前面介绍了大量预处理步骤,流程繁琐且容易出错。尤其是“只在训练集上 fit,然后在训练集和测试集上 transform”这一原则,在实际操作中极容易因疏忽而出错。
Pipeline 的作用
使用 Scikit-learn 的 Pipeline 和 ColumnTransformer 可以将整个预处理流程串联起来,自动完成操作:
示例结构:
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
# 数值特征流水线:缺失值填充 + 标准化
numeric_pipeline = Pipeline(steps=[
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler())
])
# 类别特征流水线:缺失值填充 + 独热编码
categorical_pipeline = Pipeline(steps=[
("imputer", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder())
])
# 组合不同列的不同处理方式
preprocessor = ColumnTransformer(transformers=[
("num", numeric_pipeline, ["Age", "Fare"]),
("cat", categorical_pipeline, ["Embarked", "Sex"])
])
# 一键处理
X_train_processed = preprocessor.fit_transform(X_train)
X_test_processed = preprocessor.transform(X_test)Pipeline 帮我们自动完成各种操作,减少忙中出错的可能性。
限制与待确认问题
- 本课程主要以泰坦尼克号数据集为例,不同数据集的预处理策略需根据业务场景和数据特征灵活调整
- 关于异常值是否删除或保留,需结合业务判断(如高龄乘客可能为真实数据而非异常)
- 特征缩放中归一化具体公式原文未完整给出,标准化公式已给出完整形式
- 关于后续的模型训练与过拟合评估(欠拟合/过拟合判断方法),将在下节课继续学习
- Embedding 方法作为高维独热编码的替代方案,将在后续课程中介绍
总结与核心原则
四个核心原则
- Garbage In, Garbage Out:数据质量决定模型上限,再好的算法也无法从垃圾数据中学到有价值的规律。
- 先划分,后预处理:必须先划分训练集/测试集,然后在训练集上学习(fit)参数,再对训练集和测试集进行转换(transform)。绝不对测试集进行学习,否则造成数据泄露。
- 用 EDA 指导预处理:通过图表和统计指标了解数据存在的问题,再针对性选择预处理方法,而不是盲目处理。
- 用 Pipeline 封装流程:处理流程长且易于出错时,使用 Pipeline 进行自动化串联。
数据处理全流程回顾
- 划分训练集和测试集(保持标签比例一致)
- 进行 EDA 探索性分析:查看缺失、分布、相关性
- 数据清洗:删除重复值 → 处理缺失值(删除行/列、中位数/众数填充、新增类别)→ 处理异常值(截断到合理区间)
- 特征编码:序数编码(有序特征)、独热编码(无序特征)、标签编码(二分类特征/标签列)
- 特征构造与删除:合并语义化特征、提取关键信息、删除噪音特征
- 特征缩放:标准化(近似正态、线性模型)或归一化(图像、神经网络输入)
- 使用 Pipeline 封装全流程
预处理完成后,就可以进行真正的模型训练和评估。如果发现模型无法拟合数据(欠拟合)或拟合过度(过拟合),如何判断和处理将是下节课的学习内容。