File size: 5,110 Bytes
ed7b4ce | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | #!/usr/bin/env python3
"""Head-to-head UCI match runner.
Plays two engine binaries against each other from randomised openings, with
colours swapped on every pair so an opening advantage cannot bias the result,
and reports the score with a likelihood-ratio confidence interval on Elo.
Usage: arena.py ENGINE_A ENGINE_B [games] ["nodes 20000"|"movetime 100"] [concurrency]
"""
import math
import multiprocessing as mp
import random
import subprocess
import sys
import chess
class Engine:
def __init__(self, path):
self.p = subprocess.Popen(
[path],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
bufsize=1,
)
self.send("uci")
self.wait("uciok")
self.send("setoption name Hash value 16")
self.isready()
def send(self, s):
self.p.stdin.write(s + "\n")
self.p.stdin.flush()
def wait(self, token):
while True:
line = self.p.stdout.readline()
if not line:
raise RuntimeError("engine died")
if line.startswith(token):
return line.strip()
def isready(self):
self.send("isready")
self.wait("readyok")
def bestmove(self, board, limit):
"""`limit` is either `movetime N` or `nodes N`. Node limits make the
result independent of how loaded the machine is, which matters when
the comparison itself is the point."""
self.send("position fen " + board.fen())
self.send("go " + limit)
line = self.wait("bestmove")
return line.split()[1]
def quit(self):
try:
self.send("quit")
self.p.wait(timeout=2)
except Exception:
self.p.kill()
def random_opening(rng, plies=8):
"""A shallow random opening, rejected if it is already lopsided in
material -- the point is variety, not chaos."""
while True:
b = chess.Board()
for _ in range(plies):
moves = list(b.legal_moves)
if not moves:
break
b.push(rng.choice(moves))
if b.is_game_over():
continue
vals = {chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3, chess.ROOK: 5, chess.QUEEN: 9}
bal = sum(
vals.get(p.piece_type, 0) * (1 if p.color else -1)
for p in b.piece_map().values()
)
if abs(bal) <= 2:
return b
def play(args):
"""One opening played twice, once with each engine as white."""
path_a, path_b, seed, limit = args
rng = random.Random(seed)
start = random_opening(rng)
ea, eb = Engine(path_a), Engine(path_b)
results = []
try:
for swap in (False, True):
board = start.copy()
white, black = (eb, ea) if swap else (ea, eb)
while not board.is_game_over(claim_draw=True) and board.fullmove_number < 200:
eng = white if board.turn == chess.WHITE else black
mv = eng.bestmove(board, limit)
if mv == "0000":
break
try:
board.push_uci(mv)
except ValueError:
# An illegal move loses the game outright.
board = None
results.append(0.0 if (eng is ea) else 1.0)
break
if board is None:
continue
r = board.result(claim_draw=True)
if r == "1-0":
results.append(0.0 if swap else 1.0)
elif r == "0-1":
results.append(1.0 if swap else 0.0)
else:
results.append(0.5)
finally:
ea.quit()
eb.quit()
return results
def elo(score, n):
if n == 0 or score <= 0:
return float("-inf"), 0.0
if score >= n:
return float("inf"), 0.0
p = score / n
e = -400 * math.log10(1 / p - 1)
# Standard error on the score rate, propagated through the Elo curve.
var = p * (1 - p) / n
se = 400 / math.log(10) * math.sqrt(var) / (p * (1 - p))
return e, 1.96 * se
def main():
a, b = sys.argv[1], sys.argv[2]
pairs = int(sys.argv[3]) // 2 if len(sys.argv) > 3 else 100
limit = sys.argv[4] if len(sys.argv) > 4 else "movetime 100"
conc = int(sys.argv[5]) if len(sys.argv) > 5 else 8
jobs = [(a, b, 9000 + i, limit) for i in range(pairs)]
score, n, w, d, l = 0.0, 0, 0, 0, 0
with mp.Pool(conc) as pool:
for res in pool.imap_unordered(play, jobs):
for r in res:
score += r
n += 1
w += r == 1.0
d += r == 0.5
l += r == 0.0
if n % 20 == 0:
e, err = elo(score, n)
print(f" {n} games +{w} ={d} -{l} Elo {e:+.0f} +/- {err:.0f}", flush=True)
e, err = elo(score, n)
print(f"\n{a} vs {b}")
print(f"games {n} +{w} ={d} -{l} score {score/n:.3f}")
print(f"Elo {e:+.1f} +/- {err:.1f} (95%)")
if __name__ == "__main__":
main()
|