scikit-learnとXGBoostの使い方

分類モデルの比較で本当に重要なのは、アルゴリズム名よりも実験設計です。どのデータで学習し、どのデータで選び、どのデータで最終確認するかが曖昧なままでは、どんな高精度モデルも信用できません。

scikit-learn の Pipeline と交差検証を土台に置き、ロジスティック回帰と XGBoost を同じ条件で比べると、比較を壊さずに性能差を読む作法が見えてきます。

判定問題としての二値分類

悪性か良性かの判定は、単に当てればよい問題ではありません。どこで比較し、どの段階で test に触れるかまで含めて設計しないと、評価は簡単に過大になります。

流れは、全体像の確認、devtest の分割、dev 内でのベースラインと交差検証、選ばれた 1 モデルだけを test で確認、という順です。この順序が比較の公平性を支えます。

基本語彙

交差検証中の比較は ROC-AUC、最後の運用寄りの確認では F1 や混同行列も見る、と整理しておくと見失いにくくなります。

比較の舞台をそろえる

候補モデルは、同じ dev データ、同じ前処理、同じ CV 設定の上で比べられる必要があります。条件が揃っていなければ、スコア差はモデル差ではなく実験差かもしれません。

比較で避けるべき失敗

途中で test をのぞき、その結果を見ながら特徴量やハイパーパラメータを調整すると、test は評価データではなく開発データに変わってしまいます。最後まで触らないことに意味があります。

環境について

XGBoost が利用できない環境では HistGradientBoostingClassifier を代替として使えます。重要なのは特定ライブラリへの依存ではなく、前処理と比較条件を固定したままモデルを差し替えられる設計です。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import (
    GridSearchCV,
    RandomizedSearchCV,
    StratifiedKFold,
    cross_validate,
    cross_val_predict,
    train_test_split,
)
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
    accuracy_score,
    confusion_matrix,
    f1_score,
    precision_score,
    recall_score,
    roc_auc_score,
)

try:
    from xgboost import XGBClassifier
    XGBOOST_AVAILABLE = True
except ModuleNotFoundError:
    XGBClassifier = None
    XGBOOST_AVAILABLE = False

sns.set_theme(style="whitegrid", context="notebook")
np.random.seed(42)

データの偏りを確認する

件数やクラス比率が大きく崩れていると、精度指標の読み方そのものが変わります。比較の前に分布を見るのは、後のスコア解釈を誤らないためです。

cancer = load_breast_cancer(as_frame=True)
X = cancer.data
y = cancer.target

summary = pd.DataFrame({
    "n_samples": [len(X)],
    "n_features": [X.shape[1]],
    "benign_ratio(class1)": [float(y.mean())],
})
summary

ベースラインから始める

いきなり強いモデルへ進むより、まず単純な基準線を置く方が差の意味を読みやすくします。ロジスティック回帰は弱いモデルではなく、比較の基準を与える整ったモデルです。

class_counts = y.value_counts().sort_index()
class_names = [cancer.target_names[i] for i in class_counts.index]

fig, ax = plt.subplots(figsize=(6, 3.4))
ax.bar(class_names, class_counts.values, color=["#4c78a8", "#f58518"])
ax.set_title("Class Balance")
ax.set_ylabel("count")
plt.tight_layout()
plt.show()

OOF 予測を使うと、各サンプルを未見モデルで評価できます。これにより、CV の中でどの程度安定して順位づけできているかが見えます。

X_dev, X_test, y_dev, y_test = train_test_split(
    X,
    y,
    test_size=0.2,
    random_state=42,
    stratify=y,
)

print("dev shape :", X_dev.shape)
print("test shape:", X_test.shape)
print("dev  benign ratio:", round(float(y_dev.mean()), 3))
print("test benign ratio:", round(float(y_test.mean()), 3))

ROC 曲線は、しきい値を動かしたときに真陽性率と偽陽性率がどう動くかを表します。しきい値を 1 つに固定する前に、順位づけ能力そのものを眺める意義があります。

