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):
14def bradley_terry_prob(z):
15    return base_10_sigmoid(z)
def bradley_terry_scalar_prob_g_h(z, outcome):
18def bradley_terry_scalar_prob_g_h(z, outcome):
19    prob = base_10_sigmoid(z)
20    g = LOG10 * (outcome - prob)
21    h = LOG10_SQUARED * prob * (1.0 - prob)
22    return prob, g, h
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
class VSKF(riix.core.base.OnlineRatingSystem):
 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.
rating_dim = 2
mus
vs
has_played
beta
beta2
s
s2
epsilon
prev_time_step
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

ratings
86    @property
87    def ratings(self):
88        return self.mus
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.