查看全课大纲(第 53 / 93 节)
  1. 1.计算机视觉概述
  2. 2.计算机视觉应用场景
  3. 3.困难与挑战
  4. 4.开源库介绍
  5. 5.图像显示与存储
  6. 6.CLAHE
  7. 7.腐蚀膨胀与开闭运算
  8. 8.滤波与边缘填充
  9. 9.各种滤波
  10. 10.图像金字塔
  11. 11.傅里叶变换
  12. 12.短时傅里叶变换与小波变换
  13. 13.颜色特征与颜色直方图
  14. 14.几何特征_边缘
  15. 15.几何特征-角点与斑点
  16. 16.局部特征-SIFT特征描述子
  17. 17.局部特征-SURF特征描述子
  18. 18.局部特征-ORB特征描述子
  19. 19.其他特征描述子
  20. 20.传统图像分割技术
  21. 21.GraphCut
  22. 22.GrabCut
  23. 23.人脸检测的传统方法
  24. 24.行人检测的传统方法HOG+SVM
  25. 25.行人检测DPM
  26. 26.人工神经网络的架构
  27. 27.目标函数与梯度下降
  28. 28.误差反向传播
  29. 29.可手算的神经网络
  30. 30.改进目标函数与克服梯度消散
  31. 31.神经网络之传统神经网络与深度网络的差异
  32. 32.各种梯度下降算法
  33. 33.如何选择合适的梯度下降算法
  34. 34.避免过适应
  35. 35.CNN的架构
  36. 36.CNN的误差反向传播_卷积层
  37. 37.CNN的误差反向传播_池化层
  38. 38.分类所用的数据集
  39. 39.Alexnet详解
  40. 40.VGG详解
  41. 41.GoogLeNet_v1
  42. 42.BatchNormalization
  43. 43.GoogLeNet_v3v4
  44. 44.ResNet
  45. 45.ResNeXt
  46. 46.CNN设计准则
  47. 47.图像检索与推土机距离
  48. 48.基于颜色相似性
  49. 49.基于形状相似性
  50. 50.基于纹理相似性
  51. 51.基于局部特征相似性
  52. 52.DL的二值哈希编码
  53. 53.高维空间中检索KDTree
  54. 54.高维空间中检索LSH
  55. 55.目标识别任务综述
  56. 56.RCNN
  57. 57.SPP-Net
  58. 58.Fast R-CNN
  59. 59.Faster R-CNN
  60. 60.R-CNN系列模型回顾
  61. 61.R-FCN的设计思路
  62. 62.R-FCN的实现
  63. 63.YOLOv1
  64. 64.YOLOv2_9000
  65. 65.YOLOv3
  66. 66.图像分割应用场景与数据集
  67. 67.上采样的实现方式
  68. 68.FCN网络架构的由来
  69. 69.FCN构建细节
  70. 70.DeepLabv1
  71. 71.CRF
  72. 72.DeepLabv2
  73. 73.DeepLabv3
  74. 74.特定的医学影像分割任务
  75. 75.U-Net
  76. 76.3D U-Net
  77. 77.V-Net
  78. 78.DenseNet
  79. 79.病理切片分析任务及数据集
  80. 80.病理切片分析
  81. 81.图说任务及其传统处理方法
  82. 82.深度语言模型与RNN
  83. 83.LSTM及其变种
  84. 84.NIC模型
  85. 85.SAT模型
  86. 86.图说的性能指标
  87. 87.判别模型与生成模型
  88. 88.KL散度与JS散度
  89. 89.VAE
  90. 90.GAN的基本原理
  91. 91.DCGAN与CGAN
  92. 92.Wasserstein GAN
  93. 93.SRGAN

高维空间中检索KDTree

高维空间检索:KD-Tree

小象实战讲义 · 基于深度学习的计算机视觉

在图像检索领域,我们常常需要从海量的高维数据(例如4096维的深度特征向量)中,快速找到与查询数据最相似的一个或多个数据。如果采用两两比较的暴力轮询方法,其计算开销将变得难以承受。本节我们将学习一种用于高维空间快速检索的经典数据结构——KD-Tree,它能帮助我们高效地解决“最近邻查找”问题。学完本节,你将掌握KD-Tree的构建原理与查询流程,并理解其在维度适中时的精确查找能力。

