glicko2 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: