Back to Classical ML
AI/ML · Classical ML

Content-Based Filtering

Describe every item as a vector of its own properties — the words in an article, the tags on a film, an embedding of a product photo — then describe a user as the average of the items they liked. To recommend, find the unseen items whose vector points the same way as the user’s. Nothing here looks at what other people did, which is its defining strength (a brand-new item can be recommended the moment it is described) and its defining weakness (it can only ever recommend more of what the user already showed they like). The method is a chain of three representation decisions — how items become vectors, how likes become a profile, how similarity becomes a score — and the third is where most implementations are quietly wrong. Cosine is right for TF-IDF because it divides out document length; Jaccard is right for binary tag sets because it penalises size imbalance; a raw dot product is right only when vector magnitude is meant to carry popularity or confidence. Choosing among them is a claim about the data, not a tuning knob.

SupervisedUnsupervisedRankingClassification

Two halves with different supervision. Building the item vectors (TF-IDF, tag one-hot) uses no labels at all; building the user profile uses the user’s own feedback as the label. It is also a one-class-versus-rest classifier in disguise — the profile is a linear scorer for "will this user like it" — which is why classification is listed beside ranking, although the output that matters is always an ordered list.

Also known as: Content filtering, Content-based recommendation, Rocchio

Intuition

What it actually does

Describe every item as a vector of its own properties — the words in an article, the tags on a film, an embedding of a product photo — then describe a user as the average of the items they liked. To recommend, find the unseen items whose vector points the same way as the user’s. Nothing here looks at what other people did, which is its defining strength (a brand-new item can be recommended the moment it is described) and its defining weakness (it can only ever recommend more of what the user already showed they like). The method is a chain of three representation decisions — how items become vectors, how likes become a profile, how similarity becomes a score — and the third is where most implementations are quietly wrong. Cosine is right for TF-IDF because it divides out document length; Jaccard is right for binary tag sets because it penalises size imbalance; a raw dot product is right only when vector magnitude is meant to carry popularity or confidence. Choosing among them is a claim about the data, not a tuning knob.

Assumptions it makes
  • A user’s future preferences resemble the properties of items they liked before — the smoothness assumption, and the one that produces filter bubbles when it holds too strongly
  • Item content features capture what drives preference; taste not expressed in the features is invisible to the model
  • The chosen similarity matches the representation: cosine for TF-IDF and embeddings, Jaccard for sets, dot product only if magnitude is meaningful
  • A single profile vector adequately summarises the user, which fails for people with several unrelated interests
  • Past feedback is a fair proxy for current taste, so stale interactions need decay rather than equal weight
Objective

Loss function

Choosing the profile is a least-squares problem with a closed-form answer: the point that minimizes the weighted squared distance to everything the user liked is the weighted mean of those items — the centroid, which is exactly what Rocchio’s relevance-feedback update computes. This is the one place the model is "trained", and it is trained per user, in one pass, with no iteration. The ranking step then scores unseen items by cosine similarity to that centroid, and the two pieces are consistent for a reason worth knowing: for unit-length vectors, squared Euclidean distance equals two minus two times the cosine, so minimizing squared distance to the liked items and maximizing total cosine to them select the same profile direction. If the profile weights are instead learned — a logistic or pairwise ranking loss over liked and not-liked items — the model becomes a linear classifier over content features, and the centroid is only the initialisation.

the user profile, a point in the same space as the item vectors
the set of items the user liked, or engaged with positively
the content vector of item i — TF-IDF weights, a one-hot tag vector, or an embedding
the weight of that signal: a mean-centred rating, an implicit-feedback strength, or an exponential time decay
Optimization

How it is minimized

Closed-form centroid (Rocchio) per user, then a similarity-ranked scan — no iterative fit

the previous profile or the initial query; alpha controls how much of it survives an update
Rocchio weights on the old profile, the liked centroid, and the disliked centroid; classic IR defaults are around 1, 0.75, and 0.15
the items the user explicitly rejected; the negative term pushes the profile away from them
the relevance score of candidate item j — cosine similarity between profile and item
the identity that makes cosine and Euclidean rank-equivalent once both vectors are L2-normalised

