Buri Memo:

アイデアや気づきとかが雑に書き殴られる

非同期なオンライン上で安全に振れる冪等なサイコロを作ってみた

idempotent dice の画面

非同期なオンラインで平等に当番を決めたいというときありませんか?

リモートワークで面倒だけどやらないといけない単純作業が発生した際に、最初に気づいた人がやるのだと偏りが出て不平等じゃない?という声があってサイコロを降ってみたらどうか?となった。

オンラインの場合は適当なサイコロを振るサイトを開いて結果をスクショすれば十分ではあるが、プログラマーというものそんなの何回も振り直せばインチキができるじゃないかという発想になる。(別に同僚を信頼してないわけじゃないんだけども)

そうであれば非同期でもオンライン上でもインチキができない仕組みのサイコロを用意すればいいじゃない。ということで生まれたのがハッシュ関数を利用した冪等なサイコロ Idempotent Dice である。1

idempotent-dice.burion.net

使い方はシンプル。Choices にメンバーの名前を入れて、Entropy には「当番が必要になったきっかけのイベント」の情報を入れてボタンを押すだけ。Entropy に入れる情報は、例えば slack や GitHub の URL、メッセージの送信時刻など何でもよく、改ざんが難しい程よい。これだけでオンラインでも平等にサイコロを振ることが可能になる。他のメンバーは結果の Entropy が適切であることをレビューすることで、ダイスロールの正当性が保証される。

Choices の順番を変更することで結果を操作するという攻撃も考えられる。そこで簡易的な checksum を導入した。順番を入れ替えた際には checksum が変わるためここの数字を見ておくことで不正に気づくことができる。2

選択肢を入れ替えると checksum が変わる

非同期・ダイスロール・プロトコル

この Idenpotent Dice を利用したオンライン上での正しいサイコロの振り方は以下のような流れになる。この手続きを踏むことによってオンライン上で平等にサイコロを振ることができる。

  1. Choices にメンバー全員の名前を入れて「Roll Dice」をクリックする
  2. checksum を各自メモを取る
  3. Entropy に入力する、イベント識別子を決め同意する。この説明では例として「メッセージの送信時刻」を用いる。
    • 全員が確認可能で、改ざんの難易度が高いものであればあるほど望ましい
  4. YYYY-MM-DD HH:MM:SS に slack 上で何かサイコロを振りたいイベントを発生させるメッセージが投稿された
  5. Entropy にイベント識別子 YYYY-MM-DD HH:MM:SS を入れ「Roll Dice」をクリックする
  6. 結果の URL を slack 上で共有する
  7. 他のメンバーは URL を開いて、 Entropy が適切かつ checksum が正しいことを確認する
  8. 問題ない場合はその結果は正当なものとする。誤りがある場合はダイスの振り直しを要求できる。


  1. オフラインであればみんなが集まってサイコロ振ればいいのでこんなもんいらない
  2. チェックサムは0~9999(1万通り)しかないため大量に試行が可能な場合攻撃に気づけない可能性がある。選択肢を多くするほど攻撃の難易度が下がっていくため注意が必要です。

Unity: ubuntu 22.04 で my asset のダウンロードができない

OS: Ubuntu 22.04.4 LTS
Unity: 2022.3.25f1 LTS

こういう環境で、アセットストアで無料で登録したアセットをダウンロードしようとしたら、The download could not be completed. See details in console. と出てきてダウンロードが一向に進まなくなった。

The download could not be completed. See details in console.

console を見ろとあるので Window > General > Console を開くと、My Assets がおかしいので確認してくれというエラーだった。

[Package Manager Window] The Assets Cache location is invalid or inaccessible, see configuration in Preferences | Package Manager
UnityEngine.GUIUtility:ProcessEvent (int,intptr,bool&) (at /home/bokken/build/output/unity/unity/Modules/IMGUI/GUIUtility.cs:203)

Cannot include third party package location, you should check My Assets path location in User Preferences > Package Manager. Reason : '/home/buri/.local/share/unity3d' is an invalid path
UnityEditor.EditorApplication:Internal_FocusChanged (bool)

Edit > Preferences > Package Manager を開くと The Assets Cache location is invalid or innaccesible. Change location or reset it to default location. というエラーが出ていて、 Assets Cache がどうやらおかしいらしい

The Assets Cache location is invalid or innaccesible. Change location or reset it to default location.

解決

unity3d ディレクトリがなかったので作ったら、問題なくダウンロードができるようになった。

mkdir ~/.local/share/unity3d

虫のような人工生命を観察する生態系シミュレーションゲームを開発中です

複雑な模様の卵から虫が生まれてくるアニメ

※このゲームは開発中です。予告なしのサービスダウンやデータの喪失などが発生する場合がありますがご了承ください

ここ2年位の間のんびり作っていたゲームが一区切りつきました!遊んでもらえたら嬉しいです!(まだちょっと不安定ですけど)メタなコンセプト(何を作ろうとしてるのか)や技術的な説明はそのうち書きたいです。

ゲームプレイ 🎮

※ 電気代をケチるために閉鎖中の可能性があります。

garden.burion.net

コンセプト

なんとなく暮らしていて、小さな虫を見かけたときについついじっくりと動きを観察してしまう。 たまにはそんな時間を過ごすのも悪くないと思います。

このガーデンには簡単に見逃してしまうほど小さな虫のような人工生命がくらしています。 そんな虫たちをただじっくりと観察するだけの生態系シミュレーションゲームです。

ただそれだけ、虫を観察するだけのゲームです。

虫やその卵の見た目は自動生成によって作られています。すべての行動は AI(強化学習)によって行われ学習し続けながら生きのび繁殖をめざします。ですので、製作者にもどのような見た目の虫がどのような動きをするのかは全くわかりません。

虫たちは他の大きな虫である捕食者の脅威から逃れながらも餌を確保して生きながらえて、つがい見つけなければ繁栄できない、そんな生態系に生きています。

アリの観察キットのような面白さを目指しています。

遊び方

アイテムを集めて観察を便利にする

虫を観察しているとアイテムが出現することがあり、クリックして集めることができます。
生成AI(Stable Diffusion)を利用してつくった奇妙で美しいアイテムをコレクションできます。

アイテムをコレクションする


アイテムを獲得するとお金(Core)が手に入ります。Core が溜まったらショップタブを開いてデバイスを購入すると新しい機能が追加され観察をより便利にできます。例えば CAMERA-1 を購入することで、マウスホイールでズームができるようになります。

沢山アイテムを集めて、観察をより便利にするための機能を開放していきましょう!

デバイスを購入して観察を便利にする

ガーデンの環境

ガーデンには主に「陸」と「海」の2種類の地形があります。最初は鮮やかな色ですが虫に食べられるとエネルギーが徐々に減り色が変化します。

虫と生態ピラミット

ガーデンには色々な虫がすんでますが、よく目を凝らしているとサイズが大きい虫と小さい虫がいることに気づくかもしれません。虫にはレベルがあり、レベルが高い虫ほど体格が大きくなります。このレベルは生態ピラミットにおける段階を表しており、高いレベルの虫ほど能力が高くなる分生存に必要なエネルギーが増えていきます。(なのでレベルが高いほど個体数は減る)

Lv1 の虫は第一消費者で、陸や海といった地形に生息する植物や微生物といった生産者を食べることが出来ます。Lv2 以上は高次消費者で他の虫を捕食します。

生態ピラミット


また、虫には「陸生生物」と「海洋生物」の二種類があります。陸生生物は陸で、海洋生物は海では力を発揮できますが逆の環境では生きるのが難しく、すぐに死んでしまいます。基本的な情報は虫をクリックすると確認することができます。

虫を選択すると情報が見れる

のんびりと開発中です

最後まで読んでくださりありがとうございます!

現在グラフィックの改善、機能拡充、オンラインプレイ、多言語対応を企画してます。(機能や値段設定などの方向性はまだ未定)

もしこのゲームを気に入ってくださった方がいらっしゃればスター(無料)をつけて応援していただけると非常に舞い上がります。気分が最高になりアイデアが溢れ出し、開発速度がちょっとだけ上がるかもしれません!保証はできませんが!

MD5は簡単に衝突させられる

MD5 (Message Digest 5) は暗号学的ハッシュ関数で、電子署名やパスワードのハッシュ化のような暗号的な用途に利用されていた(が、今は暗号用途で使ってはいけない)。

暗号学的ハッシュ関数はこの性質を満たさなければいけないとされている。

  • 一方向性: ハッシュ値から元の値の復元が困難である
  • 第二現像困難性: あるハッシュ値があったとき、それと同じハッシュ値となる入力値(元の値でなくて良い)を求めるのが困難である
  • 衝突困難性: 同じハッシュ値になる任意の入力値を求めることが困難である


暗号学的ハッシュ関数に求められる性質


ハッシュ関数の品質として通常利用時に偶然衝突する確率が非常に低いことはもちろん、悪意を持って衝突しようとしても偶然衝突するレベルの確率でしか成功しないようにする必要がある。ここでいう「困難」は総当たり攻撃や誕生日攻撃 1 よりも効率的な攻撃方法が無いと言い換えられる。

攻撃難易度は、 一方向性 > 第二現像困難性(弱衝突耐性) > 衝突困難性(強衝突耐性) の順番で、安全なハッシュ関数と言えるようにするためには、最低でも一番難易度が低い「衝突困難性(強衝突耐性)」ですら破れないことが求められる。

一方向性・第二現像困難性

この2つに関しては現時点は破られていない。なので直ちには電子署名を攻撃しメッセージの改ざんを検知できなくなるみたいな問題は起きない。

衝突困難性

既に破られている。

誕生日攻撃では 264 回の施行が必要になるが、2004年に 239 程度の試行で見つけられる方法が見つかり 2005年には 230 まで改善されたらしい(int 型より短いじゃん!)。

更に、2007年には chosen-prefix collition attack という前部分を任意の値にした状態で同一のハッシュを求めるような手法まで見つかっている。衝突を利用してX.509 証明書の偽装や、APOP を悪用すして31文字以下であれば現実的な時間でパスワードを盗めるような例もあったりする(詳しくは MD5の安全性の限界に関する調査研究報告書)。

実際に MD5 を衝突させてみる

HashClash というプロジェクトがある。これを使うことで画像のような md5 ハッシュ値が一致する入力のペアが作れる。

github.com

identical-prefix collision & chosen-prefix collision のイメージ

README に従ってビルドするか、releases からビルド済みのバイナリを入手できる。実際にこれを試してみる。

identical-prefix collision

プレフィックスをファイルに書き込み、準備されているスクリプトを実行する。2分半で collision1.bin collision2.bin が生成された。(一回目の実行では準備があるようでもう少しかかる)

$ echo -n hello > prefix
$ ./scripts/poc_no.sh prefix

これはプレフィックスが同じだが(o どこ??)完全には一致しないバイナリファイルで

