4、缺失模态填补技术:均值/零值填补、最近邻填补、基于矩阵分解的填补

模态缺失了怎么办?直接扔掉?太浪费了。强行训练?模型会学歪。

我个人习惯的做法是——先试试填补。说白了,就是把缺失的数据想办法补上。补得好,模型照样跑得稳。补得糙,反而引入噪声。

这一章咱们就聊聊三种最常用的填补方法。从最简单的到稍微复杂一点的,我都踩过坑,咱们一个一个说。

核心观点:填补不是万能药,但它是处理模态缺失最直接、最工程友好的手段。选对方法,能省下大量重新采集数据的成本。

4.1 均值/零值填补:简单粗暴,但有用

先讲最简单的。均值填补,就是用该特征在所有样本上的平均值,去填缺失的位置。零值填补更直接,缺失的地方直接写0。

你可能会问:这么简单的方法,真的有用吗?

我在一个工业质检项目里遇到过这种情况。传感器偶尔掉线,缺失率不到5%。当时团队想上复杂的生成模型,我拦住了。我说先试试均值填补。结果呢?精度只掉了0.3%,训练时间几乎没增加。有时候,简单就是美。

方法 适用场景 优点 缺点
零值填补 缺失率极低(<1%),特征本身非负 实现简单,计算零成本 引入偏差,破坏分布
均值填补 缺失率较低(<10%),特征分布较集中 保持均值不变,方差略缩小 忽略特征间相关性

避坑指南:我曾经在一个多模态情感分析任务里,对文本特征用了零值填补。结果模型学到的全是「缺失即负面」的假关联。后来换了均值填补,才把模型拉回来。记住:零值填补会引入「缺失即零」的强先验,不是所有场景都适用。

import numpy as np

def mean_imputation(data, missing_mask):
    """
    均值填补
    data: (n_samples, n_features)
    missing_mask: 布尔矩阵,True表示缺失
    """
    imputed = data.copy()
    for j in range(data.shape[1]):
        col = data[:, j]
        mask = missing_mask[:, j]
        if np.any(mask):
            # 用非缺失部分的均值填补
            mean_val = np.mean(col[~mask])
            imputed[mask, j] = mean_val
    return imputed

# 使用示例
X = np.array([[1.0, 2.0, np.nan],
              [4.0, np.nan, 6.0],
              [7.0, 8.0, 9.0]])
mask = np.isnan(X)
X_filled = mean_imputation(X, mask)
print(X_filled)

4.2 最近邻填补:让相似样本帮你补

均值填补有个硬伤——它不管样本之间的差异。比如用户A和用户B的喜好完全不同,你用全局均值去填,结果两边都不讨好。

最近邻填补的思路就聪明多了:找一个跟当前样本最相似的完整样本,把它的值抄过来。你想想看,这不就是「物以类聚」吗?

具体做法分三步:

  1. 找邻居:用非缺失维度计算距离,找到k个最近邻
  2. 选值:从邻居的对应维度取值
  3. 聚合:取均值或加权平均

我记得有一次做医疗影像+结构化数据的多模态任务。患者的某些化验指标经常缺失。用均值填补,模型完全分不清重症和轻症。换成最近邻填补后,AUC直接涨了4个点。为什么?因为最近邻保留了患者之间的相似性结构。

小技巧:距离度量别死磕欧氏距离。高维数据试试余弦相似度,混合类型数据试试Gower距离。我一般会先跑个t-SNE看看数据分布,再选距离度量。

from sklearn.impute import KNNImputer

# 直接用sklearn,省心
imputer = KNNImputer(n_neighbors=5, weights='distance')
X_filled = imputer.fit_transform(X)

# 自己实现核心逻辑(更灵活)
def knn_impute(data, missing_mask, k=5):
    imputed = data.copy()
    n_samples = data.shape[0]
    
    for i in range(n_samples):
        missing_dims = np.where(missing_mask[i])[0]
        if len(missing_dims) == 0:
            continue
        
        # 只使用非缺失维度计算距离
        present_dims = np.where(~missing_mask[i])[0]
        if len(present_dims) == 0:
            continue
        
        # 找最近邻
        dists = []
        for j in range(n_samples):
            if i == j or np.any(missing_mask[j, present_dims]):
                continue
            d = np.linalg.norm(data[i, present_dims] - data[j, present_dims])
            dists.append((d, j))
        
        dists.sort(key=lambda x: x[0])
        neighbors = dists[:k]
        
        # 用邻居的均值填补
        for dim in missing_dims:
            values = [data[n[1], dim] for n in neighbors 
                     if not missing_mask[n[1], dim]]
            if values:
                imputed[i, dim] = np.mean(values)
    
    return imputed

