Skip to main content

Module 5 — Hybrid approaches

Collaborative filtering knows what people like together but cannot score new items. Content-based filtering scores anything with a description but reinforces the filter bubble. A hybrid recommender lets each family cover the other's blind spot, and the choice of how to combine them is more consequential than any hyperparameter tuning downstream.

This module walks through the four hybridization patterns that show up in practice, then implements two of them on our learning catalog.

The four patterns you will actually meet

Robin Burke's taxonomy names seven, but three cover almost everything you will build, and a fourth deserves attention because it is the modern deep-learning default.

Weighted hybrid. Compute a score with each family, then combine linearly: s(u,i)=αsCF(u,i)+(1α)sCB(u,i)s(u, i) = \alpha\, s_{\text{CF}}(u, i) + (1 - \alpha)\, s_{\text{CB}}(u, i). Simple, transparent, easy to A/B test. The trap is that CF and CB scores live on different scales; a raw sum favors whichever is larger by default. Always normalize both to [0,1][0, 1] or to a rank first.

Switching hybrid. Pick one family per request based on a rule: "if the user has fewer than five interactions, use content; otherwise use CF". This is the pattern I would ship first on a new platform. It is trivially explainable, it does exactly what cold-start theory recommends (module 7), and its failure modes are localized to the rule.

Mixed hybrid. Show both families' top-kk lists side by side, either merged into one list ("here are ten courses: five because people like you took them, five because they cover the topic you just studied") or presented as distinct sections. This is the most common pattern in the wild — YouTube's home page is a mixed hybrid of many recommenders, not a single-model score.

Feature-enriched factorization. Add item and user features to the matrix factorization of module 3 so that the latent factors are informed by content. The LightFM library implements this cleanly. This is the pattern that most often wins on both cold start and long-tail items, and it is the closest classical formulation to what a two-tower model (module 6) does with side features.

Normalization is the whole game of weighted hybrids

The scores of a matrix factorization model are usually in a [0,5][0, 5] range close to the rating scale; the scores of a cosine-similarity content model are in [1,1][-1, 1]; the scores of an ALS-on-implicit are in [0,100][0, 100] or more depending on regularization. Adding them without normalization is not hybridization, it is picking the loudest model.

Two normalizations are the sensible defaults:

  • Min-max on the current candidate set: for the top-200 candidates of this request, rescale each family's scores to [0,1][0, 1]. Cheap, per-request, and it removes global-scale drift.
  • Rank-based: replace the score by its rank in [1,N][1, N] divided by NN. Robust to any monotone transformation of the underlying score, at the cost of losing the score magnitude.

On our catalog, both give recall@10 within a percentage point of each other; the rank-based one is easier to reason about and I default to it.

A working weighted hybrid on the catalog

import numpy as np
import pandas as pd

def rank_norm(scores: np.ndarray) -> np.ndarray:
order = np.argsort(-scores)
ranks = np.empty_like(order)
ranks[order] = np.arange(len(scores))
return 1.0 - ranks / max(len(scores) - 1, 1)

def hybrid_scores(user_id, cf_model, cb_index, candidate_ids, alpha=0.6):
cf = cf_model.score(user_id, candidate_ids) # e.g. p_u . q_i
cb = cb_index.score(user_id, candidate_ids) # user_vec . item_vec
s = alpha * rank_norm(cf) + (1 - alpha) * rank_norm(cb)
return s

candidates = catalog_ids_not_seen_by(user_id=42)
scores = hybrid_scores(42, als_model, content_index, candidates, alpha=0.6)
top10 = [candidates[j] for j in np.argsort(-scores)[:10]]

Two design decisions in this ten-line snippet deserve calling out.

