main
parent
c63e099693
commit
b19a8260c7
@ -1,3 +0,0 @@
|
||||
require "sequel/core"
|
||||
|
||||
DB = Sequel.connect(ENV.delete("DATABASE_URL"))
|
@ -0,0 +1,44 @@
|
||||
require_relative "rank_king/web"
|
||||
require_relative "rank_king/models"
|
||||
require_relative "rank_king/open_skill"
|
||||
|
||||
module RankKing
|
||||
OS = OpenSkill.new
|
||||
Match = Data.define(:a, :b)
|
||||
|
||||
def self.find_match(axis:)
|
||||
items = axis.pool.items
|
||||
|
||||
items.combination(2).sort_by {|combo|
|
||||
ratings = combo.map {|item| os_rating(axis:, item:) }
|
||||
Math.sqrt(
|
||||
[ratings, ratings.reverse]
|
||||
.map {|game| [game, OS.rate(game.map {[_1]}).flatten] }
|
||||
.sum {|pre,post| pre.zip(post).sum { (_1.sigma - _2.sigma)**2 }}
|
||||
)
|
||||
}.last
|
||||
end
|
||||
|
||||
def self.rank(axis:, winner:, loser:)
|
||||
fail unless [winner, loser].all? { _1.pool == axis.pool }
|
||||
|
||||
game = [winner, loser].map { os_rating(axis:, item: _1) }
|
||||
result = OS.rate(game.map { [_1] }).flatten
|
||||
|
||||
DB.transaction do
|
||||
Ranking.create(axis:, winner:, loser:)
|
||||
Rating.update_or_create({axis:, item: winner}, result.fetch(0).to_h)
|
||||
Rating.update_or_create({axis:, item: loser}, result.fetch(1).to_h)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.os_rating(**filter)
|
||||
if result = Rating.where(**filter).first
|
||||
OS.rating(mu: result.mu, sigma: result.sigma)
|
||||
else
|
||||
OS.rating
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,41 @@
|
||||
require "forwardable"
|
||||
|
||||
require "sequel"
|
||||
|
||||
require_relative "db"
|
||||
require_relative "open_skill"
|
||||
|
||||
module RankKing
|
||||
class Pool < Sequel::Model
|
||||
one_to_many :items
|
||||
one_to_many :axes, class: "RankKing::Axis"
|
||||
end
|
||||
|
||||
class Item < Sequel::Model
|
||||
many_to_one :pool
|
||||
one_to_many :ratings
|
||||
end
|
||||
|
||||
class Axis < Sequel::Model(DB[:axes])
|
||||
many_to_one :pool
|
||||
one_to_many :ratings
|
||||
end
|
||||
|
||||
class Rating < Sequel::Model
|
||||
many_to_one :item
|
||||
many_to_one :axis
|
||||
|
||||
extend Forwardable
|
||||
def_delegators :openskill, :ordinal, :with
|
||||
|
||||
def openskill
|
||||
OpenSkill::Rating.new(mu: self.mu, sigma: self.sigma)
|
||||
end
|
||||
end
|
||||
|
||||
class Ranking < Sequel::Model
|
||||
many_to_one :axis
|
||||
many_to_one :winner, class: :Item
|
||||
many_to_one :loser, class: :Item
|
||||
end
|
||||
end
|
@ -0,0 +1,116 @@
|
||||
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
|
||||
|
||||
# TODO take an implicit array
|
||||
# TODO array-ify elements
|
||||
def rate(teams)
|
||||
# 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
|
@ -0,0 +1,89 @@
|
||||
require "roda"
|
||||
|
||||
require_relative "db"
|
||||
|
||||
module RankKing
|
||||
class Web < Roda
|
||||
plugin :named_templates
|
||||
plugin :render
|
||||
|
||||
template(:layout) { <<~ERB }
|
||||
<html>
|
||||
<body>
|
||||
<h1>Rank King</h1>
|
||||
<%= yield %>
|
||||
</body>
|
||||
</html>
|
||||
ERB
|
||||
|
||||
template(:pools) { <<~ERB }
|
||||
<h2>Pools</h2>
|
||||
<ul>
|
||||
<% @pools.each do |pool| %>
|
||||
<li><a href="/pools/<%= pool.id %>"><%= pool.name %></a></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
ERB
|
||||
|
||||
template(:pool) { <<~ERB }
|
||||
<h2><%= @pool.name %></h2>
|
||||
<h3>Axes</h3>
|
||||
<ul>
|
||||
<% @pool.axes.each do |axis| %>
|
||||
<li><a href="/pools/<%= @pool.id %>/axes/<%= axis.id %>"><%= axis.name %></a></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<h3>Items</h3>
|
||||
<ol>
|
||||
<% @pool.items.each do |item| %>
|
||||
<li><%= item.title %></li>
|
||||
<% end %>
|
||||
</ol>
|
||||
ERB
|
||||
|
||||
template(:axis) { <<~ERB }
|
||||
<h2><%= @axis.pool.name %> - <%= @axis.name %></h2>
|
||||
<h3>Items</h3>
|
||||
<table>
|
||||
<% @axis.ratings.sort_by { -_1.ordinal }.each.with_index do |rating, i| %>
|
||||
<tr>
|
||||
<td><%= i+1 %></td>
|
||||
<td><%= rating.item.title %></td>
|
||||
<td><%= rating.ordinal.round(1) %></td>
|
||||
<td><%= rating.mu.round(1) %></td>
|
||||
<td><%= rating.sigma.round(1) %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</table>
|
||||
ERB
|
||||
|
||||
route do |r|
|
||||
r.root do
|
||||
r.redirect "/pools"
|
||||
end
|
||||
|
||||
r.on "pools" do
|
||||
r.is do
|
||||
@pools = Pool.all
|
||||
view :pools
|
||||
end
|
||||
|
||||
r.on Integer do |id|
|
||||
@pool = Pool[id]
|
||||
|
||||
r.is do
|
||||
view :pool
|
||||
end
|
||||
|
||||
r.on "axes" do
|
||||
r.is Integer do |id|
|
||||
@axis = Axis[id]
|
||||
|
||||
view :axis
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,132 @@
|
||||
require "rank_king/open_skill"
|
||||
|
||||
include RankKing
|
||||
|
||||
class TestRate < Minitest::Test
|
||||
def setup
|
||||
@os = OpenSkill.new(tau: 0)
|
||||
@a1 = @os.rating(mu: 29.182, sigma: 4.782)
|
||||
@b1 = @os.rating(mu: 27.174, sigma: 4.922)
|
||||
@c1 = @os.rating(mu: 16.672, sigma: 6.217)
|
||||
@d1 = @os.rating
|
||||
@e1 = @os.rating
|
||||
@f1 = @os.rating
|
||||
@w1 = @os.rating(mu: 15)
|
||||
@x1 = @os.rating(mu: 20)
|
||||
@y1 = @os.rating(mu: 25)
|
||||
@z1 = @os.rating(mu: 30)
|
||||
end
|
||||
|
||||
def test_rate
|
||||
a2, b2, c2, d2 = @os.rate([[@a1], [@b1], [@c1], [@d1]]).flatten
|
||||
|
||||
assert_rating a2, OpenSkill::Rating.new(mu: 30.209971908310553, sigma: 4.764898977359521)
|
||||
assert_rating b2, OpenSkill::Rating.new(mu: 27.64460833689499, sigma: 4.882789305097372)
|
||||
assert_rating c2, OpenSkill::Rating.new(mu: 17.403586731283518, sigma: 6.100723440599442)
|
||||
assert_rating d2, OpenSkill::Rating.new(mu: 19.214790707434826, sigma: 7.8542613981643985)
|
||||
end
|
||||
|
||||
def test_tau
|
||||
# TODO
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_rating(expected, actual)
|
||||
assert_in_delta expected.mu, actual.mu, 0.0001, "Expected #{actual} to equal #{expected}"
|
||||
assert_in_delta expected.sigma, actual.sigma, 0.0001, "Expected #{actual} to equal #{expected}"
|
||||
end
|
||||
end
|
||||
|
||||
class TestRating < Minitest::Test
|
||||
def test_initialize_rating
|
||||
rating = OpenSkill.new.rating(mu: 12, sigma: 34)
|
||||
assert_in_delta 12, rating.mu
|
||||
assert_in_delta 34, rating.sigma
|
||||
|
||||
rating = OpenSkill.new.rating(mu: 42)
|
||||
assert_in_delta 42, rating.mu
|
||||
assert_in_delta 14, rating.sigma
|
||||
|
||||
rating = OpenSkill.new.rating(sigma: 6.283185)
|
||||
assert_in_delta 25, rating.mu
|
||||
assert_in_delta 6.283185, rating.sigma
|
||||
|
||||
rating = OpenSkill.new.rating(sigma: 0)
|
||||
assert_in_delta 25, rating.mu
|
||||
assert_in_delta 0, rating.sigma
|
||||
end
|
||||
|
||||
def test_ordinal
|
||||
assert_in_delta (-1), OpenSkill::Rating.new(mu: 5, sigma: 2).ordinal
|
||||
end
|
||||
end
|
||||
|
||||
class TestUtils < Minitest::Test
|
||||
def setup
|
||||
@os = OpenSkill.new
|
||||
@r = @os.rating
|
||||
end
|
||||
|
||||
def test_team_ratings
|
||||
# TODO
|
||||
end
|
||||
|
||||
def test_utils
|
||||
team_1 = [@r]
|
||||
team_2 = [@r, @r]
|
||||
team_ratings = @os.team_ratings([team_1, team_2])
|
||||
|
||||
c = @os.util_c(team_ratings)
|
||||
assert_in_delta 15.590239, c
|
||||
|
||||
sum_q = @os.util_sum_q(team_ratings, c)
|
||||
[29.67892702634643, 24.70819334370875].zip(sum_q).each do |actual, expected|
|
||||
assert_in_delta actual, expected
|
||||
end
|
||||
end
|
||||
|
||||
def test_utils_5v5
|
||||
team = Array.new(5) { @r }
|
||||
team_ratings = @os.team_ratings([team, team])
|
||||
|
||||
c = @os.util_c(team_ratings)
|
||||
assert_in_delta 27.003, c
|
||||
|
||||
sum_q = @os.util_sum_q(team_ratings, c)
|
||||
[204.84, 102.42].zip(sum_q).each do |actual, expected|
|
||||
assert_in_delta actual, expected, 0.01
|
||||
end
|
||||
end
|
||||
|
||||
def test_util_a
|
||||
team = [@r]
|
||||
team_ratings = @os.team_ratings([team, team, team, team])
|
||||
.zip([1, 1, 1, 4])
|
||||
.map { _1.with(rank: _2) }
|
||||
|
||||
a = @os.util_a(team_ratings)
|
||||
|
||||
assert_equal [3, 3, 3, 1], a
|
||||
end
|
||||
end
|
||||
|
||||
class TestUnwind < Minitest::Test
|
||||
def test_unwind
|
||||
assert_equal [[], []], OpenSkill.unwind([], [])
|
||||
assert_equal [%w[a], [0]], OpenSkill.unwind(%w[a], [0])
|
||||
assert_equal [%w[a b], [1, 0]], OpenSkill.unwind(%w[b a], [1, 0])
|
||||
assert_equal [%w[b c a], [1, 2, 0]], OpenSkill.unwind(%w[a b c], [2, 0, 1])
|
||||
assert_equal [%w[b d c a], [1, 3, 2, 0]], OpenSkill.unwind(%w[a b c d], [3, 0, 2, 1])
|
||||
end
|
||||
|
||||
def test_reverse
|
||||
src = (?a..?z).to_a.shuffle
|
||||
rank = (0..25).to_a.shuffle
|
||||
trans, derank = OpenSkill.unwind(src, rank)
|
||||
dst, dederank = OpenSkill.unwind(trans, derank)
|
||||
|
||||
assert_equal src, dst
|
||||
refute_same rank, dederank
|
||||
end
|
||||
end
|
Loading…
Reference in new issue