From 7693e11ba85bacc990253a62fa2dd6540431c689 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Wed, 4 Dec 2024 13:20:16 +0300 Subject: [PATCH 01/47] recommend kwargs in vector model --- rectools/models/vector.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/rectools/models/vector.py b/rectools/models/vector.py index 2af68fe5..19015413 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_cpu_n_threads: int = 0 + recommend_use_gpu_ranking: tp.Optional[bool] = None def _recommend_u2i( self, @@ -59,13 +61,15 @@ def _recommend_u2i( user_vectors, item_vectors = self._get_u2i_vectors(dataset) ranker = ImplicitRanker(self.u2i_dist, user_vectors, item_vectors) - + + use_gpu = self.recommend_use_gpu_ranking and HAS_CUDA return ranker.rank( subject_ids=user_ids, 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_cpu_n_threads, + use_gpu=use_gpu ) def _recommend_i2i( @@ -79,12 +83,14 @@ def _recommend_i2i( ranker = ImplicitRanker(self.i2i_dist, item_vectors_1, item_vectors_2) + use_gpu = self.recommend_use_gpu_ranking and HAS_CUDA return ranker.rank( subject_ids=target_ids, k=k, filter_pairs_csr=None, sorted_object_whitelist=sorted_item_ids_to_recommend, - num_threads=self.n_threads, + num_threads=self.recommend_cpu_n_threads, + use_gpu=use_gpu ) def _process_biases_to_vectors( From 1db73a121367af60f859f7ad10b626635f71c77a Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Wed, 4 Dec 2024 16:46:51 +0300 Subject: [PATCH 02/47] vector and ease kwargs and ials and ease configs --- rectools/models/ease.py | 33 +++++++++++++++++-- rectools/models/implicit_als.py | 54 +++++++++++++++++++++++++++---- rectools/models/vector.py | 26 ++++++++++----- tests/models/test_ease.py | 13 ++------ tests/models/test_implicit_als.py | 2 ++ 5 files changed, 100 insertions(+), 28 deletions(-) diff --git a/rectools/models/ease.py b/rectools/models/ease.py index 3139a72e..8b7b7a17 100644 --- a/rectools/models/ease.py +++ b/rectools/models/ease.py @@ -18,6 +18,7 @@ import numpy as np import typing_extensions as tpe +from implicit.gpu import HAS_CUDA from scipy import sparse from rectools import InternalIds @@ -34,6 +35,7 @@ class EASEModelConfig(ModelConfig): regularization: float = 500.0 num_threads: int = 1 + recommend_use_gpu_ranking: tp.Optional[bool] = None class EASEModel(ModelBase[EASEModelConfig]): @@ -54,7 +56,12 @@ class EASEModel(ModelBase[EASEModelConfig]): 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. + Number of threads used for recommendation ranking on cpu. + recommend_use_gpu_ranking: Optional[bool], default ``None`` + Flag to use gpu for recommendation ranking. If ``None``, `implicit.gpu.HAS_CUDA` will be + checked before inference. + This attribute can be changed manually before calling model `recommend` method if you + want to change ranking behaviour. """ recommends_for_warm = False @@ -67,19 +74,31 @@ def __init__( regularization: float = 500.0, num_threads: int = 1, verbose: int = 0, + recommend_use_gpu_ranking: tp.Optional[bool] = None, ): super().__init__(verbose=verbose) self.weight: np.ndarray self.regularization = regularization self.num_threads = num_threads + self.recommend_use_gpu_ranking = recommend_use_gpu_ranking def _get_config(self) -> EASEModelConfig: - return EASEModelConfig(regularization=self.regularization, num_threads=self.num_threads, verbose=self.verbose) + return EASEModelConfig( + regularization=self.regularization, + num_threads=self.num_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_use_gpu_ranking=config.recommend_use_gpu_ranking, + num_threads=config.num_threads, + verbose=config.verbose, + ) def _fit(self, dataset: Dataset) -> None: # type: ignore ui_csr = dataset.get_user_item_matrix(include_weights=True) @@ -93,6 +112,13 @@ def _fit(self, dataset: Dataset) -> None: # type: ignore self.weight = np.array(gram_matrix_inv / (-np.diag(gram_matrix_inv))) np.fill_diagonal(self.weight, 0.0) + @property + def _recommend_use_gpu_ranking(self) -> bool: + use_gpu = HAS_CUDA + if self.recommend_use_gpu_ranking is False: + use_gpu = False + return use_gpu + def _recommend_u2i( self, user_ids: InternalIdsArray, @@ -116,6 +142,7 @@ def _recommend_u2i( filter_pairs_csr=ui_csr_for_filter, sorted_object_whitelist=sorted_item_ids_to_recommend, num_threads=self.num_threads, + use_gpu=self._recommend_use_gpu_ranking, ) return all_user_ids, all_reco_ids, all_scores diff --git a/rectools/models/implicit_als.py b/rectools/models/implicit_als.py index 737ea202..ca2551c0 100644 --- a/rectools/models/implicit_als.py +++ b/rectools/models/implicit_als.py @@ -104,6 +104,8 @@ class ImplicitALSWrapperModelConfig(ModelConfig): model: AlternatingLeastSquaresConfig fit_features_together: bool = False + recommend_cpu_n_threads: tp.Optional[int] = None + recommend_use_gpu_ranking: tp.Optional[bool] = None class ImplicitALSWrapperModel(VectorModel[ImplicitALSWrapperModelConfig]): @@ -123,6 +125,17 @@ class ImplicitALSWrapperModel(VectorModel[ImplicitALSWrapperModelConfig]): 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_cpu_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`. + This attribute can be changed manually before calling model `recommend` method if you + want to change ranking behaviour. + 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. + This attribute can be changed manually before calling model `recommend` method if you + want to change ranking behaviour. """ recommends_for_warm = False @@ -133,8 +146,17 @@ 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, + verbose: int = 0, + fit_features_together: bool = False, + recommend_cpu_n_threads: tp.Optional[int] = None, + recommend_use_gpu_ranking: tp.Optional[bool] = None, + ): + self._config = self._make_config( + model, verbose, fit_features_together, recommend_cpu_n_threads, recommend_use_gpu_ranking + ) super().__init__(verbose=verbose) @@ -142,13 +164,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_cpu_n_threads is None and isinstance(model, CPUAlternatingLeastSquares): + recommend_cpu_n_threads = model.num_threads + self.recommend_cpu_n_threads = recommend_cpu_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_cpu_n_threads: tp.Optional[int] = None, + recommend_use_gpu_ranking: tp.Optional[bool] = None, ) -> ImplicitALSWrapperModelConfig: params = { "factors": model.factors, @@ -183,6 +215,8 @@ def _make_config( ), verbose=verbose, fit_features_together=fit_features_together, + recommend_cpu_n_threads=recommend_cpu_n_threads, + recommend_use_gpu_ranking=recommend_use_gpu_ranking, ) def _get_config(self) -> ImplicitALSWrapperModelConfig: @@ -195,7 +229,13 @@ def _from_config(cls, config: ImplicitALSWrapperModelConfig) -> tpe.Self: else: model_cls = config.model.cls model = model_cls(**config.model.params) - 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_cpu_n_threads=config.recommend_cpu_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/vector.py b/rectools/models/vector.py index 19015413..b806c71d 100644 --- a/rectools/models/vector.py +++ b/rectools/models/vector.py @@ -41,9 +41,21 @@ class VectorModel(ModelBase[ModelConfig_T]): u2i_dist: Distance = NotImplemented i2i_dist: Distance = NotImplemented - recommend_cpu_n_threads: int = 0 + recommend_cpu_n_threads: tp.Optional[int] = None recommend_use_gpu_ranking: tp.Optional[bool] = None + @property + def _recommend_use_gpu_ranking(self) -> bool: + use_gpu = HAS_CUDA + if self.recommend_use_gpu_ranking is False: + use_gpu = False + return use_gpu + + @property + def _recommend_cpu_n_threads(self) -> int: + num_threads = 0 if self.recommend_cpu_n_threads is None else self.recommend_cpu_n_threads + return num_threads + def _recommend_u2i( self, user_ids: InternalIdsArray, @@ -61,15 +73,14 @@ def _recommend_u2i( user_vectors, item_vectors = self._get_u2i_vectors(dataset) ranker = ImplicitRanker(self.u2i_dist, user_vectors, item_vectors) - - use_gpu = self.recommend_use_gpu_ranking and HAS_CUDA + return ranker.rank( subject_ids=user_ids, k=k, filter_pairs_csr=ui_csr_for_filter, sorted_object_whitelist=sorted_item_ids_to_recommend, - num_threads=self.recommend_cpu_n_threads, - use_gpu=use_gpu + num_threads=self._recommend_cpu_n_threads, + use_gpu=self._recommend_use_gpu_ranking, ) def _recommend_i2i( @@ -83,14 +94,13 @@ def _recommend_i2i( ranker = ImplicitRanker(self.i2i_dist, item_vectors_1, item_vectors_2) - use_gpu = self.recommend_use_gpu_ranking and HAS_CUDA return ranker.rank( subject_ids=target_ids, k=k, filter_pairs_csr=None, sorted_object_whitelist=sorted_item_ids_to_recommend, - num_threads=self.recommend_cpu_n_threads, - use_gpu=use_gpu + num_threads=self._recommend_cpu_n_threads, + use_gpu=self._recommend_use_gpu_ranking, ) def _process_biases_to_vectors( diff --git a/tests/models/test_ease.py b/tests/models/test_ease.py index 20fc1701..7175a0fe 100644 --- a/tests/models/test_ease.py +++ b/tests/models/test_ease.py @@ -249,22 +249,15 @@ def test_get_config(self) -> None: regularization=500, num_threads=1, verbose=1, + recommend_use_gpu_ranking=None, ) config = model.get_config() - expected = { - "regularization": 500, - "num_threads": 1, - "verbose": 1, - } + expected = {"regularization": 500, "num_threads": 1, "verbose": 1, "recommend_use_gpu_ranking": None} assert config == expected @pytest.mark.parametrize("simple_types", (False, True)) def test_get_config_and_from_config_compatibility(self, simple_types: bool) -> None: - initial_config = { - "regularization": 500, - "num_threads": 1, - "verbose": 1, - } + initial_config = {"regularization": 500, "num_threads": 1, "verbose": 1, "recommend_use_gpu_ranking": True} assert_get_config_and_from_config_compatibility(EASEModel, DATASET, initial_config, simple_types) def test_default_config_and_default_model_params_are_the_same(self) -> None: diff --git a/tests/models/test_implicit_als.py b/tests/models/test_implicit_als.py index 91cc9514..b03ebede 100644 --- a/tests/models/test_implicit_als.py +++ b/tests/models/test_implicit_als.py @@ -496,6 +496,8 @@ def test_to_config(self, use_gpu: bool, random_state: tp.Optional[int], simple_t }, "fit_features_together": True, "verbose": 1, + "recommend_use_gpu_ranking": None, + "recommend_cpu_n_threads": None, } assert config == expected From c05950065801a8dce93a38ad0219ac57b638c8a4 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Wed, 4 Dec 2024 16:56:16 +0300 Subject: [PATCH 03/47] pure_svd config --- rectools/models/pure_svd.py | 21 +++++++++++++++++++++ tests/models/test_pure_svd.py | 6 ++++++ 2 files changed, 27 insertions(+) diff --git a/rectools/models/pure_svd.py b/rectools/models/pure_svd.py index 9984bcff..175b779e 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_cpu_n_threads: tp.Optional[int] = None + recommend_use_gpu_ranking: tp.Optional[bool] = None class PureSVDModel(VectorModel[PureSVDModelConfig]): @@ -54,6 +56,17 @@ 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_cpu_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`. + This attribute can be changed manually before calling model `recommend` method if you + want to change ranking behaviour. + 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. + This attribute can be changed manually before calling model `recommend` method if you + want to change ranking behaviour. """ recommends_for_warm = False @@ -71,6 +84,8 @@ def __init__( maxiter: tp.Optional[int] = None, random_state: tp.Optional[int] = None, verbose: int = 0, + recommend_cpu_n_threads: tp.Optional[int] = None, + recommend_use_gpu_ranking: tp.Optional[bool] = None, ): super().__init__(verbose=verbose) @@ -78,6 +93,8 @@ def __init__( self.tol = tol self.maxiter = maxiter self.random_state = random_state + self.recommend_cpu_n_threads = recommend_cpu_n_threads + self.recommend_use_gpu_ranking = recommend_use_gpu_ranking self.user_factors: np.ndarray self.item_factors: np.ndarray @@ -89,6 +106,8 @@ def _get_config(self) -> PureSVDModelConfig: maxiter=self.maxiter, random_state=self.random_state, verbose=self.verbose, + recommend_cpu_n_threads=self.recommend_cpu_n_threads, + recommend_use_gpu_ranking=self.recommend_use_gpu_ranking, ) @classmethod @@ -99,6 +118,8 @@ def _from_config(cls, config: PureSVDModelConfig) -> tpe.Self: maxiter=config.maxiter, random_state=config.random_state, verbose=config.verbose, + recommend_cpu_n_threads=config.recommend_cpu_n_threads, + recommend_use_gpu_ranking=config.recommend_use_gpu_ranking, ) def _fit(self, dataset: Dataset) -> None: # type: ignore diff --git a/tests/models/test_pure_svd.py b/tests/models/test_pure_svd.py index a197c150..98c0481e 100644 --- a/tests/models/test_pure_svd.py +++ b/tests/models/test_pure_svd.py @@ -290,6 +290,8 @@ def test_get_config(self, random_state: tp.Optional[int]) -> None: maxiter=100, random_state=random_state, verbose=1, + recommend_cpu_n_threads=None, + recommend_use_gpu_ranking=None, ) config = model.get_config() expected = { @@ -298,6 +300,8 @@ def test_get_config(self, random_state: tp.Optional[int]) -> None: "maxiter": 100, "random_state": random_state, "verbose": 1, + "recommend_cpu_n_threads": None, + "recommend_use_gpu_ranking": None, } assert config == expected @@ -309,6 +313,8 @@ def test_get_config_and_from_config_compatibility(self, simple_types: bool) -> N "maxiter": 100, "random_state": 32, "verbose": 0, + "recommend_cpu_n_threads": None, + "recommend_use_gpu_ranking": None, } assert_get_config_and_from_config_compatibility(PureSVDModel, DATASET, initial_config, simple_types) From e07c13da646719f519e7b3b82cabe8dffa9f9761 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Wed, 4 Dec 2024 17:07:44 +0300 Subject: [PATCH 04/47] lightfm config --- rectools/models/lightfm.py | 11 +++++++++++ tests/models/test_lightfm.py | 1 + 2 files changed, 12 insertions(+) diff --git a/rectools/models/lightfm.py b/rectools/models/lightfm.py index 40b93189..fbda724f 100644 --- a/rectools/models/lightfm.py +++ b/rectools/models/lightfm.py @@ -93,6 +93,7 @@ class LightFMWrapperModelConfig(ModelConfig): model: LightFMConfig epochs: int = 1 num_threads: int = 1 + recommend_use_gpu_ranking: tp.Optional[bool] = None class LightFMWrapperModel(FixedColdRecoModelMixin, VectorModel[LightFMWrapperModelConfig]): @@ -115,6 +116,12 @@ class LightFMWrapperModel(FixedColdRecoModelMixin, VectorModel[LightFMWrapperMod Will be used as `num_threads` parameter for `LightFM.fit`. verbose : int, default 0 Degree of verbose output. If 0, no output will be provided. + 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. + This attribute can be changed manually before calling model `recommend` method if you + want to change ranking behaviour. """ recommends_for_warm = True @@ -131,6 +138,7 @@ def __init__( epochs: int = 1, num_threads: int = 1, verbose: int = 0, + recommend_use_gpu_ranking: tp.Optional[bool] = None, ): super().__init__(verbose=verbose) @@ -138,6 +146,8 @@ def __init__( self._model = model self.n_epochs = epochs self.n_threads = num_threads + self.recommend_cpu_n_threads = num_threads # TODO: not consistent with VectorModel parent behaviour + self.recommend_use_gpu_ranking = recommend_use_gpu_ranking def _get_config(self) -> LightFMWrapperModelConfig: inner_model = self._model @@ -164,6 +174,7 @@ def _get_config(self) -> LightFMWrapperModelConfig: epochs=self.n_epochs, num_threads=self.n_threads, verbose=self.verbose, + recommend_use_gpu_ranking=self.recommend_use_gpu_ranking, ) @classmethod diff --git a/tests/models/test_lightfm.py b/tests/models/test_lightfm.py index a527013b..a3459c93 100644 --- a/tests/models/test_lightfm.py +++ b/tests/models/test_lightfm.py @@ -408,6 +408,7 @@ def test_to_config(self, random_state: tp.Optional[int], simple_types: bool) -> "epochs": 2, "num_threads": 3, "verbose": 1, + "recommend_use_gpu_ranking": None, } assert config == expected From 211b4b69d65169f335c572639fe1c2fe37d63222 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Thu, 5 Dec 2024 11:10:17 +0300 Subject: [PATCH 05/47] todo decide --- rectools/models/ease.py | 2 +- rectools/models/lightfm.py | 2 +- rectools/models/vector.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rectools/models/ease.py b/rectools/models/ease.py index 8b7b7a17..2cdb1af2 100644 --- a/rectools/models/ease.py +++ b/rectools/models/ease.py @@ -72,7 +72,7 @@ class EASEModel(ModelBase[EASEModelConfig]): def __init__( self, regularization: float = 500.0, - num_threads: int = 1, + num_threads: int = 1, # TODO: decide. We already have it. But this is actually recommend_cpu_n_threads verbose: int = 0, recommend_use_gpu_ranking: tp.Optional[bool] = None, ): diff --git a/rectools/models/lightfm.py b/rectools/models/lightfm.py index fbda724f..feab21ee 100644 --- a/rectools/models/lightfm.py +++ b/rectools/models/lightfm.py @@ -136,7 +136,7 @@ def __init__( self, model: LightFM, epochs: int = 1, - num_threads: int = 1, + num_threads: int = 1, # TODO: decide. this is used for both fit n_threads and ranker n_threads verbose: int = 0, recommend_use_gpu_ranking: tp.Optional[bool] = None, ): diff --git a/rectools/models/vector.py b/rectools/models/vector.py index b806c71d..4380b456 100644 --- a/rectools/models/vector.py +++ b/rectools/models/vector.py @@ -41,7 +41,7 @@ class VectorModel(ModelBase[ModelConfig_T]): u2i_dist: Distance = NotImplemented i2i_dist: Distance = NotImplemented - recommend_cpu_n_threads: tp.Optional[int] = None + recommend_cpu_n_threads: tp.Optional[int] = None # TODO: decide. Lightfm has num_threads recommend_use_gpu_ranking: tp.Optional[bool] = None @property From e859c3089938f7960d0e431b6999a6aaf3727685 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Fri, 6 Dec 2024 23:29:01 +0300 Subject: [PATCH 06/47] fixed ease --- rectools/models/ease.py | 50 ++++++++++++++++++++++++++------------- tests/models/test_ease.py | 16 ++++++++----- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/rectools/models/ease.py b/rectools/models/ease.py index f4da97fe..bd19c735 100644 --- a/rectools/models/ease.py +++ b/rectools/models/ease.py @@ -15,6 +15,7 @@ """EASE model.""" import typing as tp +import warnings import numpy as np import typing_extensions as tpe @@ -34,8 +35,9 @@ class EASEModelConfig(ModelConfig): """Config for `EASE` model.""" regularization: float = 500.0 - num_threads: int = 1 - recommend_use_gpu_ranking: tp.Optional[bool] = None + recommend_n_threads: int = 0 + recommend_use_gpu_ranking: bool = True + class EASEModel(ModelBase[EASEModelConfig]): @@ -53,15 +55,22 @@ class EASEModel(ModelBase[EASEModelConfig]): ---------- regularization : float The regularization factor of the weights. + num_threads: Optional[int], default ``None`` + Deprecated. 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 ranking behaviour, you can manually assign new value to model + `recommend_n_threads` attribute. This should be done before calling model + `recommend` method. + 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 ranking behaviour, you can manually assign new value to model + `recommend_use_gpu_ranking` attribute. This should be done before calling model + `recommend` method. 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 recommendation ranking on cpu. - recommend_use_gpu_ranking: Optional[bool], default ``None`` - Flag to use gpu for recommendation ranking. If ``None``, `implicit.gpu.HAS_CUDA` will be - checked before inference. - This attribute can be changed manually before calling model `recommend` method if you - want to change ranking behaviour. """ recommends_for_warm = False @@ -72,22 +81,31 @@ class EASEModel(ModelBase[EASEModelConfig]): def __init__( self, regularization: float = 500.0, - num_threads: int = 1, # TODO: decide. We already have it. But this is actually recommend_cpu_n_threads + num_threads: tp.Optional[int] = None, + recommend_n_threads: int = 0, + recommend_use_gpu_ranking: bool = True, verbose: int = 0, - recommend_use_gpu_ranking: tp.Optional[bool] = None, ): 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__, + cls=self.__class__, regularization=self.regularization, - num_threads=self.num_threads, + recommend_n_threads=self.recommend_n_threads, recommend_use_gpu_ranking=self.recommend_use_gpu_ranking, verbose=self.verbose, ) @@ -96,8 +114,8 @@ def _get_config(self) -> EASEModelConfig: def _from_config(cls, config: EASEModelConfig) -> tpe.Self: return cls( regularization=config.regularization, + recommend_n_threads=config.recommend_n_threads, recommend_use_gpu_ranking=config.recommend_use_gpu_ranking, - num_threads=config.num_threads, verbose=config.verbose, ) @@ -142,7 +160,7 @@ 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, ) diff --git a/tests/models/test_ease.py b/tests/models/test_ease.py index 8eb38858..91468e3d 100644 --- a/tests/models/test_ease.py +++ b/tests/models/test_ease.py @@ -236,33 +236,37 @@ 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, - recommend_use_gpu_ranking=None, ) config = model.get_config() 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 @pytest.mark.parametrize("simple_types", (False, True)) def test_get_config_and_from_config_compatibility(self, simple_types: bool) -> None: - initial_config = {"regularization": 500, "num_threads": 1, "verbose": 1, "recommend_use_gpu_ranking": True} + initial_config = {"regularization": 500, "recommend_n_threads": 1, "verbose": 1, "recommend_use_gpu_ranking": True} assert_get_config_and_from_config_compatibility(EASEModel, DATASET, initial_config, simple_types) def test_default_config_and_default_model_params_are_the_same(self) -> None: From 9754852dee18d880ada84b95feb3bd3a4a7db524 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Fri, 6 Dec 2024 23:31:01 +0300 Subject: [PATCH 07/47] fixed ease test --- tests/models/test_ease.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/models/test_ease.py b/tests/models/test_ease.py index 91468e3d..766ef8b8 100644 --- a/tests/models/test_ease.py +++ b/tests/models/test_ease.py @@ -254,7 +254,7 @@ def test_get_config(self, simple_types: bool) -> None: recommend_use_gpu_ranking=False, verbose=1, ) - config = model.get_config() + config = model.get_config(simple_types=simple_types) expected = { "cls": "EASEModel" if simple_types else EASEModel, "regularization": 500, From 76b332f5974f6631df13633aae9417e536fff64f Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Fri, 6 Dec 2024 23:38:21 +0300 Subject: [PATCH 08/47] removed property --- rectools/models/ease.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/rectools/models/ease.py b/rectools/models/ease.py index bd19c735..eba426b1 100644 --- a/rectools/models/ease.py +++ b/rectools/models/ease.py @@ -93,7 +93,7 @@ def __init__( if num_threads is not None: warnings.warn(""" - `num_threads` argument is deprecated and will be removed in future releases. + `num_threads` argument is deprecated and will be removed in future releases. Please use `recommend_n_threads` instead") """) recommend_n_threads = num_threads @@ -131,13 +131,6 @@ def _fit(self, dataset: Dataset) -> None: # type: ignore self.weight = np.array(gram_matrix_inv / (-np.diag(gram_matrix_inv))) np.fill_diagonal(self.weight, 0.0) - @property - def _recommend_use_gpu_ranking(self) -> bool: - use_gpu = HAS_CUDA - if self.recommend_use_gpu_ranking is False: - use_gpu = False - return use_gpu - def _recommend_u2i( self, user_ids: InternalIdsArray, @@ -161,7 +154,7 @@ def _recommend_u2i( filter_pairs_csr=ui_csr_for_filter, sorted_object_whitelist=sorted_item_ids_to_recommend, num_threads=self.recommend_n_threads, - use_gpu=self._recommend_use_gpu_ranking, + use_gpu=self.recommend_use_gpu_ranking is not False and HAS_CUDA, ) return all_user_ids, all_reco_ids, all_scores From 8b1d4f4faf77087b86c7d3d9472f1cb0ab8b98f5 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Fri, 6 Dec 2024 23:45:13 +0300 Subject: [PATCH 09/47] check_blas_config for cpu ranking --- rectools/models/rank.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rectools/models/rank.py b/rectools/models/rank.py index fabc389d..0b850aca 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`") @@ -252,6 +254,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, From 471cbf6905f022ef68d218d52c0ce3bf22c78029 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Fri, 6 Dec 2024 23:54:11 +0300 Subject: [PATCH 10/47] fixed als main code --- rectools/models/ease.py | 11 +++++---- rectools/models/implicit_als.py | 40 +++++++++++++++++++------------ rectools/models/rank.py | 2 +- tests/models/test_ease.py | 7 +++++- tests/models/test_implicit_als.py | 12 ++++++++-- 5 files changed, 48 insertions(+), 24 deletions(-) diff --git a/rectools/models/ease.py b/rectools/models/ease.py index eba426b1..8b3a06ce 100644 --- a/rectools/models/ease.py +++ b/rectools/models/ease.py @@ -37,7 +37,6 @@ class EASEModelConfig(ModelConfig): regularization: float = 500.0 recommend_n_threads: int = 0 recommend_use_gpu_ranking: bool = True - class EASEModel(ModelBase[EASEModelConfig]): @@ -90,14 +89,16 @@ def __init__( super().__init__(verbose=verbose) self.weight: np.ndarray self.regularization = regularization - + if num_threads is not None: - warnings.warn(""" + 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 diff --git a/rectools/models/implicit_als.py b/rectools/models/implicit_als.py index e8b1854f..cb0897d2 100644 --- a/rectools/models/implicit_als.py +++ b/rectools/models/implicit_als.py @@ -97,7 +97,7 @@ class ImplicitALSWrapperModelConfig(ModelConfig): model: AlternatingLeastSquaresConfig fit_features_together: bool = False - recommend_cpu_n_threads: tp.Optional[int] = None + recommend_n_threads: tp.Optional[int] = None recommend_use_gpu_ranking: tp.Optional[bool] = None @@ -118,7 +118,7 @@ class ImplicitALSWrapperModel(VectorModel[ImplicitALSWrapperModelConfig]): 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_cpu_n_threads: Optional[int], default ``None`` + 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`. This attribute can be changed manually before calling model `recommend` method if you @@ -144,11 +144,15 @@ def __init__( model: AnyAlternatingLeastSquares, verbose: int = 0, fit_features_together: bool = False, - recommend_cpu_n_threads: tp.Optional[int] = None, + recommend_n_threads: tp.Optional[int] = None, recommend_use_gpu_ranking: tp.Optional[bool] = None, ): self._config = self._make_config( - model, verbose, fit_features_together, recommend_cpu_n_threads, recommend_use_gpu_ranking + 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) @@ -158,9 +162,9 @@ def __init__( self.fit_features_together = fit_features_together - if recommend_cpu_n_threads is None and isinstance(model, CPUAlternatingLeastSquares): - recommend_cpu_n_threads = model.num_threads - self.recommend_cpu_n_threads = recommend_cpu_n_threads + if recommend_n_threads is None and isinstance(model, CPUAlternatingLeastSquares): + recommend_n_threads = model.num_threads + self.recommend_n_threads = recommend_n_threads if recommend_use_gpu_ranking is None: recommend_use_gpu_ranking = isinstance(model, GPUAlternatingLeastSquares) @@ -172,7 +176,7 @@ def _make_config( model: AnyAlternatingLeastSquares, verbose: int, fit_features_together: bool, - recommend_cpu_n_threads: tp.Optional[int] = None, + recommend_n_threads: tp.Optional[int] = None, recommend_use_gpu_ranking: tp.Optional[bool] = None, ) -> ImplicitALSWrapperModelConfig: model_cls = ( @@ -208,7 +212,7 @@ def _make_config( model=tp.cast(AlternatingLeastSquaresConfig, inner_model_config), verbose=verbose, fit_features_together=fit_features_together, - recommend_cpu_n_threads=recommend_cpu_n_threads, + recommend_n_threads=recommend_n_threads, recommend_use_gpu_ranking=recommend_use_gpu_ranking, ) @@ -217,12 +221,18 @@ def _get_config(self) -> ImplicitALSWrapperModelConfig: @classmethod def _from_config(cls, config: ImplicitALSWrapperModelConfig) -> tpe.Self: - if config.model.cls == ALS_STRING: - model_cls = AlternatingLeastSquares # Not actually a class, but it's ok - else: - model_cls = config.model.cls - model = model_cls(**config.model.params) - return cls(model=model, verbose=config.verbose, fit_features_together=config.fit_features_together) + inner_model_params = config.model.copy() + inner_model_cls = inner_model_params.pop("cls", AlternatingLeastSquares) + 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, + 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/rank.py b/rectools/models/rank.py index 0b850aca..1c464745 100644 --- a/rectools/models/rank.py +++ b/rectools/models/rank.py @@ -62,7 +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`") diff --git a/tests/models/test_ease.py b/tests/models/test_ease.py index 766ef8b8..ea9542f2 100644 --- a/tests/models/test_ease.py +++ b/tests/models/test_ease.py @@ -266,7 +266,12 @@ def test_get_config(self, simple_types: bool) -> None: @pytest.mark.parametrize("simple_types", (False, True)) def test_get_config_and_from_config_compatibility(self, simple_types: bool) -> None: - initial_config = {"regularization": 500, "recommend_n_threads": 1, "verbose": 1, "recommend_use_gpu_ranking": True} + initial_config = { + "regularization": 500, + "recommend_n_threads": 1, + "verbose": 1, + "recommend_use_gpu_ranking": True, + } assert_get_config_and_from_config_compatibility(EASEModel, DATASET, initial_config, simple_types) def test_default_config_and_default_model_params_are_the_same(self) -> None: diff --git a/tests/models/test_implicit_als.py b/tests/models/test_implicit_als.py index 63ccd82b..21bdbe0f 100644 --- a/tests/models/test_implicit_als.py +++ b/tests/models/test_implicit_als.py @@ -444,12 +444,16 @@ def test_from_config(self, use_gpu: bool, cls: tp.Any) -> None: "use_gpu": use_gpu, }, "fit_features_together": True, + "recommend_n_threads": None, + "recommend_use_gpu_ranking": None, "verbose": 1, } if cls is not None: config["model"]["cls"] = cls model = ImplicitALSWrapperModel.from_config(config) assert model.fit_features_together is True + assert model.recommend_n_threads is None + assert model.recommend_use_gpu_ranking is None assert model.verbose == 1 inner_model = model._model # pylint: disable=protected-access assert inner_model.factors == 16 @@ -466,6 +470,8 @@ def test_to_config(self, use_gpu: bool, random_state: tp.Optional[int], simple_t model = ImplicitALSWrapperModel( model=AlternatingLeastSquares(factors=16, num_threads=2, use_gpu=use_gpu, random_state=random_state), fit_features_together=True, + recommend_n_threads=10, + recommend_use_gpu_ranking=False, verbose=1, ) config = model.get_config(simple_types=simple_types) @@ -493,8 +499,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": None, - "recommend_cpu_n_threads": None, + "recommend_use_gpu_ranking": False, + "recommend_n_threads": 10, } assert config == expected @@ -527,6 +533,8 @@ def test_custom_model_class(self) -> None: def test_get_config_and_from_config_compatibility(self, simple_types: bool) -> None: initial_config = { "model": {"factors": 16, "num_threads": 2, "iterations": 3, "random_state": 42}, + "recommend_use_gpu_ranking": False, + "recommend_n_threads": 10, "verbose": 1, } assert_get_config_and_from_config_compatibility(ImplicitALSWrapperModel, DATASET, initial_config, simple_types) From f947f30eea36e88938b82b4e6a8996d4d6404635 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Tue, 10 Dec 2024 11:54:42 +0300 Subject: [PATCH 11/47] fixed docs in ease --- rectools/models/ease.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/rectools/models/ease.py b/rectools/models/ease.py index 8b3a06ce..595647f6 100644 --- a/rectools/models/ease.py +++ b/rectools/models/ease.py @@ -55,19 +55,18 @@ class EASEModel(ModelBase[EASEModelConfig]): regularization : float The regularization factor of the weights. num_threads: Optional[int], default ``None`` - Deprecated. Number of threads used for recommendation ranking on cpu. + 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 ranking behaviour, you can manually assign new value to model - `recommend_n_threads` attribute. This should be done before calling model - `recommend` method. + 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 ranking behaviour, you can manually assign new value to model - `recommend_use_gpu_ranking` attribute. This should be done before calling model - `recommend` method. + 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. """ @@ -94,7 +93,7 @@ def __init__( warnings.warn( """ `num_threads` argument is deprecated and will be removed in future releases. - Please use `recommend_n_threads` instead") + Please use `recommend_n_threads` instead. """ ) recommend_n_threads = num_threads @@ -155,7 +154,7 @@ def _recommend_u2i( filter_pairs_csr=ui_csr_for_filter, sorted_object_whitelist=sorted_item_ids_to_recommend, num_threads=self.recommend_n_threads, - use_gpu=self.recommend_use_gpu_ranking is not False and HAS_CUDA, + use_gpu=self.recommend_use_gpu_ranking and HAS_CUDA, ) return all_user_ids, all_reco_ids, all_scores From 153be670140432c18b1ab5161b967b03154bc519 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Tue, 10 Dec 2024 13:30:01 +0300 Subject: [PATCH 12/47] updated svd --- rectools/models/pure_svd.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/rectools/models/pure_svd.py b/rectools/models/pure_svd.py index c2a8625d..09f6cb59 100644 --- a/rectools/models/pure_svd.py +++ b/rectools/models/pure_svd.py @@ -34,8 +34,8 @@ class PureSVDModelConfig(ModelConfig): tol: float = 0 maxiter: tp.Optional[int] = None random_state: tp.Optional[int] = None - recommend_cpu_n_threads: tp.Optional[int] = None - recommend_use_gpu_ranking: tp.Optional[bool] = None + recommend_n_threads: int = 0 + recommend_use_gpu_ranking: bool = True class PureSVDModel(VectorModel[PureSVDModelConfig]): @@ -56,17 +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_cpu_n_threads: Optional[int], default ``None`` + recommend_n_threads: int, default 0 Number of threads to use for recommendation ranking on cpu. - If ``None``, then number of threads will be set same as `model.num_threads`. - This attribute can be changed manually before calling model `recommend` method if you - want to change ranking behaviour. - 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. - This attribute can be changed manually before calling model `recommend` method if you - want to change ranking behaviour. + 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 @@ -84,8 +83,8 @@ def __init__( maxiter: tp.Optional[int] = None, random_state: tp.Optional[int] = None, verbose: int = 0, - recommend_cpu_n_threads: tp.Optional[int] = None, - recommend_use_gpu_ranking: tp.Optional[bool] = None, + recommend_n_threads: int = 0, + recommend_use_gpu_ranking: bool = True, ): super().__init__(verbose=verbose) @@ -93,7 +92,7 @@ def __init__( self.tol = tol self.maxiter = maxiter self.random_state = random_state - self.recommend_cpu_n_threads = recommend_cpu_n_threads + self.recommend_n_threads = recommend_n_threads self.recommend_use_gpu_ranking = recommend_use_gpu_ranking self.user_factors: np.ndarray @@ -107,7 +106,7 @@ def _get_config(self) -> PureSVDModelConfig: maxiter=self.maxiter, random_state=self.random_state, verbose=self.verbose, - recommend_cpu_n_threads=self.recommend_cpu_n_threads, + recommend_n_threads=self.recommend_n_threads, recommend_use_gpu_ranking=self.recommend_use_gpu_ranking, ) @@ -119,7 +118,7 @@ def _from_config(cls, config: PureSVDModelConfig) -> tpe.Self: maxiter=config.maxiter, random_state=config.random_state, verbose=config.verbose, - recommend_cpu_n_threads=config.recommend_cpu_n_threads, + recommend_n_threads=config.recommend_n_threads, recommend_use_gpu_ranking=config.recommend_use_gpu_ranking, ) From 30a83632d84ce78dd9833565a92090f84f0fe9e4 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Tue, 10 Dec 2024 17:42:15 +0300 Subject: [PATCH 13/47] fixed lightfm --- rectools/models/lightfm.py | 23 +++++++++++++---------- rectools/models/vector.py | 4 ++-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/rectools/models/lightfm.py b/rectools/models/lightfm.py index 1565ee65..ba2072bc 100644 --- a/rectools/models/lightfm.py +++ b/rectools/models/lightfm.py @@ -86,7 +86,7 @@ class LightFMWrapperModelConfig(ModelConfig): model: LightFMConfig epochs: int = 1 num_threads: int = 1 - recommend_use_gpu_ranking: tp.Optional[bool] = None + recommend_use_gpu_ranking: bool = True class LightFMWrapperModel(FixedColdRecoModelMixin, VectorModel[LightFMWrapperModelConfig]): @@ -106,15 +106,18 @@ 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` and 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. verbose : int, default 0 Degree of verbose output. If 0, no output will be provided. - 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. - This attribute can be changed manually before calling model `recommend` method if you - want to change ranking behaviour. + 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 = True @@ -131,7 +134,7 @@ def __init__( epochs: int = 1, num_threads: int = 1, # TODO: decide. this is used for both fit n_threads and ranker n_threads verbose: int = 0, - recommend_use_gpu_ranking: tp.Optional[bool] = None, + recommend_use_gpu_ranking: bool = True, ): super().__init__(verbose=verbose) @@ -139,7 +142,7 @@ def __init__( self._model = model self.n_epochs = epochs self.n_threads = num_threads - self.recommend_cpu_n_threads = num_threads # TODO: not consistent with VectorModel parent behaviour + self.recommend_n_threads = num_threads self.recommend_use_gpu_ranking = recommend_use_gpu_ranking def _get_config(self) -> LightFMWrapperModelConfig: diff --git a/rectools/models/vector.py b/rectools/models/vector.py index 4380b456..bf0937e6 100644 --- a/rectools/models/vector.py +++ b/rectools/models/vector.py @@ -41,8 +41,8 @@ class VectorModel(ModelBase[ModelConfig_T]): u2i_dist: Distance = NotImplemented i2i_dist: Distance = NotImplemented - recommend_cpu_n_threads: tp.Optional[int] = None # TODO: decide. Lightfm has num_threads - recommend_use_gpu_ranking: tp.Optional[bool] = None + recommend_cpu_n_threads: tp.Optional[int] = None # TODO: decide. ALS has Optional[int], other models only int + recommend_use_gpu_ranking: tp.Optional[bool] = None # TODO: decide. ALS has Optional[bool], other models only bool @property def _recommend_use_gpu_ranking(self) -> bool: From 62cef9fe3b66f0849b9ecea47a84805fb7abdfd9 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Tue, 10 Dec 2024 17:45:44 +0300 Subject: [PATCH 14/47] fixed als --- rectools/models/implicit_als.py | 12 ++++++------ rectools/models/vector.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/rectools/models/implicit_als.py b/rectools/models/implicit_als.py index cb0897d2..0a2df028 100644 --- a/rectools/models/implicit_als.py +++ b/rectools/models/implicit_als.py @@ -121,14 +121,14 @@ class ImplicitALSWrapperModel(VectorModel[ImplicitALSWrapperModelConfig]): 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`. - This attribute can be changed manually before calling model `recommend` method if you - want to change ranking behaviour. + 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. - This attribute can be changed manually before calling model `recommend` method if you - want to change ranking behaviour. + 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 @@ -162,8 +162,8 @@ def __init__( self.fit_features_together = fit_features_together - if recommend_n_threads is None and isinstance(model, CPUAlternatingLeastSquares): - recommend_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: diff --git a/rectools/models/vector.py b/rectools/models/vector.py index bf0937e6..a70b7027 100644 --- a/rectools/models/vector.py +++ b/rectools/models/vector.py @@ -41,8 +41,8 @@ class VectorModel(ModelBase[ModelConfig_T]): u2i_dist: Distance = NotImplemented i2i_dist: Distance = NotImplemented - recommend_cpu_n_threads: tp.Optional[int] = None # TODO: decide. ALS has Optional[int], other models only int - recommend_use_gpu_ranking: tp.Optional[bool] = None # TODO: decide. ALS has Optional[bool], other models only bool + recommend_cpu_n_threads: int = 0 + recommend_use_gpu_ranking: bool = True @property def _recommend_use_gpu_ranking(self) -> bool: From 5078e9024e15bdf9e0d3377590448e6f3549b5a3 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Tue, 10 Dec 2024 17:50:32 +0300 Subject: [PATCH 15/47] fixed pure svd test --- rectools/models/ease.py | 2 +- rectools/models/vector.py | 2 +- tests/models/test_implicit_als.py | 4 ++-- tests/models/test_pure_svd.py | 12 ++++++------ 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/rectools/models/ease.py b/rectools/models/ease.py index 595647f6..5b0c8479 100644 --- a/rectools/models/ease.py +++ b/rectools/models/ease.py @@ -55,7 +55,7 @@ class EASEModel(ModelBase[EASEModelConfig]): regularization : float The regularization factor of the weights. num_threads: Optional[int], default ``None`` - Deprecated, use `recommend_n_threads` instead. + 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. diff --git a/rectools/models/vector.py b/rectools/models/vector.py index a70b7027..27e29093 100644 --- a/rectools/models/vector.py +++ b/rectools/models/vector.py @@ -41,7 +41,7 @@ class VectorModel(ModelBase[ModelConfig_T]): u2i_dist: Distance = NotImplemented i2i_dist: Distance = NotImplemented - recommend_cpu_n_threads: int = 0 + recommend_cpu_n_threads: int = 0 recommend_use_gpu_ranking: bool = True @property diff --git a/tests/models/test_implicit_als.py b/tests/models/test_implicit_als.py index 21bdbe0f..359fd005 100644 --- a/tests/models/test_implicit_als.py +++ b/tests/models/test_implicit_als.py @@ -452,8 +452,8 @@ def test_from_config(self, use_gpu: bool, cls: tp.Any) -> None: config["model"]["cls"] = cls model = ImplicitALSWrapperModel.from_config(config) assert model.fit_features_together is True - assert model.recommend_n_threads is None - assert model.recommend_use_gpu_ranking is None + assert model.recommend_n_threads == 0 + assert model.recommend_use_gpu_ranking is False assert model.verbose == 1 inner_model = model._model # pylint: disable=protected-access assert inner_model.factors == 16 diff --git a/tests/models/test_pure_svd.py b/tests/models/test_pure_svd.py index dbfa2d25..0ad02016 100644 --- a/tests/models/test_pure_svd.py +++ b/tests/models/test_pure_svd.py @@ -291,8 +291,8 @@ def test_get_config(self, random_state: tp.Optional[int], simple_types: bool) -> maxiter=100, random_state=random_state, verbose=1, - recommend_cpu_n_threads=None, - recommend_use_gpu_ranking=None, + recommend_n_threads=2, + recommend_use_gpu_ranking=False, ) config = model.get_config(simple_types=simple_types) expected = { @@ -302,8 +302,8 @@ def test_get_config(self, random_state: tp.Optional[int], simple_types: bool) -> "maxiter": 100, "random_state": random_state, "verbose": 1, - "recommend_cpu_n_threads": None, - "recommend_use_gpu_ranking": None, + "recommend_n_threads": 2, + "recommend_use_gpu_ranking": False, } assert config == expected @@ -315,8 +315,8 @@ def test_get_config_and_from_config_compatibility(self, simple_types: bool) -> N "maxiter": 100, "random_state": 32, "verbose": 0, - "recommend_cpu_n_threads": None, - "recommend_use_gpu_ranking": None, + "recommend_n_threads": 2, + "recommend_use_gpu_ranking": False, } assert_get_config_and_from_config_compatibility(PureSVDModel, DATASET, initial_config, simple_types) From b50d501a379cfebae6791e67f52d61501340005d Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Tue, 10 Dec 2024 17:54:13 +0300 Subject: [PATCH 16/47] removed None from als and lightfm tests --- tests/models/test_implicit_als.py | 6 +++--- tests/models/test_lightfm.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/models/test_implicit_als.py b/tests/models/test_implicit_als.py index 359fd005..a2ee8ca0 100644 --- a/tests/models/test_implicit_als.py +++ b/tests/models/test_implicit_als.py @@ -444,15 +444,15 @@ def test_from_config(self, use_gpu: bool, cls: tp.Any) -> None: "use_gpu": use_gpu, }, "fit_features_together": True, - "recommend_n_threads": None, - "recommend_use_gpu_ranking": None, + "recommend_n_threads": 10, + "recommend_use_gpu_ranking": False, "verbose": 1, } if cls is not None: config["model"]["cls"] = cls model = ImplicitALSWrapperModel.from_config(config) assert model.fit_features_together is True - assert model.recommend_n_threads == 0 + assert model.recommend_n_threads == 10 assert model.recommend_use_gpu_ranking is False assert model.verbose == 1 inner_model = model._model # pylint: disable=protected-access diff --git a/tests/models/test_lightfm.py b/tests/models/test_lightfm.py index 24a03cf7..78145fdc 100644 --- a/tests/models/test_lightfm.py +++ b/tests/models/test_lightfm.py @@ -405,7 +405,7 @@ def test_to_config(self, random_state: tp.Optional[int], simple_types: bool) -> "epochs": 2, "num_threads": 3, "verbose": 1, - "recommend_use_gpu_ranking": None, + "recommend_use_gpu_ranking": True, } assert config == expected From 16c97d281996070c070e871ae9b0423ccd6a62c9 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Tue, 10 Dec 2024 18:02:51 +0300 Subject: [PATCH 17/47] updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index add1b5b4..aa598bc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `load_model` function ([#213](https://github.com/MobileTeleSystems/RecTools/pull/213)) - `model_from_config` function ([#214](https://github.com/MobileTeleSystems/RecTools/pull/214)) - `get_cat_features` method to `SparseFeatures` ([#221](https://github.com/MobileTeleSystems/RecTools/pull/221)) +- 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)) ## [0.8.0] - 28.08.2024 From 583135fc68a36ea33ccd2fa8574b890bfc3a6373 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Tue, 10 Dec 2024 18:30:39 +0300 Subject: [PATCH 18/47] fixed vector_model naming --- rectools/models/vector.py | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/rectools/models/vector.py b/rectools/models/vector.py index 27e29093..3940b37d 100644 --- a/rectools/models/vector.py +++ b/rectools/models/vector.py @@ -41,21 +41,9 @@ class VectorModel(ModelBase[ModelConfig_T]): u2i_dist: Distance = NotImplemented i2i_dist: Distance = NotImplemented - recommend_cpu_n_threads: int = 0 + recommend_n_threads: int = 0 recommend_use_gpu_ranking: bool = True - @property - def _recommend_use_gpu_ranking(self) -> bool: - use_gpu = HAS_CUDA - if self.recommend_use_gpu_ranking is False: - use_gpu = False - return use_gpu - - @property - def _recommend_cpu_n_threads(self) -> int: - num_threads = 0 if self.recommend_cpu_n_threads is None else self.recommend_cpu_n_threads - return num_threads - def _recommend_u2i( self, user_ids: InternalIdsArray, @@ -79,8 +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._recommend_cpu_n_threads, - use_gpu=self._recommend_use_gpu_ranking, + num_threads=self.recommend_n_threads, + use_gpu=self.recommend_use_gpu_ranking and HAS_CUDA, ) def _recommend_i2i( @@ -99,8 +87,8 @@ def _recommend_i2i( k=k, filter_pairs_csr=None, sorted_object_whitelist=sorted_item_ids_to_recommend, - num_threads=self._recommend_cpu_n_threads, - use_gpu=self._recommend_use_gpu_ranking, + num_threads=self.recommend_n_threads, + use_gpu=self.recommend_use_gpu_ranking and HAS_CUDA, ) def _process_biases_to_vectors( From b7a6e043f95f0f2516ed8f2461b6b326648045ec Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Tue, 10 Dec 2024 18:30:45 +0300 Subject: [PATCH 19/47] fixed ease coverage --- tests/models/test_ease.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/models/test_ease.py b/tests/models/test_ease.py index ea9542f2..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,6 +232,12 @@ 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: From 01120e1420067c0cf34644d4fe062260ca293e0a Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Tue, 10 Dec 2024 18:56:08 +0300 Subject: [PATCH 20/47] changed values in test_vector --- tests/models/test_vector.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/models/test_vector.py b/tests/models/test_vector.py index 6ca827f7..68c87210 100644 --- a/tests/models/test_vector.py +++ b/tests/models/test_vector.py @@ -32,12 +32,12 @@ 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]) @@ -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, 1, 0]], [[299.0, 25.0, 7.0], [214.0, 7.0, 7.0]]), + (Distance.COSINE, [[0, 2, 1], [1, 2, 0]], [[1.0, 0.58877, 0.4899], [1.0, 0.86483, 0.4899]]), + (Distance.EUCLIDEAN, [[0, 1, 2], [1, 0, 2]], [[0.0, 4.3589, 97.68828], [0.0, 4.3589, 98.4378]]), ), ) @pytest.mark.parametrize("method", ("u2i", "i2i")) From 5c412c79f88f6c241cdcc9036c6be61a162dd023 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Tue, 10 Dec 2024 19:03:23 +0300 Subject: [PATCH 21/47] changed biases in test_vector --- tests/models/test_vector.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/models/test_vector.py b/tests/models/test_vector.py index 68c87210..13b68e4f 100644 --- a/tests/models/test_vector.py +++ b/tests/models/test_vector.py @@ -40,8 +40,8 @@ def setup_method(self) -> None: [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) @@ -98,9 +98,9 @@ def test_without_biases( @pytest.mark.parametrize( "distance,expected_reco,expected_scores", ( - (Distance.DOT, [[2, 0, 1], [2, 1, 0]], [[299.0, 25.0, 7.0], [214.0, 7.0, 7.0]]), - (Distance.COSINE, [[0, 2, 1], [1, 2, 0]], [[1.0, 0.58877, 0.4899], [1.0, 0.86483, 0.4899]]), - (Distance.EUCLIDEAN, [[0, 1, 2], [1, 0, 2]], [[0.0, 4.3589, 97.68828], [0.0, 4.3589, 98.4378]]), + (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")) From 0575c88bbfbd817768cc79a872d0cac0d832612c Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Tue, 10 Dec 2024 19:10:03 +0300 Subject: [PATCH 22/47] use_gpu_ranking in test_lightfm --- tests/models/test_lightfm.py | 39 ++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/tests/models/test_lightfm.py b/tests/models/test_lightfm.py index 78145fdc..f215f3df 100644 --- a/tests/models/test_lightfm.py +++ b/tests/models/test_lightfm.py @@ -132,9 +132,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, @@ -172,9 +175,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, @@ -188,9 +194,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, @@ -211,11 +220,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, @@ -238,9 +248,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)] @@ -299,15 +312,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, From adecfc68ee9016f6d3a792d7ea12fbcff4e60034 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Wed, 11 Dec 2024 14:01:12 +0300 Subject: [PATCH 23/47] extended implicit tests --- tests/models/test_implicit_als.py | 53 +++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/tests/models/test_implicit_als.py b/tests/models/test_implicit_als.py index a2ee8ca0..4047a1b7 100644 --- a/tests/models/test_implicit_als.py +++ b/tests/models/test_implicit_als.py @@ -435,7 +435,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,18 +448,26 @@ def test_from_config(self, use_gpu: bool, cls: tp.Any) -> None: "use_gpu": use_gpu, }, "fit_features_together": True, - "recommend_n_threads": 10, - "recommend_use_gpu_ranking": False, + "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 - assert model.recommend_n_threads == 10 - assert model.recommend_use_gpu_ranking is False + 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: @@ -466,12 +478,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=10, - recommend_use_gpu_ranking=False, + recommend_n_threads=recommend_n_threads, + recommend_use_gpu_ranking=recommend_use_gpu, verbose=1, ) config = model.get_config(simple_types=simple_types) @@ -499,8 +520,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": False, - "recommend_n_threads": 10, + "recommend_use_gpu_ranking": recommend_use_gpu, + "recommend_n_threads": recommend_n_threads, } assert config == expected @@ -530,11 +551,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": False, - "recommend_n_threads": 10, + "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) From 5136ab66949a752756f303d81ecdda2d1692a2df Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Wed, 11 Dec 2024 15:19:54 +0300 Subject: [PATCH 24/47] udpdated lightfm --- rectools/models/implicit_als.py | 6 ++--- rectools/models/lightfm.py | 40 ++++++++++++++++++++++++--------- tests/models/test_lightfm.py | 13 +++++++++-- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/rectools/models/implicit_als.py b/rectools/models/implicit_als.py index ff6b1fab..a46721e0 100644 --- a/rectools/models/implicit_als.py +++ b/rectools/models/implicit_als.py @@ -112,8 +112,6 @@ 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. @@ -129,6 +127,8 @@ class ImplicitALSWrapperModel(VectorModel[ImplicitALSWrapperModelConfig]): `implicit.gpu.HAS_CUDA` will also be checked before inference. 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 @@ -142,10 +142,10 @@ class ImplicitALSWrapperModel(VectorModel[ImplicitALSWrapperModelConfig]): def __init__( self, model: AnyAlternatingLeastSquares, - verbose: int = 0, 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, diff --git a/rectools/models/lightfm.py b/rectools/models/lightfm.py index 63c07e27..c56f9c07 100644 --- a/rectools/models/lightfm.py +++ b/rectools/models/lightfm.py @@ -86,6 +86,7 @@ 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 @@ -106,18 +107,23 @@ 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` and as number of threads to use - for recommendation ranking on cpu. + 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. - verbose : int, default 0 - Degree of verbose output. If 0, no output will be provided. + 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 + 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. """ recommends_for_warm = True @@ -132,9 +138,10 @@ def __init__( self, model: LightFM, epochs: int = 1, - num_threads: int = 1, # TODO: decide. this is used for both fit n_threads and ranker n_threads - verbose: int = 0, + num_threads: int = 1, + recommend_n_threads: tp.Optional[int] = None, recommend_use_gpu_ranking: bool = True, + verbose: int = 0, ): super().__init__(verbose=verbose) @@ -142,7 +149,12 @@ def __init__( self._model = model self.n_epochs = epochs self.n_threads = num_threads - self.recommend_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: @@ -167,8 +179,9 @@ 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, - verbose=self.verbose, + recommend_n_threads=self._recommend_n_threads, recommend_use_gpu_ranking=self.recommend_use_gpu_ranking, + verbose=self.verbose, ) @classmethod @@ -176,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/tests/models/test_lightfm.py b/tests/models/test_lightfm.py index db56312b..ba723704 100644 --- a/tests/models/test_lightfm.py +++ b/tests/models/test_lightfm.py @@ -422,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) @@ -445,8 +447,9 @@ def test_to_config(self, random_state: tp.Optional[int], simple_types: bool) -> "model": expected_inner_model_config, "epochs": 2, "num_threads": 3, - "verbose": 1, + "recommend_n_threads": None, "recommend_use_gpu_ranking": True, + "verbose": 1, } assert config == expected @@ -476,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) From 8eb2f364a1ba57192d3cc8eeb6199d97dbbfa312 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Wed, 11 Dec 2024 15:22:50 +0300 Subject: [PATCH 25/47] updated docs --- rectools/models/ease.py | 6 +++--- rectools/models/implicit_als.py | 9 +++++---- rectools/models/lightfm.py | 2 +- rectools/models/pure_svd.py | 4 ++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/rectools/models/ease.py b/rectools/models/ease.py index 5b0c8479..5d5439a6 100644 --- a/rectools/models/ease.py +++ b/rectools/models/ease.py @@ -56,13 +56,13 @@ class EASEModel(ModelBase[EASEModelConfig]): 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. + 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. + 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 + 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, diff --git a/rectools/models/implicit_als.py b/rectools/models/implicit_als.py index a46721e0..b762d4c9 100644 --- a/rectools/models/implicit_als.py +++ b/rectools/models/implicit_als.py @@ -117,15 +117,16 @@ class ImplicitALSWrapperModel(VectorModel[ImplicitALSWrapperModelConfig]): 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. + 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 + 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. - If you want to change this parameter after model is initialized, + `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. diff --git a/rectools/models/lightfm.py b/rectools/models/lightfm.py index c56f9c07..58e262b7 100644 --- a/rectools/models/lightfm.py +++ b/rectools/models/lightfm.py @@ -117,7 +117,7 @@ class LightFMWrapperModel(FixedColdRecoModelMixin, VectorModel[LightFMWrapperMod 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 + 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, diff --git a/rectools/models/pure_svd.py b/rectools/models/pure_svd.py index 09f6cb59..241a2589 100644 --- a/rectools/models/pure_svd.py +++ b/rectools/models/pure_svd.py @@ -57,11 +57,11 @@ class PureSVDModel(VectorModel[PureSVDModelConfig]): 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. + 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 + 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, From 47cf18953638aa2b9c3ad02b34ea377cf337e625 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Wed, 11 Dec 2024 16:23:20 +0300 Subject: [PATCH 26/47] added als test for gpu ranking consistent with pure implicit --- tests/models/test_implicit_als.py | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/models/test_implicit_als.py b/tests/models/test_implicit_als.py index 4047a1b7..02616078 100644 --- a/tests/models/test_implicit_als.py +++ b/tests/models/test_implicit_als.py @@ -174,6 +174,48 @@ def test_consistent_with_pure_implicit( 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.01) + + + + @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 + + 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", From dc2c2e9d28ca33de066ff75a636343369ee3cf58 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Fri, 20 Dec 2024 10:18:00 +0300 Subject: [PATCH 27/47] added implcit gpu skipif --- tests/models/test_implicit_als.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/models/test_implicit_als.py b/tests/models/test_implicit_als.py index 02616078..b372cd1c 100644 --- a/tests/models/test_implicit_als.py +++ b/tests/models/test_implicit_als.py @@ -138,7 +138,6 @@ def test_basic( actual.sort_values([Columns.User, Columns.Score], ascending=[True, False]).reset_index(drop=True), actual, ) - @pytest.mark.parametrize("fit_features_together", (False, True)) @pytest.mark.parametrize("init_model_before_fit", (False, True)) def test_consistent_with_pure_implicit( @@ -174,9 +173,8 @@ def test_consistent_with_pure_implicit( 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.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( @@ -187,22 +185,23 @@ def test_gpu_ranking_consistent_with_pure_implicit( 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 = 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 - + 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( From afe329c79f454cba3074dc3f4e5a952779452aa8 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Fri, 20 Dec 2024 10:22:07 +0300 Subject: [PATCH 28/47] added copy to implicit gpu matrix creation --- rectools/models/rank.py | 12 +++++++++--- tests/models/test_implicit_als.py | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/rectools/models/rank.py b/rectools/models/rank.py index 1c464745..b4449b9f 100644 --- a/rectools/models/rank.py +++ b/rectools/models/rank.py @@ -151,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()) diff --git a/tests/models/test_implicit_als.py b/tests/models/test_implicit_als.py index b372cd1c..3427a862 100644 --- a/tests/models/test_implicit_als.py +++ b/tests/models/test_implicit_als.py @@ -138,6 +138,7 @@ def test_basic( actual.sort_values([Columns.User, Columns.Score], ascending=[True, False]).reset_index(drop=True), actual, ) + @pytest.mark.parametrize("fit_features_together", (False, True)) @pytest.mark.parametrize("init_model_before_fit", (False, True)) def test_consistent_with_pure_implicit( From 356b4a794ebd836e8525040ae17af1655e35a565 Mon Sep 17 00:00:00 2001 From: Daria Tikhonovich Date: Fri, 20 Dec 2024 14:15:19 +0300 Subject: [PATCH 29/47] pylint disable --- tests/models/test_implicit_als.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/models/test_implicit_als.py b/tests/models/test_implicit_als.py index 3427a862..17fc2f72 100644 --- a/tests/models/test_implicit_als.py +++ b/tests/models/test_implicit_als.py @@ -194,7 +194,7 @@ def test_gpu_ranking_consistent_with_pure_implicit( 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 + wrapped_model.model = wrapped_model._model # pylint: disable=protected-access actual_reco = wrapped_model.recommend( users=users, From 7aceaf2f58a6ac3de065bdaf9ec40e1f0719d78f Mon Sep 17 00:00:00 2001 From: blondered Date: Mon, 13 Jan 2025 13:32:43 +0300 Subject: [PATCH 30/47] fixed transposed gpu matrix and pure_svd test --- rectools/models/rank.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/rectools/models/rank.py b/rectools/models/rank.py index b4449b9f..4828b409 100644 --- a/rectools/models/rank.py +++ b/rectools/models/rank.py @@ -153,17 +153,16 @@ def _rank_on_gpu( ) -> tp.Tuple[np.ndarray, np.ndarray]: # pragma: no cover 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) + # We need to explicitly create copy to handle transposed arrays correctly + return implicit.gpu.Matrix(arr.astype(np.float32).copy()) - object_factors = _convert_arr_to_implicit_gpu_matrix(object_factors.astype(np.float32)) + object_factors = _convert_arr_to_implicit_gpu_matrix(object_factors) if isinstance(subject_factors, sparse.spmatrix): warnings.warn("Sparse subject factors converted to Dense matrix") subject_factors = subject_factors.todense() - subject_factors = _convert_arr_to_implicit_gpu_matrix(subject_factors.astype(np.float32)) + subject_factors = _convert_arr_to_implicit_gpu_matrix(subject_factors) if object_norms is not None: if len(np.shape(object_norms)) == 1: From bdcd9847c15b2eeb7823045610fd58ca8640526b Mon Sep 17 00:00:00 2001 From: blondered Date: Mon, 13 Jan 2025 14:35:57 +0300 Subject: [PATCH 31/47] fixed dssm tests to get unequal scores --- tests/models/test_dssm.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/models/test_dssm.py b/tests/models/test_dssm.py index a264fd01..0d9b4908 100644 --- a/tests/models/test_dssm.py +++ b/tests/models/test_dssm.py @@ -92,7 +92,7 @@ def dataset(self) -> Dataset: pd.DataFrame( { Columns.User: [10, 10, 10, 20, 20, 20, 50, 50, 50], - Columns.Item: [13, 15, 17, 14, 15, 17, 11, 12, 13], + Columns.Item: [13, 15, 16, 15, 16, 17, 11, 12, 15], Columns.Rank: [1, 2, 3, 1, 2, 3, 1, 2, 3], } ), @@ -102,7 +102,7 @@ def dataset(self) -> Dataset: pd.DataFrame( { Columns.User: [10, 10, 10, 20, 20, 20, 50, 50, 50], - Columns.Item: [11, 12, 13, 11, 12, 13, 11, 12, 13], + Columns.Item: [12, 11, 13, 11, 12, 13, 11, 12, 15], Columns.Rank: [1, 2, 3, 1, 2, 3, 1, 2, 3], } ), @@ -115,15 +115,15 @@ def test_u2i(self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame base_model = None else: base_model = DSSM( - n_factors_item=32, - n_factors_user=32, + n_factors_item=10, + n_factors_user=10, dim_input_item=dataset.item_features.get_sparse().shape[1], # type: ignore dim_input_user=dataset.user_features.get_sparse().shape[1], # type: ignore dim_interactions=dataset.get_user_item_matrix().shape[1], ) model = DSSMModel( model=base_model, - n_factors=32, + n_factors=10, max_epochs=3, batch_size=4, deterministic=True, @@ -250,7 +250,7 @@ def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset) -> None pd.DataFrame( { Columns.TargetItem: [11, 11, 11, 12, 12, 12, 16, 16, 16], - Columns.Item: [11, 13, 17, 12, 16, 17, 16, 17, 14], + Columns.Item: [11, 15, 12, 12, 13, 11, 16, 17, 15], Columns.Rank: [1, 2, 3, 1, 2, 3, 1, 2, 3], } ), @@ -261,7 +261,7 @@ def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset) -> None pd.DataFrame( { Columns.TargetItem: [11, 11, 11, 12, 12, 12, 16, 16, 16], - Columns.Item: [13, 16, 17, 16, 17, 14, 17, 14, 12], + Columns.Item: [15, 12, 16, 13, 11, 15, 17, 15, 14], Columns.Rank: [1, 2, 3, 1, 2, 3, 1, 2, 3], } ), @@ -272,7 +272,7 @@ def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset) -> None pd.DataFrame( { Columns.TargetItem: [11, 11, 12, 12, 16, 16, 16], - Columns.Item: [12, 15, 15, 11, 12, 11, 15], + Columns.Item: [15, 12, 11, 15, 15, 11, 12], Columns.Rank: [1, 2, 1, 2, 1, 2, 3], } ), @@ -283,7 +283,7 @@ def test_i2i( self, dataset: Dataset, filter_itself: bool, whitelist: tp.Optional[np.ndarray], expected: pd.DataFrame ) -> None: model = DSSMModel( - n_factors=2, + n_factors=10, max_epochs=3, batch_size=4, deterministic=True, From d25af1ee5687ef2ee49ac76b39462e3a9adc70d2 Mon Sep 17 00:00:00 2001 From: blondered Date: Mon, 13 Jan 2025 15:09:33 +0300 Subject: [PATCH 32/47] use_gpu_ranking parametrize for dssm tests --- rectools/models/dssm.py | 14 +++++++ tests/models/test_dssm.py | 78 +++++++++++++++++++++++++-------------- 2 files changed, 64 insertions(+), 28 deletions(-) diff --git a/rectools/models/dssm.py b/rectools/models/dssm.py index c81c2951..b7836edf 100644 --- a/rectools/models/dssm.py +++ b/rectools/models/dssm.py @@ -267,6 +267,16 @@ class DSSMModel(VectorModel): deterministic : bool, default ``False`` If ``True``, sets whether PyTorch operations must use deterministic algorithms. Use `pytorch_lightning.seed_everything` together with this param to fix the random state. + 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 = True @@ -292,6 +302,8 @@ def __init__( loggers: tp.Union[Logger, tp.Iterable[Logger], bool] = True, verbose: int = 0, deterministic: bool = False, + recommend_n_threads: int = 0, + recommend_use_gpu_ranking: bool = True, ) -> None: super().__init__(verbose=verbose) self.model: DSSM @@ -313,6 +325,8 @@ def __init__( self.train_dataset_type = train_dataset_type self.user_dataset_type = user_dataset_type self.item_dataset_type = item_dataset_type + self.recommend_n_threads = recommend_n_threads + self.recommend_use_gpu_ranking = recommend_use_gpu_ranking def _fit(self, dataset: Dataset, dataset_valid: tp.Optional[Dataset] = None) -> None: # type: ignore self.trainer = deepcopy(self._trainer) diff --git a/tests/models/test_dssm.py b/tests/models/test_dssm.py index 0d9b4908..33d9adc1 100644 --- a/tests/models/test_dssm.py +++ b/tests/models/test_dssm.py @@ -17,6 +17,7 @@ import numpy as np import pandas as pd import pytest +from implicit.gpu import HAS_CUDA from lightning_fabric import seed_everything from rectools.columns import Columns @@ -32,6 +33,7 @@ @pytest.mark.filterwarnings("ignore::pytorch_lightning.utilities.warnings.PossibleUserWarning") @pytest.mark.filterwarnings("ignore::UserWarning") +@pytest.mark.parametrize("recommend_use_gpu_ranking", (False, True) if HAS_CUDA else (False,)) class TestDSSMModel: def setup_method(self) -> None: self._seed_everything() @@ -91,9 +93,9 @@ def dataset(self) -> Dataset: True, pd.DataFrame( { - Columns.User: [10, 10, 10, 20, 20, 20, 50, 50, 50], - Columns.Item: [13, 15, 16, 15, 16, 17, 11, 12, 15], - Columns.Rank: [1, 2, 3, 1, 2, 3, 1, 2, 3], + Columns.User: [10, 10, 20, 20, 50, 50], + Columns.Item: [13, 15, 15, 17, 11, 12], + Columns.Rank: [1, 2, 1, 2, 1, 2], } ), ), @@ -101,16 +103,23 @@ def dataset(self) -> Dataset: False, pd.DataFrame( { - Columns.User: [10, 10, 10, 20, 20, 20, 50, 50, 50], - Columns.Item: [12, 11, 13, 11, 12, 13, 11, 12, 15], - Columns.Rank: [1, 2, 3, 1, 2, 3, 1, 2, 3], + Columns.User: [10, 10, 20, 20, 50, 50], + Columns.Item: [12, 11, 11, 12, 11, 12], + Columns.Rank: [1, 2, 1, 2, 1, 2], } ), ), ), ) @pytest.mark.parametrize("default_base_model", (True, False)) - def test_u2i(self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame, default_base_model: bool) -> None: + def test_u2i( + self, + dataset: Dataset, + filter_viewed: bool, + expected: pd.DataFrame, + default_base_model: bool, + recommend_use_gpu_ranking: bool, + ) -> None: if default_base_model: base_model = None else: @@ -127,10 +136,11 @@ def test_u2i(self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame max_epochs=3, batch_size=4, deterministic=True, + recommend_use_gpu_ranking=recommend_use_gpu_ranking, ) model.fit(dataset=dataset, dataset_valid=dataset) users = np.array([10, 20, 50]) - actual = model.recommend(users=users, dataset=dataset, k=3, filter_viewed=filter_viewed) + actual = model.recommend(users=users, dataset=dataset, k=2, filter_viewed=filter_viewed) pd.testing.assert_frame_equal(actual.drop(columns=Columns.Score), expected) pd.testing.assert_frame_equal( actual.sort_values([Columns.User, Columns.Score], ascending=[True, True]).reset_index(drop=True), @@ -162,12 +172,15 @@ def test_u2i(self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame ), ), ) - def test_with_whitelist(self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame) -> None: + def test_with_whitelist( + self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame, recommend_use_gpu_ranking: bool + ) -> None: model = DSSMModel( n_factors=32, max_epochs=3, batch_size=4, deterministic=True, + recommend_use_gpu_ranking=recommend_use_gpu_ranking, ) model.fit(dataset=dataset) users = np.array([10, 50]) @@ -184,7 +197,7 @@ def test_with_whitelist(self, dataset: Dataset, filter_viewed: bool, expected: p actual, ) - def test_get_vectors(self, dataset: Dataset) -> None: + def test_get_vectors(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: base_model = DSSM( n_factors_item=32, n_factors_user=32, @@ -200,6 +213,7 @@ def test_get_vectors(self, dataset: Dataset) -> None: batch_size=4, dataloader_num_workers=0, callbacks=None, + recommend_use_gpu_ranking=recommend_use_gpu_ranking, ) model.fit(dataset=dataset) user_embeddings, item_embeddings = model.get_vectors(dataset) @@ -221,7 +235,7 @@ def test_get_vectors(self, dataset: Dataset) -> None: np.testing.assert_equal(vectors_reco, reco_item_ids) np.testing.assert_almost_equal(vectors_scores, reco_scores, decimal=5) - def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset) -> None: + def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: base_model = DSSM( n_factors_item=32, n_factors_user=32, @@ -249,9 +263,9 @@ def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset) -> None None, pd.DataFrame( { - Columns.TargetItem: [11, 11, 11, 12, 12, 12, 16, 16, 16], - Columns.Item: [11, 15, 12, 12, 13, 11, 16, 17, 15], - Columns.Rank: [1, 2, 3, 1, 2, 3, 1, 2, 3], + Columns.TargetItem: [11, 11, 12, 12], + Columns.Item: [11, 15, 12, 13], + Columns.Rank: [1, 2, 1, 2], } ), ), @@ -260,9 +274,9 @@ def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset) -> None None, pd.DataFrame( { - Columns.TargetItem: [11, 11, 11, 12, 12, 12, 16, 16, 16], - Columns.Item: [15, 12, 16, 13, 11, 15, 17, 15, 14], - Columns.Rank: [1, 2, 3, 1, 2, 3, 1, 2, 3], + Columns.TargetItem: [11, 11, 12, 12], + Columns.Item: [15, 12, 13, 11], + Columns.Rank: [1, 2, 1, 2], } ), ), @@ -271,29 +285,35 @@ def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset) -> None np.array([11, 15, 12]), pd.DataFrame( { - Columns.TargetItem: [11, 11, 12, 12, 16, 16, 16], - Columns.Item: [15, 12, 11, 15, 15, 11, 12], - Columns.Rank: [1, 2, 1, 2, 1, 2, 3], + Columns.TargetItem: [11, 11, 12, 12], + Columns.Item: [15, 12, 11, 15], + Columns.Rank: [1, 2, 1, 2], } ), ), ), ) def test_i2i( - self, dataset: Dataset, filter_itself: bool, whitelist: tp.Optional[np.ndarray], expected: pd.DataFrame + self, + dataset: Dataset, + filter_itself: bool, + whitelist: tp.Optional[np.ndarray], + expected: pd.DataFrame, + recommend_use_gpu_ranking: bool, ) -> None: model = DSSMModel( n_factors=10, max_epochs=3, batch_size=4, deterministic=True, + recommend_use_gpu_ranking=recommend_use_gpu_ranking, ) model.fit(dataset=dataset, dataset_valid=dataset) - target_items = np.array([11, 12, 16]) + target_items = np.array([11, 12]) actual = model.recommend_to_items( target_items=target_items, dataset=dataset, - k=3, + k=2, filter_itself=filter_itself, items_to_recommend=whitelist, ) @@ -303,7 +323,7 @@ def test_i2i( actual, ) - def test_u2i_with_cold_users(self, dataset: Dataset) -> None: + def test_u2i_with_cold_users(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: model = DSSMModel().fit(dataset) with pytest.raises(ValueError, match="doesn't support recommendations for cold users"): model.recommend( @@ -313,7 +333,7 @@ def test_u2i_with_cold_users(self, dataset: Dataset) -> None: filter_viewed=False, ) - def test_i2i_with_cold_items(self, dataset: Dataset) -> None: + def test_i2i_with_cold_items(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: model = DSSMModel().fit(dataset) with pytest.raises(ValueError, match="doesn't support recommendations for cold items"): model.recommend_to_items( @@ -323,7 +343,9 @@ def test_i2i_with_cold_items(self, dataset: Dataset) -> None: ) @pytest.mark.parametrize("exclude_features", ("user", "item")) - def test_raises_when_no_features_in_dataset(self, dataset: Dataset, exclude_features: str) -> None: + def test_raises_when_no_features_in_dataset( + self, dataset: Dataset, exclude_features: str, recommend_use_gpu_ranking: bool + ) -> None: dataset = Dataset( dataset.user_id_map, dataset.item_id_map, @@ -335,11 +357,11 @@ def test_raises_when_no_features_in_dataset(self, dataset: Dataset, exclude_feat with pytest.raises(ValueError, match="requires user and item features"): model.fit(dataset) - def test_second_fit_refits_model(self, dataset: Dataset) -> None: + def test_second_fit_refits_model(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: model = DSSMModel(deterministic=True) assert_second_fit_refits_model(model, dataset, pre_fit_callback=self._seed_everything) - def test_dumps_loads(self, dataset: Dataset) -> None: + def test_dumps_loads(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: model = DSSMModel() model.fit(dataset) assert_dumps_loads_do_not_change_model(model, dataset, check_configs=False) From f06b048ab24c38a68ec1e8996a708a2d8859dffa Mon Sep 17 00:00:00 2001 From: blondered Date: Mon, 13 Jan 2025 15:14:12 +0300 Subject: [PATCH 33/47] use_gpu_ranking parametrize for ease tests --- tests/models/test_ease.py | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/tests/models/test_ease.py b/tests/models/test_ease.py index f6adef5e..096975d5 100644 --- a/tests/models/test_ease.py +++ b/tests/models/test_ease.py @@ -18,6 +18,7 @@ import numpy as np import pandas as pd import pytest +from implicit.gpu import HAS_CUDA from rectools import Columns from rectools.dataset import Dataset @@ -32,6 +33,7 @@ ) +@pytest.mark.parametrize("recommend_use_gpu_ranking", (False, True) if HAS_CUDA else (False,)) class TestEASEModel: @pytest.fixture def dataset(self) -> Dataset: @@ -64,8 +66,10 @@ def dataset(self) -> Dataset: ), ), ) - def test_basic(self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame) -> None: - model = EASEModel(regularization=500).fit(dataset) + def test_basic( + self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame, recommend_use_gpu_ranking: bool + ) -> None: + model = EASEModel(regularization=500, recommend_use_gpu_ranking=recommend_use_gpu_ranking).fit(dataset) actual = model.recommend( users=np.array([10, 20]), dataset=dataset, @@ -101,8 +105,10 @@ def test_basic(self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFra ), ), ) - def test_with_whitelist(self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame) -> None: - model = EASEModel(regularization=500).fit(dataset) + def test_with_whitelist( + self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame, recommend_use_gpu_ranking: bool + ) -> None: + model = EASEModel(regularization=500, recommend_use_gpu_ranking=recommend_use_gpu_ranking).fit(dataset) actual = model.recommend( users=np.array([10, 20]), dataset=dataset, @@ -151,9 +157,14 @@ def test_with_whitelist(self, dataset: Dataset, filter_viewed: bool, expected: p ), ) def test_i2i( - self, dataset: Dataset, filter_itself: bool, whitelist: tp.Optional[np.ndarray], expected: pd.DataFrame + self, + dataset: Dataset, + filter_itself: bool, + whitelist: tp.Optional[np.ndarray], + expected: pd.DataFrame, + recommend_use_gpu_ranking: bool, ) -> None: - model = EASEModel(regularization=500).fit(dataset) + model = EASEModel(regularization=500, recommend_use_gpu_ranking=recommend_use_gpu_ranking).fit(dataset) actual = model.recommend_to_items( target_items=np.array([11, 12]), dataset=dataset, @@ -167,7 +178,7 @@ def test_i2i( actual, ) - def test_second_fit_refits_model(self, dataset: Dataset) -> None: + def test_second_fit_refits_model(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: model = EASEModel() assert_second_fit_refits_model(model, dataset) @@ -189,7 +200,11 @@ def test_second_fit_refits_model(self, dataset: Dataset) -> None: ) @pytest.mark.parametrize("filter_viewed", (True, False)) def test_u2i_with_warm_and_cold_users( - self, filter_viewed: bool, user_features: tp.Optional[pd.DataFrame], error_match: str + self, + filter_viewed: bool, + user_features: tp.Optional[pd.DataFrame], + error_match: str, + recommend_use_gpu_ranking: bool, ) -> None: dataset = Dataset.construct(INTERACTIONS, user_features_df=user_features) model = EASEModel(regularization=500).fit(dataset) @@ -217,7 +232,9 @@ def test_u2i_with_warm_and_cold_users( ), ), ) - def test_i2i_with_warm_and_cold_items(self, item_features: tp.Optional[pd.DataFrame], error_match: str) -> None: + def test_i2i_with_warm_and_cold_items( + self, item_features: tp.Optional[pd.DataFrame], error_match: str, recommend_use_gpu_ranking: bool + ) -> None: dataset = Dataset.construct(INTERACTIONS, item_features_df=item_features) model = EASEModel(regularization=500).fit(dataset) with pytest.raises(ValueError, match=error_match): @@ -227,12 +244,12 @@ def test_i2i_with_warm_and_cold_items(self, item_features: tp.Optional[pd.DataFr k=2, ) - def test_dumps_loads(self, dataset: Dataset) -> None: + def test_dumps_loads(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: model = EASEModel() model.fit(dataset) assert_dumps_loads_do_not_change_model(model, dataset) - def test_warn_with_num_threads(self) -> None: + def test_warn_with_num_threads(self, recommend_use_gpu_ranking: bool) -> None: with warnings.catch_warnings(record=True) as w: EASEModel(num_threads=10) assert len(w) == 1 From f624a4600ccd488748f0134e7e0363bf70b9d35e Mon Sep 17 00:00:00 2001 From: blondered Date: Mon, 13 Jan 2025 15:20:57 +0300 Subject: [PATCH 34/47] added use_gpu_ranking to vector tests --- tests/models/test_vector.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/models/test_vector.py b/tests/models/test_vector.py index 13b68e4f..4ee53a23 100644 --- a/tests/models/test_vector.py +++ b/tests/models/test_vector.py @@ -80,14 +80,17 @@ def _get_items_factors(self, dataset: Dataset) -> Factors: ), ) @pytest.mark.parametrize("method", ("u2i", "i2i")) + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) def test_without_biases( self, distance: Distance, expected_reco: tp.List[tp.List[int]], expected_scores: tp.List[tp.List[float]], method: str, + use_gpu_ranking: bool, ) -> None: model = self.make_model(self.user_factors, self.item_factors, u2i_distance=distance, i2i_distance=distance) + model.recommend_use_gpu_ranking = use_gpu_ranking if method == "u2i": _, reco, scores = model._recommend_u2i(np.array([0, 1]), self.stub_dataset, 5, False, None) else: # i2i @@ -104,16 +107,19 @@ def test_without_biases( ), ) @pytest.mark.parametrize("method", ("u2i", "i2i")) + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) def test_with_biases( self, distance: Distance, expected_reco: tp.List[tp.List[int]], expected_scores: tp.List[tp.List[float]], method: str, + use_gpu_ranking: bool, ) -> None: model = self.make_model( self.user_biased_factors, self.item_biased_factors, u2i_distance=distance, i2i_distance=distance ) + model.recommend_use_gpu_ranking = use_gpu_ranking if method == "u2i": _, reco, scores = model._recommend_u2i(np.array([0, 1]), self.stub_dataset, 5, False, None) else: # i2i From 39fae935022a6cb5d57c0a75344db3c36534e5e6 Mon Sep 17 00:00:00 2001 From: blondered Date: Mon, 13 Jan 2025 15:30:12 +0300 Subject: [PATCH 35/47] refactored all tests --- tests/models/test_dssm.py | 36 +++++++++++++++++------------------ tests/models/test_ease.py | 30 +++++++++++++---------------- tests/models/test_pure_svd.py | 26 ++++++++++++++++++------- 3 files changed, 50 insertions(+), 42 deletions(-) diff --git a/tests/models/test_dssm.py b/tests/models/test_dssm.py index 33d9adc1..f2d7f7af 100644 --- a/tests/models/test_dssm.py +++ b/tests/models/test_dssm.py @@ -17,7 +17,6 @@ import numpy as np import pandas as pd import pytest -from implicit.gpu import HAS_CUDA from lightning_fabric import seed_everything from rectools.columns import Columns @@ -33,7 +32,6 @@ @pytest.mark.filterwarnings("ignore::pytorch_lightning.utilities.warnings.PossibleUserWarning") @pytest.mark.filterwarnings("ignore::UserWarning") -@pytest.mark.parametrize("recommend_use_gpu_ranking", (False, True) if HAS_CUDA else (False,)) class TestDSSMModel: def setup_method(self) -> None: self._seed_everything() @@ -112,13 +110,14 @@ def dataset(self) -> Dataset: ), ) @pytest.mark.parametrize("default_base_model", (True, False)) + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) def test_u2i( self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame, default_base_model: bool, - recommend_use_gpu_ranking: bool, + use_gpu_ranking: bool, ) -> None: if default_base_model: base_model = None @@ -136,7 +135,7 @@ def test_u2i( max_epochs=3, batch_size=4, deterministic=True, - recommend_use_gpu_ranking=recommend_use_gpu_ranking, + recommend_use_gpu_ranking=use_gpu_ranking, ) model.fit(dataset=dataset, dataset_valid=dataset) users = np.array([10, 20, 50]) @@ -172,15 +171,16 @@ def test_u2i( ), ), ) + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) def test_with_whitelist( - self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame, recommend_use_gpu_ranking: bool + self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame, use_gpu_ranking: bool ) -> None: model = DSSMModel( n_factors=32, max_epochs=3, batch_size=4, deterministic=True, - recommend_use_gpu_ranking=recommend_use_gpu_ranking, + recommend_use_gpu_ranking=use_gpu_ranking, ) model.fit(dataset=dataset) users = np.array([10, 50]) @@ -197,7 +197,8 @@ def test_with_whitelist( actual, ) - def test_get_vectors(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) + def test_get_vectors(self, dataset: Dataset, use_gpu_ranking: bool) -> None: base_model = DSSM( n_factors_item=32, n_factors_user=32, @@ -213,7 +214,7 @@ def test_get_vectors(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> batch_size=4, dataloader_num_workers=0, callbacks=None, - recommend_use_gpu_ranking=recommend_use_gpu_ranking, + recommend_use_gpu_ranking=use_gpu_ranking, ) model.fit(dataset=dataset) user_embeddings, item_embeddings = model.get_vectors(dataset) @@ -235,7 +236,7 @@ def test_get_vectors(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> np.testing.assert_equal(vectors_reco, reco_item_ids) np.testing.assert_almost_equal(vectors_scores, reco_scores, decimal=5) - def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: + def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset) -> None: base_model = DSSM( n_factors_item=32, n_factors_user=32, @@ -293,20 +294,21 @@ def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset, recomme ), ), ) + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) def test_i2i( self, dataset: Dataset, filter_itself: bool, whitelist: tp.Optional[np.ndarray], expected: pd.DataFrame, - recommend_use_gpu_ranking: bool, + use_gpu_ranking: bool, ) -> None: model = DSSMModel( n_factors=10, max_epochs=3, batch_size=4, deterministic=True, - recommend_use_gpu_ranking=recommend_use_gpu_ranking, + recommend_use_gpu_ranking=use_gpu_ranking, ) model.fit(dataset=dataset, dataset_valid=dataset) target_items = np.array([11, 12]) @@ -323,7 +325,7 @@ def test_i2i( actual, ) - def test_u2i_with_cold_users(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: + def test_u2i_with_cold_users(self, dataset: Dataset) -> None: model = DSSMModel().fit(dataset) with pytest.raises(ValueError, match="doesn't support recommendations for cold users"): model.recommend( @@ -333,7 +335,7 @@ def test_u2i_with_cold_users(self, dataset: Dataset, recommend_use_gpu_ranking: filter_viewed=False, ) - def test_i2i_with_cold_items(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: + def test_i2i_with_cold_items(self, dataset: Dataset) -> None: model = DSSMModel().fit(dataset) with pytest.raises(ValueError, match="doesn't support recommendations for cold items"): model.recommend_to_items( @@ -343,9 +345,7 @@ def test_i2i_with_cold_items(self, dataset: Dataset, recommend_use_gpu_ranking: ) @pytest.mark.parametrize("exclude_features", ("user", "item")) - def test_raises_when_no_features_in_dataset( - self, dataset: Dataset, exclude_features: str, recommend_use_gpu_ranking: bool - ) -> None: + def test_raises_when_no_features_in_dataset(self, dataset: Dataset, exclude_features: str) -> None: dataset = Dataset( dataset.user_id_map, dataset.item_id_map, @@ -357,11 +357,11 @@ def test_raises_when_no_features_in_dataset( with pytest.raises(ValueError, match="requires user and item features"): model.fit(dataset) - def test_second_fit_refits_model(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: + def test_second_fit_refits_model(self, dataset: Dataset) -> None: model = DSSMModel(deterministic=True) assert_second_fit_refits_model(model, dataset, pre_fit_callback=self._seed_everything) - def test_dumps_loads(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: + def test_dumps_loads(self, dataset: Dataset) -> None: model = DSSMModel() model.fit(dataset) assert_dumps_loads_do_not_change_model(model, dataset, check_configs=False) diff --git a/tests/models/test_ease.py b/tests/models/test_ease.py index 096975d5..a7028593 100644 --- a/tests/models/test_ease.py +++ b/tests/models/test_ease.py @@ -18,7 +18,6 @@ import numpy as np import pandas as pd import pytest -from implicit.gpu import HAS_CUDA from rectools import Columns from rectools.dataset import Dataset @@ -33,7 +32,6 @@ ) -@pytest.mark.parametrize("recommend_use_gpu_ranking", (False, True) if HAS_CUDA else (False,)) class TestEASEModel: @pytest.fixture def dataset(self) -> Dataset: @@ -66,10 +64,9 @@ def dataset(self) -> Dataset: ), ), ) - def test_basic( - self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame, recommend_use_gpu_ranking: bool - ) -> None: - model = EASEModel(regularization=500, recommend_use_gpu_ranking=recommend_use_gpu_ranking).fit(dataset) + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) + def test_basic(self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame, use_gpu_ranking: bool) -> None: + model = EASEModel(regularization=500, recommend_use_gpu_ranking=use_gpu_ranking).fit(dataset) actual = model.recommend( users=np.array([10, 20]), dataset=dataset, @@ -105,10 +102,11 @@ def test_basic( ), ), ) + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) def test_with_whitelist( - self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame, recommend_use_gpu_ranking: bool + self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame, use_gpu_ranking: bool ) -> None: - model = EASEModel(regularization=500, recommend_use_gpu_ranking=recommend_use_gpu_ranking).fit(dataset) + model = EASEModel(regularization=500, recommend_use_gpu_ranking=use_gpu_ranking).fit(dataset) actual = model.recommend( users=np.array([10, 20]), dataset=dataset, @@ -156,15 +154,16 @@ def test_with_whitelist( ), ), ) + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) def test_i2i( self, dataset: Dataset, filter_itself: bool, whitelist: tp.Optional[np.ndarray], expected: pd.DataFrame, - recommend_use_gpu_ranking: bool, + use_gpu_ranking: bool, ) -> None: - model = EASEModel(regularization=500, recommend_use_gpu_ranking=recommend_use_gpu_ranking).fit(dataset) + model = EASEModel(regularization=500, recommend_use_gpu_ranking=use_gpu_ranking).fit(dataset) actual = model.recommend_to_items( target_items=np.array([11, 12]), dataset=dataset, @@ -178,7 +177,7 @@ def test_i2i( actual, ) - def test_second_fit_refits_model(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: + def test_second_fit_refits_model(self, dataset: Dataset) -> None: model = EASEModel() assert_second_fit_refits_model(model, dataset) @@ -204,7 +203,6 @@ def test_u2i_with_warm_and_cold_users( filter_viewed: bool, user_features: tp.Optional[pd.DataFrame], error_match: str, - recommend_use_gpu_ranking: bool, ) -> None: dataset = Dataset.construct(INTERACTIONS, user_features_df=user_features) model = EASEModel(regularization=500).fit(dataset) @@ -232,9 +230,7 @@ def test_u2i_with_warm_and_cold_users( ), ), ) - def test_i2i_with_warm_and_cold_items( - self, item_features: tp.Optional[pd.DataFrame], error_match: str, recommend_use_gpu_ranking: bool - ) -> None: + def test_i2i_with_warm_and_cold_items(self, item_features: tp.Optional[pd.DataFrame], error_match: str) -> None: dataset = Dataset.construct(INTERACTIONS, item_features_df=item_features) model = EASEModel(regularization=500).fit(dataset) with pytest.raises(ValueError, match=error_match): @@ -244,12 +240,12 @@ def test_i2i_with_warm_and_cold_items( k=2, ) - def test_dumps_loads(self, dataset: Dataset, recommend_use_gpu_ranking: bool) -> None: + def test_dumps_loads(self, dataset: Dataset) -> None: model = EASEModel() model.fit(dataset) assert_dumps_loads_do_not_change_model(model, dataset) - def test_warn_with_num_threads(self, recommend_use_gpu_ranking: bool) -> None: + def test_warn_with_num_threads(self) -> None: with warnings.catch_warnings(record=True) as w: EASEModel(num_threads=10) assert len(w) == 1 diff --git a/tests/models/test_pure_svd.py b/tests/models/test_pure_svd.py index 0ad02016..b6c7b664 100644 --- a/tests/models/test_pure_svd.py +++ b/tests/models/test_pure_svd.py @@ -64,13 +64,15 @@ def dataset(self) -> Dataset: ), ), ) + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) def test_basic( self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame, + use_gpu_ranking: bool, ) -> None: - model = PureSVDModel(factors=2).fit(dataset) + model = PureSVDModel(factors=2, recommend_use_gpu_ranking=use_gpu_ranking).fit(dataset) actual = model.recommend( users=np.array([10, 20]), dataset=dataset, @@ -108,8 +110,11 @@ def test_basic( ), ), ) - def test_with_whitelist(self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame) -> None: - model = PureSVDModel(factors=2).fit(dataset) + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) + def test_with_whitelist( + self, dataset: Dataset, filter_viewed: bool, expected: pd.DataFrame, use_gpu_ranking: bool + ) -> None: + model = PureSVDModel(factors=2, recommend_use_gpu_ranking=use_gpu_ranking).fit(dataset) actual = model.recommend( users=np.array([10, 20]), dataset=dataset, @@ -123,8 +128,9 @@ def test_with_whitelist(self, dataset: Dataset, filter_viewed: bool, expected: p actual, ) - def test_get_vectors(self, dataset: Dataset) -> None: - model = PureSVDModel(factors=2).fit(dataset) + @pytest.mark.parametrize("use_gpu_ranking", (True, False)) + def test_get_vectors(self, dataset: Dataset, use_gpu_ranking: bool) -> None: + model = PureSVDModel(factors=2, recommend_use_gpu_ranking=use_gpu_ranking).fit(dataset) user_embeddings, item_embeddings = model.get_vectors() predictions = user_embeddings @ item_embeddings.T vectors_predictions = [recommend_from_scores(predictions[i], k=5) for i in range(4)] @@ -183,10 +189,16 @@ 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, dataset: Dataset, filter_itself: bool, whitelist: tp.Optional[np.ndarray], expected: pd.DataFrame + self, + dataset: Dataset, + filter_itself: bool, + whitelist: tp.Optional[np.ndarray], + expected: pd.DataFrame, + use_gpu_ranking: bool, ) -> None: - model = PureSVDModel(factors=2).fit(dataset) + model = PureSVDModel(factors=2, recommend_use_gpu_ranking=use_gpu_ranking).fit(dataset) actual = model.recommend_to_items( target_items=np.array([11, 12]), dataset=dataset, From 4b145617b4a6fd0f5635d3df720fbfaae4b2177d Mon Sep 17 00:00:00 2001 From: blondered Date: Mon, 13 Jan 2025 15:36:03 +0300 Subject: [PATCH 36/47] linters --- rectools/models/dssm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rectools/models/dssm.py b/rectools/models/dssm.py index b7836edf..88a253bc 100644 --- a/rectools/models/dssm.py +++ b/rectools/models/dssm.py @@ -215,7 +215,7 @@ def inference_users(self, dataloader: DataLoader[tp.Any]) -> np.ndarray: return vectors -class DSSMModel(VectorModel): +class DSSMModel(VectorModel): # pylint: disable=too-many-instance-attributes """ Wrapper for `rectools.models.dssm.DSSM` From 47b5dd5ae809ca88d2124c3356f57644a34107b3 Mon Sep 17 00:00:00 2001 From: blondered Date: Mon, 13 Jan 2025 15:36:22 +0300 Subject: [PATCH 37/47] updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c89a2c85..96711346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +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)) +- 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`, `PureSVDModel` and `DSSMModel`, `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)) From 6c5dfd22dd26ea4ba2e39ed03b38f9757370a6fe Mon Sep 17 00:00:00 2001 From: blondered Date: Mon, 13 Jan 2025 15:38:44 +0300 Subject: [PATCH 38/47] fixed changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f5630c8..8bd54444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `ImplicitBPRWrapperModel` model ([#232](https://github.com/MobileTeleSystems/RecTools/pull/232)) +- 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`, `PureSVDModel` and `DSSMModel`, `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)) ## [0.9.0] - 11.12.2024 @@ -24,7 +25,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `model_from_config` function ([#214](https://github.com/MobileTeleSystems/RecTools/pull/214)) - `get_cat_features` method to `SparseFeatures` ([#221](https://github.com/MobileTeleSystems/RecTools/pull/221)) - 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`, `PureSVDModel` and `DSSMModel`, `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 - Implicit ALS matrix zero assignment size ([#228](https://github.com/MobileTeleSystems/RecTools/pull/228)) From e1a43dea5c5437db97edf8001b778448ab371e2c Mon Sep 17 00:00:00 2001 From: blondered Date: Mon, 13 Jan 2025 16:30:49 +0300 Subject: [PATCH 39/47] gpu ranking for implicit bpr --- CHANGELOG.md | 2 +- rectools/models/implicit_bpr.py | 56 ++++++++++++++++++--- tests/models/test_implicit_bpr.py | 83 +++++++++++++++++++++++++++++-- 3 files changed, 130 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bd54444..af6ead51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `ImplicitBPRWrapperModel` model ([#232](https://github.com/MobileTeleSystems/RecTools/pull/232)) -- 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`, `PureSVDModel` and `DSSMModel`, `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)) +- All vector models and `EASEModel` support for enabling ranking on gpu and selecting number of threads for cpu ranking. Added `recommend_n_threads` and `recommend_use_gpu_ranking` parameters to `EASEModel`, `ImplicitALSWrapperModel`, `ImplicitBPRWrapperModel`, `PureSVDModel` and `DSSMModel`. Added `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)) ## [0.9.0] - 11.12.2024 diff --git a/rectools/models/implicit_bpr.py b/rectools/models/implicit_bpr.py index 4f7e5d58..1ce11a41 100644 --- a/rectools/models/implicit_bpr.py +++ b/rectools/models/implicit_bpr.py @@ -77,6 +77,8 @@ class ImplicitBPRWrapperModelConfig(ModelConfig): model_config = ConfigDict(arbitrary_types_allowed=True) model: BayesianPersonalizedRankingConfig + recommend_n_threads: tp.Optional[int] = None + recommend_use_gpu_ranking: tp.Optional[bool] = None class ImplicitBPRWrapperModel(VectorModel[ImplicitBPRWrapperModelConfig]): @@ -91,6 +93,18 @@ class ImplicitBPRWrapperModel(VectorModel[ImplicitBPRWrapperModelConfig]): Base model to wrap. verbose : int, default ``0`` Degree of verbose output. If ``0``, no output will be provided. + 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. """ recommends_for_warm = False @@ -101,18 +115,39 @@ class ImplicitBPRWrapperModel(VectorModel[ImplicitBPRWrapperModelConfig]): config_class = ImplicitBPRWrapperModelConfig - def __init__(self, model: AnyBayesianPersonalizedRanking, verbose: int = 0): - self._config = self._make_config(model, verbose) + def __init__( + self, + model: AnyBayesianPersonalizedRanking, + verbose: int = 0, + recommend_n_threads: tp.Optional[int] = None, + recommend_use_gpu_ranking: tp.Optional[bool] = None, + ): + self._config = self._make_config( + model=model, + verbose=verbose, + recommend_n_threads=recommend_n_threads, + recommend_use_gpu_ranking=recommend_use_gpu_ranking, + ) super().__init__(verbose=verbose) self.model: AnyBayesianPersonalizedRanking self._model = model # for refit - self.use_gpu = isinstance(model, GPUBayesianPersonalizedRanking) - 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, CPUBayesianPersonalizedRanking) else 0 + self.recommend_n_threads = recommend_n_threads + + if recommend_use_gpu_ranking is None: + recommend_use_gpu_ranking = isinstance(model, GPUBayesianPersonalizedRanking) + self.recommend_use_gpu_ranking = recommend_use_gpu_ranking @classmethod - def _make_config(cls, model: AnyBayesianPersonalizedRanking, verbose: int) -> ImplicitBPRWrapperModelConfig: + def _make_config( + cls, + model: AnyBayesianPersonalizedRanking, + verbose: int, + recommend_n_threads: tp.Optional[int] = None, + recommend_use_gpu_ranking: tp.Optional[bool] = None, + ) -> ImplicitBPRWrapperModelConfig: model_cls = ( model.__class__ if model.__class__ not in (CPUBayesianPersonalizedRanking, GPUBayesianPersonalizedRanking) @@ -144,6 +179,8 @@ def _make_config(cls, model: AnyBayesianPersonalizedRanking, verbose: int) -> Im cls=cls, model=tp.cast(BayesianPersonalizedRankingConfig, inner_model_config), verbose=verbose, + recommend_n_threads=recommend_n_threads, + recommend_use_gpu_ranking=recommend_use_gpu_ranking, ) def _get_config(self) -> ImplicitBPRWrapperModelConfig: @@ -157,7 +194,12 @@ def _from_config(cls, config: ImplicitBPRWrapperModelConfig) -> tpe.Self: if inner_model_cls == BPR_STRING: inner_model_cls = BayesianPersonalizedRanking model = inner_model_cls(**inner_model_params) - return cls(model=model, verbose=config.verbose) + return cls( + model=model, + 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: self.model = deepcopy(self._model) diff --git a/tests/models/test_implicit_bpr.py b/tests/models/test_implicit_bpr.py index fd055449..2ed0bf2c 100644 --- a/tests/models/test_implicit_bpr.py +++ b/tests/models/test_implicit_bpr.py @@ -143,6 +143,47 @@ def test_consistent_with_pure_implicit(self, dataset: Dataset, use_gpu: bool) -> np.testing.assert_equal(actual_internal_ids, expected_ids) np.testing.assert_allclose(actual_scores, expected_scores, atol=0.03) + @pytest.mark.skipif(not implicit.gpu.HAS_CUDA, reason="implicit cannot find CUDA for gpu ranking") + def test_gpu_ranking_consistent_with_pure_implicit( + self, + dataset: Dataset, + use_gpu: bool, + ) -> None: + base_model = BayesianPersonalizedRanking( + factors=2, num_threads=2, iterations=100, use_gpu=False, random_state=42 + ) + 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 = ImplicitBPRWrapperModel(model=gpu_model, 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", ( @@ -307,7 +348,11 @@ def setup_method(self) -> None: @pytest.mark.parametrize("use_gpu", (False, True)) @pytest.mark.parametrize("cls", (None, "BayesianPersonalizedRanking", "implicit.bpr.BayesianPersonalizedRanking")) - 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": 10, @@ -319,6 +364,8 @@ def test_from_config(self, use_gpu: bool, cls: tp.Any) -> None: "use_gpu": use_gpu, }, "verbose": 1, + "recommend_n_threads": recommend_n_threads, + "recommend_use_gpu_ranking": recommend_use_gpu, } if cls is not None: config["model"]["cls"] = cls @@ -332,13 +379,33 @@ def test_from_config(self, use_gpu: bool, cls: tp.Any) -> None: assert inner_model.verify_negative_samples is False if not use_gpu: assert inner_model.num_threads == 2 + + 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 expected_model_class = GPUBayesianPersonalizedRanking if use_gpu else CPUBayesianPersonalizedRanking assert isinstance(inner_model, expected_model_class) @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 = ImplicitBPRWrapperModel( model=BayesianPersonalizedRanking( factors=10, @@ -351,6 +418,8 @@ def test_to_config(self, use_gpu: bool, random_state: tp.Optional[int], simple_t use_gpu=use_gpu, ), verbose=1, + recommend_n_threads=recommend_n_threads, + recommend_use_gpu_ranking=recommend_use_gpu, ) config = model.get_config(simple_types=simple_types) expected_inner_model_config = { @@ -375,6 +444,8 @@ def test_to_config(self, use_gpu: bool, random_state: tp.Optional[int], simple_t "cls": "ImplicitBPRWrapperModel" if simple_types else ImplicitBPRWrapperModel, "model": expected_inner_model_config, "verbose": 1, + "recommend_use_gpu_ranking": recommend_use_gpu, + "recommend_n_threads": recommend_n_threads, } assert config == expected @@ -404,10 +475,16 @@ def test_custom_model_class(self) -> None: assert model.get_config()["model"]["cls"] == CustomBPR # 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": 4, "num_threads": 2, "iterations": 2, "random_state": 42}, "verbose": 1, + "recommend_use_gpu_ranking": recommend_use_gpu, + "recommend_n_threads": recommend_n_threads, } dataset = DATASET model = ImplicitBPRWrapperModel From 3abab481dd312566cde0caa44e89c805c4106553 Mon Sep 17 00:00:00 2001 From: blondered Date: Mon, 13 Jan 2025 16:30:59 +0300 Subject: [PATCH 40/47] fixed configs example --- examples/9_model_configs_and_saving.ipynb | 96 +++++++++++++++++++++-- 1 file changed, 89 insertions(+), 7 deletions(-) diff --git a/examples/9_model_configs_and_saving.ipynb b/examples/9_model_configs_and_saving.ipynb index 54867ed7..39800a26 100644 --- a/examples/9_model_configs_and_saving.ipynb +++ b/examples/9_model_configs_and_saving.ipynb @@ -27,7 +27,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ @@ -36,6 +36,7 @@ "from rectools.models import (\n", " ImplicitItemKNNWrapperModel, \n", " ImplicitALSWrapperModel, \n", + " ImplicitBPRWrapperModel, \n", " EASEModel, \n", " PopularInCategoryModel, \n", " PopularModel, \n", @@ -329,7 +330,7 @@ "- \"ItemItemRecommender\"\n", "- A path to a class (including any custom class) that can be imported. Like \"implicit.nearest_neighbours.TFIDFRecommender\"\n", "\n", - "Specify wrapped model hyper-params under the \"model.params\" key" + "Specify wrapped model hyper-params under the \"model\" dict relevant keys." ] }, { @@ -381,9 +382,9 @@ "\n", "Specify which model you want to wrap under the \"model.cls\" key. Since there is only one default model, you can skip this step. \"implicit.als.AlternatingLeastSquares\" will be used by default. Also you can pass a path to a class (including any custom class) that can be imported.\n", "\n", - "Specify wrapped model hyper-params under the \"model.params\" key. \n", + "Specify wrapped model hyper-params under the \"model\" dict relevant keys. \n", "\n", - "Specify wrapper hyper-params under relevant keys." + "Specify wrapper hyper-params under relevant config keys." ] }, { @@ -440,6 +441,73 @@ "model.get_params(simple_types=True)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### BPR-MF\n", + "`ImplicitBPRWrapperModel` is a wrapper. \n", + "Use \"model\" key in config to specify wrapped model class and params: \n", + "\n", + "Specify which model you want to wrap un\\der the \"model.cls\" key. Since there is only one default model, you can skip this step. \"implicit.bpr.BayesianPersonalizedRanking\" will be used by default. Also you can pass a path to a class (including any custom class) that can be imported.\n", + "\n", + "Specify wrapped model hyper-params under the \"model\" dict relevant keys. \n", + "\n", + "Specify wrapper hyper-params under relevant config keys." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "config = {\n", + " \"model\": {\n", + " # \"cls\": \"BayesianPersonalizedRanking\", # will work too\n", + " # \"cls\": \"implicit.bpr.BayesianPersonalizedRanking\", # will work too\n", + " \"factors\": 16,\n", + " \"num_threads\": 2,\n", + " \"iterations\": 2,\n", + " \"random_state\": 32\n", + " },\n", + " \"recommend_use_gpu_ranking\": False,\n", + "}\n", + "model = ImplicitBPRWrapperModel.from_config(config)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'cls': 'ImplicitBPRWrapperModel',\n", + " 'verbose': 0,\n", + " 'model.cls': 'BayesianPersonalizedRanking',\n", + " 'model.factors': 16,\n", + " 'model.learning_rate': 0.01,\n", + " 'model.regularization': 0.01,\n", + " 'model.dtype': 'float64',\n", + " 'model.iterations': 2,\n", + " 'model.verify_negative_samples': True,\n", + " 'model.random_state': 32,\n", + " 'model.use_gpu': True,\n", + " 'recommend_n_threads': None,\n", + " 'recommend_use_gpu_ranking': False}" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model.get_params(simple_types=True)" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -535,9 +603,9 @@ "\n", "Specify which model you want to wrap under the \"model.cls\" key. Since there is only one default model, you can skip this step. \"LightFM\" will be used by default. Also you can pass a path to a class (including any custom class) that can be imported. Like \"lightfm.lightfm.LightFM\"\n", "\n", - "Specify wrapped model hyper-params under the \"model.params\" key. \n", + "Specify wrapped model hyper-params under the \"model\" dict relevant keys. \n", "\n", - "Specify wrapper hyper-params under relevant keys." + "Specify wrapper hyper-params under relevant config keys." ] }, { @@ -736,8 +804,22 @@ } ], "metadata": { + "kernelspec": { + "display_name": "rectools", + "language": "python", + "name": "rectools" + }, "language_info": { - "name": "python" + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.12" } }, "nbformat": 4, From 438db88eeb3b104a28c80b7528efd11fa8d2ad91 Mon Sep 17 00:00:00 2001 From: blondered Date: Mon, 13 Jan 2025 16:53:37 +0300 Subject: [PATCH 41/47] fixed dssm tests --- tests/models/test_dssm.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/models/test_dssm.py b/tests/models/test_dssm.py index f2d7f7af..db960d78 100644 --- a/tests/models/test_dssm.py +++ b/tests/models/test_dssm.py @@ -53,8 +53,8 @@ def dataset(self) -> Dataset: [14, "f2", "f2val1"], [15, "f1", "f1val2"], [15, "f2", "f2val2"], - [17, "f1", "f1val2"], - [17, "f2", "f2val3"], + [17, "f1", "f1val3"], + [17, "f2", "f2val1"], [16, "f1", "f1val2"], [16, "f2", "f2val3"], ], @@ -92,7 +92,7 @@ def dataset(self) -> Dataset: pd.DataFrame( { Columns.User: [10, 10, 20, 20, 50, 50], - Columns.Item: [13, 15, 15, 17, 11, 12], + Columns.Item: [17, 13, 14, 17, 11, 14], Columns.Rank: [1, 2, 1, 2, 1, 2], } ), @@ -102,7 +102,7 @@ def dataset(self) -> Dataset: pd.DataFrame( { Columns.User: [10, 10, 20, 20, 50, 50], - Columns.Item: [12, 11, 11, 12, 11, 12], + Columns.Item: [11, 14, 11, 14, 11, 14], Columns.Rank: [1, 2, 1, 2, 1, 2], } ), @@ -154,7 +154,7 @@ def test_u2i( pd.DataFrame( { Columns.User: [10, 10, 50, 50, 50], - Columns.Item: [13, 17, 11, 13, 17], + Columns.Item: [17, 13, 11, 17, 13], Columns.Rank: [1, 2, 1, 2, 3], } ), @@ -164,7 +164,7 @@ def test_u2i( pd.DataFrame( { Columns.User: [10, 10, 10, 50, 50, 50], - Columns.Item: [11, 13, 17, 11, 13, 17], + Columns.Item: [11, 17, 13, 11, 17, 13], Columns.Rank: [1, 2, 3, 1, 2, 3], } ), @@ -265,7 +265,7 @@ def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset) -> None pd.DataFrame( { Columns.TargetItem: [11, 11, 12, 12], - Columns.Item: [11, 15, 12, 13], + Columns.Item: [11, 12, 12, 11], Columns.Rank: [1, 2, 1, 2], } ), @@ -276,7 +276,7 @@ def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset) -> None pd.DataFrame( { Columns.TargetItem: [11, 11, 12, 12], - Columns.Item: [15, 12, 13, 11], + Columns.Item: [12, 13, 11, 13], Columns.Rank: [1, 2, 1, 2], } ), @@ -287,7 +287,7 @@ def test_raises_when_get_vectors_from_not_fitted(self, dataset: Dataset) -> None pd.DataFrame( { Columns.TargetItem: [11, 11, 12, 12], - Columns.Item: [15, 12, 11, 15], + Columns.Item: [12, 15, 11, 15], Columns.Rank: [1, 2, 1, 2], } ), From b5c178f7f4b1330683fc264b265638b3d7c5fc63 Mon Sep 17 00:00:00 2001 From: blondered Date: Wed, 15 Jan 2025 09:48:21 +0300 Subject: [PATCH 42/47] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af6ead51..14462bdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `ImplicitBPRWrapperModel` model ([#232](https://github.com/MobileTeleSystems/RecTools/pull/232)) -- All vector models and `EASEModel` support for enabling ranking on gpu and selecting number of threads for cpu ranking. Added `recommend_n_threads` and `recommend_use_gpu_ranking` parameters to `EASEModel`, `ImplicitALSWrapperModel`, `ImplicitBPRWrapperModel`, `PureSVDModel` and `DSSMModel`. Added `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)) +- All vector models and `EASEModel` support for enabling ranking on GPU and selecting number of threads for CPU ranking. Added `recommend_n_threads` and `recommend_use_gpu_ranking` parameters to `EASEModel`, `ImplicitALSWrapperModel`, `ImplicitBPRWrapperModel`, `PureSVDModel` and `DSSMModel`. Added `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)) ## [0.9.0] - 11.12.2024 From 7602c0af915495e45a1010f29108a27b0568a92b Mon Sep 17 00:00:00 2001 From: blondered Date: Wed, 15 Jan 2025 10:36:14 +0300 Subject: [PATCH 43/47] PR comments --- rectools/models/dssm.py | 1 + rectools/models/ease.py | 1 + rectools/models/implicit_als.py | 21 +++++++++++---------- rectools/models/implicit_bpr.py | 1 + rectools/models/lightfm.py | 12 +++++------- rectools/models/pure_svd.py | 1 + rectools/models/rank.py | 14 +++++--------- rectools/models/utils.py | 20 ++++++++++++++++++++ tests/models/test_implicit_bpr.py | 3 ++- 9 files changed, 47 insertions(+), 27 deletions(-) diff --git a/rectools/models/dssm.py b/rectools/models/dssm.py index 88a253bc..bd202d6d 100644 --- a/rectools/models/dssm.py +++ b/rectools/models/dssm.py @@ -269,6 +269,7 @@ class DSSMModel(VectorModel): # pylint: disable=too-many-instance-attributes Use `pytorch_lightning.seed_everything` together with this param to fix the random state. recommend_n_threads: int, default 0 Number of threads to use for recommendation ranking on CPU. + Specifying ``0`` means to default to the number of cores on the machine. 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`` diff --git a/rectools/models/ease.py b/rectools/models/ease.py index 5d5439a6..05352933 100644 --- a/rectools/models/ease.py +++ b/rectools/models/ease.py @@ -59,6 +59,7 @@ class EASEModel(ModelBase[EASEModelConfig]): 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. + Specifying ``0`` means to default to the number of cores on the machine. 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`` diff --git a/rectools/models/implicit_als.py b/rectools/models/implicit_als.py index 55bb45c1..90dee5c1 100644 --- a/rectools/models/implicit_als.py +++ b/rectools/models/implicit_als.py @@ -33,6 +33,7 @@ from rectools.utils.serialization import DType, RandomState from .rank import Distance +from .utils import convert_arr_to_implicit_gpu_matrix from .vector import Factors, VectorModel ALS_STRING = "AlternatingLeastSquares" @@ -114,6 +115,7 @@ class ImplicitALSWrapperModel(VectorModel[ImplicitALSWrapperModelConfig]): See documentations linked above for details. recommend_n_threads: Optional[int], default ``None`` Number of threads to use for recommendation ranking on CPU. + Specifying ``0`` means to default to the number of cores on the machine. 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. @@ -352,9 +354,8 @@ def fit_als_with_features_separately_inplace( """ # If model was fitted we should drop any learnt embeddings except actual latent factors if model.user_factors is not None and model.item_factors is not None: - # Without .copy() gpu.Matrix will break correct slicing - user_factors = get_users_vectors(model)[:, : model.factors].copy() - item_factors = get_items_vectors(model)[:, : model.factors].copy() + user_factors = get_users_vectors(model)[:, : model.factors] + item_factors = get_items_vectors(model)[:, : model.factors] _set_factors(model, user_factors, item_factors) iu_csr = ui_csr.T.tocsr(copy=False) @@ -384,8 +385,8 @@ def fit_als_with_features_separately_inplace( def _set_factors(model: AnyAlternatingLeastSquares, user_factors: np.ndarray, item_factors: np.ndarray) -> None: if isinstance(model, GPUAlternatingLeastSquares): # pragma: no cover - user_factors = implicit.gpu.Matrix(user_factors) - item_factors = implicit.gpu.Matrix(item_factors) + user_factors = convert_arr_to_implicit_gpu_matrix(user_factors) + item_factors = convert_arr_to_implicit_gpu_matrix(item_factors) model.user_factors = user_factors model.item_factors = item_factors @@ -403,7 +404,7 @@ def _fit_paired_factors( } if isinstance(model, GPUAlternatingLeastSquares): # pragma: no cover features_model = GPUAlternatingLeastSquares(**features_model_params) - features_model.item_factors = implicit.gpu.Matrix(y_factors) + features_model.item_factors = convert_arr_to_implicit_gpu_matrix(y_factors) features_model.fit(xy_csr) x_factors = features_model.user_factors.to_numpy() else: @@ -643,8 +644,8 @@ def _fit_combined_factors_on_gpu_inplace( iu_csr_cuda = implicit.gpu.CSRMatrix(iu_csr) ui_csr_cuda = implicit.gpu.CSRMatrix(ui_csr) - X = implicit.gpu.Matrix(user_factors) - Y = implicit.gpu.Matrix(item_factors) + X = convert_arr_to_implicit_gpu_matrix(user_factors) + Y = convert_arr_to_implicit_gpu_matrix(item_factors) # invalidate cached norms and squared factors model._item_norms = model._user_norms = None # pylint: disable=protected-access @@ -661,14 +662,14 @@ def _fit_combined_factors_on_gpu_inplace( user_factors_np = X.to_numpy() user_factors_np[:, :n_user_explicit_factors] = user_explicit_factors - X = implicit.gpu.Matrix(user_factors_np) + X = convert_arr_to_implicit_gpu_matrix(user_factors_np) model.solver.calculate_yty(X, _XtX, model.regularization) model.solver.least_squares(iu_csr_cuda, Y, _XtX, X, model.cg_steps) item_factors_np = Y.to_numpy() item_factors_np[:, n_factors - n_item_explicit_factors :] = item_explicit_factors - Y = implicit.gpu.Matrix(item_factors_np) + Y = convert_arr_to_implicit_gpu_matrix(item_factors_np) model.user_factors = X model.item_factors = Y diff --git a/rectools/models/implicit_bpr.py b/rectools/models/implicit_bpr.py index 1ce11a41..38b3a763 100644 --- a/rectools/models/implicit_bpr.py +++ b/rectools/models/implicit_bpr.py @@ -95,6 +95,7 @@ class ImplicitBPRWrapperModel(VectorModel[ImplicitBPRWrapperModelConfig]): Degree of verbose output. If ``0``, no output will be provided. recommend_n_threads: Optional[int], default ``None`` Number of threads to use for recommendation ranking on CPU. + Specifying ``0`` means to default to the number of cores on the machine. 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. diff --git a/rectools/models/lightfm.py b/rectools/models/lightfm.py index 58e262b7..fc6c9932 100644 --- a/rectools/models/lightfm.py +++ b/rectools/models/lightfm.py @@ -108,11 +108,11 @@ class LightFMWrapperModel(FixedColdRecoModelMixin, VectorModel[LightFMWrapperMod Will be used as `epochs` parameter for `LightFM.fit`. num_threads: int, default 1 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. + Can also be used as number of threads for recommendation ranking on CPU. + See `recommend_n_threads` for details. recommend_n_threads: Optional[int], default ``None`` Number of threads to use for recommendation ranking on CPU. + Specifying ``0`` means to default to the number of cores on the machine. 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. @@ -149,12 +149,10 @@ 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 + self._recommend_n_threads = recommend_n_threads # used to make a config + self.recommend_n_threads = num_threads 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: diff --git a/rectools/models/pure_svd.py b/rectools/models/pure_svd.py index 241a2589..69094f8e 100644 --- a/rectools/models/pure_svd.py +++ b/rectools/models/pure_svd.py @@ -58,6 +58,7 @@ class PureSVDModel(VectorModel[PureSVDModelConfig]): 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. + Specifying ``0`` means to default to the number of cores on the machine. 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`` diff --git a/rectools/models/rank.py b/rectools/models/rank.py index 4828b409..e00dde8e 100644 --- a/rectools/models/rank.py +++ b/rectools/models/rank.py @@ -23,13 +23,14 @@ 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 from rectools.models.base import Scores from rectools.types import InternalIdsArray +from .utils import convert_arr_to_implicit_gpu_matrix + class Distance(Enum): """Distance metric""" @@ -152,22 +153,18 @@ def _rank_on_gpu( filter_query_items: tp.Optional[tp.Union[sparse.csr_matrix, sparse.csr_array]], ) -> tp.Tuple[np.ndarray, np.ndarray]: # pragma: no cover - def _convert_arr_to_implicit_gpu_matrix(arr: np.ndarray) -> implicit.gpu.Matrix: - # We need to explicitly create copy to handle transposed arrays correctly - return implicit.gpu.Matrix(arr.astype(np.float32).copy()) - - object_factors = _convert_arr_to_implicit_gpu_matrix(object_factors) + object_factors = convert_arr_to_implicit_gpu_matrix(object_factors) if isinstance(subject_factors, sparse.spmatrix): warnings.warn("Sparse subject factors converted to Dense matrix") subject_factors = subject_factors.todense() - subject_factors = _convert_arr_to_implicit_gpu_matrix(subject_factors) + subject_factors = convert_arr_to_implicit_gpu_matrix(subject_factors) if object_norms is not None: if len(np.shape(object_norms)) == 1: object_norms = np.expand_dims(object_norms, axis=0) - object_norms = _convert_arr_to_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()) @@ -259,7 +256,6 @@ 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/utils.py b/rectools/models/utils.py index 4edef4ff..30f1e0ae 100644 --- a/rectools/models/utils.py +++ b/rectools/models/utils.py @@ -16,6 +16,7 @@ import typing as tp +import implicit.gpu import numpy as np from scipy import sparse @@ -114,3 +115,22 @@ def recommend_from_scores( reco_scores = -reco_scores return reco_ids, reco_scores + + +def convert_arr_to_implicit_gpu_matrix(arr: np.ndarray) -> implicit.gpu.Matrix: + """ + Safely convert numpy array to implicit.gpu.Matrix. + + Parameters + ---------- + arr : np.ndarray + Array to be converted. + + Returns + ------- + np.ndarray + implicit.gpu.Matrix from array. + """ + # We need to explicitly create copy to handle transposed and sliced arrays correctly + # since Matrix is created from a direct copy of the underlying memory block, and `.T` is just a view + return implicit.gpu.Matrix(arr.astype(np.float32).copy()) diff --git a/tests/models/test_implicit_bpr.py b/tests/models/test_implicit_bpr.py index 2ed0bf2c..499e91f6 100644 --- a/tests/models/test_implicit_bpr.py +++ b/tests/models/test_implicit_bpr.py @@ -480,8 +480,9 @@ def test_custom_model_class(self) -> None: 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: + # note that num_threads > 1 will make model training undeterministic initial_config = { - "model": {"factors": 4, "num_threads": 2, "iterations": 2, "random_state": 42}, + "model": {"factors": 4, "num_threads": 1, "iterations": 2, "random_state": 42}, "verbose": 1, "recommend_use_gpu_ranking": recommend_use_gpu, "recommend_n_threads": recommend_n_threads, From 15ac40f68a2207ec95c79f5953a7653e1a24449a Mon Sep 17 00:00:00 2001 From: blondered Date: Wed, 15 Jan 2025 10:58:36 +0300 Subject: [PATCH 44/47] fixed bpr refit test --- tests/models/test_implicit_bpr.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/models/test_implicit_bpr.py b/tests/models/test_implicit_bpr.py index 499e91f6..4128c619 100644 --- a/tests/models/test_implicit_bpr.py +++ b/tests/models/test_implicit_bpr.py @@ -282,11 +282,18 @@ def test_i2i( actual, ) - @pytest.mark.skip("BPR doesn't behave deterministically") def test_second_fit_refits_model(self, dataset: Dataset, use_gpu: bool) -> None: - base_model = BayesianPersonalizedRanking(factors=8, num_threads=2, use_gpu=use_gpu, random_state=1) + # note that num_threads > 1 will make model training undeterministic + # https://github.com/benfred/implicit/issues/710 + # GPU training is always undeterministic so we only test for CPU training + base_model = BayesianPersonalizedRanking(factors=8, num_threads=1, use_gpu=False, random_state=1) model = ImplicitBPRWrapperModel(model=base_model) - assert_second_fit_refits_model(model, dataset) + state = np.random.get_state() + + def set_random_state() -> None: + np.random.set_state(state) + + assert_second_fit_refits_model(model, dataset, set_random_state) def test_dumps_loads(self, dataset: Dataset, use_gpu: bool) -> None: base_model = BayesianPersonalizedRanking(factors=8, num_threads=2, use_gpu=use_gpu, random_state=1) @@ -481,6 +488,7 @@ 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: # note that num_threads > 1 will make model training undeterministic + # https://github.com/benfred/implicit/issues/710 initial_config = { "model": {"factors": 4, "num_threads": 1, "iterations": 2, "random_state": 42}, "verbose": 1, From dc85002cbd6ff72fb99a6d27ed82e18597074f3f Mon Sep 17 00:00:00 2001 From: blondered Date: Wed, 15 Jan 2025 13:55:44 +0300 Subject: [PATCH 45/47] implicit.gpu.Matrix and vector init --- rectools/models/utils.py | 2 +- rectools/models/vector.py | 7 +++++-- tests/models/test_vector.py | 5 +++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/rectools/models/utils.py b/rectools/models/utils.py index 30f1e0ae..b56b22fc 100644 --- a/rectools/models/utils.py +++ b/rectools/models/utils.py @@ -117,7 +117,7 @@ def recommend_from_scores( return reco_ids, reco_scores -def convert_arr_to_implicit_gpu_matrix(arr: np.ndarray) -> implicit.gpu.Matrix: +def convert_arr_to_implicit_gpu_matrix(arr: np.ndarray) -> tp.Any: """ Safely convert numpy array to implicit.gpu.Matrix. diff --git a/rectools/models/vector.py b/rectools/models/vector.py index 3940b37d..5f100ef9 100644 --- a/rectools/models/vector.py +++ b/rectools/models/vector.py @@ -41,8 +41,11 @@ class VectorModel(ModelBase[ModelConfig_T]): u2i_dist: Distance = NotImplemented i2i_dist: Distance = NotImplemented - recommend_n_threads: int = 0 - recommend_use_gpu_ranking: bool = True + + def __init__(self, verbose: int = 0, **kwargs: tp.Any) -> None: + super().__init__(verbose=verbose) + self.recommend_n_threads: int + self.recommend_use_gpu_ranking: bool def _recommend_u2i( self, diff --git a/tests/models/test_vector.py b/tests/models/test_vector.py index 4ee53a23..25c52b6f 100644 --- a/tests/models/test_vector.py +++ b/tests/models/test_vector.py @@ -59,6 +59,11 @@ class SomeVectorModel(VectorModel): u2i_dist = u2i_distance i2i_dist = i2i_distance + def __init__(self, verbose: int = 0): + super().__init__(verbose=verbose) + self.recommend_n_threads = 1 + self.recommend_use_gpu_ranking = False + def _fit(self, dataset: Dataset, *args: tp.Any, **kwargs: tp.Any) -> None: pass From 02e6ab8b0cdbc45dd5336f9db92c9ff29b2b3a9a Mon Sep 17 00:00:00 2001 From: blondered Date: Wed, 15 Jan 2025 14:07:45 +0300 Subject: [PATCH 46/47] pragma no cover --- rectools/models/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rectools/models/utils.py b/rectools/models/utils.py index b56b22fc..fdd7750c 100644 --- a/rectools/models/utils.py +++ b/rectools/models/utils.py @@ -133,4 +133,4 @@ def convert_arr_to_implicit_gpu_matrix(arr: np.ndarray) -> tp.Any: """ # We need to explicitly create copy to handle transposed and sliced arrays correctly # since Matrix is created from a direct copy of the underlying memory block, and `.T` is just a view - return implicit.gpu.Matrix(arr.astype(np.float32).copy()) + return implicit.gpu.Matrix(arr.astype(np.float32).copy()) # pragma: no cover From 11a40ba4d0b43afef60c05811720237fc79e2b42 Mon Sep 17 00:00:00 2001 From: Daria <93913290+blondered@users.noreply.github.com> Date: Wed, 15 Jan 2025 14:52:15 +0300 Subject: [PATCH 47/47] Update tests/models/test_implicit_bpr.py Co-authored-by: Emiliy Feldman --- tests/models/test_implicit_bpr.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/models/test_implicit_bpr.py b/tests/models/test_implicit_bpr.py index 4128c619..c77157db 100644 --- a/tests/models/test_implicit_bpr.py +++ b/tests/models/test_implicit_bpr.py @@ -285,8 +285,10 @@ def test_i2i( def test_second_fit_refits_model(self, dataset: Dataset, use_gpu: bool) -> None: # note that num_threads > 1 will make model training undeterministic # https://github.com/benfred/implicit/issues/710 - # GPU training is always undeterministic so we only test for CPU training - base_model = BayesianPersonalizedRanking(factors=8, num_threads=1, use_gpu=False, random_state=1) + # GPU training is always nondeterministic so we only test for CPU training + if use_gpu: + pytest.skip("BPR is nondeterministic on GPU") + base_model = BayesianPersonalizedRanking(factors=8, num_threads=1, use_gpu=use_gpu, random_state=1) model = ImplicitBPRWrapperModel(model=base_model) state = np.random.get_state()