CineClick now has tracked experiments and a champion model, versioned and loadable by alias. Before serving it in module 4, one silent risk remains: the model doesn't consume raw data, it consumes features, and those features will have to be recomputed in production. If the production computation differs in anything from training's — a rounding, a default value, a time window — the model will receive numbers different from the ones it learned on, and its predictions will degrade with no visible error whatsoever. This problem is called training/serving skew, and the industry has built an entire category of tools to attack it: feature stores. In this lesson you'll understand what they are, which problems they genuinely solve (skew, point-in-time correctness, reuse), and — just as important — when you do not need one. For CineClick we'll make a deliberately modest decision that module 4 will build on.
Contents
- Training/serving skew: the silent corruption
- The
ratio_ticketscase: one feature, two implementations - What a feature store is: registry, offline store, and online store
- Point-in-time correctness: temporal leakage
- Components and flow of a feature store
- The landscape: Feast and the managed offerings
- A critical look: when you don't need one (and what CineClick does)
- Closing module 3
Training/serving skew: the silent corruption
Let's recap CineClick's chain: prepare_data cleans the CSV, build_features (the code in features.py) transforms raw columns into the features the model expects, train fits the RandomForest. All of that happens at training time, with pandas, over a historical file.
Now picture module 4's service in production: a request arrives with the raw data of one customer and it must be answered in milliseconds. Someone has to compute that customer's features at that moment, in different code and a different context. That's where the crack opens:
- Two implementations: the training one (pandas, batch) and the serving one (maybe plain Python, maybe another team, maybe another language). Two codebases that "do the same thing" diverge sooner or later.
- Silent failure: if the production feature is 0.8 where training would have said 1.2, there's no exception, no error log. The model predicts; it simply predicts worse. It's the worst kind of bug: the one that doesn't warn you.
- Late detection: it gets discovered weeks later, when someone notices that retention campaigns perform below what the offline metrics promised — if anyone notices at all.
This is problem #7 from module 1's diagnosis ("features are computed in the notebook and nobody knows how to recreate them elsewhere"), and it's one of the biggest model killers in the industry. The classic paper on technical debt in ML lists it among the costliest patterns precisely because of its invisibility.
The ratio_tickets case: one feature, two implementations
Let's make it concrete. The retention team suggests a promising new feature: the customer's friction intensity, ratio_tickets = support_tickets / tenure_months — 6 tickets over 36 months is not the same as 6 tickets in 3.
Laura implements it in features.py for training:
# features.py (training, pandas, batch)
def build_features(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
# Customers with tenure 0 (signed up this month): avoid division by zero
# by treating tenure as at least 1 month
df["ratio_tickets"] = df["support_tickets"] / df["tenure_months"].clip(lower=1)
...
return dfMonths later (a hypothetical but painfully realistic scenario), another developer implements the computation in the prediction service, reading the feature's description in a document:
# prediction service (serving, another file, another person)
def compute_ratio_tickets(customer: dict) -> float:
if customer["tenure_months"] == 0:
return 0.0 # "no tenure, no ratio", they reason
return customer["support_tickets"] / customer["tenure_months"]Both implementations are reasonable. Both pass their tests. And they don't compute the same thing: for a brand-new customer with 3 tickets, training said 3/1 = 3.0 (extreme friction, a strong churn signal) and production says 0.0 (no friction at all). Precisely in the segment where the feature is most informative — new customers with problems — the production model receives the opposite value from the one it learned. Nobody will ever see an error; only a model that "doesn't work as well in production".
The possible remedies, from least to most machinery:
- A single source of truth in code: training and serving import the same function from the same package. No feature is ever implemented twice.
- A feature store: feature definitions live in a central system that computes them and serves them to both worlds.
Let's look at option 2 seriously before deciding.
What a feature store is: registry, offline, and online
A feature store is a layer of infrastructure dedicated to managing features as first-class assets. Its three pieces:
- Definition registry: a central catalog where each feature is defined once — name, the entity it belongs to (the customer), type, computation logic, owner. It's the single source of truth: training and serving consume the same definition, so implementation skew disappears by construction. It also enables reuse: if tomorrow CineClick builds a second model (say, upgrade propensity), it reuses
ratio_ticketsinstead of reimplementing it. - Offline store: historical feature values, timestamped, optimized for bulk reads. It's where training datasets are built from — and its superpower is point-in-time correctness, which deserves its own section.
- Online store: the current feature values per entity, in a low-latency store (typically Redis or similar). When customer 4812's request arrives, the service doesn't compute anything heavy: it does a millisecond lookup and gets the same features, computed by the same logic, that fed training.
Point-in-time correctness: temporal leakage
The offline store solves a second problem, subtler than skew: temporal data leakage. An example with CineClick:
Suppose we train with March labels: churned=1 means "this customer cancelled in March". To build the dataset, someone queries support_tickets from the operational database... today, in July. Customer 4812 cancelled in March after opening 4 furious tickets that very March, already mid-cancellation, and then 2 more in April fighting over the refund. The dataset says support_tickets=6 for a customer labeled with March data — but at the moment of the real prediction (February, when they could still be retained) they had 0.
The model learns a rule that's terrific in training and useless in production: "many tickets ⇒ churn"... because many of those tickets are a consequence of the cancellation, not a prior signal. Inflated offline metrics, disappointing real performance. It's the temporal equivalent of training with the answer leaked in.
The solution is called a point-in-time join (or time-travel join): for each training row, the offline store retrieves the value each feature had on the label's date, and not a day later:
| customer_id | label_date | support_tickets (today) | support_tickets (point-in-time) |
|---|---|---|---|
| 4812 | 2026-03-01 | 6 | 0 |
| 1157 | 2026-03-01 | 2 | 2 |
| 3094 | 2026-03-01 | 5 | 4 |
Doing this by hand with pandas is possible (merge_asof, surgical care with the dates) but it's a minefield; feature stores give it to you solved because they store the history with timestamps. It is, arguably, their most valuable technical contribution. (An honest note about our project: churn_customers.csv is a static snapshot with no timestamps, so this risk is latent rather than active; it will become very real when we discuss retraining on continuously arriving data in module 6.)
Components and flow
graph TD
D["Feature definitions<br/>(code versioned in Git)"] --> REG["Feature store registry"]
F["Data sources<br/>(operational DB, events)"] --> ING["Materialization pipelines"]
REG --> ING
ING --> OFF[("Offline store<br/>history + timestamps")]
ING --> ON[("Online store<br/>current values, low latency")]
OFF -->|"point-in-time join"| TR["Training dataset"]
ON -->|"lookup in ms"| SRV["Prediction service<br/>(module 4)"]
TR --> MOD["Model"]
MOD -.->|"same features,<br/>same definition"| SRVThe full flow: the definitions (in code, versioned in Git) get registered; materialization pipelines compute the values from the sources and write them to both stores; training reads from the offline store with point-in-time joins; serving reads from the online store with lookups. Training/serving symmetry is guaranteed by construction — that is the feature store's contract.
The landscape
Without going into tutorials (that's not this lesson's goal), the lay of the land:
| Option | Type | Traits |
|---|---|---|
| Feast | Open source | The self-managed de facto standard; infrastructure-agnostic (offline over your warehouse/files, online over Redis/DynamoDB...); you operate the pieces |
| SageMaker Feature Store (AWS), Vertex AI Feature Store (GCP), Databricks Feature Store | Managed | Integrated with their platform; less operation, more vendor coupling |
| Tecton, Hopsworks | Specialized commercial | Add managed transformations, streaming features, SLAs |
So the idea of a "declarative definition" is visible in code, this is how a feature view is declared in Feast — read it as a conceptual sketch, not a tutorial:
# Declarative definition: the entity and its features, defined ONCE
from feast import Entity, FeatureView, Field, FileSource
from feast.types import Float32, Int64
from datetime import timedelta
customer = Entity(name="customer", join_keys=["customer_id"])
churn_features = FeatureView(
name="churn_customer_features",
entities=[customer],
ttl=timedelta(days=30),
schema=[
Field(name="tenure_months", dtype=Int64),
Field(name="weekly_hours", dtype=Float32),
Field(name="support_tickets", dtype=Int64),
Field(name="ratio_tickets", dtype=Float32),
],
source=FileSource(path="data/features_history.parquet",
timestamp_field="event_timestamp"),
)From a definition like this, the same system answers get_historical_features(...) (offline, point-in-time) and get_online_features(...) (online, in milliseconds). The definition is one; the consumers, two.
A critical look: when you don't need one
And now, the part many courses leave out. A feature store is serious infrastructure: an online store to operate and pay for, materialization pipelines to orchestrate and watch, a framework to learn, and a new source of incidents ("yesterday's features didn't materialize"). Adopting one without needing it is pure operational debt — the symmetric error to the one this course has been fighting since module 1: the notebook without engineering is as bad as engineering without a problem to justify it.
| Signs you DO need one | Signs you DON'T need one |
|---|---|
| Several models share features and reimplement them | A single model (or disjoint features per model) |
| Expensive features: aggregations over massive history, time windows, streaming | Features cheap to compute on the fly from the request |
| Online serving with a strict latency budget and precomputed features | Batch scoring, or online with features derivable from the payload |
| You need point-in-time correctness over constantly changing data | Static datasets, or a snapshot that's already correct |
| Several teams produce/consume features (discovery, ownership) | A small team with one repo |
Let's run CineClick through this table: one model, one team (Laura, Marc, and you), features computed in microseconds from the customer's own columns, no history aggregations, no streaming. Right-hand column across the board. Decision: CineClick does not adopt a feature store today. But the skew problem is real and must be closed regardless, so we adopt the proportionate remedy — option 1 from section 2, elevated to a project rule:
features.pyis the single source of truth for features. Every transformation from raw data to features lives insrc/cineclick_churn/features.py, inside the installable package. The training pipeline imports it (it already does, via thebuild_featuresstage), and module 4's prediction service will import exactly the same function — same package, same version, same.clip(lower=1). Reimplementing a feature outside that module is forbidden.
This is a perfectly dignified "poor man's feature store": it solves implementation skew through the same mechanism (a single definition) at zero infrastructure cost. It doesn't provide point-in-time correctness or an online store — the day CineClick has three models, aggregation features over streaming viewing events, and a latency requirement that forces precomputation, the table above will say otherwise and the migration to Feast will be natural, because the definitions are already centralized and tested. The right architecture is not the most complete one, it's the one proportionate to the problem — revisited when the problem changes.
Common Mistakes and Tips
- Adopting a feature store "because that's what people do": if your case sits in the table's right-hand column, you end up operating Redis and materialization pipelines to serve features an
importwould have solved. Evaluate signals, not fashions. - The opposite: denying the skew problem because you don't use a feature store: skew isn't cured by ignoring it. If there's no store, there must be another guarantee of a single definition (a shared package, as at CineClick) and, in module 5, tests that watch over it.
- Copying "just this one function" from
features.pyinto the service "to avoid depending on the whole package": that is exactly how the second implementation ofratio_ticketsis born. The package dependency is the guarantee, not a nuisance. - Building historical datasets by querying current values: the temporal leakage from section 4. If your labels have dates, your features must be those of that date. With or without a feature store, always ask yourself: "was this value known at prediction time?".
- Confusing a feature store with "the features table in the warehouse": a shared table helps with reuse, but it gives you no point-in-time joins, no online serving, no definition registry. It may be enough — but be aware of what you're missing.
- Forgetting that feature definitions are versioned too: if
ratio_ticketschanges formula, models trained with the old formula still expect it. Definitions go in Git (with or without a store) and the model's lineage (previous lesson) must make it possible to know which version it was trained with.
Exercises
-
Hunting the skew. Beyond the
tenure_months=0case, find two other subtle ways the serving implementation ofratio_ticketsfrom section 2 could diverge from training's, even after fixing the zero case. Hint: think about data types and about whatsupport_ticketsmeans in each context. -
Temporal leakage diagnosis. Marc proposes adding the feature
weekly_hours_last_month(average viewing hours over the last month) using the current viewing table for labels from three months ago. Explain (a) what value this feature would have in training for a customer who cancelled ten weeks ago, (b) why that inflates the offline metrics, and (c) what the dataset would need in order to build this feature correctly. -
Feature store, yes or no? For each scenario, decide using the signals table and justify in one or two sentences: (a) a fintech with 14 models sharing transactional-behavior features aggregated over 1/7/30-day windows, served online with a 50 ms budget; (b) a two-person team with a monthly batch purchase-propensity model over a static CRM extract; (c) CineClick two years from now, with five models (churn, upgrade, recommendation...) sharing near-real-time aggregates of viewing events.
Solutions
-
Examples of subtle divergence (there are more): (a) types and rounding — in pandas, division produces
float64from integer columns; if the service receives the JSON withsupport_ticketsas a string ("3"), or someone rounds to 2 decimals "to clean up the response", the values stop being bit-for-bit identical, and near a threshold in the trees' splits the prediction can flip. (b) source semantics — in training,support_ticketscomes from the historical CSV (accumulated tickets as per the extract); in production, the service might read from the live support system, which perhaps also counts opened-then-cancelled tickets or excludes billing ones. Same field name, different population. The moral: skew isn't just different code, it's also a different source — which is why the single-source rule covers the function and the contract of its inputs (the model signature from the registry helps with the latter). -
(a) Close to 0: the customer has been gone for ten weeks, so their viewing "over the last month" (counted from today) is nil — when the label was generated, they were an active customer with real hours. (b) The model learns "weekly_hours_last_month ≈ 0 ⇒ churn", a nearly perfect rule in the dataset... that is useless in production, because there the feature is computed for still-active customers: the offline metric measures the ability to recognize already consummated cancellations, not to predict them. (c) A viewing history with timestamps and a point-in-time computation: for each customer, the average hours of the month before their label date. Exactly the join an offline store gives you for free — and which, without one, demands a careful
merge_asofover the history. -
(a) Yes, clearly: many models sharing expensive features (time windows), online serving with strict latency — every signal in the left-hand column at once. (b) No: one model, batch, static data, minimal team; a shared
features.pyand single-source discipline are enough, and the store's operational cost never pays off. (c) Probably yes, and it's the moment to re-evaluate: aggregation features over events shared by five models trip the signals of reuse, computation cost, and point-in-time. The advantage of today's decision is that the migration starts from definitions already centralized and tested infeatures.py, not from five divergent copies.
Conclusion
Module 3 closes the chapter on "managing what gets learned". MLflow's churn-cineclick experiment stores every run with its parameters, metrics, commit, and data hash — the question "what was v2?" can never exist again. The registry turns the winner into model churn-cineclick version 2, promoted to @champion (recall 0.68, precision 0.55, F1 0.61) with documented human review and rollback one alias away. And features have a single source: features.py, in the installable package, shared by training and by the serving that's coming — with clear criteria for which signals would justify, tomorrow, a real feature store. Review the inventory: reproducible code, versioned data, a declared pipeline, experiments with memory, a champion model with a name, a version, and a signature, features with no double implementation. Everything a production service needs to load is identified and traceable — models:/churn-cineclick@champion is waiting for someone to call it. Module 4 does exactly that: choose the right deployment pattern (batch, online, or streaming), stand up a prediction service with FastAPI that loads the champion and imports the features from the single source, and package it with Docker so it runs the same anywhere. Laura has a model; what's missing is for CineClick to be able to use it.
MLOps Course
Module 1: MLOps Fundamentals
- What MLOps is and why models die in the notebook
- The lifecycle of an ML model in production
- MLOps maturity levels and team roles
- The course project: from notebook to production
Module 2: From Notebook to Reproducible Code
- Structuring an ML project: from notebook to package
- Reproducible environments and dependency management
- Data versioning with DVC
- Reproducible training pipelines
Module 3: Experiments and Model Registry
- Experiment tracking with MLflow
- Model registry: versioning and promoting models
- Feature stores: when and what for
Module 4: Serving Models in Production
- Deployment patterns: batch, online and streaming
- A prediction service with FastAPI
- Packaging with Docker
- Scaling and deployment: Kubernetes and serverless
- Inference optimization: latency and cost
Module 5: Automation: CI/CD and Orchestration
- CI for ML: testing code, data and models
- CD: automating model deployment
- Orchestrating ML pipelines
- Release strategies: shadow, canary and A/B