There is nothing to iterate, so the engineering questions are all about the similarity function, and it is the part of this entry worth reading slowly. Cosine on TF-IDF: the dot product divided by both norms removes vector length, so a 5,000-word article does not outrank a 300-word one merely for containing more terms. A raw dot product on the same vectors ranks by document length; Euclidean distance on them ranks short documents as near everything and long documents as far from everything, neither of which has anything to do with topic. Jaccard on binary tag sets: intersection over union, which penalises size imbalance — a film with 40 tags overlapping 4 of a user’s 5 tags scores low, as it should, whereas a plain overlap count (the dot product on binary vectors) would crown the most heavily tagged item for every user. Cosine on binary vectors sits between the two, dividing by the geometric mean of the set sizes rather than the union. Dot product, deliberately: when embeddings are trained so that norm encodes popularity or confidence — as in two-tower retrieval — the magnitude is signal, and normalising it away throws it out; the price is that popular items dominate unless that is the intent. The identity ‖a−b‖² = 2 − 2cos(a,b) holds for unit vectors, so once items and profile are L2-normalised, cosine, inner product, and Euclidean distance all produce the same ranking and the choice collapses to which one your index supports. Everything else — Rocchio weights, time decay, MMR re-ranking — tunes the profile, while the metric decides what "alike" means.

Hyperparameters
  • item representation

    The real model. TF-IDF for text, multi-hot for tags and categories, a pretrained embedding for images, audio, or short text; and the similarity must match it

  • similarity function

    Cosine for TF-IDF and dense embeddings, Jaccard for binary sets, dot product only when magnitude is intentional signal. Swapping it without changing the representation changes what is recommended

  • Rocchio weights (alpha, beta, gamma)

    How much the previous profile, the liked centroid, and the disliked centroid contribute. A large gamma reacts fast to a rejection and risks over-correcting on a single dislike (alpha 1, beta 0.75, gamma 0.15 as the classical IR starting point)

  • feedback weighting

    Mean-centre ratings so that a below-average rating becomes a negative weight, or use implicit-feedback strengths. Unweighted means treat a skim and a binge identically

  • time-decay half-life

    Recency weighting on liked items so the profile tracks drifting taste rather than the whole history equally (30 to 180 days, domain-dependent)

  • TF-IDF variant

    Sublinear tf (1 + log count), smoothed idf, and stop-word handling. Smoothing stops a rare term from getting an unbounded weight on one document

  • diversity re-rank (MMR lambda)

    Trades relevance against redundancy among the returned items; the direct countermeasure to over-specialisation (0.5 to 0.9)

Convergence & cost

No iteration, so no convergence — the centroid is exact and the score is a deterministic scan. What replaces a convergence guarantee is a list of structural failure modes, which are the actual content. Over-specialisation: the objective rewards similarity to what the user already liked and nothing rewards novelty, so recommendations contract into a filter bubble and the profile becomes a self-confirming loop. The single-centroid problem: a user who likes both jazz and cooking gets a profile midway between the two, which resembles neither; the fix is several centroids per user or a max over liked items, at which point this is k-nearest neighbours again. Feature ceiling: it can recommend only along properties it can see, so quality is bounded by how well the item vectors capture what makes a user like something — taste in prose style, say, or in "vibe", is not in a bag of words. And the cold-start asymmetry is real but one-sided: a new item is recommendable immediately, while a new user with no history has no profile and no recommendations.

Item vectors: O(nnz) to build TF-IDF over the catalogue, once. Profile: O(nnz of the liked items) per user. Scoring the full catalogue: one sparse matrix-vector product, O(nnz of the catalogue); for large catalogues use an approximate inner-product index so scoring is sublinear. Memory: the sparse item matrix plus one dense or sparse profile per user. For text, vocabulary size drives dimension — often 10^5 to 10^6 — which is why sparse storage is not optional.

Applications

How, where, and why it is used

Time-Series Forecasting

Not applicable

It ranks a catalogue of items against a profile; it predicts nothing about future values of a series and has no notion of temporal dynamics. Time appears only as a decay weight on past feedback, which is recency weighting rather than forecasting.

Anomaly Detection

Not applicable

