"""
Factorization models for explicit feedback problems.
"""
import numpy as np
import torch
import torch.optim as optim
from spotlight.helpers import _repr_model
from spotlight.factorization._components import _predict_process_ids
from spotlight.factorization.representations import BilinearNet
from spotlight.losses import (poisson_loss,
regression_loss,
logistic_loss)
from spotlight.torch_utils import cpu, gpu, minibatch, set_seed, shuffle
[docs]class ExplicitFactorizationModel(object):
"""
An explicit feedback matrix factorization model. Uses a classic
matrix factorization [1]_ approach, with latent vectors used
to represent both users and items. Their dot product gives the
predicted score for a user-item pair.
The latent representation is given by
:class:`spotlight.factorization.representations.BilinearNet`.
.. [1] Koren, Yehuda, Robert Bell, and Chris Volinsky.
"Matrix factorization techniques for recommender systems."
Computer 42.8 (2009).
Parameters
----------
loss: string, optional
One of 'regression', 'poisson', 'logistic'
corresponding to losses from :class:`spotlight.losses`.
embedding_dim: int, optional
Number of embedding dimensions to use for users and items.
n_iter: int, optional
Number of iterations to run.
batch_size: int, optional
Minibatch size.
l2: float, optional
L2 loss penalty.
learning_rate: float, optional
Initial learning rate.
optimizer_func: function, optional
Function that takes in module parameters as the first argument and
returns an instance of a PyTorch optimizer. Overrides l2 and learning
rate if supplied. If no optimizer supplied, then use ADAM by default.
use_cuda: boolean, optional
Run the model on a GPU.
representation: a representation module, optional
If supplied, will override default settings and be used as the
main network module in the model. Intended to be used as an escape
hatch when you want to reuse the model's training functions but
want full freedom to specify your network topology.
sparse: boolean, optional
Use sparse gradients for embedding layers.
random_state: instance of numpy.random.RandomState, optional
Random state to use when fitting.
"""
def __init__(self,
loss='regression',
embedding_dim=32,
n_iter=10,
batch_size=256,
l2=0.0,
learning_rate=1e-2,
optimizer_func=None,
use_cuda=False,
representation=None,
sparse=False,
random_state=None):
assert loss in ('regression',
'poisson',
'logistic')
self._loss = loss
self._embedding_dim = embedding_dim
self._n_iter = n_iter
self._learning_rate = learning_rate
self._batch_size = batch_size
self._l2 = l2
self._use_cuda = use_cuda
self._representation = representation
self._sparse = sparse
self._optimizer_func = optimizer_func
self._random_state = random_state or np.random.RandomState()
self._num_users = None
self._num_items = None
self._net = None
self._optimizer = None
self._loss_func = None
set_seed(self._random_state.randint(-10**8, 10**8),
cuda=self._use_cuda)
def __repr__(self):
return _repr_model(self)
@property
def _initialized(self):
return self._net is not None
def _initialize(self, interactions):
(self._num_users,
self._num_items) = (interactions.num_users,
interactions.num_items)
if self._representation is not None:
self._net = gpu(self._representation,
self._use_cuda)
else:
self._net = gpu(
BilinearNet(self._num_users,
self._num_items,
self._embedding_dim,
sparse=self._sparse),
self._use_cuda
)
if self._optimizer_func is None:
self._optimizer = optim.Adam(
self._net.parameters(),
weight_decay=self._l2,
lr=self._learning_rate
)
else:
self._optimizer = self._optimizer_func(self._net.parameters())
if self._loss == 'regression':
self._loss_func = regression_loss
elif self._loss == 'poisson':
self._loss_func = poisson_loss
elif self._loss == 'logistic':
self._loss_func = logistic_loss
else:
raise ValueError('Unknown loss: {}'.format(self._loss))
def _check_input(self, user_ids, item_ids, allow_items_none=False):
if isinstance(user_ids, int):
user_id_max = user_ids
else:
user_id_max = user_ids.max()
if user_id_max >= self._num_users:
raise ValueError('Maximum user id greater '
'than number of users in model.')
if allow_items_none and item_ids is None:
return
if isinstance(item_ids, int):
item_id_max = item_ids
else:
item_id_max = item_ids.max()
if item_id_max >= self._num_items:
raise ValueError('Maximum item id greater '
'than number of items in model.')
[docs] def fit(self, interactions, verbose=False):
"""
Fit the model.
When called repeatedly, model fitting will resume from
the point at which training stopped in the previous fit
call.
Parameters
----------
interactions: :class:`spotlight.interactions.Interactions`
The input dataset. Must have ratings.
verbose: bool
Output additional information about current epoch and loss.
"""
user_ids = interactions.user_ids.astype(np.int64)
item_ids = interactions.item_ids.astype(np.int64)
if not self._initialized:
self._initialize(interactions)
self._check_input(user_ids, item_ids)
for epoch_num in range(self._n_iter):
users, items, ratings = shuffle(user_ids,
item_ids,
interactions.ratings,
random_state=self._random_state)
user_ids_tensor = gpu(torch.from_numpy(users),
self._use_cuda)
item_ids_tensor = gpu(torch.from_numpy(items),
self._use_cuda)
ratings_tensor = gpu(torch.from_numpy(ratings),
self._use_cuda)
epoch_loss = 0.0
for (minibatch_num,
(batch_user,
batch_item,
batch_ratings)) in enumerate(minibatch(user_ids_tensor,
item_ids_tensor,
ratings_tensor,
batch_size=self._batch_size)):
predictions = self._net(batch_user, batch_item)
if self._loss == 'poisson':
predictions = torch.exp(predictions)
self._optimizer.zero_grad()
loss = self._loss_func(batch_ratings, predictions)
epoch_loss += loss.item()
loss.backward()
self._optimizer.step()
epoch_loss /= minibatch_num + 1
if verbose:
print('Epoch {}: loss {}'.format(epoch_num, epoch_loss))
if np.isnan(epoch_loss) or epoch_loss == 0.0:
raise ValueError('Degenerate epoch loss: {}'
.format(epoch_loss))
[docs] def predict(self, user_ids, item_ids=None):
"""
Make predictions: given a user id, compute the recommendation
scores for items.
Parameters
----------
user_ids: int or array
If int, will predict the recommendation scores for this
user for all items in item_ids. If an array, will predict
scores for all (user, item) pairs defined by user_ids and
item_ids.
item_ids: array, optional
Array containing the item ids for which prediction scores
are desired. If not supplied, predictions for all items
will be computed.
Returns
-------
predictions: np.array
Predicted scores for all items in item_ids.
"""
self._check_input(user_ids, item_ids, allow_items_none=True)
self._net.train(False)
user_ids, item_ids = _predict_process_ids(user_ids, item_ids,
self._num_items,
self._use_cuda)
out = self._net(user_ids, item_ids)
if self._loss == 'poisson':
out = torch.exp(out)
elif self._loss == 'logistic':
out = torch.sigmoid(out)
return cpu(out).detach().numpy().flatten()