glicko2 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -28,15 +28,11 @@ Rating = Struct.new(:rating, :rating_deviation, :volatility)
28
28
  rating1 = Rating.new(1400, 30, 0.06)
29
29
  rating2 = Rating.new(1550, 100, 0.06)
30
30
 
31
- # Create players based on Glicko ratings
32
- player1 = Glicko2::Player.from_obj(rating1)
33
- player2 = Glicko2::Player.from_obj(rating2)
31
+ # Rating period with all participating ratings
32
+ period = Glicko2::RatingPeriod.from_objs [rating1, rating2]
34
33
 
35
- # Rating period with all participating players
36
- period = Glicko2::RatingPeriod.new [player1, player2]
37
-
38
- # Register a game in this rating period
39
- period.game([player1, player2], [1,2])
34
+ # Register a game where rating1 wins against rating2
35
+ period.game([rating1, rating2], [1,2])
40
36
 
41
37
  # Generate the next rating period with updated players
42
38
  next_period = period.generate_next
@@ -1,119 +1,43 @@
1
1
  require "glicko2/version"
2
+ require "glicko2/player"
3
+ require "glicko2/rating_period"
2
4
 
3
5
  module Glicko2
4
- TOLERANCE = 0.0000001
5
6
  DEFAULT_VOLATILITY = 0.06
6
7
  DEFAULT_GLICKO_RATING = 1500.0
7
8
  DEFAULT_GLICKO_RATING_DEVIATION = 350.0
8
9
 
9
- GLICKO_GRADIENT = 173.7178
10
- GLICKO_INTERCEPT = DEFAULT_GLICKO_RATING
11
-
12
10
  VOLATILITY_CHANGE = 0.5
13
11
 
