]> de.git.xonotic.org Git - xonotic/xonstat.git/blobdiff - xonstat/elo.py
Use score-scaling Elo for non-duels.
[xonotic/xonstat.git] / xonstat / elo.py
old mode 100755 (executable)
new mode 100644 (file)
index 9d1d467..48a24e8
-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
-# 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
+import logging
+import math
+import random
+import sys
+from xonstat.models import *
+
+
+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
+
+
+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
+                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)
+
+            eloadjust[ei.player_id] += adjustmenti
+            eloadjust[ej.player_id] += adjustmentj
+
+    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
+
+        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))
+
+    save_elo_deltas(game, session, elo_deltas)
+
+    return elos
+
+
+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_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
+
+    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))
+
+
+# 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)