simanのブログ

ゆるふわプログラマー。競技プログラミングやってます。Ruby好き

Marathon Match 147 SurfaceReconstruction 参加記

問題

www.topcoder.com

提出コード

https://gist.github.com/siman-man/2218aee9a4a99e3bc6c780dbb72066ba

方針

  • N と A の値からサンプリングの回数を決定
  • クエリーの投げる位置を焼きなましで調整
  • クエリーの結果からグリッドを生成している方程式のパラメータを焼きなましで求める

N と A の値からサンプリング回数を求める

サンプルの個数から大体これぐらいの mse スコアが出るよというのを事前に計算しておいて、ちょうどいい感じのサンプル個数を求める。 mse の推測値が悪かったのか N < 40 のケースだとうまくいかなかったので場合分け。もっと細かく調整できたと思うけど多分そんなスコア改善しないし面倒なのでやってない。(cover_rate は sample_count * 9 / (N * N) で計算してる)

      if (N < 40) {
        if (cur_cost > 4000 || cover_rate > 0.13) break;
      }
      if (N >= 40) {
        if (g_exp_mse[N - 20][sample_count] < g_exp_mse[N - 20][sample_count + 1] + sample_cost) break;
      }

mse の推測値

pythoncurve_fit を使って自分のソルバーがサンプルを取得した数に対してどれぐらいスコアが出るのかを予測する。スコアのグラフが指数関数に似てたのでそれで近似を行う。与えるデータは自分のローカルで何個か計算しておく。

# 指数関数を定義します
def exponential_function(x, a, b, c):
    return a * np.exp(-b * x) + c

def fit_exponential_function(x_data, y_data):
    # パラメータの初期値を提供します
    initial_guess = initial_guess_from_data(x_data, y_data)
    popt, _ = curve_fit(exponential_function, x_data, y_data, p0=initial_guess)
    return popt

def calculate_score(sample_count, a, b, c):
    return a * np.exp(-b * sample_count) + c

def initial_guess_from_data(x_data, y_data):
    # a, b, c の初期値を直接データから求める
    # a の初期値は y_data の最大値、b の初期値は 0.01 (小さな正の値)、c の初期値は y_data の最小値
    a = max(y_data)
    b = 0.01
    c = min(y_data)
    return a, b, c

こんな感じでデータを取って

sample_count,score
5,21483.71434
7,13698.45827
10,8337.7041
15,5314.86126
20,3673.76252
25,2867.07684
40,1446.79279
60,1007.06145
80,664.26452

こんな感じの結果が得られる

sample_count,predicted_score
1,43128
2,35842
3,29836
4,24884
5,20802
6,17437
7,14663
8,12375
9,10490
10,8936
11,7654
12,6598
13,5727
14,5009
15,4417
16,3929
17,3527
18,3195
19,2922
20,2697
.
.
.

この結果をコードに埋め込んで利用する。N ごとにデータを取ろうとしたけど途中で面倒になったので 5個飛ばしでデータを取った。(なので N = 59 の場合は N = 60 の結果を利用してたりする)

クエリーの投げる位置を焼きなましで調整

クエリーを投げる位置について焼きなましで調整した。

近傍は「ランダムにクエリの位置を変える」の 1つで、スコアは「各セルごとの一番近いクエリの箇所の距離の合計値」を計算してこれが最小になるようにした。

計算自体は BFS で行ってて最初の step は 8方向、あとは 4方向に移動

方程式のパラメータを焼きなましで求める

方程式の数は 50 で固定。遷移としては 3つ

  • ランダムに方程式を生成する (どの確率で生成するかはテスターの実装と一緒)
  • offx, offy を少し変更
  • rx, ry を少し変更

これらの変更のあとに得られたグリッドの値をクエリーによって得られた正しい値を比較してその差分が小さくなるように調整していく。50 で固定しているせいで、E が小さいケースにめちゃめちゃ弱いけど自分では E を推定することができなかったので仕方ない。

感想

推定系は毎回苦手。 rhoo さんの解法 を見て「へぇ〜 UNet なるものがあるのか」と思った。大きな差で負けてるので何か見落としてるだろうなと思ってたけどそもそも自分の知識にないものだったので次頑張ろうという気持ちになった。あと、自分だけみたいだったけど artifacts がダウンロードできなくてサーバー側でどれぐらいの速度で動いているかが確認できなくて地味に不便だった。途中ジャッジサーバが止まってたけど停電で落ちてたっぽい。

