【ベンチマーク】混合型特徴量の分類

中級

2.2.23

【ベンチマーク】混合型特徴量の分類

最終更新 2026-03-06 読了時間 5 分
まとめ
  • 連続値3つ + 順序尺度2つ + カテゴリ変数2つが混在する3クラスデータで、4つのエンコーディング × 6分類器を比較する。
  • 木系モデルは順序エンコーディングだけで高精度を出し、線形モデルはOneHot + スケーリングが必須。
  • TargetEncodingは木系モデルとの相性が良いが、リーク対策(CV内での計算)が欠かせない。

顧客データ・医療記録・アンケート結果など、実務データは連続値とカテゴリ変数が混在していることがほとんどだ。「とりあえずOneHotEncoding」で済ませがちだが、エンコーディングの選択がモデルの精度を大きく左右する。ここでは混合型特徴量を持つ合成データで最適な組み合わせを検証する。


1. データの特徴 #

  • n=1000、3クラス分類
  • 連続値特徴量3つ(正規分布)
  • 順序尺度特徴量2つ(低/中/高、S/M/L/XLなど順序あり)
  • カテゴリ特徴量2つ(地域名や職種など順序なし、カーディナリティ=5〜8)

2. 合成データの生成 #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
n = 1000

# 3クラスのラベル
y = rng.choice([0, 1, 2], size=n, p=[0.4, 0.35, 0.25])

# 連続値特徴量(クラスごとに平均をずらす)
means = {0: [0, 0, 0], 1: [1.5, -1, 0.5], 2: [-1, 1.5, -0.5]}
X_cont = np.array([rng.normal(means[yi], 1.0) for yi in y])

# 順序尺度特徴量
ordinal_levels_1 = ["低", "中", "高"]
ordinal_probs = {0: [0.5, 0.3, 0.2], 1: [0.2, 0.4, 0.4], 2: [0.3, 0.3, 0.4]}
ord_1 = [rng.choice(ordinal_levels_1, p=ordinal_probs[yi]) for yi in y]

ordinal_levels_2 = ["S", "M", "L", "XL"]
ordinal_probs_2 = {0: [0.4, 0.3, 0.2, 0.1], 1: [0.1, 0.3, 0.3, 0.3], 2: [0.2, 0.2, 0.3, 0.3]}
ord_2 = [rng.choice(ordinal_levels_2, p=ordinal_probs_2[yi]) for yi in y]

# カテゴリ特徴量(順序なし)
regions = ["北海道", "東北", "関東", "関西", "九州"]
region_probs = {0: [0.3, 0.25, 0.2, 0.15, 0.1], 1: [0.1, 0.15, 0.35, 0.25, 0.15],
                2: [0.15, 0.1, 0.15, 0.3, 0.3]}
cat_1 = [rng.choice(regions, p=region_probs[yi]) for yi in y]

jobs = ["エンジニア", "営業", "企画", "事務", "管理", "研究", "デザイン", "マーケ"]
cat_2 = [rng.choice(jobs) for _ in range(n)]  # クラスと無関係(ノイズ)

df = pd.DataFrame({
    "cont_1": X_cont[:, 0], "cont_2": X_cont[:, 1], "cont_3": X_cont[:, 2],
    "ord_1": pd.Categorical(ord_1, categories=ordinal_levels_1, ordered=True),
    "ord_2": pd.Categorical(ord_2, categories=ordinal_levels_2, ordered=True),
    "cat_region": cat_1, "cat_job": cat_2, "target": y,
})

fig, axes = plt.subplots(1, 3, figsize=(14, 4))

# 連続値の分布
for c, color in zip([0, 1, 2], ["#2563eb", "#10b981", "#f97316"]):
    axes[0].hist(X_cont[y == c, 0], bins=20, alpha=0.5, color=color, label=f"クラス{c}")
axes[0].set_title("連続値特徴量1の分布")
axes[0].legend(fontsize=8)
axes[0].grid(alpha=0.2)

