게시판장기로 이해하는 규칙 엔진과 평가 함수 탐색예제 소스

예제 소스

장기로 이해하는 규칙 엔진과 평가 함수 탐색 · 파일 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()