sw1227’s diary

Visualization, GIS, Machine Learning, Generative Art, Simulation, Math

PFN製の最適化ツール「Optuna」で富士山を登頂する

1. 背景

Optunaとは、Preferred Networks(以下PFN)の秋葉氏らが開発したハイパーパラメータ自動最適化ツールです。

ハイパーパラメータ自動最適化ツール「Optuna」公開 | Preferred Research

これは勾配法の適用できない(しない)ハイパーパラメータをベイズ最適化アルゴリズムの一種を使って自動で最適化してくれるものですが、Chainerと密結合しているわけではありません。何らかのObjective functionを与えることで簡単に最適化を行ってくれるようになっています*1

★★★

Objective...? 登山にとってそれは標高だ、と多くの人は考えるのではないでしょうか? 標高が唯一の目的ではないにせよ、世界最高峰たるエベレストが登山家を惹きつけ、命を省みない挑戦を生み出してきたことは事実です。

というわけで、この記事ではOptunaを地形に対して適用することで富士山の山頂を目指します。

山巓の岩に坐って一時間。

寂しい — という一つの言葉が粟つぶのように心に浮かんできた。だが私はそれを声にできない。

沈黙は認識ではない。でもそれはわたしを一生歩かせる。歩きつづけ登りつづける他に登山者には答えようがない。

辻まこと北岳にて』

2. 方法

以下では、富士登山を「富士山付近の標高を収めた二次元配列に関して、標高が最大値をとるようなインデックスを探索する問題」として捉えます。

最適化の対象となるのは二次元配列のインデックス(x2)であり、各インデックスにおける標高を-1倍したものを最小化するためにOptunaを使用します。

2.1. 標高タイル

標高に関するデータは国土地理院の標高タイルから入手します。詳細は以下のQiitaの記事に書いていますが、座標(タイル座標という)を指定することで、正方領域の標高値を二次元配列として取得することが可能です。

qiita.com

今回は、富士山周辺のタイル座標として (z, x, y) = (10, 906, 404) を用いました。

富士山近辺のタイル座標
富士山近辺のタイル座標

Pythonで標高タイルを取得するためには以下のようにします。

fuji =  (10, 906, 404) # Mt. Fuji
fuji_tile = fetch_tile(*fuji)

ここで、fetch_tile関数は以下のQiitaの記事に記載した通りです。 qiita.com

2.2. Optuna

公式のTutorialがあるのでそれを参考にします。 trialオブジェクトを受け取る関数で最小化したい目的関数を定義し、その中でtrial.suggest_***のようにすることで探索したいパラメータを宣言します。

今回の場合、fuji_tileという二次元配列( 256 \times 256)において標高を最大化する離散的なインデックス x, y (整数値)を探したいので、以下のようにすれば良いでしょう。

import optuna

def fuji_objective(trial):
    x = trial.suggest_discrete_uniform('x', 0, 255, 1)
    y = trial.suggest_discrete_uniform('y', 0, 255, 1)
    return - fuji_tile[int(y)][int(x)]

study = optuna.create_study()
study.optimize(fuji_objective, n_trials=100)

3. 結果

3.1. 実行例

実行するたびに結果は異なりますが、試しに一度実行してみた結果を記します。

まず、n_trials(今回は100回のtrial)内で得られた最適なパラメータは以下のように得られます。今回の場合だと、標高が最大になった二次元配列のインデックスに相当します。

study.best_params
# => {'x': 159.0, 'y': 85.0}

その際の目的関数の値(今回の場合、最大となった標高の-1倍)は以下のようにして取得できます。 256 \times 256 = 65536 個のインデックスから100通り(n_trials)しか探索していないのですが、富士山の標高3776m(の-1倍)に近いものが得られました。

study.best_value
# => -3662.8

また、一つ一つのtrialにおけるパラメータや目的関数の値などもstudy.trialsに入っているため、以下のように途中経過を可視化することができます。

plt.imshow(fuji_tile)
plt.scatter([t.params["x"] for t in study.trials], [t.params["y"] for t in study.trials], s=20, marker="+", color="white") 
plt.scatter(study.best_params["x"], study.best_params["y"], s=100, color="red")

これを実行したのが下図。塗り潰しは富士山近辺の標高に対応(黄色っぽいほど高い)し、白い点が探索過程で得られた座標、赤い点が探索過程でもっとも標高が高かった点となっています。山頂近辺を中心に探索が行われているように思われます。

探索過程
探索過程

3.2. 登山経路

先ほどの散布図に加え、trial間でどのようにパラメータ(今回はx, y座標に対応)の値が遷移したのかを可視化することもできます。

for i in range(len(study.trials[:])-1):
    plt.plot(
        [study.trials[i].params["x"], study.trials[i+1].params["x"]],
        [study.trials[i].params["y"], study.trials[i+1].params["y"]],
        color="gray", linestyle='-', linewidth=1)

実行結果は以下のようになります。勾配法とは異なり、必ずしも近くへ移動している訳ではなさそうですね。

trial間の遷移
trial間の遷移

標高の推移も可視化してみましょう。

plt.plot([-t.value for t in study.trials])
plt.xlabel("Trial")
plt.ylabel("Elevation")

Trial回数と標高の関係
Trial回数と標高の関係

縦軸を「そのTrialにおける標高」ではなく「それまでのTrialにおける最高の標高」にすると以下のようになります。

import pandas as pd
plt.plot(pd.DataFrame([-t.value for t in study.trials]).cummax())
plt.xlabel("Trial")
plt.ylabel("Cumlative Max of Elevation")

最大標高の推移
最大標高の推移

全く同じグラフを、10回のstudyで描画すると以下のようになります。横軸(Trial回数)を先ほどより広めにとっています。

for i in range(10):
    study = optuna.create_study()
    study.optimize(fuji_objective, n_trials=300)
    plt.plot(pd.DataFrame([-t.value for t in study.trials]).cummax())
plt.xlabel("Trial")
plt.ylabel("Cumlative Max of Elevation")

最大標高の推移
最大標高の推移

200trialぐらい経過するとほぼ山頂近くまで到達しているようですね。

4. まとめ

富士登山を「富士山付近の標高を収めた二次元配列に関して、標高が最大値をとるようなインデックスを探索する問題」として捉えることにより、Optunaで山頂を探すことができました。

私の認識が正しければOptunaは山頂を探すためのツールではないのですが、 256 \times 256 = 65536 個のインデックスから200ほど探索した時点で山頂付近に到達することに成功しました。ランダムor等間隔にサンプリングした場合と定量的に比較した訳ではありませんし、そもそも…と突っ込み始めたらキリのない内容ではありますが、思いのほかうまく行ったような印象を受けています。Optunaはとても使いやすいかったので、今度はちゃんと機械学習にも使ってみたいと思っています。

以上

山と高原地図 富士山 御坂・愛鷹山 (山と高原地図 32)

山と高原地図 富士山 御坂・愛鷹山 (山と高原地図 32)

*1:もちろん、問題の性質によっては他の最適化手法を用いるべきです