LightGBM で多クラス分類をするときのゲインの話

まとめ: LightGBM で多クラス分類するとき、
  • 例えばアヤメの 3 クラス分類なら LightGBM は 1 ラウンドごとに 3 本の木を構築し、それぞれの木は「セトーサらしさのスコア」「バーシカラーらしさのスコア」「バージニカらしさのスコア」を前ラウンドまでの累積スコアからどれだけ変動させるかを出力する。最終的に各ラベルらしい確率を出力するときは、各ラベルらしさの累積スコアが Softmax される。
  • 木の訓練時には交差エントロピー損失が最も減る分岐を探索するが、このとき厳密に損失を計算するのではなく、前ラウンドまでの累積スコアの周りでの 2 次までのテイラー近似 (のヘッセ行列の非対角成分を無視してその分補正したもの) で損失の減少量 (ゲイン) を見積もる。
  • 訓練時に同ラウンドの他クラスの結果を反映するなどはしない=同ラウンド内の木の根の分岐のゲイン減少量の起点は同じ (前ラウンド終了時点) である。
  1. Parameters Tuning — LightGBM 4.6.0.99 documentation: 「ゲイン」とはその分岐を追加することで損失を減じられる量のこととある。
  2. GitHub - microsoft/LightGBM: A fast, distributed, high performance gradient boosting (GBT, GBDT, GBRT, GBM or MART) framework based on decision tree algorithms, used for ranking, classification and many other machine learning tasks.: LightGBM のソースであり、不明点があればこの実装が正である。手元の Ubuntu 環境ではデバッグプリントを入れてビルド・実行することができた。
  3. 6  Gradient Boosted Decision Trees – Machine Learning for Economics: 勾配ブースティング決定木の損失の 2 次近似について記述がある。
副部長、LightGBM さんについてわからない点があるのですが。
何がわからないの?
順を追って話しますと、私は LightGBM さんと親睦を深めるべく、LightGBM さんと アヤメの特徴量 (萼片長、萼片幅、花弁長、花弁幅) から品種 (セトーサ、バーシカラー、バージニカ) を予測する 3 クラス分類をしました。今回は全 150 サンプルのうち 90 サンプルを訓練データに、残りの 60 サンプルを評価データ兼テストデータにしました。目的関数は通常の multiclass (予測確率の対数損失 = 交差エントロピーの全サンプル平均) とし、また簡単のため num_leaves=2, n_estimators=2, learning_rate=1 にしました。1 本の木には最大でも葉は 2 枚のみ (1 回分岐するのみ)、訓練は 2 ラウンドのみ、学習率 1 ということですね。
▼ train.py (クリックして展開)

import lightgbm as lgb
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
import pandas as pd
import numpy as np

def train_model(x_train, y_train, x_test, y_test):
    # LGBM モデルインスタンス作成
    # https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.LGBMClassifier.html#lightgbm-lgbmclassifier
    # https://lightgbm.readthedocs.io/en/latest/Parameters.html
    model = lgb.LGBMClassifier(
        objective='multiclass', random_state=42, verbosity=-1,
        num_leaves=2,  # 1 本の木には最大何枚の葉 (最低でも2枚にしないとエラー)
        n_estimators=2,  # 最大ラウンド数
        learning_rate=1,  # 学習率
    )
    # フィット
    # https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.LGBMClassifier.html#lightgbm.LGBMClassifier.fit
    model.fit(
        x_train, y_train, eval_set=(x_test, y_test), eval_metric='multi_logloss',
        callbacks=[
            lgb.early_stopping(stopping_rounds=3),  # 3 ラウンド連続で損失が改善しなかったら停止
            lgb.log_evaluation(1),
        ]
    )
    return model

def report(y_true, y_pred, target_names):  # 分類結果レポート
    # https://scikit-learn.org/stable/modules/generated/sklearn.metrics.classification_report.html
    d = classification_report(y_true, y_pred, target_names=target_names, output_dict=True)
    print('accuracy:', d['accuracy'])
    del d['accuracy']
    print(pd.DataFrame(d).T)  # accuracy 以外の値は同じキーを持つ辞書

