3. MNN 原理:互近邻匹配思想、MNN 对识别、校正向量计算

讲批次校正,绕不开 MNN(Mutual Nearest Neighbors)。说实话,我刚入行时也困惑过:为什么不能用普通最近邻?直接找离得最近的点不就行了?后来在真实数据上跌过跟头,才明白 MNN 的设计有多巧妙。这一章我们就把它拆开看。

3.1 互近邻匹配思想

互近邻,字面意思就是“互为最近邻”。给定两个批次的数据,对于一个批次中的某个细胞 A,我找到它在另一个批次中的最近邻细胞 B;反过来,如果 B 的最近邻也是 A,那么 (A, B) 就是一个互近邻对。

你想想看,普通最近邻只考虑了单向距离,而 MNN 要求双向确认。为什么非要双向?我打个比方:你在人群里找人,单向喊一声“谁离我最近?”可能很多人回应,但真正和你互相关注的,一定是彼此都认为对方是最近的那个。这种“双向奔赴”在批次校正里天然能过滤掉因为批次效应导致的假匹配。

我过去处理过一个 PBMC 数据集,两个批次之间有不少细胞类型迁移。如果用普通 kNN 找匹配,很多不同类型的细胞因为批次漂移被错误拉近;换成 MNN 后,这些错误匹配几乎全部消失了。说白了,MNN 是一种稳健的“双向筛选”机制,它默认只有高度相似的细胞才能互认。

3.2 MNN 对识别

算法怎么找到这些 MNN 对?步骤其实不复杂,我习惯把它拆成三步:

  1. 全局搜索:对批次 A 中每个细胞,在批次 B 中找到 top k 个最近邻(用欧氏距离或余弦距离)。
  2. 反向确认:对批次 B 中的每个候选细胞,检查它的 top k 最近邻是否包含批次 A 的原始细胞。
  3. 保留双向对:只有双向都在对方的 top k 列表里,才被标记为 MNN 对。

这里有个坑要注意——k 值的选择。k 太小,可能找不到足够的匹配;k 太大,又会引入噪声。我自己的经验是:先设 k=10,然后根据匹配数量做视觉检查。如果匹配点太多聚集在某个区域,就减小 k;如果全图都很稀疏,就增大 k。

我的小技巧: 在预处理阶段先把每个批次的数据做一次 PCA 降维(保留 15~30 主成分),再用余弦距离做最近邻搜索。这样既能去噪,又能加速计算,效果比直接在高维空间里用欧氏距离好得多。

下面的代码片段演示了 MNN 对识别的核心逻辑(用伪代码表示实际算法):

def find_mnn_pairs(batchA, batchB, k=10):
    # batchA, batchB: numpy arrays, shape (nA, d) and (nB, d)
    from sklearn.neighbors import NearestNeighbors

    # 1. 对 batchA 在 batchB 中找最近邻
    nn_A_to_B = NearestNeighbors(n_neighbors=k, metric='cosine')
    nn_A_to_B.fit(batchB)
    distances_A, indices_A = nn_A_to_B.kneighbors(batchA)

    # 2. 对 batchB 在 batchA 中找最近邻
    nn_B_to_A = NearestNeighbors(n_neighbors=k, metric='cosine')
    nn_B_to_A.fit(batchA)
    distances_B, indices_B = nn_B_to_A.kneighbors(batchB)

    # 3. 提取互近邻对
    mnn_pairs = []
    for i in range(len(batchA)):
        # A 中第 i 个细胞的最近邻列表
        neighbors_of_i = indices_A[i]
        for j in neighbors_of_i:
            # 判断 j 在 batchB 中的最近邻是否包含 i
            if i in indices_B[j]:
                mnn_pairs.append((i, j))
    return mnn_pairs

这段代码我建议你直接拿去跑一跑,但要注意:实际数据中,不同批次细胞数目可能差异很大,这时候 k 要按比例调整。比如批次 A 有 5000 个细胞,批次 B 只有 1000 个,那么 k 可以设置为 min(10, len(B)*0.01) 这样的经验值。

3.3 校正向量计算

找到 MNN 对之后,下一步就是计算每个细胞应该偏移多少。核心思想:用 MNN 对之间的差异向量来估计批次效应。

假设我们有一组 MNN 对 (x_i, y_j),其中 x_i 来自批次 A,y_j 来自批次 B。那么校正向量的计算分三步:

  1. 计算每个 MNN 对的差异向量:d_{ij} = y_j - x_i(这里方向以批次 B 为参考,也可以反过来)。
  2. 平滑映射:对批次 A 中的每个细胞,找到它在批次 B 中的最近几个 MNN 匹配点,用加权平均的方式计算它需要的校正向量。常用的权重是高斯核:距离越近,权重越大。
  3. 应用校正:将平滑后的校正向量加到原始坐标上,得到批次对齐后的新坐标。

这里有个关键点:不是所有 MNN 对都同等重要。我遇到过离群 MNN 对(比如两个批次中偶然出现的近邻,但实际细胞类型不同),它们会产生很大的噪声。解决办法是限制差异向量的最大值,或者用中位数替代均值。

我曾经踩过的坑: 有一次我直接对所有 MNN 对取均值做全局校正,结果把整个批次 B 都平移偏了。后来发现是因为一个罕见细胞类型在 MNN 对中权重过高。从此我坚持用“局部加权”而非“全局统一”的方式计算校正向量。

代码实现如下(示意):

def compute_correction(batchA, batchB, mnn_pairs, sigma=1.0):
    # mnn_pairs: list of (idx_in_A, idx_in_B)
    # 1. 计算所有 MNN 对的差异向量
    vecs = []
    for i, j in mnn_pairs:
        diff = batchB[j] - batchA[i]
        vecs.append(diff)
    vecs = np.array(vecs)  # shape (n_pairs, d)

    # 2. 为每个批次 A 中的细胞计算平滑校正量
    corrected_A = np.copy(batchA)
    for i in range(len(batchA)):
        # 找和这个细胞有关的 MNN 对(它自身参与的)
        related = [v for (a_idx, _), v in zip(mnn_pairs, vecs) if a_idx == i]
        if not related:
            # 没有匹配,用最近邻 MNN 的差异插值
            # 简化:直接取全局中位数
            continue
        # 高斯加权(此处省略距离计算,直接平均)
        corr = np.mean(related, axis=0)
        corrected_A[i] += corr
    return corrected_A

实际应用中,平滑过程会用到所有 MNN 对组成的图,而不是只依赖自身匹配。这就是后面要讲到的“MNN 校正向量平滑”的核心所在。

3.4 MNN 与普通 kNN 对比

为了让你更直观感受两者的差异,我整理了一个表格:

对比维度普通 kNNMNN
匹配条件单向:A 的最近邻包含 B双向:A 和 B 互为最近邻
对批次效应的鲁棒性弱,容易被批次漂移欺骗强,只认可高度一致的细胞
计算复杂度O(n*d + n log n)O(2*n*d + n log n)(额外一次反向搜索)
适用场景批次效应很小的数据批次效应明显的多批次数据
输出每个细胞的 k 个邻居索引一组一一对应的匹配对

从我实战来看,只要批次效应中等以上,MNN 几乎总是优于普通 kNN。除非你的数据已经经过完美归一化,否则我建议默认用 MNN。

3.5 MNN 原理总览图

下面这张流程图总结了从 MNN 对识别到校正向量计算的完整链路:

MNN 原理流程图 输入:批次A & 批次B 表达矩阵 双向最近邻搜索(k=10) MNN 对筛选(互为最近邻) 计算差异向量 -> 平滑映射 输出:批次校正后的坐标 说明: 整个流程不需要 任何先验细胞类型 信息,完全无监督。

一句话总结 MNN 思想: 两个批次中互相认定对方为最近邻的细胞对,才是可靠的锚点。基于这些锚点的差异向量,我们就能计算出平滑的批次校正场,从而将两个数据集拉到同一个空间里。