「k近傍法のkって、結局いくつにすればいいの?」「とりあえずいくつか試して、テストの点がいちばん高いものを選べばいいですよね?」

機械学習を始めると、こういうモデルの設定値(ハイパーパラメータ)の決め方で迷いますよね。じつは「テストデータで点数を見ながら選ぶ」のは、やってはいけない選び方なんです⚠️

この記事では、scikit-learnのGridSearchCVを使って、設定値の候補を総当たりで比べ、テストデータを最後まで取っておく正しい手順を一緒に見ていきましょう!

対象:scikit-learnで fit と score を使ったことがある初〜中級者。コードはすべて手元で実行し、出た結果をそのまま載せています(scikit-learn 1.9.0)。

ハイパーパラメータとは?k近傍法のkで考える

wine glasses laboratory
wine glasses laboratory / Photo by Pavel Danilyuk via Pexels

k近傍法(KNeighborsClassifier)は、「新しいデータにいちばん近いk件を見て、多数決でクラスを決める」シンプルな分類方法です。イメージとしては「近所の人に聞いて決める」感じですね。

  • kが小さい:すぐ隣の1〜2件に振り回されやすい
  • kが大きい:遠くのデータまで多数決に混ざって、境目がぼやける

このkのように、学習の前に人が決める設定値をハイパーパラメータと呼びます。データから自動で学ぶ値(重みなど)とは別物です。

題材には、scikit-learnに入っているワインの成分データ(178本・13項目・3品種)を使います。ダウンロード不要なので、そのまま試せます😊

まずはダメな例:テストの点数を見ながらkを選ぶ

ありがちなのが、次のように「kを変えてテスト正解率を比べる」やり方です。

from sklearn.datasets import load_wine
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier

# ワインの成分データ(178本・13項目・3品種)
X, y = load_wine(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=0, stratify=y
)

# k(近所を何件見るか)を変えて、テストデータの正解率を比べる
for k in [1, 3, 5, 7, 9, 15]:
    model = KNeighborsClassifier(n_neighbors=k)
    model.fit(X_train, y_train)
    score = model.score(X_test, y_test)
    print(f"k={k:2d}  テスト正解率: {score:.3f}")
実行結果:kを変えたときのテスト正解率(標準化なし)
実行結果:kを変えたときのテスト正解率(標準化なし)

実行するとこうなりました👇

  • k=1 と k=5 がどちらも 0.722 でトップ、k=3 は 0.648
  • 正解率は全体的に 0.65〜0.72 と低め

ここで「じゃあk=5で!」と決めると、問題が2つあります。

  1. テストデータに合わせて選んでしまっている:テストデータは「まだ見たことのないデータでどれくらい当たるか」を測るためのもの。それを見ながら選ぶと、たまたまテストに合う設定を選んだだけかもしれず、正解率が実力より良く見えてしまいます。
  2. 標準化をしていない:ワインのデータは項目ごとに数値の大きさがバラバラ(プロリンは数百〜千以上、色合いは1前後など)。k近傍法は「距離」で近所を決めるので、大きな数値の項目だけで距離が決まってしまいます。

これ、どちらも初めのうちはやりがちなんですよね。

GridSearchCVで「訓練データの中だけ」で選ぶ

そこでGridSearchCVの出番です。やってくれることは、つまり次の3つです。

  • 候補の組み合わせを全部(グリッド状に)試す
  • それぞれを訓練データだけを使った交差検証で採点する(訓練データを5つに分け、4つで学習・1つで採点を5回くり返して平均)
  • いちばん良かった設定で、訓練データ全体を使って学習し直す

標準化もPipelineでまとめておくと、交差検証の分割ごとに正しく標準化してくれます。

from sklearn.datasets import load_wine
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

X, y = load_wine(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=0, stratify=y
)

# 標準化 → k近傍法 を1つのモデルとしてまとめる
pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("knn", KNeighborsClassifier()),
])

# 試したい設定の組み合わせ(「部品名__引数名」で指定する)
param_grid = {
    "knn__n_neighbors": list(range(1, 31)),
    "knn__weights": ["uniform", "distance"],
}

# 訓練データだけを5分割して、30×2=60通りを交差検証で比べる
grid = GridSearchCV(pipe, param_grid, cv=5, scoring="accuracy")
grid.fit(X_train, y_train)

print("試した組み合わせ:", len(grid.cv_results_["params"]), "通り")
print("いちばん良かった設定:", grid.best_params_)
print(f"交差検証の平均正解率: {grid.best_score_:.3f}")

# テストデータは最後に1回だけ使う
print(f"テスト正解率(最後に1回だけ): {grid.score(X_test, y_test):.3f}")
実行結果:GridSearchCVで選ばれた設定と正解率
実行結果:GridSearchCVで選ばれた設定と正解率

ここが重要です👇

  • Pipelineの中の設定は "knn__n_neighbors" のように「部品名__引数名」(アンダースコア2つ)で指定する
  • weights は "uniform"(k件を同じ重みで多数決)と "distance"(近いデータほど重く数える)の2種類
  • grid.fit に渡すのは訓練データだけ。テストデータは最後の grid.score で1回だけ使う

結果は、60通りの中から k=22・uniform が選ばれ、交差検証の平均正解率は 0.976、最後に1回だけ測ったテスト正解率は 0.981 でした。標準化なしで手作業で選んだときの 0.722 から大きく上がっています🚀

交差検証の点数とテストの点数が近いので、「選び方がテストデータに引っぱられていない」ことも確認できますね。

結果を全部見てみよう:1位は11通りもあった