if __name__ == '__main__':
    # アイリスデータセットのロード
    # https://scikit-learn.org/stable/modules/generated/sklearn.datasets.load_iris.html
    data = load_iris(as_frame=True)  # データフレームとして取得
    feat_names = ['sl', 'sw', 'pl', 'pw']  # 特徴量名を短縮
    data.data.columns = feat_names

    # トレインテストスプリット (40% をテストデータに)
    # https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.train_test_split.html
    x_train, x_test, y_train, y_test = train_test_split(data.data, data.target, test_size=0.4, random_state=42)
    assert x_train.shape == (90, 4)
    assert x_test.shape == (60, 4)

    # モデル訓練・予測・レポート・保存
    model = train_model(x_train, y_train, x_test, y_test)
    y_pred = model.predict(x_test)
    report(y_test, y_pred, data.target_names)
    model.booster_.save_model('iris_lgbm.txt')

    # テストデータの予測の詳細および損失の検算
    df = x_test.copy()
    df['true'] = y_test.map(lambda x: data.target_names[x]).values
    df_proba = pd.DataFrame(model.predict_proba(x_test))
    df_proba.columns = [f'q({target_name})' for target_name in data.target_names]
    df = pd.concat([df.reset_index(drop=True), df_proba.reset_index(drop=True)], axis=1)
    df['q(true)'] = df.apply(lambda row: row['q({})'.format(row['true'])], axis=1)
    df['log(q(true))'] = df['q(true)'].map(np.log)
    print('multi_logloss:', -df['log(q(true))'].mean())  # 損失の検算
    print(df.head())

このときの実行結果は下記になりました。

[1]     valid_0's multi_logloss: 0.347051
Training until validation scores don't improve for 3 rounds
[2]     valid_0's multi_logloss: 0.157009
Did not meet early stopping. Best iteration is:
[2]     valid_0's multi_logloss: 0.157009

accuracy: 0.9333333333333333
              precision    recall  f1-score  support
setosa         1.000000  0.956522  0.977778     23.0
versicolor     0.894737  0.894737  0.894737     19.0
virginica      0.894737  0.944444  0.918919     18.0
macro avg      0.929825  0.931901  0.930478     60.0
weighted avg   0.935088  0.933333  0.933824     60.0

multi_logloss: 0.15700909674521454
    sl   sw   pl   pw        true  q(setosa)  q(versicolor)  q(virginica)   q(true)  log(q(true))
0  6.1  2.8  4.7  1.2  versicolor   0.058272       0.869997      0.071730  0.869997     -0.139265
1  5.7  3.8  1.7  0.3      setosa   0.962649       0.027779      0.009572  0.962649     -0.038066
2  7.7  2.6  6.9  2.3   virginica   0.014405       0.051828      0.933767  0.933767     -0.068528
3  6.0  2.9  4.5  1.5  versicolor   0.058272       0.869997      0.071730  0.869997     -0.139265
4  6.8  2.8  4.8  1.4  versicolor   0.058272       0.869997      0.071730  0.869997     -0.139265

正解率が 93.3 % なので、テストデータ 60 サンプルのうち 56 サンプルを正しい品種と予測していることが窺えますね。訓練終了時の評価損失が 0.157009 とロギングされていますが、これはテストデータの各サンプルの「予測分布に対する真分布の交差エントロピー」の平均、つまり、以下に等しいことが検算できました。

 L = -(1/n) \sum_i^n \sum_k p_{i, k} \log q_{i, k} = -(1/n) \sum_i \log q_{i, {\rm true}(i)}
ここで、 p_{i, k}, q_{i, k}i 番目のサンプルが品種 k である確率の真値と予測値、 {\rm true}(i) を真の品種とします。
ではこれはどのような木になっているのか、model.booster_.save_model('iris_lgbm.txt') で出力された iris_lgbm.txt をみてみました。ここに出力されていた Tree=0 の情報は以下でした。この Tree=0 の分岐は「花弁の長さが 1.8cm 未満か」というものになっています。確認すると、訓練データのうちこれを満たす 26 サンプルは全てセトーサであり、27 サンプルのセトーサのうちほとんどを分離できるので、これはよい分岐なのでしょう。実際この分岐の「ゲイン」は split_gain=56.875 と何やら大きい値となっています。LightGBM のドキュメントのパラメータチューニングのページによると、「ゲイン」とはその分岐を追加することで損失を減じられる量のことであるそうですね。LightGBM は「ゲイン」が最大になる分岐 (どの特徴量のどの閾値) を選択するのだと。

Tree=0
num_leaves=2
num_cat=0
split_feature=2  # 花弁長
split_gain=56.875
threshold=1.8  # 閾値
decision_type=2
left_child=-1
right_child=-2
leaf_value=1.0182493968717188 -2.1067506267809168
leaf_weight=8.1899999380111712 20.159999847412109
leaf_count=26 64
internal_value=-1.20397
internal_weight=28.35
internal_count=90
is_linear=0
shrinkage=1

それで、ここからがわからない点です。私にはこの「ゲイン」56.875 が大きすぎるように思われるのです。おそらく初期状態の予測確率は、訓練データ 90 サンプル中の各ラベルの存在比にしたがい、どのサンプルも一律に「27 : 31 : 32 の比率で各品種であろうと予測する」といったものになるでしょう。このときの交差エントロピーの和 (1 サンプルあたりにしない) は 98.638 です。他方、「花弁の長さが 1.8cm 未満である 26 サンプルは確実にセトーサと予測し、そうでない 64 サンプルについてはその中での各ラベル存在比 1 : 31 : 32 で各品種であろうと予測する」としたときの交差エントロピーの和は 48.811 です。「花弁の長さが 1.8cm 未満か」という分岐は、理論上ここまで交差エントロピーを減じ得るといえるでしょう。

print(- 27 * np.log(27 / 90) - 31 * np.log(31 / 90) - 32 * np.log(32 / 90))  # 98.638
print(- 1 * np.log(1 / 64) - 31 * np.log(31 / 64) - 32 * np.log(32 / 64))  # 48.811

しかし、これで減らせる交差エントロピーは 50 くらいです。「ゲイン」56.875 と近い気もしますが、そうはいっても結構誤差があります。それも、理論上減じ得る値より「ゲイン」の方が大きいのです。

わからない点は他にもあります。出力された iris_lgbm.txt 中に出てくる 6 箇所の split_gain を足し上げると 140 ちょっとになりますが (訓練後のモデルに model.booster_.feature_importance(importance_type='gain') を適用することによって出せる特徴量ごとのゲインの和もこの値ですね)、元々の損失が 98.638 なので 140 も減らすことはできないはずです。損失が 0 になった時点で完璧な予測が達成されますから。

……そういうわけで不明点をまとめますと、なぜ分岐たちが主張する「ゲイン」は、彼らが実際に減らすであろう損失より大きいのでしょうか? 分岐たちは自らが如何に優秀な分岐であるかを盛っているのですか? 決定木の分岐に採用されるのも人間の就職活動と同じなのですか? さすれば私は LightGBM さんに彼らの欺瞞を伝えなければ……。