primary_metric = "roc_auc"
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

baseline_lr = Pipeline([
    ("scaler", StandardScaler()),
    ("model", LogisticRegression(max_iter=4000, random_state=42)),
])

baseline_cv = cross_validate(
    baseline_lr,
    X_dev,
    y_dev,
    cv=cv,
    scoring={"roc_auc": "roc_auc", "f1": "f1"},
    n_jobs=-1,
    return_train_score=False,
)

baseline_summary = {
    "roc_auc_mean": float(np.mean(baseline_cv["test_roc_auc"])),
    "roc_auc_std": float(np.std(baseline_cv["test_roc_auc"])),
    "f1_mean": float(np.mean(baseline_cv["test_f1"])),
    "f1_std": float(np.std(baseline_cv["test_f1"])),
}

pd.Series(baseline_summary).round(4)

XGBoost や勾配ブースティングは、非線形な分岐を積み重ねて複雑な境界を作れます。ただし、強いモデルほど実験設計の粗さも増幅するため、比較条件の固定はむしろ厳密であるべきです。

print("ROC-AUC mean/std:", round(baseline_summary["roc_auc_mean"], 4), "/", round(baseline_summary["roc_auc_std"], 4))
print("F1      mean/std:", round(baseline_summary["f1_mean"], 4), "/", round(baseline_summary["f1_std"], 4))
print("std が小さいほど、分割によるブレが小さく安定していると解釈できます。")

勝ったモデルだけを test で確認する

最終 test は、開発過程で選ばれた 1 つのモデルに対する最終確認です。複数モデルを test で見比べ始めた時点で、その test はもはや未見データではありません。

baseline_oof_pred = cross_val_predict(
    baseline_lr,
    X_dev,
    y_dev,
    cv=cv,
    method="predict",
    n_jobs=-1,
)
cm = confusion_matrix(y_dev, baseline_oof_pred)

fig, ax = plt.subplots(figsize=(5, 4.2))
sns.heatmap(
    cm,
    annot=True,
    fmt="d",
    cmap="Blues",
    cbar=False,
    ax=ax,
    xticklabels=class_names,
    yticklabels=class_names,
)
ax.set_title("Baseline Logistic (CV/OOF Confusion Matrix)")
ax.set_xlabel("Predicted")
ax.set_ylabel("Actual")
plt.tight_layout()
plt.show()

混同行列や F1 を test で見る理由は、ROC-AUC が良くても、実運用では特定クラスの取りこぼしが問題になることがあるからです。順位づけの良さと意思決定の良さは一致しません。

lr_search = GridSearchCV(
    estimator=Pipeline([
        ("scaler", StandardScaler()),
        ("model", LogisticRegression(max_iter=5000, random_state=42, solver="liblinear")),
    ]),
    param_grid={
        "model__penalty": ["l1", "l2"],
        "model__C": [0.01, 0.1, 0.5, 1.0, 3.0, 10.0],
    },
    cv=cv,
    scoring=primary_metric,
    n_jobs=-1,
)
lr_search.fit(X_dev, y_dev)

print("best lr params:", lr_search.best_params_)
print(f"best lr cv {primary_metric}:", round(lr_search.best_score_, 4))

公平な比較を作るには、データ分割、前処理、交差検証、最終確認の順序を守る必要があります。強いモデルを知る前に、比較を壊さない作法を身につけることの方が先です。

if XGBOOST_AVAILABLE:
    xgb_search = RandomizedSearchCV(
        estimator=XGBClassifier(
            eval_metric="logloss",
            random_state=42,
        ),
        param_distributions={
            "n_estimators": [150, 250, 400, 600],
            "max_depth": [2, 3, 4, 5, 6],
            "learning_rate": [0.01, 0.03, 0.05, 0.1],
            "subsample": [0.7, 0.8, 0.9, 1.0],
            "colsample_bytree": [0.6, 0.8, 1.0],
            "reg_lambda": [0.5, 1.0, 3.0, 10.0],
            "min_child_weight": [1, 3, 5],
        },
        n_iter=20,
        scoring=primary_metric,
        cv=cv,
        random_state=42,
        n_jobs=-1,
    )
    xgb_search.fit(X_dev, y_dev)

    print("best xgb params:", xgb_search.best_params_)
    print(f"best xgb cv {primary_metric}:", round(xgb_search.best_score_, 4))
