欠損値処理シリーズ 第9回:
多変量補完① — KNN Imputer で近傍から補完する

欠損値処理シリーズ 第9回:多変量補完① — KNN Imputer で近傍から補完する

第5〜7回の単変量補完では「その列の情報だけ」を使い、第8回では時系列特有の前後関係を使いました。

第5回:単変量補完① — 定数・任意値での補完(pandas と SimpleImputer)
https://www.salesanalytics.co.jp/datascience/datascience339/

第6回:単変量補完② — 平均・中央値・最頻値での補完
https://www.salesanalytics.co.jp/datascience/datascience340/

第7回:単変量補完③ — ランダムサンプル補完で分布を保つ
https://www.salesanalytics.co.jp/datascience/datascience341/

第8回:時系列データの欠損値補完 — LOCF / NOCB / 線形補間
https://www.salesanalytics.co.jp/datascience/datascience342/

しかし、これらには共通の弱点があります。

それは 変数同士の関係(相関)を補完値に反映できない ことです。

たとえば「客室クラスが高い乗客は運賃も高い」「年齢が高い人は同伴家族が少ない」といった関係は、現実のデータに数多く存在します。

こうした 他の変数との関係を活用して補完する のが、今回扱う 多変量補完(multivariate imputation) です。

その第一歩として、直感的でわかりやすい KNN Imputer(k近傍法による補完) を紹介します。

多変量補完とは?

ここで、単変量補完と多変量補完の違いをもう一度整理しておきます。

種類 補完に使う情報 代表的な手法
単変量補完 その列の値だけ 平均・中央値・最頻値・定数・ランダム
多変量補完 他の列の情報も含めて KNN、MICE、MissForest

第1回でお話しした欠損メカニズムを思い出してください。

MAR(Missing At Random) は「欠損が他の観測変数で説明できる」状態でした。多変量補完は、まさにこの MAR に対して効果を発揮 します。

他の変数の情報を使うことで、欠損値をより正確に、バイアスを抑えて推定できるのです。

 

補足:多変量補完が MAR に強い理由

たとえば「3等船室の乗客ほど age が欠損しやすい」という MAR の状況(第2回で確認しました)を考えます。単変量の中央値補完では、全乗客の中央値で一律に埋めてしまいます。一方、多変量補完なら「3等船室・同伴家族なし・運賃が安い乗客」という属性が似た人たちの年齢を参考に補完できるため、より妥当な値になります。

 

KNN Imputer とは?

KNN Imputer は、k近傍法(k-Nearest Neighbors) の考え方を補完に応用した手法です。

基本的なアイデアはとてもシンプルです。

欠損のある行に「似ている」行を k 個探し、
その k 個の値の平均(または加重平均)で欠損を埋める

「似ている」とは、欠損していない他の特徴量の値が近い、という意味です。

たとえば年齢が欠損している乗客がいたら、客室クラス・運賃・同伴家族数などが近い乗客を k 人探し、その人たちの年齢の平均で埋める、というイメージです。

 

 アルゴリズムの流れ

1. 欠損のある行について、他の特徴量を使って各行との「距離」を計算 する
2. 距離が近い順に k 個の行(近傍) を選ぶ
3. その k 個の行の、補完したい列の値を 平均(または距離で重み付けした加重平均) して欠損を埋める

 

補足:「距離」の計算とは?

KNN では、行と行の「似ている度合い」を ユークリッド距離(各特徴量の差の二乗和の平方根)などで測ります。重要なのは、この距離計算が すべての特徴量を平等に扱う という点です。後で説明しますが、これが「標準化が必要」という重要な注意点につながります。

 

サンプルデータの準備

これまで通り、seaborn の Titanic データセットを使います。

KNN Imputer は 数値データ を対象とするため、数値列だけを抽出します。

以下、コードです。

import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split

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

# 数値列だけを使う
numeric_cols = ['age', 'fare', 'pclass', 'sibsp', 'parch']
df_num = df[numeric_cols].copy()

# 学習用とテスト用に分割
df_train, df_test = train_test_split(
    df_num, 
    test_size=0.2, 
    random_state=42
)

