Source code for components.users

"""
Suite of classes related to users of the system, including predicted user-item
scores, predicted user profiles, actual user profiles, and a Users class (which
encapsulates some of these concepts).
"""
import numpy as np
import scipy.sparse as sp

import trecs.matrix_ops as mo
from trecs.random import Generator
from trecs.utils import check_consistency
from trecs.base import Component, BaseComponent


[docs]class PredictedScores(Component): # pylint: disable=too-many-ancestors """ User scores about items generated by the model. This class is a container compatible with Numpy operations and it does not make assumptions on the size of the representation. """ def __init__(self, predicted_scores=None, verbose=False): self.name = "predicted_user_scores" Component.__init__( self, current_state=predicted_scores, size=None, verbose=verbose, seed=None )
[docs] def filter_by_index(self, item_indices): """ Return a subset of the predicted scores, filtered by the indices of valid items. Parameters ------------- item_indices: :obj:`numpy.ndarray` or :obj:`scipy.sparse.spmatrix` A matrix with :math:`|U|` rows that specifies the indices of items requested for each user. """ if item_indices.shape[0] != self.current_state.shape[0]: error_msg = "Number of users does not match between score matrix and item index matrix" raise ValueError(error_msg) # generates row matrix like the following: # [0, 0, 0, ..., 0] # [1, 1, 1, ..., 1] # [ ... ] # [n, n, n, ..., n] num_users = item_indices.shape[0] row = np.repeat(np.arange(num_users), item_indices.shape[1]).reshape((num_users, -1)) # for now, we have to keep the score matrix a dense array because scipy # sparse has no equivalent of argsort # TODO: look into potential solutions using things like Numba to maintain # speed? # https://stackoverflow.com/questions/31790819/scipy-sparse-csr-matrix-how-to-get-top-ten-values-and-indices return mo.to_dense(self.current_state)[row, item_indices]
[docs] def append_item_scores(self, new_scores): """ Appends a set of scores for new items to the current set of scores. Parameters ------------- new_scores: :obj:`numpy.ndarray` or :obj:`scipy.sparse.spmatrix` Matrix of new scores with dimension :math:`|U|\\times|I_{new}|`, where :math:`I_{new}` indicates the number of new items whose scores are being appended. """ self.current_state = mo.hstack([self.current_state, new_scores])
[docs] def append_user_scores(self, new_scores): """ Appends a set of scores for new users to the current set of scores. Parameters ------------- new_scores: :obj:`numpy.ndarray` or :obj:`scipy.sparse.spmatrix` Matrix of new scores with dimension :math:`|U_{new}|\\times|I|`, where :math:`U_{new}` indicates the number of new users whose scores are being appended. """ self.current_state = mo.vstack([self.current_state, new_scores])
[docs]class PredictedUserProfiles(Component): # pylint: disable=too-many-ancestors """ User profiles as predicted by the model. This class is a container compatible with Numpy operations and it does not make assumptions on the size of the representation. TODO: can this be made a subclass of Users? """ def __init__(self, user_profiles=None, size=None, verbose=False, seed=None): self.name = "predicted_users" Component.__init__(self, current_state=user_profiles, size=size, verbose=verbose, seed=seed) @property def num_users(self): """ Shortcut getter method for the number of users. """ return self.current_state.shape[0] @property def num_attrs(self): """ Shortcut getter method for the number of attributes in each user profile. """ return self.current_state.shape[1]
[docs] def append_new_users(self, new_users): """ Appends a set of new users (represented as some kind of matrix) to the current set of items. Assumes the new users have dimension :math:`|U_{new}|\\times|A|`, where :math:`U_{new}` indicates the number of new items to be appended. """ self.current_state = mo.vstack([self.current_state, new_users])
[docs]class ActualUserProfiles(Component): # pylint: disable=too-many-ancestors """ True user profiles, unknown to the model. This class is a container compatible with Numpy operations and it does not make assumptions on the size of the representation. """ def __init__(self, user_profiles=None, size=None, verbose=False, seed=None): self.name = "actual_user_profiles" Component.__init__(self, current_state=user_profiles, size=size, verbose=verbose, seed=seed) @property def num_users(self): """ Shortcut getter method for the number of users. """ # rows = users, cols = items return self.current_state.shape[0]
[docs] def num_attrs(self): """ Shortcut getter method for the number of attributes in each user profile. """ return self.current_state.shape[1]
[docs] def append_new_users(self, new_users): """ Appends a set of new users (represented as some kind of matrix) to the current set of items. Assumes the new users have dimension :math:`|U_{new}|\\times|A|`, where :math:`U_{new}` indicates the number of new items to be appended. """ self.current_state = mo.vstack([self.current_state, new_users])
[docs]class ActualUserScores(Component): # pylint: disable=too-many-ancestors """ Matrix of true user-item scores, unknown to the model. """ def __init__(self, user_profiles=None, size=None, verbose=False, seed=None): self.name = "actual_user_scores" if user_profiles is not None: num_users, num_items = user_profiles.shape self.user_rows = np.repeat(np.arange(num_users), num_items).reshape((num_users, -1)) else: self.user_rows = None Component.__init__(self, current_state=user_profiles, size=size, verbose=verbose, seed=seed)
[docs] def get_item_scores(self, items_shown): """ Return the user scores for the items shown, in the order specified by the list of items shown to each user. """ if self.user_rows is None or self.user_rows.shape != self.current_state.shape: num_users, num_items = self.current_state.shape self.user_rows = np.repeat(np.arange(num_users), num_items).reshape((num_users, -1)) num_items = items_shown.shape[1] return self.current_state[self.user_rows[:, :num_items], items_shown]
[docs] def set_item_scores_to_value(self, item_indices, value): """ Set scores for the specified user-item indices to the determined value. Parameters ----------- item_indices: :obj:`numpy.ndarray` or :obj:`scipy.sparse.spmatrix` A matrix with :math:`|U|` rows that specifies the indices of items requested for each user. value: float Single value with which to replace scores. """ if self.user_rows is None or self.user_rows.shape != self.current_state.shape: num_users, num_items = self.current_state.shape self.user_rows = np.repeat(np.arange(num_users), num_items).reshape((num_users, -1)) num_items = item_indices.shape[1] self.current_state[self.user_rows[:, :num_items], item_indices] = value
[docs] def append_item_scores(self, new_scores): """ Appends a set of scores for new items to the current set of scores. Parameters ------------- new_scores: :obj:`numpy.ndarray` or :obj:`scipy.sparse.spmatrix` Matrix of new scores with dimension :math:`|U|\\times|I_{new}|`, where :math:`I_{new}` indicates the number of new items whose scores are being to be appended. """ self.current_state = mo.hstack([self.current_state, new_scores]) # update user rows matrix num_users, num_items = self.current_state.shape self.user_rows = np.repeat(np.arange(num_users), num_items).reshape((num_users, -1))
[docs] def append_user_scores(self, new_scores): """ Appends a set of scores for new users to the current set of scores. Parameters ------------- new_scores: :obj:`numpy.ndarray` or :obj:`scipy.sparse.spmatrix` Matrix of new scores with dimension :math:`|U_{new}|\\times|I|`, where :math:`U_{new}` indicates the number of new users whose scores are being appended. """ self.current_state = mo.vstack([self.current_state, new_scores]) # update user rows matrix num_users, num_items = self.current_state.shape self.user_rows = np.repeat(np.arange(num_users), num_items).reshape((num_users, -1))
@property def num_users(self): """ Shortcut getter method for the number of users. """ # rows = users, cols = items return self.current_state.shape[0] @property def num_items(self): """ Shortcut getter method for the number of items. """ # rows = users, cols = items return self.current_state.shape[1]
[docs]class Users(BaseComponent): # pylint: disable=too-many-ancestors """ Class representing users in the system. This class contains the real user preferences, which are unknown to the system, and the behavior of users when interacting with items. In general, users are represented with single *array_like* objects that contain all the users' preferences and characteristics. For example, real user preferences can be represented by a Numpy ndarray of size `(number_of_users, number_of_items)` where element `[u,i]` is the score assigned by user u to item i. Models determine the size constraints of objects representing users. Requirements vary across models and, unless specified, this class does not make assumptions on the real user components. This class inherits from :class:`~base.base_components.BaseComponent`. Parameters ------------ actual_user_profiles: array_like, optional Representation of the real user profiles. actual_user_scores: array_like, optional Representation of the real scores that users assign to items. interact_with_items: callable, optional Function that specifies the behavior of users when interacting with items. If None, users follow the behavior specified in :meth:`get_user_feedback()`. num_users: int, optional The number of users in the system. size: tuple, optional Size of the user representation. It expects a tuple. If None, it is chosen randomly. drift: float, default 0 If greater than 0, user profiles will update dynamically as they interact with items, "drifting" towards the item attribute vectors they interact with. ``drift`` is a parameter between 0 and 1 that controls the degree of rotational drift. If ``t=1``, then the user profile vector takes on the exact same direction as the attribute vector of the item they just interacted with. If 0, user profiles are generated once at initialization and never change. attention_exp: float, default 0 If this parameter is non-zero, then the order of the items in the recommendation set affects the user's choice, in that the item chosen will be a function of its index in the recommendation set and the underlying user-item score. (See Chaney et al. 2018 for a description of this mechanism.) Concretely, the item chosen will be according to :math:`i_u(t)=\\mathrm{argmax}_i( \\mathrm{rank}_{u,t}(i)^{\\alpha} \\cdot S_{u,i}(t) )`, where :math:`\\alpha` is the attention exponent and :math:`S_{u,i}(t)` is the underlying user-item score. score_fn: callable Function that is used to calculate each user's scores for each candidate item. The score function should take as input ``user_profiles`` and ``item_attributes``. verbose: bool, default False If ``True``, enables verbose mode. Disabled by default. seed: int, optional Seed for random generator. Attributes ------------ Attributes from BaseComponent Inherited by :class:`~trecs.components.base_components.BaseComponent` actual_user_profiles: :obj:`numpy.ndarray` A matrix representing the *real* user profiles. actual_user_scores: :obj:`numpy.ndarray` A :math:`|U|\\times|I|` matrix representing the *true* scores assigned by each user to each item, where :math:`|U|` is the number of users and :math:`|I|` is the number of items in the system. The element of this matrix indexed by :math:`(u,i)` is the score assigned by user :math:`u` to item :math:`i`. interact_with_items: callable A function that defines user behaviors when interacting with items. If None, users follow the behavior in :meth:`get_user_feedback()`. user_vector: :obj:`numpy.ndarray` A :math:`|U|` array of user indices. score_fn: callable Function that is used to calculate each user's scores for each candidate item. The score function should take as input user_profiles and item_attributes. repeat_interactions: bool (optional, default: True) If ``True``, then users will interact with items regardless of whether they have already interacted with them before. If ``False``, users will not perform repeat interactions. Raises -------- TypeError If parameters are of the wrong type. ValueError If both ``actual_user_profiles`` and ``size`` are None. """ def __init__( self, actual_user_profiles=None, actual_user_scores=None, interact_with_items=None, size=None, num_users=None, drift=0, score_fn=mo.inner_product, verbose=False, seed=None, attention_exp=0.0, repeat_interactions=True, ): # pylint: disable=too-many-arguments self.rng = Generator(seed=seed) # general input checks if actual_user_profiles is not None: if not isinstance(actual_user_profiles, (list, np.ndarray, sp.spmatrix)): raise TypeError( "actual_user_profiles must be a list, numpy.ndarray, or scipy sparse matrix" ) if interact_with_items is not None and not callable(interact_with_items): raise TypeError("interact_with_items must be callable") if actual_user_profiles is None and size is None: raise ValueError("actual_user_profiles and size can't both be None") if actual_user_profiles is None and not isinstance(size, tuple): raise TypeError("size must be a tuple, is %s" % type(size)) if actual_user_scores is not None: if not isinstance(actual_user_scores, (list, np.ndarray, sp.spmatrix)): raise TypeError( "actual_user_profiles must be a list, numpy.ndarray, or scipy sparse matrix" ) actual_user_scores = ActualUserScores(actual_user_scores) if actual_user_profiles is None and size is not None: row_zeros = np.zeros(size[1]) # one row vector of zeroes while actual_user_profiles is None or mo.contains_row(actual_user_profiles, row_zeros): # generate matrix until no row is the zero vector actual_user_profiles = self.rng.normal(size=size) # check_consistency also returns num_items and num_attributes, which are not needed num_users = check_consistency( users=actual_user_profiles, user_item_scores=actual_user_scores, num_users=num_users )[0] self.actual_user_profiles = ActualUserProfiles(actual_user_profiles) self.interact_with_items = interact_with_items self.drift = drift self.attention_exp = attention_exp if not callable(score_fn): raise TypeError("Custom score function must be a callable method") self.score_fn = score_fn # function that dictates how scores will be generated self.actual_user_scores = actual_user_scores self.user_vector = np.arange(num_users, dtype=int) self.repeat_interactions = repeat_interactions if not repeat_interactions: self.user_interactions = np.array([], dtype=int).reshape((num_users, 0)) self.name = "actual_user_scores" BaseComponent.__init__(self, verbose=verbose, init_value=self.actual_user_profiles.value) @property def num_attrs(self): """ Shortcut getter method for the number of attributes of the items. """ # rows = users, rows = attributes return self.actual_user_profiles.num_attrs @property def num_users(self): """ Shortcut getter method for the number of items. """ # rows = users, rows = attributes return self.actual_user_profiles.num_users
[docs] def set_score_function(self, score_fn): """ Users "score" items before "deciding" which item to interact with. This function makes it possible to set an arbitrary function as the score function. Parameters ------------ score_fn: callable Function that is used to calculate each user's scores for each candidate item. Note that this function can be the same function used by the recommender system to generate its predictions for user-item scores. The score function should take as input ``user_profiles`` and ``item_attributes``. Raises -------- TypeError If ``score_fn`` is not callable. """ if not callable(score_fn): raise TypeError("score function must be callable") self.score_fn = score_fn
[docs] def compute_user_scores(self, item_attributes): """ Computes and stores the actual scores that users assign to items compatible with the system. Note that we expect the ``self.score_fn`` attribute to be set to some callable function which takes item attributes and user profiles. Parameters ------------ item_attributes: :obj:`array_like` A matrix representation of item attributes. """ if not callable(self.score_fn): raise TypeError("score function must be callable") actual_scores = self.score_fn( user_profiles=self.actual_user_profiles.value, item_attributes=item_attributes ) if self.actual_user_scores is None: self.actual_user_scores = ActualUserScores(actual_scores) else: self.actual_user_scores.value = actual_scores self.actual_user_scores.store_state()
[docs] def score_new_items(self, new_items): """ Computes and stores the actual scores that users assign to any new items that enter the system. Note that we expect the ``self.score_fn`` attribute to be set to some callable function which takes item attributes and user profiles. Parameters ------------ new_items: :obj:`array_like` A matrix representation of item attributes. Should be of dimension :math:`|A|\\times|I|`, where :math:`|I|` is the number of items and :math:`|A|` is the number of attributes. """ new_scores = self.score_fn( user_profiles=self.actual_user_profiles.value, item_attributes=new_items ) self.actual_user_scores.append_item_scores(new_scores) self.actual_user_scores.store_state()
[docs] def get_actual_user_scores(self, user=None): """ Returns an array of actual user scores. Parameters ----------- user: int or :obj:`numpy.ndarray` or list (optional, default: None) Specifies the user index (or indices) for which to return the actual user scores. If ``None``, the function returns the whole matrix. Returns -------- scores: An array of actual user scores for each item. """ if user is None: return self.actual_user_scores else: return self.actual_user_scores[user, :]
[docs] def get_user_feedback(self, items_shown): """ Generates user interactions at a given timestep, generally called by a model. Parameters ------------ args, kwargs: Parameters needed by the model's train function. items_shown: :obj:`numpy.ndarray` A :math:`|U|\\times\\text{num_items_per_iter}` matrix with recommendations and new items. Returns --------- :obj:`numpy.ndarray` Array of interactions s.t. element :math:`\\text{interactions}_u(t)` represents the index of the item selected by user `u` at time `t`. Shape: :math:`|U|\\times 1` Raises ------- ValueError If :attr:`interact_with_items` is ``None`` and there is not ``item`` parameter. """ # use custom item interaction function, if provided if self.interact_with_items is not None: return self.interact_with_items(self, items_shown) if not self.repeat_interactions: # scores must be set back later to non-infinite values prev_interacted_scores = self.actual_user_scores.get_item_scores(self.user_interactions) # "remove" items that have been interacted with by setting scores to negative infinity self.actual_user_scores.set_item_scores_to_value(self.user_interactions, float("-inf")) rec_item_scores = self.actual_user_scores.get_item_scores(items_shown) rec_item_scores = self.attention_transform(rec_item_scores) sorted_user_preferences = mo.argmax(rec_item_scores, axis=1) interactions = items_shown[self.user_vector, sorted_user_preferences] # logging information if requested if self.is_verbose(): self.log(f"User scores for given items are:\n{str(rec_item_scores)}") self.log(f"Users interact with the following items respectively:\n{str(interactions)}") # record interactions if needed to ensure users don't repeat interactions if not self.repeat_interactions: # set scores back to the original scores self.actual_user_scores.set_item_scores_to_value( self.user_interactions, prev_interacted_scores ) interactions_col = interactions.reshape((-1, 1)) # append interactions as column of user interactions self.user_interactions = np.hstack([self.user_interactions, interactions_col]) return interactions
[docs] def attention_transform(self, recommended_item_scores): """ Transforms a matrix of user-item scores based on a user attention mechanism; for example, because user attention is limited, items at the top of the recommendation list may have a higher effective score than items at the end of the recommendation list. Parameters ------------ recommended_item_scores: :obj:`numpy.ndarray` A :math:`|U|\\times\\text{num_items_per_iter}` matrix with pre-attention user-item scores. Returns --------- recommended_item_scores: :obj:`numpy.ndarray` A :math:`|U|\\times\\text{num_items_per_iter}` matrix with transformed user-item scores that take into account the positions in the recommendation list. """ if self.attention_exp != 0: num_items = recommended_item_scores.shape[1] idxs = np.arange(num_items) + 1 multiplier = np.power(idxs, self.attention_exp) # multiply each row by the attention coefficient return recommended_item_scores * multiplier else: return recommended_item_scores
[docs] def update_profiles(self, item_attributes): """In the case of dynamic user profiles, we update the user's actual profiles with new values as each user profile "drifts" towards items that they consume. Parameters ----------- interactions: :obj:`numpy.ndarray` or list A matrix where row ``i`` corresponds to the attribute vector that user ``i`` interacted with. """ # we make no assumptions about whether the user profiles or item # attributes vectors are normalized self.actual_user_profiles.value = mo.slerp( self.actual_user_profiles, item_attributes, perc=self.drift )
[docs] def store_state(self): """Store the actual user scores in the state history""" self.state_history.append(np.copy(self.actual_user_scores.value))
[docs] def append_new_users(self, new_users, existing_items): """ Appends a set of new users (represented as some kind of matrix) to the current set of items. Assumes the new users have dimension :math:`|U_{new}|\\times|A|`, where :math:`U_{new}` indicates the number of new items to be appended. """ self.actual_user_profiles.append_new_users(new_users) # update user vector existing_users = self.user_vector.size num_new = new_users.shape[0] self.user_vector = np.concatenate((self.user_vector, existing_users + np.arange(num_new))) # update new user scores new_scores = self.score_fn(user_profiles=new_users, item_attributes=existing_items) self.actual_user_scores.append_user_scores(new_scores) self.actual_user_scores.store_state()
[docs]class DNUsers(Users): """ Subclass of :class:`~components.users.Users` in which user agents perform choices in accordance with the Divisive Normalization model of choice from `Webb et al., 2020`_. .. _Webb et al., 2020: https://pubsonline.informs.org/doi/pdf/10.1287/mnsc.2019.3536 Parameters ----------- sigma: float Parameter for the DN model (see docstring). Default value is fitted parameter from Webb et al. (2020). omega: float Parameter for the DN model (see docstring). Default value is fitted parameter from Webb et al. (2020). beta: float Parameter for the DN model (see docstring). Default value is fitted parameter from Webb et al. (2020). """ def __init__( self, actual_user_profiles=None, actual_user_scores=None, interact_with_items=None, size=None, num_users=None, drift=0, score_fn=mo.inner_product, sigma=0.0, omega=0.2376, beta=0.9739, verbose=False, seed=None, ): # pylint: disable=too-many-arguments Users.__init__( self, actual_user_profiles, actual_user_scores, interact_with_items, size, num_users, drift, score_fn, verbose, seed, ) self.sigma = sigma self.omega = omega self.beta = beta
[docs] def get_user_feedback(self, items_shown): """ Generates user interactions at a given timestep, generally called by a model. Parameters ------------ args, kwargs: Parameters needed by the model's train function. items_shown: :obj:`numpy.ndarray` A :math:`|U|\\times\\text{num_items_per_iter}` matrix with recommendations and new items. Returns --------- :obj:`numpy.ndarray` Array of interactions s.t. element :math:`\\text{interactions}_u(t)` represents the index of the item selected by user `u` at time `t`. Shape: :math:`|U|\\times 1` Raises ------- ValueError If :attr:`interact_with_items` is None and there is not `item` parameter. """ if self.interact_with_items is not None: return self.interact_with_items(self, items_shown) reshaped_user_vector = self.user_vector.reshape((items_shown.shape[0], 1)) interaction_scores = self.actual_user_scores[reshaped_user_vector, items_shown] self.log("User scores for given items are:\n" + str(interaction_scores)) item_utilities = mo.to_dense(self.calc_dn_utilities(interaction_scores)) sorted_user_preferences = item_utilities.argsort()[:, -1] interactions = items_shown[self.user_vector, sorted_user_preferences] self.log("Users interact with the following items respectively:\n" + str(interactions)) return interactions
[docs] def normalize_values(self, user_item_scores): """ Calculating the expression for :math:`z(\\textbf{v})` in the equation :math:`z(\\textbf{v})+\\mathbf{\\eta}`. Parameters ----------- user_item_scores: :obj:`array_like` The element at index :math:`i,j` should represent user :math:`i`'s context-independent value for item :math:`j`. Dimension: :math:`|U|\\times|I|` Returns -------- normed_values: :obj:`numpy.ndarray` The transformed utility values (i.e., :math:`z(\\textbf{v})`). """ summed_norms = np.linalg.norm(user_item_scores, ord=self.beta, axis=1) denom = self.sigma + np.multiply(self.omega, summed_norms) return np.divide(user_item_scores.T, denom) # now |I| x |U|
[docs] def calc_dn_utilities(self, user_item_scores): """ Scores items according to divisive normalization. Note that the parameters / matrix operations we perform here are directly taken from https://github.com/UofT-Neuroecon-1/Normalization. For more information, see Webb, R., Glimcher, P. W., & Louie, K. (2020). The Normalization of Consumer Valuations: Context-Dependent Preferences from Neurobiological Constraints. Management Science. Note that the generalized DN model takes the following functional form: :math:`z_i(\\textbf{v})=\\frac{v_i}{\\sigma+\\omega(\\sum_n v_n^{\\beta})^ {\\frac{1}{\\beta}}}`, where :math:`\\sigma, \\omega, \\beta` are all parameters that specify the exact choice model. After the original values :math:`\\textbf{v}` are transformed this way, the choice is determined by choosing the maximum value over :math:`z(\\textbf{v})+\\mathbf{\\eta}`, which in our case is generated by a multivariate normal distribution. Parameters ----------- user_item_scores: :obj:`array_like` The element at index :math:`i,j` should represent user :math:`i`'s context-independent value for item :math:`j`. Dimension: :math:`|U|\\times|I|` Returns -------- utility: :obj:`numpy.ndarray` Normalized & randomly perturbed utilities for different each pair of users and items in the recommendation set. """ normed_values = self.normalize_values(user_item_scores) num_choices, num_users = normed_values.shape eps = self.sample_from_error_dist(num_choices, num_users) utility = normed_values + eps # transform so |U| x |I| return utility.T
[docs] def sample_from_error_dist(self, num_choices, num_users): """ The second stage of generating the divisive normalization utilities :math:`\\text{interactions}_{u(t)}` is adding the error term :math:`\\eta`. In this implementation, we sample from a specific multivariate normal distribution used by Webb et al. (see https://github.com/UofT-Neuroecon-1/Normalization). Parameters ----------- num_choices: int Number of items every user is choosing between. num_users: int Number of users in the system. Returns -------- eps: :obj:`numpy.ndarray` Randomly sampled errors from the error distribution. Should have shape :math:`|I|\\times|U|`. """ mean = np.zeros(num_choices) # in accordance with the DN model from Webb et al., # the following covariance matrix has the structure # [ 1 0.5 ... 0.5 0.5 ] # [ 0.5 1 ... 0.5 0.5 ] # [ 0.5 0.5 ... 1 0.5 ] # [ 0.5 0.5 ... 0.5 1 ] cov = np.ones((num_choices, num_choices)) * 0.5 cov[np.arange(num_choices), np.arange(num_choices)] = 1 # generate |I| x |U| multivariate normal eps = self.rng.multivariate_normal(mean, cov, size=num_users).T return eps