seed 1 - 10 のスコア

Score = 23559.635786576706
Score = 1726.0470920138887
Score = 7705.3169154104025
Score = 7504.327035433278
Score = 8597.371075943804
Score = 2932.7803554454154
Score = 8497.537837936074
Score = 13487.69213050256
Score = 3508.515444010831
Score = 5008.201252074448

#15 atmaCup 参加記

問題

www.guruguru.science

結果は 57位

方針

LightGBM 1本で戦いました。

  • test データに存在するユーザに対して train データに含まれている場合と含まれていない場合で 2つモデルを用意して予測
  • 考えた特徴量をすべて突っ込んで LightGBM さんに頑張ってもらう

コード

既存ユーザ用

https://github.com/siman-man/atma_cup_15/tree/model-for-seen

新規ユーザ用

https://github.com/siman-man/atma_cup_15/tree/model-for-unseen

特徴量

アニメ

  • 数値データのものはそのまま採用

  • type, rating, source, rating は label encoding

rating(before) rating(after)
PG-13 - Teens 13 or older 1
PG-13 - Teens 13 or older 1
PG-13 - Teens 13 or older 1
R+ - Mild Nudity 2
PG-13 - Teens 13 or older 1
PG-13 - Teens 13 or older 1
R - 17+ (violence & profanity) 3
  • genre は one-hot encoding
Comedy Sci-Fi Seinen Slice of Life Space Adventure Mystery Historical Supernatural Fantasy
1 1 1 1 1 0 0 0 0 0
0 0 1 1 0 1 1 1 1 1
  • aired は存在する数値の中で一番大きいものを採用
aired (before) aired (after)
Apr 1, 2012 to Mar 22, 2014 2014
Oct 23, 2005 to Jun 19, 2006 2006
Apr 5, 2014 to Jun 21, 2014 2014
Apr 5, 2014 to Jun 21, 2014 2014
Apr 2, 1995 to Sep 24, 1995 1995
  • producers, studios は区切りの中で一番先頭のものを採用。変換後のカラムに対して label encoding
producers (before) producers (after)
Aniplex, Dentsu, YTV, Trinity Sound Aniplex
Avex Entertainment, Marvelous, SKY Perfect Well Think, Delfi Sound Avex Entertainment
Aniplex, Kodansha, Delfi Sound Aniplex
Media Factory, AT-X, Sony Music Communications, Tsukuru no Mori Media Factory
TV Tokyo, Pioneer LDC TV Tokyo

one-hot encoding も試してみたが次元数が増えた割にそんなにスコアが増えたわけでは無かったので結局不採用。(モデルの学習時間が速かったら検証してこっちで採用したかもしれない)

  • duration も数値に変換してそれを使用
duration (before) duration (after)
24 min. per ep. 24
24 min. per ep. 24
24 min. per ep. 24
1 hr. 45 min. 105

watching,completed,on_hold,dropped,plan_to_watch を他の値で割ったもの

LightGBM はカラムごとの演算を手助けする必要があるそうなので、とりあえず効果がありそうなものを全部入れる。

    'watch_rate_by_members',        # 視聴率(watching / members)
    'watch_rate_by_completed',      # 視聴率(watching / completed)
    'watch_rate_by_on_hold',        # 視聴率(watching / on-hold)
    'watch_rate_by_dropped',        # 視聴率(watching / dropped)
    'watch_rate_by_plan_to_watch',  # 視聴率(watching / plan to watch)
    'comp_rate_by_members',         # 完了率(completed / members)
    'comp_rate_by_watching',        # 完了率(completed / watching)
    'comp_rate_by_on_hold',         # 完了率(completed / on-hold)
    'comp_rate_by_dropped',         # 完了率(completed / dropped)
    'comp_rate_by_plan_to_watch',   # 完了率(completed / plan to watch)
    'drop_rate_by_members',         # 中断率(dropped / members)
    'drop_rate_by_watching',        # 中断率(dropped / watching)
    'drop_rate_by_on_hold',         # 中断率(dropped / on-hold)
    'drop_rate_by_completed',       # 中断率(dropped / completed)
    'drop_rate_by_plan_to_watch',   # 中断率(dropped / plan to watch)
    'hold_rate_by_members',         # 保留率(on-hold / members)
    'hold_rate_by_watching',        # 保留率(on-hold / watching)
    'hold_rate_by_dropped',         # 保留率(on-hold / dropped)
    'hold_rate_by_completed',       # 保留率(on-hold / completed)
    'hold_rate_by_plan_to_watch'    # 保留率(on-hold / plan to watch)