The candidate set is not the whole catalog. Modern engines first retrieve a few hundred plausible candidates (from ANN over embeddings, from popularity in the user's country, from CF top-500) and only then rank them with the hybrid. This is the retrieval-then-ranking pattern module 6 makes explicit.

The exclusion happens at the candidate step. Any item the user has already consumed never enters the ranking, and this is the one place where "already consumed" needs a precise definition: for us, an item is excluded if the user enrolled in it, regardless of completion. Views without enrollment are not enough — the funnel there is too noisy.

Switching hybrid: the cold-start rule that ships

def recommend(user_id, k=10):
n_events = interaction_count(user_id)
if n_events < 5:
return content_top_k(user_id, k)
return als_top_k(user_id, k)

That is not a placeholder; that is the recommender that shipped on many production platforms for years. The number 5 is a hyperparameter to tune against your KPI, but it is almost certainly between 3 and 20 on any catalog like ours. Below it, CF's implicit assumption of "you have a taste profile we can learn" breaks; above it, the CF signal reliably beats content on the head of the distribution.

The switching rule can be refined: switch also on item side ("if the item has fewer than 10 interactions, ignore CF's score for it and use content"). Cold items and cold users are two different problems and the switch can happen on either axis.

Feature-enriched factorization with LightFM

LightFM extends matrix factorization by making both user factors and item factors linear combinations of feature embeddings. If item ii has features {f1,f2}\{f_1, f_2\}, its factor is qi=fef\mathbf{q}_i = \sum_f \mathbf{e}_f. The model learns one embedding per feature, and an item with no interactions gets its factor from its features — which solves item cold start naturally.

from lightfm import LightFM
from lightfm.data import Dataset

items = pd.read_parquet("catalog/items.parquet")
events = pd.read_parquet("catalog/events.parquet")

ds = Dataset()
ds.fit(
users=events["user_id"].unique(),
items=items["item_id"].unique(),
item_features=set(t for row in items["tags"].dropna() for t in row.split("|")),
)

(interactions, weights) = ds.build_interactions(
(row.user_id, row.item_id, row.conf) for row in events.itertuples()
)
item_features = ds.build_item_features(
(row.item_id, row.tags.split("|")) for row in items.itertuples() if row.tags
)

model = LightFM(loss="warp", no_components=64, learning_rate=0.05)
model.fit(interactions, item_features=item_features, sample_weight=weights, epochs=25, num_threads=4)

_, _, _, item_id_map = ds.mapping()[:4]
scores = model.predict(user_ids=42, item_ids=np.arange(len(item_id_map)), item_features=item_features)

Two points of note. The warp loss samples a hard negative per positive and moves the ranking, not the rating — it is a top-kk objective, not RMSE, and this alone is a reason to prefer it on our task. Item features are given as a bag: tags, difficulty level, language. Do not stuff long text into the feature bag; keep the semantic signal in a separate sentence embedding and feed that as a numeric feature vector if you must.

On our catalog, feature-enriched LightFM is the model I would recommend as the strongest single-model baseline before going to two-tower. Its cold-start performance on new items is dramatically better than either pure CF or the switching hybrid above.

Which pattern for which context

ContextPatternReason
First launch, no CF data yetContent onlyCF has nothing to learn from
Small catalog, growing user baseSwitching (by user activity)Cheap, transparent, correct on cold users
Growing catalog, established usersFeature-enriched factorizationHandles cold items without a hard switch
Multi-source home pageMixedDifferent sections for different intents
A single "top picks" carousel with mature CFWeighted with rank normalizationSimple to A/B, easy to explain

The wrong pattern for the situation is worse than a slightly weaker model in the right pattern. A team that runs a weighted hybrid with α=0.5\alpha = 0.5 before it has any CF signal is silently giving half its budget to noise.

Ablation, not addition

Before adding a component to a hybrid, remove one from the existing system and measure what breaks. If nothing measurable degrades when the content branch is switched off, the content branch was not doing useful work — and the added complexity is guaranteed to cost you every time the pipeline changes. Ablations catch dead code that added metrics never do.

Summary

  • Hybrid recommenders combine collaborative and content signals; the four patterns to know are weighted, switching, mixed and feature-enriched factorization.
  • Weighted hybrids only work if scores are normalized (min-max per request, or rank-based) — otherwise the loudest score wins by accident.
  • The switching hybrid on user activity is the simplest recommender that correctly handles cold users, and it should be the default for a first ship.
  • Feature-enriched factorization (LightFM, or the two-tower of module 6) is the strongest single-model pattern once the catalog is growing and cold items are frequent.

Next module: replace the linear factorization with a small neural network — the two-tower model — and see what depth adds when we already know how to build good features.