Back to Insights

Discovery Systems · 01

How Instagram Knows

A feed is not mind-reading. It is a useful prediction about the next few seconds of attention — and the prediction has a formula.

Aerial view of a braided river delta narrowing into fewer channels, a metaphor for retrieval narrowing a huge catalog into a small candidate set
Aerial view of a braided river delta narrowing into fewer channels, a metaphor for retrieval narrowing a huge catalog into a small candidate set
01 Discovery

A feed turns many weak signals into one useful next choice

Recommendation is a product loop with real math underneath: an implicit-feedback objective, a factorized retrieval model, a calibrated ranking score, and a way to measure whether the order was any good.

  • Implicit feedback as a weighted matrix factorization problem
  • Two-tower retrieval and the softmax that trains it
  • A ranking score built from calibrated probabilities, not raw taps
  • NDCG: measuring whether the order was worth it
Dan Stativa

Is a discovery feature making useful predictions yet?

Instagram does not need to read your mind. It needs a useful prediction about the next few seconds of your attention — and that prediction is an optimization problem with a loss function.

Open the app on a quiet Monday morning. You pause on a friend’s holiday photos, save a recipe, skip three videos about a sport you do not follow, and send a small studio-tour clip to a colleague. None of those actions is a full explanation of your taste. Together, they are enough to make the next screen less random.

signals → candidate set → ranked result → feedback

That loop sounds like product language. Underneath, it is a fitted model. Not a metaphor — an actual objective function, trained on your behavior and everyone else’s, re-scored every time you open the app. The rest of this piece stays in that layer: what the objective is, why it is shaped the way it is, and how anyone checks whether the resulting order was worth anything.

A feed is a prediction problem

Let uu be a person, ii a post, tt the moment the feed gets built. One number is being estimated:

r^ui(t)=Pr[person u values post i right now]\hat r_{ui}(t) = \Pr\big[\text{person } u \text{ values post } i \text{ right now}\big]

Not “click.” Whatever the product decides counts as value — a save, a share, a full watch-through, a follow. Nobody observes r^ui(t)\hat r_{ui}(t) directly. The system only sees a noisy trace of past behavior and has to generalize from a small, finite history to an enormous set of things it has never scored. Closing that gap is the whole job.

Two sub-problems fall out of it, solved by different parts of the system at very different scale:

  • Retrieval — of everything that exists, what few thousand items even deserve a score?
  • Ranking — of those few thousand, what order goes on screen?
huge catalog
    ↓ retrieval — cheap, approximate, high recall
small candidate set
    ↓ ranking — expensive, precise, high precision
feed

They fail differently, and the failure is visible in different ways. A weak retrieval step never gives a good post a chance — invisible, permanently. A weak ranking step buries a good post under ten worse ones — visible, but you can still scroll to it. Each stage has its own objective.

The feedback matrix has no negatives

If people rated posts one to five stars, this would be an ordinary recommender problem: build RRU×IR \in \mathbb{R}^{|U| \times |I|}, factor it, done. Nobody rates a scroll. What exists instead is implicit feedback — watch time, saves, shares, comments, skips — all positive-leaning, with no honest “I dislike this.” A skip might mean dislike. It might mean busy right now.

Hu, Koren, and Volinsky’s 2008 fix splits one thing a star rating hides into two: whether you like the item, and how sure the system should be about that read.1 For each pair (u,i)(u, i), let rui0r_{ui} \geq 0 be a raw count — seconds watched, or a weighted tally of save/share/comment events.

