bc0c332529fa8d70b4ae4e5c03cbcbe045b09b39
1 import datetime
2 import logging
3 import math
5 from xonstat.models import PlayerElo
7 log = logging.getLogger(__name__)
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
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
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
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
51         # score per second in the game
52         self.score_per_second = 0.0
54         # seconds alive during a given game
55         self.alivetime = 0
57         # current elo record
58         self.elo = None
60         # current player_game_stat record
61         self.pgstat = pgstat
63         # Elo algorithm K-factor
64         self.k = 0.0
66         # Elo points accumulator, which is not adjusted by the K-factor
67         self.adjustment = 0.0
69         # elo points delta accumulator for the game, which IS adjusted
70         # by the K-factor
71         self.elo_delta = 0.0
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
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)
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):
91         # game which we are processing
92         self.game = game
94         # work-in-progress values, indexed by player
95         self.wip = {}
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)
101         elostats = filter(elo_eligible, pgstats)
103         # only process elos for elo-eligible players
104         for pgstat in elostats:
105             self.wip[pgstat.player_id] = EloWIP(pgstat.player_id, pgstat)
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)
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
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
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)
137             # determine k reduction
138             self.wip[pid].k = KREDUCTION.eval(self.wip[pid].elo.games,
139                     self.wip[pid].alivetime, self.duration)
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}
144         # now actually process elos
145         self.process()
147         # DEBUG
148         # for w in self.wip.values():
149             # log.debug(w.player_id)
150             # log.debug(w)
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)
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
169         return scorefactor_real
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
177         ep = ELOPARMS
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
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
194                 # real score factor
195                 scorefactor_real = self.scorefactor(si, sj)
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))
202                 # initial adjustment values, which we may modify with additional rules
203                 adjustmenti = scorefactor_real - scorefactor_elo
204                 adjustmentj = scorefactor_elo - scorefactor_real
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))
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.
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.
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
242             w.elo.elo = new_elo
243             w.elo.games += 1
244             w.elo.update_dt = datetime.datetime.utcnow()
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))
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():