Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/epochs for als #203

Merged
merged 14 commits into from
Nov 11, 2024
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## Unreleased

### Added
- Optional `epochs` argument to `ImplicitALSWrapperModel.fit` method ([#203](https://github.com/MobileTeleSystems/RecTools/pull/203))
feldlime marked this conversation as resolved.
Show resolved Hide resolved


## [0.8.0] - 28.08.2024

### Added
Expand Down
153 changes: 100 additions & 53 deletions rectools/models/implicit_als.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,20 @@ def __init__(self, model: AnyAlternatingLeastSquares, verbose: int = 0, fit_feat
if not self.use_gpu:
self.n_threads = model.num_threads

def _fit(self, dataset: Dataset) -> None: # type: ignore
# TODO: move to `epochs` argument of `partial_fit` method when implemented
def _fit(self, dataset: Dataset, epochs: tp.Optional[int] = None) -> None: # type: ignore
self.model = deepcopy(self._model)
ui_csr = dataset.get_user_item_matrix(include_weights=True).astype(np.float32)
if epochs is None:
epochs = self.model.iterations

if self.fit_features_together:
fit_als_with_features_together_inplace(
self.model,
ui_csr,
dataset.get_hot_user_features(),
dataset.get_hot_item_features(),
epochs,
self.verbose,
)
else:
Expand All @@ -87,6 +91,7 @@ def _fit(self, dataset: Dataset) -> None: # type: ignore
ui_csr,
dataset.get_hot_user_features(),
dataset.get_hot_item_features(),
epochs,
self.verbose,
)

Expand Down Expand Up @@ -154,6 +159,7 @@ def fit_als_with_features_separately_inplace(
ui_csr: sparse.csr_matrix,
user_features: tp.Optional[Features],
item_features: tp.Optional[Features],
iterations: int,
verbose: int = 0,
) -> None:
"""
Expand All @@ -172,7 +178,15 @@ def fit_als_with_features_separately_inplace(
verbose : int
Whether to print output.
"""
# 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:
feldlime marked this conversation as resolved.
Show resolved Hide resolved
# 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()
_set_factors(model, user_factors, item_factors)

iu_csr = ui_csr.T.tocsr(copy=False)
model.iterations = iterations
model.fit(ui_csr, show_progress=verbose > 0)

user_factors_chunks = [get_users_vectors(model)]
Expand All @@ -193,10 +207,13 @@ def fit_als_with_features_separately_inplace(
user_factors = np.hstack(user_factors_chunks)
item_factors = np.hstack(item_factors_chunks)

_set_factors(model, user_factors, item_factors)


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)

model.user_factors = user_factors
model.item_factors = item_factors

Expand Down Expand Up @@ -235,36 +252,30 @@ def _fit_paired_factors(
def _init_latent_factors_cpu(
model: CPUAlternatingLeastSquares, n_users: int, n_items: int
) -> tp.Tuple[np.ndarray, np.ndarray]:
"""Logic is copied and pasted from original implicit library code"""
"""
Logic is copied and pasted from original implicit library code.
This method is used only for model that hasn't been fitted yet.
"""
random_state = check_random_state(model.random_state)
if model.user_factors is None:
feldlime marked this conversation as resolved.
Show resolved Hide resolved
user_latent_factors = random_state.random((n_users, model.factors)) * 0.01
else:
user_latent_factors = model.user_factors
if model.item_factors is None:
item_latent_factors = random_state.random((n_items, model.factors)) * 0.01
else:
item_latent_factors = model.item_factors
user_latent_factors = random_state.random((n_users, model.factors)) * 0.01
item_latent_factors = random_state.random((n_items, model.factors)) * 0.01
return user_latent_factors, item_latent_factors


def _init_latent_factors_gpu(
model: GPUAlternatingLeastSquares, n_users: int, n_items: int
) -> tp.Tuple[np.ndarray, np.ndarray]: # pragma: no cover
"""Logic is copied and pasted from original implicit library code"""
"""
Logic is copied and pasted from original implicit library code.
This method is used only for model that hasn't been fitted yet.
"""
random_state = check_random_state(model.random_state)
if model.user_factors is None:
feldlime marked this conversation as resolved.
Show resolved Hide resolved
user_latent_factors = random_state.uniform(
low=-0.5 / model.factors, high=0.5 / model.factors, size=(n_users, model.factors)
)
else:
user_latent_factors = model.user_factors.to_numpy()
if model.item_factors is None:
item_latent_factors = random_state.uniform(
low=-0.5 / model.factors, high=0.5 / model.factors, size=(n_items, model.factors)
)
else:
item_latent_factors = model.item_factors.to_numpy()
user_latent_factors = random_state.uniform(
low=-0.5 / model.factors, high=0.5 / model.factors, size=(n_users, model.factors)
)
item_latent_factors = random_state.uniform(
low=-0.5 / model.factors, high=0.5 / model.factors, size=(n_items, model.factors)
)
return user_latent_factors, item_latent_factors


Expand All @@ -273,6 +284,7 @@ def fit_als_with_features_together_inplace(
ui_csr: sparse.csr_matrix,
user_features: tp.Optional[Features],
item_features: tp.Optional[Features],
iterations: int,
verbose: int = 0,
) -> None:
"""
Expand All @@ -293,6 +305,65 @@ def fit_als_with_features_together_inplace(
"""
n_users, n_items = ui_csr.shape

if model.user_factors is None or model.item_factors is None:
user_factors, item_factors, n_user_explicit_factors, n_item_explicit_factors = (
_init_user_item_factors_for_combined_training_with_features(
model, n_users, n_items, user_features, item_features
)
)
else:
user_factors = get_users_vectors(model)
item_factors = get_items_vectors(model)
n_user_explicit_factors = user_features.values.shape[1] if user_features is not None else 0
n_item_explicit_factors = item_features.values.shape[1] if item_features is not None else 0

# Fix number of factors
n_latent_factors = model.factors
model.factors = n_latent_factors + n_user_explicit_factors + n_item_explicit_factors

# Give the positive examples more weight if asked for (implicit library logic copy)
ui_csr = model.alpha * ui_csr

if isinstance(model, GPUAlternatingLeastSquares): # pragma: no cover
_fit_combined_factors_on_gpu_inplace(
model,
ui_csr,
user_factors,
item_factors,
n_user_explicit_factors,
n_item_explicit_factors,
verbose,
iterations,
)
else:
_fit_combined_factors_on_cpu_inplace(
model,
ui_csr,
user_factors,
item_factors,
n_user_explicit_factors,
n_item_explicit_factors,
verbose,
iterations,
)

# Fix back model factors
model.factors = n_latent_factors


def _init_user_item_factors_for_combined_training_with_features(
model: AnyAlternatingLeastSquares,
n_users: int,
n_items: int,
user_features: tp.Optional[Features],
item_features: tp.Optional[Features],
) -> tp.Tuple[np.ndarray, np.ndarray, int, int]:
"""
Init user and item factors for model that hasn't been initialized yet.
Final factors will include latent factors, explicit factors from
user/item features and their paired item/user factors.
This method is only used when `fit_features_together` is set to ``True``
"""
# Prepare explicit factors
user_explicit_factors: np.ndarray
if user_features is None:
Expand All @@ -314,10 +385,6 @@ def fit_als_with_features_together_inplace(
else:
user_latent_factors, item_latent_factors = _init_latent_factors_cpu(model, n_users, n_items)

# Fix number of factors
n_latent_factors = model.factors
model.factors = n_latent_factors + n_user_explicit_factors + n_item_explicit_factors

# Prepare paired factors
user_factors_paired_to_items = np.zeros((n_users, n_item_explicit_factors))
item_factors_paired_to_users = np.zeros((n_items, n_user_explicit_factors))
Expand All @@ -338,29 +405,7 @@ def fit_als_with_features_together_inplace(
)
).astype(model.dtype)

# Give the positive examples more weight if asked for (implicit library logic copy)
ui_csr = model.alpha * ui_csr

if isinstance(model, GPUAlternatingLeastSquares): # pragma: no cover
_fit_combined_factors_on_gpu_inplace(
model,
ui_csr,
user_factors,
item_factors,
n_user_explicit_factors,
n_item_explicit_factors,
verbose,
)
else:
_fit_combined_factors_on_cpu_inplace(
model,
ui_csr,
user_factors,
item_factors,
n_user_explicit_factors,
n_item_explicit_factors,
verbose,
)
return user_factors, item_factors, n_user_explicit_factors, n_item_explicit_factors


def _fit_combined_factors_on_cpu_inplace(
Expand All @@ -371,6 +416,7 @@ def _fit_combined_factors_on_cpu_inplace(
n_user_explicit_factors: int,
n_item_explicit_factors: int,
verbose: int,
iterations: int,
) -> None:
n_factors = user_factors.shape[1]
user_explicit_factors = user_factors[:, :n_user_explicit_factors].copy()
Expand All @@ -384,7 +430,7 @@ def _fit_combined_factors_on_cpu_inplace(

solver = model.solver

for _ in tqdm(range(model.iterations), disable=verbose == 0):
for _ in tqdm(range(iterations), disable=verbose == 0):

solver(
ui_csr,
Expand Down Expand Up @@ -416,6 +462,7 @@ def _fit_combined_factors_on_gpu_inplace(
n_user_explicit_factors: int,
n_item_explicit_factors: int,
verbose: int,
iterations: int,
) -> None: # pragma: no cover
n_factors = user_factors.shape[1]
user_explicit_factors = user_factors[:, :n_user_explicit_factors].copy()
Expand All @@ -435,7 +482,7 @@ def _fit_combined_factors_on_gpu_inplace(
_YtY = implicit.gpu.Matrix.zeros(model.factors, model.factors)
_XtX = implicit.gpu.Matrix.zeros(model.factors, model.factors)

for _ in tqdm(range(model.iterations), disable=verbose == 0):
for _ in tqdm(range(iterations), disable=verbose == 0):

model.solver.calculate_yty(Y, _YtY, model.regularization)
model.solver.least_squares(ui_csr_cuda, X, _YtY, Y, model.cg_steps)
Expand Down
87 changes: 66 additions & 21 deletions tests/models/test_implicit_als.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@
from rectools.dataset import Dataset, DenseFeatures, IdMap, Interactions, SparseFeatures
from rectools.exceptions import NotFittedError
from rectools.models import ImplicitALSWrapperModel
from rectools.models.implicit_als import AnyAlternatingLeastSquares, GPUAlternatingLeastSquares
from rectools.models.implicit_als import (
AnyAlternatingLeastSquares,
GPUAlternatingLeastSquares,
get_items_vectors,
get_users_vectors,
)
from rectools.models.utils import recommend_from_scores

from .data import DATASET
Expand Down Expand Up @@ -56,6 +61,29 @@ def _init_model_factors_inplace(model: AnyAlternatingLeastSquares, dataset: Data
def dataset(self) -> Dataset:
return DATASET

@pytest.fixture
def dataset_w_features(self) -> Dataset:
user_id_map = IdMap.from_values(["u1", "u2", "u3"])
item_id_map = IdMap.from_values(["i1", "i2", "i3"])
interactions_df = pd.DataFrame(
[
["u1", "i1", 0.1, "2021-09-09"],
["u2", "i1", 0.1, "2021-09-09"],
["u2", "i2", 0.5, "2021-09-05"],
["u2", "i3", 0.2, "2021-09-05"],
["u1", "i3", 0.2, "2021-09-05"],
["u3", "i1", 0.2, "2021-09-05"],
],
columns=[Columns.User, Columns.Item, Columns.Weight, Columns.Datetime],
)
interactions = Interactions.from_raw(interactions_df, user_id_map, item_id_map)
user_features_df = pd.DataFrame({"id": ["u1", "u2", "u3"], "f1": [0.3, 0.4, 0.5]})
user_features = DenseFeatures.from_dataframe(user_features_df, user_id_map)
item_features_df = pd.DataFrame({"id": ["i1", "i1"], "feature": ["f1", "f2"], "value": [2.1, 100]})
item_features = SparseFeatures.from_flatten(item_features_df, item_id_map)
dataset = Dataset(user_id_map, item_id_map, interactions, user_features, item_features)
return dataset

@pytest.mark.parametrize(
"filter_viewed,expected",
(
Expand Down Expand Up @@ -198,26 +226,10 @@ def test_with_whitelist(
),
),
)
def test_happy_path_with_features(self, fit_features_together: bool, expected: pd.DataFrame, use_gpu: bool) -> None:
user_id_map = IdMap.from_values(["u1", "u2", "u3"])
item_id_map = IdMap.from_values(["i1", "i2", "i3"])
interactions_df = pd.DataFrame(
[
["u1", "i1", 0.1, "2021-09-09"],
["u2", "i1", 0.1, "2021-09-09"],
["u2", "i2", 0.5, "2021-09-05"],
["u2", "i3", 0.2, "2021-09-05"],
["u1", "i3", 0.2, "2021-09-05"],
["u3", "i1", 0.2, "2021-09-05"],
],
columns=[Columns.User, Columns.Item, Columns.Weight, Columns.Datetime],
)
interactions = Interactions.from_raw(interactions_df, user_id_map, item_id_map)
user_features_df = pd.DataFrame({"id": ["u1", "u2", "u3"], "f1": [0.3, 0.4, 0.5]})
user_features = DenseFeatures.from_dataframe(user_features_df, user_id_map)
item_features_df = pd.DataFrame({"id": ["i1", "i1"], "feature": ["f1", "f2"], "value": [2.1, 100]})
item_features = SparseFeatures.from_flatten(item_features_df, item_id_map)
dataset = Dataset(user_id_map, item_id_map, interactions, user_features, item_features)
def test_happy_path_with_features(
self, fit_features_together: bool, expected: pd.DataFrame, use_gpu: bool, dataset_w_features: Dataset
) -> None:
dataset = dataset_w_features

# In case of big number of iterations there are differences between CPU and GPU results
base_model = AlternatingLeastSquares(factors=32, num_threads=2, use_gpu=use_gpu)
Expand Down Expand Up @@ -346,3 +358,36 @@ def test_i2i_with_warm_and_cold_items(self, use_gpu: bool, dataset: Dataset) ->
dataset=dataset,
k=2,
)

# TODO: move this test to `partial_fit` method when implemented
@pytest.mark.parametrize("fit_features_together", (False, True))
@pytest.mark.parametrize("use_features_in_dataset", (False, True))
def test_per_epoch_fitting_consistent_with_regular_fitting(
self,
dataset: Dataset,
dataset_w_features: Dataset,
fit_features_together: bool,
use_features_in_dataset: bool,
use_gpu: bool,
) -> None:
if use_features_in_dataset:
dataset = dataset_w_features

iterations = 20

base_model_1 = AlternatingLeastSquares(
factors=2, num_threads=2, iterations=iterations, random_state=32, use_gpu=use_gpu
)
model_1 = ImplicitALSWrapperModel(model=base_model_1, fit_features_together=fit_features_together)
model_1.fit(dataset)

base_model_2 = AlternatingLeastSquares(
factors=2, num_threads=2, iterations=iterations, random_state=32, use_gpu=use_gpu
)
model_2 = ImplicitALSWrapperModel(model=base_model_2, fit_features_together=fit_features_together)
for _ in range(iterations):
model_2.fit(dataset, epochs=1)
model_2._model = deepcopy(model_2.model) # pylint: disable=protected-access

assert np.allclose(get_users_vectors(model_1.model), get_users_vectors(model_2.model))
assert np.allclose(get_items_vectors(model_1.model), get_items_vectors(model_2.model))
Loading