LightGBMの学習ログに、同じ警告が100行並びました。

[LightGBM] [Warning] No further splits with positive gain, best gain: -inf

この比較では、No further splitsが100回出たモデルのほうが、0回のモデルより誤差が小さくなりました。公開の回帰データを使い、分割の制約だけを変えた結果です。

No further splitsは、現在の設定では木をこれ以上分割できない、という警告です。葉に必要なデータ数などの制約も関わるので、これだけではモデル全体の学習に失敗したとは分かりません。

No further splitsが出なくても、予測は平均値のままだった

使ったのはscikit-learnのload_diabetesです。学習331行・検証111行に一度だけ分け、min_child_samplesを20と200に変えて学習しました。医療上の性能評価ではなく、回帰モデルの学習とログを見るための比較です。

木の末端で予測値を持つ部分を「葉」と呼びます。min_child_samplesは、1つの葉に必要なデータ数の目安です。値を大きくすると少数のデータだけを分ける分割が制限されるので、今回は20から200へ上げて、その条件を厳しくしました。

誤差はMAE(予測と正解の差の絶対値の平均)で比べます。小さいほど正解に近い値です。何も学習せず、学習データの目的値の平均を全行に返す「平均予測」も並べました。

観測基本(20)制約変更(200)
No further splits100回0回
特徴量なしのログなしあり
各木の葉11〜151
全行同じ予測いいえはい
検証MAE約43.76約65.53
平均予測のMAE約65.53約65.53

表の「特徴量なしのログ」は、no meaningful featuresとused features: 0です。0回のほうには、次の行がありました。

[LightGBM] [Warning] There are no meaningful features which satisfy the provided configuration. Decreasing Dataset parameters min_data_in_bin or min_data_in_leaf and re-constructing Dataset might resolve this warning.
[LightGBM] [Info] Number of data points in the train set: 331, number of used features: 0

used features: 0。この設定では、学習に使える特徴量が1つも残りませんでした。木は分割されず、誤差も平均予測と同じです。

手元のモデルで確かめる

手元でも、予測値と平均予測の誤差ならログなしで比べられます。目的値が1つの回帰モデルを対象にしたコードです。分類器には使えません。LightGBM 4.7.0、scikit-learn 1.9.1で動作を確認しています。

  • modelは学習済みのLGBMRegressor
  • X_validは学習に使っていない入力で、学習時と同じ列名・同じ列順
  • y_validはX_validと同じ行数・同じ行順の正解で、空でない
  • y_validとy_trainは有限値の1次元配列またはSeriesで、予測と同じスケール。y_trainは実際に学習に使った目的値
import numpy as np
from sklearn.metrics import mean_absolute_error

# 学習済みLGBMRegressorと、対応する1次元の正解を使う。
prediction = model.predict(X_valid, validate_features=True)
baseline = np.full(len(y_valid), np.mean(y_train), dtype=float)
print(f"予測は全行同じ: {bool(np.all(prediction == prediction[0]))}")
print(f"検証MAE: {mean_absolute_error(y_valid, prediction):.3f}")
print(f"学習平均のMAE: {mean_absolute_error(y_valid, baseline):.3f}")

検証データは複数行で試してください。1行だけでは、予測にばらつきがあるか分かりません。誤差は重みなしで計算するので、sample_weightを使っている場合は重み付きの指標も別に必要です。

ここでは冒頭の実験に合わせて平均予測を基準にしています。MAEを最小にする定数は中央値なので、平均を下回れば実用上も十分、という合格線ではありません。

全行同じ予測なら、入力を先に見ます。検証データの列が一定になっていないか、欠損ばかりになっていないか。入力に問題がなければ、no meaningful features・used features: 0のログや、min_child_samples・min_split_gainなどの制約を調べます。

予測がばらついていても、平均予測以上の誤差なら、X_validとy_validの行順、目的値のスケール、学習時との分布の違いを確認します。平均予測より誤差が小さい場合も、別の期間で同じ傾向が続くかを確かめます。ただ、No further splitsが出たという理由だけで設定を変える必要はありません。

全行同じ予測でも、間違いとは限らない

ただし、予測が全行同じというだけで異常とは決められません。0か1の値を取る特徴量xが1列だけの人工データでも試しました。目的値は10 * xで、学習用400行・検証用200行、どちらもx=0とx=1が半数ずつ、ノイズはありません。設定はn_estimators=30、num_leaves=31、learning_rate=0.1、min_child_samples=20、min_split_gain=0です。

