矿物分类实战(一):从异常值到标准化——数据清洗全流程拆解

本文基于真实矿物分类项目,完整拆解工业级表格数据清洗全流程:异常值处理、6种缺失值填充、标准化、SMOTE过采样,严格遵循"无数据泄露"原则。所有代码均来自项目源码,可直接复用。


一、项目背景与数据预处理"黄金法则"

1. 业务目标

我们有一份矿物检测数据集,包含氯、钠、镁、硫 等13项化学成分特征,目标是将样本划分为 A/B/C/D 四类矿物。原始数据存在大量非数值异常值(如 <0.01|、空格)、缺失值以及类别不平衡问题。若直接建模,模型精度会严重失真,因此数据清洗是整个项目的基石。

2. 数据预处理的"黄金法则"

在动手写代码之前,必须明确预处理顺序,避免数据泄露(测试集信息被提前"偷看"):

  1. 读取原始数据 → 删除无效类别(E类) → 处理异常值(转数值)

  2. 先划分训练集与测试集(绝不能先填充再划分)

  3. 基于训练集的统计量填充训练集和测试集的缺失值

  4. 基于训练集的均值和标准差对训练集和测试集做标准化

  5. 仅对训练集做SMOTE过采样(测试集保持原始分布)

  6. 保存清洗后的数据集,供后续模型训练

本文所有代码严格遵循这一流程。


二、环境准备与路径管理

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),并通过网格搜索调优,对比它们的分类效果。欢迎继续关注!


附:全部代码已开源(随系列文章逐步放出)

如果你在复现过程中遇到任何问题,欢迎在评论区留言,我们一起探讨。

相关推荐
春风化作秋雨1 天前
Transformer:颠覆AI的注意力革命
人工智能·深度学习·transformer
无忧智库1 天前
算力、算法、数据三位一体:构建城市级AI大模型算力池的全景式解构与未来展望(WORD)
大数据·人工智能·算法
L-影1 天前
下篇:它到底是怎么操作的——AI中半监督学习的类型与作用,以及为什么它成了行业的“最优解”
人工智能·学习·机器学习·ai·半监督学习
后端小肥肠1 天前
OpenClaw多Agent实战|手把手教你用一只小龙虾接入多个飞书Bot
人工智能·aigc·agent
北京耐用通信1 天前
从隔离到互联:工业现场中耐达讯自动化CC-Link IE转Modbus RTU实战指南
人工智能·科技·物联网·自动化·信息与通信
蓝天守卫者联盟11 天前
2026乙酸乙酯回收设备厂家选型与技术实践
java·jvm·python·算法
cyclejune1 天前
5 个本地 AI Agent 自动化工作流实战
运维·人工智能·自动化·clawdbot·openclaw
DDzqss1 天前
3.25打卡day45
c++·算法
爆更小小刘1 天前
2.3.1_2 浮点数的表示 IEEE 754(例题训练)
算法
m0_747304161 天前
机器学习入门
人工智能·深度学习·机器学习