k近傍法 (k-NN)

入門

2.2.7

k近傍法 (k-NN)

最終更新 2020-04-22 読了時間 3 分
まとめ
  • k-NN は学習時にモデルを構築せず、推論時に近傍サンプルの多数決でラベルを決めるシンプルな手法です。
  • 主なハイパーパラメータは近傍数 \(k\) と距離の重み付けで、探索が比較的容易です。
  • 非線形な決定境界を自然に表現できますが、高次元では距離の差が縮み「次元の呪い」が課題になります。
  • 前処理として標準化や特徴選択を行うと距離計算が安定し、性能が向上しやすくなります。

直感 #

「近くのサンプルは同じラベルを持つ」という仮定のもと、k-NN は予測したい点から最も近い \(k\) 個の訓練サンプルを探し、多数決(または距離に応じた重み付き投票)でラベルを決めます。あらかじめパラメータを学習しないため、しばしば「怠惰学習(lazy learning)」と呼ばれます。

flowchart LR A["テスト点x"] --> B["全訓練データ\nとの距離計算"] B --> C["近い順に\nk個を選択"] C --> D["多数決\n(重み付き投票)"] D --> E["予測ラベル"] style A fill:#2563eb,color:#fff style C fill:#1e40af,color:#fff style E fill:#10b981,color:#fff

数式による定義 #

テスト点 \(\mathbf{x}\) に対して \(\mathcal{N}_k(\mathbf{x})\) を訓練データのうち距離が近い \(k\) 個の集合とすると、クラス \(c\) に対する票数は

$$ v_c = \sum_{i \in \mathcal{N}_k(\mathbf{x})} w_i \,\mathbb{1}(y_i = c) $$

で表されます。ここで \(w_i\) は各サンプルの重み(例:距離の逆数)です。最も票の多いクラスが予測ラベルとなります。

Pythonによる実験 #

以下のコードは複数の近傍数を検証用データで評価し、最も良かったモデルの決定領域を描画します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
from __future__ import annotations

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.colors import ListedColormap
from sklearn.datasets import make_blobs
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

def run_knn_demo(
    n_samples: int = 600,
    random_state: int = 7,
    weights: str = "distance",
    k_values: tuple[int, ...] = (1, 3, 5, 7, 11),
    validation_ratio: float = 0.3,
    title: str = "k-NN の判別領域",
    xlabel: str = "特徴量1",
    ylabel: str = "特徴量2",
    class_label_prefix: str = "クラス",
) -> dict[str, object]:
    """Evaluate k-NN for several neighbour counts and plot decision regions.

    Args:
        n_samples: Number of synthetic samples to draw.
        random_state: Seed for reproducible sampling.
        weights: Weighting scheme handed to KNeighborsClassifier.
        k_values: Candidate neighbour counts to evaluate.
        validation_ratio: Fraction of the data reserved for validation.
        title: Title for the generated figure.
        xlabel: Label for the x-axis.
        ylabel: Label for the y-axis.
        class_label_prefix: Prefix used when labelling the classes.

    Returns:
        Dictionary with validation scores per k and the best-performing k.
    """
    X, y = make_blobs(
        n_samples=n_samples,
        centers=3,
        cluster_std=[1.1, 1.0, 1.2],
        random_state=random_state,
    )

    rng = np.random.default_rng(random_state)
    indices = rng.permutation(len(X))
    split = int(len(X) * (1.0 - validation_ratio))
    train_idx, valid_idx = indices[:split], indices[split:]
    X_train, X_valid = X[train_idx], X[valid_idx]
    y_train, y_valid = y[train_idx], y[valid_idx]

    scores: dict[int, float] = {}
    for k in k_values:
        model = make_pipeline(
            StandardScaler(),
            KNeighborsClassifier(n_neighbors=k, weights=weights),
        )
        model.fit(X_train, y_train)
        scores[k] = float(model.score(X_valid, y_valid))

    best_k = max(scores, key=scores.get)
    best_model = make_pipeline(
        StandardScaler(),
        KNeighborsClassifier(n_neighbors=best_k, weights=weights),
    )
    best_model.fit(X, y)

    xx, yy = np.meshgrid(
        np.linspace(X[:, 0].min() - 1.5, X[:, 0].max() + 1.5, 300),
        np.linspace(X[:, 1].min() - 1.5, X[:, 1].max() + 1.5, 300),
    )
    grid = np.column_stack([xx.ravel(), yy.ravel()])
    predictions = best_model.predict(grid).reshape(xx.shape)

    unique_classes = np.unique(y)
    levels = np.arange(unique_classes.min(), unique_classes.max() + 2) - 0.5
    cmap = ListedColormap(["#fee0d2", "#deebf7", "#c7e9c0"])

    fig, ax = plt.subplots(figsize=(7, 5.5))
    contour = ax.contourf(xx, yy, predictions, levels=levels, cmap=cmap, alpha=0.85)
    scatter = ax.scatter(
        X[:, 0],
        X[:, 1],
        c=y,
        cmap="Set1",
        edgecolor="#1f2937",
        linewidth=0.6,
    )
    ax.set_title(f"{title} (k={best_k}, weights={weights})")
    ax.set_xlabel(xlabel)
    ax.set_ylabel(ylabel)
    ax.set_xlim(xx.min(), xx.max())
    ax.set_ylim(yy.min(), yy.max())
    ax.grid(alpha=0.15)

    legend = ax.legend(
        handles=scatter.legend_elements()[0],
        labels=[f"{class_label_prefix} {cls}" for cls in unique_classes],
        loc="upper right",
        frameon=True,
    )
    legend.get_frame().set_alpha(0.9)
    fig.colorbar(contour, ax=ax, label="予測クラス")
    fig.tight_layout()
    plt.show()

    return {"scores": scores, "best_k": int(best_k), "validation_accuracy": scores[best_k]}

metrics = run_knn_demo(
    title="k-NN の判別領域",
    xlabel="特徴量1",
    ylabel="特徴量2",
    class_label_prefix="クラス",
)
print(f"最良の k: {metrics['best_k']}")
print(f"検証精度 (最良の k): {metrics['validation_accuracy']:.3f}")
for candidate_k, score in metrics["scores"].items():
    print(f"k={candidate_k}: 検証精度={score:.3f}")

k-NNの判別領域

k の値と決定境界 #

k の値を変えると決定境界がどう変化するか、リアルタイムで確認できます。

k-NNは全訓練データを保持するため、データが大きいと推論が遅くなります。また、高次元データでは距離の差が縮まり識別力が低下する「次元の呪い」が発生します。特徴選択やPCAで次元を減らしてから適用してください。

weights="distance"を指定すると、近い点ほど強い影響を持つ重み付き投票になり、境界付近の精度が改善されます。\(k\)は奇数に設定すると多数決で同票になるリスクを減らせます。

まとめ #

  • k-NNは学習フェーズを持たず、推論時に近傍サンプルの多数決でラベルを決定します。
  • 非線形な決定境界を自然に表現できますが、高次元データでは距離の差が縮み性能が低下します。
  • 標準化は必須の前処理で、スケールの異なる特徴量があると距離計算が偏ります。
  • \(k\)の選択は交差検証で行い、小さすぎるとノイズに敏感、大きすぎると境界がぼやけます。

参考文献 #

  • Cover, T. M., & Hart, P. E. (1967). Nearest Neighbor Pattern Classification. IEEE Transactions on Information Theory, 13(1), 21 E7.
  • Hastie, T., Tibshirani, R., & Friedman, J. (2009). The Elements of Statistical Learning. Springer.