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
136 changes: 86 additions & 50 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,20 @@ 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
if isinstance(model, GPUAlternatingLeastSquares): # pragma: no cover
# 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()
model.user_factors = implicit.gpu.Matrix(user_factors)
model.item_factors = implicit.gpu.Matrix(item_factors)
else:
model.user_factors = model.user_factors[:, : model.factors]
feldlime marked this conversation as resolved.
Show resolved Hide resolved
model.item_factors = model.item_factors[:, : model.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 Down Expand Up @@ -237,14 +256,8 @@ def _init_latent_factors_cpu(
) -> tp.Tuple[np.ndarray, np.ndarray]:
"""Logic is copied and pasted from original implicit library code"""
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


Expand All @@ -253,18 +266,12 @@ def _init_latent_factors_gpu(
) -> tp.Tuple[np.ndarray, np.ndarray]: # pragma: no cover
"""Logic is copied and pasted from original implicit library code"""
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 +280,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 +301,58 @@ 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(
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(
feldlime marked this conversation as resolved.
Show resolved Hide resolved
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"""
# Prepare explicit factors
user_explicit_factors: np.ndarray
if user_features is None:
Expand All @@ -314,10 +374,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 +394,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 +405,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 +419,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 +451,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 +471,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_epochs(
feldlime marked this conversation as resolved.
Show resolved Hide resolved
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