本文基于真实矿物分类项目,完整拆解工业级表格数据清洗全流程:异常值处理、6种缺失值填充、标准化、SMOTE过采样,严格遵循"无数据泄露"原则。所有代码均来自项目源码,可直接复用。
一、项目背景与数据预处理"黄金法则"
1. 业务目标
我们有一份矿物检测数据集,包含氯、钠、镁、硫 等13项化学成分特征,目标是将样本划分为 A/B/C/D 四类矿物。原始数据存在大量非数值异常值(如 <0.01、|、空格)、缺失值以及类别不平衡问题。若直接建模,模型精度会严重失真,因此数据清洗是整个项目的基石。
2. 数据预处理的"黄金法则"
在动手写代码之前,必须明确预处理顺序,避免数据泄露(测试集信息被提前"偷看"):
-
读取原始数据 → 删除无效类别(E类) → 处理异常值(转数值)
-
先划分训练集与测试集(绝不能先填充再划分)
-
基于训练集的统计量填充训练集和测试集的缺失值
-
基于训练集的均值和标准差对训练集和测试集做标准化
-
仅对训练集做SMOTE过采样(测试集保持原始分布)
-
保存清洗后的数据集,供后续模型训练
本文所有代码严格遵循这一流程。
二、环境准备与路径管理
1. 环境依赖
import pandas as pd
import matplotlib.pyplot as plt
import filldata # 自定义缺失值填充模块(后续详解)
from pathlib import Path
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from imblearn.over_sampling import SMOTE
注意:filldata是笔者自己创建的python文件,其中包含了填充数据的6种方法,后续对填充的讲解只是filldatda中的部分代码。代码已经上传,读者可直接开箱使用
2. 项目路径自动管理(pathlib 应用)
BASE_DIR = Path(__file__).resolve().parent # 脚本所在目录的绝对路径
data_path = BASE_DIR / "矿物数据.xls" # 原始数据文件路径
output_dir = BASE_DIR / "temp_data" # 输出目录
output_dir.mkdir(parents=True, exist_ok=True) # 创建目录(若不存在)
💡 细节解析
Path(__file__).resolve().parent无论从何处运行脚本,都能稳定获取脚本所在目录的绝对路径。
mkdir(parents=True, exist_ok=True)相当于mkdir -p,安全创建目录,已存在也不报错。路径拼接使用
/运算符,自动适配 Windows/Linux/macOS 的路径分隔符。
三、数据读取与初步清洗
1. 加载数据并删除 E 类
data = pd.read_excel(data_path)
data = data[data['矿物类型'] != 'E'] # 删除矿物类型为 'E' 的行
💡 布尔索引原理
data['矿物类型'] != 'E'返回一个布尔型 Series(True/False),data[布尔Series]选出所有 True 的行。这一步滤掉了不需要的 E 类样本。
2. 拆分特征与标签
X_whole = data.drop(['序号', '矿物类型'], axis=1) # 特征:删除序号和标签列
Y_whole = data['矿物类型'] # 标签
x_whole以及y_whole的展示:


3. 标签数值编码(A/B/C/D → 0/1/2/3)
label_dict = {"A":0, "B":1, "C":2, "D":3}
encoded_label = [label_dict[label] for label in Y_whole]
Y_whole = pd.Series(encoded_label, name="矿物类型")
💡 为什么用列表推导式?
列表推导式简洁高效,将字符标签一次性转为数值。最后转回
Series并保留列名,便于后续合并操作。
对y_whole进行标签数值编码后结果如下所示:

四、异常值处理(字符串→数值)
原始数据中很多特征列混入了 "<0.01"、"|"、空格等非数值内容。使用 pd.to_numeric 强制转换,无法转换的设为 NaN,为后续缺失值填充做准备。
for column_name in X_whole.columns:
X_whole[column_name] = pd.to_numeric(X_whole[column_name], errors='coerce')
💡
errors='coerce'的作用该参数使转换失败的字符串变成
NaN,而不是抛出异常。这一步将所有异常值统一为缺失值,便于后续统一处理。
进行异常值处理后的x_whole如下所示:

五、缺失值分析与可视化
1. 统计缺失值数量
null_num = X_whole.isnull() # 每个元素是否为缺失值(布尔)
null_total = null_num.sum() # 每列缺失值个数
print("各特征缺失值数量:\n", null_total)
2. 绘制缺失值分布图(可选)
plt.rcParams['font.sans-serif'] = ['SimHei'] # 支持中文
plt.figure(figsize=(12, 6))
null_total.sort_values(ascending=False).plot(kind='bar', color='#1f77b4')
plt.title('各特征缺失值数量分布', fontsize=14)
plt.xlabel('特征名称', fontsize=12)
plt.ylabel('缺失值数量', fontsize=12)
plt.grid(axis='y', linestyle='--', alpha=0.3)
plt.tight_layout()
plt.show()
结果展示:

六、数据集划分(关键步骤!)
先切分,后填充,严格避免测试集信息泄露。
x_train, x_test, y_train, y_test = train_test_split(
X_whole, Y_whole, random_state=7
)
x_train = x_train.reset_index(drop=True)
y_train = y_train.reset_index(drop=True)
x_test = x_test.reset_index(drop=True)
y_test = y_test.reset_index(drop=True)
💡 为什么先划分?
如果先填充再划分,填充时可能用到测试集的信息(如均值),导致测试集数据"污染",评估结果虚高。先划分能保证测试集在预处理阶段完全"不可见"。
💡reset_index(drop=True)的作用
train_test_split切分后,子集保留了原数据中的索引(可能不连续),reset_index(drop=True)将它们重新变为 0,1,2,... 的连续整数索引,避免后续合并或填充时索引错位。
七、六种缺失值填充方案详解
我们实现了从简单统计填充到机器学习预测填充的6种方案,核心设计:按矿物类型分组填充(因为不同矿物的化学成分分布差异显著,全局填充会引入偏差)。
填充方案对比
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 删除空余行 | 删除含缺失值的整行 | 缺失率极低(<5%)、样本量极大 |
| 均值填充 | 用组内均值填充 | 数据分布正态、无极端异常值 |
| 中位数填充 | 用组内中位数填充 | 存在极端异常值、偏态分布 |
| 众数填充 | 用组内众数填充 | 离散型特征 |
| 线性回归填充 | 用无缺失特征构建线性回归模型预测 | 特征间线性相关性强 |
| 随机森林填充 | 用无缺失特征构建随机森林模型预测 | 结构化表格数据首选,精度最高 |
1. 删除空余行(完整案例分析)
def cca_train_fill(train_data, train_label):
data = pd.concat([train_data, train_label], axis=1)
df_data = data.dropna() # 删除任何含NaN的行
df_data = df_data.reset_index(drop=True)
return df_data.drop('矿物类型', axis=1), df_data['矿物类型']
def cca_test_fill(train_data, train_label, test_data, test_label):
data = pd.concat([test_data, test_label], axis=1)
df_data = data.dropna()
df_data = df_data.reset_index(drop=True)
return df_data.drop('矿物类型', axis=1), df_data['矿物类型']
注意:测试集填充时同样只删除缺失行,不引入任何额外信息。
2. 类别内均值/中位数/众数填充
三种填充代码结构一致,仅统计方法不同。以均值填充为例:
训练集填充
def mean_train_method(data):
fill_value = data.mean() # 计算每列均值
return data.fillna(fill_value)
def mean_train_fill(train_data, train_label):
data = pd.concat([train_data, train_label], axis=1).reset_index(drop=True)
# 按矿物类型分组(0,1,2,3)
A = data[data['矿物类型'] == 0]
B = data[data['矿物类型'] == 1]
C = data[data['矿物类型'] == 2]
D = data[data['矿物类型'] == 3]
# 组内填充
A = mean_train_method(A)
B = mean_train_method(B)
C = mean_train_method(C)
D = mean_train_method(D)
# 合并
df_filled = pd.concat([A, B, C, D], axis=0).reset_index(drop=True)
return df_filled.drop('矿物类型', axis=1), df_filled['矿物类型']
测试集填充(复用训练集各组均值)
def mean_test_method(train_data, test_data):
fill_value = train_data.mean() # 使用训练集的均值
return test_data.fillna(fill_value)
def mean_test_fill(train_data, train_label, test_data, test_label):
# 分组,分别用对应组的训练集均值填充测试集
... # 结构同训练集,但填充值来自训练集
💡 为什么测试集必须用训练集的统计量?
如果测试集用自己的均值填充,就相当于"提前看到了"测试集的分布,评估结果会虚高。正确做法是:测试集永远只使用训练集学到的参数(均值、标准差、众数、回归模型)。
众数填充的 apply + lambda 详解
fill_value = data.apply(lambda x: x.mode().iloc[0] if len(x.mode()) > 0 else None)
-
x.mode()返回该列众数(可能有多个值,返回 Series) -
.iloc[0]取第一个众数 -
如果列全为空,
mode()返回空 Series,len()为 0,返回None
3. 线性回归与随机森林预测填充
这两种方法属于机器学习填充 ,核心思想:缺失值少的列先填充,填充后作为特征去预测缺失值多的列,形成迭代填充。
训练集填充流程(以线性回归为例)
def lr_train_fill(train_data, train_label):
data = pd.concat([train_data, train_label], axis=1).reset_index(drop=True)
train_data = data.drop('矿物类型', axis=1)
# 按缺失值数量从小到大排序
null_num = train_data.isnull().sum()
null_num_sorted = null_num.sort_values(ascending=True)
filling_feature = [] # 记录已填充的特征
for i in null_num_sorted.index:
filling_feature.append(i)
if null_num_sorted[i] != 0:
# 用已填充的特征作为 X,当前列作为 y
X = train_data[filling_feature].drop(i, axis=1)
y = train_data[i]
# 缺失值所在行
row_numbers = train_data[train_data[i].isnull()].index.tolist()
x_train = X.drop(row_numbers)
y_train = y.drop(row_numbers)
x_test = X.iloc[row_numbers]
# 训练模型预测
lr = LinearRegression()
lr.fit(x_train, y_train)
train_data.loc[row_numbers, i] = lr.predict(x_test)
return train_data, data['矿物类型']
💡 为什么按缺失值数量排序填充?
缺失值少的列更容易被准确预测,填充后成为"可靠特征",再去帮助预测缺失值多的列,整体精度更高。
测试集填充(复用训练集模型)
def lr_test_fill(train_data, train_label, test_data, test_label):
# 使用训练集训练好的模型(或重新用训练集数据训练)预测测试集缺失值
# 关键:只使用训练集数据,绝不涉及测试集自身
...
# 若没有特征可用,则用训练集均值兜底
if X.shape[1] == 0:
fill_val = y.mean()
test_data.loc[row_numbers, i] = fill_val
continue
随机森林填充代码结构完全相同,仅将模型换成 RandomForestRegressor,并可调节参数(如 n_estimators=100, max_depth=20)。
八、标准化(Z-Score)
化学成分量纲差异极大(氯含量可达数十万,pH值仅个位数),基于距离的模型(SVM、逻辑回归)对此敏感,必须标准化。
scaler = StandardScaler()
# 训练集:fit+transform
x_train_scaled = scaler.fit_transform(x_train_fill)
# 测试集:仅transform(复用训练集的均值和标准差)
x_test_scaled = scaler.transform(x_test_fill)
# 转回DataFrame,保留列名
x_train_scaled = pd.DataFrame(x_train_scaled, columns=x_train_fill.columns)
x_test_scaled = pd.DataFrame(x_test_scaled, columns=x_test_fill.columns)
💡 为什么训练集用
fit_transform,测试集只用transform?
fit计算训练集的均值和标准差,transform应用转换。测试集必须使用训练集的参数,否则相当于引入了测试集信息,违背"无数据泄露"原则。
九、类别不平衡优化(SMOTE过采样)
矿物样本各类别数量不均,少数类样本太少会导致模型偏向多数类。我们使用 SMOTE (合成少数类过采样技术)对训练集进行过采样,生成合成样本平衡类别分布。测试集绝不做任何采样。
from imblearn.over_sampling import SMOTE
oversample = SMOTE(k_neighbors=1, random_state=0)
os_x_train, os_y_train = oversample.fit_resample(x_train_scaled, y_train_fill)
💡 SMOTE 原理
SMOTE 不是简单复制少数类,而是在少数类样本之间"插值"生成新样本。
k_neighbors=1表示每个样本只与最近的一个邻居合成新样本,避免生成的样本过于分散,适用于数据量较小的情况。
十、最终数据保存
将清洗后的训练集和测试集保存为 Excel 文件,供后续模型训练使用。
# 训练集:合并标签与特征,打乱顺序(避免模型学习原始顺序)
data_train = pd.concat([os_y_train, os_x_train], axis=1).sample(frac=1, random_state=0)
# 测试集:合并,不打乱(便于后续评估)
data_test = pd.concat([y_test_fill, x_test_scaled], axis=1)
data_train.to_excel(output_dir / '训练数据集[lr填充].xlsx', index=False)
data_test.to_excel(output_dir / '测试数据集[lr填充].xlsx', index=False)
十一、总结
经过以上全流程处理,我们得到了:
-
✅ 无缺失值(根据业务选择最优填充方法,本例使用线性回归填充)
-
✅ 无量纲差异(Z-Score 标准化)
-
✅ 训练集类别完全平衡(SMOTE 过采样)
-
✅ 整个过程严格避免测试集信息泄露
下一篇文章,我们将基于这份清洗好的数据,训练 6 种传统机器学习模型(逻辑回归、随机森林、SVM、XGBoost、高斯贝叶斯、AdaBoost),并通过网格搜索调优,对比它们的分类效果。欢迎继续关注!
附:全部代码已开源(随系列文章逐步放出)
如果你在复现过程中遇到任何问题,欢迎在评论区留言,我们一起探讨。