Class: OpenSkill::Models::BradleyTerryPart

Inherits:
Object
  • Object
show all
Defined in:
lib/openskill/models/bradley_terry_part.rb

Overview

Bradley-Terry Full rating model (Algorithm 2)

This model uses partial pairing with a sliding window for efficiency. It uses a logistic regression approach for rating estimation.

Defined Under Namespace

Classes: Rating, TeamRating

Constant Summary collapse

DEFAULT_GAMMA =

Default gamma function for BradleyTerryPart

lambda do |_c, _k, _mu, sigma_squared, _team, _rank, _weights|
  Math.sqrt(sigma_squared) / _c
end

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(mu: 25.0, sigma: 25.0 / 3.0, beta: 25.0 / 6.0, kappa: 0.0001, gamma: DEFAULT_GAMMA, tau: 25.0 / 300.0, margin: 0.0, limit_sigma: false, balance: false, window_size: 4) ⇒ BradleyTerryPart

Returns a new instance of BradleyTerryPart.

Parameters:

  • mu (Float) (defaults to: 25.0)

    initial mean skill rating

  • sigma (Float) (defaults to: 25.0 / 3.0)

    initial standard deviation

  • beta (Float) (defaults to: 25.0 / 6.0)

    performance uncertainty

  • kappa (Float) (defaults to: 0.0001)

    minimum variance (regularization)

  • gamma (Proc) (defaults to: DEFAULT_GAMMA)

    custom gamma function

  • tau (Float) (defaults to: 25.0 / 300.0)

    dynamics factor (skill decay)

  • margin (Float) (defaults to: 0.0)

    score margin for impressive wins

  • limit_sigma (Boolean) (defaults to: false)

    prevent sigma from increasing

  • balance (Boolean) (defaults to: false)

    emphasize rating outliers

  • window_size (Integer) (defaults to: 4)

    sliding window size for partial pairing



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/openskill/models/bradley_terry_part.rb', line 31

def initialize(
  mu: 25.0,
  sigma: 25.0 / 3.0,
  beta: 25.0 / 6.0,
  kappa: 0.0001,
  gamma: DEFAULT_GAMMA,
  tau: 25.0 / 300.0,
  margin: 0.0,
  limit_sigma: false,
  balance: false,
  window_size: 4
)
  @mu = mu.to_f
  @sigma = sigma.to_f
  @beta = beta.to_f
  @kappa = kappa.to_f
  @gamma = gamma
  @tau = tau.to_f
  @margin = margin.to_f
  @limit_sigma = limit_sigma
  @balance = balance
  @window_size = window_size.to_i
end

Instance Attribute Details

#balanceObject (readonly)

Returns the value of attribute balance.



14
15
16
# File 'lib/openskill/models/bradley_terry_part.rb', line 14

def balance
  @balance
end

#betaObject (readonly)

Returns the value of attribute beta.



14
15
16
# File 'lib/openskill/models/bradley_terry_part.rb', line 14

def beta
  @beta
end

#gammaObject (readonly)

Returns the value of attribute gamma.



14
15
16
# File 'lib/openskill/models/bradley_terry_part.rb', line 14

def gamma
  @gamma
end

#kappaObject (readonly)

Returns the value of attribute kappa.



14
15
16
# File 'lib/openskill/models/bradley_terry_part.rb', line 14

def kappa
  @kappa
end

#limit_sigmaObject (readonly)

Returns the value of attribute limit_sigma.



14
15
16
# File 'lib/openskill/models/bradley_terry_part.rb', line 14

def limit_sigma
  @limit_sigma
end

#marginObject (readonly)

Returns the value of attribute margin.



14
15
16
# File 'lib/openskill/models/bradley_terry_part.rb', line 14

def margin
  @margin
end

#muObject (readonly)

Returns the value of attribute mu.



14
15
16
# File 'lib/openskill/models/bradley_terry_part.rb', line 14

def mu
  @mu
end

#sigmaObject (readonly)

Returns the value of attribute sigma.



14
15
16
# File 'lib/openskill/models/bradley_terry_part.rb', line 14

def sigma
  @sigma
end

#tauObject (readonly)

Returns the value of attribute tau.



14
15
16
# File 'lib/openskill/models/bradley_terry_part.rb', line 14

def tau
  @tau
end

#window_sizeObject (readonly)

Returns the value of attribute window_size.



