《統計學習方法》——k近鄰法

引言

k近鄰法時一種分類與迴歸方法。本文只探討分類問題中的k近鄰法。k近鄰法實際上利用訓練數據集對特徵向量空間進行劃分,不具有顯示的學習過程。

k近鄰算法

給定一個訓練集TT,對於新的實例,在訓練集中找到與該實例最鄰近的k個實例,這k個實例的多數(多數表決)屬於某個類,則該新實例就分爲這個類。

  1. 根據給定的距離度量,在訓練集TT中找出與xx最鄰近的kk個點,涵蓋這個kk個點的xx的鄰域記作Nk(x)N_k(x)
  2. Nk(x)N_k(x)中根據分類決策規則(如多數表決)決定xx的類別yy:
    y=argmaxcjxiNk(x)I(yi=cj),i=1,2,,N;j=1,2,,K y = \arg\,\max_{c_j}\sum_{x_i \in N_k(x)} I(y_i=c_j),i =1,2,\cdots,N;j=1,2,\cdots,K

k近鄰算法的特殊情況是k=1k=1的情形,稱爲最近鄰算法。

k近鄰模型

k近鄰法使用的模型實際上對應於特徵空間的劃分。模型由三個基本要素——距離度量、k值選擇和分類決策規則決定。

模型

當三個基本要素確定後,對於任一新的實例,它所屬的類也唯一確定。這相當於根據上述要素將特徵空間劃分爲一些子空間,確定子空間裏的每個點所屬的類。

特徵空間中,對每個訓練實例點xix_i,該點附近的所有點組成一個區域,叫做單元(cell)。每個訓練實例點擁有一個單元,所有訓練實例點的單元構成對特徵空間的一個劃分。 最近鄰法將實例xix_i的類yiy_i作爲其單元中所有點的類標記。

下圖是二維特徵空間劃分的一個例子。

在這裏插入圖片描述

距離度量

特徵空間中兩個實例點的距離是兩個實例點相似程度的反映。一般使用歐氏距離,也可以使用其他距離,如LpL_p距離或Minkowski距離。

設特徵空間X\mathcal{X}nn維實數向量空間RnR^nxi,xjXx_i,x_j \in \mathcal{X}
xi=(xi(1),xi(2),,xi(n))Tx_i = (x^{(1)}_i,x^{(2)}_i,\cdots , x^{(n)}_i)^T
xj=(xj(1),xj(2),,xj(n))Tx_j = (x^{(1)}_j,x^{(2)}_j,\cdots , x^{(n)}_j)^T,xi,xjx_i,x_jLpL_p距離定義爲

Lp(xi,xj)=(l=1nxi(l)xj(l)p)1p L_p(x_i,x_j) = \left(\sum_{l=1}^n |x_i^{(l)} - x_j^{(l)} | ^p\right)^{\frac{1}{p}}

p1p \geq 1,當p=2p=2時,稱爲歐氏距離(Euclidean distance)

L2(xi,xj)=(l=1nxi(l)xj(l)2)12 L_2(x_i,x_j) = \left(\sum_{l=1}^n |x_i^{(l)} - x_j^{(l)} | ^2 \right)^{\frac{1}{2}}

p=1p=1時,稱爲曼哈頓距離(Manhattan distance)

L1(xi,xj)=l=1nxi(l)xj(l) L_1(x_i,x_j) = \sum_{l=1}^n |x_i^{(l)} - x_j^{(l)} |

p=p = \infty 時,它是各個座標距離的最大值,即
L(xi,xj)=maxlxi(l)xj(l) L_\infty(x_i,x_j) = \max_l |x_i^{(l)} - x_j^{(l)} |

# 同時計算單個樣本x和X中所有樣本的距離
def L(x, X, p=2):
    if len(X.shape) == 1:
        X = X.reshape(-1,X.shape[0])
    sum = np.sum(np.power(np.abs(x - X),p),axis=1) #先求和
    return np.power(sum,1/p) #再開方

k值的選擇

k值的選擇會對k近鄰法的結果產生重大影響。

如果選擇較小的k值,預測結果會對近鄰的實例點非常敏感。如果鄰近的實例點恰好是噪聲,預測就會出錯。k值的減小意味着整體模型變得複雜,容易發生過擬合。

如果選擇教大的k值,與輸入實例較遠的不相似的訓練實例也會對預測起作用,使預測發生錯誤。k值增大就意味着整體的模型變得簡單,容易發生欠擬合。

在應用中,k值一般取一個比較小的數值。通常採用交叉驗證法來選取最優的k 值。

分類決策規則