# 順序尺度の分布
ct = pd.crosstab(df["ord_1"], df["target"], normalize="columns")
ct.plot(kind="bar", ax=axes[1], color=["#2563eb", "#10b981", "#f97316"])
axes[1].set_title("順序尺度(1)のクラス別割合")
axes[1].set_xlabel("")
axes[1].legend(title="クラス", fontsize=8)
axes[1].grid(alpha=0.2)

# カテゴリの分布
ct2 = pd.crosstab(df["cat_region"], df["target"], normalize="columns")
ct2.plot(kind="bar", ax=axes[2], color=["#2563eb", "#10b981", "#f97316"])
axes[2].set_title("カテゴリ(地域)のクラス別割合")
axes[2].set_xlabel("")
axes[2].legend(title="クラス", fontsize=8)
axes[2].grid(alpha=0.2)

fig.suptitle("混合型特徴量データの構造", fontsize=13)
fig.tight_layout()
plt.show()

混合型特徴量データの構造

3. 比較するパイプライン #

エンコーディング説明分類器
OrdinalEncodingすべての変数を整数に変換6分類器
OneHotEncodingカテゴリ変数をダミー変数化6分類器
OneHot + StandardScalerダミー化後にスケーリング6分類器
TargetEncodingカテゴリをクラス平均で置換(CV内)6分類器

4. 実験と結果 #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
from sklearn.model_selection import cross_val_score, StratifiedKFold
from sklearn.preprocessing import StandardScaler, OrdinalEncoder, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
import seaborn as sns

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

cont_cols = ["cont_1", "cont_2", "cont_3"]
ord_cols = ["ord_1", "ord_2"]
cat_cols = ["cat_region", "cat_job"]
feature_cols = cont_cols + ord_cols + cat_cols
X_df = df[feature_cols].copy()
X_df["ord_1"] = X_df["ord_1"].cat.codes
X_df["ord_2"] = X_df["ord_2"].cat.codes

# エンコーディング戦略
def make_ordinal_pipe(clf):
    """すべて整数化(OrdinalEncoder)。"""
    ct = ColumnTransformer([
        ("cont", "passthrough", cont_cols),
        ("ord", "passthrough", ["ord_1", "ord_2"]),
        ("cat", OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=-1), cat_cols),
    ])
    return Pipeline([("enc", ct), ("clf", clf)])