「k=22がベストなんだ!」と思いたくなりますが、ちょっと待ってください。これ、実行せずに答えられますか?「同じ点数の1位がほかにもあったら、GridSearchCVはどれを選ぶ?」

cv_results_ をpandasの表にして、kごとの平均正解率をグラフにしてみます。

import matplotlib.pyplot as plt
import pandas as pd
from sklearn.datasets import load_wine
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

plt.rcParams["font.family"] = "Meiryo"  # 日本語ラベル用(Windows)

X, y = load_wine(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=0, stratify=y
)
pipe = Pipeline([("scaler", StandardScaler()), ("knn", KNeighborsClassifier())])
param_grid = {"knn__n_neighbors": list(range(1, 31)),
              "knn__weights": ["uniform", "distance"]}
grid = GridSearchCV(pipe, param_grid, cv=5, scoring="accuracy")
grid.fit(X_train, y_train)

# 全60通りの結果を表にする
results = pd.DataFrame(grid.cv_results_)
cols = ["param_knn__n_neighbors", "param_knn__weights",
        "mean_test_score", "std_test_score", "rank_test_score"]
top = results[cols].sort_values("rank_test_score").head(5)
print(top.round(3).to_string(index=False))
print("1位タイの組み合わせ:", (results["rank_test_score"] == 1).sum(), "通り")

# kごとの平均正解率をグラフにする
for w, label in [("uniform", "uniform(同じ重み)"), ("distance", "distance(近いほど重く)")]:
    part = results[results["param_knn__weights"] == w]
    plt.plot(part["param_knn__n_neighbors"], part["mean_test_score"],
             marker="o", label=label)
plt.xlabel("k(近所を何件見るか)")
plt.ylabel("交差検証の平均正解率")
plt.title("GridSearchCVの結果:kと正解率")
plt.legend()
plt.grid(alpha=0.3)
plt.show()
実行結果:kごとの交差検証の平均正解率(uniformとdistance)
実行結果:kごとの交差検証の平均正解率(uniformとdistance)

表の上位を見ると、平均正解率 0.976 で並ぶ1位タイが11通りありました(k=22, 24〜27, 29, 30 あたり)。

k weights 平均正解率 順位
30 distance 0.976 1
25 distance 0.976 1
26 uniform 0.976 1
26 distance 0.976 1
27 uniform 0.976 1

同点のときは、候補を試した順番でいちばん先に出てきたものが best_params_ になります。今回は k=1 から順に試しているので、1位タイの中でkが最も小さい22が選ばれた、というわけです。

グラフから読み取れるポイントをまとめるとこんな感じです👇

  • k=1〜5 あたりは正解率が低め(すぐ隣のデータに振り回されている)
  • k=20を超えるあたりから 0.96〜0.976 で横ばい
  • uniformとdistanceの差は小さい

つまり「k=22が特別に良い」のではなく、「kは20台ならどれでもだいたい同じ」というのが実際のところです。訓練データは124件しかないので、5分割した1回分の採点は約25件。1件当たるか外れるかで0.04ほど変わるため、細かい差を気にしすぎないことも大切です。

GridSearchCVを使うときの注意点

  • 組み合わせ数×分割数だけ学習が走る:今回は60通り×5分割=300回。候補を増やしすぎると時間がかかるので、最初は粗く(例:1, 5, 10, 20…)探してから、良さそうな範囲を細かく探すのがおすすめです。
  • 前処理はPipelineに入れる:標準化を先に全データでやってからGridSearchCVに渡すと、採点用のデータの情報まで標準化に混ざってしまいます。
  • テストデータは最後に1回:テストの点を見てから候補を変えてやり直すと、結局テストデータで選んでいるのと同じになります。

公式ドキュメント:sklearn.model_selection.GridSearchCV

まとめ

  • kのようなハイパーパラメータを、テストデータの点数を見ながら選ぶのはNG
  • GridSearchCVなら、訓練データの中の交差検証だけで候補を総当たりで比べられる
  • 標準化はPipelineに入れ、設定は「部品名__引数名」で指定する
  • 今回は正解率が 0.722(標準化なし・手作業)→ 0.981(標準化あり・GridSearchCV)に
  • cv_results_ を表やグラフで見ると、1位タイが多いことや「だいたいこの範囲なら同じ」がわかる

「ハイパーパラメータ調整ってむずかしそう」が「できそう」に変わったらうれしいです。手元のデータでも、ぜひ試してみてください😊

📡 Arduinoをもっと深く学ぼう!

Arduino・ラズパイ・ロボットプログラミングを体系的に学びたい方へ。おすすめのUdemyコースや電子部品もまとめています。

Arduinoロボット入門のおすすめコース・部品を見る →

📚 関連商品・おすすめ書籍

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

もしも

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

初心者に定番のPython入門書

Amazonで見る
Arduinoをはじめよう 第4版 (Make: PROJECTS)

もしも

Arduinoをはじめよう 第4版 (Make: PROJECTS)

Arduino公式推薦の定番入門書

Amazonで見る
徹底攻略! 電子工作&プログラミング Arduinoで学ぶ電子工作完全ガイド

もしも

徹底攻略! 電子工作&プログラミング Arduinoで学ぶ電子工作完全ガイド

電子工作とプログラミングを同時に学べる

Amazonで見る

※本記事にはアフィリエイトリンクが含まれます。

ABOUT ME
やまちゃん
これまで学生と社会人を合わせて5000人以上にプログラミング学習を指導。 ゼロからイチをわかりやすく解説する専門家として活動しており、本業ではArduinoを用いたIoT開発とロボットプログラミングが専門。 Pythonを用いたアプリ開発、ウェブアプリケーションの開発で業務の効率化をサポートしています。