14
- class Player
15
- attr_reader :mean, :sd, :volatility, :obj
16
-
17
- def self.from_obj(obj)
18
- mean = (obj.rating - GLICKO_INTERCEPT) / GLICKO_GRADIENT
19
- sd = obj.rating_deviation / GLICKO_GRADIENT
20
- new(mean, sd, obj.volatility, obj)
21
- end
22
-
23
- def initialize(mean, sd, volatility, obj)
24
- @mean = mean
25
- @sd = sd
26
- @volatility = volatility
27
- @obj = obj
28
- end
29
-
30
- def g
31
- 1 / Math.sqrt(1 + 3 * sd ** 2 / Math::PI ** 2)
32
- end
33
-
34
- def e(other)
35
- 1 / (1 + Math.exp(-other.g * (mean - other.mean)))
36
- end
37
-
38
- def variance(others)
39
- return 0.0 if others.length < 1
40
- others.reduce(0) do |v, other|
41
- v + other.g ** 2 * e(other) * (1 - e(other))
42
- end ** -1
43
- end
44
-
45
- def delta(others, scores)
46
- others.zip(scores).reduce(0) do |d, (other, score)|
47
- d + other.g * (score - e(other))
48
- end * variance(others)
49
- end
50
-
51
- def f_part1(x, others, scores)
52
- sd_sq = sd ** 2
53
- v = variance(others)
54
- _x = Math.exp(x)
55
- (_x * (delta(others, scores) ** 2 - sd_sq - v - _x)) / (2 * (sd_sq + v + _x) ** 2)
56
- end
57
-
58
- def f_part2(x)
59
- (x - Math::log(volatility ** 2)) / VOLATILITY_CHANGE ** 2
60
- end
61
-
62
- def f(x, others, scores)
63
- f_part1(x, others, scores) - f_part2(x)
64
- end
65
-
66
- def generate_next(others, scores)
67
- if others.length < 1
68
- sd_pre = Math.sqrt(sd ** 2 + volatility ** 2)
69
- return self.class.new(mean, sd_pre, volatility, obj) if others.length < 1
70
- end
71
- _v = variance(others)
72
- a = Math::log(volatility ** 2)
73
- if delta(others, scores) > sd ** 2 + _v
74
- b = Math.log(_delta - sd ** 2 - _v)
75
- else
76
- k = 1
77
- k += 1 while f(a - k * VOLATILITY_CHANGE, others, scores) < 0
78
- b = a - k * VOLATILITY_CHANGE
79
- end
80
- fa = f(a, others, scores)
81
- fb = f(b, others, scores)
82
- while (b - a).abs > TOLERANCE
83
- c = a + (a - b) * fa / (fb - fa)
84
- fc = f(c, others, scores)
85
- if fc * fb < 0
86
- a = b
87
- fa = fb
88
- else
89
- fa /= 2.0
90
- end
91
- b = c
92
- fb = fc
93
- end
94
- volatility1 = Math.exp(a / 2.0)
95
- sd_pre = Math.sqrt(sd ** 2 + volatility1 ** 2)
96
- sd1 = 1 / Math.sqrt(1 / sd_pre ** 2 + 1 / _v)
97
- mean1 = mean + sd1 ** 2 * others.zip(scores).reduce(0) {|x, (other, score)| x + other.g * (score - e(other)) }
98
- self.class.new(mean1, sd1, volatility1, obj)
99
- end
100
-
101
- def update_obj
102
- @obj.rating = GLICKO_GRADIENT * mean + GLICKO_INTERCEPT
103
- @obj.rating_deviation = GLICKO_GRADIENT * sd
104
- @obj.volatility = volatility
105
- end
106
-
107
- def to_s
108
- "#<Player mean=#{mean}, sd=#{sd}, volatility=#{volatility}, obj=#{obj}>"
109
- end
110
- end
111
-
112
- class RatingPeriod
113
- def initialize(players)
114
- @players = players.reduce({}) { |memo, player| memo[player] = []; memo }
115
- end
116
-
12
+ # Collection of helper methods
13
+ class Util
14
+ GLICKO_GRADIENT = 173.7178
15
+ GLICKO_INTERCEPT = DEFAULT_GLICKO_RATING
16
+
17
+ # Convert from the original Glicko scale to Glicko2 scale
18
+ #
19
+ # @param [Numeric] r Glicko rating
20
+ # @param [Numeric] rd Glicko rating deviation
21
+ # @return [Array<Numeric>]
22
+ def self.to_glicko2(r, rd)
23
+ [(r - GLICKO_INTERCEPT) / GLICKO_GRADIENT, rd / GLICKO_GRADIENT]
24
+ end
25
+
26
+ # Convert from the Glicko2 scale to the original Glicko scale
27
+ #
28
+ # @param [Numeric] m Glicko2 mean
29
+ # @param [Numeric] sd Glicko2 standard deviation
30
+ # @return [Array<Numeric>]
31
+ def self.to_glicko(m, sd)
32
+ [GLICKO_GRADIENT * m + GLICKO_INTERCEPT, GLICKO_GRADIENT * sd]
33
+ end
34
+
35
+ # Convert from a rank, where lower numbers win against higher numbers,
36
+ # into Glicko scores where wins are `1`, draws are `0.5` and losses are `0`.
37
+ #
38
+ # @param [Integer] rank players rank
39
+ # @param [Integer] other opponents rank
40
+ # @return [Numeric] Glicko score
117
41
  def self.ranks_to_score(rank, other)
118
42
  if rank < other
119
43
  1.0
@@ -123,31 +47,5 @@ module Glicko2
123
47
  0.0
124
48
  end
125
49
  end
126
-
127
- def game(game_players, ranks)
128
- game_players.zip(ranks).each do |player, rank|
129
- game_players.zip(ranks).each do |other, other_rank|
130
- next if player == other
131
- @players[player] << [other, self.class.ranks_to_score(rank, other_rank)]
132
- end
133
- end
134
- end
135
-
136
- def generate_next
137
- p = []
138
- @players.each do |player, games|
139
- p << player.generate_next(*games.transpose)
140
- end
141
- self.class.new(p)
142
- end
143
-
144
- def players
145
- @players.keys
146
- end
147
-
148
- def to_s
149
- "#<RatingPeriod players=#{@players.keys}"
150
- end
151
50
  end
