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: . 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 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- 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 range close to the rating scale; the scores of a cosine-similarity content model are in ; the scores of an ALS-on-implicit are in 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 . Cheap, per-request, and it removes global-scale drift.
- Rank-based: replace the score by its rank in divided by . 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 has features , its factor is . 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- 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
| Context | Pattern | Reason |
|---|---|---|
| First launch, no CF data yet | Content only | CF has nothing to learn from |
| Small catalog, growing user base | Switching (by user activity) | Cheap, transparent, correct on cold users |
| Growing catalog, established users | Feature-enriched factorization | Handles cold items without a hard switch |
| Multi-source home page | Mixed | Different sections for different intents |
| A single "top picks" carousel with mature CF | Weighted with rank normalization | Simple 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 before it has any CF signal is silently giving half its budget to noise.
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.