14
15
16
# File 'lib/openskill/models/bradley_terry_part.rb', line 14

def window_size
  @window_size
end

Instance Method Details

#calculate_ratings(teams, ranks: nil, scores: nil, weights: nil, tau: nil, limit_sigma: nil) ⇒ Array<Array<Rating>>

Calculate new ratings after a match

Parameters:

  • teams (Array<Array<Rating>>)

    list of teams

  • ranks (Array<Numeric>, nil) (defaults to: nil)

    team ranks (lower is better, 0-indexed)

  • scores (Array<Numeric>, nil) (defaults to: nil)

    team scores (higher is better)

  • weights (Array<Array<Numeric>>, nil) (defaults to: nil)

    player contribution weights

  • tau (Float, nil) (defaults to: nil)

    override tau for this match

  • limit_sigma (Boolean, nil) (defaults to: nil)

    override limit_sigma for this match

Returns:

  • (Array<Array<Rating>>)

    updated teams

Raises:

  • (ArgumentError)


92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/openskill/models/bradley_terry_part.rb', line 92

def calculate_ratings(teams, ranks: nil, scores: nil, weights: nil, tau: nil, limit_sigma: nil)
  validate_teams!(teams)
  validate_ranks!(teams, ranks) if ranks
  validate_scores!(teams, scores) if scores
  validate_weights!(teams, weights) if weights

  raise ArgumentError, 'Cannot provide both ranks and scores' if ranks && scores

  # Deep copy teams to avoid mutating input
  original_teams = teams
  teams = deep_copy_teams(teams)

  # Apply tau (skill decay over time)
  tau_value = tau || @tau
  tau_squared = tau_value**2
  teams.each do |team|
    team.each do |player|
      player.sigma = Math.sqrt(player.sigma**2 + tau_squared)
    end
  end

  # Convert scores to ranks if provided
  if !ranks && scores
    ranks = scores.map { |s| -s }
    ranks = calculate_rankings(teams, ranks)
  end

  # Normalize weights to [1, 2] range
  weights = weights.map { |w| Common.normalize(w, 1, 2) } if weights

  # Sort teams by rank and track original order
  tenet = nil
  if ranks
    sorted_objects, restoration_indices = Common.unwind(ranks, teams)
    teams = sorted_objects
    tenet = restoration_indices

    weights, = Common.unwind(ranks, weights) if weights

    ranks = ranks.sort
  end

  # Compute new ratings
  result = compute_ratings(teams, ranks: ranks, scores: scores, weights: weights)

  # Restore original order
  result, = Common.unwind(tenet, result) if ranks && tenet

  # Apply sigma limiting if requested
  limit_sigma_value = limit_sigma.nil? ? @limit_sigma : limit_sigma
  if limit_sigma_value
    result = result.each_with_index.map do |team, team_idx|
      team.each_with_index.map do |player, player_idx|
        player.sigma = [player.sigma, original_teams[team_idx][player_idx].sigma].min
        player
      end
    end
  end

  result
end

#create_rating(mu: nil, sigma: nil, name: nil) ⇒ Rating

Create a new rating with default or custom parameters

Parameters:

  • mu (Float, nil) (defaults to: nil)

    override default mu

  • sigma (Float, nil) (defaults to: nil)

    override default sigma

  • name (String, nil) (defaults to: nil)

    optional player name

Returns:

  • (Rating)

    a new rating object



61
62
63
64
65
66
67
# File 'lib/openskill/models/bradley_terry_part.rb', line 61

def create_rating(mu: nil, sigma: nil, name: nil)
  Rating.new(
    mu: mu || @mu,
    sigma: sigma || @sigma,
    name: name
  )
end

#load_rating(rating_array, name: nil) ⇒ Rating

Load a rating from an array [mu, sigma]

Parameters:

  • rating_array (Array<Numeric>)
    mu, sigma
  • name (String, nil) (defaults to: nil)

    optional player name

Returns:

  • (Rating)

    a new rating object

Raises:

  • (ArgumentError)

    if rating_array is invalid



75
76
77
78
79
80
81
# File 'lib/openskill/models/bradley_terry_part.rb', line 75

