機械学習は測って選ぶ — scikit-learn分類5種を同一条件で実測比較

目次
  1. 比較の土俵を固定する
  2. 実測結果 — 精度と学習時間
  3. 「スケーリングは常に有効」が崩れた
  4. ConvergenceWarning は max_iter を増やせば直るのか
  5. この実測から言えること・言えないこと

機械学習を「まず手を動かして」学ぼうとすると、最初に当たる壁はアルゴリズムの多さだ。scikit-learn のモデルまとめ記事やチートシートは山ほどあるが、列挙を読んでも「手元のデータでどれを使えばいいのか」「教科書どおり StandardScaler を挟むと何がどれだけ変わるのか」は分からない。列挙と実測の間には距離がある。

そこで私は、主要な分類アルゴリズム5種(ロジスティック回帰・SVM・k近傍法・ランダムフォレスト・勾配ブースティング)を同一データ・同一分割・デフォルトパラメータで動かし、精度と学習時間を実測した。あわせて「前処理でスケーリングすべき」という定番アドバイスがどのモデルにどれだけ効くのかを、スケーリングあり・なしの2条件で測った。結果は「スケーリングは常に有効」という思い込みを崩すものだった。

比較の土俵を固定する

「どのアルゴリズムがいいか」は、データと分割と環境を固定しないと比較にならない。今回の土俵は次のとおり。

  • データ: load_digits()。scikit-learn 同梱の手書き数字データセットで、ダウンロード不要・1797 サンプル・64 特徴量
  • 分割: train_test_split(test_size=0.25, random_state=42, stratify=y) に固定
  • パラメータ: すべてデフォルト(random_state を持つモデルは 42 に固定)
  • 計測: time.perf_counter()fit() の所要時間、score() でテスト精度

実行環境は次のとおり。

$ sh -c 'python3 -V && sw_vers'
Python 3.14.6
...
# exit=0 (18 ms)

macOS 26.6.1、scikit-learn は 1.9.0 を使った(バージョンは後述のベンチマークログ冒頭に出る)。環境構築は uv run --with scikit-learn で済ませた。仮想環境を作らず、インストールごと1コマンドで再現できる。

ベンチマークの中身は素直で、5モデル × 2条件を回すだけだ。スケーリングあり条件は make_pipeline(StandardScaler(), model) で組んだ。

X, y = load_digits(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, random_state=42, stratify=y
)
print(f"train={X_train.shape} test={X_test.shape}")

MODELS = {
    "LogisticRegression": lambda: LogisticRegression(max_iter=100),
    "SVC": lambda: SVC(random_state=42),
    "KNeighbors": lambda: KNeighborsClassifier(),
    "RandomForest": lambda: RandomForestClassifier(random_state=42),
    "GradientBoosting": lambda: GradientBoostingClassifier(random_state=42),
}

LogisticRegression(max_iter=100) の 100 はデフォルト値を明示しただけで、挙動はデフォルトと同じだ(後でこの値を動かして比較するため引数に出してある)。計測部は各モデルを2条件で fit して時間と精度を記録し、出た警告も握りつぶさずに拾う。

for name, factory in MODELS.items():
    for scaled in (False, True):
        model = factory()
        est = make_pipeline(StandardScaler(), model) if scaled else model
        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            t0 = time.perf_counter()
            est.fit(X_train, y_train)
            fit_sec = time.perf_counter() - t0
        acc = est.score(X_test, y_test)

実測結果 — 精度と学習時間

結果がこれだ。

$ uv run --with scikit-learn python bench.py
scikit-learn 1.9.0
train=(1347, 64) test=(450, 64)
[warning] LogisticRegression/raw: ConvergenceWarning: lbfgs failed to converge after 100 iteration(s) (status=1):

model                cond    accuracy  fit_sec  warnings
LogisticRegression   raw       0.9622    0.020  ConvergenceWarning
LogisticRegression   scaled    0.9778    0.006  -
SVC                  raw       0.9911    0.013  -
SVC                  scaled    0.9800    0.017  -
KNeighbors           raw       0.9844    0.000  -
KNeighbors           scaled    0.9644    0.001  -
RandomForest         raw       0.9600    0.159  -
RandomForest         scaled    0.9622    0.129  -
GradientBoosting     raw       0.9556    3.014  -
GradientBoosting     scaled    0.9556    3.077  -
...
# exit=0 (7830 ms)
0.9622LogisticRegression raw0.9778LogisticRegression scaled0.9911SVC raw0.98SVC scaled0.9844KNeighbors raw0.9644KNeighbors scaled0.96RandomForest raw0.9622RandomForest scaled0.9556GradientBoosting raw0.9556GradientBoosting scaled
accuracy(digits・test 450件)

読み取れることは3つある。

まず、精度のトップはスケーリングなしの SVC で 0.9911。次点がスケーリングなしの kNN で 0.9844 だった。「とりあえずランダムフォレスト」と手が動きがちだが、この土俵では距離ベースの2つが上に来た。

次に、学習時間の差が大きい。kNN の fit は 0.000 秒(学習時にほぼ何もしないアルゴリズムなので当然ではある)、SVC・ロジスティック回帰も 0.020 秒以下に収まる一方、GradientBoosting は 3.014 秒かかった。しかも精度は 0.9556 で今回の5種の中では最下位。デフォルト設定同士の比較なら、遅くて弱いという結果になった。

