MambaのInduction Heads再現

 Mambaが気になったので触ってみることにした。大規模な実験を回すのは大変なので、元論文で使われていた人工的なタスクのInduction Headsをやる。

p.6 Figure 2

Mamba

 とりあえず動かしてみることを優先してMamba自体の理解は後回しにする。

 概要としては以下の資料がわかりやすい。

 細かい内容については

などがある。実装で参考にしたのは

など。

Parallel Scan

 Mambaの内容を軽く追っているときに、単純な累積和が2分木を利用したParallel Scanで実行できることはわかるが、実際には

 
H _ t = A _ t H _ {t - 1} + X _ t

を計算することになる。これもできるのか一瞬迷うところはあった。根本的には、Parallel Scanは結合則が成り立つものに対してはできるということなのだと思う。全く厳密ではないが、直感的にはもう1ステップ展開して

 
\begin{align}
H _ {t + 1} &= A _ {t + 1} H _ t + X _ {t + 1} \\
& = A _ {t + 1} (A _ t H _ {t - 1} + X _ t) + X _ {t + 1} \\
& = A _ {t + 1} A _ t H _ {t - 1} + A _ {t + 1} X _ t + X _ {t + 1}
\end{align}

まで見ると、上手くデータを保持すればできそうということになる。

 たとえば4系列で処理の動きを考えてみると

となる。これはコード的にはmamba.pyのpscan.pyを読むとわかりやすい。

https://github.com/alxndrTL/mamba.py/blob/a21c448e0f46a011d8edf792f8349c952e87d620/pscan.py#L37-L92

Induction Head

 論文のTable 2(Table? Figureでは?)のMambaの線を再現する。

p.11 Table 2

 ハイパーパラメータは論文の通り。比較手法はなしでMambaだけがちゃんと動くことを確認した。

 学習の損失推移は以下の通り。途中で"気づく"タイミングが発生してそこから損失が一気に減る。

 学習系列長は256で固定して、学習終了後、テストとして32から2倍ずつした系列長で汎化を確認した。各長さ100サンプルで簡単に検証したところ、系列長16384までは正答率100%になった。

 つまり系列長に対して行動を丸暗記しているのではなく、規定のトークンが来たときにその次のトークンを記憶しておくということがちゃんとできていると考えられる。

 系列長をさらに2倍にするとGPUのメモリが足りなかったため、自己回帰モードでの推論ができることも確認した。系列長262144まで正答率100%であり、524288で82%、1048576で49%という結果になった。

 速度については、Parallel Scanでの実行も自己回帰も、系列全体として考えると O(N)であり、自己回帰の方がとても定数倍が大きく遅い。たとえば系列長1048576をテストするのには2時間以上かかっている。(以下は両対数グラフ)

 系列がすでに定まっている状態で推論させるなら当然Parallel Scanで良いが、逐次的な生成が必要になる場面ではやはり各ステップで時間・空間計算量が O(1)であるというのは魅力的だ。

Can large language models explore in-context?を読んだメモ

前書き

 多腕バンディット問題は強化学習のとても基本的な設定だが、そこにはすでに探索と知識利用のトレードオフが存在しており、そのバランスをいかに取るかを見ることができる。MCTSなども各ノードでの行動選択はほぼ多腕バンディット問題そのものなので、これができるようになると応用先は多い。

 昨今では大規模言語モデル(LLM)に思考能力があるのだのないのだのいろいろ言われている気がするが、個人的にLLMが多腕バンディット問題に適切に対処できるかどうかは、一つ確認しておくべきことなのではないかと思う。広い意味で試行錯誤の挑戦をしたり、過去の行動から良い方針を決めたりすることがそもそもできそうなのかどうかを、まずこういうシンプルな設定で試すことは良いことに思われる。

読んだ論文

 この論文では、いくつかの既存のLLMについて、パラメータは固定した状態でIn-Context学習だけで多腕バンディット問題が上手く解けるのかどうかを検証している。使用モデルと入力Promptの工夫を複数試した結果、GPT-4を使い、過去の結果から平均報酬などの統計量を事前計算してPromptに与えるようにした場合だけまともに解けるとのことだ。

 「Promptを工夫すれば解ける」と肯定的に捉えるべきか、「そこまで丁寧に誘導してやらないと解けない」と否定的に捉えるべきかはこの論文でも迷っていそうなところだったが、最終的に結論としては、世の中の問題は統計量が計算しやすいものばかりでもないので、まだこの時点では適用先を広く取れないだろうという感触でやや否定的ではある。個人的にも同意する。

実験設定

バンディット設定

 多腕バンディットの設定としては、確率的ベルヌーイバンディットを使う。ここでは、 K本のアームがあり、それぞれのアーム aは未知の平均報酬 \mu _ a \in \lbrack 0, 1 \rbrackを持つ。エージェントは T回の相互作用ができ、アーム aを選んだ場合には \mu _ aを平均とするベルヌーイ分布からサンプリングされた報酬を受け取る。

 難易度パラメータとして \Deltaを定め、最良アームは \mu ^ * = 0.5 + \Delta / 2、その他全てのアームは \mu = 0.5 - \Delta / 2を平均報酬とするように設定する。

  • Hard設定  K = 5,  \Delta = 0.2
  • Easy設定  K = 4,  \Delta = 0.5

Prompt設定

 いくつかの独立した要素を選択する。

  • シナリオ
    • (a)LLMに押すべきボタンを選ぶように問う
    • (b)ユーザーへ表示する広告を推薦するように問う
  • フレーム
    • (a)探索と知識利用のバランスを取るように明示的に示唆する
    • (b)特に何も言及しない
  • 履歴
    • (a)生の値リストとして与える
    • (b)各アームについて試行回数と平均報酬を計算して与える
  • 過程
    • (a)答えのみ要求する
    • (b)chain-of-thought(CoT)の説明も許可する

 もっとも基本的な設定が、「シナリオ:ボタン、フレーム:言及なし、履歴:生の値リスト、過程:答えのみ」であり、それぞれのもう片方は性能を上げる可能性があるものとして用意されている。(シナリオで推薦設定が良いとされているのは、多腕バンディットが推薦システムのこのような文脈で使われることが多く、LLMに多腕バンディットの情報を想起させる可能性があるため)

LLM設定

 使用するLLMとしては GPT-3.5、GPT-4、LLAMA2 の3種類となる。

 GPT-4だけは、毎回のユーザ入力としてアームを引いた結果に加えてCoTを使うならそのことも追記する。(そうしないとCoTを使うことを忘れてしまうらしい)

 また、生成時の温度パラメータについては、

  • 0
  • 1
  • 0で、引きたいアームの分布を指定させてそこから分布に従って選択

の3通りを試した。

ベースライン

  • UCB
  • Thompson Sampling
  • Greedy

