-import sys\r
-import math\r
-import random\r
-\r
-class EloParms:\r
- def __init__(self, global_K = 15, initial = 100, floor = 100, logdistancefactor = math.log(10)/float(400), maxlogdistance = math.log(10)):\r
- self.global_K = global_K\r
- self.initial = initial\r
- self.floor = floor\r
- self.logdistancefactor = logdistancefactor\r
- self.maxlogdistance = maxlogdistance\r
-\r
-\r
-class KReduction:\r
- def __init__(self, fulltime, mintime, minratio, games_min, games_max, games_factor):\r
- self.fulltime = fulltime\r
- self.mintime = mintime\r
- self.minratio = minratio\r
- self.games_min = games_min\r
- self.games_max = games_max\r
- self.games_factor = games_factor\r
-\r
- def eval(self, mygames, mytime, matchtime):\r
- if mytime < self.mintime:\r
- return 0\r
- if mytime < self.minratio * matchtime:\r
- return 0\r
- if mytime < self.fulltime:\r
- k = mytime / float(self.fulltime)\r
- else:\r
- k = 1.0\r
- if mygames >= self.games_max:\r
- k *= self.games_factor\r
- elif mygames > self.games_min:\r
- k *= 1.0 - (1.0 - self.games_factor) * (mygames - self.games_min) / float(self.games_max - self.games_min)\r
- return k\r
-\r
-\r
-# For team games where multiple scores and elos are at play, the elos\r
-# must be adjusted according to their strength relative to the player\r
-# in the next-lowest scoreboard position.\r
-def update(elos, ep):\r
- for x in elos:\r
- if x.elo == None:\r
- x.elo = ep.initial\r
- x.eloadjust = 0\r
- if len(elos) < 2:\r
- return elos\r
- for i in xrange(0, len(elos)):\r
- ei = elos[i]\r
- for j in xrange(i+1, len(elos)):\r
- ej = elos[j]\r
- si = ei.score\r
- sj = ej.score\r
-\r
- # normalize scores\r
- ofs = min(0, si, sj)\r
- si -= ofs\r
- sj -= ofs\r
- if si + sj == 0:\r
- si, sj = 1, 1 # a draw\r
-\r
- # real score factor\r
- scorefactor_real = si / float(si + sj)\r
-\r
- # estimated score factor by elo\r
- elodiff = min(ep.maxlogdistance, max(-ep.maxlogdistance, (ei.elo - ej.elo) * ep.logdistancefactor))\r
- scorefactor_elo = 1 / (1 + math.exp(-elodiff))\r
-\r
- # how much adjustment is good?\r
- # scorefactor(elodiff) = 1 / (1 + e^(-elodiff * logdistancefactor))\r
- # elodiff(scorefactor) = -ln(1/scorefactor - 1) / logdistancefactor\r
- # elodiff'(scorefactor) = 1 / ((scorefactor) (1 - scorefactor) logdistancefactor)\r
- # elodiff'(scorefactor) >= 4 / logdistancefactor\r
-\r
- # adjust'(scorefactor) = K1 + K2\r
-\r
- # so we want:\r
- # K1 + K2 <= 4 / logdistancefactor <= elodiff'(scorefactor)\r
- # as we then don't overcompensate\r
-\r
- adjustment = scorefactor_real - scorefactor_elo\r
- ei.eloadjust += adjustment\r
- ej.eloadjust -= adjustment\r
- for x in elos:\r
- x.elo = max(x.elo + x.eloadjust * x.k * ep.global_K / float(len(elos) - 1), ep.floor)\r
- x.games += 1\r
- return elos\r
-\r
-\r
-# parameters for K reduction\r
-# this may be touched even if the DB already exists\r
-KREDUCTION = KReduction(600, 120, 0.5, 0, 32, 0.2)\r
-\r
-# parameters for chess elo\r
-# only global_K may be touched even if the DB already exists\r
-# we start at K=200, and fall to K=40 over the first 20 games\r
-ELOPARMS = EloParms(global_K = 200)\r
-\r
+import datetime
+import logging
+import math
+
+from xonstat.models import PlayerElo
+
+log = logging.getLogger(__name__)
+
+
+class EloParms:
+ def __init__(self, global_K = 15, initial = 100, floor = 100, logdistancefactor = math.log(10)/float(400), maxlogdistance = math.log(10)):
+ self.global_K = global_K
+ self.initial = initial
+ self.floor = floor
+ self.logdistancefactor = logdistancefactor
+ self.maxlogdistance = maxlogdistance
+
+
+class KReduction:
+ def __init__(self, fulltime, mintime, minratio, games_min, games_max, games_factor):
+ self.fulltime = fulltime
+ self.mintime = mintime
+ self.minratio = minratio
+ self.games_min = games_min
+ self.games_max = games_max
+ self.games_factor = games_factor
+
+ def eval(self, mygames, mytime, matchtime):
+ if mytime < self.mintime:
+ return 0
+ if mytime < self.minratio * matchtime:
+ return 0
+ if mytime < self.fulltime:
+ k = mytime / float(self.fulltime)
+ else:
+ k = 1.0
+ if mygames >= self.games_max:
+ k *= self.games_factor
+ elif mygames > self.games_min:
+ k *= 1.0 - (1.0 - self.games_factor) * (mygames - self.games_min) / float(self.games_max - self.games_min)
+ return k
+
+
+class EloWIP:
+ """EloWIP is a work-in-progress Elo value. It contains all of the
+ attributes necessary to calculate Elo deltas for a given game."""
+ def __init__(self, player_id, pgstat=None):
+ # player_id this belongs to
+ self.player_id = player_id
+
+ # score per second in the game
+ self.score_per_second = 0.0
+
+ # seconds alive during a given game
+ self.alivetime = 0
+
+ # current elo record
+ self.elo = None
+
+ # current player_game_stat record
+ self.pgstat = pgstat
+
+ # Elo algorithm K-factor
+ self.k = 0.0
+
+ # Elo points accumulator, which is not adjusted by the K-factor
+ self.adjustment = 0.0
+
+ # elo points delta accumulator for the game, which IS adjusted
+ # by the K-factor
+ self.elo_delta = 0.0
+
+ def should_save(self):
+ """Determines if the elo and pgstat attributes of this instance should
+ be persisted to the database"""
+ return self.k > 0.0
+
+ def __repr__(self):
+ return "<EloWIP(player_id={}, score_per_second={}, alivetime={}, \
+ elo={}, pgstat={}, k={}, adjustment={}, elo_delta={})>".\
+ format(self.player_id, self.score_per_second, self.alivetime, \
+ self.elo, self.pgstat, self.k, self.adjustment, self.elo_delta)
+
+
+class EloProcessor:
+ """EloProcessor is a container for holding all of the intermediary AND
+ final values used to calculate Elo deltas for all players in a given
+ game."""
+ def __init__(self, session, game, pgstats):
+
+ # game which we are processing
+ self.game = game
+
+ # work-in-progress values, indexed by player
+ self.wip = {}
+
+ # used to determine if a pgstat record is elo-eligible
+ def elo_eligible(pgs):
+ return pgs.player_id > 2 and pgs.alivetime > datetime.timedelta(seconds=0)
+
+ elostats = filter(elo_eligible, pgstats)
+
+ # only process elos for elo-eligible players
+ for pgstat in elostats:
+ self.wip[pgstat.player_id] = EloWIP(pgstat.player_id, pgstat)
+
+ # determine duration from the maximum alivetime
+ # of the players if the game doesn't have one
+ self.duration = 0
+ if game.duration is not None:
+ self.duration = game.duration.seconds
+ else:
+ self.duration = max(i.alivetime.seconds for i in elostats)
+
+ # Calculate the score_per_second and alivetime values for each player.
+ # Warmups may mess up the player alivetime values, so this is a
+ # failsafe to put the alivetime ceiling to be the game's duration.
+ for e in self.wip.values():
+ if e.pgstat.alivetime.seconds > self.duration:
+ e.score_per_second = e.pgstat.score/float(self.duration)
+ e.alivetime = self.duration
+ else:
+ e.score_per_second = e.pgstat.score/float(e.pgstat.alivetime.seconds)
+ e.alivetime = e.pgstat.alivetime.seconds
+
+ # Fetch current Elo values for all players. For players that don't yet
+ # have an Elo record, we'll give them a default one.
+ for e in session.query(PlayerElo).\
+ filter(PlayerElo.player_id.in_(self.wip.keys())).\
+ filter(PlayerElo.game_type_cd==game.game_type_cd).all():
+ self.wip[e.player_id].elo = e
+
+ for pid in self.wip.keys():
+ if self.wip[pid].elo is None:
+ self.wip[pid].elo = PlayerElo(pid, game.game_type_cd, ELOPARMS.initial)
+
+ # determine k reduction
+ self.wip[pid].k = KREDUCTION.eval(self.wip[pid].elo.games,
+ self.wip[pid].alivetime, self.duration)
+
+ # we don't process the players who have a zero K factor
+ self.wip = { e.player_id:e for e in self.wip.values() if e.k > 0.0}
+
+ # now actually process elos
+ self.process()
+
+ # DEBUG
+ # for w in self.wip.values():
+ # log.debug(w.player_id)
+ # log.debug(w)
+
+ def scorefactor(self, si, sj):
+ """Calculate the real scorefactor of the game. This is how players
+ actually performed, which is compared to their expected performance as
+ predicted by their Elo values."""
+ scorefactor_real = si / float(si + sj)
+
+ # duels are done traditionally - a win nets
+ # full points, not the score factor
+ if self.game.game_type_cd == 'duel':
+ # player i won
+ if scorefactor_real > 0.5:
+ scorefactor_real = 1.0
+ # player j won
+ elif scorefactor_real < 0.5:
+ scorefactor_real = 0.0
+ # nothing to do here for draws
+
+ return scorefactor_real
+
+ def process(self):
+ """Perform the core Elo calculation, storing the values in the "wip"
+ dict for passing upstream."""
+ if len(self.wip.keys()) < 2:
+ return
+
+ ep = ELOPARMS
+
+ pids = self.wip.keys()
+ for i in xrange(0, len(pids)):
+ ei = self.wip[pids[i]].elo
+ for j in xrange(i+1, len(pids)):
+ ej = self.wip[pids[j]].elo
+ si = self.wip[pids[i]].score_per_second
+ sj = self.wip[pids[j]].score_per_second
+
+ # normalize scores
+ ofs = min(0, si, sj)
+ si -= ofs
+ sj -= ofs
+ if si + sj == 0:
+ si, sj = 1, 1 # a draw
+
+ # real score factor
+ scorefactor_real = self.scorefactor(si, sj)
+
+ # expected score factor by elo
+ elodiff = min(ep.maxlogdistance, max(-ep.maxlogdistance,
+ (float(ei.elo) - float(ej.elo)) * ep.logdistancefactor))
+ scorefactor_elo = 1 / (1 + math.exp(-elodiff))
+
+ # initial adjustment values, which we may modify with additional rules
+ adjustmenti = scorefactor_real - scorefactor_elo
+ adjustmentj = scorefactor_elo - scorefactor_real
+
+ # DEBUG
+ # log.debug("(New) Player i: {0}".format(ei.player_id))
+ # log.debug("(New) Player i's K: {0}".format(self.wip[pids[i]].k))
+ # log.debug("(New) Player j: {0}".format(ej.player_id))
+ # log.debug("(New) Player j's K: {0}".format(self.wip[pids[j]].k))
+ # log.debug("(New) Scorefactor real: {0}".format(scorefactor_real))
+ # log.debug("(New) Scorefactor elo: {0}".format(scorefactor_elo))
+ # log.debug("(New) adjustment i: {0}".format(adjustmenti))
+ # log.debug("(New) adjustment j: {0}".format(adjustmentj))
+
+ if scorefactor_elo > 0.5:
+ # player i is expected to win
+ if scorefactor_real > 0.5:
+ # he DID win, so he should never lose points.
+ adjustmenti = max(0, adjustmenti)
+ else:
+ # he lost, but let's make it continuous (making him lose less points in the result)
+ adjustmenti = (2 * scorefactor_real - 1) * scorefactor_elo
+ else:
+ # player j is expected to win
+ if scorefactor_real > 0.5:
+ # he lost, but let's make it continuous (making him lose less points in the result)
+ adjustmentj = (1 - 2 * scorefactor_real) * (1 - scorefactor_elo)
+ else:
+ # he DID win, so he should never lose points.
+ adjustmentj = max(0, adjustmentj)
+
+ self.wip[pids[i]].adjustment += adjustmenti
+ self.wip[pids[j]].adjustment += adjustmentj
+
+ for pid in pids:
+ w = self.wip[pid]
+ old_elo = float(w.elo.elo)
+ new_elo = max(float(w.elo.elo) + w.adjustment * w.k * ep.global_K / float(len(pids) - 1), ep.floor)
+ w.elo_delta = new_elo - old_elo
+
+ w.elo.elo = new_elo
+ w.elo.games += 1
+ w.elo.update_dt = datetime.datetime.utcnow()
+
+ # log.debug("Setting Player {0}'s Elo delta to {1}. Elo is now {2}\
+ # (was {3}).".format(pid, w.elo_delta, new_elo, old_elo))
+
+ def save(self, session):
+ """Put all changed PlayerElo and PlayerGameStat instances into the
+ session to be updated or inserted upon commit."""
+ # first, save all of the player_elo values
+ for w in self.wip.values():
+ session.add(w.elo)
+
+ try:
+ w.pgstat.elo_delta = w.elo_delta
+ session.add(w.pgstat)
+ except:
+ log.debug("Unable to save Elo delta value for player_id {0}".format(w.player_id))
+
+
+# parameters for K reduction
+# this may be touched even if the DB already exists
+KREDUCTION = KReduction(600, 120, 0.5, 0, 32, 0.2)
+
+# parameters for chess elo
+# only global_K may be touched even if the DB already exists
+# we start at K=200, and fall to K=40 over the first 20 games
+ELOPARMS = EloParms(global_K = 200)