甲斐性なしのブログ

うどんくらいしか食べる気がしない

二乗誤差を評価値とする焼きなましの差分計算をEigenで行う(Atcoder Heuristic Contest 030)

はじめに

しばらくブログを書いてなかったのでAHC参加ネタを投稿。

先日Atcoder Heuristic Contest 030(AHC030)に参加し最終56位という結果だったが、提出した基本方針に二乗誤差を最小化する焼きなましが含まれていた。この方法では二乗誤差の計算を何度も行うことになるためその計算時間が肝になるのだが、線形代数ライブラリのEigenを使うと下手に差分計算するより愚直に全計算した方が速かったりする*1。とはいえ、少し工夫して差分計算にもEigenを活用すれば、状況によっては愚直計算よりも速くなる。

ということで、本記事では実際にAHC030で使ったEigenによる二乗誤差の差分計算の方法を説明する。このテクニックはAHC030に限らず二乗誤差を評価値として焼きなましする場合にも応用できる(かも?)。

AHC030の問題については以下を参照。 atcoder.jp

AHC030解法基本方針概要

解法基本方針についてはtwitterのリンクおよびそのスレッドを参照。

二乗誤差最小化によるポリオミノの配置最適化

まずは評価値の計算を行列演算の形で表す。占いをn回行ったとしてi回目の占いで得られる値をy_iy_ii番目の要素に持つn要素のベクトルを\mathbf{y}i行目にi回目の占いにおいてどのマスを選んだか?という情報を持つn \times N^2の行列を\mathbf{X}(各列がマスに対応し、選ばれたマスなら1, そうでなければ0)とする*2。そして、\mathbf{w}を現状態のポリオミノの配置において、各マス何個のポリオミノが重なっているかを表すN^2要素のベクトルとし、これが最適化対象の変数である。これらを使って最適化問題として定式化すると、

\displaystyle 
\begin{aligned}
&{\rm arg}\min_{\mathbf{p}} \parallel \mathbf{y} - \mathbf{X} \mathbf{w} \parallel_2^2 \\
&{\rm s.t. \ \ }   \mathbf{w}はポリオミノの配置の組み合わせであり得る値
\end{aligned}
\tag{1}

となる。これを1つのポリオミノを選択して置く場所を変える焼きなましによって最適化を図る。そのため二乗誤差\parallel \mathbf{y} - \mathbf{X} \mathbf{w} \parallel_2^2の計算を高速化できれば短い時間で焼きなましのループ回数が稼げ、より正解を出せる確率が上がる。

二乗誤差計算の高速化

Eigenの活用

C++においては行列計算を高速に実行できるライブラリEigenを活用すれば、愚直計算でもかなり速く計算できる。コードもこんな感じで1行で書ける。

double cost = (y - X * w).squaredNorm();

EigenではSIMD命令の活用、ループ変形等によりメモリアクセスの効率化などが行われているため演算回数だけ見れば遅くなりそうな処理も高速にできる場合がある。逆に差分計算はメモリアクセスが飛び飛びになりがちで、SIMD化もしにくいため処理時間上不利になることがある。

二乗誤差差分計算の行列表現

差分計算を行列演算で行うことを考える。ポリオミノの配置変更による\mathbf{w}の変化を\Delta \mathbf{w}とすると、二乗誤差の変化量Dは以下のようになる。

\displaystyle
\begin{align}
D &= \parallel \mathbf{y} - \mathbf{X} \mathbf{w} \parallel_2^2 - \parallel \mathbf{y} - \mathbf{X} \left(\mathbf{w} + \Delta \mathbf{w} \right) \parallel_2^2 \\
&=2  \left(\mathbf{y}^T \mathbf{X} \Delta \mathbf{w} - \mathbf{w}^T \mathbf{X}^T \mathbf{X} \Delta \mathbf{w} \right) - \Delta \mathbf{w}^T \mathbf{X}^T \mathbf{X} \Delta \mathbf{w}
\end{align}
\tag{2}

この式の中で \mathbf{X}^T \mathbf{X} は焼きなましの中で変化しないので、事前に計算しておくことができる。 加えて、焼きなましの1ループでは1つのポリオミノしか動かさないので、\mathbf{w}の中で変化する要素はごく一部、つまり\Delta \mathbf{w}の大半の要素は0になる。このような多くの要素が0のベクトルを扱うためEigenにはSparseVectorが用意されている。\Delta \mathbf{w}を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]内に何回回るかをnを変えて比較する。その際のテストデータはseed=1とする(N = 13, M = 6, \epsilon = 0.04)。結果は以下の通り。

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

このように\mathbf{X}の行数(=占い回数)nが小さい間は愚直に全計算した方が回数を稼げるが、n \geq 100あたりで逆転し差分計算した方が速い*3。従って、実際提出したコードでは占いした回数nに応じて愚直全計算と差分計算を切り替える処理とした。

ちなみに

今回の手法を使ったseed=0のビジュアライザは以下の通り。

*1:焼きなましでは1回の更新の中で状態が一部しか変化しないことがしばしばある。そのため基本的にはその差分のみを使って評価値を計算できれば評価値計算の時間を短縮につながる、ヒューリスティックコンテストにおいてはこの差分計算は典型的なテクニックの1つとなっている。

*2:実際の提出したコードでは、問題文にある占いで得られる平均値\muの式に基づき\mathbf{v}\mathbf{X}を補正している。

*3:愚直全計算で顕著だがnが8の倍数の時にLoop回数が盛り返す。これはおそらくEigenの内部でAVX512命令を使っているのが関係していると思われる。