diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d618a5f..c89a2c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `get_cat_features` method to `SparseFeatures` ([#221](https://github.com/MobileTeleSystems/RecTools/pull/221)) - Support `fit_partial()` for LightFM ([#223](https://github.com/MobileTeleSystems/RecTools/pull/223)) - LightFM Python 3.12+ support ([#224](https://github.com/MobileTeleSystems/RecTools/pull/224)) +- all vector models and `EASEModel` support for enabling ranking on gpu and selecting number of threads for cpu ranking. `recommend_n_threads` and `recommend_use_gpu_ranking` parameters to `EASEModel`, `ImplicitALSWrapperModel` and `PureSVDModel`, `recommend_use_gpu_ranking` to `LightFMWrapperModel`. Gpu and cpu ranking may provide different ordering of items with identical scores in recommendation table, so this could change ordering items in recommendations since gpu ranking is now used as a default one. ([#218](https://github.com/MobileTeleSystems/RecTools/pull/218)) ### Fixed - Fix Implicit ALS matrix zero assignment size ([#228](https://github.com/MobileTeleSystems/RecTools/pull/228)) diff --git a/rectools/models/ease.py b/rectools/models/ease.py index 7c90bc25..5d5439a6 100644 --- a/rectools/models/ease.py +++ b/rectools/models/ease.py @@ -15,9 +15,11 @@ """EASE model.""" import typing as tp +import warnings import numpy as np import typing_extensions as tpe +from implicit.gpu import HAS_CUDA from scipy import sparse from rectools import InternalIds @@ -33,7 +35,8 @@ class EASEModelConfig(ModelConfig): """Config for `EASE` model.""" regularization: float = 500.0 - num_threads: int = 1 + recommend_n_threads: int = 0 + recommend_use_gpu_ranking: bool = True class EASEModel(ModelBase[EASEModelConfig]): @@ -51,10 +54,21 @@ class EASEModel(ModelBase[EASEModelConfig]): ---------- regularization : float The regularization factor of the weights. + num_threads: Optional[int], default ``None`` + Deprecated, use `recommend_n_threads` instead. + Number of threads used for recommendation ranking on CPU. + recommend_n_threads: int, default 0 + Number of threads to use for recommendation ranking on CPU. + If you want to change this parameter after model is initialized, + you can manually assign new value to model `recommend_n_threads` attribute. + recommend_use_gpu_ranking: bool, default ``True`` + Flag to use GPU for recommendation ranking. Please note that GPU and CPU ranking may provide + different ordering of items with identical scores in recommendation table. + If ``True``, `implicit.gpu.HAS_CUDA` will also be checked before ranking. + If you want to change this parameter after model is initialized, + you can manually assign new value to model `recommend_use_gpu_ranking` attribute. verbose : int, default 0 Degree of verbose output. If 0, no output will be provided. - num_threads: int, default 1 - Number of threads used for `recommend` method. """ recommends_for_warm = False @@ -65,23 +79,45 @@ class EASEModel(ModelBase[EASEModelConfig]): def __init__( self, regularization: float = 500.0, - num_threads: int = 1, + num_threads: tp.Optional[int] = None, + recommend_n_threads: int = 0, + recommend_use_gpu_ranking: bool = True, verbose: int = 0, ): super().__init__(verbose=verbose) self.weight: np.ndarray self.regularization = regularization - self.num_threads = num_threads + + if num_threads is not None: + warnings.warn( + """ + `num_threads` argument is deprecated and will be removed in future releases. + Please use `recommend_n_threads` instead. + """ + ) + recommend_n_threads = num_threads + + self.recommend_n_threads = recommend_n_threads + self.recommend_use_gpu_ranking = recommend_use_gpu_ranking def _get_config(self) -> EASEModelConfig: return EASEModelConfig( - cls=self.__class__, regularization=self.regularization, num_threads=self.num_threads, verbose=self.verbose + cls=self.__class__, + regularization=self.regularization, + recommend_n_threads=self.recommend_n_threads, + recommend_use_gpu_ranking=self.recommend_use_gpu_ranking, + verbose=self.verbose, ) @classmethod def _from_config(cls, config: EASEModelConfig) -> tpe.Self: - return cls(regularization=config.regularization, num_threads=config.num_threads, verbose=config.verbose) + return cls( + regularization=config.regularization, + recommend_n_threads=config.recommend_n_threads, + recommend_use_gpu_ranking=config.recommend_use_gpu_ranking, + verbose=config.verbose, + ) def _fit(self, dataset: Dataset) -> None: # type: ignore ui_csr = dataset.get_user_item_matrix(include_weights=True) @@ -117,7 +153,8 @@ def _recommend_u2i( k=k, filter_pairs_csr=ui_csr_for_filter, sorted_object_whitelist=sorted_item_ids_to_recommend, - num_threads=self.num_threads, + num_threads=self.recommend_n_threads, + use_gpu=self.recommend_use_gpu_ranking and HAS_CUDA, ) return all_user_ids, all_reco_ids, all_scores diff --git a/rectools/models/implicit_als.py b/rectools/models/implicit_als.py index ae74b17b..b762d4c9 100644 --- a/rectools/models/implicit_als.py +++ b/rectools/models/implicit_als.py @@ -97,6 +97,8 @@ class ImplicitALSWrapperModelConfig(ModelConfig): model: AlternatingLeastSquaresConfig fit_features_together: bool = False + recommend_n_threads: tp.Optional[int] = None + recommend_use_gpu_ranking: tp.Optional[bool] = None class ImplicitALSWrapperModel(VectorModel[ImplicitALSWrapperModelConfig]): @@ -110,12 +112,24 @@ class ImplicitALSWrapperModel(VectorModel[ImplicitALSWrapperModelConfig]): ---------- model : AnyAlternatingLeastSquares Base model that will be used. - verbose : int, default 0 - Degree of verbose output. If 0, no output will be provided. fit_features_together: bool, default False Whether fit explicit features together with latent features or not. Used only if explicit features are present in dataset. See documentations linked above for details. + recommend_n_threads: Optional[int], default ``None`` + Number of threads to use for recommendation ranking on CPU. + If ``None``, then number of threads will be set same as `model.num_threads`. + If you want to change this parameter after model is initialized, + you can manually assign new value to model `recommend_n_threads` attribute. + recommend_use_gpu_ranking: Optional[bool], default ``None`` + Flag to use GPU for recommendation ranking. If ``None``, then will be set same as + `model.use_gpu`. + `implicit.gpu.HAS_CUDA` will also be checked before inference. Please note that GPU and CPU + ranking may provide different ordering of items with identical scores in recommendation + table. If you want to change this parameter after model is initialized, + you can manually assign new value to model `recommend_use_gpu_ranking` attribute. + verbose : int, default 0 + Degree of verbose output. If 0, no output will be provided. """ recommends_for_warm = False @@ -126,8 +140,21 @@ class ImplicitALSWrapperModel(VectorModel[ImplicitALSWrapperModelConfig]): config_class = ImplicitALSWrapperModelConfig - def __init__(self, model: AnyAlternatingLeastSquares, verbose: int = 0, fit_features_together: bool = False): - self._config = self._make_config(model, verbose, fit_features_together) + def __init__( + self, + model: AnyAlternatingLeastSquares, + fit_features_together: bool = False, + recommend_n_threads: tp.Optional[int] = None, + recommend_use_gpu_ranking: tp.Optional[bool] = None, + verbose: int = 0, + ): + self._config = self._make_config( + model=model, + verbose=verbose, + fit_features_together=fit_features_together, + recommend_n_threads=recommend_n_threads, + recommend_use_gpu_ranking=recommend_use_gpu_ranking, + ) super().__init__(verbose=verbose) @@ -135,13 +162,23 @@ def __init__(self, model: AnyAlternatingLeastSquares, verbose: int = 0, fit_feat self._model = model # for refit self.fit_features_together = fit_features_together - self.use_gpu = isinstance(model, GPUAlternatingLeastSquares) - if not self.use_gpu: - self.n_threads = model.num_threads + + if recommend_n_threads is None: + recommend_n_threads = model.num_threads if isinstance(model, CPUAlternatingLeastSquares) else 0 + self.recommend_n_threads = recommend_n_threads + + if recommend_use_gpu_ranking is None: + recommend_use_gpu_ranking = isinstance(model, GPUAlternatingLeastSquares) + self.recommend_use_gpu_ranking = recommend_use_gpu_ranking @classmethod def _make_config( - cls, model: AnyAlternatingLeastSquares, verbose: int, fit_features_together: bool + cls, + model: AnyAlternatingLeastSquares, + verbose: int, + fit_features_together: bool, + recommend_n_threads: tp.Optional[int] = None, + recommend_use_gpu_ranking: tp.Optional[bool] = None, ) -> ImplicitALSWrapperModelConfig: model_cls = ( model.__class__ @@ -176,6 +213,8 @@ def _make_config( model=tp.cast(AlternatingLeastSquaresConfig, inner_model_config), verbose=verbose, fit_features_together=fit_features_together, + recommend_n_threads=recommend_n_threads, + recommend_use_gpu_ranking=recommend_use_gpu_ranking, ) def _get_config(self) -> ImplicitALSWrapperModelConfig: @@ -188,7 +227,13 @@ def _from_config(cls, config: ImplicitALSWrapperModelConfig) -> tpe.Self: if inner_model_cls == ALS_STRING: inner_model_cls = AlternatingLeastSquares # Not actually a class, but it's ok model = inner_model_cls(**inner_model_params) # type: ignore # mypy misses we replaced str with a func - return cls(model=model, verbose=config.verbose, fit_features_together=config.fit_features_together) + return cls( + model=model, + verbose=config.verbose, + fit_features_together=config.fit_features_together, + recommend_n_threads=config.recommend_n_threads, + recommend_use_gpu_ranking=config.recommend_use_gpu_ranking, + ) def _fit(self, dataset: Dataset) -> None: self.model = deepcopy(self._model) diff --git a/rectools/models/lightfm.py b/rectools/models/lightfm.py index 2c860af7..58e262b7 100644 --- a/rectools/models/lightfm.py +++ b/rectools/models/lightfm.py @@ -86,6 +86,8 @@ class LightFMWrapperModelConfig(ModelConfig): model: LightFMConfig epochs: int = 1 num_threads: int = 1 + recommend_n_threads: tp.Optional[int] = None + recommend_use_gpu_ranking: bool = True class LightFMWrapperModel(FixedColdRecoModelMixin, VectorModel[LightFMWrapperModelConfig]): @@ -105,7 +107,21 @@ class LightFMWrapperModel(FixedColdRecoModelMixin, VectorModel[LightFMWrapperMod epochs: int, default 1 Will be used as `epochs` parameter for `LightFM.fit`. num_threads: int, default 1 - Will be used as `num_threads` parameter for `LightFM.fit`. + Will be used as `num_threads` parameter for `LightFM.fit`. Should be larger then 0. + This will also be used as number of threads to use for recommendation ranking on CPU. + If you want to change number of threads for ranking after model is initialized, + you can manually assign new value to model `recommend_n_threads` attribute. + recommend_n_threads: Optional[int], default ``None`` + Number of threads to use for recommendation ranking on CPU. + If ``None``, then number of threads will be set same as `num_threads`. + If you want to change this parameter after model is initialized, + you can manually assign new value to model `recommend_n_threads` attribute. + recommend_use_gpu_ranking: bool, default ``True`` + Flag to use GPU for recommendation ranking. Please note that GPU and CPU ranking may provide + different ordering of items with identical scores in recommendation table. + If ``True``, `implicit.gpu.HAS_CUDA` will also be checked before ranking. + If you want to change this parameter after model is initialized, + you can manually assign new value to model `recommend_use_gpu_ranking` attribute. verbose : int, default 0 Degree of verbose output. If 0, no output will be provided. """ @@ -123,6 +139,8 @@ def __init__( model: LightFM, epochs: int = 1, num_threads: int = 1, + recommend_n_threads: tp.Optional[int] = None, + recommend_use_gpu_ranking: bool = True, verbose: int = 0, ): super().__init__(verbose=verbose) @@ -131,6 +149,13 @@ def __init__( self._model = model self.n_epochs = epochs self.n_threads = num_threads + self._recommend_n_threads = recommend_n_threads + self.recommend_n_threads = 0 + if recommend_n_threads is not None: + self.recommend_n_threads = recommend_n_threads + elif num_threads > 0: + self.recommend_n_threads = num_threads + self.recommend_use_gpu_ranking = recommend_use_gpu_ranking def _get_config(self) -> LightFMWrapperModelConfig: inner_model = self._model @@ -154,6 +179,8 @@ def _get_config(self) -> LightFMWrapperModelConfig: model=tp.cast(LightFMConfig, inner_config), # https://github.com/python/mypy/issues/8890 epochs=self.n_epochs, num_threads=self.n_threads, + recommend_n_threads=self._recommend_n_threads, + recommend_use_gpu_ranking=self.recommend_use_gpu_ranking, verbose=self.verbose, ) @@ -162,7 +189,14 @@ def _from_config(cls, config: LightFMWrapperModelConfig) -> tpe.Self: params = config.model.copy() model_cls = params.pop("cls", LightFM) model = model_cls(**params) - return cls(model=model, epochs=config.epochs, num_threads=config.num_threads, verbose=config.verbose) + return cls( + model=model, + epochs=config.epochs, + num_threads=config.num_threads, + recommend_n_threads=config.recommend_n_threads, + recommend_use_gpu_ranking=config.recommend_use_gpu_ranking, + verbose=config.verbose, + ) def _fit(self, dataset: Dataset) -> None: self.model = deepcopy(self._model) diff --git a/rectools/models/pure_svd.py b/rectools/models/pure_svd.py index dc2ca816..241a2589 100644 --- a/rectools/models/pure_svd.py +++ b/rectools/models/pure_svd.py @@ -34,6 +34,8 @@ class PureSVDModelConfig(ModelConfig): tol: float = 0 maxiter: tp.Optional[int] = None random_state: tp.Optional[int] = None + recommend_n_threads: int = 0 + recommend_use_gpu_ranking: bool = True class PureSVDModel(VectorModel[PureSVDModelConfig]): @@ -54,6 +56,16 @@ class PureSVDModel(VectorModel[PureSVDModelConfig]): Pseudorandom number generator state used to generate resamples. verbose : int, default ``0`` Degree of verbose output. If ``0``, no output will be provided. + recommend_n_threads: int, default 0 + Number of threads to use for recommendation ranking on CPU. + If you want to change this parameter after model is initialized, + you can manually assign new value to model `recommend_n_threads` attribute. + recommend_use_gpu_ranking: bool, default ``True`` + Flag to use GPU for recommendation ranking. Please note that GPU and CPU ranking may provide + different ordering of items with identical scores in recommendation table. + If ``True``, `implicit.gpu.HAS_CUDA` will also be checked before ranking. + If you want to change this parameter after model is initialized, + you can manually assign new value to model `recommend_use_gpu_ranking` attribute. """ recommends_for_warm = False @@ -71,6 +83,8 @@ def __init__( maxiter: tp.Optional[int] = None, random_state: tp.Optional[int] = None, verbose: int = 0, + recommend_n_threads: int = 0, + recommend_use_gpu_ranking: bool = True, ): super().__init__(verbose=verbose) @@ -78,6 +92,8 @@ def __init__( self.tol = tol self.maxiter = maxiter self.random_state = random_state + self.recommend_n_threads = recommend_n_threads + self.recommend_use_gpu_ranking = recommend_use_gpu_ranking self.user_factors: np.ndarray self.item_factors: np.ndarray @@ -90,6 +106,8 @@ def _get_config(self) -> PureSVDModelConfig: maxiter=self.maxiter, random_state=self.random_state, verbose=self.verbose, + recommend_n_threads=self.recommend_n_threads, + recommend_use_gpu_ranking=self.recommend_use_gpu_ranking, ) @classmethod @@ -100,6 +118,8 @@ def _from_config(cls, config: PureSVDModelConfig) -> tpe.Self: maxiter=config.maxiter, random_state=config.random_state, verbose=config.verbose, + recommend_n_threads=config.recommend_n_threads, + recommend_use_gpu_ranking=config.recommend_use_gpu_ranking, ) def _fit(self, dataset: Dataset) -> None: # type: ignore diff --git a/rectools/models/rank.py b/rectools/models/rank.py index fabc389d..b4449b9f 100644 --- a/rectools/models/rank.py +++ b/rectools/models/rank.py @@ -23,6 +23,7 @@ import numpy as np from implicit.cpu.matrix_factorization_base import _filter_items_from_sparse_matrix as filter_items_from_sparse_matrix from implicit.gpu import HAS_CUDA +from implicit.utils import check_blas_config from scipy import sparse from rectools import InternalIds @@ -61,6 +62,7 @@ class ImplicitRanker: def __init__( self, distance: Distance, subjects_factors: tp.Union[np.ndarray, sparse.csr_matrix], objects_factors: np.ndarray ) -> None: + if isinstance(subjects_factors, sparse.csr_matrix) and distance != Distance.DOT: raise ValueError("To use `sparse.csr_matrix` distance must be `Distance.DOT`") @@ -149,18 +151,24 @@ def _rank_on_gpu( object_norms: tp.Optional[np.ndarray], filter_query_items: tp.Optional[tp.Union[sparse.csr_matrix, sparse.csr_array]], ) -> tp.Tuple[np.ndarray, np.ndarray]: # pragma: no cover - object_factors = implicit.gpu.Matrix(object_factors.astype(np.float32)) + + def _convert_arr_to_implicit_gpu_matrix(arr: np.ndarray) -> implicit.gpu.Matrix: + if arr.base is not None: + arr = arr.copy() + return implicit.gpu.Matrix(arr) + + object_factors = _convert_arr_to_implicit_gpu_matrix(object_factors.astype(np.float32)) if isinstance(subject_factors, sparse.spmatrix): warnings.warn("Sparse subject factors converted to Dense matrix") subject_factors = subject_factors.todense() - subject_factors = implicit.gpu.Matrix(subject_factors.astype(np.float32)) + subject_factors = _convert_arr_to_implicit_gpu_matrix(subject_factors.astype(np.float32)) if object_norms is not None: if len(np.shape(object_norms)) == 1: object_norms = np.expand_dims(object_norms, axis=0) - object_norms = implicit.gpu.Matrix(object_norms) + object_norms = _convert_arr_to_implicit_gpu_matrix(object_norms) if filter_query_items is not None: filter_query_items = implicit.gpu.COOMatrix(filter_query_items.tocoo()) @@ -252,6 +260,7 @@ def rank( # pylint: disable=too-many-branches filter_query_items=filter_query_items, ) else: + check_blas_config() ids, scores = implicit.cpu.topk.topk( # pylint: disable=c-extension-no-member items=object_factors, query=subject_factors, diff --git a/rectools/models/vector.py b/rectools/models/vector.py index 2af68fe5..3940b37d 100644 --- a/rectools/models/vector.py +++ b/rectools/models/vector.py @@ -18,6 +18,7 @@ import attr import numpy as np +from implicit.gpu import HAS_CUDA from rectools import InternalIds from rectools.dataset import Dataset @@ -40,7 +41,8 @@ class VectorModel(ModelBase[ModelConfig_T]): u2i_dist: Distance = NotImplemented i2i_dist: Distance = NotImplemented - n_threads: int = 0 # TODO: decide how to pass it correctly for all models + recommend_n_threads: int = 0 + recommend_use_gpu_ranking: bool = True def _recommend_u2i( self, @@ -65,7 +67,8 @@ def _recommend_u2i( k=k, filter_pairs_csr=ui_csr_for_filter, sorted_object_whitelist=sorted_item_ids_to_recommend, - num_threads=self.n_threads, + num_threads=self.recommend_n_threads, + use_gpu=self.recommend_use_gpu_ranking and HAS_CUDA, ) def _recommend_i2i( @@ -84,7 +87,8 @@ def _recommend_i2i( k=k, filter_pairs_csr=None, sorted_object_whitelist=sorted_item_ids_to_recommend, - num_threads=self.n_threads, + num_threads=self.recommend_n_threads, + use_gpu=self.recommend_use_gpu_ranking and HAS_CUDA, ) def _process_biases_to_vectors( diff --git a/tests/models/test_ease.py b/tests/models/test_ease.py index 15e26dbb..f6adef5e 100644 --- a/tests/models/test_ease.py +++ b/tests/models/test_ease.py @@ -13,6 +13,7 @@ # limitations under the License. import typing as tp +import warnings import numpy as np import pandas as pd @@ -231,31 +232,41 @@ def test_dumps_loads(self, dataset: Dataset) -> None: model.fit(dataset) assert_dumps_loads_do_not_change_model(model, dataset) + def test_warn_with_num_threads(self) -> None: + with warnings.catch_warnings(record=True) as w: + EASEModel(num_threads=10) + assert len(w) == 1 + assert "`num_threads` argument is deprecated" in str(w[-1].message) + class TestEASEModelConfiguration: def test_from_config(self) -> None: config = { "regularization": 500, - "num_threads": 1, + "recommend_n_threads": 1, + "recommend_use_gpu_ranking": True, "verbose": 1, } model = EASEModel.from_config(config) - assert model.num_threads == 1 + assert model.recommend_n_threads == 1 assert model.verbose == 1 assert model.regularization == 500 + assert model.recommend_use_gpu_ranking is True @pytest.mark.parametrize("simple_types", (False, True)) def test_get_config(self, simple_types: bool) -> None: model = EASEModel( regularization=500, - num_threads=1, + recommend_n_threads=1, + recommend_use_gpu_ranking=False, verbose=1, ) config = model.get_config(simple_types=simple_types) expected = { "cls": "EASEModel" if simple_types else EASEModel, "regularization": 500, - "num_threads": 1, + "recommend_n_threads": 1, + "recommend_use_gpu_ranking": False, "verbose": 1, } assert config == expected @@ -264,8 +275,9 @@ def test_get_config(self, simple_types: bool) -> None: def test_get_config_and_from_config_compatibility(self, simple_types: bool) -> None: initial_config = { "regularization": 500, - "num_threads": 1, + "recommend_n_threads": 1, "verbose": 1, + "recommend_use_gpu_ranking": True, } assert_get_config_and_from_config_compatibility(EASEModel, DATASET, initial_config, simple_types) diff --git a/tests/models/test_implicit_als.py b/tests/models/test_implicit_als.py index 581504c6..17fc2f72 100644 --- a/tests/models/test_implicit_als.py +++ b/tests/models/test_implicit_als.py @@ -175,6 +175,48 @@ def test_consistent_with_pure_implicit( np.testing.assert_equal(actual_internal_ids, expected_ids) np.testing.assert_allclose(actual_scores, expected_scores, atol=0.01) + @pytest.mark.skipif(not implicit.gpu.HAS_CUDA, reason="implicit cannot find CUDA for gpu ranking") + @pytest.mark.parametrize("fit_features_together", (False, True)) + @pytest.mark.parametrize("init_model_before_fit", (False, True)) + def test_gpu_ranking_consistent_with_pure_implicit( + self, dataset: Dataset, fit_features_together: bool, use_gpu: bool, init_model_before_fit: bool + ) -> None: + base_model = AlternatingLeastSquares(factors=10, num_threads=2, iterations=30, use_gpu=False, random_state=32) + if init_model_before_fit: + self._init_model_factors_inplace(base_model, dataset) + users = np.array([10, 20, 30, 40]) + + ui_csr = dataset.get_user_item_matrix(include_weights=True) + base_model.fit(ui_csr) + gpu_model = base_model.to_gpu() + + wrapped_model = ImplicitALSWrapperModel( + model=gpu_model, fit_features_together=fit_features_together, recommend_use_gpu_ranking=True + ) + wrapped_model.is_fitted = True + wrapped_model.model = wrapped_model._model # pylint: disable=protected-access + + actual_reco = wrapped_model.recommend( + users=users, + dataset=dataset, + k=3, + filter_viewed=False, + ) + + for user_id in users: + internal_id = dataset.user_id_map.convert_to_internal([user_id])[0] + expected_ids, expected_scores = gpu_model.recommend( + userid=internal_id, + user_items=ui_csr[internal_id], + N=3, + filter_already_liked_items=False, + ) + actual_ids = actual_reco.loc[actual_reco[Columns.User] == user_id, Columns.Item].values + actual_internal_ids = dataset.item_id_map.convert_to_internal(actual_ids) + actual_scores = actual_reco.loc[actual_reco[Columns.User] == user_id, Columns.Score].values + np.testing.assert_equal(actual_internal_ids, expected_ids) + np.testing.assert_allclose(actual_scores, expected_scores, atol=0.00001) + @pytest.mark.parametrize( "filter_viewed,expected", ( @@ -435,7 +477,11 @@ def setup_method(self) -> None: @pytest.mark.parametrize("use_gpu", (False, True)) @pytest.mark.parametrize("cls", (None, "AlternatingLeastSquares", "implicit.als.AlternatingLeastSquares")) - def test_from_config(self, use_gpu: bool, cls: tp.Any) -> None: + @pytest.mark.parametrize("recommend_use_gpu", (None, False, True)) + @pytest.mark.parametrize("recommend_n_threads", (None, 10)) + def test_from_config( + self, use_gpu: bool, cls: tp.Any, recommend_use_gpu: tp.Optional[bool], recommend_n_threads: tp.Optional[int] + ) -> None: config: tp.Dict = { "model": { "factors": 16, @@ -444,14 +490,26 @@ def test_from_config(self, use_gpu: bool, cls: tp.Any) -> None: "use_gpu": use_gpu, }, "fit_features_together": True, + "recommend_n_threads": recommend_n_threads, + "recommend_use_gpu_ranking": recommend_use_gpu, "verbose": 1, } if cls is not None: config["model"]["cls"] = cls model = ImplicitALSWrapperModel.from_config(config) + inner_model = model._model # pylint: disable=protected-access assert model.fit_features_together is True + if recommend_n_threads is not None: + assert model.recommend_n_threads == recommend_n_threads + elif not use_gpu: + assert model.recommend_n_threads == inner_model.num_threads + else: + assert model.recommend_n_threads == 0 + if recommend_use_gpu is not None: + assert model.recommend_use_gpu_ranking == recommend_use_gpu + else: + assert model.recommend_use_gpu_ranking == use_gpu assert model.verbose == 1 - inner_model = model._model # pylint: disable=protected-access assert inner_model.factors == 16 assert inner_model.iterations == 100 if not use_gpu: @@ -462,10 +520,21 @@ def test_from_config(self, use_gpu: bool, cls: tp.Any) -> None: @pytest.mark.parametrize("use_gpu", (False, True)) @pytest.mark.parametrize("random_state", (None, 42)) @pytest.mark.parametrize("simple_types", (False, True)) - def test_to_config(self, use_gpu: bool, random_state: tp.Optional[int], simple_types: bool) -> None: + @pytest.mark.parametrize("recommend_use_gpu", (None, False, True)) + @pytest.mark.parametrize("recommend_n_threads", (None, 10)) + def test_to_config( + self, + use_gpu: bool, + random_state: tp.Optional[int], + simple_types: bool, + recommend_use_gpu: tp.Optional[bool], + recommend_n_threads: tp.Optional[int], + ) -> None: model = ImplicitALSWrapperModel( model=AlternatingLeastSquares(factors=16, num_threads=2, use_gpu=use_gpu, random_state=random_state), fit_features_together=True, + recommend_n_threads=recommend_n_threads, + recommend_use_gpu_ranking=recommend_use_gpu, verbose=1, ) config = model.get_config(simple_types=simple_types) @@ -493,6 +562,8 @@ def test_to_config(self, use_gpu: bool, random_state: tp.Optional[int], simple_t "model": expected_inner_model_config, "fit_features_together": True, "verbose": 1, + "recommend_use_gpu_ranking": recommend_use_gpu, + "recommend_n_threads": recommend_n_threads, } assert config == expected @@ -522,9 +593,15 @@ def test_custom_model_class(self) -> None: assert model.get_config()["model"]["cls"] == CustomALS # pylint: disable=unsubscriptable-object @pytest.mark.parametrize("simple_types", (False, True)) - def test_get_config_and_from_config_compatibility(self, simple_types: bool) -> None: + @pytest.mark.parametrize("recommend_use_gpu", (None, False, True)) + @pytest.mark.parametrize("recommend_n_threads", (None, 10)) + def test_get_config_and_from_config_compatibility( + self, simple_types: bool, recommend_use_gpu: tp.Optional[bool], recommend_n_threads: tp.Optional[int] + ) -> None: initial_config = { "model": {"factors": 16, "num_threads": 2, "iterations": 3, "random_state": 42}, + "recommend_use_gpu_ranking": recommend_use_gpu, + "recommend_n_threads": recommend_n_threads, "verbose": 1, } assert_get_config_and_from_config_compatibility(ImplicitALSWrapperModel, DATASET, initial_config, simple_types) diff --git a/tests/models/test_lightfm.py b/tests/models/test_lightfm.py index f13896f6..ba723704 100644 --- a/tests/models/test_lightfm.py +++ b/tests/models/test_lightfm.py @@ -129,9 +129,12 @@ def dataset_with_features(self, interactions_df: pd.DataFrame) -> Dataset: ), ), ) - def test_without_features(self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame) -> None: + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) + def test_without_features( + self, use_gpu_ranking: bool, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame + ) -> None: base_model = DeterministicLightFM(no_components=2, loss="logistic") - model = LightFMWrapperModel(model=base_model, epochs=50).fit(dataset) + model = LightFMWrapperModel(model=base_model, epochs=50, recommend_use_gpu_ranking=use_gpu_ranking).fit(dataset) actual = model.recommend( users=np.array([10, 20, 150]), # hot, hot, cold dataset=dataset, @@ -169,9 +172,12 @@ def test_without_features(self, dataset: Dataset, filter_viewed: bool, expected: ), ), ) - def test_with_whitelist(self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame) -> None: + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) + def test_with_whitelist( + self, use_gpu_ranking: bool, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame + ) -> None: base_model = DeterministicLightFM(no_components=2, loss="logistic") - model = LightFMWrapperModel(model=base_model, epochs=50).fit(dataset) + model = LightFMWrapperModel(model=base_model, epochs=50, recommend_use_gpu_ranking=use_gpu_ranking).fit(dataset) actual = model.recommend( users=np.array([20, 150]), # hot, cold dataset=dataset, @@ -185,9 +191,12 @@ def test_with_whitelist(self, dataset: Dataset, filter_viewed: bool, expected: p actual, ) - def test_with_features(self, dataset_with_features: Dataset) -> None: + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) + def test_with_features(self, use_gpu_ranking: bool, dataset_with_features: Dataset) -> None: base_model = DeterministicLightFM(no_components=2, loss="logistic") - model = LightFMWrapperModel(model=base_model, epochs=50).fit(dataset_with_features) + model = LightFMWrapperModel(model=base_model, epochs=50, recommend_use_gpu_ranking=use_gpu_ranking).fit( + dataset_with_features + ) actual = model.recommend( users=np.array([10, 20, 130, 150]), # hot, hot, warm, cold dataset=dataset_with_features, @@ -208,11 +217,12 @@ def test_with_features(self, dataset_with_features: Dataset) -> None: actual, ) - def test_with_weights(self, interactions_df: pd.DataFrame) -> None: + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) + def test_with_weights(self, use_gpu_ranking: bool, interactions_df: pd.DataFrame) -> None: interactions_df.loc[interactions_df[Columns.Item] == 14, Columns.Weight] = 100 dataset = Dataset.construct(interactions_df) base_model = DeterministicLightFM(no_components=2, loss="logistic") - model = LightFMWrapperModel(model=base_model, epochs=50).fit(dataset) + model = LightFMWrapperModel(model=base_model, epochs=50, recommend_use_gpu_ranking=use_gpu_ranking).fit(dataset) actual = model.recommend( users=np.array([20]), dataset=dataset, @@ -235,9 +245,12 @@ def test_with_warp_kos(self, dataset: Dataset) -> None: # LightFM raises ValueError with the dataset pass - def test_get_vectors(self, dataset_with_features: Dataset) -> None: + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) + def test_get_vectors(self, use_gpu_ranking: bool, dataset_with_features: Dataset) -> None: base_model = LightFM(no_components=2, loss="logistic") - model = LightFMWrapperModel(model=base_model).fit(dataset_with_features) + model = LightFMWrapperModel(model=base_model, recommend_use_gpu_ranking=use_gpu_ranking).fit( + dataset_with_features + ) user_embeddings, item_embeddings = model.get_vectors(dataset_with_features) predictions = user_embeddings @ item_embeddings.T vectors_predictions = [recommend_from_scores(predictions[i], k=5) for i in range(4)] @@ -296,15 +309,19 @@ def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset) -> None ), ), ) + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) def test_i2i( self, + use_gpu_ranking: bool, dataset_with_features: Dataset, filter_itself: bool, whitelist: tp.Optional[np.ndarray], expected: pd.DataFrame, ) -> None: base_model = DeterministicLightFM(no_components=2, loss="logistic") - model = LightFMWrapperModel(model=base_model, epochs=100).fit(dataset_with_features) + model = LightFMWrapperModel(model=base_model, epochs=100, recommend_use_gpu_ranking=use_gpu_ranking).fit( + dataset_with_features + ) actual = model.recommend_to_items( target_items=np.array([11, 12, 16, 17]), # hot, hot, warm, cold dataset=dataset_with_features, @@ -405,6 +422,8 @@ def test_to_config(self, random_state: tp.Optional[int], simple_types: bool) -> model=LightFM(no_components=16, learning_rate=0.03, random_state=random_state), epochs=2, num_threads=3, + recommend_n_threads=None, + recommend_use_gpu_ranking=True, verbose=1, ) config = model.get_config(simple_types=simple_types) @@ -428,6 +447,8 @@ def test_to_config(self, random_state: tp.Optional[int], simple_types: bool) -> "model": expected_inner_model_config, "epochs": 2, "num_threads": 3, + "recommend_n_threads": None, + "recommend_use_gpu_ranking": True, "verbose": 1, } assert config == expected @@ -458,10 +479,16 @@ def test_custom_model_class(self) -> None: assert model.get_config()["model"]["cls"] == CustomLightFM # pylint: disable=unsubscriptable-object @pytest.mark.parametrize("simple_types", (False, True)) - def test_get_config_and_from_config_compatibility(self, simple_types: bool) -> None: + @pytest.mark.parametrize("recommend_use_gpu", (False, True)) + @pytest.mark.parametrize("recommend_n_threads", (None, 10)) + def test_get_config_and_from_config_compatibility( + self, simple_types: bool, recommend_use_gpu: bool, recommend_n_threads: tp.Optional[int] + ) -> None: initial_config = { "model": {"no_components": 16, "learning_rate": 0.03, "random_state": 42}, "verbose": 1, + "recommend_n_threads": recommend_n_threads, + "recommend_use_gpu_ranking": recommend_use_gpu, } assert_get_config_and_from_config_compatibility(LightFMWrapperModel, DATASET, initial_config, simple_types) diff --git a/tests/models/test_pure_svd.py b/tests/models/test_pure_svd.py index 5d1471a6..0ad02016 100644 --- a/tests/models/test_pure_svd.py +++ b/tests/models/test_pure_svd.py @@ -291,6 +291,8 @@ def test_get_config(self, random_state: tp.Optional[int], simple_types: bool) -> maxiter=100, random_state=random_state, verbose=1, + recommend_n_threads=2, + recommend_use_gpu_ranking=False, ) config = model.get_config(simple_types=simple_types) expected = { @@ -300,6 +302,8 @@ def test_get_config(self, random_state: tp.Optional[int], simple_types: bool) -> "maxiter": 100, "random_state": random_state, "verbose": 1, + "recommend_n_threads": 2, + "recommend_use_gpu_ranking": False, } assert config == expected @@ -311,6 +315,8 @@ def test_get_config_and_from_config_compatibility(self, simple_types: bool) -> N "maxiter": 100, "random_state": 32, "verbose": 0, + "recommend_n_threads": 2, + "recommend_use_gpu_ranking": False, } assert_get_config_and_from_config_compatibility(PureSVDModel, DATASET, initial_config, simple_types) diff --git a/tests/models/test_vector.py b/tests/models/test_vector.py index 6ca827f7..13b68e4f 100644 --- a/tests/models/test_vector.py +++ b/tests/models/test_vector.py @@ -32,16 +32,16 @@ class TestVectorModel: # pylint: disable=protected-access, attribute-defined-ou def setup_method(self) -> None: stub_interactions = pd.DataFrame([], columns=Columns.Interactions) self.stub_dataset = Dataset.construct(stub_interactions) - user_embeddings = np.array([[-4, 0, 3], [0, 0, 0]]) + user_embeddings = np.array([[-4, 0, 3], [0, 1, 2]]) item_embeddings = np.array( [ [-4, 0, 3], - [0, 0, 0], - [1, 1, 1], + [0, 1, 2], + [1, 10, 100], ] ) - user_biases = np.array([0, 1]) - item_biases = np.array([0, 1, 3]) + user_biases = np.array([2, 1]) + item_biases = np.array([2, 1, 3]) self.user_factors = Factors(user_embeddings) self.item_factors = Factors(item_embeddings) self.user_biased_factors = Factors(user_embeddings, user_biases) @@ -74,9 +74,9 @@ def _get_items_factors(self, dataset: Dataset) -> Factors: @pytest.mark.parametrize( "distance,expected_reco,expected_scores", ( - (Distance.DOT, [[0, 1, 2], [2, 1, 0]], [[25, 0, -1], [0, 0, 0]]), - (Distance.COSINE, [[0, 1, 2], [2, 1, 0]], [[1, 0, -1 / (5 * 3**0.5)], [0, 0, 0]]), - (Distance.EUCLIDEAN, [[0, 1, 2], [1, 2, 0]], [[0, 5, 30**0.5], [0, 3**0.5, 5]]), + (Distance.DOT, [[2, 0, 1], [2, 0, 1]], [[296.0, 25.0, 6.0], [210.0, 6.0, 5.0]]), + (Distance.COSINE, [[0, 2, 1], [1, 2, 0]], [[1.0, 0.58903, 0.53666], [1.0, 0.93444, 0.53666]]), + (Distance.EUCLIDEAN, [[0, 1, 2], [1, 0, 2]], [[0.0, 4.24264, 97.6422], [0.0, 4.24264, 98.41748]]), ), ) @pytest.mark.parametrize("method", ("u2i", "i2i")) @@ -98,9 +98,9 @@ def test_without_biases( @pytest.mark.parametrize( "distance,expected_reco,expected_scores", ( - (Distance.DOT, [[0, 2, 1], [2, 1, 0]], [[25, 2, 1], [4, 2, 1]]), - (Distance.COSINE, [[0, 1, 2], [1, 2, 0]], [[1, 0, -1 / (5 * 12**0.5)], [1, 3 / (1 * 12**0.5), 0]]), - (Distance.EUCLIDEAN, [[0, 1, 2], [1, 2, 0]], [[0, 26**0.5, 39**0.5], [0, 7**0.5, 26**0.5]]), + (Distance.DOT, [[2, 0, 1], [2, 0, 1]], [[301.0, 29.0, 9.0], [214.0, 9.0, 7.0]]), + (Distance.COSINE, [[0, 1, 2], [1, 2, 0]], [[1.0, 0.60648, 0.55774], [1.0, 0.86483, 0.60648]]), + (Distance.EUCLIDEAN, [[0, 1, 2], [1, 0, 2]], [[0.0, 4.3589, 97.64732], [0.0, 4.3589, 98.4378]]), ), ) @pytest.mark.parametrize("method", ("u2i", "i2i"))