レビューの統計データ

  • anime_1_review_count, anime_2_review_count のようにこのアニメがどのような評価をされたのかを集計
    • レビューの平均値を出すとそれに偏った値が出たので不採用
anime_6_review_count anime_7_review_count anime_8_review_count
0 2 7
5 7 31
2 5 13

アニメの名前を一部抜き出して「シリーズ名」として追加

前に amakanize というライブラリがあったなーと思い、japanese_name を split で分割したものの最初の値を入れて出てきた値を series_name として扱う。最初の名前を使ったら 劇場版 がノイズすぎたので最初に削除しておく。

irb(main):010:0> Amakanize::SeriesName.new('とある魔術の禁書目録').to_s
=> "とある魔術の禁書目録"
irb(main):011:0> Amakanize::SeriesName.new('とある魔術の禁書目録Ⅱ').to_s
=> "とある魔術の禁書目録"
irb(main):012:0> Amakanize::SeriesName.new('とある魔術の禁書目録Ⅲ').to_s
=> "とある魔術の禁書目録"

python ならなんかもっといい方法ある気もするけど手持ちの知識で戦うとこんな感じになった。

ざっとしか見てないけど大体うまくいってそうなのでそのまま採用。細かく見ていったらおかしなところもありそう。

split して先頭の値の採用なので仕方ないけど「それでいいのか」みたいな気持ちになった。

ユーザー

アニメ視聴の統計データ

train データからどの程度アニメを見ているかをチェック。train データに含まれていないユーザ対しては使用していない(使用したらスコアが下がったので)

  • レビュー数
  • どのジャンルを見ているかの割合
comedy_rate dementia_rate demons_rate
0.4634146341463415 0.04878048780487805 0.14634146341463414
0.55625 0.0125 0.03125
0.5142857142857142 0.0 0.14285714285714285
  • ジャンルごとの平均スコア (母数が 5未満なら欠損値扱い)
comedy_score dementia_score demons_score
7.894736842105263 6.166666666666667
7.50561797752809 7.4
8.666666666666666 8.6
user_8_review_count user_9_review_count user_10_review_count
10 7 4
66 25 4
7 6 9
  • anime_id にスコアを紐づけたもの(数が 2000 ぐらいあって激重)

重いけどこれでスコアが上がったので採用

047b47eda9d02f50dd75_score 04a3d0b122b24965e909_score 04fddcb5918f66c618df_score
6
10
9

他にも色々あった気がするけど大体こんな感じかも。

fold の切り方

既存ユーザ用

fold = StratifiedKFold(n_splits = 5, shuffle = True, random_state = 510)
cv = fold.split(X, y)

新規ユーザ用

fold = GroupKFold(n_splits = 5)
cv = fold.split(X, y, groups = train_df["user_id"])

イメージとしては既存ユーザはランダムで、新規ユーザ用は学習データの中に検証データのユーザが入らないようにすることを意識。 明らかに CV の値が LB スコアより良かったので「絶対 leak してるな」と思いつつ 相関はあったので気にしないことにした。

CV
seen: 0.9297
unseen: 1.3881

Private Score: 1.1706

seed値を変えながら生成したモデルの予測値の平均を採用

seed値を変えて生成したモデルを複数生成してそれらの予測値の平均を取ったもとのを最終予測値として採用

感想

kaggle のコンテスト情報が定期的に流れてきては「開催期間 数ヶ月もあるのか。。。やめとこ」みたいな感じでずっとスルーしてたので、1週間という比較的短いコンテストだったので参加してみました(1週間を短期間というのもスゴイなと思いつつ)

データコンペ初参加の感想としてはまず、ディスカッションを眺めておくことで知識がなくてもある程度スコアが取れるコードが書けるというのが助かりました。

自分のコードは基本的に

#1 初心者向け講座 データと課題を理解してSubmitする!

#2 初心者向け講座 モデルを改善する

に書かれているコードをそのまま持ってきて書き換えただけなので自分で 1から書いたのはデータの前処理部分と特徴量ぐらいじゃないかな。

