Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A search ranker makes a decision that shapes its own future data: which products shoppers see near the top of results. Clicks and purchases then reflect that exposure, so they can't be treated as unbiased relevance labels.
Your task is to ship a ranking candidate for searches such as insulated delivery bag. It may reorder eligible, in-stock listings. It may not surface blocked listings or call clicks an unbiased truth signal.

Specify the Ranking Surface
Define the scope tightly:
| Contract | Decision |
|---|---|
| surface | marketplace search results |
| query set | frozen judged search queries plus online experiment traffic |
| eligibility | in-stock, deliverable region, policy-approved listing |
| offline metric | recall@K and NDCG@K on the fixture (this lesson uses ; production targets are often recall@100 and NDCG@10) |
| online primary metric | purchase conversion per search |
| guardrails | returns, latency, unsafe listings, seller concentration |
Candidate generation and reranking are separate artifacts. The candidate component must retrieve relevant items reliably. The ranker can only adjust order inside that eligible set.
Learning-to-rank methods can learn pairwise preferences so a relevant product receives a higher score than a less relevant candidate.[1] That modeling detail doesn't excuse missing policy: eligibility filtering must run before scoring, and its version belongs in every impression log.

Build offline evidence first
Create a judged fixture set. Each query has eligible candidates and graded relevance from human review:
1ranking-product/
2 data/
3 catalog_snapshot.jsonl
4 judged_queries.jsonl
5 eligibility_policy.json
6 retrieval/
7 candidate_generator.py
8 ranking/
9 ranker.py
10 evaluate_ndcg.py
11 experiments/
12 impression_schema.json
13 ab_plan.md
14 tests/
15 test_blocked_listing_never_surfaces.py
16 test_ndcg_regression_gate.pyOffline gates in this lesson score NDCG@3 / recall@3 on the small judged fixture. Production contracts often pin NDCG@10 and recall@100; keep the K explicit so the receipt and the surface table match.
Required offline checks:
| Check | Why it blocks release |
|---|---|
| eligible-only results | a relevant prohibited listing still can't display |
| candidate recall@K (fixture @3; prod often @100) | the ranker can't repair missing products |
| NDCG@K by query category (fixture @3; prod often @10) | overall improvement may hide poor critical categories |
| scoring latency | a slower list damages shopping experience |
| diversity/seller concentration | one seller with many feedback events shouldn't crowd out catalog |
Filter policy before retrieval
Start with the boundary that a model can't learn safely: listing eligibility. A prohibited or sold-out product may have an excellent text match and a high learned score. It still can't reach the ranker.
The compact fixture below keeps two judged searches in memory. Production might retrieve 100 candidates per query; this local receipt uses 3 so each listing stays visible.
1from collections import Counter
2from dataclasses import dataclass
3from datetime import datetime
4from math import ceil, log2
5import json
6
7@dataclass(frozen=True)
8class Listing:
9 product_id: str
10 query: str
11 relevance: int
12 retrieval_score: float
13 baseline_rank_score: float
14 candidate_rank_score: float
15 in_stock: bool
16 policy_approved: bool
17 deliverable_region: bool
18 seller_id: str
19
20CATALOG = [
21 Listing("P1", "insulated-bag", 3, 0.96, 0.82, 0.97, True, True, True, "S1"),
22 Listing("P2", "insulated-bag", 1, 0.88, 0.75, 0.62, True, True, True, "S2"),
23 Listing("P3", "insulated-bag", 2, 0.84, 0.65, 0.90, True, True, True, "S3"),
24 Listing("P9", "insulated-bag", 3, 0.99, 0.99, 0.99, True, False, True, "S9"),
25 Listing("P10", "insulated-bag", 2, 0.95, 0.98, 0.96, False, True, True, "S1"),
26 Listing("P4", "label-printer", 1, 0.74, 0.90, 0.45, True, True, True, "S4"),
27 Listing("P5", "label-printer", 3, 0.93, 0.72, 0.96, True, True, True, "S5"),
28 Listing("P6", "label-printer", 2, 0.86, 0.65, 0.84, True, True, True, "S6"),
29 Listing("P8", "label-printer", 3, 0.98, 0.99, 0.99, False, False, True, "S8"),
30 Listing("P11", "label-printer", 2, 0.97, 0.97, 0.97, True, True, False, "S11"),
31]
32
33QUERIES = ("insulated-bag", "label-printer")
34
35def exclusion_reason(listing: Listing) -> str | None:
36 if not listing.policy_approved:
37 return "policy_block"
38 if not listing.in_stock:
39 return "out_of_stock"
40 if not listing.deliverable_region:
41 return "unavailable_region"
42 return None
43
44print("catalog listings:", len(CATALOG))
45print("queries:", QUERIES)1catalog listings: 10
2queries: ('insulated-bag', 'label-printer')1def retrieve(query: str, budget: int = 3) -> list[Listing]:
2 eligible = [
3 listing for listing in CATALOG
4 if listing.query == query and exclusion_reason(listing) is None
5 ]
6 return sorted(eligible, key=lambda listing: (-listing.retrieval_score, listing.product_id))[:budget]
7
8def relevant_ids(query: str) -> set[str]:
9 return {
10 listing.product_id for listing in CATALOG
11 if listing.query == query and exclusion_reason(listing) is None and listing.relevance >= 2
12 }
13
14def candidate_recall(query: str, candidates: list[Listing]) -> float:
15 relevant = relevant_ids(query)
16 if not relevant:
17 raise ValueError(f"{query}: no relevant eligible judgments")
18 return len(relevant & {listing.product_id for listing in candidates}) / len(relevant)
19
20retrieved = {query: retrieve(query) for query in QUERIES}
21rejected = {
22 listing.product_id: exclusion_reason(listing)
23 for listing in CATALOG
24 if exclusion_reason(listing) is not None
25}
26
27for query, candidates in retrieved.items():
28 print(query, [listing.product_id for listing in candidates], f"recall@3={candidate_recall(query, candidates):.3f}")
29print("rejected:", rejected)1insulated-bag ['P1', 'P2', 'P3'] recall@3=1.000
2label-printer ['P5', 'P6', 'P4'] recall@3=1.000
3rejected: {'P9': 'policy_block', 'P10': 'out_of_stock', 'P8': 'policy_block', 'P11': 'unavailable_region'}Products P9 and P8 have the strongest scores for their searches, but policy excludes them. Product P10 is approved but out of stock. Product P11 can't ship to the shopper's region. Filtering first prevents the ranker from treating a business invariant as a preference it can trade away.
Measure Retrieval and Ordering Separately
Candidate recall asks whether relevant eligible products reached the ranker. Normalized discounted cumulative gain (NDCG) then asks whether stronger judgments appear near the top of the returned list. A ranker can't repair a relevant product that retrieval dropped.
The next cell reranks each eligible candidate set and compares baseline and candidate NDCG. It also rechecks the invariant after scoring.
1def dcg(rows: list[Listing]) -> float:
2 return sum((2**listing.relevance - 1) / log2(rank + 2) for rank, listing in enumerate(rows))
3
4def ndcg(rows: list[Listing]) -> float:
5 ideal = sorted(rows, key=lambda listing: (-listing.relevance, listing.product_id))
6 ideal_dcg = dcg(ideal)
7 return dcg(rows) / ideal_dcg if ideal_dcg else 0.0
8
9def rerank(rows: list[Listing], score_field: str) -> list[Listing]:
10 return sorted(rows, key=lambda listing: (-getattr(listing, score_field), listing.product_id))
11
12baseline_ranked = {
13 query: rerank(candidates, "baseline_rank_score")
14 for query, candidates in retrieved.items()
15}
16candidate_ranked = {
17 query: rerank(candidates, "candidate_rank_score")
18 for query, candidates in retrieved.items()
19}
20
21print("queries ranked:", list(candidate_ranked))1for query in QUERIES:
2 print(
3 query,
4 f"baseline_ndcg@3={ndcg(baseline_ranked[query]):.3f}",
5 f"candidate_ndcg@3={ndcg(candidate_ranked[query]):.3f}",
6 )
7
8blocked_hits = [
9 listing.product_id
10 for rows in candidate_ranked.values()
11 for listing in rows
12 if exclusion_reason(listing) is not None
13]
14print("blocked hits:", blocked_hits)1insulated-bag baseline_ndcg@3=0.972 candidate_ndcg@3=1.000
2label-printer baseline_ndcg@3=0.736 candidate_ndcg@3=1.000
3blocked hits: []The candidate improves ordering for both query fixtures. The metric helpers also make two edge policies explicit: a query with no relevant eligible judgments is a fixture error for this capstone, while NDCG returns 0.0 when a ranked set has no positive gain. This still isn't a launch decision. Offline judgments support a controlled experiment, and the displayed slate creates the future data used to evaluate or retrain the system.
Log exposure before learning from outcomes
Every displayed slate should log:
| Field | Why |
|---|---|
request_id, query, served_at | group one displayed decision |
shopper_id, experiment_id, experiment_arm | replay stable experiment assignment |
catalog_snapshot, eligibility_version | show available choices |
candidate_version, ranker_version | identify scoring path |
product_id, position | preserve exposure |
position_propensity, propensity_model_version | enable replayable IPS-style learning from implicit feedback |
Clicks alone aren't reliable targets: top-ranked items receive attention because they are top-ranked. Joachims, Swaminathan, and Schnabel show why position bias makes direct click training suboptimal and derive a counterfactual correction for biased implicit feedback.[2] Use judged offline sets for regression detection, then use a controlled A/B experiment to compare user outcomes. Kohavi et al. describe online experimentation as a disciplined way to measure product changes.[3] When training_use=implicit, refuse to train if impression rows lack the actual randomized action probability or a calibrated examination propensity with a pinned model version. A convenient formula such as 1 / position isn't a measured propensity.
Clicks, purchases, and returns arrive later. Append them as outcome events with event_id, occurred_at, request_id, product_id, and outcome. Don't overwrite the served impression row. An A/B plan should state allocation, duration or sample-size rule, primary metric, latency and return-rate guardrails, prohibited-listing invariant, and stop conditions. A conversion lift that increases returns or policy violations isn't a successful ranking release.
The final cell publishes replayable impression rows, illustrative joined outcome events, and one candidate receipt. It doesn't invent live lift. It proves offline evidence, exposure schema, stable assignment, outcome join, and rollback pointer are ready before traffic begins.
1CATALOG_SNAPSHOT = "catalog-2026-05-01"
2ELIGIBILITY_VERSION = "market-eligibility-v1"
3CANDIDATE_VERSION = "retrieval-v1"
4RANKER_VERSION = "market-ranker-v1"
5PREVIOUS_RANKER = "market-ranker-v0"
6EXPERIMENT_ID = "ranking-ab-2026-05"
7PROPENSITY_MODEL_VERSION = "position-examination-randomized-v1"
8# Frozen estimates from a separate randomized-position logging study.
9POSITION_PROPENSITY = {1: 0.95, 2: 0.78, 3: 0.62}
10
11ARM_CONFIG = {
12 "control": (PREVIOUS_RANKER, baseline_ranked),
13 "treatment": (RANKER_VERSION, candidate_ranked),
14}
15
16def impression_rows(
17 request_id: str,
18 shopper_id: str,
19 served_at: str,
20 query: str,
21 arm: str,
22) -> list[dict[str, object]]:
23 ranker_version, ranked_by_query = ARM_CONFIG[arm]
24 return [
25 {
26 "request_id": request_id,
27 "shopper_id": shopper_id,
28 "served_at": served_at,
29 "query": query,
30 "catalog_snapshot": CATALOG_SNAPSHOT,
31 "eligibility_version": ELIGIBILITY_VERSION,
32 "candidate_version": CANDIDATE_VERSION,
33 "ranker_version": ranker_version,
34 "experiment_id": EXPERIMENT_ID,
35 "experiment_arm": arm,
36 "product_id": listing.product_id,
37 "position": position,
38 "position_propensity": POSITION_PROPENSITY[position],
39 "propensity_model_version": PROPENSITY_MODEL_VERSION,
40 "slate_size": len(ranked_by_query[query]),
41 "training_use": "implicit",
42 }
43 for position, listing in enumerate(ranked_by_query[query], start=1)
44 ]
45
46print("experiment:", EXPERIMENT_ID, "arms:", list(ARM_CONFIG))1control_impressions = impression_rows("REQ-500", "SHOP-100", "2026-05-01T12:00:00Z", "label-printer", "control")
2treatment_impressions = impression_rows("REQ-501", "SHOP-200", "2026-05-01T12:00:05Z", "label-printer", "treatment")
3impressions = control_impressions + treatment_impressions
4outcomes = [
5 {
6 "event_id": "EVT-900",
7 "occurred_at": "2026-05-01T12:00:08Z",
8 "request_id": "REQ-501",
9 "product_id": "P5",
10 "outcome": "click",
11 },
12 {
13 "event_id": "EVT-901",
14 "occurred_at": "2026-05-01T12:04:18Z",
15 "request_id": "REQ-501",
16 "product_id": "P5",
17 "outcome": "purchase",
18 },
19]
20
21print("impressions:", len(impressions), "outcomes:", len(outcomes))1required_impression_fields = {
2 "request_id", "shopper_id", "served_at", "query", "catalog_snapshot",
3 "eligibility_version", "candidate_version", "ranker_version",
4 "experiment_id", "experiment_arm", "product_id", "position",
5 "position_propensity", "propensity_model_version", "slate_size", "training_use",
6}
7required_outcome_fields = {"event_id", "occurred_at", "request_id", "product_id", "outcome"}
8outcome_event_ids = [row["event_id"] for row in outcomes]
9latencies_ms = [11, 13, 12, 15, 14, 16, 13, 12, 17, 18]
10# Toy online conversion table on the two arms (stop-rule practice).
11arm_searches = {"control": 100, "treatment": 100}
12arm_purchases = {"control": 4, "treatment": 6}
13arm_returns = {"control": 0, "treatment": 1}
14conversion = {
15 arm: arm_purchases[arm] / arm_searches[arm]
16 for arm in arm_searches
17}
18return_rate = {
19 arm: (arm_returns[arm] / arm_purchases[arm]) if arm_purchases[arm] else 0.0
20 for arm in arm_searches
21}
22stop_returns = return_rate["treatment"] > return_rate["control"] + 0.01
23
24def nearest_rank(values: list[int], percentile: float) -> int:
25 if not values:
26 raise ValueError("latency sample must not be empty")
27 rank = max(1, ceil(len(values) * percentile))
28 return sorted(values)[rank - 1]
29
30baseline_ndcg = sum(ndcg(rows) for rows in baseline_ranked.values()) / len(QUERIES)
31candidate_ndcg = sum(ndcg(rows) for rows in candidate_ranked.values()) / len(QUERIES)
32max_seller_count = max(
33 max(Counter(listing.seller_id for listing in rows).values())
34 for rows in candidate_ranked.values()
35)
36impression_index = {
37 (row["request_id"], row["product_id"]): row
38 for row in impressions
39}
40shopper_arms: dict[str, set[str]] = {}
41for row in impressions:
42 shopper_arms.setdefault(row["shopper_id"], set()).add(row["experiment_arm"])
43
44def parse_utc(value: str) -> datetime:
45 return datetime.fromisoformat(value.replace("Z", "+00:00"))
46
47def outcome_follows_impression(row: dict[str, object]) -> bool:
48 impression = impression_index.get((row["request_id"], row["product_id"]))
49 return impression is not None and parse_utc(impression["served_at"]) < parse_utc(row["occurred_at"])
50
51print("baseline_ndcg:", round(baseline_ndcg, 3), "candidate_ndcg:", round(candidate_ndcg, 3))1release_gates = {
2 "candidate_recall_complete": all(candidate_recall(query, retrieved[query]) == 1.0 for query in QUERIES),
3 "candidate_ndcg_improves": candidate_ndcg > baseline_ndcg,
4 "ndcg_slices_at_least_0_95": all(ndcg(rows) >= 0.95 for rows in candidate_ranked.values()),
5 "ranker_p95_latency_ms_at_most_20": nearest_rank(latencies_ms, 0.95) <= 20,
6 "blocked_items_absent": not blocked_hits,
7 "impression_schema": all(required_impression_fields <= row.keys() for row in impressions),
8 "implicit_feedback_has_propensity": all(
9 row.get("training_use") != "implicit"
10 or (
11 isinstance(row.get("position_propensity"), (int, float))
12 and 0 < row["position_propensity"] <= 1
13 and row.get("propensity_model_version") == PROPENSITY_MODEL_VERSION
14 and isinstance(row.get("slate_size"), int)
15 and row["slate_size"] >= 1
16 )
17 for row in impressions
18 ),
19 "outcome_schema": all(required_outcome_fields <= row.keys() for row in outcomes),
20 "outcome_event_ids_unique": len(outcome_event_ids) == len(set(outcome_event_ids)),
21 "outcomes_join_impressions": all((row["request_id"], row["product_id"]) in impression_index for row in outcomes),
22 "outcomes_follow_impressions": all(outcome_follows_impression(row) for row in outcomes),
23 "one_arm_per_shopper": all(len(arms) == 1 for arms in shopper_arms.values()),
24 "seller_concentration_at_most_2_per_slate": max_seller_count <= 2,
25}
26
27print("release_gates_pass:", all(release_gates.values()))1receipt = {
2 "bundle_id": RANKER_VERSION,
3 "previous_ranker": PREVIOUS_RANKER,
4 "catalog_snapshot": CATALOG_SNAPSHOT,
5 "eligibility_version": ELIGIBILITY_VERSION,
6 "candidate_version": CANDIDATE_VERSION,
7 "offline": {
8 "baseline_ndcg_at_3": round(baseline_ndcg, 3),
9 "candidate_ndcg_at_3": round(candidate_ndcg, 3),
10 "ranker_p95_latency_ms": nearest_rank(latencies_ms, 0.95),
11 },
12 "release_gates": release_gates,
13 "experiment": {
14 "experiment_id": EXPERIMENT_ID,
15 "assignment_unit": "shopper_id",
16 "control": PREVIOUS_RANKER,
17 "treatment": RANKER_VERSION,
18 "allocation": {"control": 0.5, "treatment": 0.5},
19 "minimum_eligible_searches": 10000,
20 "primary_metric": "purchase_conversion_per_search",
21 "guardrails": ["returns_per_purchase", "search_p95_latency_ms", "blocked_listing_impressions", "seller_concentration_at_3"],
22 "stop_conditions": [
23 "blocked_listing_impressions > 0",
24 "search_p95_latency_ms > 120",
25 "returns_per_purchase > control + 0.01",
26 ],
27 },
28 "illustrative_online_review": {
29 "status": "stop_treatment_return_guardrail",
30 "control_conversion": conversion["control"],
31 "treatment_conversion": conversion["treatment"],
32 "control_return_rate": return_rate["control"],
33 "treatment_return_rate": return_rate["treatment"],
34 "stop_returns": stop_returns,
35 "release_evidence": False,
36 },
37 "candidate_decision": "candidate_for_ab_test" if all(release_gates.values()) else "hold",
38}
39
40print("candidate_decision:", receipt["candidate_decision"])1print("control slate:", [row["product_id"] for row in control_impressions])
2print("treatment slate:", [row["product_id"] for row in treatment_impressions])
3print("offline:", receipt["offline"])
4print("release_gates_pass:", all(receipt["release_gates"].values()))
5print("online_review:", receipt["illustrative_online_review"]["status"])
6print("candidate_decision:", receipt["candidate_decision"])1control slate: ['P4', 'P5', 'P6']
2treatment slate: ['P5', 'P6', 'P4']
3offline: {'baseline_ndcg_at_3': 0.854, 'candidate_ndcg_at_3': 1.0, 'ranker_p95_latency_ms': 18}
4release_gates_pass: True
5online_review: stop_treatment_return_guardrail
6candidate_decision: candidate_for_ab_testcandidate_for_ab_test is intentionally narrower than launch approval. A local 20 millisecond gate measures ranker scoring only; the experiment's 120 millisecond stop applies to end-to-end search latency. Its tiny online table is stop-rule practice, not live release evidence: treatment conversion rises, but the return-rate guardrail fires. This receipt says which ranker deserves controlled exposure, which immutable logs make later outcomes interpretable, and which previous alias remains available if guardrails fail.
Submission checklist
| Artifact | Evidence |
|---|---|
| eligibility policy | blocked items can't enter ranking |
| candidate evaluation | recall measured before reranking |
| ranker evaluation | NDCG slices and latency recorded |
| impression schema | positions, versions, and assignment context logged before outcomes |
| outcome stream | later events append separately and join displayed products |
| experiment plan | stable assignment, metrics, guardrails, stop rules |
| rollback | stable ranker alias remains available |
Practice: break the ranking contract
Use the runnable examples as a release harness. Change one condition at a time, predict the result, then rerun the examples.
- Change
P3retrieval score from0.84to0.10, then changeretrieve()default budget from3to2. Which metric exposes missing relevant product? - Set
P9.policy_approvedtoTrue. Why isn't that a harmless relevance change? - Change candidate score for
P6from0.84to0.20. Which NDCG slice fails? - Remove
positionfromimpression_rows(). Which receipt gate blocks experiment readiness? - Change all three eligible
insulated-baglistings to sellerS1. Which marketplace guardrail fails? - Use
SHOP-100for both control and treatment impressions. Which assignment gate fails? - Change first outcome's
product_idfromP5toP9. Which replay gate fails? - Append a retry with the same
event_idasEVT-900. Which deduplication gate fails?
Practice answer sketches
Which metric catches dropped P3 after retrieval budget falls to 2?
Answer
candidate_recall_complete fails because the ranker never receives one relevant eligible product. NDCG on surviving candidates can't repair that omission.
Why isn't approving P9 a harmless relevance edit?
Answer
P9 was excluded by marketplace policy. Changing that field changes eligibility contract, not ranking quality, and requires its own reviewed policy release.
What happens when P6 candidate score falls from 0.84 to 0.20?
Answer
label-printer no longer receives ideal ordering, so ndcg_slices_at_least_0_95 fails.
Which gate fails when impression rows drop position?
Answer
impression_schema fails. Without displayed position, later click and purchase data can't be interpreted against exposure.
Which gate fails when three products in one displayed slate share seller S1?
Answer
seller_concentration_at_most_2_per_slate fails, blocking an experiment that would crowd too much exposure into one seller.
Which gate fails when SHOP-100 appears in both experiment arms?
Answer
one_arm_per_shopper fails. A shopper-level experiment must keep each shopper in one arm so treatment effects stay interpretable.
Which gate fails when outcome product P9 was never displayed for REQ-501?
Answer
outcomes_join_impressions fails. A click or purchase can't become ranking feedback unless it joins a product exposed by the same request.
Which gate fails when a retry reuses EVT-900?
Answer
outcome_event_ids_unique fails. Event IDs distinguish one outcome from a replayed delivery, so a duplicate must be deduplicated before training or experiment analysis.