pui={1rui>00rui=0cui=1+αruip_{ui} = \begin{cases} 1 & r_{ui} > 0 \\ 0 & r_{ui} = 0 \end{cases} \qquad\qquad c_{ui} = 1 + \alpha \, r_{ui}

puip_{ui} is preference: a flat yes/no on whether any positive signal exists. cuic_{ui} is confidence: it grows with signal strength but never hits zero, because absence is weak evidence of disinterest, not proof of it. α\alpha is tuned empirically (Hu et al. used α=40\alpha = 40) and sets how much a strong signal outweighs a merely-present one.

Retrieval learns two low-rank vectors, xu,yiRdx_u, y_i \in \mathbb{R}^d, by minimizing:

minx,y  u,icui(puixuyi)2  +  λ(uxu2+iyi2)\min_{x_*,\, y_*} \; \sum_{u,i} c_{ui} \left(p_{ui} - x_u^\top y_i\right)^2 \;+\; \lambda \left(\sum_u \lVert x_u \rVert^2 + \sum_i \lVert y_i \rVert^2\right)

The confidence weight is the whole point: the model pays a small penalty everywhere nothing happened, across billions of pairs, and a much larger one wherever something did. That is what “learn from what people actually did, without treating silence as rejection” looks like as an objective.

Retrieval: from a rating matrix to a two-tower model

Close-up of a woven blue fishing net, a metaphor for a two-tower model where a user vector and an item vector meet through a single connection

That confidence-weighted objective is matrix factorization — it approximates a giant, mostly-empty matrix as P^XY\hat P \approx X Y^\top. Once trained, finding candidates for a person stops being a scan of the catalog and becomes a nearest-neighbor search in Rd\mathbb{R}^d. That is the only reason retrieval is cheap enough to run at feed scale.

Modern systems generalize this into a two-tower model: a network ϕ(u)\phi(u) mapping recent behavior to a vector, a second network ψ(i)\psi(i) mapping a post’s content and metadata into the same space.2 The towers never see each other’s raw inputs — they only meet through a dot product:

score(u,i)=ϕ(u)ψ(i)ϕ(u)ψ(i)\text{score}(u, i) = \frac{\phi(u)^\top \psi(i)}{\lVert \phi(u) \rVert \, \lVert \psi(i) \rVert}

That separation is the entire performance story. Every ψ(i)\psi(i) gets precomputed and indexed offline; serving a request is one ANN lookup for ϕ(u)\phi(u), not a forward pass over the catalog. Training uses a sampled softmax — one positive i+i^+ against a batch of in-batch negatives N\mathcal{N}:

L(u,i+)=logexp(score(u,i+)/τ)j{i+}Nexp(score(u,j)/τ)\mathcal{L}(u, i^+) = -\log \frac{\exp\big(\text{score}(u, i^+) / \tau\big)}{\displaystyle\sum_{j \,\in\, \{i^+\} \cup \mathcal{N}} \exp\big(\text{score}(u, j) / \tau\big)}

τ\tau controls how hard the model separates the positive from the crowd. This is the same contrastive shape you find behind most embedding search, not just feeds — which is why retrieval and ranking keep showing up as the same two moves in completely different products.

Ranking: several probabilities, one order

Retrieval answers “what deserves a chance.” Ranking answers “what goes first,” by predicting a calibrated probability per action type and combining them:

p^k(u,i,t)=σ(fθk(xu,i,t)),σ(z)=11+ez\hat p_k(u, i, t) = \sigma\big(f_{\theta_k}(x_{u,i,t})\big), \qquad \sigma(z) = \frac{1}{1 + e^{-z}}

kk ranges over action types — save, share, full watch, skip — each its own model or its own head on a shared network. The sigmoid is not the interesting part. Calibration is: if the model says “70% likely to be saved,” roughly 70% of those posts had better actually get saved, or the score is meaningless the moment it gets combined with a different action’s score. And it always gets combined:

score(u,i,t)=(kwkp^k(u,i,t))eλdecay(tti)\text{score}(u,i,t) = \left(\sum_k w_k \, \hat p_k(u,i,t)\right) \cdot e^{-\lambda_{\text{decay}} \, (t - t_i)}

The decay term is why a close friend’s five-minute-old post can beat a globally popular post from yesterday — ttit - t_i grows, the exponential shrinks, freshness loses ground on a curve instead of falling off a cliff. The weights wkw_k are where product intent actually lives:

def score(candidate, person, now, weights, decay_rate):
    action_value = sum(
        w * predict_probability(action, candidate, person)
        for action, w in weights.items()
    )
    age = now - candidate.published_at
    return action_value * math.exp(-decay_rate * age)

ranked_feed = sorted(
    candidates,
    key=lambda c: score(c, you, now, WEIGHTS, DECAY_RATE),
    reverse=True,
)

WEIGHTS is not learned. A product team sets wsave>wlikew_{\text{save}} > w_{\text{like}} on purpose, because a save is a stronger claim on future value than a like. The model’s job is making each p^k\hat p_k trustworthy. The weighting is a statement about what the product is for.

Rows of smooth, pill-shaped 3D forms in violet light, a metaphor for candidates sorted into a ranked order

A worked example

Three candidates for the Monday-morning feed, model outputs already computed:

Postp^(save)\hat p(\text{save})p^(share)\hat p(\text{share})p^(skip)\hat p(\text{skip})age (h)
A — friend’s photos0.100.050.201
B — recipe0.550.100.0518
C — studio-tour clip0.200.600.103

Weights wsave=3.0w_{\text{save}} = 3.0, wshare=2.0w_{\text{share}} = 2.0, wskip=2.0w_{\text{skip}} = -2.0, decay λdecay=0.02/hour\lambda_{\text{decay}} = 0.02\,/\text{hour}:

scoreA=(3.00.10+2.00.052.00.20)e0.021=(0.10)0.9800.098\text{score}_A = \big(3.0 \cdot 0.10 + 2.0 \cdot 0.05 - 2.0 \cdot 0.20\big) \cdot e^{-0.02 \cdot 1} = (-0.10) \cdot 0.980 \approx -0.098 scoreB=(3.00.55+2.00.102.00.05)e0.0218=(1.85)0.6991.293\text{score}_B = \big(3.0 \cdot 0.55 + 2.0 \cdot 0.10 - 2.0 \cdot 0.05\big) \cdot e^{-0.02 \cdot 18} = (1.85) \cdot 0.699 \approx 1.293 scoreC=(3.00.20+2.00.602.00.10)e0.023=(1.60)0.9421.507\text{score}_C = \big(3.0 \cdot 0.20 + 2.0 \cdot 0.60 - 2.0 \cdot 0.10\big) \cdot e^{-0.02 \cdot 3} = (1.60) \cdot 0.942 \approx 1.507

Order: C, B, A. The share-worthy clip beats the older, more save-able recipe. The recipe still beats the friend’s photos at sixteen hours older. And post A scores negative — the predicted skip outweighs the thin save and share signal.

Sit with that last one. A post from someone you follow closely is not automatically ranked first. The model does not know about the relationship. It knows about the predicted action, and in this toy example none of the three predicted actions are enthusiastic about post A.

Checking whether the order was any good

None of this means anything without a way to check it. NDCG — normalized discounted cumulative gain — rewards relevant items for showing up early and punishes them for showing up late, on a curve rather than a cliff:

DCG@k=j=1k2relj1log2(j+1)NDCG@k=DCG@kIDCG@k\text{DCG@}k = \sum_{j=1}^{k} \frac{2^{\,\text{rel}_j} - 1}{\log_2(j + 1)} \qquad\qquad \text{NDCG@}k = \frac{\text{DCG@}k}{\text{IDCG@}k}

relj\text{rel}_j is the true relevance of whatever landed at position jj. IDCG@k\text{IDCG@}k is the DCG of the best possible ordering of the same set — dividing by it rescales everything to [0,1][0, 1], which is what makes NDCG comparable across people with wildly different amounts of engagement.

import math

def dcg(relevances: list[float]) -> float:
    return sum(
        (2 ** rel - 1) / math.log2(position + 2)
        for position, rel in enumerate(relevances)
    )

def ndcg_at_k(ranked_relevances: list[float], k: int) -> float:
    actual = dcg(ranked_relevances[:k])
    ideal = dcg(sorted(ranked_relevances, reverse=True)[:k])
    return actual / ideal if ideal > 0 else 0.0

# C scored highest and was shared; B was saved; A was skipped.
observed_relevance_in_ranked_order = [2, 1, 0]  # C, B, A
print(ndcg_at_k(observed_relevance_in_ranked_order, k=3))

Precision@kk and recall@kk ride alongside it: precision tells you if the feed is wasting attention, recall tells you if retrieval quietly dropped a good candidate before ranking ever saw it.

Why the loop needs an escape hatch

A system trained only to maximize predicted engagement will narrow. More of the same creator, the same format, the same three interests — that is not a bug, it is the objective working as designed. Nothing in score(u,i,t)\text{score}(u,i,t) rewards uncertainty. Production systems counter this with a small, deliberate exploration bonus, often nothing fancier than an upper-confidence-bound term added before ranking:

explore-score(u,i)=score(u,i,t)+βlnNunu,i+1\text{explore-score}(u,i) = \text{score}(u,i,t) + \beta \sqrt{\frac{\ln N_u}{n_{u,i} + 1}}

nu,in_{u,i} counts how many similar items this person has already seen, NuN_u counts total impressions. The bonus shrinks as evidence piles up — exploration is front-loaded, and fades as the model gets confident. β\beta sets how much the product is willing to spend on curiosity.

The rest of the escape hatch has no math in it: an unfollow, a mute, a search bar, an explore tab. People need ways to correct the system that do not route back through its own reward function. A recommendation system should widen what you find, not turn your past into the ceiling on your future feed.

The next piece starts where this one cannot: what does a product do before any of these matrices have a single entry in them? The cold start problem is not an AI miracle. It is a first-use design decision.

Footnotes

  1. Y. Hu, Y. Koren, and C. Volinsky, “Collaborative Filtering for Implicit Feedback Datasets,” IEEE International Conference on Data Mining, 2008. The preference/confidence split comes straight from that paper and is still the standard way to reason about implicit engagement data.

  2. X. Yi, J. Yang, L. Hong, et al., “Sampling-Bias-Corrected Neural Modeling for Large Corpus Item Recommendations,” ACM RecSys, 2019. The two-tower / dual-encoder retrieval architecture and the sampled-softmax objective referenced above.

Dan Stativa

Is a discovery feature making useful predictions yet?