上周帮一位做工业设备故障预测的朋友看模型,他的 XGBoost 二分类模型 AUC 卡在 0.72 上不去,换了三种网络结构、调了两轮学习率、连特征重要性都重排了,依然纹丝不动。我把他三万多条训练记录拉出来扫了一遍,问题全在数据里:有两千多条重复采样记录,压力那一列单位一会儿是 MPa 一会儿是 kPa,还有一整段停机时长是负数。花了一下午把数据预处理整条链路重做,模型一行没改,AUC 直接到 0.89。这件事之后我更加确信那句话——数据预处理不是训练前的杂活,它直接决定了模型能学到的上限。这篇内容我想聊的就是数据预处理这件事本身:它包含哪些环节、每个环节为什么这么做、参数怎么算、不同模型对预处理的要求差在哪、以及我在实操中踩过的那些坑。不管你是刚接触机器学习、在实训平台上做 pandas 数据预处理练习的新手,还是已经能独立训练 Transformer、UNet 这类模型、但总觉得效果差一口气的从业者,下面的内容应该都能对上号。
1. 预处理决定了模型的天花板:先把整体思路捋清楚
1.1 一个真实的对比:同样的模型,预处理前后差多少
我习惯把数据预处理分成四件事:清洗、转换、降维、特征构造。这四个词听起来像教科书目录,但真正决定效果的往往不是"做没做",而是"顺序对不对、参数从哪来"。
上面那个设备预测的例子,我做的操作其实很朴素。第一步把重复样本按设备编号加时间戳去重,去掉 2147 条;第二步统一压力单位,全部换算成 kPa;第三步把停机时长里的负值按业务规则修正为 0,因为负值在物理上不成立,它其实是传感器在重启瞬间写进去的脏数据;第四步对偏态严重的振动幅值做了对数变换,再做标准化。四步做完,特征分布从"拖着一条长尾巴"变成接近对称,树模型分裂时的候选切分点质量明显提升,这就是 AUC 跳上去的直接原因。
反过来我也见过反例。有个朋友做照片修复模型,直接把手机拍的图丢进去训练,网络是改进过的 UNet,结构没问题,但训练 loss 震荡得厉害。后来发现他的训练图片尺寸从 320×240 到 4000×3000 都有,而且没做统一的归一化,像素值有的在 0 到 255,有的已经被别的脚本除过 255。输入尺度不统一,等于给每一张图随机换了一把尺子,模型当然学不稳。同一批数据,统一缩放到 512×512、像素除 255、加一点随机水平翻转做增强,loss 曲线立刻平顺了。这两个例子指向同一个结论:模型结构和超参是"上层建筑",预处理是"地基",地基歪了,楼上怎么装修都没用。
1.2 预处理流水线的四个环节与顺序为什么不能乱
很多人做预处理是按"想起什么做什么"的顺序来的,这很容易出问题。我一般按下面这个顺序走,理由写在每一项后面:
- 清洗:去重、修异常、补缺失、统一单位与编码。必须先做,因为后面所有的统计量(均值、方差、分位数)都会被脏数据污染。
- 转换:类型转换、日期拆解、数值缩放、分布调整。放在清洗之后,这时候算出来的均值方差才是可信的。
- 特征构造:交叉项、聚合统计、滑动窗口特征、文本与图像的额外表示。放在转换之后,因为构造过程经常会依赖已经标准化的量。
- 降维与选择:PCA、特征筛选、相关性剔除。放在最后,因为它要用到完整特征集的信息。
顺序里最容易出错的是"缩放"和"划分数据集"的先后。正确做法是:先划分训练集和验证集,只在训练集上 fit 缩放器,再 transform 到验证集。如果先对全量数据做标准化再划分,验证集的均值和方差信息就通过缩放参数"泄漏"进了训练过程,线下指标会虚高,上线就掉。这个坑我见过太多次,后面第 6 章会给一个用 sklearn Pipeline 自动规避的写法。
提示:任何"需要计算统计量"的预处理步骤(标准化、缺失值填充、目标编码、PCA),都必须遵守"只在训练集 fit"这条铁律,它比选什么模型重要得多。
另外要说一句关于滑动窗口滤波这类时序预处理。做温度控制系统辨识、FPODT 模型参数拟合时,很多人会先对原始信号做滑动平均去掉高频噪声。这一步本身没问题,但窗口长度不能拍脑袋定:窗口太长会把真实的阶跃响应抹平,导致辨识出来的时间常数偏大;窗口太短则噪声还在。我一般的做法是先看信号的采样周期,窗口取采样周期的 5 到 10 倍,再用残差自相关图确认噪声是否已经接近白噪声。
2. 数据清洗:把脏数据挡在训练集门外
2.1 缺失值:删、填、还是留一个标记位
缺失值处理没有标准答案,只有"哪种代价你更能接受"。我常用的判断逻辑是这样:先算每一列的缺失比例,再判断缺失是否随机。
| 缺失比例 | 缺失类型 | 我通常的处理方式 | 理由 |
|---|---|---|---|
| 小于 5% | 随机缺失 | 中位数或众数填充 | 影响小,填充成本最低 |
| 5% 到 30% | 随机缺失 | 模型填充(KNN、迭代填充)或加缺失指示列 | 保留信息量,同时让模型知道这里缺过 |
| 大于 30% | 随机缺失 | 考虑直接丢弃该列 | 填充引入的偏差可能大于信息收益 |
| 任意比例 | 非随机缺失 | 必须加缺失指示列,慎重填充 | 缺失本身可能携带强信号 |
"缺失本身携带信号"这句话不是玄学。举个真实场景:某类传感器在设备过载时会短暂掉线,那么"这一列缺失"就和"设备过载"强相关,直接把它填成均值反而把这个信号抹掉了。所以我处理缺失值时的默认动作是——先加一列 is_missing 的 0/1 标记,再考虑填充,多这一列几乎不增加成本,但经常能救回一部分效果。
填充方法里,中位数比均值更稳,因为均值本身会被异常值拉动;对于明显有前后依赖的时序数据,用前向填充(ffill)比全局中位数合理得多,因为传感器读数在短时间内是连续的。
注意:用前向填充时一定要配合时间排序。如果数据是按设备编号排的,直接 ffill 会把上一台设备的最后一条读数填到下一台设备的第一条上,这种错误非常隐蔽。
2.2 异常值:什么时候该删,什么时候必须留
新手最容易犯的错是"看到离群点就删"。我一般先问三个问题:这个值物理上可能吗?它是录入错误还是真实事件?删掉它会不会把正样本删光?
物理不可能的值必须处理,比如负的停机时长、超过量程上限的压力读数、年龄 300 岁。这类值我按业务规则修正或置为缺失,而不是直接删行,因为同一行的其他列可能还是有效的。
物理可能但极端的值要谨慎。做设备故障预测时,真正的故障瞬间往往就是极端值,你要是一股脑按 3σ 规则删掉,等于把最珍贵的正样本扔了。这时候更合适的做法是分位数截断(winsorize)而不是删除:把超过 99 分位或低于 1 分位的值压到边界上,既限制了它对线性模型和神经网络的破坏力,又保留了"这里出过极端情况"的信息。
判断异常值的手段我常用三种,按可靠性排序:业务规则(最可靠)、IQR 箱线图规则(对偏态数据比 3σ 稳)、孤立森林等无监督方法(适合高维,但结果需要人工复核)。3σ 规则的前提是数据近似正态,而真实业务数据十有八九是长尾的,硬套 3σ 会误杀大量正常样本,这一点做工业时序数据的人应该有体会。
2.3 重复值与一致性校验:最容易被忽略的一步
重复值不只是"整行完全一样"。真正麻烦的是实体级重复:同一个用户被录入了两次但手机号格式不同,同一台设备因为编号规则变更产生了两个 ID。这类重复合并后,模型会以为这两个样本独立,实际上它们的标签高度相关,导致验证集指标虚高。
一致性校验我固定会做这几件事:检查类别列的取值集合有没有"北京""北京市""Beijing"这种同义异形;检查日期列的格式是否统一,有没有混着时间戳和字符串;检查数值列的量纲,同一物理量在不同表里单位是否一致;检查主键在表内是否唯一、在关联表间是否能完全匹配。这些检查用几行 pandas 就能跑完,但能省下后面几个小时的排查时间。
我还习惯在清洗完成后做一次"数据契约"记录:把每列的期望类型、取值范围、允许的类别集合写成一个字典存下来。这样下次数据更新时,跑一遍校验脚本就能立刻发现上游哪张表改了格式。这个习惯是从一次事故里养成的——上游同事把某个字段从整型改成字符串,模型没报错,只是静默地把这一列当成了类别特征,效果掉了一大截,我们查了两天才定位到。
3. 数据转换:标准化、归一化与分布调整
3.1 标准化和归一化的选择与手算过程
这两个词经常被混用,我用一句话区分:标准化(Z-score)改变的是分布的位置和尺度,归一化(Min-Max)改变的是取值范围。
标准化的公式是 z = (x - μ) / σ,其中 μ 是均值,σ 是标准差。举个数:某列振动幅值的训练集均值是 42,标准差是 8,样本值 58 标准化后就是 (58 - 42) / 8 = 2.0。归一化的公式是 x' = (x - min) / (max - min),同样的样本,如果训练集最小 10、最大 74,归一化后是 (58 - 10) / (74 - 10) = 0.75。
选哪个,我一般看三件事:
| 判断维度 | 选标准化 | 选归一化 |
|---|---|---|
| 算法类型 | 线性回归、逻辑回归、SVM、PCA、神经网络 | 需要固定输入范围的场景,如图像像素、某些距离度量 |
| 数据分布 | 近似正态,或有少量极端值 | 边界明确且稳定,如评分 0 到 100 |
| 新数据风险 | 较小,新样本超出范围也不会越界 | 较大,新样本超出训练 min/max 就会跑出 0 到 1 区间 |
神经网络特别是深层结构,我基本默认用标准化,因为权重初始化、BatchNorm 这类机制都假设输入接近零均值单位方差。图像数据是例外,直接把像素除以 255 归一到 0 到 1 就够了,因为像素的物理边界本来就是确定的。
注意:Min-Max 归一化的 max 和 min 必须来自训练集。如果用了全量数据的极值,验证集和线上数据很容易落到 0 到 1 之外,一些对输入范围敏感的模型会直接给出荒谬输出。
3.2 偏态分布的修正:对数、Box-Cox 与分箱
真实数据里偏态是常态,尤其是金额、时长、计数这类量,几乎都是右偏长尾。线性模型和神经网络对偏态比较敏感,因为少数极大值会主导梯度;树模型对单调变换不敏感,但对极端值的分裂点选择仍然会受影响。
我常用的三种修正手段,按使用频率排:对数变换 log(1+x),适合非负且右偏的数据,那个加 1 是为了处理 0 值;Box-Cox 或 Yeo-Johnson 变换,适合需要自动寻找最优幂次的场景,Yeo-Johnson 的好处是能处理负值;分箱,把一个连续变量切成若干区间变成有序类别,适合那些"数值本身不重要、落在哪个区间才重要"的特征,比如年龄分段、金额档位。
分箱有个容易忽略的收益:它能吸收掉一部分异常值的影响,也能让线性模型表达非线性关系。代价是丢失了区间内的精细信息,而且分箱边界如果按全量数据的分位数来定,同样会造成泄漏,必须按训练集分位数来切。
3.3 类别特征编码:独热、序数、目标编码各自的坑
类别编码是预处理里"选项最多、踩坑最密"的一块。三种主流方式我这么用:
独热编码适合类别数少(一般少于 15 个)且无序的列,比如性别、设备类型。它的坑在于类别数一多就维度爆炸,一个城市列可能给你整出三百多维稀疏特征,还顺手把树模型的分裂效率拖垮。
序数编码适合本身有顺序的类别,比如学历、评分等级。它的坑是把无序类别当有序用,比如把"红色=1、蓝色=2、绿色=3"喂给线性模型,模型会真的以为绿色是红色的三倍。
目标编码(用类别对应标签的统计量替代类别本身)适合高基数类别,效果好但风险也最大,必须用交叉验证或平滑的方式计算,否则等于把标签直接写进特征里,训练集指标漂亮得离谱,验证集一塌糊涂。我通常还会加一个平滑项,让样本数少的类别向全局均值靠拢,避免个别稀有类别编码出极端值。
4. 数据降维与特征选择:维度不是越多越好
4.1 PCA 的使用条件与常见误用
PCA 做的事是找一组正交方向,让数据投影后的方差最大。它有两个隐含前提:数据应该是数值型且已标准化;我们关心的是方差大的方向携带了更多信息。
第一个误区是不标准化直接跑 PCA。假如你有两个特征,一个是年收入(万元级),一个是年龄(十到一百),不标准化的话第一个主成分几乎就是年收入本身,年龄的信息被完全淹没。所以跑 PCA 之前一定要标准化,这一步不能省。
第二个误区是拿 PCA 当万能的特征筛选器。PCA 的主成分是原始特征的线性组合,可解释性差。做工业场景的模型时,如果业务方要求"告诉我哪个传感器最关键",你给出一堆主成分是没法交代的。这种场景下我宁可做特征选择,保留原始特征的物理含义。
另外 PCA 是线性方法,对非线性流形结构无能为力。如果数据分布明显是弯的,用 t-SNE 或 UMAP 做可视化探索是可以的,但别拿它们的结果直接当特征喂给下游模型,因为这两个方法的邻域结构不稳定,换个随机种子结果就变。
4.2 特征选择三条路线怎么选
特征选择我按"计算成本从低到高"分三档:
过滤式用统计指标打分,比如方差、卡方、互信息、相关系数。它快、与模型无关,适合第一轮粗筛,把常数特征、近乎常数的特征、高度共线的特征先剔掉。缺点是只看单特征,忽略特征组合效应。
包裹式直接用模型效果做评价,比如递归特征消除。它准,但计算量随特征数指数增长,特征上百列的时候基本跑不动。
嵌入式把选择过程融进模型训练,比如 L1 正则、树模型的特征重要性。这是我日常用得最多的一类,成本可控,结果也够用。用树模型的重要性做筛选时要注意一点:重要性对高基数类别特征和取值多的连续特征有偏好,城市这种几百个取值的列容易被误判为重要,最好换成排列重要性(permutation importance)再确认一遍。
4.3 降维之后还能解释吗:工程上的折中
工程里常见的折中是:主线模型用原始特征加嵌入式选择,保证可解释;同时在探索阶段用降维快速看看数据结构、找找异常簇。降维结果用于"诊断"而不是"直接上线",这是我比较推荐的分工。
还有一点,降维和特征选择不是必须的环节。特征只有二三十列、样本量也够的时候,硬上 PCA 反而可能损失信息。判断依据很简单:如果去掉一部分特征后,交叉验证指标没有下降甚至上升,那就说明这些特征确实是噪声;如果指标明显下降,就别强行降维。
5. 不同模态的数据预处理各有各的门道
5.1 图片训练数据预处理:从尺寸对齐到归一化与增强
图片预处理我固定走这几步:统一尺寸、统一色彩空间、归一化、增强。
统一尺寸这一步,如果是目标检测类任务,直接缩放会改变目标的宽高比,导致标注框失真,这时候要用带 padding 的等比缩放。做医学图像分割,比如 ISIC 2017 那类皮肤病变数据集,图像里有大量黑色边框,预处理时要先裁掉边框再缩放,否则网络会浪费大量容量去学"黑边"这个毫无意义的模式。这是个很容易被忽略的点,我看过不少复现效果不佳的案例,问题就出在没裁边框。
归一化按前面说的除以 255,再加按通道的标准化(用数据集自身的均值和标准差)。这里的数据集统计量同样要在训练集上算,别嫌麻烦。
增强是图片任务里性价比最高的一步:随机水平翻转、小角度旋转、随机裁剪、亮度对比度扰动。但增强必须符合语义,比如数字识别不能水平翻转(6 会变成不存在的东西),医学影像的左右翻转也要慎重,因为器官位置本身有左右语义。做照片修复、扩散模型这类生成任务时,增强策略还要考虑退化模型的一致性,训练时的退化方式必须和推理时的真实退化分布接近,否则模型学到的映射用不上。
5.2 文本与序列:分词、标注与滑动窗口
文本预处理的第一步是清洗,去掉乱码、统一全半角、处理重复标点。中文还要分词,分词工具的选择会影响下游效果,尤其是做实体识别时,分词边界和实体边界不一致会带来标注错位。
实体识别类任务的预处理重点是标签对齐。原始标注通常是"字符起止位置"或者"实体文本加出现次序",要转成逐字符或逐词的 BIO 标签序列。这一步最容易出的错是文本被清洗后长度变了,但标签还是按原文本算的,导致标签整体错位,模型学出来全是噪声。我一般的做法是清洗和标注对齐放在同一步完成,任何会改变字符数量的操作都要同步更新标签位置。
序列任务还有个绕不开的点是长度处理。做 TCN 这类时序卷积模型时,感受野由卷积核大小和层数决定,序列切分长度至少要覆盖你能接受的依赖范围;做 Transformer 类模型时,要注意位置编码和外推能力,序列远长于训练长度时效果会掉。滑动窗口切分要注意窗口之间的重叠比例,重叠太少会切断跨窗口的模式,重叠太多又会让训练样本高度相关,我一般取 20% 到 50% 之间,具体看事件的持续时间长度。
5.3 树模型与神经网络的预处理分歧
同样一份数据,喂给 XGBoost 和喂给神经网络,预处理策略差别很大,我这里列个对照:
| 预处理项 | 树模型 | 神经网络 |
|---|---|---|
| 数值缩放 | 基本不需要,单调变换不影响分裂 | 必须做,否则梯度尺度失衡 |
| 缺失值 | 原生支持,可以留缺失让模型自己学方向 | 必须填充或加掩码 |
| 类别特征 | 可用序数编码或目标编码,高基数也能扛 | 一般用嵌入层,独热维度太高 |
| 偏态处理 | 影响有限 | 影响明显,建议做变换 |
| 特征共线性 | 不敏感 | 敏感,建议剔除或正则化 |
| 异常值 | 相对稳健 | 非常敏感,建议截断 |
这张表能解释不少"为什么换个模型效果就崩了"的疑惑。比如你把一份没做缩放的原始数据喂给树模型效果挺好,转头换成神经网络就完全训不动,问题不在网络结构,在于你少做了标准化。
6. 落地实操:两条可复现的预处理流水线
6.1 Pandas 版脚本:从原始表到训练集
这是我处理结构化数据时用的骨架,可以直接抄。假设原始表里有设备编号、时间戳、压力、振动幅值、停机时长和标签。
import numpy as np import pandas as pd df = pd.read_csv("raw_device.csv", parse_dates=["ts"]) # 1. 去重:同设备同时间只保留一条 df = df.sort_values(["device_id", "ts"]).drop_duplicates( subset=["device_id", "ts"], keep="first" ) # 2. 物理规则修正 df.loc[df["stop_minutes"] < 0, "stop_minutes"] = np.nan df.loc[df["pressure"] > 1500, "pressure"] = np.nan # 超出量程 # 3. 缺失指示列 + 分组填充 df["pressure_missing"] = df["pressure"].isna().astype(int) df["pressure"] = df.groupby("device_id")["pressure"].transform( lambda s: s.ffill().fillna(s.median()) ) # 4. 分位数截断,压住极端值 for col in ["vibration", "stop_minutes"]: lo, hi = df[col].quantile([0.01, 0.99]) df[col] = df[col].clip(lo, hi) # 5. 对数变换修正右偏 df["vibration_log"] = np.log1p(df["vibration"]) # 6. 时间特征拆解 df["hour"] = df["ts"].dt.hour df["dow"] = df["ts"].dt.dayofweek df["is_weekend"] = (df["dow"] >= 5).astype(int)几个细节值得说。第 3 步用groupby加ffill,是按设备分组做的,避免跨设备串数据;第 4 步的分位数来自当前数据集,如果这是训练阶段,没问题,但如果是推理阶段,应该把训练集算出的分位点存下来复用,不能每次重新算;第 5 步之所以单独生成一列而不覆盖原列,是为了同时保留线性和非线性表达,让模型自己选。
6.2 sklearn Pipeline:把预处理和模型绑在一起
手工写预处理脚本最大的风险是训练和推理不一致:训练时做了标准化,推理时忘了,或者填充用的均值不一样。用 Pipeline 能把这个问题从根上消掉。
from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.model_selection import train_test_split from xgboost import XGBClassifier num_cols = ["pressure", "vibration_log", "stop_minutes"] cat_cols = ["device_type", "shift"] numeric_pipe = Pipeline([ ("imputer", SimpleImputer(strategy="median")), ("scaler", StandardScaler()), ]) categorical_pipe = Pipeline([ ("imputer", SimpleImputer(strategy="most_frequent")), ("encoder", OneHotEncoder(handle_unknown="ignore")), ]) preprocess = ColumnTransformer([ ("num", numeric_pipe, num_cols), ("cat", categorical_pipe, cat_cols), ]) clf = Pipeline([ ("prep", preprocess), ("model", XGBClassifier(n_estimators=400, max_depth=6, learning_rate=0.05)), ]) X_train, X_valid, y_train, y_valid = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) clf.fit(X_train, y_train)这段代码的关键在于:先划分,再 fit。因为clf.fit只会用X_train去拟合那些 imputer 和 scaler,X_valid只是被 transform。如果哪天需要保存模型上线,直接把这个 Pipeline 整体 dump 出去,推理时输入原始格式的数据即可,中间所有统计量都跟着走,不会出现线上线下不一致。handle_unknown="ignore"也很重要,它保证线上出现训练时没见过的类别时不会直接报错,而是当成全零向量处理。
我另外会做的一件事是把 Pipeline 的中间产物落盘检查一遍:取几条验证数据,把preprocess.transform的输出打印出来,确认数值范围合理、没有全 NaN 的列、独热后的维度符合预期。这个检查花不了两分钟,但能拦住大部分静默失败。
7. 常见问题与排查技巧速查
7.1 训练跑得好、上线就崩:分布一致性怎么查
线下指标高、线上拉胯,我一般按这个顺序查:先看特征分布有没有漂移,再看预处理是否一致,最后才怀疑模型本身。
分布漂移的检查方法是把训练集和线上数据的每个特征画分位数对比表,或者算 PSI(群体稳定性指标)。连续特征特别容易出问题,比如线上某台设备的量程和训练集不同,压力值整体高出一截,标准化后虽然有缓解,但如果超出训练集分布范围太多,模型给出的预测本来就不可信。
预处理一致性则要检查有没有哪个环节在线上被绕过。我遇到过的情况是训练脚本里有一步"过滤掉停机时长超过 24 小时的记录",但这个过滤规则没有写进线上服务,导致线上来了一批超长停机样本,模型根本没见过这种分布。凡是会影响样本集合的操作,都要写进统一的预处理模块,而不是散落在训练脚本里。
7.2 症状对照表与排查顺序
这张表是我这些年攒下来的,出问题的时候按顺序对一遍,能省不少时间:
| 症状 | 最可能的原因 | 排查动作 |
|---|---|---|
| 训练 loss 一开始就是 NaN | 输入里有 NaN 或无穷值,或学习率过大 | 检查 transform 后是否有 NaN,打印每列 min/max |
| 训练 loss 震荡剧烈 | 输入尺度不统一 | 检查是否漏了标准化,看各列量级 |
| 训练集指标高、验证集崩 | 数据泄漏,通常是缩放或目标编码用了全量数据 | 检查 fit 是否只在训练集上 |
| 训练集指标本身就低 | 特征与标签无关,或标签噪声大 | 算单特征与标签的互信息,人工抽查标签 |
| 某类别样本预测全错 | 该类在训练集中样本极少,或被编码成了未知类别 | 统计类别分布,检查 handle_unknown 行为 |
| 线上预测值恒定 | 某一列线上全是缺失或常数 | 打印线上 transform 后的方差 |
| 换模型后效果骤降 | 新模型对预处理有不同要求 | 对照第 5.3 节的表格逐项检查 |
7.3 我踩过的几个坑
第一个坑是用全量数据算标准化参数。当时觉得"数据都有,为什么不全用上",验证集指标确实漂亮,上线后掉了七八个点。后来改成只在训练集 fit,验证集指标从 0.92 掉到 0.88,我心里还慌了一下,但线上实测就是 0.88,说明之前那个 0.92 是假的。
第二个坑是类别编码引入了未来信息。做时间序列相关的分类任务时,我用整个数据集的类别统计量做目标编码,等于让模型看到了未来样本的标签分布,线下指标虚高得离谱。改成分时间窗口滚动计算之后,指标降下来了,但也终于和线上对得上了。
第三个坑是清洗时改变了文本长度却忘了对齐标签。做版面检测和实体识别时都遇到过这个问题,模型训练 loss 正常下降但预测出来的框位置系统性偏移,查了半天才发现是某个正则替换把连续空格合并了,字符索引整体前移。后来我定了个规矩:任何会改变字符数的文本清洗操作,都必须放在标注对齐之前,或者同步调整标注位置。
第四个坑是图片预处理里把测试集的增强也打开了。训练集做随机翻转没问题,测试集也开随机增强的话,同一张图跑两次结果都不一样,指标完全不可复现。正确的做法是测试阶段只做确定性的尺寸缩放和归一化,把所有随机性关掉。
第五个坑关于降维的时机。我曾经在一个二分类任务里先跑了 PCA 降到 20 维,然后做特征选择,效果一直不理想。后来把顺序调过来,先做特征选择再从剩下的特征里做降维,效果好了一截。原因是 PCA 会把噪声特征的方差也混进主成分里,先降维等于让噪声参与了主成分构造。经验是:如果特征有明显冗余,先筛掉再降维;如果特征之间高度相关且都需要保留,那降维在前。
这些坑的共同点是:它们都不会报错,只是安静地把效果拉低。所以我现在的习惯是每做完一份预处理,都要回头抽查十几条样本,从原始值一路看到最终喂给模型的向量,确认每一步的变化都符合预期。这个动作看起来笨,但它是我见过最有效的质量保障手段。另外一个小技巧是,把预处理脚本的中间输出存成 parquet 缓存,下次调模型时直接读缓存,既省时间,也保证每次实验用的是完全同一份数据——不然你会发现换了模型效果变好,其实只是因为预处理脚本不小心改了一行。