forked from conanbatt/OpenKaya
/
glicko.rb
207 lines (189 loc) · 7.63 KB
/
glicko.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# Implementation of the Glicko rating system as described at
# http://www.glicko.net/glicko/glicko.pdf
# With support to transform from the classic Elo/Glicko scale to other scales such as the KGS dan/kyu scale
# Rating scales:
#
# Glicko: Same as the classic Elo scale
#
# Natural: Glicko * Q -- not really used directly here
#
# Gamma: log(Natural) -- not used here at all
# Other algorithms such as Glicko2 and WHR use this scale.
#
# 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,
# because it will work correctly across the dan/kyu boundary
#
# aga: 1.0 for each stone. Gap from (-1.0, 1.0)
# 1.0+epsilon = weakest 1d
# -1.0-epsilon = strongest 1k
# The aga scale is nice for displaying, because you can get the rank by chopping of the decimal portion.
# Be careful of rounding errors (1.99d should not round up to 2.0d)
#
require 'date'
require 'delegate'
require File.expand_path("../system", File.dirname(__FILE__))
# Abstract out what scale the ratings are on
class Rating
attr_accessor :elo, :rd, :time_last_played
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 -- 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
def self.new_aga(aga_rating)
r = Rating.new()
r.aga = aga_rating
return r
end
def self.new_kyudan(kyudan)
r = Rating.new()
r.kyudan = kyudan
return r
end
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 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
@elo = ((A*kyudan+B)**2.0 - B**2.0)/(2.0*A)
end
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.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.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.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.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)
raise GlickoError, "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.advantage_in_elo(white, black, rules, handi, komi)
advantage_in_stones = advantage_in_stones(handi, komi, EVEN_KOMI[rules])
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)
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)
raise GlickoError, "Invalid arguments #{input}" unless input[:white_player] && input[:black_player] && input[:winner] && input[:datetime] && input[:rules] && input[:handicap] && input[:komi]
white = players[input[:white_player]]
black = players[input[:black_player]]
handi = input[:handicap]
komi = (input[:komi]).floor
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.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.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.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.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.validate(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
end