Day 070 — EDA体系的手法② — カテゴリ変数の分布・目的変数との関係

2026-06-20 水 / Phase 3 分析 カーディナリティ / groupby / Target Encoding / 希少カテゴリ

📚 背景知識(読んでから問題へ)

🎯
Day 070 — Phase 3 継続: 昨日は数値変数の歪度・尖度を学んだ。今日はカテゴリ変数のEDAを体系的に習得する。カテゴリ変数は「ただのラベル」ではなく、エンコーディング戦略次第でスコアが大きく変わる。

カテゴリ変数EDAの3ステップ

1. 単変量分析
nunique / value_counts
カーディナリティ確認・出現頻度・希少カテゴリの検出。エンコーディング戦略の入口。
2. 二変量分析
groupby + median
グループ別の目的変数統計量(mean/median)・ボックスプロット。変数の予測力を把握。
3. 戦略決定
Encoding 選択
OHE / Label / Target / Frequency。カーディナリティと順序性でルール化できる。
4. 希少カテゴリ処理
Rare → "Rare"
5件以下は "Rare" にまとめる。モデルが学習できないサンプルを排除。

🔢 カーディナリティ(ユニーク値の数)

House Prices のカテゴリ変数カーディナリティ一覧。高いほど OHE は危険。

Neighborhood
25 ユニーク値 — 高カーディナリティ
25
GarageType
7
7
SaleCondition
6
6
Foundation
6
6
MSZoning
5
5
BldgType
5
5
KitchenQual
5
5
HeatingQC
5
5
ExterQual
4
4
💡
Neighborhood は25カテゴリ → OHEで25列追加。
1460サンプルで25列 = 各列平均58サンプル。希少Neighborhoodは数件しかないため、Target Encodingが有効。

⚙️ エンコーディング戦略の決定木

カテゴリ変数 エンコーディング選択 順序性がある?(Ex>Gd>TA…) Yes No Ordinal Encoding KitchenQual, ExterQual 等 カーディナリティ > 10? No (OHE) Yes One-Hot Encoding MSZoning, BldgType 等 Target / Freq Neighborhood 等
変数カーディナリティ順序性推奨エンコーディング
Neighborhood 25 なし Target Encoding
KitchenQual 5 あり (Po→Ex) Ordinal Encoding
ExterQual 4 あり (Fa→Ex) Ordinal Encoding
MSZoning 5 なし One-Hot Encoding
BldgType 5 なし One-Hot Encoding
SaleCondition 6 なし OHE / 二値化
GarageType 7 なし OHE (None含む)
Foundation 6 なし One-Hot Encoding

🏘️ Neighborhood 別 SalePrice 中央値(上位 / 下位)

同じ床面積・品質でも地区で価格が大きく変わる。最高地区 vs 最低地区で約3〜4倍の差。

高価格地区 TOP 5

NoRidge
TOP 1
~335,000
NridgHt
~320,000
StoneBr
~310,000
Timber
~280,000
Veenker
~265,000

低価格地区 BOTTOM 5

MeadowV
BOTTOM 1
~90,000
IDOTRR
~100,000
BrDale
~105,000
Edwards
~115,000
BrkSide
~120,000
💡
最高地区 (NoRidge) と最低地区 (MeadowV) の中央値比率 = 約3.7倍
Neighborhood はサイズ・品質と並ぶ最重要特徴量。Target Encodingで「価格帯情報」として1列に凝縮するのが上位解法の定石。

📊 OverallQual × SalePrice — 品質スコアと価格の関係

品質スコア(1〜10)ごとのSalePrice中央値。単調増加なら Ordinal Encoding が有効。

400k 300k 200k 100k 1 2 3 4 5 6 7 8 9 10 OverallQual (1〜10) 単調増加 → Ordinal Encoding が有効
📈
品質スコアが上がるにつれて価格が単調増加 → Ordinal Encoding(数値に変換)の妥当性を確認できる。
スコア1〜4: 低品質帯(〜10万ドル)/ スコア7〜10: 高品質帯(20〜40万ドル超)。品質10はほぼ2倍以上。

🗄️ データスキーマ(House Prices カテゴリ変数)

列名データ型カーディナリティ説明推奨エンコーディング
Neighborhood object 25 近隣地区(最重要特徴量の一つ) Target Encoding
KitchenQual object 5 キッチン品質 (Po/Fa/TA/Gd/Ex) Ordinal (0-4)
ExterQual object 4 外装品質 (Fa/TA/Gd/Ex) Ordinal (0-3)
MSZoning object 5 用途地域 (RL/RM/FV/C/RH) OHE
BldgType object 5 建物タイプ (1Fam/Twnhs等) OHE
SaleCondition object 6 売却条件 (Normal/Partial等) OHE
GarageType object 7 ガレージ種別 (None含む) OHE + None処理
Foundation object 6 基礎構造 (PConc/CBlock等) OHE
HeatingQC object 5 暖房品質 (Po/Fa/TA/Gd/Ex) Ordinal (0-4)

