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