前回はつぼはちのルドーを分析してみた。
今回はその裏側として、どんな感じで分析したのかを書いてみたい。
ちなみに分析は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
if next_number > 10:
if next_area == player:
next_number = min(next_number, 14)
else:
next_number -= 10
return Position(next_area, next_number)
def get_distance_from_start(self, player: Player) -> int:
if self.is_start:
return 0
if self.is_goal:
return 40 + self.number - 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:
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)
dice = action.dices % 10
to_pos = action.from_pos.get_next(action.player, dice)
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言語の世界に戻って嬉しがってるだけにしか見えない)
で、次はこのテーブルで分析をやっていくんだけど、長くなったので一旦区切り。
今日はここまで!