|
|
|
module RankKing
|
|
|
|
class OpenSkill
|
|
|
|
# Gaussian curve where mu is the mean and sigma the stddev
|
|
|
|
Rating = Data.define(:mu, :sigma) do
|
|
|
|
def ordinal = self.mu - 3*self.sigma
|
|
|
|
end
|
|
|
|
|
|
|
|
TeamRating = Data.define(:mu, :sigma_sq, :team, :rank)
|
|
|
|
|
|
|
|
def initialize(tau: nil)
|
|
|
|
@z = 3.0
|
|
|
|
@mu = 25.0
|
|
|
|
@tau = tau || @mu / 300.0
|
|
|
|
@sigma = @mu / @z
|
|
|
|
|
|
|
|
@epsilon = 0.0001
|
|
|
|
@beta = @sigma / 2.0
|
|
|
|
end
|
|
|
|
|
|
|
|
def rate(*teams)
|
|
|
|
# allow for passing in single-rating teams
|
|
|
|
teams = teams.map { Array(_1) }
|
|
|
|
|
|
|
|
# tau keeps sigma from dropping too low so that ratings stay pliable
|
|
|
|
# after many games
|
|
|
|
tau_sq = @tau ** 2
|
|
|
|
teams = teams.map {|team|
|
|
|
|
team.map {|r| r.with(sigma: Math.sqrt(r.sigma ** 2 + tau_sq)) }
|
|
|
|
}
|
|
|
|
|
|
|
|
rank = (0...teams.size).to_a
|
|
|
|
|
|
|
|
ordered_teams, tenet = self.class.unwind(teams, rank)
|
|
|
|
new_ratings = plackett_luce(ordered_teams, rank)
|
|
|
|
reordered_teams = self.class.unwind(new_ratings, tenet)
|
|
|
|
|
|
|
|
# TODO prevent sigma increase?
|
|
|
|
|
|
|
|
reordered_teams
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.unwind(src, rank)
|
|
|
|
fail unless src.size == rank.size
|
|
|
|
|
|
|
|
return [[], []] if src.empty?
|
|
|
|
|
|
|
|
src.each.with_index
|
|
|
|
.sort_by { rank.fetch(_2) }
|
|
|
|
.transpose
|
|
|
|
end
|
|
|
|
|
|
|
|
def plackett_luce(game, rank)
|
|
|
|
team_ratings = self.team_ratings(game)
|
|
|
|
c = util_c(team_ratings)
|
|
|
|
sum_q = util_sum_q(team_ratings, c)
|
|
|
|
a = util_a(team_ratings)
|
|
|
|
|
|
|
|
team_ratings.map.with_index {|x, i|
|
|
|
|
x_mu_over_ce = Math.exp(x.mu / c) # tmp1
|
|
|
|
|
|
|
|
omega_sum, delta_sum = team_ratings.each.with_index
|
|
|
|
.filter {|y,_| y.rank <= x.rank }
|
|
|
|
.inject([0, 0]) {|(omega, delta), (y, i)|
|
|
|
|
quotient = x_mu_over_ce / sum_q.fetch(i)
|
|
|
|
[
|
|
|
|
omega + (x == y ? 1 - quotient : -quotient) / a.fetch(i),
|
|
|
|
delta + (quotient * (1 - quotient)) / a.fetch(i),
|
|
|
|
]
|
|
|
|
}
|
|
|
|
|
|
|
|
x_gamma = Math.sqrt(x.sigma_sq) / c
|
|
|
|
x_omega = omega_sum * (x.sigma_sq / c)
|
|
|
|
x_delta = x_gamma * delta_sum * (x.sigma_sq / c ** 2)
|
|
|
|
|
|
|
|
x.team.map {|team| Rating.new(
|
|
|
|
mu: team.mu + (team.sigma ** 2 / x.sigma_sq) * x_omega,
|
|
|
|
sigma: team.sigma * Math.sqrt([1 - (team.sigma ** 2 / x.sigma_sq) * x_delta, @epsilon].max),
|
|
|
|
)}
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
def rating(mu: nil, sigma: nil)
|
|
|
|
mu ||= @mu
|
|
|
|
sigma ||= mu / @z
|
|
|
|
Rating.new(mu: mu.to_f, sigma: sigma.to_f)
|
|
|
|
end
|
|
|
|
|
|
|
|
def team_ratings(game)
|
|
|
|
game.map.with_index {|team, i|
|
|
|
|
TeamRating.new(
|
|
|
|
mu: team.sum(&:mu),
|
|
|
|
sigma_sq: team.sum { _1.sigma ** 2 },
|
|
|
|
team:,
|
|
|
|
rank: i,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
def util_a(team_ratings)
|
|
|
|
team_ratings.map {|q|
|
|
|
|
team_ratings.count {|i| i.rank == q.rank }
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
def util_c(team_ratings)
|
|
|
|
Math.sqrt(team_ratings.sum { _1.sigma_sq + @beta ** 2 })
|
|
|
|
end
|
|
|
|
|
|
|
|
def util_sum_q(team_ratings, c)
|
|
|
|
team_ratings.map {|q|
|
|
|
|
team_ratings
|
|
|
|
.select {|i| i.rank >= q.rank }
|
|
|
|
.sum {|i| Math.exp(i.mu / c) }
|
|
|
|
}
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|