金融時系列予測における評価指標に応じた損失関数のデザイン
こんにちは、tonic(@tonic3561)です。
この記事はマケデコ Advent Calendar 2024の19日目への寄稿です。
今回は、機械学習で投資戦略を構築する際に生じる、評価指標と損失関数とのギャップについて考えてみたいと思います。
評価指標の重要性
教師あり学習では、与えられた目的変数と予測値との乖離を評価するなんらかの損失関数を用意し、この出力を最小化するようにパラメータを調整します。ところが、機械学習モデルの「良さ」と、一般的な損失関数の意味で「高精度であること」は、(ファイナンス分野に限らず)一致しないことが多いと感じます。
例えばドローダウンができるだけ小さいような安定した投資戦略を構築したい場合、目的変数(リターン)と予測値との二乗損失を最小化するだけでは不十分であることが考えられます。
このように、ご自身の戦略の方針などに照らして、どんな性質をもつ予測が「良い」予測だろう?と考えていくことが重要です。
金融時系列予測に関する評価指標の重要性については、この記事が参考になります。
評価指標と損失関数とのギャップ
さて、モデルの挙動を適切に評価できるなんらかの指標を定義できたとしましょう。
これで学習済モデルの予測を評価することはできますが、損失関数はやはりMSEやクロスエントロピー等、一般的なものを使う場合が多いと思います。
これらの損失関数の出力は評価指標と一定の相関をもつことが期待できますが、もちろん完全に一致するわけではありません。損失関数の値は減少していくのに、評価指標は一向に改善しない…こんな経験がみなさんにもあるのではないでしょうか。
どうにかならんの?
そこで本記事では、上記のギャップをどうにかして埋めていきたいと思います。
題材として、予測値と目的変数(リターン)との順位相関係数を扱います。順位相関係数はスピアマンの順位相関係数とも呼ばれ、ファクター(機械学習の場合は予測値)を評価する際に広く利用されている評価指標です(参照)。
以下では、みんな大好きLightGBMを使って、この評価指標を近似する損失関数を実装していきます。
順位相関損失の理論と実装
これから実装していく損失関数を順位相関損失と呼ぶことにしましょう。
実装にあたって、まず最初に問題設計を明確にします。続いて、LightGBMにおけるカスタム損失の実装方法について触れ、そこへ順位相関係数をどう取り込むかについて議論したいと思います。
なお、順位相関損失はNumeraiのフォーラムで提案され、日本語の記事でも紹介されていますので、目新しいものではありません(期待された方、スミマセン…)。
本記事の趣旨は、評価指標に応じて損失関数を適切にデザインしよう!というものです。ご自身の問題設計に応じて拡張できるよう、できるだけかみ砕いて説明する所存ですので、よければお付き合いください。
問題設計
ここでは、あるユニバースにおける各銘柄の日足リターン系列を目的変数とし、予測値と目的変数間の順位相関を日平均した値を評価指標とします。
データ全体を使った順位相関係数ではない点が重要です。モデルの予測値をファクターとして捉える場合、ある日内の相対的な予測値の大小が問題となるからです。
なお、上述した順位相関損失の提案元記事では、このことを考慮しているコードと、そうでないコードが入り混じっているように見えるので、注意した方がよいでしょう。
LightGBMのカスタム損失について
ご存じの方も多いかとは思いますが、まずはじめにLightGBMでカスタム損失関数を実装する方法を紹介します。
LightGBMでは、予測値と目的変数の配列を受け取り、予測値に関するgradient(1回微分)とhessian(2回微分)を返す関数を定義することで、任意の損失関数を定義することができます。
例えば、回帰タスクで一般的な二乗誤差をあえてカスタム損失として定義すると、以下のようになります[1]。
from typing import Tuple
import lightgbm as lgb
import numpy as np
import pandas as pd
# 特徴量と目的変数(target)を含むデータを用意
train = pd.DataFrame(...)
# カスタム損失関数(二乗誤差)
def custom_squared_loss(pred: np.ndarray, dataset: lgb.Dataset) -> Tuple[np.ndarray, np.ndarray]:
label = dataset.get_label()
grad = pred - label # Gradient
hess = np.ones_like(label) # Hessian
return grad, hess
# カスタム損失を使って学習
params = {"objective": custom_squared_loss}
train_set = lgb.Dataset(train.drop(columns="target"), label=train["target"])
model = lgb.train(params=params, train_set=train_set)
つまり、1回微分と2回微分さえ計算できれば、どんな評価指標も直接最適化できるということです!
わくわくしてきましたね。
自動微分を活用する
とはいえ、2回微分をゴリゴリ計算するのは大変です。そこで、Pytorchの自動微分を活用して楽をしましょう。
まず簡単のため、スピアマンの順位相関係数ではなく、ピアソンの相関係数(順位変換を行わない通常の相関係数)を考えます。定義式は割愛しますが、ピアソンの相関係数は四則演算と累乗のみで構成されているため、微分可能です。すげーめんどくさいですが。
ところが、Pytorchの自動微分機能を活用すれば、以下のようにとても簡単にgradientを計算できます[2][3]。
import torch
# 相関係数を計算する関数
def corr(pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
return torch.corrcoef(torch.stack([pred, target]))[0, 1]
# カスタム損失関数(相関係数)
def custom_corr_loss(pred: np.ndarray, dataset: lgb.Dataset) -> Tuple[np.ndarray, np.ndarray]:
# 初期の予測値がすべて同一の値になる場合があるため、相関がnanになるのを回避
if np.all(pred == pred[0]):
return custom_squared_loss(pred, dataset)
# required_grad=True で勾配を計算するように設定
pred_tensor = torch.tensor(pred, dtype=torch.float32, requires_grad=True)
target_tensor = torch.tensor(dataset.get_label(), dtype=torch.float32)
# 可能ならGPUを使う
if torch.cuda.is_available():
pred_tensor = pred_tensor.to("cuda")
target_tensor = target_tensor.to("cuda")
# 相関係数を計算(損失として扱うためマイナスをかける)
loss = -corr(pred_tensor, target_tensor)
# Gradientを計算
grad = torch.autograd.grad(loss, pred_tensor)[0].to("cpu").detach().numpy()
# Hessianは1で固定
hess = np.ones_like(pred)
return grad, hess
あまりにも便利すぎる…!
日ごとの相関係数に分解する
ただ、これでめでたしめでたし、というわけにはいきません。
上記のコードは、データ全体を使った相関係数を計算しているからです。これを日ごとに計算するように修正しましょう。そのためには、LightGBMのカスタム損失関数内で、日付のデータを参照できるようにする必要があります。
そこで、あらかじめ学習データから各日付のデータを抽出するindexを作っておくことにします。これを用いて日ごとの相関係数を計算し、それらの平均をもとに微分を計算するように修正を行います[4]。
+ # 日付を抽出するindexを作成(学習データのdate列を日付データとする)
+ date_index = [(train["date"] == i).values for i in train["date"].unique()]
+ # date列は学習には使わないため削除
+ train = train.drop(columns=["date"])
# カスタム損失関数(相関係数)
def custom_corr_loss(pred: np.ndarray, dataset: lgb.Dataset) -> Tuple[np.ndarray, np.ndarray]:
# 初期の予測値がすべて同一の値になる場合があるため、相関がnanになるのを回避
if np.all(pred == pred[0]):
return custom_squared_loss(pred, dataset)
# required_grad=True で勾配を計算するように設定
pred_tensor = torch.tensor(pred, dtype=torch.float32, requires_grad=True)
target_tensor = torch.tensor(dataset.get_label(), dtype=torch.float32)
# 可能ならGPUを使う
if torch.cuda.is_available():
pred_tensor = pred_tensor.to("cuda")
target_tensor = target_tensor.to("cuda")
# 相関係数を計算(損失として扱うためマイナスをかける)
- loss = -corr(pred_tensor, target_tensor)
+ loss = torch.stack([-corr(pred_tensor[i], target_tensor[i]) for i in date_index])
+ loss = loss.mean()
# Gradientを計算
grad = torch.autograd.grad(loss, pred_tensor)[0].to("cpu").detach().numpy()
# Hessianは1で固定
hess = np.ones_like(pred)
return grad, hess
順位変換をどうにか微分する
上記では(順位変換を伴わない)ピアソンの相関係数を微分しました。初めから順位相関を扱わなかったのは、順位変換という操作が曲者だからです。
ある系列をその順位に変換するには、ソート(並び替え)を行う必要があります。ソートを微分するのは直観的に無理そうな気がしますね。実際、Pytorchでもソートをそのまま微分することはできません。
いや、諦めるのはまだ早いです。なんとソートやランキングを微分するアルゴリズムをGoogleの研究チームが開発しています。
詳細はよくわかっておりませんが、最適輸送問題というものを考えるようです。ソートは最適輸送問題とみなすことが可能であり、最適輸送問題は微分可能だからソートも近似的に微分可能、ということらしい…。日本語の解説記事もありますので、気になる方はどうぞ。
まぁとにかく、これで順位変換も微分できるようになりました。
Pytorchでの高速な実装を行ったtorchsortというライブラリが公開されているので、こちらを使っていきましょう。
まずはパッケージをお好きな方法でインストールします。ここでは簡単のためpipで。
pip install torchsort
先ほど実装したピアソンの相関係数の計算に微分可能ランキング処理を加え、順位相関を直接最適化できるようにします。
torchsortでは微分可能ソートを行うsoft_sort
メソッドと、順位変換を行うsoft_rank
メソッドが提供されています。ここでは順位を求めたいので後者を使います。
def corr(pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
- return torch.corrcoef(torch.stack([pred, target]))[0, 1]
+ pred_rank = torchsort.soft_rank(pred.unsqueeze(0))
+ target_rank = torchsort.soft_rank(target.unsqueeze(0))
+ return torch.corrcoef(torch.concat([pred_rank, target_rank]))[0, 1]
これで日ごとの順位相関係数を直接最適化する損失関数が実装できました。めでたい!
コードの全体像は以下のようになります。
from typing import Tuple
import lightgbm as lgb
import numpy as np
import pandas as pd
import torch
import torchsort
# 特徴量と目的変数(target)を含むデータを用意
train = pd.DataFrame(...)
# 日付を抽出するindexを作成(学習データのdate列を日付データとする)
date_index = [(train["date"] == i).values for i in train["date"].unique()]
# date列は学習には使わないため削除
train = train.drop(columns=["date"])
# カスタム損失関数(二乗誤差)
def custom_squared_loss(pred: np.ndarray, dataset: lgb.Dataset) -> Tuple[np.ndarray, np.ndarray]:
label = dataset.get_label()
grad = pred - label # Gradient
hess = np.ones_like(label) # Hessian
return grad, hess
# 相関係数を計算
def corr(pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
pred_rank = torchsort.soft_rank(pred.unsqueeze(0))
target_rank = torchsort.soft_rank(target.unsqueeze(0))
return torch.corrcoef(torch.concat([pred_rank, target_rank]))[0, 1]
# カスタム損失関数(相関係数)
def custom_corr_loss(pred: np.ndarray, dataset: lgb.Dataset) -> Tuple[np.ndarray, np.ndarray]:
# 初期の予測値がすべて同一の値になる場合があるため、相関がnanになるのを回避
if np.all(pred == pred[0]):
return custom_squared_loss(pred, dataset)
# required_grad=True で勾配を計算するように設定
pred_tensor = torch.tensor(pred, dtype=torch.float32, requires_grad=True)
target_tensor = torch.tensor(dataset.get_label(), dtype=torch.float32)
# 可能ならGPUを使う
if torch.cuda.is_available():
pred_tensor = pred_tensor.to("cuda")
target_tensor = target_tensor.to("cuda")
# 相関係数を計算(損失として扱うためマイナスをかける)
loss = torch.stack([-corr(pred_tensor[i], target_tensor[i]) for i in date_index])
loss = loss.mean()
# Gradientを計算
grad = torch.autograd.grad(loss, pred_tensor)[0].to("cpu").detach().numpy()
# Hessianは1で固定
hess = np.ones_like(pred)
return grad, hess
# カスタム損失を使って学習
params = {"objective": custom_corr_loss}
train_set = lgb.Dataset(train.drop(columns="target"), label=train["target"])
model = lgb.train(params=params, train_set=train_set)
まとめ
本記事では、評価指標と整合的な損失関数を実装する考え方について紹介しました。
Pytorchの自動微分機能を使えば、任意の(微分可能な)評価指標を簡単に損失関数に組み込むことができます。
今回は一例として順位相関損失を紹介しましたが、ご自身の問題設計に応じて、好きなようにアレンジしていただければと思います。
経験則ですが、モデル自体の改善において、損失関数を工夫することはそれなりにインパクトがあると感じています。
それでは良い機械学習ライフを!
-
LightGBM4.0以降の書き方です。3系と4系でカスタム損失の記述方法が大きく変わりました。 ↩︎
-
hessianは提案元の実装でも定数で固定されていたため、本記事でもそうしていますが、根拠はよくわかっていません。わかる方、ぜひコメントで教えてください。 ↩︎
-
学習初期の数ステップで予測値がすべて同じ値になることがあります。この場合は相関係数が計算できないので、予測が安定するまで通常の損失関数を使って回避しています。 ↩︎
-
この実装では日ごとの相関係数の計算をforループで行っているため、めちゃくちゃ遅いです。後のセクションで順位変換を取り入れるため、このような実装になっています。もっとも、順位変換についてもライブラリを用いず自前で実装すればTensorの操作だけでいけるはずですが、本旨からずれるため扱いませんでした。(
というか僕では実装できません。誰かオシエテ) ↩︎
Discussion