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 对?步骤其实不复杂,我习惯把它拆成三步:
- 全局搜索:对批次 A 中每个细胞,在批次 B 中找到 top k 个最近邻(用欧氏距离或余弦距离)。
- 反向确认:对批次 B 中的每个候选细胞,检查它的 top k 最近邻是否包含批次 A 的原始细胞。
- 保留双向对:只有双向都在对方的 top k 列表里,才被标记为 MNN 对。
这里有个坑要注意——k 值的选择。k 太小,可能找不到足够的匹配;k 太大,又会引入噪声。我自己的经验是:先设 k=10,然后根据匹配数量做视觉检查。如果匹配点太多聚集在某个区域,就减小 k;如果全图都很稀疏,就增大 k。
下面的代码片段演示了 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。那么校正向量的计算分三步:
- 计算每个 MNN 对的差异向量:d_{ij} = y_j - x_i(这里方向以批次 B 为参考,也可以反过来)。
- 平滑映射:对批次 A 中的每个细胞,找到它在批次 B 中的最近几个 MNN 匹配点,用加权平均的方式计算它需要的校正向量。常用的权重是高斯核:距离越近,权重越大。
- 应用校正:将平滑后的校正向量加到原始坐标上,得到批次对齐后的新坐标。
这里有个关键点:不是所有 MNN 对都同等重要。我遇到过离群 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 对比
为了让你更直观感受两者的差异,我整理了一个表格:
| 对比维度 | 普通 kNN | MNN |
|---|---|---|
| 匹配条件 | 单向: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 思想: 两个批次中互相认定对方为最近邻的细胞对,才是可靠的锚点。基于这些锚点的差异向量,我们就能计算出平滑的批次校正场,从而将两个数据集拉到同一个空间里。