def load_rating(rating_array, name: nil)
  raise ArgumentError, "Rating must be an Array, got #{rating_array.class}" unless rating_array.is_a?(Array)
  raise ArgumentError, 'Rating array must have exactly 2 elements' unless rating_array.size == 2
  raise ArgumentError, 'Rating values must be numeric' unless rating_array.all? { |v| v.is_a?(Numeric) }

  Rating.new(mu: rating_array[0], sigma: rating_array[1], name: name)
end

#predict_draw_probability(teams) ⇒ Float

Predict draw probability

Parameters:

  • teams (Array<Array<Rating>>)

    list of teams

Returns:

  • (Float)

    probability of a draw



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/openskill/models/bradley_terry_part.rb', line 200

def predict_draw_probability(teams)
  validate_teams!(teams)

  total_player_count = teams.sum(&:size)
  draw_probability = 1.0 / total_player_count
  draw_margin = Math.sqrt(total_player_count) * @beta * phi_major_inverse((1 + draw_probability) / 2)

  pairwise_probs = []
  teams.combination(2).each do |team_a, team_b|
    team_a_ratings = calculate_team_ratings([team_a])
    team_b_ratings = calculate_team_ratings([team_b])

    mu_a = team_a_ratings[0].mu
    sigma_a = team_a_ratings[0].sigma_squared
    mu_b = team_b_ratings[0].mu
    sigma_b = team_b_ratings[0].sigma_squared

    denominator = Math.sqrt(2 * @beta**2 + sigma_a + sigma_b)

    pairwise_probs << (
      phi_major((draw_margin - mu_a + mu_b) / denominator) -
      phi_major((mu_b - mu_a - draw_margin) / denominator)
    )
  end

  pairwise_probs.sum / pairwise_probs.size
end

#predict_rank_probability(teams) ⇒ Array<Array(Integer, Float)>

Predict rank probability for each team

Parameters:

  • teams (Array<Array<Rating>>)

    list of teams

Returns:

  • (Array<Array(Integer, Float)>)

    rank and probability for each team



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/openskill/models/bradley_terry_part.rb', line 232

def predict_rank_probability(teams)
  validate_teams!(teams)

  n = teams.size
  team_ratings = calculate_team_ratings(teams)

  # Calculate win probability for each team against all others
  win_probs = team_ratings.map do |team_i|
    prob = 0.0
    team_ratings.each do |team_j|
      next if team_i == team_j

      prob += phi_major(
        (team_i.mu - team_j.mu) /
        Math.sqrt(2 * @beta**2 + team_i.sigma_squared + team_j.sigma_squared)
      )
    end
    prob / (n - 1)
  end

  # Normalize probabilities
  total = win_probs.sum
  normalized_probs = win_probs.map { |p| p / total }

  # Sort by probability (descending) and assign ranks
  sorted_indices = normalized_probs.each_with_index.sort_by { |prob, _| -prob }
  ranks = Array.new(n)

  current_rank = 1
  sorted_indices.each_with_index do |(prob, team_idx), i|
    current_rank = i + 1 if i > 0 && prob < sorted_indices[i - 1][0]
    ranks[team_idx] = current_rank
  end

  ranks.zip(normalized_probs)
end

#predict_win_probability(teams) ⇒ Array<Float>

Predict win probability for each team

Parameters:

  • teams (Array<Array<Rating>>)

    list of teams

Returns:

  • (Array<Float>)

    probability each team wins



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/openskill/models/bradley_terry_part.rb', line 158

def predict_win_probability(teams)
  validate_teams!(teams)

  n = teams.size

  # Special case for 2 teams
  if n == 2
    team_ratings = calculate_team_ratings(teams)
    a = team_ratings[0]
    b = team_ratings[1]

    result = phi_major(
      (a.mu - b.mu) / Math.sqrt(2 * @beta**2 + a.sigma_squared + b.sigma_squared)
    )
    return [result, 1 - result]
  end

  # For n teams, compute pairwise probabilities
  team_ratings = teams.map { |team| calculate_team_ratings([team])[0] }

  win_probs = []
  team_ratings.each_with_index do |team_i, i|
    prob_sum = 0.0
    team_ratings.each_with_index do |team_j, j|
      next if i == j

      prob_sum += phi_major(
        (team_i.mu - team_j.mu) / Math.sqrt(2 * @beta**2 + team_i.sigma_squared + team_j.sigma_squared)
      )
    end
    win_probs << prob_sum / (n - 1)
  end

  # Normalize to sum to 1
  total = win_probs.sum
  win_probs.map { |p| p / total }
end