前回はつぼはちのルドーを分析してみた。
今回はその裏側として、どんな感じで分析したのかを書いてみたい。
ちなみに分析は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
- 各色ごとに15マスで、色と数字の組み合わせで表す(例:
- 行動
- 1ターンを、ターンプレイヤー、サイコロの出目、動かすコマの座標を繋げて表現
- 複数振った場合は出目を順に繋げる
- 動かすコマがない場合は座標を空にする
- ターンの区切りはカンマ
- 6を出して複数回行動する場合、ターンプレイヤーが連続することになる
- 改行や空白は無視
- コメントがある場合は
#でコメントを続ける - 例:
- 黄がサイコロを3回振ってスタートから出られなかった:
y154, - 青がサイコロを2回振ってスタートから出て、さらに4で出したコマを進めた:
b26b0, b4b1, - 赤がサイコロで3を出し、緑の道中のコマを進めた:
r3 g1, - コメントをつける:
b3b10 #1つ目ゴール,
- 黄がサイコロを3回振ってスタートから出られなかった:
- 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言語の世界に戻って嬉しがってるだけにしか見えない)
で、次はこのテーブルで分析をやっていくんだけど、長くなったので一旦区切り。
今日はここまで!