📈

勾配ブースティングで「幅を持たせた予測」を行う

2022/11/13に公開
2

背景と目的

ビジネスにおいて機械学習による回帰モデルを構築する際に、単に「予測値(点予測)」が知りたいというよりも、「予測値が(どれくらいの確率で)どれくらいの範囲に収まりそうか(区間予測)」を知りたい場面があります。例えば天気予報を眺めてみると台風の進路予測には予報円が出ており、ある程度幅を持たせて進路を予測するといったことが行われています。あるいは需要予測を行う場合に「95%の確率で40個~60個の間に入りそうだ」という幅を持たせた予測をすることができれば、「売れ残りがでると損失が大きいので、明日の仕入れ量は40個にしよう」といった意思決定に繋げることができるかもしれません。

そこで、本記事ではテーブルデータの回帰タスクで広く利用されており皆様にとって馴染み深い勾配ブースティングアルゴリズムを用いて、「幅を持たせた予測」を実装する方法を紹介します。

各手法の概要

この区間予測を実装する方法として筆者に思いつくのは以下の2つの手法です。

  1. NGBoost
  2. LightGBM
    • Quantile Regression (分位点回帰)
    • 事後的な予測誤差の推定

NGBoost

NGBoostとは勾配ブースティングによる区間予測を実施することを目的に開発されたライブラリです。公式ページは以下です。

https://stanfordmlgroup.github.io/projects/ngboost/

実際、公開されている公式のユーザーガイドに従って分析を行うことで簡単に区間予測を実現することができます。

https://stanfordmlgroup.github.io/ngboost/intro.html

一方著者が使った感じでは大規模なデータになると LightGBM と比べて推論速度が大幅に悪化したり、そもそも特徴量の欠損値に対応していなかったりすることがあるため実用上の利用は少し怖いかなという印象でした。

LightGBM

言わずもがな非常に広く利用されているライブラリです。LightGBM を用いた幅を持たせた予測の実現方法として筆者に思いつくのは以下の2パターンです。

Quantile Regression (分位点回帰)

まず一つ目の手法として Quantile Regression(分位点回帰) というやり方が存在します。Quantile Regression では「予測値よりも目的変数が低い値をとる確率が \alpha \times 100\% となる」ような予測値(=\alpha \times 100\% 分位点)の導出を行うことができます。

例えば \alpha = 0.05 として、「明日のおにぎりの販売量の予測値が 45個」と推測されたのであれば、これは「明日のおにぎりの販売量が45個を切る確率は5%(逆に言えば95%の確率で45個以上売れる)」と予測されたことを意味します。

事後的な予測誤差のあてはめ

二つ目の手法として、「事後的に誤差の従う分布」を推定するというアプローチが考えられます。この手法は以下のページが参考になります。

https://blog.ysk.im/x/ngboost-with-lightgbm

これは「ひとまず LightGBM の通常の予測」を行うことで目的変数の平均的な値を近似できたと見なし、この近似的な目的変数の平均と実際の目的変数の値の誤差を確率分布でモデリングするアプローチです。

例えば学習データにおいて、「あるA店のおにぎりの販売個数の予測値と実際の値が大きくずれている一方で、B店のおにぎりの販売個数の予測値と実際の値があまりずれていない」という傾向が確認されたとしましょう。つまり「B店の販売個数は割と自信をもって予測できる一方でA店の販売個数の予測には自信がない」状態です。そこで、この誤差を正規分布を使って近似すると、A店の正規分布の分散は高く推測されるがB店の正規分布の分散は低く推定されるといった形になります。

理論と実装

以下では、LightGBM を用いた2つの手法について簡単な理論と実装を確認します。実装は Google Colaboratory を用いて行いますので適宜そちらをご参照ください。それぞれに簡単な理論説明をつけていますが、必要に応じて読み飛ばしていただいて構いません。

なお、実装はこちらの記事内でも全て網羅していますが、github にも上げておきますので適宜ご参照ください。

まず2つの実装において共通して必要となるコードの準備をします。

必要なパッケージのインストール

!pip install japanize-matplotlib

必要なライブラリのインポート

import numpy as np
import matplotlib.pyplot as plt
import japanize_matplotlib
import lightgbm as lgbm
import seaborn as sns

ダミーデータの作成

ground truth (真の値) が \sin(x) で表現され、そこに正規分布に従う誤差を載せる形で目的変数 y を定義します。ただし、正規分布に従う誤差は分散が一定でなく、\sigma^2 = \cos(x)^2 で表現されるものとします。

np.random.seed(1)
num_of_sample = 3000

x = np.random.uniform(low=-5, high=5, size=num_of_sample)

# 後のmatplotlibを用いた描画に備えて小さい順に並び替えておく。そうしないと描画がぐちゃぐちゃになる。
x = np.sort(x)

# ground truth (真の値) は sin カーブであるとする。
ground_truth = np.sin(x)
# 目的変数の実際の値は ground truth に誤差が乗った状態で現れる。
# 今回は正規分布に従って誤差が発生すると考えるが、その正規分布の分散は cos カーブの2乗で表現されるものとする。
# 2乗するのは分散にマイナスの値が表れるのを防ぐため。
true_var = np.cos(x)**2
error = np.random.randn(num_of_sample) * np.sqrt(true_var)

# 目的変数の値 = 真の値 + 誤差
y = ground_truth + error

# lightgbm に学習データとして渡すとき用に整形
x_for_lgbm = x.reshape((-1,1))
y_for_lgbm = y.reshape((-1,))

目的変数の値と ground truth を図示します。

fig, ax = plt.subplots(figsize=(12,6))
sns.scatterplot(
    x=x, y=y, ax=ax, label='y'
)
sns.lineplot(
    x=x, y=ground_truth, ax=ax, label='ground_truth', color='red'
)
ax.grid()
ax.legend()

LightGBMを用いた分位点回帰

理論

まず分位点回帰について確認します。分位点回帰は損失関数を Pinball loss 関数と呼ばれるものにすることにより実装します。i 番目のサンプルに対する目的変数の値を y_i, その目的変数に対する予測値を \hat{y}_i と置きます。分位点 \alpha \times 100\% に対応する pinball loss 関数は以下のように記述できます。

\begin{align} L_{\alpha}(y_i, \hat{y}_i) = \begin{cases} (y_i - \hat{y}_i)\alpha & \mathrm{if} & y_i \geq \hat{y_i} \\ (\hat{y}_i - y_i)(1-\alpha) & \mathrm{if} & y_i < \hat{y_i} \end{cases} \end{align}

\alpha を色々な値に替えて図示してみると以下のようになります。

alphas = [0.1, 0.5, 0.9]
colors = ['blue', 'red', 'green']
labels = ['10%分位点', '50%分位点(中央値)', "90%分位点"]

def get_pinball_loss(y_diff: float, alpha: float):
    if y_diff > 0:
        return y_diff * alpha
    else:
        return -y_diff * (1 - alpha)

y_diffs = np.linspace(-5, 5, 100)
fig, ax = plt.subplots(figsize=(12,6))
for i, alpha in enumerate(alphas):
    color = colors[i]
    label = labels[i]
    pinball_loss = [
        get_pinball_loss(y_diff, alpha) for y_diff in y_diffs 
    ]
    sns.lineplot(
        x=y_diffs, 
        y=pinball_loss, 
        label=label, 
        color=color,
        ax=ax
    )
ax.grid()
ax.set_xlabel('予測誤差(正の値であれば予測値>実際の値)')
fig.show()

図より、分位点の値によって上振れ、下振れのペナルティの重みが変わることが理解できます。例えば10%分位点(つまり小さめの予測)の時は、目的変数の値が予測値を上回った場合の損失はそれほど大きくありませんが、下回った場合の損失は大きくなっています。これにより、なるべく予測値を目的変数の値より小さくならないように定めるようになります。

以上の説明はあくまで直感的な説明に過ぎませんが、この損失関数を利用して予測値を導出することにより、ちょうど \alpha \times 100 \% 分位点となる予測値が得られることが分かっています。より厳密には以下のようです。

累積型分布関数は「確率変数がある値以下になる確率」を示すものなので、F(\hat{y}_i) = \alphay_i\hat{y}_i 以下の値をとる確率が \alpha \times 100 \% になることを意味します。まさに分位点ですね。

一応証明チックなものをつけておくので興味のある方はご参照ください。

証明チックなもの

pinball loss 関数の期待値は以下のように記述できます。