部長の疑問は「分岐のゲインがその分岐が減少させうる損失幅より大きいのはなぜか」と「分岐のゲインの和が初期損失を超えているのはなぜか」の 2 点かな? それぞれに簡潔に回答するなら「近似である上に定数倍されているから」「それらを抜きにしても多クラス分類の分岐のゲインは厳密には足せないから」になるかな。
えっ、近似だったのですか? 私でも簡単に検算できる交差エントロピー損失を近似しているとは思いませんでした。
「与えられた一つの分岐の仕方の損失を計算すること」はそりゃあ簡単だろうけど、それと「損失の減少幅が最大の分岐を探すためにあらゆる分岐の損失を計算すること」とはコストが全然違うでしょ。加えて、現実のデータは往々にして iris に比べサンプル数も特徴量数も膨大になるよ。そんな中であらゆる閾値の最適スコアに対する対数損失 (非線形) の減少幅を厳密に求めるのは全然 "Light" じゃない。
確かに、言われてみれば……。
順を追って確認していこうか。部長の訓練結果では、訓練終了時には訓練データの最初の 5 サンプルへの予測確率は以下になるね (model.predict_proba(x_train) で予測確率を出力)。
萼長萼幅花長花幅正解ラベルq(setosa)q(versicolor)q(virginica)
06.32.74.91.8virginica0.0120.1850.803
14.83.41.90.2setosa0.2520.6910.057
25.03.01.60.2setosa0.9630.0280.010
35.13.31.70.5setosa0.9630.0280.010
45.62.74.21.3versicolor0.0580.8700.072
訓練データの予測結果はそのようになるのですね。2 行目のサンプル 1 だけは、正解ラベルはセトーサなのに予測確率の最大点がバーシカラーになっていますね。これは訓練データ中で唯一花弁の長さが 1.8cm 未満でないセトーサのようですから、正しく分離境界を引けなかったのでしょうね。
時間を訓練開始前に戻そう。訓練データの各サンプルには「セトーサらしさのスコア、バーシカラーらしさのスコア、バージニカらしさのスコア」の初期値としては以下の値が割り当てられるよ。部長が考えていた初期状態の通り、Softmax 後の予測確率が訓練データ中の各ラベルの存在比にしたがうように、初期スコアはその対数にするわけだね。
萼長萼幅花長花幅正解ラベルs(setosa)s(versicolor)s(virginica)q(setosa)q(versicolor)q(virginica)
06.32.74.91.8virginica-1.204-1.066-1.0340.30.3440.356
14.83.41.90.2setosa-1.204-1.066-1.0340.30.3440.356
25.03.01.60.2setosa-1.204-1.066-1.0340.30.3440.356
35.13.31.70.5setosa-1.204-1.066-1.0340.30.3440.356
45.62.74.21.3versicolor-1.204-1.066-1.0340.30.3440.356
確かに、各初期スコアは君データ中のその品種の存在比の対数になっていますね!

assert np.isclose(np.log(27 / 90), -1.204, atol=1e-03)  # セトーサの存在比の対数
assert np.isclose(np.log(31 / 90), -1.066, atol=1e-03)  # バーシカラーの存在比の対数
assert np.isclose(np.log(32 / 90), -1.034, atol=1e-03)  # バージニカの存在比の対数

そうか、これから木によって訓練サンプルを分岐させて、「この葉に来たらセトーサらしさのスコアを上げよう」「この葉に来たらバーシカラーらしさのスコアを下げよう」などといったスコア調整をしていくわけですね! ……しかし、その分岐位置をどうやって見出すんです? 先ほどの副部長の言によりますと、地道にあらゆる分岐の損失減少幅を計算してみることはしないのですよね?

セトーサらしさのスコアから調整しよう。じゃあ、あるサンプルの損失をセトーサらしさのスコアで偏微分できる? 2 回まで。
え? えっと、あるサンプルの損失は  L_i = - \log q_{i, {\rm true}(i)} ですから、そのサンプルがセトーサなら  L_i = - \log q_{i, {\rm setosa}} ですね。ここで、予測確率とはスコアの Softmax であったので、以下です。
 q_{i, {\rm setosa}} = \exp(s_{i, {\rm setosa}}) / \bigl( \exp(s_{i, {\rm setosa}}) + \exp(s_{i, {\rm versi}}) + \exp(s_{i, {\rm virgi}}) \bigr)
これを損失に代入すると、以下です。
 L_i = - s_{i, {\rm setosa}} + \log \bigl(\exp(s_{i, {\rm setosa}}) + \exp(s_{i, {\rm versi}}) + \exp(s_{i, {\rm virgi}}) \bigr)
これを  s_{i, {\rm setosa}}偏微分するには合成関数の微分公式を使えばよく、そのサンプルがセトーサでないときも合わせて、以下です。
 \displaystyle \frac{\partial L_i}{\partial s_{i, {\rm setosa}}} = \begin{cases} - 1 + \frac{\exp(s_{i, {\rm setosa}})}{\exp(s_{i, {\rm setosa}}) + \exp(s_{i, {\rm versi}}) + \exp(s_{i, {\rm virgi}})} = - 1 + q_{i, {\rm setosa}} & \bigl({\rm true}(i) = {\rm setosa}\bigr) \\ \frac{\exp(s_{i, {\rm setosa}})}{\exp(s_{i, {\rm setosa}}) + \exp(s_{i, {\rm versi}}) + \exp(s_{i, {\rm virgi}})} = q_{i, {\rm setosa}} & \bigl({\rm true}(i) \neq {\rm setosa}\bigr) \end{cases}
2 回まで偏微分してほしいとのことでしたね。上記をまた偏微分すればよいのですか? それであれば、分数関数の微分公式を使えばよいですね。あ、今度はそのサンプルの品種に関わらず、以下になりますね。
 \displaystyle \frac{\partial^2 L_i}{\partial s_{i, {\rm setosa}}^2} = q_{i, {\rm setosa}} (1 - q_{i, {\rm setosa}})
しかし、ここからどうするんです?
じゃあそれらの 1 回偏微分と 2 回偏微分 g_{i, {\rm setosa}}, h_{i, {\rm setosa}} としよう。いま、訓練前だから「セトーサらしさの木」はまだ枝分かれしていなくて、ルートノードに全サンプルいて、全サンプルがスコア -1.204 を割り当てられているわけだけど、仮にここからサンプル i だけを新しい葉に連れ出して新しい最適なスコアを割り当てるとしたら損失はいくら減るか、 g_{i, {\rm setosa}}, h_{i, {\rm setosa}} を使って近似しよう。セトーサらしさのスコアを  d_{i, {\rm setosa}} だけ動かしたときの損失の変化幅は、元のスコアの周りのテイラー展開 (2 次まで) に基づけば、以下だよね。
 \Delta L_i = g_{i, {\rm setosa}} d_{i, {\rm setosa}} + (1/2) h_{i, {\rm setosa}} d_{i, {\rm setosa}}^2
これを  d_{i, {\rm setosa}}偏微分して「=0」を解けば、最大の損失変化幅を与えるスコア変化幅が求まり、つまり  \hat{d}_{i, {\rm setosa}} = - g_{i, {\rm setosa}} / h_{i, {\rm setosa}} になる。このときの損失変化幅は  \Delta \hat{L_i} = - (1/2) (g_{i, {\rm setosa}}^2 / h_{i, {\rm setosa}}) だ。
なんと、近似ではありますが、サンプル i を新しい葉に連れ出したときに減らせる損失の幅はそのように決まるのですね。……しかし、決定木においてはサンプル i だけを連れ出すのではなく、もうちょっとごっそり連れ出すというか、サンプルを 2 手に分けるのですよね?
だから、左の葉に振り分けるサンプルの 1 回偏微分と 2 回偏微分をそれぞれ合計して  g_{L, {\rm setosa}}, h_{L, {\rm setosa}} とするよ。右の葉も  g_{R, {\rm setosa}}, h_{R, {\rm setosa}} とする。振り分ける前の全員の合計は  g_{{\rm setosa}}, h_{{\rm setosa}} とする。このとき、「左右の葉に分岐してからスコア最適化するのと、このままスコア最適化するよりもどれだけ損失を減らせるか」、つまり「ゲイン」は以下となる。
 - \Delta \hat{L_L} - \Delta \hat{L_R} + \Delta \hat{L} = (1/2)(g_{L, {\rm setosa}}^2 / h_{L, {\rm setosa}} + g_{R, {\rm setosa}}^2 / h_{R {\rm setosa}} - g_{{\rm setosa}}^2 / h_{{\rm setosa}})
