はじめに
しばらくブログを書いてなかったのでAHC参加ネタを投稿。
先日Atcoder Heuristic Contest 030(AHC030)に参加し最終56位という結果だったが、提出した基本方針に二乗誤差を最小化する焼きなましが含まれていた。この方法では二乗誤差の計算を何度も行うことになるためその計算時間が肝になるのだが、線形代数ライブラリのEigenを使うと下手に差分計算するより愚直に全計算した方が速かったりする*1。とはいえ、少し工夫して差分計算にもEigenを活用すれば、状況によっては愚直計算よりも速くなる。
ということで、本記事では実際にAHC030で使ったEigenによる二乗誤差の差分計算の方法を説明する。このテクニックはAHC030に限らず二乗誤差を評価値として焼きなましする場合にも応用できる(かも?)。
AHC030の問題については以下を参照。 atcoder.jp
AHC030解法基本方針概要
解法基本方針についてはtwitterのリンクおよびそのスレッドを参照。
#AHC030 お疲れさまでした。暫定47位でした。
— 甲斐性なし (@YamagenSakam) 2024年2月19日
- ランダムにn回占った後、焼きなましでポリオミノの配置による各マスの合計値と占いで得た合計値の2乗誤差が最小になるポリオミノの配置を求めて出力
- 駄目だったらn'回更に占う
を正解するまで繰り返しました(epsとMが小さければ占いのみ行う)
二乗誤差最小化によるポリオミノの配置最適化
まずは評価値の計算を行列演算の形で表す。占いを回行ったとして回目の占いで得られる値を、を番目の要素に持つ要素のベクトルを、行目に回目の占いにおいてどのマスを選んだか?という情報を持つの行列を(各列がマスに対応し、選ばれたマスなら, そうでなければ)とする*2。そして、を現状態のポリオミノの配置において、各マス何個のポリオミノが重なっているかを表す要素のベクトルとし、これが最適化対象の変数である。これらを使って最適化問題として定式化すると、
となる。これを1つのポリオミノを選択して置く場所を変える焼きなましによって最適化を図る。そのため二乗誤差の計算を高速化できれば短い時間で焼きなましのループ回数が稼げ、より正解を出せる確率が上がる。
二乗誤差計算の高速化
Eigenの活用
C++においては行列計算を高速に実行できるライブラリEigenを活用すれば、愚直計算でもかなり速く計算できる。コードもこんな感じで1行で書ける。
double cost = (y - X * w).squaredNorm();
EigenではSIMD命令の活用、ループ変形等によりメモリアクセスの効率化などが行われているため演算回数だけ見れば遅くなりそうな処理も高速にできる場合がある。逆に差分計算はメモリアクセスが飛び飛びになりがちで、SIMD化もしにくいため処理時間上不利になることがある。
二乗誤差差分計算の行列表現
差分計算を行列演算で行うことを考える。ポリオミノの配置変更によるの変化をとすると、二乗誤差の変化量は以下のようになる。
この式の中では焼きなましの中で変化しないので、事前に計算しておくことができる。 加えて、焼きなましの1ループでは1つのポリオミノしか動かさないので、の中で変化する要素はごく一部、つまりの大半の要素はになる。このような多くの要素がのベクトルを扱うためEigenにはSparseVectorが用意されている。をSparseVector型として扱うことで、式(2)の計算がだいぶ速くなる。コードはこんな感じ。
Eigen::SparseVector<double> sdw(N * N); vector<Eigen::Triplet<double>> trivec; rep(i, N * N){ if(abs(dw(i)) > 1e-5){ sdw.insert(i) = dw(i); } } VectorXd XXdw = XX * sdw; VectorXd Xdw = X * sdw; double D = 2.0 * (y.dot(Xdw) - old_w.dot(XXdw)) - dw.dot(XXdw);
比較
上記2方法で評価値計算をする焼きなましがatcoderのジャッジサーバー上で400[ms]内に何回回るかをを変えて比較する。その際のテストデータはseed=1とする()。結果は以下の通り。
n | 全計算 | 差分計算 |
---|---|---|
20 | 814301 | 342042 |
40 | 598892 | 338786 |
60 | 312957 | 320429 |
80 | 311225 | 319219 |
100 | 175905 | 303552 |
120 | 226957 | 297810 |
140 | 143947 | 275129 |
160 | 176061 | 250388 |
180 | 115353 | 249101 |
200 | 146547 | 229857 |
このようにの行数(=占い回数)が小さい間は愚直に全計算した方が回数を稼げるが、あたりで逆転し差分計算した方が速い*3。従って、実際提出したコードでは占いした回数に応じて愚直全計算と差分計算を切り替える処理とした。
ちなみに
今回の手法を使ったseed=0のビジュアライザは以下の通り。