1
1
import math
2
- from typing import Dict , Any , ClassVar , Tuple , Type , TypeVar
2
+ from typing import Dict , Any , ClassVar , Tuple , Type , TypeVar , Optional
3
+ from datetime import datetime
3
4
4
5
from elote .competitors .base import BaseCompetitor , InvalidRatingValueException , InvalidParameterException
5
6
@@ -17,19 +18,25 @@ class GlickoCompetitor(BaseCompetitor):
17
18
the reliability of the rating. A higher RD indicates a less reliable rating.
18
19
19
20
Class Attributes:
20
- _c (float): Constant that determines how quickly the RD increases over time. Default: 1.
21
+ _c (float): Rating volatility constant that determines how quickly the RD increases over time.
22
+ Default: 34.6, which is calibrated so that it takes about 100 rating periods
23
+ for a player's RD to grow from 50 to 350 (maximum uncertainty).
21
24
_q (float): Scaling factor used in the rating calculation. Default: 0.0057565.
25
+ _rating_period_days (float): Number of days that constitute one rating period.
26
+ Default: 1.0 (one day per rating period).
22
27
"""
23
28
24
- _c : ClassVar [float ] = 1
29
+ _c : ClassVar [float ] = 34.6 # sqrt((350^2 - 50^2)/100) as per Glickman's paper
25
30
_q : ClassVar [float ] = 0.0057565
31
+ _rating_period_days : ClassVar [float ] = 1.0
26
32
27
- def __init__ (self , initial_rating : float = 1500 , initial_rd : float = 350 ):
33
+ def __init__ (self , initial_rating : float = 1500 , initial_rd : float = 350 , initial_time : Optional [ datetime ] = None ):
28
34
"""Initialize a Glicko competitor.
29
35
30
36
Args:
31
37
initial_rating (float, optional): The initial rating of this competitor. Default: 1500.
32
38
initial_rd (float, optional): The initial rating deviation of this competitor. Default: 350.
39
+ initial_time (datetime, optional): The initial timestamp for this competitor. Default: current time.
33
40
34
41
Raises:
35
42
InvalidRatingValueException: If the initial rating is below the minimum rating.
@@ -47,6 +54,7 @@ def __init__(self, initial_rating: float = 1500, initial_rd: float = 350):
47
54
self ._initial_rd = initial_rd
48
55
self ._rating = initial_rating
49
56
self .rd = initial_rd
57
+ self ._last_activity = initial_time if initial_time is not None else datetime .now ()
50
58
51
59
def __repr__ (self ) -> str :
52
60
"""Return a string representation of this competitor.
@@ -84,6 +92,7 @@ def _export_current_state(self) -> Dict[str, Any]:
84
92
return {
85
93
"rating" : self ._rating ,
86
94
"rd" : self .rd ,
95
+ "last_activity" : self ._last_activity .isoformat (),
87
96
}
88
97
89
98
def _import_parameters (self , parameters : Dict [str , Any ]) -> None :
@@ -130,6 +139,12 @@ def _import_current_state(self, state: Dict[str, Any]) -> None:
130
139
raise InvalidParameterException ("RD must be positive" )
131
140
self .rd = rd
132
141
142
+ # Set last activity time
143
+ if "last_activity" in state :
144
+ self ._last_activity = datetime .fromisoformat (state ["last_activity" ])
145
+ else :
146
+ self ._last_activity = datetime .now ()
147
+
133
148
@classmethod
134
149
def _create_from_parameters (cls : Type [T ], parameters : Dict [str , Any ]) -> T :
135
150
"""Create a new competitor instance from parameters.
@@ -268,46 +283,65 @@ def expected_score(self, competitor: BaseCompetitor) -> float:
268
283
E = 1 / (1 + 10 ** ((- 1 * g_term * (self ._rating - competitor .rating )) / 400 ))
269
284
return E
270
285
271
- def beat (self , competitor : "GlickoCompetitor" ) -> None :
286
+ def beat (self , competitor : "GlickoCompetitor" , match_time : Optional [ datetime ] = None ) -> None :
272
287
"""Update ratings after this competitor has won against the given competitor.
273
288
274
289
This method updates the ratings of both this competitor and the opponent
275
290
based on the match outcome where this competitor won.
276
291
277
292
Args:
278
293
competitor (GlickoCompetitor): The opponent competitor that lost.
294
+ match_time (datetime, optional): The time when the match occurred. Default: current time.
279
295
280
296
Raises:
281
297
MissMatchedCompetitorTypesException: If the competitor types don't match.
282
298
"""
283
299
self .verify_competitor_types (competitor )
284
- self ._compute_match_result (competitor , s = 1 )
300
+ self ._compute_match_result (competitor , s = 1 , match_time = match_time )
285
301
286
- def tied (self , competitor : "GlickoCompetitor" ) -> None :
302
+ def tied (self , competitor : "GlickoCompetitor" , match_time : Optional [ datetime ] = None ) -> None :
287
303
"""Update ratings after this competitor has tied with the given competitor.
288
304
289
305
This method updates the ratings of both this competitor and the opponent
290
306
based on a drawn match outcome.
291
307
292
308
Args:
293
309
competitor (GlickoCompetitor): The opponent competitor that tied.
310
+ match_time (datetime, optional): The time when the match occurred. Default: current time.
294
311
295
312
Raises:
296
313
MissMatchedCompetitorTypesException: If the competitor types don't match.
297
314
"""
298
315
self .verify_competitor_types (competitor )
299
- self ._compute_match_result (competitor , s = 0.5 )
316
+ self ._compute_match_result (competitor , s = 0.5 , match_time = match_time )
300
317
301
- def _compute_match_result (self , competitor : "GlickoCompetitor" , s : float ) -> None :
318
+ def _compute_match_result (
319
+ self , competitor : "GlickoCompetitor" , s : float , match_time : Optional [datetime ] = None
320
+ ) -> None :
302
321
"""Compute the result of a match and update ratings.
303
322
304
323
Args:
305
324
competitor (GlickoCompetitor): The opponent competitor.
306
325
s (float): The score of this competitor (1 for win, 0.5 for draw, 0 for loss).
326
+ match_time (datetime, optional): The time when the match occurred. Default: current time.
307
327
308
328
Raises:
309
329
MissMatchedCompetitorTypesException: If the competitor types don't match.
330
+ InvalidParameterException: If the match time is before either competitor's last activity.
310
331
"""
332
+ # Get the match time
333
+ current_time = match_time if match_time is not None else datetime .now ()
334
+
335
+ # Validate match time is not before last activity
336
+ if current_time < self ._last_activity :
337
+ raise InvalidParameterException ("Match time cannot be before competitor's last activity time" )
338
+ if current_time < competitor ._last_activity :
339
+ raise InvalidParameterException ("Match time cannot be before opponent's last activity time" )
340
+
341
+ # Update RDs for both competitors based on inactivity
342
+ self .update_rd_for_inactivity (current_time )
343
+ competitor .update_rd_for_inactivity (current_time )
344
+
311
345
self .verify_competitor_types (competitor )
312
346
# first we update ourselves
313
347
s_new_r , s_new_rd = self .update_competitor_rating (competitor , s )
@@ -322,6 +356,10 @@ def _compute_match_result(self, competitor: "GlickoCompetitor", s: float) -> Non
322
356
competitor .rating = c_new_r
323
357
competitor .rd = c_new_rd
324
358
359
+ # Update last activity time for both competitors
360
+ self ._last_activity = current_time
361
+ competitor ._last_activity = current_time
362
+
325
363
def update_competitor_rating (self , competitor : "GlickoCompetitor" , s : float ) -> Tuple [float , float ]:
326
364
"""Update the rating and RD of this competitor based on a match result.
327
365
@@ -333,11 +371,39 @@ def update_competitor_rating(self, competitor: "GlickoCompetitor", s: float) ->
333
371
tuple: A tuple containing the new rating and RD.
334
372
"""
335
373
E_term = self .expected_score (competitor )
336
- d_squared = (self ._q ** 2 * (self ._g (competitor .rd ) ** 2 * E_term * (1 - E_term ))) ** - 1
337
- s_new_r = self ._rating + (self ._q / (1 / self .rd ** 2 + 1 / d_squared )) * self ._g (competitor .rd ) * (s - E_term )
374
+ g = self ._g (competitor .rd ** 2 )
375
+ d_squared = (self ._q ** 2 * (g ** 2 * E_term * (1 - E_term ))) ** - 1
376
+
377
+ # The rating change is proportional to 1/RD^2, so a higher RD means a larger change
378
+ rating_change = (self ._q / (1 / self .rd ** 2 + 1 / d_squared )) * g * (s - E_term )
379
+ s_new_r = self ._rating + rating_change
338
380
339
381
# Ensure the new rating doesn't go below the minimum rating
340
382
s_new_r = max (self ._minimum_rating , s_new_r )
341
383
384
+ # The new RD is smaller (more certain) after a match
342
385
s_new_rd = math .sqrt ((1 / self .rd ** 2 + 1 / d_squared ) ** - 1 )
343
386
return s_new_r , s_new_rd
387
+
388
+ def update_rd_for_inactivity (self , current_time : datetime = None ) -> None :
389
+ """Update the rating deviation based on time elapsed since last activity.
390
+
391
+ This implements Glickman's formula for increasing uncertainty in ratings
392
+ over time when a player is inactive. The RD increase is controlled by the _c parameter
393
+ and the number of rating periods that have passed.
394
+
395
+ Args:
396
+ current_time (datetime, optional): The current time to calculate inactivity against.
397
+ If None, uses the current system time.
398
+ """
399
+ if current_time is None :
400
+ current_time = datetime .now ()
401
+
402
+ # Calculate number of rating periods (can be fractional)
403
+ days_inactive = (current_time - self ._last_activity ).total_seconds () / (24 * 3600 )
404
+ rating_periods = days_inactive / self ._rating_period_days
405
+
406
+ if rating_periods > 0 :
407
+ # Use Glickman's formula for RD increase over time
408
+ new_rd = min ([350 , math .sqrt (self .rd ** 2 + (self ._c ** 2 * rating_periods ))])
409
+ self .rd = new_rd
0 commit comments