152
-
153
51
  end
@@ -0,0 +1,189 @@
1
+ module Glicko2
2
+ # Calculates a new Glicko2 ranking based on a seed object and game outcomes.
3
+ #
4
+ # The example from the Glicko2 paper, where a player wins against the first
5
+ # opponent, but then looses against the next two:
6
+ #
7
+ # Rating = Struct.new(:rating, :rating_deviation, :volatility)
8
+ #
9
+ # player_seed = Rating.new(1500, 200, 0.06)
10
+ # opponent1_seed = Rating.new(1400, 30, 0.06)
11
+ # opponent2_seed = Rating.new(1550, 100, 0.06)
12
+ # opponent3_seed = Rating.new(1700, 300, 0.06)
13
+ #
14
+ # player = Glicko2::Player.from_obj(player_seed)
15
+ # opponent1 = Glicko2::Player.from_obj(opponent1_seed)
16
+ # opponent2 = Glicko2::Player.from_obj(opponent2_seed)
17
+ # opponent3 = Glicko2::Player.from_obj(opponent3_seed)
18
+ #
19
+ # new_player = player.generate_next([opponent1, opponent2, opponent3],
20
+ # [1, 0, 0])
21
+ # new_player.update_obj
22
+ #
23
+ # puts player_seed
24
+ #
25
+ class Player
26
+ TOLERANCE = 0.0000001
27
+
28
+ attr_reader :mean, :sd, :volatility, :obj
29
+
30
+ # Create a {Player} from a seed object, converting from Glicko
31
+ # ratings to Glicko2.
32
+ #
33
+ # @param [#rating,#rating_deviation,#volatility] obj seed values object
34
+ # @return [Player] constructed instance.
35
+ def self.from_obj(obj)
36
+ mean, sd = Util.to_glicko2(obj.rating, obj.rating_deviation)
37
+ new(mean, sd, obj.volatility, obj)
38
+ end
39
+
40
+ # @param [Numeric] mean player mean
41
+ # @param [Numeric] sd player standard deviation
42
+ # @param [Numeric] volatility player volatility
43
+ # @param [#rating,#rating_deviation,#volatility] obj seed values object
44
+ def initialize(mean, sd, volatility, obj=nil)
45
+ @mean = mean
46
+ @sd = sd
47
+ @volatility = volatility
48
+ @obj = obj
49
+ @e = {}
50
+ end
51
+
52
+ # Calculate `g(phi)` as defined in the Glicko2 paper
53
+ #
54
+ # @return [Numeric]
55
+ def g
56
+ @g ||= 1 / Math.sqrt(1 + 3 * sd ** 2 / Math::PI ** 2)
57
+ end
58
+
59
+ # Calculate `E(mu, mu_j, phi_j)` as defined in the Glicko2 paper
60
+ #
61
+ # @param [Player] other the `j` player
62
+ # @return [Numeric]
63
+ def e(other)
64
+ @e[other] ||= 1 / (1 + Math.exp(-other.g * (mean - other.mean)))
65
+ end
66
+
67
+ # Calculate the estimated variance of the team's/player's rating based only
68
+ # on the game outcomes.
69
+ #
70
+ # @param [Array<Player>] others other participating players.
71
+ # @return [Numeric]
72
+ def variance(others)
73
+ return 0.0 if others.length < 1
74
+ others.reduce(0) do |v, other|
75
+ e_other = e(other)
76
+ v + other.g ** 2 * e_other * (1 - e_other)
77
+ end ** -1
78
+ end
79
+
80
+ # Calculate the estimated improvement in rating by comparing the
81
+ # pre-period rating to the performance rating based only on game outcomes.
82
+ #
83
+ # @param [Array<Player>] others list of opponent players
84
+ # @param [Array<Numeric>] scores list of correlating scores (`0` for a loss,
85
+ # `0.5` for a draw and `1` for a win).
86
+ # @return [Numeric]
87
+ def delta(others, scores)
88
+ others.zip(scores).reduce(0) do |d, (other, score)|
89
+ d + other.g * (score - e(other))
90
+ end * variance(others)
91
+ end
92
+
93
+ # Calculate `f(x)` as defined in the Glicko2 paper
94
+ #
95
+ # @param [Numeric] x
96
+ # @param [Numeric] d the result of calculating {#delta}
97
+ # @param [Numeric] v the result of calculating {#variance}
98
+ # @return [Numeric]
99
+ def f(x, d, v)
100
+ f_part1(x, d, v) - f_part2(x)
101
+ end
102
+
103
+ # Calculate the new value of the volatility
104
+ #
105
+ # @param [Numeric] d the result of calculating {#delta}
106
+ # @param [Numeric] v the result of calculating {#variance}
107
+ # @return [Numeric]
108
+ def volatility1(d, v)
109
+ a = Math::log(volatility ** 2)
110
+ if d > sd ** 2 + v
111
+ b = Math.log(d - sd ** 2 - v)
112
+ else
113
+ k = 1
114
+ k += 1 while f(a - k * VOLATILITY_CHANGE, d, v) < 0
115
+ b = a - k * VOLATILITY_CHANGE
116
+ end
117
+ fa = f(a, d, v)
118
+ fb = f(b, d, v)
119
+ while (b - a).abs > TOLERANCE
120
+ c = a + (a - b) * fa / (fb - fa)
121
+ fc = f(c, d, v)
122
+ if fc * fb < 0
123
+ a = b
124
+ fa = fb
125
+ else
126
+ fa /= 2.0
127
+ end
128
+ b = c
129
+ fb = fc
130
+ end
131
+ Math.exp(a / 2.0)
132
+ end
133
+
134
+ # Create new {Player} with updated values.
135
+ #
136
+ # This method will not modify any objects that are passed into it.
137
+ #
138
+ # @param [Array<Player>] others list of opponent players
139
+ # @param [Array<Numeric>] scores list of correlating scores (`0` for a loss,
140
+ # `0.5` for a draw and `1` for a win).
141
+ # @return [Player]
142
+ def generate_next(others, scores)
143
+ if others.length < 1
144
+ generate_next_without_games
145
+ else
146
+ generate_next_with_games(others, scores)
147
+ end
148
+ end
149
+
150
+ # Update seed object with this player's values
151
+ def update_obj
152
+ @obj.rating, @obj.rating_deviation = Util.to_glicko(mean, sd)
153
+ @obj.volatility = volatility
154
+ end
155
+
156
+ def to_s
157
+ "#<Player mean=#{mean}, sd=#{sd}, volatility=#{volatility}, obj=#{obj}>"
158
+ end
159
+
160
+ private
161
+
162
+ def generate_next_without_games
163
+ sd_pre = Math.sqrt(sd ** 2 + volatility ** 2)
164
+ self.class.new(mean, sd_pre, volatility, obj)
165
+ end
166
+
167
+ def generate_next_with_games(others, scores)
168
+ _v = variance(others)
169
+ _d = delta(others, scores)
170
+ _volatility = volatility1(_d, _v)
171
+ sd_pre = Math.sqrt(sd ** 2 + _volatility ** 2)
172
+ _sd = 1 / Math.sqrt(1 / sd_pre ** 2 + 1 / _v)
173
+ _mean = mean + _sd ** 2 * others.zip(scores).reduce(0) {
174
+ |x, (other, score)| x + other.g * (score - e(other))
175
+ }
176
+ self.class.new(_mean, _sd, _volatility, obj)
177
+ end
178
+
179
+ def f_part1(x, d, v)
180
+ exp_x = Math.exp(x)
181
+ sd_sq = sd ** 2
182
+ (exp_x * (d ** 2 - sd_sq - v - exp_x)) / (2 * (sd_sq + v + exp_x) ** 2)
183
+ end
184
+
185
+ def f_part2(x)
186
+ (x - Math::log(volatility ** 2)) / VOLATILITY_CHANGE ** 2
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,66 @@
1
+ module Glicko2
2
+ # Glicko ratings are calculated in bulk at the end of arbitrary, but fixed
3
+ # length, periods named rating periods. Where a period is fixed to be long
4
+ # enough that the average number of games that each player has played in is
5
+ # about 5 to 10 games. It could be weekly, monthly or more as required.
6
+ class RatingPeriod
7
+ attr_reader :players
8
+
9
+ # @param [Array<Player>] players
10
+ def initialize(players)
11
+ @players = players
12
+ @games = Hash.new { |h, k| h[k] = [] }
13
+ @cache = players.reduce({}) { |memo, player| memo[player.obj] = player; memo }
14
+ end
15
+
16
+ # Create rating period from list of seed objects
17
+ #
18
+ # @param [Array<#rating,#rating_deviation,#volatility>] objs seed value objects
19
+ # @return [RatingPeriod]
20
+ def self.from_objs(objs)
21
+ new(objs.map { |obj| Player.from_obj(obj) })
22
+ end
23
+
24
+ # Register a game with this rating period
25
+ #
26
+ # @param [Array<#rating,#rating_deviation,#volatility>] game_seeds ratings participating in a game
27
+ # @param [Array<Integer>] ranks corresponding ranks
28
+ def game(game_seeds, ranks)
29
+ game_seeds.zip(ranks).each do |seed, rank|
30
+ game_seeds.zip(ranks).each do |other, other_rank|
31
+ next if seed == other
32
+ @games[player(seed)] << [player(other),
33
+ Util.ranks_to_score(rank, other_rank)]
34
+ end
35
+ end
36
+ end
37
+
38
+ # Generate a new {RatingPeriod} with a new list of updated {Player}
39
+ #
40
+ # @return [RatingPeriod]
41
+ def generate_next
42
+ p = []
43
+ @players.each do |player|
44
+ games = @games[player]
45
+ if games.length > 0
46
+ p << player.generate_next(*games.transpose)
47
+ else
48
+ p << player.generate_next([], [])
49
+ end
50
+ end
51
+ self.class.new(p)
52
+ end
53
+
54
+ # Fetch the player associated with a seed object
55
+ #
56
+ # @param [#rating,#rating_deviation,#volatility] obj seed object
57
+ # @return [Player]
58
+ def player(obj)
59
+ @cache[obj]
60
+ end
61
+
62
+ def to_s
63
+ "#<RatingPeriod players=#{@players}"
64
+ end
65
+ end
66
+ end
@@ -1,3 +1,3 @@
1
1
  module Glicko2
