4. 数据预处理:缺失值处理、归一化、对数变换

数据预处理这一步,说白了你就是在给原始数据“洗澡”。质谱仪器吐出来的东西,直接喂给模型?我劝你别这么干。我在项目里见过太多次,因为预处理没做好,后面模型调参调到崩溃——问题根本不在模型,而在数据本身就没洗干净。

这一章,咱们只聚焦三个核心动作:缺失值处理、归一化、对数变换。你别小看这三板斧,它们决定了批次校正能校到什么程度。

4.1 缺失值处理:堵住数据里的洞

质谱数据里的缺失值,不是真“缺失”。仪器没检测到,不代表那个代谢物不存在。我记得有一次跑脂质组学数据,30%的缺失值直接用0填充,结果PCA图上出现了一堆离群样本——全是填充0造成的假象。

常见的缺失值类型就那么几种:

类型 产生原因 常见占比
完全随机缺失 仪器抖动、进样误差 < 5%
随机缺失 阈值过滤、批次效应 5% - 20%
非随机缺失 低丰度代谢物未检出 20% - 50%

处理策略也是有讲究的:

  • 删除法:缺失率超过50%的特征,直接删。这在质谱里很常见,因为很多代谢物只在特定批次里被检测到。
  • 固定值填充:我最常用的是用最小值的1/2或1/10填充。用0?那会让后续对数变换时出现-Inf,你会哭的。
  • 插补法:kNN、RF插补在小缺失率(< 10%)时效果不错。但别在缺失率高的特征上用,那是自欺欺人。

个人经验:我建议先按代谢物在样本中的缺失率分层处理。
样本内缺失率 > 80% → 直接删除该代谢物。
样本内缺失率在 20% - 80% → 用最小值/2填充。
样本内缺失率 < 20% → 可以用kNN插补。

看看代码怎么写:

import pandas as pd
import numpy as np
from sklearn.impute import KNNImputer

# 读取原始质谱数据
df = pd.read_csv('raw_intensity.csv', index_col=0)

# 计算缺失率
missing_rate = df.isnull().sum(axis=1) / df.shape[1]
# 删除缺失率 > 80% 的特征
df_filtered = df.loc[missing_rate < 0.8]

# 缺失率 20%-80% 用最小值/2填充
mask_mid = (missing_rate >= 0.2) & (missing_rate <= 0.8)
for idx in df_filtered.index[mask_mid]:
    col_min = df_filtered.loc[idx].min()
    df_filtered.loc[idx] = df_filtered.loc[idx].fillna(col_min / 2)

# 缺失率 < 20% 用 kNN 插补
mask_low = missing_rate < 0.2
df_knn = df_filtered.loc[mask_low]
imputer = KNNImputer(n_neighbors=5)
df_knn_imputed = pd.DataFrame(
    imputer.fit_transform(df_knn.T).T,
    index=df_knn.index,
    columns=df_knn.columns
)
df_filtered.loc[mask_low] = df_knn_imputed.values

避坑指南:我曾经在使用kNN插补时,忘了考虑批次分组,结果插补出来的值在批次边界上出现明显断裂。记住,插补前必须先按批次分组,组内插补。

4.2 归一化:让数据站在同一起跑线

归一化解决的问题很简单:不同代谢物的强度范围可能差好几个数量级。你不用归一化,模型会天然偏向高丰度的代谢物。你想想看,权重都在高丰度特征上,低丰度但生物意义重大的代谢物就被淹没了。

常用的归一化方法,我列个表对比一下:

方法 公式 适用场景 我的评价
Z-score (x - μ) / σ 数据近似正态分布 最常用,但受离群点影响大
Min-Max (x - min) / (max - min) 值域有明确上下界 保留原始分布形状
Robust scaling (x - median) / IQR 存在明显离群点 我偏爱这个,尤其是质谱数据
总和归一化 x / sum(sample) 样本间总强度差异大 适合批次间总强度不一致的情况

