Feature Stores
A feature store solves one problem: training-serving skew — the model trains on one version of a feature and serves predictions using a different version, silently degrading accuracy.
The Problem Without a Feature Store
graph TD
subgraph "Without Feature Store (common pattern)"
DW["Data Warehouse<br/>(Snowflake / BigQuery)"] -->|batch SQL| TRAIN["Training job<br/>features computed one way"]
APP["Application DB<br/>(Postgres / DynamoDB)"] -->|real-time query| SERVE["Serving code<br/>features computed differently"]
TRAIN -. "features computed<br/>different time windows,<br/>different logic" .-> SERVE
end
The training feature avg_order_value_30d is computed over a 30-day window from the data warehouse. The serving feature is computed over the last 100 records from Postgres. These are different — model gets different inputs at training vs serving time → accuracy degrades silently.
Feature Store Architecture
graph TD
RAW["Raw data<br/>(events, transactions, user actions)"] --> TRANS["Feature transformation<br/>(batch + streaming)"]
subgraph FeatureStore["Feature Store"]
OFF["Offline Store<br/>Parquet / BigQuery / Snowflake<br/>Historical features for training"]
ON["Online Store<br/>Redis / DynamoDB / Cassandra<br/>Low-latency for serving"]
REG["Feature Registry<br/>schema, metadata, ownership"]
end
TRANS --> OFF & ON
OFF -->|"point-in-time join<br/>(prevents leakage)"| TRAIN["Training dataset"]
ON -->|"<10ms lookup"| SERVE["Serving: predict(features)"]
REG --> TRANS
Both training and serving read from the same feature definitions → identical features guaranteed.
Online vs Offline Store
| Offline Store | Online Store | |
|---|---|---|
| Storage | Parquet / BigQuery / Redshift | Redis / DynamoDB / Cassandra |
| Latency | Seconds to minutes | < 10ms |
| Scale | Petabytes (all history) | Gigabytes (latest values only) |
| Use case | Training dataset generation | Real-time inference |
| Access pattern | Full scan / range query | Point lookup by entity ID |
The online store contains only the latest feature values. If you need user_123's avg_order_value_30d at inference time, the online store has the pre-computed current value — no need to query the data warehouse.
Feast — Open-Source Feature Store
Install on K8s
pip install feast[redis,aws]
# Initialize a feature repo
feast init my-feature-repo
cd my-feature-repo
Define features
# feature_repo/features.py
from datetime import timedelta
from feast import Entity, FeatureView, Field, FileSource
from feast.types import Float64, Int64, String
# Entity = the primary key your features are about
user = Entity(name="user_id", description="User identifier")
# Source = where raw feature data lives
user_stats_source = FileSource(
path="s3://my-data/features/user_stats.parquet",
timestamp_field="event_timestamp", # for point-in-time joins
)
# FeatureView = a group of related features from a source
user_stats = FeatureView(
name="user_stats",
entities=[user],
ttl=timedelta(days=7), # features expire after 7 days
schema=[
Field(name="avg_order_value_30d", dtype=Float64),
Field(name="order_count_90d", dtype=Int64),
Field(name="days_since_last_order", dtype=Int64),
Field(name="preferred_category", dtype=String),
],
source=user_stats_source,
online=True, # also materialized to online store
)
Materialize features to online store
# Apply feature definitions to registry
feast apply
# Materialize: copy latest features from offline → online store (Redis)
# Run this as a CronJob — e.g., every hour
feast materialize-incremental $(date -u +"%Y-%m-%dT%H:%M:%S")
# K8s CronJob for materialization
apiVersion: batch/v1
kind: CronJob
metadata:
name: feast-materialize
spec:
schedule: "0 * * * *" # every hour
jobTemplate:
spec:
template:
spec:
containers:
- name: feast
image: myrepo/feast:v1.0
command:
- feast
- materialize-incremental
- $(date -u +"%Y-%m-%dT%H:%M:%S")
env:
- name: FEAST_REPO_PATH
value: /app/feature_repo
restartPolicy: OnFailure
Training: get historical features with point-in-time join
from feast import FeatureStore
import pandas as pd
store = FeatureStore(repo_path="./feature_repo")
# Entity dataframe: what entities and when (prevents future leakage)
entity_df = pd.DataFrame({
"user_id": ["user_1", "user_2", "user_3"],
"event_timestamp": ["2024-01-15", "2024-01-15", "2024-01-15"],
})
# Feast retrieves feature values AS OF the event_timestamp
# This prevents data leakage: no future data used in training
training_df = store.get_historical_features(
entity_df=entity_df,
features=[
"user_stats:avg_order_value_30d",
"user_stats:order_count_90d",
"user_stats:preferred_category",
],
).to_df()
# training_df has features as they existed at each event_timestamp
# No leakage, identical computation to what serving will use
Serving: get online features (< 10ms)
# At inference time — same feature definitions, different store
online_features = store.get_online_features(
features=[
"user_stats:avg_order_value_30d",
"user_stats:order_count_90d",
"user_stats:preferred_category",
],
entity_rows=[{"user_id": "user_123"}],
).to_dict()
# Pass to model
prediction = model.predict(online_features)
Point-in-Time Join — Preventing Data Leakage
Without point-in-time correctness, your training data uses future information that won't be available at serving time:
User placed order on Jan 15.
Feature: avg_order_value_30d as of Jan 15 = $45 (correct — only past 30 days)
Without PIT join: feature value = avg over ALL orders including future ones = $62 (leakage)
With PIT join: feature value = avg over orders before Jan 15 = $45 (correct)
This is why the feature store — not a manual SQL join — handles feature retrieval for training. The timestamp-aware join is the core correctness guarantee.
Training-Serving Skew — The Key Problem to Solve
| Cause | Detection | Fix |
|---|---|---|
| Different computation logic | Compare feature distributions between training and serving | Use same feature definitions in both |
| Different time windows | A/B test shows model worse than expected | Point-in-time join in feature store |
| Stale online features | Drift in online feature values vs offline | Reduce materialization interval |
| Missing features at serving | High null rate in serving logs | Add default values + monitoring |
# Detect skew: compare feature stats between training and serving
from evidently.metric_preset import DataDriftPreset
training_features = store.get_historical_features(...).to_df()
serving_features = pd.read_parquet("s3://logs/serving-features-last-week.parquet")
report = Report(metrics=[DataDriftPreset()])
report.run(reference_data=training_features, current_data=serving_features)
# If significant drift: training-serving skew is present
When to Use a Feature Store
✅ Use when:
- Multiple models share the same features (don't recompute)
- Training-serving skew is causing model degradation
- Feature computation is expensive (30-day aggregations, graph features)
- Compliance requires reproducible feature values for audit
❌ Skip when:
- Single model, simple features (just pass raw fields)
- Features are just the raw DB columns (no transformation)
- Small team, early stage — add later when skew becomes a problem