2
- VERSION = "0.0.1"
2
+ VERSION = "0.1.0"
3
3
  end
@@ -1,2 +1,3 @@
1
+ require 'minitest/benchmark'
1
2
  require 'minitest/autorun'
2
3
  require 'glicko2'
@@ -92,6 +92,10 @@ describe Glicko2::Player do
92
92
  p.volatility.must_equal @player.volatility
93
93
  p.sd.must_be_close_to Math.sqrt(@player.sd ** 2 + @player.volatility ** 2)
94
94
  end
95
+
96
+ bench_performance_linear "default" do |n|
97
+ @player.generate_next(@others * n, @scores * n)
98
+ end
95
99
  end
96
100
 
97
101
  describe "#update_obj" do
@@ -2,47 +2,36 @@ require 'minitest_helper'
2
2
 
3
3
  describe Glicko2::RatingPeriod do
4
4
  before do
5
- @player = Glicko2::Player.from_obj(Rating.new(1500, 200, 0.06))
6
- @player1 = Glicko2::Player.from_obj(Rating.new(1400, 30, 0.06))
7
- @player2 = Glicko2::Player.from_obj(Rating.new(1550, 100, 0.06))
8
- @player3 = Glicko2::Player.from_obj(Rating.new(1700, 300, 0.06))
5
+ @player = Rating.new(1500, 200, 0.06)
6
+ @player1 = Rating.new(1400, 30, 0.06)
7
+ @player2 = Rating.new(1550, 100, 0.06)
8
+ @player3 = Rating.new(1700, 300, 0.06)
9
9
  @players = [@player, @player1, @player2, @player3]