$ od collision1.bin -c
0000000   h   e   l   l 327 023 254 025   -   - 277   [ 277 373 215 266
0000020   x 311 356 302   l 327 247   7   | 373 322   e 216   %  \f 373
0000040   o 320   Q   Z   Y   E 225 326 033 265   s 272 204   d 272   O
0000060 317   / 265 037  \n 225 352 264 336 024 301   4   ;   @   ,   <
0000100   : 267   2 323 307 333 253 333  \n 350 031 204   6   | 243 237
0000120 016 323 256 357   z 227 213 260 303   m 362  \f   0  \n   j 340
0000140 016 377   l 351  \t   ( 025 261   ^ 177   ; 005   %   u   r   x
0000160 346 264   O   j 215 004 321 317 001   e   S   @ 211   S 335 334
0000200

$ od collision2.bin -c
0000000   h   e   l   l 327 023 254 025   -   . 277   [ 277 373 215 266
0000020   x 311 356 302   l 327 247   7   | 373 322   e 216   %  \f 373
0000040   o 320   Q   Z   Y   E 225 326 033 265   s 272 204   d 272   O
0000060 317   / 265 037  \n 225 352 264 336 024 301   4   ;   @   ,   <
0000100   : 267   2 323 307 333 253 333  \n 347 031 204   6   | 243 237
0000120 016 323 256 357   z 227 213 260 303   m 362  \f   0  \n   j 340
0000140 016 377   l 351  \t   ( 025 261   ^ 177   ; 005   %   u   r   x
0000160 346 264   O   j 215 004 321 317 001   e   S   @ 211   S 335 334
0000200

$ cmp collision1.bin collision2.bin 
collision1.bin collision2.bin 異なります: バイト 10、行 1

MD5 ハッシュが一致している!

$ md5sum collision1.bin 
92a2817fd6556fc7b9c7f823b89c3728  collision1.bin

$ md5sum collision2.bin 
92a2817fd6556fc7b9c7f823b89c3728  collision2.bin

chosen-prefix collision

プレフィックスをファイルに書き込み、準備されているスクリプトを実行する。187分で prefix-1.coll prefix-2.coll が生成された。

$ echo -n hello > prefix-1
$ echo -n world > prefix-2

$ ./scripts/cpc.sh prefix-1 prefix-2

これは期待通り prefix が helloworld から始まるバイナリファイルで

$ od prefix-1.coll -c
0000000   h   e   l   l   o   =   b 204 021 001   u 323   M 353 200 223
0000020 336   1 301 331   0   E 373 276 036   q 360  \n   c   u 250   0
0000040 252 230 027 312 343 242   k 216   =   D 251 217 362 016   g 226
0000060   H 227   % 246  \0  \0  \0  \0   9 206 377 323   %   ]   , 353
0000100 372   b   U 247 246 305 312 034 260   k   q   u 201 210   ! 303
0000120   o   u 025 270   a 213 211 257 367 021 253   k   8   l 206   {
0000140   ! 224 203 204   > 257 323 362 362 021   ) 341   X 202 270 352
0000160 325 347 335   t 355   M 026   0 201   | 246 021 354   ]   4 322
0000200 334 266   4  \b 265 321 346 210 377   [ 277 026   } 365 372 357
0000220 231   @ 204 250 035   D 373 006 256 271 251 212 353   / 327 324
0000240 232   u   " 337   ]   ~ 205 304 301   0 243 373 024   y 300 350
0000260 232 210 303 017   u   s 324   { 333 321 224 324 262  \0 027 337
0000300 344 354 330   & 224  \t 307 037 002   r 351   & 240   " 311 324
0000320 034 024 355   . 304 001 356   > 361   F 370   * 017   A   P   H
0000340 316   3   a   U   y   !   7   ; 325   o  \t   q   6 242 036 327
0000360   -   0 353   d 301 221 347 226   @   N   2 334 354   + 200   S
0000400 032 345 352 350 247   = 022 025   U 250  \n 213 240   y 020   1
0000420 016 240 214 266   0 332   5 366  \b   s 307 376 323   s 342 346
0000440 023   Z 372   K 361 177 256 237 243   ` 231   ~   ; 341  \b   /
0000460 305 005 316   - 211 371 004   6 365 005 023 303   g   V   2 301
0000500   o   w   J   Q 377 220 360   # 232 303 337 373 340 034 277   `
0000520   D 030 317 264 302   z   Q 372 336 327   o 334   }   @ 025 021
0000540   < 342 344 244   N   &   G 342   < 333   }   T 332   K 233   7
0000560 205 331 316 226   P 347 242   R 032   0   I 257     356 036 035
0000600 274   } 331   u   {  \t   > 203 330 331  \v   ? 377   G   + 240
0000620 207 214   e 357   3 276 020   ( 026  \r 243 361   t 336   ` 235
0000640 315 344 227 334   0   ' 245   *   X 256 244 333 276   ! 331 373
0000660 340   e 211   n 273   z 343 345   p 266   H  \t 337 261 307 274
0000700 342  \0 265 266   K   . 331  \v   % 020   w 264 033   H 341 234
0000720   7 315 033 373  \f   H 242 016 325 266 314 354 362 271   W   t
0000740 201 370   d 340 313 211 374 207 347   F 275 021 363   L 221 372
0000760 350 216 241   (   s   } 002 333   " 343   { 302  \t 004   <   c
0001000

$ od prefix-2.coll -c
0000000   w   o   r   l   d 206 345   _ 320 203 001 233   M   U 006   a
0000020 253 210 021 212 372   M   4 263   u   Y   F   V 227 357   l   J
0000040  \a 220 314 376 031 327 317   o 222 003 234 221 252 245 332   V
0000060 222 301 004 346  \0  \0  \0  \0  \b 213 205 375 275 211 262 307
0000100 372   b   U 247 246 305 312 034 260   k   q   u 201 210   ! 303
0000120   o   u 025 270   a 213 211 257 367 021 253   k   8   l 206   {
0000140   ! 224 203 204   > 257 323 362 362 021   ) 341   X 202 310 352
0000160 325 347 335   t 355   M 026   0 201   | 246 021 354   ]   4 322
0000200 334 266   4  \b 265 321 346 210 377   [ 277 026   } 365 372 357
0000220 231   @ 204 250 035   D 373 006 256 271 251 212 353   / 327 324
0000240 232   u   " 337   ]   ~ 205 304 301   0 243 373 024 231 300 350
0000260 232 210 303 017   u   s 324   { 333 321 224 324 262  \0 027 337
0000300 344 354 330   & 224  \t 307 037 002   r 351   & 240   " 311 324
0000320 034 024 355   . 304 001 356   > 361   F 370   * 017   A   P   H
0000340 316   3   a   U   y   !   7   ; 325   o  \t   q   5 242 036 327
0000360   -   0 353   d 301 221 347 226   @   N   2 334 354   + 200   S
0000400 032 345 352 350 247   = 022 025   U 250  \n 213 240   y 020   1
0000420 016 240 214 266   0 332   5 366  \b   s 307 376 323   s 342 346
0000440 023   Z 372   K 361 177 256 237 243   ` 231   ~   K 341  \b   /
0000460 305 005 316   - 211 371 004   6 365 005 023 303   g   V   2 301
0000500   o   w   J   Q 377 220 360   # 232 303 337 373 340 034 277   `
0000520   D 030 317 264 302   z   Q 372 336 327   o 334   }   @ 025 021
0000540   < 342 344 244   N   &   G 342   < 333   }   T 332   K 232   7
0000560 205 331 316 226   P 347 242   R 032   0   I 257     356 036 035
0000600 274   } 331   u   {  \t   > 203 330 331  \v   ? 377   G   + 240
0000620 207 214   e 357   3 276 020   ( 026  \r 243 361   t 336   ` 235
0000640 315 344 227 334   0   ' 245   *   X 256 244 333 276   # 331 373
0000660 340   e 211   n 273   z 343 345   p 266   H  \t 337 261 307 274
0000700 342  \0 265 266   K   . 331  \v   % 020   w 264 033   H 341 234
0000720   7 315 033 373  \f   H 242 016 325 266 314 354 362 271   W   t
0000740 201 370   d 340 313 211 374 207 347   F 275 021 363   L 221 376
0000760 350 216 241   (   s   } 002 333   " 343   { 302  \t 004   <   c
0001000

MD5 ハッシュが一致している!!!

$ md5sum prefix-1.coll 
0c577ac6e8f6ed9a13297996bf32a787  prefix-1.coll

$ md5sum prefix-2.coll 
0c577ac6e8f6ed9a13297996bf32a787  prefix-2.coll

まとめ

HashClash を利用して示したように、MD5 は一般的なPCでも数分~数時間あれば入力値の一部を任意の値にして衝突を発生させられることがわかった。(ちなみに最初はアルゴリズムを理解しようと論文を開いたが難しすぎて諦めた)

MD5 は一方向性・第二現像困難性が破られていないとはいえ衝突を悪用した攻撃方法は存在する。衝突困難性が破られてから20年以上経ってるので今後新しい悪用方法が現れる(どころか既に現れてる)ことは容易に想像できる。

ということで、MD5 やその他衝突困難性が破られているハッシュ関数(RIPEMD, SHA0, SHA1,... など)は暗号用途には使ってはいけない。

実験で作成した MD5 を衝突させたファイルはこちら

github.com

参考


  1. MD5 の場合出力が 128bit なので、第二現像困難性に対する総当り攻撃の平均試行回数は 2127 回、衝突困難性に対する誕生日攻撃の平均試行回数は 1.25 * √(2128) 回(≒ 264)程度

2D空間のある範囲にあるオブジェクトを高速に取得する npm package 作ってみた

趣味で開発しているシミュレーションゲーム的なもので当たり判定っぽいものが必要になった。

ゲームでは何匹もの虫がいる。虫1匹毎に周囲の情報を集めてそれを Neural Network のモデルに渡してから次の移動方向を決定する...、みたいな流れになっている。(詳細は省くがこういうゲーム

その際に周囲にいる自分以外の虫の情報が必要になる。例えば画像のような状態で赤い虫が行動する場合、赤い虫から数px(ピンクの範囲)に存在する虫 4, 5, 6 を取得したい。

2D空間のある範囲を検索したい

2023-12-26 追記

最後の方で紹介しているが R-tree など多次元インデックス(空間インデックス)を実装しているライブラリを探したほうが良いかも。
ちなみに本稿で紹介している手法はグリッド(メッシュ)の方式らしい。

単純な実装

単純には以下のように実装できる。

しかしこの方法では虫の数に比例して計算量が増加する。実際、虫が数百〜数千程まで増えるとパフォーマンスが問題になった。

type Entity = {
    id: string;
    x: number;
    y: number;
}

// フィールド上にいる虫たち
const entities: Entity[] = [
    {id: "A", x: 10, y: 9},
    {id: "B", x: 20, y: 15},
    {id: "C", x: 20, y: 20},
    {id: "D", x: 30, y: 50},
]

// 検索したい範囲を決める
const query = {
    xFrom: 10, yFrom: 10,
    xTo: 20, yTo: 20
}

// ループで1匹ずつ検証して、範囲内なら results に追加する
const results: Entity[] = [];
for (const entity of entities) {
    const isContained = (query.xFrom <= entity.x && entity.x <= query.xTo)
        && (query.yFrom <= entity.y && entity.y <= query.yTo);
    if (isContained) {
        results.push(entity);
    }
}

console.log(results);
/*
[
    {
        "id": "B",
        "x": 20,
        "y": 15
    }, 
    {
        "id": "C",
        "x": 20,
        "y": 20
    }
] 
*/

グリッドに分割する方式

単純な実装では全ての虫をチェックする必要があったので遅くなった。

そこで、フィールドを細かいグリッドに分割しその範囲に存在する虫ごとに区切るようなデータ構造を使用してみた。

グリッドに分割されたフィールド

このように分割するとチェックする範囲は緑と青のマスだけで十分になる。

青いマスは確実にピンクの範囲の内側にあるため、4 は確定する。緑色のマスには範囲内と範囲外の虫が含まれるため、2, 5, 6 だけ座標が範囲内にあるのかをチェックするだけで良くなり速くなった。

単純な実装では 1~8 の 8回座標を比較する必要があったが、グリッドに分割する方式では 2, 5, 6 の 3回だけで良くなった。更に、虫が増えたとしても白いマスに存在する虫は精査の必要がないため単純な増加は起きない。(密に存在しなければ)

更に、メモリ効率も悪くない。一つの Array ではなくマスの分 Map に分割してオブジェクトを保持する必要はあるものの、オブジェクトは重複して保存されることはない。オーバーヘッドは 空の Map が求めるサイズ * マスの数なのでグリットを細かくしすぎなければ大した差は出ないはず。

最初のものと比較すると大分複雑になるが、以下のように実装できる。

type Entity = {
    id: string;
    x: number;
    y: number;
}

type Query = {
    xFrom: number;
    yFrom: number;
    xTo: number;
    yTo: number;
}

const height = 50;
const width = 50;

const gridHeightCount = 5;
const gridWidthCount = 5;

// 各マスに Map を使用すると、虫の座標が変わった際の更新が単純で高速になる(今回は考慮しないが)
const grid: Map<string, Entity>[][] = [
    [new Map(), new Map(), new Map(), new Map(), new Map()],
    [new Map(), new Map(), new Map(), new Map(), new Map()],
    [new Map(), new Map(), new Map(), new Map(), new Map()],
    [new Map(), new Map(), new Map(), new Map(), new Map()],
    [new Map(), new Map(), new Map(), new Map(), new Map()],
];

// 座標をグリッドの index に変換する
function xIndex(x: number): number {
    if (x <= 0) {
        return 0;
    }
    if (x >= width) {
        return gridWidthCount - 1;
    }
    return Math.floor(x / (width / gridWidthCount));
}

function yIndex(y: number): number {
    if (y <= 0) {
        return 0;
    }
    if (y >= height) {
        return gridHeightCount - 1;
    }
    return Math.floor(y / (height / gridHeightCount));
}

const entities: Entity[] = [
    { id: "A", x: 10, y: 9 },
    { id: "B", x: 20, y: 15 },
    { id: "C", x: 20, y: 20 },
    { id: "D", x: 30, y: 50 },
]

// グリッドへ登録する
for (const entity of entities) {
    grid[yIndex(entity.y)][xIndex(entity.x)].set(entity.id, entity);
}

function search(query: Query): Entity[] {
    const isContained = (entity: Entity): boolean => {
        return (
            entity.x >= query.xFrom &&
            entity.x <= query.xTo &&
            entity.y >= query.yFrom &&
            entity.y <= query.yTo
        );
    };

    const yIndexFrom = yIndex(query.yFrom);
    const yIndexTo = yIndex(query.yTo);

    const xIndexFrom = xIndex(query.xFrom);
    const xIndexTo = xIndex(query.xTo);

    const results: Entity[] = [];

     // 1マスだけが対象の場合
     if(yIndexFrom === yIndexTo && xIndexFrom === xIndexTo){
         for(const target of grid[yIndexFrom][xIndexFrom].values()){
             if (isContained(target)) {
                 results.push(target)
             }
         }
         return results;
     }

    // 絶対範囲の内側なのでチェックはしない(画像の青い範囲)
    for (let y = yIndexFrom + 1; y <= yIndexTo - 1; y++) {
        for (let x = xIndexFrom + 1; x <= xIndexTo - 1; x++) {
            for (const target of grid[yIndex(y)][xIndex(x)].values()) {
                results.push(target)
            }
        }
    }

    // 範囲内に存在するかどうかが確定しないのでチェックが必要(画像の緑の範囲)
    for (let x = xIndexFrom; x <= xIndexTo; x++) {
        // top 一列
        for (const target of grid[yIndexFrom][x].values()) {
            if (isContained(target)) {
                results.push(target)
            }
        }

        // bottom 一列
        for (const target of grid[yIndexTo][x].values()) {
            if (isContained(target)) {
                results.push(target)
            }
        }
    }

    for (let y = yIndexFrom + 1; y <= yIndexTo - 1; y++) {
        // left 一列
        for (const target of grid[y][xIndexFrom].values()) {
            if (isContained(target)) {
                results.push(target)
            }
        }

        // right 一列
        for (const target of grid[y][xIndexTo].values()) {
            if (isContained(target)) {
                results.push(target)
            }
        }
    }

    return results;
}

const results = search({
    xFrom: 10, yFrom: 10,
    xTo: 20, yTo: 20
});
console.log(results);
/*
[
    {
        "id": "B",
        "x": 20,
        "y": 15
    }, 
    {
        "id": "C",
        "x": 20,
        "y": 20
    }
] 
*/

npm package として公開してみた

個人的な開発で使うものとして作ってみたが、パッケージとして管理できたら面白いんじゃないかと思いたち npm のライブラリとして公開してみた。公開までは無料で簡単に行えた。自分だけがアクセスできる private な package を登録したい場合は月 7ドルかかるらしい。

GitHub Actions を設定してテストや npm 公開の自動化もついでにやってみた。これが無料で使わせてもらえるのすごいね。

ちなみにこちらでは、先程考慮しなかった座標の変化にも対応している。

www.npmjs.com

R-tree という多次元インデックス(rbush)

この記事を書いたあとで気づいたのだが、R-tree という多次元(ライブラリは2次元)空間を効率よく検索するための木構造アルゴリズムを見つけてしまった。

パフォーマンスを比較すると検索速度に関しては完全に負けている。しかも search2d(自作)は点(高さと幅がゼロ)のオブジェクトしか対応していないに対し RBush は矩形に対応してる。基本的にこちらで良さそう。

ただし、データの登録速度に関しては search2d の方が20~30倍速かった。ひょっとすると高頻度で座標の更新がある & 矩形オブジェクトの検索が不要な場合はアドバンテージがあるかもしれない。

[
    {
    name: 'Map1dSearch',  // search2d (自作)
    registerTimeMs: 10,
    searchTimeMs: 118,
    deregisterTimeMs: 4
    },
    {
    name: 'RBushSearch',  // rbush (R-tree)
    registerTimeMs: 351,
    searchTimeMs: 89,
    deregisterTimeMs: 2
    }
]

www.npmjs.com

Node.js v20のnpm ciが進まなくなりnetwork read ETIMEDOUT が出た

ミニPC vs 埃ラズパイ
ミニPC vs 埃ラズパイ
最近ミニPCを買いました。趣味で CPUをそこそこ使う処理を一日中動かしたいなぁという状況になったのですが VPS だと費用がバカにならないので自宅サーバーとして手頃なミニPCにしてみました。

TRIGKEY 製の安価な物で、CPUコアが8個もついてるのにこの大きさでこのお値段。消費電力も 5~40w 位と今の所はなかなかいい感じ(1ヶ月辺り千円くらい)。まだ触り初めて数日だけど。

届いて諸々の準備を済ませてウキウキと docker build (docker-compose build)を実行してみたところ、RUN npm ci でハングしました。メインのPCや VPS 上だと問題なかったのに!

風呂に入りながら数分間放置してみると以下のようなエラーが出ていました。

ARG image=node:20-slim

FROM ${image} AS dev

USER root

WORKDIR /common
COPY ./common/ /common/
RUN npm ci
RUN npx tsc

# 以下省略
 > [simulator dev 10/14] RUN npm ci:
360.1 npm ERR! code ETIMEDOUT
360.1 npm ERR! syscall read
360.1 npm ERR! errno -110
360.1 npm ERR! network read ETIMEDOUT
360.1 npm ERR! network This is a problem related to network connectivity.
360.1 npm ERR! network In most cases you are behind a proxy or have bad network settings.
360.1 npm ERR! network
360.1 npm ERR! network If you are behind a proxy, please make sure that the
360.1 npm ERR! network 'proxy' config is set properly.  See: 'npm help config'
360.1
360.1 npm ERR! A complete log of this run can be found in: /root/.npm/_logs/2023-12-05T16_16_40_516Z-debug-0.log
------
failed to solve: process "/bin/sh -c npm ci" did not complete successfully: exit code: 146

解決

1. network=host にする(解決したと思ったら再発)

docker build --network=host . のように RUN の実行を host ネットワークモードを使うように指定するとうまく行きました。docker-compose の場合は build の network を設定するだけ。

version: "3.8"

services:
    app:
        build:
            dockerfile: Dockerfile
            context: .
            network: host

2. npm v10 以上(Node v20以上)の場合: v10.5.1 以上に上げる(解決🎉)

1 の解決策では一度は直ったのですが再発しました。GitHub 上に以下のような issue を見つけました。この issue によると npm install 時に大量のコネクションを確立してしまう結果、ネットワークの問題で停止するとのことでした。npm v10.5.1で解消されたらしいので最新までバージョンを上げることで解消しました。

$ npm install -g npm@latest

マイナーバージョンの変化ですがなにか理由があってバージョンアップしたくない場合は v9 以下に下げるのも有効みたいです。

github.com

参考

Z3で疑似乱数生成器(xorshift)の出力を予測する

1年以上昔にこんな記事を書いた。

burion.net

久しぶりに読み返してみると、疑似乱数が決定的・周期的なことまではなんとなく示せているものの具体的な予測方法は全然書いてないじゃん!!!と内なる声に突っ込まれた。「予測できるか試したい」なんてタイトル掲げておいて、なんて中途半端な奴なんだ。

多分このときは周期性を実験するところで気力が尽きたのかもしれない、許してあげてね。

そういうわけで今回こそは実際に予測してみようと思う。

xorshift 32bit

今回は python を使いたかったので前の記事で使った疑似乱数生成器を移植した。

また、今回出力は self.s をそのまま使わずに下位 16bit だけを取り出す形にした。これは「出力 = 内部状態」だと予測する必要がなくなってしまうためである。1

ちなみに内部状態と出力を異なる bit 数にする効果については以下の記事で昔考えている。

疑似乱数(xorshift)の内部状態と生成される数値の関係について考えてみる - Buri Memo:

class XorShift():
    def __init__(self, seed):
        self.s = seed

    # python の int 型は無制限なので 32bit に切り詰める
    def mask(self, x):
        return x & 0xffffffff

    def rand(self):
        # https://ja.wikipedia.org/wiki/Xorshift の xorshift32
        self.s = self.mask(self.s ^ (self.s << 13))
        self.s = self.mask(self.s ^ (self.s >> 17))
        self.s = self.mask(self.s ^ (self.s << 5))
        
        # 出力は内部状態変数 s の下位 16bit
        return self.s & 0xffff

これを実行すると、以下のように 0~65535 の乱数を得られる。今回はこの 10個の値から次の乱数出力(7649)を予想できるか試してみたいと思う。

sequence = [rng.rand() for i in range(10)]

# [17433, 63802, 48521, 8888, 60923, 59364, 53581, 56036, 61202, 34977]
print("Randoms", sequence)

# 7649 (これを予想する)
print(f"Next random is {rng.rand()}")  

総当りで予測する

一番簡単に思いつく方法はこれ。適当なシード値から [17433, 63802, 48521, 8888, 60923, 59364, 53581, 56036, 61202, 34977] の並びが出現するまで乱数を作りまくるというシンプルな方法。

この乱数生成器の周期は 232-1 であるので平均的には 21億回ほどで見つかるはず。実際にやってみると 50分くらいかかったが次の出力 7649 を予測することができた。

st = time.time()
rng = XorShift(1)
samples = [rng.rand() for i in range(len(sequence))]
while samples != sequence:
    samples = [*samples[1:], rng.rand()]
print(f"Predict: {rng.rand()} ({time.time() - st}s)")
# Predict: 7649 (2990.971052646637s)

流石に時間かかりすぎなので C言語でもやってみると 5秒くらいで終わった。やっぱ python は総当りに向いてないなぁ。
ところで Math.random() で使われる xorshift 128+ は周期が 2128 - 1 なので、平均でその半分の 8 * 1028 倍もかかることになる。現実的な時間で終わるのは厳しそう。

blog-assets/xorshift32-prediction/main.c at main · buri83/blog-assets · GitHub

Z3 を使用して予測する

Z3 は Microsoft Research の SMT solver らしい。正直 SMT について全く理解できてないが、とりあえずは与えられた論理式(制約)を満たす解を1個高速に探してくれるソフトウェアというイメージでいいかなと思う。

github.com

例えば z3 を使って以下のような x, y を求めてみる。

  • x, y は自然数
  • x + 2y < 5 を満たす

以下のスクリプトを実行すると [x = 1, y = 1] [x = 2, y = 1] と正しい答えが得られた。スゲ〜〜

import z3

x = z3.Int("x")
y = z3.Int("y")

solver = z3.Solver()
solver.add(x > 0, y > 0)
solver.add(x + 2 * y < 5)

# [x = 1, y = 1] [x = 2, y = 1]
while solver.check() == z3.sat:
    answer = solver.model()
    print(answer)
    # 解を1個ずつしか表示できないので、見つけた解を除外してもう一度解いてもらう
    solver.add(z3.Not(z3.And(x == answer[x], y == answer[y])))

乱数生成器もただの数式なので z3 を使って同じように計算できる。(そういえば逆方向の変換できないことを目的にしているハッシュ関数はどうなるんだろう?)

実行すると一瞬で seed を求めることができた!(たったの 63 ms !!)

import z3

st = time.time()
solver = z3.Solver()

s = z3.BitVec("s", 32)
for i in sequence:
    s = s ^ (s << 13)
    s = s ^ z3.LShR(s, 17)
    s = s ^ (s << 5)
    solver.add(s & 0xffff == i)

if solver.check() != z3.sat:
    raise Exception("unsat !!!")

model = solver.model()
states = {str(s): model[s] for s in model}

seed = states["s"].as_long()

rng = XorShift(seed)
for _ in range(len(sequence)):
    rng.rand()

print(f"Predict: {rng.rand()} ({time.time() - st}s)")
print("Predicted initial seed", seed)
# Predicted initial seed 777
# Predict: 7649 (0.06324553489685059s)

ちなみにこの方のスクリプトや解説動画をかなり参考にさせてもらった。こちらでは Node.js の Math.random() を実際に予想しており周期が 128bit にもかかわらず一瞬で求められている。また、Math.random() の出力は少数の値なので float を vector に変換するような処理もありより実用的な実装もされていたりもする。

気になる方は是非見てほしい。

github.com

おまけ

今回使ったプログラム全体はこちら

github.com


  1. 乱数列から次の出力される乱数を予測するのがこの記事の目的であるが、出力=内部状態の場合は乱数列の一番最後を seed にして乱数を生成するだけで次の乱数を得られてしまうので面白くない。また、実際使われる乱数では内部状態よりも出力の bit 数は小さいことが多いし、そうするべきだと思う。

なぜUUIDはハイフンで8-4-4-4-12に区切られているか?

ある特徴的な文字列がある。

11b484b6-cfeb-4730-8fba-467aee2d26ad

使ったことがあれば、恐らくすぐに UUID であると分かると思う。UUID は16進数がハイフンで分けられた特徴的なフォーマットをしている。その区切り方は、 8-4-4-4-12 となんだか不思議な感じだ。

そもそも、UUID は 128bit の数値であり、それを文字列で表現したものなのでハイフンが無くたって問題ない。にもかかわらずわざわざ覚えにくいケタで区切ることが多い。

その理由として、最初に思いつくのは人間が読みやすくするためだとは思うが、覚えやすく均等に区切ったり、左右対称の形にはできなかったのだろうか?

UUID の定義

UUID は RFC4122 で以下のように定義されている。因みにあまり推奨はされないらしいが大文字も許容されているらしい(知らなかった)。

      UUID                   = time-low "-" time-mid "-"
                               time-high-and-version "-"
                               clock-seq-and-reserved
                               clock-seq-low "-" node
      time-low               = 4hexOctet
      time-mid               = 2hexOctet
      time-high-and-version  = 2hexOctet
      clock-seq-and-reserved = hexOctet
      clock-seq-low          = hexOctet
      node                   = 6hexOctet
      hexOctet               = hexDigit hexDigit
      hexDigit =
            "0" / "1" / "2" / "3" / "4" / "5" / "6" / "7" / "8" / "9" /
            "a" / "b" / "c" / "d" / "e" / "f" /
            "A" / "B" / "C" / "D" / "E" / "F"

命名は UUIDv1 に完全に対応している。UUIDv1 ではザックリ以下のような値が入る。(ビットオーダーはビッグエンディアン)

  • 青色: 100ns 精度の生成時刻(1582-10-15 00:00:00+00:00 からの経過時間)
  • 赤色: UUID のバージョン。UUIDv1 の場合は 0x1 になる
  • 黄色: バリアント。 RFC4122 で定義された形式は 0b10 で固定されている
  • 緑色: 生成のたびに増加するシーケンス番号で、100ns 以内に2つ以上UUIDが生成されたり時間が過去に設定されるなどした際の重複の確率を減らすためのもの
  • 紫色: UUID を生成した機器のを識別するもので、MACアドレスが使われる。取得できない場合は乱数を使用する 1

UUIDの定義

因みによく使われる UUIDv4 はバリアントとバージョン(赤、黄色)の部分以外がランダムに生成されている。

UUIDv1 は python だと以下のように生成できる。このモジュールではシーケンス番号は渡してあげる必要がある(渡さない場合は乱数が使われる)。

import uuid
uuid.uuid1()

生成した UUIDv1 を眺めてみる

上で説明した内容が正しいことを確認するために、軽く検証をしてみる。 1s (= 1,000,000ns)の sleep を入れてシーケンス番号を0から増やしながら生成した。

import time
import datetime 
import uuid

print(datetime.datetime.now())  # 2023-06-18 20:00:05.535037
print(uuid.uuid1(clock_seq=0))  # 4828cd80-0dc7-11ee-8000-1ff59d5c9f69
time.sleep(1)

print(datetime.datetime.now())  # 2023-06-18 20:00:06.536212
print(uuid.uuid1(clock_seq=1))  # 48c192cb-0dc7-11ee-8001-1ff59d5c9f69
time.sleep(1)

print(datetime.datetime.now())  # 2023-06-18 20:00:07.537512
print(uuid.uuid1(clock_seq=2))  # 495a5c37-0dc7-11ee-8002-1ff59d5c9f69

まず、一番最後の node セグメントは生成したホストを識別するものであるためすべて 1ff59d5c9f69 である。先頭が 1 であり乱数で生成したものだということが分かる。(注釈1を参照)

最後から2番目のセグメントは渡したシーケンス番号が使われていて 8000, 8001, 8002 とインクリメントされた値になっている。最初が 8 なのは先頭のビットがバリアント 0b10であるためだ。

最初の3セグメントは生成時刻が使われているが、そのうち違いが出たのは一番最初のセグメントだけだった。以下の表に各セグメントがインクリメントされる頻度を計算した数字を記載したが。2,3番目のセグメントが変化しなかったのはそれぞれ 約430 s, 約326 days 経過しないと変化しないためである。

セグメント インクリメントされる頻度
time-low 100 ns 毎
time-mid 約430 s 毎 2
time-hi 約326 days 毎 3

先頭のセグメント time-low を比べてみる。1s (= 10,000,000ns)の sleep を入れていれたので大体1千万ずつ増えていて、想定通りだ。

time-low の値 10進数表記
4828cd80 1,210,633,600
48c192cb 1,220,645,579
495a5c37 1,230,658,615

元祖 UUID(Network Computer System の UUID)

ところで、UUID が初めて使われたのは Apollo Computer 社の Network Computer System (以後 NCS と書く)である。4 このときの UUID の形式は以下のようになっていて、前述の UUIDv1 と非常に似ている。RFC4122 と比較してみると 「reserved / address family」 が 「version / variant」 に圧縮され、Host の識別コードのサイズが小さくなり、空いた空間を timestamp や clock sequence に割り当てたように見える。

NCSとRFC4122のUUIDの比較

形式の謎

これまで説明したように、8-4-4-4-12 なのは RFC 4122 で定義されているからである.....というのは確かにそうなんだろう。 定義を見れば、最後の2セグメントは納得した。

しかし、timestamp の部分をわざわざ 8-4-4 の 3セグメントに分割したのはなぜか?という新しい疑問が生まれてしまった。

UUIDの定義

前項の NCS の UUID では timestamp は分割もされていなければ、順序も自然である。一方で RFC4122 の定義では time-low time-mid time-hi と分割した上で順番を逆にしている。もし、RFC4122 の timestamp が自然に並んでいたとすれば、16-4-12 と区切られていたかもしれない。

そうなっていない理由を調べまくってみたものの、ソースと言えるような情報が見つからなかったのでここから先は予想の割合が多くなる、と予め断っておく。

RFC4122 で timestamp を3分割して順番を入れ替えたのはなぜだろう?

結論を先にいうと、timestamp をわざわざ分割して順序を入れ替えた理由として尤もらしいのは、検索効率を高めるため かなと思う。

UUID は 128bit の値であるが、多くのコンピューターでは一度に扱えるのは 32~64 bit までである。そのため UUID の比較は一度では完結しない。文字列形式で比較する場合はハイフンを含めると36回の比較が必要となる。

自然に実装された比較用の関数では恐らく前から比較を繰り返すはずである。もし、timestamp が自然に並んでいる場合最上位ビットから比較をしていくことになる。(以下の図では簡単のため version 部分を無視している)

その場合、一番最初に比較することになるであろう bit は 1827年間もの間変化が起きない。もしあなたがヴァンパイアでもなければ生きている内に更新されることはほとんど無い。つまりめったに変わらない値を無駄に比較する計算コストが毎回発生してしまう。

timestamp が自然に並んでいたときの比較と更新頻度

これは、頻繁に変化する部分から比較していくことで改善できる。下位のビットを前に持ってくるだけで一番最初に比較する bit の更新頻度が約215秒まで短くなる。よって、異なる UUID 同士を比較する際、はじめに見た部分が重複している確率が低くなる(カーディナリティが高くなる)ため「一致しない」と判断できる時間が平均で短くなる。

また、ビットオーダーがビックエンディアンであるため、計算効率を考えると2分割するよりも3分割するほうが効率が良い。

つまり、timestamp を3分割し、更に順番を入れ替えたのは計算コスト的なメリットがあるためではないだろうか。

timestamp がRFC4122の順番のときの比較と更新頻度

更に、ほとんどの CPU では 32bit の値同士の比較は一発で完結する。timestamp の下位 32bit を time-low として定義し、先頭に配置することで計算効率が最大化できるはずだ。

そう考えると、8-4-4-12 と区切るのはそこまで不思議には見えなくなりませんか??

ハイフンで区切られたUUID

比較速度の実験

前項で UUID の timestamp が3セグメントに区切られているのは、比較演算の効率が良くなるからではないかという仮説を挙げた。実際に比較速度に差が出るのかを試してみる。

そのために以下のような関数を作成してみた。やっていることは単純に、UUIDv1 の timestamp 部分を入れ替えて形式を整えているだけである。

import uuid

def timestamp_sorted(uuid1):
    split_uuid = str(uuid1).split("-")
    joined_uuid = f"{split_uuid[2]}{split_uuid[1]}{split_uuid[0]}{split_uuid[3]}{split_uuid[4]}" # timestamp 部分の順番を入れ替える
    hyphen_separated = f"{joined_uuid[:8]}-{joined_uuid[8:12]}-{joined_uuid[12:16]}-{joined_uuid[16:20]}-{joined_uuid[20:]}" # 8-4-4-12 に区切り直す
    return uuid.UUID(hyphen_separated)

uuid1 = uuid.uuid1()

print(uuid1)  # 57f5eda8-0de2-11ee-9592-1ff59d5c9f69
print(timestamp_sorted(uuid1))  # 11ee0de2-57f5-eda8-9592-1ff59d5c9f69

計測結果

普通の UUIDv1 と先程の関数を使って timestamp 部分を入れ替えた UUID をそれぞれ用意し、「str型」「int型」にキャストした。それを in 演算子で存在を確認しそれにかかる時間を計測した。

計測コードや実行環境はこちら

import uuid
import time

def timestamp_sorted(uuid1):
    split_uuid = str(uuid1).split("-")
    joined_uuid = f"{split_uuid[2]}{split_uuid[1]}{split_uuid[0]}{split_uuid[3]}{split_uuid[4]}" # timestamp 部分の順番を入れ替える
    hyphen_separated = f"{joined_uuid[:8]}-{joined_uuid[8:12]}-{joined_uuid[12:16]}-{joined_uuid[16:20]}-{joined_uuid[20:]}" # 8-4-4-12 に区切り直す
    return uuid.UUID(hyphen_separated)

for N in [10_000, 20_000, 40_000, 80_000]:
    # UUID 型
    original_uuids = []
    for i in range(N):
        original_uuids.append(uuid.uuid1(clock_seq=i))

    timestamp_sorted_uuids = []
    for uuid1 in original_uuids:
        timestamp_sorted_uuids.append(timestamp_sorted(uuid1))  # 公平にするために同じ UUID を使う

    # str 型
    original_str_uuids = [str(i) for i in original_uuids]
    timestamp_sorted_str_uuids = [str(i) for i in timestamp_sorted_uuids]

    # int 型
    original_int_uuids = [i.int for i in original_uuids]
    timestamp_sorted_int_uuids = [i.int for i in timestamp_sorted_uuids]


    # 比較速度を計測
    print(f"\nN={N}")
    st = time.time()
    for target in original_str_uuids:
        assert target in original_str_uuids
    print(f"(1) original_str_uuids: {time.time() - st}")

    st = time.time()
    for target in timestamp_sorted_str_uuids:
        assert target in timestamp_sorted_str_uuids
    print(f"(2) timestamp_sorted_str_uuids: {time.time() - st}")

    st = time.time()
    for target in original_int_uuids:
        assert target in original_int_uuids
    print(f"(3) original_int_uuids: {time.time() - st}")

    st = time.time()
    for target in timestamp_sorted_int_uuids:
        assert target in timestamp_sorted_int_uuids
    print(f"(4) timestamp_sorted_int_uuids: {time.time() - st}")

    st = time.time()
    for target in original_uuids:
        assert target in original_uuids
    print(f"(5) original_uuids: {time.time() - st}")

    st = time.time()
    for target in timestamp_sorted_uuids:
        assert target in timestamp_sorted_uuids
    print(f"(6) timestamp_sorted_uuids: {time.time() - st}")


"""
# 実行環境
- CPU: Ryzen 5 3600
- RAM: DDR4 2666Mhz 16GB * 4

# 結果
N=10000
(1) original_str_uuids: 0.3417835235595703
(2) timestamp_sorted_str_uuids: 0.3435380458831787
(3) original_int_uuids: 0.22490262985229492
(4) timestamp_sorted_int_uuids: 0.24231791496276855
(5) original_uuids: 3.326829671859741
(6) timestamp_sorted_uuids: 3.4304888248443604

N=20000
(1) original_str_uuids: 1.3766262531280518
(2) timestamp_sorted_str_uuids: 1.414670705795288
(3) original_int_uuids: 0.9101331233978271
(4) timestamp_sorted_int_uuids: 0.971062421798706
(5) original_uuids: 13.154400110244751
(6) timestamp_sorted_uuids: 13.526567697525024

N=40000
(1) original_str_uuids: 5.531908750534058
(2) timestamp_sorted_str_uuids: 5.541355133056641
(3) original_int_uuids: 3.6439473628997803
(4) timestamp_sorted_int_uuids: 3.8880937099456787
(5) original_uuids: 52.94257402420044
(6) timestamp_sorted_uuids: 53.115707874298096

N=80000
(1) original_str_uuids: 22.447378873825073
(2) timestamp_sorted_str_uuids: 22.161968231201172
(3) original_int_uuids: 14.249858140945435
(4) timestamp_sorted_int_uuids: 15.570267915725708
(5) original_uuids: 210.6411235332489
(6) timestamp_sorted_uuids: 213.35009026527405
"""

実験の結果、やはり全体的に通常のUUID v1(RFC4122 準拠)の方が比較が速かった(比率がマイナス = RFC4122形式での比較の方が速い)。特に int 型では顕著に差が出た。

str 型と uuid 型では比較回数を増やしていくと差が縮まった。ループ回数を増やしたことで python 特有のオーバーヘッドが大きくなったからだと考えられそう。

結論としては、UUID のフォーマットが 8-4-4-4-12 と不思議な形になった理由には検索効率を高めるための工夫が隠れていたと考えるのは、そこまで見当違いでもないと思う。

uuid 型

N 通常の UUID timestamp 部分を入れ替えた UUID 比率
10,000 3.33 3.43 -3.12%
20,000 13.2 13.5 -2.83%
40,000 52.9 53.1 -0.33%
80,000 210.6 213.4 -1.29%

str 型

N 通常の UUID timestamp 部分を入れ替えた UUID 比率
10,000 0.342 0.344 -0.51%
20,000 1.38 1.41 -2.76%
40,000 5.53 5.54 -0.17%
80,000 22.4 22.2 1.27%

int 型

N 通常の UUID timestamp 部分を入れ替えた UUID 比率
10,000 0.225 0.242 -7.74%
20,000 0.910 0.971 -6.69%
40,000 3.64 3.89 -6.70%
80,000 14.2 15.6 -9.27%

※経過時間の単位は秒

参考


  1. node に乱数を使う場合、最初のオクテットの最下位ビットが 1 に設定される。ネットワークカードから取得した MAC アドレスは必ずこのビットが0になっており、MAC アドレスとは被らないことが保証される。
  2. (232-1) * 100 * 10^-9
  3. (248-1) * 100 * 10^-9
  4. 現在の UUID のバリアント 0b0 は Network Computer System の UUID として定義されている。Network Computer System のアドレスファミリーは 0~13 しか使われておらずセグメントの最上位ビットは必ず 0 なので互換性が保たれている。

Tensorflow.js の学習時(fit)に出てくるログを無効化したい

デフォルトで tensorflow の model.fit() を実行すると以下のようなログが出てくる。見たいログが埋もれてしまい邪魔なので消そうと思ったがすぐ見つからなかったので残しておく。

Epoch 1 / 8
31ms 122us/step - loss=0.150

verbose (ModelLoggingVerbosity) Verbosity level.

Expected to be 0, 1, or 2. Default: 1. 0 - No printed message during fit() call. 1 - In Node.js (tfjs-node), prints the progress bar, together with real-time updates of loss and metric values and training speed. In the browser: no action. This is the default. 2 - Not implemented yet. ( TensorFlow.js API の fit )

verbose を 0 に設定するだけ、完。

最初見たときは verbose ってなんだよ!!ってなったけど、ログレベルのことを一般的にこう呼ぶこともあるらしい....。ちょっとだけ賢くなった。

model.fit(x, y, {
    verbose: 0, // log 無効化
});

乱数生成器の気持ちになって、キーボードをたたきまくるゲームを作ってみた

誰しも一度くらいは、乱数生成器を疑似体験1してみたいと思ったことがあるのではないでしょうか?

あなたは乱数が好きですか?私はどちらかと言えば好きよりの人間です。どれくらいかと言えば、三度は思うほどに。飯の代わりになるほどに。

乱数を疑似体験したい、すべての人に贈るゲームを作ってみました。

あなたは乱数生成器ですか?

あなたは乱数生成器ですか?のタイトル

あなたは乱数生成器ですか?

この記事を読んでいるほとんどの人は、乱数生成器ではないかもしれない。しかし、自分を乱数生成器だと主張する人が現れるかもしれない。そんな時にランダムにキーボードを叩かせて、その結果が非常にランダムだった場合、その人は乱数生成器といっても過言ではないだろう。

それを簡単にできるゲームを提供する(下のリンクから実際に遊べます)。

are-you-rng.game.burion.net

このゲームの遊び方は簡単だ、0123456789abcdefghijklmnopqrstuvwxyz をランダムに打つ、ただそれだけ。最後には、入力された乱数がどの程度ランダムかを評価してくれる2。そして、そのスコアを既存のアルゴリズムと比較したランキングが表示される。

評価について

ゲームとして分かりやすい評価が無いか調べていたところ以下のような資料に当たった。その中で「度数検定」「継次検定」「ポーカー検定」の3つが何となく分かりやすそうだったのでその評価手法を参考にした。ただし、統計的に厳密な手法ではなく何となく雰囲気で実装しているため、正しい評価にはなってないと思う。

長崎大学学術研究成果リポジトリ

これらの手法は、乱数生成器が生成した乱数のサンプルを用意してそのサンプルと「完全な乱数であればこうなるだろう」という理論値と比較して、どれだけずれているかを計算する。この際にはカイ二乗検定によってどれくらいズレがあるかを評価する。

カイ二乗検定 - Wikipedia

ちなみにゲームではこの値を元にスコアを計算しているため、大きければ大きいほど「悪い乱数」という評価になる。

度数検定

0~9a~z は36種類あるため完全にランダムに重複ありで選択した場合、1/36 (2.78%)の確率でそれぞれの文字が出現する。ゲームではプレイヤーが入力した文字を数え上げ、その割合が 1/36 に近いかどうかを計算している。

継次検定

0~9a~z を完全にランダムに2つ選択した場合、その並び順(0->0, 0->1 .... 0->z, .... z->z)は 1/362(0.08%)で出現する。

このゲームでは 200文字しかサンプル数を得られないため、出現回数の期待値が0未満(200 * 0.0008 = 0.16)となってしまい正しく判定できない。そのため 0~9a~z の 36種類を Mod 演算的に 0~8 の9種類に変換している。

こうすることで、ある並び順の出現確率が 1/91(1.23%)になり、200文字の中には 2.5回程度は現れる計算になるため大分マシになった。

ポーカー検定

0~9a~z を完全にランダムに5つ選択すると、ちょうどポーカーの手札のようになる。この時の役(ノーペア、ワンペア、ツーペア、スリーカード、フルハウス、フォーカード、ファイブカード)が出現する確率は計算で求めることができる。

ゲームでは入力された200文字から5個セットの手札を作り、そこに含まれる役を数え上げて期待値と比較する。

ポーカーの役が出現する確率の期待値
「乱数の検定について」より、表3・1 ポーカー帰無仮説

こちらも 0~9a~z をそのまま使ってしまうと出現確率の低い役が全く発生しないので、継次検定同様に36種類の文字を 0~8の9種類に変換している。更に、出現確率のかなり低いフォーカードとファイブカードは「フォーカード+」とまとめた。これによってある程度まともにスコアが計算できるようになった。

ちなみに、確率の期待値としては上の表の値を使用している。作り終わってしばらくして気づいたのだが、この確率は 0~9 の数字をランダムに選択したときのものであるがこのゲームでは 0~8 の9種類のため役の出現確率は正しくない....。

1種類の差なので恐らく大きくずれているとは思えないが、検定としては全く正しくない評価になってしまっている。

ここはゲームということで許して......

Twitter で結果をシェアできるようにしてみた

リザルト画面のTweetボタン

せっかくゲームを作ったのだから、結果を Tweet する機能も作ってみてみたい!ということで、シェアボタンを設置してみた。

このボタンから Tweet すると、プレイヤーが入力した乱数列が埋め込まれたリンクがシェアされるため、それを開くことで結果を再現できる。

ランキングにいる他の乱数(?)たちは、実際にプレイヤーに行っている評価を計算しているが seed を固定して毎回同じ値になるように工夫もしてみた。

ソースコード

github.com

参考


  1. 疑似乱数生成器体験と並び変えた方が良いかもしれない
  2. 簡易的な手法かつ適当に調べて作ったので、精度は期待しないでほしい。また、たった 200桁しか利用できないので大数の法則が効いてくれない。

数学が苦手でも、強化学習の仕組みを理解したいなんて

モチベーション

半年ほど前、「強化学習」に興味を持ち勉強しようとして検索していたが、やはり数学や数式がたくさん出てくる。機械学習の分野では当然必要なスキルであることは理解していても、苦手なものはやっぱり苦手なので読む気にならない。(というかまともに読めない)

逆に数学が無い説明を見つけたとしても、説明がふわっとしていて実装はおろか実際どういう処理が行われているかすらイメージできない状況だった。

そんな中で『強化学習(第2版)』を見つけて読んでみた。必要なところでは数学が出てくるが他の本と比較すると控えめだし、その数学があるページでさえ例を交えて丁寧に説明されていて置いてけぼりにならずに済んだ。

また、難しくて他の部分の理解に必要じゃない章には * マークが付けられており飛ばして読むことで、ある程度要領よく読むことができたと思う(ちゃんと理解できたかはさておき)。結構厚めの本ではあるが強化学習の理論を深めに勉強したいが数学は苦手!という人にはお勧めしたい。

また、先に読んでおいたことで数学アレルギーが軽減されたかもしれない『数学ビギナーズマニュアル』もついでに共有しておく(薄いのでサクッと読めた)。

自分の勉強を兼ねて、数学や数式をなるべく使わずに半年前の俺に話すならこんな感じかな~と強化学習をイメージできるように頑張って説明してみようと思う。

強化学習の何が嬉しいのか

機械学習で特に有名なのは深層学習(ディープラーニング)だと思うが、これは機械学習のジャンルとしては教師あり学習に分類される。

教師あり学習は「入力値」と「期待する出力値」のサンプルを大量に与え、現在の出力と期待する出力の誤差を減らすように内部パラメータを調整することを繰り返す。うまく学習ができると入力に対して期待した出力を返す関数を作ることができるし、学習に使っていない入力を与えた時にある程度期待した出力が出てくるようなものになる。

色々な手法はあるものの教師あり学習は、入力に対して期待する出力、すなわち答えを定義できなければいけない。

例えば犬か猫の写真を入力すると犬か猫を判定する判定機を作る場合では、犬の写真に対して「犬」という答えを、猫の写真には「猫」という答えを一意に決めることができる。当然だけど。

犬と猫とラベル

教師あり学習は人間でいえば英単語を覚えるために英単語(入力)を見て日本語訳(期待する出力)を言えるようになんども英単語帳を反復するようなイメージに近いかもしれない。

しかし問題によってはこのような明確な答えを与えることができない(もしくは非常に難しい)特性のものがある。

付箋

制御の問題はわかりやすいと思う。たとえばスーパーマリオブラザーズの画面を見ながら敵を避けたり落下しないようにマリオを適切に操作してゴールまで移動させることは制御だと考えることができる。

store-jp.nintendo.com

これを教師あり学習で行うのはかなり難しいと思う。前述した犬猫判定機の例と違って、入力と出力を考えようとしても何だか楽そうじゃない。「Aの状態のときはジャンプする」「Bの状態のときは右に走る」「Cの状態のときは左に向かってジャンプする」....という全パターンを網羅すればうまくいくかもしれない。

しかし、作業を進めているとマリオの位置だけじゃなく、クリボーやノコノコの位置、ブロックの状態、残り時間....など様々な状況を考慮しないといけないことに気づくはずだ。状態の数が多すぎて全てが嫌になってきた。

当然ながら英単語帳を反復する様にスーパーマリオブラザーズをプレイする人なんていない(もしいたらごめんなさい)。人間が初めてプレイする際は色々と操作して何度も死にながら、どう攻略すればいいか試行錯誤を繰り返すことで徐々にゴールへ近づけるようになっていく。

強化学習ではこれに近い「試行錯誤」のアプローチを取ることができる。この点で教師あり学習と違って制御問題では有利になる。

強化学習の説明でよく見るすごく抽象的な図を描いてみた。またこの意味わからない図が出たよ.....と読む気が失せて戻るボタンにカーソルを動かすのはもう少しだけ待ってほしい。

よくある強化学習の図

教科書的な説明だが、強化学習ではエージェントが以下のような処理を繰り返し行い、最終的に得られる報酬を最大化するように試行錯誤を繰り返す。このアプローチによって適切な報酬を与えてあげることにより徐々にゴールへ近づくことができるようになる。

  1. environment(ゲーム)から state(現在の状態、すなわちゲーム画面)を受け取る
  2. agent(マリオを操作するプログラム)は state を元に action(移動する方向やジャンプ・ダッシュするかどうかなど)を選択する
  3. action を実行する
  4. environment から次の state と reward(報酬、例えば前に進めば +1、死んだら -100 みたいな値)を受け取る
  5. reward を使い agent の判断を改善する
  6. 2に戻り繰り返す

それは分かったから、どうやって判断したり学習するの?

この図を見た時にそう思った。あの図だけでは強化学習を具体的にイメージすることができない。ここからは強化学習の仕組みについてもう少し具体的に(そして単純化のために詳細を省いたり、いろいろと誤魔化しながら)説明してみようと思う。

マルコフ決定過程(MDP)

強化学習では環境(environment)をマルコフ決定過程(MDP)という確率モデルとして扱う。

以下の図では S0, S1, S2 の状態(state)があり、a0, a1 の行動(action)を選択すると、予め決められた確率によって次の状態に推移する。またその際に報酬(reward、黄色い矢印)を受け取ることがある。

たとえば、最初は S0 の状態にあるとする。そこから a1 の行動を選択すると S2 に 100% の確率で遷移する。S2 の状態で a1 の行動を選択すると 40%の確率で元の S2 に戻り、30%の確率で S1 に遷移し、30% の確率で報酬 -1 を得て S0 に遷移する....という、そんな感じのモデル。

Markov Decision Process example

また、このモデルはマルコフ性を仮定している。マルコフ性とは、行動後の状態遷移確率や報酬は、元の状態と行動にのみ依存するような性質。つまり、過去にどのようなことが起きようが全く関係なく、現在の状態と選択した行動が同じであれば状態遷移の確率や報酬の期待値は同じだとする。1

この簡単な例では報酬の最大化は簡単だろう。以下のような組み合わせの行動をとり続けることで長期的に報酬を多く得ることができるようになる。これは言い換えると、「将来的に得られる報酬の期待値」が最大になる状態と行動の組み合わせである。

状態 最適な行動
S0 a1
S1 a0
S2 a1

このように、報酬の期待値が最大になる状態と行動の組み合わせを「最適方策」と呼ぶ。強化学習はこれを探すことが目的になる。

もちろんこんなに単純な例は珍しい。そもそも現在の状態から行動を選択した際にある状態に遷移する確率(ダイナミクスと呼ぶ)は事前に分からない場合が多いし2、状態数や取れる行動の数ももっと多い。さらに、ダイナミクスや報酬が時間とともに変化する場合もある。

テーブル形式での強化学習

行動価値関数

報酬の期待値が高い行動ができるようにするためには、まず行動を評価できるようにする必要がある。そのために「行動価値関数」を定義する。

行動価値関数とは状態と行動を渡すとその推定価値を返す関数で Q(s, a) と表したりする。以下の画像でいうと Q(S1, a0) と書くことで S1 の状態から行動 a0 をしたとき、将来的に得られる報酬の期待値が返るというもの。

状態と行動

完璧な行動価値関数が作れれば、現在の状態で一番価値が高い行動を選び続けることで最適な行動がとれるようになる。(もちろんここが難しい)

関数といっても一番単純な手法では、状態*行動の組み合わせごとに数値を保存しておくだけの形式が考えられる(テーブル形式)。3

当然ながら最初は行動価値が分からないので、とりあえず 0で初期化しておくとする。この行動価値関数をどのように改善していくのかを次の章で説明する。

行動価値関数の改善

パブロフの犬の実験がある。メトロノームの音を犬に聞かせた後エサを与えると唾液を出す。この手順を繰り返すことによって犬はメトロノームの音を聞くだけで唾液を出すようになる。

更にそのあと(エサは一切見せずに)黒い四角形の画像を見せる -> メトロノームの音を聞かせることを繰り返すと「黒い四角形の画像」を見ただけで唾液を出すようになるらしい。4

犬が唾液を出すのはエサを食べることを期待するからだが、本来関係のないメトロノームの音とエサを結び付けることでメトロノームの音を聞いただけでエサを期待できると学習した。また、この条件は経験で得たもので犬はメトロノームの音自体を求めてる訳ではない。にもかかわらず、メトロノームの音に関連付けされた黒い四角形の画像を、エサの期待に結びつけるように学習した。

パブロフの犬の実験

恣意的な実験なので奇妙な結果になってしまっているが、自然状況下では例えばシマウマがライオンに捕食されないようにするために「捕食される事」と直接関係ないライオンの見た目、足跡や鳴き声、その他の雰囲気などを怖がって避けるようにすることで生存率は上がることは想像できる。

強化学習でも行動価値関数の更新を似たように行う。行動価値関数が初期値だと最適な行動が分からないためエージェントは最初はランダムに動く。そして正の報酬が得られた際には「その前の状態でとった行動」というのは高い価値(逆に報酬が負の場合は低い価値)と更新する。

例えばエージェントがランダムに行動を繰り返した結果、以下の図の様に S2 で行動 a0 をした際に正の報酬が発生したとする(既にとった行動を表しているためダイナミクスの考慮はしていない点に注意。S2 で行動 a0 を選択したからと言って必ず S3 に遷移できるとは限らない)。

すると、Q(S2, a0) は報酬を期待できるので価値が高くなり、S2 状態に遷移する行動の価値 Q(S1, a1) も少し高くなる。こういった更新を繰り返すことによりダイナミクスが未知だったとしても徐々に正の報酬が得やすい行動価値がより高く、報酬が得にくかったり負の報酬が得やすい行動価値がより低く更新されていく。

報酬の伝播

ちなみに、得られた報酬をどれだけ後ろに伝播させるかは手法によっては調整できたりする。この後説明する手法は1回のみの最も単純な方法になる。5

ε-greedy方策

行動は完全にランダムだったとしても評価の価値更新はできる。しかし目的は正しく行動価値を評価することではなく最適方策を探すことなので、明らかに価値が低いとわかっている行動も高い行動も平等に選択してしまうと学習効率が悪くなる。逆に、価値の推定値が高い行動ばかりをとっていると「現時点では低いと推定されているものの、実は価値が高い行動」が学習できないため、局所的な最適解からいつまでたっても抜け出せない。

この中間の行動の方策として ε-greedy方策 がある。これは確率 ε でランダムに行動を取り、(1 - ε) で「現時点で最も価値が高いと推定されている行動」をとるといったもの。ε は 0.1 とか小さい確率が使われることが多い。

学習が終わった後は、推定価値が高い行動だけをとる greedy 方策に切り替えればいい。

単純な問題を実際にとかせてみる

Q学習(TD法)のサンプルコード

非常に単純な問題を想定してみる。

このゲームでは S0 ~ S3 のマスがあり、行動は Right, Left の2つが選択できる。ゴール地点である S3 のマスにいる場合は Right, Left のどちらを選択したとしても報酬 +1 を得て S0 のマスに飛ばされる...といったもの。この程度の問題であればテーブル方式でも余裕で実装できる。

練習用の簡単な問題

ちなみに、このゲームも前述したマルコフ決定過程(MDP)のモデル図として表すことができる。(行動時の確率的な遷移は存在しないので、遷移確率はすべて 1)

モデル図を見る ゲームのMDPモデル図

このコードでは以下のような処理を繰り返すことで、行動価値関数を改善する。

  1. エージェントが ε-greedy 方策を元に行動を選択する
  2. 行動によって得られる報酬と遷移先の状態を受け取る
  3. 報酬を用いてテーブルに保存されている行動価値を改善する

そしてこれを実際に TypeScript で実装したコードは以下にある。(TypeScript Playground ですぐ実行できる)

github.com

このプログラムでは実行すると、試行錯誤を行いながら報酬を 10回手に入れるまで動き続ける。初めのうちはランダムに行動を選択するためなかなか S3までたどり着けない。12回行動してやっと報酬が得られるようなケースも見られた。

学習前

学習を進めていくと、最後の方では最適方策(Right を選択し続ける)を学習でき、最小の4回の行動で報酬が得られるようになった。

学習後

処理内容は前述のステップの通りであるが、「3. 報酬を用いてテーブルに保存されている行動価値を改善する」部分が少し理解しにくいので説明する。

行動価値の更新を行っているのは updateQ(s, a, r, nextS) 関数である。Q(s, a) 関数は state と action を渡すとその現在の推定価値をテーブルから取得する関数。maxQ(s) は state を渡すととれるすべての行動の中で一番高い推定価値を返す関数である。

function Q(s: State, a: Action): number {
    const actionValue = qTable.get(getKey(s, a));
    if (actionValue === undefined) {
        return 0;
    }
    return actionValue;
}

function updateQ(s: State, a: Action, r: number, nextS: State): void {
    const step = 0.1; // 学習率
    const discount = 0.9; // 割引率

    const newQ = Q(s, a) + step * (r + discount * maxQ(nextS) - Q(s, a));
    qTable.set(getKey(s, a), newQ);
}

function maxQ(s: State): number {
    const rightActionValue = Q(s, "Right");
    const leftActionValue = Q(s, "Left");
    return Math.max(rightActionValue, leftActionValue);
}

updateQ 関数の newQ = Q(s, a) + step * (r + discount * maxQ(nextS) - Q(s, a)); は3ステップに分解できる。書き込んだコメントの様に Q(s, a) を target に近づけるように更新を行っている。

// 報酬 + 次の状態で greedy に行動を選択した際得られる報酬の推定値を減衰させたもの
const target = r + discount * maxQ(nextS);

// target と現在の行動価値関数 Q(s, a) との誤差
const error = target - Q(s, a);

// Q(s, a) を step 分だけ target に近づけるように更新する
const newQ = Q(s, a) + step * error;

ちなみに、step は学習率で一度にどれくらい更新を行うかを決めるための 0 < step <= 1 のパラメーター。大きくしすぎると発散してしまいうまく学習できないし、小さくしすぎると収束に時間がかかるようになるような値である。

discount は割引率でどれくらい将来の報酬の価値を重視するかを決めるための、0 <= discount <= 1 のパラメーター。 0 に設定すると報酬 r だけが残るため近視眼的なエージェントになるし、1 に近づけていくほど長期的な視点で学習できるようになる。

エピソードタスクと連続タスク

チェスの様に終わりがあるようなタスクを「エピソード的タスク」といい、1回目のエピソードと2回目のエピソードは完全に独立している。逆に、不安定な道を歩き続けるロボットの様に明確な区切りが無く延々と学習させられるようなタスクを「連続タスク」と呼ぶ。

割引率を1に設定した場合、エピソードタスクの場合は問題ないが、連続タスクでは報酬がどんどん大きくなってしまってパラメーターがオーバーフローしてしまう可能性がある。状況にもよるが、一般的にそれを避ける意味で 1未満にしておいた方が良い。

テーブル形式の限界

先ほどのサンプルコードでは行動価値関数 Q(s, a) をテーブル形式で実装したが、テーブル形式には以下の欠点がある。

  • 状態*行動の組み合わせが非常に小さい場合でのみ使用できる(強化学習を使いたい現実の問題のほとんどではメモリが溢れる)
  • 状態行動価値を丸覚えしているため、学習時に現れる頻度が低かった状況については上手く判断できない(般化性能が無い)

こういった欠点を解消するために、行動価値関数には深層学習(ディープラーニング)をはじめとした教師あり学習が使われる。行動価値関数を関数近似してしまうというアイデアだ。

2015年に、囲碁で人間のプロを破ったことで話題になった AlphaGo には DQN (Deep Q-Network)という Q学習 & 深層学習 のアプローチが使われていた。

ja.wikipedia.org

We trained this model using Reinforcement Learning from Human Feedback (RLHF), Introducing ChatGPT

2022年に発表された、非常に高性能なチャットボット ChatGPT では、GPT-3 や GPT-4 という言語モデルの精度を改善するために RLHF という強化学習の手法を用いているらしい。

応用の紹介に留め、踏み込むことは避けるが、実際に深層学習を行動価値関数として扱う場合には特有の問題があったりして、一筋縄ではいかないよう。

最後に

本記事では説明しやすい手法だったので Q学習(TD法)と ε-greedy方策を取り上げたが、学習時の方策や行動価値関数の更新方法のバリエーションはいくつか存在する6。また、Q学習(TD法)7では行動価値関数を学習することで最適方策を求めたが、方策を直接学習するような手法も存在しそれ特有のメリットがある。

状態をどう与えるかや報酬の与え方をいい感じに設定してあげないとうまく学習できないので、取り組むタスクごとに試行錯誤することも必要になる。

また、数学よりの説明を求めている方は以下の本が合うかもしれない(軽くページをめくっただけでくしゃみが止まらなくくなった)


  1. 強化学習の基本的なアルゴリズムではマルコフ性を仮定するので、仮定から大きく外れるタスクには弱かったりする
  2. ちなみに環境のダイナミクスが分かっている状態であればベルマン方程式を解くことで期待値を計算できるらしいが、めちゃくちゃ複雑な計算になり現実的じゃないので実際は動的計画法での近似を求める。
  3. 勘のいい方は察したかもしれないが、状態数や行動数が多くなると組み合わせ爆発を起こすので非常に単純な問題にしか使えない。詳しくは「テーブル形式の限界」で軽く説明する。
  4. この特性は古典条件付けの高次条件付けと呼ばれるらしい
  5. 『強化学習(第2版)』では伝播回数を一般化した TD(λ)法などが紹介されていた。本記事で紹介している手法はこれのもっとも単純な TD(0) といえるらしい。
  6. 例えばSARSA法というものがあり、行動価値関数の更新方法がQ学習と少し異なる。実装してみたものの結局使使わなかったコードがあるので共有しておく。blog-assets/reinforcement_learning/td-sarsa.ts at main · buri83/blog-assets · GitHub
  7. 急に出てきて、一切説明がないけれど結局 Q学習やTD法って何?と気になって、そのままここまで読み進めてしまった結果、睡眠の質が悪くなる運命が確定した方がいるかもしれない(可哀そうに)。しかし、ここはすでに「最後に」の章なのである。わざわざ大きな文字で「最後に」と題している以上はダラダラと説明するのも避けたいところだが、ここから用語の説明を始めてしまうと話が膨らんでしまいそうな予感がする。もしかすると、この人生でしてきた予感という予感の中でも相当大きなものかもしれない。そうじゃないかというほどに、ひしひし予感を感じてる。当然ながら、誠に当然のことながら説明したいのは山々なのだが、断念せざるを得ないことは非常に悔やまれる。あーあ残念だ、本当に。そしてここまで長々と文章を書き連ねてきたからかヘトヘトで、これ以上章を増やすのも、他の章との整合性を直すのも、新しい説明を考えたりすることも面倒くさくなってきた。ウダウダと書いてはいるが要するに、兎にも角にも疲れたのだ、そろそろ筆をおきたいな...。そういうことで、寝れないあなたは今流行りの ChatGPT にでも尋ねてみてほしい。グッナイ!!!!!!!!!!

XSSのエスケープだけで防げないケース、その対策とテスト方法

XSS (クロスサイトスクリプティング)とは Web サイトの代表的な脆弱性。有名なだけあって実際 XSS の脆弱性があるだけでマジでやばいが、その割に対策は意外と簡単じゃなかったりする。

脆弱性自体のヤバさは他の記事に説明してもらう事にして、エスケープだけでは防げないケースが面白かったのでまとめてみる。最後に簡易的なテスト方法も紹介する。

エスケープ(サニタイジング)による基本的な対策

XSS を防ぐ根本的対策として良く挙げられるのは攻撃に使われる HTML の特殊文字をサーバーサイドでレンダリングする際にエスケープして「ただの文字」にしてしまうこと。

& < > " ' → escape → &amp; &lt; &gt; &quot; &#039;

基本的にはこれによって多くのケースを防ぐことはできるが、防げないものもある。

以降は jinja みたいなテンプレートエンジンを使用して HTML を生成する際に、{{ data }} と書くと data に入っている値が(自動的に)エスケープされて埋め込まれると思って読んでほしい。

Template Designer Documentation — Jinja Documentation (3.1.x)

エスケープするだけでは防げないケース

script タグの内側にレンダリングした場合

script タグの内側に埋め込まれた場合、エスケープされる文字が含まれていなくても攻撃コードを注入できる。以下のようなコードでは、data に alert() が渡されるとダイアログが表示される。

埋め込む場合は必ず "{{ data }}"'{{ data }}' の様に囲ってあげる必要がある。

<script>
    const userName = {{ data }};
    // userName を使う処理
</script>

「"」や「'」無しに任意のタグの属性としてレンダリングした場合

例えばフォームの初期値として入れるために input タグの value にレンダリングする事がある。

以下の様に書かれていて、 data に 1 onmouseover=alert() style=position:absolute;top:0;left:0;height:4000;width:4000 が渡された場合、ブラウザにカーソルが乗った瞬間にダイアログが表示される。

こちらも対策としては "{{ data }}"'{{ data }}' と囲ってあげる必要がある。

<form>
    <input type=text value={{ data }}>
</form>

a タグの href 属性にレンダリングした場合

a タグの href には通常 URL を入れるが、data に javascript:alert() と javascript: から始まる文字列を入れることで、クリックされたときに JavaScript として解釈されて実行される。

このケースでは http: から始まらない URL は無効化(例えばブランクに置換してしまう)するという対策がとれる。

<a href="{{ data }}">テスト</a>

(IE5, IE6, IE7 のみ) スタイルシートがレンダリングできる場合

流石に問題視されたのか、殆どのブラウザでは動かない。

IE5~IE7 では CSS の expression() に JavaScript を埋め込むことができたらしい。data に left:expression(alert()) を渡すことで ダイアログが表示される(環境が無くて試せなかったけど)。

<div style="{{ data }}">テスト</div>

DOM-based XSS

若干趣旨と異なるが、JavaScript を使ったクライアントサイドレンダリング時にも XSS に脆弱になる場合がある。サーバーサイドでのレンダリング時にエスケープが十分できていてもクライアントサイドが疎かだと問題になってしまう。

以下の様に JavaScript でデータをとってきた後、innerHTML (もしくは outerHTML やライブラリやフレームワークの、直接 HTML を生成できる機能)を使ってレンダリングすると、ダイアログが表示されてしまう。ちなみにinnerHTMLで直接 script タグを埋め込んでも動かないようになっている(HTML の仕様)。

対策としては、innerHTML の代わりに textContent を使用するか、事前にエスケープ処理を行う必要がある。さらに、エスケープする場合も前述のケースに気を付ける必要がありそう。

<p id="info"></p>

<script>
    fetch("http://example.com/").then((response) => {
        // "<img src='x' onerror='alert()'>"
        document.getElementById("info").innerHTML = response.text();
    })
</script>

テスト方法

レンダリング時にエスケープを完全に施したとしても、挿入位置によっては XSS 攻撃を防げない場合があることが分かった。

簡易的なテスト方法としては紹介してきたような攻撃コードを入れてみて、alert が出たりタグが壊れ HTML の表示が崩れないことを確認するということができる。

以下にいくつか入力パターンを載せておく(網羅性は保証しないけど)

テストパターン

エスケープ漏れ

"><script>alert()</script><!--
'><script>alert()</script><!--
</script><script>alert()</script><!--
<img src=x onerror=alert()>

エスケープしていても脆弱になるケース

alert()
1 onmouseover=alert() style=position:absolute;top:0;left:0;height:4000;width:4000
javascript:alert()

参考

失敗した場合に安全に処理を中断するためのbash内での環境変数export方法(ShellCheck に教えてもらう)

例えば以下のようなスクリプトがあって、その結果を bash 内で実行し標準出力を環境変数として export したい。しかも、そのスクリプトは失敗する可能性があると仮定する(インターネット接続が必要なのに繋がってない環境で実行された。設定ミスによって権限が足りてない。単純なバグ...など)。

これがビルドやデプロイのために実行されるコードだった場合(例えば Docker の entrypoint )、実行に失敗した瞬間に安全のため処理を中断させたい。必要な環境変数を設定し損ねたアプリケーションは正常に動くとは全く期待できないし、想定してない動作をする恐れもあるため。

import sys

# 検証のため何か引数が渡された場合、エラーにする
raise_error = bool(len(sys.argv) >= 2 and sys.argv[1])
if raise_error:
    raise Exception("Cannot get the token.") 

print("secret-token-string")

中断させる書き方

set -e と書くことで、実行したコマンドでエラーが出た時に中断させることができる。

export は一行で export SECRET_TOKEN="$(python ./get_token.py)" と書くこともできるが、このように書いてしまうと python ./get_token.py が失敗しても「export コマンド」は成功する。よって最終的なコマンドの実行ステータスは 0 となり中断されない。

export コマンドを分けることによって、ちゃんとコマンドの失敗時に処理が中断されるようになる。

#!/bin/bash

set -e

SECRET_TOKEN="$(python ./get_token.py error)"
export SECRET_TOKEN

echo "\$SECRET_TOKEN = '$SECRET_TOKEN'"
Traceback (most recent call last):
  File "./get_token.py", line 6, in <module>
    raise Exception("Cannot get the token.") 
Exception: Cannot get the token.

export を一行で書いた場合

一番最後の echo コマンドが実行された。環境変数に空文字が設定された状態になってしまった。

#!/bin/bash

set -e

export SECRET_TOKEN="$(python ./get_token.py error)"
 
echo "\$SECRET_TOKEN = '$SECRET_TOKEN'"
Traceback (most recent call last):
  File "./get_token.py", line 6, in <module>
    raise Exception("Cannot get the token.") 
Exception: Cannot get the token.
$SECRET_TOKEN = ''

ShellCheck でまずい書き方を教えてもらう

export の書き方は ShellCheck の wiki で知ることができた。

ShellCheck: SC2155 – Declare and assign separately to avoid masking return values.

ShellCheck は shell script 用の静的な解析ツール(linter)で、VS Code の extension として使えたり、CI に組み込むことができる(公式の docker image があるので CI 化は楽にできた)。

個人的に、shell script に関してはわからないことだらけなので積極的に使っていきたい。

github.com

node-postgres の query_timeout でタイムアウトした後、DB のプロセスが残り続ける。

Node.js で PostgreSQL にアクセスするために node-postgres を使っている。設定で query_timeout を設定すると、実行時間が長いクエリをタイムアウトさせることができる。 しかし、タイムアウト後に DB 内のプロセスが残ったままになってリソースを圧迫し続けた。

SELECT * FROM pg_stat_activity;

node-postgres.com

結論

statement_timeout を設定することで、タイムアウト時に DB のプロセスをキャンセルできる。

statement_timeout?: number, // number of milliseconds before a statement in query will time out, default is no timeout
query_timeout?: number, // number of milliseconds before a query call will timeout, default is no timeout

クエリ実行のタイムアウト設定はこの2種類で、説明的にもタイムアウトのタイミングに違いがあるようにしか見えないが、コード見てみると実装が大きく違うっぽい。

DB クライアントのタイムアウト設定はどういう実装かちゃんと確認しておいた方が良さそう。

query_timeout

query_timeout は SQL 実行開始(直前)から一定時間後に例外を出すことで中断している。なので client 側ではタイムアウトになるが DB 上のプロセスは動き続ける。コード上ではこの辺。

readTimeout = config.query_timeout || this.connectionParameters.query_timeout
// ... 中略 ...
readTimeoutTimer = setTimeout(() => {
  var error = new Error('Query read timeout')

  process.nextTick(() => {
    query.handleError(error, this.connection)
  })

  queryCallback(error)

  // we already returned an error,
  // just do nothing if query completes
  query.callback = () => {}

  // Remove from queue
  var index = this.queryQueue.indexOf(query)
  if (index > -1) {
    this.queryQueue.splice(index, 1)
  }

  this._pulseQueryQueue()
}, readTimeout)

https://github.com/brianc/node-postgres/blob/master/packages/pg/lib/client.js#L527-L547

statement_timeout

一方、 statement_timeout は DB との connection 確立時に送信される。コード的にはこの辺り。

    // once connection is established send startup message
    con.on('connect', function () {
      if (self.ssl) {
        con.requestSsl()
      } else {
        con.startup(self.getStartupConf())
      }
    })

    con.on('sslconnect', function () {
      con.startup(self.getStartupConf())
    })

https://github.com/brianc/node-postgres/blob/master/packages/pg/lib/client.js#L115-L126

詳しくコードは読んでいないが、以下と同等にセッションに対して statement_timeout を設定してると思われる。よって、タイムアウトは DB 内で発生するのでプロセスが生き続けることがなくなる。

SET statement_timeout TO 10000;

PostgreSQL ドキュメンテーション: statement_timeout パラメータ

PythonのJinjaを使って環境毎の設定をシンプルに管理したい

ある OSS を使いたい。そして、テスト環境と本番環境ではもちろん異なる config ファイルを使う必要がある。

そういった状況での一番単純な方法として、test-config.yaml, prod-config.yaml のように環境毎の設定ファイルを作ることがまず思い浮かぶが、以下のようなデメリットがあると思う。

  • (環境間で共通の)設定を変えたい時に、両方のファイルを変更する必要があって面倒くさい
  • 手動で変更すると打ち間違えや変更漏れによって異なる設定になってしまう恐れがある
  • 意図しない差があるとテスト環境はうまく動くが本番環境で壊れるようなことが起きる

そこで、環境が複数あっても基本的な記述はひとつにまとめ、差がある部分だけを変数として分けることで環境間の差を最小限に保つことはできないかと考えていた。そうすることが出来ればリスクが最小限になる。1

結論としてはタイトルの通り python のテンプレートエンジンである jinja を使うことで簡単に達成できた。例では環境ごとの変数を YAML で書いているが、辞書型であれば何でも渡せるので少し修正すれば JSON や INI でも記述できる。

jinja は単純に変数をレンダリングするだけでなく、条件分岐やマクロ等の複雑な機能を使うこともできて便利。

jinja.palletsprojects.com

ちなみに、undefined=StrictUndefined を設定すれば、必要な変数が足りてない場合にエラーになるため設定ミスに早期で気づくことができる(自動ビルドに組み込んだり、CI 化も簡単)。

environment: broken

admin:
    p0rt: 9090  # port --> p0rt とタイポした
$ python render.py 
Traceback (most recent call last):
  ... 中略 ..
jinja2.exceptions.UndefinedError: 'dict object' has no attribute 'port'

サンプルコード

スクリプト

import yaml
import os
from jinja2 import Environment, FileSystemLoader, StrictUndefined

VARS_DIR = "./vars/"
OUTPUT_DIR  = "./output/"

# undefined=StrictUndefined に設定するとテンプレートが求める変数が存在しない場合に失敗させられる
env = Environment(loader=FileSystemLoader("./"), undefined=StrictUndefined)
template = env.get_template("./config.yaml.jinja2")

class RenderVars:
    def __init__(self, var_file, output):
        self.var_file = var_file
        self.output = output

render_vars = [
    RenderVars(var_file="test.yaml", output="test-config.yaml"),
    RenderVars(var_file="prod.yaml", output="prod-config.yaml"),
]

os.makedirs(OUTPUT_DIR, exist_ok=True)
for render_var in render_vars:
    var_path = VARS_DIR + render_var.var_file
    output_path = OUTPUT_DIR + render_var.output

    # 変数をファイルから読み出しレンダリングして、設定ファイルとして書き出す
    with open(var_path) as f:
        vars_dict = yaml.safe_load(f.read())
        template.stream(vars_dict).dump(output_path)

テンプレート

# environment: {{ environment }}

admin:
  address:
    socket_address:
      address: 127.0.0.1
      port_value: {{ admin.port }}

環境ごとの変数ファイル

environment: test

admin:
    port: 8080
environment: prod

admin:
    port: 80

実行結果

# environment: test

admin:
  address:
    socket_address:
      address: 127.0.0.1
      port_value: 8080
# environment: prod

admin:
  address:
    socket_address:
      address: 127.0.0.1
      port_value: 80

  1. もちろん本番環境の変数にミスがあれば壊れるため慎重に設定する必要はある。しかし、それ以外の部分についてはテスト環境でテストできていれば心配せずに済む。