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.
@@ -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
@@ -0,0 +1,6 @@
1
+ # :enddoc:
2
+
3
+ icu_ratings_files = Array.new
4
+ icu_ratings_files.concat %w{tournament player result}
5
+
6
+ icu_ratings_files.each { |file| require "icu_ratings/#{file}" }
@@ -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
@@ -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
@@ -0,0 +1,3 @@
1
+ require 'rubygems'
2
+ require 'spec'
3
+ require File.dirname(__FILE__) + '/../lib/icu_ratings'