🤖 AI/ML

k-NN

k-Nearest Neighbors

가장 가까운 k개 이웃으로 분류하는 알고리즘. 단순하지만 효과적인 비모수적 머신러닝 기법.

📖 상세 설명

k-NN(k-Nearest Neighbors, k-최근접 이웃)은 새로운 데이터 포인트를 분류하거나 예측할 때, 가장 가까운 k개의 학습 데이터를 참조하여 결정하는 알고리즘입니다. 분류 문제에서는 k개 이웃 중 다수결로 클래스를 결정하고, 회귀 문제에서는 k개 이웃의 평균값을 예측값으로 사용합니다.

k-NN은 1951년 Evelyn Fix와 Joseph Hodges가 제안한 가장 오래된 머신러닝 알고리즘 중 하나입니다. "게으른 학습(lazy learning)" 알고리즘으로 불리는데, 학습 단계에서 모델을 구축하지 않고 단순히 데이터를 저장만 하다가 예측 시점에 계산을 수행하기 때문입니다. 이는 학습이 빠르지만 예측이 느린 특성으로 이어집니다.

k-NN의 핵심은 거리 측정 방식과 k 값 선택입니다. 일반적으로 유클리드 거리를 사용하지만, 맨해튼 거리, 민코프스키 거리, 코사인 유사도 등도 활용됩니다. k 값은 너무 작으면 노이즈에 민감해지고(과적합), 너무 크면 결정 경계가 흐려집니다(과소적합). 보통 교차 검증으로 최적의 k를 찾습니다.

실무에서 k-NN은 추천 시스템, 이상 탐지, 이미지 분류의 베이스라인으로 자주 사용됩니다. 또한 벡터 데이터베이스(Pinecone, Milvus 등)에서 유사 문서 검색에 활용되는 ANN(Approximate Nearest Neighbor)의 기초가 됩니다. 다만 고차원 데이터에서는 "차원의 저주"로 인해 성능이 저하될 수 있어 차원 축소와 함께 사용하는 것이 좋습니다.

💻 코드 예제

# k-NN 완전 가이드: 분류, 회귀, 최적화
import numpy as np
from sklearn.neighbors import KNeighborsClassifier, KNeighborsRegressor
from sklearn.model_selection import cross_val_score, GridSearchCV
from sklearn.preprocessing import StandardScaler
from sklearn.datasets import load_iris, load_diabetes
from sklearn.pipeline import Pipeline
import matplotlib.pyplot as plt

# 1. 기본 k-NN 분류
def basic_knn_classification():
    """기본 k-NN 분류 예제"""
    # 데이터 로드
    iris = load_iris()
    X, y = iris.data, iris.target

    # 스케일링 + k-NN 파이프라인
    pipeline = Pipeline([
        ('scaler', StandardScaler()),  # k-NN은 스케일에 민감!
        ('knn', KNeighborsClassifier(n_neighbors=5))
    ])

    # 교차 검증으로 성능 평가
    scores = cross_val_score(pipeline, X, y, cv=5)
    print(f"평균 정확도: {scores.mean():.3f} (+/- {scores.std()*2:.3f})")

    # 학습 및 예측
    pipeline.fit(X, y)
    new_sample = [[5.1, 3.5, 1.4, 0.2]]
    prediction = pipeline.predict(new_sample)
    probabilities = pipeline.predict_proba(new_sample)

    print(f"예측 클래스: {iris.target_names[prediction[0]]}")
    print(f"클래스별 확률: {dict(zip(iris.target_names, probabilities[0]))}")

    return pipeline

# 2. 최적 k 값 찾기
def find_optimal_k():
    """교차 검증으로 최적의 k 찾기"""
    iris = load_iris()
    X, y = iris.data, iris.target

    k_range = range(1, 31)
    scores = []

    for k in k_range:
        knn = KNeighborsClassifier(n_neighbors=k)
        score = cross_val_score(
            knn, X, y,
            cv=10,
            scoring='accuracy'
        ).mean()
        scores.append(score)

    optimal_k = k_range[np.argmax(scores)]
    print(f"최적 k: {optimal_k}, 정확도: {max(scores):.3f}")

    # 시각화
    plt.figure(figsize=(10, 6))
    plt.plot(k_range, scores, 'bo-')
    plt.axvline(x=optimal_k, color='r', linestyle='--', label=f'최적 k={optimal_k}')
    plt.xlabel('k (이웃 수)')
    plt.ylabel('교차 검증 정확도')
    plt.title('k 값에 따른 정확도 변화')
    plt.legend()
    plt.grid(True)
    plt.savefig('knn_k_selection.png')
    plt.close()

    return optimal_k

