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