欠損値処理シリーズ 第3回:
削除戦略① — リストワイズ削除(完全ケース分析・CCA)

欠損値処理シリーズ 第3回:削除戦略① — リストワイズ削除(完全ケース分析・CCA)

第2回までで、欠損値の検出と可視化ができるようになりました。

次のステップは、いよいよ「実際にどう処理するか」です。

欠損値への対処法は大きく分けて 削除(deletion)補完(imputation) の2種類があります。

今回はそのうち、もっともシンプルな リストワイズ削除(listwise deletion)、別名「完全ケース分析(Complete Case Analysis、CCA) 」を取り上げます。

「シンプル」というと安易な手法のように聞こえますが、実は 使うべき場面と避けるべき場面の見極め こそがデータサイエンティストの腕の見せどころです。

pandas の dropna() の使い方を一通り押さえつつ、削除のメリット・デメリットも整理していきましょう。

リストワイズ削除(CCA)とは?

リストワイズ削除 とは、欠損値を1つでも含む行を、データセットからまるごと削除する方法です。

残るのは「すべての列に値が入っている行」だけ、つまり完全な観測ケースだけを使った分析 になるので、完全ケース分析(CCA) とも呼ばれます。
 

補足:「リストワイズ」と「ペアワイズ」の違い

「リストワイズ(listwise)」は「リスト全体(=行全体)を対象に削除する」という意味です。これと対になるのが「ペアワイズ削除(pairwise deletion)」で、こちらは「分析に使う変数のペアごとに、その変数に欠損がない行だけを使う」という方法です。

 

CCA を使うべき場面・避けるべき場面

CCA は実装が極めて簡単で、削除した後のデータは 「すべての列が揃った完全なデータ」 になるため、その後の分析が非常にスムーズに進むという利点があります。

一方で、行を捨てるという性質上、サンプル数の減少バイアス(偏り)の混入 という2つのリスクを常に抱えています。

状況 CCA の適否
欠損率が小さく(5%未満)、MCAR が想定される 適している
欠損率が高い(30%超)または MAR / MNAR が想定される 避けるべき
サンプル数が十分多く、削除しても統計的検出力が落ちない 適している
サンプル数が少なく、1件ずつが貴重 避けるべき

第1回でお話しした通り、MCAR(完全にランダムな欠損)であれば、欠損行を削除しても残ったデータは元の集団の縮小版とみなせるため、バイアスは生じにくいです。

しかし、MAR や MNAR の場合は、削除によって特定の属性を持つサンプルだけが偏って失われ、分析結果が歪む可能性があります。
 

重要:「とりあえず dropna()」は危険

Python 初心者向けの教材では、df.dropna() がいきなり登場することがあります。確かに1行で書けて簡単ですが、何も考えずに使うと データの大半を失ったり、バイアスを抱え込んだりする 危険があります。実行前に必ず、第2回で紹介した 欠損率と欠損パターン を確認しましょう。

 

サンプルデータの準備

実際のコードに入っていきましょう。前回と同じ、seaborn の Titanic データセットを使います。

以下、コードです。

import pandas as pd
import numpy as np
import seaborn as sns

# Titanicデータセットの読み込み
df = sns.load_dataset('titanic')

print(f"元データの形状: {df.shape}")
print(f"\n欠損のある列と欠損率:")
print(
    (df.isnull().mean() * 100)    # 欠損率の計算
    .round(2)                     # 小数点第2位まで表示
    .sort_values(ascending=False) # 欠損率の降順に並び替え
    .head(6)                      # 上位6件を表示
)

 

以下、実行結果です。

元データの形状: (891, 15)

欠損のある列と欠損率:
deck           77.22
age            19.87
embarked        0.22
embark_town     0.22
sex             0.00
pclass          0.00
dtype: float64

 

データは891行15列で、deck が約77%、age が約20%、embarkedembark_town が約0.22% の欠損率を持つことが確認できます。

 

1. 全列を対象にした dropna():もっとも単純な使い方

dropna()何もオプションを指定せずに 呼び出すと、1つでも欠損を含む行をすべて削除 します。

これがリストワイズ削除の最も基本的な動作です。

以下、コードです。

# すべての欠損を含む行を削除
df_drop_all = df.dropna()

print(f"削除前: {df.shape}")
print(f"削除後: {df_drop_all.shape}")
print(f"残存率: {len(df_drop_all) / len(df) * 100:.1f}%")
  • df.dropna():欠損を1つでも含む行を削除した新しいデータフレームを返します
  • df.shape:データの形状を (行数, 列数) で返します

 

以下、実行結果です。

削除前: (891, 15)
削除後: (182, 15)
残存率: 20.4%

 

891行あったデータが わずか182行(残存率約20%)まで減ってしまうことがわかります。

これは、deck 列の欠損率が77% と非常に高いため、その影響で大半の行が削除されてしまうためです。
 

これは典型的な「やってはいけないパターン」

1つの列の高い欠損率に引きずられて、本来は使える行まで失っています。deck 列を分析に使わないのであれば、その列の欠損は無視すべきです。

 

2. subset で対象列を絞る

実用上、最も役立つのが subset パラメータ です。

これを使うと、「特定の列に欠損がある行だけを削除する」という、より柔軟な削除ができます。

 

 単一列を対象にする

たとえば、embarked 列の欠損(2件しかない)だけを削除してみましょう。

以下、コードです。

# embarked 列に欠損がある行だけを削除
df_drop_embarked = df.dropna(subset=['embarked'])

print(f"削除前: {df.shape}")
print(f"削除後: {df_drop_embarked.shape}")
print(f"削除された行数: {len(df) - len(df_drop_embarked)}件")
  • subset=['embarked']embarked 列に欠損がある行だけを削除対象にします
  • 他の列(agedeck)に欠損があっても、それらの行は残ります

 

以下、実行結果です。

削除前: (891, 15)
削除後: (889, 15)
削除された行数: 2件

 

削除されるのは embarked の欠損 2件だけで、残り889行はそのまま保持されます。

これは、欠損率が極めて低い列に対する CCA の理想的な使い方です。

 

 複数列を対象にする

subset にはリストを渡せるので、複数の列を同時に指定できます。

以下、コードです。

# embarked と age の両方を対象に削除
df_drop_two = df.dropna(subset=['embarked', 'age'])

print(f"削除前: {df.shape}")
print(f"削除後: {df_drop_two.shape}")
print(f"削除された行数: {len(df) - len(df_drop_two)}件")

 

以下、実行結果です。

削除前: (891, 15)
削除後: (712, 15)
削除された行数: 179件

 

embarkedageどちらか一方でも欠損している行 が削除されます。

age の欠損率が約20% なので、削除される行数もやや大きくなります。
 

subset の使いどころ

「分析に使う列」だけを subset に指定するのが基本です。たとえば、agefare を使った回帰分析をしたいなら subset=['age', 'fare'] を指定すれば、それ以外の列の欠損は無視できます。これで 必要最小限の行だけを削除 できます。

 

3. how パラメータで削除条件を変える

subset で複数列を指定したとき、「どれか1つでも欠損なら削除」するのか「すべて欠損のときだけ削除」するのかを選べるのが、how パラメータです。

how の値 動作
'any'(デフォルト) 指定列のうち1つでも欠損があれば削除
'all' 指定列がすべて欠損のときだけ削除

 

 how='any':1つでも欠損があれば削除

以下、コードです。

# どちらかが欠損なら削除(デフォルト動作)
df_any = df.dropna(
    subset=['age', 'embarked'], 
    how='any'
)
print(f"how='any': {df_any.shape}")

 

以下、実行結果です。

how='any': (712, 15)

 

これは先ほどと同じ動作で、age または embarked のどちらかでも欠損していれば削除されます。

 

 how='all':すべて欠損のときだけ削除

以下、コードです。

# 両方とも欠損のときだけ削除
df_all = df.dropna(
    subset=['age', 'embarked'], 
    how='all'
)
print(f"how='all': {df_all.shape}")

 

以下、実行結果です。

how='all': (891, 15)

 

ageembarked両方 欠損している行だけが削除されます。

Titanic データではそういう行はほとんどないため、削除される行はごくわずかです。
 

how='all' が役立つ場面