通常是多數表決,由輸入實例的k個近鄰的訓練實例中的多數類決定輸入實例的類。

如果分類的損失函數爲0-1損失函數,分類函數爲:

f:Rn{c1,c2,,cK} f:R^n \rightarrow \{c_1,c_2,\cdots,c_K\}

那麼誤分類的概率爲:P(Yf(X))=1P(Y=f(X))P(Y\neq f(X)) = 1 - P(Y = f(X)) (1-分類正確的概率)

對於給定的實例xx,其最近鄰的kk個訓練實例點的集合爲Nk(x)N_k(x)。如果涵蓋Nk(x)N_k(x)的區域的類別是cjc_j,那麼誤分類率是

1kxiNk(x)I(yicj)=11kxiNk(x)I(yi=cj) \frac{1}{k}\sum_{x_i \in N_k(x)} I(y_i \neq c_j) = 1 - \frac{1}{k} \sum_{x_i \in N_k(x)} I(y_i = c_j)

I()I(\cdot)是指數函數,要使誤分類率最小即經驗風險最小,就要使得xiNk(x)I(yi=cj)\sum_{x_i \in N_k(x)} I(y_i = c_j)最大,所以多數表決規則等價於經驗風險最小化。

knn代碼實現

import numpy as np
from collections import Counter

# 同時計算單個樣本x和X中所有樣本的距離
def L(x, X, p=2):
    if len(X.shape) == 1:
        X = X.reshape(-1,X.shape[0])
    sum = np.sum(np.power(np.abs(x - X),p),axis=1) #先求和
    return np.power(sum,1/p) #再開方

class KNN:
    def __init__(self,n_neighbors=3, p=2):
        """
        parameter: n_neighbors 臨近點個數
        parameter: p 距離度量
        """
        self.k = n_neighbors
        self.p = p
        self.X_train = None
        self.y_train = None
    
    def fit(self,X_train, y_train):
        self.X_train = X_train
        self.y_train = y_train
        
    def predict(self,X):
        return np.array([self._predict(x) for x in X])
        
    def _predict(self,x):
        # 找到距離最近的k個點
        top_k_indexes = np.argsort(L(x,self.X_train,self.p))[:self.k] #得到最近k個點的索引
        top_k = self.y_train[top_k_indexes]
        return Counter(top_k).most_common(1)[0][0]
    
    def score(self,X_test,y_test):
        y_predict = self.predict(X_test)
        return np.sum(y_predict == y_test) / len(X_test)
        
        

下面用iris數據集進測試:

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split

from collections import Counter

# data
iris = load_iris()
df = pd.DataFrame(iris.data, columns=iris.feature_names)
df['label'] = iris.target
df.columns = ['sepal length', 'sepal width', 'petal length', 'petal width', 'label']
# data = np.array(df.iloc[:100, [0, 1, -1]])

data = np.array(df.iloc[:100, [0, 1, -1]])
X, y = data[:,:-1], data[:,-1]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)

knn = KNN()
knn.fit(X_train,y_train)
knn.score(X_test,y_test)

在這裏插入圖片描述
準確率很高,因爲每次都需要比較目標與所有訓練樣本點的距離,但是如果數據量很大的時候會非常耗時。

kd樹

實現k近鄰算法時,當特徵空間的維數很大或者數據量很大時,主要考慮的問題是如何快速地進行k近鄰搜索。

上面實現k近鄰的方法需要計算輸入實例與每個訓練樣本的距離,當訓練集很大時,非常耗時。

爲了提高k近鄰搜索效率,可以考慮使用特殊的結構存儲訓練數據,比如kd樹。

構造kd樹

kd樹是二叉樹,表示對k維空間的一個劃分。構造kd樹相當於不斷地用垂直於座標軸的超平面將k維空間切分,構成一系列的k維超矩形區域。

kd 樹是每個節點均爲k維數值點的二叉樹,其上的每個節點代表一個超平面,該超平面垂直於當前劃分維度的座標軸,並在該維度上將空間劃分爲兩部分,一部分在其左子樹,另一部分在其右子樹。即若當前節點的劃分維度爲d,其左子樹上所有點在d維的座標值均小於當前值,右子樹上所有點在d維的座標值均大於等於當前值,本定義對其任意子節點均成立。

以書上的例子爲例,假設有6個二維數據點{(2,3),(5,4),(9,6),(4,7),(8,1),(7,2)}\{(2,3),(5,4),(9,6),(4,7),(8,1),(7,2)\},數據點位於二維空間內,如下圖黑點所示:

在這裏插入圖片描述

