K-近鄰演算法介紹與程式碼實現

HeoLis發表於2019-07-05

宣告:如需轉載請先聯絡我。

最近學習了k近鄰演算法,在這裡進行了總結。

KNN介紹

k近鄰法(k-nearest neighbors)是由Cover和Hart於1968年提出的,它是懶惰學習(lazy learning)的著名代表。
它的工作機制比較簡單:

  • 給定一個測試樣本
  • 計算它到訓練樣本的距離
  • 取離測試樣本最近的k個訓練樣本
  • “投票法”選出在這k個樣本中出現最多的類別,就是預測的結果

距離衡量的標準有很多,常見的有:$L_p$距離、切比雪夫距離、馬氏距離、巴氏距離、餘弦值等。

什麼意思呢?先來看這張圖

2個類

我們對應上面的流程來說

  • 1.給定了紅色和藍色的訓練樣本,綠色為測試樣本
  • 2.計算綠色點到其他點的距離
  • 3.選取離綠點最近的k個點
  • 4.選取k個點中,同種顏色最多的類。例如:k=1時,k個點全是藍色,那預測結果就是Class 1;k=3時,k個點中兩個紅色一個藍色,那預測結果就是Class 2

舉例

這裡用歐氏距離作為距離的衡量標準,用鳶尾花資料集舉例說明。
鳶尾花資料集有三個類別,每個類有150個樣本,每個樣本有4個特徵。
先來回顧一下歐氏距離的定義(摘自維基百科):

在歐幾里得空間中,點 x = (x1,...,xn) 和 y = (y1,...,yn) 之間的歐氏距離為

$d(x,y):={\sqrt {(x_{1}-y_{1})^{2}+(x_{2}-y_{2})^{2}+\cdots +(x_{n}-y_{n})^{2}}}={\sqrt {\sum {{i=1}}^{n}(x{i}-y_{i})^{2}}}$

向量 ${\displaystyle {\vec {x}}}$的自然長度,即該點到原點的距離為

$|{\vec {x}}|{2}={\sqrt {|x{1}|^{2}+\cdots +|x_{n}|^{2}}}$

它是一個純數值。在歐幾里得度量下,兩點之間線段最短。
現在給出六個訓練樣本,分為三類,每個樣本有4個特徵,編號為7的名稱是我們要預測的。
|編號|花萼長度(cm)|花萼寬度(cm)|花瓣長度(cm)|花瓣寬度(cm)|名稱|
|----|----------|-----------|-----------|----------|----|
|1|4.9|3.1|1.5|0.1|Iris setosa|
|2|5.4|3.7|1.5|0.2|Iris setosa|
|3|5.2|2.7|3.9|1.4|Iris versicolor|
|4|5.0|2.0|3.5|1.0|Iris versicolor|
|5|6.3|2.7|4.9|1.8|Iris virginica|
|6|6.7|3.3|5.7|2.1|Iris virginica|
|7|5.5|2.5|4.0|1.3| ? |

按照之前說的步驟,我們來計算測試樣本到各個訓練樣本的距離。例如到第一個樣本的距離:
$d_{1}=\sqrt{(4.9 - 5.5)^2 + (3.1 - 2.5)^2 + (1.5 - 4.0)^2 + (0.1 - 1.3)^2} = 2.9$

寫一個函式來執行這個操作吧

import numpy as np
def calc_distance(iA,iB):
   temp = np.subtract(iA, iB)      # 對應元素相減
   temp = np.power(temp, 2)        # 元素分別平方
   distance = np.sqrt(temp.sum())  # 先求和再開方
   return distance

testSample = np.array([5.5, 2.5, 4.0, 1.3])
print("Distance to 1:", calc_distance(np.array([4.9, 3.1, 1.5, 0.1]), testSample))
print("Distance to 2:", calc_distance(np.array([5.4, 3.7, 1.5, 0.2]), testSample))
print("Distance to 3:", calc_distance(np.array([5.2, 2.7, 3.9, 1.4]), testSample))
print("Distance to 4:", calc_distance(np.array([5.0, 2.0, 3.5, 1.0]), testSample))
print("Distance to 5:", calc_distance(np.array([6.3, 2.7, 4.9, 1.8]), testSample))
print("Distance to 6:", calc_distance(np.array([6.7, 3.3, 5.7, 2.1]), testSample))

Distance to 1: 2.9

Distance to 2: 2.98496231132

Distance to 3: 0.387298334621

Distance to 4: 0.916515138991

Distance to 5: 1.31909059583

Distance to 6: 2.36854385647

如果我們把k定為3,那麼離測試樣本最近3個依次是:

編號 名稱
3 Iris versicolor
4 Iris versicolor
5 Iris virginica

顯然測試樣本屬於Iris versicolor類的“票數”多一點,事實上它的確屬於這個類。

優/缺點

這裡參考了CSDN蘆金宇部落格上的總結

優點

  • 簡單好用,容易理解,精度高,理論成熟,既可以用來做分類也可以用來做迴歸;
  • 可用於數值型資料和離散型資料;
  • 訓練時間複雜度為O(n);無資料輸入假定;
  • 對異常值不敏感。

缺點

  • 計算複雜性高;空間複雜性高;
  • 樣本不平衡問題(即有些類別的樣本數量很多,而其它樣本的數量很少);
  • 一般數值很大的時候不用這個,計算量太大。但是單個樣本又不能太少,否則容易發生誤分。
  • 最大的缺點是無法給出資料的內在含義。

補充一點:由於它屬於懶惰學習,因此需要大量的空間來儲存訓練例項,在預測時它還需要與已知所有例項進行比較,增大了計算量。

