Add headers and rearrange so things look clean.
[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                 # adjust the elo prediction according to ping
222                 ping_ratio = self.pingfactor(pi, pj)
223                 scorefactor_ping = ep.latency_trend_factor * (0.5 - ping_ratio)
224                 scorefactor_elo_adjusted = max(0.0, min(1.0, scorefactor_elo + scorefactor_ping))
225
226                 # initial adjustment values, which we may modify with additional rules
227                 adjustmenti = scorefactor_real - scorefactor_elo_adjusted
228                 adjustmentj = scorefactor_elo_adjusted - scorefactor_real
229
230                 # DEBUG
231                 # log.debug("(New) Player i: {0}".format(ei.player_id))
232                 # log.debug("(New) Player i's K: {0}".format(self.wip[pids[i]].k))
233                 # log.debug("(New) Player j: {0}".format(ej.player_id))
234                 # log.debug("(New) Player j's K: {0}".format(self.wip[pids[j]].k))
235                 # log.debug("(New) Ping ratio: {0}".format(ping_ratio))
236                 # log.debug("(New) Scorefactor real: {0}".format(scorefactor_real))
237                 # log.debug("(New) Scorefactor elo: {0}".format(scorefactor_elo))
238                 # log.debug("(New) Scorefactor ping: {0}".format(scorefactor_ping))
239                 # log.debug("(New) adjustment i: {0}".format(scorefactor_real - scorefactor_elo))
240                 # log.debug("(New) adjustment j: {0}".format(scorefactor_elo - scorefactor_real))
241                 # log.debug("(New) adjustment i with ping: {0}".format(adjustmenti))
242                 # log.debug("(New) adjustment j with ping: {0}\n".format(adjustmentj))
243
244                 if scorefactor_elo > 0.5:
245                     # player i is expected to win
246                     if scorefactor_real > 0.5:
247                         # he DID win, so he should never lose points.
248                         adjustmenti = max(0, adjustmenti)
249                     else:
250                         # he lost, but let's make it continuous
251                         # (making him lose less points in the result)
252                         adjustmenti = (2 * scorefactor_real - 1) * scorefactor_elo
253                 else:
254                     # player j is expected to win
255                     if scorefactor_real > 0.5:
256                         # he lost, but let's make it continuous
257                         # (making him lose less points in the result)
258                         adjustmentj = (1 - 2 * scorefactor_real) * (1 - scorefactor_elo)
259                     else:
260                         # he DID win, so he should never lose points.
261                         adjustmentj = max(0, adjustmentj)
262
263                 self.wip[pids[i]].adjustment += adjustmenti
264                 self.wip[pids[j]].adjustment += adjustmentj
265
266         for pid in pids:
267             w = self.wip[pid]
268             old_elo = float(w.elo.elo)
269             new_elo = max(float(w.elo.elo) + w.adjustment * w.k * ep.global_K / float(len(pids) - 1), ep.floor)
270             w.elo_delta = new_elo - old_elo
271
272             log.debug("{}'s Old Elo: {} New Elo: {} Delta {}"
273                       .format(pid, old_elo, new_elo, w.elo_delta))
274
275             w.elo.elo = new_elo
276             w.elo.games += 1
277             w.elo.update_dt = datetime.datetime.utcnow()
278
279     def save(self, session):
280         """Put all changed PlayerElo and PlayerGameStat instances into the
281         session to be updated or inserted upon commit."""
282         # first, save all of the player_elo values
283         for w in self.wip.values():
284             session.add(w.elo)
285
286             try:
287                 w.pgstat.elo_delta = w.elo_delta
288                 session.add(w.pgstat)
289             except:
290                 log.debug("Unable to save Elo delta value for player_id {0}".format(w.player_id))
291