このゲインは「左右の葉にどのラベルのサンプルをいくつずつ振り分けるか」からただちに求まる。
た、確かに、左右の葉それぞれで  g_{i, {\rm setosa}}, h_{i, {\rm setosa}} を足し上げればよいのですね。左の葉にきたサンプル全員に同じだけ「この葉に来たらセトーサらしさのスコアをこれだけ上げる (下げる)」とするわけですから、左の葉にきたサンプル全員で減らせる損失は以下です。
 \Delta \hat{L_L} = \sum_{i \in I(L)} g_{i, {\rm setosa}} d_{L, {\rm setosa}} + (1/2) \sum_{i \in I(L)} h_{i, {\rm setosa}} d_{L, {\rm setosa}}^2
ただここで、LightGBM が実際に計算している形に修正するんだけど、まず、LightGBM は  1/2 倍を落としている。これは、ゲイン最大の分岐を選ぶ分には差し支えないからだと思う。これは、ゲインを真の損失変化幅より大きくする大きな要因だと思う。あと、さっきは誤魔化したんだけど、3 クラス分類の対数損失を 2 次までテイラー展開をするなら本来 2 次の項は 2 次形式でないといけない。にもかかわらずさっきの損失変化幅の計算では非対角成分を無視したんだけど、これだと曲率を過少に (変化幅を過大に) 見積もっていることになるんだよね。だから、説明は省くけど、K クラス分類の対数損失をテイラー展開するとき 2 回偏微分  h_{i, {\rm setosa}} K/(K-1) 倍に補正する必要がある。だから、今回のアヤメの分類だと実際の LightGBM におけるゲインは以下になる。
 - \Delta \hat{L_L} - \Delta \hat{L_R} + \Delta \hat{L} = (2/3)(g_{L, {\rm setosa}}^2 / h_{L, {\rm setosa}} + g_{R, {\rm setosa}}^2 / h_{R {\rm setosa}} - g_{{\rm setosa}}^2 / h_{{\rm setosa}})
た、確かにその数式で Tree=0split_gain=56.875 が再現できます。

q_setosa = 27 / 90

# 全体の 1, 2 回偏微分値の和
g = 27 * (- 1 + q_setosa) + 63 * q_setosa  # 0
h = 90 * (3 / 2) * q_setosa * (1 - q_setosa)  # 28.35

# 左の葉の 1, 2 回偏微分値の和 (花弁の長さが 1.8cm 未満の 26 サンプル) (全てセトーサ)
g_L = 26 * (- 1 + q_setosa)  # -18.2
h_L = 26 * (3 / 2) * q_setosa * (1 - q_setosa)  # 8.19

# 右の葉の 1, 2 回偏微分値の和 (花弁の長さが 1.8cm 以上の 64 サンプル) (セトーサ 1 サンプルとその他)
g_R = 1 * (- 1 + q_setosa) + 63 * q_setosa  # 18.2
h_R = 64 * (3 / 2) * q_setosa * (1 - q_setosa)  # 20.16

# 「花弁の長さが 1.8cm 未満か」という分岐のゲイン
gain = g_L ** 2 / h_L + g_R ** 2 / h_R - g ** 2 / h
print(gain)  # 56.875

それに、この左右の葉の 2 回偏微分h_L, h_Rleaf_weight=8.1899999380111712 20.159999847412109 に一致していますね。

うん。加えて、そのゲインを与えるスコア変化幅を初期スコアに加えた値がleaf_value=1.0182493968717188 -2.1067506267809168 に一致しているよ。第 2 ラウンド以降の leaf_value は「スコア変化幅」だけになっているけど、第 1 ラウンドだけは「初期値 + スコア変化幅」になっているみたい。勾配ブースティング決定木の記述方法としてこうするのが一般的なのかな?

print(- 1.204 - g_L / h_L)  # 1.018
print(- 1.204 - g_R / h_R)  # -2.107

ともあれ、実際に「セトーサらしさのスコア」を更新しよう。Tree=0 の分岐の結果、以下のようになる。

