我第一次接触Neighbor-Joining(NJ)算法是在研究生时期的生物信息学课上。当时教授在黑板上画了一个简单的距离矩阵,然后像变魔术一样把它变成了一棵进化树,让我对这个"神奇"的算法产生了浓厚兴趣。
NJ算法是一种自底向上的聚类方法,专门用于构建系统发育树(也就是常说的进化树)。它最大的特点是不需要假设所有分支都以相同速率进化(即不需要分子钟假设),这使得它在处理真实生物数据时特别实用。想象一下你要给一群动物画家谱,但它们的进化速度各不相同 - 这正是NJ算法大显身手的时候。
这个算法由Saitou和Nei在1987年提出,虽然已经有些年头了,但至今仍是生物信息学领域最常用的建树方法之一。原因很简单:它计算速度快,适合处理大量数据;而且不需要数据满足超度量条件(即不要求所有外部节点到根的距离相等)。
NJ算法的起点是一个距离矩阵,这个矩阵记录了每对分类单元(比如不同物种的基因序列)之间的进化距离。这些距离通常来自序列比对结果,比如用BLAST比对后计算得到的遗传距离。
举个例子,假设我们有6个物种(A-F),它们的距离矩阵可能长这样:
code复制 A B C D E F
A 0 5 4 7 6 8
B 5 0 7 10 9 11
C 4 7 0 7 6 8
D 7 10 7 0 5 9
E 6 9 6 5 0 8
F 8 11 8 9 8 0
这个矩阵告诉我们,比如物种A和B的距离是5,A和C的距离是4,依此类推。对角线上的0表示每个物种与自身的距离当然是0。
接下来,我们要计算每个分类单元的净分化距离(r值)。这个值表示某个分类单元与其他所有分类单元的总距离。计算公式很简单:
r(i) = Σ d(i,j),对所有j≠i
以物种A为例:
r(A) = d(A,B) + d(A,C) + d(A,D) + d(A,E) + d(A,F)
= 5 + 4 + 7 + 6 + 8
= 30
同理我们可以计算出所有物种的r值:
r(A)=30, r(B)=42, r(C)=32, r(D)=41, r(E)=34, r(F)=44
这是NJ算法最巧妙的部分。我们需要构建一个新矩阵M,其元素计算公式为:
M(i,j) = d(i,j) - [r(i) + r(j)]/(n-2)
其中n是当前分类单元的数量(初始时为6)。
让我们计算M(A,B):
M(A,B) = d(A,B) - [r(A)+r(B)]/(6-2)
= 5 - (30+42)/4
= 5 - 18
= -13
计算完所有组合后,我们得到新的M矩阵:
code复制 A B C D E
B -13.0
C -11.5 -11.5
D -10.0 -10.0 -10.5
E -10.0 -10.0 -10.5 -13.0
F -10.5 -10.5 -11.0 -11.5 -11.5
在M矩阵中找最小值,这个最小值对应的两个分类单元就是当前阶段的"最近邻居"。在我们的例子中,M(A,B)=-13是最小值,因此A和B被选为邻居。
接下来计算它们到新节点U的分支长度:
S(A,U) = d(A,B)/2 + [r(A)-r(B)]/[2(n-2)]
= 5/2 + (30-42)/8
= 2.5 - 1.5
= 1
S(B,U) = d(A,B) - S(A,U)
= 5 - 1
= 4
这意味着在最终的进化树中,A到U的分支长度是1,B到U的分支长度是4。
现在我们需要计算新节点U到其他节点的距离:
d(U,k) = [d(A,k) + d(B,k) - d(A,B)]/2
例如计算d(U,C):
d(U,C) = [d(A,C)+d(B,C)-d(A,B)]/2
= [4+7-5]/2
= 3
同理计算出所有d(U,k)后,我们得到新的距离矩阵(现在包含U,C,D,E,F):
code复制 U C D E F
U 0 3 6 5 7
C 3 0 7 6 8
D 6 7 0 5 9
E 5 6 5 0 8
F 7 8 9 8 0
现在重复上述步骤,用新的距离矩阵继续寻找最近邻居。每次迭代都会减少一个分类单元,直到最后只剩下两个节点,这时将它们连接起来就完成了整棵树的构建。
为了更好地理解,让我们用Python实现一个简化版的NJ算法。我们将使用BioPython库中的工具来处理生物数据。
python复制from Bio.Phylo.TreeConstruction import DistanceMatrix, DistanceTreeConstructor
import numpy as np
# 定义距离矩阵
data = [
[0, 5, 4, 7, 6, 8],
[5, 0, 7, 10, 9, 11],
[4, 7, 0, 7, 6, 8],
[7, 10, 7, 0, 5, 9],
[6, 9, 6, 5, 0, 8],
[8, 11, 8, 9, 8, 0]
]
# 物种名称
names = ['A', 'B', 'C', 'D', 'E', 'F']
# 创建距离矩阵对象
dm = DistanceMatrix(names, data)
# 使用NJ算法构建树
constructor = DistanceTreeConstructor()
nj_tree = constructor.nj(dm)
# 打印树结构
print(nj_tree)
运行这段代码,你会得到一棵系统发育树。在实际项目中,我们通常会进一步美化这棵树的展示:
python复制from Bio import Phylo
import matplotlib.pyplot as plt
# 绘制进化树
plt.figure(figsize=(10, 6))
Phylo.draw(nj_tree, do_show=False)
plt.title('Neighbor-Joining Tree')
plt.show()
速度快是NJ算法最突出的优点。它的时间复杂度是O(n³),对于包含数百个分类单元的数据集也能在合理时间内完成计算。我记得第一次处理一个包含200多个细菌基因组的数据时,NJ算法只用了不到1分钟就完成了建树,而其他方法可能需要几个小时。
另一个重要优势是不需要分子钟假设。现实中,不同谱系的进化速率往往不同。比如病毒可能进化得很快,而某些"活化石"物种则几乎保持不变。NJ算法能够很好地适应这种情况。
NJ算法最常被诟病的问题是可能产生负分支长度。从生物学角度看,分支长度代表进化距离,负值显然没有意义。虽然可以通过设置最小为0来修正,但这会影响树的准确性。
另一个问题是缺乏明确的优化标准。像最大似然法有明确的统计基础,而NJ更像是一个启发式算法。这意味着我们很难评估得到的树到底有多"好"。
在建树之前,选择合适的距离计算方法至关重要。对于DNA序列,常用的有:
蛋白质序列则常用:
选择不当会导致距离估计偏差,进而影响树的准确性。我曾经比较过不同距离模型对同一组数据的影响,结果树的拓扑结构确实会有明显差异。
为了评估树的可靠性,通常会进行自举检验(bootstrap test)。简单说就是从原始数据中随机抽取(有放回)生成多个数据集,对每个数据集建树,然后统计各分支在这些树中出现的频率。频率越高,分支越可靠。
在R中可以使用ape包轻松实现:
r复制library(ape)
data(woodmouse)
dm <- dist.dna(woodmouse)
tree <- nj(dm)
bst <- boot.phylo(tree, woodmouse, FUN=function(x) nj(dist.dna(x)), B=100)
plot(tree)
nodelabels(bst)
虽然我们演示了用Python实现,但在实际研究中,人们更常用专业软件:
每个软件的具体参数设置会影响结果,建议先阅读文档并进行测试运行。我曾经因为没注意PHYLIP的默认参数,导致建树结果与预期不符,浪费了不少时间重新分析。
现实数据常常不完整,某些分类单元可能缺少部分序列信息。NJ算法原则上可以处理这种情况,但需要注意:
一个实用的解决方法是使用成对删除(pairwise deletion),即计算每对距离时只使用两者都有的位点。
当处理大量分类单元时,可以尝试以下优化:
我曾经处理过一个包含5000多个微生物OTU的数据集,直接运行NJ算法内存不足。后来我先用层次聚类将数据缩减到500个代表性OTU,问题就解决了。
好的可视化能极大提升树的解读效果。推荐:
例如在iTOL中,你可以上传树文件后:
记得保存模板,这样处理类似数据时可以直接套用。