たとえば、複数のセンサーから取得したデータで「どれか1つでも値があれば、その行は使えるかもしれない」というケースに有効です。「全センサーが故障した行だけを削除する」という発想です。

 

4. thresh パラメータ:「○個以上の値があれば残す」

thresh(threshold=閾値の略)パラメータは、「非欠損の値が○個以上ある行は残す」 という、より柔軟な指定ができます。

まず、非欠損数を各行ごとに求め、非欠損数ごとに何行あるのか数えてみます。データの列数(変数の数)は15です。

以下、コードです。

# 各行の非欠損数を数える例
print("各行の非欠損数の分布:")
print(
    df.notnull()    # 各要素が非欠損かどうかの真理値
    .sum(axis=1)    # 各行の非欠損数を数える
    .value_counts() # 非欠損数ごとの行数を数える
    .sort_index()   # 非欠損数でソート
)
  • df.notnull():欠損でないセルを True とするデータフレーム
  • .sum(axis=1):行方向に合計(=各行の非欠損数)

 

以下、実行結果です。

各行の非欠損数の分布:
13    160
14    549
15    182
Name: count, dtype: int64

 

Titanic データの15列のうち、15列すべて埋まっている行数が182行です。

ほとんどの行で14列以上が埋まっています。いくつかの行では13列しか埋まっていません。

以下、コードです。

# 非欠損が14個以上ある行だけを残す
df_thresh = df.dropna(thresh=14)

print(f"削除前: {df.shape}")
print(f"削除後(thresh=14): {df_thresh.shape}")

 

以下、実行結果です。

削除前: (891, 15)
削除後(thresh=14): (731, 15)

 

非欠損が13個以下の行(=欠損が2個以上ある行)だけが削除されます。

subset ほど明示的に列を指定しないため、コードはシンプルですが、どの列が削除条件に効いているかが見えにくい という欠点があります。

実務では subset を使うほうが意図が伝わりやすいでしょう。

 

5. CCA の前後を比較する:分布のチェック

CCA を実行したあと、残ったデータが元のデータと似た性質を持っているか を確認することが大切です。

これによって、削除によるバイアスが大きいか小さいかを推測できます。

 

 数値変数の分布を比較する

age 列に欠損のある行を削除したあと、age 以外の重要な変数(farepclass)の分布が元と変わっていないかを見てみましょう。

以下、コードです。

# age を含む CCA
df_cca = df.dropna(subset=['age'])

print("【pclass の割合(%)】")
print("削除前:")
print(
    # pclass の割合を計算
    (df['pclass'].value_counts(normalize=True) * 100) 
    .round(2)     # 小数点第2位まで表示
    .sort_index() # インデックス順に並び替え
)
print("\n削除後:")
print(
    # pclass の割合を計算
    (df_cca['pclass'].value_counts(normalize=True)*100)
    .round(2)     # 小数点第2位まで表示
    .sort_index() # インデックス順に並び替え
)
  • value_counts(normalize=True):各値の出現割合(合計1)を返します
  • .sort_index():インデックス順(ここでは pclass 順)に並べます

 

以下、実行結果です。

【pclass の割合(%)】
削除前:
pclass
1    24.24
2    20.65
3    55.11
Name: proportion, dtype: float64

削除後:
pclass
1    26.05
2    24.23
3    49.72
Name: proportion, dtype: float64

 

削除前は1等が約24%、2等が約21%、3等が約55% であるのに対し、削除後は1等が約26%、2等が約24%、3等が約50% に変化しています。

3等船室の乗客が削除によって減っている のがわかります。これは第2回で確認した「3等船室で age の欠損率が高い」という MAR の兆候と一致します。

つまり、CCA によって 3等船室の乗客が選択的に失われ、残ったデータは「裕福な乗客に偏った集団」になっているのです。

 

 可視化で違いを確認する

数値だけでは差が伝わりにくいので、ヒストグラムで比較してみましょう。

以下、コードです。

import matplotlib.pyplot as plt

# グラフサイズとレイアウトの設定
fig, axes = plt.subplots(2, 1, figsize=(6, 8))