萼長萼幅花長花幅正解ラベルs(setosa)s(versicolor)s(virginica)q(setosa)q(versicolor)q(virginica)
06.32.74.91.8virginica-2.107-1.066-1.0340.1480.4190.433
14.83.41.90.2setosa-2.107-1.066-1.0340.1480.4190.433
25.03.01.60.2setosa1.018-1.066-1.0340.7980.0990.103
35.13.31.70.5setosa1.018-1.066-1.0340.7980.0990.103
45.62.74.21.3versicolor-2.107-1.066-1.0340.1480.4190.433
ちなみに上の表では「セトーサらしさのスコア」の更新に伴ってソフトマックス予測確率も更新させているけど、実際に LightGBM が Tree=0 構築時点でこの値を計算するという意味ではないよ。
花弁の長さが 1.8cm 未満であるサンプル 2, 3 を正しくセトーサと予測する確率が約 80% にまで上がりましたね。……とはいえ、花弁の長さが 1.8cm 未満のサンプルが 100% セトーサであることを思えばもっと 100% に近づけてもよいところなのですよね。特に今回、正則化もしていないし学習率も 1 にしていますので、本来であれば予測確率 100% にするべきです。ここは近似誤差ですか。
対数損失を 2 次近似していることを思えば、最小値から遠いところでは近似誤差は大きいんじゃないかな。次にTree=1 では「バーシカラーらしさのスコア」を更新するよ。面白いことに選ばれた分岐の特徴量・閾値Tree=0 と全く同じだね。 Tree=0 では「花弁の長さが 1.8cm 未満ならセトーサらしい」が見出され、Tree=1 では「花弁の長さが 1.8cm 未満ならバーシカラーらしくない」が見出されたわけだ。この結果、以下になる。
萼長萼幅花長花幅正解ラベルs(setosa)s(versicolor)s(virginica)q(setosa)q(versicolor)q(virginica)
06.32.74.91.8virginica-2.107-0.653-1.0340.1220.5220.356
14.83.41.90.2setosa-2.107-0.653-1.0340.1220.5220.356
25.03.01.60.2setosa1.018-2.083-1.0340.8520.0380.109
35.13.31.70.5setosa1.018-2.083-1.0340.8520.0380.109
45.62.74.21.3versicolor-2.107-0.653-1.0340.1220.5220.356
ちなみに Tree=1 の構築にあたって、Tree=0 による「セトーサらしさのスコア」の更新に伴う予測確率の更新は「反映されていない」よ。もし反映されていたらすべてのバーシカラーサンプルはバーシカラー予測確率が 0.419 になるけど、それだと右の葉の leaf_weight が 23.37 になるはずだから。ラウンドごとに各ラベルらしさのスコア更新が独立に走るんだから、Tree=0Tree=1Tree=2 のゲインを足せないのがわかるよね。3 人がバトンタッチしながら下山しているんじゃなくて、3 人同じ地点から下山して「自分は何 m 下りた」といっている状態だからね。
▼ Tree=1 の情報と検算 (クリックして展開)

Tree=1
num_leaves=2
num_cat=0
split_feature=2
split_gain=12.8072
threshold=1.8
decision_type=2
left_child=-1
right_child=-2
leaf_value=-2.0827716810239876 -0.65268688567404975
leaf_weight=8.8062959909439105 21.677036285400391
leaf_count=26 64
internal_value=-1.06582
internal_weight=30.4833
internal_count=90
is_linear=0
shrinkage=1
q_versi = 31 / 90

g = 31 * (- 1 + q_versi) + 59 * q_versi  # 0
g_L = 26 * q_versi  # 8.9556
g_R = 31 * (- 1 + q_versi) + 33 * q_versi  # -8.9556

h = 90 * (3 / 2) * q_versi * (1 - q_versi)  # 30.483
h_L = 26 * (3 / 2) * q_versi * (1 - q_versi)  # 8.806
h_R = 64 * (3 / 2) * q_versi * (1 - q_versi)  # 21.677

gain = g_L ** 2 / h_L + g_R ** 2 / h_R - g ** 2 / h
print(gain)  # 12.8072

print(- 1.066 - g_L / h_L)  # -2.083
print(- 1.066 - g_R / h_R)  # -0.653


続く Tree=2 では「バージニカらしさのスコア」を更新するわけだけど、選ばれた分岐は「花弁の幅が 1.55cm 未満ならバージニカらしくない」だね。ここまででスコアは以下になるよ。
萼長萼幅花長花幅正解ラベルs(setosa)s(versicolor)s(virginica)q(setosa)q(versicolor)q(virginica)
06.32.74.91.8virginica-2.107-0.6530.5760.0500.2150.735
14.83.41.90.2setosa-2.107-0.653-1.9660.1550.6660.179
25.03.01.60.2setosa1.018-2.083-1.9660.9130.0410.046
35.13.31.70.5setosa1.018-2.083-1.9660.9130.0410.046
45.62.74.21.3versicolor-2.107-0.653-1.9660.1550.6660.179
各ラベルらしさのスコア更新が独立に走るのですか。それでは、ゲインを単純に足してはいけませんね。LightGBM さんは model.booster_.feature_importance(importance_type='gain') とすると各特徴量が稼いだゲインをみせてくれますが、それを踏まえてみる必要がありそうですね。
もっとも、このあたりの誤差は学習率を小さくすれば小さくなるけどね。部長は学習率を 1 にしたから誤差が顕著になっているけど。
しかし、セトーサらしさのスコアを更新したのにバーシカラーのスコアの更新時にそれを反映することはしていなかったのですね。その方が速く収束しそうに思ったのですが、木同士で仲が悪いのですか?
それをやったらセトーサらしさの更新とバーシカラーらしさの更新を並列実行できないじゃないか。訓練がラベル順序に依存してしまうし。あとラウンドが減ったとしても計算コストが減るかは疑問だし、もし「こちらの予測をラベルを立てたらあちらのラベルの予測が立たず」みたいな場合だったらかえって訓練が蛇行してラウンドが増えるかもしれないよ。
ああ、そう言われるとそうですね。
ちなみに第 2 ラウンド目も同じ要領で木が構築されていくけど (以下)、第 2 ラウンド開始前には第 1 ラウンドの結果を受けて予測確率が更新されているのが検算できるよ。Tree=5 の予測確率は最終結果に等しくなっているね。
終わり

Tree=3

萼長萼幅花長花幅正解ラベルs(setosa)s(versicolor)s(virginica)q(setosa)q(versicolor)q(virginica)
06.32.74.91.8virginica-2.850-0.6530.5760.0250.2210.755
14.83.41.90.2setosa-1.156-0.653-1.9660.3230.5340.143
25.03.01.60.2setosa1.969-2.083-1.9660.9640.0170.019
35.13.31.70.5setosa1.969-2.083-1.9660.9640.0170.019
45.62.74.21.3versicolor-2.850-0.653-1.9660.0810.7250.195
Tree=4
萼長萼幅花長花幅正解ラベルs(setosa)s(versicolor)s(virginica)q(setosa)q(versicolor)q(virginica)
06.32.74.91.8virginica-2.850-0.1470.5760.0210.3200.659
14.83.41.90.2setosa-1.156-0.147-1.9660.2390.6550.106
25.03.01.60.2setosa1.969-1.577-1.9660.9540.0280.019
35.13.31.70.5setosa1.969-1.577-1.9660.9540.0280.019
45.62.74.21.3versicolor-2.850-0.147-1.9660.0540.8140.132
Tree=5
萼長萼幅花長花幅正解ラベルs(setosa)s(versicolor)s(virginica)q(setosa)q(versicolor)q(virginica)
06.32.74.91.8virginica-2.850-0.1471.3220.0120.1850.803
14.83.41.90.2setosa-1.156-0.147-2.6420.2520.6910.057
25.03.01.60.2setosa1.969-1.577-2.6420.9630.0280.010
35.13.31.70.5setosa1.969-1.577-2.6420.9630.0280.010
45.62.74.21.3versicolor-2.850-0.147-2.6420.0580.8700.072