pandas の操作が全然分からなかったので、Rubycsv 読み込み -> 特徴量を追加した csv 生成 -> python で読み込みという処理を行っていた。全部 python 側で処理したほうが将来的な効率は良くなるだろうなと思ったけど結局慣れている言語で処理した。今後は python 縛りで参加するとかしないと成長できなさそう。

わからないところは全部 ChatGPT さんに聞いた。なんか賢いので ChatGPT がコンテストに出たほうが良いスコア取れそうだなと感じた。

コンテスト後半、モデルの学習時間が 1h を超えたりして正直もうちょっと強いマシンが欲しいなと思った。ローカル側で計算するのでマシンリソースはあるだけ得になりそう。

コンテストドリブンで学習するのが一番モチベが保てるので今後も似たようなコンテストがあったら参加してみたいと思います。(あとは過去問を見て学習)

ヒューリスティックコンテストとの違い

一番の違いを感じたのは CV (Cross-Validation) スコアと LB (Leader Board) スコアを一致させる難易度。ヒューリスティックコンテストではローカルで常に真スコアが得られるので、ローカルで検証したスコアは submit しても大体同じ点数を取れる。(なので提出回数は最低 1回でもいいぐらい) しかし、今回のコンテストでは真スコアはサーバー側にしか存在しないので CV スコアが改善していざ submit してもスコアが大幅に悪化するなんてことがザラにあった。この場合は学習時に leak (検証データの値が学習データに漏れている状態) しているとか fold の切り方が悪いとか色々あるらしいのだが、このあたりの知見が無さすぎてかなり苦労した。(結局最後まで苦労した)

ヒューリスティックコンテストでは基本的にコンテスト期間中の解法の公開については厳禁なので、それとは違って Discussion が活発なのは初参加者としては助かった。とにかく引き出しの少なさが致命的なのでこの部分を補える仕組みがあるのは良い。ただ、常に監視していないと上位の方針が公開されたときについていけないのでそれはそれで大変だなと感じた。(コンテストによっては公開されている手法 + α でそれなりの順位とか取れるコンテストとか存在してそう)

今回のコンテストがそうなのかわからないが基本的に点差がものすごい僅差なので、private スコア (system test) による順位の変動がかなり大きいなと感じた。あと、テストデータの数も大きいなと思った。普段のヒューリスティックコンテストなら大体 2-3000 件ぐらいだけどテストデータが 10万件超えてたので。(多くても 5000件ぐらい)

すべての提出に対して private score が計算されてその結果が確認出来るので「あれを提出していたらどうなっていたかなー」みたいな事が無いのは良かった。代わりに「これを提出しておけば。。。」みたいな事がありそうだけど。 全部計算しているならその中で一番良いものを選んであげても良いのではと思ったが、ベストなものを選ぶのも実力のうちの一つなのかもしれない。

ICFP Programming Contest 2023 参加記

問題

https://www.icfpcontest.com/specification_v3.pdf

M人のミュージシャンと A人の観客がいて、観客の満足度を最大化する問題でした。

満足度はミュージシャンと観客の距離とミュージシャンが演奏する楽器の種類によって決定されます。また、ミュージシャンと観客の間に別のミュージシャンが別のミュージシャンが一定範囲内に存在する場合は「他のミュージシャンの演奏によって音をブロックされた」と判断されスコアの計算から除外されます。

あとは、同じ楽器を演奏するミュージシャンの距離によってボーナスポイントが増えたりとか、演奏のボリュームによってスコアが増減したりとかありますがそのあたりは元の問題を参照してください。

方針

ミュージシャンの位置を変化させる焼きなまし、近傍としては次の 3つです

  • ミュージシャンをステージのランダムな位置に移動させる
  • ミュージシャンを現在の位置から少しだけ移動させる
  • ミュージシャン 2人を選び位置を交換する

変更後はスコアの差分計算を行いますが、自分の計算量は O(MA) だったのに対して上位の人達を見る感じあともっとオーダーを減らせたみたいです。(多分千倍ぐらい違うのかな)

順位としてはフリーズ時の時点で 14位でした。

感想

初めて参加してみたのですが、思ったより普通のヒューリスティックの問題が出たなという印象でした。(毎年 twitter のタイムラインに流れてくる情報だけだと変わった問題が多い印象だったので)

焼きなましの問題だとまずスコアの差分計算を高速化しないことには勝負の土俵にも立てないので全然ダメでしたね。(マシンパワーを持ってしても数百倍近くを覆すのはまぁ無理)