問題

House Prices データセットのカテゴリ変数を体系的に分析してください。

  1. カテゴリ変数(object 型)を全て抽出し、カーディナリティ(ユニーク数)を降順で表示する
  2. カーディナリティが高い変数(ユニーク数 > 10)と低い変数(≤ 10)を分類する
  3. Neighborhood(近隣地区)ごとの SalePrice の中央値を計算し、価格差の大きい上位5地区・下位5地区を表示する
  4. OverallQual(総合品質)と SalePrice の関係を groupby で分析し、品質スコアが上がるにつれて価格がどう変わるかを確認する
  5. 出現頻度が 5件以下の希少カテゴリを持つ変数とその希少カテゴリを一覧表示する
📝
使用データ: House Prices カテゴリ変数のシミュレーションデータ(模範解答内でサンプル生成)。実際のデータがある場合は pd.read_csv("train.csv") で読み込む。

💡 ヒント

ヒント1 — 方向性

select_dtypes(include='object') でカテゴリ変数を抽出できます。まず各変数のユニーク数を把握してからグループ分析に進みましょう。

ヒント2 — アプローチ

  • df.select_dtypes(include='object').nunique().sort_values(ascending=False) でカーディナリティ確認
  • df.groupby('Neighborhood')['SalePrice'].median().sort_values(ascending=False) で地区別中央値
  • value_counts()[value_counts() <= 5] で希少カテゴリ抽出

ヒント3 — コード骨格

import numpy as np
import pandas as pd

# カテゴリ変数のカーディナリティ
cat_cols = df.select_dtypes(include='object').columns
cardinality = df[cat_cols].nunique().sort_values(ascending=False)

# 地区別SalePrice中央値
neighborhood_median = (df.groupby('Neighborhood')['SalePrice']
                         .median().sort_values(ascending=False))
print("高価格地区Top5:", neighborhood_median.head(5))
print("低価格地区Bottom5:", neighborhood_median.tail(5))

# 希少カテゴリの検出
for col in cat_cols:
    vc = df[col].value_counts()
    rare = vc[vc <= 5]
    if len(rare) > 0:
        print(f"{col}: 希少カテゴリ {rare.index.tolist()}")

模範解答

import numpy as np
import pandas as pd

# ── データ準備(House Prices カテゴリ変数シミュレーション)──────────────────
np.random.seed(42)
n = 1460

neighborhoods = [
    'NoRidge','NridgHt','StoneBr','Timber','Veenker',
    'Somerst','ClearCr','Crawfor','CollgCr','Blmngtn',
    'Gilbert','NWAmes','SawyerW','Mitchel','NAmes',
    'NPkVill','SWISU','Blueste','Sawyer','OldTown',
    'BrkSide','Edwards','BrDale','IDOTRR','MeadowV'
]
price_by_neighborhood = {
    'NoRidge': 335000, 'NridgHt': 320000, 'StoneBr': 310000,
    'Timber': 280000, 'Veenker': 265000, 'Somerst': 250000,
    'ClearCr': 240000, 'Crawfor': 220000, 'CollgCr': 210000,
    'Blmngtn': 205000, 'Gilbert': 200000, 'NWAmes': 190000,
    'SawyerW': 185000, 'Mitchel': 180000, 'NAmes': 170000,
    'NPkVill': 165000, 'SWISU': 155000, 'Blueste': 150000,
    'Sawyer': 145000, 'OldTown': 130000, 'BrkSide': 120000,
    'Edwards': 115000, 'BrDale': 105000, 'IDOTRR': 100000,
    'MeadowV': 90000
}
nbh_weights = np.array([2,3,2,3,1, 4,2,4,8,3, 5,5,5,5,12, 2,3,1,5,8, 6,7,3,5,3], dtype=float)
nbh_weights /= nbh_weights.sum()
nbh_list = np.random.choice(neighborhoods, size=n, p=nbh_weights)
sale_prices = np.array([
    max(50000, int(np.random.normal(price_by_neighborhood[nbh], 40000)))
    for nbh in nbh_list
])
data = {
    'SalePrice':      sale_prices,
    'Neighborhood':   nbh_list,
    'MSZoning':       np.random.choice(['RL','RM','C (all)','FV','RH'],
                                        n, p=[0.79,0.11,0.03,0.04,0.03]),
    'BldgType':       np.random.choice(['1Fam','TwnhsE','Duplex','Twnhs','2fmCon'],
                                        n, p=[0.84,0.07,0.04,0.03,0.02]),
    'KitchenQual':    np.random.choice(['Ex','Gd','TA','Fa','Po'],
                                        n, p=[0.15,0.41,0.38,0.05,0.01]),
    'SaleCondition':  np.random.choice(['Normal','Partial','Abnorml','Family','Alloca','AdjLand'],
                                        n, p=[0.82,0.09,0.06,0.01,0.01,0.01]),
    'ExterQual':      np.random.choice(['Ex','Gd','TA','Fa'],
                                        n, p=[0.14,0.45,0.38,0.03]),
    'HeatingQC':      np.random.choice(['Ex','Gd','TA','Fa','Po'],
                                        n, p=[0.50,0.16,0.29,0.04,0.01]),
    'GarageType':     np.random.choice(['Attchd','Detchd','BuiltIn','CarPort','None','Basment','2Types'],
                                        n, p=[0.59,0.27,0.07,0.02,0.04,0.009,0.001]),
    'Foundation':     np.random.choice(['PConc','CBlock','BrkTil','Wood','Slab','Stone'],
                                        n, p=[0.44,0.42,0.10,0.01,0.016,0.004]),
    'OverallQual':    np.random.choice(range(1,11), n,
                                        p=[0.01,0.01,0.03,0.05,0.08,0.17,0.25,0.23,0.12,0.05]),
}
qual_multiplier = {1:0.4, 2:0.5, 3:0.6, 4:0.7, 5:0.8, 6:0.9, 7:1.0, 8:1.2, 9:1.5, 10:1.9}
for i in range(n):
    q = data['OverallQual'][i]
    data['SalePrice'][i] = int(data['SalePrice'][i] * qual_multiplier[q] * np.random.uniform(0.85, 1.15))
