パイプライン、交差検証、グリッドサーチを実践で理解する！機械学習モデルチューニング入門

機械学習モデルの性能を最大限に引き出すためには、適切な前処理やアルゴリズムの選定だけでなく、ハイパーパラメータのチューニングやモデル評価の手法を理解し、実践することが不可欠です。本稿では、パイプライン構築・交差検証・グリッドサーチといった代表的な手法を通じて、モデルの改善に欠かせないチューニングのベストプラクティスを実践的に学びます。

- [1 パイプラインの利用](#1-パイプラインの利用)
    - [1.1 パイプラインの実装](#11-パイプラインの実装)
- [2 交差検証](#2-交差検証)
    - [2.1 ホールドアウト法](#21-ホールドアウト法)
    - [2.2 k分割交差検証法](#22-k分割交差検証法)
    - [2.3 交差検証の実装](#23-交差検証の実装)
- [3 学習曲線と検証曲線](#3-学習曲線と検証曲線)
    - [3.1 学習曲線](#31-学習曲線)
    - [3.2 学習曲線の描写](#32-学習曲線の描写)
    - [3.3 検証曲線の描写](#33-検証曲線の描写)
- [4 グリッドサーチ](#4-グリッドサーチ)
    - [4.1 グリッドサーチの実装](#41-グリッドサーチの実装)
    - [4.2 ダブルクロスバリデーションの実装](#41-ダブルクロスバリデーションの実装)
- [5 クラスの不均衡](#5-クラスの不均衡)
    - [5.1 ペナルティの調整](#51-ペナルティの調整)
    - [5.2 少数派クラスのアップサンプリング](#52-少数派クラスのアップサンプリング)
    - [5.3 多数派クラスのダウンサンプリング](#53-多数派クラスのダウンサンプリング)
    - [5.4 訓練データの生成](#54-訓練データの生成)
    - [5.5 不均衡データの対処法まとめ](#55-不均衡データの対処法まとめ)

# 1. パイプラインの利用
以下のように、機械学習における標準化や主成分分析などの特徴量抽出は、学習データとテストデータの両方に適用する必要があります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4072443/b100ab7b-88ad-4e4d-ad46-699b9bdd6efd.png" width="100%">

このような機械学習の構築において、`scikit-learn`の`Pipelineクラス`の活用は非常に一般的かつ推奨されている方法です。パイプラインを利用することで、データの前処理からモデル学習までの一連の処理を1つにまとめることができます。これにより、コードの視認性が向上するため、テストデータに処理を施し忘れるなどのミスが減り、安全にモデル構築を行うことができます。

## 1.1 パイプラインの実装
本記事では[UCI Machine Learning RepositoryのBreast Cancer Wisconsinデータ](https://archive.ics.uci.edu/dataset/17/breast+cancer+wisconsin+diagnostic)を用いて、様々な実装を行なっていきます。このデータは30個の特徴量と1個の目的関数で構成されており、目的変数の0が悪性、1が良性を示します。
<details><summary>データをインポートして、訓練用とテスト用データに分けるコード</summary>

```python
from sklearn.datasets import load_breast_cancer
import pandas as pd
from sklearn.model_selection import train_test_split

# データを読み込む
data = load_breast_cancer()
# pandasのDataFrameに変換
df = pd.DataFrame(data.data, columns=data.feature_names)
df['target'] = data.target

# 特徴量とターゲットに分ける
X = df.loc[:, df.columns != 'target'].values
y = df.loc[:, 'target'].values
# データを訓練用とテスト用に分割
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
```

</details>

本実装では、データを標準化した後にPCAを使って30次元から2次元の部分空間にデータを圧縮します。その後、ロジスティック回帰でモデルを学習させ、予測精度を確認します。これらをパイプラインを使って実装すると

```python
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline

# 標準化、PCA、ロジスティック回帰を組み合わせたパイプラインを作成
pipeline_lr = make_pipeline(StandardScaler(), PCA(n_components=2), LogisticRegression(random_state=42, solver='liblinear'))
# モデルを訓練
pipeline_lr.fit(X_train, y_train)
# 予測結果を取得
# y_prd = pipeline_lr.predict(X_test)
# テストデータのスコアを表示
print(f"Test Accuracy: {pipeline_lr.score(X_test, y_test):.3f} ")
# Test Accuracy: 0.947 
```

もしパイプラインを使わずに同じ実装をすると以下のようになります。
```python
#　上のコードをパイプラインなしで実行
# 標準化
scaler = StandardScaler()
X_train_std = scaler.fit_transform(X_train)
X_test_std = scaler.transform(X_test)
# PCA
pca = PCA(n_components=2)
X_train_pca = pca.fit_transform(X_train_std)
X_test_pca = pca.transform(X_test_std)
# ロジスティック回帰
lr = LogisticRegression(random_state=42, solver='liblinear')
# モデルを訓練
lr.fit(X_train_pca, y_train)
# 予測結果を取得
#y_prd = lr.predict(X_test_pca)
# テストデータのスコアを表示
print(f"Test Accuracy: {lr.score(X_test_pca, y_test):.3f} ")
# Test Accuracy: 0.947 
```
パイプラインありの方がコードが短く、理解しやすい形で書かれていますね。
また、後述する交差検証とも相性が良い書き方になります。`GridSearch`や`cross_val_score`と組み合わせることで、前処理も含めて交差検証が可能になります。

# 2. 交差検証

## 2.1 ホールドアウト法

機械学習の学習を始めたばかりの頃、多くの人はまず「入力データを訓練データ（モデルを学習させるためのデータ）」と「テストデータ（モデルの性能を評価するためのデータ）」の2つに分けて使う方法を学びます。実際、scikit-learnのtrain_test_split関数も、このようにデータを2つに分けることを前提としています。
しかし、実際の機械学習のプロジェクトにおいては、この2分割だけでは不十分であることがわかってきます。というのも、機械学習モデルには大きく分けて2種類のパラメータがあるからです。
1つ目は、モデルが訓練データから自動的に学習するパラメータです。たとえば、ロジスティック回帰の重みなどが該当します。
2つ目は、開発者があらかじめ設定する**ハイパーパラメータ**です。これはモデルの性能に大きな影響を与えるパラメータで、たとえば正則化の強さや、決定木の最大の深さなどがその例です。これらのハイパーパラメータは、適切な値を選ばないと、モデルがうまく機能しないことがあります。
このようなハイパーパラメータの調整を行う作業は、「モデル選択」と呼ばれます。そして、このモデル選択を行うには、訓練データとは別に、モデルの性能を評価するための「検証データ（validation data）」が必要になります。この検証データを使って、さまざまなハイパーパラメータの組み合わせを試し、どの設定が最も良いかを判断します。
私は以前、明示的に検証データを用意しなければならないと勘違いをしていました（初学者用の教科書では、検証データを使用していることを明示的に示すため、検証データを作成するコードを書くことがよくあるため）。しかし、`scikit-learn`に含まれている交差検証のライブラリを用いると、自動で訓練データから検証データを作成してくれます。そのため、検証データを明示的に作成する必要はありません。初学者の人は、以下のようなコードを教科書で見かけることも多いですが、基本的にこのコードを使う必要はないことに注意しましょう。

```python
# 不要な分割を実施しているコード
# 訓練データ：検証データ：テストデータ=8:1:1の割合で分割する
# 1回目：訓練80%、残り20%（val + test）
X_train, X_temp, y_train, y_temp = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
# 2回目：val10%、test10% → X_tempを半分ずつに
X_val, X_test, y_val, y_test = train_test_split(X_temp, y_temp, test_size=0.5, stratify=y_temp, random_state=42)
```


実践的な機械学習では、自動でデータを次の3つに分けて使うことができます。
- 訓練データ：モデルの学習に使用
- 検証データ：ハイパーパラメータの調整やモデルの選択、バイアス・バリアンスのバランスを確認するのに使用
- テストデータ：最終的なモデルの汎化性能（未知のデータに対する性能）の評価に使用

このように、元々の訓練データをさらに「訓練用」と「検証用」に分ける方法を、ホールドアウト法と呼びます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4072443/df7d469f-96f3-44ec-a08e-5ce9ff2903c1.png" width="100%">

このホールドアウト法には欠点があります。それは、元の訓練データを訓練データと検証データに分ける際の分け方が、性能の評価に影響を与える点です。この問題を解決した方法が次に紹介する**k分割交差検証法**（k-fold cross-validation）です。

## 2.2 k分割交差検証法
この方法では、非復元抽出（重複を許さない抽出法）を用いて、訓練データセットをk個に分割します。そして、そのうちのk-1個のデータセットをモデルの学習に使い、1個を性能の評価に使います。この手順をk回繰り返すことで、k個のモデルを取得し、性能を評価します。k=10とすることが一般的とされていますが、データセットのサイズによって変わります。データのサイズが小さい場合は、kの値を大きくすることで汎化性能の向上が期待されますが、kの値を大きくすると、計算コストが大きくなってしまいます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4072443/7717edfc-fd53-4587-ab50-962dea6bc155.png" width="100%">

[出典](https://scikit-learn.org/stable/modules/cross_validation.html)

基本的なk分割交差検証を僅かに改善したものが、**層化k分割交差検証**（stratified k-fold cross-validation）です。この方法では、各サブセットのクラスラベルの比率が維持されるように、データを分割して、k分割交差検証が実行されます。すなわち、元のデータセットにAとBのクラスラベルがあり、この比率が9:1なら、訓練データ・学習データ・テストデータの各データセットに含まれるクラスラベルAとBの比率が9:1になるように、元データを分割するというとです。`scikit-learn`では、`StratifiedKFold`イテレータを使って実行されます。

## 2.3 交差検証の実装
今回の実装では、層化k分割交差検証を実装していく。本来、交差検証法はハイパーパラメータのチューニングに利用されることが多いですが、ここではk=10の時の検証データにおける、モデルのパフォーマンスを評価する実装を行う。ハイパーパラメータをチューニングする実装は、後に行います。

```python
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_val_score

# StratifiedKFoldを使用して層化k分割交差検証を行う
skf = StratifiedKFold(n_splits=10, shuffle=True, random_state=42)
# 前章のパイプラインを使用して交差検証を行う
pipeline_lr = make_pipeline(StandardScaler(), PCA(n_components=2), LogisticRegression(random_state=42, solver='liblinear'))
# 交差検証のスコアを計算。trainデータを用いることで、検証用データにおけるスコアを計算
scores = cross_val_score(pipeline_lr, X_train, y_train, cv=skf, scoring='accuracy')
# 交差検証の結果を表示
print(f"Cross-Validation Results: {[f'{s:.3f}' for s in scores]}")
# Cross-Validation Results: ['0.978', '0.913', '0.957', '0.978', '0.935', '0.956', '0.956', '1.000', '0.978', '0.933']
print(f"Cross-Validation Accuracy: {scores.mean():.3f} ± {scores.std():.3f}")
# Cross-Validation Accuracy: 0.958 ± 0.025
```

# 3 学習曲線と検証曲線
機械学習では、モデルが訓練データを過学習（バリアンスが高い）してしまったり、逆に学習不足（バイアスが高い）になってしまうのを防ぐ必要があります。これらを判断するのに簡単かつ強力なツールとして**学習曲線**（learning curve）と**検証曲線**(validation curve)があります。これらを用いることで、以下の2つの課題を簡単に突き止めることができます。
- モデルのバリアンスとバイアスが高いかどうか
- データをさらに集めることがこの問題の解決に役に立つかどうか

次節からは、学習曲線からバイアス・バリアンスを判断する方法とその解決策を解説します。

# 3.1 学習曲線
まず、バイアス・バリアンスが高い時、学習曲線は以下のようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4072443/b29eef1a-d3da-4a63-821a-346affa7f666.png)

バイアスが高いモデルでは、訓練と検証両方の正解率が低いです。バイアスが高い時の解決策は、モデルのパラメータの個数を増やすことです。具体的には、
- 特徴量を収集して追加するor生成する
- 正則化の強さを下げる

バリアンスが高いモデルにおいては、訓練と検証の正解率に大きささがあります。バリアンスが高い時の解決策は、モデルの複雑さを下げることです。具体的には、
- 訓練データをさらに採集する
- 正則化のパラメータを増やす
- 特徴量選択・抽出により特徴量の個数を減らす

## 3.2 学習曲線の描写
以下に、学習曲線をプロットします。`scikit-learn`の`learning_curve`を使うことで、簡単に学習曲線が描写できます。通常、サンプルサイズの変化で、訓練データと検証データの正解率がどのように変化するかを描写します。今回の場合だと、訓練データのサンプルサイズが大きくなるにつれて、訓練データと検証データの予測精度の差が縮まることから、過学習されておらず汎化性能が担保されたモデルになっていく様子が確認できます。

```python
import matplotlib.pyplot as plt
from sklearn.model_selection import learning_curve
import numpy as np

# モデルパイプラインの作成
# - 標準化 → 主成分分析（2次元）→ ロジスティック回帰
pipeline_lr = make_pipeline(
    StandardScaler(),
    PCA(n_components=2),
    LogisticRegression(random_state=42, solver='liblinear', max_iter=10000)
)

# 学習曲線を描画するためのデータを取得
# - train_sizes: 学習に使う訓練データのサイズ（10段階）
# - train_scores: 各訓練サイズでの訓練データに対するスコア（交差検証）
# - test_scores: 各訓練サイズでの検証データに対するスコア（交差検証）
train_sizes, train_scores, test_scores = learning_curve(
    pipeline_lr,         # 分析対象モデル（パイプライン）
    X_train, y_train,    # 訓練データ
    cv=skf,              # Stratified K-Fold 交差検証
    scoring='accuracy',  # 精度（Accuracy）を指標として使用
    n_jobs=-1,           # 全CPUを使って並列計算
    train_sizes=np.linspace(0.1, 1.0, 10)  # 10%〜100%まで訓練データを増やして評価
)

# 各訓練サイズごとにスコアの平均と標準偏差を計算
train_scores_mean = train_scores.mean(axis=1)
test_scores_mean = test_scores.mean(axis=1)
train_scores_std = train_scores.std(axis=1)
test_scores_std = test_scores.std(axis=1)

# グラフの描画設定
plt.figure(figsize=(10, 6))
plt.title("Learning Curve")
plt.xlabel("Training examples")
plt.ylabel("Score")
plt.grid()
plt.fill_between(train_sizes, train_scores_mean - train_scores_std, train_scores_mean + train_scores_std, alpha=0.1, color="r")
plt.fill_between(train_sizes, test_scores_mean - test_scores_std, test_scores_mean + test_scores_std, alpha=0.1, color="g")
plt.plot(train_sizes, train_scores_mean, 'o-', color="r", label="Training score")
plt.plot(train_sizes, test_scores_mean, 'o-', color="g", label="Cross-validation score")
plt.legend(loc="best")
plt.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4072443/f36ffe76-7b55-4f62-a9a8-1342143afc6d.png)


## 3.3 検証曲線の描写
検証曲線では、通常、モデルのパラメータの値の変化により、どのように訓練データと検証データにおける正解率が変化するかを描写します。`scikit-learn`の`validation_curve`を使うことで、簡単に学習曲線が描写できます。
今回は、ロジスティック回帰における逆正則化パラメータCを変化させます。Cの値が大きくなると、より強く正則化を実施することになります。今回の場合、Cの違いによる正解率の差はごく僅かですが、Cの値を小さくして、正則化を弱めると、モデルが少しだけ過学習に陥ることが確認できます。

```python
from sklearn.model_selection import validation_curve

param_range = [0.001, 0.01, 0.1, 1, 10, 100]
# validation_curveを使用して、パラメータの変化に対するモデルの性能を評価
train_scores, test_scores = validation_curve(
    pipeline_lr,         # 分析対象モデル（パイプライン）
    X_train, y_train,    # 訓練データ
    param_name="logisticregression__C",  # ロジスティック回帰の正則化パラメータ
    param_range=param_range,  # パラメータの範囲
    cv=skf,              # Stratified K-Fold 交差検証
    scoring='accuracy',  # 精度（Accuracy）を指標として使用
    n_jobs=-1            # 全CPUを使って並列計算
)

# 各パラメータごとにスコアの平均と標準偏差を計算
train_scores_mean = train_scores.mean(axis=1)
test_scores_mean = test_scores.mean(axis=1)
train_scores_std = train_scores.std(axis=1)
test_scores_std = test_scores.std(axis=1)
# グラフの描画設定
plt.figure(figsize=(10, 6))
plt.title("Validation Curve")
plt.xlabel("C (Regularization parameter)")
plt.ylabel("Score")
plt.grid()
plt.fill_between(param_range, train_scores_mean - train_scores_std, train_scores_mean + train_scores_std, alpha=0.1, color="r")
plt.fill_between(param_range, test_scores_mean - test_scores_std, test_scores_mean + test_scores_std, alpha=0.1, color="g")
plt.plot(param_range, train_scores_mean, 'o-', color="r", label="Training score")
plt.plot(param_range, test_scores_mean, 'o-', color="g", label="Cross-validation score")
plt.legend(loc="best")
plt.xscale('log')  # x軸を対数スケールに設定
plt.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4072443/3e135d57-4c1f-4fe9-a15a-55ed113657d5.png)

# 4. グリッドサーチ
ここでは、**グリッドサーチ**と呼ばれるハイパーパラメータチューニングの手法を紹介します。この方法を使えば、モデルが検証データを使って半自動的に最適なハイパーパラメータの組み合わせを決定してくれます。
グリッドサーチはしらみつぶしの網羅的探索手法であるので、さまざまなハイパーパラメータのリストを指定してあげると、それらの組み合わせごとにモデルを評価することで、最適な値の組み合わせを見つけるといった手法です。

## 4.1 グリッドサーチの実装
今回の実装では、サポートベクトルマシーン（SVM）のパイプライン訓練とハイパーパラメータチューニングを実行します。
```python
from sklearn.model_selection import GridSearchCV
from sklearn.svm import SVC
# SVCのパラメータをグリッドサーチで最適化
# 'svc__' はパイプライン内のSVCステップを指定する接頭辞
# C：正則化パラメータ（大きいほど学習データに厳密にフィット）
# kernel：カーネル関数の種類（線形 or RBF）
# gamma：RBFカーネルにおけるガウス関数の幅（'scale'や'auto'は自動設定）
param_grid = {
    'svc__C': [0.001, 0.01, 0.1, 1, 10, 100],
    'svc__kernel': ['linear', 'rbf'],
    'svc__gamma': ['scale', 'auto']
}

# SVCを含むパイプラインを作成
pipe_svc = make_pipeline(StandardScaler(), SVC(random_state=42))

# グリッドサーチを実行
grid_search = GridSearchCV(pipe_svc, param_grid, cv=skf, scoring='accuracy', n_jobs=-1)
grid_search.fit(X_train, y_train)
# 最適なパラメータとスコアを表示
print("Best parameters found: ", grid_search.best_params_)
# Best parameters found:  {'svc__C': 0.1, 'svc__gamma': 'scale', 'svc__kernel': 'linear'}
print("Best cross-validation score: {:.3f}".format(grid_search.best_score_))
# Best cross-validation score: 0.980
```

最適化されたハイパーパラメータを使って、テストデータの精度を確認したい場合は、以下を実装します。clfはclassifier（分類器）を意味します。
```python
clf = grid_search.best_estimator_
# テストデータでのスコアを表示
print(f"Test Accuracy: {clf.score(X_test, y_test):.3f} ")
# Test Accuracy: 0.965 
```

## 4.2 ダブルクロスバリデーションの実装
上記では、通常の交差検証を使ったハイパーパラメータチューニングについて解説しました（下図のNon-nested Cross Validation）。
しかし、この方法ではハイパーパラメータのチューニングに検証用データが使われてしまいます。そのため、検証データを使って評価されるモデルの性能が過大評価されてしまう可能性があります。この問題は、サンプルデータが少ないときにより顕著に現れてしまいます。
そこで、下図でダブルクロスバリデーション（Nested Cross Validation）と呼ばれる手法が考えられました。この方法では、外側の交差検証でデータを分割し、そこで分割された学習データのみを用いて新たに内側の交差検証で、ハイパーパラメータチューニングが行われます。その結果得られたモデルを、外側の交差検証で分けられた検証データに適応して性能を評価します。
ダブルクロスバリデーションの方が、より厳密にモデルの汎化性能を評価できますが、実務では通常のクロスバリデーション（Non-nested CV）で十分であると言われています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4072443/a88d35a3-29d9-4cbb-bc70-95d67d4cb8ab.png)
[出典](https://qiita.com/oki_kosuke/items/a09878562a0c87b217c2)

それでは、ダブルクロスバリデーションの実装を行います。今回は、グリッドサーチよりも計算コストが少ない手法である、ランダムサーチを使います。ランダムサーチはグリッドサーチよりも計算コストに優れているが、性能の差はほとんどないという[研究結果](https://jmlr.org/papers/v13/bergstra12a.html)が出ています。

```python
from sklearn.model_selection import RandomizedSearchCV
from sklearn.svm import SVC
# SVCのパラメータをグリッドサーチで最適化
param_grid = {
    'svc__C': [0.001, 0.01, 0.1, 1, 10, 100],
    'svc__kernel': ['linear', 'rbf'],
    'svc__gamma': ['scale', 'auto']
}

# SVCを含むパイプラインを作成
pipe_svc = make_pipeline(StandardScaler(), SVC(random_state=42))

# 入れ子式のグリッドサーチを実行
nested_gs = RandomizedSearchCV(pipe_svc, param_grid, cv=2, scoring='accuracy', n_jobs=-1)
scores = cross_val_score(nested_gs, X_train, y_train, cv=5, scoring='accuracy')
print(f"Nested Cross-Validation Accuracy: {scores.mean():.3f} ± {scores.std():.3f}")
# Nested Cross-Validation Accuracy: 0.976 ± 0.016
```

# 5 クラスの不均衡
実務では、ある1つや複数の特定のクラスのサンプルが過剰に存在するデータセット（クラスの不均衡と呼びます）を扱わなければならないこともあると思います。例えば多数派クラスが90％を占めるデータセットの予測をする場合、全てのテストデータに対して、多数派クラスの値を予測するだけで90％の精度を得ることができます。この場合、もし機械学習モデルを構築しておこなった予測の結果が90％であれば、モデルは何も学習していないのと同義です。
このようなデータセットにおいて、学習を行う際、モデルは多数派クラスの特徴のみを学習してしまいます。そこで、以下のような対策が考えられます。

- 少数派クラスに関する誤った予測に大きなペナルティを課す（`scikit-learn`で`classs_weight='balanced'`にする）
- 少数派クラスのアップサンプリング（`scikit-learn`で`resample`を使う）
- 多数派クラスのダウンサンプリング（`scikit-learn`で`resample`を使う）
- 人工的なデータの生成（`imblearn.over_sampling.SMOTE`が使える）

また評価指標としては、単に正解率を用いるのではなく、別の指標（適合率、再現率、ROC曲線など）を用いると良いです。
以降の実装の前に、元のBreast Cancer Wisconsinデータセットから、不均衡クラスデータを作成しておく。以下のコードを使って、 357個の良性腫瘍（クラス1）と40個の悪性腫瘍（クラス0）をもつデータセットを作ります。
<details><summary>不均衡クラスを作成するコードを表示</summary>

```python
# 不均衡データの作成
X_imb = np.vstack((X[y == 0][:40], X[y == 1]))
y_imb = np.hstack((y[y == 0][:40], y[y == 1]))
print("Unique class labels:", np.unique(y_imb))
# Unique class labels: [0 1]

# 各クラスの出現数を表示
unique, counts = np.unique(y_imb, return_counts=True)
print("Class distribution:")
for label, count in zip(unique, counts):
    print(f"Class {label}: {count} samples")
# Class distribution:
# Class 0: 40 samples
# Class 1: 357 samples
```
</details>

まず、不均衡データをそのまま使って、機械学習モデルを構築し、その性能を確認しましょう。不均衡データに標準化、PCAを実行し、ロジスティック回帰を行うモデルを構築します。
```python
#不均衡データで訓練とテストデータを分割
X_train_imb, X_test_imb, y_train_imb, y_test_imb = train_test_split(X_imb, y_imb, test_size=0.2, stratify=y_imb, random_state=42)

# 不均衡データをそのまま使ってmodelを訓練
pipeline_lr_imb = make_pipeline(StandardScaler(), PCA(n_components=2), LogisticRegression(random_state=42, solver='liblinear'))
# モデルを訓練
pipeline_lr_imb.fit(X_train_imb, y_train_imb)
# 予測結果を取得
y_prd_imb = pipeline_lr_imb.predict(X_test_imb)
# テストデータのスコアを表示
print(f"Test Accuracy (Imbalanced Data): {pipeline_lr_imb.score(X_test_imb, y_test_imb):.3f} ")
from sklearn.metrics import classification_report
# 分類レポートを表示
print(classification_report(y_test_imb, y_prd_imb, target_names=data.target_names))

# 分類レポートを表示
# Test Accuracy (Imbalanced Data): 0.975 
#               precision    recall  f1-score   support

#    malignant       1.00      0.75      0.86         8
#       benign       0.97      1.00      0.99        72

#     accuracy                           0.97        80
#    macro avg       0.99      0.88      0.92        80
```
結果から、malignant（悪性）を陽性とした時の再現率（recall）が低いことがわかります。これは、本当は癌だったけど、癌ではないと診断してしまった人が多かったことを表しています。この場合だと、 8件の陽性患者の2件は陰性と判断してしまいました。
以降では、不均衡データに対する対策を行いモデルの構築を行った際の、予測精度の変化について見ていきましょう。


# 5.1 ペナルティの調整
まず、`scikit-learn`で`classs_weight='balanced'`を設定して実装を行います。
```python
# 少数派クラスの重みを調整してモデルを訓練
pipeline_balanced = make_pipeline(StandardScaler(), PCA(n_components=2), LogisticRegression(class_weight='balanced', random_state=42, solver='liblinear'))
# モデルを訓練
pipeline_balanced.fit(X_train_imb, y_train_imb)
# 予測結果を取得
y_prd_balanced = pipeline_balanced.predict(X_test_imb)
# テストデータのスコアを表示
print(f"Test Accuracy (Balanced Weights): {pipeline_balanced.score(X_test_imb, y_test_imb):.3f} ")
# 分類レポートを表示
print(classification_report(y_test_imb, y_prd_balanced, target_names=data.target_names))

# Test Accuracy (Balanced Weights): 0.938 
#               precision    recall  f1-score   support

#    malignant       0.62      1.00      0.76         8
#       benign       1.00      0.93      0.96        72

#     accuracy                           0.94        80
#    macro avg       0.81      0.97      0.86        80
# weighted avg       0.96      0.94      0.94        80
```

# 5.2 少数派クラスのアップサンプリング
`scikit-learn`で`resample`を使います。`replace=True`とすることで、アップサンプリングが行えます。この手法では、単純に少数派クラスのデータをランダムに選択して、コピー（復元抽出）を行うことで、サンプル数を増やします。
```python
# 少数派クラスのアップサンプリング
from sklearn.utils import resample
# 少数派クラスのインデックスを取得
minority_class = y_train_imb == 0
# 少数派クラスのデータをアップサンプリング
X_minority = X_train_imb[minority_class]
y_minority = y_train_imb[minority_class]
X_minority_upsampled, y_minority_upsampled = resample(X_minority, y_minority, 
                                                      replace=True,     # サンプリングを置き換えありで行う
                                                      n_samples=X_train_imb[~minority_class].shape[0],  # 多数派クラスと同じ数にする
                                                      random_state=42)  # 再現性のための乱数シード
# アップサンプリングした少数派クラスと多数派クラスを結合
X_train_upsampled = np.vstack((X_train_imb[~minority_class], X_minority_upsampled))
y_train_upsampled = np.hstack((y_train_imb[~minority_class], y_minority_upsampled))
# 結合したデータのサイズを表示
print(f"Upsampled Training Data Size: {X_train_upsampled.shape[0]} samples")
# 各クラスの出現数を表示
unique, counts = np.unique(y_train_upsampled, return_counts=True)
print("Upsampled Class Distribution:")
for label, count in zip(unique, counts):
    print(f"Class {label}: {count} samples")
# アップサンプリングしたデータでモデルを訓練
pipeline_upsampled = make_pipeline(StandardScaler(), PCA(n_components=2), LogisticRegression(random_state=42, solver='liblinear'))
# モデルを訓練
pipeline_upsampled.fit(X_train_upsampled, y_train_upsampled)
# 予測結果を取得
y_prd_upsampled = pipeline_upsampled.predict(X_test_imb)
# テストデータのスコアを表示
print(f"Test Accuracy (Upsampled Data): {pipeline_upsampled.score(X_test_imb, y_test_imb):.3f} ")
# 分類レポートを表示
print(classification_report(y_test_imb, y_prd_upsampled, target_names=data.target_names))

# Upsampled Training Data Size: 570 samples
# Upsampled Class Distribution:
# Class 0: 285 samples
# Class 1: 285 samples
# Test Accuracy (Upsampled Data): 0.950 
#               precision    recall  f1-score   support

#    malignant       0.67      1.00      0.80         8
#       benign       1.00      0.94      0.97        72

#     accuracy                           0.95        80
#    macro avg       0.83      0.97      0.89        80
# weighted avg       0.97      0.95      0.95        80
```

# 5.3 多数派クラスのダウンサンプリング
同様に`scikit-learn`で`resample`を使います。`replace=False`とすることで、ダウンサンプリングが行えます。この手法では、少数派クラスのサンプルサイズと同じになるまで、ランダムに多数派クラスのサンプルを削除しています。
```
# 多数派クラスのダウンサンプリング
X_majority_downsampled, y_majority_downsampled = resample(
    X_train_imb[~minority_class], y_train_imb[~minority_class],
    replace=False,
    n_samples=len(y_train_imb[minority_class]),
    random_state=42
)
# ダウンサンプリングした多数派クラスと少数派クラスを結合
X_train_downsampled = np.vstack((X_train_imb[minority_class], X_majority_downsampled))
y_train_downsampled = np.hstack((y_train_imb[minority_class], y_majority_downsampled))
# 結合したデータのサイズを表示
print(f"Downsampled Training Data Size: {X_train_downsampled.shape[0]} samples")
# 各クラスの出現数を表示
unique, counts = np.unique(y_train_downsampled, return_counts=True)
print("Upsampled Class Distribution:")
for label, count in zip(unique, counts):
    print(f"Class {label}: {count} samples")
# ダウンサンプリングしたデータでモデルを訓練
pipeline_downsampled = make_pipeline(StandardScaler(), PCA(n_components=2), LogisticRegression(random_state=42, solver='liblinear'))
# モデルを訓練
pipeline_downsampled.fit(X_train_downsampled, y_train_downsampled)
# 予測結果を取得
y_prd_downsampled = pipeline_downsampled.predict(X_test_imb)
# テストデータのスコアを表示
print(f"Test Accuracy (Downsampled Data): {pipeline_downsampled.score(X_test_imb, y_test_imb):.3f} ")
# 分類レポートを表示
print(classification_report(y_test_imb, y_prd_downsampled, target_names=data.target_names))

# Downsampled Training Data Size: 64 samples
# Upsampled Class Distribution:
# Class 0: 32 samples
# Class 1: 32 samples
# Test Accuracy (Downsampled Data): 0.912 
#               precision    recall  f1-score   support

#    malignant       0.53      1.00      0.70         8
#       benign       1.00      0.90      0.95        72

#     accuracy                           0.91        80
#    macro avg       0.77      0.95      0.82        80
# weighted avg       0.95      0.91      0.92        80
```

# 5.4 訓練データの生成
`imblearn.over_sampling.SMOTE`を使います。この手法では、少数派クラスの近傍のデータをもとに、新しいサンプルを合成します。簡易的な仕組みを説明すると、
1. 少数派データの1点を選ぶ
1. その近くの少数派データ（k個）を探す
1. それらとの間にある点をランダムに生成

```python
#　訓練データを生成
from imblearn.over_sampling import SMOTE
from imblearn.pipeline import make_pipeline as make_pipeline_imb

# SMOTEを使用して少数派クラスを合成
smote = SMOTE(random_state=42)
X_train_resampled, y_train_resampled = smote.fit_resample(X_train_imb, y_train_imb)
# 全体のデータ数
print(f"Resampled dataset size: {X_train_resampled.shape[0]} samples")
# 各クラスの出現数を表示
unique, counts = np.unique(y_train_resampled, return_counts=True)
print("Upsampled Class Distribution:")
for label, count in zip(unique, counts):
    print(f"Class {label}: {count} samples")
# パイプラインを作成
pipeline_manual_smote = make_pipeline(StandardScaler(), PCA(n_components=2), LogisticRegression(random_state=42, solver='liblinear'))
# モデルを訓練
pipeline_manual_smote.fit(X_train_resampled, y_train_resampled)
# 予測結果を取得
y_prd_smote = pipeline_manual_smote.predict(X_test_imb)
# テストデータのスコアを表示
print(f"Test Accuracy (SMOTE): {pipeline_manual_smote.score(X_test_imb, y_test_imb):.3f} ")
# 分類レポートを表示
print(classification_report(y_test_imb, y_prd_smote, target_names=data.target_names))

#　訓練データを生成
from imblearn.over_sampling import SMOTE
from imblearn.pipeline import make_pipeline as make_pipeline_imb

# SMOTEを使用して少数派クラスを合成
smote = SMOTE(random_state=42)
X_train_resampled, y_train_resampled = smote.fit_resample(X_train_imb, y_train_imb)
# 全体のデータ数
print(f"Resampled dataset size: {X_train_resampled.shape[0]} samples")
# 各クラスの出現数を表示
unique, counts = np.unique(y_train_resampled, return_counts=True)
print("Upsampled Class Distribution:")
for label, count in zip(unique, counts):
    print(f"Class {label}: {count} samples")
# パイプラインを作成
pipeline_manual_smote = make_pipeline(StandardScaler(), PCA(n_components=2), LogisticRegression(random_state=42, solver='liblinear'))
# モデルを訓練
pipeline_manual_smote.fit(X_train_resampled, y_train_resampled)
# 予測結果を取得
y_prd_smote = pipeline_manual_smote.predict(X_test_imb)
# テストデータのスコアを表示
print(f"Test Accuracy (SMOTE): {pipeline_manual_smote.score(X_test_imb, y_test_imb):.3f} ")
# 分類レポートを表示
print(classification_report(y_test_imb, y_prd_smote, target_names=data.target_names))

# Resampled dataset size: 570 samples
# Upsampled Class Distribution:
# Class 0: 285 samples
# Class 1: 285 samples
# Test Accuracy (SMOTE): 0.975 
#               precision    recall  f1-score   support

#    malignant       0.80      1.00      0.89         8
#       benign       1.00      0.97      0.99        72

#     accuracy                           0.97        80
#    macro avg       0.90      0.99      0.94        80
# weighted avg       0.98      0.97      0.98        80
```

# 5.5 不均衡データの対処法まとめ

| 対処法 | 特徴 | メリット | デメリット | 実務での活用ポイント |
|--------|------|----------|------------|----------------------|
| 重みの調整| 少数派クラスの誤分類にペナルティを与えることで、学習バランスを取る | - データを変更せずに実装が簡単<br>- 過学習のリスクが少ない | - 極端に不均衡な場合は効果が薄い<br>- モデルによっては非対応（例：KNNなど） | ロジスティック回帰・SVM・決定木など、対応モデルで手軽に試せる初手段 |
| アップサンプリング | 少数派クラスを複製（復元抽出）して、クラス比を均等化 | - データ構造を変えずに学習可能<br>- 実装が簡単 | - 過学習のリスクあり（同じデータを繰り返す）<br>- 学習コストが増える | 少数派クラスのデータがとても少ない場合に、簡単な試行として有効 |
|ダウンサンプリング| 多数派クラスを間引いて少数派とバランスを取る（非復元抽出） | - 学習バランスが取れ、処理が軽い<br>- 過学習しにくい | - 多数派の情報が失われる<br>- データ量が減って精度が落ちることも | 十分なデータがあるときに試すべき選択肢（事前に影響分析が必要） |
|人工データの生成| 少数派クラスの特徴空間を元に新たなサンプルを合成 | - データの多様性を保てる<br>- 複製ではないため過学習しにくい | - ノイズの多いデータでは逆効果も<br>- 高次元データでは近傍の選定が難しい | 多くの実務現場で使われる代表手法。 |

実務のおすすめステップ
1. まずは `class_weight='balanced'` を試す（お手軽）
2. 次に **SMOTE** などの合成手法を検討
3. 状況に応じてアップ／ダウンサンプリングも試して比較評価

# 出典
[Python機械学習プログラミング 達人データサイエンティストによる理論と実践](https://amzn.to/4ktOMjQ)の第6章を参考にしている。

```python
Python version: 3.10.4
numpy version: 2.2.5
pandas version: 2.2.3
scikit-learn version: 1.6.1
matplotlib version: 3.10.1
imblearn version: 0.13.0
```