せっかくチームOK なのでチームで出ても良かったなと思いました。(ただ、自分の熱量として「うおおお絶対勝つぞ!!!」みたいな感じでもないのでメンバーに迷惑をかけそうという不安はある)

雑なまとめ

序盤、API 経由でサブミットしたときにずっと「Processing」状態で何が悪いのかわからなかったのがかなり面倒だった(結局は提出時の JSON の形式が問題だった)

絶対スコアが順位が決まるけど、問題ごとの点数のブレが大きくて (100倍ぐらい違う)寄与率が高いケースに強い人がそのまま勝ちそうだなという印象。

discord にアナウンスが流れてきてるけど英語読むの面倒だな。

3日目、キーボードを触り続けてたせいか小指を痛めた。

コンテスト開始から 24h ごとに追加の仕様が発表されるけど最初から全部表示じゃダメなんかな。この仕様だと最終日にちゃんと参加出来るかどうかが順位にかなり影響しそう。

1個目の追加はまぁまぁ影響あったけど 2個目の追加はよくわからなかった(スコアが定数倍伸びただけでは)

無制限に実行出来たけど、ずっとマシンリソース取られるのも嫌だったので最長でも 20分とかしか回していない(一応前回の実行結果を次の初期値にしてたので実質 20分以上は回してはいた)

Marathon Match 146 HappyGrid 参加記

問題

www.topcoder.com

最終提出コード

https://gist.github.com/siman-man/f8e9022891d996d36d5f505f67ff929d

方針

「盤面の作成」「ボールの移動」の 2つのフェーズがあります。

  • 焼きなましによる盤面の作成
  • 最小全域木を作成し葉の頂点を埋めるようにボールを移動させる

盤面の作成 (start ~ 9.0 sec)

まず最初に高得点が得られるような盤面の作成を焼きなまし法を用いて行います。遷移としては最初にランダムなボールを 1つ選択し、そのボールの元の位置からマンハッタン距離が 4以内の座標をランダムに選択してその位置と入れ替えるという処理を行っています。

最初はランダムな 2点を選んでスワップを行っていましたが、完成後の盤面を眺めているとほとんど元の位置から動いていないので選択する座標を制限するようにしました。(オレンジで色塗られている部分がスワップ対象の範囲になります)

スワップ後は盤面のスコア計算を行います。スコアは「連結サイズ」「ボールの元の位置からの距離」の 2つで構成されています。

連結サイズスコア

ボールの連結サイズによってスコアを決定します。スコア関数は下記のようにしました。

  int calc_size_score(int k) {
    if (k == K) {
      return P;
    } else if (abs(K - k) <= 1) {
      return P / 8;
    } else {
      return -1 * abs(K - k);
    }
  }

距離スコア

ボールが元の位置からどの程度離れているかによって決定されるスコアです。距離が 1離れるごとに 2点減点を行っていきます。

g_distance_penalty[cz][z] = 2 * dist;

盤面作成パートの制限時間は 9.0sec に設定していて試行回数は大体 2000万回程度となっています。ここで求めた盤面を元にボール移動フェーズへと進みます。

ボールの移動 (9.0 ~ end)

ボールの移動は以下の処理を繰り返して行います。

  • 移動先の対象となるセルを選択
  • 選択したセルに必要な色のボールまでの経路を探索
  • ボールを探索した経路に沿って移動させる

セルの選択

まず盤面に対して最小全域木を構築し、葉に該当する部分のセルを選択します。対象が複数存在する場合は盤面の中心からマンハッタン距離が一番遠いものを選択します。(オレンジ色の部分が対象となるセルの一覧になります)

必要な色のボールまでの経路を探索

選択したセルからスタートしてそのセルに必要な色のボールまでの経路を探索します。経路探索にはダイクストラ法を用いています。経路としてはなるべく全体の移動回数が少なくなるような経路を選択するようにしています。大まかな条件としては以下のようになっています。

  • 既に移動済みのボールの上は通らない(壁として扱う)
  • 移動時のスワップを行った際に既に完成後の盤面の色の配置と異なる色のボールが来る場合は減点
    • 同じ色のボールが来る場合は加点

経路を発見したらそれに沿ってボールの移動を行います。移動後はその位置を固定化して木の更新を行います。

これをすべてのボールの配置が完了するまで繰り返します。

時間制限一杯繰り返す

