欠損値処理シリーズ 第7回:
単変量補完③ — ランダムサンプル補完で分布を保つ

欠損値処理シリーズ 第7回:単変量補完③ — ランダムサンプル補完で分布を保つ

第6回の最後に、平均値・中央値補完には「分散が縮小し、分布が不自然に歪む」という重要な副作用があることをいお話ししました。

すべての欠損を1つの代表値で埋めるため、その値の周辺にデータが人工的に集中してしまうためです。

この問題を解決するのが、今回扱う ランダムサンプル補完(random sample imputation) です。

その列の実際の値からランダムに選んで埋める」というシンプルな発想ながら、元のデータ分布をそのまま保てる という大きな利点があります。

ランダムサンプル補完とは?

ランダムサンプル補完 とは、欠損値を、同じ列にある観測値(欠損していない値)の中からランダムに選んで 埋める方法です。

たとえば age 列に欠損が3件あり、観測値が [22, 35, 28, 40, 19, ...] だとします。

このとき、欠損3件を埋めるために、観測値の中からランダムに3つ(たとえば 28, 19, 35)を選んで割り当てます。

 

平均値補完との決定的な違い

ここに文字を入力する。

  • 平均値補完:欠損をすべて同じ値(平均値)で埋める → 1点に集中して分散が縮小
  • ランダムサンプル補完:欠損を実在する値のばらつきで埋める → 元の分布の形がほぼ保たれる

「実際にありえた値」で埋めるため、分布の自然さが失われにくいのがランダムサンプル補完の最大の強みです。

 

どんなときに使うべきか

ランダムサンプル補完が向いているのは、主に次のような場面です。

  • 欠損が MCAR(完全にランダム)と想定される:観測値の分布が、欠損値の本来の分布をよく代表していると考えられる場合
  • 分布の形を保ったまま補完したい:相関分析や統計モデリングで、ばらつきを潰したくない場合
  • 数値・カテゴリのどちらにも使いたい:平均・中央値が使えないカテゴリ変数にも適用できる

一方で、注意すべき点もあります。

  • 再現性の確保が必須:ランダム性があるため、乱数の種(シード)を固定しないと実行のたびに結果が変わる
  • 共分散への影響:1変数ずつ独立にランダム補完するため、変数間の相関関係(共分散)が弱まることがある
  • メモリを使う:補完のために、観測値のプールを保持しておく必要がある

 

補足:なぜ「共分散」に影響するのか?

ランダムサンプル補完は、各変数を バラバラに(独立に) 埋めます。たとえば「年齢が高い人は年収も高い」という関係があっても、年齢の欠損と年収の欠損をそれぞれ無関係にランダム補完すると、その関係が補完値には反映されません。結果として、変数間の相関がやや弱まります。変数間の関係を保ちたい場合は、第9回以降の 多変量補完 が必要になります。

 

サンプルデータの準備

前回までと同じ、seaborn の Titanic データセットを使います。age 列(欠損177件)を題材にします。

以下、コードです。

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

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

print(f"age の欠損数: {df['age'].isnull().sum()}")
print(f"age の観測値数: {df['age'].notnull().sum()}")

 

以下、実行結果です。

age の欠損数: 177
age の観測値数: 714

 

実行すると、age 列に177件の欠損があり、714件の観測値があることが確認されました。

 

pandas でのランダムサンプル補完

基本的な流れは次の3ステップです。

  • ステップ 1:欠損の 件数 を数える
  • ステップ 2:観測値の中から、その件数分だけ ランダムに抽出
  • ステップ 3:抽出した値を欠損箇所に 割り当てる

 学習データでの実装

データ漏洩を防ぐため、第6回で学んだ通り 学習データの観測値プールから補完値を取る という原則を守ります。

以下、コードです。

from sklearn.model_selection import train_test_split

# 学習用とテスト用に分割
df_train, df_test = train_test_split(
    df,             # 全データフレーム
    test_size=0.2,  # テストデータの割合
    random_state=42 # 乱数シード
)

# 学習データのコピーを作成
df_train_imp = df_train.copy()

# ステップ1:学習データの欠損数を数える
n_missing_train = (
    df_train_imp['age'] # 年齢の変数の列
    .isnull()           # 欠損判定
    .sum()              # 件数合計
)

print(f"学習データの age 欠損数: {n_missing_train}")

# ステップ2:学習データの観測値からランダムに n_missing 個を抽出
random_sample_train = (
    df_train_imp['age']    # 年齢の変数の列
    .dropna()              # 欠損を除去
    .sample(               # ランダムサンプリング
        n=n_missing_train,  # 欠損数と同数を抽出
        random_state=42     # 乱数シード
    )
)

# ステップ3:抽出した値を欠損箇所に割り当てる
random_sample_train.index = (
    df_train_imp[           # 学習データフレーム
        df_train_imp['age']  # 年齢の変数の列
        .isnull()            # 欠損判定
    ].index                 # 欠損箇所のインデックス
)
# 補完実行
df_train_imp.loc[
    df_train_imp['age'].isnull(), # 欠損箇所のインデックス
    'age'                         # 年齢の変数の列
] = random_sample_train

print(f"補完後の欠損数: {df_train_imp['age'].isnull().sum()}")
  • df_train_imp['age'].dropna():欠損を除いた観測値だけのプールを作る
  • .sample(n=n_missing_train, random_state=42):そこから欠損数と同じ件数をランダム抽出
  • random_sample_train.index = ...:抽出した値のインデックスを、欠損箇所のインデックスに合わせる(これをしないと正しく代入されない)
  • df_train_imp.loc[欠損箇所, 'age'] = ...:欠損箇所に抽出値を代入

 

以下、実行結果です。

学習データの age 欠損数: 140
補完後の欠損数: 0

 

学習データの age 欠損がゼロになっています。

 

重要:インデックスの付け替えを忘れない

sample() で抽出した値は、元の観測値のインデックスを引き継いでいます。これを 欠損箇所のインデックスに付け替えてから代入 しないと、pandas はインデックスを照合しようとして、意図しない結果(多くが NaN のまま)になります。ここは初心者がつまずきやすいポイントです。

 

 テストデータでの実装(学習データのプールを使う)

テストデータの欠損も、学習データの観測値プール から抽出して埋めます。これがデータ漏洩を防ぐ正しい流れです。

以下、コードです。

# テストデータのコピーを作成
df_test_imp = df_test.copy()

# テストデータの欠損数を数える
n_missing_test = (
    df_test_imp['age'] # 年齢の変数の列
    .isnull()          # 欠損判定
    .sum()             # 件数合計
)

print(f"テストデータの age 欠損数: {n_missing_test}")

# 補完値は「学習データの観測値」から抽出する(テストデータ自身からではない)
random_sample_test = (
    df_train['age'] # 学習データの年齢列
    .dropna()       # 欠損を除去
    .sample(        # ランダムサンプリング
        n=n_missing_test, # 欠損数と同数を抽出
        random_state=42   # 乱数シード
    )
)

# 補完値のインデックスを欠損箇所のインデックスに合わせる
random_sample_test.index = (
    df_test_imp[  # テストデータフレーム
        df_test_imp['age'] # 年齢の変数の列
        .isnull()          # 欠損判定
    ].index       # 欠損箇所のインデックス
)
# 補完実行
df_test_imp.loc[
    df_test_imp['age'].isnull(), # 欠損箇所のインデックス
    'age'                        # 年齢の変数の列
] = random_sample_test

print(f"補完後の欠損数: {df_test_imp['age'].isnull().sum()}")
  • df_train['age'].dropna().sample(...):補完値の抽出元は 学習データ。テストデータ自身は使わない
  •  これにより、テストデータの情報が補完に紛れ込むのを防ぐ

 

以下、実行結果です。

テストデータの age 欠損数: 37
補完後の欠損数: 0

 

テストデータの age 欠損もゼロになりました。

「補完値のプールは常に学習データから」 という原則は、ランダムサンプル補完でも変わりません。

 

再現性の確保:random_state の重要性

ランダムサンプル補完には乱数が関わるため、実行するたびに結果が変わってしまう という問題があります。

これでは、分析の再現や結果の検証ができません。

random_state(乱数の種)を固定すると、毎回同じ結果が得られます。

 

逆に固定しないと、毎回違う値で補完されてしまうことを確認してみましょう。

以下、コードです。

# poolに年齢の変数の列を代入
pool = df_train['age'].dropna()

# random_state を固定しない場合:毎回違う結果
print("【random_state なし】")
print("1回目:", pool.sample(n=3).values)
print("2回目:", pool.sample(n=3).values)

# random_state を固定する場合:毎回同じ結果
print("\n【random_state=42 で固定】")
print("1回目:", pool.sample(n=3, random_state=42).values)
print("2回目:", pool.sample(n=3, random_state=42).values)
  • random_state を指定しない sample() は、呼ぶたびに異なる値を返す
  • random_state=42 を指定すると、何度呼んでも同じ値を返す

 

以下、実行結果です。

【random_state なし】
1回目: [ 4. 16. 50.]
2回目: [27. 18. 35.]

【random_state=42 で固定】
1回目: [ 2. 65. 33.]
2回目: [ 2. 65. 33.]

 

何度か実行してみてくだい。

random_state なしでは1回目と2回目で違う値が、固定すると同じ値が返ることがわかります。

 

再現性はデータサイエンスの基本マナー

ここに文字を入力する。
補完にランダム性が関わる場合、random_state を固定するのは必須です。これは、第三者が同じコードで同じ結果を再現できるようにするため、そして自分が後で結果を検証できるようにするためです。論文や業務レポートでは、使ったシード値を明記するのが望ましい習慣です。

 

分布が保たれることを可視化する

ランダムサンプル補完の真価は、元の分布を保つ ことにあります。

第6回の中央値補完と並べて、分布がどう変わるかを比較してみましょう。

以下、コードです。

# 比較用に3パターンを用意
df_orig = df.copy()  # 元データ(欠損行は除いて描画)

# パターンA:第6回の中央値補完
df_median = df.copy()
df_median['age'] = df_median['age'].fillna(df_median['age'].median())

# パターンB:今回のランダムサンプル補完
df_random = df.copy()
n_miss = df_random['age'].isnull().sum()
samples = df_random['age'].dropna().sample(n=n_miss, random_state=42)
samples.index = df_random[df_random['age'].isnull()].index
df_random.loc[df_random['age'].isnull(), 'age'] = samples

# 3つを並べて可視化
fig, axes = plt.subplots(3, 1, figsize=(8, 10))

# 元データ(欠損行は除く)のヒストグラム
axes[0].hist(
    df_orig['age'].dropna(), 
    bins=30, 
    color='gray', 
    edgecolor='black'
)
axes[0].set_title('元データ(欠損行は除く)')
axes[0].set_xlabel('Age')
axes[0].set_ylabel('Frequency')

# パターンA:第6回の中央値補完のヒストグラム
axes[1].hist(
    df_median['age'], 
    bins=30, 
    color='steelblue', 
    edgecolor='black'
)
axes[1].set_title('パターンA:第6回の中央値補完')
axes[1].set_xlabel('Age')

# パターンB:今回のランダムサンプル補完のヒストグラム
axes[2].hist(
    df_random['age'], 
    bins=30, 
    color='seagreen', 
    edgecolor='black'
)
axes[2].set_title('パターンB:今回のランダムサンプル補完')
axes[2].set_xlabel('Age')

plt.tight_layout()
plt.show()

 

以下、実行結果です。

 

3つのヒストグラムが並んでいます。

  • 元データ(欠損行は除く)のヒストグラム:元の分布
  • パターンA:第6回の中央値補完のヒストグラム:中央値付近(約28)に 不自然に高い棒 が出現
  • パターンB:今回のランダムサンプル補完のヒストグラム:元データとほぼ 同じ形 を保っている

ランダムサンプル補完だけが、元の分布の形を崩していないことが視覚的にはっきりわかります。

 

分散が保たれることを数値で確認する

第6回では、平均値・中央値補完で標準偏差が縮小することを確認しました。

ランダムサンプル補完では、これがどうなるか数値で見てみましょう。

以下、コードです。

# 標準偏差(ばらつきの大きさ)を各データで計算
# - 元データは欠損を除外して計算
# - 中央値補完とランダム補完は、すでに欠損が埋まっているためそのまま計算
std_original = df['age'].dropna().std()
std_median = df_median['age'].std()
std_random = df_random['age'].std()

# 各パターンの標準偏差を小数点4桁で表示
print(f"元データの標準偏差         : {std_original:.4f}")
print(f"中央値補完後の標準偏差     : {std_median:.4f}")
print(f"ランダム補完後の標準偏差   : {std_random:.4f}")

# 元データに対して標準偏差がどれだけ減少したか(%)を表示
# 減少率 = (1 - 補完後の標準偏差 / 元データの標準偏差) * 100
print(f"\n中央値補完での減少率   : {(1 - std_median / std_original) * 100:.2f}%")
print(f"ランダム補完での減少率 : {(1 - std_random / std_original) * 100:.2f}%")

 

以下、実行結果です。

元データの標準偏差         : 14.5265
中央値補完後の標準偏差     : 13.0197
ランダム補完後の標準偏差   : 14.2953

中央値補完での減少率   : 10.37%
ランダム補完での減少率 : 1.59%

 

実行結果を見ると、中央値補完では標準偏差が大きく減少するのに対し、ランダムサンプル補完では元データとほぼ同じ標準偏差 が保たれていることがわかります。

これは、補完値が「実在する値のばらつき」をそのまま使っているためです。

 

数値で見る一貫した結論

第6回で「平均・中央値補完は分散を縮小させる」と学びました。今回の数値はその裏返しで、「ランダムサンプル補完は分散を保つ」ことを示しています。分布や分散を保ちたい統計分析では、ランダムサンプル補完が有力な選択肢 になります。

 

カテゴリ変数への応用

ランダムサンプル補完は、平均・中央値が使えない カテゴリ変数にもそのまま適用できる のが利点です。

embarked 列(カテゴリ)で試してみましょう。

以下、コードです。

# df_cat に元のデータフレーム df をコピー
df_cat = df.copy()

# 補完前の embarked 列の分布(相対度数)を表示
print("補完前の embarked の分布:")
print(df_cat['embarked'].value_counts(normalize=True).round(4))
print(f"補完前の欠損数: {df_cat['embarked'].isnull().sum()}")

# カテゴリ変数(embarked)のランダムサンプル補完を行う
# 手順の意図:元データの分布をできるだけ維持しつつ欠損を埋める

# 1) embarked の欠損数をカウント
n_miss_cat = df_cat['embarked'].isnull().sum()

# 2) 観測されている値(欠損でないもの)から
#    欠損数と同数だけランダムサンプリング
samples_cat = df_cat['embarked'].dropna().sample(
    n=n_miss_cat, random_state=42
)

# 3) サンプリングした補完値のインデックスを、
#    欠損している行のインデックスに合わせる
samples_cat.index = df_cat[df_cat['embarked'].isnull()].index

# 4) 欠損箇所にサンプリングした値を代入して補完
df_cat.loc[df_cat['embarked'].isnull(), 'embarked'] = samples_cat

# 補完後の分布と欠損数を確認
print(f"\n補完後の embarked の分布:")
print(df_cat['embarked'].value_counts(normalize=True).round(4))
print(f"補完後の欠損数: {df_cat['embarked'].isnull().sum()}")
  • カテゴリ変数でも、数値変数とまったく同じコードで補完できる
  • value_counts(normalize=True):各カテゴリの割合を確認

 

以下、実行結果です。

補完前の embarked の分布:
embarked
S    0.7244
C    0.1890
Q    0.0866
Name: proportion, dtype: float64
補完前の欠損数: 2

補完後の embarked の分布:
embarked
S    0.7250
C    0.1886
Q    0.0864
Name: proportion, dtype: float64
補完後の欠損数: 0

 

embarked の欠損2件がランダムに選ばれたカテゴリで埋めました。

欠損数がごく少ないため分布はほぼ変わりませんが、最頻値補完と違って「最も多いカテゴリだけを増やさない」 ため、カテゴリの構成比を保ちやすい利点があります。

 

単変量補完(第5~第7回)の整理

第5回〜第7回で扱った単変量補完を、ここで整理します。

手法 分布の保存 分散の保存 再現性 主な用途
定数補完(第5回) × 強く歪む × ◎ 常に同じ カテゴリの「未回答」明示
平均値補完(第6回) × 1点集中 × 縮小 ◎ 常に同じ 正規分布の数値変数
中央値補完(第6回) × 1点集中 × 縮小 ◎ 常に同じ 外れ値のある数値変数
最頻値補完(第6回) △ 最頻値増加 ◎ 常に同じ カテゴリ変数
ランダムサンプル補完(第7回) ◎ ほぼ保つ ◎ ほぼ保つ △ 要シード固定 分布を保ちたいとき

 

単変量補完は「その列の情報だけ」を使うため、実装が簡単で高速です。

しかし、どの手法も 変数間の関係(相関) を補完値に反映できません。

「年齢が高い人は運賃も高い」といった関係を活かして補完したい場合は、次回からの 多変量補完 が必要になります。

 

まとめ

今回のポイントを振り返ります。

  • ランダムサンプル補完 は、観測値の中からランダムに選んで欠損を埋める手法
  • 平均値・中央値補完と違い、元の分布の形と分散をほぼ保てる のが最大の利点
  • 実装は「欠損数を数える → 観測値から抽出 → インデックスを揃えて代入」の3ステップ
  • インデックスの付け替え を忘れると正しく代入されないので注意
  • 補完値のプールは学習データから取る(データ漏洩を防ぐ原則は変わらない)
  • ランダム性があるため、random_state の固定による再現性の確保が必須
  • カテゴリ変数にもそのまま適用できる
  • ただし、変数間の関係は補完値に反映されない(→ 多変量補完へ)