]> de.git.xonotic.org Git - xonotic/xonstat.git/blobdiff - xonstat/elo.py
Allow opting out of the ranking process.
[xonotic/xonstat.git] / xonstat / elo.py
index 74c49cd589137f6e4e15c974bff65238512368a2..60e7505d1d65135d2cd53d4032b804da01743c8c 100644 (file)
@@ -1,20 +1,22 @@
+import datetime
 import logging
 import math
-import random
-import sys
-from xonstat.models import *
 
+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)):
+    def __init__(self, global_K=15, initial=100, floor=100,
+                 logdistancefactor=math.log(10)/float(400), maxlogdistance=math.log(10),
+                 latency_trend_factor=0.2):
         self.global_K = global_K
         self.initial = initial
         self.floor = floor
         self.logdistancefactor = logdistancefactor
         self.maxlogdistance = maxlogdistance
+        self.latency_trend_factor = latency_trend_factor
 
 
 class KReduction:
@@ -42,185 +44,248 @@ class KReduction:
         return k
 
 
-def process_elos(game, session, game_type_cd=None):
-    if game_type_cd is None:
-        game_type_cd = game.game_type_cd
-
-    # we do not have the actual duration of the game, so use the 
-    # maximum alivetime of the players instead
-    duration = 0
-    for d in session.query(sfunc.max(PlayerGameStat.alivetime)).\
-                filter(PlayerGameStat.game_id==game.game_id).\
-                one():
-        duration = d.seconds
-
-    scores = {}
-    alivetimes = {}
-    for (p,s,a) in session.query(PlayerGameStat.player_id, 
-            PlayerGameStat.score, PlayerGameStat.alivetime).\
-            filter(PlayerGameStat.game_id==game.game_id).\
-            filter(PlayerGameStat.alivetime > timedelta(seconds=0)).\
-            filter(PlayerGameStat.player_id > 2).\
-            all():
-                # scores are per second
-                # with a short circuit to handle alivetimes > game
-                # durations, which can happen due to warmup being
-                # included (most often in duels)
-                if game.duration is not None:
-                    if a.seconds > game.duration.seconds:
-                        scores[p] = s/float(game.duration.seconds)
-                else:
-                    scores[p] = s/float(a.seconds)
-
-                alivetimes[p] = a.seconds
-
-    player_ids = scores.keys()
-
-    elos = {}
-    for e in session.query(PlayerElo).\
-            filter(PlayerElo.player_id.in_(player_ids)).\
-            filter(PlayerElo.game_type_cd==game_type_cd).all():
-                elos[e.player_id] = e
-
-    # ensure that all player_ids have an elo record
-    for pid in player_ids:
-        if pid not in elos.keys():
-            elos[pid] = PlayerElo(pid, game_type_cd, ELOPARMS.initial)
-
-    for pid in player_ids:
-        elos[pid].k = KREDUCTION.eval(elos[pid].games, alivetimes[pid],
-                duration)
-        if elos[pid].k == 0:
-            del(elos[pid])
-            del(scores[pid])
-            del(alivetimes[pid])
-
-    elos = update_elos(game, session, elos, scores, ELOPARMS)
-
-    # add the elos to the session for committing
-    for e in elos:
-        session.add(elos[e])
-
-
-def update_elos(game, session, elos, scores, ep):
-    if len(elos) < 2:
-        return elos
-
-    pids = elos.keys()
-
-    eloadjust = {}
-    for pid in pids:
-        eloadjust[pid] = 0.0
-
-    for i in xrange(0, len(pids)):
-        ei = elos[pids[i]]
-        for j in xrange(i+1, len(pids)):
-            ej = elos[pids[j]]
-            si = scores[ei.player_id]
-            sj = scores[ej.player_id]
-
-            # 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 = si / float(si + sj)
-
-            # duels are done traditionally - a win nets
-            # full points, not the score factor
-            if 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
-
-            # 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
-
-            # log.debug("Player i: {0}".format(ei.player_id))
-            # log.debug("Player i's K: {0}".format(ei.k))
-            # log.debug("Player j: {0}".format(ej.player_id))
-            # log.debug("Player j's K: {0}".format(ej.k))
-            # log.debug("Scorefactor real: {0}".format(scorefactor_real))
-            # log.debug("Scorefactor elo: {0}".format(scorefactor_elo))
-            # log.debug("adjustment i: {0}".format(adjustmenti))
-            # log.debug("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)
+# parameters for K reduction
+# this may be touched even if the DB already exists
+KREDUCTION = KReduction(600, 120, 0.5, 0, 32, 0.2)
 
