はじめに
多くの機械学習モデルは、欠損値(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
中央値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_missing・zero_as_missingで欠損の扱いを調整できる(通常はデフォルトで可)