KNN原理及Python代碼實現(超詳細版)

1、原理

1. 概述

K近鄰法(k-nearest neighbors,KNN)是一種有監督的學習算法,也是機器學習中最簡單、且不那麼依靠各種假設的算法(基本上全部算法都會有假設的前提條件,在數據分佈符合算法的假設條件時,其效果每每會更好)。node

1.1 核心思想

物以類聚,人以羣分。俗話說,「看一個男人好很差,就看他身邊的朋友絕對沒錯」,對咱們要學習和預測的樣原本說,道理也是同樣的。咱們要判斷一個樣本屬於什麼類別,能夠經過圍在他身邊的樣原本判斷,在特徵空間內與這個樣本距離最近的 k 個樣本應該與待預測樣本是一夥的,因此若是這 k 個樣本大多屬於類別A,那麼認爲待預測樣本也屬於類別A。python

1.2 用途

KNN能夠用於分類(二分類多分類均可以,且算法不須要做修改),也能夠用於迴歸,用於分類仍是迴歸主要在於算法輸出結果的計算方式,分類問題可能是經過對待預測樣本附近的k個樣本對所屬類別進行投票,根據投票結果來決定待分類樣本的類別,即多數表決法(少數服從多數);迴歸問題是對待預測樣本最近的k個樣本的輸出進行平均,均值做爲待預測樣本的輸出,即平均法算法

1.3 KNN優缺點

優勢:數據結構

  • 對數據的分佈沒有假設,能夠適用於各類分佈形式的數據集,不過通常來講密集一些的數據更好,太稀疏的數據更難控制 k 的取值,易被誤導;
  • 算法思想簡單,容易理解和實現,並且能夠分類也能夠迴歸;

缺點:app

  • KNN基本沒有根據數據訓練和學習的過程,每次分類都要跑一次整個分類過程,速度慢;
  • 對樣本不均衡狀況比較敏感,容易被樣本量大的類別干擾(能夠考慮使用距離來加權,增大距離最近的數據的影響);
  • 暴力實現計算量大,KD樹等實現耗內存。

2. 算法流程及實現

2.1 KNN算法流程(以KNN分類算法爲例)

輸入:訓練集\(T=\{(x_{1}, y_{1}),(x_{2}, y_{2}),...(x_{n}, y_{n})\}\),其中\(x\)爲樣本的特徵向量,爲樣本的類別
輸出:樣本 x 所屬的類別 y
(1)選定距離度量方法,在訓練集T中找出與待預測樣本 x 最接近的 k個樣本點,包含這 k個點的 x 的鄰域記做;
(2)在中根據分類決策規則(如多數表決法)來決定 x 所屬的類別 y。


機器學習

由上述KNN算法流程能夠發現,在運算中存在四個須要關注的重點:ide

  • 距離度量方式的選擇;
  • k值的選取;
  • 怎樣找出這k個近鄰的樣本點;
  • 分類決策規則的選擇。

其中度量方式、k值、分類決策規則稱爲KNN算法的三要素,會影響算法的效果,很是重要;而怎樣找出這k個近鄰的樣本點則直接決定了KNN算法的實現後的具體計算過程,影響算法的複雜度。接下來就詳細分析這四個要點:工具

(1)距離度量方式:通常來講距離度量方式主要包括歐式距離、曼哈頓距離、閔可夫斯基距離,歐式距離是咱們比較經常使用的,這是比較基礎的內容,本文再也不贅述。學習

(2)k值的選取:k值的選取沒有很好的辦法,通常才用交叉驗證來選擇合適的k值,好比使用gridsearchcv工具。k值的選擇會對預測的結果形成比較大的影響,k值越小,模型越複雜(k越大模型越簡單,好比k=n,全部待預測樣本都被劃分爲同一類了,模型就很簡單),這時模型的誤差bias減少,方差variance增大,模型容易過擬合,因此對k值的選取要慎重。測試

(3)分類決策規則的選擇,分類問題大多才用多數表決法。

(4)怎樣找出這k個近鄰的樣本點,經常使用的方法有三種:

a. 暴力實現,即直接遍歷獲得訓練集中全部樣本點與預測點的距離,而後排序取前k個最近鄰點,因此其計算的時間複雜度爲O(n);

b. KD樹,使用暴力實現沒有利用到數據自己蘊含的結構信息,在樣本量較大時效率過低,計算複雜度高,在實際工程應用中,對成百上千個特徵幾百萬的樣本量計算比較困難,所以能夠經過索引樹,對搜索空間進行層次劃分,以此來優化計算,其時間複雜度爲O(logn),詳細內容在後面會講到;