\begin{align*} \mathbb{E}\bigl[ L(y_i, \hat{y}_i) \bigr] &= \int_{-\infty}^{\infty} L(y_i, \hat{y}_i)f(y_i)dy_i \\ &= \int_{-\infty}^{\hat{y_i}}(\hat{y}_i - y_i)(1-\alpha)f(y_i)dy_i + \int_{\hat{y}_i}^{\infty}(y_i - \hat{y}_i)\alpha f(y_i) dy_i \\ &= (1-\alpha)\int_{-\infty}^{\hat{y_i}}(\hat{y}_i - y_i)f(y_i)dy_i + \alpha\int_{\hat{y}_i}^{\infty}(y_i - \hat{y}_i)f(y_i) dy_i \end{align*}

ここで、\hat{y}_i による一階微分は以下のように導出できます。

\begin{align*} \frac{\partial \mathbb{E}\bigl[ L(y_i, \hat{y}_i) \bigr]}{\partial \hat{y_i}} =&\ (1-\alpha)\biggl[ (\hat{y}_i - \hat{y_i})f(\hat{y}_i) + \int_{-\infty}^{\hat{y}_i} f(y_i)dy_i \biggr] \\ & + \alpha \biggl[ -(\hat{y_i} - \hat{y}_i)f(\hat{y_i}) - \int_{\hat{y}_i}^{\infty}f(y_i)dy_i \biggr] \\ =&\ (1-\alpha)F(\hat{y}_i) - \alpha[1-F(\hat{y}_i)] \\ =&\ F(\hat{y}_i) - \alpha \end{align*}

最初の変形が難しい場合は 以下の解説が分かりやすいです。

https://manabitimes.jp/math/1334

したがって、F(\hat{y}_i) = \alpha を満たすような \hat{y}_i において一階微分値はゼロになります。つまりこの \hat{y}_i で極小値か極大値を取ります。

後は、\mathbb{E}\bigl[ L(y_i, \hat{y}_i) \bigr] が下に凸であることを示せば一階微分値をゼロとする \hat{y}_i によって pinball loss 関数の期待値が最小化されることを示せます。これを示すためには二階微分が非負であることを示せばよいです。これも難しい場合はやっぱり以下の解説が分かりやすいです。

https://manabitimes.jp/math/927

よって二階微分を計算すると

\frac{\partial^2 \mathbb{E}\bigl[ L(y_i, \hat{y}_i) \bigr]}{\partial \hat{y_i}^2} = f(\hat{y}_i)

f(\cdot) は確率密度関数ですので任意の \hat{y}_i について f(\hat{y_i}) \geq 0 です。よって、二階微分が非負であることを示せたため、 pinball loss 関数の期待値は F(\hat{y}_i) = \alpha を満たす \hat{y}_i によって最小化されます。\square

実装

さて以下では分位点予測を実装していきましょう。ここでは 2.5 \% 分位点予測と 97.5 \% 分位点予測を同時に行います。実装自体は非常に簡単でパラメータとして objective"quantile" を指定し、alpha に分位点を与えるだけで実装できます。

lower_quantile = 0.025
upper_quantile = 0.975

predict_dict = {}
for quantile in [lower_quantile, upper_quantile]:
    quantile_params = {
        "objective": "quantile", # quantile を指定することで分位点予測となる。
        "alpha": quantile # alpha には分位点の値を指定する。
    }
    quantile_regressor = lgbm.train(
        quantile_params,
        lgbm.Dataset(x_for_lgbm, y_for_lgbm),
    )
    quantile_predict = quantile_regressor.predict(x_for_lgbm)
    predict_dict[quantile] = quantile_predict

可視化してみましょう。

fig, ax = plt.subplots(figsize=(12,6))
# ground truth
ax.plot(x, ground_truth, c='red', label='ground truth')
# 2.5% 分位予測
ax.plot(x, predict_dict[lower_quantile], c='green', label=f'{lower_quantile*100:.1f}%分位点予測')
# 97.5% 分位予測
ax.plot(x, predict_dict[upper_quantile], c='purple', label=f'{upper_quantile*100:.1f}%分位点予測')

# 描画で指定している alpha は透明度です。
ax.fill_between(
    x, 
    predict_dict[lower_quantile],
    predict_dict[upper_quantile],
    color='orange',
    alpha = 0.5,
    label='90%予測区間'
)

sns.scatterplot(x=x,y=y,ax=ax, alpha=0.5)
ax.grid()
ax.legend()
fig.show()

とてもいい感じに推測できていることがうかがえます。それでは実際にこの予測区間内に収まっている正解データの割合を計算してみましょう。

upper = predict_dict[upper_quantile]
lower = predict_dict[lower_quantile]
total = 0
for i in range(num_of_sample):
    upper_i = upper[i]
    lower_i = lower[i]
    y_i = y[i]
    # 区間内に含まれたデータの数をカウント
    total += (lower_i <= y_i <= upper_i)

# 割合を計算する。
print(total/num_of_sample)

94.7\% とほとんどズレのない結果になりました。

LightGBM を用いた事後的な予測誤差の推定

理論

事後的な予測誤差の当てはめでは、まずは通常通り LightGBM による学習を行い目的変数 y_i の値に対する予測値 \hat{y}_i を生成します。その後、y_i -\hat{y_i} で定義される誤差が従う確率分布に対して前提を置きます。ここでは y_i - \hat{y_i} が期待値 0、分散 \sigma^2_i の正規分布に従っていると想定しましょう。この \sigma^2_i を特徴量 x_i によって「回帰」することが今回の目的となります。

さてそのために、\sigma^2_i の推測値の当てはまりの良さを尤度関数によって表現することにしましょう。先ほど言及したようにここでは y_i-\hat{y}_i が期待値 0、分散が \sigma^2_i の正規分布に従いますので、確率密度関数は以下のように表現できます。

f(y_i) = \frac{1}{\sqrt{2\pi\sigma^2_i}}\exp\biggl(-\frac{(y_i - \hat{y}_i)^2}{2\sigma^2_i}\biggr)

これをパラメータ \sigma_i^2 に対する関数と見ることで、尤度関数と置き換えて考えることができます。また、尤度関数に自然対数をとっても大小関係は不変なので、ここでは対数尤度を当てはまりの良さと見ることにします。つまり

\log\bigl(L(\sigma^2_i|y_i - \hat{y}_i)\bigr) = -\frac{1}{2}\log(2\pi) -\frac{1}{2}\log(\sigma_i^2) - \frac{(y_i - \hat{y}_i)^2}{2\sigma_i^2}

ここで V_i \equiv \sigma_i^2 と定義すると、

\log\bigl(L(\sigma^2_i|y_i - \hat{y}_i)\bigr) = -\frac{1}{2}\log(2\pi) -\frac{1}{2}\log(V_i) - \frac{(y_i - \hat{y}_i)^2}{2V_i}

さらに、v_i \equiv \log(V_i) とすると V_i = \exp(v_i) なので、

\log\bigl(L(\sigma^2_i|y_i - \hat{y}_i)\bigr) = -\frac{1}{2}\log(2\pi) -\frac{1}{2}v_i - \frac{1}{2}(y_i - \hat{y}_i)^2\exp(v_i)^{-1}

最後に、これを対数尤度を損失関数に置き換えることを考えます。対数尤度は「当てはまりの良さ」を表す指標なので、負の値を取ることで(推測値 v_i に対する)損失関数と見なすことができるようになります。すなわち

\mathrm{Loss}(v_i) \equiv - \log\bigl(L(\sigma^2_i|y_i-\hat{y}_i)\bigr) = \frac{1}{2}\log(2\pi) +\frac{1}{2}v_i + \frac{1}{2}(y_i - \hat{y}_i)^2\exp(v_i)^{-1}

さて、ここでは勾配の計算の都合上この v_i を推定することとしましょう。LightGBM ではこういったカスタムの損失関数を用いた計算を行うためには、その一階微分値(gradient)と二階微分値(hessian)を計算する関数を定義する必要があります。そのため、ここで計算しておきましょう。

まず一階微分値は以下のように計算できます。

\frac{\partial \mathrm{Loss}(v_i)}{\partial v_i} = \frac{1}{2} - \frac{(y_i -\hat{y}_i)^2}{2}\exp(v_i)^{-1}

次に二階微分値は以下のように計算できます。

\frac{\partial^2 \mathrm{Loss}(v_i)}{\partial v_i^2} = \frac{(y_i -\hat{y}_i)^2}{2}\exp(v_i)^{-1}

ここまでくれば準備完了です!実装に入りましょう!

実装

まず初めにすることは目的変数 y_i に対する予測値である \hat{y}_i (y_pred) の導出です。これは以下のように実装できます。

# まず目的変数に対する予測値を導出する。
y_reg_params = {
    "objective": 'regression'
}

y_regressor = lgbm.train(
    y_reg_params,
    lgbm.Dataset(x_for_lgbm, y_for_lgbm)
)

y_pred = y_regressor.predict(x_for_lgbm)

次に、この y_pred を用いて損失関数の対数分散についての一階微分と二階微分を導出する関数を定義し、これに基づいて標準偏差を推測しましょう。これは以下のように実装できます。

# 次に、分散推測モデルを構築し、分散の推測を行う。
def grad_hess_for_logvar(log_var: np.ndarray, data: lgbm.Dataset):
    '''
    推測された分散対数値に対する損失の一階微分及び二階微分を導出する
    y_pred については引数としてではなくグローバル変数として与えていることに注意。
    '''
    y_true = data.get_label()
    grad = 1/2 - 1/2 * (y_true - y_pred)**2 / np.exp(log_var)
    hess = 1/2 * (y_true - y_pred)**2 / np.exp(log_var)
    return grad, hess

log_var_reg_params = {
    "objective": "regression"
}

log_var_regressor = lgbm.train(
    log_var_reg_params,
    lgbm.Dataset(x_for_lgbm, y_for_lgbm),
    fobj=grad_hess_for_logvar # fobj 引数に自作の grad, hess 導出関数を定義する。
)
# 推測された標準偏差を導出する。
log_var_pred = log_var_regressor.predict(x_for_lgbm)
std_pred = np.sqrt(np.exp(log_var_pred))

この標準偏差を用いることで、95%信頼区間の上限と下限を計算することができます。

# 推測された分散から95%区間予測を導出する。
# 謎な場合には https://bellcurve.jp/statistics/course/8888.html などが分かりやすいです。
upper_pred = y_pred + 1.96 * std_pred
lower_pred = y_pred - 1.96 * std_pred

最後にこの結果を可視化してみましょう。

fig, ax = plt.subplots(figsize=(12,6))
ax.scatter(x, y, color='blue', s=1, alpha=0.5)
ax.plot(x, ground_truth, color='red', label='ground truth', linestyle='--')
ax.plot(x, y_pred, color='orange', label='pred')
ax.plot(x, upper_pred, color='purple', linestyle='--', label='97.5%分位点予測')
ax.plot(x, lower_pred, color='green', linestyle='--', label='2.5%分位点予測')
ax.grid()
ax.legend()
plt.show()

以上で実装は終了です。お疲れさまでした!

まとめ

本記事では勾配ブースティングを用いてどのように予測区間を導出できるかを概観しました。さらに LightGBM を用いた分位点予測と事後的な確率分布の当てはめについては実装まで含めて紹介いたしました。

分位点予測のメリットは、「目的変数の従う確率的な分布」に前提を置くことなく \alpha \times 100\% 分位点を予測することができることにあります。しかし複数の分位点の予測を行うためにはその数だけ新たな予測モデルを構築する必要があるのがネックです。

一方で誤差に対する事後的な確率分布のあてはめについては先ほど紹介した分位点回帰とは異なり、どういった確率分布での誤差分布の近似を行うかといった前提を分析者が仮定する(今回の例でいえば正規分布)必要があり、それ自体が適切でないと分析が妥当なものになりません。また、予測値を「実際の平均値」と見なして誤差をモデリングする都合上、導出された予測値が平均値のよい近似でなければ誤った分析となってしまいます。例えば過学習をしていた場合誤差を低く見積もってしまうことになります。しかしながら、分位点回帰とは異なり誤差の従う確率分布を推定するため、いちいち必要な分位点についてモデルをたくさん作る必要がない点がメリットになります。

以上になります。何か皆様の参考になるところがあれば幸いです。長文最後まで読んでいただきありがとうございました!

Discussion

Yuhsak InoueYuhsak Inoue

途中で紹介頂いた「LightGBMで予測区間を推定する」記事の筆者です。
分位点回帰の発想は無かったので非常に勉強になりました!ありがとうございます!

JoanOfArcJoanOfArc

Yuhsak Inoue さま
コメントいただきありがとうございます!まさか記事を執筆くださった方にコメントいただけると思っていてなくて、本当に嬉しいです。
こちらこそ、当時「予測区間の推定ってどうやるんだろう...?」と色々調べていた時にとても勉強になって記事として執筆させていただきました。有益な情報発信を本当に有難うございます!