💡 核心导读

  • 问题背景:理解为什么在海量高维数据(如深度学习特征)中进行相似性检索需要专门的索引技术,而非暴力比较。
  • KD-Tree定义:掌握KD-Tree作为一种用于多维度检索的二叉平衡树的基本概念。
  • 构建过程:学习KD-Tree的构建步骤,包括选择分裂维度、确定分割点、递归构建左右子树。
  • 查询与回溯:理解KD-Tree的最近邻查询算法,特别是从根节点到叶子的搜索路径,以及关键的“回溯”过程,以确保找到真正的最近邻点。
  • 适用场景与局限:明确KD-Tree适用于维度不是特别高的精确查找,而在维度极高时(如4096维),局部敏感哈希(LSH)等近似查找方法更为合适。

为什么需要高维空间快速检索?

在图像检索中,无论是使用传统的SIFT特征,还是基于深度学习的特征(例如从卷积神经网络第7层提取的4096维向量),我们处理的数据都具有很高的维度。假设我们有一个包含一百万张图片的数据库,每张图片用一个4096维的向量表示。当用户提交一张查询图片时,我们需要计算查询向量与数据库中所有一百万向量的距离(如欧氏距离),并找出距离最小的前k个。这种“暴力搜索”(Brute-force Search)的复杂度是O(N*D),其中N是数据量,D是维度。当N和D都很大时,计算将变得极其缓慢。

因此,我们需要一种索引技术来加速查找过程。这类技术主要分为两类:

  1. 精确最近邻查找(Exact Nearest Neighbor):保证找到的确实是距离最近的点,例如本节介绍的KD-Tree。
  2. 近似最近邻查找(Approximate Nearest Neighbor, ANN):以一定的精度损失为代价,换取更快的查找速度,例如局部敏感哈希(LSH)。当维度高到一定程度(如4096维)时,KD-Tree的效率会下降,此时近似查找方法更具优势。

什么是KD-Tree?

KD-Tree(K-Dimensional Tree)是一种用于在多维空间(K维)中组织点的数据结构。它是一种二叉平衡树,通过递归地将空间划分为两个半空间来构建。

其输入是N个D维空间的数据点。构建过程可以概括为以下步骤,我们结合一个二维空间的例子来具体说明。

KD-Tree的构建过程

假设我们有以下6个二维数据点:(2,3), (5,4), (4,7), (9,6), (7,2), (8,1)。我们将以此为例,演示KD-Tree的构建。

构建步骤:

  1. 确定分裂维度(Split Dimension):通常选择当前数据点集合中方差最大的那个维度进行分裂,因为方差大意味着数据在该维度上更分散,分裂效果更好。在简化示例或某些实现中,也可以轮流选择维度(例如,第一层选X轴,第二层选Y轴,第三层再选X轴,依此类推)。在我们的例子中,第一层我们选择X轴(维度0)进行分裂。
  2. 确定分割点(Split Point):在选定的分裂维度上,找到所有数据点在该维度上取值的中位数对应的点。这个点将作为当前子树的根节点。如果中位数有两个候选值,可以约定取较大的那个。在我们的例子中,X轴的值有[2,5,4,9,7,8],排序后为[2,4,5,7,8,9],中位数是7(取5和7中的较大者),对应的点是(7,2)。因此,(7,2)成为整棵树的根节点。
  3. 确定分割超平面(Split Hyperplane):通过分割点,构造一个垂直于分裂维度的超平面。在二维空间中,这就是一条垂直于X轴的竖直线 x = 7。这个超平面将当前空间划分为两部分。
  4. 确定左右子树:
    • 左子树:包含所有在分裂维度上值小于分割点的数据点。即 x < 7 的点:(2,3), (5,4), (4,7)。
    • 右子树:包含所有在分裂维度上值大于分割点的数据点。即 x > 7 的点:(9,6), (8,1)。
  5. 递归构建:对左子树和右子树分别重复步骤1-4,直到每个子空间只包含一个数据点为止。

接下来对左子树 {(2,3), (5,4), (4,7)} 进行构建。此时我们切换到下一个维度(Y轴,维度1)进行分裂。Y轴的值是[3,4,7],中位数是4,对应的点是(5,4)。(5,4)成为左子树的根节点。超平面是 y = 4。

  • 左子节点:y < 4 的点 (2,3)。
  • 右子节点:y > 4 的点 (4,7)。