c. 球數,球樹和KD樹相似,但不會像KD樹同樣作一些多餘的計算,主要區別在於KD樹獲得的是節點樣本組成的超矩形體,而球樹獲得的是節點樣本組成的最小超球體,這個超球體要比對應的KD樹的超矩形體小,這樣在作最近鄰搜索的時候,能夠避免一些無謂的搜索。

接下來,對基於KD樹實現的 KNN 進行詳述,爲何寫KD樹不寫球樹呢?一是二者差很少,二是KD樹看起來跟凱文杜蘭特好像有什麼莫名其妙的聯繫。

2.2 KD樹

KD樹(K-dimension tree),即K個特徵維度的樹,這個樹的功能就是能夠幫咱們快速找到跟目標點相近的數據點,想一想這個功能,是否是很像咱們玩的猜數字的遊戲,一我的偷偷想一個數字(目標數據點),其餘人不停的猜數字縮小範圍,這不就跟咱們爲目標點找最相近的 k 個點是同樣同樣的嗎?

因此,再想一想咱們玩猜數字經常使用的套路:二分法,這也是KD樹的核心思想:分而治之。所以,KD樹是一個二叉樹,他的每一個節點會將全部的數字點在某個維度上一分爲二,設想一下,若是數據空間在各個維度上被咱們劃分紅了不少個小的part,那麼咱們要肯定一個新的數據點相近的點,只要找到這個點所在的part不就八九不離十了。

不過這個樹應該在哪一個維度上、怎麼分呢?固然是選擇能把數字分的最開的維度來開刀(好比使用方差大的,數據方差大說明沿該座標軸方向上數據點分散的比較開,這個方向上,進行數據分割能夠得到最好的分辨率)。這是否是跟決策樹的思路很像?固然,決策樹是能夠直接用於分類的,特徵選擇更爲複雜(好比用信息增益比),並且每一個特徵只會用到一次,而且並不要求特徵是數值類型的,不過其基本思想仍是很像的,好了不說決策樹了,來看看怎麼實現一個KD樹。

2.2.1 KD樹的構建

先舉個常見的簡單例子:假設有六個二維數據點\(\{(2,3),(5,4),(9,6),(4,7),(8,1),(7,2)\}\),數據點位於二維空間中。根據上面的介紹,咱們須要根據這些數據點把數據空間劃分紅不少個小的part。事實上,六個二維數據點生成的Kd-樹的圖,即劃分結果爲:

這是怎麼作到的呢?

  • 找到劃分的特徵( split )。6個數據點在 x,y 維度上的數據方差分別爲6.97,5.37,因此在x軸上方差更大,用第1維特徵建樹;
  • 肯定劃分點(7,2)( Node-Data )。根據 x 維上的值將數據排序,6個數據的中值(所謂中值,即中間大小的值)爲6,因此劃分點的數據是(7,2)(選(5,4)也能夠)。這樣,該節點的分割超平面就是經過(7,2)並垂直於:劃分點維度的直線 x=7;
  • 肯定左子空間 ( left ) 和右子空間 ( right )。分割超平面 x=7 將整個空間分爲兩部分:x<=7 的部分爲左子空間,包含3個節點 {(2,3),(5,4),(4,7)};另外一部分爲右子空間,包含2個節點={(9,6),(8,1)};
  • 迭代。用一樣的辦法劃分左子樹的節點{(2,3),(5,4),(4,7)}和右子樹的節點{(9,6),(8,1)}。

咱們能夠看出,以下圖:點 (7,2) 爲根結點,(5,4) 和 (9,6) 則爲根結點的左右子結點,而 (2,3),(4,7) 則爲 (5,4) 的左右子,最後,(8,1) 爲 (9,6) 的左子。如此,便造成了這樣一棵k-d樹。

從以上過程和結果的描述,咱們能夠總結出KD樹的基本數據結構和構建流程:
k-d樹的數據結構:

Kd-tree構建流程的僞代碼:

輸入:數據點集DataSet,和其所在的空間
輸出:Kd,類型爲Kd-tree

