openskill 0.1.0 → 1.0.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.
- checksums.yaml +4 -4
- data/README.md +37 -14
- data/lib/openskill/models/bradley_terry_full.rb +616 -0
- data/lib/openskill/models/bradley_terry_part.rb +606 -0
- data/lib/openskill/models/common.rb +69 -0
- data/lib/openskill/models/thurstone_mosteller_full.rb +595 -0
- data/lib/openskill/models/thurstone_mosteller_part.rb +610 -0
- data/lib/openskill/version.rb +1 -1
- data/lib/openskill.rb +4 -0
- metadata +5 -1
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require_relative '../statistics/normal'
|
|
5
|
+
require_relative 'common'
|
|
6
|
+
|
|
7
|
+
module OpenSkill
|
|
8
|
+
module Models
|
|
9
|
+
# Thurstone-Mosteller Full rating model (Algorithm 3)
|
|
10
|
+
#
|
|
11
|
+
# This model uses full pairing where all teams are compared against each other.
|
|
12
|
+
# It uses a maximum likelihood estimation approach for rating estimation.
|
|
13
|
+
class ThurstoneMostellerFull
|
|
14
|
+
attr_reader :mu, :sigma, :beta, :kappa, :tau, :margin, :limit_sigma, :balance, :gamma, :epsilon
|
|
15
|
+
|
|
16
|
+
# Default gamma function for ThurstoneMostellerFull
|
|
17
|
+
DEFAULT_GAMMA = lambda do |_c, _k, _mu, sigma_squared, _team, _rank, _weights|
|
|
18
|
+
Math.sqrt(sigma_squared) / _c
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param mu [Float] initial mean skill rating
|
|
22
|
+
# @param sigma [Float] initial standard deviation
|
|
23
|
+
# @param beta [Float] performance uncertainty
|
|
24
|
+
# @param kappa [Float] minimum variance (regularization)
|
|
25
|
+
# @param gamma [Proc] custom gamma function
|
|
26
|
+
# @param tau [Float] dynamics factor (skill decay)
|
|
27
|
+
# @param epsilon [Float] draw margin for Thurstone-Mosteller
|
|
28
|
+
# @param margin [Float] score margin for impressive wins
|
|
29
|
+
# @param limit_sigma [Boolean] prevent sigma from increasing
|
|
30
|
+
# @param balance [Boolean] emphasize rating outliers
|
|
31
|
+
def initialize(
|
|
32
|
+
mu: 25.0,
|
|
33
|
+
sigma: 25.0 / 3.0,
|
|
34
|
+
beta: 25.0 / 6.0,
|
|
35
|
+
kappa: 0.0001,
|
|
36
|
+
gamma: DEFAULT_GAMMA,
|
|
37
|
+
tau: 25.0 / 300.0,
|
|
38
|
+
epsilon: 0.1,
|
|
39
|
+
margin: 0.0,
|
|
40
|
+
limit_sigma: false,
|
|
41
|
+
balance: false
|
|
42
|
+
)
|
|
43
|
+
@mu = mu.to_f
|
|
44
|
+
@sigma = sigma.to_f
|
|
45
|
+
@beta = beta.to_f
|
|
46
|
+
@kappa = kappa.to_f
|
|
47
|
+
@gamma = gamma
|
|
48
|
+
@tau = tau.to_f
|
|
49
|
+
@epsilon = epsilon.to_f
|
|
50
|
+
@margin = margin.to_f
|
|
51
|
+
@limit_sigma = limit_sigma
|
|
52
|
+
@balance = balance
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Create a new rating with default or custom parameters
|
|
56
|
+
#
|
|
57
|
+
# @param mu [Float, nil] override default mu
|
|
58
|
+
# @param sigma [Float, nil] override default sigma
|
|
59
|
+
# @param name [String, nil] optional player name
|
|
60
|
+
# @return [Rating] a new rating object
|
|
61
|
+
def create_rating(mu: nil, sigma: nil, name: nil)
|
|
62
|
+
Rating.new(
|
|
63
|
+
mu: mu || @mu,
|
|
64
|
+
sigma: sigma || @sigma,
|
|
65
|
+
name: name
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Load a rating from an array [mu, sigma]
|
|
70
|
+
#
|
|
71
|
+
# @param rating_array [Array<Numeric>] [mu, sigma]
|
|
72
|
+
# @param name [String, nil] optional player name
|
|
73
|
+
# @return [Rating] a new rating object
|
|
74
|
+
# @raise [ArgumentError] if rating_array is invalid
|
|
75
|
+
def load_rating(rating_array, name: nil)
|
|
76
|
+
raise ArgumentError, "Rating must be an Array, got #{rating_array.class}" unless rating_array.is_a?(Array)
|
|
77
|
+
raise ArgumentError, 'Rating array must have exactly 2 elements' unless rating_array.size == 2
|
|
78
|
+
raise ArgumentError, 'Rating values must be numeric' unless rating_array.all? { |v| v.is_a?(Numeric) }
|
|
79
|
+
|
|
80
|
+
Rating.new(mu: rating_array[0], sigma: rating_array[1], name: name)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Calculate new ratings after a match
|
|
84
|
+
#
|
|
85
|
+
# @param teams [Array<Array<Rating>>] list of teams
|
|
86
|
+
# @param ranks [Array<Numeric>, nil] team ranks (lower is better, 0-indexed)
|
|
87
|
+
# @param scores [Array<Numeric>, nil] team scores (higher is better)
|
|
88
|
+
# @param weights [Array<Array<Numeric>>, nil] player contribution weights
|
|
89
|
+
# @param tau [Float, nil] override tau for this match
|
|
90
|
+
# @param limit_sigma [Boolean, nil] override limit_sigma for this match
|
|
91
|
+
# @return [Array<Array<Rating>>] updated teams
|
|
92
|
+
def calculate_ratings(teams, ranks: nil, scores: nil, weights: nil, tau: nil, limit_sigma: nil)
|
|
93
|
+
validate_teams!(teams)
|
|
94
|
+
validate_ranks!(teams, ranks) if ranks
|
|
95
|
+
validate_scores!(teams, scores) if scores
|
|
96
|
+
validate_weights!(teams, weights) if weights
|
|
97
|
+
|
|
98
|
+
raise ArgumentError, 'Cannot provide both ranks and scores' if ranks && scores
|
|
99
|
+
|
|
100
|
+
# Deep copy teams to avoid mutating input
|
|
101
|
+
original_teams = teams
|
|
102
|
+
teams = deep_copy_teams(teams)
|
|
103
|
+
|
|
104
|
+
# Apply tau (skill decay over time)
|
|
105
|
+
tau_value = tau || @tau
|
|
106
|
+
tau_squared = tau_value**2
|
|
107
|
+
teams.each do |team|
|
|
108
|
+
team.each do |player|
|
|
109
|
+
player.sigma = Math.sqrt(player.sigma**2 + tau_squared)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Convert scores to ranks if provided
|
|
114
|
+
if !ranks && scores
|
|
115
|
+
ranks = scores.map { |s| -s }
|
|
116
|
+
ranks = calculate_rankings(teams, ranks)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Normalize weights to [1, 2] range
|
|
120
|
+
weights = weights.map { |w| Common.normalize(w, 1, 2) } if weights
|
|
121
|
+
|
|
122
|
+
# Sort teams by rank and track original order
|
|
123
|
+
tenet = nil
|
|
124
|
+
if ranks
|
|
125
|
+
sorted_objects, restoration_indices = Common.unwind(ranks, teams)
|
|
126
|
+
teams = sorted_objects
|
|
127
|
+
tenet = restoration_indices
|
|
128
|
+
|
|
129
|
+
weights, = Common.unwind(ranks, weights) if weights
|
|
130
|
+
|
|
131
|
+
ranks = ranks.sort
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Compute new ratings
|
|
135
|
+
result = compute_ratings(teams, ranks: ranks, scores: scores, weights: weights)
|
|
136
|
+
|
|
137
|
+
# Restore original order
|
|
138
|
+
result, = Common.unwind(tenet, result) if ranks && tenet
|
|
139
|
+
|
|
140
|
+
# Apply sigma limiting if requested
|
|
141
|
+
limit_sigma_value = limit_sigma.nil? ? @limit_sigma : limit_sigma
|
|
142
|
+
if limit_sigma_value
|
|
143
|
+
result = result.each_with_index.map do |team, team_idx|
|
|
144
|
+
team.each_with_index.map do |player, player_idx|
|
|
145
|
+
player.sigma = [player.sigma, original_teams[team_idx][player_idx].sigma].min
|
|
146
|
+
player
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
result
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Predict win probability for each team
|
|
155
|
+
#
|
|
156
|
+
# @param teams [Array<Array<Rating>>] list of teams
|
|
157
|
+
# @return [Array<Float>] probability each team wins
|
|
158
|
+
def predict_win_probability(teams)
|
|
159
|
+
validate_teams!(teams)
|
|
160
|
+
|
|
161
|
+
n = teams.size
|
|
162
|
+
|
|
163
|
+
# Special case for 2 teams
|
|
164
|
+
if n == 2
|
|
165
|
+
team_ratings = calculate_team_ratings(teams)
|
|
166
|
+
a = team_ratings[0]
|
|
167
|
+
b = team_ratings[1]
|
|
168
|
+
|
|
169
|
+
result = phi_major(
|
|
170
|
+
(a.mu - b.mu) / Math.sqrt(2 * @beta**2 + a.sigma_squared + b.sigma_squared)
|
|
171
|
+
)
|
|
172
|
+
return [result, 1 - result]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# For n teams, compute pairwise probabilities
|
|
176
|
+
team_ratings = teams.map { |team| calculate_team_ratings([team])[0] }
|
|
177
|
+
|
|
178
|
+
win_probs = []
|
|
179
|
+
team_ratings.each_with_index do |team_i, i|
|
|
180
|
+
prob_sum = 0.0
|
|
181
|
+
team_ratings.each_with_index do |team_j, j|
|
|
182
|
+
next if i == j
|
|
183
|
+
|
|
184
|
+
prob_sum += phi_major(
|
|
185
|
+
(team_i.mu - team_j.mu) / Math.sqrt(2 * @beta**2 + team_i.sigma_squared + team_j.sigma_squared)
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
win_probs << prob_sum / (n - 1)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Normalize to sum to 1
|
|
192
|
+
total = win_probs.sum
|
|
193
|
+
win_probs.map { |p| p / total }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Predict draw probability
|
|
197
|
+
#
|
|
198
|
+
# @param teams [Array<Array<Rating>>] list of teams
|
|
199
|
+
# @return [Float] probability of a draw
|
|
200
|
+
def predict_draw_probability(teams)
|
|
201
|
+
validate_teams!(teams)
|
|
202
|
+
|
|
203
|
+
total_player_count = teams.sum(&:size)
|
|
204
|
+
draw_probability = 1.0 / total_player_count
|
|
205
|
+
draw_margin = Math.sqrt(total_player_count) * @beta * phi_major_inverse((1 + draw_probability) / 2)
|
|
206
|
+
|
|
207
|
+
pairwise_probs = []
|
|
208
|
+
teams.combination(2).each do |team_a, team_b|
|
|
209
|
+
team_a_ratings = calculate_team_ratings([team_a])
|
|
210
|
+
team_b_ratings = calculate_team_ratings([team_b])
|
|
211
|
+
|
|
212
|
+
mu_a = team_a_ratings[0].mu
|
|
213
|
+
sigma_a = team_a_ratings[0].sigma_squared
|
|
214
|
+
mu_b = team_b_ratings[0].mu
|
|
215
|
+
sigma_b = team_b_ratings[0].sigma_squared
|
|
216
|
+
|
|
217
|
+
denominator = Math.sqrt(2 * @beta**2 + sigma_a + sigma_b)
|
|
218
|
+
|
|
219
|
+
pairwise_probs << (
|
|
220
|
+
phi_major((draw_margin - mu_a + mu_b) / denominator) -
|
|
221
|
+
phi_major((mu_b - mu_a - draw_margin) / denominator)
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
pairwise_probs.sum / pairwise_probs.size
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Predict rank probability for each team
|
|
229
|
+
#
|
|
230
|
+
# @param teams [Array<Array<Rating>>] list of teams
|
|
231
|
+
# @return [Array<Array(Integer, Float)>] rank and probability for each team
|
|
232
|
+
def predict_rank_probability(teams)
|
|
233
|
+
validate_teams!(teams)
|
|
234
|
+
|
|
235
|
+
n = teams.size
|
|
236
|
+
team_ratings = calculate_team_ratings(teams)
|
|
237
|
+
|
|
238
|
+
# Calculate win probability for each team against all others
|
|
239
|
+
win_probs = team_ratings.map do |team_i|
|
|
240
|
+
prob = 0.0
|
|
241
|
+
team_ratings.each do |team_j|
|
|
242
|
+
next if team_i == team_j
|
|
243
|
+
|
|
244
|
+
prob += phi_major(
|
|
245
|
+
(team_i.mu - team_j.mu) /
|
|
246
|
+
Math.sqrt(2 * @beta**2 + team_i.sigma_squared + team_j.sigma_squared)
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
prob / (n - 1)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Normalize probabilities
|
|
253
|
+
total = win_probs.sum
|
|
254
|
+
normalized_probs = win_probs.map { |p| p / total }
|
|
255
|
+
|
|
256
|
+
# Sort by probability (descending) and assign ranks
|
|
257
|
+
sorted_indices = normalized_probs.each_with_index.sort_by { |prob, _| -prob }
|
|
258
|
+
ranks = Array.new(n)
|
|
259
|
+
|
|
260
|
+
current_rank = 1
|
|
261
|
+
sorted_indices.each_with_index do |(prob, team_idx), i|
|
|
262
|
+
current_rank = i + 1 if i > 0 && prob < sorted_indices[i - 1][0]
|
|
263
|
+
ranks[team_idx] = current_rank
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
ranks.zip(normalized_probs)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
private
|
|
270
|
+
|
|
271
|
+
# Helper for log(1 + x)
|
|
272
|
+
def log1p(value)
|
|
273
|
+
Math.log(1 + value)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Rating class for individual players
|
|
277
|
+
class Rating
|
|
278
|
+
attr_accessor :mu, :sigma, :name
|
|
279
|
+
attr_reader :id
|
|
280
|
+
|
|
281
|
+
def initialize(mu:, sigma:, name: nil)
|
|
282
|
+
@id = SecureRandom.hex
|
|
283
|
+
@mu = mu.to_f
|
|
284
|
+
@sigma = sigma.to_f
|
|
285
|
+
@name = name
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Calculate display rating (conservative estimate)
|
|
289
|
+
#
|
|
290
|
+
# @param z [Float] number of standard deviations
|
|
291
|
+
# @param alpha [Float] scaling factor
|
|
292
|
+
# @param target [Float] target adjustment
|
|
293
|
+
# @return [Float] the ordinal rating
|
|
294
|
+
def ordinal(z: 3.0, alpha: 1.0, target: 0.0)
|
|
295
|
+
alpha * ((@mu - z * @sigma) + (target / alpha))
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def <=>(other)
|
|
299
|
+
return nil unless other.is_a?(Rating)
|
|
300
|
+
|
|
301
|
+
ordinal <=> other.ordinal
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def <(other)
|
|
305
|
+
raise ArgumentError, 'comparison with non-Rating' unless other.is_a?(Rating)
|
|
306
|
+
|
|
307
|
+
ordinal < other.ordinal
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def >(other)
|
|
311
|
+
raise ArgumentError, 'comparison with non-Rating' unless other.is_a?(Rating)
|
|
312
|
+
|
|
313
|
+
ordinal > other.ordinal
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def <=(other)
|
|
317
|
+
raise ArgumentError, 'comparison with non-Rating' unless other.is_a?(Rating)
|
|
318
|
+
|
|
319
|
+
ordinal <= other.ordinal
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def >=(other)
|
|
323
|
+
raise ArgumentError, 'comparison with non-Rating' unless other.is_a?(Rating)
|
|
324
|
+
|
|
325
|
+
ordinal >= other.ordinal
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def ==(other)
|
|
329
|
+
return false unless other.is_a?(Rating)
|
|
330
|
+
|
|
331
|
+
@mu == other.mu && @sigma == other.sigma
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def hash
|
|
335
|
+
[@id, @mu, @sigma].hash
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def eql?(other)
|
|
339
|
+
self == other
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def to_s
|
|
343
|
+
"Rating(mu=#{@mu}, sigma=#{@sigma}#{", name=#{@name}" if @name})"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def inspect
|
|
347
|
+
to_s
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Internal class for team ratings
|
|
352
|
+
class TeamRating
|
|
353
|
+
attr_reader :mu, :sigma_squared, :team, :rank
|
|
354
|
+
|
|
355
|
+
def initialize(mu:, sigma_squared:, team:, rank:)
|
|
356
|
+
@mu = mu.to_f
|
|
357
|
+
@sigma_squared = sigma_squared.to_f
|
|
358
|
+
@team = team
|
|
359
|
+
@rank = rank.to_i
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def ==(other)
|
|
363
|
+
return false unless other.is_a?(TeamRating)
|
|
364
|
+
|
|
365
|
+
@mu == other.mu &&
|
|
366
|
+
@sigma_squared == other.sigma_squared &&
|
|
367
|
+
@team == other.team &&
|
|
368
|
+
@rank == other.rank
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def hash
|
|
372
|
+
[@mu, @sigma_squared, @team, @rank].hash
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def to_s
|
|
376
|
+
"TeamRating(mu=#{@mu}, sigma_squared=#{@sigma_squared}, rank=#{@rank})"
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Validate teams structure
|
|
381
|
+
def validate_teams!(teams)
|
|
382
|
+
raise ArgumentError, 'Teams must be an Array' unless teams.is_a?(Array)
|
|
383
|
+
raise ArgumentError, 'Must have at least 2 teams' if teams.size < 2
|
|
384
|
+
|
|
385
|
+
teams.each_with_index do |team, idx|
|
|
386
|
+
raise ArgumentError, "Team #{idx} must be an Array" unless team.is_a?(Array)
|
|
387
|
+
raise ArgumentError, "Team #{idx} must have at least 1 player" if team.empty?
|
|
388
|
+
|
|
389
|
+
team.each do |player|
|
|
390
|
+
raise ArgumentError, "All players must be Rating objects, got #{player.class}" unless player.is_a?(Rating)
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Validate ranks
|
|
396
|
+
def validate_ranks!(teams, ranks)
|
|
397
|
+
raise ArgumentError, "Ranks must be an Array, got #{ranks.class}" unless ranks.is_a?(Array)
|
|
398
|
+
raise ArgumentError, 'Ranks must have same length as teams' if ranks.size != teams.size
|
|
399
|
+
|
|
400
|
+
ranks.each do |rank|
|
|
401
|
+
raise ArgumentError, "All ranks must be numeric, got #{rank.class}" unless rank.is_a?(Numeric)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Validate scores
|
|
406
|
+
def validate_scores!(teams, scores)
|
|
407
|
+
raise ArgumentError, "Scores must be an Array, got #{scores.class}" unless scores.is_a?(Array)
|
|
408
|
+
raise ArgumentError, 'Scores must have same length as teams' if scores.size != teams.size
|
|
409
|
+
|
|
410
|
+
scores.each do |score|
|
|
411
|
+
raise ArgumentError, "All scores must be numeric, got #{score.class}" unless score.is_a?(Numeric)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Validate weights
|
|
416
|
+
def validate_weights!(teams, weights)
|
|
417
|
+
raise ArgumentError, "Weights must be an Array, got #{weights.class}" unless weights.is_a?(Array)
|
|
418
|
+
raise ArgumentError, 'Weights must have same length as teams' if weights.size != teams.size
|
|
419
|
+
|
|
420
|
+
weights.each_with_index do |team_weights, idx|
|
|
421
|
+
raise ArgumentError, "Weights for team #{idx} must be an Array" unless team_weights.is_a?(Array)
|
|
422
|
+
unless team_weights.size == teams[idx].size
|
|
423
|
+
raise ArgumentError, "Weights for team #{idx} must match team size"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
team_weights.each do |weight|
|
|
427
|
+
raise ArgumentError, "All weights must be numeric, got #{weight.class}" unless weight.is_a?(Numeric)
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Deep copy teams to avoid mutation
|
|
433
|
+
def deep_copy_teams(teams)
|
|
434
|
+
teams.map do |team|
|
|
435
|
+
team.map do |player|
|
|
436
|
+
Rating.new(mu: player.mu, sigma: player.sigma, name: player.name).tap do |new_player|
|
|
437
|
+
new_player.instance_variable_set(:@id, player.id)
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Calculate team ratings from individual player ratings
|
|
444
|
+
def calculate_team_ratings(game, ranks: nil, weights: nil)
|
|
445
|
+
ranks ||= calculate_rankings(game)
|
|
446
|
+
|
|
447
|
+
game.each_with_index.map do |team, idx|
|
|
448
|
+
sorted_team = team.sort_by { |p| -p.ordinal }
|
|
449
|
+
max_ordinal = sorted_team.first.ordinal
|
|
450
|
+
|
|
451
|
+
mu_sum = 0.0
|
|
452
|
+
sigma_squared_sum = 0.0
|
|
453
|
+
|
|
454
|
+
sorted_team.each do |player|
|
|
455
|
+
balance_weight = if @balance
|
|
456
|
+
ordinal_diff = max_ordinal - player.ordinal
|
|
457
|
+
1 + (ordinal_diff / (max_ordinal + @kappa))
|
|
458
|
+
else
|
|
459
|
+
1.0
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
mu_sum += player.mu * balance_weight
|
|
463
|
+
sigma_squared_sum += (player.sigma * balance_weight)**2
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
TeamRating.new(
|
|
467
|
+
mu: mu_sum,
|
|
468
|
+
sigma_squared: sigma_squared_sum,
|
|
469
|
+
team: team,
|
|
470
|
+
rank: ranks[idx].to_i
|
|
471
|
+
)
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Calculate rankings from scores or indices
|
|
476
|
+
def calculate_rankings(game, ranks = nil)
|
|
477
|
+
return [] if game.empty?
|
|
478
|
+
|
|
479
|
+
team_scores = if ranks
|
|
480
|
+
ranks.each_with_index.map { |rank, idx| rank || idx }
|
|
481
|
+
else
|
|
482
|
+
game.each_index.to_a
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
sorted_scores = team_scores.sort
|
|
486
|
+
rank_map = {}
|
|
487
|
+
sorted_scores.each_with_index do |value, index|
|
|
488
|
+
rank_map[value] ||= index
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
team_scores.map { |score| rank_map[score].to_f }
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Core rating computation algorithm using Thurstone-Mosteller Full pairing
|
|
495
|
+
def compute_ratings(teams, ranks: nil, scores: nil, weights: nil)
|
|
496
|
+
team_ratings = calculate_team_ratings(teams, ranks: ranks)
|
|
497
|
+
|
|
498
|
+
# Build score mapping for margin calculations
|
|
499
|
+
score_mapping = {}
|
|
500
|
+
if scores && scores.size == team_ratings.size
|
|
501
|
+
team_ratings.each_with_index do |_, i|
|
|
502
|
+
score_mapping[i] = scores[i]
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
team_ratings.each_with_index.map do |team_i, i|
|
|
507
|
+
omega = 0.0
|
|
508
|
+
delta = 0.0
|
|
509
|
+
|
|
510
|
+
# Thurstone-Mosteller Full: Compare with ALL other teams using v/w functions
|
|
511
|
+
team_ratings.each_with_index do |team_q, q|
|
|
512
|
+
next if q == i
|
|
513
|
+
|
|
514
|
+
# Calculate c_iq
|
|
515
|
+
c_iq = Math.sqrt(team_i.sigma_squared + team_q.sigma_squared + 2 * @beta**2)
|
|
516
|
+
|
|
517
|
+
# Calculate margin factor
|
|
518
|
+
margin_factor = 1.0
|
|
519
|
+
if scores && score_mapping.key?(i) && score_mapping.key?(q)
|
|
520
|
+
score_diff = (score_mapping[i] - score_mapping[q]).abs
|
|
521
|
+
margin_factor = log1p(score_diff / @margin) if score_diff > @margin && @margin > 0.0
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Calculate delta_mu
|
|
525
|
+
delta_mu = ((team_i.mu - team_q.mu) / c_iq) * margin_factor
|
|
526
|
+
epsilon_over_c = @epsilon / c_iq
|
|
527
|
+
|
|
528
|
+
# Use v/w/vt/wt functions based on rank comparison
|
|
529
|
+
if team_q.rank > team_i.rank
|
|
530
|
+
# team_i won
|
|
531
|
+
omega += (team_i.sigma_squared / c_iq) * Common.v(delta_mu, epsilon_over_c)
|
|
532
|
+
v_val = Common.w(delta_mu, epsilon_over_c)
|
|
533
|
+
elsif team_q.rank < team_i.rank
|
|
534
|
+
# team_i lost
|
|
535
|
+
omega -= (team_i.sigma_squared / c_iq) * Common.v(-delta_mu, epsilon_over_c)
|
|
536
|
+
v_val = Common.w(-delta_mu, epsilon_over_c)
|
|
537
|
+
else
|
|
538
|
+
# draw
|
|
539
|
+
omega += (team_i.sigma_squared / c_iq) * Common.vt(delta_mu, epsilon_over_c)
|
|
540
|
+
v_val = Common.wt(delta_mu, epsilon_over_c)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Apply gamma
|
|
544
|
+
team_weights = weights ? weights[i] : nil
|
|
545
|
+
gamma_value = @gamma.call(
|
|
546
|
+
c_iq,
|
|
547
|
+
team_ratings.size,
|
|
548
|
+
team_i.mu,
|
|
549
|
+
team_i.sigma_squared,
|
|
550
|
+
team_i.team,
|
|
551
|
+
team_i.rank,
|
|
552
|
+
team_weights
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
delta += (gamma_value * team_i.sigma_squared / (c_iq**2)) * v_val
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Update each player in the team
|
|
559
|
+
team_i.team.each_with_index.map do |player, j|
|
|
560
|
+
weight = weights ? weights[i][j] : 1.0
|
|
561
|
+
|
|
562
|
+
new_mu = player.mu
|
|
563
|
+
new_sigma = player.sigma
|
|
564
|
+
|
|
565
|
+
if omega >= 0
|
|
566
|
+
new_mu += (new_sigma**2 / team_i.sigma_squared) * omega * weight
|
|
567
|
+
new_sigma *= Math.sqrt(
|
|
568
|
+
[1 - (new_sigma**2 / team_i.sigma_squared) * delta * weight, @kappa].max
|
|
569
|
+
)
|
|
570
|
+
else
|
|
571
|
+
new_mu += (new_sigma**2 / team_i.sigma_squared) * omega / weight
|
|
572
|
+
new_sigma *= Math.sqrt(
|
|
573
|
+
[1 - (new_sigma**2 / team_i.sigma_squared) * delta / weight, @kappa].max
|
|
574
|
+
)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
player.mu = new_mu
|
|
578
|
+
player.sigma = new_sigma
|
|
579
|
+
player
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Normal distribution CDF
|
|
585
|
+
def phi_major(value)
|
|
586
|
+
Statistics::Normal.cdf(value)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Normal distribution inverse CDF
|
|
590
|
+
def phi_major_inverse(value)
|
|
591
|
+
Statistics::Normal.inv_cdf(value)
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
end
|