-            eloadjust[ei.player_id] += adjustmenti
-            eloadjust[ej.player_id] += adjustmentj
+# 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)
 
-    elo_deltas = {}
-    for pid in pids:
-        old_elo = float(elos[pid].elo)
-        new_elo = max(float(elos[pid].elo) + eloadjust[pid] * elos[pid].k * ep.global_K / float(len(elos) - 1), ep.floor)
-        elo_deltas[pid] = new_elo - old_elo
 
-        elos[pid].elo = new_elo
-        elos[pid].games += 1
+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
 
-        log.debug("Setting Player {0}'s Elo delta to {1}. Elo is now {2} (was {3}).".format(pid, elo_deltas[pid], new_elo, old_elo))
+        # score per second in the game
+        self.score_per_second = 0.0
 
-    save_elo_deltas(game, session, elo_deltas)
+        # seconds alive during a given game
+        self.alivetime = 0
 
-    return elos
+        # current elo record
+        self.elo = None
 
+        # current player_game_stat record
+        self.pgstat = pgstat
 
-def save_elo_deltas(game, session, elo_deltas):
-    """
-    Saves the amount by which each player's Elo goes up or down
-    in a given game in the PlayerGameStat row, allowing for scoreboard display.
+        # Elo algorithm K-factor 
+        self.k = 0.0
 
-    elo_deltas is a dictionary such that elo_deltas[player_id] is the elo_delta
-    for that player_id.
-    """
-    pgstats = {}
-    for pgstat in session.query(PlayerGameStat).\
-            filter(PlayerGameStat.game_id == game.game_id).\
-            all():
-                pgstats[pgstat.player_id] = pgstat
+        # Elo points accumulator, which is not adjusted by the K-factor
+        self.adjustment = 0.0
 
-    for pid in elo_deltas.keys():
-        try:
-            pgstats[pid].elo_delta = elo_deltas[pid]
-            session.add(pgstats[pid])
-        except:
-            log.debug("Unable to save Elo delta value for player_id {0}".format(pid))
+        # 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
 
-# parameters for K reduction
-# this may be touched even if the DB already exists
-KREDUCTION = KReduction(600, 120, 0.5, 0, 32, 0.2)
+    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()
+
+    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 pingfactor(self, pi, pj):
+        """ Calculate the ping differences between the two players, but only if both have them. """
+        if pi is None or pj is None or pi < 0 or pj < 0:
+            # default to a draw
+            return 0.5
+
+        else:
+            return float(pi)/(pi+pj)
+
+    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
+            pi = self.wip[pids[i]].pgstat.avg_latency
+            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
+                pj = self.wip[pids[j]].pgstat.avg_latency
+
+                # 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))
+
+                # adjust the elo prediction according to ping
+                ping_ratio = self.pingfactor(pi, pj)
+                scorefactor_ping = ep.latency_trend_factor * (0.5 - ping_ratio)
+                scorefactor_elo_adjusted = max(0.0, min(1.0, scorefactor_elo + scorefactor_ping))
+
+                # initial adjustment values, which we may modify with additional rules
+                adjustmenti = scorefactor_real - scorefactor_elo_adjusted
+                adjustmentj = scorefactor_elo_adjusted - 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) Ping ratio: {0}".format(ping_ratio))
+                # log.debug("(New) Scorefactor real: {0}".format(scorefactor_real))
+                # log.debug("(New) Scorefactor elo: {0}".format(scorefactor_elo))
+                # log.debug("(New) Scorefactor ping: {0}".format(scorefactor_ping))
+                # log.debug("(New) adjustment i: {0}".format(scorefactor_real - scorefactor_elo))
+                # log.debug("(New) adjustment j: {0}".format(scorefactor_elo - scorefactor_real))
+                # log.debug("(New) adjustment i with ping: {0}".format(adjustmenti))
+                # log.debug("(New) adjustment j with ping: {0}\n".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
+
+            log.debug("{}'s Old Elo: {} New Elo: {} Delta {}"
+                      .format(pid, old_elo, new_elo, w.elo_delta))
+
+            w.elo.elo = new_elo
+            w.elo.games += 1
+            w.elo.update_dt = datetime.datetime.utcnow()
+
+    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 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)