◆ Data Science layer

how-to-feature-to-model

Six pipelines were built to predict which orders come back. Five of them leak. Only two look wrong. Because this dataset's generative process is known, the best score any model could possibly reach can be computed rather than guessed - which turns every claim on this page into something measured instead of asserted. The result: 35 engineered features tie a one-line SQL rule, and the honest deliverable is a policy, not a model. Seed 42, learn-python env, real Everrest marts.

raw dump→lake→warehouse→marts→modelling table + scored decision
Case: Everrest · B2B2C retail platform seed = 42 pandas · LightGBM · scikit-learn 18 executed charts 4 installable modules
01
Step 1

Input - the marts, and one question asked of every single column

This skill reads the marts produced by how-to-schema-and-warehouse. They are clean. Clean is not the same as modellable. Before a single feature is written, every column gets the same question: could a scorer have read this at the moment it had to decide? Three columns fail that question, and each one fails differently.

orders 50,600 rows
  • order_id · 600 rows duplicated
  • order_ts · the decision moment
  • status · fails: known only after
returns 2,672 rows
  • order_id · fk, becomes the label
  • return_ts · fails: the label's own clock
  • reason · fails: written at return time
merchants 400 rows
  • tier · standard / premium / enterprise
  • the only real driver of returns
order_items · customers · payments 175,157 rows
  • order shape, tenure, channel, region
  • payments.method · 8% null, not at random

Population: 39,046 delivered orders carrying 2,672 returns, a base rate of 6.84%. A cancelled order cannot be returned, which is exactly why status is so dangerous as a feature.

02
Step 2

Sample data - the same seeded marts, and what makes them a modelling minefield

No new generator: the marts already exist (seed 42). Four quirks were planted in them long before this skill, and each one attacks a model at a different seam.

🎯 One real driverpremium-tier merchants return at 15.94% against 3.93% for everyone else. That is the entire signal - which is what makes the ceiling computable
👯 The retry bug600 orders double-fired in March 2026. Split at random and the identical order lands in train and test
⏳ The late labelreturns arrive 2 to 20 days after the order, so the newest rows are unlabelled, not safe
🕳️ Informative nullspayments.method is null 59.0% of the time on the affiliate channel against 11.2% elsewhere - the hole carries channel
🐘 The bulk merchantM0007 orders run 61x the platform median. Trees do not care; the money decision cares a great deal
🚦 Structural zeroes22% of orders never reach delivery, so their label is 0 by definition, not by behaviour
📅 The one left unusedthe planted November demand spike moves order volume, not return propensity - the monthly return rate is flat, so no calendar feature earns its place. Checked, not assumed
03
Step 3

Objective - which orders get held, not "predict returns"

Everrest runs a pre-dispatch QC hold: repack and photo verification on orders it believes will come back. The hold costs money and delays the order, so it can only be spent on a minority. A model that ranks well but cannot name the orders to hold has not finished the job.

Which orders should Everrest hold for QC before dispatch - and does holding them actually pay?
  • Could a scorer have known each feature at the moment of the decision?
  • Has the label had time to arrive, and what do we do with the rows where it has not?
  • How high can any model on this data possibly score, and how close are we?
  • Does the model beat a one-line SQL rule by enough to be worth maintaining?
  • At what probability does holding an order stop costing more than the return?
04
Step 4

Find-skills - four small modules that do the unglamorous half

The modelling library is off the shelf. What was missing was everything around it, so this skill ships it: four dependency-light modules (numpy and pandas only), each usable on its own against your own table without running the rest of the pipeline.

asof.add_history()

Point-in-time entity aggregates with a separate availability lag per fact. Self-inclusion is impossible by construction, which is the property groupby.transform("mean") silently lacks.

leakcheck.audit()

Reads the feature table and flags label echo, informative missingness, future-fact category levels, the self-inclusion fingerprint, duplicate ids across a split, and a split that ignores time.

censor.maturity_days()

Measures how long the outcome actually takes to arrive, then refuses to label anything newer than that.

economics.decide()

Turns a calibrated probability into an act/do-not-act call using the cost of the event, the cost of acting, and the one parameter nobody can measure.

OSS tool

LightGBM

Gradient boosting with native categorical handling and early stopping. Indifferent to the M0007 outlier, which a scaled linear model is not.

OSS tool

scikit-learn

Isotonic calibration, permutation importance on held-out data, and the logistic baseline that keeps the tree honest.

Pattern

Rolling origin with an embargo

