Day 071 — EDA体系的手法③ — 欠損値パターンの分析と可視化

2026-06-21 水 / Phase 3 コーディング MCAR / MAR / MNAR / 欠損フラグ / groupby補完

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

🎯
Day 071 — Phase 3 継続: EDA第3弾は欠損値の徹底調査。「とりあえず平均で埋める」だけでは上位に入れない。なぜ欠損しているのかを理解することが正しい補完戦略の出発点。

欠損値EDAの5ステップ

1. 全体俯瞰
isnull().mean()
変数ごとの欠損率(%)を降順でリスト化。何が問題かを最初に把握する。
2. パターン確認
sum(axis=1)
複数変数が同時に欠損する「共欠損」の発見。Garage系・Basement系は同時欠損が多い。
3. 目的変数との関係
欠損フラグ × SalePrice
欠損フラグ(0/1)を作って目的変数との相関を見る。MNAR の証拠になる。
4. 他変数との関係
groupby / boxplot
欠損している行の他変数の分布が非欠損と異なるか確認。補完戦略の根拠になる。

🔍 欠損値の3タイプ(MCAR / MAR / MNAR)

MCAR Missing Completely At Random 完全ランダム欠損 例: システム障害でランダム記録漏れ → 単純補完でOK MAR Missing At Random 条件付きランダム欠損 グループA グループB 例: 女性は体重を非回答しやすい → グループ別補完が有効 MNAR ⚠️ Missing Not At Random 非ランダム欠損(最危険) 例: 高所得者は年収を非公開 欠損自体がシグナル! → 欠損フラグを特徴量に追加
タイプHouse Prices での例欠損の理由推奨対応
MNAR PoolQC (99.5%欠損) プールのない物件 → 欠損 "None"カテゴリとして扱う
MNAR GarageType (5.5%欠損) ガレージなし物件 → 欠損 "None" + 数値列は0
MAR LotFrontage (17.7%欠損) 近隣地区で欠損傾向が異なる Neighborhood別中央値で補完
MCAR Electrical (0.07%) 1件のみ → 記録漏れ 最頻値で補完

📊 House Prices 欠損率ランキング(上位10変数)

赤(>50%): 施設「なし」を意味する、オレンジ(10-50%): 真の欠損または条件付き、青(<10%): 軽微な欠損

PoolQC
99.5% — プールなし物件
99.5%
MiscFeature
96.3% — 特殊設備なし
96.3%
Alley
93.8% — 路地アクセスなし
93.8%
Fence
80.8% — フェンスなし
80.8%
FireplaceQu
47.3% — 暖炉なし
47.3%
LotFrontage
17.7% — 真のMissing
17.7%
GarageType
5.5%
5.5%
GarageYrBlt
5.5%
5.5%
GarageFinish
5.5%
5.5%
BsmtQual
2.5%
2.5%
💡
Garage系は全て同じ欠損率(5.5%) — これは「ガレージのない81物件」が一斉に欠損しているサイン。MNAR の典型例。

🔗 共欠損パターン — Garage系の同時欠損

Garage系6変数が 同時に 欠損している行数の分布。「施設なし」パターンの証拠。

Garage系変数の同時欠損数(0=欠損なし, 6=全変数欠損)

0変数欠損(全て存在)
1,379行
1,379行
6変数同時欠損(ガレージなし)
81行
81行
1〜5変数欠損(中間)
≈0行
⚠️
「0個欠損 or 全部欠損」に二極化している → MNAR の証拠。部分欠損がほぼ存在しない = ガレージの有無という構造的理由。この場合は「None」で統一補完が最善。

LotFrontage 欠損フラグ × SalePrice の関係

$0 $150k $250k $400k LotFrontage あり 中央値 ≈ $183k LotFrontage 欠損 中央値 ≈ $163k

欠損グループの方が価格が低い傾向 → MNAR の可能性あり → 欠損フラグを特徴量に

🗂️ 主要欠損変数のスキーマ

変数名欠損率欠損の理由補完戦略
PoolQC カテゴリ 99.5% プールなし物件 (MNAR) fillna("None")
MiscFeature カテゴリ 96.3% 特殊設備なし (MNAR) fillna("None")
Alley カテゴリ 93.8% 路地なし (MNAR) fillna("None")
Fence カテゴリ 80.8% フェンスなし (MNAR) fillna("None")
LotFrontage 数値 17.7% 真の欠損 (MAR) Neighborhood別中央値
GarageType カテゴリ 5.5% ガレージなし (MNAR) fillna("None")
GarageYrBlt 数値 5.5% ガレージなし → 年が存在しない fillna(0)
BsmtQual カテゴリ 2.5% 地下室なし (MNAR) fillna("None")
Electrical カテゴリ 0.07% 記録漏れ1件 (MCAR) 最頻値で補完

🧩 問題

📂
House Prices データセット(train.csv)を使って以下の欠損値EDAを実装せよ。

1 欠損率ランキング(降順)の可視化

欠損を含む全変数について欠損率(%)を計算し降順で表示。水平棒グラフで上位20変数を可視化すること。

2 共欠損パターンの分析

Garage系・Basement系に絞り、「どの行が同時に欠損しているか」を isnull().sum(axis=1) などで分析せよ。

3 欠損フラグと目的変数の関係

LotFrontage(欠損率17.7%)について:

  • 欠損フラグ列 LotFrontage_missing(0/1)を作成
  • 欠損あり・なしグループで SalePrice の分布を boxplot で比較
  • 欠損の有無が価格に影響するかを数値で確認

4 適切な補完戦略の実装

変数推奨補完戦略
PoolQC (+ MiscFeature, Alley, Fence)"None"(施設なし)
LotFrontageNeighborhood ごとの中央値
GarageYrBlt 等 Garage数値列0(ガレージなし)

💡 ヒント

ヒント1 — 方向性(クリックで展開)
欠損値の分析は「なぜ欠損しているか」を考えることから始まる。
df.isnull().sum() で件数、/ len(df) * 100 でパーセントに変換。
まずは全変数の欠損率をソートして一覧表示してみよう。
ヒント2 — アプローチ(クリックで展開)
欠損率: df.isnull().mean() * 100 で一発計算
共欠損: df[garage_cols].isnull().sum(axis=1).value_counts()
欠損フラグ: df['LotFrontage_missing'] = df['LotFrontage'].isnull().astype(int)
グループ別中央値補完: df.groupby('Neighborhood')['LotFrontage'].transform('median')
ヒント3 — コード骨格(クリックで展開)
# タスク1: 欠損率計算
missing_rate = df.isnull().mean() * 100
missing_rate = missing_rate[missing_rate > 0].sort_values(ascending=False)

# タスク2: Garage系共欠損
garage_cols = [c for c in df.columns if 'Garage' in c]
df[garage_cols].isnull().sum(axis=1).value_counts()

# タスク3: 欠損フラグ
df['LotFrontage_missing'] = df['LotFrontage'].isnull().astype(int)

# タスク4: 補完
df['PoolQC'] = df['PoolQC'].fillna('None')
df['LotFrontage'] = df.groupby('Neighborhood')['LotFrontage'].transform(
    lambda x: x.fillna(x.median())
)

模範解答

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import matplotlib
matplotlib.rcParams['font.family'] = 'IPAexGothic'  # Colab: !apt install fonts-ipafont

df = pd.read_csv('train.csv')

# ──────────────────────────────────────
# タスク1: 欠損率ランキング(上位20)
# ──────────────────────────────────────
missing_rate = df.isnull().mean() * 100
missing_rate = missing_rate[missing_rate > 0].sort_values(ascending=False)

fig, ax = plt.subplots(figsize=(9, 8))
colors = ['#ef4444' if v > 50 else '#f97316' if v > 10 else '#3b82f6'
          for v in missing_rate.head(20).values]
ax.barh(missing_rate.head(20).index[::-1],
        missing_rate.head(20).values[::-1],
        color=colors[::-1])
ax.set_xlabel('欠損率 (%)')
ax.set_title('欠損率ランキング(上位20変数)')
ax.axvline(50, color='red', linestyle='--', alpha=0.5, label='50%')
ax.axvline(10, color='orange', linestyle='--', alpha=0.5, label='10%')
ax.legend()
plt.tight_layout()
plt.show()