再对右子树 {(9,6), (8,1)} 进行构建。按“按层轮流”的规则,它与左子树的根同处第二层,因此同样用Y轴(维度1)分裂;即便改用“方差最大”来选,这一子集的Y值[6,1]方差也远大于X值,结论一致。Y轴的值是[6,1],中位数是6(取1和6中的较大者),对应点(9,6)。(9,6)成为右子树的根节点。超平面是 y = 6。

  • 左子节点:y < 6 的点 (8,1)。
  • 右子节点:无。

至此,所有点都已分配完毕,KD-Tree构建完成。其结构如下图所示(以文字示意):

根节点: (7,2) [分裂维度: X]
├── 左子树 (分裂维度: Y)
│   ├── 节点: (5,4)
│   │   ├── 左子节点: (2,3)
│   │   └── 右子节点: (4,7)
└── 右子树 (分裂维度: Y)
    └── 节点: (9,6)
        └── 左子节点: (8,1)

KD-Tree的最邻近查询

构建好KD-Tree后,如何查询一个点 Q 的最近邻点呢?查询过程分为两步:路径搜索和回溯。

我们以查询点 Q = (2, 4.5) 为例。

1. 路径搜索(类似二叉树搜索)

从根节点开始,根据节点所在的分裂维度,比较查询点 Q 与该节点在该维度上的值,决定进入左子树还是右子树,直到到达一个叶子节点。注意:每访问一个节点,都要计算它与 Q 的距离并维护“当前最近点”;叶子节点只是路径终点,不一定是当前最近点。

  • 第一层(根节点 (7,2),分裂维度X):比较 Q.x = 2 和 7。因为 2 < 7,进入左子树。计算距离 d = sqrt((2-7)^2 + (4.5-2)^2) = sqrt(25 + 6.25) ≈ 5.590,当前最近点为 (7,2),d_current ≈ 5.590。
  • 第二层(节点 (5,4),分裂维度Y):比较 Q.y = 4.5 和 4。因为 4.5 > 4,进入右子树。计算距离 d = sqrt((2-5)^2 + (4.5-4)^2) = sqrt(9 + 0.25) ≈ 3.041 < 5.590,更新当前最近点为 (5,4)。
  • 到达叶子节点 (4,7):计算距离 d = sqrt((2-4)^2 + (4.5-7)^2) ≈ 3.202 > 3.041,不更新。

此时,我们得到一条搜索路径:(7,2) -> (5,4) -> (4,7),当前最近点是 (5,4),d_current ≈ 3.041。

2. 回溯(Backtracking)

关键步骤来了。我们不能因为找到了一个叶子节点就停止。因为真正的最近邻点可能位于搜索路径上某个节点的另一半子空间里。我们需要沿着搜索路径向上回溯,检查每个节点的另一半子空间是否可能存在更近的点。

判断依据是:以查询点 Q 为圆心、以当前最近距离 d_current 为半径画一个超球体(二维中是圆),检查它是否与当前回溯节点的分割超平面相交。

  • 第一次回溯:回到节点 (5,4)。

    • 当前最近点:(5,4),距离 d_current ≈ 3.041。
    • 节点 (5,4) 的分割超平面是 y = 4。
    • 判断:查询点 Q(2,4.5) 到超平面 y=4 的垂直距离是 |4.5 - 4| = 0.5。
    • 因为 0.5 < 3.041,这意味着以 Q 为圆心、3.041 为半径的圆会与直线 y=4 相交。
    • 结论:在节点 (5,4) 的另一半子空间(即左子树,y < 4 的区域)中,有可能存在比 (5,4) 更近的点。我们必须去检查那一半。
    • 检查左子树 (2,3):计算 Q 到 (2,3) 的距离 d = sqrt((2-2)^2 + (4.5-3)^2) = 1.5。
    • 因为 1.5 < 3.041,所以更新当前最近点为 (2,3),更新 d_current = 1.5。
  • 第二次回溯:回到节点 (7,2)。

    • 当前最近点:(2,3),距离 d_current = 1.5。
    • 节点 (7,2) 的分割超平面是 x = 7。
    • 判断:查询点 Q(2,4.5) 到超平面 x=7 的垂直距离是 |2 - 7| = 5。
    • 因为 5 > 1.5,这意味着以 Q 为圆心、1.5 为半径的圆不会与直线 x=7 相交。
    • 结论:在节点 (7,2) 的另一半子空间(即右子树,x > 7 的区域)中,不可能存在比 (2,3) 更近的点。因此无需检查右子树。

回溯完成。最终确定查询点 Q(2, 4.5) 的最近邻点是 (2,3),距离为 1.5。

