aboutsummaryrefslogtreecommitdiff
path: root/train/src/ctak_train/strength.py
blob: 23026068ba2a213cf1203c99edc6904d43bb1700 (plain)
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
#
# This file is part of ctak.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with ctak. If not, see <https://www.gnu.org/licenses/>.

import math
from pathlib import Path

import matplotlib
import matplotlib.pyplot as plt
import numpy as np
from ctak_train.candidate import Candidate
from ctak_train.data import GameLog
from scipy.optimize import minimize

matplotlib.use("Agg")

# Elo points per unit of logit strength
ELO_PER_LOGIT = 400.0 / math.log(10.0)

# Spread of the zero-mean prior on the ratings, in logit units. Head-to-head
# results alone leave the likelihood unbounded while the data is thin (a pair
# that has never split draws runs off to +-inf).
PRIOR_SD_LOGIT = 1.0


def fit_strengths(log: GameLog, n_candidates: int) -> tuple[np.ndarray, np.ndarray]:
    """Bradley-Terry logistic model (draws scored 0.5) fit in natural-logit
    units, mean anchored to 0, reported as Elo points around 1500. Returns
    (strengths, +-1 sd).
    """
    rated = log.black != log.white
    black = log.black[rated].astype(np.int64)
    white = log.white[rated].astype(np.int64)
    if n_candidates < 2 or black.size == 0:
        return np.full(n_candidates, 1500.0), np.zeros(n_candidates)
    score = (log.result[rated].astype(np.float64) + 1.0) / 2.0

    # n_candidates - 1 free parameters; the last candidate anchors the mean to 0
    design = np.eye(n_candidates, n_candidates - 1)
    design[n_candidates - 1] = -1.0

    def nll_grad(theta: np.ndarray) -> tuple[float, np.ndarray]:
        rating = design @ theta
        x = np.clip(rating[black] - rating[white], -350.0, 350.0)
        p = 1.0 / (1.0 + np.exp(-x))
        sp = np.maximum(x, 0.0) + np.log1p(np.exp(-np.abs(x)))  # softplus(x)
        sp_neg = np.maximum(-x, 0.0) + np.log1p(np.exp(-np.abs(x)))
        # nll = score*softplus(-x) + (1-score)*softplus(x): finite for all x
        nll = float(np.sum(score * sp_neg + (1.0 - score) * sp))
        nll += 0.5 * float(rating @ rating) / PRIOR_SD_LOGIT**2

        grad_rating = rating / PRIOR_SD_LOGIT**2
        np.add.at(grad_rating, black, p - score)
        np.add.at(grad_rating, white, score - p)
        return nll, design.T @ grad_rating

    def hessian(theta: np.ndarray) -> np.ndarray:
        rating = design @ theta
        x = np.clip(rating[black] - rating[white], -350.0, 350.0)
        p = 1.0 / (1.0 + np.exp(-x))
        w = p * (1.0 - p)

        diag = np.bincount(black, weights=w, minlength=n_candidates) + np.bincount(
            white, weights=w, minlength=n_candidates
        )
        off = np.bincount(
            black * n_candidates + white, weights=-w, minlength=n_candidates**2
        ).reshape(n_candidates, n_candidates)
        hess_rating = (
            off
            + np.diag(diag - np.diag(off))
            + np.eye(n_candidates) / PRIOR_SD_LOGIT**2
        )
        return design.T @ hess_rating @ design

    res = minimize(nll_grad, np.zeros(n_candidates - 1), jac=True, method="L-BFGS-B")
    if not res.success:
        print(f"strength fit did not converge: {res.message}")
    cov = design @ np.linalg.pinv(hessian(res.x)) @ design.T
    se = np.sqrt(np.maximum(np.diag(cov), 0.0)) * ELO_PER_LOGIT
    return 1500.0 + (design @ res.x) * ELO_PER_LOGIT, se


def plot_strengths(
    history: list[np.ndarray], candidates: list[Candidate], path: Path
) -> None:
    fig, ax = plt.subplots(figsize=(10, 6))
    iterations = range(1, len(history) + 1)
    for idx, cand in enumerate(candidates):
        ax.plot(
            iterations,
            [row[idx] for row in history],
            marker=".",
            ms=3,
            lw=1.2,
            label=f"cand {idx:03d} {cand}",
        )
    ax.axhline(1500.0, color="gray", lw=0.5)
    ax.set_xscale("log")
    ax.set_xlabel("Iteration")
    ax.set_ylabel("Strength (fit on all games so far, mean 1500)")
    ax.set_title("Candidate strength, refitted on the full history each iteration")
    ax.legend(fontsize=8)
    fig.tight_layout()
    _ = fig.savefig(path, dpi=150)
    plt.close(fig)