print(f"欠損を含む変数数: {len(missing_rate)} / {df.shape[1]}")
print("\n上位10変数:")
print(missing_rate.head(10).round(1).to_string())

# ──────────────────────────────────────
# タスク2: Garage系の共欠損パターン
# ──────────────────────────────────────
garage_cols = [c for c in df.columns if 'Garage' in c]
basement_cols = [c for c in df.columns if 'Bsmt' in c]

print("\n=== Garage系変数の欠損 ===")
print(df[garage_cols].isnull().sum())

garage_missing_count = df[garage_cols].isnull().sum(axis=1)
print("\nGarage系: 同時欠損変数の数")
print(garage_missing_count.value_counts().sort_index())
# → 「0変数欠損」or「全変数同時欠損」の二極化 = ガレージのMNAR

print("\n=== Basement系変数の欠損 ===")
bsmt_missing_count = df[basement_cols].isnull().sum(axis=1)
print(bsmt_missing_count.value_counts().sort_index())

# ──────────────────────────────────────
# タスク3: LotFrontage の欠損フラグ × SalePrice
# ──────────────────────────────────────
df['LotFrontage_missing'] = df['LotFrontage'].isnull().astype(int)

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

sns.boxplot(x='LotFrontage_missing', y='SalePrice', data=df, ax=axes[0],
            palette={0: '#3b82f6', 1: '#ef4444'})
axes[0].set_xticklabels([
    f'LotFrontage あり\n(n={(df["LotFrontage_missing"]==0).sum()})',
    f'LotFrontage 欠損\n(n={(df["LotFrontage_missing"]==1).sum()})'
])
axes[0].set_title('LotFrontage欠損 vs SalePrice')
axes[0].set_ylabel('SalePrice ($)')

stats = df.groupby('LotFrontage_missing')['SalePrice'].agg(['mean', 'median', 'std'])
stats.index = ['あり', '欠損']
axes[1].axis('off')
table_data = [[idx, f"${row['mean']:,.0f}", f"${row['median']:,.0f}", f"${row['std']:,.0f}"]
              for idx, row in stats.iterrows()]
table = axes[1].table(
    cellText=table_data,
    colLabels=['グループ', '平均', '中央値', '標準偏差'],
    loc='center', cellLoc='center'
)
table.auto_set_font_size(False)
table.set_fontsize(12)
table.scale(1.2, 2.5)
axes[1].set_title('SalePrice統計(欠損フラグ別)')

plt.tight_layout()
plt.show()

missing_mean = df[df['LotFrontage_missing']==1]['SalePrice'].mean()
exist_mean   = df[df['LotFrontage_missing']==0]['SalePrice'].mean()
print(f"LotFrontage欠損グループの平均価格: ${missing_mean:,.0f}")
print(f"LotFrontage存在グループの平均価格: ${exist_mean:,.0f}")
print(f"差: ${exist_mean - missing_mean:,.0f} ({(exist_mean/missing_mean - 1)*100:.1f}%)")

# ──────────────────────────────────────
# タスク4: 補完戦略の実装
# ──────────────────────────────────────

# (a) 施設なし → "None"
for col in ['PoolQC', 'MiscFeature', 'Alley', 'Fence']:
    if col in df.columns:
        df[col] = df[col].fillna('None')

# (b) LotFrontage: Neighborhood別中央値補完
lot_before = df['LotFrontage'].isnull().sum()
df['LotFrontage'] = df.groupby('Neighborhood')['LotFrontage'].transform(
    lambda x: x.fillna(x.median())
)
lot_after = df['LotFrontage'].isnull().sum()
print(f"\nLotFrontage 補完: {lot_before}件 → {lot_after}件 欠損")

# (c) Garage数値列: 0補完
for col in ['GarageYrBlt', 'GarageArea', 'GarageCars']:
    if col in df.columns:
        df[col] = df[col].fillna(0)
# Garageカテゴリ列: "None"補完
for col in ['GarageType', 'GarageFinish', 'GarageQual', 'GarageCond']:
    if col in df.columns:
        df[col] = df[col].fillna('None')

print(f"Garage系 欠損残: {df[garage_cols].isnull().sum().sum()}件")
print("\n最終欠損チェック(上位5):")
print((df.isnull().mean()*100).sort_values(ascending=False).head())