結果

 まずGPT-4を工夫なしで利用した場合、Greedyにも及ばないほどの性能になる結果が得られた。

 この論文ではよくある失敗パターンとして2つを挙げている。

  • suffix failure : ある程度の初期探索フェーズが終わった後、最適なアームを選ぶところで間違ったアームに収束してしまう。これが失敗のうち60%ほどを占める
  • uniformly : 全てのアームをおおよそ等しいくらいの確率で選ぶようになってしまう。

 多腕バンディットの報酬から性能を評価しようとすると大きなタイムステップが必要になるので、代理指標としてsuffix failureやuniformlyを定式化して、短いタイムステップでも失敗を判別できるようにしている(それはそうだろうという感じなので細かい指標は省略)。

 GPT-4にPromptの工夫を全部詰め込んだ場合、Thompson Samplingと同じくらいの性能になる。

 原因の分析として、UCBで得られた系列を使ってLLMに次のアームを決定させるなどもやっているが、あまり明瞭に原因がわかるという状況ではなさそうだった。

気になった参考文献

所感

 平均まで計算した状況ならできるというのは、実践的にはそういう計算ができないことも多いだろうから、やはりイマイチには思える。計算できるのならUCBなりをそのまま使えば良いのだし、LLMがやるならそこまで計算できないような報酬とも言えないような中途半端な状況設定だったりするのだろうか。

 GPT-4だけ成功してGPT-3.5やLLAMA2ではダメだったという結果から、ある意味ではLLMのなんらかの側面の評価には使えるのかもしれない。

 あとは、学習も加えればどの程度まで伸びるかという側面か。参考文献を読んでいきたい。

small_gicpトライアル

 まだソースコードはほとんど読んでおらず、とりあえずexampleの通りに利用する形で、速度と精度を簡単に検証する。

データ

 AWSIM西新宿v1.2.0を用いて、シミュレータのGTつきデータを取得した。経路は以下の通り。

 点群マッチングには関係ないが、様子を把握するためにカメラ画像を動画化すると以下の通り。

 点群マッチングで使われるものは、以下の白く小さい点がマップ点群で、緑色の正方形が各タイミングでのセンサ点群(ダウンサンプリング済み)となる。

 保存したrosbagを分解して、各スキャンデータでのPCDファイルと、そのときのタイムスタンプに合わせて線形補間したGT PoseのCSVファイルにした。合計で819フレームのデータが得られた。

データリンク : https://drive.google.com/file/d/1PLaDYRKQ7HqIVlHvc1OBDSd0S7lKTAEh/view?usp=drive_link

PCDマップデータはAWSIM西新宿のものを利用した。

手法

  • ndt_omp (tier4のフォーク版)
  • fast_gicpのVGICP
  • small_gicpのVGICP

の3種類についてそれぞれ実行した。

パラメータ

 パラメータとしては

項目
Resolution 2.0
NumThreads 4
MaximumIterations 30
TransformationEpsilon 0.01

を基本として他はデフォルト、ndt_ompについてだけはstep_sizeを0.1とした。実験コード

ノイズ

 GT Poseから各種alignを始めても面白くないので、GT Poseに対してxとyの位置について、-3mから3mまでの範囲から一様分布で乱数を取り、それをノイズとして加えた位置を初期位置としてalignを実行した。同一フレームのデータについては、同じノイズが乗った初期位置が3種類の手法に与えられる。

結果

実行時間

実行環境:Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz 8コア

手法 平均処理時間 [msec]
ndt_omp 14.94
fast_gicp 1.47
small_gicp 1.80

 fast_gcipとsmall_gicpは1回目のalignは非常に時間がかかる(fast_gcipで約5秒、small_gicpで約4秒)ので、1回目は除外して計算している。(上手く利用すれば1回目の構築処理は事前に実行できると思われるため)。

 fast_gicp, small_gicpはndt_ompよりも処理時間が1/10程度になっている。信じられないくらい速い。この理由として、今回の実験はノイズを加えてそれなりに離れた位置からalignを始めているのもあり、収束にかかるイテレーション数に差があると思われる。tier4フォーク版ndt_ompではline_searchを切っているためか、離れたところに収束するのは結構イテレーション回数がかかる。実際にndt_ompのイテレーション回数を表示してみると上限の30に達していることが多かった。fast_gicpでもNrIterationを取得する関数を追加しており、そちらを確認すると30回には行かずすぐに終わっていることが多かった。

 これを検証するため、3手法全てsetTransformationEpsilon(0.0);として、強制的に上限の30回まで回るようにして実行した。

手法 平均処理時間 [msec]
ndt_omp 18.08
fast_gicp 5.14
small_gicp 1.84

 fast_gicpはちゃんと遅くなったが、small_gicpはほとんど変わらないままだった。純粋にとても速い可能性が示唆されている。


 [2024/04/09追記] 本文中のsetTransformationEpsilon(0.0);とする実験について、設定が上手く反映されていなかった可能性があるとのことだったため、修正後のコードで再実験した結果を追記

手法 修正前平均処理時間 [msec] 修正後[msec]
ndt_omp 18.08 17.95
fast_gicp 5.14 5.18
small_gicp 1.84 2.14

 そこまで極端には変わっていないと思われる。

収束PoseとGT Poseとの差分:位置

 ここでの位置は、GTのPoseから見た相対的な位置関係である。つまりx方向が自車前方向、y方向が左手方向、z方向が上方向。TransformationEpsilonはデフォルトの0.01に戻している。

手法 平均位置誤差 [m]
ndt_omp 0.45
fast_gicp 1.29
small_gicp 0.17

 fast_gicpは若干精度が悪く出てしまうが、small_gicpはとても良い結果となった。

収束PoseとGT Poseとの差分:向き

 GT Poseから見た相対的なroll, pitch, yaw, rot_vecで表現したときのノルムをプロットした。TransformationEpsilonはデフォルトの0.01に戻している。

手法 平均角度誤差 [deg]
ndt_omp 0.18
fast_gicp 0.19
small_gicp 0.12

 どの手法でも時々スパイクは出るが、見た目のスパイク頻度的にも平均数値的にもsmall_gicpが最も良かった。

まとめ

 今回の簡単な検証では、small_gicpはとても高速かつ精度も良く、かなり有望そうな結果であった。ソースコードを丁寧に読んだり、今後複数シーンなどで検証していく価値が大いにあると思われる。

MC Digital プログラミングコンテスト2024(AtCoder Heuristic Contest 031)

 昨日見た時点では160位とかだった。

方針概要

シード0000

 領域を縦線 or 横線で2つに分断していくこと繰り返すような操作を考える。途中まではKDTreeみたいな気持ちで、Depthが奇数のときは縦、偶数のときは横みたいにすることを意識していた。それを全日で固定とする線をできるだけ多く作るようにしたい。

 最終的には結局縦横どっちに分割するかも最適化要素に入れた。そうすると、各深さで決めるべきは

  • どちらの方向に分割するか
  • どれだけの長さで分割するか
  • 分割した2つの領域にどの予約を割り当てるか

になる。方向は0 or 1、長さは切る方向の全体からみた割合として、分割はできるだけ面積の差が少なくなるように後ろから振り分けていくとして焼き鈍し状態を定義して、適当に焼く。あまり時間をかけても改善しなかったので良い方法ではなかったのだと思う。

 シート0001とかは全然固定線を作れていない。

シード0001

かけた時間

時間 やったこと概要
1日目(金) 1時間くらい ちょっと問題読んで環境整備
2日目(土) 4時間くらい 各予約の長方形を独立に焼き鈍す。全然スコアが出なかった
3日目(日) 0 -
4日目(月) 2時間くらい ここでKDTreeみたいに分割していくアイデアを思いつく。ようやく面積を溢れさせないような振り分けができるようになり、多少スコアがまともになる。250位くらい?
5日目(火) 1時間くらい 評価関数で区切り線の生成分解によるコストをようやく考慮する
6日目(水) 2時間くらい KDTree的区切りをして、各ターンでの振り分けをランダム化してそこを山登り法で最適化してみたが上手くいかず
7日目(木) 4時間くらい ようやく全日通して固定する線を決め打ちする実装を入れる。多少スコアが良くなる。200位くらい?
8日目(金) 0 -
9日目(土) 6時間くらい 高速化したり時間いっぱい回すようにしたり分割をスワップするようにしたり。150位くらい
10日目(日) 1時間くらい 遷移の調整程度
11日目(月) 0 -

 計20時間程度か。苦手めな問題で、あまりやる気も高まらなかった。もう一つくらい発見をして多少順位伸ばせれば良かったが、日曜日にAGCがあったのもあり、力尽きた。

転一

 転職して一年経ったので振り返り。もちろん書けないことは書かないので、公開情報(つまりGitHub)を主に参照する。

 まず、GitHubのアカウントはそのままのものを使っているので、だいぶContributionsに色が付くようになった。

https://github.com/SakodaShintaro

 7月頃に研修が終わってから、平日はそれなりの割合でなんらかのContributionはしているのだろう。

 プルリクエストの数としては、各種リポジトリをまとめて計算すると144個 Closedとなっているようだ。諸々考慮すると大雑把に2日~3日に1個くらいか。よく触るリポジトリであるautoware.universeは、パラメータ部分だけは独立して編集できるようにautoware_launchというリポジトリに弾き出しているのもあり、つまりパラメータを変更すると2つのリポジトリに同時にPRを出すことになって単純な数として増えがちという面はある。

 autoware.universeに絞ってこのリポジトリcontributorsを見ると自分は現在31番目となっている。

 高ければいいというものでもないとは思うが、コードを書く以外に大した能のない立場としてはある程度やっていかなければならないところではある。

主要なプルリクエス

 細かい診断機能追加とかバグ対応とかが多く、なにか目玉っぽい機能追加にはそこまで関わっていない。LocalizationのNDT、EKF、IMU、ARタグあたりをぼちぼちと見ているバグ対応とかちょっとした修正が多い。その中でもいくらかピックアップする。

NeRFベース自己位置推定

 研修のときにやっていたものをある程度仕上げてプルリクエストまでは出したが、まぁマージできるような状態ではないのでDraftに戻してわりと放置気味。少しずつ触っておきたいとは思っているが。

ARタグベース自己位置推定

 一応自分がメインで機能追加した立場となっているが、ライブラリを持ってきただけであまり実装をすごくやったという感じでもない。これ以降もリファクタリングなどちょこちょこ手だけは入れている。

TPE

 NDTによる初期位置推定で、それまではランダムに姿勢候補をばらまいて評価していたのを、Tree-Structured Parzen Estimator(TPE)というブラックボックス最適化を使ってちょっと改善した。ただ、姿勢候補の散らばり方に怪しいところがあり、なんかバグっているのではないかという疑惑がある。また変更はしていくことになりそう。

NDT単体テスト

 機能という感じではないが、こういう方面にもいくらか守備範囲があるということで。ROS 2のメッセージやりとりの制御をするのが面倒だったのか誰も手を付けておらず、いろいろ起きたのもあってじゃあ自分がやるしかないか……と。ndt_ompの方にもGitHub Actionsを使ったテストを入れたりした。

所感

 振り返り前はもっと目も当てられないくらい何もできていないのではないかと思っていたが、こうしてみるとなんとか最低限かもしれない。低いレベルで満足してしまうのが良くないという説はある。ぽつぽつ考えていることはあるが、とりあえず時間をたくさん投入してどうにかしていくしかない面も大きい。

 キャリア? とかいう謎のものがどうなっていくのか。自己位置推定をいろいろ触っているが、専門家というほどではなく。テスト・評価、あるいは堅牢かつ変更しやすいソフトウェア構造・実装というものに興味はあるが、専門家というほどではなく。数理最適化に興味はあるが、専門家というほどではなく。まぁ普通に生きていたらこうなるよな、という中途半端具合は少し感じる。

 興味のないものには全然興味でないままだということもわかってきた。英語も翻訳ソフトウェアにだいぶ頼っているし、話す聞くは放置。ハードウェア系もあまり首突っ込んでみようとならなかった。モチベーション低いままに「必要そうだから……」で学習するのは厳しいので、なんとかするなら根本から対処しなければならない。

 一年は短いな。

Levenberg-Marquardt法あたりの実装練習

 非線形最小二乗問題を解く上で、

と整理できて、これらを実装してみる。

 サンプルの関数としてお馴染みのRosenbrock functionを使う。

 (1, 1)のところに極小値が存在する。

 これに対してPyhtonで各種最適化手法を実装した。Line Searchなどは使わず、Levenberg-Marquardt法でもλの混合具合の調整などはせず、かなりシンプルに関係を把握する目的のコードになる。

実装

import numpy as np
from copy import deepcopy
from collections import defaultdict
import matplotlib.pyplot as plt


def error1(x1: float, x2: float) -> float:
    return x1 - 1


def error2(x1: float, x2: float) -> float:
    return 10 * (x2 - x1**2)


def rosenbrock_function(x1: float, x2: float) -> float:
    return 1 / 2 * (error1(x1, x2) ** 2 + error2(x1, x2) ** 2)


def rosenbrock_gradient(x1: float, x2: float) -> np.ndarray:
    g1 = error1(x1, x2) * 1.0 + error2(x1, x2) * (-20 * x1)
    g2 = error1(x1, x2) * 0.0 + error2(x1, x2) * 10
    return np.array([g1, g2])


def hessian_matrix(x1: float, x2: float) -> np.ndarray:
    return np.array([[1 - 200 * x2 + 600 * x1**2, -200 * x1], [-200 * x1, 100]])


def jacobian_matrix(x1: float, x2: float) -> np.ndarray:
    row1 = np.array([1, 0])
    row2 = np.array([-20 * x1, 10])
    return np.vstack([row1, row2])


if __name__ == "__main__":
    np.set_printoptions(formatter={"float": "{:.3f}".format})

    initial_guess = np.array([-10.985, 50.940])

    # Newton method
    x = deepcopy(initial_guess)
    dict_newton = defaultdict(list)
    dict_newton["x1"].append(x[0])
    dict_newton["x2"].append(x[1])
    dict_newton["f(x)"].append(rosenbrock_function(x[0], x[1]))
    for i in range(100):
        g = rosenbrock_gradient(x[0], x[1])
        h = hessian_matrix(x[0], x[1])
        x -= np.linalg.inv(h) @ g
        dict_newton["x1"].append(x[0])
        dict_newton["x2"].append(x[1])
        dict_newton["f(x)"].append(rosenbrock_function(x[0], x[1]))
        if np.linalg.norm(g) < 1e-6:
            break
    plt.plot(dict_newton["x1"], dict_newton["x2"], marker="o", label="Newton")

    # Gauss-Newton method
    # print("Gauss-Newton method")
    x = deepcopy(initial_guess)
    dict_gauss_newton = defaultdict(list)
    dict_gauss_newton["x1"].append(x[0])
    dict_gauss_newton["x2"].append(x[1])
    dict_gauss_newton["f(x)"].append(rosenbrock_function(x[0], x[1]))
    for i in range(100):
        g = rosenbrock_gradient(x[0], x[1])
        j = jacobian_matrix(x[0], x[1])
        h_hat = j.T @ j
        x -= np.linalg.inv(h_hat) @ g
        dict_gauss_newton["x1"].append(x[0])
        dict_gauss_newton["x2"].append(x[1])
        dict_gauss_newton["f(x)"].append(rosenbrock_function(x[0], x[1]))
        if np.linalg.norm(g) < 1e-6:
            break
    plt.plot(dict_gauss_newton["x1"], dict_gauss_newton["x2"], marker="x", label="Gauss-Newton")

    # Gradient descent method
    x = deepcopy(initial_guess)
    dict_gradient_descent = defaultdict(list)
    dict_gradient_descent["x1"].append(x[0])
    dict_gradient_descent["x2"].append(x[1])
    dict_gradient_descent["f(x)"].append(rosenbrock_function(x[0], x[1]))
    for i in range(100):
        g = rosenbrock_gradient(x[0], x[1])
        x -= 0.000025 * g
        dict_gradient_descent["x1"].append(x[0])
        dict_gradient_descent["x2"].append(x[1])
        dict_gradient_descent["f(x)"].append(rosenbrock_function(x[0], x[1]))
        if np.linalg.norm(g) < 1e-6:
            break
    plt.plot(dict_gradient_descent["x1"], dict_gradient_descent["x2"], marker="s", label="Gradient descent")

    # Levenberg-Marquardt method
    x = deepcopy(initial_guess)
    dict_levenberg_marquardt = defaultdict(list)
    dict_levenberg_marquardt["x1"].append(x[0])
    dict_levenberg_marquardt["x2"].append(x[1])
    dict_levenberg_marquardt["f(x)"].append(rosenbrock_function(x[0], x[1]))
    for i in range(100):
        g = rosenbrock_gradient(x[0], x[1])
        j = jacobian_matrix(x[0], x[1])
        h_hat = j.T @ j
        x -= np.linalg.inv(h_hat + 0.1 * np.eye(2)) @ g
        dict_levenberg_marquardt["x1"].append(x[0])
        dict_levenberg_marquardt["x2"].append(x[1])
        dict_levenberg_marquardt["f(x)"].append(rosenbrock_function(x[0], x[1]))
        if np.linalg.norm(g) < 1e-6:
            break
    plt.plot(dict_levenberg_marquardt["x1"], dict_levenberg_marquardt["x2"], marker="^", label="Levenberg-Marquardt")

    plt.scatter(initial_guess[0], initial_guess[1], color="blue", label="Initial guess")
    plt.scatter(1, 1, color="red", label="Minimum")
    plt.legend()
    plt.savefig("optimization.png", bbox_inches="tight", pad_inches=0.05)

 (-10.985, 50.940)というかなり離れたところから始めて、ある程度は(1, 1)へ収束していくことがわかった。

 だいぶ大きく跳ね回っていたり最急降下法は途中で止まっていたりなど、かなり怪しいところは多いが、Levenberg-Marquardt法がとりあえず挙動としてそうなりそうというところが見えてきたのでこれでいったん打ち止め。

 本来、実装としては明に逆行列を求めるのではなく、np.linalg.solveを使うと良いとのこと。

おまけ

 ここに興味を持ったきっかけは、fast_gicpではNDTやGICPがlsq_registrationという形で抽象化されており、最適化手法としてGaussNewtonとLevenbergMarquardtが選択できるようになっていたのを発見したことにある。

 もとのPCLではNDTの最適化はかなりいろいろと工夫が凝らされており、なにをやっているのかまだあまり理解できていないが、こうやって整理できるならかなり良さそう。

ガウス過程の実装練習

 ベイズ最適化に興味がありガウス過程を学びたいと思ったので以下の本を読んだ。

 一周目なので細かい式変形は追っておらず、まず大枠の導出の流れを理解することで妥協したが、結局実装部分がわからないとピンとこないので、そこだけPythonで実装した。といっても本に疑似コードが載っており、それをほぼそのまま写すだけ。

 まず、1次元の関数 f(x) = x ^ 3 - 2 xが未知のものとしてあり、 x = -5, -4, \dots, 4, 5の11点についてノイズありで値が与えられるので関数を回帰する問題設定でやってみた。

main_1d.py

"""Gaussian Processの実装練習."""

import matplotlib.pyplot as plt
import numpy as np


def f(x: float) -> float:
    return x**3 - 2 * x


def radial_basis_function(x1: np.ndarray, x2: np.ndarray) -> float:
    diff = x1 - x2
    norm = np.linalg.norm(diff)
    theta1 = 100
    theta2 = 1
    return theta1 * np.exp(-norm / theta2)