這裡介紹一下,當樣本不平衡時的影響。

不平衡

從直觀上可以看出X應該屬於$\omega_{1}$,這是理所應當的。對於Y看起來應該屬於$\omega_{1}$,但事實上在k範圍內,更多的點屬於$\omega_{2}$,這就造成了錯誤分類。

一個結論

在周志華編著的《機器學習》中證明了最近鄰學習器的泛化錯誤率不超過貝葉斯最優分類器的錯誤率的兩倍,在原書的226頁,這裡就不摘抄了。

程式碼實現

知道KNN的原理後,應該可以很輕易的寫出程式碼了,這裡介紹一下在距離計算上的優化,在結尾給上完整程式碼(程式碼比較亂,知道思想就好)。

函式的輸入:train_Xtest_X是numpy array,假設它們的shape分別為(n, 4)、(m, 4);要求輸出的是它們兩點間的距離矩陣,shape為(n, m)。

不就是計算兩點之間的距離,再存起來嗎,直接暴力上啊︿( ̄︶ ̄)︿,於是就有了下面的

雙迴圈暴力實現


def euclideanDistance_two_loops(train_X, test_X):
   num_test = test_X.shape[0]
   num_train = train_X.shape[0]
   dists = np.zeros((num_test, num_train))
   for i in range(num_test):
       for j in range(num_train):
           test_line = test_X[i]
           train_line = train_X[j]
           temp = np.subtract(test_line, train_line)
           temp = np.power(temp, 2)
           dists[i][j] = np.sqrt(temp.sum())
   return dists

不知道你有沒有想過,這裡的樣本數只有100多個,所以時間上感覺沒有等太久,但是當樣本量非常大的時候呢,雙迴圈計算的弊端就顯露出來了。解決方案是把它轉換為兩個矩陣之間的運算,這樣就能避免使用迴圈了。

轉化為矩陣運算實現

L2 Distance

此處參考CSDNfrankzd的部落格

記測試集矩陣P的大小為$MD$,訓練集矩陣C的大小為$ND$(測試集中共有M個點,每個點為D維特徵向量。訓練集中共有N個點,每個點為D維特徵向量)


記$P_{i}$是P的第i行$P_i = [ P_{i1}\quad P_{i2} \cdots P_{iD}]$,記$C_{j}$是C的第j行$C_j = [ C_{j1} C_{j2} \cdots \quad C_{jD}]$

  • 首先計算Pi和Cj之間的距離dist(i,j)


    image


  • 我們可以推廣到距離矩陣的第i行的計算公式


    image


  • 繼續將公式推廣為整個距離矩陣(也就是完全平方公式)


    image

知道距離矩陣怎麼算出來的之後,在程式碼上只需要套公式帶入就能實現了。

def euclideanDistance_no_loops(train_X, test_X):
   num_test = test_X.shape[0]
   num_train = train_X.shape[0]
   sum_train = np.power(train_X, 2)
   sum_train = sum_train.sum(axis=1)
   sum_train = sum_train * np.ones((num_test, num_train))
   sum_test = np.power(test_X, 2)
   sum_test = sum_test.sum(axis=1)
   sum_test = sum_test * np.ones((1, sum_train.shape[0]))
   sum_test = sum_test.T
   sum = sum_train + sum_test - 2 * np.dot(test_X, train_X.T)
   dists = np.sqrt(sum)
   return dists

是不是很簡單,這裡兩種方法我們衡量兩點間距離的標準是歐氏距離。如果想用其他的標準呢,比如$L_{1}$距離該怎麼實現呢,這裡我參照上面推導公式的思想得出了計算$L_{1}$距離的矩陣運算。

L1 Distance

記測試集矩陣P的大小為$MD$,訓練集矩陣C的大小為$ND$(測試集中共有M個點,每個點為D維特徵向量。訓練集中共有N個點,每個點為D維特徵向量)


記$P_{i}$是P的第i行$P_i = [ P_{i1}\quad P_{i2} \cdots P_{iD}]$,記$C_{j}$是C的第j行$C_j = [ C_{j1} C_{j2} \cdots \quad C_{jD}]$

  • 首先計算Pi和Cj之間的距離dist(i,j)


    $d(P_{i}, C_{j}) = |P_{i1} - C_{j1}| + |P_{i2} - C_{j2}| + \cdots + |P_{iD} - C_{jD}|$

  • 我們可以推廣到距離矩陣的第i行的計算公式


    $dist[i] = \left | [P_{i}\quad P_{i} \cdots P_{i}] - [C_{1}\quad C_{2}\cdots C_{N}] \right|=[|P_{i} - C_{1}|\quad |P_{i} - C_{2}| \cdots |P_{i} - C_{N}|]$

  • 繼續將公式推廣為整個距離矩陣


    image

    其中$P_i = [ P_{i1}\quad P_{i2} \cdots P_{iD}]$、$C_j = [ C_{j1} C_{j2} \cdots \quad C_{jD}]$

def l1_distance_no_loops(train_X, test_X):
   num_test = test_X.shape[0]
   num_train = train_X.shape[0]
   test = np.tile(test_X, (num_train, 1, 1))
   train = np.tile(train_X, (num_test, 1, 1))
   train = np.transpose(train, axes=(1, 0, 2))
   sum = np.subtract(test, train)
   sum = np.abs(sum)
   sum = np.sum(sum, axis=2)
   dists = sum.T
   return dists

由於測試集樣本數量有限,兩種距離衡量標準下的準確率分別是0.94和0.98。

完整程式碼

近期文章

wechat.jpg

相關文章