条件No further splits予測に使う木分割に使った列全行同じ予測検証MAE平均予測のMAE
基本3030本1いいえ約0.2125
検証の正解を反転3030本1いいえ約9.7885
検証入力をx=0の行だけに3030本1はい約0.2125

検証の正解を反転した条件では、学習は基本と同じまま、検証の正解だけを10 - y_validに置き換えました。木も分割も予測の動きも基本と同じなのに、MAEは平均予測を上回りました。自然に起きるデータの変化を再現したものではありません。

一方、検証データをx=0の行だけに絞ると、100行すべての予測が同じ値になりました。それでもMAEは平均予測よりずっと小さく、後述の診断関数でも、学習時から検証時に一定になった列としてxが返りました。

予測を確かめてから、表示を消す

確認が済んだら、verbosity=-1で表示を止められます。人工データでは、表示を止めても予測は完全に一致しました。ただし、上のno meaningful featuresなどほかの警告も表示されなくなるので、確認の前には使いません。警告を消すためだけに分割の制約を緩めることも勧めません。制約は過学習を抑えるために設定していることがあり、緩めるかどうかは検証データの誤差で決めます。

ソースではどこで止まっているか

LightGBM 4.7.0のソースでは、1本の木を育てる途中で、制約を考慮して選んだ最良の分割候補のgain(分割による改善を表す評価値)が0以下なら、この警告を出してその木の成長を止めます(serial_tree_learner.cpp)。num_leavesは葉の数の上限なので、上限に届く前に分割できる場所がなくなれば出ます。1本の木の成長が止まったことと、モデル全体が何も学習していないことは別の話です。

候補を探す処理では、葉のデータ数・Hessianの合計・要求する改善量などを検査します。採用できる候補がなく、評価値の初期値が更新されない場合にもbest gain: -infになります。損失そのものがマイナス無限大になったという意味ではなく、この表示だけで、どの制約や改善量が原因かは決められません(feature_histogram.hppの候補評価)。

1本目の木から分割できないときは、別の警告Stopped training because there are no more leaves that meet the split requirementsが出て、木を追加しなくなります(gbdt.cpp)。検証データの誤差が改善しなくなったら止めるearly stoppingとは別の停止です。学習前に使える特徴量が1つもないと判断された場合はThere are no meaningful features which satisfy the provided configuration.が出て、ログのnumber of used featuresが0になります(dataset.cpp)。既定のfeature_pre_filter=Trueでは、学習前に葉の制約で分割に使えない特徴量が除かれます。

補足:木・入力列・ログをまとめて調べるdiagnose

ここまでの確認に、木の葉数や入力列の点検を加えた関数がdiagnoseです。1つのモデルを調べ、次に確認する列や設定の候補を返します。原因の確定や設定の変更は行いません。変更の前後を比較するときは、それぞれのモデルに対して呼び出します。

必要なライブラリは次のとおりです。

python -m pip install "lightgbm==4.7.0" "scikit-learn==1.9.1" "pandas==3.0.5" "numpy==2.4.6" "scipy==1.17.1"

診断関数のファイルdiagnose.pyをダウンロードし、自分のスクリプトと同じフォルダへ保存します。学習済みモデルを保持しているスクリプトやノートブックで、次のコードを実行します。

from pathlib import Path
from diagnose import diagnose

report = diagnose(
    model, X_valid, y_valid, y_train,
    X_train=X_train,
    training_log=Path("training.log").read_text(encoding="utf-8"),
)
print(report["log_observations"])
print(report["train_columns"], report["valid_columns"])
for check in report["checks"]:
    print(check)

1つの学習済みモデルと、その学習・検証に対応するデータやログを渡します。

  • model:学習済みのLGBMRegressor。目的値が1つの回帰モデルが対象で、分類器は拒否します。lgb.trainで作ったBoosterは受け付けません
  • X_valid:学習に使っていない検証データ。DataFrameなら学習時と同じ列名・列順、NumPy配列なら同じ列順
  • y_valid:X_validと同じ行数・同じ行順の正解。行順が合っているかは関数では確かめられないので、呼び出す側で保証します
  • y_train:実際に学習に使った目的値
  • X_train(省略可):実際に学習に使った入力。y_trainと同じ行数が必要です
  • training_log(省略可):そのモデルの学習で出たログ全文の文字列。training.logは自分のログファイルのパスに置き換えます

