riix.models.melo
Multidimensional Elo
1"""Multidimensional Elo""" 2import math 3import numpy as np 4from riix.core.base import OnlineRatingSystem 5from riix.models.elo import Elo 6from riix.utils.math_utils import sigmoid 7 8 9def generate_orthogonal_matrix(d, k): 10 # Constraint check 11 if d > k + 1: 12 raise ValueError('d must be less than or equal to k + 1') 13 14 # Initialize the matrix with zeros 15 matrix = np.zeros((d, k)) 16 17 # Generate a base pattern 18 base_pattern = np.array([1.0, -1.0] * (k // 2) + [1.0] * (k % 2)) 19 20 # Fill the first d-1 rows 21 for i in range(d - 1): 22 shift = i % k # Calculate the shift for the pattern 23 matrix[i] = np.roll(base_pattern, shift) 24 25 # Fill the d-th row 26 matrix[-1] = -np.sum(matrix[:-1], axis=0) 27 return matrix 28 29 30class Melo(Elo): 31 """Multidimensional Elo rating system, (good for rock paper scissors problems)""" 32 33 rating_dim = 1 34 35 def __init__( 36 self, 37 competitors: list, 38 initial_rating: float = 1500.0, 39 dimension: int = 1, # this is the k in melo_2k, not sure why they had to use that letter when it's already used in Elo smh 40 eta_r: float = 32.0, # this is the normal elo k factor 41 eta_c: float = 0.125, # 1 is bad: https://dclaz.github.io/mELO/articles/03_noise.html#simulations-1 42 alpha: float = math.log(10.0) / 400.0, 43 update_method: str = 'online', 44 dtype=np.float64, 45 ): 46 OnlineRatingSystem.__init__(self, competitors) 47 self.eta_r = eta_r 48 self.eta_c = eta_c 49 self.alpha = alpha 50 self.ratings = np.zeros(shape=self.num_competitors, dtype=dtype) + initial_rating 51 two_k = 2 * dimension 52 self.c = generate_orthogonal_matrix(d=two_k, k=self.num_competitors).T # / 10.0 53 54 # rng = np.random.default_rng(42) 55 # self.c = rng.uniform(low=-1.0, high=1.0, size=(num_competitors, two_k)) 56 57 self.omega = np.zeros((two_k, two_k), dtype=np.int32) 58 # Set every other off-diagonal element to 1 or -1 59 self.omega[np.arange(0, two_k - 1, 2), np.arange(1, two_k, 2)] = 1 # Upper off diagonal 60 self.omega[np.arange(1, two_k, 2), np.arange(0, two_k - 1, 2)] = -1 # Lower off diagonal 61 62 if update_method == 'batched': 63 self.update = self.batched_update 64 if update_method == 'online': 65 self.update = self.online_update 66 67 self.cache = {'probs': None, 'c_1_times_omega': None} 68 69 def get_pre_match_ratings(self, matchups, **kwargs): 70 return self.ratings[matchups] 71 72 def predict(self, matchups: np.ndarray, set_cache: bool = False, **kwargs): 73 """generate predictions""" 74 ratings_1 = self.ratings[matchups[:, 0]] 75 ratings_2 = self.ratings[matchups[:, 1]] 76 c_1 = self.c[matchups[:, 0], None, :] # [bs, 1, 2k] 77 c_2 = self.c[matchups[:, 1], :, None] # [bs, 2k, 1] 78 elo_diff = ratings_1 - ratings_2 79 c_1_times_omega = np.matmul(c_1, self.omega) 80 melo_diff = np.matmul(c_1_times_omega, c_2)[:, 0, 0] 81 probs = sigmoid(self.alpha * (elo_diff + melo_diff)) 82 if set_cache: 83 self.cache['probs'] = probs 84 self.cache['c_1_times_omega'] = c_1_times_omega.squeeze(axis=1) 85 86 return probs 87 88 def batched_update(self, matchups, outcomes, time_step=None, use_cache=False): 89 """apply one update based on all of the results of the rating period""" 90 active_in_period = np.unique(matchups) 91 masks = np.equal(matchups[:, :, None], active_in_period[None, :]) # N x 2 x active 92 93 c_1 = self.c[matchups[:, 0], :] # [bs, 2k] 94 c_2 = self.c[matchups[:, 1], :] # [bs, 2k] 95 96 if use_cache: 97 probs = self.cache['probs'] 98 dp_dc_2 = self.cache['c_1_times_omega'] 99 else: 100 probs = self.predict(time_step=None, matchups=matchups, set_cache=False) 101 dp_dc_2 = np.matmul(c_1, self.omega) 102 103 per_match_diff = (outcomes - probs)[:, None] 104 per_match_diff = np.hstack([per_match_diff, -per_match_diff]) 105 per_competitor_diff = (per_match_diff[:, :, None] * masks).sum(axis=(0, 1)) 106 107 dp_dc_1 = np.matmul(c_2, self.omega) 108 dp_dc = np.stack([dp_dc_1, -1.0 * dp_dc_2], axis=1) 109 c_updates = self.eta_c * per_match_diff[:, :, None] * dp_dc 110 c_updates_pooled = (c_updates[:, :, None] * masks[:, :, :, None]).sum(axis=(0, 1)) 111 112 self.ratings[active_in_period] += self.eta_r * per_competitor_diff 113 self.c[active_in_period] += c_updates_pooled 114 115 def online_update(self, matchups, outcomes, use_cache=False, **kwargs): 116 """treat the matchups in the rating period as if they were sequential""" 117 for idx in range(matchups.shape[0]): 118 comp_1, comp_2 = matchups[idx] 119 elo_diff = self.ratings[comp_1] - self.ratings[comp_2] 120 c_1 = self.c[comp_1] 121 c_2 = self.c[comp_2] 122 123 melo_diff = np.dot(c_1, np.dot(self.omega, c_2)).item() 124 prob = sigmoid(self.alpha * (elo_diff + melo_diff)) 125 delta = outcomes[idx] - prob 126 127 rating_update = self.eta_r * delta 128 129 self.ratings[comp_1] += rating_update 130 self.ratings[comp_2] -= rating_update 131 132 dp_dc_1 = np.dot(self.omega, c_2).flatten() 133 dp_dc_2 = np.dot(self.omega, c_1).flatten() 134 135 self.c[comp_1] += self.eta_c * delta * dp_dc_1 136 self.c[comp_2] -= self.eta_c * delta * dp_dc_2
def
generate_orthogonal_matrix(d, k):
10def generate_orthogonal_matrix(d, k): 11 # Constraint check 12 if d > k + 1: 13 raise ValueError('d must be less than or equal to k + 1') 14 15 # Initialize the matrix with zeros 16 matrix = np.zeros((d, k)) 17 18 # Generate a base pattern 19 base_pattern = np.array([1.0, -1.0] * (k // 2) + [1.0] * (k % 2)) 20 21 # Fill the first d-1 rows 22 for i in range(d - 1): 23 shift = i % k # Calculate the shift for the pattern 24 matrix[i] = np.roll(base_pattern, shift) 25 26 # Fill the d-th row 27 matrix[-1] = -np.sum(matrix[:-1], axis=0) 28 return matrix
31class Melo(Elo): 32 """Multidimensional Elo rating system, (good for rock paper scissors problems)""" 33 34 rating_dim = 1 35 36 def __init__( 37 self, 38 competitors: list, 39 initial_rating: float = 1500.0, 40 dimension: int = 1, # this is the k in melo_2k, not sure why they had to use that letter when it's already used in Elo smh 41 eta_r: float = 32.0, # this is the normal elo k factor 42 eta_c: float = 0.125, # 1 is bad: https://dclaz.github.io/mELO/articles/03_noise.html#simulations-1 43 alpha: float = math.log(10.0) / 400.0, 44 update_method: str = 'online', 45 dtype=np.float64, 46 ): 47 OnlineRatingSystem.__init__(self, competitors) 48 self.eta_r = eta_r 49 self.eta_c = eta_c 50 self.alpha = alpha 51 self.ratings = np.zeros(shape=self.num_competitors, dtype=dtype) + initial_rating 52 two_k = 2 * dimension 53 self.c = generate_orthogonal_matrix(d=two_k, k=self.num_competitors).T # / 10.0 54 55 # rng = np.random.default_rng(42) 56 # self.c = rng.uniform(low=-1.0, high=1.0, size=(num_competitors, two_k)) 57 58 self.omega = np.zeros((two_k, two_k), dtype=np.int32) 59 # Set every other off-diagonal element to 1 or -1 60 self.omega[np.arange(0, two_k - 1, 2), np.arange(1, two_k, 2)] = 1 # Upper off diagonal 61 self.omega[np.arange(1, two_k, 2), np.arange(0, two_k - 1, 2)] = -1 # Lower off diagonal 62 63 if update_method == 'batched': 64 self.update = self.batched_update 65 if update_method == 'online': 66 self.update = self.online_update 67 68 self.cache = {'probs': None, 'c_1_times_omega': None} 69 70 def get_pre_match_ratings(self, matchups, **kwargs): 71 return self.ratings[matchups] 72 73 def predict(self, matchups: np.ndarray, set_cache: bool = False, **kwargs): 74 """generate predictions""" 75 ratings_1 = self.ratings[matchups[:, 0]] 76 ratings_2 = self.ratings[matchups[:, 1]] 77 c_1 = self.c[matchups[:, 0], None, :] # [bs, 1, 2k] 78 c_2 = self.c[matchups[:, 1], :, None] # [bs, 2k, 1] 79 elo_diff = ratings_1 - ratings_2 80 c_1_times_omega = np.matmul(c_1, self.omega) 81 melo_diff = np.matmul(c_1_times_omega, c_2)[:, 0, 0] 82 probs = sigmoid(self.alpha * (elo_diff + melo_diff)) 83 if set_cache: 84 self.cache['probs'] = probs 85 self.cache['c_1_times_omega'] = c_1_times_omega.squeeze(axis=1) 86 87 return probs 88 89 def batched_update(self, matchups, outcomes, time_step=None, use_cache=False): 90 """apply one update based on all of the results of the rating period""" 91 active_in_period = np.unique(matchups) 92 masks = np.equal(matchups[:, :, None], active_in_period[None, :]) # N x 2 x active 93 94 c_1 = self.c[matchups[:, 0], :] # [bs, 2k] 95 c_2 = self.c[matchups[:, 1], :] # [bs, 2k] 96 97 if use_cache: 98 probs = self.cache['probs'] 99 dp_dc_2 = self.cache['c_1_times_omega'] 100 else: 101 probs = self.predict(time_step=None, matchups=matchups, set_cache=False) 102 dp_dc_2 = np.matmul(c_1, self.omega) 103 104 per_match_diff = (outcomes - probs)[:, None] 105 per_match_diff = np.hstack([per_match_diff, -per_match_diff]) 106 per_competitor_diff = (per_match_diff[:, :, None] * masks).sum(axis=(0, 1)) 107 108 dp_dc_1 = np.matmul(c_2, self.omega) 109 dp_dc = np.stack([dp_dc_1, -1.0 * dp_dc_2], axis=1) 110 c_updates = self.eta_c * per_match_diff[:, :, None] * dp_dc 111 c_updates_pooled = (c_updates[:, :, None] * masks[:, :, :, None]).sum(axis=(0, 1)) 112 113 self.ratings[active_in_period] += self.eta_r * per_competitor_diff 114 self.c[active_in_period] += c_updates_pooled 115 116 def online_update(self, matchups, outcomes, use_cache=False, **kwargs): 117 """treat the matchups in the rating period as if they were sequential""" 118 for idx in range(matchups.shape[0]): 119 comp_1, comp_2 = matchups[idx] 120 elo_diff = self.ratings[comp_1] - self.ratings[comp_2] 121 c_1 = self.c[comp_1] 122 c_2 = self.c[comp_2] 123 124 melo_diff = np.dot(c_1, np.dot(self.omega, c_2)).item() 125 prob = sigmoid(self.alpha * (elo_diff + melo_diff)) 126 delta = outcomes[idx] - prob 127 128 rating_update = self.eta_r * delta 129 130 self.ratings[comp_1] += rating_update 131 self.ratings[comp_2] -= rating_update 132 133 dp_dc_1 = np.dot(self.omega, c_2).flatten() 134 dp_dc_2 = np.dot(self.omega, c_1).flatten() 135 136 self.c[comp_1] += self.eta_c * delta * dp_dc_1 137 self.c[comp_2] -= self.eta_c * delta * dp_dc_2
Multidimensional Elo rating system, (good for rock paper scissors problems)
Melo( competitors: list, initial_rating: float = 1500.0, dimension: int = 1, eta_r: float = 32.0, eta_c: float = 0.125, alpha: float = 0.005756462732485115, update_method: str = 'online', dtype=<class 'numpy.float64'>)
36 def __init__( 37 self, 38 competitors: list, 39 initial_rating: float = 1500.0, 40 dimension: int = 1, # this is the k in melo_2k, not sure why they had to use that letter when it's already used in Elo smh 41 eta_r: float = 32.0, # this is the normal elo k factor 42 eta_c: float = 0.125, # 1 is bad: https://dclaz.github.io/mELO/articles/03_noise.html#simulations-1 43 alpha: float = math.log(10.0) / 400.0, 44 update_method: str = 'online', 45 dtype=np.float64, 46 ): 47 OnlineRatingSystem.__init__(self, competitors) 48 self.eta_r = eta_r 49 self.eta_c = eta_c 50 self.alpha = alpha 51 self.ratings = np.zeros(shape=self.num_competitors, dtype=dtype) + initial_rating 52 two_k = 2 * dimension 53 self.c = generate_orthogonal_matrix(d=two_k, k=self.num_competitors).T # / 10.0 54 55 # rng = np.random.default_rng(42) 56 # self.c = rng.uniform(low=-1.0, high=1.0, size=(num_competitors, two_k)) 57 58 self.omega = np.zeros((two_k, two_k), dtype=np.int32) 59 # Set every other off-diagonal element to 1 or -1 60 self.omega[np.arange(0, two_k - 1, 2), np.arange(1, two_k, 2)] = 1 # Upper off diagonal 61 self.omega[np.arange(1, two_k, 2), np.arange(0, two_k - 1, 2)] = -1 # Lower off diagonal 62 63 if update_method == 'batched': 64 self.update = self.batched_update 65 if update_method == 'online': 66 self.update = self.online_update 67 68 self.cache = {'probs': None, 'c_1_times_omega': None}
Initializes the Elo rating system with the given parameters.
Arguments:
- competitors (list): A list of competitors to be rated within the system.
- initial_rating (float, optional): The initial Elo rating for new competitors. Defaults to 1500.0.
- k (float, optional): The K-factor, which controls the rate at which ratings change. Defaults to 32.0.
- alpha (float, optional): Scaling factor used in the calculation of expected scores. Defaults to log(10) / 400.
- update_method (str, optional): Method used to update ratings ('online' or other methods if implemented). Defaults to 'online'.
- dtype: The data type for internal numpy computations. Defaults to np.float64.
Initializes an Elo rating system with customizable settings for initial ratings, K-factor, and update method.
def
get_pre_match_ratings(self, matchups, **kwargs):
Returns the ratings for competitors at the timestep of the matchups Useful when using pre-match ratings as features in downstream ML pipelines
Arguments:
- matchups (np.ndarray of shape (n,2)): competitor indices
- time_step (optional int)
Returns:
np.ndarray of shape (n,2): ratings for specified competitors
def
predict(self, matchups: numpy.ndarray, set_cache: bool = False, **kwargs):
73 def predict(self, matchups: np.ndarray, set_cache: bool = False, **kwargs): 74 """generate predictions""" 75 ratings_1 = self.ratings[matchups[:, 0]] 76 ratings_2 = self.ratings[matchups[:, 1]] 77 c_1 = self.c[matchups[:, 0], None, :] # [bs, 1, 2k] 78 c_2 = self.c[matchups[:, 1], :, None] # [bs, 2k, 1] 79 elo_diff = ratings_1 - ratings_2 80 c_1_times_omega = np.matmul(c_1, self.omega) 81 melo_diff = np.matmul(c_1_times_omega, c_2)[:, 0, 0] 82 probs = sigmoid(self.alpha * (elo_diff + melo_diff)) 83 if set_cache: 84 self.cache['probs'] = probs 85 self.cache['c_1_times_omega'] = c_1_times_omega.squeeze(axis=1) 86 87 return probs
generate predictions
def
batched_update(self, matchups, outcomes, time_step=None, use_cache=False):
89 def batched_update(self, matchups, outcomes, time_step=None, use_cache=False): 90 """apply one update based on all of the results of the rating period""" 91 active_in_period = np.unique(matchups) 92 masks = np.equal(matchups[:, :, None], active_in_period[None, :]) # N x 2 x active 93 94 c_1 = self.c[matchups[:, 0], :] # [bs, 2k] 95 c_2 = self.c[matchups[:, 1], :] # [bs, 2k] 96 97 if use_cache: 98 probs = self.cache['probs'] 99 dp_dc_2 = self.cache['c_1_times_omega'] 100 else: 101 probs = self.predict(time_step=None, matchups=matchups, set_cache=False) 102 dp_dc_2 = np.matmul(c_1, self.omega) 103 104 per_match_diff = (outcomes - probs)[:, None] 105 per_match_diff = np.hstack([per_match_diff, -per_match_diff]) 106 per_competitor_diff = (per_match_diff[:, :, None] * masks).sum(axis=(0, 1)) 107 108 dp_dc_1 = np.matmul(c_2, self.omega) 109 dp_dc = np.stack([dp_dc_1, -1.0 * dp_dc_2], axis=1) 110 c_updates = self.eta_c * per_match_diff[:, :, None] * dp_dc 111 c_updates_pooled = (c_updates[:, :, None] * masks[:, :, :, None]).sum(axis=(0, 1)) 112 113 self.ratings[active_in_period] += self.eta_r * per_competitor_diff 114 self.c[active_in_period] += c_updates_pooled
apply one update based on all of the results of the rating period
def
online_update(self, matchups, outcomes, use_cache=False, **kwargs):
116 def online_update(self, matchups, outcomes, use_cache=False, **kwargs): 117 """treat the matchups in the rating period as if they were sequential""" 118 for idx in range(matchups.shape[0]): 119 comp_1, comp_2 = matchups[idx] 120 elo_diff = self.ratings[comp_1] - self.ratings[comp_2] 121 c_1 = self.c[comp_1] 122 c_2 = self.c[comp_2] 123 124 melo_diff = np.dot(c_1, np.dot(self.omega, c_2)).item() 125 prob = sigmoid(self.alpha * (elo_diff + melo_diff)) 126 delta = outcomes[idx] - prob 127 128 rating_update = self.eta_r * delta 129 130 self.ratings[comp_1] += rating_update 131 self.ratings[comp_2] -= rating_update 132 133 dp_dc_1 = np.dot(self.omega, c_2).flatten() 134 dp_dc_2 = np.dot(self.omega, c_1).flatten() 135 136 self.c[comp_1] += self.eta_c * delta * dp_dc_1 137 self.c[comp_2] -= self.eta_c * delta * dp_dc_2
treat the matchups in the rating period as if they were sequential