最小全域木を作る際には辺のコストをランダムに設定して毎回違う形の最小木が出来るようにして、この「木を作る」「ボールを配置する」の処理を時間制限一杯まで繰り返し行い目的の盤面を作成するまでの手数が一番少ないものを採用します。

感想

上位との点差がかなり僅差なので細かいところを詰めてるか詰めてないかで差が付きそう。最終日ほとんどパラメータ調整しかしてないので戦うための手札が少ないなと感じた。(毎回これ言ってる気がするな。。。)

あと、最後の移動を構築する部分は時間があればビームサーチを使用してもっと最適なものを選べた気がするけど時間が無いので諦めた。(期間的にも制限時間的にも)

ALGO ARTIS プログラミングコンテスト2023 参加記

問題

atcoder.jp

方針

  • 放送局の電波の強さを焼きなましで求める
  • 使用する放送局を頂点とした最小全域木を求める

電波の強さを焼きなましで求める

各放送局の電波の強さを焼きなましで求めました。遷移としてはランダムな放送局を 1つ選んで電波の強さを変化させます。出力強度 は 0 から 5000 までの値をとれますが無駄な変化を避けるためにあらかじめ「このぐらいの強さならこの領域をカバーできる」という情報を計算しておいて、カバー領域が変わらない部分についてはまとめるようにしています。

この計算をしておくことで、電波の強さを変化させたときに「カバー領域が変化しない」ということを避けることが出来ます(この場合は変化のパターンは 0, 250, 450, 700 の 4パターンに限定する)。また、50% の確率でその放送局の出力強度を 0 にします。まとめると以下の通りになります。

  • 50% の確率で出力強度を 0 に変化させる
  • それ以外はランダムに出力強度を変化

変化させるなかでスコアが一番高くなった状態を採用します。

最小全域木の計算

使用する放送局が求まったらそれらを頂点とした最小全域木を作成します。使用する放送局間が直接繋がっていない場合はダイクストラで求めた経路で使用する辺をまとめて 1つの辺として扱います。

2 つの基地局を接続する際には辺をまとめて採用する。

2 つ基地局を接続したら再度辺のコストの計算を行います(すでに採用した辺のコストを 0 とする)。毎回やると計算量が大変なことになりそうですが軽く調べた感じ放送局の平均使用数が大体 10個前後ぐらいなのであまり問題にはなりませんでした。

複数回実行

先程までの処理を 1ループとして時間制限が来るまで複数回行いました。正直時間が足りなくて悪あがき的な実装なのであまりよろしくないです。

感想

ビジュアライザ見た感じ電波の強さの調整とか木の構築とかいくらでも改善点ありそうな感じだったけど時間足りなくてパラメータガチャに走ったのは残念。短期コンだと事前準備しておかないと「調査して」「実装探して」「試してみる」みたいな時間が取りにくいから、自分みたいな実装が遅い人だと持ってるライブラリがあるかないかで大分変わってしまう。

最後に宣伝。今週から Marathon Match 146 が始まります。

CodinGame のローカル対戦ツール psyleague を試してみた

CodinGame のコンテストでは終盤になるとサブミットしたボットがすべての対戦を終了するのに数時間かかることも珍しくないので、ローカル環境で開発 & 検証を出来ることが重要になっています。

Spring Challenge 2023 では cg-brutaltester を利用してローカル対戦を行っていたのですが

github.com

psyleague も同じことが出来るみたいなので試してみました。

github.com

対戦環境の構築

ローカルの対戦環境については Jiro さんのブログを参考にしました。

shuu0914.hatenablog.com

以降は docker 上での環境が整った状態で話をします。

psyleague のインストール

README に従って pip コマンドで psyleague をインストールします。

pip install psyleague --upgrade

対戦スクリプトの作成

Create a script that given two bots, simulates the game and prints out 4 integers on a single line: "P1_rank P2_rank P1_error P2_error". The first two are ranks of the bots, i.e. if first player wins print "0 1", if the second player wins print "1 0", in case of a draw print "0 0". The last two signify that bot crashed during the game: 0 = no error, 1 = error.

ボットの名前を 2つ受け取って対戦を行いその結果を返すスクリプトを用意します。 ファイル名は play_game.py にしておきます。

import sys
import subprocess

args = sys.argv
bot1 = args[1]
bot2 = args[2]

