Skip to main content
ML Engineering

Feature engineering that survives contact with reality

Models that look strong offline and disappoint in production usually have nothing wrong with the model. Six things go wrong with the features, all of them are engineering problems, and each one has a test that catches it before a customer does.

The pattern is familiar enough to name. Strong offline numbers, a launch, a first month well below what the validation set promised, then a week of arguing about regularization. Regularization is almost never the answer. In the systems we get called into, the data reaching the model at inference time is not the data it was fit on, and the difference came from a join, a default value, a timestamp, or a second implementation of the same feature written six weeks later by someone reading the first one.

Feature engineering gets taught as a creative act: a clever ratio, a rolling window, a well-chosen encoding. That part matters less than people expect. What decides whether a model earns its operating cost is unglamorous and mostly about time, identity and defaults. This is the checklist we work through, in order, with the test that catches each failure.

You are probably here because

  • The validation numbers were strong, the launch happened, and the first month live is nowhere near them.
  • The same feature returns one value from the warehouse query and a different value from the service, and nobody can say which one is correct.
  • An upstream feed started returning nothing, the nulls became zeros, and it took weeks before anyone noticed.
  • You cannot answer, for one scored entity, what its features were at the moment the decision was made.

The sections below on leakage, point-in-time correctness and training-serving skew take these one at a time, and in almost every system we open they turn out to share a single root cause: the rows the model was fit on could not have existed at the moment of the decision.

Leakage, and the four kinds that survive code review

Target leakage is the one everybody knows: a column populated only because the outcome already happened. A refund_amount field in a refund model, a collections_agent_id in a default model, an account status of closed in a churn model. These get caught because somebody eventually asks what the column means.

The other four survive review because each looks like ordinary data work.

Temporal leakage. The feature is computed from a window that includes data which did not exist at the moment of prediction. A thirty-day aggregate anchored on the label date instead of the decision date pulls the future into the row. This is the most common leak we find, and it comes from writing the training query in the warehouse, where every table is complete, then serving from a system where it is not.

Group leakage. One entity appears on both sides of the split. A customer with forty sessions gets thirty in train and ten in test, and the model learns the customer rather than the behavior. Random train_test_split guarantees this on any event-level data with repeated entities. The fix is GroupKFold keyed on the entity, a time-based split, or both.

Preprocessing leakage. A scaler, imputer or encoder fit on the full dataset before the split, so every fold sees statistics computed from its own holdout. It inflates results by a small amount that is hard to attribute, which is what makes it dangerous. Put every fitted transform inside a scikit-learn Pipeline or ColumnTransformer, and keep the fitted object with the model artifact rather than reconstructing it in the serving code.

Duplicate leakage. Reprocessed batches, retried writes and near-identical events under different ids put the same record on both sides of the split. Any pipeline with at-least-once delivery produces these eventually. Deduplicate on a business key before splitting, not after.

LeakWhat it looks likeWhere it comes fromThe test that catches it
TargetOne feature carries most of the importance and nobody can explain whyA column written by the process that produces the outcomeAblate it and retrain. If performance collapses, read the column's write path.
TemporalOffline results well above anything the model achieves on live trafficWindow anchored on label time instead of decision timeRecompute the training set through the serving path at the historical decision timestamp and compare.
GroupCross-validation scores with unusually low variance across foldsRandom splits on event rows with repeated entitiesCount entities appearing in more than one fold. It should be zero.
PreprocessingA small, consistent gap between cross-validation and a held-out setFitting transforms before splittingMove every transform inside the pipeline, rerun, compare.
DuplicateNear-perfect performance on a subset of rowsRetries, reprocessed batches, soft deletesHash the business key, count collisions across the split.

Point-in-time correctness is most of the job

Every training row needs its features as they stood at the moment the decision would have been made, not as they stand when you run the query. That is easy to agree with and hard to implement, because most operational schemas are built to answer "what is true now" and destroy the history you need.

