Skip to content

Commit

Permalink
Create Rating object to help deal with converting between rating scales
Browse files Browse the repository at this point in the history
Preparing to try out glicko2
  • Loading branch information
aolsen committed Oct 2, 2011
1 parent 4af93f2 commit 2049f77
Show file tree
Hide file tree
Showing 2 changed files with 185 additions and 167 deletions.
201 changes: 111 additions & 90 deletions rating/strategies/glicko.rb
Expand Up @@ -14,7 +14,7 @@
# kyudan: 1.0 for each stone. No gap around zero.
# 0.0+epsilon = weakest 1d
# 0.0-epsilon = strongest 1k
# Use this to do operations like determining correct handicap,
# Use this to do operations like determining correct handicap,
# because it will work correctly across the dan/kyu boundary
#
# aga: 1.0 for each stone. Gap from (-1.0, 1.0)
Expand All @@ -28,83 +28,117 @@
require 'delegate'
require File.expand_path("../system", File.dirname(__FILE__))

# Glicko requires :rd, :time_last_player, in addition to simply :rating.
# Add them in here. Intial value of nil is fine.
class Player
attr_accessor :rd, :time_last_played
end
# Abstract out what scale the ratings are on
class Rating
attr_accessor :elo, :rd, :time_last_played

module Glicko
INITIAL_RATING = 0.0
DEBUG = false
MAX_RD = 240.0 # maximum rating deviation for new/inactive players
MIN_RD = 70.0 # minimum rating deviation for very active players
RD_DECAY = 2*365 # Number of days for RD to decay from MIN to MAX
C_SQUARED = (MAX_RD**2.0-MIN_RD**2.0)/RD_DECAY
Q = Math.log(10)/400.0 # Convert from classic Elo to natural scale
KGS_KYU_TRANSFORM = 0.85/Q # kgs 5k-
KGS_DAN_TRANSFORM = 1.30/Q # kgs 2d+
KD_FIVE_KYU = -4.0 # Strongest 5k on the kyudan scale
KD_TWO_DAN = 1.0 # Weakest 2d on the kyudan scale
A = (KGS_DAN_TRANSFORM - KGS_KYU_TRANSFORM) / (KD_TWO_DAN - KD_FIVE_KYU) # ~ 17.4 Intermediate constant for conversions
B = KGS_KYU_TRANSFORM - KD_FIVE_KYU*A # ~ 208.6 Intermediate constant for conversions
FIVE_KYU = (A/2.0)*((KD_FIVE_KYU)**2) + (B*KD_FIVE_KYU) # ~ 695.2 -- Glicko rating of the strongest 5k
TWO_DAN = (A/2.0)*((KD_TWO_DAN )**2) + (B*KD_TWO_DAN ) # ~ 217.3 -- Glicko rating of the weakest 2d
EVEN_KOMI = { "aga" => 7, "jpn" => 6 } # even komi, after doing floor()
FIVE_KYU = (A/2.0)*((KD_FIVE_KYU)**2) + (B*KD_FIVE_KYU) # ~ 695.2 -- Elo rating of the strongest 5k
TWO_DAN = (A/2.0)*((KD_TWO_DAN )**2) + (B*KD_TWO_DAN ) # ~ 217.3 -- Elo rating of the weakest 2d

class GlickoError < StandardError; end

def self.g(player)
return 1.0/Math.sqrt(1.0 + 3.0*(Q**2.0)*(player.rd**2.0)/Math::PI**2.0)
def self.new_aga(aga_rating)
r = Rating.new()
r.aga = aga_rating
return r
end

def self.win_probability(player, opp, hka=0)
return 1.0 / (1.0 + 10.0**(-g(opp)**2.0*(player.rating+hka-opp.rating)/400.0))
def self.new_kyudan(kyudan)
r = Rating.new()
r.kyudan = kyudan
return r
end

def self.d_squared(player, opp, hka)
e = win_probability(player, opp, hka)
return 1.0 / (Q**2.0 * g(opp)**2.0 * e * (1.0-e))
def self.advantage_in_stones(handi, komi, even_komi)
raise WhrError, "Handi=1 is illegal" if handi == 1
komi = komi.floor
even_komi = even_komi.floor
handi -= 1 if handi > 0
return handi + (even_komi-komi)/(even_komi*2.0)
end

def self.initial_rd_update(player, curr_time)
if player.time_last_played
delta_days = (curr_time-player.time_last_played).to_f
player.rd = [Math.sqrt(player.rd**2.0+C_SQUARED*delta_days), MAX_RD].min
def initialize(elo=0)
@elo = elo
return self
end
def gamma=(gamma)
@elo = 400.0*Math::log10(gamma)
return self
end
def gamma()
return 10**(@elo/400.0)
end
def kyudan()
return KD_FIVE_KYU + (@elo-FIVE_KYU)/KGS_KYU_TRANSFORM if @elo < FIVE_KYU
return KD_TWO_DAN + (@elo- TWO_DAN)/KGS_DAN_TRANSFORM if @elo > TWO_DAN
return (Math.sqrt(2.0*A*@elo+B**2.0)-B)/A
end
def aga()
r = kyudan()
return r < 0.0 ? r - 1.0 : r + 1.0 # Add the (-1.0,1.0) gap
end
def kyudan=(kyudan)
if kyudan < KD_FIVE_KYU
@elo = (kyudan - KD_FIVE_KYU)*KGS_KYU_TRANSFORM + FIVE_KYU
elsif kyudan > KD_TWO_DAN
@elo = (kyudan - KD_TWO_DAN )*KGS_DAN_TRANSFORM + TWO_DAN
else
player.rd = MAX_RD
@elo = ((A*kyudan+B)**2.0 - B**2.0)/(2.0*A)
end
return player
return self
end
def aga=(aga_rating)
raise WhrError, "Illegal aga_rating #{aga_rating}" unless aga_rating.abs >= 1.0 # Ratings in (-1.0,1.0) are illegal
self.kyudan = aga_rating < 0.0 ? aga_rating + 1.0 : aga_rating - 1.0 # Close the (-1.0,1.0) gap
return self
end
def rank()
r = self.aga
return r < 0.0 ? "%dk" % -r.ceil : "%dd" % r.floor
end
def aga_rank_str()
r = self.aga
return r < 0.0 ?
"%0.1fk" % [(r*10.0).ceil/10.0] :
"%0.1fd" % [(r*10.0).floor/10.0]
end
end


module Glicko
INITIAL_RATING = Rating.new()
DEBUG = false
MAX_RD = 240.0 # maximum rating deviation for new/inactive players
MIN_RD = 70.0 # minimum rating deviation for very active players
RD_DECAY = 2*365 # Number of days for RD to decay from MIN to MAX
C_SQUARED = (MAX_RD**2.0-MIN_RD**2.0)/RD_DECAY
EVEN_KOMI = { "aga" => 7, "jpn" => 6 } # even komi, after doing floor()

class GlickoError < StandardError; end

def self.get_kyudan_rating(player)
return KD_FIVE_KYU + (player.rating-FIVE_KYU)/KGS_KYU_TRANSFORM if player.rating < FIVE_KYU
return KD_TWO_DAN + (player.rating- TWO_DAN)/KGS_DAN_TRANSFORM if player.rating > TWO_DAN
return (Math.sqrt(2.0*A*player.rating+B**2.0)-B)/A
def self.g(rating)
return 1.0/Math.sqrt(1.0 + 3.0*(Rating::Q**2.0)*(rating.rd**2.0)/Math::PI**2.0)
end

def self.get_aga_rating(player)
r = get_kyudan_rating(player)
return r < 0.0 ? r - 1.0 : r + 1.0 # Add the (-1.0,1.0) gap
def self.win_probability(player_r, opp_r, hka=0)
return 1.0 / (1.0 + 10.0**(-g(opp_r)**2.0*(player_r.elo+hka-opp_r.elo)/400.0))
end

def self.set_kyudan_rating(player, kyudan_rating)
if kyudan_rating < KD_FIVE_KYU
r = (kyudan_rating - KD_FIVE_KYU)*KGS_KYU_TRANSFORM + FIVE_KYU
elsif kyudan_rating > KD_TWO_DAN
r = (kyudan_rating - KD_TWO_DAN )*KGS_DAN_TRANSFORM + TWO_DAN
else
r = ((A*kyudan_rating+B)**2.0 - B**2.0)/(2.0*A)
end
player.rating = r
return player
def self.d_squared(player_r, opp_r, hka)
e = win_probability(player_r, opp_r, hka)
return 1.0 / (Rating::Q**2.0 * g(opp_r)**2.0 * e * (1.0-e))
end

def self.set_aga_rating(player, aga_rating)
raise GlickoError, "Illegal aga_rating #{aga_rating}" unless aga_rating.abs >= 1.0 # Ratings in (-1.0,1.0) are illegal
kyudan_rating = aga_rating < 0.0 ? aga_rating + 1.0 : aga_rating - 1.0 # Close the (-1.0,1.0) gap
set_kyudan_rating(player, kyudan_rating)
return player
def self.initial_rd_update(player_r, curr_time)
if player_r.time_last_played
delta_days = (curr_time-player_r.time_last_played).to_f
player_r.rd = [Math.sqrt(player_r.rd**2.0+C_SQUARED*delta_days), MAX_RD].min
else
player_r.rd = MAX_RD
end
return player_r
end

def self.advantage_in_stones(handi, komi, even_komi)
Expand All @@ -115,19 +149,18 @@ def self.advantage_in_stones(handi, komi, even_komi)
return handi + (even_komi-komi)/(even_komi*2.0)
end

