# 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))
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 = []
return new_rating
-class KReduction:
+class KReduction(object):
"""
Scale the points gained or lost for players based on time played in the given game.
"""
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 = []
# the list of results for those games in the ranking period
self.results = []
+ def __repr__(self):
+ return ("<GlickoWIP({0.pg}, k={0.k_factors}, ping={0.ping_factors}, "
+ "opponents={0.opponents}, results={0.results})>".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.
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():