Each fold trains behind a 20-day gap so no training label is still unresolved when the test window opens.

Pattern

Computed Bayes ceiling

The generative process is known, so the best possible AUC is calculable - as a sampling band, not a line.

Skill

upstream Everrest skills

The warehouse skill built the marts; the DQ skill gated them. This layer inherits their lineage and their planted traps.

Pattern

The one-line baseline

WHERE tier = 'premium'. Every model owes its existence to beating this, and most do not.

05
Step 5

Build - six pipelines, one ceiling, and a decision in dollars

One deterministic script (seed 42, two runs byte-identical) walks from the marts to a signable policy. Grab the real code:

5a · Ask when each fact becomes knowable, not just what it says

This is the step almost every tutorial skips. That an order happened is known instantly. Whether it came back is known up to 20 days later. Using one as-of cutoff for both quietly trains the model on labels that had not been observed.

Timeline diagram showing prior orders knowable at lag zero and prior outcomes only knowable after a twenty day maturity window
Histogram of days between order and return spanning two to twenty days with the maturity cutoff marked at twenty days

The maturity window is measured, not chosen: 20 days covers 99% of observed return arrivals.

5b · Cut the label where the clock has not finished

Weekly observed return rate flat across the year then collapsing in the final weeks marked in red as right-censored
Timeline of burn-in, train, valid, test and censored windows with row counts and return rates for each

Left: the return rate appears to collapse at the edge of the window. That is the calendar, not the business - those orders simply have not come back yet. Their apparent rate is 5.18% against a true base of 6.84%. Right: so 2,334 rows after 2026-06-10 are dropped rather than labelled zero, and the split runs by time with a burn-in that exists only to build history.

5c · The leak ladder - and the number that makes it provable

Because the generator draws returns from merchant tier alone, the Bayes-optimal AUC can be computed. On a finite test set even the perfect scorer returns a random number, so it is a band, not a line. Each rung is judged against the ceiling for its own population.

Horizontal bar chart of six pipelines and one rule against their own ceiling bands, with M0 at 1.000 and M2 at 0.973 far above their ceilings in red
Five of these pipelines leak. Only two look wrong. The other three land inside their own sampling band and pass every metric check you could run.

That is the finding worth carrying to your own work. A held-out score is not a leak detector - it exposes a leak only when the leak happens to be enormous. Catching the rest means reading the pipeline.

Two histograms comparing a whole-dataset customer return rate which separates the label almost perfectly against the as-of matured version which does not
Bar chart showing AUC of 0.816 on test rows the model already saw in training against 0.665 on rows it never saw

Left: the median customer has 2 delivered orders, so a groupby.transform("mean") "history" is substantially the row's own label handed back to it. Right: the duplicate leak is severe where it lands - 0.816 on the 179 memorisable rows against 0.665 elsewhere - and completely invisible in the headline, because those rows are 1.8% of the test set. At a 20% duplicate rate, one bad backfill, it moves the headline and you ship it.

Bar chart comparing outcome features with no maturity lag at 0.680 against features lagged twenty days at 0.677

The availability leak is worth only +0.0031 AUC here, well inside the band. Undetectable, and still wrong: in production those features would be computed from a warehouse that has not received the returns yet, so the model would meet inputs it never trained on.

5d · The screen that reads the pipeline instead of the score

Because three of the five leaks are invisible in the metric, the check has to look at the table. This is a real captured run of leakcheck.audit() against the naive one-big-table version - 8 findings, 4 critical:

output/leak_audit.txt · real run
[!!] ceiling_breach     -
     measured AUC 0.9732 exceeds the defensible ceiling 0.6717 by +0.3015.
     Something in the table knows the answer.
[!!] random_split       order_ts
     100.0% of training rows are NEWER than the oldest held-out row.
     The model is being asked to predict the past.
[!!] label_echo         cust_rate_global
     alone scores AUC 0.9632. A single column this good is almost always
     the answer written down somewhere.
[!!] missingness_echo   return_lag_days
     is null for 94.7% of rows and the NULLNESS alone scores AUC 0.0000.
     The join that created this column probably only fires after the outcome.
[! ] constant_subset    status
     level 'cancelled' covers 3,997 rows and the label is always 0 there.
     If that is knowable only after the decision, it is a future fact.

It is a screen, not a proof. It cannot know which of your columns is populated after the decision - only you know that. What it can do is refuse to let an obvious one through unremarked.

5e · Does the model beat one line of SQL?

