2023年09月25日にQiitaで公開していた日記のアーカイブです。
こんな記事を見かけた
https://gigazine.net/news/20230812-ergodicity-breaking-peters-coin-toss/
「ピータースのコイントス」は「大勢の参加者の平均」を求めると必ずプラスになる賭け事に見えるのに、実際に何度も挑むと大敗を喫することになる魔のコインゲームです。
コイントスはコインを投げて裏が出るか表が出るかで賭ける運ゲーです。 ピーターのコイントスは、特定の条件の状態ではコイントスを繰り返せば損をする確率が上がるというアルゴリズムだそうです。
この記事ではコイントスを何度も繰り返して行い統計を出すツールが紹介されていますが、実際に投げてみて体感できるページは貼られていないように見受けられました...
おあー...
やってみたいな
ということで実際にピーターと同じ条件でコイントスを投げてみるコマンドツールをGoで作ってみました。
作戦
Goで作るところまで決まったのでざっくり目標を立てて、どういったコードにするかを考えていきます。 今回はこんな感じで考えてみました。
目標:ピーターのコイントスを体で感じる -目標へたどり着くための要件:CLIで件のコイントスが遊べる -更に細分化された要件: ・ピーターのコイントスの仕組みを理解する ・コイントスの仕組みをGOで再現する -さらにさらに細分化された条件: ・コインの裏表を実装 ・コインの裏表で金額が増減するようにする ・コインが無くなったら終了する。
ということで今回は
①コインを投げる ②コインの裏か表が出る ③コインが表なら勝ち、裏なら負けにする ④勝ちなら報酬が増え、負けなら報酬が減るようにする(コインを投げ続ける限り繰り返す)↺ ⑤お金が無くなったらゲームオーバー、途中で退場することも可能
という仕組みにしました。
組み立て
コインの表裏の実装
func coinToss() string { if rand.Intn(2) == 0 { return "表" } return "裏" }
まずはコインの裏と表をランダムで出す機能を実装していきます。
裏と表はランダムで出力される必要があるのでrandパッケージを使用して乱数生成により0か1を生成するようにします。
if rand.Intn(2) == 0
は「もしランダムで選んだのが0な時」となるため、半分の確率でこのパターンがマッチします。その場合は表、そうでない場合は裏を返すようにしました。
下準備
コインの挙動が決まったのでルールに沿って下準備します。
balance := 100.0 tosNage := 0
コイントスが始まった状態の初期状態のセッティングです。
バランス(残高)はコイントスが始まった段階で所持している金額です。 あまりもっていてもろくな使い方しないので100ドルにしておきました。
トスナゲはコインを投げた回数です。
コイントスのメインループを作る
コインを投げる、表か裏か、報酬か没収か といったコイントスのメインの部分を繰り返し動作できるようにします。
for { // ユーザーに続行または終了の選択を促すメッセージを表示 fmt.Print("続ける場合はエンターキーを押します、辞める場合は 'quit' を入力してください: ") var input string fmt.Scanln(&input) if strings.ToLower(input) == "quit" { break } tosResult := coinToss()
ゲームを辞めるタイミングは入力する方にに委ねることにします。エンターキーを押した場合は処理、quitと入力した場合にゲームを終了させる挙動にしました。コインを投げれば表か裏の結果が表示されます。
勝てば加点、負ければ減点する機能の実装
// コイントスの結果に応じて所持金を更新し、結果メッセージを表示 if tosResult == "表" { //表が出た場合 kachiKaisuu := 0.5 * balance balance += kachiKaisuu message = fmt.Sprintf("俺は賭けるぜ! #%d: おぎゃーっ”!やったぁ! $%.2f が全ての財産になりました。\n", tosNage+1, kachiKaisuu) } else { //裏が出た場合 makeKaisuu := 0.4 * balance balance -= makeKaisuu message = fmt.Sprintf("俺は賭けるぜ! #%d: ぎぎぇー!やっちまった... $%.2f が全ての財産になりました。\n", tosNage+1, makeKaisuu) }
コインを投げて表が出たら所持金の50%を追加でもらえる
とのことなので、表が出た場合は勝ちとして、現在の保有金に50%が加わるようにしました。 その加算された値とコインの面をユーザーにわかるように出力します。 逆に負けると40%差し引かれるので、同様の処理を行っています。
残高がそこを尽きた場合にゲームを終了させる
// 所持金が0以下になったら破産 if int(balance) <= 0 { message = "...破産しました。" break } tosNage++
ギャンブルはお金がないと遊べないので物理的に0円になった時点で強制退場する機能を実装しておきます。
もし、残高が0になったとき、処理が終了するという挙動なのですが、ここで僕が躓いたのは1行目
if balance <= 0{
ではなくif int(balance) <= 0 {
と書いた部分です。
これは残高が浮上点少数で計算することから0以下も参照してしまうことを防ぐために整数に変換しています。
これを行うことで物理的にお金として扱えない少数の桁に入った瞬間に強制退場させることが出来ます。
ここではメッセージ(文字列)に言葉を渡していますが、まだここで表示する機能は実装しません。
途中退場とゲームオーバーの文章の切り分け
if message != "...破産しました。" { fmt.Printf("コイントスが終了しました。総コイントス回数: %d\n", tosNage) } fmt.Println(message)
先程の処理を実行したかユーザー側が中断したかで表示する文章を切り替えます。 もし、ゲームオーバーではない場合には中断したということになるのでPrintf以下の表示を出します。
こんな感じで完成しましたよ、ワトソンくん。
実際に遊んでみる
早速遊んでみましょう。
そのまま走らせます。すると「あなたの全財産」が出力されます。
指示通りエンターを押していくといい感じに勝ったり負けたりして価格が増減していきます。
おあー!
残金が少数を下回ったので退場させられました。 ここは上手く動いたみたい。
次は途中中断。
何回か遊んで最後にquit。 ゲームが中断され投げた回数が表示されました。
どっちも上手くいってますね。よかった。
感想
今回はランダム関数とInt、Floatに関する知見、ギャンブルはよしておこうという教訓がこれを書くことによってわかった気になれました。 特に残高が無くなったのにゲームが続いてしまうバグは本気で悩んだので非常に勉強になりました。 冷や汗は出ましたが楽しかったです。
2023/10/15日補足
藤田様からバグを指摘頂き、どこが問題なのかを考えてみました。
①大量の金を稼いだら急に破産するバグ
運良く勝ち進んでいるのに急に破産宣告されるってかなり鬼畜ですよね。 何度かテストをしてみて推測するには、float64型はあくまで浮上点少数に近似する値を扱うものであって、極端に大きい、小さい値を扱った場合に精度がずれてしまう可能性があるのではないかと推測しました。
こうなるとfloat64を使わないかある程度安全な額でお帰りいただく方法を採用すべきと考えました。
//あまりにも金を稼がれたらカジノ側が困ってしまうので強制退場 if float64(balance)/100 >= 1000000 { message = "お客様!これ以上儲かっていただくと我がカジノが..." break }
$1000000以上稼がれた時点でお帰り頂ければ、理不尽な破産とカジノの経営破綻が防げます。
②なぜか合計金額がおかしい問題
毎回計算するのはいいのですが合計金額が合わない原因として考えられるのは、所持金の更新時に小数点数を使ってしまっているからではないかと推測しました。 kachiKaisuuとmakeKaisuuを使用せずに、直接balanceを使用すればずれが是正されると考え、
fmt.Printf("\n", tosNage+1, balance)
と修正しました。
おそらく双方の問題は合計金額の計算にfloatを使用している部分にあると見たので、intを使って作り直しつつ、各型がどのような数値を扱うのかを学んでいきたいと思います。
2023年11月補足
コードが上手く動かなかった原因はintで扱う部分をfloat64にキャストしてしまうミスがあったためでした。 ずっと悩んでたので助かりました。アドバイスありがとうございました。