1 if DataSet is null , return null;
2 else 調用Node-Data生成程序:
a 計算 Split。對於全部數據(高維向量),統計他們在每一個維度上的方差,方差最大值所對應的維度就是 Split 域的值;
b 計算 Node-Data。數據點集 DataSet 按照第 split 維的值排序,最靠近中位數的那個數據點被選爲 Node-Data;
3 dataleft = { d 屬於 DataSet & d[:split] <= Node-data[:split] };
 Left-Range = { Range && dataleft };
 dataright = {d 屬於 DataSet & d[:split] > Node-data[:split] };
 Right-Range = { Range && dataright };
4 left = 由(dataleft, Left-Range)創建的Kd-tree;
 設置 left 爲 KD樹的Left;
 right =由(dataright,Right-Range)創建的Kd-tree;
 設置 right 爲KD樹的Right;
5 在left 和 right 上重複上面的過程,直到數據所有分完。











根據以上僞代碼就能夠寫出構建KD樹的程序,Python實現以下:

Kd-tree構建流程的Python代碼:

class KDNode(object):
	def __init__(self, node_data, split, left, right):
		self.node_data = node_data
		self.split = split
		self.left = left
		self.right = right


class KDTree(object):
	def __init__(self, dataset):
		self.dim = len(dataset[0]) 
		self.tree = self.generate_kdtree(dataset)

	def generate_kdtree(self, dataset):
		'''
		遞歸生成一棵樹
		'''
		if not dataset:
			return None
		else:
			split, data_node = self.cal_node_data(dataset)
			left = [d for d in dataset if d[split] < data_node[split]]
			right = [d for d in dataset if d[split] > data_node[split]]
			return KDNode(data_node, split, self.generate_kdtree(left), self.generate_kdtree(right))

	def cal_node_data(self, dataset):
		std_lst = []  # 用標準差代替方差,都同樣
		for i in range(self.dim):
			std_i = np.std([d[i] for d in dataset])
			std_lst.append(std_i)
		split = std_lst.index(max(std_lst))
		dataset.sort(key=lambda x: x[split])
		indx=int((len(dataset) + 2) / 2)-1
		data_node = dataset[indx]
		return split, data_node
2.2.2 使用KD樹獲得 k 近鄰

(1)使用KD樹獲得最近鄰

1)順着二叉樹搜索,直到找到葉子節點中的最近鄰節點,這個過程會肯定一條搜索路經;
2)由於二叉樹每次劃分數據空間都是以某一個特徵爲標準進行的,因此綜合考慮全部特徵,根據二叉樹搜索到的子節點不必定是最近鄰的,不過最近鄰點確定位於以查詢點爲圓心且經過葉子節點的圓域內(否則就會比這個葉子節點離得遠了),因此根據搜索路徑進行回溯操做,找到最近鄰點。

舉兩個例子,在以前創建的Kd樹中搜索(3, 4.5)的最近鄰點:

  • 二叉樹搜索查找(3, 4.5),獲得搜索路經 <(7,2) - (5,4) - (4,7)>
  • 首先以(4,7)做爲當前最近鄰點,計算其到查詢點(2.1,3.1)的距離爲2.69;
  • 而後回溯到其父節點(5,4),並判斷在該父節點的其餘子節點空間中是否有距離查詢點更近的數據點。以(3, 4.5)爲圓心,以2.69爲半徑畫圓,以下圖所示。發現該圓和超平面y = 4交割,所以計算(5,4)與(3, 4.5)的距離爲2.06,小於2.69,更新最近鄰爲(5,4),更新距離爲2.06,畫一個綠色的圓;
  • 由於畫的圓進入到了(5,4)分割的另外一部分區域,因此須要在這個區域查找。發現(2,3)結點與目標點距離爲1.8,比(5,4)更近,更新最近鄰爲(2,3),以(3, 4.5)爲圓心畫一個藍色的圓。
  • 再回溯到(7,2),藍色的圓與x = 7超平面不相交,所以不用進入(7,2)右子空間進行查找。至此,搜索路徑中的節點已經所有回溯完,結束整個搜索,返回最近鄰點(2,3),最近距離爲1.8。

這是找到最近鄰的過程,只找到了最近的一個點,那麼怎麼找 k 近鄰個點呢?

(2)使用KD樹獲得 k 近鄰

李航老師在《統計學習方法》中只提了一句,按上面的思路就能找到 k 近鄰,咱們就改造一下上面的思路:上面是找1個,如今要找k個,因此咱們能夠維護一個長度爲k的有序數據集合,在搜索過程當中發現更小的距離,就替換掉集合裏的最大值,這樣搜索結束後就獲得了 k 近鄰,有序數據集合咱們用優先隊列來實現彷佛很是的合理,ok,下面來試試看:

class NodeDis(object):
	'''
	自定義將節點與其和目標距離綁定的類,並自定義根據距離比較對象的大小,由於要從大的開始pop,因此self.distance > other.distance
	'''

	def __init__(self, node, dis):
		self.node = node
		self.distance = dis

	def __lt__(self, other):
		return self.distance > other.distance


def search_k_neighbour(kdtree, target, k):
	k_queue = PriorityQueue()
	return search_path(kdtree, target, k, k_queue)


def search_path(kdtree, target, k, k_queue):
	'''
	遞歸找到整個樹中的k近鄰
	'''
	if kdtree is None:
		return NodeDis([], np.inf)
	path = []
	while kdtree:
		if target[kdtree.split] <= kdtree.node_data[kdtree.split]:
			path.append((kdtree.node_data, kdtree.split, kdtree.right))
			kdtree = kdtree.left
		else:
			path.append((kdtree.node_data, kdtree.split, kdtree.left))
			kdtree = kdtree.right
	path.reverse()
	radius = np.inf
	for i in path:
		node_data = i[0]
		split = i[1]
		opposite_tree = i[2]
		# 先判斷圈定的區域與分割軸是否相交
		distance_axis = abs(node_data[split] - target[split])
		if distance_axis > radius:
			break
		else:
			distance = cal_Euclidean_dis(node_data, target)
			k_queue.put(NodeDis(node_data, distance))
			if k_queue.qsize() > k:
				k_queue.get()
				radius = k_queue.queue[-1].distance
			# print(radius,[i.distance for i in k_queue.queue])
			search_path(opposite_tree, target, k, k_queue)
	return k_queue


def cal_Euclidean_dis(point1, point2):
	return np.sqrt(np.sum((np.array(point1) - np.array(point2)) ** 2))

對例子中的數據進行測試,輸出(3, 4.5)的3個近鄰點結果以下圖:

KD樹搜索k近鄰結果

2.3 基於暴力搜索與KD樹的KNN分類

根據暴力搜索和上一節中使用KD樹獲得k近鄰的方法,咱們來實現一個KNN分類器:

KNN的python實現

import numpy as np
from collections import Counter
import kd_tree  # 上一節實現的KD樹

class KNNClassfier(object):

	def __init__(self, k, distance='Euclidean',kdtree=True):
		'''
		初始化肯定距離度量方式和k、是否使用KD樹
		'''
		self.distance = distance
		self.k = k
		self.kdtree=kdtree

	def get_k_neighb(self, new_point, train_data, labels):
		'''
		暴力搜索獲得k近鄰的k個label
		'''
		distance_lst = [(self.cal_Euclidean_dis(new_point, point), label) for point, label in zip(train_data, labels)]
		distance_lst.sort(key=lambda x: x[0], reverse=False)
		k_labels = [i[1] for i in distance_lst[:self.k]]
		return k_labels

	def fit(self, train_data):
		'''
		使用KD樹的話,能夠事先將樹訓練好,好像有了點學習的過程同樣,要提升效率的話能夠將構建好的樹保存起來
		'''
		kdtree = kd_tree.KDTree(train_data)
		return kdtree

	def get_k_neighb_kdtree(self, new_point, train_data, labels, kdtree):
		'''
		經過KD樹搜索獲得k近鄰的k個label
		'''
		result = kd_tree.search_k_neighbour(kdtree.tree, new_point, self.k)
		data_dict={data:label for data,label in zip(train_data, labels)}
		k_labels=[data_dict[data.node] for data in result.queue]
		return k_labels

	def predict(self, new_point, train_data, labels,kdtree):
		if self.kdtree:
			k_labels = self.get_k_neighb_kdtree(new_point, train_data, labels,kdtree)
		else:
			k_labels = self.get_k_neighb(new_point, train_data, labels)
		# print(k_labels)
		return self.decision_rule(k_labels)

	def decision_rule(self, k_labels):
		'''
		分類決策規則:投票
		'''
		label_count = Counter(k_labels)
		new_label = None
		max = 0
		for label, count in label_count.items():
			if count > max:
				new_label = label
				max = count
		return new_label

	def cal_Euclidean_dis(self, point1, point2):
		return np.sqrt(np.sum((np.array(point1) - np.array(point2)) ** 2))

分類結果以下,兩種方式實現的KNN的結果是同樣的(圖中的兩個正方形點爲待分類點):

暴力搜索和KD樹實現的KNN測試結果

相關文章
相關標籤/搜索