X-Git-Url: http://de.git.xonotic.org/?p=xonotic%2Fxonstat.git;a=blobdiff_plain;f=xonstat%2Felo.py;h=60e7505d1d65135d2cd53d4032b804da01743c8c;hp=74c49cd589137f6e4e15c974bff65238512368a2;hb=eedc246f8d3fc836c3f939cf7505a3f497bb3241;hpb=6f1725f94b09169db01acbd2b4f709a9e7d51e98 diff --git a/xonstat/elo.py b/xonstat/elo.py index 74c49cd..60e7505 100644 --- a/xonstat/elo.py +++ b/xonstat/elo.py @@ -1,20 +1,22 @@ +import datetime import logging import math -import random -import sys -from xonstat.models import * +from xonstat.models import PlayerElo 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)): + def __init__(self, global_K=15, initial=100, floor=100, + logdistancefactor=math.log(10)/float(400), maxlogdistance=math.log(10), + latency_trend_factor=0.2): self.global_K = global_K self.initial = initial self.floor = floor self.logdistancefactor = logdistancefactor self.maxlogdistance = maxlogdistance + self.latency_trend_factor = latency_trend_factor class KReduction: @@ -42,185 +44,248 @@ 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: - if a.seconds > game.duration.seconds: - scores[p] = s/float(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) +# parameters for K reduction +# this may be touched even if the DB already exists +KREDUCTION = KReduction(600, 120, 0.5, 0, 32, 0.2) - eloadjust[ei.player_id] += adjustmenti - eloadjust[ej.player_id] += adjustmentj +# 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) - 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 +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 - 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)) + # score per second in the game + self.score_per_second = 0.0 - save_elo_deltas(game, session, elo_deltas) + # seconds alive during a given game + self.alivetime = 0 - return elos + # current elo record + self.elo = None + # current player_game_stat record + self.pgstat = pgstat -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 algorithm K-factor + self.k = 0.0 - 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 + # Elo points accumulator, which is not adjusted by the K-factor + self.adjustment = 0.0 - 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)) + # elo points delta accumulator for the game, which IS adjusted + # by the K-factor + self.elo_delta = 0.0 + 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 -# parameters for K reduction -# this may be touched even if the DB already exists -KREDUCTION = KReduction(600, 120, 0.5, 0, 32, 0.2) + 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) + + +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): + + # game which we are processing + self.game = game + + # 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 > datetime.timedelta(seconds=0) + + elostats = filter(elo_eligible, pgstats) + + # only process elos for elo-eligible players + for pgstat in elostats: + 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() + + 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 pingfactor(self, pi, pj): + """ Calculate the ping differences between the two players, but only if both have them. """ + if pi is None or pj is None or pi < 0 or pj < 0: + # default to a draw + return 0.5 + + else: + return float(pi)/(pi+pj) + + 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 + pi = self.wip[pids[i]].pgstat.avg_latency + 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 + pj = self.wip[pids[j]].pgstat.avg_latency + + # 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)) + + # adjust the elo prediction according to ping + ping_ratio = self.pingfactor(pi, pj) + scorefactor_ping = ep.latency_trend_factor * (0.5 - ping_ratio) + scorefactor_elo_adjusted = max(0.0, min(1.0, scorefactor_elo + scorefactor_ping)) + + # initial adjustment values, which we may modify with additional rules + adjustmenti = scorefactor_real - scorefactor_elo_adjusted + adjustmentj = scorefactor_elo_adjusted - 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) Ping ratio: {0}".format(ping_ratio)) + # log.debug("(New) Scorefactor real: {0}".format(scorefactor_real)) + # log.debug("(New) Scorefactor elo: {0}".format(scorefactor_elo)) + # log.debug("(New) Scorefactor ping: {0}".format(scorefactor_ping)) + # log.debug("(New) adjustment i: {0}".format(scorefactor_real - scorefactor_elo)) + # log.debug("(New) adjustment j: {0}".format(scorefactor_elo - scorefactor_real)) + # log.debug("(New) adjustment i with ping: {0}".format(adjustmenti)) + # log.debug("(New) adjustment j with ping: {0}\n".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 + + log.debug("{}'s Old Elo: {} New Elo: {} Delta {}" + .format(pid, old_elo, new_elo, w.elo_delta)) + + w.elo.elo = new_elo + w.elo.games += 1 + w.elo.update_dt = datetime.datetime.utcnow() + + 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 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)