df = pd.DataFrame(data)

# ── Step 1: カーディナリティ確認 ─────────────────────────────────────
print("Step 1: カテゴリ変数のカーディナリティ(ユニーク数)")
print("="*60)
cat_cols = df.select_dtypes(include='object').columns
cardinality = df[cat_cols].nunique().sort_values(ascending=False)
for col, cnt in cardinality.items():
    tag = "🔴 高カーディナリティ" if cnt > 10 else "🟢 低カーディナリティ"
    print(f"  {col:<20} {cnt:>3} ユニーク値  {tag}")

# ── Step 2: カーディナリティ分類 ──────────────────────────────────────
print("\nStep 2: カーディナリティ分類")
high_card = cardinality[cardinality > 10].index.tolist()
low_card  = cardinality[cardinality <= 10].index.tolist()
print(f"高カーディナリティ (>10):  {high_card}")
print(f"低カーディナリティ (<=10): {low_card}")

# ── Step 3: Neighborhood別 SalePrice中央値 ────────────────────────────
print("\nStep 3: Neighborhood別 SalePrice中央値")
nbh_median = (df.groupby('Neighborhood')['SalePrice']
                .agg(['median','mean','count'])
                .sort_values('median', ascending=False))
nbh_median.columns = ['中央値', '平均値', 'サンプル数']
print("高価格地区 TOP 5:"); print(nbh_median.head(5).to_string())
print("低価格地区 BOTTOM 5:"); print(nbh_median.tail(5).to_string())
print(f"最高/最低 中央値比率: {nbh_median['中央値'].iloc[0] / nbh_median['中央値'].iloc[-1]:.1f}倍")

# ── Step 4: OverallQual × SalePrice ──────────────────────────────────
print("\nStep 4: OverallQual(品質スコア)別 SalePrice統計")
qual_stats = (df.groupby('OverallQual')['SalePrice']
                .agg(['median','mean','count'])
                .rename(columns={'median':'中央値','mean':'平均値','count':'件数'}))
print(qual_stats.to_string())

# ── Step 5: 希少カテゴリの検出 ────────────────────────────────────────
print("\nStep 5: 希少カテゴリ(出現5件以下)")
for col in cat_cols:
    vc = df[col].value_counts()
    rare = vc[vc <= 5]
    if len(rare) > 0:
        for cat, cnt in rare.items():
            print(f"  [{col}] '{cat}': {cnt}件 ({cnt/n*100:.2f}%)")

🪜 Step-by-Step 解説