y_validとy_trainは、空でない有限値の1次元配列またはSeriesを渡します。1列のDataFrameや(n, 1)の配列、NaN・無限大は受け付けません。予測・y_valid・y_trainは同じスケールにそろえます。MAEは重みなしで計算するので、sample_weightを使って学習した場合の重み付き指標は別に確かめます。

別の学習のログや前処理前の入力を渡しても、関数には見分けられません。手元にないものは、training_logやX_trainの行ごと消して実行してください。

画面で見ただけでログファイルがない場合

次の学習でログを残すなら、fitの前に受け取り先を登録します。変数は自分の学習コードのものを使ってください。

import lightgbm as lgb
from diagnose import diagnose

class TrainingLog:
    def __init__(self):
        self.lines = []
    def info(self, message):
        self.lines.append(str(message))
    def warning(self, message):
        self.lines.append(str(message))

log = TrainingLog()
lgb.register_logger(log)
model.set_params(verbosity=1)
model.fit(X_train, y_train)
report = diagnose(model, X_valid, y_valid, y_train,
                  X_train=X_train, training_log="\n".join(log.lines))

この例は通常のfitです。early stoppingなどを使っている場合は、本来のfit引数やcallbackを保ってください。ログを得るためだけの再学習は必須ではありません。

register_loggerの登録はプロセス全体に効くので、同じプロセスで別のモデルを並行学習させず、対象の1回の学習ログだけを渡します。表示を抑制するとInfoのused features: 0などが残らないため、この例はverbosity=1です。

診断結果の全項目と、None・0の読み方

early stoppingで学習が止まっていれば、予測には最も良かった反復までの木(best_iteration_)が使われます。diagnoseも、その範囲にそろえて木を数えます。

キー意味
prediction_trees予測に使う木の数。best_iteration_があればその範囲、なければ全部の木
leaves_per_treeその範囲の木それぞれの葉の数
split_featuresその範囲の木で、分割に使われた異なる列の数。入力の列数ではない
valid_rows検証データの行数
prediction_std検証データでの予測の標準偏差(参考値)
constant_on_valid検証データの予測が全行で完全に同じ値か
valid_mae / train_mean_baseline_mae検証MAEと、平均予測の検証MAE
log_observationsログ中の4種の文字列(No further splits、Stopped training、no meaningful features、used features: 0)の一致数
train_columns / valid_columns学習・検証それぞれの一定列(constant)と全欠損列(all_missing)の列名
varying_train_constant_valid学習では値が変わるのに、検証では一定の列名
checks上の観測から組み立てた、次の確認先の短い文のリスト

ログのNoneと0は意味が違います。 training_logを渡さなければ4項目ともNoneで、「確認していない」という意味です。渡したログにその文言がなければ、その項目は0になります。4項目すべて0の場合は、checksに「未発生の保証ではない」という文が入ります。verbosity=-1で表示を止めていた、ログの一部しか保存していない、LightGBMの版で文言が違う、といった場合も0になるからです。

ログの件数と木の数は対応しません。冒頭の比較では、Stopped trainingが100件あっても木は1本でした。

列名の一覧を得るにはDataFrameを渡します。値が1種類しかない列を「一定列」と数え、欠損だけの列も含めています。NumPy配列を渡した場合、この欄はNoneになり、checksにも列を比較していないことが書かれます。

先ほどの人工データで、学習後に検証データをx=0の行に絞ると、次の結果が返りました。

  • constant_on_valid:True、valid_mae:約0.212、train_mean_baseline_mae:5
  • train_columns:{"constant": [], "all_missing": []}、valid_columns:{"constant": ["x"], "all_missing": []}
  • varying_train_constant_valid:["x"]
  • checks:
    • この検証入力では定数予測。入力の偏りと分割制約を点検(異常とは未確定)
    • 木の追加分割が止まったログあり。回数だけで失敗とせず、葉数・検証MAEと併せて判断
    • 学習では変動し検証では一定の列あり。検証範囲や抽出条件を確認: x

調べる先はxの抽出条件だと分かります。

このとき予測の標準偏差は丸め誤差で約2.8e-17となり、0とは一致しません。constant_on_validは標準偏差ではなく、全要素が最初の要素と完全に等しいかで判定しています。

木が1本・葉が1つだったら

prediction_treesが1、葉が1、split_featuresが0なら、分割を使わないモデルになっています。ログのno meaningful features・used features: 0・Stopped trainingと、min_child_samples・min_split_gainなどの制約を照合します。同じログでも、入力が一定だった場合と制約が厳しすぎた場合があるため、関数の候補だけで原因を決めないでください。