🪜 Step-by-Step 解説

1
欠損率の計算: df.isnull().mean() * 100
isnull() は欠損なら True(=1)、存在なら False(=0)。.mean() で列ごとの平均 → 欠損率(0〜1)。*100 でパーセントに変換。[missing_rate > 0] で欠損ゼロの変数を除外して見やすくする。
2
共欠損の確認: df[garage_cols].isnull().sum(axis=1)
axis=1 は「行方向に合計」= 1行あたりの欠損変数数。Garage系なら「0個欠損 or 全部欠損」に二極化するはず → これが MNAR(ガレージ自体がない)の証拠。
3
欠損フラグの作成: df['col_missing'] = df['col'].isnull().astype(int)
欠損情報を特徴量として保持するテクニック。補完した後も「もともと欠損だった」情報をモデルに伝えられる。Kaggle上位解法でほぼ必須。必ず補完前にフラグを作ること。
4
グループごとの中央値補完: groupby + transform
df.groupby('Neighborhood')['LotFrontage'].transform(lambda x: x.fillna(x.median()))
各行を「自分が属するグループの統計量」で補完。transform は元のDataFrameと同じ行数のSeriesを返すので直接代入できる。単純な全体中央値より精度が高い。

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

なぜ「平均」でなく「中央値」で補完するのか?

外れ値があると平均は引っ張られる

例: [100, 100, 100, 100, 1000]

平均 = 280
全員の実態とかけ離れる
280
中央値 = 100
外れ値の影響なし
100

価格や面積のような歪んだ分布では中央値補完が安全。

欠損率と情報価値のトレードオフ

💡
欠損率が高い変数(>50%)は削除候補に見えるが、Kaggleでは残す方が良い場合が多いPoolQC=None(プールなし)という情報自体が高額物件フィルタになるからだ。削除ではなく "None" カテゴリとして活用せよ。

🏆 Kaggleでの実践的な使い方

欠損値EDAのコンペ別チェックリスト

データ種別主な欠損パターン推奨アプローチ
表形式(House Prices等) 施設なし・記録漏れ "None"カテゴリ + 欠損フラグ
時系列データ センサー停止・取引なし日 前後の値で線形補間 (interpolate())
医療データ 検査未実施(意図的欠損) 欠損フラグ必須 + IterativeImputer
ユーザー行動ログ 未操作 = 暗黙の0 0埋め or スパース行列

上位解法でよく見るメタ特徴量

# 各行の欠損数をメタ特徴として追加
df['n_missing_total'] = df.isnull().sum(axis=1)

# カテゴリ別欠損数
df['n_missing_garage'] = df[garage_cols].isnull().sum(axis=1)
df['n_missing_bsmt']   = df[basement_cols].isnull().sum(axis=1)

# 欠損フラグをまとめて作成
for col in ['LotFrontage', 'MasVnrArea', 'GarageYrBlt']:
    df[f'{col}_missing'] = df[col].isnull().astype(int)

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
欠損を全て平均で埋める 「何か値を入れなきゃ」という焦り 欠損の理由を先に調べる。MNARは"None"カテゴリが最適
欠損率が高い変数を削除する 「欠損 = 使えない」という思い込み 欠損自体が重要な情報(PoolQC=99.5%でも有用)
補完後に欠損フラグを作る 順番を気にしない 補完で情報が消える!必ず補完前にフラグを作成
groupby補完でNaNが残る グループ内が全員欠損のエッジケース .fillna(df['col'].median()) で二段構えに
訓練+テストを一緒に補完 「一気に処理」の効率優先 テストへのデータリーク!訓練データのみで統計値を計算し、テストに適用

🚀 次のステップ

発展1: missingno
可視化ライブラリ
欠損パターンのヒートマップ・デンドログラム可視化。共欠損を直感的に把握できる。
発展2: IterativeImputer
scikit-learn
多変量回帰補完。他の変数を使ってMissing値を予測する高精度な補完手法。
次回予告
Day 072
EDA体系的手法④ — 外れ値の検出と処理(IQR法・Isolation Forest・外れ値の扱い方)

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

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

← Day 070 一覧へ