1
カーディナリティを把握する理由
カーディナリティ = ユニーク値の数。Neighborhood のように25カテゴリある場合、OHEで25列が追加される。 1460サンプルで各列平均58件 → 希少カテゴリは数件しかない。モデルが「あっても無くても同じ」と判断するリスク大。
2
高/低カーディナリティで戦略を分岐
>10: Target Encoding / Frequency Encoding
≤10 + 順序性あり: Ordinal Encoding
≤10 + 順序性なし: One-Hot Encoding
この3分岐でほぼ全カテゴリ変数の処理が決まる。
3
groupby + median で目的変数との関係を可視化
df.groupby('Neighborhood')['SalePrice'].median()
中央値(median)を使う理由: 住宅価格は右歪み → 外れ値が平均を引き上げる。中央値が「代表価格」をより正確に表す。 地区別の価格差が大きいほど、Target Encodingの効果が高い。
4
OverallQual の単調増加を確認 → Ordinal の妥当性検証
品質スコアと価格が単調増加していれば、数値に変換(0〜9など)してもモデルが正しく「品質が高い=価格が高い」を学習できる。 もし非単調(品質5より品質3の方が高価格)なら Ordinal は不適切で OHE が必要。
5
希少カテゴリの処理 — "Rare" グルーピング
5件以下のカテゴリはモデルが学習不十分。解決策:
vc = df[col].value_counts() で頻度確認
df[col] = df[col].where(vc[df[col]] > 5, "Rare") で "Rare" に置換
これにより希少カテゴリが1つの "Rare" グループに統合される。
⚠️
Target Encoding のリーク注意: 全データで fit してから CV を行うと、テストデータの情報が訓練に混入(data leakage)。必ず CV の fold 内 で fit する (KFold + transform)。
sklearn の TargetEncoder (v1.3+) は自動でスムージング処理が入る。

🔢 数学・統計の補足(文系向け)

カーディナリティと次元の呪いの直感

Neighborhood が25カテゴリの場合、OHEで25列追加される。
1460サンプル ÷ 25列 = 各列平均 58サンプル
さらに希少カテゴリが5件だとその列は 0.3% の1しか持たない → モデルがほぼ学習できない。

Target Encoding の直感

「Neighborhood = NoRidge の家の平均価格は 335,000ドル」という情報を 1つの数値に変換する手法。
高カーディナリティでも情報量を保ちつつ1列に収められる。
数式で書くと: encoding(カテゴリ c) = mean(SalePrice | Neighborhood = c)
ただし過学習(リーク)防止のため、スムージング(全体平均との加重平均)が一般的。

中央値 vs 平均値

指標特徴適した場面
平均値 (mean) 外れ値に敏感。右歪みで高くなる 外れ値が少ない・対称分布
中央値 (median) 外れ値に頑健。50%点の値 住宅価格・年収など右歪みデータ ✓

🏆 Kaggleでの実践的な使い方

カテゴリEDA 標準ワークフロー

1
df.select_dtypes(include='object').nunique() — カーディナリティ一覧
2
高カーディナリティ変数を特定 → Target/Frequency Encodingを選択
3
groupby + median で目的変数との関係を確認・可視化
4
希少カテゴリを "Rare" にグルーピング(5件以下が目安)
5
順序性のあるカテゴリ(KitchenQual等)を Ordinal に変換
6
残りの低カーディナリティ変数を OHE → Pipeline に組み込み

代表的なコンペでの活用例

コンペ高カーディナリティ変数採用エンコーディング
House Prices Neighborhood (25) Target Encoding
Ames Housing Neighborhood, MSSubClass Target Encoding
TalkingData app, device, os (百万単位) Frequency Encoding
Tabular Playground 多数のカテゴリ変数 CatBoost(自動処理)

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
全カテゴリ変数をOHE 「とりあえずOHE」という先入観 高カーディナリティはTarget/Freq Encodingを検討
Target Encodingをfull dataでfit 早く実装したい CVのfoldごとにfit → リーク防止 ⚠️
希少カテゴリをそのまま放置 サンプル数を確認していない "Rare"にまとめるか削除
順序変数をOHEしてしまう カテゴリ=OHEと思い込む KitchenQual (Po<Fa<TA<Gd<Ex) はOrdinalが適切
Neighborhoodの価格差を見ない 変数の重要性を軽視 Neighborhoodは最重要特徴量のひとつ。EDAで必ず確認
groupbyにmeanを使う 平均が一般的と思っている 住宅価格は右歪みなのでmedianが代表値として適切

🚀 次のステップ

  • 発展: EDA体系的手法③ — 欠損値パターンの可視化・補完戦略(Day 071)
  • 次回予告: missingno ライブラリ・欠損の MAR/MNAR/MCAR 分類・SimpleImputer vs IterativeImputer
  • 実践課題: Kaggle の House Prices データをダウンロードして Neighborhood の Target Encoding を実装してみる(sklearn.preprocessing.TargetEncoder

📝 自己評価(解いた後に記入)

理解度チェック:

自分の回答・気づき・メモ: