From 88b46e7d1f679742766cb8f936217821783ab5fd Mon Sep 17 00:00:00 2001 From: Artsem Lebiadzevich Date: Sun, 25 Oct 2026 22:11:50 +0300 Subject: [PATCH] Don't pad recommendations when N is larger than the number of items The CPU topk function allocated k columns even if there were fewer items, so recommend/similar_items/similar_users with N > n_items returned the remaining slots filled with itemid 0 and a score of 0. ranking_metrics_at_k counted those padded entries as hits (precision > 1 when a user liked item 0), and read past the end of the returned ids when the model returned fewer than K items. Limit k to the number of items in topk, and only look at the returned items in ranking_metrics_at_k. --- implicit/cpu/topk.pyx | 4 ++++ implicit/evaluation.pyx | 8 ++++++-- tests/evaluation_test.py | 36 +++++++++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/implicit/cpu/topk.pyx b/implicit/cpu/topk.pyx index 05dca11d..585739a8 100644 --- a/implicit/cpu/topk.pyx +++ b/implicit/cpu/topk.pyx @@ -17,6 +17,10 @@ def topk(items, query, int k, item_norms=None, filter_query_items=None, filter_i query = query.reshape((1, len(query))) cdef int query_rows = query.shape[0] + + # we can't return more results than there are items + k = min(k, items.shape[0]) + indices = np.zeros((query_rows, k), dtype="int32") distances = np.zeros((query_rows, k), dtype=query.dtype) diff --git a/implicit/evaluation.pyx b/implicit/evaluation.pyx index 29f3fd6d..0d5b99b6 100644 --- a/implicit/evaluation.pyx +++ b/implicit/evaluation.pyx @@ -399,7 +399,7 @@ def ranking_metrics_at_k(model, train_user_items, test_user_items, int K=10, test_user_items = test_user_items.tocsr() cdef int users = test_user_items.shape[0], items = test_user_items.shape[1] - cdef int u, i, batch_idx + cdef int u, i, batch_idx, num_recs # precision cdef double relevant = 0, pr_div = 0, total = 0 # map @@ -434,6 +434,10 @@ def ranking_metrics_at_k(model, train_user_items, test_user_items, int K=10, ids, _ = model.recommend(batch, train_user_items[np.asarray(batch)], N=K) start_idx += batch_size + # the model can return fewer than K items (for instance if K is larger than + # the number of items), so only look at the items that were actually returned + num_recs = min(K, ids.shape[1]) + with nogil: for batch_idx in range(len(batch)): u = batch[batch_idx] @@ -450,7 +454,7 @@ def ranking_metrics_at_k(model, train_user_items, test_user_items, int K=10, num_pos_items = likes.size() num_neg_items = items - num_pos_items - for i in range(K): + for i in range(num_recs): if likes.find(ids[batch_idx, i]) != likes.end(): relevant += 1 hit += 1 diff --git a/tests/evaluation_test.py b/tests/evaluation_test.py index df022618..426cbb8d 100644 --- a/tests/evaluation_test.py +++ b/tests/evaluation_test.py @@ -3,11 +3,20 @@ from scipy.sparse import csr_matrix, random import implicit -from implicit.evaluation import leave_k_out_split, precision_at_k, train_test_split +from implicit.als import AlternatingLeastSquares +from implicit.bpr import BayesianPersonalizedRanking +from implicit.evaluation import ( + leave_k_out_split, + precision_at_k, + ranking_metrics_at_k, + train_test_split, +) +from implicit.lmf import LogisticMatrixFactorization pytest.importorskip("h5py") from implicit.datasets.movielens import get_movielens # noqa +from recommender_base_test import get_checker_board # noqa def _get_sample_matrix(): @@ -117,3 +126,28 @@ def test_evaluate_movielens_100k(): model.fit(train) assert precision_at_k(model, train, test) > 0.2 + + +@pytest.mark.parametrize( + "model", + [ + AlternatingLeastSquares(factors=8, use_gpu=False, random_state=42), + BayesianPersonalizedRanking(factors=8, use_gpu=False, random_state=42), + LogisticMatrixFactorization(factors=8, use_gpu=False, random_state=42), + ], +) +def test_ranking_metrics_k_larger_than_number_of_items(model): + user_items = get_checker_board(50) + model.fit(user_items, show_progress=False) + test_user_items = csr_matrix(np.eye(50)) + + # asking for more items than there are in the model should only return the available + # items, and the metrics shouldn't count any item twice + ids, _ = model.recommend(np.arange(50), user_items, N=100) + assert ids.shape == (50, 50) + + all_items = ranking_metrics_at_k(model, user_items, test_user_items, K=50, show_progress=False) + too_many = ranking_metrics_at_k(model, user_items, test_user_items, K=100, show_progress=False) + + assert too_many == pytest.approx(all_items) + assert all(0 <= value <= 1 for value in too_many.values())