# 3. 하이퍼파라미터 그리드 서치
def optimize_knn_hyperparameters():
    """GridSearchCV로 하이퍼파라미터 최적화"""
    iris = load_iris()
    X, y = iris.data, iris.target

    pipeline = Pipeline([
        ('scaler', StandardScaler()),
        ('knn', KNeighborsClassifier())
    ])

    param_grid = {
        'knn__n_neighbors': [3, 5, 7, 9, 11],
        'knn__weights': ['uniform', 'distance'],  # 거리 가중치
        'knn__metric': ['euclidean', 'manhattan', 'minkowski']
    }

    grid_search = GridSearchCV(
        pipeline,
        param_grid,
        cv=5,
        scoring='accuracy',
        n_jobs=-1
    )

    grid_search.fit(X, y)

    print(f"최적 파라미터: {grid_search.best_params_}")
    print(f"최고 정확도: {grid_search.best_score_:.3f}")

    return grid_search.best_estimator_

# 4. k-NN 회귀
def knn_regression():
    """k-NN 회귀 예제"""
    diabetes = load_diabetes()
    X, y = diabetes.data, diabetes.target

    pipeline = Pipeline([
        ('scaler', StandardScaler()),
        ('knn', KNeighborsRegressor(n_neighbors=5, weights='distance'))
    ])

    scores = cross_val_score(
        pipeline, X, y,
        cv=5,
        scoring='neg_mean_squared_error'
    )

    rmse = np.sqrt(-scores.mean())
    print(f"RMSE: {rmse:.2f}")

    return pipeline.fit(X, y)

# 5. 벡터 검색에서의 k-NN (RAG 시스템)
def knn_for_vector_search():
    """RAG 시스템에서 유사 문서 검색"""
    from sklearn.neighbors import NearestNeighbors

    # 가상의 문서 임베딩 (실제로는 OpenAI/Sentence-Transformers 사용)
    documents = [
        "파이썬은 배우기 쉬운 프로그래밍 언어입니다.",
        "자바스크립트는 웹 개발에 필수적입니다.",
        "머신러닝은 인공지능의 한 분야입니다.",
        "딥러닝은 신경망을 활용합니다.",
        "k-NN은 분류 알고리즘입니다."
    ]

    # 간단한 TF-IDF 임베딩 (실제로는 dense embedding 사용)
    from sklearn.feature_extraction.text import TfidfVectorizer
    vectorizer = TfidfVectorizer()
    embeddings = vectorizer.fit_transform(documents).toarray()

    # k-NN 모델 (코사인 유사도)
    nn_model = NearestNeighbors(
        n_neighbors=3,
        metric='cosine',
        algorithm='brute'  # 작은 데이터셋에서는 brute force
    )
    nn_model.fit(embeddings)

    # 쿼리 검색
    query = "AI와 기계학습에 대해 알려주세요"
    query_embedding = vectorizer.transform([query]).toarray()

    distances, indices = nn_model.kneighbors(query_embedding)

    print("유사 문서 검색 결과:")
    for i, (dist, idx) in enumerate(zip(distances[0], indices[0])):
        similarity = 1 - dist  # 코사인 거리를 유사도로 변환
        print(f"  {i+1}. [{similarity:.3f}] {documents[idx]}")

    return nn_model

