Skip to content

Commit 11064db

Browse files
committed
Introduce ImplicitBPRWrapper model
1 parent a1c1a2f commit 11064db

8 files changed

Lines changed: 558 additions & 9 deletions

File tree

rectools/metrics/intersection.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from typing import Dict, Hashable, Optional, Union
15+
from collections.abc import Hashable
16+
from typing import Dict, Optional, Union
1617

1718
import attr
1819
import numpy as np

rectools/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
from .ease import EASEModel
4040
from .implicit_als import ImplicitALSWrapperModel
41+
from .implicit_bpr import ImplicitBPRWrapperModel
4142
from .implicit_knn import ImplicitItemKNNWrapperModel
4243
from .popular import PopularModel
4344
from .popular_in_category import PopularInCategoryModel
@@ -59,6 +60,7 @@
5960
__all__ = (
6061
"EASEModel",
6162
"ImplicitALSWrapperModel",
63+
"ImplicitBPRWrapperModel",
6264
"ImplicitItemKNNWrapperModel",
6365
"LightFMWrapperModel",
6466
"PopularModel",

rectools/models/implicit_als.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,15 @@
2222
from implicit.cpu.als import AlternatingLeastSquares as CPUAlternatingLeastSquares
2323
from implicit.gpu.als import AlternatingLeastSquares as GPUAlternatingLeastSquares
2424
from implicit.utils import check_random_state
25-
from pydantic import BeforeValidator, ConfigDict, PlainSerializer, SerializationInfo, WrapSerializer
25+
from pydantic import BeforeValidator, ConfigDict, SerializationInfo, WrapSerializer
2626
from scipy import sparse
2727
from tqdm.auto import tqdm
2828

2929
from rectools.dataset import Dataset, Features
3030
from rectools.exceptions import NotFittedError
3131
from rectools.models.base import ModelConfig
3232
from rectools.utils.misc import get_class_or_function_full_path, import_object
33-
from rectools.utils.serialization import RandomState
33+
from rectools.utils.serialization import DType, RandomState
3434

3535
from .rank import Distance
3636
from .vector import Factors, VectorModel
@@ -68,10 +68,6 @@ def _serialize_alternating_least_squares_class(
6868
),
6969
]
7070

71-
DType = tpe.Annotated[
72-
np.dtype, BeforeValidator(func=np.dtype), PlainSerializer(func=lambda dtp: dtp.name, when_used="json")
73-
]
74-
7571

7672
class AlternatingLeastSquaresConfig(tpe.TypedDict):
7773
"""Config for implicit `AlternatingLeastSquares` model."""

