はじめに
CNN(畳み込みニューラルネットワーク)を使った画像分類は以前触ったことがあったのですが、よく考えると「機械学習の基本パターン」と呼ばれるような、もっとシンプルな古典的手法をきちんと実装したことがありませんでした。
- 回帰と分類って、何がどう違うんだっけ
- アンサンブル学習ってよく聞くけど、単体のモデルよりどれくらい強いのか
- 教師なし学習(クラスタリングとか)は、ラベルなしで一体何をしているのか
- 過学習ってグラフでよく見るけど、自分の手元でも再現できるのか
この記事では、こういった疑問を「実際に手を動かして確認する」ことをゴールに、scikit-learn付属の定番データセット(iris、diabetes)を使って、機械学習の基本パターンをひと通り実装していきます。
理論の深掘りよりも「実装して動かして、出力された数字を見て納得する」ことを重視した内容です。
全体像: 機械学習の基本パターン
機械学習は大きく「教師あり学習」と「教師なし学習」に分けられます。今回はその中でも特に基本的な5パターン(+前処理・評価指標・過学習対策)を扱います。
環境
- Python 3
- scikit-learn 1.8
- pandas 3.0
- numpy 2.4
- パッケージ管理は uv を使用
# プロジェクトを作成
uv init ml-basic-patterns
cd ml-basic-patterns
# 必要なライブラリを追加(pyproject.tomlに記録され、専用の仮想環境に自動でインストールされる)
uv add scikit-learn pandas numpy
スクリプトを実行するときは、仮想環境をアクティベートしなくても uv run で直接実行できます。
uv run python ml_patterns.py
Windows環境の場合、uv 自体は PowerShell からそのまま使えます(curl のような別コマンドとのエイリアス衝突もありません)。pip install 特有の「仮想環境の有効化を忘れる」問題も uv run を使えば起きにくくなります。
以降の各セクションのコードブロックには print() を含めているので、そのままファイル(例: snippet.py)に保存して実行すれば、下に記載している「実行結果」がそのまま再現されます。
uv run python snippet.py
全体をまとめて実行したい場合は、この記事の各コードを1つのファイル(ml_patterns.py)にまとめて保存し、以下を実行してください。
uv run python ml_patterns.py
1. 前処理・特徴量エンジニアリング
どんなモデルを使うにしても、まずデータを整える工程が必要です。ここでは「欠損値の補完」と「標準化」を試します。
from sklearn.datasets import load_iris
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
import numpy as np
import pandas as pd
iris = load_iris(as_frame=True)
df = iris.frame.copy()
# わざと欠損値を作って挙動を確認する
rng = np.random.default_rng(42)
missing_idx = rng.choice(df.index, size=5, replace=False)
df.loc[missing_idx, "sepal length (cm)"] = np.nan
print(f"欠損値を意図的に混入: {len(missing_idx)}件")
print(f"欠損数: {df['sepal length (cm)'].isna().sum()}")
# 欠損値補完(平均値で埋める)
imputer = SimpleImputer(strategy="mean")
df["sepal length (cm)"] = imputer.fit_transform(df[["sepal length (cm)"]])
print(f"補完後の欠損数: {df['sepal length (cm)'].isna().sum()}")
# 標準化(平均0, 分散1に揃える)
feature_cols = iris.feature_names
scaler = StandardScaler()
scaled = scaler.fit_transform(df[feature_cols])
print("標準化前の平均・標準偏差:")
print(df[feature_cols].agg(["mean", "std"]).round(2))
print("標準化後の平均・標準偏差(平均≒0, 標準偏差≒1になるはず):")
print(pd.DataFrame(scaled, columns=feature_cols).agg(["mean", "std"]).round(2))
実行結果:
欠損値を意図的に混入: 5件
欠損数: 5
補完後の欠損数: 0
標準化前の平均・標準偏差:
sepal length (cm) sepal width (cm) petal length (cm) petal width (cm)
mean 5.85 3.06 3.76 1.20
std 0.81 0.44 1.77 0.76
標準化後の平均・標準偏差(平均≒0, 標準偏差≒1になるはず):
sepal length (cm) sepal width (cm) petal length (cm) petal width (cm)
mean 0.0 -0.0 -0.0 -0.0
std 1.0 1.0 1.0 1.0
StandardScaler をかけると、どの特徴量も平均0・標準偏差1に揃うことが確認できました。特徴量ごとにスケール(単位)が違うデータをそのままモデルに渡すと、値の大きい特徴量に引っ張られてしまうことがあるので、この前処理は地味ですが重要な工程です。
2. 回帰(線形回帰) — 数値を予測する
回帰は「数値」を予測するタスクです。糖尿病患者の検査データから、1年後の病状進行度(数値)を予測してみます。
from sklearn.datasets import load_diabetes
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error, r2_score
diabetes = load_diabetes()
X_train, X_test, y_train, y_test = train_test_split(
diabetes.data, diabetes.target, test_size=0.2, random_state=42
)
model = LinearRegression()
model.fit(X_train, y_train)
pred = model.predict(X_test)
rmse = mean_squared_error(y_test, pred) ** 0.5
r2 = r2_score(y_test, pred)
print(f"RMSE(誤差の目安。小さいほど良い): {rmse:.2f}")
print(f"R²(決定係数。1に近いほど良い): {r2:.3f}")
実行結果:
RMSE(誤差の目安。小さいほど良い): 53.85
R²(決定係数。1に近いほど良い): 0.453
R²が0.453というのは、実データのばらつきの半分弱をこのシンプルな線形回帰モデルで説明できている、という状態です。決して高い精度ではありませんが、これが「まず素朴な線形回帰を当ててみた」ときのベースラインになります。ここから特徴量を増やしたり、別のモデルを試したりして精度を上げていく、という流れが実務でもよくあります。
3. 分類(ロジスティック回帰) — カテゴリを予測する
分類は「カテゴリ(クラス)」を予測するタスクです。定番のアヤメ(iris)データセットで、花びらの大きさなどから3つの品種を判定します。
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
accuracy_score, precision_score, recall_score, f1_score, confusion_matrix,
)
iris = load_iris()
X_train, X_test, y_train, y_test = train_test_split(
iris.data, iris.target, test_size=0.2, random_state=42, stratify=iris.target
)
model = LogisticRegression(max_iter=200)
model.fit(X_train, y_train)
pred = model.predict(X_test)
print(f"正解率(accuracy): {accuracy_score(y_test, pred):.3f}")
print(f"適合率(precision, macro平均): {precision_score(y_test, pred, average='macro'):.3f}")
print(f"再現率(recall, macro平均): {recall_score(y_test, pred, average='macro'):.3f}")
print(f"F1スコア(macro平均): {f1_score(y_test, pred, average='macro'):.3f}")
print("混同行列(縦=正解, 横=予測):")
print(confusion_matrix(y_test, pred))
実行結果:
正解率(accuracy): 0.967
適合率(precision, macro平均): 0.970
再現率(recall, macro平均): 0.967
F1スコア(macro平均): 0.967
混同行列(縦=正解, 横=予測):
[[10 0 0]
[ 0 9 1]
[ 0 0 10]]
混同行列を見ると、30件中29件を正しく判定できていて、1件だけ2番目のクラスを誤って判定していることが分かります。「正解率」だけだとこの1件の中身までは見えないので、混同行列を合わせて見るクセをつけておくと、どのクラスで間違えやすいかが分かりやすくなります。
4. アンサンブル(ランダムフォレスト) — 複数モデルの多数決
アンサンブル学習は、複数のモデルを組み合わせて1つの予測を作る手法です。決定木を100本集めて多数決を取る「ランダムフォレスト」を、決定木単体と比較してみます。
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score
import pandas as pd
iris = load_iris()
X_train, X_test, y_train, y_test = train_test_split(
iris.data, iris.target, test_size=0.2, random_state=42, stratify=iris.target
)
# 比較対象: 決定木単体
tree = DecisionTreeClassifier(random_state=42)
tree.fit(X_train, y_train)
tree_acc = accuracy_score(y_test, tree.predict(X_test))
# ランダムフォレスト(決定木を100本集めた多数決)
forest = RandomForestClassifier(n_estimators=100, random_state=42)
forest.fit(X_train, y_train)
forest_acc = accuracy_score(y_test, forest.predict(X_test))
print(f"決定木単体の正解率: {tree_acc:.3f}")
print(f"ランダムフォレスト(100本)の正解率: {forest_acc:.3f}")
# 特徴量重要度
importances = pd.Series(forest.feature_importances_, index=iris.feature_names)
print("特徴量重要度(どの特徴が予測に効いているか):")
print(importances.sort_values(ascending=False).round(3))
実行結果:
決定木単体の正解率: 0.933
ランダムフォレスト(100本)の正解率: 0.900
特徴量重要度(どの特徴が予測に効いているか):
petal width (cm) 0.437
petal length (cm) 0.431
sepal length (cm) 0.116
sepal width (cm) 0.015
最初は「アンサンブルの方が精度が上がる」という結果を想定していましたが、実際に実行すると決定木単体(0.933)の方がランダムフォレスト(0.900)より高い結果になりました。
irisはクラスがきれいに分かれている簡単なデータセットで、テストデータも30件しかないため、1〜2件の予測差が精度に大きく影響します。ランダムフォレストは各木の学習に使う特徴量やサンプルをランダムに変えることで多様性を持たせる仕組みですが、この「簡単すぎるデータ」と「テストデータの少なさ」の組み合わせでは、そのランダム性がむしろ足を引っ張ることがあります。
「アンサンブルなら必ず精度が上がる」わけではなく、データの性質やサンプルサイズによって結果が変わる、というのは実装してみて初めて実感できたポイントでした。
特徴量重要度を見ると、花びらの幅・長さ(petal width / petal length)が品種判定に大きく効いていて、がく片の幅(sepal width)はほとんど寄与していないことが分かります。どの特徴量が効いているかを確認できるのも、決定木系モデルの利点の一つです。
5. クラスタリング(k-means) — ラベルなしでグループ分け
ここからは教師なし学習です。クラスタリングは「正解ラベルを一切使わず」に、データの特徴量の分布だけでグループ分けを行います。
from sklearn.datasets import load_iris
from sklearn.cluster import KMeans
from sklearn.metrics import adjusted_rand_score
import numpy as np
iris = load_iris()
X = iris.data
y_true = iris.target # 正解ラベルは「答え合わせ用」にのみ使う
kmeans = KMeans(n_clusters=3, random_state=42, n_init=10)
clusters = kmeans.fit_predict(X)
ari = adjusted_rand_score(y_true, clusters)
print(f"クラスタごとのサンプル数: {np.bincount(clusters)}")
print(f"実際の品種との一致度(Adjusted Rand Index, 1に近いほど一致): {ari:.3f}")
実行結果:
クラスタごとのサンプル数: [62 50 38]
実際の品種との一致度(Adjusted Rand Index, 1に近いほど一致): 0.730
学習時には品種ラベルを一切見せていないのに、Adjusted Rand Indexが0.730という、それなりに高い一致度が出ました。花びらの大きさなどの特徴量の分布だけでも、品種はある程度自然に分かれる構造を持っている、ということが確認できます。
6. 次元削減(PCA) — 特徴量を圧縮する
次元削減は、情報をなるべく保ったまま特徴量の数を減らす手法です。可視化(2次元・3次元のグラフにする)や、モデルの学習を軽くする目的でよく使われます。
from sklearn.datasets import load_iris
from sklearn.decomposition import PCA
iris = load_iris()
X = iris.data # 4次元の特徴量
pca = PCA(n_components=2)
X_2d = pca.fit_transform(X)
print(f"元の次元数: {X.shape[1]} → 圧縮後: {X_2d.shape[1]}")
print(f"各主成分の寄与率: {pca.explained_variance_ratio_.round(3)}")
print(f"2成分までの累積寄与率: {pca.explained_variance_ratio_.sum():.3f}")
実行結果:
元の次元数: 4 → 圧縮後: 2
各主成分の寄与率: [0.925 0.053]
2成分までの累積寄与率: 0.978
4種類あった特徴量(がく片・花びらの長さと幅)を2次元に圧縮しても、元の情報の97.8%を保持できています。iris はもともと特徴量同士に強い相関があるデータなので、次元を減らしても情報がほとんど失われない、分かりやすい例になりました。
7. 評価指標の使い分け早見表
ここまでで色々な指標が出てきたので、一度整理します。
| タスク | 指標 | 何を見ているか |
|---|---|---|
| 回帰 | RMSE | 予測値と正解値の誤差の大きさ。小さいほど良い |
| 回帰 | R²(決定係数) | モデルがデータのばらつきをどれだけ説明できているか。1に近いほど良い |
| 分類 | Accuracy(正解率) | 全体のうち正しく予測できた割合。クラス比率が偏っていると誤解を招く |
| 分類 | Precision(適合率) | 「陽性と予測した中で実際に陽性だった割合」。誤検知を避けたい時に重視 |
| 分類 | Recall(再現率) | 「実際に陽性のうち正しく陽性と予測できた割合」。見逃しを避けたい時に重視 |
| 分類 | F1スコア | PrecisionとRecallのバランスを見る指標 |
| 教師なし | Adjusted Rand Index等 | 正解ラベルとの一致度(ラベルがある場合の検証用) |
Accuracyだけを見て「精度96%だから優秀」と判断するのではなく、タスクの性質に応じて適合率・再現率のどちらを重視すべきかを考える必要がある、というのが実装してみての実感です。
8. 過学習と汎化 — train/test分割・交差検証
最後に「過学習(overfitting)」を実際に再現してみます。決定木の深さ(max_depth)を1・2・制限なし、の3段階で変えて、訓練データとテストデータそれぞれの正解率を比較します。
from sklearn.datasets import load_iris
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
iris = load_iris()
X, y = iris.data, iris.target
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y
)
def judge_fit(train_acc: float, test_acc: float, gap: float) -> str:
# 差分「だけ」で過学習と判定すると、train/testが両方低いケース(未学習)を
# 見逃してしまう。train・testそれぞれの水準も合わせて見る。
if train_acc >= 0.95 and gap > 0.05:
return "過学習気味(訓練は高精度なのにテストで落ちる)"
if train_acc < 0.90 and test_acc < 0.90:
return "未学習気味(訓練データ自体をうまく捉えられていない)"
return "おおむね良好なバランス"
models = [
("極端に浅い決定木(max_depth=1)", DecisionTreeClassifier(max_depth=1, random_state=42)),
("浅い決定木(max_depth=2)", DecisionTreeClassifier(max_depth=2, random_state=42)),
("深い決定木(制限なし)", DecisionTreeClassifier(max_depth=None, random_state=42)),
]
for name, model in models:
model.fit(X_train, y_train)
train_acc = accuracy_score(y_train, model.predict(X_train))
test_acc = accuracy_score(y_test, model.predict(X_test))
gap = train_acc - test_acc
print(f"[{name}]")
print(f" 訓練データの正解率: {train_acc:.3f}")
print(f" テストデータの正解率: {test_acc:.3f}")
print(f" 判定: {judge_fit(train_acc, test_acc, gap)}")
実行結果:
[極端に浅い決定木(max_depth=1)]
訓練データの正解率: 0.667
テストデータの正解率: 0.667
判定: 未学習気味(訓練データ自体をうまく捉えられていない)
[浅い決定木(max_depth=2)]
訓練データの正解率: 0.971
テストデータの正解率: 0.889
判定: 過学習気味(訓練は高精度なのにテストで落ちる)
[深い決定木(制限なし)]
訓練データの正解率: 1.000
テストデータの正解率: 0.933
判定: 過学習気味(訓練は高精度なのにテストで落ちる)
max_depth=1 では訓練データすら67%程度しか正しく分類できておらず、これは「未学習(underfitting)」と呼ばれる状態です。木を深くするほど訓練データへの正解率は100%に近づいていきますが、その分テストデータとの差(汎化性能の低下)が生まれやすくなります。
最初、「訓練とテストの正解率の差」だけを見て過学習かどうかを判定するロジックを書いていました。しかし、これだと訓練・テスト両方の正解率が低い「未学習」のケースを見分けられないことに気づきました(差が小さくても、両方低ければ良いモデルとは言えません)。
修正後は、差分に加えて「訓練データの正解率自体が高いか」「テストデータの正解率自体が低いか」もあわせて判定するようにしています。過学習・未学習をひとつの指標だけで判断しようとすると見誤ることがある、という点は実際にロジックを書いてみて初めて気づいたポイントでした。
さらに、1回のtrain/test分割だけだと「たまたま」良い(悪い)結果が出ている可能性があるため、データの分割を5パターン変えて評価する交差検証(Cross Validation)も試しました。
from sklearn.datasets import load_iris
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import cross_val_score
iris = load_iris()
X, y = iris.data, iris.target
scores = cross_val_score(DecisionTreeClassifier(max_depth=2, random_state=42), X, y, cv=5)
print("5分割交差検証の結果(浅い決定木):")
print(f"各分割の正解率: {scores.round(3)}")
print(f"平均: {scores.mean():.3f} (標準偏差: {scores.std():.3f})")
実行結果:
5分割交差検証の結果(浅い決定木):
各分割の正解率: [0.933 0.967 0.9 0.867 1. ]
平均: 0.933 (標準偏差: 0.047)
分割によって0.867〜1.000までばらつきがあり、1回だけの評価では偶然良い(悪い)結果に引っ張られる可能性がある、ということがこの標準偏差からも見て取れます。
実装の全体フロー
ここまでの実装を1つの流れとしてまとめると、以下のようになります。
まとめ
今回、CNNより手前にある古典的な機械学習パターンを、実際に手を動かしながら一通り確認しました。
- 教師あり学習は「答え(ラベル)」がある状態で学習し、回帰(数値予測)と分類(カテゴリ予測)に大別される
- アンサンブル学習は複数モデルの多数決で精度を上げる手法だが、データが簡単すぎる・サンプル数が少ない場合は必ずしも単体モデルに勝てるとは限らない
- 教師なし学習(クラスタリング・次元削減)はラベルを使わずにデータの構造を捉える
- 評価指標はタスクの性質に応じて使い分ける必要がある(Accuracyだけでは不十分な場面がある)
- 過学習・未学習は「訓練とテストの差」だけでなく、それぞれの正解率の水準も合わせて見る必要がある
理論を先に頭に入れるより、小さく実装して数字のズレ(想定と違う結果)に出会うたびに理解が深まる、というのは今回も実感した部分でした。特に4章のアンサンブルの逆転結果と、8章の過学習判定ロジックの見直しは、実装してみないと気づけなかったポイントです。