k-d樹算法就是要確定圖中這些分割空間的分割線(多維空間即爲分割平面,一般爲超平面)。下面就要通過一步步展示k-d樹是如何確定這些分割線的。

由於數據維度只有2維,所以可以簡單地給x,y兩個方向軸編號爲0,1,也即split={0,1}。

  1. 首先split取0,也就是x軸方向;

  2. 確定分割點的值。根據x軸方向對數據進行排序得到{(2,3),(4,7),(5,4),(7,2),(8,1),(9,6)}\{(2, 3),(4, 7),(5, 4),(7, 2),(8, 1),(9, 6)\}(此時只看第0維),從中選出中位數爲77,得到分割節點的值爲(7,2)(7,2)。該節點的分割超平面就是通過(7,2)(7,2)並垂直於split = 0(x軸)的直線x=7x = 7
    在這裏插入圖片描述

  3. 確定左子空間和右子空間。這樣分割超平面x=7x = 7將整個空間分爲兩部分,如上圖所示。x<=7x < = 7的部分爲左子空間,包含3個節點{(2,3),(5,4),(4,7)}\{(2,3),(5,4),(4,7)\};另一部分爲右子空間,包含2個節點{(9,6),(8,1)}\{(9,6),(8,1)\}

  4. 如算法所述,k-d樹的構建是一個遞歸的過程。然後對左子空間和右子空間內的數據重複根節點的過程,但此時split取1,也就是y軸方向, 就可以得到下一級子節點(5,4)(5,4)(9,6)(9,6),同時將空間和數據集進一步細分。如此反覆直到空間中只包含一個數據點,最後生成的k-d樹如下圖所示:

在這裏插入圖片描述

構造kd樹代碼

# KD-Tree的節點
class Node:
	def __init__(self, data=None, split=0, left=None, right=None):
		self.data = data
		self.left = left
		self.right = right
		self.split = split  # 當前節點的劃分維度

	def __repr__(self):
		return "data:%s (split:%d)" % (self.data, self.split)


class KdTree:
	def __init__(self, data):
		k = data.shape[1]  # 數據的維度

		# 同時保存數據的值和索引信息
		data_with_indexes = list(zip(data, range(data.shape[0])))

		def create_node(data_set, split):
			if len(data_set) == 0:
				return None
			# 根據劃分維度進行排序,
			data_set_sorted = sorted(data_set, key=lambda x: x[0][split])

			# 傳入排序後的傳入就是排序後的數組
			mid = len(data_set_sorted) // 2  # 找到中位數
			mid_data = data_set_sorted[mid]  # 找到中位數對應的元素

			# print('mid_data: %s for axis = %d' %(mid_data,split))
			next_split = (split + 1) % k  # 下個維度

			# 左子樹的所有點在split維的值都小於當前mid_data值
			# 右子樹都大於mid_data值
			return Node(mid_data, split,
			            create_node(data_set_sorted[:mid], split=next_split),
			            create_node(data_set_sorted[mid + 1:], split=next_split),
			            )

		self.root = create_node(data_with_indexes, 0)

	def preorder(self, node):
		if node:
			print(node.data)
		if node.left:
			self.preorder(node.left)
		if node.right:
			self.preorder(node.right)

接下來跑下上面的例子:

data_set = np.array([[2, 3], [5, 4], [9, 6], [4, 7], [8, 1], [7, 2]])
tree = KdTree(data_set)
tree.preorder(tree.root)

在這裏插入圖片描述
用先序遍歷打印出結果如上,這裏維護了數據的索引信息,方便後面通過這些索引找到標籤進行投票。

搜索kd樹

  1. 在kd樹中找出包含目標點x的葉節點:從根節點出發,遞歸地向下訪問kd樹。若目標點x當前維的座標小於切分點的座標,則移動到左子節點,否則移動到右子節點。直到子節點爲葉節點爲止。
  2. 以此葉節點爲當前最近點
  3. 遞歸地向上回退,在每個節點進行一下操作:
    • 如果該節點保存的實例點比當前最近點距離目標點更近,則以該實例點爲當前最近點
    • 當前最近點一定存在於該節點一個子節點對應的區域。檢查該子節點的父節點的另一個子節點對應的區域是否有更近的點。具體地,檢查另一個子節點頂的區域是否以目標點爲球心(圓心)、以目標點與當前最近點間的距離爲半徑的超球體(圓)相交。如果相交,可能在另一個子節點對應的區域內存在距目標點更近的點,移動到另一個子節點。接着,遞歸地進行最近鄰搜索;如果不想交,向上回退。
  4. 回退到根節點,搜索結束,最後的當前最近點即爲x的最鄰近點

