5 from xonstat.models import PlayerElo
7 log = logging.getLogger(__name__)
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
17 self.logdistancefactor = logdistancefactor
18 self.maxlogdistance = maxlogdistance
19 self.latency_trend_factor = latency_trend_factor
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
31 def eval(self, mygames, mytime, matchtime):
32 if mytime < self.mintime:
34 if mytime < self.minratio * matchtime:
36 if mytime < self.fulltime:
37 k = mytime / float(self.fulltime)
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)
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)
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)
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
64 # score per second in the game
65 self.score_per_second = 0.0
67 # seconds alive during a given game
73 # current player_game_stat record
76 # Elo algorithm K-factor
79 # Elo points accumulator, which is not adjusted by the K-factor
82 # elo points delta accumulator for the game, which IS adjusted
86 def should_save(self):
87 """Determines if the elo and pgstat attributes of this instance should
88 be persisted to the database"""
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)
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
102 def __init__(self, session, game, pgstats):
104 # game which we are processing
107 # work-in-progress values, indexed by player
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)
114 elostats = filter(elo_eligible, pgstats)
116 # only process elos for elo-eligible players
117 for pgstat in elostats:
118 self.wip[pgstat.player_id] = EloWIP(pgstat.player_id, pgstat)
120 # determine duration from the maximum alivetime
121 # of the players if the game doesn't have one
123 if game.duration is not None:
124 self.duration = game.duration.seconds
126 self.duration = max(i.alivetime.seconds for i in elostats)
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
136 e.score_per_second = e.pgstat.score/float(e.pgstat.alivetime.seconds)
137 e.alivetime = e.pgstat.alivetime.seconds
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
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)
150 # determine k reduction
151 self.wip[pid].k = KREDUCTION.eval(self.wip[pid].elo.games, self.wip[pid].alivetime,
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}
157 # now actually process elos
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)
166 # duels are done traditionally - a win nets
167 # full points, not the score factor
168 if self.game.game_type_cd == 'duel':
170 if scorefactor_real > 0.5:
171 scorefactor_real = 1.0
173 elif scorefactor_real < 0.5:
174 scorefactor_real = 0.0
175 # nothing to do here for draws
177 return scorefactor_real
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:
186 return float(pi)/(pi+pj)
189 """Perform the core Elo calculation, storing the values in the "wip"
190 dict for passing upstream."""
191 if len(self.wip.keys()) < 2:
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
211 si, sj = 1, 1 # a draw
214 scorefactor_real = self.scorefactor(si, sj)
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))
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))
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
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))
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)
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
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)
260 # he DID win, so he should never lose points.
261 adjustmentj = max(0, adjustmentj)
263 self.wip[pids[i]].adjustment += adjustmenti
264 self.wip[pids[j]].adjustment += adjustmentj
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
272 log.debug("{}'s Old Elo: {} New Elo: {} Delta {}"
273 .format(pid, old_elo, new_elo, w.elo_delta))
277 w.elo.update_dt = datetime.datetime.utcnow()
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():
287 w.pgstat.elo_delta = w.elo_delta
288 session.add(w.pgstat)
290 log.debug("Unable to save Elo delta value for player_id {0}".format(w.player_id))