# 比率データ(0-1)を計算
before_prop = df['pclass'].value_counts(normalize=True).sort_index()
after_prop = df_cca['pclass'].value_counts(normalize=True).sort_index()

# プロット(上段: Before)
before_prop.plot(
    kind='bar', # 棒グラフ
    ax=axes[0], # 上段のグラフにプロット
    color='skyblue', 
    edgecolor='white'
)
axes[0].set_title('Before CCA')
axes[0].set_ylabel('Proportion')
axes[0].set_xticklabels(['1st', '2nd', '3rd'], rotation=0)

# 数値(%表記)をバーの上に表示
for p in axes[0].patches:
    height = p.get_height()
    axes[0].annotate(
        f"{height*100:.1f}%", 
        (p.get_x() + p.get_width()/2, height),
        ha='center', va='bottom', fontsize=10
    )

# プロット(下段: After)
after_prop.plot(
    kind='bar', 
    ax=axes[1], 
    color='pink', 
    edgecolor='white'
)
axes[1].set_title('After CCA (age column)')
axes[1].set_xlabel('Passenger Class')
axes[1].set_ylabel('Proportion')
axes[1].set_xticklabels(['1st', '2nd', '3rd'], rotation=0)

# 数値(%表記)をバーの上に表示
for p in axes[1].patches:
    height = p.get_height()
    axes[1].annotate(
        f"{height*100:.1f}%", 
        (p.get_x() + p.get_width()/2, height),
        ha='center', va='bottom', fontsize=10
    )

plt.tight_layout()
plt.show()

 

以下、実行結果です。

 

CCA 後は3等船室の比率が下がり、1等・2等の比率が上がっていることが視覚的に確認できます。

これが CCA が引き起こすバイアス の典型例です。

 

6. inplace=True の落とし穴

dropna() には inplace というパラメータがあり、True にすると 元のデータフレームから行を削除し直接書き換え ます。

以下、コードです。

df_copy = df.copy()
df_copy.dropna(subset=['embarked'], inplace=True)

 

なんとなく、便利そうにも見えます。

しかし、以下の理由からinplace=True の使用は推奨されない方向に進んでいます

  • 元データが失われる:間違いに気づいても元に戻せない
  • チェーンができないdf.dropna().head() のような連鎖的な書き方ができない
  • 将来的に非推奨化される可能性:pandas の開発方針として inplace 引数は段階的に減らされる傾向がある

 

なので、基本的には 新しい変数に代入する書き方 を覚えましょう。

以下、コードです。

# 推奨される書き方
df_clean = df.dropna(subset=['embarked'])

 

補足:チェーンの便利さ

inplace=False(デフォルト)であれば、複数の処理を . で繋げて書けます。たとえば次のように、削除→特定列の選択→先頭5行の表示までを1行で書けます。df.dropna(subset=['age']).loc[:, ['age', 'fare', 'pclass']].head()

 

削除戦略のまとめ

CCA を使うかどうかは、欠損率と欠損メカニズムから判断します。

決まった正解はありませんが、おおよその判断基準を以下に示します。

状況 推奨される対応
欠損率が5%未満で、MCAR と推定される dropna(subset=[...]) で対象列を絞って削除
欠損率が中程度(5〜30%)で、MAR と推定される 削除よりも補完を優先
欠損率が高い(30%超) 列の削除を検討
重要な変数で MNAR が疑われる 専用のモデリング手法で対応

つまり、CCA は「欠損率が小さく、ランダム性が確認できる列」に限って使うべき手法 ということになります。

 

まとめ

今回のポイントを振り返りましょう。

  • リストワイズ削除(CCA) は欠損値処理の最もシンプルな方法
  • pandas の dropna() で実装でき、複数のパラメータがある

    • subset:対象列を絞る(実務で最もよく使う)
    • how'any'(1つでも欠損で削除)か 'all'(すべて欠損で削除)
    • thresh:非欠損数の閾値で残すかどうかを判断
  • CCA は 欠損率が小さく MCAR が想定される場合 に適している
  • MAR / MNAR の場合は バイアスが残る ため、削除前後の分布を必ず確認する
  • inplace=True は避け、新しい変数に代入する書き方 を習慣にする