Low maximum similarity to a user’s history or to a reference set can be read as a novelty signal, but that is nearest-neighbour novelty detection under another name, and k-nearest neighbours covers it properly. The model’s own purpose, ordering items by relevance, is not an outlier decision, so no honest fit is claimed here.

Optimization

Not applicable

The centroid is a closed-form least-squares answer and the ranking is a sort; there is no constrained decision problem, no search over a feasible set, and nothing to optimize beyond scoring. A diversity-aware re-rank (MMR) is a greedy selection heuristic layered on top, not a use of the model as an optimizer.

Also applied in

Recommendation & Ranking

Primary fit
How

Each item is described by its own content — text, tags, metadata, or an embedding — and each user by the weighted mean of the items they engaged with. Candidates are scored by similarity to the profile, filtered for already-seen items, and the top k are returned. Used alone it is a complete recommender; used as one retrieval source it supplies exactly the items a collaborative model cannot, namely ones nobody has interacted with yet.

Where
  • News and article feeds, where an item is stale before enough interactions accumulate to learn it collaboratively
  • Job, real-estate, and marketplace listings, where each listing is new, short-lived, and richly described
  • Cold-start items in a hybrid system — a content score takes over until interaction data exists, then hands over to the collaborative signal
  • Related-item shelves ("more like this"), which need no user profile at all — the item vector is the query
Why

It is the right first model when items are new faster than interactions arrive, when metadata is rich, and when an explanation is required — "because it shares these properties with what you liked" is the model’s real mechanism. It is the wrong sole model when taste is not captured by content features, when interaction data is plentiful (collaborative methods then find structure no feature set encodes), or when serendipity matters, because the objective rewards similarity to the known and nothing rewards the surprising. In practice it is a candidate generator and a cold-start fallback inside a hybrid, not the whole system.

Featurization
  • Cosine similarity on TF-IDF for text: length normalisation keeps long descriptions from dominating, which a raw dot product or Euclidean distance would not
  • Jaccard on binary tag or category sets: intersection over union penalises size imbalance, so a heavily tagged item is not preferred for overlapping a few tags
  • Dot product only for embeddings trained so norm means popularity or confidence; otherwise L2-normalise so dot product, cosine, and Euclidean give the same ranking
  • Weight liked items by mean-centred rating or implicit strength, with exponential time decay, and subtract a down-weighted centroid of rejected items
  • Several profile centroids per user (clustered likes) for users with distinct interests, rather than one blurred average
Evaluation

Recall@k and NDCG@k on a temporal split with the profile built only from interactions before the cut. Report intra-list diversity, catalogue coverage, and novelty beside accuracy, since a content model can post excellent recall by recommending near-duplicates of what the user already has. Cold-item recall is the number that justifies the model: score it separately on items with zero interactions.

Pitfalls
  • Filter bubble: recommending only near-neighbours of past likes, with no exploration, shrinks the user’s exposure over time and starves the feedback that would correct the profile
  • Metric-representation mismatch — Euclidean distance on raw TF-IDF, or a dot product on unnormalised vectors — silently ranks by document length
  • A single blended centroid for a multi-interest user, which ranks items resembling none of their interests highest
  • Recommending items the user already has: seen items are always the most similar to the profile and must be masked explicitly
  • Refitting TF-IDF as the catalogue grows, which changes every stored vector; the idf statistics are part of the model and need versioning

Natural Language

Viable
How

Documents become TF-IDF or sentence-embedding vectors; a query, a reading history, or a set of relevant documents becomes a profile; ranking is cosine similarity. This is the original home of the method: Rocchio’s 1971 relevance-feedback update for the SMART retrieval system moves a query vector toward documents a searcher marked relevant and away from those marked irrelevant.

Where
  • Related-article and "more like this" retrieval over documents and knowledge bases
  • Relevance feedback in search: refining a query from a few judged results
  • Near-duplicate and plagiarism screening by thresholding cosine similarity between document vectors
  • Matching résumés to job descriptions, or support tickets to knowledge-base articles, as a transparent baseline before a learned ranker
Why