The usual offender is a mutable dimension table. A customers row has plan_tier, and an upgrade overwrites it in place. Train on that column and every historical row carries today's plan, including rows from before the upgrade. The model learns that premium customers behave in a way they only began behaving after they upgraded. Nothing errors. Offline numbers improve.

Three mechanics fix this, all ordinary engineering. Capture history at the source with slowly changing dimensions, which dbt snapshots implement directly. Store the table in a format with snapshot semantics, which Apache Iceberg and Delta Lake both give you, so a query can read it as of a timestamp. Or write an append-only event log and derive state from it, which is more work up front and the least fragile.

The Join That Has To Be Right

Two timestamps per row, and an as-of join instead of an equality join

Every feature value carries an effective time (when the fact became true) and an available time (when your systems could have known it). Training joins must use available time, because that is what the serving path would have had. The operation is an as-of join, not an equality join: for each entity and decision timestamp, take the most recent row whose available time is at or before it. That is merge_asof in pandas, a windowed join in Spark or SQL, and the main reason feature stores exist. A query that joins on date alone produces rows that could not have existed.

Training and serving disagree for four reasons

Skew is not a mysterious property of production. It has a short list of causes, and on any given system you can go and check each one in an afternoon.

Two code paths. The offline feature is a SQL expression in the warehouse. The online feature is Python in a request handler. They agree the day they are written. Then someone changes a rounding rule, or a window to include today, on one side only.

Two data sources. Offline reads the warehouse, which has been deduplicated, backfilled and repaired. Online reads the operational database or a cache, which has none of that. Same column name, different content.

Two time semantics. The batch job computes a thirty-day window over complete days. The service computes one ending mid-afternoon, over a partial and still-arriving day. Counts run low at serve time for structural reasons, and the model reads low counts as signal.

Two default policies. The offline join misses and produces NULL, which the training pipeline imputes with a median. The online lookup misses and the service returns 0 because that was the simplest thing to write. The single most common production skew we find is a disagreement about what absence means.

A feature is a promise that the same computation ran on the same inputs at training time and at serving time. Nothing in the type system enforces that promise, so something else has to.

One definition, one code path, and a parity test when you cannot have one

The structural fix is to define each feature once, in version control, as code rather than as two dialects of the same intent. Feature stores like Feast and Tecton exist mostly to enforce this, and at smaller scale a warehouse plus a disciplined library gets you a long way. What matters is that there is one definition and it has an owner.

Name features so the definition is legible without opening the code. orders_completed_30d_count gives a reader the event, the state filter, the window and the unit. orders gives them nothing, and in six months two teams will mean different things by it.

Sometimes one code path is genuinely impossible. A batch job in Spark and a low-latency service in Go will not share an implementation. When that happens you owe a parity test. Sample real production requests, compute every feature both ways, and assert equality within a tight tolerance. Run it in continuous integration on every change to either side, and nightly against yesterday's live traffic. A mismatch rate above zero is a bug with a known cost.

  • Two implementations of the same feature with no automated comparison between them
  • Feature names with no window and no unit, so nobody can tell 30 days from 30 events
  • fillna(0) applied across a whole frame because the model would not fit otherwise
  • Transforms fit on the full dataset, then reimplemented by hand in the serving code
  • Joins on ingestion date because the event timestamp was inconvenient
  • Derived features stored without the raw inputs, so no value can ever be recomputed or explained
  • A backfill written as a separate script from the forward path, with different logic in it

Labels arrive late, and that shortens every honest evaluation window

Feature timing gets attention. Label timing gets much less, and it decides how much of your data is usable. Card disputes can land months after the transaction, an insurance claim settles on its own schedule, a subscription is not churned until a renewal date passes, and collections outcomes resolve over quarters.

Every label therefore has a maturity period, and rows younger than it are not labeled, they are provisionally labeled. Include them and the recent past looks clean, because only the fast-settling outcomes have arrived. The model learns that recent activity is safe. It is not, it is unresolved.

