예제 소스
장기로 이해하는 규칙 엔진과 평가 함수 탐색 · 파일 59개
tune.py
"""자가대국(arena)으로 평가 가중치 조정 - 이긴 판 수가 목표.
python tune.py compare A.txt B.txt [--games 200] [--depth 3] 두 가중치 맞대결 (B 기준 승률)
python tune.py spsa [--start FILE] [--iters 300] [--depth 3] SPSA: θ+Δ vs θ-Δ 대전 결과로 θ 이동
20번마다 기준(base) 상대 검증, 최고 기록 저장
판마다 초·한 번갈아, 상차림 무작위. 수 제한(--max-ply)에 걸리면 기물 점수로 판정.
결과: data/tune/ (spsa.log, best.txt, theta_XXXX.txt)
"""
from __future__ import annotations
import argparse
import concurrent.futures as cf
import json
import os
import random
import re
import subprocess
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent
ARENA = ROOT.parent / "arena" / "arena"
TUNE = ROOT / "data" / "tune"
WORKERS = max(1, (os.cpu_count() or 8) - 2)
DEFAULTS = {
"chariot": 130, "cannon": 70, "horse": 50, "elephant": 30, "advisor": 30, "pawn": 20,
"pawnAdvance": 3, "minorFile": 2, "minorRank": 1, "chariotKingLine": 8, "kingCenter": 6,
"checkPenalty": 18, "hanBonus": 15,
"palaceIntruder": 8, "palaceChariot": 15, "palaceGuard": 5, "qsearch": 4,
}
# SPSA로 움직일 값과 한 번에 흔드는 폭 (hanBonus·qsearch는 고정)
STEP = {
"chariot": 8, "cannon": 6, "horse": 5, "elephant": 4, "advisor": 4, "pawn": 3,
"pawnAdvance": 1, "minorFile": 1, "minorRank": 1, "chariotKingLine": 3, "kingCenter": 3,
"checkPenalty": 5, "palaceIntruder": 3, "palaceChariot": 5, "palaceGuard": 3,
}
def write_weights(path: Path, w: dict) -> Path:
path.write_text("".join(f"{k}={int(round(v))}\n" for k, v in w.items()))
return path
def read_weights(path: Path) -> dict:
w = dict(DEFAULTS)
for line in path.read_text().splitlines():
if "=" in line:
k, v = line.split("=", 1)
w[k.strip()] = float(v)
return w
def _pair(wa: Path, wb: Path, depth: int, seed: int, max_ply: int, depth_a: int | None = None) -> tuple[int, int, int]:
"""A/B가 초·한을 한 번씩 맡는 2판. (A승, A패, 무)"""
out = subprocess.run(
[str(ARENA), "--games", "2", "--a", str(depth_a or depth), "--b", str(depth), "--wa", str(wa), "--wb", str(wb),
"--seed", str(seed), "--max-ply", str(max_ply)],
capture_output=True, text=True, check=True,
).stdout
m = re.search(r"A (\d+)승 (\d+)패 (\d+)무", out)
if not m:
raise RuntimeError(out[-300:])
return int(m.group(1)), int(m.group(2)), int(m.group(3))
def match(wa: Path, wb: Path, games: int, depth: int, max_ply: int, pool: cf.Executor,
depth_a: int | None = None) -> tuple[int, int, int]:
"""B 기준 (승, 패, 무)."""
seeds = [random.randrange(1 << 30) for _ in range(max(1, games // 2))]
win = loss = draw = 0
for a_w, a_l, d in pool.map(lambda s: _pair(wa, wb, depth, s, max_ply, depth_a), seeds):
win, loss, draw = win + a_l, loss + a_w, draw + d
return win, loss, draw
def score(w: int, l: int, d: int) -> float:
n = w + l + d
return (w + 0.5 * d) / n if n else 0.5
def cmd_compare(args: argparse.Namespace) -> None:
with cf.ThreadPoolExecutor(WORKERS) as pool:
t0 = time.time()
w, l, d = match(Path(args.a), Path(args.b), args.games, args.depth, args.max_ply, pool, args.depth_a)
print(f"B({args.b}, 깊이 {args.depth}) vs A({args.a}, 깊이 {args.depth_a or args.depth}), {w + l + d}판: "
f"B {w}승 {l}패 {d}무 → B 승률 {score(w, l, d):.1%} ({time.time() - t0:.0f}초)")
def cmd_spsa(args: argparse.Namespace) -> None:
TUNE.mkdir(parents=True, exist_ok=True)
theta = read_weights(Path(args.start)) if args.start else dict(DEFAULTS)
base = write_weights(TUNE / "base.txt", read_weights(Path(args.base)) if args.base else dict(DEFAULTS))
log = (TUNE / "spsa.log").open("a")
best_score = -1.0
keys = list(STEP)
with cf.ThreadPoolExecutor(WORKERS) as pool:
for k in range(1, args.iters + 1):
ck = 1.0 / (k ** 0.101) # 흔드는 폭은 천천히 줄임
ak = args.lr / ((k + 20) ** 0.602)
delta = {p: random.choice((-1, 1)) for p in keys}
plus = {**theta, **{p: theta[p] + ck * STEP[p] * delta[p] for p in keys}}
minus = {**theta, **{p: theta[p] - ck * STEP[p] * delta[p] for p in keys}}
fp = write_weights(TUNE / "plus.txt", plus)
fm = write_weights(TUNE / "minus.txt", minus)
w, l, d = match(fm, fp, args.games, args.depth, args.max_ply, pool) # plus 기준
r = (w - l) / max(1, w + l + d)
for p in keys:
theta[p] += ak * r * delta[p] * STEP[p] * 10
theta[p] = max(0.0, theta[p])
line = {"iter": k, "plus": [w, l, d], "theta": {p: round(theta[p], 1) for p in keys}}
print(f"[{k}] plus {w}승 {l}패 {d}무 " + " ".join(f"{p}={theta[p]:.0f}" for p in keys), flush=True)
if k % args.check_every == 0:
cur = write_weights(TUNE / f"theta_{k:04d}.txt", theta)
vw, vl, vd = match(base, cur, args.check_games, args.depth, args.max_ply, pool)
s = score(vw, vl, vd)
line["vs_base"] = [vw, vl, vd, round(s, 3)]
print(f" 검증: 기준 상대 {vw}승 {vl}패 {vd}무 → 승률 {s:.1%}", flush=True)
if s > best_score:
best_score = s
write_weights(TUNE / "best.txt", theta)
print(f" 최고 기록 → data/tune/best.txt", flush=True)
log.write(json.dumps(line, ensure_ascii=False) + "\n")
log.flush()
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("compare")
p.add_argument("a")
p.add_argument("b")
p.add_argument("--games", type=int, default=200)
p.add_argument("--depth", type=int, default=3)
p.add_argument("--depth-a", type=int, help="A만 다른 깊이 (생각 시간 맞춘 비교용)")
p.add_argument("--max-ply", type=int, default=200)
p.set_defaults(func=cmd_compare)
p = sub.add_parser("spsa")
p.add_argument("--start")
p.add_argument("--base", help="검증 상대 가중치 (기본: DEFAULTS)")
p.add_argument("--iters", type=int, default=300)
p.add_argument("--games", type=int, default=28, help="반복마다 θ+ vs θ- 판 수")
p.add_argument("--depth", type=int, default=3)
p.add_argument("--max-ply", type=int, default=200)
p.add_argument("--lr", type=float, default=2.0)
p.add_argument("--check-every", type=int, default=20)
p.add_argument("--check-games", type=int, default=112)
p.set_defaults(func=cmd_spsa)
args = ap.parse_args()
args.func(args)
if __name__ == "__main__":
main()