10
- @period = Glicko2::RatingPeriod.new(@players)
10
+ @period = Glicko2::RatingPeriod.from_objs(@players)
11
11
  end
12
12
 
13
- describe ".new" do
14
- it "must assign players" do
15
- @period.players.must_include @player
16
- @period.players.must_include @player1
17
- @period.players.must_include @player2
18
- @period.players.must_include @player3
19
- end
20
- end
21
-
22
- describe ".ranks_to_score" do
23
- it "must return 1.0 when rank is less" do
24
- Glicko2::RatingPeriod.ranks_to_score(1, 2).must_equal 1.0
25
- end
26
-
27
- it "must return 0.5 when rank is equal" do
28
- Glicko2::RatingPeriod.ranks_to_score(1, 1).must_equal 0.5
29
- end
30
-
31
- it "must return 0.0 when rank is more" do
32
- Glicko2::RatingPeriod.ranks_to_score(2, 1).must_equal 0.0
33
- end
34
- end
35
-
36
- describe "complete rating period" do
13
+ describe "#generate_next" do
37
14
  it "must be close to example" do
38
15
  @period.game([@player, @player1], [1, 2])
39
16
  @period.game([@player, @player2], [2, 1])
40
17
  @period.game([@player, @player3], [2, 1])
41
18
  @period.generate_next.players.each { |p| p.update_obj }
