Use score-scaling Elo for non-duels.
authorAnt Zucaro <azucaro@gmail.com>
Wed, 1 Aug 2012 11:52:19 +0000 (07:52 -0400)
committerAnt Zucaro <azucaro@gmail.com>
Wed, 1 Aug 2012 11:52:19 +0000 (07:52 -0400)
Scale S according to the real scorefactor in non-duel games.
This eliminates the effects of people switching to the winning
or losing team at the very last second. This also avoids a
"winner take all" in DM mode, where most people would lose points.

Additionally, all players are compared to everyone else, not just
those on their own team. This makes it more fair anyway - no
averages are done over the opponent team only. You now have to
perform better overall to get more points.

xonstat/elo.py
xonstat/models.py
xonstat/views/submission.py

index 19bcfbada1ba62f117fe8c8128ec6377369ca7a3..48a24e831bfd1a6dddbec61130aeb2314d63dfc2 100644 (file)
@@ -1,6 +1,12 @@
-import sys
+import logging
 import math
 import random
 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)):
 
 class EloParms:
     def __init__(self, global_K = 15, initial = 100, floor = 100, logdistancefactor = math.log(10)/float(400), maxlogdistance = math.log(10)):
@@ -36,6 +42,172 @@ class KReduction:
         return k
 
 
         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 K reduction
 # this may be touched even if the DB already exists
 KREDUCTION = KReduction(600, 120, 0.5, 0, 32, 0.2)
index 466bfa06e9f53276ee341d51c7cd22833ac9bc60..84a64ea9a665fc6c9d49e52f12c041d8d48bca17 100644 (file)
@@ -8,7 +8,6 @@ from sqlalchemy.orm import mapper
 from sqlalchemy.orm import scoped_session
 from sqlalchemy.orm import sessionmaker
 from sqlalchemy.ext.declarative import declarative_base
 from sqlalchemy.orm import scoped_session
 from sqlalchemy.orm import sessionmaker
 from sqlalchemy.ext.declarative import declarative_base
-from xonstat.elo import ELOPARMS, KREDUCTION
 from xonstat.util import strip_colors, html_colors, pretty_date
 
 log = logging.getLogger(__name__)
 from xonstat.util import strip_colors, html_colors, pretty_date
 
 log = logging.getLogger(__name__)
@@ -99,148 +98,6 @@ class Game(object):
     def fuzzy_date(self):
         return pretty_date(self.start_dt)
 
     def fuzzy_date(self):
         return pretty_date(self.start_dt)
 
-    def process_elos(self, session, game_type_cd=None):
-        if game_type_cd is None:
-            game_type_cd = self.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==self.game_id).\
-                    one():
-            duration = d.seconds
-
-        scores = {}
-        alivetimes = {}
-        winners = []
-        losers = []
-        for (p,s,a,r,t) in session.query(PlayerGameStat.player_id, 
-                PlayerGameStat.score, PlayerGameStat.alivetime,
-                PlayerGameStat.rank, PlayerGameStat.team).\
-                filter(PlayerGameStat.game_id==self.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
-
-                    # winners are either rank 1 or on the winning team
-                    # team games are where the team is set (duh)
-                    if r == 1 or (t == self.winner and t is not None):
-                        winners.append(p)
-                    else:
-                        losers.append(p)
-
-        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)
-
-        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])
-
-                if pid in winners:
-                    winners.remove(pid)
-                else:
-                    losers.remove(pid)
-
-        elos = self.update_elos(session, elos, scores, winners, losers, ELOPARMS)
-
-        # add the elos to the session for committing
-        for e in elos:
-            session.add(elos[e])
-
-    def update_elos(self, session, elos, scores, winners, losers, ep):
-        if len(elos) < 2 or len(winners) == 0 or len(losers) == 0:
-            return elos
-
-        pids = elos.keys()
-
-        elo_deltas = {}
-        for w_pid in winners:
-            w_elo = elos[w_pid]
-            for l_pid in losers:
-                l_elo = elos[l_pid]
-
-                w_q = math.pow(10, float(w_elo.elo)/400.0)
-                l_q = math.pow(10, float(l_elo.elo)/400.0)
-
-                w_delta = w_elo.k * ELOPARMS.global_K * (1 - w_q/(w_q + l_q))
-                l_delta = l_elo.k * ELOPARMS.global_K * (0 - l_q/(l_q + w_q))
-
-                elo_deltas[w_pid] = (elo_deltas.get(w_pid, 0.0) + w_delta)
-                elo_deltas[l_pid] = (elo_deltas.get(l_pid, 0.0) + l_delta)
-
-                log.debug("Winner {0}'s elo_delta vs Loser {1}: {2}".format(w_pid,
-                    l_pid, w_delta))
-
-                log.debug("Loser {0}'s elo_delta vs Winner {1}: {2}".format(l_pid,
-                    w_pid, l_delta))
-
-                log.debug("w_elo: {0}, w_k: {1}, w_q: {2}, l_elo: {3}, l_k: {4}, l_q: {5}".\
-                        format(w_elo.elo, w_elo.k, l_q, l_elo.elo, l_elo.k, l_q))
-
-        for pid in pids:
-            # average the elo gain for team games
-            if pid in winners:
-                elo_deltas[pid] = elo_deltas.get(pid, 0.0) / len(losers)
-            else:
-                elo_deltas[pid] = elo_deltas.get(pid, 0.0) / len(winners)
-
-            old_elo = float(elos[pid].elo)
-            new_elo = max(float(elos[pid].elo) + elo_deltas[pid], ep.floor)
-
-            # in case we've set a different delta from the above
-            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))
-
-        self.save_elo_deltas(session, elo_deltas)
-
-        return elos
-
-
-    def save_elo_deltas(self, 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 == self.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))
-
-
-
 
 class PlayerGameStat(object):
     def __init__(self, player_game_stat_id=None, create_dt=None):
 
 class PlayerGameStat(object):
     def __init__(self, player_game_stat_id=None, create_dt=None):
@@ -323,13 +180,13 @@ class PlayerNick(object):
 
 
 class PlayerElo(object):
 
 
 class PlayerElo(object):
-    def __init__(self, player_id=None, game_type_cd=None):
+    def __init__(self, player_id=None, game_type_cd=None, elo=None):
 
         self.player_id = player_id
         self.game_type_cd = game_type_cd
 
         self.player_id = player_id
         self.game_type_cd = game_type_cd
+        self.elo = elo
         self.score = 0
         self.games = 0
         self.score = 0
         self.games = 0
-        self.elo = ELOPARMS.initial
 
     def __repr__(self):
         return "<PlayerElo(pid=%s, gametype=%s, elo=%s)>" % (self.player_id, self.game_type_cd, self.elo)
 
     def __repr__(self):
         return "<PlayerElo(pid=%s, gametype=%s, elo=%s)>" % (self.player_id, self.game_type_cd, self.elo)
index d584915193706fa0bd47e9f1789fd750a1f72e11..78f02c94512301948da85053458c4d563f1726a8 100644 (file)
@@ -8,6 +8,7 @@ from pyramid.response import Response
 from sqlalchemy import Sequence
 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
 from xonstat.d0_blind_id import d0_blind_id_verify
 from sqlalchemy import Sequence
 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
 from xonstat.d0_blind_id import d0_blind_id_verify
+from xonstat.elo import process_elos
 from xonstat.models import *
 from xonstat.util import strip_colors, qfont_decode
 
 from xonstat.models import *
 from xonstat.util import strip_colors, qfont_decode
 
@@ -612,7 +613,7 @@ def stats_submit(request):
 
         # update elos
         try:
 
         # update elos
         try:
-            game.process_elos(session)
+            process_elos(game, session)
         except Exception as e:
             log.debug('Error (non-fatal): elo processing failed.')
 
         except Exception as e:
             log.debug('Error (non-fatal): elo processing failed.')