For short-horizon, keyword-anchored matching it is hard to beat for cost and transparency: no training, deterministic, inspectable term by term. It loses wherever meaning differs from vocabulary — synonyms, paraphrase, and negation are invisible to a bag of words, which is what dense embeddings (a word2vec-style model, or a modern sentence encoder) repair at the cost of interpretability. The usual upgrade is not to abandon the method but to swap the item representation and keep the profile-and-cosine machinery unchanged.

Featurization
  • Lowercase, strip stop words, and apply sublinear tf with a smoothed idf; consider bigrams for phrases like "machine learning"
  • L2-normalise every document vector so cosine and inner-product search are identical and length drops out
  • Where vocabulary mismatch dominates, replace TF-IDF with a dense embedding and keep cosine as the similarity
Evaluation

Mean average precision and NDCG against human relevance judgements, plus a vocabulary-mismatch slice (queries whose relevant documents share few terms with them) to expose where bag-of-words fails.

Pitfalls
  • Vocabulary mismatch: a relevant document using different words scores near zero, with no warning
  • Idf computed on a corpus unlike the serving corpus, which mis-weights domain terms
  • Stop-word and stemming choices baked into the index; changing them later invalidates every stored vector
Deployment

What it costs to run

Training

Effectively nothing: TF-IDF statistics are one pass over the catalogue, and a profile is a weighted sum computed per user on demand or on each feedback event. An embedding-based variant pays for the encoder once, offline. There is no training run to schedule.

Inference

One sparse matrix-vector product per user, or an approximate inner-product search over L2-normalised item vectors, which is why item vectors are normalised at index time so that online scoring needs no norm computation. Latency scales with catalogue nonzeros for an exact scan and is sublinear with an ANN index. Per-user profiles are small enough to cache and update incrementally on each interaction.

Retraining

Item vectors are recomputed when new items arrive, which is incremental for TF-IDF only if the idf statistics are frozen between periodic full rebuilds. Profiles update on every feedback event. If the representation changes (a new embedding model, a new vocabulary), every stored item vector and every profile must be invalidated and rebuilt together, because mixing vector spaces gives meaningless similarities.

Drift & monitoring
  • Track intra-list diversity and catalogue coverage over time — a falling trend is the filter bubble forming, visible long before engagement metrics move
  • Monitor the share of recommendations drawn from the user’s own history neighbourhood against explore slots
  • Watch the idf and vocabulary statistics as versioned artefacts; a vocabulary shift silently re-scales every similarity
  • Compare cold-item click-through to warm-item click-through to confirm the content signal is earning its place beside a collaborative model
  • Alert on profiles whose norm collapses toward zero, which means feedback weights cancelled out and the user has effectively no profile
Production gotchas
  • Mask already-seen and already-purchased items before ranking; they are the highest-scoring candidates and the least useful
  • A new user has no profile at all — decide the fallback (popularity, onboarding picks) explicitly instead of returning an empty or random list
  • Normalise item vectors at index time and persist the idf table with them; recomputing either at query time is the commonest latency bug
  • Do not mix similarity functions between indexing and serving — an index built for inner product on normalised vectors queried with raw Euclidean distance returns a different ordering
  • Explanations are cheap and tempting: the top contributing terms to the dot product are the real reason, so surface those instead of a post-hoc story
Trade-offs

Pros and cons, in context

Strengths
  • New items are recommendable the instant they are described

    Decisive for news, listings, and any catalogue that turns over faster than interactions accumulate; collaborative filtering cannot score an item nobody has touched. Worth little where the catalogue is stable and interaction data is plentiful, where a collaborative signal is strictly richer.

  • No dependence on other users

    It works for a single user in isolation, resists popularity bias and shilling attacks, and needs no cross-user data, which is a privacy advantage. The cost is that it cannot learn from the crowd, so it never discovers that fans of A also like B when A and B share no features.

  • Transparent and explainable by construction

    The score decomposes into per-term or per-tag contributions, so "recommended because it shares these properties with items you liked" is the actual mechanism. Valuable in regulated or review-heavy settings; less so where the features themselves are an opaque embedding.

  • Closed-form, cheap, and incrementally updatable

    A profile is a weighted mean that updates in one vector operation per feedback event, so there is nothing to retrain or schedule. It is the right baseline to beat before introducing a learned ranker, and often the baseline that survives.