# 6. 대용량 데이터를 위한 Approximate NN
def approximate_nn():
    """대용량 데이터를 위한 근사 최근접 이웃 (FAISS)"""
    try:
        import faiss

        # 100만 개의 128차원 벡터 (예시)
        d = 128  # 차원
        nb = 100000  # 데이터베이스 크기
        nq = 10  # 쿼리 수

        np.random.seed(1234)
        xb = np.random.random((nb, d)).astype('float32')
        xq = np.random.random((nq, d)).astype('float32')

        # IVF 인덱스 (클러스터 기반 근사 검색)
        nlist = 100  # 클러스터 수
        quantizer = faiss.IndexFlatL2(d)
        index = faiss.IndexIVFFlat(quantizer, d, nlist)

        index.train(xb)  # 클러스터 중심 학습
        index.add(xb)    # 벡터 추가

        # 검색 (k=4 이웃)
        k = 4
        index.nprobe = 10  # 검색할 클러스터 수
        distances, indices = index.search(xq, k)

        print(f"첫 번째 쿼리의 4개 이웃 인덱스: {indices[0]}")
        print(f"거리: {distances[0]}")

    except ImportError:
        print("FAISS가 설치되지 않았습니다: pip install faiss-cpu")

# 실행
if __name__ == "__main__":
    # 기본 분류
    print("=== 기본 k-NN 분류 ===")
    basic_knn_classification()

    # 최적 k 찾기
    print("\n=== 최적 k 찾기 ===")
    find_optimal_k()

    # 하이퍼파라미터 최적화
    print("\n=== 하이퍼파라미터 최적화 ===")
    optimize_knn_hyperparameters()

    # 벡터 검색
    print("\n=== 벡터 검색 ===")
    knn_for_vector_search()

🗣️ 실무에서 이렇게 말하세요

💬 회의에서

"추천 시스템 베이스라인이 필요한데요." - "일단 k-NN으로 시작하면 어떨까요? 사용자 임베딩 간 유사도로 비슷한 취향의 k명을 찾고, 그들이 좋아한 아이템을 추천하는 방식이요. 구현이 간단하고 결과 해석도 쉬워서 비즈니스 검증에 좋습니다. 성능이 확인되면 행렬 분해나 딥러닝으로 업그레이드하면 됩니다."

💬 면접에서

"k-NN의 장단점과 k 값 선택 방법을 설명해주세요." - "장점은 구현이 간단하고 해석이 쉽다는 것, 새 클래스 추가가 용이하다는 점입니다. 단점은 예측 시 모든 데이터를 비교해서 느리고, 고차원에서 성능이 떨어지는 '차원의 저주' 문제가 있어요. k는 교차 검증으로 찾는데, 보통 홀수를 선택해 동점을 피합니다. 작은 k는 과적합, 큰 k는 과소적합 경향이 있어 적절한 균형을 찾아야 합니다."

💬 기술 토론에서

"RAG에서 벡터 검색하는 것도 k-NN인가요?" - "네, 정확히는 ANN(Approximate Nearest Neighbor)을 사용해요. 수백만 개 문서에서 정확한 k-NN을 하면 너무 느려서, FAISS나 HNSW 같은 근사 알고리즘을 씁니다. 정확도를 약간 희생하는 대신 검색 속도를 수백 배 올릴 수 있어요. Pinecone, Milvus 같은 벡터 DB 내부에서도 이런 알고리즘이 동작합니다."

⚠️ 흔한 실수 & 주의사항

스케일링 미적용

k-NN은 거리 기반이므로 특성의 스케일에 민감합니다. 나이(0~100)와 연봉(0~1억) 같이 단위가 다르면 연봉이 거리 계산을 지배합니다. 반드시 StandardScaler나 MinMaxScaler를 적용하세요.

고차원 데이터에서의 사용

차원이 높아지면 모든 점 간 거리가 비슷해지는 "차원의 저주"가 발생합니다. 수백 차원 이상에서는 PCA, t-SNE 등으로 차원을 줄이거나, 고차원에 강한 코사인 유사도를 사용하세요.

올바른 접근 방법

파이프라인으로 스케일링과 k-NN을 묶어 사용하세요. k는 교차 검증으로 찾고, weights='distance' 옵션으로 가까운 이웃에 더 큰 가중치를 줄 수 있습니다. 대용량 데이터에서는 FAISS나 Annoy 같은 ANN 라이브러리를 고려하세요.

🔗 관련 용어

📚 더 배우기