いものやま。

雑多な知識の寄せ集め

未踏ジュニア2025年度成果報告会に行ってみた。

今年も未踏ジュニアの成果報告会が11/3(祝)にあり、自分も行ってきたのでその感想とか。

全体的な感想

相変わらず全体的にレベルが高く、今年も楽しく見ることができた。 クロージングで近畿大学の方もおっしゃってたけど、しっかりとユーザテストを行なって、フィードバックを反映できてるプロジェクトが多いのはとてもいいよね。

あと、生成AIもなんかもう道具として自然に溶け込んでる感じで、それを目玉にしているというより、さらっと使っている印象だった。 学習サポートとして用いる場合も、ノートや教科書の内容を取り込んで使うようになってて、そんなのも最近っぽいなと感じたり。

そんな感じで全体的にとてもいいんだけど、そうなってくると個人的にはやっぱり「これがやりたいんだ」みたいな動機がハッキリ見えるプロジェクトの方が刺さった。 あとでちょっと言及したいけど「Paper CAD」とか「TalkBoost」とかは特にそれがあった気がする。 一方で、「SynRM」みたいに、直接的な理由は語られなかったけどそれに掛ける努力と技術の高さから、なんか知らんがとんでもない熱量を持ってるんだなというのが伝わってくるプロジェクトもあったりするのが面白くもあったりするけど。

以下はいくつか印象に残った発表について。

Paper CAD

今回の発表の中で個人的に一番印象に残ったもので、「なるほど、そんな課題、需要があったのか」と目から鱗だった。

紙を使ってミニチュアの建物を作るときに、一番大変なのが建物の展開図を作ること。 それを、住所検索とかモデリングアプリから3Dデータを取り込んで、自動で展開図を作れるようにしたアプリ。 さらに、3Dモデルと展開図との対応を分かりやすく表示してくれたり、組み立てでも便利な機能は入ってる。

まず、紙でミニチュアの建物を作るというニーズがあることを知らなかったので、そこにビックリした。 たしかに言われてみれば模型とかでジオラマを作ってる人には刺さるニーズよね。 こういった自分の知らない分野での隠れたニーズを知れたのがとても大きい。 あと、そういうのが作れると分かると、自分でも作ってみたいなと思ったり。 作るのも飾るのも面白そう。

そして、国土交通省がPLATEAUという3D都市モデルを出してるのも知らなかったので、そんなのがあるんだとこれまた驚いた。

これを使って住所から建物の3Dデータを引っ張ってこれると。 使ってるデータフォーマットがちょっと特殊らしく、変換とかで苦労があったみたいだけど、そこを熱量持って取り組んだのはさすがという感じ。

あとは、実際に自分が第一のユーザとして、こうだったら使いやすいという機能を実現していってるのもとてもいい。 こういうDIY精神って、OSSとか同人誌の好きな自分としては、とても好感が持てた。

最適化やってる身としては、展開図をどのように作ってるのかもちょっと気になった。 同じ図形でも展開図は何通りか考えられるわけで、どういう評価軸で考えて最適なものにしてるのかなぁと。 たとえば、使う紙の面積が最小になるようにするとか、接着する辺の長さが最小になるようにするとか、いろいろ軸は考えられそう。 それがどんな形で最適化問題に落とし込めるのかとか、面白そうよね。

TalkBoost

吃音症の方の発話意欲を高めたいという想いで作られたアプリ。 デバイスを身につけて生活して、どんなときに吃音がでやすいかを分析し、ふりかえりができるようになってる。 発表者自身、吃音があっていろいろ諦めてきたことがあったけど、それでもやっぱり伝えることが大事だと思うようになり、このアプリを作ろうと思ったとのこと。

発表も大変そうだったし、緊張する場面だとより吃音も出やすかったと思うけど、堂々と発表できてたのが印象的。 自身がどうにかしたいと思ってる課題についてしっかり向き合って取り組んでいるのがいいよね。

吃音の話で思い出したのが『史上最強の哲学入門』とかを書かれている飲茶さん。 著書の一つである『飲茶の「最強!」のニーチェ』で、自身に吃音があったこととか、それがニーチェ哲学とどう関わってきたのかとかを書かれている。

その中では以下のような話も:

えっと、実はこれ、「吃音症あるある」なんだけど、吃音症の人って「言い換え」をうまくやれば、結構、会話ができたりするんだ。
ちょっと「五十音の表」を思い浮かべてみてくれないかな。 その表のうち、「ア行」と「カ行」が黒く塗りつぶされていると思ってほしい。 で、その黒くなった「ア行、カ行」から始まる単語を、僕は絶対に言うことができないんだ。 逆に言うと、「ア行、カ行」から始まらない単語だけを使えば、実はみんなと同じように会話ができるんだ。
ちなみに、その「使えない文字」の数は、日によって減ったり増えたりする。 調子が良い日は楽なんだけど、五十音の表が、ほとんど真っ黒な日もあって、そんな時はもう大変。 不自然な「言い換え」どころか、どんどん会話が虚言になっていく。
(いろいろ省略して引用)

なので、吃音の様子を分析していくこのアプリは、たしかに有効なんだろうなと思った。

ちなみに、この発表のように技術でアプローチしていく他に、飲茶さんのように哲学的な観点でアプローチしていく方向も、「発話意欲を高めたい」という心理的な問題の解決に役立つかもしれないので、興味があればこの飲茶さんの本も読んでみてほしいな。

Cian

ちょっと違う観点でなるほどなぁと思ったことがあったのがこの発表。

このアプリは、日記をもっと気軽に書いて、さらにふりかえりも気軽にできるようにしようというもの。 ここで、いろんな性格を持ったペルソナ(AI)が用意されていて、悩みを相談したり、一緒にふりかえりができるようになっている。

発表された方は過去に日記でのふりかえりで立ち直ったこともあるとのことで、だから日記を書くことは有意義で、それゆえ多くの人に日記を書いてほしいとこのアプリを作ったとのこと。 これはそうだよなぁと思いつつ、実際に強かったのはいいふりかえりができてたからかなとも思ったり。 日記は入り口で、もちろん記録を残しておくだけでもあとから見返して面白かったりするのでいいんだけど、そこからしっかりとふりかえりをできてたのが大きいようにも思う。 そういう意味で、一日や一週間という単位で、日記という記録からふりかえりをするだけじゃなくて、もっとパッと思い立ったときにふりかえりするのでもいいのかも。 森さんのふりかえり本とか、きっと学べることが多いだろうから、読んでみてほしいと思った。

で、ここからが本題で、相談役としていろんな性格のペルソナを用意した理由が、いろんな性格のAIと話すことで自分にはなかった価値観を見つけたりしていけるから、というものだったんだけど、今はそういう時代なのかぁと思ったり。

自分とかだと、自分にこれまでなかった価値観とか考えを知りたいときには、人の話を聞きにいったり、本を読んだりすることが多かったよなぁと。 それが今はAIと対話するとかなのかと驚いた。 逆に言うと、そういうときに本を読む人も減ってるのかも。

考えてみると、本とか読むときに自分自身の価値観や考え方を変えたり深めていったりするには、著者がどのように考えているかを汲み取って、それに対して自分の考えをぶつけていき、思考を練っていくといった、著者と対話するような読み方が必要になってくるけど、そういった読み方ってどこかで習った記憶がないので、けっこう難しいことなのかも。 そういう意味で、実際に対話しながら考えを深めていけるのはいいのかもしれない。 エコーチェンバーとかはちょっと怖いけど、まぁそれを防ぐ意味でも複数のAIが用意されてるんだろうなぁ。

ちなみに、自分が未踏ジュニアとかの発表を見にきてるのはこの「知らなかった価値観や考え方を知りたい」という部分もあって、この発表もそうだし、前述したPaper CADとかは、そういう意味でも面白いと思った。

SynRM