def self.handi_komi_advantage(white, black, rules, handi, komi)
def self.advantage_in_elo(white, black, rules, handi, komi)
advantage_in_stones = advantage_in_stones(handi, komi, EVEN_KOMI[rules])
avg_kyudan_rating = (get_kyudan_rating(white) + get_kyudan_rating(black)) / 2.0
# Creating tmp player objects is weird, would be nicer if there was a Rating object
r1 = set_kyudan_rating(Player.new("", nil), avg_kyudan_rating + advantage_in_stones*0.5)
r2 = set_kyudan_rating(Player.new("", nil), avg_kyudan_rating - advantage_in_stones*0.5)
return r1.rating-r2.rating
avg_kyudan_rating = (white.rating.kyudan + black.rating.kyudan) / 2.0
r1 = Rating.new_kyudan(avg_kyudan_rating + advantage_in_stones*0.5)
r2 = Rating.new_kyudan(avg_kyudan_rating - advantage_in_stones*0.5)
return r1.elo-r2.elo
end

def self.rating_to_s(player)
p_min = Player.new("", player.rating - player.rd*2)
p_max = Player.new("", player.rating + player.rd*2)
return "%5.0f [+-%3.0f] %6.2f [+-%5.2f]" % [player.rating, (p_max.rating-p_min.rating)/2.0, get_aga_rating(player), (get_aga_rating(p_max)-get_aga_rating(p_min))/2.0]
r_min = Rating.new(player.rating.elo - player.rating.rd*2)
r_max = Rating.new(player.rating.elo + player.rating.rd*2)
return "%5.0f [+-%3.0f] %6.2f [+-%5.2f]" % [player.rating.elo, (r_max.elo-r_min.elo)/2.0, player.rating.aga, (r_max.aga-r_min.aga)/2.0]
end

def self.add_result(input, players)
Expand All @@ -136,49 +169,37 @@ def self.add_result(input, players)
black = players[input[:black_player]]
handi = input[:handicap]
komi = (input[:komi]).floor
hka = handi_komi_advantage(white, black, input[:rules], handi, komi)
hka = advantage_in_elo(white, black, input[:rules], handi, komi)
white_won = input[:winner] == 'W'
print "%sw=%s %sb=%s h=%d k=%d hka=%0.0f " % [white_won ? "+":" ", white.id, white_won ? " ":"+", black.id, handi, komi, hka] if DEBUG
# Initial update on RD based on how long it has been since the player's last game
for player in [white, black] do
initial_rd_update(player, input[:datetime])
initial_rd_update(player.rating, input[:datetime])
end
new_r = {} # Updates must be calculated first, then applied. Temp store updates here.
new_rd = {}
for player, opp, player_won, hka in [[white, black, white_won, -hka], [black, white, !white_won, hka]] do
score = player_won ? 1.0 : 0.0
d_squared = d_squared(player, opp, hka)
e = win_probability(player, opp, hka)
q_term = Q / ((1.0/player.rd**2.0)+1.0/d_squared)
g_term = g(opp)
d_squared = d_squared(player.rating, opp.rating, hka)
e = win_probability(player.rating, opp.rating, hka)
q_term = Rating::Q / ((1.0/player.rating.rd**2.0)+1.0/d_squared)
g_term = g(opp.rating)
s_term = score - e
new_r[player] = player.rating + q_term*g_term*s_term
new_rd[player] = [MIN_RD, Math.sqrt(1.0/((1.0/player.rd**2.0)+1.0/d_squared))].max
new_r[player] = player.rating.elo + q_term*g_term*s_term
new_rd[player] = [MIN_RD, Math.sqrt(1.0/((1.0/player.rating.rd**2.0)+1.0/d_squared))].max
end
# Apply updates
for player in [white, black]
player.rating = new_r[player]
player.rd = new_rd[player]
player.time_last_played = input[:datetime]
print "id=%s rating=%7.2f rd=%6.2f " % [player.id, player.rating, player.rd] if DEBUG
player.rating.elo = new_r[player]
player.rating.rd = new_rd[player]
player.rating.time_last_played = input[:datetime]
print "id=%s rating=%7.2f rd=%6.2f " % [player.id, player.rating.elo, player.rating.rd] if DEBUG
end
print "\n" if DEBUG
end

def self.rank(player)
r = get_aga_rating(player)
return r < 0.0 ? "%dk" % -r.ceil : "%dd" % r.floor
end

def self.aga_rank_str(player)
r = get_aga_rating(player)
return r < 0.0 ?
"%0.1fk" % [(r*10.0).ceil/10.0] :
"%0.1fd" % [(r*10.0).floor/10.0]
end

def self.validate(player)
aga_rating = get_aga_rating(player)
aga_rating = player.rating.aga
raise GlickoError, "Rating less than 35k" if aga_rating <= -36.0
raise GlickoError, "Rating more than 12d" if aga_rating >= 13.0
end
Expand Down

0 comments on commit 2049f77

Please sign in to comment.