用python手写KNN算法+kd树及其BBF优化(原理与实现)(下篇)
接上一篇用python手写KNN算法+kd树及其BBF优化(原理与实现)(上篇)
我们使用training2和test2两个数据集时,运行结果如下:
当不使用kd-tree时(将最小划分个数设为10000),运行情况如下:
可以发现,使用kd-tree并不会减少预测用时,甚至多于不使用kd-tree时。
这似乎有点违背常理,我们使用详细print版代码(使用kd-tree)再运行一遍,就可以找到原因:
可以看到每次回溯都几乎遍历了全部训练样本,kd-tree并没有发挥应有的效果。
事实上,我们之前的回溯算法只能适用于样本特征维数较小的情况,当维数太大时,与前面介绍的超球面相交的超矩形就会大大增多,这意味这我们回溯的次数大增,最终丧失了二叉树查找的优势。
于是我们需要改进查找算法,减少回溯次数,并且尽可能让找出K近邻的准度不至于下降太多。这就是下面的BBF优化。
BBF优化主要有两点:
(1) 限制回溯次数,这个阈值需要手动设置
(2)选择回溯点,这里我们采取和前面不一样的办法:
1、在向叶结点查找的同时,将途经分支结点放入优先级队列,这里的优先级指待确定的测试样本点在分支结点的划分维度上与划分值的差值,即前面的|Q[Di]-Dv|,此值越小,优先级越高,稍后就优先回溯
2、到达叶结点后,取优先级队首的分支结点进行回溯,回到步骤1
BBF优化后的完整代码如下:
ps: 之所以搞一个queueBack类是为了避免限制回溯次数时设置外部变量。
import numpy as np
import time
import queue
"""que是用于回溯的分支结点优先级队列,bbfmax是最大回溯次数,为0时不再回溯"""
class queueBack():
def __init__(self, bbfmax):
self.que = queue.PriorityQueue()
self.bbfmax = bbfmax
def put(self, node):
self.que.put(node)
def get(self):
if not self.que.empty():
self.bbfmax -= 1
return self.que.get()
def empty(self):
return self.que.empty()
def getbbfmax(self):
return self.bbfmax
def cangoback(self):
return (not self.que.empty()) and self.bbfmax!=0
def loadData(filePath):
with open(filePath, 'r+') as fr: # with语句会自动调用close()方法,且比显式调用更安全
lines = fr.readlines()
data = []
for line in lines:
items = line.strip().split(",")
data.append([int(items[i]) for i in range(len(items))])
return np.asarray(data)
class kdNode():
# 分支结点
def __init__(self, demo, value, left, right):
# 切割维度,切割值,左子树,右子树
self.demo = demo
self.value = value
self.left = left
self.right = right
self.dis = 1000000000 #球面与超平面的距离,保险起见初始给一个较大的值
def setdis(self, value):
self.dis = value
def __lt__(self, other): #用于优先级队列判断
return self.dis < self.dis
class kdtree():
# kd树
def __init__(self, data_array, threshold): # data_array为初始的数据集合
row, col = data_array.shape
k = col - 1 # k指维度,即特征向量的元素个数
self.threshold = threshold # 最小分支阈值,数据个数低于此值不在划分
"""寻找方差最小的维度"""
def getMaxDimension(data): # data即当前待划分的数据集合(数组)
maxv = -1 # 记录当前最大方差
maxi = -1 # 记录当前方差最大的维度
for i in range(k):
a = np.var(data[:, i]) # 计算维度i对应的方差
if a > maxv:
maxi = i
maxv = a
return maxi, maxv # 返回最大方差对应的维度和最大方差值
"""创建一个分支结点"""
def createNode(data):
split_dimension, maxv = getMaxDimension(data) # split_dimension, maxv分别指划分轴(维度)和最大方差值
if maxv == 0: # 考虑边界情况,最大方差为0时当前数据不必划分,直接作为叶子结点
return data
split_value = np.median(data[:, split_dimension]) # 取当前维度下的中位数作为划分值
maxvalue = np.max(data[:, split_dimension]) # 当前维度下的最大元素
minvalue = np.min(data[:, split_dimension]) # 当前维度下的最小元素
left = [] # 保存在split_dimension下小于(或等于)split_value的点
right = [] # 保存在split_dimension下大于(或等于)split_value的点
for i in range(len(data)):
if split_value < maxvalue: # 避免0,0,0,1,2这样的分不开
if data[i][split_dimension] <= split_value:
left.append(list(data[i]))
else:
right.append(list(data[i]))
elif split_value > minvalue: # 避免0,1,2,2,2这样的分不开
if data[i][split_dimension] < split_value:
left.append(list(data[i]))
else:
right.append(list(data[i]))
# 最小分支阈值,低于此值不在划分
root = kdNode(split_dimension, split_value,
(createNode(np.asarray(left)) if len(left) >= threshold else np.asarray(left)),
(createNode(np.asarray(right)) if len(right) >= threshold else np.asarray(right)))
# 递归建树,注意当点集中元素个数小于最小分支阈值时直接作为叶结点而不必分支
return root
self.root = createNode(data_array)
"""寻找vec对应的k邻近,klist为(距离,[向量])构成的列表,存放vec的k个近邻点的信息,初始为空
pque是一个queueBack实例"""
n = 0
def findn(root, vec, klist, k, pque):
if type(root) == np.ndarray:
if len(root) == 0:
return
temp = (root[:, :-1] - vec) ** 2
for i in range(len(temp)):
global n
n += 1
a = sum(temp[i])
if len(klist) != k:
klist.append((a, root[i]))
klist.sort(key=lambda x: x[0]) # 按距离排序
else:
if a < klist[k - 1][0]:
klist[k - 1] = [a, root[i]]
klist.sort(key=lambda x: x[0]) # 按距离排序
while pque.cangoback():
newroot = pque.get()
findn(newroot, vec, klist, k, pque)
else:
#print("2:" + str(bbfmax))
if vec[root.demo] < root.value:
#findn(root.left, vec, klist, k, pque, bbfmax)
#if abs((vec[root.demo] - root.value)**2) < klist[len(klist) - 1][0]:
if type(root.right) == np.ndarray: # 如果是叶结点则无需入队直接遍历
findn(root.right, vec, klist, k, pque)
else:
dis = abs(vec[root.right.demo] - root.right.value)
root.right.setdis(dis)
#print("dis: "+ str(dis))
pque.put(root.right)
findn(root.left, vec, klist, k, pque)
else:
#findn(root.right, vec, klist, k, pque, bbfmax)
#if abs((vec[root.demo] - root.value)**2) < klist[len(klist) - 1][0]:
if type(root.left) == np.ndarray: # 如果是叶结点则无需入队直接遍历
findn(root.left, vec, klist, k, pque)
else:
dis = abs(vec[root.left.demo] - root.left.value)
root.left.setdis(dis)
#print("dis: " + str(dis))
pque.put(root.left)
findn(root.right, vec, klist, k, pque)
""" 选出列表中出现次数最多的元素,一个需要注意的问题是像[2,2,1,1,3]这样的怎么选,因为之前已经按距离从小到大排序,所以应选2"""
def findMain(alist):
hashtable = [0 for i in range(10)]
for i in range(len(alist)):
hashtable[alist[i]]+=1
maxnum = -1
main = -1
for i in range(len(alist)):
if hashtable[alist[i]]>maxnum:
main = alist[i]
maxnum = hashtable[alist[i]]
return main
def forecast(root, data, k, bbfmax):
a = [] # 作为findn方法中的klist参数
global n
n = 0
que = queueBack(bbfmax)
findn(root, data, a, k, que)
#print("遍历了" + str(n) + "个点")
L = len(a[0][1]) # 其实就是向量维度
res = []
for i in range(len(a)):
res.append(a[i][1][L - 1])
#return np.argmax(np.bincount(np.asarray(res))) #这样并不能正确处理[2,2,1,1,3]这样的情况
return findMain(res)
def knn(train_list, test_list, k, bbfmax):
tic1 = time.time()
root = kdtree(train_list, 10).root
print("最小划分个数: 10")
print("k = " + str(k))
tic2 = time.time()
res = []
num = 0
for i in range(len(test_list)):
a = forecast(root, np.asarray(test_list[i][:-1]), k, bbfmax)
#print(test_list[i][-1])
if a == test_list[i][-1]:
num += 1
res.append(str(a) + "\n")
print("最大回溯数:"+str(bbfmax))
print("正确率:" + str(num / len(test_list))) # 预测准确率
toc = time.time()
print("总用时:" + str(1000 * (toc - tic1)) + "ms")
print("训练用时:" + str(1000 * (tic2 - tic1)) + "ms")
print("预测用时:" + str(1000 * (toc - tic2)) + "ms")
if __name__ == "__main__":
train_list = loadData("training2.txt")
test_list = loadData("test2.txt")
knn(train_list, test_list, 3, 10)
运行结果:
最大回溯数为10:
最大回溯数为50:
对比前面未用BBF的结果,可以发现,使用BBF优化提高了查找速度,但降低了准确率。