4.3 基于矩阵分解的填补:挖掘潜在结构

前面两种方法都是「局部修补」。如果缺失模式很复杂,比如某个模态整体缺失了30%以上,局部修补就不够用了。

这时候我会上矩阵分解。核心思想很简单:把数据矩阵X分解成两个低秩矩阵的乘积。缺失值?没关系,只对观测到的部分做优化,缺失的部分自然就被低秩结构「推断」出来了。

数学上就是解这个优化问题:

minimize || (X - UV^T) ⊙ M ||_F^2 + λ(||U||_F^2 + ||V||_F^2)

其中:
- X: 原始数据矩阵 (n_samples, n_features)
- U: 样本隐因子矩阵 (n_samples, k)
- V: 特征隐因子矩阵 (n_features, k)
- M: 观测掩码矩阵 (0表示缺失)
- k: 隐因子数量(超参数)

说白了,就是让模型自己学出数据背后的「潜规则」。比如在推荐系统里,用户-物品矩阵的缺失值,通过矩阵分解就能预测出用户可能喜欢什么。

方法 缺失容忍度 计算开销 可解释性
均值/零值填补 低(<10%) O(1) 高
最近邻填补 中(<30%) O(n²) 中
矩阵分解填补 高(<60%) O(n·k·iter) 低(隐因子含义不明确)

注意:矩阵分解的隐因子数量k需要调参。k太小,欠拟合,补出来的值太平滑;k太大,过拟合,把噪声也学进去了。我一般用交叉验证,或者观察重构误差的下降曲线——当误差下降变缓时,就是合适的k。

import numpy as np

def matrix_factorization_impute(X, mask, k=10, lr=0.01, reg=0.1, epochs=100):
    """
    基于矩阵分解的缺失值填补
    X: (n, m) 数据矩阵,缺失处为0
    mask: (n, m) 布尔矩阵,True表示观测到
    k: 隐因子数量
    """
    n, m = X.shape
    U = np.random.randn(n, k) * 0.01
    V = np.random.randn(m, k) * 0.01
    
    for epoch in range(epochs):
        # 只对观测到的位置计算梯度
        pred = U @ V.T
        error = (X - pred) * mask
        
        # 梯度下降
        U_grad = -2 * error @ V + 2 * reg * U
        V_grad = -2 * error.T @ U + 2 * reg * V
        
        U -= lr * U_grad
        V -= lr * V_grad
        
        # 监控损失
        loss = np.sum(error**2) + reg * (np.sum(U**2) + np.sum(V**2))
        if epoch % 20 == 0:
            print(f"Epoch {epoch}, Loss: {loss:.4f}")
    
    # 用分解结果填补缺失值
    X_filled = X.copy()
    X_filled[~mask] = (U @ V.T)[~mask]
    return X_filled

4.4 三种方法对比与选型指南

方法讲完了,怎么选?我总结了一张决策表,你直接对着用:

场景特征 推荐方法 理由
缺失率 < 5%,特征独立 均值填补 够用,快,不折腾
缺失率 5%-20%,有相似样本 最近邻填补 保留局部结构,效果好
缺失率 > 20%,特征相关性强 矩阵分解 挖掘全局结构,鲁棒性高
实时推理,算力受限 均值/零值填补 零延迟,无额外计算
多模态融合前预处理 最近邻或矩阵分解 保留模态内结构,融合更稳

我的经验:别一上来就上矩阵分解。先跑个简单的均值填补,看看模型掉点多少。如果掉点超过5%,再升级方法。工程上,够用就好,不要过度设计。

4.5 本章小结:填补方法全景图

下面这张图把三种方法的关系和适用边界画清楚了。你可以把它当作选型时的速查表。

缺失模态填补技术全景图 缺失模态填补技术全景图 缺失模态填补 均值/零值填补 缺失率 < 10% 特征独立,分布集中 计算量:O(1) 最近邻填补 缺失率 5% - 20% 有相似样本结构 计算量:O(n²) 矩阵分解填补 缺失率 > 20% 特征相关性强 计算量:O(n·k·iter) 选型原则:从简单到复杂,先看缺失率,再看数据特性 工程上够用就好,不要一上来就上复杂模型 三种方法不是互斥的,可以组合使用 例如:先用均值填补低缺失率特征,再用矩阵分解处理高缺失率模态

三种方法各有各的脾气。均值填补快但糙,最近邻填补稳但慢,矩阵分解强但调参麻烦。我的建议是:从最简单的开始,让数据告诉你什么时候该升级方法。