最後に、スケーリングなしのロジスティック回帰だけが ConvergenceWarning を出した。これはこの後の節で掘る。

「スケーリングは常に有効」が崩れた

2条件の差分を並べ直すと、教科書的な予想を裏切る形になっている。

  • ロジスティック回帰: 0.9622 → 0.9778 に改善(しかも警告が消え、fit も 0.020 秒 → 0.006 秒に短縮)
  • SVC: 0.9911 → 0.9800 に悪化
  • kNN: 0.9844 → 0.9644 に悪化
  • ランダムフォレスト: 0.9600 → 0.9622 でほぼ不変
  • GradientBoosting: 0.9556 → 0.9556 で完全に不変

「SVM と kNN は距離ベースだからスケーリング必須」というのがよく言われるセオリーだが、この土俵では逆に働いた。なぜか。digits の特徴量スケールを確認するとヒントが出る。

$ uv run --with scikit-learn python check_range.py
shape=(1797, 64)
global min=0.0 max=16.0
per-feature max of min=0.0 min of max=0.0
# exit=0 (803 ms)

64 個の特徴量はすべて 0.0〜16.0 の画素値で、最初から同じ単位・同じレンジにある。スケーリングが効くのは「特徴量ごとに単位がバラバラ」な場面であって、digits はそもそもその前提を満たしていない。さらに min of max=0.0、つまり常に 0 の画素(列)が存在する。StandardScaler は列ごとに平均 0・分散 1 へ変換するので、もともと揃っていた画素間の相対的な強弱を列単位で引き伸ばすことになる。ほぼ動かない端の画素が中央の画素と同じ重みに拡大されれば、距離ベースのモデルには雑音が増える——というのが私の解釈だが、どの列がどれだけ効いたかまでは今回検証していない。実測として言えるのは「既に単位が揃ったデータでは、StandardScaler が距離ベースのモデルの精度を下げることがある」ところまでだ。

一方、木系の2つ(ランダムフォレスト・GradientBoosting)がスケーリングにほぼ・完全に不変なのは実測どおりで、分岐の閾値は単調変換の影響を受けないという性質と整合する。「木系にスケーリングは不要」はこのデータでも成り立った。

ConvergenceWarning は max_iter を増やせば直るのか

スケーリングなしのロジスティック回帰が出した警告はこれだ(ベンチマークログから再掲)。

$ uv run --with scikit-learn python bench.py
scikit-learn 1.9.0
train=(1347, 64) test=(450, 64)
[warning] LogisticRegression/raw: ConvergenceWarning: lbfgs failed to converge after 100 iteration(s) (status=1):
...
# exit=0 (7830 ms)

検索するとまず出てくる対処は「max_iter を増やせ」だ。警告文自体もそれを勧めてくる。本当にそれが正解なのか、max_iter を増やす案とスケーリングする案を並べて測った。

$ uv run --with scikit-learn python fix_convergence.py
case                   accuracy  fit_sec  converged
raw max_iter=100         0.9622    0.019  no (ConvergenceWarning)
raw max_iter=1000        0.9600    0.027  yes
raw max_iter=5000        0.9600    0.026  yes
scaled max_iter=100      0.9778    0.006  yes
# exit=0 (1084 ms)

max_iter=1000 にすると警告は消える。しかし精度は 0.9622 → 0.9600 と、収束させたのにわずかに下がった。5000 まで増やしても 0.9600 のままなので、頭打ちであってイテレーション不足ではない。つまり「未収束の状態がたまたまテストデータに少しだけ有利だった」だけで、回す回数を増やしても得られるものはない。

対してスケーリング案は、max_iter=100 のままで収束し、精度 0.9778、fit 0.006 秒と全項目で最良だった。この警告の実体は「反復が足りない」ではなく「最適化が進みにくい入力を食わせている」であり、正しい対処は回数ではなく入力の整形だった。max_iter を増やして警告だけ消すのは、症状を黙らせて原因を残す対処だということが数字で分かる。

この実測から言えること・言えないこと

今回の土俵(小規模・特徴量の単位が揃った数値データ・デフォルトパラメータ)での結論はこうなる。

  • 最初の1モデルは SVC か kNN でいい。速くて精度が出る
  • 確率と係数の解釈が欲しいならロジスティック回帰。ただし必ずスケーリングとセットで使う
  • GradientBoosting をデフォルト設定のまま選ぶ理由は薄い。3.014 秒かけて 0.9556 だった
  • StandardScaler は反射で入れない。特徴量の単位が既に揃っているかを X.min() / X.max() で先に見る

言えないことも明確にしておく。この結果は 1797 サンプル・64 特徴量の digits に閉じた話だ。特徴量の単位がバラバラな実データ(年収と年齢が混ざるような表形式データ)ではスケーリングの効き方が変わるはずだが、それは今回測っていない。勾配ブースティングもハイパーパラメータを調整すれば別の顔を見せる可能性が高いが、それも未検証だ。大規模データでは kNN の予測時間がボトルネックになる点も、今回は fit 時間しか測っていないので数字を持っていない。

それでも、モデル選びの解説記事を読み比べるより、この規模の実測を一度回すほうが早い。ベンチマーク一式は uv run --with scikit-learn python bench.py の1コマンドで動くので、自分のデータに差し替えて測り直すのが次の一歩として実用的だ。