Capstone — Recommendations
Ana's Discover page should fill with tracks her network plays — and a track her close friends love should outrank one that a stranger three follows away happens to have on repeat. That's the whole section in one feature: a graph of follows, a traversal that finds who's nearby and how near, and a ranking built from what they play. You've written every piece already. Now wire them into Tunebox's recommender.
What you're building
recommend_for(follows, plays, listener, max_hops)follows— the follow graph, an adjacency dict. A name with no key follows nobody.plays— each listener's tracks, e.g.{"ben": ["Drift", "Glow"]}. No repeats within one list; a listener with no key plays nothing.listener— who the recommendations are for.max_hops— how far out to look.
The rules, in order:
- Walk the follows breadth-first from
listener, out tomax_hopshops. - Every listener found at distance d (1 ≤ d ≤
max_hops) has a weight ofmax_hops - d + 1— direct follows count most. - Each of those listeners adds their weight to every track they play — except
tracks
listeneralready plays. - Return
[track, score]pairs, highest score first, ties alphabetically.
Here's the example, worked by hand, with max_hops = 2:
| Listener | Hops from ana | Weight | Plays | Adds |
|---|---|---|---|---|
| ben | 1 | 2 | Drift, Glow | Drift +2 (Glow skipped — ana plays it) |
| cal | 1 | 2 | Drift | Drift +2 |
| dia | 2 | 1 | Aura | Aura +1 |
So recommend_for(follows, plays, "ana", 2) → [["Drift", 4], ["Aura", 1]].
Step 1 — find the neighbourhood
This is BFS from the BFS lesson, with one addition: don't expand anyone who is
already max_hops out. The hops dict records distance and doubles as the visited
set, so follow loops can't send you round forever.
One thing to notice before you use that dict:
from collections import deque
follows = {"ana": ["ben", "cal"], "ben": ["dia"], "cal": ["dia"], "dia": ["ana"]}
hops = {"dia": 0}
queue = deque(["dia"])
while queue:
name = queue.popleft()
if hops[name] >= 2:
continue
for nxt in follows.get(name, []):
if nxt not in hops:
hops[nxt] = hops[name] + 1
queue.append(nxt)
print(sorted(hops))Step 2 — closer listeners count more
A direct follow is a deliberate choice; a friend-of-a-friend is a weaker signal. The
weight max_hops - d + 1 turns distance into influence: with max_hops = 3,
direct follows weigh 3, the next ring 2, the outermost 1. Step through it:
❓ Cross-question — "Why not just count how many nearby listeners play each track?" Then a hundred strangers two hops away drown out your three closest friends. Weighting by distance is the simplest way to say who recommends something matters, not just how many.
Step 3 — tally the votes
Add each listener's weight to each of their tracks. A track the listener already plays isn't a discovery, however popular it is nearby. This tally forgets that:
This should score only tracks new to ana — Drift 4 and Aura 1 — but Glow sneaks in with 2. Fix the tally.
A set of the listener's tracks makes that check O(1) per track, however long
their history.
Step 4 — rank
Highest score first, ties alphabetically — one sort with a two-part key. Negating the score sorts it high-to-low while the name still sorts A-to-Z:
scores = {"Zen": 2, "Echo": 3, "Bloom": 2}
ranked = sorted(scores.items(), key=lambda item: (-item[1], item[0]))
print([track for track, _ in ranked])Turn each (track, score) tuple into a [track, score] list on the way out —
tuples aren't equal to lists, and the grader compares lists.
What it costs
| Stage | Cost |
|---|---|
| Neighbourhood BFS | O(V + E) of the part of the graph within max_hops |
| Weights and tally | O(total tracks played by that neighbourhood) |
| Own-track check | O(1) per track, with a set |
| Ranking | O(K log K) for K candidate tracks |
Idioms & real-world patterns
Counter for tallies — but not for tie-breaking
collections.Counter adds weighted votes neatly. Its most_common() breaks ties by
first insertion, though, which depends on traversal order — so sort explicitly
when the order has to be deterministic:
Choosing a decay
Linear decay is easy to reason about. Real systems often halve the weight per hop so far-away listeners fade faster:
This family of techniques — recommend what similar or connected people like — is collaborative filtering. The graph version you're building is its most direct form.
⚡ Advanced — taste distance instead of hops
Hops treat every follow as equally close. If links carried a taste gap — small when two listeners' histories overlap a lot — the neighbourhood becomes "everyone within a total gap of r", which is a shortest-path question, not a hop count. The recommender's shape doesn't change: find the neighbourhood, weight by closeness, tally, rank. Only step 1 swaps BFS for Dijkstra.
⚡ Advanced — why real recommenders cap the radius
With each listener following about b others, the neighbourhood grows like b^d.
At b = 200, three hops is already 8 million listeners — for one Discover page.
Production systems keep max_hops tiny, sample the neighbourhood, or precompute
scores offline. And a brand-new listener who follows nobody gets nothing from this
approach at all — the cold-start problem — so real apps fall back to charts.
🎯 Your turn
Write recommend_for(follows, plays, listener, max_hops):
- BFS the follows from
listener, out tomax_hopshops. - Every listener d hops away (1 ≤ d ≤
max_hops) addsmax_hops - d + 1to each track they play, skipping trackslisteneralready plays. - Return
[track, score]pairs, highest score first, ties alphabetically.
With follows = {"ana": ["ben", "cal"], "ben": ["dia"], "cal": ["dia"], "dia": ["ana"]}
and plays = {"ana": ["Glow"], "ben": ["Drift", "Glow"], "cal": ["Drift"], "dia": ["Aura"]}:
recommend_for(follows, plays, "ana", 2)→[["Drift", 4], ["Aura", 1]]recommend_for(follows, plays, "ana", 1)→[["Drift", 2]]— with one hop, every direct follow weighs 1
Hint — reuse your BFS from the BFS lesson, tally into a dict, and sort with the key
(-score, track).
Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. All green = Tunebox's recommendations are live — and the Graphs section is complete. 🎉