def predict(
    x_test: np.ndarray, y_train: np.ndarray, x_train: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
    assert len(x_train) == len(y_train)
    n = len(x_train)
    m = len(x_test)
    K = np.zeros((n, n))
    for i in range(n):
        for j in range(n):
            K[i][j] = radial_basis_function(x_train[i], x_train[j])

    K += 25 * np.eye(n)

    K_inv = np.linalg.inv(K)
    yy = K_inv @ y_train
    mu = []
    var = []
    for i in range(m):
        k = np.zeros((n, 1))
        for j in range(n):
            k[j, 0] = radial_basis_function(x_train[j], x_test[i])
        curr_mu = k.T @ yy
        curr_var = radial_basis_function(x_test[i], x_test[i]) - k.T @ K_inv @ k
        mu.append(curr_mu[0])
        var.append(curr_var[0, 0])
    return np.array(mu), np.array(var)


if __name__ == "__main__":
    MIN_X = -5
    MAX_X = 5
    x = np.arange(MIN_X, MAX_X, 1, dtype=np.float32)
    n = len(x)
    y = f(x)

    # ノイズ付与
    y += np.random.normal(0, 20, n)

    WIDTH = (MAX_X - MIN_X) * 0.25
    x_list = np.arange(MIN_X - WIDTH, MAX_X + WIDTH, 0.1, dtype=np.float32)
    mean_list, variance_list = predict(x_list, y, x)
    gt_list = f(x_list)

    plt.plot(x_list, mean_list, label="predict_mean")
    plt.fill_between(
        x_list, mean_list - variance_list, mean_list + variance_list, alpha=0.3
    )
    plt.plot(x_list, gt_list, linestyle=":", label="GT(y=x**3 - 2 * x)")
    plt.scatter(x, y, marker="x", label="data", color="black")
    plt.legend()
    save_path = "./plot_1d.png"
    plt.savefig(save_path, bbox_inches="tight", pad_inches=0.05)
    print(f"Saved to {save_path}")
    plt.close()

 やってみるとカーネル関数(今回だとRadial Basis Function)のパラメータを上手く設定しないと想定していたようなグラフにならず、そこに気づくのが難しかった。上の図だと手動でいくつか試して \theta _ 1 = 100, \theta _ 2 = 1という値になっている。これがどの程度それっぽいのかはよくわからない。もちろん本でもハイパーパラメータの決め方についてはきちんとフォローされており、パラメータの推定方法まできちんと載っている。微分は大変そうに見えたので、今回は適当な山登り法である程度調整した。

 xがサンプリングされる区間が一定だと見た目が面白くないのでそれもちょっと変えて、パラメータは \theta _ 1 = 1079.53, \theta _ 2 = 1.25となった。 \theta _ 1はまだまだ大きいほうが良さそうなのでちょっと怖い。

 GTのオレンジ点線がある程度は 1\sigma区間に入っているのが正当と考えると、これくらい大きくするのが当たり前か。多分これで間違ってない、と思う。まぁ真剣に使うときになったらもっとよく調べるということで。

 自分の考えている問題だと2Dまでは最低限欲しいと思われるので、xyの2Dでも試した。

main_2d.py

"""Gaussian Processの実装練習."""

import matplotlib.pyplot as plt
import numpy as np


def f(x: np.ndarray) -> float:
    mu = np.array([[0, 0]])
    sigma = [
        [0.2, 0.0],
        [0.0, 10.0],
    ]
    det = np.linalg.det(sigma)
    inv = np.linalg.inv(sigma)
    const = 1.0 / (2.0 * np.pi * np.sqrt(det))
    diff = x - mu
    exp_term = np.exp(-0.5 * diff @ inv @ diff.T)
    exp_term = np.diag(exp_term)
    return const * exp_term


def radial_basis_function(x1: np.ndarray, x2: np.ndarray) -> float:
    diff = x1 - x2
    norm = np.linalg.norm(diff)
    theta1 = 100
    theta2 = 10
    return theta1 * np.exp(-norm / theta2)


def predict(
    x_test: np.ndarray, y_train: np.ndarray, x_train: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
    assert len(x_train) == len(y_train)
    n = len(x_train)
    m = len(x_test)
    K = np.zeros((n, n))
    for i in range(n):
        for j in range(n):
            K[i][j] = radial_basis_function(x_train[i], x_train[j])

    K += 25 * np.eye(n)

    K_inv = np.linalg.inv(K)
    yy = K_inv @ y_train
    mu = []
    var = []
    for i in range(m):
        k = np.zeros((n, 1))
        for j in range(n):
            k[j, 0] = radial_basis_function(x_train[j], x_test[i])
        curr_mu = k.T @ yy
        curr_var = radial_basis_function(x_test[i], x_test[i]) - k.T @ K_inv @ k
        mu.append(curr_mu[0])
        var.append(curr_var[0, 0])
    return np.array(mu), np.array(var)


if __name__ == "__main__":
    MIN_X = -5
    MAX_X = 5
    # [-5, 5]の範囲で2次元のランダムな点
    x = np.random.uniform(MIN_X, MAX_X, (20, 2))
    n = len(x)
    y = f(x)

    print(f"{x.shape=}")
    print(f"{y.shape=}")

    # ノイズ付与
    # y += np.random.normal(0, 20, n)

    fig = plt.figure(figsize=(20, 10))

    # GTをプロット
    x_list = np.arange(MIN_X, MAX_X, 0.05, dtype=np.float32)
    x_list = np.array(np.meshgrid(x_list, x_list)).T.reshape(-1, 2)
    gt_list = f(x_list)
    ax = fig.add_subplot(311, projection="3d")
    ax.scatter(x_list[:, 0], x_list[:, 1], gt_list, c=gt_list)
    ax.set_xlim(MIN_X, MAX_X)
    ax.set_ylim(MIN_X, MAX_X)
    ax.set_title("GT")

    # サンプル点をプロット
    ax = fig.add_subplot(312, projection="3d")
    ax.scatter(x[:, 0], x[:, 1], y, c=y)
    ax.set_xlim(MIN_X, MAX_X)
    ax.set_ylim(MIN_X, MAX_X)
    ax.set_title("Sample points")

    WIDTH = (MAX_X - MIN_X) * 0.25
    # [-5, 5]の範囲で0.1刻みのxの2dリスト
    x_list = np.arange(MIN_X, MAX_X, 0.1, dtype=np.float32)
    x_list = np.array(np.meshgrid(x_list, x_list)).T.reshape(-1, 2)
    mean_list, variance_list = predict(x_list, y, x)
    gt_list = f(x_list)

    print(f"{x_list.shape=}")
    print(f"{mean_list.shape=}")
    print(f"{variance_list.shape=}")
    print(f"{gt_list.shape=}")

    # 3dプロット
    ax = fig.add_subplot(322, projection="3d")
    ax.scatter(x_list[:, 0], x_list[:, 1], mean_list, c=mean_list)
    ax.set_xlim(MIN_X, MAX_X)
    ax.set_ylim(MIN_X, MAX_X)
    ax.set_title("Predict mean")

    ax = fig.add_subplot(324, projection="3d")
    ax.scatter(
        x_list[:, 0],
        x_list[:, 1],
        mean_list + variance_list,
        c=mean_list + variance_list,
    )
    ax.set_xlim(MIN_X, MAX_X)
    ax.set_ylim(MIN_X, MAX_X)
    ax.set_title("Predict mean + var")

    plt.tight_layout()

    save_path = "./plot_2d.png"
    plt.savefig(save_path, bbox_inches="tight", pad_inches=0.05)
    print(f"Saved to {save_path}")
    plt.close()

 GTとして、y方向にだけ分散値が大きい正規分布のようなものを考えて、それに対して20点分評価できるサンプル点がもらえるので、そこから関数を回帰する。

 また、ベイズ最適化をちょっと意識して、予測した平均と分散を足したもの(簡易UCBみたいな気持ち)もプロットした。

 3Dでのプロットにしてしまったのでちょっと見づらいところもあるが、まぁだいたい近似はできていそうで、mean + varだと手薄になっている奥のところがちゃんと優先されそうというのも見えたのでだいたい満足。

 しかし、ベイズ最適化の文脈になると、ここから次に調べるべき点をUCBだったりEIだったりを基準にして決めていくわけだが、結局そういう代理関数の最適化パートが挟まることになり、これの極大点自体は解析的には求まらない(と理解している)ので、勾配法やサンプリングで見つけていかないといけない。そこでまた計算コストがかかってしまうので、やっぱり大元の対象がとても高コストのときにこそ有効なのかとは感じた。

 簡単な適用はできないかもしれないということがわかっただけでも収穫なので、ガウス過程は手段の一つとして取り出せる程度に頭の一部に置いておくこととして、いったん切り上げか。

Gaussian Splattingを試す

概要理解

 流石に中身を全く理解しないままに動かして結果だけ得るのもつまらないと思って、簡単なガウス球3つをレンダリングする部分だけ簡単に実装して原理を確認した。

実装と結果

サンプル実装

from dataclasses import dataclass
import torch
import roma


@dataclass
class GaussianPoint:
    position = torch.zeros((3))
    rotation = torch.tensor([0.0, 0.0, 0.0, 1.0])
    scale = torch.ones((3))
    color = torch.zeros((3))
    opacity = torch.tensor(0.5)

    # when projected to the screen
    i = torch.tensor(0.0)
    j = torch.tensor(0.0)
    z = torch.tensor(0.0)
    sigma_2d = torch.zeros((2, 2))


def render_image(T: torch.Tensor, # extrinsic translation (3,)
                 W: torch.Tensor, # extrinsic orientation (3, 3)
                 K: torch.Tensor, # intrinsic (3, 3)
                 height: int,
                 width: int,
                 gaussian_points: list[GaussianPoint]
                 ) -> torch.Tensor:
    for gaussian in gaussian_points:
        # convert to camera coordinate
        p_in_world = gaussian.position
        p_from_camera = W.T @ (p_in_world - T)
        p_in_screen = K @ p_from_camera
        i = p_in_screen[1] / p_in_screen[2]
        j = p_in_screen[0] / p_in_screen[2]
        if p_from_camera[2] <= 0:
            gaussian.z = float("inf")
            continue

        R = roma.unitquat_to_rotmat(gaussian.rotation)
        S = torch.diag(gaussian.scale)
        sigma_3d = R @ S @ S.T @ R.T

        # ref. EWA Volume Splatting p.6
        # https://www.cs.umd.edu/~zwicker/publications/EWAVolumeSplatting-VIS01.pdf
        # (with scale of K)
        l = torch.norm(p_from_camera)
        u0 = p_from_camera[0]
        u1 = p_from_camera[1]
        u2 = p_from_camera[2]
        J = torch.zeros((3, 3))
        J[0, 0] = 1 / u2
        J[1, 1] = 1 / u2
        J[0, 2] = -u0 / (u2 ** 2)
        J[1, 2] = -u1 / (u2 ** 2)
        J[2, 0] = u0 / l
        J[2, 1] = u1 / l
        J[2, 2] = u2 / l

        sigma_2d = J @ W @ sigma_3d @ W.T @ J.T
        sigma_2d = sigma_2d[0:2, 0:2]
        sigma_2d = K[0:2, 0:2] @ sigma_2d

        # update
        gaussian.i = i
        gaussian.j = j
        gaussian.z = u2
        gaussian.sigma_2d = sigma_2d

    # sort by z
    gaussian_points = sorted(gaussian_points, key=lambda x: x.z)

    rem_alpha = torch.ones((height, width), dtype=torch.float32)
    pred_image = torch.zeros((height, width, 3), dtype=torch.float32)
    I, J = torch.meshgrid(torch.arange(
        height), torch.arange(width), indexing="ij")
    for gaussian in gaussian_points:
        if gaussian.z == float("inf"):
            continue
        i = gaussian.i
        j = gaussian.j
        u2 = gaussian.z
        sigma_2d = gaussian.sigma_2d

        # calc inv and det
        sigma_2d_inv = torch.inverse(sigma_2d)
        sigma_2d_det = torch.det(sigma_2d)

        # calc diff vec
        diff_i = I - i
        diff_j = J - j
        diff_vec = torch.stack([diff_j, diff_i], dim=2)  # [h, w, 2]
        diff_vec = diff_vec.unsqueeze(-2)  # [h, w, 2] -> [h, w, 1, 2]

        # calc 2d gauss
        # [h, w, 1, 2] @ [2, 2] @ [h, w, 2, 1] -> [h, w, 1, 1]
        exp = -0.5 * diff_vec @ sigma_2d_inv @ diff_vec.transpose(-1, -2)
        exp = exp.squeeze()  # [h, w, 1, 1] -> [h, w]
        factor = 1 / (2 * torch.pi * sigma_2d_det)
        gauss = factor * torch.exp(exp)

        # calc color
        curr_weight = rem_alpha * gaussian.opacity * gauss
        add_color = curr_weight.unsqueeze(-1) * gaussian.color * 50000
        pred_image += add_color
        rem_alpha *= 1.0 - curr_weight

    pred_image = torch.clamp(pred_image, 0, 1)
    pred_image *= 255
    pred_image = pred_image.to(torch.uint8)
    return pred_image

 簡単に3つのGaussianを浮かべて実行した。

  • 青:X方向(画像左右方向)だけ分散大
  • 緑:Y方向(画像上下方向)だけ分散大
  • 赤:Z方向(画像奥行き方向)だけ分散大

 赤いのが横に伸びて見えるのは、左前方にあるものを斜めから眺めるような形になっているからだと思うが、もしかしたらなにか間違えているかもしれない。自信はない。

知見

 射影変換が非線形なのでテイラー展開で近似するというのはなるほど納得だった。この実装で、大本のEWA Volume Splattingの論文だと、カメラ座標系から光線座標系への変換において、Z座標として座標までの距離としている(なので射影変換というか純粋に座標変換?)が、Gaussian Splattingのcuda_rasterizerの実装だとよくある射影変換を偏微分した行列になっており、それで良いらしい。自分の実装だとカメラの内部パラメータをいつかければ良いのかがわからなくて2x2行列にした後でかけてみていた(そうでないと2回かかってしまう気がした)。

 色の計算がよくわからず、そのままだとGaussianの値が小さすぎるので色が付かなかった。仕方なく50000などとんでもない係数をかけているが、これは妙だ。まだ間違っているところはあるのだろうが、あまりデバッグに時間かける気力が出なかったのでここで切り上げた。

疑問点

  • ガウス球なので、不透明度を1にしても、すでに中央は確率密度としては1にならない。なので、完全に不透明な赤い球を表現するのに、ガウス球が一個では足りないし、薄くなっていくことも考えるとなんだかとても不揃いなガウス球をたくさん浮かべないといけなさそう。多分実際は完全な球とかは少ないので問題ない? というか確率密度関数を使うので良いのだっけ? ピクセルが1x1の面積を持つので、確率密度が確率の積分値と一致するということ?

実装しなかった部分

 ガウス球のレンダリングというところまでは20年前にもあるような話ではあるので、Gaussian Splatting論文の貢献部分とは言い難いかもしれない。Gaussian Splatting論文だと、その他ガウス球の生成削除アルゴリズム部分と、レンダリングの高速化パートという概ね2つの貢献があると見なせると思う。

 特に高速化パートの実装が大変そうだ。必要な処理として

  1. 画像を小さなタイルに分割し、そのタイルごとにガウス球がその範囲に十分被っているかチェックする
  2. ガウス球がどのタイルに属するかという情報と奥行き情報を1つの変数にパッキングする
  3. パッキングした変数についてGPU基数ソートを行う
  4. ソートされた結果から、タイルごとに分割して前からαブレンディングをする

などがあり、さらに勾配を計算することなどを考えると実装がとんでもないことになりそう。論文だけ見せられて「再現実装をしなさい」と言われたら、まず半年専念することは覚悟して、それでも全然できないかもしれない。ところで公式実装はライセンスが商用利用不可なので、頑張る人は再現実装をするのだろうか……。

公式を動かす

 AWSIMで取得したデータで試した。PCDとして西新宿の点群マップデータがあるので、それを読み込んで初期値として利用するようにした。scene/dataset_readers.pyに追加で読み込むようなコードを書けばとりあえず動いた。

使用したコード片

def readAutowareSceneInfo(path):
    import pandas as pd
    from scipy.spatial.transform import Rotation
    import yaml
    from copy import deepcopy
    camera_info = yaml.load(open(os.path.join(path, "camera_info.yaml"), 'r'), Loader=yaml.FullLoader)
    width = camera_info["width"]
    height = camera_info["height"]
    K = np.array(camera_info["K"]).reshape(3, 3)
    fx = K[0, 0]
    fy = K[1, 1]
    FovX = 2 * np.arctan(width / (2 * fx))
    FovY = 2 * np.arctan(height / (2 * fy))
    df_cam_pose = pd.read_csv(os.path.join(path, "pose.tsv"), sep="\t")
    train_cam_infos = []
    T0 = None
    min_pos = np.min(df_cam_pose[["x", "y", "z"]].values, axis=0)
    max_pos = np.max(df_cam_pose[["x", "y", "z"]].values, axis=0)
    for i in range(0, len(df_cam_pose), 3):
        row = df_cam_pose.iloc[i]
        R = Rotation.from_quat([row.qx, row.qy, row.qz, row.qw]).as_matrix()
        T = np.array([row.x, row.y, row.z])
        if i == 0:
            T0 = deepcopy(T)
        T -= T0
        c2w = np.eye(4)
        c2w[0:3, 0:3] = R
        c2w[0:3, 3] = T
        w2c = np.linalg.inv(c2w)
        R = w2c[0:3, 0:3].T
        T = w2c[0:3, 3]
        image_path = os.path.join(path, "images", f"{i:08d}.png")
        image = Image.open(image_path)
        train_cam_infos.append(
            CameraInfo(uid=i,
                       R=R,
                       T=T,
                       FovY=FovY,
                       FovX=FovX,
                       image=image,
                       image_path=image_path,
                       image_name=f"{i:08d}",
                       width=width,
                       height=height,
                       )
        )
    test_cam_infos = train_cam_infos

    nerf_normalization = getNerfppNorm(train_cam_infos)

    ply_path = os.path.join(path, "points3D.ply")
    if not os.path.exists(ply_path):
        print("Converting point3d.bin to .ply, will happen only the first time you open the scene.")
        import open3d as o3d
        loaded_pcd = o3d.io.read_point_cloud(f"{path}/pointcloud_map.pcd")

        print(f"before downsample: {len(loaded_pcd.points)}")
        margin = 100  # [m]
        min_bound = min_pos - margin
        max_bound = max_pos + margin
        print(f"{min_bound=}, {max_bound=}")
        bbox = o3d.geometry.AxisAlignedBoundingBox(min_bound, max_bound)
        loaded_pcd = loaded_pcd.crop(bbox)
        print(f"after  downsample: {len(loaded_pcd.points)}")

        xyz = np.asarray(loaded_pcd.points)
        xyz -= T0
        num_pts = len(xyz)

        shs = np.random.random((num_pts, 3)) / 255.0
        storePly(ply_path, xyz, SH2RGB(shs) * 255)
    try:
        pcd = fetchPly(ply_path)
    except:
        print("Error reading ply file")
        exit(1)

    scene_info = SceneInfo(point_cloud=pcd,
                           train_cameras=train_cam_infos,
                           test_cameras=test_cam_infos,
                           nerf_normalization=nerf_normalization,
                           ply_path=ply_path)
    return scene_info

結果

 あまり良い結果にならなかった。特に直進している場合に、なにも存在していないはずの道路上に浮いているものが多い。学習時の表示ではPSNR25あたり出ているということだったが、途中の崩壊しているところではそこまであるとは思えない。序盤の静止中などはきれいなので、平均値としてはそこで底上げされてしまっているのかもしれない。

 流石に単眼カメラの、視野に重複が多くはないシーンでは難しかったか。6方向カメラとかがあればまだ違うかもしれないが、これ以上試すのは大変なので、とりあえずここで切り上げる。

Emergent Communication through Metropolis-Hastings Naming Game with Deep Generative Modelsを読んだメモ

 メトロポリスヘイスティングス名付けゲームが気になったので読んだ。

概要

 まずは図1を見て概要を把握する。

 二者間でだいたい同じものを見ている(共同注意)という前提のもと、明示的なフィードバックなしでサインの授受だけをして、メトロポリスヘイスティングス法に従って受け入れ/棄却を判断していくと、二者間で同じものに対しては同じサインが割り当てられていくというものだと理解した。人間の言語が社会的にちゃんと同じサインが同じものを指示することのモデルになっている。

実験と評価指標

 概要はわかったが、結局それはなにがどうできたら成功と見なせるのか。

 今回だとMNISTをラベルなしで教師なしクラスタリングするタスクを考える。10クラスという事前知識はあり、10個のサインが使える。共同で最適化した際に同じサインには同じクラスが対応するようなクラスタリングができていれば成功となる。(たとえば、個別でクラスタリングした場合、クラスタリング自体は上手くいくかもしれないが、サインとクラスの対応関係が揃うことは望めないのは直観的に理解できる)

 Adjusted Rand Index(ARI)という指標があるのでそれを用いる。

グラフィカルモデル

 手法に戻ってグラフィカルモデルを理解する。こういうグラフィカルモデルに慣れていないのでいろいろ混乱した。

  •  w _ dはlatent variableであるということがまず若干よくわからなかった。他者から見ると観測になりそうだが? でも内部的には確かに潜在変数なのかも
    • さらに実際の分布ではなくサンプリングされたものをやり取りすることになるわけなのでとても概念がややこしい
  •  \thetaから o _ dに矢印が伸びている意味がわからなかったが、VAEのデコーダのパラメータということらしい
  • 普通のVAEだと zとしては標準正規分布を考えるが、別にそうしないこともできるので、ここではGMMと w _ dから z _ dが導かれるというものを考えているらしい

アルゴリズム

 話し手側がサインを振り出して、受け手側がそれを採用するか、自分が思ったサインをまだ信じるかが確率的に決まる。この確率がメトロポリスヘイスティングス法の計算になっている。

 ( w _ d ^ {Sp}のサンプリングまでしてそれを引数として渡す方が、なんとなく依存関係がきれいなような? Communication過程と考えるとこのシグニチャが適切なのだろうか)


 各データについてMH-Communicationをして今回での暫定名付けを決めて、それで受け手側のパラメータを更新する。立場を逆転してMH-Commnicationをやって、元の話し手側のパラメータも更新する。なるほど明快。

コード

 さらに具体的に理解するにはコードを見た方が良い。main.py

 各iterationでのループが、

  1. それぞれのVAE学習
    • VAEのパラメータは毎回初期から学習開始されている
    • GMMのmuとvarがあるのでそれを事前分布として使うことになるのだと思う
  2. GMM更新パート
    1. VAEから得られる事後確率から各データのサインを振り出す
    2. それをMH-Communicationで受け入れ/棄却の判定を行う
    3. 得られたラベルでGMMのパラメータを推論する

となっていることがなんとなく理解できてくる。

疑問

 GMMは必須か?→ 「6. Conclusion and discussion」のところに書いてある通り、まぁ他の手法で置き換えることもたぶん可能ではありそう。ニューラルネットにしか馴染みのない人にとってはそこの部分が簡潔になるとわかりやすいなぁと感じる。

 最近の生成系の話題だとトークナイズとかいってVQ-VAE系を使うことが多いが、この手法のVAEをVQ-VAEにすることはできるか?→GMMと繋げるという事自体は難しくなってしまうかもしれないが、理屈として確率モデルとして成立している以上は同じことができそうな気がする。

AHC030復習

 やったこと

  • 解説ページにあるものを読んだ
  • 特にwriter解を参考にして(細かい高速化などは省いて)自分なりにRustからC++に書き直し、自分の本番時より良いスコアを得る提出を実装した

理解内容

 解説ページが充実しているのであえて繰り返すまでもないが、自分の理解をもう一度書いておくと

(1) 複数の占いが所与のとき、事前分布が一様であるという情報から、事後分布は尤度に比例する。尤度は問題で定義されている通りのものを、正規分布の累積分布関数を使って計算できる。ここまではよくあるベイズの定理使用法。

(1') ただ、ありえる配置を全て列挙するのは Mが大きい場合には難しいので、焼き鈍し法で有力な配置をサンプリングする。これはMCMC(の特にメトロポリス法)と近いものと見なせる。

 (AHCラジオでの言及としては、温度1にすれば遷移ルールが同じになり、MCMCだと出現頻度を分布として見たサンプリングとして使うが、今回尤度が求めるので尤度を正規化すれば良い)

(2) 次の占い範囲の決定は、「占い結果がyになる」という事象と「真の配置が x である」という事象の相互情報量を求めて、相互情報量/コストが高いものとしたい。厳密に求めるのは計算量的に難しいので各マスの相互情報量を計算して山登り法で範囲を作る。

 一般に、「複数の選択肢から一つ選んで行動が可能なときに、得られる情報量が一番大きい行動を選択したい」ということはそれなりにありそうで、条件によってどこまで使えるのかはわからないが、考える一つの方針にはなりそう。


 (1), (2)両方とも、今までは「上位陣はなにかよくわからないことをやっているなぁ」という感じだったのが一歩理屈が見えてきて取っ掛かりが掴めてきた、かもしれない。それを実際に使えるレベルとなるとまた遠いのだろうが。

実装面

 手法自体を大まかに理解しても、実装してみると躓くところがいくつかあった。しょうもないパートとしては

  • 積分布関数と確率密度関数を勘違いして意味不明な値が出る
  • 対数尤度から確率に直すときに、最大の対数尤度をベースラインとして引くことを忘れてnanが多発する
  • 何と何の相互情報量を求めるのか混乱する
  • 各クラスがどの情報を管理しているのかという関係がハチャメチャになる

などのことが起きる。今回はwriter解という指針があったので細かい実装のテクニックやクラスの切り方なども困ったらそこに立ち返ればよかったが、自分で考えて実装しているとまとめきるのは相当大変なことになりそう。

焼き鈍しパートがとても難しい

 最終的にはwriter解の遷移をそのまま真似することになったが、それでも精度が出きらなかった。seed 0019などでちゃんと収束していかない。また、処理の高速化もとても難しいのでかなり妥協して、制限時間を超えそうだったら諦めて[tex: N2]を全部掘るという処理にした。それでも間に合うケースでは自分の本番時よりとてもスコアが良いので総合的にも本番時超えには十分だった。

 操作回数が少なくなってきた場合に、できれば滑らかに1マス掘りへ移行させたかったのだが、焼き鈍しの最中で1マス掘りの確定情報と整合しない候補でトラップされて抜けられない、有効な遷移を作れないということになってそれも挫折した。

 何も考えずにやると遷移 O(1)で、評価関数 O(QK) Kはクエリの面積)になってしまうが、遷移 O(Q)、評価関数 O(Q)とした方が良いというのは当たり前なんだけどそう。

 遷移の種類として二つの油田をスワップするのはギリギリ思いつきたいとして、スワップするときにできるだけ被るマスが多くなるようにズラす量を事前計算しておいて適用するなどは厳しそう。

所感

 学びの多い回だった。余裕があればこの知識をもってAHC022とかに立ち戻ると良さそうだが、時間。