print("欠損数(学習データ):")
print(df_train.isnull().sum())
  • numeric_cols:KNN の距離計算に使える数値列を選択
  • age には欠損があり、これを他の列(fare, pclass, sibsp, parch)の情報で補完します

 

以下、実行結果です。

欠損数(学習データ):
age       140
fare        0
pclass      0
sibsp       0
parch       0
dtype: int64

 

学習データの age 列に欠損があることが確認できます。

 

基本的な KNN Imputer の使い方

scikit-learn の KNNImputer クラスを使います。

第5〜6回で紹介した SimpleImputer と同じく、fit_transform(学習データ)/ transform(テストデータ)の流れでデータ漏洩を防げます。

以下、コードです。

from sklearn.impute import KNNImputer

# KNN Imputer を作成(近傍数 k=5)
knn_imputer = KNNImputer(n_neighbors=5, weights='uniform')

# 学習データで fit_transform
train_imputed = knn_imputer.fit_transform(df_train)

# テストデータには transform のみ
test_imputed = knn_imputer.transform(df_test)

# 配列をデータフレームに戻す
df_train_knn = pd.DataFrame(
    train_imputed, 
    columns=numeric_cols,
    index=df_train.index
)
df_test_knn = pd.DataFrame(
    test_imputed, 
    columns=numeric_cols,
    index=df_test.index
)

print(f"補完前の欠損(学習): {df_train['age'].isnull().sum()}")
print(f"補完後の欠損(学習): {df_train_knn['age'].isnull().sum()}")
print(f"補完後の欠損(テスト): {df_test_knn['age'].isnull().sum()}")
  • KNNImputer(n_neighbors=5, weights='uniform'):5つの近傍の 単純平均 で補完
  • fit_transform:学習データの行から近傍関係を学び、変換する
  • transform:テストデータを、学習データの行を参照して補完する

 

以下、実行結果です。

補完前の欠損(学習): 140
補完後の欠損(学習): 0
補完後の欠損(テスト): 0

 

学習データ・テストデータの両方で age の欠損がゼロになっています。

 

注意:KNN Imputer は数値データのみ

KNNImputer は距離計算を行うため、数値データしか扱えません。カテゴリ変数(embarked など)を含めたい場合は、事前に数値へエンコーディング(ワンホット化など)する必要があります。本記事では数値列のみを扱います。

 

最重要ポイント:標準化(スケーリング)

KNN Imputer を使ううえで、絶対に押さえておくべき注意点 があります。

それは 特徴量のスケール(数値の大きさ)を揃える こと、すなわち 標準化(standardization) です。

 

 なぜ標準化が必要なのか

KNN は「距離」で近傍を判断します。

ところが、Titanic データの特徴量はスケールがバラバラです。

以下、コードです。

print(df_train.describe().round(2))

 

以下、実行結果です。

          age    fare  pclass   sibsp   parch
count  572.00  712.00  712.00  712.00  712.00
mean    29.50   32.59    2.33    0.55    0.38
std     14.50   51.97    0.82    1.18    0.79
min      0.42    0.00    1.00    0.00    0.00
25%     21.00    7.92    2.00    0.00    0.00
50%     28.00   14.45    3.00    0.00    0.00
75%     38.00   30.50    3.00    1.00    0.00
max     80.00  512.33    3.00    8.00    6.00

 

fare は0〜500超の範囲、pclass は1〜3、sibspparch は0〜8程度、と スケールがまったく違う ことがわかります。

このまま距離を計算すると、値が大きい fare の差ばかりが距離を支配 してしまい、pclasssibsp の違いがほとんど無視されます。

これでは「本当に似ている行」を正しく見つけられません。

 

補足:スケールの問題を数値で実感する

たとえば乗客Aと乗客Bの差が「fare の差=100、pclass の差=2」だったとします。ユークリッド距離は √(100² + 2²) ≈ 100.02 となり、pclass の差(2)はほぼ無視されます。fare を標準化して両方を同じスケールにすれば、両特徴量が公平に距離へ寄与するようになります。

 

 標準化を組み込んだ正しい実装

StandardScaler で標準化してから KNN 補完し、最後に元のスケールへ戻す、という流れを実装します。

以下、コードです。

from sklearn.preprocessing import StandardScaler