重要提示:回溯过程必须执行完整,直到检查完根节点的另一半子空间(或确认无需检查)。许多简化教程在第一次回溯找到更近点后就停止,这是不严谨的。完整的回溯保证了查找的精确性。

代码示例:构建与查询简化示意

虽然完整的KD-Tree实现涉及递归构建和复杂回溯,但我们可以用代码演示其核心思想:按维度比较进行二叉搜索。以下是一个高度简化的二维KD-Tree搜索路径演示,不包含回溯逻辑。

import numpy as np

# 定义数据点 (格式: [x, y])
points = np.array([
    [7, 2],
    [5, 4],
    [2, 3],
    [4, 7],
    [9, 6],
    [8, 1]
])
# 为演示,我们假设树已经按前述过程构建好,并直接定义节点的左右关系。
# 在实际中,你需要一个TreeNode类和build_kdtree函数。

# 模拟一个简化版的搜索路径(无回溯)
def simple_kdtree_search(query_point, current_node_idx, depth, points, node_relations):
    """
    node_relations: 字典,key为节点在points中的索引,value为(left_child_idx, right_child_idx, split_dim)
    这是一个极度简化的演示,仅展示搜索路径。
    """
    if current_node_idx is None:
        return []
    
    node_point = points[current_node_idx]
    split_dim = node_relations[current_node_idx][2]
    
    path = [current_node_idx]
    
    # 决定搜索方向
    if query_point[split_dim] < node_point[split_dim]:
        next_node_idx = node_relations[current_node_idx][0]  # 左孩子
    else:
        next_node_idx = node_relations[current_node_idx][1]  # 右孩子
    
    # 递归搜索
    path.extend(simple_kdtree_search(query_point, next_node_idx, depth + 1, points, node_relations))
    return path

# 根据我们之前构建的树,手动定义节点关系 (索引对应points中的行)
# 格式: {node_index: (left_child_index, right_child_index, split_dim)}
# split_dim: 0 for X, 1 for Y
# 根节点(7,2)索引0,左子树根(5,4)索引1,右子树根(9,6)索引4
# (2,3)索引2, (4,7)索引3, (8,1)索引5
node_relations = {
    0: (1, 4, 0),   # (7,2): 左孩子是索引1(5,4), 右孩子索引4(9,6), 分裂维度X
    1: (2, 3, 1),   # (5,4): 左孩子索引2(2,3), 右孩子索引3(4,7), 分裂维度Y
    2: (None, None, 0), # (2,3): 叶子
    3: (None, None, 0), # (4,7): 叶子
    4: (5, None, 1),   # (9,6): 左孩子索引5(8,1), 无右孩子, 分裂维度Y
    5: (None, None, 1)  # (8,1): 叶子
}

query = np.array([2, 4.5])
search_path_indices = simple_kdtree_search(query, current_node_idx=0, depth=0, points=points, node_relations=node_relations)

print("查询点 Q =", query)
print("搜索路径上的节点索引:", search_path_indices)
print("对应的节点坐标:")
for idx in search_path_indices:
    print(f"  {points[idx]} (分裂维度: {'X' if node_relations[idx][2]==0 else 'Y'})")
查询点 Q = [2.  4.5]
搜索路径上的节点索引: [0, 1, 3]
对应的节点坐标:
  [7 2] (分裂维度: X)
  [5 4] (分裂维度: Y)
  [4 7] (分裂维度: X)

这段代码仅模拟了从根节点到叶子节点 (4,7) 的搜索路径,印证了我们之前的分析。要实现完整的最近邻查询,必须在此基础上加入距离计算和回溯机制。

📝 动手练一练

  1. 概念辨析:KD-Tree 和普通的二叉搜索树(BST)在查找“最近邻”时,核心的额外步骤是什么?为什么这个步骤对于KD-Tree是必需的,而对于在一维数据中查找最近值的BST却不必要?
👉 点击查看参考答案

