unirating 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unirating/__init__.py +35 -0
- unirating/_version.py +1 -0
- unirating/calibration.py +103 -0
- unirating/cli.py +113 -0
- unirating/exceptions.py +35 -0
- unirating/fusion.py +161 -0
- unirating/models.py +143 -0
- unirating/py.typed +0 -0
- unirating-0.1.0.dist-info/METADATA +236 -0
- unirating-0.1.0.dist-info/RECORD +13 -0
- unirating-0.1.0.dist-info/WHEEL +4 -0
- unirating-0.1.0.dist-info/entry_points.txt +2 -0
- unirating-0.1.0.dist-info/licenses/LICENSE +21 -0
unirating/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""unirating — Bayesian inverse-variance fusion of FIDE, Chess.com and Lichess ratings.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
Rating, Prior, Calibration, TimeControl, FusionResult, fuse
|
|
5
|
+
InvalidRatingError, MissingSigmaWarning, ProvisionalRatingWarning
|
|
6
|
+
|
|
7
|
+
See README.md and docs/derivation.md for the mathematical background.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .calibration import Calibration, TimeControl
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
InvalidRatingError,
|
|
16
|
+
MissingSigmaWarning,
|
|
17
|
+
ProvisionalRatingWarning,
|
|
18
|
+
UniratingError,
|
|
19
|
+
)
|
|
20
|
+
from .fusion import fuse
|
|
21
|
+
from .models import FusionResult, Prior, Rating
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Calibration",
|
|
25
|
+
"FusionResult",
|
|
26
|
+
"InvalidRatingError",
|
|
27
|
+
"MissingSigmaWarning",
|
|
28
|
+
"Prior",
|
|
29
|
+
"ProvisionalRatingWarning",
|
|
30
|
+
"Rating",
|
|
31
|
+
"TimeControl",
|
|
32
|
+
"UniratingError",
|
|
33
|
+
"__version__",
|
|
34
|
+
"fuse",
|
|
35
|
+
]
|
unirating/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
unirating/calibration.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Source <-> FIDE affine calibration constants per time control.
|
|
2
|
+
|
|
3
|
+
A measurement on a non-FIDE source is modelled as
|
|
4
|
+
|
|
5
|
+
R_source = alpha + beta * theta + noise
|
|
6
|
+
|
|
7
|
+
so the FIDE-equivalent estimate is
|
|
8
|
+
|
|
9
|
+
theta_hat = (R_source - alpha) / beta
|
|
10
|
+
|
|
11
|
+
with variance (sigma_source / beta)^2.
|
|
12
|
+
|
|
13
|
+
The default constants below come from community regressions; they are not
|
|
14
|
+
authoritative. Pass your own `Calibration` instance to `fuse()` when you have
|
|
15
|
+
better-fitted constants (e.g. a dataset of players holding ratings on multiple
|
|
16
|
+
sites for the time control you care about).
|
|
17
|
+
|
|
18
|
+
Default sigma values are coarse fallbacks used only when the caller does not
|
|
19
|
+
supply an explicit sigma for a Rating. They will trigger MissingSigmaWarning.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from enum import Enum
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TimeControl(str, Enum):
|
|
29
|
+
"""Standard chess time-control buckets."""
|
|
30
|
+
|
|
31
|
+
BULLET = "bullet"
|
|
32
|
+
BLITZ = "blitz"
|
|
33
|
+
RAPID = "rapid"
|
|
34
|
+
CLASSICAL = "classical"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# (chesscom_alpha, chesscom_beta, lichess_alpha, lichess_beta)
|
|
38
|
+
# Source: see docs/citations.md (Lichess FAQ, Chess.com community regressions,
|
|
39
|
+
# Reddit r/chess long-running comparisons).
|
|
40
|
+
_DEFAULT_CALIBRATION_BY_TC: dict[TimeControl, tuple[float, float, float, float]] = {
|
|
41
|
+
TimeControl.BULLET: (150.0, 1.0, 400.0, 1.0),
|
|
42
|
+
TimeControl.BLITZ: (120.0, 1.0, 350.0, 1.0),
|
|
43
|
+
TimeControl.RAPID: (100.0, 1.0, 250.0, 1.0),
|
|
44
|
+
TimeControl.CLASSICAL: ( 50.0, 1.0, 200.0, 1.0),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Default fallback sigmas (per native scale) when a Rating supplies no sigma.
|
|
49
|
+
# Deliberately conservative so a missing-sigma input contributes little.
|
|
50
|
+
_DEFAULT_SIGMA_BY_TC: dict[TimeControl, dict[str, float]] = {
|
|
51
|
+
TimeControl.BULLET: {"fide": 100.0, "chesscom": 100.0, "lichess": 100.0},
|
|
52
|
+
TimeControl.BLITZ: {"fide": 90.0, "chesscom": 90.0, "lichess": 90.0},
|
|
53
|
+
TimeControl.RAPID: {"fide": 80.0, "chesscom": 80.0, "lichess": 80.0},
|
|
54
|
+
TimeControl.CLASSICAL: {"fide": 70.0, "chesscom": 70.0, "lichess": 70.0},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Calibration:
|
|
60
|
+
"""Affine calibration source -> FIDE.
|
|
61
|
+
|
|
62
|
+
For FIDE the identity is implicit: alpha=0, beta=1.
|
|
63
|
+
|
|
64
|
+
Override any subset of fields; the rest are filled from the time-control
|
|
65
|
+
defaults at fuse() time.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
chesscom_alpha: float = float("nan")
|
|
69
|
+
chesscom_beta: float = float("nan")
|
|
70
|
+
lichess_alpha: float = float("nan")
|
|
71
|
+
lichess_beta: float = float("nan")
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def for_time_control(cls, tc: TimeControl) -> Calibration:
|
|
75
|
+
a_c, b_c, a_l, b_l = _DEFAULT_CALIBRATION_BY_TC[tc]
|
|
76
|
+
return cls(
|
|
77
|
+
chesscom_alpha=a_c,
|
|
78
|
+
chesscom_beta=b_c,
|
|
79
|
+
lichess_alpha=a_l,
|
|
80
|
+
lichess_beta=b_l,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def filled(self, tc: TimeControl) -> Calibration:
|
|
84
|
+
"""Return a Calibration with NaN slots filled from the TC defaults."""
|
|
85
|
+
import math
|
|
86
|
+
|
|
87
|
+
a_c, b_c, a_l, b_l = _DEFAULT_CALIBRATION_BY_TC[tc]
|
|
88
|
+
return Calibration(
|
|
89
|
+
chesscom_alpha=self.chesscom_alpha if math.isfinite(self.chesscom_alpha) else a_c,
|
|
90
|
+
chesscom_beta=self.chesscom_beta if math.isfinite(self.chesscom_beta) else b_c,
|
|
91
|
+
lichess_alpha=self.lichess_alpha if math.isfinite(self.lichess_alpha) else a_l,
|
|
92
|
+
lichess_beta=self.lichess_beta if math.isfinite(self.lichess_beta) else b_l,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def default_sigma(tc: TimeControl, source: str) -> float:
|
|
97
|
+
"""Fallback sigma on the source's native scale when the caller omits one."""
|
|
98
|
+
try:
|
|
99
|
+
return _DEFAULT_SIGMA_BY_TC[tc][source]
|
|
100
|
+
except KeyError as exc:
|
|
101
|
+
raise KeyError(
|
|
102
|
+
f"no default sigma registered for time_control={tc!r} source={source!r}"
|
|
103
|
+
) from exc
|
unirating/cli.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Command-line interface for unirating."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .calibration import TimeControl
|
|
11
|
+
from .fusion import fuse
|
|
12
|
+
from .models import Prior, Rating
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _maybe_rating(value: float | None, sigma: float | None) -> Rating | None:
|
|
16
|
+
if value is None:
|
|
17
|
+
return None
|
|
18
|
+
return Rating(value=value, sigma=sigma)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
22
|
+
p = argparse.ArgumentParser(
|
|
23
|
+
prog="unirating",
|
|
24
|
+
description=(
|
|
25
|
+
"Fuse FIDE, Chess.com and Lichess ratings into a single FIDE-scale "
|
|
26
|
+
"rating using Bayesian inverse-variance weighting."
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
30
|
+
p.add_argument(
|
|
31
|
+
"--time-control", "-t",
|
|
32
|
+
choices=[tc.value for tc in TimeControl],
|
|
33
|
+
default=TimeControl.RAPID.value,
|
|
34
|
+
help="Time control bucket all ratings belong to (default: rapid).",
|
|
35
|
+
)
|
|
36
|
+
p.add_argument("--fide", type=float, default=None, help="FIDE rating value.")
|
|
37
|
+
p.add_argument("--fide-sigma", type=float, default=None,
|
|
38
|
+
help="FIDE rating 1-sigma uncertainty (default: time-control fallback).")
|
|
39
|
+
p.add_argument("--chesscom", type=float, default=None, help="Chess.com rating value.")
|
|
40
|
+
p.add_argument("--chesscom-rd", type=float, default=None,
|
|
41
|
+
help="Chess.com Glicko RD (preferred name for --chesscom-sigma).")
|
|
42
|
+
p.add_argument("--chesscom-sigma", type=float, default=None,
|
|
43
|
+
help=argparse.SUPPRESS)
|
|
44
|
+
p.add_argument("--lichess", type=float, default=None, help="Lichess rating value.")
|
|
45
|
+
p.add_argument("--lichess-rd", type=float, default=None,
|
|
46
|
+
help="Lichess Glicko-2 RD (preferred name for --lichess-sigma).")
|
|
47
|
+
p.add_argument("--lichess-sigma", type=float, default=None,
|
|
48
|
+
help=argparse.SUPPRESS)
|
|
49
|
+
p.add_argument("--prior-mu", type=float, default=None,
|
|
50
|
+
help="Prior mean on the FIDE scale (default: 1500).")
|
|
51
|
+
p.add_argument("--prior-sigma", type=float, default=None,
|
|
52
|
+
help="Prior std-dev on the FIDE scale (default: 350).")
|
|
53
|
+
p.add_argument("--json", action="store_true",
|
|
54
|
+
help="Emit a JSON object instead of human-readable output.")
|
|
55
|
+
return p
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _pct(x: float) -> str:
|
|
59
|
+
return f"{100.0 * x:.0f}%"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
63
|
+
args = build_parser().parse_args(argv)
|
|
64
|
+
|
|
65
|
+
tc = TimeControl(args.time_control)
|
|
66
|
+
|
|
67
|
+
cc_sigma = args.chesscom_rd if args.chesscom_rd is not None else args.chesscom_sigma
|
|
68
|
+
li_sigma = args.lichess_rd if args.lichess_rd is not None else args.lichess_sigma
|
|
69
|
+
|
|
70
|
+
prior_kwargs = {}
|
|
71
|
+
if args.prior_mu is not None:
|
|
72
|
+
prior_kwargs["mu"] = args.prior_mu
|
|
73
|
+
if args.prior_sigma is not None:
|
|
74
|
+
prior_kwargs["sigma"] = args.prior_sigma
|
|
75
|
+
prior = Prior(**prior_kwargs) if prior_kwargs else None
|
|
76
|
+
|
|
77
|
+
result = fuse(
|
|
78
|
+
fide=_maybe_rating(args.fide, args.fide_sigma),
|
|
79
|
+
chesscom=_maybe_rating(args.chesscom, cc_sigma),
|
|
80
|
+
lichess=_maybe_rating(args.lichess, li_sigma),
|
|
81
|
+
time_control=tc,
|
|
82
|
+
prior=prior,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if args.json:
|
|
86
|
+
import json
|
|
87
|
+
|
|
88
|
+
payload = {
|
|
89
|
+
"rating": result.rating,
|
|
90
|
+
"sigma": result.sigma,
|
|
91
|
+
"ci95_low": result.ci95[0],
|
|
92
|
+
"ci95_high": result.ci95[1],
|
|
93
|
+
"used_sources": list(result.used_sources),
|
|
94
|
+
"is_prior_only": result.is_prior_only,
|
|
95
|
+
"contributions": dict(result.contributions),
|
|
96
|
+
"prior": {"mu": result.prior.mu, "sigma": result.prior.sigma},
|
|
97
|
+
"time_control": tc.value,
|
|
98
|
+
}
|
|
99
|
+
print(json.dumps(payload, indent=2))
|
|
100
|
+
else:
|
|
101
|
+
low, high = result.ci95
|
|
102
|
+
sources = ", ".join(result.used_sources) if result.used_sources else "(none, prior only)"
|
|
103
|
+
contrib_parts = [f"{k}={_pct(v)}" for k, v in result.contributions.items()]
|
|
104
|
+
print(f"Unified rating (FIDE scale): {result.rating:.0f} +/- {result.sigma:.0f}")
|
|
105
|
+
print(f"95% CI: [{low:.0f}, {high:.0f}]")
|
|
106
|
+
print(f"Sources used: {sources}")
|
|
107
|
+
print(f"Contributions: {' '.join(contrib_parts)}")
|
|
108
|
+
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
sys.exit(main())
|
unirating/exceptions.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Exception and warning types raised by unirating."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UniratingError(Exception):
|
|
7
|
+
"""Base class for all errors raised by this package."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidRatingError(UniratingError, ValueError):
|
|
11
|
+
"""Raised when a Rating or Prior contains an invalid value.
|
|
12
|
+
|
|
13
|
+
Cases:
|
|
14
|
+
- non-finite value or sigma (NaN, +/-inf)
|
|
15
|
+
- non-positive sigma
|
|
16
|
+
- rating outside the configured plausible bounds
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MissingSigmaWarning(UserWarning):
|
|
21
|
+
"""Emitted when a Rating has no sigma and a default had to be substituted.
|
|
22
|
+
|
|
23
|
+
A default sigma is a coarse approximation. Pass an explicit sigma whenever
|
|
24
|
+
you can — Lichess exposes RD via the public API, and Chess.com exposes it
|
|
25
|
+
via /pub/player/{user}/stats.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProvisionalRatingWarning(UserWarning):
|
|
30
|
+
"""Emitted when a rating's sigma indicates a provisional account.
|
|
31
|
+
|
|
32
|
+
Glicko-2 considers RD >= 110 provisional (Lichess shows '?' next to it).
|
|
33
|
+
The fusion still works — such ratings simply receive near-zero weight —
|
|
34
|
+
but the caller is warned in case they want to treat it specially.
|
|
35
|
+
"""
|
unirating/fusion.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""The fusion entry point.
|
|
2
|
+
|
|
3
|
+
`fuse()` computes the Bayesian posterior mean and standard deviation of the
|
|
4
|
+
latent FIDE-scale skill theta given any subset of {FIDE, Chess.com, Lichess}
|
|
5
|
+
ratings, with a Gaussian prior. See docs/derivation.md for the proof.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import warnings
|
|
12
|
+
|
|
13
|
+
from .calibration import Calibration, TimeControl, default_sigma
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
InvalidRatingError,
|
|
16
|
+
MissingSigmaWarning,
|
|
17
|
+
ProvisionalRatingWarning,
|
|
18
|
+
)
|
|
19
|
+
from .models import FusionResult, Prior, Rating
|
|
20
|
+
|
|
21
|
+
# Glicko-2 threshold beyond which Lichess displays '?' (provisional).
|
|
22
|
+
_PROVISIONAL_RD_THRESHOLD = 110.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _coerce_sigma(
|
|
26
|
+
source: str,
|
|
27
|
+
rating: Rating,
|
|
28
|
+
tc: TimeControl,
|
|
29
|
+
) -> float:
|
|
30
|
+
"""Return the sigma to use for `rating`, emitting warnings where needed."""
|
|
31
|
+
if rating.sigma is None:
|
|
32
|
+
fallback = default_sigma(tc, source)
|
|
33
|
+
warnings.warn(
|
|
34
|
+
f"{source}: no sigma supplied; using default {fallback} for "
|
|
35
|
+
f"time_control={tc.value!r}. Pass an explicit sigma (e.g. the "
|
|
36
|
+
"Glicko RD from the API) for a tighter estimate.",
|
|
37
|
+
MissingSigmaWarning,
|
|
38
|
+
stacklevel=3,
|
|
39
|
+
)
|
|
40
|
+
return fallback
|
|
41
|
+
|
|
42
|
+
sigma = float(rating.sigma)
|
|
43
|
+
if source in {"chesscom", "lichess"} and sigma >= _PROVISIONAL_RD_THRESHOLD:
|
|
44
|
+
warnings.warn(
|
|
45
|
+
f"{source}: sigma={sigma} indicates a provisional rating "
|
|
46
|
+
f"(>= {_PROVISIONAL_RD_THRESHOLD}); it will receive very little "
|
|
47
|
+
"weight in the fusion.",
|
|
48
|
+
ProvisionalRatingWarning,
|
|
49
|
+
stacklevel=3,
|
|
50
|
+
)
|
|
51
|
+
return sigma
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fuse(
|
|
55
|
+
*,
|
|
56
|
+
fide: Rating | None = None,
|
|
57
|
+
chesscom: Rating | None = None,
|
|
58
|
+
lichess: Rating | None = None,
|
|
59
|
+
time_control: TimeControl = TimeControl.RAPID,
|
|
60
|
+
prior: Prior | None = None,
|
|
61
|
+
calibration: Calibration | None = None,
|
|
62
|
+
) -> FusionResult:
|
|
63
|
+
"""Fuse up to three ratings into a single FIDE-scale rating.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
fide: FIDE rating measurement, or None if unavailable.
|
|
67
|
+
chesscom: Chess.com rating measurement, or None if unavailable.
|
|
68
|
+
lichess: Lichess rating measurement, or None if unavailable.
|
|
69
|
+
time_control: Which time-control bucket the ratings belong to.
|
|
70
|
+
All three Rating arguments MUST be from the same bucket --
|
|
71
|
+
mixing time controls is a user error this function cannot
|
|
72
|
+
detect, so the caller is responsible.
|
|
73
|
+
prior: Gaussian prior over the latent skill theta on the FIDE scale.
|
|
74
|
+
Defaults to N(1500, 350^2).
|
|
75
|
+
calibration: Affine source->FIDE calibration. Any field left as NaN
|
|
76
|
+
is filled from the per-time-control defaults. Defaults to the
|
|
77
|
+
time-control defaults entirely.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A FusionResult on the FIDE scale.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
TypeError: if `time_control` is not a TimeControl.
|
|
84
|
+
InvalidRatingError: if any Rating or Prior is malformed
|
|
85
|
+
(already raised at construction time, but re-raised here for
|
|
86
|
+
programmatic checks like negative beta).
|
|
87
|
+
"""
|
|
88
|
+
if not isinstance(time_control, TimeControl):
|
|
89
|
+
raise TypeError(
|
|
90
|
+
f"time_control must be a TimeControl, got {type(time_control).__name__}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
prior = prior if prior is not None else Prior()
|
|
94
|
+
calibration = (calibration or Calibration()).filled(time_control)
|
|
95
|
+
|
|
96
|
+
# Sanity-check calibration slopes -- a zero or non-finite beta would
|
|
97
|
+
# produce a degenerate (infinite-variance) calibrated estimate.
|
|
98
|
+
for name, beta in (
|
|
99
|
+
("chesscom_beta", calibration.chesscom_beta),
|
|
100
|
+
("lichess_beta", calibration.lichess_beta),
|
|
101
|
+
):
|
|
102
|
+
if not math.isfinite(beta) or beta <= 0.0:
|
|
103
|
+
raise InvalidRatingError(
|
|
104
|
+
f"Calibration.{name} must be a positive finite number, got {beta!r}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Accumulators on the FIDE scale.
|
|
108
|
+
# We work in PRECISION space (1/variance) to avoid catastrophic
|
|
109
|
+
# cancellation when one source dominates the others.
|
|
110
|
+
w_total = prior.precision
|
|
111
|
+
wx_total = prior.mu * prior.precision
|
|
112
|
+
|
|
113
|
+
per_source_weight: dict[str, float] = {"prior": prior.precision}
|
|
114
|
+
used_sources: list[str] = []
|
|
115
|
+
|
|
116
|
+
if fide is not None:
|
|
117
|
+
sigma = _coerce_sigma("fide", fide, time_control)
|
|
118
|
+
theta_hat = float(fide.value) # alpha=0, beta=1
|
|
119
|
+
tau2 = sigma * sigma # variance on FIDE scale
|
|
120
|
+
w = 1.0 / tau2
|
|
121
|
+
w_total += w
|
|
122
|
+
wx_total += w * theta_hat
|
|
123
|
+
per_source_weight["fide"] = w
|
|
124
|
+
used_sources.append("fide")
|
|
125
|
+
|
|
126
|
+
if chesscom is not None:
|
|
127
|
+
sigma = _coerce_sigma("chesscom", chesscom, time_control)
|
|
128
|
+
a, b = calibration.chesscom_alpha, calibration.chesscom_beta
|
|
129
|
+
theta_hat = (float(chesscom.value) - a) / b
|
|
130
|
+
tau2 = (sigma * sigma) / (b * b)
|
|
131
|
+
w = 1.0 / tau2
|
|
132
|
+
w_total += w
|
|
133
|
+
wx_total += w * theta_hat
|
|
134
|
+
per_source_weight["chesscom"] = w
|
|
135
|
+
used_sources.append("chesscom")
|
|
136
|
+
|
|
137
|
+
if lichess is not None:
|
|
138
|
+
sigma = _coerce_sigma("lichess", lichess, time_control)
|
|
139
|
+
a, b = calibration.lichess_alpha, calibration.lichess_beta
|
|
140
|
+
theta_hat = (float(lichess.value) - a) / b
|
|
141
|
+
tau2 = (sigma * sigma) / (b * b)
|
|
142
|
+
w = 1.0 / tau2
|
|
143
|
+
w_total += w
|
|
144
|
+
wx_total += w * theta_hat
|
|
145
|
+
per_source_weight["lichess"] = w
|
|
146
|
+
used_sources.append("lichess")
|
|
147
|
+
|
|
148
|
+
# w_total can never be 0: the prior always contributes prior.precision > 0.
|
|
149
|
+
posterior_mu = wx_total / w_total
|
|
150
|
+
posterior_sigma = math.sqrt(1.0 / w_total)
|
|
151
|
+
|
|
152
|
+
contributions = {k: v / w_total for k, v in per_source_weight.items()}
|
|
153
|
+
|
|
154
|
+
return FusionResult(
|
|
155
|
+
rating=posterior_mu,
|
|
156
|
+
sigma=posterior_sigma,
|
|
157
|
+
contributions=contributions,
|
|
158
|
+
used_sources=tuple(used_sources),
|
|
159
|
+
is_prior_only=len(used_sources) == 0,
|
|
160
|
+
prior=prior,
|
|
161
|
+
)
|
unirating/models.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Data models: Rating, Prior, FusionResult.
|
|
2
|
+
|
|
3
|
+
All models are immutable (frozen dataclasses) and validate on construction.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from .exceptions import InvalidRatingError
|
|
13
|
+
|
|
14
|
+
# Plausible rating bounds on the native scale of any of the three systems.
|
|
15
|
+
# Wide enough to admit any human rating ever recorded; tight enough to catch
|
|
16
|
+
# obvious typos (e.g. someone passing a percentage instead of an Elo).
|
|
17
|
+
_DEFAULT_MIN_RATING = 100.0
|
|
18
|
+
_DEFAULT_MAX_RATING = 3500.0
|
|
19
|
+
|
|
20
|
+
# Default prior on the FIDE scale.
|
|
21
|
+
# mu0=1500 is the historical Elo starting point and roughly the world median
|
|
22
|
+
# of rated adult club players. sigma0=350 covers ~800-2200 within +/-2sigma.
|
|
23
|
+
_DEFAULT_PRIOR_MU = 1500.0
|
|
24
|
+
_DEFAULT_PRIOR_SIGMA = 350.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_finite(name: str, value: float) -> None:
|
|
28
|
+
if not math.isfinite(value):
|
|
29
|
+
raise InvalidRatingError(f"{name} must be a finite number, got {value!r}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _validate_rating_bounds(
|
|
33
|
+
value: float,
|
|
34
|
+
min_value: float = _DEFAULT_MIN_RATING,
|
|
35
|
+
max_value: float = _DEFAULT_MAX_RATING,
|
|
36
|
+
) -> None:
|
|
37
|
+
if not (min_value <= value <= max_value):
|
|
38
|
+
raise InvalidRatingError(
|
|
39
|
+
f"rating value {value!r} is outside the plausible range "
|
|
40
|
+
f"[{min_value}, {max_value}]. If your data legitimately lies "
|
|
41
|
+
f"outside, construct Rating with min_value/max_value overrides."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Rating:
|
|
47
|
+
"""A noisy measurement of a player's strength from a single source.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
value: The rating on the source's native scale (FIDE Elo, Chess.com
|
|
51
|
+
Glicko, Lichess Glicko-2, etc.).
|
|
52
|
+
sigma: The 1-sigma measurement uncertainty on the same native scale.
|
|
53
|
+
For Lichess and Chess.com this is the Glicko RD exposed by the API.
|
|
54
|
+
For FIDE, use a rule of thumb (~60 for settled players) or
|
|
55
|
+
leave as None to fall back to a coarse default.
|
|
56
|
+
min_value, max_value: Plausibility bounds for the rating value.
|
|
57
|
+
Defaults reject anything outside [100, 3500].
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
value: float
|
|
61
|
+
sigma: float | None = None
|
|
62
|
+
min_value: float = _DEFAULT_MIN_RATING
|
|
63
|
+
max_value: float = _DEFAULT_MAX_RATING
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
_validate_finite("Rating.value", float(self.value))
|
|
67
|
+
_validate_rating_bounds(float(self.value), self.min_value, self.max_value)
|
|
68
|
+
if self.sigma is not None:
|
|
69
|
+
_validate_finite("Rating.sigma", float(self.sigma))
|
|
70
|
+
if self.sigma <= 0.0:
|
|
71
|
+
raise InvalidRatingError(
|
|
72
|
+
f"Rating.sigma must be strictly positive, got {self.sigma!r}. "
|
|
73
|
+
"A non-positive sigma implies infinite precision, which is "
|
|
74
|
+
"never true of a real rating measurement."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Prior:
|
|
80
|
+
"""Gaussian prior on the latent FIDE-scale skill theta.
|
|
81
|
+
|
|
82
|
+
Defaults to N(1500, 350^2), appropriate for an adult club-player
|
|
83
|
+
population. Lower mu for juniors, raise it for titled-player populations.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
mu: Prior mean of latent skill (FIDE scale).
|
|
87
|
+
sigma: Prior standard deviation. MUST be strictly positive and finite;
|
|
88
|
+
use a large value (e.g. 1e6) to obtain an effectively flat prior.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
mu: float = _DEFAULT_PRIOR_MU
|
|
92
|
+
sigma: float = _DEFAULT_PRIOR_SIGMA
|
|
93
|
+
|
|
94
|
+
def __post_init__(self) -> None:
|
|
95
|
+
_validate_finite("Prior.mu", float(self.mu))
|
|
96
|
+
_validate_finite("Prior.sigma", float(self.sigma))
|
|
97
|
+
if self.sigma <= 0.0:
|
|
98
|
+
raise InvalidRatingError(
|
|
99
|
+
f"Prior.sigma must be strictly positive, got {self.sigma!r}."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def precision(self) -> float:
|
|
104
|
+
"""1 / sigma^2."""
|
|
105
|
+
return 1.0 / (self.sigma * self.sigma)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class FusionResult:
|
|
110
|
+
"""The output of `fuse()`.
|
|
111
|
+
|
|
112
|
+
Attributes:
|
|
113
|
+
rating: The unified rating on the FIDE scale (posterior mean of theta).
|
|
114
|
+
sigma: 1-sigma posterior uncertainty on the FIDE scale.
|
|
115
|
+
contributions: For each source key ('prior', 'fide', 'chesscom',
|
|
116
|
+
'lichess'), its share of the total precision -- i.e. how much
|
|
117
|
+
it pulled the answer. Always sums to 1.0.
|
|
118
|
+
used_sources: The subset of {'fide', 'chesscom', 'lichess'} that
|
|
119
|
+
actually contributed observations (sigma>0 and value present).
|
|
120
|
+
is_prior_only: True iff no rating sources were available and the
|
|
121
|
+
returned (rating, sigma) is exactly the prior.
|
|
122
|
+
prior: The Prior used.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
rating: float
|
|
126
|
+
sigma: float
|
|
127
|
+
contributions: Mapping[str, float] = field(default_factory=dict)
|
|
128
|
+
used_sources: tuple[str, ...] = ()
|
|
129
|
+
is_prior_only: bool = False
|
|
130
|
+
prior: Prior = field(default_factory=Prior)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def ci95(self) -> tuple[float, float]:
|
|
134
|
+
"""The 95% credible interval, computed as mean +/- 1.96 sigma."""
|
|
135
|
+
half = 1.96 * self.sigma
|
|
136
|
+
return (self.rating - half, self.rating + half)
|
|
137
|
+
|
|
138
|
+
def __repr__(self) -> str:
|
|
139
|
+
sources = ",".join(self.used_sources) if self.used_sources else "prior-only"
|
|
140
|
+
return (
|
|
141
|
+
f"FusionResult(rating={self.rating:.1f}, sigma={self.sigma:.1f}, "
|
|
142
|
+
f"sources=[{sources}])"
|
|
143
|
+
)
|
unirating/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unirating
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bayesian inverse-variance fusion of FIDE, Chess.com and Lichess ratings into a single FIDE-scale rating.
|
|
5
|
+
Project-URL: Homepage, https://github.com/souravsahums/unirating
|
|
6
|
+
Project-URL: Documentation, https://github.com/souravsahums/unirating#readme
|
|
7
|
+
Project-URL: Issues, https://github.com/souravsahums/unirating/issues
|
|
8
|
+
Project-URL: Source, https://github.com/souravsahums/unirating
|
|
9
|
+
Author-email: Sourav Sahu <souravsahu@duck.com>
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 Sourav Sahu
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: bayesian,chess,chess.com,elo,fide,glicko,lichess,meta-analysis,rating,sensor-fusion
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: Intended Audience :: Science/Research
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Operating System :: OS Independent
|
|
38
|
+
Classifier: Programming Language :: Python :: 3
|
|
39
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
45
|
+
Classifier: Topic :: Games/Entertainment :: Board Games
|
|
46
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
47
|
+
Classifier: Typing :: Typed
|
|
48
|
+
Requires-Python: >=3.9
|
|
49
|
+
Provides-Extra: dev
|
|
50
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
51
|
+
Requires-Dist: hypothesis>=6; extra == 'dev'
|
|
52
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
53
|
+
Requires-Dist: pytest-cov>=4; extra == 'dev'
|
|
54
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
55
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
56
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
57
|
+
Provides-Extra: test
|
|
58
|
+
Requires-Dist: hypothesis>=6; extra == 'test'
|
|
59
|
+
Requires-Dist: pytest-cov>=4; extra == 'test'
|
|
60
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
61
|
+
Description-Content-Type: text/markdown
|
|
62
|
+
|
|
63
|
+
# unirating
|
|
64
|
+
|
|
65
|
+
[](https://www.python.org/downloads/)
|
|
66
|
+
[](LICENSE)
|
|
67
|
+
|
|
68
|
+
> Author: **Sourav Sahu** ([@souravsahums](https://github.com/souravsahums))
|
|
69
|
+
|
|
70
|
+
Bayesian inverse-variance **fusion** of a player's **FIDE**, **Chess.com** and **Lichess** ratings into a single number on the **FIDE scale**, with proper handling of missing ratings, calibrated per time control.
|
|
71
|
+
|
|
72
|
+
The estimator is the **maximum-likelihood / Best Linear Unbiased Estimator** (Gauss–Markov) when all sources are present, and the **posterior mean of a Gaussian–Gaussian Bayesian model** when one or more sources are missing — so a player with no ratings at all gracefully degrades to the population prior (the "base rating") rather than crashing.
|
|
73
|
+
|
|
74
|
+
Full mathematical derivation with proof: [docs/derivation.md](docs/derivation.md).
|
|
75
|
+
Citations: [docs/citations.md](docs/citations.md).
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Install
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install unirating
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Zero runtime dependencies. Python 3.9+.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Quick start
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from unirating import Rating, TimeControl, fuse
|
|
93
|
+
|
|
94
|
+
result = fuse(
|
|
95
|
+
fide = Rating(value=1820, sigma=60), # FIDE Rapid, settled
|
|
96
|
+
chesscom = Rating(value=1950, sigma=55), # Chess.com Rapid, RD=55
|
|
97
|
+
lichess = Rating(value=2100, sigma=50), # Lichess Rapid, RD=50
|
|
98
|
+
time_control = TimeControl.RAPID,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
print(result.rating) # 1838.95 (FIDE scale)
|
|
102
|
+
print(result.sigma) # 31.36 (1-sigma uncertainty)
|
|
103
|
+
print(result.ci95) # (1777.5, 1900.4)
|
|
104
|
+
print(result.contributions) # per-source weight share, sums to 1.0
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
A player with no ratings at all:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from unirating import fuse, TimeControl
|
|
111
|
+
|
|
112
|
+
result = fuse(time_control=TimeControl.RAPID)
|
|
113
|
+
print(result.rating) # 1500.0 (prior mean — the "base rating")
|
|
114
|
+
print(result.sigma) # 350.0 (prior std-dev — full uncertainty)
|
|
115
|
+
print(result.is_prior_only) # True
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Lichess gives you the RD directly via API — pass it in. If you only have the rating number, the package will use a sensible default $\sigma$ but you should treat the result as approximate:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from unirating import Rating, TimeControl, fuse
|
|
122
|
+
|
|
123
|
+
result = fuse(
|
|
124
|
+
lichess = Rating(value=1850), # sigma=None → default used + warning
|
|
125
|
+
time_control = TimeControl.RAPID,
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## What's in the box
|
|
132
|
+
|
|
133
|
+
| Module | Purpose |
|
|
134
|
+
|---|---|
|
|
135
|
+
| [`unirating.fuse`](src/unirating/fusion.py) | The main entry point — call this. |
|
|
136
|
+
| [`unirating.Rating`](src/unirating/models.py) | Immutable `(value, sigma)` measurement. |
|
|
137
|
+
| [`unirating.Prior`](src/unirating/models.py) | Gaussian prior over latent skill. Defaults to $\mathcal N(1500, 350^2)$. |
|
|
138
|
+
| [`unirating.Calibration`](src/unirating/calibration.py) | Affine map source ↔ FIDE. Defaults per time control. |
|
|
139
|
+
| [`unirating.TimeControl`](src/unirating/calibration.py) | `BULLET`, `BLITZ`, `RAPID`, `CLASSICAL`. |
|
|
140
|
+
| [`unirating.FusionResult`](src/unirating/models.py) | `(rating, sigma, ci95, contributions, used_sources, …)`. |
|
|
141
|
+
| `unirating` (CLI) | One-shot fusion from the shell. |
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## CLI
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
unirating \
|
|
149
|
+
--fide 1820 --fide-sigma 60 \
|
|
150
|
+
--chesscom 1950 --chesscom-rd 55 \
|
|
151
|
+
--lichess 2100 --lichess-rd 50 \
|
|
152
|
+
--time-control rapid
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Output:
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
Unified rating (FIDE scale): 1839 ± 31
|
|
159
|
+
95% CI: [1778, 1900]
|
|
160
|
+
Sources used: fide, chesscom, lichess
|
|
161
|
+
Contributions: prior=1% fide=27% chesscom=33% lichess=39%
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## How the math works (one paragraph)
|
|
167
|
+
|
|
168
|
+
Each rating is treated as a noisy linear measurement $R_i = \alpha_i + \beta_i \theta + \varepsilon_i$, $\varepsilon_i \sim \mathcal N(0, \sigma_i^2)$, of the latent FIDE-equivalent skill $\theta$. After inverting each measurement to a FIDE-scale estimate $\hat\theta_i$ with variance $\tau_i^2 = \sigma_i^2/\beta_i^2$, the **posterior mean of $\theta$** given a Gaussian prior $\mathcal N(\mu_0, \sigma_0^2)$ is the precision-weighted average
|
|
169
|
+
|
|
170
|
+
$$
|
|
171
|
+
\hat\theta = \frac{\mu_0/\sigma_0^2 + \sum_{i \in S} \hat\theta_i/\tau_i^2}{1/\sigma_0^2 + \sum_{i \in S} 1/\tau_i^2}
|
|
172
|
+
$$
|
|
173
|
+
|
|
174
|
+
over whatever subset $S$ of sources is present. With no sources, the formula collapses to $\mu_0$. The full proof (MLE + Gauss–Markov + Bayes), the choice of constants, and a worked example are in [docs/derivation.md](docs/derivation.md).
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Calibration constants (defaults)
|
|
179
|
+
|
|
180
|
+
| Time control | Chess.com $\alpha,\beta$ | Lichess $\alpha,\beta$ |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `BULLET` | 150, 1.0 | 400, 1.0 |
|
|
183
|
+
| `BLITZ` | 120, 1.0 | 350, 1.0 |
|
|
184
|
+
| `RAPID` | 100, 1.0 | 250, 1.0 |
|
|
185
|
+
| `CLASSICAL` | 50, 1.0 | 200, 1.0 |
|
|
186
|
+
|
|
187
|
+
Source: published community regressions (see [docs/citations.md](docs/citations.md)). You can override per call:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from unirating import Calibration, fuse
|
|
191
|
+
|
|
192
|
+
custom = Calibration(chesscom_alpha=80, lichess_alpha=300)
|
|
193
|
+
result = fuse(..., calibration=custom)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Edge cases handled
|
|
199
|
+
|
|
200
|
+
- **No ratings** → returns prior, `is_prior_only=True`.
|
|
201
|
+
- **One rating** → posterior shrinks toward prior; weight reported.
|
|
202
|
+
- **Missing $\sigma_i$** → fills with conservative default, emits `MissingSigmaWarning`.
|
|
203
|
+
- **Provisional rating** (large RD, Lichess `?`) → naturally down-weighted; warns if RD > 110.
|
|
204
|
+
- **Zero / negative $\sigma_i$** → raises `InvalidRatingError`.
|
|
205
|
+
- **Non-finite values** (`nan`, `inf`) → raises `InvalidRatingError`.
|
|
206
|
+
- **Out-of-range ratings** → raises `InvalidRatingError` (configurable bounds).
|
|
207
|
+
- **Mixed time controls** → only one `TimeControl` per call; the calibration enforces consistency.
|
|
208
|
+
- **Custom prior** → pass any `Prior(mu, sigma)` (e.g. for juniors, titled players).
|
|
209
|
+
- **Numerical stability** → all sums computed in precision-space then inverted at the end.
|
|
210
|
+
|
|
211
|
+
See [tests/](tests/) for the formal coverage.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Citation
|
|
216
|
+
|
|
217
|
+
If you use this package in academic work, please cite:
|
|
218
|
+
|
|
219
|
+
```bibtex
|
|
220
|
+
@software{sahu_unirating_2026,
|
|
221
|
+
author = {Sahu, Sourav},
|
|
222
|
+
title = {unirating: Bayesian inverse-variance fusion of FIDE,
|
|
223
|
+
Chess.com and Lichess ratings},
|
|
224
|
+
year = {2026},
|
|
225
|
+
url = {https://github.com/souravsahums/unirating},
|
|
226
|
+
version = {0.1.0},
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Plain text: Sahu, S. (2026). *unirating: Bayesian inverse-variance fusion of FIDE, Chess.com and Lichess ratings* (v0.1.0) [Computer software]. https://github.com/souravsahums/unirating
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## License
|
|
235
|
+
|
|
236
|
+
MIT — see [LICENSE](LICENSE). Copyright © 2026 Sourav Sahu.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
unirating/__init__.py,sha256=rZYr2a12EsiGLIi55zwiGauELPo7CLxiOc1dFMBultU,906
|
|
2
|
+
unirating/_version.py,sha256=QTYqXqSTHFRkM9TEgpDFcHvwLbvqHDqvqfQ9EiXkcAM,23
|
|
3
|
+
unirating/calibration.py,sha256=EUJnHamy6WOX8gikwNSvxnpIydfcC1F0fZC7L2EvQVM,3801
|
|
4
|
+
unirating/cli.py,sha256=SW74NputKnLCcqxlWfvS2vTPVKAITs3zhN1U8MaDzjY,4419
|
|
5
|
+
unirating/exceptions.py,sha256=4tDCFqcacPy2KicbEnHWG64fjb6hJaJWKe9LmncuyJg,1173
|
|
6
|
+
unirating/fusion.py,sha256=NhtsJ-b2Idt_KIpXKsv03vDIIhBt10Fz8AqYd_NHV3E,6026
|
|
7
|
+
unirating/models.py,sha256=KWOwVUrcE6GZPPzLqzQlrPgFjeMZl2McVGE6k33dBoQ,5464
|
|
8
|
+
unirating/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
unirating-0.1.0.dist-info/METADATA,sha256=uoC_CksqW07d9bhRT7eo-UArfZqPDNLuj_OecoYUWPk,9481
|
|
10
|
+
unirating-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
unirating-0.1.0.dist-info/entry_points.txt,sha256=gORoXeeDenHl7p6C_P13HNlbA1WfzU6gnrDjA8KQ2F8,49
|
|
12
|
+
unirating-0.1.0.dist-info/licenses/LICENSE,sha256=pRsC9XFLTKAVp345FiHOt82M6gv5nhadRatN2d5HnPA,1089
|
|
13
|
+
unirating-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sourav Sahu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|