result = subprocess.run(f'java -jar spring-2023-ants-1.0-SNAPSHOT.jar -p1 {bot1} -p2 {bot2}', shell=True, capture_output=True)
output = result.stdout.split()
score1 = int(output[0])
score2 = int(output[1])
rank1 = 0 if score1 > score2 else 1
rank2 = 0 if score1 < score2 else 1
error1 = 0 if score1 != -1 else 1
error2 = 0 if score2 != -1 else 1

print(f'{rank1} {rank2} {error1} {error2}')

今回のコンテストだとスコアが高ければ勝利、また -1 だとエラー扱いなのでこんな感じになりました。(このあたりはコンテストごとに変わるはずです)

設定ファイルの作成

以下のコマンドで設定ファイルを作成します。

psyleague config

デフォルトだとこんな感じのファイルが生成されるはずです

version = "0.2.0"

# [MATCHMAKING]
n_workers = 1
mm_min_matches = 200
mm_min_matches_preference = 0.75
mm_coeff = 2.5

# [SHOW]
# ? at the end means that the column is optional and it's only showed when it contains different values
date_format = "%Y/%m/%d %H:%M:%S" #more about the format: https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes
leaderboard = "POS,NAME,SCORE,GAMES,MU,SIGMA,ACTIVE?,ERRORS?,DATE,DESCRIPTION?"

# [RANKING MODEL]
model = "trueskill"
draw_prob = 0.0001
tau = 0.00025

# [COMMANDS]
dir_bots = "bots"
cmd_bot_setup = "g++ -std=c++17 %SRC%.cpp -o %DIR%/%NAME%.exe && cp %SRC%.cpp %DIR%/%NAME%.cpp"
cmd_play_game = "python play_game.py %DIR%/%P1%.exe %DIR%/%P2%.exe"

# [FILES]
file_log = "psyleague.log"
file_msg = "psyleague.msg"
file_db = "psyleague.db"
file_games = "psyleague.games"

これを自分の環境に合わせて修正します。今回は n_workerscmd_bot_setupcmd_play_game の 3つを修正しました。

 version = "0.2.0"
 
 # [MATCHMAKING]
-n_workers = 1
+n_workers = 4
 mm_min_matches = 200
 mm_min_matches_preference = 0.75
 mm_coeff = 2.5
@@ -18,12 +18,11 @@ tau = 0.00025
 
 # [COMMANDS]
 dir_bots = "bots"
-cmd_bot_setup = "g++ -std=c++17 %SRC%.cpp -o %DIR%/%NAME%.exe && cp %SRC%.cpp %DIR%/%NAME%.cpp"
-cmd_play_game = "python play_game.py %DIR%/%P1%.exe %DIR%/%P2%.exe"
+cmd_bot_setup = "docker exec local_battle g++ -std=c++17 %SRC% -o %DIR%/%NAME% && cp %SRC% %DIR%/%NAME%.cpp"
+cmd_play_game = "docker exec local_battle python3 play_game.py %DIR%/%P1% %DIR%/%P2%"

n_workers は同時に実行するプロセスの数を決定します。速くたくさん試合したいので 4 にしました。強いマシンを持ってる人ならもっと増やしてもいいかも。

cmd_bot_setup にはボットをビルドするときのコマンドを設定します。

cmd_play_game にはボットを対戦させるときのコマンドを設定します。

対戦サーバーの起動

以下のコマンドでサーバーを起動します。

psyleague run

何も無い状態だとこんな感じの表示になります。

Starting Psyleague server, press Ctrl+C to kill it
Active Bots: 0  Games since launch: 0  Games in the last 60s: 0 

ボットの追加

bot add コマンドでボットを追加します。

$ psyleague bot add cur_bot -s my_bot.cpp
Running Setup: docker exec local_battle g++ -std=c++17 my_bot.cpp -o bots/cur_bot && cp my_bot.cpp bots/cur_bot.cpp

コマンドが成功すると dir_bots で指定したディレクトリ以下にボットの実行ファイルとソースコードが追加されます。

$ tree bots 
bots
├── cur_bot
└── cur_bot.cpp

またサーバー側のボットの数も増えます。

Starting Psyleague server, press Ctrl+C to kill it
ADD_BOT : cur_bot : n/a                                                            
Active Bots: 1  Games since launch: 0  Games in the last 60s: 0 

1体だとまだ動作しないのでもう一体追加を行います。