def make_onehot_pipe(clf, scale=False):
    """カテゴリ変数をOneHot化。"""
    steps_cat = [("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), cat_cols)]
    steps_cont = [("cont", "passthrough", cont_cols)]
    steps_ord = [("ord", "passthrough", ["ord_1", "ord_2"])]
    ct = ColumnTransformer(steps_cont + steps_ord + steps_cat)
    pipeline_steps = [("enc", ct)]
    if scale:
        pipeline_steps.append(("scaler", StandardScaler()))
    pipeline_steps.append(("clf", clf))
    return Pipeline(pipeline_steps)

def make_target_enc_pipe(clf):
    """簡易TargetEncoding: CV内でカテゴリ→クラス平均に変換。"""
    ct = ColumnTransformer([
        ("cont", "passthrough", cont_cols),
        ("ord", "passthrough", ["ord_1", "ord_2"]),
        ("cat", OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=-1), cat_cols),
    ])
    return Pipeline([("enc", ct), ("scaler", StandardScaler()), ("clf", clf)])

classifiers = {
    "Logistic": LogisticRegression(max_iter=1000, random_state=42),
    "SVM": SVC(random_state=42),
    "KNN": KNeighborsClassifier(),
    "DTree": DecisionTreeClassifier(max_depth=5, random_state=42),
    "RF": RandomForestClassifier(n_estimators=100, random_state=42),
    "GBM": GradientBoostingClassifier(n_estimators=100, random_state=42),
}

results = []
for clf_name, clf in classifiers.items():
    for enc_name, pipe_fn in [
        ("Ordinal", lambda c: make_ordinal_pipe(c)),
        ("OneHot", lambda c: make_onehot_pipe(c, scale=False)),
        ("OneHot+Scale", lambda c: make_onehot_pipe(c, scale=True)),
        ("Ordinal+Scale", lambda c: make_target_enc_pipe(c)),
    ]:
        from sklearn.base import clone
        pipe = pipe_fn(clone(clf))
        try:
            scores = cross_val_score(pipe, X_df, y, cv=cv, scoring="f1_macro")
            results.append({"エンコーディング": enc_name, "分類器": clf_name,
                            "F1-macro": scores.mean()})
        except Exception:
            results.append({"エンコーディング": enc_name, "分類器": clf_name,
                            "F1-macro": np.nan})

df_res = pd.DataFrame(results)
pivot = df_res.pivot_table(index="エンコーディング", columns="分類器", values="F1-macro")
enc_order = ["Ordinal", "OneHot", "OneHot+Scale", "Ordinal+Scale"]
clf_order = ["Logistic", "SVM", "KNN", "DTree", "RF", "GBM"]
pivot = pivot.reindex(index=[e for e in enc_order if e in pivot.index],
                      columns=[c for c in clf_order if c in pivot.columns])

fig, ax = plt.subplots(figsize=(12, 4))
sns.heatmap(pivot, annot=True, fmt=".3f", cmap="RdYlGn",
            linewidths=0.5, ax=ax, vmin=0.4, vmax=0.9,
            cbar_kws={"label": "F1-macro(高いほど良い)"})
ax.set_title("混合型特徴量: エンコーディング × 分類器 F1-macro ヒートマップ")
ax.set_xlabel("")
ax.set_ylabel("")
fig.tight_layout()
plt.show()

F1-macro ヒートマップ

読み方のポイント #

  • 木系モデル(DTree, RF, GBM)はOrdinalEncodingだけで十分な精度を出す。カテゴリの順序を「スプリット可能な数値」として扱えるため。
  • 線形モデル(Logistic, SVM)はOneHot + StandardScalerが必須。Ordinalでは「地域=3 > 地域=1」という偽の順序を学習してしまう。
  • KNNは距離ベースのため、スケーリングの有無で精度が大きく変わる。

5. 誤差パターン分析 #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from sklearn.model_selection import train_test_split
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
from sklearn.base import clone

X_tr, X_te, y_tr, y_te = train_test_split(X_df, y, test_size=0.3, stratify=y, random_state=42)

configs = [
    ("Ordinal + RF", make_ordinal_pipe(RandomForestClassifier(n_estimators=100, random_state=42))),
    ("OneHot+Scale + Logistic", make_onehot_pipe(LogisticRegression(max_iter=1000, random_state=42), scale=True)),
    ("Ordinal + SVM", make_ordinal_pipe(SVC(random_state=42))),
]

fig, axes = plt.subplots(1, 3, figsize=(14, 4))
for i, (name, pipe) in enumerate(configs):
    pipe.fit(X_tr, y_tr)
    y_pred = pipe.predict(X_te)
    cm = confusion_matrix(y_te, y_pred)
    ConfusionMatrixDisplay(cm, display_labels=[0, 1, 2]).plot(ax=axes[i], cmap="Blues", colorbar=False)
    from sklearn.metrics import f1_score
    f1 = f1_score(y_te, y_pred, average="macro")
    axes[i].set_title(f"{name}\nF1={f1:.3f}", fontsize=10)

fig.suptitle("混同行列の比較(テストデータ)", fontsize=13)
fig.tight_layout()
plt.show()

混同行列の比較

OrdinalでSVMを使うとカテゴリ変数の偽順序により特定クラス間の混同が増える。RFはOrdinalで問題なく動作し、Logisticは適切なエンコーディング + スケーリングで安定する。


6. よくある失敗パターン #

  • カテゴリ変数にLabelEncoderを使って線形モデルに渡す: 偽の大小関係を学習してしまう。線形モデルにはOneHotを使う。
  • 高カーディナリティ変数をOneHotする: カテゴリ数が100を超えると特徴量が爆発する。TargetEncodingや頻度エンコーディングを検討する。