以下の内容はhttps://yamaimo.hatenablog.jp/entry/2025/08/23/220000より取得しました。


ルドー分析の裏側の話。(その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言語の世界に戻って嬉しがってるだけにしか見えない)

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

今日はここまで!




以上の内容はhttps://yamaimo.hatenablog.jp/entry/2025/08/23/220000より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14