$ psyleague bot add silver_bot -s silver_bot.cpp 
Running Setup: docker exec local_battle g++ -std=c++17 silver_bot.cpp -o bots/silver_bot && cp silver_bot.cpp bots/silver_bot.cpp
$ tree bots 
bots
├── cur_bot
├── cur_bot.cpp
├── silver_bot
└── silver_bot.cpp

2体目が追加されるとすぐに対戦が開始されます。

Starting Psyleague server, press Ctrl+C to kill it
ADD_BOT : cur_bot : n/a                                                            
ADD_BOT : silver_bot : n/a                                                         
Active Bots: 2  Games since launch: 83  Games in the last 60s: 28 

対戦結果の確認

psyleague show で対戦の結果を確認することが出来ます。

$ psyleague show
Pos  Name         Score  Games      Mu  Sigma  Errors  Created
---  ----------  ------  -----  ------  -----  ------  -------------------
  1  cur_bot     23.815    111  26.143  0.776       6  2023/06/07 00:03:55
  2  silver_bot  21.528    111  23.857  0.776       4  2023/06/07 00:09:25

対戦回数については何も指定しない場合 sys.maxsize 回行われるので (64ビット環境だと 9223372036854775807) 適当なところで止めてあげると良いです。

games_total = args.games or sys.maxsize

止めるのが面倒な場合は --games オプションで試合する回数の上限を設定してあげると良いでしょう。

$ psyleague run --games 100
Starting Psyleague server, press Ctrl+C to kill it
Active Bots: 2  Games since launch: 2 / 100  Games in the last 60s: 2

まとめ

今回は psyleague の Quick Setup Guide を試してみました。ローカルで実際の CodinGame のようなランキングシステムを構築出来るのは良いと思いましたし、過去の bot とまとめて対戦出来るのは便利でした。次回がいつになるかわかりませんが、その際には psyleague を使ってみようかなと思います。他にも便利な機能があるみたいなので興味がある人は是非試してみてください。

Spring Challenge 2023 参加記

問題

www.codingame.com

久々の参加

方針

  • 拠点と資源(クリスタル・卵)を頂点として最小全域木を作る
  • 木の上にビーコンを配置し、ビーコンの強さを焼きなましで決定
  • どの資源を狙うかについてはターン毎にルールベースによって決定する

最小全域木の作成

資源を効率よく回収するために拠点と資源を頂点とした最小全域木を作成しました。

各頂点間のコスト計算時に「資源マスを通るようにする」「アリのいるマスを優先的に使用する」となるように距離以外のコストを含めた経路探索になっています。

例) 拠点とクリスタル資源を頂点とした最小全域木を作った場合

ビーコンの配置 & 強さの決定

先程作成した木の上にビーコンを配置し初期の強さを 10に設定します。そのあとに木の上のタイルをランダムに選びそのタイルのビーコンの強さをランダムに変化させ、その後アリの動きをシミュレートしアリの配置の評価を行います。

基本的な評価基準としては「目的の資源をどれだけ集められたか」をベースにその他諸々を評価します。

ターン毎に狙う資源を決定

アリの数が多ければ多いほどゲームを有利に進められるようになるので序盤は基本的には卵を優先して取得するようにします。 アリの数がある程度確保出来たらクリスタルを回収します。優先度としては敵陣と自陣の中間にある資源を優先的に獲得するようにしています。

今回のゲームの勝利条件では以下のようになっています

  • 初期のクリスタル総数の半分以上を集める
  • 100ターン経過後に相手より多くのクリスタルを所持する

そのため、クリスタルの位置を「相手陣側」「自陣側」「中立地点」と分けた場合

  • 相手陣側のクリスタルを 1個以上取る (中間地点が存在しない場合)
  • 中立地点のクリスタルの過半数を取得

自陣のクリスタルをすべて集めた上でどちらかの条件を満たせば負けはなくなるので基本的に中間地点に近い資源の優先度が高くなります。 また、相手陣から遠い資源についてはそもそも chain attack の仕様上集めるのが非常に困難なのでほっといても特に問題になりません。

これらの条件を考えつつ毎ターンごとに狙う資源をルールベースによって決定していました。

感想

とにかく卵とクリスタルの回収タイミングがゲームの盤面ごとに異なってその判断がうまく評価値に落とせないままコンテストが終了しました。早く取りすぎるとあとから相手に奪われたり、遅すぎると相手に回収されてしまったりと、毎回異なる盤面ごとに難しい判断が要求されました。こういった評価が難しいケースは強化学習とか強いイメージあるんですけどどうなんですかね。