42
- obj = @player.obj
19
+ obj = @player
43
20
  obj.rating.must_be_close_to 1464.06, 0.01
44
21
  obj.rating_deviation.must_be_close_to 151.52, 0.01
45
22
  obj.volatility.must_be_close_to 0.05999, 0.00001
46
23
  end
24
+
25
+ it "must process non-competing players" do
26
+ @period.game([@player, @player1], [1, 2])
27
+ @period.generate_next
28
+ end
29
+
30
+ bench_performance_linear "default" do |n|
31
+ n.times do
32
+ @period.game(@players.sample(2), [1, 2])
33
+ end
34
+ @period.generate_next
35
+ end
47
36
  end
48
37
  end
@@ -0,0 +1,17 @@
1
+ require 'minitest_helper'
2
+
3
+ describe Glicko2::Util do
4
+ describe ".ranks_to_score" do
5
+ it "must return 1.0 when rank is less" do
6
+ Glicko2::Util.ranks_to_score(1, 2).must_equal 1.0
7
+ end
8
+
9
+ it "must return 0.5 when rank is equal" do
10
+ Glicko2::Util.ranks_to_score(1, 1).must_equal 0.5
11
+ end
12
+
13
+ it "must return 0.0 when rank is more" do
14
+ Glicko2::Util.ranks_to_score(2, 1).must_equal 0.0
15
+ end
16
+ end
17
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: glicko2
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-12-12 00:00:00.000000000 Z
12
+ date: 2012-12-26 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: minitest
@@ -41,10 +41,13 @@ files:
41
41
  - Rakefile
42
42
  - glicko2.gemspec
43
43
  - lib/glicko2.rb
44
+ - lib/glicko2/player.rb
45
+ - lib/glicko2/rating_period.rb
44
46
  - lib/glicko2/version.rb
45
47
  - spec/minitest_helper.rb
46
48
  - spec/player_spec.rb
47
49
  - spec/rating_period_spec.rb
50
+ - spec/util_spec.rb
48
51
  homepage: https://github.com/proglottis/glicko2
49
52
  licenses: []
50
53
  post_install_message:
@@ -73,3 +76,5 @@ test_files:
73
76
  - spec/minitest_helper.rb
74
77
  - spec/player_spec.rb
75
78
  - spec/rating_period_spec.rb
79
+ - spec/util_spec.rb
80
+ has_rdoc: