목차
도미와 빙어로 이해하는 K-최근접 이웃
도미와 빙어의 길이·무게를 기록했다면 새로운 물고기의 어종을 어떻게 맞힐 수 있을까? 사람이 “길이 20cm 이상이면 도미”라는 규칙을 직접 정할 수도 있다. 하지만 경계에 있는 물고기를 처리하려면 이미 어종을 아는 물고기와 얼마나 가까운지 비교하는 방법이 유용하다. 이 글은 그 방법인 K-최근접 이웃(KNN)을 실제 데이터 형태로 따라가 본다.
- 구별할 종류 하나를 클래스(class)라고 한다.
- 여러 클래스 중 하나를 고르는 문제를 분류(classification)라고 한다.
- 클래스가 두 개이면 이진 분류(binary classification)다.
- 특성(feature)은 물고기의 길이처럼 샘플을 표현하는 값이다.
- 훈련(training)은 데이터에서 예측에 쓸 규칙을 준비하는 과정이다.
- 정확도(accuracy)는 전체 샘플 중 정답을 맞힌 비율이다. 사이킷런은 0~1 사이의 값으로 반환한다.
- 정확도 = (정확히 맞힌 개수) / (전체 데이터 개수)
사이킷런에서 fit(X, y)는 특성 X와 정답 y를 모델에 전달하는 과정이다. KNN은 복잡한 계수를 학습하는 대신 훈련 샘플을 보관하고 새 샘플이 오면 거리를 계산한다. 그래서 “훈련”이라는 말이 다른 모델의 학습과 정확히 같은 작업을 뜻하지는 않는다.
K-최근접 이웃 알고리즘
- 어떤 데이터에 대한 답을 구할 때 주위의 다른 데이터를 보고 다수를 차지하는 것을 정답으로 사용한다. 마치 근묵자흑과 같이 주위의 데이터로 현재 데이터를 판단.
- 새 데이터와 훈련 샘플 사이의 거리를 계산하고 가까운
k개의 레이블을 투표로 모은다. - 거리 계산 방식과 이웃 탐색 방식은 설정에 따라 달라진다. 기본 설정에서
p=2인 Minkowski 거리는 유클리드 거리와 같다. - 데이터가 많으면 저장 공간과 예측 시 이웃 탐색 비용이 커질 수 있다. 사이킷런은 입력에 따라 brute force, KD-tree, Ball-tree 등을 선택할 수 있으므로 “항상 모든 거리를 직접 계산한다”는 설명은 단순화한 개념도에 가깝다. KNeighborsClassifier 문서를 참고한다.
import matplotlib.pyplot as plt
from sklearn.neighbors import KNeighborsClassifier
bream_length = [25.4, 26.3, 26.5, 29.0, 29.0, 29.7, 29.7, 30.0, 30.0, 30.7, 31.0, 31.0,
31.5, 32.0, 32.0, 32.0, 33.0, 33.0, 33.5, 33.5, 34.0, 34.0, 34.5, 35.0,
35.0, 35.0, 35.0, 36.0, 36.0, 37.0, 38.5, 38.5, 39.5, 41.0, 41.0]
bream_weight = [242.0, 290.0, 340.0, 363.0, 430.0, 450.0, 500.0, 390.0, 450.0, 500.0, 475.0, 500.0,
500.0, 340.0, 600.0, 600.0, 700.0, 700.0, 610.0, 650.0, 575.0, 685.0, 620.0, 680.0,
700.0, 725.0, 720.0, 714.0, 850.0, 1000.0, 920.0, 955.0, 925.0, 975.0, 950.0]
smelt_length = [9.8, 10.5, 10.6, 11.0, 11.2, 11.3, 11.8, 11.8, 12.0, 12.2, 12.4, 13.0, 14.3, 15.0]
smelt_weight = [6.7, 7.5, 7.0, 9.7, 9.8, 8.7, 10.0, 9.9, 9.8, 12.2, 13.4, 12.2, 19.7, 19.9]
plt.scatter(bream_length, bream_weight, label='도미')
plt.scatter(smelt_length, smelt_weight, label='빙어')
plt.xlabel('length')
plt.ylabel('weight')
plt.legend()
length = bream_length + smelt_length
weight = bream_weight + smelt_weight
fish_data = [[l,w] for l, w in zip(length, weight)]
fish_target = [1] * 35 + [0] * 14
kn = KNeighborsClassifier(n_neighbors=5)
kn.fit(fish_data, fish_target)
training_accuracy = kn.score(fish_data, fish_target)
print('훈련 데이터 정확도:', training_accuracy)
plt.scatter(30, 600, marker='^')
plt.show()
print('길이 30, 무게 600의 예측:', kn.predict([[30, 600]]))
핵심 패키지와 함수
- matplotlib
- scatter()는 산점도를 그리는 Matplotlib 함수
- 처음 2개의 x축과 y축 값을 전달
- marker 매개변수를 통해 스타일을 정의 할 수 있다
- scikit-learn
- KNeighborsClassifier() : k-최근접 이웃 분류 모델을 만드는 사이킷 런 클래스
- n_neighbors : 매개변수로 이웃의 개수를 지정 (기본값 : 5)
- p : 매개변수로 거리를 재는 방법을 지정 (1: 맨해튼 거리, 2: 유클리디안 거리[기본값 :2])
- n_jobs : 이웃 탐색의 병렬 작업 수.
-1은 사용 가능한 프로세서를 활용한다. 기본값은None이며 일반적으로 한 작업으로 동작한다. 이 값을 올린다고 작은 데이터에서 항상 빨라지는 것은 아니다. - fit(X, y) : 특성 데이터와 정답 레이블을 받아 모델에 저장한다. 분류 모델의
fit에는 두 인수가 필요하다. - score() : 훈련된 사이킷런 모델의 성능을 측정, 처음 두 매개변수로 특성과 정답 데이터를 전달.
- 분류 모델에서는 예측을 정답과 비교한 정확도를 반환한다.
kn.score(fish_data, fish_target)은 훈련에 사용한 바로 그 데이터의 점수이므로 새 물고기에 대한 성능으로 해석하면 안 된다.
- 분류 모델에서는 예측을 정답과 비교한 정확도를 반환한다.
코드의 데이터 흐름 읽기
bream_length와 bream_weight는 같은 위치의 값끼리 한 마리의 도미를 가리킨다. zip(length, weight)로 두 특성을 [길이, 무게] 한 행으로 묶는다. fish_target의 첫 35개 값 1은 도미, 뒤의 14개 값 0은 빙어라는 뜻이다. 길이와 무게가 바뀌어 들어가거나 레이블 순서가 어긋나면 모델은 엉뚱한 물고기를 학습하게 된다.
plt.scatter는 두 어종이 특성 공간에서 어떻게 분포하는지 보여준다. 산점도에 찍은 삼각형 (30, 600)은 질문할 새 물고기다. 이 점이 어디에 놓이는지 시각화는 할 수 있지만, 그래프에서 가까워 보이는 몇 점을 눈으로 세는 것과 kn.predict가 정확히 같은 기준인지는 축의 단위와 이웃 수를 확인해야 한다.
거리의 크기를 원본 데이터로 직접 확인할 수 있다. (30, 600)과 훈련 데이터의 도미 (32, 600) 사이의 유클리드 거리는 다. 반면 빙어 (15, 19.9)까지는 무게 차이만 해도 580.1이므로 훨씬 멀다. 이 예시에서는 새 점이 도미 쪽에 있다는 사실을 쉽게 읽을 수 있다. 그러나 이것은 한 점과 두 훈련 샘플의 거리 비교일 뿐 5개 이웃의 최종 투표를 계산한 것은 아니다. 모든 이웃을 보려면 아래처럼 질의한다.
distances, indices = kn.kneighbors([[30, 600]])
print('이웃 거리:', distances[0])
print('이웃 레이블:', [fish_target[i] for i in indices[0]])
distances[0]은 질문 한 건에 대한 가까운 이웃 다섯 개의 거리, indices[0]은 fish_data 안에서의 행 번호다. 그 행 번호로 fish_target을 조회해 도미와 빙어가 몇 표인지 셀 수 있다. 이렇게 이웃을 확인하면 “예측값 1”만 보는 것보다 경계 근처 오분류를 분석하기 쉽다. 출력 숫자는 여기서 코드를 실행하지 않았으므로 적지 않는다.
flowchart LR
A[49마리: 길이·무게·어종] --> B[각 행을 길이, 무게로 묶기]
B --> C[fit: 훈련 샘플과 정답 보관]
D[새 물고기: 30, 600] --> E[훈련 샘플과 거리 계산]
C --> E
E --> F[가까운 5마리 선택]
F --> G[다수결로 어종 예측]
이 도식에서 fit 이후의 거리 계산은 예측할 때 일어난다. 기본값 n_neighbors=5이므로 가까운 5마리의 표를 센다. 실제로 어떤 물고기가 선택됐는지 확인하려면 kneighbors가 돌려주는 거리와 인덱스를 조회할 수 있다. 확률처럼 보이는 predict_proba도 이웃의 레이블 비율을 계산한 값이지, 검증 없이 신뢰할 수 있는 실제 확률이라고 생각하면 안 된다.
길이와 무게의 단위가 만드는 문제
도미의 길이는 대략 수십 cm이고 무게는 수백 g이다. 유클리드 거리 를 그대로 쓰면 무게의 숫자 차이가 길이보다 크게 작용하기 쉽다. 길이를 cm에서 mm로만 바꿔도 이웃 순서가 달라질 수 있다. 물고기의 생물학적 의미가 바뀐 것이 아닌데 결과가 달라지는 것이다.
따라서 특성의 스케일을 맞추는 실험을 해 본다. 표준화는 각 특성에서 훈련 세트의 평균을 빼고 훈련 세트의 표준편차로 나눈다. 테스트 세트의 평균까지 섞으면 평가 데이터 정보가 새어 들어간다. 다음 글의 데이터 분할을 먼저 적용한 뒤 Pipeline으로 표준화와 KNN을 묶으면 같은 변환을 예측 시에도 사용할 수 있다.
훈련 데이터의 kn.score(fish_data, fish_target)에는 또 다른 함정이 있다. 점수를 계산하는 대상이 이미 훈련에 들어간 물고기이기 때문이다. 특히 인 경우 훈련 샘플을 다시 예측하면 자기 자신이 거리 0인 이웃으로 선택될 수 있다. 그 결과는 처음 보는 물고기를 얼마나 잘 맞히는지 알려 주지 않는다. 라도 같은 훈련 샘플에 대한 점수는 독립 평가보다 낙관적일 수 있다. 산점도와 함께 훈련 점수를 확인하는 것은 코드가 대략 작동하는지 살펴보는 단계이고, 성능을 말할 때에는 아래의 분리된 테스트 점수가 필요하다.
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
X_train, X_test, y_train, y_test = train_test_split(
fish_data, fish_target, test_size=0.25,
stratify=fish_target, random_state=42,
)
model = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=5))
model.fit(X_train, y_train)
print('테스트 정확도:', model.score(X_test, y_test))
stratify=fish_target은 도미와 빙어의 비율을 두 세트에서 비슷하게 유지한다. StandardScaler는 fit 단계에서 훈련 샘플만 사용해 평균과 표준편차를 계산하고, 테스트와 새 물고기에는 그 값을 그대로 적용한다. score가 출력하는 숫자는 이 분할에서의 정확도다. 49마리 중 테스트에 남는 수는 작기 때문에 몇 마리만 틀려도 수치가 크게 달라진다. 이 코드의 결과를 일반적인 물고기 분류 성능이라고 주장할 수 없는 이유다. 사이킷런의 데이터 누수 안내를 참고한다.
결과를 해석할 때
k=1이면 바로 옆의 한 점에 크게 좌우되고, 너무 큰k는 소수 클래스인 빙어를 묻어 버릴 수 있다.k는 테스트 세트를 보기 전에 훈련 부분의 검증 절차에서 고른다.- 전체 정확도가 높아도 빙어를 모두 도미라고 예측할 수 있다. 도미·빙어별 혼동 행렬과 재현율을 함께 본다.
- 산점도에서 두 그룹 사이에 놓인 물고기는 근처 이웃의 작은 변화에도 예측이 달라질 수 있다. 이때 예측 레이블 하나만 표시하기보다 이웃들의 거리와 정답을 함께 조사한다.
- 길이와 무게만으로 충분하지 않은 어종 구분도 있다. 모델 설정을 바꾸기 전에 측정 방식과 필요한 추가 특성을 검토한다.
KNN은 이해하기 쉬운 기준선이지만, 특성의 단위·평가 분할·이웃 선택에 따라 결과가 달라진다. 산점도와 훈련 정확도를 본 뒤 바로 운영에 쓰기보다 새 데이터에서 잘 작동하는지 확인해야 한다.