Source code for cornac.models.narre.recom_narre

# Copyright 2018 The Cornac Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ============================================================================

import os
import pickle
from tqdm.auto import trange
import numpy as np

from ..recommender import Recommender
from ..recommender import ANNMixin, MEASURE_DOT
from ...exception import ScoreException


os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"


[docs] class NARRE(Recommender, ANNMixin): """Neural Attentional Rating Regression with Review-level Explanations Parameters ---------- name: string, default: 'NARRE' The name of the recommender model. embedding_size: int, default: 100 Word embedding size id_embedding_size: int, default: 32 User/item review id embedding size n_factors: int, default: 32 The dimension of the user/item's latent factors. attention_size: int, default: 16 Attention size kernel_sizes: list, default: [3] List of kernel sizes of conv2d n_filters: int, default: 64 Number of filters dropout_rate: float, default: 0.5 Dropout rate of neural network dense layers max_text_length: int, default: 50 Maximum number of tokens in a review instance max_num_review: int, default: 32 Maximum number of reviews that you want to feed into training. By default, the model will be trained with 32 reviews. batch_size: int, default: 64 Batch size max_iter: int, default: 10 Max number of training epochs optimizer: string, optional, default: 'adam' Optimizer for training is either 'adam' or 'rmsprop'. learning_rate: float, optional, default: 0.001 Initial value of learning rate for the optimizer. model_selection: str, optional, default: 'last' Model selection strategy is either 'best' or 'last'. user_based: boolean, optional, default: True Evaluation strategy for model selection, by default, it measures for every users and taking the average `user_based=True`. Set `user_based=False` if you want to measure per rating instead. trainable: boolean, optional, default: True When False, the model will not be re-trained, and input of pre-trained parameters are required. verbose: boolean, optional, default: True When True, running logs are displayed. init_params: dictionary, optional, default: None Initial parameters, pretrained_word_embeddings could be initialized here, e.g., init_params={'pretrained_word_embeddings': pretrained_word_embeddings} seed: int, optional, default: None Random seed for weight initialization. If specified, training will take longer because of single-thread (no parallelization). References ---------- * Chen, C., Zhang, M., Liu, Y., & Ma, S. (2018, April). Neural attentional rating regression with review-level explanations. In Proceedings of the 2018 World Wide Web Conference (pp. 1583-1592). """ def __init__( self, name="NARRE", embedding_size=100, id_embedding_size=32, n_factors=32, attention_size=16, kernel_sizes=[3], n_filters=64, dropout_rate=0.5, max_text_length=50, max_num_review=32, batch_size=64, max_iter=10, optimizer="adam", learning_rate=0.001, model_selection="last", # last or best user_based=True, trainable=True, verbose=True, init_params=None, seed=None, ): super().__init__(name=name, trainable=trainable, verbose=verbose) self.seed = seed self.embedding_size = embedding_size self.id_embedding_size = id_embedding_size self.n_factors = n_factors self.attention_size = attention_size self.n_filters = n_filters self.kernel_sizes = kernel_sizes self.dropout_rate = dropout_rate self.max_text_length = max_text_length self.max_num_review = max_num_review self.batch_size = batch_size self.max_iter = max_iter self.optimizer = optimizer self.learning_rate = learning_rate if model_selection not in ["best", "last"]: raise ValueError( "model_selection is either 'best' or 'last' but {}".format( model_selection ) ) self.model_selection = model_selection self.user_based = user_based # Init params if provided self.init_params = {} if init_params is None else init_params self.losses = {"train_losses": [], "val_losses": []}
[docs] def fit(self, train_set, val_set=None): """Fit the model to observations. Parameters ---------- train_set: :obj:`cornac.data.Dataset`, required User-Item preference data as well as additional modalities. val_set: :obj:`cornac.data.Dataset`, optional, default: None User-Item preference data for model selection purposes (e.g., early stopping). Returns ------- self : object """ Recommender.fit(self, train_set, val_set) if self.trainable: if not hasattr(self, "model"): from .narre import NARREModel self.model = NARREModel( train_set.num_users, train_set.num_items, train_set.review_text.vocab, train_set.global_mean, n_factors=self.n_factors, embedding_size=self.embedding_size, id_embedding_size=self.id_embedding_size, attention_size=self.attention_size, kernel_sizes=self.kernel_sizes, n_filters=self.n_filters, dropout_rate=self.dropout_rate, max_text_length=self.max_text_length, max_num_review=self.max_num_review, pretrained_word_embeddings=self.init_params.get( "pretrained_word_embeddings" ), verbose=self.verbose, seed=self.seed, ) self._fit_tf(train_set, val_set) return self
def _fit_tf(self, train_set, val_set): import tensorflow as tf from tensorflow import keras from .narre import get_data from ...eval_methods.base_method import rating_eval from ...metrics import MSE loss = keras.losses.MeanSquaredError() if not hasattr(self, "_optimizer"): if self.optimizer == "rmsprop": self._optimizer = keras.optimizers.RMSprop( learning_rate=self.learning_rate ) elif self.optimizer == "adam": self._optimizer = keras.optimizers.Adam( learning_rate=self.learning_rate ) else: raise ValueError( "optimizer is either 'rmsprop' or 'adam' but {}".format( self.optimizer ) ) train_loss = keras.metrics.Mean(name="loss") val_loss = float("inf") best_val_loss = float("inf") self.best_epoch = None loop = trange( self.max_iter, disable=not self.verbose, bar_format="{l_bar}{bar:10}{r_bar}{bar:-10b}", ) for i_epoch, _ in enumerate(loop): train_loss.reset_states() for i, (batch_users, batch_items, batch_ratings) in enumerate( train_set.uir_iter(self.batch_size, shuffle=True) ): user_reviews, user_iid_reviews, user_num_reviews = get_data( batch_users, train_set, self.max_text_length, by="user", max_num_review=self.max_num_review, ) item_reviews, item_uid_reviews, item_num_reviews = get_data( batch_items, train_set, self.max_text_length, by="item", max_num_review=self.max_num_review, ) with tf.GradientTape() as tape: predictions = self.model.graph( [ batch_users, batch_items, user_reviews, user_iid_reviews, user_num_reviews, item_reviews, item_uid_reviews, item_num_reviews, ], training=True, ) _loss = loss(batch_ratings, predictions) gradients = tape.gradient(_loss, self.model.graph.trainable_variables) self._optimizer.apply_gradients( zip(gradients, self.model.graph.trainable_variables) ) train_loss(_loss) if i % 10 == 0: loop.set_postfix( loss=train_loss.result().numpy(), val_loss=val_loss, best_val_loss=best_val_loss, best_epoch=self.best_epoch, ) current_weights = self.model.get_weights(train_set, self.batch_size) if val_set is not None: ( self.X, self.Y, self.W1, self.user_embedding, self.item_embedding, self.bu, self.bi, self.mu, ) = current_weights [current_val_mse], _ = rating_eval( model=self, metrics=[MSE()], test_set=val_set, user_based=self.user_based, ) val_loss = current_val_mse if best_val_loss > val_loss: best_val_loss = val_loss self.best_epoch = i_epoch + 1 best_weights = current_weights loop.set_postfix( loss=train_loss.result().numpy(), val_loss=val_loss, best_val_loss=best_val_loss, best_epoch=self.best_epoch, ) self.losses["train_losses"].append(train_loss.result().numpy()) self.losses["val_losses"].append(val_loss) loop.close() # save weights for predictions ( self.X, self.Y, self.W1, self.user_embedding, self.item_embedding, self.bu, self.bi, self.mu, ) = ( best_weights if val_set is not None and self.model_selection == "best" else current_weights ) if self.verbose: print("Learning completed!")
[docs] def save(self, save_dir=None): """Save a recommender model to the filesystem. Parameters ---------- save_dir: str, default: None Path to a directory for the model to be stored. """ if save_dir is None: return graph = self.model.graph del self.model.graph _optimizer = self._optimizer del self._optimizer model_file = Recommender.save(self, save_dir) self._optimizer = _optimizer self.model.graph = graph self.model.graph.save(model_file.replace(".pkl", ".cpt")) with open(model_file.replace(".pkl", ".opt"), "wb") as f: pickle.dump(self._optimizer.get_weights(), f) return model_file
[docs] @staticmethod def load(model_path, trainable=False): """Load a recommender model from the filesystem. Parameters ---------- model_path: str, required Path to a file or directory where the model is stored. If a directory is provided, the latest model will be loaded. trainable: boolean, optional, default: False Set it to True if you would like to finetune the model. By default, the model parameters are assumed to be fixed after being loaded. Returns ------- self : object """ import tensorflow as tf from tensorflow import keras import absl.logging absl.logging.set_verbosity(absl.logging.ERROR) model = Recommender.load(model_path, trainable) model.model.graph = keras.models.load_model( model.load_from.replace(".pkl", ".cpt"), compile=False ) if model.optimizer == "rmsprop": model._optimizer = keras.optimizers.RMSprop( learning_rate=model.learning_rate ) else: model._optimizer = keras.optimizers.Adam(learning_rate=model.learning_rate) zero_grads = [tf.zeros_like(w) for w in model.model.graph.trainable_variables] model._optimizer.apply_gradients( zip(zero_grads, model.model.graph.trainable_variables) ) with open(model.load_from.replace(".pkl", ".opt"), "rb") as f: optimizer_weights = pickle.load(f) model._optimizer.set_weights(optimizer_weights) return model
[docs] def score(self, user_idx, item_idx=None): """Predict the scores/ratings of a user for an item. Parameters ---------- user_idx: int, required The index of the user for whom to perform score prediction. item_idx: int, optional, default: None The index of the item for that to perform score prediction. If None, scores for all known items will be returned. Returns ------- res : A scalar or a Numpy array Relative scores that the user gives to the item or to all known items """ if self.is_unknown_user(user_idx): raise ScoreException("Can't make score prediction for user %d" % user_idx) if item_idx is not None and self.is_unknown_item(item_idx): raise ScoreException("Can't make score prediction for item %d" % item_idx) if item_idx is None: h0 = (self.user_embedding[user_idx] + self.X[user_idx]) * ( self.item_embedding + self.Y ) known_item_scores = h0.dot(self.W1) + self.bu[user_idx] + self.bi + self.mu return known_item_scores.ravel() else: h0 = (self.user_embedding[user_idx] + self.X[user_idx]) * ( self.item_embedding[item_idx] + self.Y[item_idx] ) known_item_score = ( h0.dot(self.W1) + self.bu[user_idx] + self.bi[item_idx] + self.mu ) return known_item_score
[docs] def get_vector_measure(self): """Getting a valid choice of vector measurement in ANNMixin._measures. Returns ------- measure: MEASURE_DOT Dot product aka. inner product """ return MEASURE_DOT
[docs] def get_user_vectors(self): """Getting a matrix of user vectors serving as query for ANN search. Returns ------- out: numpy.array Matrix of user vectors for all users available in the model. """ user_vectors = np.concatenate( ( self.user_embedding + self.X, np.ones([self.user_embedding.shape[0], 1]), ), axis=1, ) return user_vectors
[docs] def get_item_vectors(self): """Getting a matrix of item vectors used for building the index for ANN search. Returns ------- out: numpy.array Matrix of item vectors for all items available in the model. """ item_vectors = np.concatenate( ( (self.item_embedding + self.Y) * self.W1.reshape((-1, 1)), self.bi.reshape((-1, 1)), ), axis=1, ) return item_vectors