【LightGBM】欠損値(NaN)の扱い方

はじめに

多くの機械学習モデルは、欠損値(NaN)が含まれていると学習前に補完(imputation)する必要があります。 一方でLightGBMは、NaNをそのまま入力できます。補完しなくても学習・予測が通り、しかも「欠損をどちらの枝に送るか」をデータから学習してくれます。

この記事では、その挙動を実際のコードで確認します。

NaNをそのまま学習する

まず、欠損を含んだデータを補完せずに学習できることを確認します。 特徴量xの一部をNaNにしたデータを作り、そのままlgb.Datasetに渡します。

import numpy as np
import lightgbm as lgb

rng = np.random.RandomState(0)
n = 5000

x = rng.uniform(0, 1, n).astype(float)
y = x + rng.randn(n) * 0.05

mask = rng.rand(n) < 0.2      # 2割を欠損に
x[mask] = np.nan             # 補完せずそのままNaN

X = x.reshape(-1, 1)
model = lgb.train(dict(objective="regression", num_leaves=15, learning_rate=0.1,
                       min_child_samples=20, verbose=-1),
                  lgb.Dataset(X, y), num_boost_round=200)

エラーなく学習が完了します。前処理としての補完は不要です。

欠損はどちらの枝へ送られるか

LightGBMは決定木の各分岐で、欠損値を左右どちらの枝に送るかを、損失が最も下がる方向に決めます。この方向はデータから学習されます。

これを確認するために、「欠損している行のyが高い」データを作ってみます。欠損が意味を持つ(欠損しているとyが高い)状況です。

def make(missing_target):
    x = rng.uniform(0, 1, n).astype(float)
    y = x + rng.randn(n) * 0.05                 # 非欠損: y ≈ x (0〜1)
    mask = rng.rand(n) < 0.2
    y[mask] = missing_target + rng.randn(mask.sum()) * 0.05  # 欠損行のyを指定
    x[mask] = np.nan
    return x.reshape(-1, 1), y

# 欠損 → 高い(3.0)
X, y = make(3.0)
model = lgb.train(dict(objective="regression", num_leaves=15, learning_rate=0.1,
                       min_child_samples=20, verbose=-1),
                  lgb.Dataset(X, y), num_boost_round=200)

print("predict(x=NaN):", round(float(model.predict([[np.nan]])[0]), 3))
print("predict(x=0.2):", round(float(model.predict([[0.2]])[0]), 3))
print("predict(x=0.8):", round(float(model.predict([[0.8]])[0]), 3))
predict(x=NaN): 2.999
predict(x=0.2): 0.192
predict(x=0.8): 0.795

xが値を持つ行ではy ≈ x(0〜1)を予測する一方、xがNaNの入力にはほぼ3.0を返しています。「欠損はyが高い」という関係を学習し、NaNを高い側の枝へ送っていることがわかります。

逆に「欠損 → 低い(-2.0)」でデータを作り直すと、予測の向きも変わります。

X2, y2 = make(-2.0)
model2 = lgb.train(dict(objective="regression", num_leaves=15, learning_rate=0.1,
                        min_child_samples=20, verbose=-1),
                   lgb.Dataset(X2, y2), num_boost_round=200)

print("predict(x=NaN):", round(float(model2.predict([[np.nan]])[0]), 3))
predict(x=NaN): -2.001

同じNaN入力でも、今度は-2.0付近を返します。欠損の送り先が固定ではなく、データに応じて決まることが確認できます。

補完との比較

欠損が情報を持つ場合、補完してしまうとその情報が消えることがあります。 特に、補完に使う値が実データにも存在する値だと、モデルは「補完された行」と「元からその値だった行」を区別できません。

ここでは0〜9の整数を取る特徴量xで、**欠損している行だけ y が高い(≈12)**データを作ります。欠損を学習データの中央値(=実データによくある値)で補完した場合と、NaNのまま渡した場合とでテスト精度を比べます。

rng = np.random.RandomState(0)
n = 6000