この発表者は人工心臓を作りたいという強い想いがあるらしく、これまでにも部品となるものをいろいろ作ってきているようで、シンプルに凄いと感心した。 このプロジェクトは(たぶんポンプを動かすための動力として)永久磁石が不要で耐久性も高いモーターを作るというもので、正直原理とかはよく分からなかったんだけど、「なんか凄いことやってる」と感じさせる熱量があった。 こういう技術に全振りしたようなプロジェクトもやっぱりいいよね。

個人的に興味深かったのは、作ったモーターは省電力で発熱もほぼないという話。 それだけ電気エネルギーを効率よく運動エネルギーに変えられてるということなんだと思うけど、それは人工心臓以外にも使い道がありそうに感じた。 発表の中でも応用として電気自動車や電車に使う話が出てて、実際にはトルクがどうなのかとかはあるだろうけど、もし使えるのなら凄くよさそうよね。


こんなところかな。

今日はここまで!

技書博12にサークル参加してきた。

10/26(日)に第十二回技術書同人誌博覧会にサークル「いもあらい。」として参加してきたので感想とか。

技書博12

大宮ソニックシティ

今回の会場は大宮ソニックシティ。 埼玉県民としては地味に嬉しかったりw

そして今回は試しに電車ではなく車で行ってみた。 大宮ならそこまで時間かからず行けるし、既刊のビジネスモデル本を刷り直したり、新刊の設計書本も刷ったので、それなりの量の本を持ち帰る必要があると思ったから。 そういうときに車は便利よね。