上面的描述看不懂的話沒關係,我們通過簡單的例子來掌握它的思想。

以一個簡單的實例來描述最鄰近查找的基本思路:

星號表示要查詢的點(2.1,3.1)(2.1,3.1)。通過二叉搜索,順着搜索路徑很快就能找到最鄰近的近似點,也就是葉子節點(2,3(2,3

而找到的葉子節點並不一定就是最鄰近的,最鄰近肯定距離查詢點更近,應該位於以查詢點爲圓心且通過葉子節點的圓域內。

在這裏插入圖片描述

爲了找到真正的最近鄰,還需要進行’回溯’操作:算法沿搜索路徑反向查找是否有距離查詢點更近的數據點。

此例中先從(7,2)(7,2)點開始進行二叉查找,然後到達(5,4)(5,4),最後到達(2,3)(2,3),此時搜索路徑中的節點爲(7,2),(5,4),(2,3)(7,2),(5,4),(2,3)
首先以(2,3)(2,3)作爲當前最近鄰點,計算其到查詢點(2.1,3.1)(2.1,3.1)的距離爲0.1414,然後回溯到其父節點(5,4)(5,4),並判斷在該父節點的其他子節點空間中是否有距離查詢點更近的數據點。以(2.1,3.1)(2.1,3.1)爲圓心,以0.1414爲半徑畫圓,如下圖所示。發現該圓並不和超平面y=4y = 4交割,因此不用進入(5,4)(5,4)節點右子空間中去搜索。
在這裏插入圖片描述
再回溯到(7,2)(7,2),以(2.1,3.1)(2.1,3.1)爲圓心,以0.1414爲半徑的圓不會與x=7x = 7超平面交割,因此不用進入(7,2)(7,2)右子空間進行查找。至此,搜索路徑中的節點已經全部回溯完,結束整個搜索,返回最近鄰點(2,3)(2,3),最近距離爲0.1414。

下面再來一個複雜點的例子。例子如查找點爲(2,4.5)(2,4.5)。同樣先進行二叉查找,先從(7,2)(7,2)查找到(5,4)(5,4)節點,在進行查找時是由y=4y = 4爲分割超平面的,由於查找點爲y值爲4.5,因此進入右子空間查找到(4,7)(4,7),形成搜索路徑(7,2)(5,4)(4,7)(7,2),(5,4),(4,7)
(4,7)(4,7)爲當前最近鄰點,計算其與目標查找點的距離爲3.202。然後回溯到(5,4)(5,4),計算其與查找點之間的距離爲3.041。以(2,4.5)(2,4.5)爲圓心,以3.041爲半徑作圓,如圖所示。

在這裏插入圖片描述

可見該圓和y=4y = 4超平面交割,所以需要進入(5,4)(5,4)左子空間進行查找。此時需將(2,3)(2,3)節點加入搜索路徑中得(7,2),(2,3)(7,2),(2,3)。回溯至(2,3)(2,3)葉子節點,(2,3)(2,3)距離(2,4.5)(2,4.5)(5,4)(5,4)要近,所以最近鄰點更新爲(2,3)(2,3),最近距離更新爲1.5。

回溯至(7,2)(7,2),以(2,4.5)(2,4.5)爲圓心1.5爲半徑作圓,並不和x=7x = 7分割超平面交割,如圖所示。
在這裏插入圖片描述
至此,搜索路徑回溯完。返回最近鄰點(2,3)(2,3),最近距離1.5。

搜索kd樹代碼

import numpy as np
import heapq  # 優先隊列

np.set_printoptions(suppress=True)#防止科學技術法輸出,輸出帶e的數字


# KD-Tree的節點
class Node:
    def __init__(self, data=None, split=0, left=None, right=None):
        self.data = data
        self.left = left
        self.right = right
        self.split = split  # 當前節點的劃分維度

    def __repr__(self):
        return "data:%s (split:%d)" % (self.data, self.split)


class KdTree:
    def __init__(self, data):
        k = data.shape[1]  # 數據的維度

        # 同時保存數據的值和索引信息
        data_with_indexes = list(zip(data, range(data.shape[0])))

        def create_node(data_set, split):
            if len(data_set) == 0:
                return None
            # 根據劃分維度進行排序,
            data_set_sorted = sorted(data_set, key=lambda x: x[0][split])

            # 傳入排序後的傳入就是排序後的數組
            mid = len(data_set_sorted) // 2  # 找到中位數
            mid_data = data_set_sorted[mid]  # 找到中位數對應的元素

            # print('mid_data: %s for axis = %d' %(mid_data,split))
            next_split = (split + 1) % k  # 下個維度

            # 左子樹的所有點在split維的值都小於當前mid_data值
            # 右子樹都大於mid_data值
            return Node(mid_data, split,
                        create_node(data_set_sorted[:mid], split=next_split),
                        create_node(data_set_sorted[mid + 1:], split=next_split),
                        )

        self.root = create_node(data_with_indexes, 0)

    def preorder(self, node):
        if node:
            print(node.data)
        if node.left:
            self.preorder(node.left)
        if node.right:
            self.preorder(node.right)

    def query(self, x, k=3, p=2):
        # 因爲python裏面只有小頂堆,所以乘以-1變成大頂堆(最大的值乘以-1就變成了最小的)
        # 先初始化這個小頂堆,全部初始化爲負無窮大
        # [(距離的負數,索引)]
        nearest = [(-np.inf, -1)] * k
        self.times = 0

        def search(node):
            if node:
                # 如果目標點x當前維的座標小於切分點的座標
                split = node.split  # 進行分割的維度
                dis = x[split] - node.data[0][split]  # 同時也是超平面距離

                self.times = self.times + 1

                # 遞歸地訪問子節點
                search(node.left if dis < 0 else node.right)
                # 如果該遞歸函數返回,我們得到當前最近點。 上面搜索過程中第2步

                # 計算目標點與當前點node的距離
                cur_dis = np.linalg.norm(x - node.data[0], p)

                # 如果當前距離 小於 nearest中最遠的距離,則替換最遠的距離
                if cur_dis < -nearest[0][0]:
                    heapq.heapreplace(nearest, (-cur_dis, node.data[1]))

    
                # 繼續查找另外一邊,如果另一邊可能存在比nearest最遠的距離要小的距離
                # 比較目標點和分離超平面的距離dis 和當前nearest最遠的距離 
                # 因爲我們要查詢最近的k個
                if -(nearest[0][0]) > abs(dis):
                    # 如果相交,可能在另一個子節點對應的區域內存在距目標點更近的點,移動到另一個子節點。
                    search(node.right if dis < 0 else node.left)

        search(self.root)
        top_k = [e for e in heapq.nlargest(k, nearest)]
        distances = [-e[0] for e in top_k]
        indexes = [e[1] for e in top_k]
        print('search times :%d' % self.times)
        return distances, indexes

這也是kd樹的完整代碼,這裏通過超平面距離判斷是否相交。

下面我們測試下上面的兩種情況。

在這裏插入圖片描述
首先是查詢距離點(2.1,3.1)(2.1,3.1)最近的點,得到最近的距離爲0.1414,最近點的索引爲0,我們就知道了最近點爲(2,3)(2,3)

在這裏插入圖片描述
查詢(2,4.5)(2,4.5)的結果也和上面分析的一致。

下面用更多的數據來測試一下吧:

def test0():
    np.random.seed(666)
    ndata = 1000000
    ndim = 2
    data = np.random.rand(ndata * ndim).reshape((-1, ndim))
    kdtree = KdTree(data)
    target = np.array([0.5, 0.2])
    print(kdtree.query(target))

運行測試:

import datetime
start = datetime.datetime.now()
test0()
end = datetime.datetime.now()
print(end - start)

輸出爲:

search times :54
([0.0005330975668433067, 0.000596530184194294, 0.0006526855405323105], [11345, 731231, 761132])
0:00:35.145470

從輸出可以看到,只搜索了54次就找到了最近鄰的3個節點。不過耗時35秒,其實這裏的耗時主要是在構建的時候,搜索的時候是很快的,不信可以試下。

下面我們用sklearn提供的KDTree來試一下同樣的數據,看我們實現的kd樹查詢結果是否正確。

def test1():
    from sklearn.neighbors import KDTree
    np.random.seed(666)
    ndata = 1000000
    ndim = 2
    data = np.random.rand(ndata * ndim).reshape((-1, ndim))
    tree = KDTree(data, metric='euclidean', leaf_size=2)
    target = np.array([[0.5, 0.2]])
    print(tree.query(target, k=3))

調用test1()進行測試:

import datetime

start = datetime.datetime.now()
test1()
end = datetime.datetime.now()
print(end - start)

輸出:

(array([[0.0005331 , 0.00059653, 0.00065269]]), array([[ 11345, 731231, 761132]], dtype=int64))
0:00:03.885332

對比二者的輸出結果,可以看到結果是一樣的。sklearn的KDTree還是高效很多啊。

有時間可以研究下如何優化構造代碼。

參考

  1. 統計學習方法
  2. https://www.cnblogs.com/eyeszjwang/articles/2429382.html
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章