← Gym/Mini Search Ranker
00:00/ 55 min

🧭 Do not search for the first 15 minutes. When stuck: re-read the requirements → define I/O → choose the data structure → trace a small example by hand → write code.

Train a pointwise linear ranker, SearchRanker, that reorders search or feed results — with SGD and without sklearn. There is no randomness at all: the weights always start at 0, making this fully deterministic (the same discipline as the bias-only baseline in Mini Recommender L1). This problem builds the reranking stage of a real search/feed pipeline end to end: scoring → diversity (MMR) → business rules (quotas) → online feedback.

Shared spec

python
model = SearchRanker(n_features=4, epochs=10, lr=0.1, reg=0.01, online_lr=0.01)
model.fit(docs)                 # docs: list[(features: list[float], relevance: float)]
model.score(features)           # float
model.rank(candidates)          # candidates: list[(doc_id, features)] -> list[doc_id]

Linear scoring formula

score(x)=∑i=0n−1wixi+b\text{score}(x) = \sum_{i=0}^{n-1} w_i x_i + b

The weight vector w (length n_features, all starting at 0.0) and the bias b (starting at 0.0) stay untouched until fit is called — so score before fit is always 0.0.

Training — stochastic gradient descent on squared error

Repeat epochs times. Each epoch walks docs once in its original order (no shuffling). For each (features, relevance):

text
pred = score(features)              # computed with the pre-update w, b
err  = relevance - pred

for i in range(n_features):
    w[i] <- w[i] + lr * (err * features[i] - reg * w[i])
b      <- b + lr * err

⚠️ There is no regularization term on b. Each w[i] decays by - reg * w[i], but b updates from err alone — this asymmetry is deliberate, not an oversight. Regularizing b like w produces different results.

Compute err once from the w and b at the start of this step, and use that one value to update all of w and b together (do not update w[i] first and then use the new value when computing w[i+1] or b).

An empty docs finishes fit without raising, leaving w and b at 0. Re-fitting the same model discards the previous w and b entirely and starts again from 0. fit returns self so it can be chained.

Assume features always has exactly n_features elements — other lengths are out of spec.

Sort tie-breaking — the shared convention

Every sort in this problem is score descending, ties by doc_id ascending (the same convention as Mini Recommender's recommend). rank returns only doc_id values, not scores.

Level structure

LevelAdded requirement
1fit, score, rank — pointwise linear scoring trained by SGD
2rank_diverse — MMR (Maximal Marginal Relevance) diversity reranking
3rank_with_quota — a per-category exposure cap (quota)
4record_click — online fine-tuning from click logs (small fixed learning rate)

Each level must preserve the behavior of the previous ones. rank_with_quota is a separate mode that does not compose with rank_diverse — L3 applies pure score order plus quotas, with no diversity.

L2 — MMR diversity reranking

python
rank_diverse(candidates, k, lambda_param, similarity) -> list[doc_id]

similarity(doc_id_a, doc_id_b) -> float is an injected callback — the ranker never needs to know how similarity is computed.

  • First pick: the remaining candidate with the highest raw score (ties by doc_id ascending).
  • Later picks: with SS the already-selected set, compute for each remaining candidate cc mmr(c)=λ⋅score(c)−(1−λ)⋅max⁡s∈Ssimilarity(c,s)\text{mmr}(c) = \lambda \cdot \text{score}(c) - (1-\lambda) \cdot \max_{s \in S} \text{similarity}(c, s) and take the largest (ties by doc_id ascending). Repeat until k are chosen or the candidates run out.
  • max⁡s∈S\max_{s \in S} is taken only over already-selected items — do not include the remaining unselected candidates in that maximum. With lambda_param=1.0 the similarity term's coefficient becomes 0, so it naturally reduces to score-ordered top-k (no special branch needed).
  • Return [] when k <= 0 or candidates is empty.

L3 — Category quotas

python
rank_with_quota(candidates, k, category_of, max_per_category) -> list[doc_id]
  • category_of: dict[doc_id, str] — a document missing from this dictionary has no category constraint at all (it can always be picked).
  • max_per_category: dict[str, int] — a category missing from here is unlimited.
  • Sort by raw score descending (ties by doc_id ascending), then walk that order and keep each document whose category cap would not be exceeded by taking it. A document that would exceed the cap is skipped, and you keep going to the next one — you do not stop there. Continue until k are filled or the candidates run out.
  • Return [] when k <= 0 or candidates is empty.

L4 — Online click feedback

python
record_click(doc_id, features, was_clicked) -> None
  • A lightweight online update, separate from fit's batch SGD loop. Each call performs exactly one L1 SGD step, but with the new constructor argument self.online_lr (default 0.01) in place of self.lr, and a target of 1.0 when was_clicked is True and 0.0 otherwise (treating a click as a binary relevance signal).
  • online_lr is a trailing keyword argument — SearchRanker(n_features, epochs, lr, reg, online_lr=0.01) — so existing L1–L3 code that omits it keeps working (a regression safeguard).
  • This online update has no regularization term — just w[i] <- w[i] + online_lr * err * features[i] and b <- b + online_lr * err. self.reg is not used here at all (a deliberate simplification relative to batch fit).
  • w and b are the same state shared by score, rank, rank_diverse, and rank_with_quota — after a few record_click calls, later rankings can shift noticeably.

Level 1 · Linear scoring — SGD training

Build the skeleton of the pointwise linear ranker. w and b start at 0, so everything is deterministic — no randomness required.

python
SearchRanker(n_features: int, epochs: int, lr: float, reg: float)
fit(docs: list[tuple]) -> self       # docs: list[(features: list[float], relevance: float)]
score(features: list[float]) -> float
rank(candidates: list[tuple]) -> list   # candidates: list[(doc_id, features)]
  • score(features) = sum(w[i] * features[i] for i in range(n_features)) + b.
  • fit follows the shared spec's SGD rules exactly — walk docs in its original order epochs times, and at each step compute err from the pre-update w and b, then update all of w and b together. There is no regularization term on b — only each w[i] decays by - reg * w[i].
  • Empty docs finishes without raising, leaving w and b at 0.
  • Re-fitting the same model discards the previous w and b entirely and starts again from 0 (nothing accumulates).
  • Before fit is called, score is always 0.0.
  • rank(candidates) scores every candidate and returns the doc_id list sorted by score descending, ties by doc_id ascending (scores are not included).
  • Assume features always has exactly n_features elements — other lengths are out of spec.
  • fit returns self so it can be chained.