KNN (K Nearest Neighbor) K 최근접 이웃 – 머신러닝 with python

사용할 모듈 포함문

from sklearn.neighbors import KNeighborsRegressor #K 최근접 이웃 회귀
from sklearn.neighbors import KNeighborsClassifier #K 최근접 이웃 분류
from sklearn.base import BaseEstimator #기반 모델

from sklearn.model_selection import train_test_split #학습 및 테스트 데이터 분리
from sklearn.preprocessing import MinMaxScaler #MIN-MAX 스케일 변환

from sklearn.metrics import r2_score #r2
from sklearn.metrics import accuracy_score #accuracy

from sklearn.datasets import load_iris #붓꽃 데이터
from sklearn.datasets import load_breast_cancer #유방암 데이터

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

KNN 회귀 모델 클래스 만들기

KNN 모델은 K개의 가까운 이웃을 찾아 이들의 특징으로 예측하는 모델입니다.

가까운 이웃은 자신과 비슷한 특성을 갖는다는 의미로 거리 계산을 통해 이웃과의 거리를 계산합니다.

먼저 KNN 회귀 모델 클래스를 만들어 본 후 사이킷 런의 KNeighborsRegressor 모델과 비교해 볼게요.

클래스 이름은 KNNReg라고 할고 BaseEstimator를 기반으로 파생 클래스로 정의합시다.

사이킷 런의 머신 러닝 모델의 기반 형식이 BaseEstimator입니다.

class KNNReg(BaseEstimator):
  def __init__(self,k=5):
    super().__init__()
    self.k = k

거리를 계산하는 메서드도 정의합시다. 해당 메서드는 개체의 멤버보다 클래스 멤버로 정의할게요.

class KNNReg(BaseEstimator):
  def __init__(self,k=5):
    super().__init__()
    self.k = k
  @classmethod
  def distance(cls,x1,x2):
    return np.sqrt(sum((x1-x2)**2))

KNN 모델은 fit 과정에서 하는 일은 학습 데이터를 복사하는 일 뿐입니다. 실제 이웃과의 거리 계산은 예측 과정에서 진행합니다.

이러한 특징 때문에 KNN 모델은 학습 비용은 적게 들지만 예측 비용이 많이 드는 모델입니다.

class KNNReg(BaseEstimator):
  def __init__(self,k=5):
    super().__init__()
    self.k = k
  @classmethod
  def distance(cls,x1,x2):
    return np.sqrt(sum((x1-x2)**2))
  def fit(self,train_x,train_y):
    self.train_x = train_x.copy()
    self.train_y = train_y.copy()

이제 K개의 이웃을 찾는 메서드와 예측하는 메서드를 정의합시다.

K개의 이웃을 찾는 메서드는 예측할 데이터를 학습 데이터와 거리를 계산한 후 정렬한 후 K개의 이웃 정보를 반환합니다.

예측하는 메서드는 이웃을 찾는 메서드를 호출한 후 평균 값을 반환합니다.

class KNNReg(BaseEstimator):
  def __init__(self,k=5):
    super().__init__()
    self.k = k
  @classmethod
  def distance(cls,x1,x2):
    return np.sqrt(sum((x1-x2)**2))
  def fit(self,train_x,train_y):
    self.train_x = train_x.copy()
    self.train_y = train_y.copy()
  def find_kneighbor(self,tx):
    dis_arr = []
    for i,x in enumerate(self.train_x):      
      dis_arr.append((KNNReg.distance(x,tx),i))
    dis_arr.sort(key = lambda x:x[0])
    kindex = min(self.k,len(dis_arr))
    kneighbor = [x[1] for x in dis_arr[:kindex]]
    return sum(self.train_y[kneighbor]) 
  def predict(self,test_x):
    result = []
    for tx in test_x:
      result.append(self.find_kneighbor(tx)/self.k)#이웃의 평균
    return result 
  • 붓꽃 데이터로 실험

KNNReg 모델과 사이킷 런에서 제공하는 KNeighborsRegressor 모델을 사용해 봅시다.

여기에서는 붓꽃 데이터를 로딩하여 비교하기로 할게요.

독립 변수는 붗꽃 중에 setosa 품종의 꽃받침 길이, 종속 변수는 꽃받침 너비로 합시다.

iris = load_iris()
data = iris['data'][:50,[0]] #독립 변수 - sepal length
target = iris['data'][:50,[1]] #종속 변수 - sepal width

x_train,x_test,y_train,y_test = train_test_split(data,target)

KNNReg 모델과 KNeighborsRegressor 모델을 생성하고 학습한 후 예측한 값으로 r2 점수를 비교합시다.

model1 = KNNReg()
model2 = KNeighborsRegressor()
for model in [model1,model2]:
  print(model.__class__.__name__,"###")
  model.fit(x_train,y_train)
  pred = model.predict(x_test)
  print("r2:",r2_score(y_test,pred))
[out]
KNNReg ###
r2: 0.418490272373541
KNeighborsRegressor ###
r2: 0.4184902723735412

두 개의 모델의 r2 점수는 같거나 매우 비슷합니다.

아무래도 같은 알고리즘을 사용하기 때문에 결과도 같거나 비슷하게 나옵니다.

KNN 분류 모델

KNN 회귀 모델에서는 K개의 이웃을 찾아 평균 값으로 예측하였습니다.

KNN 분류 모델에서는 K개의 이웃을 찾아 가장 많은 분포를 갖는 값으로 예측합니다.

KNNCls 이름으로 클래스를 만들어 봅시다.

KNNCls 클래스의 __init__ 메서드, fit, 이웃 찾기(find_kneighbor) 메서드는 KNNReg 클래스와 거의 같습니다.

(fit 메서드에서 종속 변수의 값의 종류를 기억하는 멤버를 추가하는 부분이 있습니다.)

class KNNCls(BaseEstimator):
  def __init__(self,k=5):
    super().__init__()
    self.k = k
  @classmethod
  def distance(cls,x1,x2):
    return np.sqrt(sum((x1-x2)**2))
  def fit(self,train_x,train_y):
    self.train_x = train_x.copy()
    self.train_y = train_y.copy()
    self.class_cnt = len(np.unique(train_y))
  def find_kneighbor(self,tx):
    dis_arr = []
    for i,x in enumerate(self.train_x):      
      dis_arr.append((KNNReg.distance(x,tx),i))
    dis_arr.sort(key = lambda x:x[0])
    kindex = min(self.k,len(dis_arr))
    kneighbor = [x[1] for x in dis_arr[:kindex]]
    return (self.train_y[kneighbor])

예측(predict) 메서드에서는 k개의 이웃을 찾은 후 가장 많은 분포를 갖는 값을 반환합니다.

find_kneighbor 메서드 호출로 k개의 이웃을 찾습니다.

그리고 np.unique 함수를 호출하여 어떤 값이 몇 개씩 있는지 파악합니다.

그리고 개수에 따라 정렬한 후 가장 많이 분포하는 값을 반환 컬렉션에 보관합니다.

class KNNCls(BaseEstimator):
  def __init__(self,k=5):
    super().__init__()
    self.k = k
  @classmethod
  def distance(cls,x1,x2):
    return np.sqrt(sum((x1-x2)**2))
  def fit(self,train_x,train_y):
    self.train_x = train_x.copy()
    self.train_y = train_y.copy()
    self.class_cnt = len(np.unique(train_y))
  def find_kneighbor(self,tx):
    dis_arr = []
    for i,x in enumerate(self.train_x):      
      dis_arr.append((KNNReg.distance(x,tx),i))
    dis_arr.sort(key = lambda x:x[0])
    kindex = min(self.k,len(dis_arr))
    kneighbor = [x[1] for x in dis_arr[:kindex]]
    return (self.train_y[kneighbor])
  def predict(self,test_x):
    tl = len(test_x)
    result = []
    for tx in test_x:
      re = self.find_kneighbor(tx)
      re,cnt = np.unique(re,return_counts=True)
      idx = np.argsort(cnt)
      result.append(re[idx[-1]])
    return np.array(result) 

분류 모델에서는 predict_proba도 제공합시다.

k 개의 이웃을 찾아 분포의 비율을 반환 컬렉션에 보관합니다.

class KNNCls(BaseEstimator):
  def __init__(self,k=5):
    super().__init__()
    self.k = k
  @classmethod
  def distance(cls,x1,x2):
    return np.sqrt(sum((x1-x2)**2))
  def fit(self,train_x,train_y):
    self.train_x = train_x.copy()
    self.train_y = train_y.copy()
    self.class_cnt = len(np.unique(train_y))
  def find_kneighbor(self,tx):
    dis_arr = []
    for i,x in enumerate(self.train_x):      
      dis_arr.append((KNNReg.distance(x,tx),i))
    dis_arr.sort(key = lambda x:x[0])
    kindex = min(self.k,len(dis_arr))
    kneighbor = [x[1] for x in dis_arr[:kindex]]
    return (self.train_y[kneighbor])
  def predict(self,test_x):
    tl = len(test_x)
    result = []
    for tx in test_x:
      re = self.find_kneighbor(tx)
      re,cnt = np.unique(re,return_counts=True)
      idx = np.argsort(cnt)
      result.append(re[idx[-1]])
    return np.array(result) 
  def predict_proba(self,test_x):
    result = []
    for tx in test_x:
      re = self.find_kneighbor(tx)
      re,cnt = np.unique(re,return_counts=True)
      trr =np.zeros(self.class_cnt)
      for i,r1 in enumerate(cnt):
        trr[re[i]] = (cnt[i]/self.k)
        print(cnt[i],self.k)
      result.append(trr)
    return np.array(result) 
  • 실습

이번에는 붓꽃 데이터의 data 부분을 독립 변수, target 부분을 종속 변수로 할게요.

data 부분은 꽃받침 길이, 너비, 꽃잎 길이, 너비입니다.

target 부분은 0은 setosa, 1은 versicolor, 2는 virginica 품종을 의미합니다.

data = iris['data']
target = iris['target']
x_train,x_test,y_train,y_test = train_test_split(data,target)

KNNCls 모델과 사이킷 런의 KNeighborClassifier 모델을 생성하고 fit 한 후에 예측합니다.

예측 결과와 실제 결과로 적합도를 계산하고 도면으로 도식합시다.

model1 = KNNCls()
model2 = KNeighborsClassifier()
for model in [model1,model2]:
  print(model.__class__.__name__,"###")
  model.fit(x_train,y_train)
  pred = model.predict(x_test)
  print("accuracy:",accuracy_score(y_test,pred))
  plt.plot(y_test,'ro',label='actual')
  plt.plot(pred,'b.',label='predict')
  plt.legend()
  plt.title(model.__class__.__name__)
  plt.show()
[out]
KNNCls ###
accuracy: 0.9473684210526315
KNNReg 예측 결과
KNNReg 예측 결과
[out]
KNeighborsClassifier ###
accuracy: 0.9473684210526315
KNeighborsClassifier 예측 결과
KNeighborsClassifier 예측 결과

KNNCls 모델과 KNeighborsClassifier 모델은 같은 알고리즘을 사용하기 때문에 결과는 같거나 비슷합니다.

다음은 KNNCls 모델로 predict_proba 메서드를 호출한 후 결과를 출력하는 코드입니다.

model = KNNCls()
model.fit(x_train,y_train)
pred_proba = model.predict_proba(x_test)
print(pred_proba[:10])
[out]
[[0.  1.  0. ]
 [1.  0.  0. ]
 [0.  1.  0. ]
 [0.  1.  0. ]
 [0.  1.  0. ]
 [0.  0.2 0.8]
 [0.  0.  1. ]
 [0.  0.4 0.6]
 [0.  1.  0. ]
 [1.  0.  0. ]]

KNN 모델 사용 시 주의할 점

KNN 모델은 K 개의 최근접 이웃을 찾아서 예측하는 모델입니다.

이웃을 찾을 때 거리를 계산한다고 앞에서 얘기를 했어요.

그런데 독립 변수의 특성들의 값의 폭이 너무 차이가 난다면 거리 계산에 특정 특성 값이 너무 큰 영향을 줍니다.

이러한 특징을 없애기 위해 모든 특성의 스케일을 조절하면 특성 값에 관계없이 거리 계산에 같은 영향을 줄 수 있습니다.

이를 확인하기 위해 유방암 데이터로 예측하는 것을 스케일 조절 전 후를 비교해 보기로 할게요.

먼저 유방암 데이터를 로딩하여 독립 변수와 종속 변수를 만듭니다.

bc = load_breast_cancer()
data = bc['data']
target = bc['target']
x_train,x_test,y_train,y_test = train_test_split(data,target)

독립 변수 부분을 Min-Max 스케일 변환을 적용합니다.

Min-Max 스케일 변환하면 0~1 사이의 값으로 변환합니다.

mms = MinMaxScaler()
scaled_data = mms.fit_transform(data) 
xs_train,xs_test,ys_train,ys_test = train_test_split(scaled_data,target)

다음은 스케일 변환 전에 전체 특성 중에서 5개의 특성의 min, mean,max 값입니다.

df1 = pd.DataFrame(data)
print(df1.describe().loc[['min','mean','max']].iloc[:,:5])
[out]
              0          1           2            3        4
min    6.981000   9.710000   43.790000   143.500000  0.05263
mean  14.127292  19.289649   91.969033   654.889104  0.09636
max   28.110000  39.280000  188.500000  2501.000000  0.16340

다음은 스케일 변환 후에 전체 특성 중에서 5개의 특성의 min, mean,max 값입니다.

df2 = pd.DataFrame(scaled_data)
print(df2.describe().loc[['min','mean','max']].iloc[:,:5])
[out]
             0         1         2        3         4
min   0.000000  0.000000  0.000000  0.00000  0.000000
mean  0.338222  0.323965  0.332935  0.21692  0.394785
max   1.000000  1.000000  1.000000  1.00000  1.000000

결과를 보면 스케일 변환 전에 min, max 값은 특성마다 차이가 크지만 스케일 변환 후에는 min은 0, max는 1임을 알 수 있습니다.

이제 스케일 변환 전 독립 변수와 학습 후 예측 결과의 적합도를 계산합시다.

model1 = KNNCls()
model2 = KNeighborsClassifier()
for model in [model1,model2]:
  print(model.__class__.__name__,"###")
  model.fit(x_train,y_train)
  pred = model.predict(x_test)
  print("accuracy:",accuracy_score(y_test,pred))
[out]
KNNCls ###
accuracy: 0.9370629370629371
KNeighborsClassifier ###
accuracy: 0.951048951048951

이번에는 스케일 변환 후 독립 변수로 학습 후 예측 결과의 적합도를 계산합시다.

model1 = KNNCls()
model2 = KNeighborsClassifier()
for model in [model1,model2]:
  print(model.__class__.__name__,"###")
  model.fit(xs_train,ys_train)
  pred = model.predict(xs_test)
  print("accuracy:",accuracy_score(ys_test,pred))
[out]
KNNCls ###
accuracy: 0.9440559440559441
KNeighborsClassifier ###
accuracy: 0.972027972027972

결과를 보면 스케일 변환 후에 나은 결과가 나온 것을 알 수 있습니다.

언제나 이러한 결과를 갖고 오는 것은 아닙니다.

실제 작업에서 스케일 변환 전 후를 모두 실험하여 좋은 결과를 내는 형태를 선택합니다.