ROC curve with model, computed ceiling and tier rule almost exactly overlapping, beside a precision-recall curve at a 6.7 percent base rate
Histogram of the null distribution of AUC difference from arbitrary tie-breaking with the observed difference marked inside it

Left: three lines almost exactly on top of each other. Right: the model is +0.0054 AUC over tier = 'premium', and the correct null - redraw labels from the true probabilities, since ordering within a tier cannot carry information - puts that at p = 0.22. Note which test you run: the paired bootstrap most teams reach for gives a far tighter interval, because resampling one test set measures how stable the difference is within that sample, never whether the sample was lucky.

Five monthly rolling folds showing model and rule AUC nearly identical, with per-fold differences scattering both sides of zero inside the null band

Five rolling windows, each trained fresh behind a 20-day embargo: mean +0.0072, 3 of 5 favouring the model, p = 0.08. Two windows disagree outright and the fold-to-fold spread is several times the effect. And here is the part worth keeping: we KNOW the true difference is exactly zero, and the evidence still lands at p = 0.08 - the kind of number that gets a model shipped in a Friday review. A p-value near 0.1 on a true null is not a rare accident. It is what a true null produces about one time in ten.

5f · What 35 features actually bought

Split gain beside permutation importance on held-out data, with merchant tier dominating and every other feature near zero

11 of 35 features clear a permutation test, but tier alone accounts for 92% of the total positive importance. The other ten are the tail of a noise distribution, which is exactly what a permutation test looks like when there is nothing left to find.

Coverage bars showing merchant history at 100 percent and customer outcome history at 78.6 percent
Monthly return rate flat across the window with monthly test AUC also flat

Left: roughly one order in five must be scored with the customer block missing entirely. Coverage is knowable on day one; check it before you invest in a feature family, because the fallback path is part of the model whether you designed it or not. Right: flat base rate, flat monthly AUC. The model is stable, and now that is known rather than hoped.

5g · The decision, in dollars

Reliability diagram comparing raw and isotonic-calibrated probabilities against observed return rate

Calibration reported honestly: isotonic moved the Brier score from 0.0605 to 0.0607. It did not help. With so little real signal the model already produces near-calibrated probabilities at three levels, and fitting a calibration map on a small held-out slice adds more variance than it removes. Worth running, worth reporting either way.

Net saving against a global flag threshold, peaking at 934 dollars, with the per-order rule at 1534 dollars marked above it
Scatter of order value against calibrated return probability with a break-even curve separating held orders from normally dispatched ones

Act when p × effectiveness × cost_of_return > cost_of_hold. Because a return's cost scales with order value, that is a threshold per order, not one number - and it beats the best possible single cutoff by $600 on the test window. The policy is a curve through risk and value.

Hold 887 orders (11.7%) · net $1,534 per test window
Catches 25.7% of returns at 14.8% precision against a 6.7% base rate · median flagged order $236 against $82 across all orders · and the whole thing rests on one number nobody measured: that a QC hold prevents 30% of returns.
Net saving rising with assumed QC effectiveness, breaking even at five percent, with the assumed thirty percent marked

The declared assumption, swept. The programme breaks even near 5% against 30% assumed, so the policy has real headroom.

$1,534 a test window is about $7,900 a year. That will not pay for the quarter it takes to build this, the platform it runs on, or the person who maintains it.

Two things are missing from that number, and both make it worse: the hold delays orders, and 85% of held orders were never going to come back - that customer cost is real and is not in the model. So the honest recommendation is not "ship the model". It is: run the hold manually on the 11.7% of orders the rule selects, measure effectiveness against a randomised holdout for one quarter, and revisit. If it comes back near 30% and volume grows, the engineering is a week. If it comes back near 5%, you have saved yourself the quarter.

5h · The scoreboard

PipelineScored onAUCIts own ceilingLeakCaught by the metric?
M0 one big table, returns joinedall orders1.00000.7486label echo✓ obvious
M1 label dropped, status keptall orders0.75110.7486future fact✗ invisible
M2 whole-dataset entity ratesdelivered only0.97320.6707aggregate + self✓ obvious
M3 as-of features, random splitdelivered only0.66740.6736duplicate rows✗ invisible
M3b as-of, no maturity lagdelivered only0.68020.6717availability✗ invisible
M4 as-of + matured + temporaldelivered only0.67710.6717none found-
RULE tier = 'premium'delivered only0.67170.6717none-