Limitations
  • Over-specialisation and the filter bubble

    The objective rewards similarity to past likes and nothing rewards novelty, so recommendations narrow over time and the feedback needed to correct the profile dries up. Mitigated by MMR diversity re-ranking and explicit exploration slots, never by tuning the similarity.

  • Bounded by the quality of the item features

    It can only recommend along dimensions it can see. If preference depends on style, quality, or social signals absent from the content, accuracy plateaus no matter how the profile is weighted, and a collaborative model will beat it once interaction data exists.

  • New users have no profile

    The cold-start advantage is one-sided: a new item is fine, a new user is not. A hybrid with an onboarding step or a popularity fallback is required, so the model cannot be the whole system for acquisition-heavy products.

  • A single centroid blurs multi-interest users

    Averaging likes from unrelated interests produces a profile resembling neither. The fix — several centroids, or scoring against each liked item and taking the max — moves the model toward k-nearest neighbours and costs more per query.

Implementation

Intuitive, idiomatic, optimized

The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.

1. Intuitive

The math, transcribed literally. No dependencies.

"""Content-based filtering - the item vectors, the profile, and the score, transcribed.

There is no fit step in the neural sense. Item vectors are TF-IDF weights, the
user profile is the weighted mean of the liked items (the centroid), and the
score is the cosine between the profile and every unseen item.
"""

import math


def tokenize(text):
    return text.lower().split()


def tfidf_vectors(documents):
    """One dict per document mapping term -> (1 + log tf) * idf."""
    n_docs = len(documents)
    token_lists = [tokenize(document) for document in documents]

    document_frequency = {}
    for tokens in token_lists:
        for term in set(tokens):
            document_frequency[term] = document_frequency.get(term, 0) + 1

    vectors = []
    for tokens in token_lists:
        counts = {}
        for term in tokens:
            counts[term] = counts.get(term, 0) + 1

        vector = {}
        for term, count in counts.items():
            idf = math.log((1 + n_docs) / (1 + document_frequency[term])) + 1.0
            vector[term] = (1.0 + math.log(count)) * idf
        vectors.append(vector)
    return vectors


def build_profile(vectors, liked_items, weights):
    """Weighted mean of the liked item vectors - the closed-form minimizer."""
    profile = {}
    total_weight = 0.0
    for position in range(len(liked_items)):
        weight = weights[position]
        total_weight += weight
        for term, value in vectors[liked_items[position]].items():
            profile[term] = profile.get(term, 0.0) + weight * value

    if total_weight == 0.0:
        raise ValueError("liked items carry no weight")
    for term in profile:
        profile[term] = profile[term] / total_weight
    return profile


def norm(vector):
    total = 0.0
    for value in vector.values():
        total += value * value
    return math.sqrt(total)


def cosine(profile, item):
    dot = 0.0
    for term, value in item.items():
        if term in profile:
            dot += profile[term] * value

    denominator = norm(profile) * norm(item)
    if denominator == 0.0:
        return 0.0
    return dot / denominator


def recommend(vectors, liked_items, weights, k=5):
    profile = build_profile(vectors, liked_items, weights)

    scored = []
    for item in range(len(vectors)):
        if item in liked_items:
            continue
        scored.append((cosine(profile, vectors[item]), item))

    # Full sort of every candidate to keep k of them - correct, and wasteful.
    scored.sort(reverse=True)
    return scored[:k]

O(nnz) to vectorize, then per user: O(nnz of liked items) for the profile plus O(nnz of the catalogue) to score, with both norms recomputed for every pair, in interpreter loops.

2. IdiomaticNumPy / SciPy sparse

How this language actually wants it written.

"""Content-based filtering - typed, validated, sparse, rows normalised once."""

from collections.abc import Sequence
from dataclasses import dataclass

import numpy as np
from numpy.typing import NDArray
from scipy import sparse


class ColdStartError(ValueError):
    """Raised when a user has no usable feedback, so no profile exists."""


@dataclass(frozen=True)
class ItemIndex:
    """The catalogue as a sparse matrix whose rows are unit vectors.

    Rows are L2-normalised once, here, because cosine similarity is then a
    plain dot product - length drops out of the ranking and the per-item
    denominator never has to be recomputed at query time.
    """

    matrix: sparse.csr_matrix      # (n_items, n_terms), rows have norm 1
    item_ids: tuple[str, ...]


def build_index(counts: sparse.csr_matrix, item_ids: Sequence[str]) -> ItemIndex:
    """TF-IDF with sublinear tf and smoothed idf, then L2 row normalisation."""
    n_items, n_terms = counts.shape
    if n_items != len(item_ids):
        raise ValueError(f"{n_items} rows but {len(item_ids)} item ids")
    if n_items == 0:
        raise ValueError("the catalogue is empty")

    counts = counts.tocsr().astype(np.float64)
    counts.sum_duplicates()

    document_frequency = np.bincount(counts.indices, minlength=n_terms)
    idf = np.log((1.0 + n_items) / (1.0 + document_frequency)) + 1.0

    counts.data = 1.0 + np.log(counts.data)               # sublinear tf
    weighted = sparse.csr_matrix(counts @ sparse.diags(idf))

    norms = np.sqrt(np.asarray(weighted.multiply(weighted).sum(axis=1)).ravel())
    norms[norms == 0.0] = 1.0                              # empty documents stay zero
    normalised = sparse.csr_matrix(sparse.diags(1.0 / norms) @ weighted)
    return ItemIndex(matrix=normalised, item_ids=tuple(item_ids))


def build_profile(
    index: ItemIndex,
    liked_rows: NDArray[np.int64],
    ratings: NDArray[np.float64],
    neutral_rating: float,
) -> NDArray[np.float64]:
    """Mean-centred weighted centroid, returned as a unit vector.

    A rating below neutral_rating gets a negative weight, so a dislike pushes
    the profile away from an item instead of being ignored (Rocchio's gamma).
    """
    if liked_rows.size == 0:
        raise ColdStartError("no interactions to build a profile from")
    if liked_rows.shape != ratings.shape:
        raise ValueError("liked_rows and ratings must align")

    weights = ratings - neutral_rating
    profile = np.asarray(index.matrix[liked_rows].T @ weights).ravel()

    length = np.linalg.norm(profile)
    if length == 0.0:
        raise ColdStartError("feedback weights cancel out; profile has no direction")
    return profile / length


def recommend(
    index: ItemIndex,
    profile: NDArray[np.float64],
    seen_rows: NDArray[np.int64],
    k: int,
) -> list[tuple[str, float]]:
    """Top-k unseen items by cosine, which is a dot product on unit rows."""
    n_items = index.matrix.shape[0]
    if not 1 <= k <= n_items:
        raise ValueError(f"k must lie in [1, {n_items}], got {k}")

    scores = index.matrix @ profile                       # one sparse mat-vec
    scores[seen_rows] = -np.inf                           # never re-recommend

    # argpartition is O(n) selection; only the k survivors are then ordered.
    top = np.argpartition(scores, n_items - k)[n_items - k:]
    top = top[np.argsort(scores[top])[::-1]]
    return [(index.item_ids[row], float(scores[row])) for row in top]

What changed: Three changes beyond moving to sparse arrays. Item vectors are L2-normalised once, at index time, so cosine collapses to a dot product and the per-pair norm computation the previous stage repeated for every item disappears. The full sort becomes argpartition — selecting k of n is a linear-time problem and ordering all of them was the algorithmic mistake. And the profile now takes mean-centred ratings, so a below-neutral rating is a negative weight and a dislike repels instead of being silently dropped. Structurally, the cold-start case is a typed exception rather than a division by zero, the seen-item mask is explicit, and malformed shapes fail at the boundary.

Explicit type hints on every public signatureDataclasses or NamedTuples over ad-hoc dictsRaise specific exceptions, never bare exceptGuard clauses over nested conditionals

O(nnz) one-time indexing; per user O(nnz of liked rows) for the profile, O(nnz of catalogue) for one sparse mat-vec, and O(n) selection instead of O(n log n) sorting.

3. OptimizedSciPy sparse / NumPy

Faster, and the justification for each change.

"""Content-based filtering - batched users, one sparse product, float32."""

import numpy as np
from numpy.typing import NDArray
from scipy import sparse


