riix.models.skf
simplified kalman filter
1"""simplified kalman filter""" 2import math 3import numpy as np 4from scipy.stats import norm 5from riix.core.base import OnlineRatingSystem 6from riix.utils.math_utils import base_10_sigmoid 7from riix.utils.math_utils import v_and_w_win_scalar, v_and_w_draw_scalar, norm_cdf 8 9LOG10 = math.log(10.0) 10LOG10_SQUARED = LOG10**2.0 11 12 13def bradley_terry_prob(z): 14 return base_10_sigmoid(z) 15 16 17def bradley_terry_scalar_prob_g_h(z, outcome): 18 prob = base_10_sigmoid(z) 19 g = LOG10 * (outcome - prob) 20 h = LOG10_SQUARED * prob * (1.0 - prob) 21 return prob, g, h 22 23 24def thurstone_mosteller_scalar_prob_g_h(z, outcome): 25 prob = norm_cdf(z) 26 if outcome != 0.5: 27 sign_multiplier = (2 * outcome) - 1 # maps 1 to 1 and 0 to -1 28 v, w = v_and_w_win_scalar(z * sign_multiplier, 0.0) 29 v = v * sign_multiplier 30 else: 31 v, w = v_and_w_draw_scalar(z, 0.0) 32 return prob, v, w 33 34 35class VSKF(OnlineRatingSystem): 36 """vector covariance simplified kalman filter""" 37 38 rating_dim = 2 39 40 def __init__( 41 self, 42 competitors: list, 43 mu_0: float = 0.0, 44 v_0: float = 1.0, 45 beta: float = 1.0, 46 s: float = 1.0, 47 epsilon: float = 1e-2, 48 dtype=np.float64, 49 model='bt', # legal values are bt, tm, and dd 50 update_method='online', 51 ): 52 super().__init__(competitors) 53 self.mus = np.zeros(self.num_competitors, dtype=dtype) + mu_0 54 self.vs = np.zeros(self.num_competitors, dtype=dtype) + v_0 55 self.has_played = np.zeros(self.num_competitors, dtype=np.bool_) 56 self.beta = beta 57 self.beta2 = beta**2.0 58 self.s = s 59 self.s2 = s**2.0 60 self.epsilon = epsilon 61 self.prev_time_step = 0 62 63 if update_method == 'batched': 64 self.update = self.batched_update 65 elif update_method == 'online': 66 self.update = self.online_update 67 else: 68 raise ValueError(f'update_method must be one of online or batched, got {update_method}') 69 70 if model == 'bt': 71 self.predict_func = bradley_terry_prob 72 self.prob_g_h_func = bradley_terry_scalar_prob_g_h 73 elif model == 'tm': 74 self.predict_func = norm.cdf 75 self.prob_g_h_func = thurstone_mosteller_scalar_prob_g_h 76 77 def predict(self, matchups: np.ndarray, set_cache: bool = False, **kwargs): 78 """generate predictions""" 79 ratings_1 = self.mus[matchups[:, 0]] 80 ratings_2 = self.mus[matchups[:, 1]] 81 rating_diffs = ratings_1 - ratings_2 82 probs = self.predict_func(rating_diffs / self.s) 83 return probs 84 85 @property 86 def ratings(self): 87 return self.mus 88 89 def get_pre_match_ratings(self, matchups: np.ndarray, **kwargs): 90 mus = self.mus[matchups] 91 vs = self.vs[matchups] 92 ratings = np.concatenate((mus[..., None], vs[..., None]), axis=2).reshape(mus.shape[0], -1) 93 return ratings 94 95 def time_dynamics_update(self, time_step, matchups): 96 """called once per period to model the increase in variance over time""" 97 active_in_period = np.unique(matchups) 98 self.has_played[active_in_period] = True 99 time_delta = time_step - self.prev_time_step 100 101 # update parameters for passage of time 102 beta_t = self.beta**time_delta 103 self.mus[self.has_played] = beta_t * self.mus[self.has_played] 104 self.vs[self.has_played] = (beta_t**2) * self.vs[self.has_played] + (time_delta * self.epsilon) 105 self.prev_time_step = time_step 106 107 return active_in_period 108 109 def batched_update(self, matchups, outcomes, time_step, use_cache=False): 110 """apply one update based on all of the results of the rating period""" 111 active_in_period = self.time_dynamics_update(time_step, matchups) 112 masks = np.equal(matchups[:, :, None], active_in_period[None, :]) # N x 2 x active 113 114 mus = self.mus[matchups] 115 vs = self.vs[matchups] 116 117 omegas = np.sum(vs, axis=1) 118 probs = base_10_sigmoid((mus[:, 0] - mus[:, 1]) / self.s) 119 120 g = LOG10 * (outcomes - probs) 121 h = LOG10_SQUARED * probs * (1.0 - probs) 122 123 denom = (self.s2) + h * omegas 124 mu_updates = (self.s * g) / denom 125 v_updates = h / denom 126 127 mu_updates = mu_updates[:, None].repeat(2, 1) 128 mu_updates[:, 1] *= -1 129 130 v_updates = 1.0 - vs * v_updates[:, None] 131 132 mu_updates_pooled = (mu_updates[:, :, None] * masks).sum(axis=(0, 1)) 133 v_updates_pooled = (v_updates[:, :, None] * masks).max(axis=(0, 1)) 134 135 self.mus[active_in_period] += self.vs[active_in_period] * mu_updates_pooled 136 self.vs[active_in_period] *= v_updates_pooled 137 138 def online_update(self, matchups, outcomes, time_step, **kwargs): 139 """treat the matchups in the rating period as if they were sequential""" 140 self.time_dynamics_update(time_step, matchups) 141 for idx in range(matchups.shape[0]): 142 comp_1, comp_2 = matchups[idx] 143 mu_1 = self.mus[comp_1] 144 mu_2 = self.mus[comp_2] 145 v_1 = self.vs[comp_1] 146 v_2 = self.vs[comp_2] 147 outcome = outcomes[idx] 148 149 omega = v_1 + v_2 150 z = (mu_1 - mu_2) / self.s 151 152 prob, g, h = self.prob_g_h_func(z, outcome) 153 154 denom = (self.s2) + h * omega 155 mu_update = (self.s * g) / denom 156 157 v_update = h / denom 158 159 self.mus[comp_1] += v_1 * mu_update 160 self.mus[comp_2] -= v_2 * mu_update 161 self.vs[comp_1] *= 1.0 - v_1 * v_update 162 self.vs[comp_2] *= 1.0 - v_2 * v_update 163 164 def print_leaderboard(self, num_places): 165 sort_array = self.mus - (3.0 * np.sqrt(self.vs)) 166 sorted_idxs = np.argsort(-sort_array)[:num_places] 167 max_len = min(np.max([len(comp) for comp in self.competitors] + [10]), 25) 168 print(f'{"competitor": <{max_len}}\t{"mu - (3 * sd)"}') 169 for p_idx in range(num_places): 170 comp_idx = sorted_idxs[p_idx] 171 print(f'{self.competitors[comp_idx]: <{max_len}}\t{sort_array[comp_idx]:.6f}')
LOG10 =
2.302585092994046
LOG10_SQUARED =
5.301898110478399
def
bradley_terry_prob(z):
def
bradley_terry_scalar_prob_g_h(z, outcome):
def
thurstone_mosteller_scalar_prob_g_h(z, outcome):
25def thurstone_mosteller_scalar_prob_g_h(z, outcome): 26 prob = norm_cdf(z) 27 if outcome != 0.5: 28 sign_multiplier = (2 * outcome) - 1 # maps 1 to 1 and 0 to -1 29 v, w = v_and_w_win_scalar(z * sign_multiplier, 0.0) 30 v = v * sign_multiplier 31 else: 32 v, w = v_and_w_draw_scalar(z, 0.0) 33 return prob, v, w
36class VSKF(OnlineRatingSystem): 37 """vector covariance simplified kalman filter""" 38 39 rating_dim = 2 40 41 def __init__( 42 self, 43 competitors: list, 44 mu_0: float = 0.0, 45 v_0: float = 1.0, 46 beta: float = 1.0, 47 s: float = 1.0, 48 epsilon: float = 1e-2, 49 dtype=np.float64, 50 model='bt', # legal values are bt, tm, and dd 51 update_method='online', 52 ): 53 super().__init__(competitors) 54 self.mus = np.zeros(self.num_competitors, dtype=dtype) + mu_0 55 self.vs = np.zeros(self.num_competitors, dtype=dtype) + v_0 56 self.has_played = np.zeros(self.num_competitors, dtype=np.bool_) 57 self.beta = beta 58 self.beta2 = beta**2.0 59 self.s = s 60 self.s2 = s**2.0 61 self.epsilon = epsilon 62 self.prev_time_step = 0 63 64 if update_method == 'batched': 65 self.update = self.batched_update 66 elif update_method == 'online': 67 self.update = self.online_update 68 else: 69 raise ValueError(f'update_method must be one of online or batched, got {update_method}') 70 71 if model == 'bt': 72 self.predict_func = bradley_terry_prob 73 self.prob_g_h_func = bradley_terry_scalar_prob_g_h 74 elif model == 'tm': 75 self.predict_func = norm.cdf 76 self.prob_g_h_func = thurstone_mosteller_scalar_prob_g_h 77 78 def predict(self, matchups: np.ndarray, set_cache: bool = False, **kwargs): 79 """generate predictions""" 80 ratings_1 = self.mus[matchups[:, 0]] 81 ratings_2 = self.mus[matchups[:, 1]] 82 rating_diffs = ratings_1 - ratings_2 83 probs = self.predict_func(rating_diffs / self.s) 84 return probs 85 86 @property 87 def ratings(self): 88 return self.mus 89 90 def get_pre_match_ratings(self, matchups: np.ndarray, **kwargs): 91 mus = self.mus[matchups] 92 vs = self.vs[matchups] 93 ratings = np.concatenate((mus[..., None], vs[..., None]), axis=2).reshape(mus.shape[0], -1) 94 return ratings 95 96 def time_dynamics_update(self, time_step, matchups): 97 """called once per period to model the increase in variance over time""" 98 active_in_period = np.unique(matchups) 99 self.has_played[active_in_period] = True 100 time_delta = time_step - self.prev_time_step 101 102 # update parameters for passage of time 103 beta_t = self.beta**time_delta 104 self.mus[self.has_played] = beta_t * self.mus[self.has_played] 105 self.vs[self.has_played] = (beta_t**2) * self.vs[self.has_played] + (time_delta * self.epsilon) 106 self.prev_time_step = time_step 107 108 return active_in_period 109 110 def batched_update(self, matchups, outcomes, time_step, use_cache=False): 111 """apply one update based on all of the results of the rating period""" 112 active_in_period = self.time_dynamics_update(time_step, matchups) 113 masks = np.equal(matchups[:, :, None], active_in_period[None, :]) # N x 2 x active 114 115 mus = self.mus[matchups] 116 vs = self.vs[matchups] 117 118 omegas = np.sum(vs, axis=1) 119 probs = base_10_sigmoid((mus[:, 0] - mus[:, 1]) / self.s) 120 121 g = LOG10 * (outcomes - probs) 122 h = LOG10_SQUARED * probs * (1.0 - probs) 123 124 denom = (self.s2) + h * omegas 125 mu_updates = (self.s * g) / denom 126 v_updates = h / denom 127 128 mu_updates = mu_updates[:, None].repeat(2, 1) 129 mu_updates[:, 1] *= -1 130 131 v_updates = 1.0 - vs * v_updates[:, None] 132 133 mu_updates_pooled = (mu_updates[:, :, None] * masks).sum(axis=(0, 1)) 134 v_updates_pooled = (v_updates[:, :, None] * masks).max(axis=(0, 1)) 135 136 self.mus[active_in_period] += self.vs[active_in_period] * mu_updates_pooled 137 self.vs[active_in_period] *= v_updates_pooled 138 139 def online_update(self, matchups, outcomes, time_step, **kwargs): 140 """treat the matchups in the rating period as if they were sequential""" 141 self.time_dynamics_update(time_step, matchups) 142 for idx in range(matchups.shape[0]): 143 comp_1, comp_2 = matchups[idx] 144 mu_1 = self.mus[comp_1] 145 mu_2 = self.mus[comp_2] 146 v_1 = self.vs[comp_1] 147 v_2 = self.vs[comp_2] 148 outcome = outcomes[idx] 149 150 omega = v_1 + v_2 151 z = (mu_1 - mu_2) / self.s 152 153 prob, g, h = self.prob_g_h_func(z, outcome) 154 155 denom = (self.s2) + h * omega 156 mu_update = (self.s * g) / denom 157 158 v_update = h / denom 159 160 self.mus[comp_1] += v_1 * mu_update 161 self.mus[comp_2] -= v_2 * mu_update 162 self.vs[comp_1] *= 1.0 - v_1 * v_update 163 self.vs[comp_2] *= 1.0 - v_2 * v_update 164 165 def print_leaderboard(self, num_places): 166 sort_array = self.mus - (3.0 * np.sqrt(self.vs)) 167 sorted_idxs = np.argsort(-sort_array)[:num_places] 168 max_len = min(np.max([len(comp) for comp in self.competitors] + [10]), 25) 169 print(f'{"competitor": <{max_len}}\t{"mu - (3 * sd)"}') 170 for p_idx in range(num_places): 171 comp_idx = sorted_idxs[p_idx] 172 print(f'{self.competitors[comp_idx]: <{max_len}}\t{sort_array[comp_idx]:.6f}')
vector covariance simplified kalman filter
VSKF( competitors: list, mu_0: float = 0.0, v_0: float = 1.0, beta: float = 1.0, s: float = 1.0, epsilon: float = 0.01, dtype=<class 'numpy.float64'>, model='bt', update_method='online')
41 def __init__( 42 self, 43 competitors: list, 44 mu_0: float = 0.0, 45 v_0: float = 1.0, 46 beta: float = 1.0, 47 s: float = 1.0, 48 epsilon: float = 1e-2, 49 dtype=np.float64, 50 model='bt', # legal values are bt, tm, and dd 51 update_method='online', 52 ): 53 super().__init__(competitors) 54 self.mus = np.zeros(self.num_competitors, dtype=dtype) + mu_0 55 self.vs = np.zeros(self.num_competitors, dtype=dtype) + v_0 56 self.has_played = np.zeros(self.num_competitors, dtype=np.bool_) 57 self.beta = beta 58 self.beta2 = beta**2.0 59 self.s = s 60 self.s2 = s**2.0 61 self.epsilon = epsilon 62 self.prev_time_step = 0 63 64 if update_method == 'batched': 65 self.update = self.batched_update 66 elif update_method == 'online': 67 self.update = self.online_update 68 else: 69 raise ValueError(f'update_method must be one of online or batched, got {update_method}') 70 71 if model == 'bt': 72 self.predict_func = bradley_terry_prob 73 self.prob_g_h_func = bradley_terry_scalar_prob_g_h 74 elif model == 'tm': 75 self.predict_func = norm.cdf 76 self.prob_g_h_func = thurstone_mosteller_scalar_prob_g_h
Initializes a new instance of an online rating system with a list of competitors.
Arguments:
- competitors (list): A list of competitors to be included in the rating system. Each competitor should have a structure or identifier compatible with the specific rating system's requirements.
def
predict(self, matchups: numpy.ndarray, set_cache: bool = False, **kwargs):
78 def predict(self, matchups: np.ndarray, set_cache: bool = False, **kwargs): 79 """generate predictions""" 80 ratings_1 = self.mus[matchups[:, 0]] 81 ratings_2 = self.mus[matchups[:, 1]] 82 rating_diffs = ratings_1 - ratings_2 83 probs = self.predict_func(rating_diffs / self.s) 84 return probs
generate predictions
def
get_pre_match_ratings(self, matchups: numpy.ndarray, **kwargs):
90 def get_pre_match_ratings(self, matchups: np.ndarray, **kwargs): 91 mus = self.mus[matchups] 92 vs = self.vs[matchups] 93 ratings = np.concatenate((mus[..., None], vs[..., None]), axis=2).reshape(mus.shape[0], -1) 94 return ratings
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
time_dynamics_update(self, time_step, matchups):
96 def time_dynamics_update(self, time_step, matchups): 97 """called once per period to model the increase in variance over time""" 98 active_in_period = np.unique(matchups) 99 self.has_played[active_in_period] = True 100 time_delta = time_step - self.prev_time_step 101 102 # update parameters for passage of time 103 beta_t = self.beta**time_delta 104 self.mus[self.has_played] = beta_t * self.mus[self.has_played] 105 self.vs[self.has_played] = (beta_t**2) * self.vs[self.has_played] + (time_delta * self.epsilon) 106 self.prev_time_step = time_step 107 108 return active_in_period
called once per period to model the increase in variance over time
def
batched_update(self, matchups, outcomes, time_step, use_cache=False):
110 def batched_update(self, matchups, outcomes, time_step, use_cache=False): 111 """apply one update based on all of the results of the rating period""" 112 active_in_period = self.time_dynamics_update(time_step, matchups) 113 masks = np.equal(matchups[:, :, None], active_in_period[None, :]) # N x 2 x active 114 115 mus = self.mus[matchups] 116 vs = self.vs[matchups] 117 118 omegas = np.sum(vs, axis=1) 119 probs = base_10_sigmoid((mus[:, 0] - mus[:, 1]) / self.s) 120 121 g = LOG10 * (outcomes - probs) 122 h = LOG10_SQUARED * probs * (1.0 - probs) 123 124 denom = (self.s2) + h * omegas 125 mu_updates = (self.s * g) / denom 126 v_updates = h / denom 127 128 mu_updates = mu_updates[:, None].repeat(2, 1) 129 mu_updates[:, 1] *= -1 130 131 v_updates = 1.0 - vs * v_updates[:, None] 132 133 mu_updates_pooled = (mu_updates[:, :, None] * masks).sum(axis=(0, 1)) 134 v_updates_pooled = (v_updates[:, :, None] * masks).max(axis=(0, 1)) 135 136 self.mus[active_in_period] += self.vs[active_in_period] * mu_updates_pooled 137 self.vs[active_in_period] *= v_updates_pooled
apply one update based on all of the results of the rating period
def
online_update(self, matchups, outcomes, time_step, **kwargs):
139 def online_update(self, matchups, outcomes, time_step, **kwargs): 140 """treat the matchups in the rating period as if they were sequential""" 141 self.time_dynamics_update(time_step, matchups) 142 for idx in range(matchups.shape[0]): 143 comp_1, comp_2 = matchups[idx] 144 mu_1 = self.mus[comp_1] 145 mu_2 = self.mus[comp_2] 146 v_1 = self.vs[comp_1] 147 v_2 = self.vs[comp_2] 148 outcome = outcomes[idx] 149 150 omega = v_1 + v_2 151 z = (mu_1 - mu_2) / self.s 152 153 prob, g, h = self.prob_g_h_func(z, outcome) 154 155 denom = (self.s2) + h * omega 156 mu_update = (self.s * g) / denom 157 158 v_update = h / denom 159 160 self.mus[comp_1] += v_1 * mu_update 161 self.mus[comp_2] -= v_2 * mu_update 162 self.vs[comp_1] *= 1.0 - v_1 * v_update 163 self.vs[comp_2] *= 1.0 - v_2 * v_update
treat the matchups in the rating period as if they were sequential
def
print_leaderboard(self, num_places):
165 def print_leaderboard(self, num_places): 166 sort_array = self.mus - (3.0 * np.sqrt(self.vs)) 167 sorted_idxs = np.argsort(-sort_array)[:num_places] 168 max_len = min(np.max([len(comp) for comp in self.competitors] + [10]), 25) 169 print(f'{"competitor": <{max_len}}\t{"mu - (3 * sd)"}') 170 for p_idx in range(num_places): 171 comp_idx = sorted_idxs[p_idx] 172 print(f'{self.competitors[comp_idx]: <{max_len}}\t{sort_array[comp_idx]:.6f}')
Prints the leaderboard of the rating system.
Arguments:
- num_places int: The number of top places to display on the leaderboard.