# 特徴量 x は 0〜9 の整数(カウントや評価値のようなデータを想定)
x = rng.randint(0, 10, n).astype(float)
y = x + rng.randn(n) * 0.3                        # 非欠損: y ≈ x
mask = rng.rand(n) < 0.2
y[mask] = 12.0 + rng.randn(mask.sum()) * 0.3      # 欠損行だけ y が高い
x_nan = x.copy(); x_nan[mask] = np.nan

tr = np.arange(n) < 4500                          # train/test split
params = dict(objective="regression", num_leaves=15, learning_rate=0.1,
              min_child_samples=20, verbose=-1)
rmse = lambda p, t: float(np.sqrt(np.mean((p - t) ** 2)))

# ① ネイティブ: NaN のまま
m1 = lgb.train(params, lgb.Dataset(x_nan[tr].reshape(-1,1), y[tr]), num_boost_round=200)
r1 = rmse(m1.predict(x_nan[~tr].reshape(-1,1)), y[~tr])

# ② 中央値で補完: NaN を学習データの中央値で埋める
fill = np.nanmedian(x_nan[tr])                    # = 4.0(実データによくある値)
Xtr = np.where(np.isnan(x_nan[tr]), fill, x_nan[tr]).reshape(-1,1)
Xte = np.where(np.isnan(x_nan[~tr]), fill, x_nan[~tr]).reshape(-1,1)
m2 = lgb.train(params, lgb.Dataset(Xtr, y[tr]), num_boost_round=200)
r2 = rmse(m2.predict(Xte), y[~tr])

print(f"① ネイティブ(NaNのまま) test RMSE: {r1:.4f}")
print(f"② 中央値で補完         test RMSE: {r2:.4f}")
① ネイティブ(NaNのまま) test RMSE: 0.2932
② 中央値で補完         test RMSE: 1.9693

lightgbm-missing

中央値4で補完された欠損行は、元からx=4だった通常の行に紛れてしまいます。モデルはその4が「欠損だった(yが高い)行」なのか「本当に4だった(yが低い)行」なのか区別できず、両者の中間あたりを予測します。上の図の通り、この例では補完側がテストの欠損行(真のy≈12)を10付近と低めに外しています。NaNのまま渡せば、LightGBMは欠損を別の枝に分けられるため、欠損行に対しても12付近を返せています。

なお、ここでの数値はあくまで一例です。差がどれくらい出るか(あるいは出ないか)はデータ次第で、補完しても情報が失われず、精度がほとんど変わらないこともあります。「補完すると精度が下がる可能性がある」という程度の話です。

use_missing と zero_as_missing

欠損の扱いに関わるパラメータが2つあります。

  • use_missing(デフォルトtrue)… 欠損値を特別扱いするかどうか。falseにすると欠損の専用処理を無効化します。
  • zero_as_missing(デフォルトfalse)… trueにすると、0も欠損として扱います。疎行列(大半が0のデータ)で、0と「値なし」を同一視したい場合に使います。

通常はデフォルトのままで問題ありません。

注意点

  • NaNをそのまま渡せるのはLightGBM側の話です。pandasから渡す場合、np.nanはそのまま欠損として扱われます。
  • 欠損の送り先はあくまで学習データで決まります。学習データに欠損がほとんど無い特徴量では、テスト時の欠損に対する挙動が安定しないことがあります。
  • 欠損が完全にランダム(yと無関係)な場合は、ネイティブ処理と補完で大きな差は出ません。差が出るのは、今回のように欠損自体が情報を持つケースです。
  • 欠損が情報を持つ場合でも、補完に使う値が実データに存在しない値なら、木がその点を切り出して欠損を復元できるため、補完でも精度が出ることがあります。差が明確に出るのは、今回のように補完値が実データと重なるときです。

まとめ

  • LightGBMはNaNを補完せずそのまま学習・予測できる
  • 欠損を左右どちらの枝へ送るかは、損失が下がる方向にデータから学習される
  • 欠損が情報を持ち、補完値が実データと重なる場合、補完では欠損の情報が失われ、ネイティブ処理の方が精度が高くなる可能性がある
  • use_missingzero_as_missingで欠損の扱いを調整できる(通常はデフォルトで可)

参考リンク