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