Each rung is judged against the ceiling for its own population: a non-delivered order cannot be returned, so on the all-orders population the true probability is structurally zero there, which lifts the ceiling to 0.7486. Comparing every rung to one number would have been its own quiet error. Filtering M1 to delivered-only rows drops it to 0.6709 - the status leak disappears the moment the population matches what you will actually score.

06
Step 6

Expert review - the panel that found a bug in the skill's own library

Five senior reviewer agents - ML methodology, data engineering, MLOps, commercial operations, executive insight - reviewed the v1 code and its real outputs. This was not polish. One finding was a genuine bug in a shipped module that had inflated a headline number by a factor of forty, and two findings changed what the page concludes. Every fix below is in the shipped code, and the whole pipeline was re-run afterwards.

Data Engineering Lead
12 yrs · reproducible pipelines

"Your asof_reduce uses searchsorted(..., side='right') for every lag. At lag 0 that means ts <= ts, so every row counts itself, and rows sharing a timestamp count each other. Your own docstring promises self-inclusion is impossible. It is not, and your M3b rung is measuring that bug, not the availability lag you think it is."

Fix applied: strict comparison at lag 0, inclusive beyond it. M3b fell from 0.9762 to 0.6802 - the +0.30 "availability leak" the v1 page would have published was the library's own defect. The real leak is +0.0031 and honestly undetectable.
ML Methodology Lead
15 yrs · applied prediction

"Two problems with the ceiling. It is a point estimate on a finite sample, so drawing it as a hard line convicts honest models and acquits leaky ones - your own M4 crossed it by 0.0004 and got labelled impossible. And M0 and M1 are scored on all orders while the rest are delivered-only. Those populations have different ceilings. You are comparing rungs to a number that does not belong to them."

Fix applied: the ceiling is now a simulated 95% sampling band, computed per population. The all-orders ceiling is 0.7486 against 0.6717 delivered-only. Reframed the whole chart: 5 leaks, only 2 detectable from the metric.
MLOps Lead
12 yrs · models in production

"Your isotonic calibration is fit on the same validation rows that chose the tree count. The probabilities are being tuned on their own selection set. Separately: the script imports its library through a relative sys.path hop and will fail with an import error nobody can diagnose if the folder moves."

Fix applied: validation split in half by time - earlier half stops training, later half calibrates. A path guard now fails with an explicit message. Calibration then honestly showed no improvement (Brier 0.0605 to 0.0607), which the page now reports rather than hides.
Commercial Operations Lead
18 yrs · fulfilment P&L

"You are reporting a per-window figure and calling the headroom 'fundable'. Annualise it. And where is the cost of delaying the 85% of held orders that were never going to come back? You have costed the return and not the intervention's own damage."

Fix applied: the page now leads the commercial section with roughly $7,900 a year and states plainly that it will not pay for the build. The uncosted customer-delay externality is named. The recommendation changed from "ship the model" to "run it manually for a quarter and measure the effectiveness you assumed".
Head of Insights
14 yrs · exec reporting

"Your significance chart is titled 'sits inside the noise it should' while the chart plainly shows the observed value outside the null band. A title that contradicts its own figure is worse than a wrong number. Also, every dollar figure on your money charts is rendering as mangled italics."

Fix applied: title now matches the figure. The dollar signs were being parsed as matplotlib mathtext and are escaped throughout. Two further claims in findings.md contradicted their own computed values ("the duplicate leak did not bite" when it measured 0.816 vs 0.665, and "absent for most orders" at 78.6% coverage) and were corrected against the numbers.
before → after · the one-character bug that inflated a headline by 40x
# v1 (before): one comparison for every lag.
# At lag 0 this asks ts <= ts, so a row counts ITSELF, and its own label
# lands in its own "prior history" feature.
k = np.searchsorted(g_ts, g_ts - lag, side="right")

# v2 (after): strict when there is no lag, inclusive when there is
# strict at lag 0 so a row never counts itself or its timestamp twins;
# inclusive beyond that so a just-matured row is not thrown away
k = np.searchsorted(g_ts, g_ts - lag, side="left" if lag == 0 else "right")

# M3b: 0.9762 -> 0.6802. The leak the page was about to headline was ours.

Run this skill on your own schema

Install once, then use whichever piece you need - the leakage screen alone is worth an afternoon on any table you are about to model.

terminal
/plugin marketplace add phoebefu6/phoebe-data-skills
/plugin install how-to-feature-to-model@phoebe-data-skills