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