icu_ratings 0.2.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/.autotest +8 -0
- data/.gitignore +3 -0
- data/LICENCE +22 -0
- data/README.rdoc +64 -0
- data/Rakefile +44 -0
- data/VERSION.yml +5 -0
- data/lib/icu_ratings/player.rb +264 -0
- data/lib/icu_ratings/result.rb +172 -0
- data/lib/icu_ratings/tournament.rb +114 -0
- data/lib/icu_ratings.rb +6 -0
- data/spec/player_spec.rb +180 -0
- data/spec/result_spec.rb +106 -0
- data/spec/spec_helper.rb +3 -0
- data/spec/tournament_spec.rb +618 -0
- metadata +72 -0
@@ -0,0 +1,114 @@
|
|
1
|
+
module ICU
|
2
|
+
|
3
|
+
=begin rdoc
|
4
|
+
|
5
|
+
== Creating Tournaments
|
6
|
+
|
7
|
+
ICU::RatedTournament object are created directly.
|
8
|
+
|
9
|
+
t = ICU::RatedTournament.new
|
10
|
+
|
11
|
+
They have one optional parameter called _:desc_ (short for description) the value of which can be
|
12
|
+
any object but will, if utilized, typically be the name of the tournament as a string.
|
13
|
+
|
14
|
+
t = ICU::RatedTournament.new(:desc => "Irish Championships 2008")
|
15
|
+
puts t.desc # "Irish Championships 2008"
|
16
|
+
|
17
|
+
== Rating Tournaments
|
18
|
+
|
19
|
+
To rate a tournament, first add the players (see ICU::RatedPlayer for details):
|
20
|
+
|
21
|
+
t.add_player(1, :rating => 2534, :kfactor => 16)
|
22
|
+
# ...
|
23
|
+
|
24
|
+
Then add the results (see ICU::RatedResult for details):
|
25
|
+
|
26
|
+
t.add_result(1, 1, 2, 'W')
|
27
|
+
# ...
|
28
|
+
|
29
|
+
Then rate the tournament by calling the <em>rate!</em> method:
|
30
|
+
|
31
|
+
t.rate!
|
32
|
+
|
33
|
+
Now the results of the rating calculations can be retrieved from the players in the tournement
|
34
|
+
or their results. For example, player 1's new rating would be:
|
35
|
+
|
36
|
+
t.player(1).new_rating
|
37
|
+
|
38
|
+
See ICU::RatedPlayer and ICU::RatedResult for more details.
|
39
|
+
|
40
|
+
== Error Handling
|
41
|
+
|
42
|
+
Some of the above methods have the potential to raise RuntimeError exceptions.
|
43
|
+
In the case of _add_player_ and _add_result_, the use of invalid arguments
|
44
|
+
would cause such an error. Theoretically, the <em>rate!</em> method could also throw an
|
45
|
+
exception if the iterative algorithm it uses to estimate performance ratings
|
46
|
+
of unrated players failed to converge. However, practical experience has shown that
|
47
|
+
this is highly unlikely.
|
48
|
+
|
49
|
+
Since exception throwing is how errors are signalled, you should arrange for them
|
50
|
+
to be caught and handled in some suitable place in your code.
|
51
|
+
|
52
|
+
=end
|
53
|
+
|
54
|
+
class RatedTournament
|
55
|
+
attr_accessor :desc
|
56
|
+
|
57
|
+
# Add a new player to the tournament. Returns the instance of ICU::RatedPlayer created.
|
58
|
+
# See ICU::RatedPlayer for details.
|
59
|
+
def add_player(num, args={})
|
60
|
+
raise "player with number #{num} already exists" if @player[num]
|
61
|
+
@player[num] = ICU::RatedPlayer.new(num, args)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Add a new result to the tournament. Two instances of ICU::RatedResult are
|
65
|
+
# created. One is added to the first player and the other to the second player.
|
66
|
+
# The method returns _nil_. See ICU::RatedResult for details.
|
67
|
+
def add_result(round, player, opponent, score)
|
68
|
+
n1 = player.is_a?(ICU::RatedPlayer) ? player.num : player.to_i
|
69
|
+
n2 = opponent.is_a?(ICU::RatedPlayer) ? opponent.num : opponent.to_i
|
70
|
+
p1 = @player[n1] || raise("no such player number (#{n1})")
|
71
|
+
p2 = @player[n2] || raise("no such player number (#{n2})")
|
72
|
+
r1 = ICU::RatedResult.new(round, p2, score)
|
73
|
+
r2 = ICU::RatedResult.new(round, p1, r1.opponents_score)
|
74
|
+
p1.add_result(r1)
|
75
|
+
p2.add_result(r2)
|
76
|
+
nil
|
77
|
+
end
|
78
|
+
|
79
|
+
# Rate the tournament. Called after all players and results have been added.
|
80
|
+
def rate!
|
81
|
+
performance_ratings
|
82
|
+
players.each { |p| p.rate! }
|
83
|
+
end
|
84
|
+
|
85
|
+
# Return an array of all players, in order of player number.
|
86
|
+
def players
|
87
|
+
@player.keys.sort.map{ |num| @player[num] }
|
88
|
+
end
|
89
|
+
|
90
|
+
# Return a player (ICU::RatedPlayer) given a player number (returns _nil_ if the number is invalid).
|
91
|
+
def player(num)
|
92
|
+
@player[num]
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
# Create a new, empty (no players, no results) tournament.
|
98
|
+
def initialize(opt={})
|
99
|
+
[:desc].each { |atr| self.send("#{atr}=", opt[atr]) unless opt[atr].nil? }
|
100
|
+
@player = Hash.new
|
101
|
+
end
|
102
|
+
|
103
|
+
def performance_ratings
|
104
|
+
@player.values.each { |p| p.init_performance }
|
105
|
+
stable, count = false, 0
|
106
|
+
while !stable && count < 30
|
107
|
+
@player.values.each { |p| p.estimate_performance }
|
108
|
+
stable = @player.values.inject(true) { |ok, p| p.update_performance && ok }
|
109
|
+
count+= 1
|
110
|
+
end
|
111
|
+
raise "performance rating estimation did not converge" unless stable
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
data/lib/icu_ratings.rb
ADDED
data/spec/player_spec.rb
ADDED
@@ -0,0 +1,180 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper'
|
2
|
+
|
3
|
+
module ICU
|
4
|
+
describe RatedPlayer do
|
5
|
+
context "#new - different types of players" do
|
6
|
+
before(:all) do
|
7
|
+
@r = ICU::RatedPlayer.new(1, :rating => 2000, :kfactor => 10.0)
|
8
|
+
@p = ICU::RatedPlayer.new(2, :rating => 1500, :games => 10)
|
9
|
+
@f = ICU::RatedPlayer.new(3, :rating => 2500)
|
10
|
+
@u = ICU::RatedPlayer.new(4)
|
11
|
+
end
|
12
|
+
|
13
|
+
it "rated players have a rating and k-factor" do
|
14
|
+
@r.num.should == 1
|
15
|
+
@r.rating.should == 2000
|
16
|
+
@r.kfactor.should == 10.0
|
17
|
+
@r.games.should be_nil
|
18
|
+
@r.type.should == :rated
|
19
|
+
@r.full_rating?.should be_true
|
20
|
+
end
|
21
|
+
|
22
|
+
it "provisionally rated players have a rating and number of games" do
|
23
|
+
@p.num.should == 2
|
24
|
+
@p.rating.should == 1500
|
25
|
+
@p.kfactor.should be_nil
|
26
|
+
@p.games.should == 10
|
27
|
+
@p.type.should == :provisional
|
28
|
+
@p.full_rating?.should be_false
|
29
|
+
end
|
30
|
+
|
31
|
+
it "foreign players just have a rating" do
|
32
|
+
@f.num.should == 3
|
33
|
+
@f.rating.should == 2500
|
34
|
+
@f.kfactor.should be_nil
|
35
|
+
@f.games.should be_nil
|
36
|
+
@f.type.should == :foreign
|
37
|
+
@f.full_rating?.should be_true
|
38
|
+
end
|
39
|
+
|
40
|
+
it "unrated players just have nothing other than their number" do
|
41
|
+
@u.num.should == 4
|
42
|
+
@u.rating.should be_nil
|
43
|
+
@u.kfactor.should be_nil
|
44
|
+
@u.games.should be_nil
|
45
|
+
@u.type.should == :unrated
|
46
|
+
@u.full_rating?.should be_false
|
47
|
+
end
|
48
|
+
|
49
|
+
it "other combinations are invalid" do
|
50
|
+
[
|
51
|
+
{ :games => 10 },
|
52
|
+
{ :games => 10, :kfactor => 10 },
|
53
|
+
{ :games => 10, :kfactor => 10, :rating => 1000 },
|
54
|
+
{ :kfactor => 10 },
|
55
|
+
].each { |opts| lambda { ICU::RatedPlayer.new(1, opts) }.should raise_error(/invalid.*combination/i) }
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
context "#new - miscellaneous" do
|
60
|
+
it "attribute values can be given by strings, even when space padded" do
|
61
|
+
p = ICU::RatedPlayer.new(' 1 ', :kfactor => ' 10.0 ', :rating => ' 1000 ')
|
62
|
+
p.num.should == 1
|
63
|
+
p.kfactor.should == 10.0
|
64
|
+
p.rating.should == 1000
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
context "restrictions, or lack thereof, on attributes" do
|
69
|
+
it "the player number can be zero or even negative" do
|
70
|
+
lambda { ICU::RatedPlayer.new(-1) }.should_not raise_error
|
71
|
+
lambda { ICU::RatedPlayer.new(0) }.should_not raise_error
|
72
|
+
end
|
73
|
+
|
74
|
+
it "k-factors must be positive" do
|
75
|
+
lambda { ICU::RatedPlayer.new(1, :kfactor => 0) }.should raise_error(/invalid.*factor/i)
|
76
|
+
lambda { ICU::RatedPlayer.new(1, :kfactor => -1) }.should raise_error(/invalid.*factor/i)
|
77
|
+
end
|
78
|
+
|
79
|
+
it "the rating can be zero or even negative" do
|
80
|
+
lambda { ICU::RatedPlayer.new(1, :rating => 0) }.should_not raise_error
|
81
|
+
lambda { ICU::RatedPlayer.new(1, :rating => -1) }.should_not raise_error
|
82
|
+
end
|
83
|
+
|
84
|
+
it "ratings are stored as floats but can be specified with an integer" do
|
85
|
+
ICU::RatedPlayer.new(1, :rating => 1234.5).rating.should == 1234.5
|
86
|
+
ICU::RatedPlayer.new(1, :rating => 1234.0).rating.should == 1234
|
87
|
+
ICU::RatedPlayer.new(1, :rating => 1234).rating.should == 1234
|
88
|
+
end
|
89
|
+
|
90
|
+
it "the number of games shoud not exceed 20" do
|
91
|
+
lambda { ICU::RatedPlayer.new(1, :rating => 1000, :games => 19) }.should_not raise_error
|
92
|
+
lambda { ICU::RatedPlayer.new(1, :rating => 1000, :games => 20) }.should raise_error
|
93
|
+
lambda { ICU::RatedPlayer.new(1, :rating => 1000, :games => 21) }.should raise_error
|
94
|
+
end
|
95
|
+
|
96
|
+
it "a description, such as a name, but can be any object, is optional" do
|
97
|
+
ICU::RatedPlayer.new(1, :desc => 'Fischer, Robert').desc.should == 'Fischer, Robert'
|
98
|
+
ICU::RatedPlayer.new(1, :desc => 1).desc.should be_an_instance_of(Fixnum)
|
99
|
+
ICU::RatedPlayer.new(1, :desc => 1.0).desc.should be_an_instance_of(Float)
|
100
|
+
ICU::RatedPlayer.new(1).desc.should be_nil
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
context "results" do
|
105
|
+
before(:each) do
|
106
|
+
@p = ICU::RatedPlayer.new(1, :kfactor => 10, :rating => 1000)
|
107
|
+
@r1 = ICU::RatedResult.new(1, ICU::RatedPlayer.new(2), 'W')
|
108
|
+
@r2 = ICU::RatedResult.new(2, ICU::RatedPlayer.new(3), 'L')
|
109
|
+
@r3 = ICU::RatedResult.new(3, ICU::RatedPlayer.new(4), 'D')
|
110
|
+
end
|
111
|
+
|
112
|
+
it "should be returned in round order" do
|
113
|
+
@p.add_result(@r2)
|
114
|
+
@p.results.size.should == 1
|
115
|
+
@p.results[0].should == @r2
|
116
|
+
@p.add_result(@r3)
|
117
|
+
@p.results.size.should == 2
|
118
|
+
@p.results[0].should == @r2
|
119
|
+
@p.results[1].should == @r3
|
120
|
+
@p.add_result(@r1)
|
121
|
+
@p.results.size.should == 3
|
122
|
+
@p.results[0].should == @r1
|
123
|
+
@p.results[1].should == @r2
|
124
|
+
@p.results[2].should == @r3
|
125
|
+
end
|
126
|
+
|
127
|
+
it "the total score should stay consistent with results as they are added" do
|
128
|
+
@p.score.should == 0.0
|
129
|
+
@p.add_result(@r1)
|
130
|
+
@p.score.should == 1.0
|
131
|
+
@p.add_result(@r2)
|
132
|
+
@p.score.should == 1.0
|
133
|
+
@p.add_result(@r3)
|
134
|
+
@p.score.should == 1.5
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
context "Rdoc examples" do
|
139
|
+
before(:each) do
|
140
|
+
@t = ICU::RatedTournament.new
|
141
|
+
@t.add_player(1)
|
142
|
+
end
|
143
|
+
|
144
|
+
it "the same player number can't be added twice" do
|
145
|
+
lambda { @t.add_player(2) }.should_not raise_error
|
146
|
+
lambda { @t.add_player(2) }.should raise_error
|
147
|
+
end
|
148
|
+
|
149
|
+
it "parameters can be specified using strings, even with whitespace padding" do
|
150
|
+
p = @t.add_player(" 0 ", :rating => " 2000.5 ", :kfactor => " 20.5 ")
|
151
|
+
p.num.should == 0
|
152
|
+
p.num.should be_an_instance_of(Fixnum)
|
153
|
+
p.rating.should == 2000.5
|
154
|
+
p.rating.should be_an_instance_of(Float)
|
155
|
+
p.kfactor.should == 20.5
|
156
|
+
p.kfactor.should be_an_instance_of(Float)
|
157
|
+
p = @t.add_player(" -1 ", :rating => " 2000.5 ", :games => " 15 ")
|
158
|
+
p.games.should == 15
|
159
|
+
p.games.should be_an_instance_of(Fixnum)
|
160
|
+
end
|
161
|
+
|
162
|
+
it "the games parameter should not exceed 20" do
|
163
|
+
lambda { @t.add_player(2, :rating => 1500, :games => 20 ) }.should raise_error
|
164
|
+
end
|
165
|
+
|
166
|
+
it "adding different player types" do
|
167
|
+
p = @t.add_player(3, :rating => 2000, :kfactor => 16)
|
168
|
+
p.type.should == :rated
|
169
|
+
p = @t.add_player(4, :rating => 1600, :games => 10)
|
170
|
+
p.type.should == :provisional
|
171
|
+
p = @t.add_player(5)
|
172
|
+
p.type.should == :unrated
|
173
|
+
p = @t.add_player(6, :rating => 2500)
|
174
|
+
p.type.should == :foreign
|
175
|
+
lambda { @t.add_player(7, :rating => 2000, :kfactor => 16, :games => 10) }.should raise_error
|
176
|
+
lambda { t.add_plater(7, :kfactor => 16) }.should raise_error
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
data/spec/result_spec.rb
ADDED
@@ -0,0 +1,106 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper'
|
2
|
+
|
3
|
+
module ICU
|
4
|
+
describe RatedResult do
|
5
|
+
context "a basic rated result" do
|
6
|
+
before(:all) do
|
7
|
+
@o = ICU::RatedPlayer.new(2)
|
8
|
+
end
|
9
|
+
|
10
|
+
it "needs a round, opponent and score (win, loss or draw)" do
|
11
|
+
r = ICU::RatedResult.new(1, @o, 'W')
|
12
|
+
r.round.should == 1
|
13
|
+
r.opponent.should be_an_instance_of(ICU::RatedPlayer)
|
14
|
+
r.score.should == 1.0
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
context "restrictions, or lack thereof, on attributes" do
|
19
|
+
before(:each) do
|
20
|
+
@p = ICU::RatedPlayer.new(2)
|
21
|
+
end
|
22
|
+
|
23
|
+
it "round numbers must be positive" do
|
24
|
+
lambda { ICU::RatedResult.new(0, 1, 'W') }.should raise_error(/invalid.*round number/i)
|
25
|
+
lambda { ICU::RatedResult.new(-1, 1, 'W') }.should raise_error(/invalid.*round number/i)
|
26
|
+
end
|
27
|
+
|
28
|
+
it "the opponent must be an object, not a number" do
|
29
|
+
lambda { ICU::RatedResult.new(1, 0, 'W') }.should raise_error(/invalid.*class.*Fixnum/)
|
30
|
+
lambda { ICU::RatedResult.new(1, @p, 'W') }.should_not raise_error
|
31
|
+
end
|
32
|
+
|
33
|
+
it "the score can be any of the usual suspects" do
|
34
|
+
['W', 'w', 1, 1.0].each { |r| ICU::RatedResult.new(1, @p, r).score.should == 1.0 }
|
35
|
+
['L', 'l', 0, 0.0].each { |r| ICU::RatedResult.new(1, @p, r).score.should == 0.0 }
|
36
|
+
['D', 'd', '½', 0.5].each { |r| ICU::RatedResult.new(1, @p, r).score.should == 0.5 }
|
37
|
+
lambda { ICU::RatedResult.new(1, @p, '') }.should raise_error(/invalid.*score/)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
context "#opponents_score" do
|
42
|
+
before(:each) do
|
43
|
+
@p = ICU::RatedPlayer.new(2)
|
44
|
+
end
|
45
|
+
|
46
|
+
it "should give the score from the opponent's perspective" do
|
47
|
+
ICU::RatedResult.new(1, @p, 'W').opponents_score.should == 0.0
|
48
|
+
ICU::RatedResult.new(1, @p, 'L').opponents_score.should == 1.0
|
49
|
+
ICU::RatedResult.new(1, @p, 'D').opponents_score.should == 0.5
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
context "equality" do
|
54
|
+
before(:each) do
|
55
|
+
@p1 = ICU::RatedPlayer.new(1)
|
56
|
+
@p2 = ICU::RatedPlayer.new(2)
|
57
|
+
@r1 = ICU::RatedResult.new(1, @p1, 'W')
|
58
|
+
@r2 = ICU::RatedResult.new(1, @p1, 'W')
|
59
|
+
@r3 = ICU::RatedResult.new(2, @p1, 'W')
|
60
|
+
@r4 = ICU::RatedResult.new(1, @p2, 'W')
|
61
|
+
@r5 = ICU::RatedResult.new(1, @p1, 'L')
|
62
|
+
end
|
63
|
+
|
64
|
+
it "should return true only if all attributes match" do
|
65
|
+
(@r1 == @r2).should be_true
|
66
|
+
(@r1 == @r3).should be_false
|
67
|
+
(@r1 == @r4).should be_false
|
68
|
+
(@r1 == @r5).should be_false
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
context "Rdoc examples" do
|
73
|
+
before(:each) do
|
74
|
+
@t = ICU::RatedTournament.new
|
75
|
+
@t.add_player(10)
|
76
|
+
@t.add_player(20)
|
77
|
+
@t.add_player(30)
|
78
|
+
@t.add_result(1, 10, 20, 'W')
|
79
|
+
[0,1,2,3,4].each { |num| @t.add_player(num) }
|
80
|
+
[3,1].each { |rnd| @t.add_result(rnd, 0, rnd, 'W') }
|
81
|
+
[4,2].each { |rnd| @t.add_result(rnd, 0, rnd, 'L') }
|
82
|
+
end
|
83
|
+
|
84
|
+
it "it is OK but unnecessary to add the same result from the other players perspective" do
|
85
|
+
@t.player(10).results.size.should == 1
|
86
|
+
@t.player(20).results.size.should == 1
|
87
|
+
lambda { @t.add_result(1, 20, 10, 'L') }.should_not raise_error
|
88
|
+
@t.player(10).results.size.should == 1
|
89
|
+
@t.player(20).results.size.should == 1
|
90
|
+
end
|
91
|
+
|
92
|
+
it "adding results against other players in the same round will cause an exception" do
|
93
|
+
lambda { @t.add_result(1, 10, 30, 'W') }.should raise_error(/inconsistent/i)
|
94
|
+
lambda { @t.add_result(1, 10, 20, 'L') }.should raise_error(/inconsistent/i)
|
95
|
+
end
|
96
|
+
|
97
|
+
it "a player cannot have a result against himself/herself" do
|
98
|
+
lambda { @t.add_result(2, 10, 10, 'D') }.should raise_error(/players.*cannot.*sel[fv]/i)
|
99
|
+
end
|
100
|
+
|
101
|
+
it "results are returned in score order irrespecive of the order they're added in" do
|
102
|
+
@t.player(0).results.map{ |r| r.round }.join(',').should == "1,2,3,4"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
data/spec/spec_helper.rb
ADDED