BBKNN 算法原理:基于平衡 k 近邻

上一章我们聊了整合质量的评估指标,但说到底,你得先有整合结果才能去评估。那么 BBKNN 到底是怎么把那些「各自为政」的单细胞数据集揉到一起的?说白了,它玩的还是「找邻居」这个老路子,不过加了个很巧妙的平衡策略。

我刚开始接触单细胞数据整合时,试过不少传统方法。那些方法要么计算量太大,要么容易把不同批次的细胞强行拉到一起,造成「假整合」。BBKNN 给了我不一样的感受——它干净利落,只在局部做文章。

从 k 近邻说起

先回顾一下最基础的 k 近邻(kNN)算法。你想想看,在降维后的空间里,每个细胞就是一个点。找最近邻,就是计算欧氏距离或余弦距离,挑出距离最近的 k 个点。

不过问题来了——单细胞数据里,不同批次的细胞往往因为技术差异而各自抱团。如果你直接在全局范围内找 k 个最近邻,大概率找到的都是自己批次的「老乡」。整合?根本谈不上。

嗯,传统的 kNN 在这里确实不太灵光。

BBKNN 的核心改进

BBKNN 全称是 Balanced Batch K-Nearest Neighbors,它的改进思路其实很直白:

不是在全量数据里找 k 个最近邻,而是每个批次各找若干近邻,拼成一个「跨批次」的邻居集合。

这里的「平衡」体现在两点:

  • 批次内平衡:每个批次贡献的邻居数量大致相同,避免大批次「淹没」小批次
  • 距离尺度平衡:不同批次间的距离分布不同,需要自适应缩放

我有个项目恰好处理了 8 个不同技术平台的 PBMC 数据。用传统 kNN 时,10x 平台的细胞邻居全是 10x 的,Smart-seq2 的细胞邻居全是 Smart-seq2 的。而 BBKNN 一次性就把「跨平台桥梁」搭起来了。避坑指南:如果你的数据集批次数量差异很大(比如 1 个大批次 vs 9 个小批次),BBKNN 的平衡效果会打折扣。 我曾经踩过这个坑,后来通过先对小批次做 oversampling 才解决。

算法执行流程

BBKNN 的具体步骤可以拆成 4 个阶段:

  1. 输入准备:接收降维后的细胞坐标矩阵(通常是 PCA 结果)和批次标签
  2. 批次内搜索:对每个细胞,分别在它所属批次的其它细胞中找 n_neighbors 个最近邻
  3. 跨批次邻居合并:从其它每个批次中各自找 n_neighbors // (n_batches - 1) 个近邻,拼成平衡邻居集
  4. 加权图构建:用这些邻居关系构建一个加权图,后续用于 UMAP 可视化

我用 SVG 画了一张流程图,把这几个步骤串起来——

BBKNN 算法执行流程 输入:PCA坐标 + 批次标签 (n维) 批次内搜索: 每个批次找 k 近邻 跨批次邻居合并: 各批次取 ceil(k/(B-1)) 加权图构建: 邻居关系 → 边权(距离倒数) 输出:整合后的邻接图 关键参数 n_neighbors:总邻居数(默认 30) batch_key:批次标签列名

平衡机制详解

你可能会问:这个「平衡」到底是怎么做到数学上可操作的?

BBKNN 引入了两个关键的缩放因子:

缩放因子 作用对象 计算公式说明
批次内距离缩放 每个批次内部的距离矩阵 除以该批次所有距离的中位数,使不同批次的距离尺度归一
邻居配额分配 每个批次向目标细胞贡献的邻居数 ceil(k / (B - 1)),其中 B 为总批次数

举个例子——你有 3 个批次的数据,设置 n_neighbors = 30。那么对于批次 A 中的一个细胞,它会在批次 B 和批次 C 中各找 ceil(30 / 2) = 15 个最近邻,共计 30 个跨批次邻居。至于它自己批次(批次 A)里的邻居,完全不参与整合。

技巧:如果你的数据只有 2 个批次,BBKNN 的效果最「纯粹」——它强制要求只能跨批次找邻居。但批次数量超过 5 个时,每个批次能贡献的邻居数变少,整合精度可能下降。我建议这时把 n_neighbors 适当调大到 50-80。

代码实现要点

BBKNN 的 Python 接口非常简单。以下是核心调用示例——

import bbknn

# 假设你已经有了降维后的数据 (adata.obsm['X_pca'])
# 批次标签存在 adata.obs['batch'] 中

bbknn.bbknn(adata,
            batch_key='batch',
            n_neighbors=30,
            neighbors_within_batch=3,
            metric='euclidean')

这里的 neighbors_within_batch 参数值得多说两句。它控制每个批次的内部邻居数量:

  • 设为 0(默认):完全不考虑同一批次的邻居,只做跨批次连接
  • 设为 3-5:保留少量同批次邻居,有助于保持细胞亚群的局部结构
注意:neighbors_within_batch 不要设太大。我有一个教训——有次设了 10,结果整合后的图上出现了明显的批次内「团块」,失去了跨批次整合的意义。经验值:neighbors_within_batch 不要超过 n_neighbors 的 20%。

跟传统 kNN 比,区别在哪?

我整理了一张对比表,应该能帮你快速抓住 BBKNN 的独特之处——

维度 传统 kNN BBKNN
邻居来源 全局搜索,不考虑批次 各批次分别搜索,强制跨批次
批次偏倚 严重,容易形成批次团簇 显著抑制,难以形成纯批次团簇
内存消耗 O(n²),n 为总细胞数 约等于 O(n²),但分批次搜索可并行
适用场景 单批次或批次效应很弱 多批次、批次效应明显
调参复杂度 低,k 即可 中等,多了 batch_key 和 neighbors_within_batch

一些容易忽略的细节

BBKNN 的原理虽然不复杂,但实际用起来有几个地方得留个心眼:

  1. 输入空间必须是降维后的:BBKNN 在原始基因表达矩阵上跑会慢到怀疑人生。我个人习惯先用 PCA 降到 50-100 维。
  2. 距离度量建议用 'euclidean':虽然支持 'cosine',但在高维 PCA 空间里,欧氏距离的表现通常更稳定。
  3. 对称性要求:BBKNN 构建的图默认是单向的(细胞 A 选了 B 做邻居,但 B 不一定选了 A)。最终会做一次「对称化」处理,把单向边转成双向。
  4. 跟 UMAP 的衔接:BBKNN 生成的邻接图可以直接丢给 UMAP 做可视化。这也是 BBKNN 的最大卖点——整合和可视化一体化。

一句话总结:BBKNN 通过「分批搜索 + 配额分配 + 距离缩放」三套组合拳,在局部邻居层面实现了跨批次的知识迁移。它不全局对齐,不做变换,只改邻居关系——效果却出奇的好。