else:
    xgb_search = None
    print("xgboost が未導入のため、XGBoost探索をスキップしました。")

候補モデルの勝ち負けは、最後の test ではなく CV で決めます。test は勝者 1 人の最終確認だけに使う。この役割分担を守ると、実験の結論がかなりぶれにくくなります。

candidates = {
    "Logistic (baseline)": baseline_summary[f"{primary_metric}_mean"],
    "Logistic (tuned)": float(lr_search.best_score_),
}
if xgb_search is not None:
    candidates["XGBoost (tuned)"] = float(xgb_search.best_score_)

cv_compare = pd.DataFrame({
    "model": list(candidates.keys()),
    f"cv_{primary_metric}": list(candidates.values()),
}).sort_values(f"cv_{primary_metric}", ascending=False)
cv_compare

勝ったモデルだけを test で確認する

ここで初めて、選ばれたモデルを test に触れさせます。見るのは 1 つのスコアだけではなく、Precision / Recall / F1 と混同行列です。現場では「何をどれだけ取り逃したか」が重要になることが多いからです。

winner_name = cv_compare.iloc[0]["model"]
if winner_name == "Logistic (baseline)":
    final_model = baseline_lr
elif winner_name == "Logistic (tuned)":
    final_model = lr_search.best_estimator_
else:
    final_model = xgb_search.best_estimator_

final_model.fit(X_dev, y_dev)
final_pred = final_model.predict(X_test)
final_proba = final_model.predict_proba(X_test)[:, 1]

final_metrics = {
    "accuracy": accuracy_score(y_test, final_pred),
    "precision": precision_score(y_test, final_pred),
    "recall": recall_score(y_test, final_pred),
    "f1": f1_score(y_test, final_pred),
    "roc_auc": roc_auc_score(y_test, final_proba),
}

print("selected model:", winner_name)
pd.Series(final_metrics).round(4)

混同行列は、単なる accuracy では見えない誤り方を見せてくれます。どのクラスを取り違えやすいのかを見ると、改善の方向が具体化します。

cm_test = confusion_matrix(y_test, final_pred)
fig, ax = plt.subplots(figsize=(5, 4.2))
sns.heatmap(
    cm_test,
    annot=True,
    fmt="d",
    cmap="Greens",
    cbar=False,
    ax=ax,
    xticklabels=class_names,
    yticklabels=class_names,
)
ax.set_title(f"Final Model on Test ({winner_name})")
ax.set_xlabel("Predicted")
ax.set_ylabel("Actual")
plt.tight_layout()
plt.show()

特徴量重要度や係数を見るのは、精度確認のあとです。まず比較を壊さずに終えること、そのうえで「どの特徴が効いていそうか」を読む順番にした方が混乱しません。

if xgb_search is not None and winner_name == "XGBoost (tuned)":
    importances = pd.Series(final_model.feature_importances_, index=X.columns).sort_values(ascending=False).head(12)
    fig, ax = plt.subplots(figsize=(7.2, 5))
    sns.barplot(x=importances.values, y=importances.index, orient="h", ax=ax, color="#4c78a8")
    ax.set_title("Top 12 Feature Importances (Final XGBoost)")
    ax.set_xlabel("importance")
    ax.set_ylabel("feature")
    plt.tight_layout()
    plt.show()
else:
    print("最終モデルがXGBoostではないか、xgboost未導入のため重要度表示をスキップしました。")

このノートで本当に持ち帰るべきなのは、scikit-learn は実験手順を再現可能に保つ土台であり、XGBoost はその土台の上で強い候補になりやすい、という関係です。アルゴリズム名より、比較の設計を守ることの方が重要です。