🧭 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.
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]
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.
Repeat epochs times. Each epoch walks docs once in its original order (no
shuffling). For each (features, relevance):
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. Eachw[i]decays by- reg * w[i], butbupdates fromerralone — this asymmetry is deliberate, not an oversight. Regularizingblikewproduces 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.
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 | Added requirement |
|---|---|
| 1 | fit, score, rank — pointwise linear scoring trained by SGD |
| 2 | rank_diverse — MMR (Maximal Marginal Relevance) diversity reranking |
| 3 | rank_with_quota — a per-category exposure cap (quota) |
| 4 | record_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.
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.
score (ties by doc_id
ascending).doc_id ascending). Repeat until k are chosen or the
candidates run out.lambda_param=1.0 the similarity
term's coefficient becomes 0, so it naturally reduces to score-ordered top-k (no
special branch needed).[] when k <= 0 or candidates is empty.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.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.[] when k <= 0 or candidates is empty.record_click(doc_id, features, was_clicked) -> None
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).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.Build the skeleton of the pointwise linear ranker. w and b start at 0, so
everything is deterministic — no randomness required.
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].docs finishes without raising, leaving w and b at 0.w and b entirely and starts again
from 0 (nothing accumulates).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).features always has exactly n_features elements — other lengths are out of spec.fit returns self so it can be chained.