我个人习惯,拿到质谱数据后先做Robust scaling。为什么?质谱的离群值太多了——仪器偶尔的脉冲噪声、单个样本的异常高值,Z-score会被这些点拉偏。稳健归一化用中位数和四分位距,抗干扰能力强不少。

from sklearn.preprocessing import RobustScaler

# 转置:样本在行,特征在列
df_t = df_filtered.T

# 应用稳健归一化
scaler = RobustScaler()
df_scaled = pd.DataFrame(
    scaler.fit_transform(df_t),
    index=df_t.index,
    columns=df_t.columns
)

# 转置回来,恢复特征在行的格式
df_scaled = df_scaled.T

小技巧:如果你在做多批次整合,建议先对每个批次独立做归一化,再合并。不然批次间的整体强度差异会被当成真实生物差异。

4.3 对数变换:拉直指数关系

质谱数据的分布,我见过的90%都是偏态的——低丰度代谢物一大把,高丰度就那么几个。对数变换本质上是在压缩动态范围,把乘法关系变成加法关系。

你可能会问,为什么要做对数变换?举个例子,一个代谢物在正常组强度是100,在疾病组是1000。直接看原始值,差异是10倍。但对数变换后,差异就只有log(1000) - log(100) = 1个单位了。这种压缩,让后续的线性模型、PCA等假设正态分布的方法更好用。

用什么底数?我的经验是:

  • log2:最容易解释,差异1个单位就是2倍变化。我90%的情况用这个。
  • log10:丰度值跨5-6个数量级的极宽动态范围数据。
  • 自然对数ln:后续要用到概率统计模型时。

千万注意:做对数变换前,必须确保所有值都大于0!我见过有人直接在含0的矩阵上log,结果出现一堆-Inf,然后跑来问我PCA图怎么全是NaN。0值必须在前面的缺失值处理步骤里解决掉。

import numpy as np

# 确保没有0或负值
assert (df_scaled > 0).all().all(), "存在非正值,请检查缺失值处理"

# log2变换
df_log = np.log2(df_scaled)

# 或者用log10
# df_log = np.log10(df_scaled)

4.4 三步流程总览

刚才讲的三个步骤,在实际项目中是串在一起的。我画了张流程图,帮你把逻辑理清楚:

数据预处理核心流程 原始强度矩阵 缺失值处理 删除 >80% | 最小值/2 | kNN 归一化 Robust scaling | 按批次分组 对数变换 log2 | 确保全 > 0 清洗后数据 → 批次校正 ⚠️ 关键顺序 缺失值处理 → 归一化 → 对数变换,顺序 不能乱。先做对数 再归一化?那会破坏 对数变换的方差结构。

看到这个流程,你应该明白了:顺序很重要。先做缺失值处理,把坑填平;再做归一化,消除量纲差异;最后做对数变换,拉近分布形态。别想着先对数再归一化,那会让你损失对数变换带来的方差稳定效果。

4.5 常见陷阱与核对清单

做预处理时,我建议你手里拿个清单,一条条对过去:

  1. 缺失值处理前,先画个缺失热图。看看缺失是不是集中在特定批次或特定样本类型里。我遇到过缺失集中在某个批次的情况,那说明仪器有问题,而不是生物差异。
  2. 归一化参数,只从训练集学。如果你在批量校正场景中,测试集的归一化参数必须来自训练集,不能独立计算。否则数据分布会被扭曲。
  3. 对数变换后,检查分布形态。如果偏度依然很大,你可以试试Box-Cox变换或者Yeo-Johnson变换,它们能自动找最佳变换参数。
from scipy.stats import boxcox
from sklearn.preprocessing import PowerTransformer

# 自动选择最佳变换
pt = PowerTransformer(method='yeo-johnson')
df_transformed = pt.fit_transform(df_scaled.T).T

# 检查变换后的偏度
import seaborn as sns
# 随机选一个代谢物查看分布
sns.histplot(df_transformed.iloc[0, :])

核心思想:数据预处理不是为了“好看”,而是为了让后续的批次校正模型能学到真正的生物信号,而不是被噪声、量纲、分布形态这些无关因素误导。每一步操作都要问自己一句:这一步是为了消除哪种噪声?