Remove Persona since it was shut down. :(
[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 > datetime.timedelta(seconds=0)
100
101         elostats = filter(elo_eligible, pgstats)
102
103         # only process elos for elo-eligible players
104         for pgstat in elostats:
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)