矿物成分数据这件事我是在前两篇把数据探索和基础特征工程做完之后才真正开始纠结缺失值怎么处理的。这个系列写到第三篇核心任务其实已经收敛得很清楚拿一批带有缺失值的成矿元素分析数据走完从预处理到模型训练再到性能评估的完整流程。而这一次我选择用平均值填充缺失数据在PyTorch框架下用MLP做矿物成分分类最后对比了几种填充方案的线上表现。整个过程跑下来的体会是平均值填充表面上看是最省事的操作但背后牵扯到数据泄漏、分布扭曲、类别倾向等一系列问题处理得好是加速器处理不好就是模型翻车的导火索。这篇文章就把我在预处理、网络搭建、训练调参和性能分析四个环节里的实际操作完整记录下来适合正在做表格型数据分类、尤其是岩矿或地球化学数据的同学参考。1. 为什么是平均值填充先看清这批矿物数据的缺失本质1.1 缺失比例与分布该删行的场景我直接删但这里不行先交代一下这批数据的背景。样本来自某批次岩矿标本的光谱半定量分析结果记录了SiO2、Al2O3、Fe2O3、MgO、CaO、Na2O、K2O、TiO2以及烧失量共9项组分指标标签是按矿物大类划分的6个类别样本总量约1200条。数据探索阶段就发现接近18%的样本至少存在一个字段缺失其中SiO2缺失最突出缺失比例接近14%MgO和CaO的缺失率也在6%到8%之间。很多人遇到缺失值第一反应是删行。我一开始也这么想但算了一笔账就放弃了直接丢弃所有含NaN的样本会损失大约210条记录相当于总样本量的17.5%。更要命的是几个样本量本来就不大的类别删除后直接跌破50条训练时类别不平衡会被进一步放大。比如某一类铁镁质矿物原本只有80多条样本删完只剩40多条模型在这个类别上基本学不到有效边界。所以对于这批数据删行不是合理选择。而缺失机制也值得看一眼缺失样本在某些类别中的占比明显偏高说明缺失并非完全随机而是和样本本身的矿物特征相关。这种情况下如果草率地删除样本或一律填零等于把“缺失”这个信号从数据里抹掉模型的决策边界会被迫做出有偏修正。1.2 均值、中位数还是众数矿物成分数据的量纲决定了选择确定不做删行之后下一步是选填充值。三种最常见的选择是均值、中位数和众数。矿物成分数据是连续型变量而且每个特征都在百分之零到几十的区间内浮动众数这种针对类别特征的方案直接排除。剩下就是均值和中位数之争。我先对每个特征的分布形态做了直方图和偏度检查。SiO2这种主量元素在大多数岩矿样本中的分布接近对称均值和中位数几乎没有差别但Fe2O3这类组分由于部分矿石中磁性矿物富集形成右偏拖尾少数高品位样本能把均值拉上去不少。如果用均值去填充Fe2O3的缺失值那些原本低铁含量的样本就会被统一抬到偏高的位置导致某些类别的特征重心偏移。最终我选择均值填充作为主方案并不是因为它理论最优而是因为它简单、稳定、可复现而且对对称分布特征几乎没有副作用。真正的工程决策是对每个特征先算偏度偏度绝对值大于0.8的用中位数其余用均值。这样可以规避右偏特征被少数极端值绑架的问题。这步检查只用几行代码就能完成但它决定了填充后数据分布是否扭曲。2. 平均值填充在PyTorch数据管道里的落地细节2.1 训练集与测试集必须分开处理一次数据泄漏的复盘这里要说一个很多人踩过但我这次特别留意的坑填充均值必须在完成数据集划分之后分别计算而且测试集要使用训练集的均值。我第一版代码为了省事直接在完整DataFrame上fillna(df.mean())然后才做train_test_split。模型训练时loss下降得异常顺利验证集准确率也很高但我总觉得哪里不对。后来复盘时意识到全量填充让测试集的信息提前流入了训练过程——测试集的缺失值是用包含了测试样本自身信息的整体均值填充的这属于典型的数据泄漏。模型在评估时已经间接见过测试集的数据分布分数再高也不可信。修正后的做法是先划分再填充import torch from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X raw_data.drop(columns[mineral_type]) y raw_data[mineral_type] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) # 仅在训练集上计算填充值 train_means X_train.mean() mean_bias_mask X_train.columns[pd.Series( [abs(skew(X_train[c])) for c in X_train.columns] ) 0.8] median_vals X_train.median() fill_values train_means.to_dict() for col in mean_bias_mask: fill_values[col] median_vals[col] X_train X_train.fillna(fill_values) X_test X_test.fillna(fill_values) # 注意测试集使用训练集算出的参数测试集填充用的不是它自己的列均值而是训练集统计出来的值。这跟特征缩放时StandardScaler只在训练集上fit的道理一模一样。还要养成习惯填充后检查一下还有没有NaN残留并确认填充值没有被错误地应用到非缺失值上。2.2 填充后的分布检查与特征缩放填充完成不等于预处理结束。我每次都会做一个简单但关键的可视化检查把填充后的特征分布和原始非缺失值的分布叠加对比观察均值填充带来的“方差收缩”效应。以SiO2为例原始非缺失值的标准差大约为6.8而填充之后整体标准差降到了5.9左右。原因很直白约14%的样本被塞进了同一个点而这个点又恰好落在分布中心附近相当于给分布注入了一批相同数值的样本中心位置的权重被放大。从概率密度图上看填充值对应的横坐标处会出现一个不自然的尖峰这个尖峰在后续训练中会反复影响网络对该特征的梯度计算。对于MLP这类全连接网络特征尺度不一致会导致初始梯度在不同维度上失衡。矿物成分虽然都是百分比含量但SiO2的分布范围远大于TiO2等微量元素组分量纲一致不代表尺度一致。所以我在填充之后统一做了StandardScaler标准化依然严格遵循“训练集fit测试集transform”的原则scaler StandardScaler() X_train scaler.fit_transform(X_train) X_test scaler.transform(X_test)标准化之后填充值位置从原始均值变到了0附近这会放大“中心点样本”的聚集程度但并不会改变它们的权重关系。这里如果换用MinMaxScaler也可以只是对异常值更敏感我调研后觉得StandardScaler对后续MLP收敛最友好。3. MLP网络结构设计从9个矿物特征到6类分类输出3.1 输入层到隐藏层的维度推演数据维度是9个组分特征输出是6个矿物类别。网络结构没有一步到位我按实验记录的顺序说。第一版网络是单隐藏层256个神经元参数量大约9×256256×62820个训练很快但验证集准确率卡在82%左右上不去。第二版改成两层128和64准确率升到88%。第三版加到三层结构为128-64-32准确率提升到91%左右再往深加比如128-128-64-32准确率反而略有回落。这个趋势符合我的预期表格型数据的信息密度远低于图像或文本数据1200条样本量根本喂不饱过深的网络。堆参数量只会让模型更快记住训练集里的噪声。最终固定为三层结构输入层9维隐藏层128-64-32输出层6维全连接层之间使用ReLU激活。各层参数量的分布也值得算一下第一层Linear(9, 128)包含9×1281281280个参数第二层Linear(128, 64)包含8256个参数第三层Linear(64, 32)包含2080个参数最后一层Linear(32, 6)包含198个参数。总参数量不到1.2万对于这个数据规模是合理的。如果隐藏层单元数无脑翻倍参数量很快会突破2万而数据量只有1200条正则化压力会显著增大。3.2 激活函数和正则化层怎么配激活函数的实验我做过三组对比。Tanh激活在早期虽然也能收敛但速度比ReLU慢得多尤其在前两层的梯度传播上Tanh在饱和区的梯度接近零反向传播效率明显低。ReLU收敛快但有一个问题是Dead ReLU部分神经元在训练中可能永远不激活。这批矿物数据特征标准化后在-2到2之间浮动ReLU在正区间的激活比例正常所以Dead ReLU问题不严重。正则化方面我的设置是第二个Linear层后接Dropout(0.2)第三个Linear层后接Dropout(0.3)每个Linear层与ReLU之间接BatchNorm1d。BatchNorm对表格型数据的争议一直存在有人说作用有限但我的实测是带上BatchNorm后训练曲线的振荡明显减少验证集loss的下探更平滑。原因大概是标准化后的特征在经过线性变换后分布又开始发散BatchNorm在每层之前把分布重新拉回均值为0方差为1的状态对MLP这种深层堆叠结构是有益的。输入数据一共只有9个特征BatchNorm引入的额外参数完全可以忽略。import torch.nn as nn class MLPClassifier(nn.Module): def __init__(self, in_features9, num_classes6): super().__init__() self.net nn.Sequential( nn.Linear(in_features, 128), nn.BatchNorm1d(128), nn.ReLU(), nn.Dropout(0.2), nn.Linear(128, 64), nn.BatchNorm1d(64), nn.ReLU(), nn.Dropout(0.2), nn.Linear(64, 32), nn.BatchNorm1d(32), nn.ReLU(), nn.Dropout(0.3), nn.Linear(32, num_classes) ) def forward(self, x): return self.net(x)输出层不加激活函数直接把logits交给交叉熵损失处理这是PyTorch的CrossEntropyLoss的标准用法。如果手动在输出层加了Softmax再配合CrossEntropyLoss会因为内部又做了一次Softmax和取对数而得到错误结果。4. 训练实验记录学习率、批大小与早停的实际试错4.1 第一轮跑崩的loss曲线训练配置不是一次调到位的我把第一轮真实跑崩的情况拿来说方便大家避开同样的坑。第一版训练参数是Adam优化器学习率1e-2批大小32固定训练50个epoch没用任何正则化。结果第一个epoch结束时训练loss降到2.1第二个epoch突然跳到8.3后面几个epoch一直在4到9之间反复横跳验证集准确率始终在40%以下。打开loss曲线一看完全没有收敛趋势高频振荡非常明显。原因是两个。第一学习率1e-2对于Adam虽然不算极端但对于只有9维输入、样本量1200的数据集来说太大网络在参数空间中来回穿越最优点第二批大小32在高方差数据上增加了一步更新的噪声加上没有Dropout模型在前期就开始对训练集噪声敏感。修正方案是学习率降到1e-3批大小调到64添加了上文说的Dropout和BatchNorm。调整之后loss曲线在5个epoch内降到0.6以下20个epoch稳定到0.3左右。这个对比说明了一件事MLP在这些中小规模表格数据上优化器的选择、学习率和批大小的匹配比网络宽度本身更能决定成败。4.2 Adam优化器配合StepLR的重现结果最终确定并复现成功的训练配置如下优化器Adambetas(0.9, 0.999)weight_decay设为1e-5初始学习率1e-3批大小64学习率调度StepLRstep_size10gamma0.5损失函数CrossEntropyLoss训练轮数100搭配早停策略patience10数据划分训练集960条测试集240条StepLR的效果是每10个epoch学习率减半。到第30个epoch时学习率降到2.5e-4loss从0.35降到0.19第50个epoch后降到1.25e-4验证集loss基本在0.15附近不再变化。早停配置监控验证集loss连续10个epoch没有改善就终止训练实际在第64个epoch触发了早停。简单实现一个早停逻辑并不复杂class EarlyStopping: def __init__(self, patience10, min_delta1e-4): self.patience patience self.min_delta min_delta self.counter 0 self.best_loss float(inf) self.should_stop False def __call__(self, val_loss): if val_loss self.best_loss - self.min_delta: self.best_loss val_loss self.counter 0 else: self.counter 1 if self.counter self.patience: self.should_stop True实测下来早停触发的epoch比我预设的100轮提前了接近一半让我确信最终模型没有在训练集上过度打磨。训练过程中我还记录了中位数填充对照组的训练曲线它的收敛趋势与平均值填充相似但最终验证集loss少了大约0.02这个差异在后面的性能分析中也有体现。5. 性能分析准确率只是开始混淆矩阵里信息更多5.1 基线对比平均值填充 vs 删除缺失行 vs 零填充 vs 中位数填充模型训练完成之后我用相同的MLP结构和训练配置对四组不同预处理数据重新训练每个实验固定随机种子最终比较测试集准确率和加权F1分数。结果如下预处理方案测试集准确率加权F1说明删除缺失样本83.6%0.82训练样本仅剩约990条部分类别样本不均衡加剧零填充85.1%0.84矿物中零含量语义与缺失混淆特征分布偏移明显均值填充91.2%0.90填充值集中在分布中心整体类别边界保持较好中位数填充91.8%0.91对偏移特征更鲁棒但提升幅度有限四组实验放在一起看平均值填充相比删除样本提升了大约7.6个百分点相比零填充提升了6.1个百分点。中位数填充是最后的赢家但优势不到1个百分点。说实话如果对偏态不敏感平均值填充的性价比更高因为它计算简单、逻辑透明、便于复现而中位数填充的优势要基于更细致的分布检查才有意义。零填充的表现最出乎我意料它竟然比删除样本还要高1.5个百分点。原因大概是零在矿物数据中有实际语义某些组分含量确实可能接近零模型学到的是“这个特征为零”的某种隐含指示。但零填充的风险在于它把化学意义上的缺失和含量极低混为一谈本质上是在教模型忽略一种数据质量信号。5.2 误分类样本的实际含义与后续改进方向只看准确率会漏掉很多信息。我仔细看了均值填充模型在测试集上的混淆矩阵发现主要错误集中在两类矿物上一类以钙为主一类以镁为主。这两类样本在CaO和MgO含量上本来就非常接近边界区域重叠严重。而那些原本就缺失MgO的样本在均值填充下被统一填到中心值附近直接导致这类样本中相当一部分落进了两个类别的边界模糊区。举例来说某个真实标签为镁质矿物的样本因为其MgO缺失被填到了均值附近而这个均值在两类区分度最高的维度上恰好不偏向镁质矿物最终被模型判成了钙质矿物。这就是平均值填充的典型副作用它把缺失样本的特征重心拖向全局中心而某些类别的辨识信号恰恰在偏离中心的位置上。一个可行的改进方向是把“是否缺失”本身编码成额外特征比如对每个缺失字段增加一个0/1指示列。这样网络可以学习到缺失模式与矿物类别之间的关联而不必让填充值承担全部语义。这个方案我后续跑了一版实验准确率又提升了1到2个百分点但指示特征会增加输入维度需要重新调参这个实践可以作为本系列后续篇目详细展开。在整组实验做完之后我得出的综合建议是平均值填充不是万能药但它作为默认基线非常合适真正决定模型上限的是你在填充之外有没有保留缺失信号以及你是否愿意为特征分布检验多花十分钟。我做这部分的切身体会是预处理阶段每多一次可视化和分布核查训练阶段就能少几次莫名其妙的调试。这条经验适用于所有类似的化学成分数据分类任务不只是矿物类别这一种。