Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
37 / 37 |
|
100.00% |
1 / 1 |
CRAP | |
100.00% |
1 / 1 |
Glicko2 | |
100.00% |
37 / 37 |
|
100.00% |
1 / 1 |
7 | |
100.00% |
1 / 1 |
rating | |
100.00% |
37 / 37 |
|
100.00% |
1 / 1 |
7 |
1 | <?php |
2 | /** |
3 | * Jingga |
4 | * |
5 | * PHP Version 8.1 |
6 | * |
7 | * @package phpOMS\Algorithm\Rating |
8 | * @copyright Dennis Eichhorn |
9 | * @license OMS License 2.0 |
10 | * @version 1.0.0 |
11 | * @link https://jingga.app |
12 | */ |
13 | declare(strict_types=1); |
14 | |
15 | namespace phpOMS\Algorithm\Rating; |
16 | |
17 | use phpOMS\Math\Solver\Root\Bisection; |
18 | |
19 | /** |
20 | * Elo rating calculation using Glicko-2 |
21 | * |
22 | * @package phpOMS\Algorithm\Rating |
23 | * @license OMS License 2.0 |
24 | * @link https://jingga.app |
25 | * @see https://en.wikipedia.org/wiki/Glicko_rating_system |
26 | * @see http://www.glicko.net/glicko/glicko2.pdf |
27 | * @since 1.0.0 |
28 | * |
29 | * @todo: implement |
30 | */ |
31 | final class Glicko2 |
32 | { |
33 | /** |
34 | * Glicko scale factor |
35 | * |
36 | * @latex Q = 400 / ln(10) |
37 | * |
38 | * @var int |
39 | * @since 1.0.0 |
40 | */ |
41 | private const Q = 173.7177927613; |
42 | |
43 | /** |
44 | * Constraint for the volatility over time (smaller = stronger constraint) |
45 | * |
46 | * @var float |
47 | * @since 1.0.0 |
48 | */ |
49 | public float $tau = 0.5; |
50 | |
51 | /** |
52 | * Default elo to use for new players |
53 | * |
54 | * @var int |
55 | * @since 1.0.0 |
56 | */ |
57 | public int $DEFAULT_ELO = 1500; |
58 | |
59 | /** |
60 | * Default rd to use for new players |
61 | * |
62 | * @var int |
63 | * @since 1.0.0 |
64 | */ |
65 | public int $DEFAULT_RD = 350; |
66 | |
67 | /** |
68 | * Valatility (sigma) |
69 | * |
70 | * Expected flactuation = how erratic is the player's performance |
71 | * |
72 | * @var float |
73 | * @since 1.0.0 |
74 | */ |
75 | public float $DEFAULT_VOLATILITY = 0.06; |
76 | |
77 | /** |
78 | * Lowest elo allowed |
79 | * |
80 | * @var int |
81 | * @since 1.0.0 |
82 | */ |
83 | public int $MIN_ELO = 100; |
84 | |
85 | /** |
86 | * Lowest rd allowed |
87 | * |
88 | * @example 50 means that the player rating is probably between -100 / +100 of the current rating |
89 | * |
90 | * @var int |
91 | * @since 1.0.0 |
92 | */ |
93 | public int $MIN_RD = 50; |
94 | |
95 | /** |
96 | * Calcualte the glicko-2 elo |
97 | * |
98 | * @example $glicko->elo(1500, 200, 0.06, [1,0,0], [1400,1550,1700], [30,100,300]) // 1464, 151, 0.059 |
99 | * |
100 | * @param int $elo Current player "elo" |
101 | * @param int $rdOld Current player deviation (RD) |
102 | * @param float $volOld Last match date used to calculate the time difference (can be days, months, ... depending on your match interval) |
103 | * @param int[] $oElo Opponent "elo" |
104 | * @param float[] $s Match results (1 = victor, 0 = loss, 0.5 = draw) |
105 | * @param int[] $oRd Opponent deviation (RD) |
106 | * |
107 | * @return array{elo:int, rd:int, vol:float} |
108 | * |
109 | * @since 1.0.0 |
110 | */ |
111 | public function rating( |
112 | int $elo = 1500, |
113 | int $rdOld = 50, |
114 | float $volOld = 0.06, |
115 | array $oElo = [], |
116 | array $s = [], |
117 | array $oRd = [] |
118 | ) : array |
119 | { |
120 | $tau = $this->tau; |
121 | |
122 | // Step 0: |
123 | $rdOld /= self::Q; |
124 | $elo = ($elo - $this->DEFAULT_ELO) / self::Q; |
125 | |
126 | foreach ($oElo as $idx => $value) { |
127 | $oElo[$idx] = ($value - $this->DEFAULT_ELO) / self::Q; |
128 | } |
129 | |
130 | foreach ($oRd as $idx => $value) { |
131 | $oRd[$idx] = $value / self::Q; |
132 | } |
133 | |
134 | // Step 1: |
135 | $g = []; |
136 | foreach ($oRd as $idx => $rd) { |
137 | $g[$idx] = 1 / \sqrt(1 + 3 * $rd * $rd / (\M_PI * \M_PI)); |
138 | } |
139 | |
140 | $E = []; |
141 | foreach ($oElo as $idx => $oe) { |
142 | $E[$idx] = 1 / (1 + \exp(-$g[$idx] * ($elo - $oe))); |
143 | } |
144 | |
145 | $v = 0; |
146 | foreach ($g as $idx => $t) { |
147 | $v += $t * $t * $E[$idx] * (1 - $E[$idx]); |
148 | } |
149 | $v = 1 / $v; |
150 | |
151 | $tDelta = 0; |
152 | foreach ($g as $idx => $t) { |
153 | $tDelta += $t * ($s[$idx] - $E[$idx]); |
154 | } |
155 | $Delta = $v * $tDelta; |
156 | |
157 | // Step 2: |
158 | $fn = function($x) use ($Delta, $rdOld, $v, $tau, $volOld) |
159 | { |
160 | return 0.5 * (\exp($x) * ($Delta ** 2 - $rdOld ** 2 - $v - \exp($x))) / (($rdOld ** 2 + $v + \exp($x)) ** 2) |
161 | - ($x - \log($volOld ** 2)) / ($tau ** 2); |
162 | }; |
163 | |
164 | $root = Bisection::root($fn, -100, 100, 1000); |
165 | $vol = \exp($root / 2); |
166 | |
167 | // Step 3: |
168 | $RD = 1 / \sqrt(1 / ($rdOld ** 2 + $vol ** 2) + 1 / $v); |
169 | $r = $elo + $RD ** 2 * $tDelta; |
170 | |
171 | // Undo step 0: |
172 | $RD = self::Q * $RD; |
173 | $r = self::Q * $r + $this->DEFAULT_ELO; |
174 | |
175 | return [ |
176 | 'elo' => (int) \max($r, $this->MIN_ELO), |
177 | 'rd' => (int) \max($RD, $this->MIN_RD), |
178 | 'vol' => $vol, |
179 | ]; |
180 | } |
181 | } |