# ステップ1:標準化(学習データで fit)
scaler = StandardScaler()
train_scaled = scaler.fit_transform(df_train)
test_scaled = scaler.transform(df_test)

# ステップ2:標準化した状態で KNN 補完
knn = KNNImputer(
    n_neighbors=5, 
    weights='uniform'
)
train_scaled_imp = knn.fit_transform(train_scaled)
test_scaled_imp = knn.transform(test_scaled)

# ステップ3:元のスケールに戻す(inverse_transform)
train_final = scaler.inverse_transform(train_scaled_imp)
test_final = scaler.inverse_transform(test_scaled_imp)

df_train_scaled_knn = pd.DataFrame(
    train_final, 
    columns=numeric_cols,
    index=df_train.index
)

print("標準化あり KNN 補完後の age(先頭5件):")
print(df_train_scaled_knn['age'].head())
print(f"補完後の欠損: {df_train_scaled_knn['age'].isnull().sum()}")
  • StandardScaler:各特徴量を平均0・標準偏差1に変換(スケールを揃える)
  • scaler.fit_transform(df_train)学習データで スケーリングのパラメータを学ぶ(データ漏洩防止)
  • scaler.inverse_transform(...):補完後、元の単位(歳・ドルなど)に戻す

 

以下、実行結果です。

標準化あり KNN 補完後の age(先頭5件):
331    45.5
733    23.0
382    32.0
704    26.0
813     6.0
Name: age, dtype: float64
補完後の欠損: 0

 

この「標準化 → 補完 → 逆変換」の流れが、KNN Imputer を正しく使う鉄則です。

 

Pipeline を使うとさらにすっきり書ける

scikit-learn の Pipeline を使うと、標準化と補完を1つのオブジェクトにまとめられ、fit / transform の管理がより安全になります。シリーズの最終回(第10回)の総合実験で、パイプラインの書き方に触れます。

 

パラメータ n_neighbors の影響を見る

KNN Imputer の最も重要なパラメータは n_neighbors(近傍数 k) です。

k をいくつにするかで補完結果が変わります。

  • k が小さい(例:1〜2):少数の近い行だけを見る。局所的だが、外れ値の影響を受けやすい
  • k が大きい(例:20〜):多くの行を平均する。安定するが、全体平均に近づき、平滑化されすぎる

 

実際に k を変えて、補完後の age 分布がどう変わるかを可視化してみましょう。

以下、コードです。

fig, ax = plt.subplots(figsize=(10, 5))

# 元データ(欠損を除いた age)の分布
original_age = df_train['age'].dropna()
ax.hist(original_age, bins=30, density=True, alpha=0.4,
        color='gray', label='Original (NaN dropped)')

# k を変えて補完した結果を重ねる
for k in [1, 5, 20]:
    scaler_k = StandardScaler()
    tr_scaled = scaler_k.fit_transform(df_train)
    knn_k = KNNImputer(n_neighbors=k)
    tr_imp = scaler_k.inverse_transform(knn_k.fit_transform(tr_scaled))
    age_imp = pd.DataFrame(tr_imp, columns=numeric_cols)['age']

    # 密度の折れ線として描く(ヒストグラムの山の頂点を結ぶ)
    counts, bin_edges = np.histogram(
        age_imp, 
        bins=30, 
        density=True
    )
    bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
    ax.plot(
        bin_centers, 
        counts, 
        linewidth=2, 
        label=f'KNN (k={k})'
    )

ax.set_title('Effect of n_neighbors on Imputed Age Distribution')
ax.set_xlabel('Age')
ax.set_ylabel('Density')
ax.legend()
plt.tight_layout()
plt.show()
  • np.histogram(..., density=True):ヒストグラムの密度を計算(描画はせず数値だけ取得)
  • 各 k の補完結果を密度の折れ線として重ね描き

 

以下、実行結果です。

 

k=1 は元データに近いギザギザした分布、k=20 は滑らかだがピークが過度に集中した分布、k=5 はその中間、というように k によって分布の性質が変わる ことが視覚的にわかります。

一般的には k=5 前後が出発点としてよく使われますが、データに応じて調整します。

 

weights パラメータ:単純平均か加重平均か

もう一つの重要なパラメータが weights です。近傍 k 個の値をどう集約するかを決めます。

weights の値 動作
'uniform'(デフォルト) k 個すべてを 均等に平均
'distance' 距離が近い行ほど大きく重み付け して平均

'distance' は「より似ている行の値を、より強く反映する」という、より洗練された集約方法です。

以下、コードです。

# uniform と distance を比較
for w in ['uniform', 'distance']:
    sc = StandardScaler()
    tr_s = sc.fit_transform(df_train)
    knn_w = KNNImputer(n_neighbors=5, weights=w)
    tr_i = sc.inverse_transform(knn_w.fit_transform(tr_s))
    age_w = pd.DataFrame(tr_i, columns=numeric_cols)['age']
    print(
        f"weights='{w}': 補完後 age の平均={age_w.mean():.2f}, "
        f"標準偏差={age_w.std():.2f}"
    )
  • weights='uniform':5つの近傍を均等に平均
  • weights='distance':近い行ほど強く反映した加重平均

 

以下、実行結果です。

weights='uniform': 補完後 age の平均=29.18, 標準偏差=13.63
weights='distance': 補完後 age の平均=29.19, 標準偏差=13.86

 

uniformdistance で補完後の平均・標準偏差がわずかに異なることが確認できます。

'distance' のほうが「より似た行」を重視するため、データによってはより妥当な補完になることがあります。

 

単変量補完との分布比較

KNN 補完が、第6回の中央値補完と比べてどれだけ自然な分布を保つかを確認しましょう。

以下、コードです。

# 中央値補完
df_median = df_train.copy()
df_median['age'] = df_median['age'].fillna(df_train['age'].median())

# KNN 補完(標準化あり)
sc = StandardScaler()
tr_s = sc.fit_transform(df_train)
knn_f = KNNImputer(n_neighbors=5)
tr_i = sc.inverse_transform(knn_f.fit_transform(tr_s))
df_knn = pd.DataFrame(
    tr_i, 
    columns=numeric_cols, 
    index=df_train.index
)

# グラフ化
fig, axes = plt.subplots(3, 1, figsize=(10, 10))

# 元データ(NaN を除く)
axes[0].hist(
    df_train['age'].dropna(), 
    bins=30,
    color='gray', 
    edgecolor='black'
)
axes[0].set_title('Original (NaN dropped)')
axes[0].set_xlabel('Age')
axes[0].set_ylabel('Frequency')

# 中央値補完
axes[1].hist(
    df_median['age'], 
    bins=30,
    color='steelblue', 
    edgecolor='black'
)
axes[1].set_title('Median Imputation')
axes[1].set_xlabel('Age')

# KNN 補完
axes[2].hist(
    df_knn['age'], 
    bins=30,
    color='seagreen', 
    edgecolor='black'
)
axes[2].set_title('KNN Imputation (k=5)')
axes[2].set_xlabel('Age')

plt.tight_layout()
plt.show()

 

以下、実行結果です。

 

中央値補完は中央値付近に不自然な突出が出るのに対し、KNN 補完は元の分布の形をより自然に保つ ことがわかります。

これは、KNN が「似た行の実際の値」を使って補完しているためです。

 

KNN 補完の強みと弱み
  • 強み:他の変数の情報を活かし、MAR に強い。分布も比較的自然に保たれる
  • 弱み:計算コストが高い(全行間の距離計算が必要)。標準化が必須。カテゴリ変数をそのまま扱えない。データ量が多いと遅い

データ規模が大きい場合は、計算時間とのトレードオフを意識する必要があります。

 

まとめ

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

  • 多変量補完 は他の変数の情報を使う補完で、特に MAR に対して効果的
  • KNN Imputer は「似た行を k 個探し、その値の平均で埋める」手法
  • scikit-learn の KNNImputer で実装でき、fit_transform/transform でデータ漏洩を防げる
  • 標準化(StandardScaler)が必須:スケールを揃えないと、値の大きい特徴量に距離が支配される
  • 正しい流れは「標準化 → KNN補完 → 逆変換
  • n_neighbors(k) は補完結果に影響:小さいと局所的、大きいと平滑化される
  • weights で単純平均('uniform')か加重平均('distance')を選べる
  • 中央値補完より 自然な分布を保てる が、計算コストが高く数値データに限られる