Playbook — recommendation and learning-to-rank¶
For experiments of type: "recommendation" (next-best product: one calibrated propensity model
per product, blended into a top-K list) and type: "ltr" (learning-to-rank: one global ranking
model over the products at each decision occasion). Both rank a catalogue of products for each
entity; both read the same two input shapes (grain):
event— a purchase log, one row per acquisition(entity, product, time); every row is a positive and the engine mines the negatives;long— a candidate frame, one row per(entity, candidate product)with a 0/1label_col; the negatives are written out.
The plan carries a recommendation or an ltr block (the column mapping and its config;
describe_platform("plan_schema", name="recommendation") or name="ltr"). The delivery lifecycle
is get_playbook("deliver"); this playbook is what each of its steps does here.
Frame¶
- The decision — who receives the list, how many items they are shown (
config.k), and what a good list is: the bought item near the top (primary_metric:ndcg,mrr,hit), or reach across the catalogue (ranking_objective:accuracy,balanced,coverage). - The floor the person cares about — popularity (the bestsellers), and a per-segment popularity when the person names one: the model has to beat it at the person's K to be worth serving.
- The engine — the requirement choice.
recommendationwhen products differ a lot and each has enough buyers (min_positivesper product), and per-product models are wanted (their drivers, drift and cost lens);ltrwhen the catalogue is large or long-tailed, or a decision occasion (bundle_col) groups several purchases. A per-product requirement (regulated, real time) isconfig.submodel_template_name— any supervised template for the sub-models. - States and eligibility — establish whether held products or win-backs are allowed.
holdings_colandprior_holdings_colroute new / existing / returning submodels; they do not exclude held products. Both engines' evaluation and published lists rank the full fitted catalogue, including products absent from an entity's long-grain candidate rows: anltrrun is no closer to "order only the offered rows" than arecommendationrun. Missing rows are not an eligibility declaration. No matched eligible-only policy is exposed by the current recommendation plan. Identify that limitation before fitting when exclusion is required. Proceed with an unfiltered offline baseline only when that scope is authorized; do not claim its metrics validate a filtered shortlist or invent an eligibility setting. - The prediction moment — on the event grain the
timestamp_col; features are the entity's state before that moment. A latest-event row is still a pre-acquisition snapshot, not automatically the customer's current holdings. Label lists from it as historical illustrations, or construct a justified post-event state that includes the observed acquisition and discloses unknown later changes. - Why this product — when a person must explain a list (a call-centre script): a
recommendation run publishes, per recommended product, its
holding(held, previously held, not held) and the top drivers of that product's sub-model for the entity (reason_<j>_*) with areason_textto say out loud; anltrrun publishes no reasons. That can decide the engine. - Metric —
describe_platform("metric_catalog", context="recommendation")orcontext="ranking".
Data¶
- Recipe — the event log or the candidate frame, bounded at the moment (
author_recipe). Events dated after the moment (or after today) are dropped and reported. - Roles — on the plan's block:
entity_col,target_key_col(the product),label_col(long grain only),timestamp_col,target_group_col(a product category, never a feature),holdings_col/prior_holdings_col(recommendation),bundle_col(ltr).feature_colslists the entity features (or is omitted for every column that is not a role and not ignored). In long-grain recommendation, features must be constant across an entity's candidate rows. Event-grain recommendation permits features that change across events; its evaluation projects those varying features from the previous event of that entity (first events have missing history), while entity-constant features keep their values. This is not a single first/last customer snapshot. Check that this history interpretation matches the supplied feature semantics; an already pre-event feature can consequently be older than intended. LTR features describe the decision occasion and must agree within its candidate group. A product-derived attribute can identify the answer and must not become a predictor. Do not also list a role column infeature_cols. Check the fitted pipeline's realized encodings: assigning a tag-list domain on the dataset does not establish that the ranker built per-tag features. Report raw categorical treatment when that is what the model used. - Leakage —
prepare_data(action="leakage"); the leak is a feature measured after the purchase (a tenure counted to today, holdings that include the bought product, a spend that includes it). Excluding a whole driver family for a suspected snapshot leak costs signal: prefer an as-of version of it (computed before each event) and say what the exclusion costs. - Signal — no probe: say so at the data stop.
Validation¶
Recommendation: a holdout of entities (config.holdout_fraction), ordered by each entity's
first appearance when timestamp_col is set. Whole entities stay together; their event ranges
can overlap across sides. This measures unseen-entity performance, not a strict future-time
backtest. Each product's sub-model also carries its own validation and test split.
For long-grain recommendation, ranking grades one row per positive customer/product
outcome, repeating a customer's score list for each positive. Customers with no positive
outcome do not enter those ranking metrics. Report both graded rows and evaluated customers
and distinguish them from the full outer holdout. This is positive-outcome-weighted ranking,
not customer-averaged multi-relevance NDCG or an estimate of conversion across all customers.
Entity bootstrap uncertainty preserves customer grouping but does not change that estimand.
LTR: config.split_strategy — auto, temporal (fit on the past, rank the future: matches
deployment), entity (cold start), random (optimistic whenever an occasion is finer than a
customer). Ranking metrics at K are measured on the holdout and compared with popularity.
LTR reserves that outer holdout before tuning: Optuna receives only the training side,
and the catalogue and popularity reference are built from training rows. The final ranking
report is therefore separate from trial selection within that fit. Repeatedly choosing new
experiments from this same report still turns it into selection evidence; it is not a fresh
test for each new scientific decision. The summary exposes the resolved split strategy and
holdout occasions omitted because their products are outside the fitted catalogue.
The comparison must use the same K, candidate catalogue, eligibility/holdings policy and population. Changing K or filtering published lists changes the policy; the unfiltered metric does not certify the filtered list. Use the ranking report's paired model-minus-popularity interval where returned, rather than comparing marginal confidence intervals. Observed purchases reflect exposure and availability; an unpurchased or sampled negative is not necessarily disliked. LTR scores are squashed ranking scores, not calibrated purchase probabilities. Per-product propensities also require the population and prior-correction caveats of their fitted models.
Baseline¶
On an acquisition event log — one row per customer purchase (event grain):
{
"experiment": {
"workspace_id": "<workspace_id>", "name": "cross-sell baseline", "type": "recommendation",
"dataset_id": "<dataset_id>",
"recommendation": {
"grain": "event", "entity_col": "customer_id", "target_key_col": "product",
"timestamp_col": "event_date", "holdings_col": "held_products",
"prior_holdings_col": "prior_products", "target_group_col": "product_category",
"config": {"k": 3, "primary_metric": "ndcg"}
}
},
"hypothesis": {"lever": "baseline", "expected_effect": "Household features rank the next product above popularity."}
}
On a candidate table — one row per customer and offered product, with a bought flag (long grain):
{
"experiment": {
"workspace_id": "<workspace_id>", "name": "candidate baseline", "type": "recommendation",
"dataset_id": "<dataset_id>",
"recommendation": {
"grain": "long", "entity_col": "customer_id", "target_key_col": "product",
"label_col": "bought", "holdings_col": "held_products", "target_group_col": "product_category"
}
},
"hypothesis": {"lever": "baseline", "expected_effect": "Per-product propensities beat popularity at K=10."}
}
The same event log as a ranking problem (LTR):
On the same acquisition event log:
{
"experiment": {
"workspace_id": "<workspace_id>", "name": "cross-sell ranker", "type": "ltr",
"dataset_id": "<dataset_id>",
"ltr": {
"grain": "event", "entity_col": "customer_id", "target_key_col": "product",
"timestamp_col": "event_date", "holdings_col": "held_products",
"target_group_col": "product_category",
"config": {"split_strategy": "temporal", "k": 3}
}
},
"hypothesis": {"lever": "baseline", "expected_effect": "One global ranker beats popularity on the latest purchases."}
}
The floor first: the popularity ranking, reported beside the model's metrics
(get_insight(kind="ranking"): baselines.global_pop). A model below popularity at the
person's K recommends worse than the bestsellers: it is not a candidate, and that is the headline
— first check the evaluation, available signal and policy match. Do not assume an excluded
driver is the cause or restore a prohibited one. Popularity itself may be a useful reference
or deliverable. An edge needs its interval:
uncertainty.baselines.global_pop.difference["hit@K"] is the model-minus-popularity gap with a
paired bootstrap interval over the holdout's customers (uncertainty.n_units); verdict:
"undecided" means the holdout cannot tell the model from popularity — say so, with the interval,
instead of calling +0.02 a win.
Iterate¶
Lever (hypothesis.lever) |
Change |
|---|---|
features |
feature_cols, or a new entity feature in the recipe (as-of history: prior events, recency) |
recipe |
the history window, the candidate set |
split |
holdout_fraction, split_strategy (ltr) |
metric |
primary_metric, ranking_objective, k — the person's |
other |
negative sampling (neg_ratio, negative_strategy; ltr negatives_per_positive), min_positives — with the evidence in note (a validation–test ROC gap in recommendation_submodels) |
decision_layer |
the popularity discount and value weights: not a refit (Decide) |
- Removing entity features needs a mechanism that predicts a gain: dropping age or household size "to shrink a validation–test gap" is none.
- Sub-models that score worse than chance on test (ROC below 0.5 in
recommendation_submodels) across a family of products: investigate label direction, population shift, sampling, small evaluation pools and pipeline integrity before more tuning. Report a platform defect only with supporting evidence; below-chance estimates alone do not identify the cause.
Insights¶
Read get_insight(kind="kinds") for each insight's supported query_params.
Class-scoped interpretation reads may accept class_key; do not copy that filter
to run-wide diagnosis, trust or operating-point reads that do not advertise it.
Recommendation runs do not support single-row what_if: use published lists and
native batch scoring for the supported questions.
Interpretation checklist: choose the relevant available reads for the question and candidate:
get_insight(kind="recommendation")orget_insight(kind="ltr")— the run's headline;get_insight(kind="ranking")— Hit@K, MRR, NDCG@K against popularity, with the bootstrap interval of each metric and of the model-minus-popularity gap (uncertainty).get_insight(kind="recommendation_submodels")andget_insight(kind="recommendation_classes")— per product: positives, validation and test ROC, the quality gate.get_insight(kind="recommendation_drift", params={target_key})for the top three products — how their buyers differ from the rest.get_insight(kind="ale"),get_insight(kind="archetype_summary"),get_insight(kind="error_tree")— the explanation profile the explain stage computed.- LTR:
get_rules(experiment_id)— ranking rules: a condition on the entity's features with a set of products as its head (target_products, ortarget_products_excludedfor "any product except …"); quotemetrics_holdoutandholds_up. A rule that holds up is a targeting segment in plain words. - Recommendation: the published reasons —
query_sqlon the*__recommendationstable (holding,reason_text,reason_<j>_*) for a few entities: are the drivers plausible, and do the held products follow the approved states?
Then the Gems table (get_playbook("iterate")).
Challenge¶
| Check | Reads required |
|---|---|
| Leakage | the features against the moment: holdings and tenure measured before the event; no per-product attribute among the features (get_run(view="settings")); a ranking rule whose condition is a holdings column measured after the event |
| Validation design | the split against how lists are used: temporal for a model that ranks the future |
| Stability | get_insight(kind="recommendation_submodels"): products that failed their quality gate (below_chance_on_test: the sub-model ranks its held-out data backwards) or have few positives |
| Plausible drivers | get_insight(kind="recommendation_drift"), get_insight(kind="ale"), the published reasons (recommendation), the ranking rules (ltr) |
| Beats popularity | the model against popularity at the person's K — the gap's bootstrap interval, not the two point values (get_insight(kind="ranking"), uncertainty) — overall and per segment the person named |
Decide¶
- The tilt —
simulate_decision(kind="ranking_at", params={alpha, value_weights, value_alpha}): ranking metrics with a popularity discount (alpha, accuracy ↔ coverage) and per-product value weights, from the holdout — no refit. - One product's cost —
simulate_decision(kind="reco_business_value", params={target_key, cost_fp, cost_fn, gain_tp})(recommendation) orkind="ltr_business_value"(ltr). - The committed operating point is
get_insight(kind="recommendation_operating_point")orget_insight(kind="ltr_operating_point"); the person commits a new one in the experiment's UI.
Release and operate¶
- A recommendation or LTR run is not registered:
register_modelrefuses it. Its release is the committed operating point and the published lists (therun_allchain trains, explains, scores and publishes them — a failed score or publish stage means there is nothing to release). Record it withregister_model(action="record_release", experiment_id, …); bind a committed tilt withapply{"kind": "ranking_operating_point"}. - New entities are scored with
score_batch(action="submit")(reasons_per_item, default 3, 0–5: how many drivers each recommended product carries). - Results:
get_playbook("schema-recommendation")— the ranked lists with each item'sscore; a recommendation run addsholdingand the reasons (reason_<j>_feature,_value,_direction,_contribution,reason_text). Quotereason_textand the drivers; never invent a reason. Anltrrun has none. - Operate: a product's buyer drift (
get_insight(kind="recommendation_drift")) andcompare(kind="datasets")on the entity features.