From 36ee45a0e6f186a6978143f318a3bc7a75dd8fb0 Mon Sep 17 00:00:00 2001 From: Ant Zucaro Date: Wed, 1 Aug 2012 07:52:19 -0400 Subject: [PATCH] Use score-scaling Elo for non-duels. 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 | 174 +++++++++++++++++++++++++++++++++++- xonstat/models.py | 147 +----------------------------- xonstat/views/submission.py | 3 +- 3 files changed, 177 insertions(+), 147 deletions(-) diff --git a/xonstat/elo.py b/xonstat/elo.py index 19bcfba..48a24e8 100644 --- a/xonstat/elo.py +++ b/xonstat/elo.py @@ -1,6 +1,12 @@ -import sys +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)): @@ -36,6 +42,172 @@ 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 + 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) diff --git a/xonstat/models.py b/xonstat/models.py index 466bfa0..84a64ea 100644 --- a/xonstat/models.py +++ b/xonstat/models.py @@ -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 xonstat.elo import ELOPARMS, KREDUCTION 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 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): @@ -323,13 +180,13 @@ class PlayerNick(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.elo = elo self.score = 0 self.games = 0 - self.elo = ELOPARMS.initial def __repr__(self): return "" % (self.player_id, self.game_type_cd, self.elo) diff --git a/xonstat/views/submission.py b/xonstat/views/submission.py index d584915..78f02c9 100644 --- a/xonstat/views/submission.py +++ b/xonstat/views/submission.py @@ -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 xonstat.elo import process_elos from xonstat.models import * from xonstat.util import strip_colors, qfont_decode @@ -612,7 +613,7 @@ def stats_submit(request): # update elos try: - game.process_elos(session) + process_elos(game, session) except Exception as e: log.debug('Error (non-fatal): elo processing failed.') -- 2.39.2