余裕を持って8時過ぎくらいに家を出て、9時半過ぎくらいに無事到着。 駐車場もソニックシティの地下駐車場に停められたのでよかった。 駐車場の通路幅とか狭くて擦らないか怖かったけど(^^;

10時になったらサークル入場開始で、刷った本がちゃんと届いてることを確認し、設営。

出店の様子

このあたりはもう慣れたものよね。

トークイベント

今回の技書博では面白い試みとしてトークイベントがあった。 10時半からということでトークイベントの会場に移動。

パネルディスカッションみたいな感じで、お題に対して緑陽社の神保さん、ねこのしっぽのねこ社長、しまや出版の 小早川社長がそれぞれお話しする感じだった。 普段なかなか聞けないような話を聞けたのがで面白かったなぁ。 それぞれキャラなのか方向性なのかの違いも感じられて、堅実さと誠実さが第一にある神保さん、自分自身を含め作者のやりたいことを実現しようとするねこ社長、ネタに全力で挑んでいく小早川社長みたいなイメージを自分は受けたかなぁ。

特殊装丁とかの話を聞くと、いつかやってみたいなぁと思いつつ、お値段もかかるだろうし、それを使う意義のある場面をどう作るのかという方が難しそうとも思ったり。 まぁ所詮は趣味だから、ただやってみたいからやるでもいいんだけど。 あとそういった特殊なことをやる場合にいろいろ相談するのは大変だなぁという感じもする。 悩ましいなぁ。

イベントの様子

トークイベントが終わって11:30から頒布開始。

こちらはいつも通りで、まったりした時間が流れた。 パラパラと、でもあまり途切れることもなくいろんな人が立ち止まってくれて、見本誌を手に取ってくれてた。 なんだかんだ苦労した新刊だったので、いろんな人に見てもらえたのは嬉しいよね。

今回は前回のイベント(技術書典18)の反省を踏まえて、見本誌には「見本」の紙を挟むようにして、平置きの方にも見本誌を置くようにしてみた。 どれくらい効果があったかはちょっと分からないけど(というのも立て掛けてる見本誌を手に取りたがる動きをする方もちらほらいた)、いい感じだったと思う。

もう少し改善ができるかなと思ったのは、平置きの新刊の位置で、立て掛けてる見本誌に近い方に新刊を置いていたけど、これは逆にした方がよかったかもしれない。 というのも、一方の見本誌が手に取られてると、他方の見本誌が陰になってしまって見えにくくなっていたから。 次ちょっと試してみたいかなぁ。

技書博名物のサークル向けお弁当サービス

カツサンド、美味しかった(^^)

新刊の話

今回の新刊は前回の『「ビジネスって何を学んだらいいの?」と思ったときに読む本』に続くシリーズということで『「設計書って何を書いたらいいの?」と思ったときに読む本』。

これは以前ブログで書いた内容を整理して書き直したもの。 章立てとか説明を整理して、さらに図をマシマシにして、よりよくできたと思う。

で、今回一番反省だったのが、スケジュール。

一度書いてる内容だから、サクッと書けるかなと気軽に構えてたんだけど、いざ書いてみると全然そんなことなかった。 執筆で一番大変なのが言葉選びだったりすることを考えればこれは当然で、見積もりが甘すぎた。 図を増やしたかったというのもあったし(実際12個も図を書いた)。

あと、普通に仕事が忙しくて平日は作業する気力が出なくて、土日しか作業ができなかったというのも。 車買ったので土日のどちらかは出掛けたいという気持ちもあったりで、作業できる時間はさらに減ったり。 でも気分転換しないと潰れてしまうだろうからなぁ。

さらに、いつもなら10/21(火)が締め切りになるんだけど、今回は直接搬入ではなく宅配搬入だったので、締め切りが10/17(金)だったのも痛かった。 土日が1回使えないのは厳しい。 睡眠時間を削りつつ、10/17(金)は午前半休もとって必死に作業するハメになった。 時間が足りなくて校正のチェックがしきれないまま入稿せざるをえなくなったのはよくなかったなぁ。 幸い、あとから確認して誤字らしいものはなさそうだったのでよかったけど。

そんな新刊については、次のイベントである技術書典19でも頒布予定で、それが終わったらBOOTHでも頒布予定。 興味のある人はぜひ。

今日はここまで!

OR学会の2025年秋季大会に行ってきた。

だいぶ時間が経ってしまったけど、9/10(水)〜9/12(金)でOR学会の2025年秋季シンポジウムと研究発表会があって、いろいろ話を聞いてきたので、感想とか。

シンポジウム

初日はシンポジウムで、関東から広島まで新幹線で移動して、そこからさらに電車で広島大学に行く予定だったんだけど、ここで思わぬトラブルが。 なんと大雨の影響で山陽本線が運休になり、広島から西条に行けない事態に。 しかたないので新幹線で東広島に向かったんだけど、ここでも信号機故障で遅延が発生。 新幹線に2時間強閉じ込められた。

そんなこんなでかなり遅れて広島大学に到着。 一応、シンポジウム側でも発表順を変えたり時間を後ろにズラしてたんだけど、半分の発表しか聞けなかった。

で、肝心の発表はというと、個人的にはそんなに刺さらなかったかなぁ。 大学発ベンチャーの話が聞きたかったんだけど、聞けなかったのが残念(動画が出るはずだけどまだ見れてない)。

特別講演

今回の特別講演は酪農や農業の話だった。

  • デジタル技術で変わる乳牛管理:異分野連携による酪農実装モデル
  • IoP技術を活用した農業課題への数理最適化アプローチ

普段なかなか聞けない話だから、面白かったなぁ。 知らない業界の話を聞けるのっていいよね。

EV活用に関する発表

自分の仕事に関連するので積極的に聞きにいったところもあるけど、EV活用に関する発表がだいぶ増えた印象。

次のような発表があった:

  • 時間窓および非線形放充電を考慮した電気自動車の配送計画問題
    • EVトラックでの配送計画
    • 以下のようなものを考慮に入れた:
      • 顧客が受け取れる時間の幅が決まっている(時間窓の設定)
      • 充電でSoCが高くなると効率が悪くなる(非線形
      • 放電で積載量が増えると効率が悪くなる(線形)
    • ヒューリスティクスで近似解を求めた
  • 消費電力と所要時間の不確実性を考慮した電動バスの運行計画最適化
    • EVバスの運行計画を作る
    • 電欠や遅延を回避するようにする
    • ロバスト最適化だと保守的だし、確率計画だと大規模になったときに大変
    • 複数シナリオを用意して不確実性を表現し、整数計画として定式化して解いた
  • BEVにおける交換式電池の割当や充放電計画作成の最適化
    • EVトラックで、手動交換可能な小型バッテリーをいくつか積んだものを考える
    • バッテリーの割当や充放電計画をどうすればいいか
    • 割当問題と充放電計画を交互に解いていく改善していくアプローチ
  • 充電の先着順制約を考慮したEVトラックの経路充電計画立案手法
    • 長距離輸送のEVトラックで、専用の充電スポットがいくつかあるような場合の充電計画
    • 先着トラックが優先的に充電できるように考慮した
    • 整数計画でまず荒く解いて、シミュレーションで細かい制約を満たすように調整

それぞれの発表で解こうとしている問題がけっこう違うのが面白いところ。

あと、質問の中では全固体電池とかどうなの?みたいなのもあったり。 それについては(企業秘密で)答えられないとかもあったので、現場の研究はもっと先のことをやってるんだろうなぁとも思った。 自分のところでも発表できない内容あるしなぁ。

分布的ロバスト最適化

今回、個人的にかなり気になったのは分布的ロバスト最適化。

「不確実性下の配電系統における分散型エネルギー資源制御に対するデータ駆動型分布的ロバスト最適化」という発表があって、これ自体は再エネの不確実性に対処するための制御をしようというものなんだけど、分布的ロバスト最適化というのを知らなかったので、そういうのもあるんだと感心した。

不確実性に対処する方法として、ロバスト最適化や確率計画があるわけだけど、ロバスト最適化は解が保守的になりすぎるというのがやはりツラいところ。 そこで出てきたのが分布的ロバスト最適化という手法で、イメージとしてはいろんな確率分布が考えられる中で、最悪な分布での期待値を最小化するというものらしい。 そうすると保守的になりにくく、かつ不確実性にもある程度対処した計画が得られるんだとか。

実際、数値実験での結果もいい感じで、たしかに本当に悪いときはロバスト最適化の方がいいんだけど、そうでないときは分布的ロバスト最適化の方がコストを抑えられてた。

この分布的ロバスト最適化については調べていきたいなぁ。 (ちなみに2021, 2022くらいでけっこう盛り上がってたっぽい;全然知らなかった(^^;)

機械学習関連の発表

機械学習関連の発表でも興味深いものがいくつかあった。

まずは「最適決定木を用いてアンサンブル学習を近似する単一木構築手法」。

機械学習の精度はけっこう凄くなってるけど、「どうしてそういう判断になったのか?」という説明可能性が低くなるのが難点。 そこで説明性の高い決定木が使われたりして、特に最適決定木というのがあったりするらしいんだけど、ここではまず高性能な分類器を学習させたうえで、その学習器でラベルを振り直したデータを教師データとして最適決定木を作るという手法が紹介されていた。 そうすることで説明性も高く、性能も高い分類器が得られたらしい。

ちょっと不思議なのは、ラベルを振り直してるのであえて正解ではない教師データで学習させていることになるわけだけど、そっちの方が汎化性能が高まったということ。 なんでだろうと思って質問してみたんだけど、どうやらそうすることでノイズとなるようなデータを取り除けて、過学習を防げるかららしい。 これはなるほどなぁってなった。

次に「混合整数線形計画における目的関数の重みを決定する高速な逆最適化アルゴリズム」。

これは多目的最適化で各指標の重みづけをどうすべきかと言う問題で、熟練者の答えから逆に最適な重みを得ようというもの。 ただ、目的関数が滑らかにならないので、勾配法が使えないらしい。 でもそこでSuboptimality損失というのを考えると凸な関数が出てきて、勾配法で解けるようになるとのこと。 このSuboptimality損失というのはちょっと理解できてないけど、問題自体はけっこう解きたくなることが多そうなので、かなり興味深かった。

面白い問題設定の発表

他にも問題設定が面白いなと思った発表があった:

  • 建物内外の移動距離から見た面的施設の最適な形状
    • ショッピングモールとかで、駐車場から入り口まではできるだけ歩かなくて済むようにしたい
    • けど、建物内は(利用者が最短で進むとしても)できるだけ長く歩いてほしい(いろいろ見てほしい)
    • 矩形の敷地に対して建物の形(矩形)や階数はどうなっているといいか
    • 入り口の位置の違いによる分析も
  • パズル型駐車システムの車両出庫における運搬ロボットの有効性評価
    • 駐車場から車を出すパズルみたいなやつw
    • 敷地を効率的に利用して、できるだけ少ない回数で出せるようにもする
    • 運搬ロボットを使って車を運べば、横移動とか旋回も可能になるので、その効果を調べた

いずれも問題設定が面白いよねw 発表内容も面白くて、なるほどなという感じだった。

後者については、同じ向きで止めるならスライドパズルみたいになるのではという指摘も質問で出てきて、なるほどという感じ。 駐車場から車を出すパズルの広告はよく見てたけど、いやまさかそんな実用的な話に繋がるとはなぁw

今日はここまで!

ルドー分析の裏側の話。(その3)

少し空いてしまったけど、前回の続き。

進み具合の分析

最後に実施したのが進み具体の分析。

次のようなコードで、スゴロクとしての進み具合がどうだったのかを分析した:

# 進み方の表を作る
def make_progress_tables(
    log_table: pd.DataFrame
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
    acc_pos = {player: [0] for player in Player}
    acc_move = {player: [0] for player in Player}
    acc_back = {player: [0] for player in Player}

    for i in log_table.index:
        turn_player = log_table.loc[i, "player"]
        back_player = log_table.loc[i, "back_player"]
        for player in Player:
            if player == turn_player:
                move_count = log_table.loc[i, "move_count"]
                next_pos = acc_pos[player][-1] + move_count
                next_move = acc_move[player][-1] + move_count
                new_back = acc_back[player][-1]
            elif player == back_player:
                back_count = log_table.loc[i, "back_count"]
                next_pos = acc_pos[player][-1] - back_count
                next_move = acc_move[player][-1]
                new_back = acc_back[player][-1] - back_count
            else:
                next_pos = acc_pos[player][-1]
                next_move = acc_move[player][-1]
                new_back = acc_back[player][-1]
            acc_pos[player].append(next_pos)
            acc_move[player].append(next_move)
            acc_back[player].append(new_back)

    return pd.DataFrame(acc_pos), pd.DataFrame(acc_move), pd.DataFrame(acc_back)

# 計算を実行
acc_pos, acc_move, acc_back = make_progress_tables(log_table)

ここではターンごとに、各プレイヤーについて「各駒のスタートからの距離の合計」「進んだ距離の累積」「戻った距離の累積」を求め、表を作っている。

そして、まずは「各駒のスタートからの距離の合計」について可視化:

# プレイヤー名、色
player_name_color = {
    Player.Y: ("ちは", "green"),
    Player.B: ("わためぇ", "orange"),
    Player.R: ("ばんちょー", "purple"),
    Player.G: ("ししろん", "black"),
}

# 進み具合の様子
fig, ax = plt.subplots(figsize=(12, 5))
ax.set_title("進み具合")
for player in Player:
    name, color = player_name_color[player]
    ax.plot(acc_pos[player], label=name, color=color)
ax.set_xlim(0, len(acc_pos))
ax.set_yticks([0, 44, 87, 129, 170])
ax.grid(True)
ax.legend();

こうして得られたのが次のグラフ:

進み具合

ちなみにy軸は44, 87, 129, 170とちょっと変なところに線を引いてるけど、一周してきてゴール手前のコマまで進むと40進んだことになってて、ゴールの一番奥まで1つコマを進めたときに44、次のコマを奥まで進めたときに87(44+43)、同様にして129(44+43+42)、そして全部ゴールさせたときに170(44+43+42+41)となるので、何個くらいコマがゴールしているかの目安になっている。

このグラフにすると、順位がどんな感じだったのかが一目で分かるのがいい。 普通に盤面を見るのだと、誰が一番なのかはちょっと分からないからねぇ。 実際の分析は元の記事を参照。

そして、この図だと結果としての進み具合は分かるけど、その内訳(前進と後退がどれくらいか)は分からないので、それを分けて可視化したのが次:

# 進んだ数と戻った数の様子
fig, ax = plt.subplots(figsize=(12, 8))
ax.set_title("前進と後退の累積")
for player in Player:
    name, color = player_name_color[player]
    ax.plot(acc_move[player], label=f"{name} (前進)", color=color)
    ax.plot(acc_back[player], label=f"{name} (後退)", color=color, linestyle="dashed")
ax.set_xlim(0, len(acc_pos))
ax.grid(True)
ax.legend();

前進と後退の累積

分析の内容は元の記事を参照してもらうとして、前進側でそれほど差が出てないというのは個人的には意外だった。 元の記事でも言及したとおり、冷静に考えてみれば納得できる結果なんだけど。 このことに気づけたのは、この可視化のおかげと言える。

で、勝敗の差を生んだのが、後退をどれだけしなかったのかということ。 それもこの可視化で明確になっている。

より進んだ考察

で、元の記事では「ちはバリア」と面白おかしく締めてしまったけど、さらに分析するなら、場に出ていたコマの数とかを分析してみてもよかったかもしれない。

場に出ているコマの数が多ければ多いほど、基本的には踏まれるリスクは高いはず。 その一方で、コマが1つしか出てないと選択の自由度がなく、他の人の前に出ないように別のコマを動かすことができないので、これもまた踏まれるリスクが増える可能性がありそう。

そのあたりを踏まえて、実際に場に出ていたコマの数がどうだったのか、そしてその状態がどれくらい長く続いていたのかとかを見ると、また何か発見があるかもしれない。

で、6が出たときには基本的にはスタートから出してたけど、コマがすでに2つ出てたり、あるいは1つであっても他の人の前に出る可能性が少なかったりするなら、あえてスタートから出さずに今出てるコマをまずはゴールに向かわせて安全にする方がよかったりするのかもしれない。 そういった戦術的なところもそのうち分析してみたいかな。

長く続いたけど、ルドー分析はこれで一旦オシマイ。

今日はここまで!

ルドー分析の裏側の話。(その2)

前回は分析用のテーブルを作った。

今回はそれを使って実際の分析を進めていく。

出目の分析

まずは出目の分布がどうなっているか。

分析用のテーブルから出目を抽出して分析する:

# 対象のプレイヤーの出目だけ抽出する
def extract_dices(log_table: pd.DataFrame, player: Player) -> pd.Series:
    dices_series = log_table.loc[log_table["player"] == player, "dices"]

    dice_list = []
    for dices in dices_series:
        # 数値を10進数の数字にして、各桁を数値に戻し、リストに加える
        dice_list += list(map(int, str(dices)))

    return pd.Series(dice_list)

# 各プレイヤーに対する出目を抽出し、分布を得る
dices = {}
dices_dist = {}
for player in Player:
    dices[player] = extract_dices(log_table, player)
    dices_dist[player] = dices[player].value_counts().sort_index()

そして分布を棒グラフで描画:

# 表示での名前と対応するプレイヤーを定義しておく
display = {
    "ばんちょー": Player.R,
    "ししろん": Player.G,
    "わためぇ": Player.B,
    "ちは": Player.Y,
}

# 分布を棒グラフで描画
fig, ax = plt.subplots(2, 2, figsize=(8, 5), sharex=True, sharey=True)
fig.suptitle("出目の分布")

for i, (name, player) in enumerate(display.items()):
    row, col = divmod(i, 2)
    mean = dices[player].mean()
    std = dices[player].std()
    ax[row, col].set_title(f"{name} (平均: {mean:.2f}, 標準偏差: {std:.2f})")
    dices_dist[player].plot.bar(ax=ax[row, col])
    ax[row, col].grid(True, axis="y")
fig.tight_layout()

これで得られたのが次のグラフ:

出目の分布

わためぇの出目がやたらいいのは最初の記事で言及したとおり。

で、じゃあ有効な出目だけで分布を取った場合はどうかなと追加で調べたのが以下:

# 対象のプレイヤーの有効な出目だけ抽出する
# (有効な出目にならなかった場合、出目は0とする)
def extract_valid_dices(log_table: pd.DataFrame, player: Player) -> tuple[pd.Series, int]:
    dices_series = log_table.loc[log_table["player"] == player, "dices"]

    dice_list = []
    invalid_count = 0
    for dices in dices_series:
        # 数値を10進数の数字にして、各桁を数値に戻す
        all_items = list(map(int, str(dices)))
        
        # 1つだけなら常に有効
        # 2つ以上の場合、最後の数字が6ならそれだけ有効
        if len(all_items) == 1:
            dice_list.append(all_items[0])
        else:
            if all_items[-1] == 6:
                dice_list.append(all_items[-1])
                invalid_count += len(all_items) - 1
            else:
                dice_list.append(0)
                invalid_count += len(all_items)

    return pd.Series(dice_list), invalid_count

# 各プレイヤーに対する有効な出目、無効になった数を抽出し、分布を得る
valid_dices = {}
invalid_count = {}
valid_dices_dist = {}
for player in Player:
    valid_dices[player], invalid_count[player] = extract_valid_dices(log_table, player)
    valid_dices_dist[player] = valid_dices[player].value_counts().sort_index()

# 分布を棒グラフで描画
fig, ax = plt.subplots(2, 2, figsize=(8, 5), sharex=True, sharey=True)
fig.suptitle("有効な出目の分布")

for i, (name, player) in enumerate(display.items()):
    row, col = divmod(i, 2)
    mean = valid_dices[player].mean()
    std = valid_dices[player].std()
    ax[row, col].set_title(
        f"{name} (平均: {mean:.2f}, 標準偏差: {std:.2f}, 無効: {invalid_count[player]})"
    )
    valid_dices_dist[player].plot.bar(ax=ax[row, col])
    ax[row, col].grid(True, axis="y")
fig.tight_layout()

これで次のグラフが得られる:

有効な出目の分布(0はパス)

いやー、改めて見ても出目がおかしい(^^; 豪運シープよねぇ。

ただ、全体的な運では「千速のサイコロ」が否定されたとしても、やっぱり短期的にはやってたんじゃないかという疑惑が生まれると思うので、時系列的な分析を行ったのが次の話。

ある一定期間での運を見るということで、移動平均を取ればその目的は果たせるだろうと思い、次のようにグラフを描いてみた:

# 出目を折れ線グラフで描画
fig, ax = plt.subplots(2, 2, figsize=(10, 5), sharex=True, sharey=True)
fig.suptitle("有効な出目の時系列")
x_max = max(len(valid_dices[player]) for player in Player)

for i, (name, player) in enumerate(display.items()):
    row, col = divmod(i, 2)
    ax[row, col].set_title(name)
    valid_dices[player].plot(ax=ax[row, col], alpha=0.5, label="raw")
    # 移動平均(10回)
    valid_dices[player].rolling(10).mean().plot(ax=ax[row, col], label="roll mean(10)")
    # 移動平均(30回)
    valid_dices[player].rolling(30).mean().plot(ax=ax[row, col], label="roll mean(30)")
    ax[row, col].grid(True)
    ax[row, col].set_xlim(0, x_max)
    if i == 3:
        ax[row, col].legend()
fig.tight_layout()

有効な出目の時系列(0はパス)

こうすることで、ちはの後半ですごい結果が出るのかと思ったけど、最初の記事で言及したとおり、著しく運がよかったとは出てこなかったのは意外なところ。 むしろ、ばんちょーの波が激しいことや、ししろんの後半の失速が目についたよね。 そういうところに気付けたのは可視化のよかったところ。

キルの分析

続いてキルリーダーの分析をするために誰が誰を何回踏んだのか確認した:

# キル回数とその対象を抽出する
def extract_kill_count(log_table: pd.DataFrame, player: Player) -> pd.Series:
    kill_series = log_table.loc[log_table["player"] == player, "back_player"]
    return kill_series.value_counts()

# 各プレイヤーに対して、他のプレイヤーを何回踏んだか
kill_count = {}
for player in Player:
    kill_count[player] = extract_kill_count(log_table, player)

# テーブルの形にする
kill_table = pd.DataFrame(kill_count)
# 順番を整える
kill_table = kill_table.loc[list(Player)]
kill_table = kill_table[list(Player)]
# 行が戻された人
kill_table.index.name = "killed"
# 集計を追加
kill_table.loc["total"] = kill_table.sum()
kill_table["total"] = kill_table.sum(axis=1)
kill_table

こうして得られたテーブルが以下:

killed Player.Y Player.B Player.R Player.G total
Player.Y NaN 4.0 1.0 1.0 6.0
Player.B 4.0 NaN 2.0 4.0 10.0
Player.R 2.0 1.0 NaN 6.0 9.0
Player.G 2.0 2.0 4.0 NaN 8.0
total 8.0 7.0 7.0 11.0 33.0

記事にするときにはこの名前を適切なものに変えてる。

それにしてもししろんのキル数すごいよなぁ。

長くなったので一旦区切り。

今日はここまで!

ルドー分析の裏側の話。(その1)

前回はつぼはちのルドーを分析してみた。

今回はその裏側として、どんな感じで分析したのかを書いてみたい。

ちなみに分析はPythonを使ってJupyterで行っている。 とりあえずライブラリのインポートは以下のような感じ:

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import japanize_matplotlib  # グラフで日本語を使えるようにする

from enum import Enum
from dataclasses import dataclass
from typing import Self, Optional, ClassVar
import re

棋譜の定義

分析するためには、まず盤面の情報を読み込ませる必要がある。 そこで棋譜の仕様を次のように定義した:

  • プレイヤー
    • y(黄、右下)
    • b(青、左下)
    • r(赤、左上)
    • g(緑、右上)
  • 座標
    • 各色ごとに15マスで、色と数字の組み合わせで表す(例:y0
    • スタート: 0
    • 道中: 1~9
    • ゴール前: 10
    • ゴール: 11~14
  • 行動
    • 1ターンを、ターンプレイヤー、サイコロの出目、動かすコマの座標を繋げて表現
      • 複数振った場合は出目を順に繋げる
      • 動かすコマがない場合は座標を空にする
    • ターンの区切りはカンマ
    • 6を出して複数回行動する場合、ターンプレイヤーが連続することになる
    • 改行や空白は無視
    • コメントがある場合は#でコメントを続ける
    • 例:
      • 黄がサイコロを3回振ってスタートから出られなかった:y154,
      • 青がサイコロを2回振ってスタートから出て、さらに4で出したコマを進めた:b26b0, b4b1,
      • 赤がサイコロで3を出し、緑の道中のコマを進めた:r3 g1,
      • コメントをつける:b3b10 #1つ目ゴール,

盤面の座標の様子

ちなみに、(実際のルドーでの操作がそうであるように)動かすコマの座標を指定すれば行き先の座標は一意に定まるので、棋譜ではその座標だけを記録するようにしている。 また、誰が踏まれたかとかも先頭から順に追えば分かるので、棋譜には入れてない。

この定義で採譜したのが以下:

score = """
y16y0,y2y1,
b555,
r125,
g132,
y2y3,
b6b0,b5b1,
r551,
g6g0,g6g1,g3g7,
y3y5,
b4b6,
r412,
g2y10,
y6y0,y5y1,
b4r10,
r46r0,r1r1,
g2y2,
y4y6,
b6b0,b5r4,
r2r2,
g5y4,
y1y8  #ぼたんかわためか,
b6b1,b6r9,b5g5,
r6r0,r2r1,
g6g0,g5g1,
y2b10,
b6y10,b3y6,
r5r3,
g4g6,
y4b2,
b3y9  #わためゴール1,
r5r4,
g2y10,
y1b6  #ごちそうさまでした,
b5b12,
r1r9,
g5y2,
y4b7,
b526b0,b4b1,
r2g10,
g5y7,
y4r1,
b3b5,
r4g2,
g6g0  #わため許される,g5g1  #ばんちょー踏まれる,
y6y0,y4y1,
b2b8,
r4r8,
g4b2,
y3r5,
b5r10,
r2g2,
g1b6,
y2r8,
b3r5,
r4g4,
g4b7,
y3g10,
b4r8,
r6r0  #わため「6出ないか〜」からばんちょー6出す,r5g8,
g6g0,g5g6,
y3g3,
b4g2  #わため「事故が起きそうだよ」「こんなふうにね」,
r6r1,r2y3  #ちは全部スタートへ,
g6g1,g4y1,
y6y0,y5y1,
b3g6,
r3r7,
g6g0,g1y5  #何ラーメン好き?,
y46y0,y6y1,y2y7,
b6b0,b2g9,
r4g10,
g2y6,
y5y9,
b1y1,
r4g4,
g3y8,
y1b4,
b3y2,
r2g8,
g3g7  #ばんちょー全部スタートへ,
y3b5,
b2y5,
r236r0,r4r1,
g4b1,
y5b8,
b1y7,
r4r5,
g2b5,
y1r3,
b1y8,
r2r9,
g1b7,
y5r4,
b6b0,b1y9,
r4g1,
g4b8,
y3r9,
b4b10  #わためゴール2,
r4g5,
g3r2,
y2g2,
b4b1,
r5g9,
g5r5,
y5g4,
b6b0,b5b5,
r4y4,
g3g10  #ぼたんゴール1,
y1g9,
b6r10,b5b1,
r4y8,
g5g13,
y5y10  #ちはゴール1,
b1r6,
r5b2,
g6g0,g5g1,
y443,
b3r7,
r5b7  #ばんちょーゴール1,
g5g6,
y414,
b1b6,
r2r12,
g3y1,
y413,
b2b7,
r56r0,r4r1,
g4y4,
y545,
b5b9,
r6r0,r3r1  #わためを戻す,
g5y8,
y544  #ちはずっと出れない,
b6b0  #わためはすぐ復帰,b6g10,b4g6,
r2r5,
g6g0,g2g1,
y331  #6回出れず,
b3y10,
r5r7,
g4g3,
y223  #7回出れず,
b1y3,
r4g2,
g6b3,g5b9  #ばんちょーを生贄に,
y6y0  #ちは召喚,y3y1  #わためをスタートへ,
b6b1,b5b7,
r4g6,
g6r4,g3g7  #ゴールせずにばんちょーを墓地へ,
y3y4,
b4r2,
r442,
g4g10  #ぼたんゴール2,
y5y7,
b6b0,b6r6,b4g2  #墓地送りの効果,
r223  #運吸われてる,
g6g0,g1g1  #墓地送りの効果,
y4b2,
b5b1  #ちはスタートへ,
r253  #3回出れず,
g5g2,
y241,
b2b6,
r534  #4回出れず,
g2y10,
y36y0,y6y1,y2y7,
b3g6,
r6r0,r4r1,
g2g7  #わためスタートへ,
y3y9,
b6b0,b1b1  #ちはスタートへ,
r2r5,
g3y2,
y456y0,y1y1,
b4b8,
r3r7,
g1y5,
y1y2,
b4r2,
r3g10,
g6y6  #わため墓地へ,g5b2,
y2y3,
b5r6,
r6g3  #復讐に生きるばんちょー,r6r0,r3r1,
g6g0  #またわため墓地,g5g1,
y3y5,
b314,
r4g9,
g3b7,
y3y8,
b433,
r3r4,
g2g6,
y1b1,
b26b0,b6b1,b1b7,
r6y3,r6r7,r4g3,
g1g8,
y5b2,
b2b8  #わため「がぶがぶ」,
r3y9,
g5g9,
y3b7  #わため食べられる,
b6b0,b4b1,
r5g7,
g5y4,
y5r10,
b2b5,
r6r0,r5b2,
g2y9,
y3r5,
b423,
r4b7  #ばんちょーゴール2,
g5b1,
y6y0,y6r8,y6g4,y5y10  #ちはゴール2,
b456b0,b3b1,
r6y2,r4r1,
g2b6,
y1y1,
b5b4,
r2r5,
g3b8,
y2y2,
b1b9,
r2y8,
g5r1,
y6y0,y6y4  #ばんちょー戻される,y6b10,y6y1,y4y7  #ちは「ジンギスカンタイム逃したー」,
b1r10,
r2r7,
g6g0,g6r6  #ぼたんゴール3,g4g1,
y6b1  #細工した?,y1b7,
b6b0,b4r1,
r3r9,
g3g5,
y6b8,y3b6  #やってる?,
b5r5,
r3g2,
g4g8,
y3r4,
b2g10,
r3g5,
g5y2,
y6b9,y6r7  #磁石感じるなぁ,y5g3  #ちは「手が止まらなかった」(ばんちょー墓地),
b5g2,
r1r11,
g6y7,g5b3,
y3g8  #ちはゴール3,
b4b1,
r3r12,
g3b8,
y2r5,
b6b5  #ぼたんのゴール阻止,b1r1,
r435,
g244,
y6r7  #やっぱやってる?,y4g3,
b5r2,
r6r0,r3r1,
g434,
y4y11,
b5r7,
r2r4,
g211,
y3g7,
b4g2,
r5r6,
g343,
y1y10  #ちはゴール4,
b5g6,
r1g1,
g532  #5回出れず,
b3y1,
r2g2,
g16g0,g2g1,
b1y4,
r3g4,
g2g3,
b1y5,
r4g7,
g6g5  #ばんちょー墓地へ,g4y1,
b4y6,
r545  #しょうがないで片付けられるばんちょー,
g1y5,
b2b10  #わためゴール3,
r36r0,r6r1,r6r0,r6r7,r6g3,r4g9  #サイコロもらった?,
g1y6,
b6b0,b5b1,
r4y3  #ぼたんを墓地へ,
g446g0,g5g1,
b5b6,
r5y7,
g1g6,
b2r1,
r5b2,
g1g7,
b4r3,
r6r0,r1r1,
g5g8,
b2r7,
r2b7,
g1y3,
b5r9,
r3b9  #ばんちょーゴール3,
g4y4,
b4g4,
r3r2,
g4y8,
b4g8,
r5r5,
g1b2,
b2y2,
r1g10,
g5b3,
b6y4,b6b10  #わためゴール4,
r3g1,
g2b8,
r4g4,
g2r10,
r2g8,
g2r2,
r1y10,
g2r4,
r6y1,r6y7,r3b3,
g1r6,
r4b6  #ばんちょーあと1でゴールまで追い上げる,
g5r7  #ぼたんゴール4
"""

ぶっちゃけた話、ここが一番大変だった。 (連続したターンもそれぞれ別とカウントして)400ターン強あるからね(^^;

採譜しつつ、適度に以下の実装を混ぜたりして、飽きないように進めた感じ。

分析用テーブルの作成

次は棋譜から分析用のテーブルを作るところ。

出目の様子やどれくらい進んだのか、戻ったのか、誰が戻されたのかなどを分析したかったので、各行でターンを表し、以下のような列を持つテーブルを作ることにした:

  • ターンプレイヤー
  • サイコロの出目(整数;複数回振った場合は繋げたもの(316など))
  • 動かす前のコマの位置(ない場合は空)
  • 動かした後のコマの位置(ない場合は空)
  • 進んだ数(進んでない場合は0)
  • 戻されたプレイヤー(ない場合は空)
  • 戻された数(戻されてない場合は0)
  • コメント(ない場合は空)

まずはプレイヤーの定義:

class Player(Enum):
    Y = "y"
    B = "b"
    R = "r"
    G = "g"

    @property
    def next_player(self) -> Self:
        return {
            Player.Y: Player.B,
            Player.B: Player.R,
            Player.R: Player.G,
            Player.G: Player.Y,
        }[self]

    @property
    def prev_player(self) -> Self:
        return {
            Player.Y: Player.G,
            Player.B: Player.Y,
            Player.R: Player.B,
            Player.G: Player.R,
        }[self]

次のプレイヤーや前のプレイヤーが簡単に分かるようにするためのプロパティも定義している。

続いて座標の定義:

@dataclass(frozen=True)
class Position:
    area: Player
    number: int

    @classmethod
    def from_str(cls, pos_str: str) -> Self:
        area = Player(pos_str[0])
        number = int(pos_str[1:])
        assert 0 <= number <15
        return cls(area, number)

    @property
    def is_start(self) -> bool:
        return self.number == 0

    @property
    def is_goal(self) -> bool:
        return self.number > 10

    def __str__(self) -> str:
        return f"{self.area.value}{self.number}"

    def get_next(self, player: Player, dice: int) -> Self:
        assert 1 <= dice <= 6

        if self.is_start:
            assert player == self.area
            assert dice == 6
            return Position(self.area, 1)

        next_area = self.area
        next_number = self.number + dice

        # 境界を跨ぐときは次のプレイヤーのエリアに入る
        if self.number < 10 and next_number >= 10:
            next_area = next_area.next_player

        # 数字が11より大きい場合、
        # - プレイヤーのエリアに戻ってきてた場合、ゴールに向かう(ただし15は超えない)
        # - そうでない場合、そのエリアを進む(数字は1~9に戻る)
        if next_number > 10:
            if next_area == player:
                next_number = min(next_number, 14)  # 14以下
            else:
                next_number -= 10

        return Position(next_area, next_number)

    def get_distance_from_start(self, player: Player) -> int:
        # スタートのときは0
        if self.is_start:
            return 0

        # ゴールしてる場合は1周と越えた分
        if self.is_goal:
            return 40 + self.number - 10

        # 数えやすくするために数字が10のときは前のエリアの10という扱いにしておく
        # (エリア内で、10->1->...->9とならず、1->2->...->10となる)
        target_area = self.area
        if self.number == 10:
            target_area = target_area.prev_player

        dist = 0
        area = player
        while True:
            if area == target_area:
                dist += self.number
                break
            else:
                dist += 10
                area = area.next_player
        return dist

データとしてはエリア(どのプレイヤーのエリアかで表現)とそのエリア内での番号。 ただ、スタート地点か、ゴール内かといったプロパティや、出目に対して次の座標がどこになるのか、そしてスタート地点からの距離とかをメソッドで得られるようにしてある。

そしてターンでの行動:

@dataclass(frozen=True)
class Action:
    player: Player
    dices: int
    from_pos: Optional[Position]
    comment: Optional[str]

    __space_pattern: ClassVar[re.Pattern] = re.compile(r"\s")
    __dices_pattern: ClassVar[re.Pattern] = re.compile(r"\d+")

    @classmethod
    def parse(cls, action_str: str) -> Self:
        action_str = cls.__trim(action_str)

        comment: Optional[str] = None
        if "#" in action_str:
            action_str, comment = action_str.split("#")

        player = Player(action_str[0])

        match = cls.__dices_pattern.search(action_str)
        assert match
        dices = int(match.group(0))

        from_pos: Optional[Position] = None
        from_pos_str = action_str[match.end(0):]
        if len(from_pos_str) > 0:
            from_pos = Position.from_str(from_pos_str)

        return cls(player, dices, from_pos, comment)

    @classmethod
    def __trim(cls, action_str: str) -> str:
        return cls.__space_pattern.sub("", action_str)

    def __str__(self) -> str:
        action_str = f"{self.player.value}{self.dices}"
        if self.from_pos is not None:
            action_str += str(self.from_pos)
        if self.comment is not None:
            action_str += f"  #{self.comment}"
        return action_str

これもデータとしてはターンプレイヤー、サイコロの出目、動かしたコマの位置(パスの場合はNone)、コメント(ない場合はNone)。 ただ、棋譜から行動オブジェクトを簡単に得られるように、クラスメソッドで文字列からパースする機能を実装してる。 (あとデバッグ用に文字列にするメソッドも実装)

加えて、テーブルでは行き先や踏まれたプレイヤーなどの情報も欲しかったので、これは行動の結果として定義した:

@dataclass(frozen=True)
class ActionResult:
    to_pos: Optional[Position]
    move_count: int
    back_player: Optional[Player]
    back_count: int

    def __str__(self) -> str:
        if self.to_pos is None:
            return "pass"

        result_str = f"{self.to_pos} (+{self.move_count}"
        if self.back_player is not None:
            result_str += f", player {self.back_player.value} -{self.back_count}"
        result_str += ")"
        return result_str

あとは行動に応じて盤面の状態を更新しながら各ターンの様子を追っていけばいいので、盤面の状態を定義:

class BoardState:
    def __init__(self) -> None:
        # 各プレイヤーの位置の一覧
        # (最初は全部スタート地点)
        self.__positions: dict[Player, list[Position]] = {
            player: [Position(player, 0) for _ in range(4)] for player in Player
        }
        # 各位置(スタートを除く)のプレイヤー(キーがない場合はプレイヤーなし)
        self.__player: dict[Position, Player] = {}

    def perform_action(self, action: Action) -> ActionResult:
        # from_posがない場合はパス
        if action.from_pos is None:
            return ActionResult(None, 0, None, 0)

        # 状態のチェックと動かすコマのインデックス
        positions = self.__positions[action.player]
        assert action.from_pos in positions
        assert (
            action.from_pos.is_start or
            (self.__player[action.from_pos] == action.player)
        )
        pos_idx = positions.index(action.from_pos)

        # 出目は1の位
        dice = action.dices % 10

        to_pos = action.from_pos.get_next(action.player, dice)
        # to_posがゴールの場合、同じ場所に重なる可能性がある(本当は手前に止まるべき)
        # その場合に位置を十分に手前に戻す
        while to_pos.is_goal and (to_pos in self.__player):
            to_pos = Position(to_pos.area, to_pos.number - 1)

        move_count = (
            to_pos.get_distance_from_start(action.player)
            - action.from_pos.get_distance_from_start(action.player)
        )

        # 他のプレイヤーがいるなら、そのプレイヤーを戻す
        if to_pos in self.__player:
            back_player = self.__player[to_pos]
            back_count = to_pos.get_distance_from_start(back_player)
            # 位置を戻す処理
            back_pos_idx = self.__positions[back_player].index(to_pos)
            self.__positions[back_player][back_pos_idx] = Position(back_player, 0)
        else:
            back_player = None
            back_count = 0

        # 状態の更新
        self.__positions[action.player][pos_idx] = to_pos
        self.__player[to_pos] = action.player
        if not action.from_pos.is_start:
            del self.__player[action.from_pos]

        return ActionResult(to_pos, move_count, back_player, back_count)

コマを動かすために、各プレイヤーのコマの座標の情報を持っていて、また、コマが踏まれたかどうか簡単に分かるようにするために、各座標(スタート地点を除く)のプレイヤーの情報を持っている。 そして、実行された行動に対して状態を更新し、行動の結果を返す感じ。

ここまで作ればあとは簡単で、棋譜から各行動を取得して、盤面の状態を更新しつつ、行動の結果を受け取って、それをテーブルにまとめるだけ:

def make_log_table(score: str, verbose: bool = False) -> pd.DataFrame:
    player_list = []
    dices_list = []
    from_pos_list = []
    to_pos_list = []
    move_count_list = []
    back_player_list = []
    back_count_list = []
    comment_list = []

    actions = [Action.parse(action_str) for action_str in score.split(",")]

    state = BoardState()
    for action in actions:
        if verbose:
            print(action, end=" ", flush=True)

        result = state.perform_action(action)

        if verbose:
            print("=>", result)
        
        player_list.append(action.player)
        dices_list.append(action.dices)
        from_pos_list.append(action.from_pos)
        to_pos_list.append(result.to_pos)
        move_count_list.append(result.move_count)
        back_player_list.append(result.back_player)
        back_count_list.append(result.back_count)
        comment_list.append(action.comment)

    return pd.DataFrame(
        {
            "player": player_list,
            "dices": dices_list,
            "from_pos": from_pos_list,
            "to_pos": to_pos_list,
            "move_count": move_count_list,
            "back_player": back_player_list,
            "back_count": back_count_list,
            "comment": comment_list,
        }
    )

実行してみるとこんな感じ:

log_table = make_log_table(score)

このとき、log_tableは次のようになる:

player dices from_pos to_pos move_count back_player back_count comment
0 Player.Y 16 y0 y1 1 None 0 None
1 Player.Y 2 y1 y3 2 None 0 None
2 Player.B 555 None None 0 None 0 None
3 Player.R 125 None None 0 None 0 None
4 Player.G 132 None None 0 None 0 None
... ... ... ... ... ... ... ... ...
407 Player.R 6 y7 b3 6 None 0 None
408 Player.R 3 b3 b6 3 None 0 None
409 Player.G 1 r6 r7 1 None 0 None
410 Player.R 4 b6 r10 4 None 0 ばんちょーあと1でゴールまで追い上げる
411 Player.G 5 r7 g11 4 None 0 ぼたんゴール4

いい感じにテーブルが作れてるのが分かると思う。

ちなみに、この実装はかなりオブジェクト指向的になってて、知識が各クラスにまとまっていて、それを使うクラスは内部のデータ構造や実装を気にする必要がなくなってるのが分かると思う。 型というのが(部分的な)データ構造を表すものという原始的な見方が幅を効かせてるけど、本当はこのように振る舞い(できること)を表すものだという見方がもっと広まってほしいものだけど・・・(TypeScript界隈の話;自分からすると古のC言語の世界に戻って嬉しがってるだけにしか見えない)

で、次はこのテーブルで分析をやっていくんだけど、長くなったので一旦区切り。

今日はここまで!

つぼはちのルドーを分析してみた。

ホロライブのわためぇ(角巻わため)、ししろん(獅白ぼたん)、ばんちょー(轟はじめ)、ちは(輪堂千速)によるユニット「つぼはち」(のまき、たん、じめ、はや)。

この4人によるルドーがめちゃくちゃ面白かったので、#わためと自由研究2025 で分析してみた。

出目の分析

このルドー、面白い場面がたくさんあって、一番の見どころといえば「ばんちょーを生贄に千速を召喚、わため死す!」(32分頃〜)だと思うんだけど、終盤の「千速のサイコロ」も面白ポイントの1つ。 それまでなかなか進めてなかったのに、急に6が出まくって「細工した?」と疑われるほど。 ちはがゴールしたあとにもばんちょーが6を連発すると「千速ちゃんからサイコロもらった?」と言われたりしてた。

じゃあ実際出目の良し悪しはどうだったのかということで分析。

無効な出目(スタート地点では6以外すべて無効)も含めてサイコロの出目を数え、分布を調べてみると、次の図のようになっていた:

出目の分布

・・・これ、わためやってね?

ホロライブに入るだけあって(?)みんな運は良く、出目の平均は余裕で3.5を超えている。 ただ、その中でもわためぇの平均は3.9近くになってて、これはサイコロに仕込みをしてるとしか思えない。 実際、分布を見てみると明らかに1〜3より4〜6が出ていて、サイコロの片側に重りをつけていたのでは?みたいな不自然さが *1

ちなみにあれだけ最後のブーストが凄かったちはも全体を通して見れば普通で、むしろばんちょーの方が出目はよかったところがある。

ただ、これは無効な出目を含めたものなので、有効な出目だけで分布を見ると、また違った結果が見えるかもしれない。 たとえば、スタート地点で「4, 4, 5」という出目を出すと、これらは全部無効になるので、「運の無駄遣い」となっている。

そこで、有効な出目(パスの場合は0とする)だけで分布を調べてみると、次の図:

有効な出目の分布(0はパス)

この羊、やってます。

有効な出目に限ると他の3人は平均が3.5前後に落ち着くのに対し、わためぇだけは3.8弱と高い平均を維持してる。 分布で見ても4〜6が多いままで、これは何かやってるとしか思えない。 特筆すべきはパスの回数で、わためぇのパスの回数が圧倒的に少ない。 無効になった出目の回数を見ても、ばんちょーやちはが30回前後出目を無駄にしているのに対し、わためぇは20回弱しか無駄にしてない。 やってんねぇ・・・

わため「でもわためは主催だし、悪くないよねぇ?」
スバル「いや悪いだろ」

そんな掛け合いが聞こえてきそう。

それはさておき、こうしてみると、ばんちょーとちはは出目が無効になってしまった回数がかなり響いていそう。 ばんちょーとかは3.75とかあった平均が3.55と落ち込んでるし、ちはも3.66が3.48と減って3.5を下回ってる。

そんな感じで全体での出目の分布を見ると、わためぇの不正疑惑はさておき、ちははやってない感じがある。 ただ、流れで見るとめっちゃツイてたときもあるので、時系列で出目の様子を確認してみた。

時系列に沿って有効な出目をプロットし、移動平均(10回と30回)の様子を確認したのが次の図:

有効な出目の時系列(0はパス)

橙(10回の移動平均)は短期的なトレンド、緑(30回の移動平均)は長期的なトレンドと考えてもらえればいい。

これを見ると、たしかにちはは中盤でかなり出目が悪くなって、そこから終盤にかけて出目がグンとよくなっているのが分かる。 それが長期的なトレンドにも表れるほどに顕著。 ただ、短期のトレンドとして見ても、実はそこまで飛び抜けて出目がよかったわけでもなかったりする。

飛び抜け方としては、実はばんちょーの終盤の出目の方が凄かったりする。 短期的には平均で5近く出ていたりする。 ただ、浮き沈みも激しく、長期での平均を見ると落ち着いてしまっている感じ。

その点、わためぇは中盤以降、ずっと安定している。 それどころか、序盤での飛び抜け方が実はヤバい。 みんながまだゲームに慣れてないうちにスパートかけてるのは初心者狩りっぽさがある・・・(やっぱりやってる?)

あと、こうしてトレンドで見ると、ししろんの終盤の落ち込み具合が分かる。 中盤までは安定してたのに、終盤で出目がかなり渋くなっている。 これがあわや逆転かとなった原因になっていそう。

キルの分析

配信では「ししろんキルリーダーでは?」という話もあった。 なので、キル数(他の人をスタートに戻した回数)も調べてみた。

各列を踏んだ人、各行を踏まれた人として、表にまとめたものが以下:

踏まれた人\踏んだ人 ちは わためぇ ばんちょー ししろん 合計
ちは - 4 1 1 6
わためぇ 4 - 2 4 10
ばんちょー 2 1 - 6 9
ししろん 2 2 4 - 8
合計 8 7 7 11 33

結果は見ての通りで、ししろんが圧倒的キルリーダー。 さすがライオン。

逆に、誰が一番スタートに戻されたかを確認してみると、わためぇ。 やっぱり羊はみんなから美味しく食べられる運命だったのかもしれないw

ちなみに、ししろんはばんちょーを6回も墓地送りにしている。 その場での効果は大きかったかもしれないけど、もしかしたら終盤での失速は、ばんちょーの呪いだったのかもしれない・・・

あと、あれだけ出目がよかったわためぇが勝ちきれなかったのは、この戻された回数の多さが影響していそうと考えられる。

進み具合の分析

じゃあ、実際にスゴロクとしての進み具合がどうだったのかも分析してみた。

横軸をターン数、縦軸を各駒のスタートからの距離の合計として、図にしてみた:

進み具合

縦軸は0がスタート、170(=44+43+42+41)がゴールを表し、手番が来ると基本的には上がり(パスだと横ばい)、他の人に踏まれるとガクッと下がる図になっている。

この図を見ると、序盤から中盤にかけてわためぇが独走しているのがまず分かる(初心者狩り・・・)。 ただ、中盤に差し掛かった頃からさすがに他からの攻撃が厳しくなり、進んでは踏まれて戻るのを繰り返していて、停滞している様子が分かる。

そんな停滞しているわためぇを追いかけていったのがししろんで、200ターンを超えたあたりでは逆転してトップになり、320ターンくらいではゴール目前というところまで迫っていた。 けど、そこで踏まれてガクッと進捗を落とし、しばらくパスも続いて停滞することになる。

それに対してちはは150ターン手前あたりから200ターン強までずっと停滞。 けど、ここで出目が爆発して一気に駆け上がり、ゴール目前のししろんを抜いてトップでゴール。

ずっと停滞していたわためぇも、ししろんがガクッと進捗を落としたところから、地道に伸びていって2位でゴールしている。

そしてばんちょーはというと、浮き沈みの激しさが出ている感じ。 200ターン前後はちはと一緒に停滞してたけど、250ターン過ぎではししろんとトップ争いをするほどに伸びてる。 ただ、そこからガクッと落ちてまた停滞し、最後は怒涛の勢いでししろんを追いかけるものの、一歩届かずという結果になっている。

こうして見ると、やはりどれだけ停滞(進んでは戻っての繰り返し)を少なくできるかが重要そうと見えてくる。

そこで、進み具合を前進した累積量と後退した累積量に分けてみたのが次の図:

前進と後退の累積

この図を見てみると、前進の累積量は最終的にはそれほど大きな差が出ていないことに気づく。 わためぇとししろんはほぼ一緒に上がっていってるし、ちはは途中でパスが続いて上がらない区間があったものの、ゴール直前(350ターン手前)ではわためぇやししろんと同じくらいに回復しているし、ばんちょーも同様に上がらない区間が2つくらいあるけど、ゴール直前(400ターンくらい)ではししろんと同じくらいになってる。

理屈としては、パスで無効になっている出目があるとはいえ、基本的には(出目の期待値) * (振った回数)に収束していくはずなので、これは納得のいくところ。 なので、途中の出目のバラつきはあれど、最終的には同程度進めるとなる。

そして一番注目すべきなのは、ちはの後退の累積数が他より圧倒的に少ないこと。 進み具合というのは(前進の累積量) - (後退の累積量)で計算されるので、後退の累積量が少なければ必要な前進の累積量も減り、圧倒的に早くゴールできることになる。 これがちはがトップでゴールできた鍵だったっぽい。 200ターンちょっと過ぎからはゴールする350ターン手前まで一度も踏まれてなくて、一気にゴールまで駆け抜けることができている。

そうしてみると、わためぇも170ターンくらいまではほとんど後退してなくて、これが序盤でトップに躍り出ていた鍵になっていそう(出目がよかったというのもあるけど)。

そんなわけで、ちはがトップでゴールできたのは、怪しいサイコロを使ってたというより、バリア的な能力を実は使ってたのかもしれない(君子危に近寄らず?)。

まとめ

  • サイコロでやってたのはわためぇ
  • キルリーダーはししろん
  • ししろん終盤の失速はばんちょーの呪い?
  • ちはのバリアが最強

今日はここまで!

*1:厳密には仮説検定が必要だけど、そこはジョークなのでスルーしてもろて