答案: 核心的额外步骤是回溯(Backtracking)。

  • 对于KD-Tree(多维):在沿着树向下搜索到某个叶子节点后,真正的最近邻点可能位于搜索路径上某个节点的“另一半”子空间中(即被该节点的分割超平面分开的、未被访问的那部分空间)。因为距离是欧氏距离,一个点即使在某一个维度上距离查询点较远,在其他维度上可能非常接近,从而导致整体距离更近。因此,必须通过回溯,检查以查询点为圆心、当前最近距离为半径的超球体是否与路径上各节点的分割超平面相交,以决定是否需要搜索另一半空间。
  • 对于一维BST:数据全在一维直线上。树的构建基于值的大小划分左右,搜索路径本身就已经保证了路径上的节点是值最接近查询值的候选。最近邻点要么是搜索路径终点,要么是其前驱或后继(在BST中很容易找到),不存在一个点在“值的另一侧”但“距离更近”的情况(因为距离就是绝对差)。因此不需要复杂的回溯机制。
  1. 手算练习:对于本节示例中构建的KD-Tree,如果查询点是 Q = (6, 5),请手动推导其完整的查询过程(搜索路径+回溯),并找出最终的最近邻点及其距离。(提示:从根节点 (7,2) 开始,比较 Q.x=6 和 7)
👉 点击查看参考答案

答案: 查询点 Q = (6, 5)。

1. 路径搜索(每访问一个节点都计算距离并维护当前最近点):

  • 根节点 (7,2),分裂维度X。6 < 7,进入左子树。距离 d = sqrt((6-7)^2 + (5-2)^2) = sqrt(1+9) ≈ 3.162,当前最近点为 (7,2)。
  • 节点 (5,4),分裂维度Y。5 > 4,进入右子树。距离 d = sqrt((6-5)^2 + (5-4)^2) = sqrt(1+1) ≈ 1.414 < 3.162,更新当前最近点为 (5,4)。
  • 到达叶子节点 (4,7)。距离 d = sqrt((6-4)^2 + (5-7)^2) = sqrt(4+4) ≈ 2.828 > 1.414,不更新。
  • 搜索路径:(7,2) -> (5,4) -> (4,7);当前最近点 (5,4),d_current ≈ 1.414。

2. 回溯:

  • 回溯到 (5,4):
    • 当前最近距离 d_current ≈ 1.414。
    • 分割超平面 y=4。Q到该平面的距离 |5-4| = 1。
    • 因为 1 < 1.414,圆与平面相交,需检查左子树 (2,3)。
    • 计算 Q 到 (2,3) 的距离 d = sqrt((6-2)^2 + (5-3)^2) = sqrt(16+4) ≈ 4.472 > 1.414,不更新。
  • 回溯到 (7,2):
    • 当前最近距离 d_current ≈ 1.414。
    • 分割超平面 x=7。Q到该平面的距离 |6-7| = 1。
    • 因为 1 < 1.414,圆与平面相交,需检查右子树(以 (9,6) 为根的子树)。
    • 检查右子树:从 (9,6) 开始(分裂维度Y)。
      • (9,6) 本身距离 d = sqrt((6-9)^2 + (5-6)^2) = sqrt(9+1) ≈ 3.162 > 1.414,不更新。
      • Q.y = 5 < 6,进入 (9,6) 的左子树 (8,1)。
      • (8,1) 是叶子节点。距离 d = sqrt((6-8)^2 + (5-1)^2) = sqrt(4+16) ≈ 4.472 > 1.414,不更新。
    • ((9,6) 的右子树为空,无需进一步回溯。)

最终结果:最近邻点是 (5,4),距离约为 1.414。

本章小结

本节我们深入探讨了用于高维空间快速检索的经典数据结构——KD-Tree。我们首先明确了在海量高维数据中进行相似性检索时,构建索引的必要性。随后,我们详细学习了KD-Tree的构建过程,包括选择分裂维度、确定中位数分割点以及递归划分空间。最后,我们重点剖析了KD-Tree的最近邻查询算法,特别是其区别于普通二叉树搜索的关键——“回溯”机制,并通过实例逐步推演,确保了查找结果的精确性。

需要牢记的是,KD-Tree适用于维度不是特别高(通常D<20时效果较好)的精确最近邻查找问题。当维度继续升高时,其性能会趋近于线性扫描,此时应转向局部敏感哈希(LSH) 等近似最近邻查找方法,以精度换取速度。

📋 行动清单

  • 在纸上复现本节二维KD-Tree的构建过程,并针对一个新的查询点(如(3, 6))手动执行完整的搜索与回溯,找出最近邻。
  • 思考:本节用“在分裂维度上取中位数”批量建树,所以树在结点数上必然平衡;如果改成像 BST 那样按数据到达顺序逐点插入来建树,KD-Tree 才可能退化成近似链状结构、查询退化为线性扫描——两者的差别在哪里?(提示:回看构建步骤2)
  • 了解除了“方差最大”之外,选择分裂维度的其他策略(如轮流选择、范围最大等),并比较其优劣。

—— 小象教研组