rectools/models/implicit_bpr.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import typing as tp
2+
from copy import deepcopy
3+
4+
import numpy as np
5+
import typing_extensions as tpe
6+
from implicit.bpr import BayesianPersonalizedRanking
7+
from implicit.cpu.bpr import (
8+
BayesianPersonalizedRanking as CPUBayesianPersonalizedRanking, # pylint: disable=no-name-in-module
9+
)
10+
from implicit.gpu.bpr import (
11+
BayesianPersonalizedRanking as GPUBayesianPersonalizedRanking, # pylint: disable=no-name-in-module
12+
)
13+
from pydantic import BeforeValidator, ConfigDict, SerializationInfo, WrapSerializer
14+
15+
from rectools.dataset.dataset import Dataset
16+
from rectools.exceptions import NotFittedError
17+
from rectools.models.base import ModelConfig
18+
from rectools.models.rank import Distance
19+
from rectools.models.vector import Factors, VectorModel
20+
from rectools.utils.misc import get_class_or_function_full_path, import_object
21+
from rectools.utils.serialization import DType, RandomState
22+
23+
BPR_STRING = "BayesianPersonalizedRanking"
24+
25+
AnyBayesianPersonalizedRanking = tp.Union[CPUBayesianPersonalizedRanking, GPUBayesianPersonalizedRanking]
26+
BayesianPersonalizedRankingType = tp.Union[
27+
tp.Type[AnyBayesianPersonalizedRanking], tp.Literal["BayesianPersonalizedRanking"]
28+
]
29+
30+
31+
def _get_bpr_class(spec: tp.Any) -> tp.Any:
32+
if spec in (BPR_STRING, get_class_or_function_full_path(BayesianPersonalizedRanking)):
33+
return "BayesianPersonalizedRanking"
34+
if isinstance(spec, str):
35+
return import_object(spec)
36+
return spec
37+
38+
39+
def _serialize_bpr_class(
40+
cls: BayesianPersonalizedRankingType, handler: tp.Callable, info: SerializationInfo
41+
) -> tp.Union[None, str, AnyBayesianPersonalizedRanking]:
42+
if cls in (CPUBayesianPersonalizedRanking, GPUBayesianPersonalizedRanking) or cls == "BayesianPersonalizedRanking":
43+
return BPR_STRING
44+
if info.mode == "json":
45+
return get_class_or_function_full_path(cls)
46+
return cls
47+
48+
49+
BayesianPersonalizedRankingClass = tpe.Annotated[
50+
BayesianPersonalizedRankingType,
51+
BeforeValidator(_get_bpr_class),
52+
WrapSerializer(
53+
func=_serialize_bpr_class,
54+
when_used="always",
55+
),
56+
]
57+
58+
59+
class BayesianPersonalizedRankingConfig(tpe.TypedDict):
60+
"""Config for implicit `BayesianPersonalizedRanking` model."""
61+
62+
cls: tpe.NotRequired[BayesianPersonalizedRankingClass]
63+
factors: tpe.NotRequired[int]
64+
learning_rate: tpe.NotRequired[float]
65+
regularization: tpe.NotRequired[float]
66+
dtype: tpe.NotRequired[DType]
67+
num_threads: tpe.NotRequired[int]
68+
iterations: tpe.NotRequired[int]
69+
verify_negative_samples: tpe.NotRequired[bool]
70+
random_state: tpe.NotRequired[tp.Union[RandomState, tp.Dict[str, tp.Any]]]
71+
use_gpu: tpe.NotRequired[bool]
72+
73+
74+
class ImplicitBPRWrapperModelConfig(ModelConfig):
75+
"""Config for `ImplicitBPRWrapperModel`"""
76+
77+
model_config = ConfigDict(arbitrary_types_allowed=True)
78+
79+
model: BayesianPersonalizedRankingConfig
80+
81+
82+
class ImplicitBPRWrapperModel(VectorModel[ImplicitBPRWrapperModelConfig]):
83+
"""
84+
Wrapper for `implicit.bpr.BayesianPersonalizedRanking` model.
85+
86+
See https://implicit.readthedocs.io/en/latest/bpr.html for details of the base model.
87+
88+
Parameters
89+
----------
90+
model : BayesianPersonalizedRanking
91+
Baes model to wrap.
92+
verbose : int, default ``0``
93+
Degree of verbose output. If ``0``, no output will be provided.
94+
"""
95+
96+
recommends_for_warm = False
97+
recommends_for_cold = False
98+
99+
u2i_dist = Distance.DOT
100+
i2i_dist = Distance.COSINE
101+
102+
config_class = ImplicitBPRWrapperModelConfig
103+
104+
def __init__(self, model: AnyBayesianPersonalizedRanking, verbose: int = 0):
105+
self._config = self._make_config(model, verbose)
106+
super().__init__(verbose=verbose)
107+
self.model: AnyBayesianPersonalizedRanking
108+
self._model = model # for refit
109+
110+
self.use_gpu = isinstance(model, GPUBayesianPersonalizedRanking)
111+
if not self.use_gpu:
112+
self.n_threads = model.num_threads
113+
114+
@classmethod
115+
def _make_config(cls, model: AnyBayesianPersonalizedRanking, verbose: int) -> ImplicitBPRWrapperModelConfig:
116+
model_cls = (
117+
model.__class__
118+
if model.__class__ not in (CPUBayesianPersonalizedRanking, GPUBayesianPersonalizedRanking)
119+
else "BayesianPersonalizedRanking"
120+
)
121+
random_state = model.random_state
122+
if model.random_state and isinstance(model.random_state, np.random.RandomState):
123+
random_state = random_state.get_state()
124+
125+
inner_model_config = {
126+
"cls": model_cls,
127+
"factors": model.factors,
128+
"learning_rate": model.learning_rate,
129+
"regularization": model.regularization,
130+
"iterations": model.iterations,
131+
"verify_negative_samples": model.verify_negative_samples,
132+
"random_state": random_state,
133+
}
134+
if isinstance(model, GPUBayesianPersonalizedRanking):
135+
inner_model_config["use_gpu"] = True
136+
else:
137+
inner_model_config.update(
138+
{
139+
"use_gpu": False,
140+
"dtype": model.dtype,
141+
"num_threads": model.num_threads,
142+
}
143+
)
144+
145+
return ImplicitBPRWrapperModelConfig(
146+
cls=cls,
147+
model=tp.cast(BayesianPersonalizedRankingConfig, inner_model_config),
148+
verbose=verbose,
149+
)
150+
151+
def _get_config(self) -> ImplicitBPRWrapperModelConfig:
152+
return self._config
153+
154+
@classmethod
155+
def _from_config(cls, config: ImplicitBPRWrapperModelConfig) -> tpe.Self:
156+
inner_model_params = deepcopy(config.model)
157+
inner_model_cls = inner_model_params.pop("cls", BayesianPersonalizedRanking)
158+
inner_model_cls = tp.cast(tp.Callable, inner_model_cls)
159+
if "random_state" in inner_model_params and isinstance(inner_model_params["random_state"], dict):
160+
inner_model_params["random_state"] = np.random.set_state(inner_model_params["random_state"])
161+
if inner_model_cls == BPR_STRING:
162+
inner_model_cls = BayesianPersonalizedRanking
163+
model = inner_model_cls(**inner_model_params)
164+
return cls(model=model, verbose=config.verbose)
165+
166+
def _fit(self, dataset: Dataset) -> None:
167+
self.model = deepcopy(self._model)
168+
169+
ui_csr = dataset.get_user_item_matrix(include_weights=True).astype(np.float32)
170+
self.model.fit(ui_csr, show_progress=self.verbose > 0)
171+
172+
def _get_users_factors(self, dataset: Dataset) -> Factors:
173+
return Factors(get_users_vectors(self.model))
174+
175+
def _get_items_factors(self, dataset: Dataset) -> Factors:
176+
return Factors(get_items_vectors(self.model))
177+
178+
def get_vectors(self) -> tp.Tuple[np.ndarray, np.ndarray]:
179+
"""
180+
Return user and item vector representation from fitted model.
181+
182+
Returns
183+
-------
184+
(np.ndarray, np.ndarray)
185+
User and item vectors.
186+
Shapes are (n_users, n_factors) and (n_items, n_factors).
187+
"""
188+
if not self.is_fitted:
189+
raise NotFittedError(self.__class__.__name__)
190+
return get_users_vectors(self.model), get_items_vectors(self.model)
191+
192+
193+
def get_users_vectors(model: AnyBayesianPersonalizedRanking) -> np.ndarray:
194+
"""
195+
Get user vectors from BPR model as a numpy array.
196+
197+
Parameters
198+
----------
199+
model : BayesianPersonalizedRanking
200+
Fitted BPR model. Can be CPU or GPU model
201+
202+
Returns
203+
-------
204+
np.ndarray
205+
User vectors.
206+
"""
207+
if isinstance(model, GPUBayesianPersonalizedRanking):
208+
return model.user_factors.to_numpy()
209+
return model.user_factors
210+
211+
212+
def get_items_vectors(model: AnyBayesianPersonalizedRanking) -> np.ndarray:
213+
"""
214+
Get item vectors from BPR model as a numpy array.
215+
216+
Parameters
217+
----------
218+
model : BayesianPersonalizedRanking
219+
Fitted BPR model. Can be CPU or GPU model
220+
221+
Returns
222+
-------
223+
np.ndarray
224+
Item vectors.
225+
"""
226+
if isinstance(model, GPUBayesianPersonalizedRanking):
227+
return model.item_factors.to_numpy()
228+
return model.item_factors

rectools/utils/serialization.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import numpy as np
1919
import typing_extensions as tpe
20-
from pydantic import PlainSerializer
20+
from pydantic import BeforeValidator, PlainSerializer
2121

2222
FileLike = tp.Union[str, Path, tp.IO[bytes]]
2323

@@ -37,6 +37,10 @@ def _serialize_random_state(rs: tp.Optional[tp.Union[None, int, np.random.Random
3737
PlainSerializer(func=_serialize_random_state, when_used="json"),
3838
]
3939

40+
DType = tpe.Annotated[
41+
np.dtype, BeforeValidator(func=np.dtype), PlainSerializer(func=lambda dtp: dtp.name, when_used="json")
42+
]
43+
4044

4145
def read_bytes(f: FileLike) -> bytes:
4246
"""Read bytes from a file."""

0 commit comments

Comments
 (0)