If a label takes sixty days to settle, an evaluation window shorter than sixty days is measuring the outcomes that were easy to observe, not the ones that matter.

Two practices handle this. Record a label-available timestamp beside the event timestamp, so evaluation can filter on maturity rather than intuition. And end the test window at least one maturity period before today, accepting that the most recent evaluation is always somewhat stale. That cost is smaller than shipping on numbers computed from half-arrived labels.

Missing is not zero, and a default is a decision

There are at least three reasons a value is absent, and each calls for different handling. The attribute does not apply to this entity, it applies but has not been observed yet, or the upstream system failed and the value should exist.

The third is the one that hurts, because a failed feed presents as a legitimate value. A join that silently returns nothing becomes a zero, the zero is in range, and the model scores it. Nothing alerts. Catch it by alerting on null rate and freshness per feature rather than inferring breakage from model output, which moves last.

For numeric features where zero is meaningful, a zero-fill is a factual claim about the world. days_since_last_login filled with 0 says the customer logged in today, the opposite of what absence usually means. Prefer an explicit missingness indicator alongside an imputed value, so the model can learn that absence carries signal. XGBoost and LightGBM handle missing values natively and learn a default direction per split, usually better than anything you would impute by hand.

Whatever the policy, it has to be one object shared by both paths. Fit the imputer, serialize it with the model, and load it in the service. A default that lives in serving code as a literal will diverge from the training default, and the divergence will be invisible until somebody diffs the two.

Cardinality is where features quietly stop working

Categorical features degrade in a way continuous ones do not. They keep returning values, and the values keep meaning less.

Unseen categories. New merchants, device types, SKUs, campaign codes. The encoder needs an explicit out-of-vocabulary bucket, and that rate needs to be a live metric. A rate climbing from low single digits toward double digits is a retraining trigger, and it moves long before accuracy visibly does.

Target encoding done carelessly. Replacing a category with its mean outcome is a strong technique and a leakage engine if that mean is computed on the same rows the model trains on. Compute it out of fold, with smoothing toward the global mean for rare categories. Done in one line over the whole training frame, it is leakage wearing a respectable name.

Identifier features. Raw user_id or device_id as a category memorizes individuals. On a group-leaked split it looks excellent. On new entities, which is the production case, it contributes nothing and consumes capacity.

Growth as a capacity problem. An embedding table or hash space sized for today's cardinality is a commitment. Hashing bounds memory by trading it for collisions, and the collision rate rises as cardinality grows. Track distinct counts per feature over time and treat the trend as a planning input.

Send it over and we will tell you what we would change.

Email the offline definition and the online lookup for three features you rely on most, plus the offline and live numbers for the model that uses them, to contact@precisionfederal.com. You get back a short written note naming the three things we would change and why. One business day. No charge, no meeting, no deck.

contact@precisionfederal.com

The latency budget decides what a feature is allowed to be

A feature that takes 400 milliseconds to compute is not a feature for a synchronous decision, however predictive it is. The budget belongs to the request, measured at p99 rather than the mean, because the tail is what users and downstream timeouts experience.

Work backwards. If the product allows 150 milliseconds end to end and the model takes 20, then retrieval, transformation and serialization share what is left with network overhead. Below is how we allocate a 50 millisecond retrieval budget. The numbers move per system, and writing them down at all is most of the value, because it turns an argument about elegance into an argument about milliseconds.

A 50 ms Online Feature Retrieval Budget

On-demand aggregation over a recent window
15 ms
Key lookup against the online store
12 ms
Network hop and authentication
8 ms
Transformation and encoding
6 ms
Headroom reserved for the tail
5 ms
Serialization into the model input
4 ms

A starting allocation, measured at p99 with a cold cache. Adjust to your endpoint, then hold the line.

Four classes of feature fit that budget differently, and the class is a design decision to make before the feature is written rather than discover during load testing.

ClassExampleFreshnessBehavior when the source is down
Precomputed batch90-day spend percentile per customerHours to a dayServes the last written value, which usually works and always needs a staleness metric
Streaming windowEvents in the last five minutes for this accountSecondsWindow silently truncates, so counts read low; needs a watermark lag alarm
Request-derivedAmount relative to the account's stored averageExactFails only if the request itself is malformed
Third-party callExternal enrichment or geolocation lookupExact, at a costNeeds a timeout and a fallback that also exists in the training data

That last row is worth dwelling on. A synchronous external call needs a timeout, and a timeout needs a fallback value. If that fallback never appeared during training, the first time the vendor has a bad afternoon your model sees a distribution it has never seen, at the moment traffic is already degraded. Inject the fallback into training data at a realistic rate.

Backfills are a separate program from the pipeline

Every feature you add needs history, and generating it is not the same job as running the pipeline forward. Treating a backfill as "the same code with an older date parameter" is how leakage gets introduced after launch rather than before it.

Writes must be idempotent, keyed on entity plus feature plus event time, so a rerun converges instead of double-counting. The backfill must read historical dimension state rather than current state, the same as-of discipline as above and the easiest place to lose it, because current state is right there and faster. And the compute cost deserves an estimate before anyone commits to a date.

What we check before a feature ships

Below are the weights we use when reviewing a feature set, fixed before we look at anything so the review is not steered by whatever we find first. Adjust them to context: long label maturity moves label timing up, a strict latency endpoint moves serving cost up.

Feature Review Weights

Leakage and point-in-time correctness
25
Training and serving parity
22
Label availability and maturity handling
18
Missingness policy and shared defaults
13
Cardinality, vocabulary and encoder health
12
Serving latency and failure behavior
10

Weights sum to 100. Set them before scoring, not after.

And the artifacts that travel with a feature before we consider it done:

  • A definition in version control, with an owner and a name carrying window and unit
  • An as-of join test proving no training row used data unavailable at its decision time
  • A parity test comparing offline and online values on sampled real requests, running in CI
  • Assertions on null rate, range and distinct count, enforced in the pipeline
  • A documented default per failure mode, shared by both paths as one serialized object
  • A p99 latency measurement taken with a cold cache, not a warm one
  • An idempotent backfill that reuses the forward code path
  • A lineage query that answers why this entity had this value at this timestamp

What to monitor once it is live

Monitor features, not only the model. Prediction distribution and accuracy move late, after the damage, and by then the diagnosis is a long one. Feature-level signals move first and point at an owner.

The set worth instrumenting is short. Null rate per feature. Freshness lag from event time to available time, which is the best early indicator of an upstream problem. Out-of-vocabulary rate for categoricals. Distinct-count trend. A population stability index or Kolmogorov-Smirnov statistic against a fixed reference window, per feature rather than only on the score. Mismatch rate from the shadow comparison. Retrieval p99.

The prediction distribution is the last thing to move. By the time it does, the feature that broke has been broken for a week and the retraining set is already contaminated.

Alert on the operational ones first: freshness and null rate have owners and runbooks. Compute distribution statistics from day one and page on them only once you know their normal variation, otherwise they generate noise that trains everyone to ignore the channel.

The cost of finding it late

These are the planning multipliers we use when deciding how much test work a feature deserves. They are estimates, not measurements, and they make one point: the cost of a defect is dominated by when it is found, not by how hard it is to fix.

Planning Multiplier — Cost To Fix By Where It Is Caught

Caught while writing the definition
Caught by an offline parity or as-of test
Caught replaying traffic in staging
Caught in the first week of live traffic
15×
Found after six months of decisions
40×

Planning heuristic, not a measurement. The shape is what matters.

The curve is steep because a late discovery is never only a code fix. It is a fix, a backfill, a retrain, a revalidation, and an explanation to everyone who consumed outputs while the input was broken. The engineering is the cheap part.

The order we would do the work

Hardening Pass

1
Inventory every feature with its owner, source, window and serving path
Days 1–3
2
Audit leakage and rebuild the training set through an as-of join
Days 3–9
3
Stand up the offline-to-online parity test on sampled real requests
Days 8–14
4
Fix defaults and missingness, and make both paths share one fitted object
Days 12–18
5
Instrument freshness, null rate, vocabulary and retrieval p99
Days 16–22
6
Retrain on the corrected set and record the honest before-and-after
Days 20–26

Step six usually produces an uncomfortable meeting, because the corrected offline number is lower than the original. It is also the first number anyone has that predicts production behavior, which makes it the more useful of the two.

Bottom line

Feature engineering that survives production is mostly about time and agreement. Time, because every value has a moment it became knowable and training has to respect it. Agreement, because the offline and online paths make claims about the same quantity and nothing forces them to match unless you build the thing that does. Get both right and ordinary features perform close to their offline promise. Get them wrong and no model selection recovers the gap, because the model was never what broke.

Common objections

Do we need a feature store for any of this?

No. A feature store packages point-in-time joins, paired offline and online stores, and a shared definition layer, which earns its cost once you have many features across several models. Below that, a versioned definition library, snapshot-capable tables and a parity test in CI cover the same failures with less operational surface. Adopt one when hand-maintained consistency becomes the bottleneck, not before.

Can we skip all this by logging features at serving time and training on those logs?

Logging exactly what the model received removes skew by construction and is a good practice. It has two limits. You can only train on features that already exist in production, so a new feature needs a backfill through the offline path anyway. And logged features inherit whatever bug the serving path had, so the training set encodes it. Log the served vectors, and keep the offline path correct as well.

Our warehouse and dbt models are already tested. Is that not the same thing?

Warehouse tests check that a table is internally consistent: uniqueness, referential integrity, accepted values, freshness. They do not check that a training row could have been produced at its decision timestamp, and they do not compare the warehouse to the serving path. Those two checks catch the failures in this article, and they have to be written specifically.

Frequently asked questions

What is training-serving skew and how do you detect it?

It is any difference between the feature values a model was trained on and the values it receives in production. Detect it by sampling live requests, recomputing every feature through the offline path at the same timestamp, and comparing value by value. Run it nightly and treat a nonzero mismatch rate as a defect.

What is a point-in-time join and why does it matter?

It selects, for each entity and decision timestamp, the most recent feature value that was actually available then. It matters because most operational tables are mutable, so a naive join attaches today's values to historical rows. That produces training data that could never have existed, and offline results production cannot reproduce.

How do you handle labels that take weeks or months to settle?

Store a label-available timestamp beside the event timestamp, define the maturity period explicitly, and exclude immature rows from training and evaluation. End the test window at least one maturity period before today. Partially settled labels inflate results, because the outcomes that arrive fastest are not representative.

What should you monitor on features after launch?

Null rate, freshness lag from event time to available time, out-of-vocabulary rate on categoricals, distinct-count trend, a distribution statistic against a fixed reference window, offline-versus-online mismatch rate, and retrieval p99. Page on freshness and null rate first, since those have owners and runbooks. Compute distribution statistics immediately, and page on them only once you know their normal range.

Is target encoding safe to use on high-cardinality categories?

Yes, computed out of fold and smoothed toward the global mean so rare categories are not encoded by one or two observations. Computed in a single pass over the whole training frame, it leaks the outcome into the feature and inflates offline results by a margin that only shows up in production.

1 business day response

Offline numbers you cannot reproduce in production?

Send us the feature list, how each one is computed on both paths, and what the live results look like. Our engineers will read it and come back with the ranked findings, or take the hardening pass as a scoped piece of work. Email bo@precisionfederal.com.

Email an engineerCapabilitiesMore insights →
Feature EngineeringML PlatformData InfrastructureBackend Systems