From a1653d3aad61f3cbbe5f67af7ca5e3163d9b8c6f Mon Sep 17 00:00:00 2001 From: Ant Zucaro Date: Sat, 14 Nov 2015 16:20:33 -0500 Subject: [PATCH] Refactor the Elo processing code into EloProcessor. Before, Elo processing was simply a couple of extra methods that mutated PlayerGameStat and PlayerElo rows, adding them to a session for updating or inserting later. In order to support an end-of-match game report, we now need to hold onto to those intermediate values so they can be passed into a template. To do this I created an EloProcessor class to perform all of the computation and an EloWIP class to hold onto all of the intermediate values. EloProcessor can be used to find out what changed during Elo calculations. --- xonstat/elo.py | 367 ++++++++++++++++++++---------------- xonstat/views/submission.py | 15 +- 2 files changed, 210 insertions(+), 172 deletions(-) diff --git a/xonstat/elo.py b/xonstat/elo.py index 60da531..f9b289d 100644 --- a/xonstat/elo.py +++ b/xonstat/elo.py @@ -43,178 +43,221 @@ 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 and a.seconds > game.duration.seconds: - scores[p] = s/float(game.duration.seconds) - alivetimes[p] = 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) +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 - eloadjust[ei.player_id] += adjustmenti - eloadjust[ej.player_id] += adjustmentj + # current player_game_stat record + self.pgstat = pgstat - 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 + # Elo algorithm K-factor + self.k = 0.0 - elos[pid].elo = new_elo - elos[pid].games += 1 - elos[pid].update_dt = datetime.datetime.utcnow() + # Elo points accumulator, which is not adjusted by the K-factor + self.adjustment = 0.0 - 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)) + # elo points delta accumulator for the game, which IS adjusted + # by the K-factor + self.elo_delta = 0.0 - save_elo_deltas(game, session, elo_deltas) + 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 - return elos + def __repr__(self): + return "".\ + format(self.player_id, self.score_per_second, self.alivetime, \ + self.elo, self.pgstat, self.k, self.adjustment, self.elo_delta) -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. +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): - 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 + # game which we are processing + self.game = game - 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)) + # 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 > timedelta(seconds=0) + + # only process elos for elo-eligible players + for pgstat in filter(elo_eligible, pgstats): + 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 diff --git a/xonstat/views/submission.py b/xonstat/views/submission.py index 18ff005..dab96af 100644 --- a/xonstat/views/submission.py +++ b/xonstat/views/submission.py @@ -8,7 +8,7 @@ import sqlalchemy.sql.expression as expr from pyramid.response import Response from sqlalchemy import Sequence from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound -from xonstat.elo import process_elos +from xonstat.elo import EloProcessor from xonstat.models import * from xonstat.util import strip_colors, qfont_decode, verify_request, weapon_map @@ -831,14 +831,6 @@ def create_weapon_stats(session, game_meta, game, player, pgstat, events): return pwstats -def create_elos(session, game): - """Elo handler for all game types.""" - try: - process_elos(game, session) - except Exception as e: - log.debug('Error (non-fatal): elo processing failed.') - - def submit_stats(request): """ Entry handler for POST stats submissions. @@ -900,6 +892,7 @@ def submit_stats(request): # keep track of the players we've seen player_ids = [] + pgstats = [] for events in raw_players: player = get_or_create_player( session = session, @@ -908,6 +901,7 @@ def submit_stats(request): pgstat = create_game_stat(session, game_meta, game, server, gmap, player, events) + pgstats.append(pgstat) if player.player_id > 1: anticheats = create_anticheats(session, pgstat, game, player, @@ -930,7 +924,8 @@ def submit_stats(request): raise e if should_do_elos(game_type_cd): - create_elos(session, game) + ep = EloProcessor(session, game, pgstats) + ep.save(session) session.commit() log.debug('Success! Stats recorded.') -- 2.39.2