icu_ratings 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/.autotest ADDED
@@ -0,0 +1,8 @@
1
+ #
2
+ # Since the default autotest mappings in gems/rspec-<version>/lib/autotest/rspec.rb
3
+ # assume lib/ and spec/ are structured in the same way, we have to replace them.
4
+ #
5
+ Autotest.add_hook :initialize do |at|
6
+ at.remove_mapping %r%^lib/(.*)\.rb$%
7
+ at.add_mapping(%r%^lib/icu_ratings/(.*)\.rb$%) { |_, m| "spec/#{m[1]}_spec.rb" }
8
+ end
data/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ rdoc
2
+ pkg
3
+ tmp
data/LICENCE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2010 Mark Orr
2
+
3
+ Permission is hereby granted, free of charge, to any person
4
+ obtaining a copy of this software and associated documentation
5
+ files (the "Software"), to deal in the Software without
6
+ restriction, including without limitation the rights to use,
7
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the
9
+ Software is furnished to do so, subject to the following
10
+ conditions:
11
+
12
+ The above copyright notice and this permission notice shall be
13
+ included in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,64 @@
1
+ = ICU Chess Ratings
2
+
3
+ For calculating the Elo ratings of players in a chess tournament. The software is a port
4
+ the Irish Chess Union's existing rating software written in Microsoft Visual Basic and
5
+ intended to replace it in the near future.
6
+
7
+ The rating calculations are identical to FIDE's for tournaments that consist entirely
8
+ of established players (with the exception of player bonuses, which can be turned off).
9
+ However, the ICU has it's own peculiar way of dealing with unrated players (players
10
+ with only a provisional rating or without any prior rating) which is not the same as
11
+ FIDE's.
12
+
13
+ == Install
14
+
15
+ sudo gem install icu_ratings
16
+
17
+ == Usage
18
+
19
+ First, create a new ICU::RatedTournament object:
20
+
21
+ t = ICU::RatedTournament.new(:desc => "Irish Championships 2008")
22
+
23
+ Then add players (see ICU::RatedPlayer for details):
24
+
25
+ t.add_player(1, :rating => 2534, :desc => 'Alexander Baburin (7085)', :kfactor => 16)
26
+ t.add_player(2, :rating => 2525, :desc => 'Alon Greenfeld') # foreign (non-ICU) rated player
27
+ t.add_player(8, :rating => 2084, :desc => 'Anthony Fox (456)', :kfactor => 24)
28
+ # ...
29
+
30
+ Then add results (see ICU::RatedResult for details):
31
+
32
+ t.add_result(1, 1, 8, 'W') # players 1 and 8 played in round 1, player 1 won
33
+ t.add_result(4, 2, 1, 'D') # players 1 and 2 drew in round 4
34
+ # ...
35
+
36
+ Then call the <em>rate!</em> method.
37
+
38
+ t.rate!
39
+
40
+ If no exceptions have been raised yet, the tournament is now rated and the
41
+ main results of the rating calculations can be extracted by querying the
42
+ previously created player objects:
43
+
44
+ (1..32).each do |num|
45
+ player = t.player(num)
46
+ puts "Name: #{t.desc}"
47
+ puts "Score: #{p.score}/#{p.results.size}"
48
+ puts "New Rating: #{p.new_rating.round}"
49
+ puts "Performance Rating: #{p.performance.round}"
50
+ end
51
+
52
+ # Name: Alexander Baburin (7085)
53
+ # Score: 8.0/9
54
+ # New Rating: 2558
55
+ # Performance Rating: 2607
56
+ # ...
57
+
58
+ See ICU::RatedPlayer for further details. Further breakdown of the rating calculations
59
+ are available, if desired, from the results belonging to each player. See ICU::RatedResult
60
+ for more details.
61
+
62
+ == Author
63
+
64
+ Mark Orr, Irish Chess Union (ICU[http://icu.ie]) rating officer.
data/Rakefile ADDED
@@ -0,0 +1,44 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'rake/rdoctask'
4
+ require 'spec/rake/spectask'
5
+
6
+ begin
7
+ require 'jeweler'
8
+ Jeweler::Tasks.new do |gem|
9
+ gem.name = "icu_ratings"
10
+ gem.summary = "For rating chess tournaments."
11
+ gem.description = "Build an object that represents a chess tournament then get it to calculate ratings of all the players."
12
+ gem.homepage = "http://github.com/sanichi/icu_ratings"
13
+ gem.authors = ["Mark Orr"]
14
+ gem.email = "mark.j.l.orr@googlemail.com"
15
+ gem.files = FileList['[A-Z]*', '{lib,spec}/**/*', '.gitignore', '.autotest']
16
+ gem.has_rdoc = true
17
+ gem.rdoc_options = ["--charset", "UTF-8"]
18
+ end
19
+ Jeweler::GemcutterTasks.new
20
+ rescue LoadError
21
+ puts "Jeweler not available. Install it with: sudo gem install jeweler."
22
+ end
23
+
24
+ task :default => :spec
25
+
26
+ Spec::Rake::SpecTask.new(:spec) do |spec|
27
+ spec.libs << 'lib' << 'spec'
28
+ spec.spec_files = FileList['spec/**/*_spec.rb']
29
+ spec.spec_opts = ['--colour --format nested']
30
+ end
31
+
32
+ Rake::RDocTask.new(:rdoc) do |rdoc|
33
+ if File.exist?('VERSION.yml')
34
+ config = YAML.load(File.read('VERSION.yml'))
35
+ version = "#{config[:major]}.#{config[:minor]}.#{config[:patch]}"
36
+ else
37
+ version = ""
38
+ end
39
+
40
+ rdoc.rdoc_dir = 'rdoc'
41
+ rdoc.title = "ICU Ratings #{version}"
42
+ rdoc.rdoc_files.include('README*')
43
+ rdoc.rdoc_files.include('lib/**/*.rb')
44
+ end
data/VERSION.yml ADDED
@@ -0,0 +1,5 @@
1
+ ---
2
+ :patch: 0
3
+ :build:
4
+ :major: 0
5
+ :minor: 2
@@ -0,0 +1,264 @@
1
+ module ICU
2
+
3
+ =begin rdoc
4
+
5
+ == Adding Players to Tournaments
6
+
7
+ You don't directly create players, rather you add them to tournaments with the _add_player_ method.
8
+
9
+ t = ICU::RatedTournament.new
10
+ t.add_player(1)
11
+
12
+ There is only one mandatory parameter - the player number - which can be any integer value
13
+ except player numbers must be unique in each tournament:
14
+
15
+ t.add_player(2) # fine
16
+ t.add_player(2) # attempt to add a second player with the same number - exception!
17
+
18
+ == Retrieving Players from Tournaments
19
+
20
+ Player objects can be retrieved from ICU::RatedTournament objects with the latter's _player_ method
21
+ in conjunction with the appropriate player number:
22
+
23
+ p = t.player(2)
24
+ p.num # 2
25
+
26
+ Or the player object can be saved from the return value from _add_player_:
27
+
28
+ p = t.add_player(-2)
29
+ p.num # -2
30
+
31
+ If the number supplied to _player_ is an invalid player number, the method returns _nil_.
32
+
33
+ Different types of players are signalled by different combinations of the three optional
34
+ parameters: _rating_, _kfactor_ and _games_.
35
+
36
+ == Full Ratings
37
+
38
+ Rated players have a full rating and a K-factor and are added by including valid values for those two parameters:
39
+
40
+ p = t.add_player(3, :rating => 2000, :kfactor => 16)
41
+ p.type # :rated
42
+
43
+ == Provisional Ratings
44
+
45
+ Players that don't yet have a full rating but do have a provisonal rating estimated on some number
46
+ of games played prior to the tournament are indicated by values for the _rating_ and _games_ parameters:
47
+
48
+ p = t.add_player(4, :rating => 1600, :games => 10)
49
+ p.type # :provisional
50
+
51
+ The value for the number of games should not exceed 19 since players with 20 or more games
52
+ should have a full rating.
53
+
54
+ == Fixed Ratings
55
+
56
+ Players with fixed ratings just have a rating - no K-factor or number of previous games.
57
+ When the tournament is rated, these players will have their tournament performance ratings
58
+ calculated but the value returned by the method _new_rating_ will just be the rating they
59
+ started with. Typically these are foreign players with FIDE ratings who are not members of
60
+ the ICU and for whom ICU ratings are not desired.
61
+
62
+ p = t.add_player(6, :rating => 2500)
63
+ p.type # :foreign
64
+
65
+ == No Rating
66
+
67
+ Unrated players who do not have any rated games at all are indicated by leaving out any values for
68
+ _rating_, _kfactor_ or _games_.
69
+
70
+ p = t.add_player(5)
71
+ p.type # :unrated
72
+
73
+ == Invalid Combinations
74
+
75
+ The above four types of players (_rated_, _provisional_, _unrated_, _foreign_) are the only
76
+ valid ones and any attempt to add players with other combinations of the attributes
77
+ _rating_, _kfactor_ and _games_ will cause an exception. For example:
78
+
79
+ t.add_player(7, :rating => 2000, :kfactor => 16, :games => 10) # exception! - cannot have both kfactor and games
80
+ t.add_plater(7, :kfactor => 16) # exception! - kfactor makes no sense without a rating
81
+
82
+ == String Input Values
83
+
84
+ Although _rating_ and _kfactor_ are held as Float values and _games_ and _num_ (the player number) as Fixnums,
85
+ all these parameters can be specified using strings, even when padded with whitespace.
86
+
87
+ p = t.add_player(" 0 ", :rating => " 2000.5 ", :kfactor => " 20.5 ")
88
+ p.num # 0 (Fixnum)
89
+ p.rating # 2000.5 (Float)
90
+ p.kfactor # 20.5 (Float)
91
+
92
+ == Description Parameter
93
+
94
+ There is one other optional parameter, _desc_ (short for "description"). It has no effect on player
95
+ type or rating calculations and it cannot be used to retrieve players from a tournament (only the
96
+ player number can be used for that). Its only use is to attach additional arbitary data to players.
97
+ Any object can be used and descriptions don't have to be unique. The attribute's typical use,
98
+ if it's used at all, is expected to be for player names in the form of String values.
99
+
100
+ t.add_player(8, :rating => 2800, :desc => 'Gary Kasparov (4100018)')
101
+ t.player(8).desc # "Gary Kasparov (4100018)"
102
+
103
+ == After the Tournament is Rated
104
+
105
+ After the <em>rate!</em> method has been called on the ICU::RatedTournament object, the results
106
+ of the rating calculations are available via various methods of the player objects:
107
+ _new_rating_, _rating_change_, _performance_, _expected_score_.
108
+
109
+ == Unrateable Players
110
+
111
+ If a tournament contains groups of provisonal or unrated players who play games
112
+ only amongst themselves and not against any rated or foreign opponents, they can't
113
+ be rated. This is indicated by a value of _nil_ returned from the _new_rating_
114
+ method.
115
+
116
+ =end
117
+
118
+ class RatedPlayer
119
+ attr_reader :num, :rating, :kfactor, :games
120
+ attr_accessor :desc
121
+
122
+ # After the tournament has been rated, this is the player's new rating. For rated players this is the old rating
123
+ # plus the _rating_change_. For provisional players it is their performance rating, including their previous
124
+ # games. For unrated players it is their tournament performance rating. For foreign players it is the same
125
+ # as their start _rating_.
126
+ def new_rating
127
+ full_rating? ? rating + rating_change : performance
128
+ end
129
+
130
+ # After the tournament has been rated, this is the difference between the old and new ratings for
131
+ # rated players, based on sum of expected scores in each games and the player's K-factor.
132
+ # Zero for all other types of players.
133
+ def rating_change
134
+ @results.inject(0.0) { |c, r| c + (r.rating_change || 0.0) }
135
+ end
136
+
137
+ # After the tournament has been rated, this returns the sum of expected scores over all results.
138
+ # Although this is calculated for provisional and unrated players it is not used to estimate their
139
+ # new ratings. For rated players, this number times the K-factor gives the change in rating.
140
+ def expected_score
141
+ @results.inject(0.0) { |e, r| e + (r.expected_score || 0.0) }
142
+ end
143
+
144
+ # After the tournament has been rated, this returns the tournament rating performance for
145
+ # rated, unrated and foreign players. For provisional players it returns a weighted average
146
+ # of the player's tournament performance and their previous games. For provisional and
147
+ # unrated players it is the same as _new_rating_.
148
+ def performance
149
+ @performance
150
+ end
151
+
152
+ # Returns an array of the player's results (ICU::RatedResult) in round order.
153
+ def results
154
+ @results
155
+ end
156
+
157
+ # The sum of the player's scores in all rounds in which they have a result.
158
+ def score
159
+ @results.inject(0.0) { |e, r| e + r.score }
160
+ end
161
+
162
+ # Returns the type of player as a symbol: one of _rated_, _provisional_, _unrated_ or _foreign_.
163
+ def type
164
+ @type
165
+ end
166
+
167
+ def add_result(result) # :nodoc:
168
+ raise "invalid result (#{result.class})" unless result.is_a? ICU::RatedResult
169
+ raise "players cannot score results against themselves" if self == result.opponent
170
+ duplicate = false
171
+ @results.each do |r|
172
+ if r.round == result.round
173
+ raise "inconsistent result in round #{r.round}" unless r == result
174
+ duplicate = true
175
+ end
176
+ end
177
+ return if duplicate
178
+ @results << result
179
+ @results.sort!{ |a,b| a.round <=> b.round }
180
+ end
181
+
182
+ def rate! # :nodoc:
183
+ @results.each { |r| r.rate!(self) }
184
+ end
185
+
186
+ def full_rating? # :nodoc:
187
+ @type == :rated || @type == :foreign
188
+ end
189
+
190
+ def init_performance # :nodoc:
191
+ @performance = nil
192
+ @estimated_performance = nil
193
+ end
194
+
195
+ def estimate_performance # :nodoc:
196
+ new_games, new_performance = results.inject([0,0.0]) do |sum, result|
197
+ opponent = result.opponent
198
+ if opponent.full_rating? || opponent.performance
199
+ sum[0]+= 1
200
+ sum[1]+= (opponent.full_rating? ? opponent.rating : opponent.performance) + (2 * result.score - 1) * 400.0
201
+ end
202
+ sum
203
+ end
204
+ if new_games > 0
205
+ old_games, old_performance = type == :provisional ? [games, games * rating] : [0, 0.0]
206
+ @estimated_performance = (new_performance + old_performance) / (new_games + old_games)
207
+ end
208
+ end
209
+
210
+ def update_performance # :nodoc:
211
+ stable = case
212
+ when @performance && @estimated_performance then (@performance - @estimated_performance).abs < 0.5
213
+ when !@performance && !@estimated_performance then true
214
+ else false
215
+ end
216
+ @performance = @estimated_performance if @estimated_performance
217
+ stable
218
+ end
219
+
220
+ def ==(other) # :nodoc:
221
+ return false unless other.is_a? ICU::RatedPlayer
222
+ num == other.num
223
+ end
224
+
225
+ private
226
+
227
+ def initialize(num, opt={}) # :nodoc:
228
+ self.num = num
229
+ [:rating, :kfactor, :games, :desc].each { |atr| self.send("#{atr}=", opt[atr]) unless opt[atr].nil? }
230
+ @results = []
231
+ @type = deduce_type
232
+ end
233
+
234
+ def num=(num)
235
+ @num = num.to_i
236
+ raise "invalid player num (#{num})" if @num == 0 && !num.to_s.match(/^\s*\d/)
237
+ end
238
+
239
+ def rating=(rating)
240
+ @rating = rating.to_f
241
+ raise "invalid player rating (#{rating})" if @rating == 0.0 && !rating.to_s.match(/^\s*\d/)
242
+ end
243
+
244
+ def kfactor=(kfactor)
245
+ @kfactor = kfactor.to_f
246
+ raise "invalid player k-factor (#{kfactor})" if @kfactor <= 0.0
247
+ end
248
+
249
+ def games=(games)
250
+ @games = games.to_i
251
+ raise "invalid number of games (#{games})" if @games <= 0 || @games >= 20
252
+ end
253
+
254
+ def deduce_type
255
+ case
256
+ when @rating && @kfactor && !@games then :rated
257
+ when @rating && !@kfactor && @games then :provisional
258
+ when @rating && !@kfactor && !@games then :foreign
259
+ when !@rating && !@kfactor && !@games then :unrated
260
+ else raise "invalid combination of player attributes"
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,172 @@
1
+ module ICU
2
+
3
+ =begin rdoc
4
+
5
+ == Adding Results to Tournaments
6
+
7
+ You don't create results directly with a constructor, instead, you add them to a tournament
8
+ using the _add_result_ method, giving the round number, the player numbers and the score
9
+ of the first player relative to the second:
10
+
11
+ t = ICU::RatedTournament.new
12
+ t.add_player(10)
13
+ t.add_player(20)
14
+ t.add_result(1, 10, 20, 'W')
15
+
16
+ The above example expresses the result that in round 1 player 10 won against player 20. An exception is raised
17
+ if either of the two players does not already exist in the tournament, if either player already has a game
18
+ with another opponent in that round or if the two players already have a different result against each other
19
+ in that round. Note that the result is added to both players: in the above example a win in round 1 against
20
+ player 20 is added to player 10's results and a loss against player 10 in round 1 is added to player 20's results.
21
+ It's OK (but unnecessary) to add the same result again from the other player's prespective as long as
22
+ the score is consistent.
23
+
24
+ t.add_result(1, 20, 10, 'L') # unnecessary (nothing would change) but would not cause an exception
25
+ t.add_result(1, 20, 10, 'D') # inconsistent result - would raise an exception
26
+
27
+ == Specifying the Score
28
+
29
+ The method _score_ will always return a Float value (either 0.0, 0.5 or 1.0).
30
+ When specifying a score using the _add_result_ of ICU::Tourmanent the same values
31
+ can be used as can other, equally valid alternatives:
32
+
33
+ win:: "1", "1.0", "W", "w" (String), 1 (Fixnum), 1.0 (Float)
34
+ loss:: "0", "0.0", "L", "l" (String), 0 (Fixnum), 0.0 (Float)
35
+ draw:: "½", "D", "d" (String), 0.5 (Float)
36
+
37
+ Strings padded with whitespace also work (e.g. " 1.0 " and " W ").
38
+
39
+ == Specifying the Players
40
+
41
+ As described above, one way to specify the two players is via player numbers. Equally possible is player objects:
42
+
43
+ t = ICU::RatedTournament.new
44
+ p = t.add_player(10)
45
+ q = t.add_plater(20)
46
+ t.add_result(1, p, q, 'W')
47
+
48
+ Or indeed (although this is unnecessary):
49
+
50
+ t = ICU::RatedTournament.new
51
+ t.add_player(10)
52
+ t.add_plater(20)
53
+ t.add_result(1, t.player(10), t.player(20), 'W')
54
+
55
+ A players cannot have a results against themselves:
56
+
57
+ t.add_player(2, 10, 10, 'D') # exception!
58
+
59
+ == Retrieving Results
60
+
61
+ Results belong to players (ICU::RatedPlayer objects) and are stored in an array accessed by the method _results_.
62
+ Each result has a _round_ number, an _opponent_ object (also an ICU::RatedPlayer object) and a _score_ (1.0, 0.5 or 0.0):
63
+
64
+ p = t.player(10)
65
+ p.results.size # 1
66
+ r = p.results[0]
67
+ r.round # 1
68
+ r.opponent.num # 20
69
+ r.score # 1.0 (Float)
70
+
71
+ The _results_ method returns results in round order, irrespective of what order they were added in:
72
+
73
+ t = ICU::RatedTournament.new
74
+ [0,1,2,3,4].each { |num| t.add_player(num) }
75
+ [3,1].each { |rnd| t.add_result(rnd, 0, rnd, 'W') }
76
+ [4,2].each { |rnd| t.add_result(rnd, 0, rnd, 'L') }
77
+ t.player(0).results.map{ |r| r.round }.join(',') # "1,2,3,4"
78
+
79
+ == Unrated Results
80
+
81
+ Results that are not for rating, such as byes, walkovers and defaults, should not be
82
+ added to the tournament. Instead, players can simply have no results for certain rounds.
83
+ Indeed, it's even valid for players not to have any results at all (although, in that
84
+ case, for those players, no new rating can be calculated from the tournament).
85
+
86
+ == After the Tournament is Rated
87
+
88
+ The main rating calculations are avaiable from player methods (see ICU::RatedPlayer)
89
+ but additional details are available via methods of each player's individual results:
90
+ _expected_score_, _rating_change_.
91
+
92
+ =end
93
+
94
+ class RatedResult
95
+ # The round number.
96
+ def round
97
+ @round
98
+ end
99
+
100
+ # The player's opponent (an instance of ICU::RatedPlayer).
101
+ def opponent
102
+ @opponent
103
+ end
104
+
105
+ # The player's score in this game (1.0, 0.5 or 0.0).
106
+ def score
107
+ @score
108
+ end
109
+
110
+ # After the tournament has been rated, this returns the expected score (between 0 and 1)
111
+ # for the player based on the rating difference with the opponent scaled by 400.
112
+ # The standard Elo formula is used: 1/(1 + 10^(diff/400)).
113
+ def expected_score
114
+ @expected_score
115
+ end
116
+
117
+ # After the tournament has been rated, returns the change in rating due to this particular
118
+ # result. Only for rated players (returns _nil_ for other types of players). Computed from
119
+ # the difference between actual and expected scores multiplied by the player's K-factor.
120
+ # The sum of these changes is the overall rating change for rated players.
121
+ def rating_change
122
+ @rating_change
123
+ end
124
+
125
+ def rate!(player) # :nodoc:
126
+ player_rating = player.full_rating? ? player.rating : player.performance
127
+ opponent_rating = opponent.full_rating? ? opponent.rating : opponent.performance
128
+ if (player_rating && opponent_rating)
129
+ @expected_score = 1 / (1 + 10 ** ((opponent_rating - player_rating) / 400.0))
130
+ @rating_change = (@score - @expected_score) * player.kfactor if player.type == :rated
131
+ end
132
+ end
133
+
134
+ def ==(other) # :nodoc:
135
+ return false unless other.round == round
136
+ return false unless other.opponent == opponent
137
+ return false unless other.score == score
138
+ true
139
+ end
140
+
141
+ def opponents_score # :nodoc:
142
+ 1.0 - score
143
+ end
144
+
145
+ private
146
+
147
+ def initialize(round, opponent, score) # :nodoc:
148
+ self.round = round
149
+ self.opponent = opponent
150
+ self.score = score
151
+ end
152
+
153
+ def round=(round)
154
+ @round = round.to_i
155
+ raise "invalid round number (#{round})" if @round <= 0
156
+ end
157
+
158
+ def opponent=(opponent)
159
+ raise "invalid opponent class (#{opponent.class})" unless opponent.is_a? ICU::RatedPlayer
160
+ @opponent = opponent
161
+ end
162
+
163
+ def score=(score)
164
+ @score = case score.to_s.strip
165
+ when /^(1\.0|1|\+|W|w)$/ then 1.0
166
+ when /^(0\.5|½|\=|D|d)$/ then 0.5
167
+ when /^(0\.0|0|\-|L|l)$/ then 0.0
168
+ else raise "invalid score (#{score})"
169
+ end
170
+ end
171
+ end
172
+ end