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 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