X-Git-Url: http://git.xonotic.org/?a=blobdiff_plain;f=xonstat%2Fglicko.py;h=167304392ce280e1a48d92e60528dc5bf6ef8994;hb=refs%2Fheads%2Fglicko;hp=f8ca65303998141e67360cab48020153c58d5cef;hpb=be5373ac54c2e431c5273179a1f2d043f297e0d6;p=xonotic%2Fxonstat.git diff --git a/xonstat/glicko.py b/xonstat/glicko.py index f8ca653..1673043 100644 --- a/xonstat/glicko.py +++ b/xonstat/glicko.py @@ -13,6 +13,9 @@ log = logging.getLogger(__name__) # the default system volatility constant TAU = 0.3 +# how much ping influences results +LATENCY_TREND_FACTOR = 0.2 + def calc_g(phi): return 1 / math.sqrt(1 + (3 * phi ** 2) / (math.pi ** 2)) @@ -89,6 +92,9 @@ def rate(player, opponents, results): Calculate the ratings improvement for a given player, provided their opponents and corresponding results versus them. """ + if len(opponents) == 0 or len(results) == 0: + return player + p_g2 = player.to_glicko2() gs = [] @@ -130,7 +136,7 @@ def rate(player, opponents, results): return new_rating -class KReduction: +class KReduction(object): """ Scale the points gained or lost for players based on time played in the given game. """ @@ -177,7 +183,10 @@ class GlickoWIP(object): self.pg = pg # the list of k factors for each game in the ranking period - self.ks = [] + self.k_factors = [] + + # the list of ping factors for each game in the ranking period + self.ping_factors = [] # the list of opponents (PlayerGlicko or PlayerGlickoBase) in the ranking period self.opponents = [] @@ -185,47 +194,25 @@ class GlickoWIP(object): # the list of results for those games in the ranking period self.results = [] + def __repr__(self): + return ("".format(self)) + class GlickoProcessor(object): """ - Processes the given list games using the Glicko2 algorithm. + Processes an arbitrary list games using the Glicko2 algorithm. """ def __init__(self, session): """ Create a GlickoProcessor instance. :param session: the SQLAlchemy session to use for fetching/saving records. - :param game_ids: the list of game_ids that need to be processed. """ self.session = session self.wips = {} - def scorefactor(self, si, sj, game_type_cd): - """ - Calculate the real scorefactor of the game. This is how players - actually performed, which is compared to their expected performance. - - :param si: the score per second of player I - :param sj: the score per second of player J - :param game_type_cd: the game type of the game in question - :return: float - """ - scorefactor_real = si / float(si + sj) - - # duels are done traditionally - a win nets - # full points, not the score factor - if 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): + def _pingratio(self, pi, pj): """ Calculate the ping differences between the two players, but only if both have them. @@ -240,43 +227,174 @@ class GlickoProcessor(object): else: return float(pi)/(pi+pj) - def load(self, game_id): - """ - Load all of the needed information from the database. - """ + def _load_game(self, game_id): try: game = self.session.query(Game).filter(Game.game_id==game_id).one() - except: + return game + except Exception as e: log.error("Game ID {} not found.".format(game_id)) - return + log.error(e) + raise e + + def _load_pgstats(self, game): + """ + Retrieve the game stats from the database for the game in question. + :param game: the game record whose player stats will be retrieved + :return: list of PlayerGameStat + """ try: pgstats_raw = self.session.query(PlayerGameStat)\ - .filter(PlayerGameStat.game_id==game_id)\ + .filter(PlayerGameStat.game_id==game.game_id)\ .filter(PlayerGameStat.player_id > 2)\ .all() + return pgstats_raw + + except Exception as e: + log.error("Error fetching player_game_stat records for game {}".format(game.game_id)) + log.error(e) + raise e + + def _filter_pgstats(self, game, pgstats_raw): + """ + Filter the raw game stats so that all of them are Glicko-eligible. + + :param pgstats_raw: the list of raw PlayerGameStat + :return: list of PlayerGameStat + """ + pgstats = [] + for pgstat in pgstats_raw: # ensure warmup isn't included in the pgstat records - for pgstat in pgstats_raw: - if pgstat.alivetime > game.duration: - pgstat.alivetime = game.duration + if pgstat.alivetime > game.duration: + pgstat.alivetime = game.duration + + # ensure players played enough of the match to be included + k = KREDUCTION.eval(pgstat.alivetime.total_seconds(), game.duration.total_seconds()) + if k <= 0.0: + continue + elif pgstat.player_id <= 2: + continue + else: + pgstats.append(pgstat) + + return pgstats + + def _load_glicko_wip(self, player_id, game_type_cd, category): + """ + Retrieve a PlayerGlicko record from the database or local cache. + + :param player_id: the player ID to fetch + :param game_type_cd: the game type code + :param category: the category of glicko to retrieve + :return: PlayerGlicko + """ + if (player_id, game_type_cd, category) in self.wips: + return self.wips[(player_id, game_type_cd, category)] + + try: + pg = self.session.query(PlayerGlicko)\ + .filter(PlayerGlicko.player_id==player_id)\ + .filter(PlayerGlicko.game_type_cd==game_type_cd)\ + .filter(PlayerGlicko.category==category)\ + .one() + except: - log.error("Error fetching player_game_stat records for game {}".format(self.game_id)) - return + pg = PlayerGlicko(player_id, game_type_cd, category) + + # cache this in the wips dict + wip = GlickoWIP(pg) + self.wips[(player_id, game_type_cd, category)] = wip + + return wip + + def load(self, game_id, game=None, pgstats=None): + """ + Load all of the needed information from the database. Compute results for each player pair. + """ + if game is None: + game = self._load_game(game_id) + + if pgstats is None: + pgstats = self._load_pgstats(game) + + pgstats = self._filter_pgstats(game, pgstats) + + game_type_cd = game.game_type_cd + category = game.category + + # calculate results: + # wipi/j => work in progress record for player i/j + # ki/j => k reduction value for player i/j + # si/j => score per second for player i/j + # pi/j => ping ratio for player i/j + for i in xrange(0, len(pgstats)): + wipi = self._load_glicko_wip(pgstats[i].player_id, game_type_cd, category) + ki = KREDUCTION.eval(pgstats[i].alivetime.total_seconds(), + game.duration.total_seconds()) + si = pgstats[i].score/float(game.duration.total_seconds()) + + for j in xrange(i+1, len(pgstats)): + # ping factor is opponent-specific + pi = self._pingratio(pgstats[i].avg_latency, pgstats[j].avg_latency) + pj = 1.0 - pi + + wipj = self._load_glicko_wip(pgstats[j].player_id, game_type_cd, category) + kj = KREDUCTION.eval(pgstats[j].alivetime.total_seconds(), + game.duration.total_seconds()) + sj = pgstats[j].score/float(game.duration.seconds) + + # normalize scores + ofs = min(0.0, si, sj) + si -= ofs + sj -= ofs + if si + sj == 0: + si, sj = 1, 1 # a draw + + scorefactor_i = si / float(si + sj) + scorefactor_j = 1.0 - si + + wipi.k_factors.append(ki) + wipi.ping_factors.append(pi) + wipi.opponents.append(wipj.pg) + wipi.results.append(scorefactor_i) + + wipj.k_factors.append(kj) + wipj.ping_factors.append(pj) + wipj.opponents.append(wipi.pg) + wipj.results.append(scorefactor_j) def process(self): """ Calculate the Glicko2 ratings, deviations, and volatility updates for the records loaded. - :return: bool """ - pass + for wip in self.wips.values(): + new_pg = rate(wip.pg, wip.opponents, wip.results) + + log.debug("New rating for player {} before factors: mu={} phi={} sigma={}" + .format(new_pg.player_id, new_pg.mu, new_pg.phi, new_pg.sigma)) + + avg_k_factor = sum(wip.k_factors)/len(wip.k_factors) + avg_ping_factor = LATENCY_TREND_FACTOR * sum(wip.ping_factors)/len(wip.ping_factors) - def save(self, session): + points_delta = (new_pg.mu - wip.pg.mu) * avg_k_factor * avg_ping_factor + + wip.pg.mu += points_delta + wip.pg.phi = new_pg.phi + wip.pg.sigma = new_pg.sigma + + log.debug("New rating for player {} after factors: mu={} phi={} sigma={}" + .format(wip.pg.player_id, wip.pg.mu, wip.pg.phi, wip.pg.sigma)) + + def save(self): """ Put all changed PlayerElo and PlayerGameStat instances into the session to be updated or inserted upon commit. """ - pass + for wip in self.wips.values(): + self.session.add(wip.pg) + + self.session.commit() def main():