]> git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/elo.py
Apply the ping factor to Elo adjustment values.
[xonotic/xonstat.git] / xonstat / elo.py
1 import datetime
2 import logging
3 import math
4
5 from xonstat.models import PlayerElo
6
7 log = logging.getLogger(__name__)
8
9
10 class EloParms:
11     def __init__(self, global_K=15, initial=100, floor=100,
12                  logdistancefactor=math.log(10)/float(400), maxlogdistance=math.log(10),
13                  latency_trend_factor=0.2):
14         self.global_K = global_K
15         self.initial = initial
16         self.floor = floor
17         self.logdistancefactor = logdistancefactor
18         self.maxlogdistance = maxlogdistance
19         self.latency_trend_factor = latency_trend_factor
20
21
22 class KReduction:
23     def __init__(self, fulltime, mintime, minratio, games_min, games_max, games_factor):
24         self.fulltime = fulltime
25         self.mintime = mintime
26         self.minratio = minratio
27         self.games_min = games_min
28         self.games_max = games_max
29         self.games_factor = games_factor
30
31     def eval(self, mygames, mytime, matchtime):
32         if mytime < self.mintime:
33             return 0
34         if mytime < self.minratio * matchtime:
35             return 0
36         if mytime < self.fulltime:
37             k = mytime / float(self.fulltime)
38         else:
39             k = 1.0
40         if mygames >= self.games_max:
41             k *= self.games_factor
42         elif mygames > self.games_min:
43             k *= 1.0 - (1.0 - self.games_factor) * (mygames - self.games_min) / float(self.games_max - self.games_min)
44         return k
45
46
47 # parameters for K reduction
48 # this may be touched even if the DB already exists
49 KREDUCTION = KReduction(600, 120, 0.5, 0, 32, 0.2)
50
51 # parameters for chess elo
52 # only global_K may be touched even if the DB already exists
53 # we start at K=200, and fall to K=40 over the first 20 games
54 ELOPARMS = EloParms(global_K = 200)
55
56
57 class EloWIP:
58     """EloWIP is a work-in-progress Elo value. It contains all of the
59     attributes necessary to calculate Elo deltas for a given game."""
60     def __init__(self, player_id, pgstat=None):
61         # player_id this belongs to
62         self.player_id = player_id
63
64         # score per second in the game
65         self.score_per_second = 0.0
66
67         # seconds alive during a given game
68         self.alivetime = 0
69
70         # current elo record
71         self.elo = None
72
73         # current player_game_stat record
74         self.pgstat = pgstat
75
76         # Elo algorithm K-factor 
77         self.k = 0.0
78
79         # Elo points accumulator, which is not adjusted by the K-factor
80         self.adjustment = 0.0
81
82         # elo points delta accumulator for the game, which IS adjusted 
83         # by the K-factor
84         self.elo_delta = 0.0
85
86     def should_save(self):
87         """Determines if the elo and pgstat attributes of this instance should
88         be persisted to the database"""
89         return self.k > 0.0
90
91     def __repr__(self):
92         return "<EloWIP(player_id={}, score_per_second={}, alivetime={}, \
93                 elo={}, pgstat={}, k={}, adjustment={}, elo_delta={})>".\
94                 format(self.player_id, self.score_per_second, self.alivetime, \
95                 self.elo, self.pgstat, self.k, self.adjustment, self.elo_delta)
96
97
98 class EloProcessor:
99     """EloProcessor is a container for holding all of the intermediary AND
100     final values used to calculate Elo deltas for all players in a given
101     game."""
102     def __init__(self, session, game, pgstats):
103
104         # game which we are processing
105         self.game = game
106
107         # work-in-progress values, indexed by player
108         self.wip = {}
109
110         # used to determine if a pgstat record is elo-eligible
111         def elo_eligible(pgs):
112             return pgs.player_id > 2 and pgs.alivetime > datetime.timedelta(seconds=0)
113
114         elostats = filter(elo_eligible, pgstats)
115
116         # only process elos for elo-eligible players
117         for pgstat in elostats:
118             self.wip[pgstat.player_id] = EloWIP(pgstat.player_id, pgstat)
119
120         # determine duration from the maximum alivetime
121         # of the players if the game doesn't have one
122         self.duration = 0
123         if game.duration is not None:
124             self.duration = game.duration.seconds
125         else:
126             self.duration = max(i.alivetime.seconds for i in elostats)
127
128         # Calculate the score_per_second and alivetime values for each player.
129         # Warmups may mess up the player alivetime values, so this is a 
130         # failsafe to put the alivetime ceiling to be the game's duration.
131         for e in self.wip.values():
132             if e.pgstat.alivetime.seconds > self.duration:
133                 e.score_per_second = e.pgstat.score/float(self.duration)
134                 e.alivetime = self.duration
135             else:
136                 e.score_per_second = e.pgstat.score/float(e.pgstat.alivetime.seconds)
137                 e.alivetime = e.pgstat.alivetime.seconds
138
139         # Fetch current Elo values for all players. For players that don't yet 
140         # have an Elo record, we'll give them a default one.
141         for e in session.query(PlayerElo).\
142                 filter(PlayerElo.player_id.in_(self.wip.keys())).\
143                 filter(PlayerElo.game_type_cd==game.game_type_cd).all():
144                     self.wip[e.player_id].elo = e
145
146         for pid in self.wip.keys():
147             if self.wip[pid].elo is None:
148                 self.wip[pid].elo = PlayerElo(pid, game.game_type_cd, ELOPARMS.initial)
149
150             # determine k reduction
151             self.wip[pid].k = KREDUCTION.eval(self.wip[pid].elo.games, self.wip[pid].alivetime,
152                                               self.duration)
153
154         # we don't process the players who have a zero K factor
155         self.wip = {e.player_id:e for e in self.wip.values() if e.k > 0.0}
156
157         # now actually process elos
158         self.process()
159
160     def scorefactor(self, si, sj):
161         """Calculate the real scorefactor of the game. This is how players
162         actually performed, which is compared to their expected performance as
163         predicted by their Elo values."""
164         scorefactor_real = si / float(si + sj)
165
166         # duels are done traditionally - a win nets
167         # full points, not the score factor
168         if self.game.game_type_cd == 'duel':
169             # player i won
170             if scorefactor_real > 0.5:
171                 scorefactor_real = 1.0
172             # player j won
173             elif scorefactor_real < 0.5:
174                 scorefactor_real = 0.0
175             # nothing to do here for draws
176
177         return scorefactor_real
178
179     def pingfactor(self, pi, pj):
180         """ Calculate the ping differences between the two players, but only if both have them. """
181         if pi is None or pj is None or pi < 0 or pj < 0:
182             # default to a draw
183             return 0.5
184
185         else:
186             return float(pi)/(pi+pj)
187
188     def process(self):
189         """Perform the core Elo calculation, storing the values in the "wip"
190         dict for passing upstream."""
191         if len(self.wip.keys()) < 2:
192             return
193
194         ep = ELOPARMS
195
196         pids = self.wip.keys()
197         for i in xrange(0, len(pids)):
198             ei = self.wip[pids[i]].elo
199             pi = self.wip[pids[i]].pgstat.avg_latency
200             for j in xrange(i+1, len(pids)):
201                 ej = self.wip[pids[j]].elo
202                 si = self.wip[pids[i]].score_per_second
203                 sj = self.wip[pids[j]].score_per_second
204                 pj = self.wip[pids[j]].pgstat.avg_latency
205
206                 # normalize scores
207                 ofs = min(0, si, sj)
208                 si -= ofs
209                 sj -= ofs
210                 if si + sj == 0:
211                     si, sj = 1, 1 # a draw
212
213                 # real score factor
214                 scorefactor_real = self.scorefactor(si, sj)
215
216                 # expected score factor by elo
217                 elodiff = min(ep.maxlogdistance, max(-ep.maxlogdistance,
218                     (float(ei.elo) - float(ej.elo)) * ep.logdistancefactor))
219                 scorefactor_elo = 1 / (1 + math.exp(-elodiff))
220
221                 # raw ping ratios
222                 ping_ratio_i = self.pingfactor(pi, pj)
223                 ping_ratio_j = self.pingfactor(pj, pi)
224
225                 if ping_ratio_i > 0.5:
226                     if scorefactor_real > 0.5:
227                         log.debug("player i has the ping disadvantage and won")
228                         pingfactor_i = 1 + ping_ratio_i * ep.latency_trend_factor
229                         pingfactor_j = 1 + ping_ratio_j * ep.latency_trend_factor
230                     else:
231                         log.debug("player i has the ping disadvantage and lost")
232                         pingfactor_i = 1 - ping_ratio_i * ep.latency_trend_factor
233                         pingfactor_j = 1 - ping_ratio_j * ep.latency_trend_factor
234
235                 elif ping_ratio_i < 0.5:
236                     if scorefactor_real > 0.5:
237                         log.debug("player j has the ping disadvantage and lost")
238                         pingfactor_i = 1 - ping_ratio_i * ep.latency_trend_factor
239                         pingfactor_j = 1 - ping_ratio_j * ep.latency_trend_factor
240                     else:
241                         log.debug("player j has the ping disadvantage and won")
242                         pingfactor_i = 1 + ping_ratio_i * ep.latency_trend_factor
243                         pingfactor_j = 1 + ping_ratio_j * ep.latency_trend_factor
244                 else:
245                     log.debug("the pings are equal")
246                     pingfactor_i = pingfactor_j = 1
247
248                 # initial adjustment values, which we may modify with additional rules
249                 adjustmenti = (scorefactor_real - scorefactor_elo) * pingfactor_i
250                 adjustmentj = (scorefactor_elo - scorefactor_real) * pingfactor_j
251
252                 # DEBUG
253                 log.debug("(New) Player i: {0}".format(ei.player_id))
254                 log.debug("(New) Player i's K: {0}".format(self.wip[pids[i]].k))
255                 log.debug("(New) Player i's pingfactor: {0}".format(pingfactor_i))
256                 log.debug("(New) Player j: {0}".format(ej.player_id))
257                 log.debug("(New) Player j's K: {0}".format(self.wip[pids[j]].k))
258                 log.debug("(New) Player j's pingfactor: {0}".format(pingfactor_j))
259                 log.debug("(New) Scorefactor real: {0}".format(scorefactor_real))
260                 log.debug("(New) Scorefactor elo: {0}".format(scorefactor_elo))
261                 log.debug("(New) adjustment i: {0}".format(scorefactor_real - scorefactor_elo))
262                 log.debug("(New) adjustment j: {0}".format(scorefactor_elo - scorefactor_real))
263                 log.debug("(New) adjustment i with ping: {0}".format(adjustmenti))
264                 log.debug("(New) adjustment j with ping: {0}\n".format(adjustmentj))
265
266                 if scorefactor_elo > 0.5:
267                     # player i is expected to win
268                     if scorefactor_real > 0.5:
269                         # he DID win, so he should never lose points.
270                         adjustmenti = max(0, adjustmenti)
271                     else:
272                         # he lost, but let's make it continuous
273                         # (making him lose less points in the result)
274                         adjustmenti = (2 * scorefactor_real - 1) * scorefactor_elo
275                 else:
276                     # player j is expected to win
277                     if scorefactor_real > 0.5:
278                         # he lost, but let's make it continuous
279                         # (making him lose less points in the result)
280                         adjustmentj = (1 - 2 * scorefactor_real) * (1 - scorefactor_elo)
281                     else:
282                         # he DID win, so he should never lose points.
283                         adjustmentj = max(0, adjustmentj)
284
285                 self.wip[pids[i]].adjustment += adjustmenti
286                 self.wip[pids[j]].adjustment += adjustmentj
287
288         for pid in pids:
289             w = self.wip[pid]
290             old_elo = float(w.elo.elo)
291             new_elo = max(float(w.elo.elo) + w.adjustment * w.k * ep.global_K / float(len(pids) - 1), ep.floor)
292             w.elo_delta = new_elo - old_elo
293
294             log.debug("{}'s Old Elo: {} New Elo: {} Delta {}"
295                       .format(pid, old_elo, new_elo, w.elo_delta))
296
297             w.elo.elo = new_elo
298             w.elo.games += 1
299             w.elo.update_dt = datetime.datetime.utcnow()
300
301     def save(self, session):
302         """Put all changed PlayerElo and PlayerGameStat instances into the
303         session to be updated or inserted upon commit."""
304         # first, save all of the player_elo values
305         for w in self.wip.values():
306             session.add(w.elo)
307
308             try:
309                 w.pgstat.elo_delta = w.elo_delta
310                 session.add(w.pgstat)
311             except:
312                 log.debug("Unable to save Elo delta value for player_id {0}".format(w.player_id))
313