Class: OpenSkill::Models::PlackettLuce

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

Overview

Plackett-Luce rating model

This is a Bayesian rating system for multiplayer games that can handle teams of varying sizes and asymmetric matches.

Defined Under Namespace

Classes: Rating, TeamRating

Constant Summary collapse

DEFAULT_GAMMA =

Default gamma function for PlackettLuce

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) ⇒ PlackettLuce

Returns a new instance of PlackettLuce.

Parameters:

  • (defaults to: 25.0)

    initial mean skill rating

  • (defaults to: 25.0 / 3.0)

    initial standard deviation

  • (defaults to: 25.0 / 6.0)

    performance uncertainty

  • (defaults to: 0.0001)

    minimum variance (regularization)

  • (defaults to: DEFAULT_GAMMA)

    custom gamma function

  • (defaults to: 25.0 / 300.0)

    dynamics factor (skill decay)

  • (defaults to: 0.0)

    score margin for impressive wins

  • (defaults to: false)

    prevent sigma from increasing

  • (defaults to: false)

    emphasize rating outliers



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

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
)
  @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
end

Instance Attribute Details

#balanceObject (readonly)

Returns the value of attribute balance.



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

def balance
  @balance
end

#betaObject (readonly)

Returns the value of attribute beta.



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

def beta
  @beta
end

#gammaObject (readonly)

Returns the value of attribute gamma.



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

def gamma
  @gamma
end

#kappaObject (readonly)

Returns the value of attribute kappa.



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

def kappa
  @kappa
end

#limit_sigmaObject (readonly)

Returns the value of attribute limit_sigma.



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

def limit_sigma
  @limit_sigma
end

#marginObject (readonly)

Returns the value of attribute margin.



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

def margin
  @margin
end

#muObject (readonly)

Returns the value of attribute mu.



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

def mu
  @mu
end

#sigmaObject (readonly)

Returns the value of attribute sigma.



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

def sigma
  @sigma
end

#tauObject (readonly)

Returns the value of attribute tau.



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

def tau
  @tau
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:

  • list of teams

  • (defaults to: nil)

    team ranks (lower is better, 0-indexed)

  • (defaults to: nil)

    team scores (higher is better)

  • (defaults to: nil)

    player contribution weights

  • (defaults to: nil)

    override tau for this match

  • (defaults to: nil)

    override limit_sigma for this match

Returns:

  • updated teams

Raises:



89
90
91
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
# File 'lib/openskill/models/plackett_luce.rb', line 89

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

  # Can't have both ranks and scores
  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:

  • (defaults to: nil)

    override default mu

  • (defaults to: nil)

    override default sigma

  • (defaults to: nil)

    optional player name

Returns:

  • a new rating object



58
59
60
61
62
63
64
# File 'lib/openskill/models/plackett_luce.rb', line 58

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:

  • mu, sigma
  • (defaults to: nil)

    optional player name

Returns:

  • a new rating object

Raises:

  • if rating_array is invalid



72
73
74
75
76
77
78
# File 'lib/openskill/models/plackett_luce.rb', line 72

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:

  • list of teams

Returns:

  • probability of a draw



198
199
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
# File 'lib/openskill/models/plackett_luce.rb', line 198

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:

  • list of teams

Returns:

  • rank and probability for each team



230
231
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
# File 'lib/openskill/models/plackett_luce.rb', line 230

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:

  • list of teams

Returns:

  • probability each team wins



156
157
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
# File 'lib/openskill/models/plackett_luce.rb', line 156

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