入力・制約・ログ設定を変えた追試

入力や制約を変えた場合も、警告の出方は違っていました。

  • 入力xを学習・検証とも0に置き換えると、No further splitsは出ず、no meaningful featuresとused features: 0が1件、Stopped trainingが30件で、1本・葉1の全行同じ予測(MAE 5)になった。train_columnsとvalid_columnsの一定列にxが返った。min_child_samples=1まで緩めても同じままで、入力に情報がなければ制約を緩めても直らない
  • min_split_gain=1e9ではNo further splitsが基本と同じ30回出たうえでStopped trainingも30回出て、1本・葉1の全行同じ予測になった。min_child_samples=201ではNo further splitsは0回で、同じく全行同じ予測になった。どちらも基本の値に戻すと基本と同じ結果に戻った
  • verbosity=-1では警告が表示されず、予測は基本と完全に一致した。表示を止めてもモデルは変わらず、ログの一致数が0でも警告が起きなかった証拠にはならない
  • 反転した正解を監視してearly stopping(stopping_rounds=3)を使うとbest_iteration_は1で、diagnoseは予測と木の集計をこの1本の範囲にそろえて返した
公開データの全設定とログの件数

scikit-learnのload_diabetes(442行、10特徴量の回帰データ)をscaled=Falseで読み、train_test_split(test_size=0.25, random_state=42)で学習331行・検証111行に一度だけ分けました。前半の比較に使ったデータの詳細です。

設定はn_estimators=100、learning_rate=0.05、num_leaves=31、min_child_samples=20、min_split_gain=0を基本とし、対照ではmin_child_samplesだけを200に変えました。

ログ文言min_child_samples=20min_child_samples=200
No further splits1000
Stopped training0100
no meaningful features01
used features: 001

どちらの条件でも、学習・検証の入力に一定列と全欠損列はありませんでした。予測に使った木は基本条件で100本、対照で1本、分割に使った列はそれぞれ10列と0列です。前半の短いコードも両モデルで実行し、保存していた予測と全要素が一致しました。

この比較では入力を変えていないので、差はmin_child_samplesの変更によるものです。同じログでも、前の人工例のように入力が一定だった場合とは切り分ける必要があります。min_data_in_leaf(scikit-learn形式ではmin_child_samples)は公式の説明ではHessian(損失の2階微分)にもとづく近似とされるので、「学習行数がいくつ未満なら必ず分割できない」とは一般化できません。

詳しい検証条件

  • 環境:Python 3.11.15、LightGBM 4.7.0、scikit-learn 1.9.1、pandas 3.0.5、NumPy 2.4.6、macOS arm64 CPU
  • 共通の設定:random_state=42、deterministic=True、force_col_wise=True、n_jobs=1、verbosity=1。ログはLightGBMにloggerを登録し、1回のfitごとに分けて保存した
  • diagnoseが返すMAEはscikit-learnのmean_absolute_errorと一致し、split_featuresは同じ範囲の木のfeature_importance("split")で0でない列の数と一致した
  • 人工データの基本と入力を0にした条件の予測は、以前に同じデータ・設定で保存した予測と完全に一致した
  • ログ未提供で4項目がNone、空のログで4項目が0、NumPy配列で列の観測がNone、検証のxを全部欠損にすると全欠損列と「学習では変動し検証では一定の列」の両方にxが返ることを確かめた
  • 分類器、空・2次元・NaNを含む検証の正解、空または無限大を含む学習の目的値、行数の不一致、検証の列名・列数の違い、学習入力の列名の違いは、いずれも例外で止まった
  • 上の使用例のコードをそのまま実行し、返り値の全項目が人工データの基本条件で保存した値と一致した

適用範囲と限界

  • 使ったのは、2値の特徴量1列の人工データと、公開の回帰データ1つを1通りの分割で分けたものです。公開データの結果は医療上の性能ではなく、ほかのデータや分割での誤差、良いハイパーパラメータ、一般的なベンチマークは示しません。
  • 平均予測との比較は、1組の検証データ上の比較です。差の大きさの安定性や、未知のデータへの汎化は確かめていません。
  • 列の観測は通常の数値列で確かめました。1つの列に複数の種類の欠損表現が混ざるobject型の列などは確かめていません。
  • 扱った原因は、分割できる値がなくなる場合、min_split_gainとmin_child_samplesが大きすぎる場合、入力が一定の場合、検証データの偏りと人工的な正解の反転だけです。