class BatchRecommender:
    """Scores a block of users against the catalogue with sparse products.

    Items are unit rows (cosine == dot product). User profiles do not need
    normalising either: dividing a user's scores by a constant leaves their
    ranking untouched, so that norm is never computed on the hot path.
    """

    def __init__(self, unit_items: sparse.csr_matrix, k: int = 10) -> None:
        if not 1 <= k <= unit_items.shape[0]:
            raise ValueError(f"k must lie in [1, {unit_items.shape[0]}], got {k}")
        # One dtype, contiguous CSR: scipy hands the buffers to BLAS-style kernels
        # without an internal conversion, and float32 halves memory traffic.
        self._items = unit_items.astype(np.float32).tocsr()
        self._items_t = self._items.T.tocsr()          # (n_terms, n_items), built once
        self._k = k

    def recommend(
        self,
        feedback: sparse.csr_matrix,     # (n_users, n_items) mean-centred weights
        chunk: int = 2048,
    ) -> NDArray[np.int64]:
        """Top-k item rows for every user, best first."""
        n_users = feedback.shape[0]
        n_items = self._items.shape[0]
        k = self._k

        out = np.empty((n_users, k), dtype=np.int64)          # allocated once

        # Chunking bounds the dense score block at (chunk, n_items) rather than
        # (n_users, n_items), which is what makes a million-user batch possible.
        for start in range(0, n_users, chunk):
            stop = min(start + chunk, n_users)
            weights = feedback[start:stop]

            # Profiles for the whole block in one sparse-sparse product, then
            # scores in a second: (chunk, items) @ (items, terms) @ (terms, items).
            profiles = weights @ self._items
            scores = (profiles @ self._items_t).toarray()

            # Mask seen items in place using the feedback sparsity pattern.
            rows, columns = weights.nonzero()
            scores[rows, columns] = -np.inf

            partitioned = np.argpartition(scores, n_items - k, axis=1)[:, n_items - k:]
            chosen = np.take_along_axis(scores, partitioned, axis=1)
            order = np.argsort(-chosen, axis=1)               # sorts k, not n
            out[start:stop] = np.take_along_axis(partitioned, order, axis=1)

        return out

What changed: The loop over users becomes two sparse products per chunk: weights times the item matrix yields every profile in the block at once, and profiles times the transposed item matrix yields every score. That replaces one Python-level mat-vec per user with a handful of calls per few thousand users, and the transpose is built once rather than per query. Two changes remove arithmetic rather than reorganise it: the user-profile norm is dropped entirely, because dividing a row of scores by a positive constant cannot change its ranking, and everything is float32 on contiguous CSR buffers. Chunking bounds the dense score block so peak memory is independent of the number of users. The honest caveat is that this is still an exact scan of the catalogue per user; past a few million items the answer is an approximate inner-product index, which is why the item rows were normalised in the first place.

  • Vectorize to NumPy/BLAS (@, np.dot, einsum)

    Profiles for a whole chunk of users come from one sparse product and their scores from a second, instead of a mat-vec and a norm per user.

    Trade-off: The score block is dense (chunk by n_items), so a sparse-friendly formulation temporarily becomes memory-hungry and the chunk size has to be chosen against catalogue size.

  • Pre-allocate output arrays and use in-place operations

    The result array is allocated once for all users, and seen-item masking writes negative infinity directly into the score block instead of building a masked copy.

    Trade-off: In-place masking destroys the true scores of seen items, so any caller wanting an explanation or a debug score for them must recompute it.

  • Ensure contiguous memory layout and a single dtype

    Float32 CSR with a prebuilt transposed copy halves memory traffic and avoids dtype conversions inside the product.

    Trade-off: Float32 accumulates rounding error across long sparse dot products, and ties near the top-k boundary can be broken differently from the float64 version; the transposed copy doubles item-matrix memory.

  • Batch work to amortize interpreter overhead

    Chunking over users turns thousands of Python-level calls into a few BLAS-style calls and bounds peak memory independently of batch size.

    Trade-off: Chunk size is a tuning parameter with no universal default — too small and dispatch dominates, too large and the score block spills out of cache.

O(nnz of the catalogue) per user in sparse kernels, plus O(n) selection per user, with memory bounded by the chunk. Illustrative, not a measured benchmark.