Skip to main content

Feature Store with Feast for Training and Serving

Build a Feast feature store with Redis for a fraud model: hourly features, point-in-time training, online serving, parity and freshness checks.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project builds a feature store for a fraud detection model with Feast, using Parquet files as the offline store and Redis as the online store. It computes hourly account and merchant features from raw payments, builds a point-in-time correct training set, serves the same features to a FastAPI scoring endpoint in a few milliseconds, and proves with tests, parity and freshness checks that training and serving see the same values and that training never saw the future.

acme-shop's fraud team has a second request after the counter incident in the Feast module. The chargeback analysts have noticed that fraud clusters at a handful of compromised merchants, and they want merchant signals in the model too. The platform team says yes, on one condition: every feature must come with proof that training and serving see the same values, and proof that training never saw the future.

That is this project. You will build a small but complete feature store for acme-fraud, the way a platform engineer would build the first version at work, and finish with a repository you can put on GitHub and explain in an interview.

This project assumes you have completed Feature Stores with Feast. It does not re-teach entities, feature views, point-in-time joins or materialization; it uses them.

What the finished system does

The finished repository, acme-fraud-feature-store, does six things:

  • Generates 30 days of seeded raw payments for 300 accounts and 40 merchants.
  • Computes hourly account and merchant features with a vectorized pandas pipeline, following a written feature contract.
  • Registers two entities, two feature views and one feature service in Feast, with Parquet as the offline store and Redis as the online store.
  • Builds a point-in-time correct training set, proves every value against an independent as-of join, and trains a model with a time-based split.
  • Serves predictions from a FastAPI endpoint that reads both entities' features from Redis, with shared defaults for missing values.
  • Checks offline-to-online parity and two-part freshness, and survives three deliberate failures.

The diagram shows how the pieces connect.

graph TD subgraph Batch [Batch side] G[generate.py
raw payments] --> P[pipeline.py
hourly] P --> O[(Parquet
offline store)] end subgraph Serve [Serving side] R[(Redis
online store)] --> A[FastAPI
/v1/score] end O -->|point-in-time| T[train.py] O -->|materialize| R classDef code fill:#326CE5,color:#ffffff classDef store fill:#8B5CF6,color:#ffffff classDef use fill:#10B981,color:#ffffff class G,P,T code class O,R store class A use

Blue boxes are code you write, purple are the two stores, and green is the API that checkout would call.

How the project will be judged

Treat this table as your acceptance criteria. Each line is something you can demonstrate with a command, not a claim in a README.

Requirement Proof Command
Features mean what the contract says Definition tests pass make test
Training never saw the future 0 values differ from as-of reference make train
Online equals offline Parity on every entity make parity
Online data is current and complete Two-part freshness passes make fresh
Serving uses training defaults Shared contract module code review
Final score reported once, with uncertainty results/final_test.json make final

Milestones at a glance

Milestone You build Proves
1 Repo, pinned deps, Redis Reproducible setup
2 Feature contract and tests What features mean
3 Hourly pandas pipeline Contract implemented
4 Feast entities and service Shared definitions
5 Verified training set No future data
6 Redis materialization Online equals offline
7 FastAPI scoring Same defaults live
8 Break-it drills Each check earns its place
9 Portfolio packaging One-command proof
Tip

A portfolio project is judged on what it proves, not on how many tools it uses. "Here is the test that would have caught the bug" beats "I used Feast and Redis" in every interview.

Milestone 1: Set Up the Repository and Redis Online Store

This milestone creates the standalone repository, a pinned Python environment and a Redis container that Feast will use as its online store.

The project lives in its own repository, separate from mlops-lab, so it reads as a standalone piece of work on GitHub.

Step 1

Create the repository layout, a virtual environment and the pinned requirements.

Bash
mkdir -p acme-fraud-feature-store && cd acme-fraud-feature-store
git init
mkdir -p src/featurekit feature_repo tests data results models
touch src/featurekit/__init__.py
python3.12 -m venv .venv && source .venv/bin/activate

Save requirements.txt:

TEXT
feast[redis]==0.67.0
scikit-learn==1.9.1
joblib==1.6.0
fastapi==0.162.0
uvicorn==0.54.0
httpx==0.28.1
pytest==9.2.0

Save pyproject.toml, which makes featurekit an installable package and tells pytest where the source lives:

TOML
[project]
name = "featurekit"
version = "0.1.0"
requires-python = ">=3.12"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
pythonpath = ["src"]
Bash
pip install -r requirements.txt
pip install -e .
pip freeze --exclude-editable > requirements.lock
feast version
TEXT
Feast SDK Version: "0.67.0"
Note

feast[redis] installs Feast plus the Redis client it needs; plain feast cannot talk to Redis. If pip reports a version conflict between Feast and the FastAPI or Uvicorn pins, remove those two pins, let pip choose versions Feast accepts, and record what it chose in requirements.lock. The lock file is what a reviewer installs, so commit it.

Add a .gitignore containing .venv/, data/, models/ and __pycache__/. Keep results/ tracked: the JSON files written there are the evidence your README quotes. Generated data and models are rebuilt by the pipeline, never committed.

Step 2

Start Redis in Docker, bound to your laptop only. Save docker-compose.yml:

YAML
services:
redis:
image: redis:8.8.1
ports:
- "127.0.0.1:6379:6379" # reachable from this laptop only
command: ["redis-server", "--save", "", "--appendonly", "no"]
Bash
docker compose up -d
docker compose exec redis redis-cli ping
TEXT
PONG
Security

This Redis has no password, which is acceptable only because the port is bound to 127.0.0.1. Never publish it as 6379:6379, which listens on every interface. In production, the Feast module's production topic covers TLS, a password from a secret store, and a rendered config file that never enters Git.

Note

--save "" and --appendonly no turn off persistence. The online store is a cache of the offline store: if Redis loses its data, you rebuild it with one materialization run. That property is worth stating in your README, because it is why Redis is safe to treat as disposable here.

Step 3

Point Feast at Redis. Save feature_repo/feature_store.yaml:

YAML
project: acme_fraud_fs
registry: data/registry.db
provider: local
online_store:
type: redis
connection_string: "localhost:6379"
entity_key_serialization_version: 3

The registry path is relative to feature_repo/, so the registry lands in feature_repo/data/. Add that folder to .gitignore too: the registry is rebuilt by feast apply from the definitions, which are what you commit.

Common Mistake

Installing plain feast instead of feast[redis]. Everything works until the first feast apply or materialization against Redis, which then fails with a missing Redis client. Install the extra from the start and record it in the lock file.

Milestone 2: Write the Feature Contract and Definition Tests

This milestone writes down exactly what every feature means, in a contract file, a shared Python module and tests that fail until the pipeline matches the contract.

Before any pipeline code, write down what each feature means. The counter incident happened because two teams had one feature name and two meanings. A contract is the one place the meaning lives, and tests make it executable.

Step 4

Write FEATURES.md at the repository root. This is the human-readable contract a reviewer reads first.

Bash
# acme-fraud feature contract (v2)
Snapshots are written every hour, at the start of hour h.
"Before h" means strictly before h. A payment at exactly h
is never part of the snapshot for h.
acct_txn_24h payments by the account in [h-24h, h)
acct_avg_amount_7d mean amount by the account in [h-7d, h);
0.0 when there were none
acct_chargebacks_90d fraud payments by the account whose hour
bucket starts at least 73 hours before h,
within the last 90 days (chargebacks arrive
72 hours after the payment; the extra hour
makes the rule conservative)
merch_txn_24h payments at the merchant in [h-24h, h)
merch_cb_rate_30d known chargebacks / payments at the merchant,
over buckets 73 hours to 30 days + 73 hours
before h; 0.0 when there were none
Every row has event_timestamp = h and created = h + 10 minutes,
the time the hourly job finishes writing it.
Defaults when an entity has no row in the store: all zeros.
Note

The windows are half-open, written [h-24h, h): they include the start and exclude the end. Half-open windows are the usual choice for time buckets, because consecutive windows never overlap and never leave a gap. The exact choice matters less than writing it down and testing it.

Step 5

Turn the contract into code that training, serving and tests all import. Save src/featurekit/contract.py:

Python
"""Feature contract: names, defaults and timing rules (v2)."""
from datetime import timedelta
ACCOUNT_DEFAULTS = {"acct_txn_24h": 0,
"acct_avg_amount_7d": 0.0,
"acct_chargebacks_90d": 0}
MERCHANT_DEFAULTS = {"merch_txn_24h": 0,
"merch_cb_rate_30d": 0.0}
DEFAULTS = {**ACCOUNT_DEFAULTS, **MERCHANT_DEFAULTS}
FEATS = list(DEFAULTS)
X_COLS = FEATS + ["amount"] # amount arrives with the request
CB_DELAY_HOURS = 73 # 72h chargeback delay + 1h bucket
JOB_DELAY = timedelta(minutes=10)
FEATURE_SERVICE = "fraud_v2"
REPO = "feature_repo"
DATA = "data"

Step 6

Write the definition tests now, before the pipeline exists. They will fail until Step 8, which is the point: the tests describe the contract, and the pipeline is written to satisfy them. Save tests/test_definitions.py:

Python
"""Each test pins one rule from FEATURES.md."""
import pandas as pd
from featurekit.contract import DEFAULTS
from featurekit.pipeline import account_snapshots, merchant_snapshots
T = pd.Timestamp("2026-11-01 12:00", tz="UTC")
HOURS = pd.date_range(T - pd.Timedelta(days=40), T, freq="h")
def payments(minutes, fraud=None, account=1001, merchant=501):
"""Payments at T plus each offset in minutes."""
fraud = fraud or [0] * len(minutes)
return pd.DataFrame({
"account_id": account, "merchant_id": merchant,
"event_timestamp": [T + pd.Timedelta(minutes=m)
for m in minutes],
"amount": 100.0, "is_fraud": fraud})
def at_t(snaps):
"""The snapshot row for hour T."""
return snaps[snaps["event_timestamp"] == T].iloc[0]
def test_window_is_half_open():
"""Exactly 24h ago counts; the payment at T does not."""
day = 24 * 60
p = payments([-day, -day - 1, -1, 0])
assert at_t(account_snapshots(p, HOURS))["acct_txn_24h"] == 2
def test_chargeback_waits_73_hours():
"""Fraud counts once its hour bucket is 73h or more old."""
known = payments([-73 * 60], fraud=[1])
pending = payments([-72 * 60], fraud=[1])
k = at_t(account_snapshots(known, HOURS))
p = at_t(account_snapshots(pending, HOURS))
assert k["acct_chargebacks_90d"] == 1
assert p["acct_chargebacks_90d"] == 0
def test_no_history_matches_contract_defaults():
"""An idle account's row equals the serving defaults."""
p = payments([-30 * 24 * 60]) # one payment 30 days ago
row = at_t(account_snapshots(p, HOURS))
assert row["acct_txn_24h"] == DEFAULTS["acct_txn_24h"]
assert (row["acct_avg_amount_7d"]
== DEFAULTS["acct_avg_amount_7d"])
def test_merchant_rate_uses_known_chargebacks_only():
"""4 payments 4 days ago, 1 fraud: rate 0.25 at T."""
p = payments([-96 * 60] * 4, fraud=[1, 0, 0, 0])
assert at_t(merchant_snapshots(p, HOURS))[
"merch_cb_rate_30d"] == 0.25
Note

Each test builds a tiny history by hand where you can work out the right answer on paper. That is what makes it an independent check: the expected value comes from the contract, not from running the pipeline and copying its output.

Common Mistake

Writing the expected value in a test by running the pipeline and copying its output. That only proves the code agrees with itself. Work out each expected value on paper from FEATURES.md, then let the code prove it matches.

Milestone 3: Build Hourly Feature Snapshots with pandas

This milestone generates seeded raw payments and turns them into hourly account and merchant snapshots that satisfy the definition tests.

The Feast module's lab looped over accounts and days. That is fine for 30,000 rows and far too slow for hourly data. This pipeline buckets payments by hour once, then uses rolling sums, which pandas runs in compiled code.

Step 7

Write the seeded data generator. Save src/featurekit/generate.py:

Python
"""Seeded raw payments: 300 accounts, 40 merchants, 30 days."""
import numpy as np
import pandas as pd
SEED, N_ACC, N_MER, DAYS = 42, 300, 40, 30
def generate(now: pd.Timestamp) -> pd.DataFrame:
"""Risky accounts and compromised merchants drive fraud."""
rng = np.random.default_rng(SEED)
end = now.floor("h") - pd.Timedelta(hours=1)
start = end - pd.Timedelta(days=DAYS)
risky = rng.random(N_ACC) < 0.08
counts = rng.poisson(np.where(risky, 25, 12))
acct = np.repeat(np.arange(1001, 1001 + N_ACC), counts)
is_risky = np.repeat(risky, counts)
n = len(acct)
merchants = np.arange(501, 501 + N_MER)
bad = merchants[rng.random(N_MER) < 0.10]
if len(bad) == 0: # keep at least one compromised merchant
bad = merchants[:1]
merch = rng.choice(merchants, n)
steer = is_risky & (rng.random(n) < 0.5)
merch[steer] = rng.choice(bad, int(steer.sum()))
p_fraud = 0.01 + 0.24 * is_risky + 0.10 * np.isin(merch, bad)
seconds = rng.random(n) * (end - start).total_seconds()
return pd.DataFrame({
"account_id": acct, "merchant_id": merch,
"event_timestamp": start + pd.to_timedelta(seconds, "s"),
"amount": np.round(rng.lognormal(7, 1, n), 2),
"is_fraud": (rng.random(n) < p_fraud).astype(int),
}).sort_values("event_timestamp", ignore_index=True)
Note

The data ends one hour before the current hour, and every timestamp is timezone-aware UTC because now is. The data is synthetic: risky accounts and compromised merchants are hidden flags the model never sees directly, only through the features.

Step 8

Write the feature pipeline. Save src/featurekit/pipeline.py:

Python
"""Hourly account and merchant snapshots, per FEATURES.md."""
import pandas as pd
from featurekit.contract import CB_DELAY_HOURS, DATA, JOB_DELAY
from featurekit.generate import generate
def buckets(pay, key, hours):
"""Payments, amounts and frauds per entity per hour."""
b = pay.assign(hour=pay["event_timestamp"].dt.floor("h"))
agg = b.groupby([key, "hour"]).agg(
n=("amount", "size"), amt=("amount", "sum"),
fraud=("is_fraud", "sum"))
grid = pd.MultiIndex.from_product(
[sorted(pay[key].unique()), hours], names=[key, "hour"])
return agg.reindex(grid, fill_value=0)
def window(agg, col, size, skip):
"""
Sum of col over `size` hourly buckets, ending `skip` buckets
before each hour. skip=1 means "strictly before h".
"""
return agg.groupby(level=0)[col].transform(
lambda s: s.shift(skip, fill_value=0)
.rolling(size, min_periods=1).sum())
def finish(out):
"""Add event and created timestamps; flatten the index."""
out = out.reset_index().rename(
columns={"hour": "event_timestamp"})
out["created"] = out["event_timestamp"] + JOB_DELAY
return out
def account_snapshots(pay, hours):
"""acct_* features for every account and hour."""
agg = buckets(pay, "account_id", hours)
out = pd.DataFrame(index=agg.index)
out["acct_txn_24h"] = window(agg, "n", 24, 1).astype("int64")
n7, a7 = window(agg, "n", 168, 1), window(agg, "amt", 168, 1)
out["acct_avg_amount_7d"] = (a7 / n7.where(n7 > 0)).fillna(0.0)
out["acct_chargebacks_90d"] = window(
agg, "fraud", 90 * 24, CB_DELAY_HOURS).astype("int64")
return finish(out)
def merchant_snapshots(pay, hours):
"""merch_* features for every merchant and hour."""
agg = buckets(pay, "merchant_id", hours)
out = pd.DataFrame(index=agg.index)
out["merch_txn_24h"] = window(agg, "n", 24, 1).astype("int64")
cb = window(agg, "fraud", 30 * 24, CB_DELAY_HOURS)
base = window(agg, "n", 30 * 24, CB_DELAY_HOURS)
out["merch_cb_rate_30d"] = (cb / base.where(base > 0)).fillna(0.0)
return finish(out)
def main():
"""Generate payments and write both snapshot tables."""
now = pd.Timestamp.now(tz="UTC")
pay = generate(now)
hours = pd.date_range(pay["event_timestamp"].min().ceil("h"),
now.floor("h") - pd.Timedelta(hours=1),
freq="h")
pay.to_parquet(f"{DATA}/payments.parquet", index=False)
acct = account_snapshots(pay, hours)
merch = merchant_snapshots(pay, hours)
acct.to_parquet(f"{DATA}/account_stats.parquet", index=False)
merch.to_parquet(f"{DATA}/merchant_stats.parquet", index=False)
print(f"{len(pay)} payments, fraud rate "
f"{pay['is_fraud'].mean():.3f}, {len(acct)} account rows, "
f"{len(merch)} merchant rows")
if __name__ == "__main__":
main()
Note

shift(skip) moves every bucket skip hours later before the rolling sum, so the value at hour h only sums buckets that ended before h. With skip=1 that is the "strictly before h" rule; with skip=73 it is the chargeback delay. A single wrong shift is exactly the off-by-one from the counter incident, which is why the definition tests check both.

Step 9

Run the tests, then the pipeline.

Bash
python -m pytest -q
python -m featurekit.pipeline

Expected output (the payment count and rate depend on library versions; the shape should match):

TEXT
.... [100%]
4 passed in 1.92s
4402 payments, fraud rate 0.071, 216000 account rows,
28800 merchant rows

300 accounts times 720 hours gives 216,000 account rows, and 40 merchants times 720 hours gives 28,800. If your counts differ slightly, check how many whole hours your run covered; the generator anchors on the current hour.

Common Mistake

Mixing timezone-naive and UTC timestamps between the generator, the pipeline and the entity dataframe. Feast then compares times that are hours apart, and point-in-time joins return empty features with no error. Keep every timestamp timezone-aware UTC end to end.

Milestone 4: Register Two Entities and a Feature Service in Feast

This milestone registers two entities, two feature views and one feature service that spans both, so training and serving ask for the same features by one name.

Now register the tables with Feast. The new part compared with the module is a second entity: one feature service now pulls features keyed by two different IDs.

Step 10

Save feature_repo/features.py:

Python
"""Feast definitions: account and merchant features, one service."""
from datetime import timedelta
from pathlib import Path
from feast import (Entity, FeatureService, FeatureView, Field,
FileSource)
from feast.types import Float64, Int64
DATA = Path(__file__).resolve().parent.parent / "data"
TTL = timedelta(hours=3) # hourly job, one missed run allowed
account = Entity(name="account", join_keys=["account_id"])
merchant = Entity(name="merchant", join_keys=["merchant_id"])
def source(name: str) -> FileSource:
"""Parquet source with event and created timestamps."""
return FileSource(name=f"{name}_source",
path=str(DATA / f"{name}.parquet"),
timestamp_field="event_timestamp",
created_timestamp_column="created")
account_stats = FeatureView(
name="account_stats", entities=[account], ttl=TTL,
schema=[Field(name="acct_txn_24h", dtype=Int64),
Field(name="acct_avg_amount_7d", dtype=Float64),
Field(name="acct_chargebacks_90d", dtype=Int64)],
online=True, source=source("account_stats"),
tags={"owner": "fraud-platform", "contract": "v2",
"data_class": "pseudonymous"})
merchant_stats = FeatureView(
name="merchant_stats", entities=[merchant], ttl=TTL,
schema=[Field(name="merch_txn_24h", dtype=Int64),
Field(name="merch_cb_rate_30d", dtype=Float64)],
online=True, source=source("merchant_stats"),
tags={"owner": "fraud-platform", "contract": "v2",
"data_class": "business"})
fraud_v2 = FeatureService(name="fraud_v2",
features=[account_stats, merchant_stats])
Note

The contract tag links each feature view to the version of FEATURES.md it implements. When the contract changes meaning, bump it, and create a new feature service name, so a model trained on v2 never silently reads v3 features. That naming discipline is a team convention; Feast does not enforce it.

Step 11

Register the definitions.

Bash
cd feature_repo && feast apply && cd ..

Expected output, similar to:

TEXT
Created entity account
Created entity merchant
Created feature view account_stats
Created feature view merchant_stats
Created feature service fraud_v2

Why one service can span two entities

A feature service is a list of features, not a table, so its features can belong to different entities. At retrieval time you pass every join key the service needs: the training entity dataframe has both account_id and merchant_id columns, and each online request passes both IDs. Feast looks up each feature view with its own key and returns one combined row. Forget one key and Feast raises an error instead of guessing, which is the behaviour you want.

Common Mistake

Changing what a feature means while keeping its name and the feature service name. The model in production keeps reading the new values as if they were the old ones. Bump the contract version and create a new feature service, such as fraud_v3, for any change in meaning.

Milestone 5: Build and Verify the Point-in-Time Training Set

This milestone builds the training set through Feast, checks every feature value against an independent pandas as-of join, and trains a model with an honest, time-based evaluation.

This is where the project proves its most important claim: no training row saw information from after its own timestamp. Feast builds the training set, and an independent pandas join checks every value Feast returned.

Step 12

Save src/featurekit/train.py. It builds the training set through Feast, verifies all five features against merge_asof, applies the contract defaults, drops a warm-up period, trains on a time split, and compares with an intentionally invalid "latest stats" join. The final test window is only scored when you pass --final, and only once.

Python
"""Point-in-time training set, verified, and a time-split model."""
import argparse
import json
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
from feast import FeatureStore
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import average_precision_score
from featurekit.contract import (DATA, DEFAULTS, FEATURE_SERVICE,
REPO, X_COLS)
SEED = 42
WARMUP = pd.Timedelta(days=7) # 7-day windows not yet full
VIEWS = {"account_stats": ("account_id", ["acct_txn_24h",
"acct_avg_amount_7d", "acct_chargebacks_90d"]),
"merchant_stats": ("merchant_id", ["merch_txn_24h",
"merch_cb_rate_30d"])}
def historical(store, entity_df):
"""PIT join, also filtering on created time if supported."""
service = store.get_feature_service(FEATURE_SERVICE)
try:
df = store.get_historical_features(
entity_df=entity_df, features=service,
filter_by_created_timestamp=True).to_df()
return df, "created"
except (TypeError, NotImplementedError, ValueError) as exc:
print(f"created-time filter unavailable: {exc}")
df = store.get_historical_features(
entity_df=entity_df, features=service).to_df()
return df, "event_timestamp"
def verify_asof(pit, time_col):
"""Count values that differ from an independent as-of join."""
left = pit.sort_values("event_timestamp")
bad = 0
for view, (key, cols) in VIEWS.items():
snaps = pd.read_parquet(f"{DATA}/{view}.parquet")
if time_col == "created": # available time, not as-of time
snaps = (snaps.drop(columns="event_timestamp")
.rename(columns={"created": "event_timestamp"}))
ref = pd.merge_asof(
left[["event_timestamp", key, *cols]],
snaps.sort_values("event_timestamp")[
["event_timestamp", key, *cols]],
on="event_timestamp", by=key, direction="backward",
tolerance=pd.Timedelta(hours=3), suffixes=("", "_ref"))
for c in cols:
a, b = ref[c], ref[c + "_ref"]
bad += int(((a != b) & ~(a.isna() & b.isna())).sum())
return bad
def split(df):
"""Train, validation and a reserved final test window."""
ts = df["event_timestamp"]
test_start = ts.max() - pd.Timedelta(days=5) # reserved
val_start = ts.max() - pd.Timedelta(days=12)
return (df[ts < val_start],
df[(ts >= val_start) & (ts < test_start)],
df[ts >= test_start])
def pr_auc_interval(y, p, n=300):
"""95% bootstrap interval for PR-AUC: shows the noise."""
rng = np.random.default_rng(SEED)
y, p, vals = np.asarray(y), np.asarray(p), []
for _ in range(n):
i = rng.integers(0, len(y), len(y))
if y[i].any():
vals.append(average_precision_score(y[i], p[i]))
low, high = np.percentile(vals, [2.5, 97.5])
return [round(float(low), 3), round(float(high), 3)]
def fit(train):
"""Fit the model on training rows only."""
model = HistGradientBoostingClassifier(random_state=SEED)
return model.fit(train[X_COLS], train["is_fraud"])
def evaluate(model, rows):
"""PR-AUC with its bootstrap interval."""
p = model.predict_proba(rows[X_COLS])[:, 1]
return {"pr_auc": round(average_precision_score(
rows["is_fraud"], p), 3),
"interval_95": pr_auc_interval(rows["is_fraud"], p),
"rows": len(rows), "frauds": int(rows["is_fraud"].sum())}
def leaky_baseline(pay):
"""Deliberately invalid: every row gets the latest snapshot."""
out = pay.copy()
for view, (key, cols) in VIEWS.items():
snaps = pd.read_parquet(f"{DATA}/{view}.parquet")
last = (snaps.sort_values("event_timestamp")
.groupby(key).tail(1))
out = out.merge(last[[key, *cols]], on=key, how="left")
return out.fillna(DEFAULTS)
def main():
"""Build, verify, train, compare; --final scores test once."""
ap = argparse.ArgumentParser()
ap.add_argument("--final", action="store_true")
args = ap.parse_args()
store = FeatureStore(repo_path=REPO)
pay = pd.read_parquet(f"{DATA}/payments.parquet")
pit, time_col = historical(store, pay)
bad = verify_asof(pit, time_col)
print(f"joined on {time_col}: {len(pit)} payments, "
f"{bad} values differ from reference")
empty = pit[list(DEFAULTS)].isna().any(axis=1)
print(f"rows defaulted: {int(empty.sum())}")
pit = pit.fillna(DEFAULTS) # the same defaults serving uses
first = pit["event_timestamp"].min()
pit = pit[pit["event_timestamp"] >= first + WARMUP]
train, val, test = split(pit)
model = fit(train)
if args.final:
out = Path("results/final_test.json")
if out.exists():
raise SystemExit("final test already scored")
out.write_text(json.dumps(evaluate(model, test), indent=2))
print("final test:", out.read_text())
return
result = evaluate(model, val)
leaky = leaky_baseline(pay)
leaky = leaky[leaky["event_timestamp"] >= first + WARMUP]
l_train, l_val, _ = split(leaky)
result["invalid_latest_join_pr_auc"] = evaluate(
fit(l_train), l_val)["pr_auc"]
Path("results/validation.json").write_text(
json.dumps(result, indent=2))
joblib.dump(model, "models/model.joblib")
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
Note

A bootstrap interval resamples the validation rows with replacement a few hundred times and recomputes PR-AUC each time. The middle 95 percent of those values shows how much the score would move with a different sample of the same size. With a few dozen fraud cases in validation, that range is wide, and reporting it is more honest than a single number with three decimals.

Step 13

Run it.

Bash
python -m featurekit.train

Illustrative output (your numbers will differ; the shape should match):

TEXT
joined on created: 4402 payments, 0 values differ from reference
rows defaulted: 4
{
"pr_auc": 0.412,
"interval_95": [0.296, 0.538],
"rows": 1043,
"frauds": 71,
"invalid_latest_join_pr_auc": 0.633
}

Read the result in three parts. Zero differing values means Feast's join and your independent join agree on every one of roughly 22,000 feature values, so the training set respects both event and available times. The point-in-time PR-AUC comes with an interval about a quarter wide, so a change of 0.02 between two runs means nothing. And the invalid latest join scores far higher because it reads chargebacks and merchant rates from the end of the period, including ones caused by the validation payments themselves. It is a deliberately broken baseline that shows the size of the leak; it is never a model you would ship.

Common Mistake

Choosing features, thresholds or model settings by looking at the final test window. Every decision in this project uses validation rows. The test window is scored once, in Step 24, and the script refuses to score it twice.

Why the warm-up period is dropped

The data starts on day 0, so on day 2 a 7-day average has only 2 days of history and a 90-day chargeback count has almost none. Those early rows describe a world where every account looks new, which never happens in production. Dropping the first 7 days removes the worst of that. The 90-day chargeback window is still never full in 30 days of data; say so in your README as a known limit of the synthetic setup rather than hiding it.

Milestone 6: Materialize Features to Redis and Prove Parity

This milestone loads the latest feature values into Redis and proves, entity by entity, that Redis holds what the offline store considers current.

The training side is proven. Now load the online store and prove it holds the same values the offline store considers current.

Step 14

Save src/featurekit/materialize.py:

Python
"""Load the last 3 days of snapshots into Redis."""
from datetime import datetime, timedelta, timezone
from feast import FeatureStore
from featurekit.contract import REPO
def main():
"""Materialize a fixed window; safe to rerun."""
end = datetime.now(timezone.utc)
store = FeatureStore(repo_path=REPO)
store.materialize(start_date=end - timedelta(days=3),
end_date=end)
print(f"materialized up to {end:%Y-%m-%d %H:%M} UTC")
if __name__ == "__main__":
main()
Tip

This project materializes a fixed 3-day window every time instead of using materialize_incremental. Rewriting a few hundred thousand rows costs seconds, and a fixed window is idempotent: rerunning it after any failure always produces the same online state. At millions of entities, incremental runs become worth their extra state to track.

Bash
python -m featurekit.materialize
docker compose exec redis redis-cli dbsize
TEXT
materialized up to 2026-11-04 10:42 UTC
(integer) 340

340 keys: one per account plus one per merchant, each holding that entity's latest feature values. The exact key layout is Feast's internal format; never read or write it directly.

Step 15

Save src/featurekit/parity.py, the offline-to-online materialization parity check. It pairs every account with a merchant so that every entity of both kinds is checked, and compares Feast's offline answer "as of the newest snapshot" with Redis.

Python
"""Materialization parity for every account and merchant."""
import sys
import pandas as pd
from feast import FeatureStore
from featurekit.contract import DATA, FEATS, FEATURE_SERVICE, REPO
def main():
"""Exit non-zero if any entity differs between the stores."""
acct = pd.read_parquet(f"{DATA}/account_stats.parquet")
merch = pd.read_parquet(f"{DATA}/merchant_stats.parquet")
accounts = sorted(acct["account_id"].unique())
merchants = sorted(merch["merchant_id"].unique())
pairs = pd.DataFrame({
"account_id": accounts,
"merchant_id": [merchants[i % len(merchants)]
for i in range(len(accounts))]})
newest = max(acct["event_timestamp"].max(),
merch["event_timestamp"].max())
as_of = newest + pd.Timedelta(seconds=1)
store = FeatureStore(repo_path=REPO)
service = store.get_feature_service(FEATURE_SERVICE)
keys = ["account_id", "merchant_id"]
offline = store.get_historical_features(
entity_df=pairs.assign(event_timestamp=as_of),
features=service).to_df().set_index(keys).sort_index()
online = pd.DataFrame(store.get_online_features(
features=service,
entity_rows=pairs.astype(int).to_dict(orient="records"),
).to_dict()).set_index(keys).sort_index()
same = (offline[FEATS].astype(float).round(6)
== online[FEATS].astype(float).round(6)).all(axis=1)
print(f"{int(same.sum())}/{len(same)} pairs match "
f"({len(accounts)} accounts, {len(merchants)} merchants)")
if not same.all():
bad = same[~same].index[0]
print("first mismatch", bad)
print("offline:", offline.loc[bad, FEATS].tolist())
print("online: ", online.loc[bad, FEATS].tolist())
sys.exit(1)
if __name__ == "__main__":
main()
Note

The comparison time is the newest snapshot time plus one second, not the wall clock. Comparing "as of now" would fail on a slow afternoon for the wrong reason: once the newest snapshot is older than the 3-hour TTL, the offline join returns nothing while Redis still returns the last values. Staleness is real, but it is the freshness check's job, not this one's.

Bash
python -m featurekit.parity
TEXT
300/300 pairs match (300 accounts, 40 merchants)
Common Mistake

Treating a passing parity check as proof the features are correct. Parity compares two copies of the same numbers; if the pipeline computed them wrongly, both copies agree. Meaning is proven by the definition tests from Milestone 2.

Milestone 7: Serve Features from a FastAPI Endpoint

This milestone adds a FastAPI scoring endpoint that reads account and merchant features from Redis at request time and fills gaps with the same defaults training used.

The online store is only useful if a prediction can read it fast, with the same defaults training used. This endpoint is a slimmed-down version of fraud-api from Model Serving with FastAPI and BentoML, with the feature lookup added.

Step 16

Save src/featurekit/api.py:

Python
"""Fraud scoring API that reads account and merchant features."""
import time
from contextlib import asynccontextmanager
import joblib
import pandas as pd
from fastapi import FastAPI, Request
from feast import FeatureStore
from pydantic import BaseModel, ConfigDict, Field
from featurekit.contract import (DEFAULTS, FEATURE_SERVICE, REPO,
X_COLS)
class ScoreIn(BaseModel):
"""What checkout sends: IDs plus request-time fields."""
model_config = ConfigDict(extra="forbid")
payment_id: str = Field(min_length=1, max_length=64)
account_id: int = Field(ge=1)
merchant_id: int = Field(ge=1)
amount: float = Field(gt=0, le=2_000_000)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Open the store and load the model once per worker."""
app.state.store = FeatureStore(repo_path=REPO)
app.state.service = app.state.store.get_feature_service(
FEATURE_SERVICE)
app.state.model = joblib.load("models/model.joblib")
yield
app = FastAPI(title="acme-fraud-features", lifespan=lifespan)
@app.post("/v1/score")
def score(req: ScoreIn, request: Request):
"""Look up features, apply contract defaults, score."""
st = request.app.state
t0 = time.perf_counter()
online = st.store.get_online_features(
features=st.service,
entity_rows=[{"account_id": req.account_id,
"merchant_id": req.merchant_id}]).to_dict()
lookup_ms = (time.perf_counter() - t0) * 1000
row, warnings = {"amount": req.amount}, []
for name, default in DEFAULTS.items():
value = online[name][0]
if value is None:
value = default
warnings.append(f"{name} missing, default used")
row[name] = value
prob = st.model.predict_proba(pd.DataFrame([row])[X_COLS])[0, 1]
return {"payment_id": req.payment_id,
"fraud_probability": round(float(prob), 4),
"lookup_ms": round(lookup_ms, 2), "warnings": warnings}
Note

lookup_ms is returned only so this project can measure the feature lookup on its own. In a real service you would record it as a metric, not send it to checkout. The defaults come from contract.py, the same dictionary train.py passed to fillna, so a new account is represented identically in training and serving.

Step 17

Measure the lookup. Save src/featurekit/bench.py, which calls the API in-process 500 times with random known IDs and reports server-side lookup percentiles:

Python
"""Lookup latency percentiles for the online feature read."""
import numpy as np
from fastapi.testclient import TestClient
from featurekit.api import app
rng = np.random.default_rng(7)
with TestClient(app) as client:
times = []
for i in range(500):
body = {"payment_id": f"bench_{i}",
"account_id": int(rng.integers(1001, 1301)),
"merchant_id": int(rng.integers(501, 541)),
"amount": 2500.0}
times.append(client.post("/v1/score", json=body)
.json()["lookup_ms"])
p50, p95, p99 = np.percentile(times, [50, 95, 99])
print(f"lookup ms p50 {p50:.2f} p95 {p95:.2f} p99 {p99:.2f}")
Bash
python -m featurekit.bench

Illustrative output from a laptop:

TEXT
lookup ms p50 1.84 p95 3.10 p99 5.62

This is one client calling sequentially against local Redis, so it measures the cost of one lookup, not capacity under load. Put the numbers in your README with that sentence next to them. The Model Serving module's Locust setup is the tool for a concurrency test.

Common Mistake

Creating the FeatureStore object inside the request handler. Every request then re-reads the registry and opens a new connection, which can cost more than the lookup itself. Create it once in the lifespan handler, as above.

Milestone 8: Run Freshness Checks and Break-It Drills

This milestone adds a two-part freshness check, then breaks the system three ways to show that definition tests, freshness and parity each catch a failure the others miss.

Each drill below breaks one thing, and each is caught by a different check. That is the argument for having all three.

Step 18

Save src/featurekit/freshness.py, the two-part freshness check from the Feast module, extended to both views and to entity coverage:

Python
"""Materialization and data freshness, with entity coverage."""
import argparse
import sys
from datetime import datetime, timedelta, timezone
import pandas as pd
from feast import FeatureStore
from featurekit.contract import DATA, REPO
VIEWS = {"account_stats": "account_id",
"merchant_stats": "merchant_id"}
def main():
"""Print each check; exit non-zero if any fails."""
ap = argparse.ArgumentParser()
ap.add_argument("--max-age-minutes", type=int, default=90)
max_age = timedelta(minutes=ap.parse_args().max_age_minutes)
now = datetime.now(timezone.utc)
store, failed = FeatureStore(repo_path=REPO), False
for view, key in VIEWS.items():
last_run = store.get_feature_view(view).most_recent_end_time
snaps = pd.read_parquet(f"{DATA}/{view}.parquet",
columns=[key, "event_timestamp"])
newest = snaps["event_timestamp"].max()
covered = snaps.loc[snaps["event_timestamp"] == newest,
key].nunique()
expected = snaps[key].nunique()
checks = {
"materialized": last_run is not None
and now - last_run <= max_age,
"data_fresh": now - newest <= max_age,
"coverage": covered == expected}
failed |= not all(checks.values())
print(f"{view}: {checks} newest={newest:%H:%M} "
f"covered={covered}/{expected}")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()
Bash
python -m featurekit.freshness
TEXT
account_stats: {'materialized': True, 'data_fresh': True,
'coverage': True} newest=09:00 covered=300/300
merchant_stats: {'materialized': True, 'data_fresh': True,
'coverage': True} newest=09:00 covered=40/40

If data_fresh is false, more than 90 minutes have passed since you ran the pipeline. That is the check working; rerun python -m featurekit.pipeline and python -m featurekit.materialize.

Step 19

Drill 1, the counter bug. In pipeline.py, change the first line of account_snapshots that computes acct_txn_24h from window(agg, "n", 24, 1) to window(agg, "n", 24, 0), which lets each hour count payments in its own bucket. Run the tests.

Bash
python -m pytest -q
TEXT
F... [100%]
FAILED tests/test_definitions.py::test_window_is_half_open
assert 3 == 2

That one-character change is the acme-shop incident: the count now includes the payment being scored. Parity would not catch it, because both stores would hold the same wrong number. Only a test of the definition can. Change it back to 1 and confirm all four tests pass.

Step 20

Drill 2, the half-finished job. Simulate an hourly run that crashed after writing 20 accounts:

Bash
python - <<'PY'
import pandas as pd
path = "data/account_stats.parquet"
s = pd.read_parquet(path)
last = s[s.event_timestamp == s.event_timestamp.max()].head(20)
new = last.assign(
event_timestamp=pd.Timestamp.now(tz="UTC").floor("h"),
created=pd.Timestamp.now(tz="UTC"))
pd.concat([s, new]).to_parquet(path, index=False)
print("wrote", len(new), "rows for", new.event_timestamp.iloc[0])
PY
python -m featurekit.materialize
python -m featurekit.parity
python -m featurekit.freshness

Illustrative output (trimmed):

TEXT
wrote 20 rows for 2026-11-04 10:00:00+00:00
300/300 pairs match (300 accounts, 40 merchants)
account_stats: {'materialized': True, 'data_fresh': True,
'coverage': False} newest=10:00 covered=20/300

Parity passes, because Redis faithfully holds what the offline store contains. Materialization looks healthy, and the newest data is fresh. Only the coverage check notices that 280 accounts did not get this hour's snapshot. Rebuild with python -m featurekit.pipeline followed by python -m featurekit.materialize.

Step 21

Drill 3, the lost cache. Restart Redis; with persistence off, it comes back empty.

Bash
docker compose restart redis
python -m featurekit.parity
python -m featurekit.freshness
TEXT
0/300 pairs match (300 accounts, 40 merchants)
first mismatch (1001, 501)
offline: [2.0, 1310.5, 0.0, 6.0, 0.0425]
online: [nan, nan, nan, nan, nan]
account_stats: {'materialized': True, 'data_fresh': True,
'coverage': True} newest=10:00 covered=300/300

This time freshness reports healthy, because the registry still records the last materialization and the source data is fine. Parity catches it. Every request to the API would now get all-default features with five warnings, which a dashboard on the warning rate would also show within a minute. Recover with one command, python -m featurekit.materialize, and confirm parity is back to 300/300.

Drill What broke Caught by
Counter bug Feature meaning Definition tests
Half-finished job Upstream coverage Freshness coverage
Lost cache Online store contents Parity check
Common Mistake

Keeping only one of the three checks because it "usually catches things". Each drill above passed at least one check. Run all three on every scheduled cycle.

Milestone 9: Package the Project for Your Portfolio

This milestone turns the working code into a repository a reviewer can run with one command and understand in five minutes.

A reviewer gives a portfolio repository about five minutes. In that time they should be able to run one command, see every proof pass, and understand what you would do next.

Step 22

Save a Makefile so the whole project runs from one command.

TEXT
.PHONY: up data test apply train materialize parity fresh \
serve bench final all
up: ; docker compose up -d
data: ; python -m featurekit.pipeline
test: ; python -m pytest -q
apply: ; cd feature_repo && feast apply
train: ; python -m featurekit.train
materialize: ; python -m featurekit.materialize
parity: ; python -m featurekit.parity
fresh: ; python -m featurekit.freshness
serve: ; uvicorn featurekit.api:app --port 8000
bench: ; python -m featurekit.bench
final: ; python -m featurekit.train --final
all: up data test apply train materialize parity fresh bench
Note

The target: ; command form puts a one-line recipe on the same line as the target, so no tab characters are needed. make all runs every proof in order and stops at the first failure, which is exactly what a reviewer wants to see.

Step 23

Write README.md with these sections, in this order. Keep each one short; link to FEATURES.md instead of repeating it.

  • Problem: two sentences on training-serving skew and leakage in fraud features, with the counter incident as the example.
  • Architecture: the Mermaid diagram from this project, which GitHub renders.
  • Quick start: python3.12 -m venv .venv, install from requirements.lock, make all.
  • Proofs: the acceptance table from the first topic, with the command for each line.
  • Results: validation and final PR-AUC with intervals, the invalid-join score labelled as a leak demonstration, and lookup p50 and p95 with the "one sequential client" caveat.
  • Drills: the three-row table from Step 21.
  • Limits: synthetic data, hourly batch features rather than a live counter, a 90-day window that is never full, single-node Redis without TLS.
  • Next steps: two or three items from the stretch goals below.

Commit, then tag the release so a reviewer sees a finished version: git tag v1.0.

How to talk about this project in an interview

Interviewers ask "walk me through a project". Use this order, and keep it under three minutes.

  1. The problem: a fraud model that looked good offline and failed live because a feature meant different things in training and serving.
  2. The design: one feature contract, hourly features from one pipeline, Feast with Parquet offline and Redis online, two entities in one feature service.
  3. The proofs: definition tests for meaning, an independent as-of join for leakage, parity and two-part freshness for the online store.
  4. A failure you caught: pick one drill and say which check caught it and why the others could not.
  5. The numbers, honestly: PR-AUC with its interval, and why the invalid join's higher score is a warning, not a result.

Stretch goals

Each of these extends the project in a direction real teams take. Pick one or two; do not try all of them.

  • Add a streaming-style counter: a Redis INCR per payment for acct_txn_24h, and run the same definition tests against it. This is the acme-shop incident built properly.
  • Schedule pipeline, materialize, parity and freshness hourly with an Airflow DAG, using the patterns from Automating ML Training Pipelines with Airflow.
  • Run make test and a reduced make all in GitHub Actions on every pull request; CI/CD and Continuous Training for ML covers the gate design.
  • Log the training run, the Feast feature service name and the contract version to MLflow, so a registered model records which features it needs.
Common Mistake

Listing tools in the README instead of proofs. "Feast, Redis, FastAPI" tells a reviewer nothing they could not guess from the file names. The acceptance table, results with intervals and the drills table show what the system guarantees.

Validation & Testing

This step runs every proof from a clean start, scores the reserved test window once, and shuts the project down cleanly.

Run everything from scratch

Delete the generated state and rebuild it with the Makefile, exactly as a reviewer would. Every target must pass.

Bash
docker compose down
rm -rf data/* feature_repo/data models/* results/validation.json
mkdir -p data models results
make all

Expected output, trimmed to the key lines (numbers illustrative):

TEXT
4 passed in 1.92s
4402 payments, fraud rate 0.071, 216000 account rows,
28800 merchant rows
Created feature service fraud_v2
joined on created: 4402 payments, 0 values differ from reference
materialized up to 2026-11-04 10:42 UTC
300/300 pairs match (300 accounts, 40 merchants)
account_stats: {'materialized': True, 'data_fresh': True,
'coverage': True} newest=09:00 covered=300/300
lookup ms p50 1.84 p95 3.10 p99 5.62

Validation checklist

Check Pass condition Command
Definition tests 4 passed make test
As-of verification 0 values differ make train
Parity every pair matches make parity
Freshness all three checks True make fresh
API defaults unknown IDs return warnings make serve then curl
Final score file written once make final

Check the API's default handling by hand once:

Bash
make serve &
sleep 5
curl -s localhost:8000/v1/score -H 'content-type: application/json' \
-d '{"payment_id":"p1","account_id":99999,
"merchant_id":999,"amount":2500.0}'
JSON
{"payment_id":"p1","fraud_probability":0.0213,
"lookup_ms":1.71,"warnings":["acct_txn_24h missing, default used",
...]}

An unknown account and merchant come back with five warnings and a score built from the contract defaults, the same values the training set used for entities with no history.

Step 24

Score the final test window, once. Do this only after every decision in the project is made.

Bash
python -m featurekit.train --final

Illustrative output:

TEXT
final test: {
"pr_auc": 0.389,
"interval_95": [0.262, 0.521],
"rows": 742,
"frauds": 48
}

Report this number in your README next to the validation result. If they differ by less than the width of the intervals, that is noise, not a finding. Running it again stops with "final test already scored", which is the guard working.

Step 25

Shut the project down. Redis holds no data you cannot rebuild, so removing it is safe.

Bash
kill %1 2>/dev/null ## stop the API started with make serve
docker compose down
deactivate

Commit results/validation.json and results/final_test.json with the code: they are the evidence your README quotes.

Quick reference

Task Command Proves
Start Redis make up Online store on localhost
Build features make data Hourly snapshots per contract
Check meaning make test Definitions match FEATURES.md
Register in Feast make apply Two entities, one service
Train and verify make train No future values, honest score
Load Redis make materialize Idempotent 3-day window
Compare stores make parity Online equals offline
Check staleness make fresh Recent run, recent data, full coverage
Measure lookup make bench Per-lookup latency
Score test once make final Final number, guarded

Common mistakes

Writing the pipeline before the contract is how one feature name ends up with two meanings. It happens because code feels like progress and documentation does not. The fix is the order this project used: write FEATURES.md, encode its rules in contract.py and definition tests, then write the pipeline until the tests pass.

Trusting parity as proof of correctness misses every bug that lives upstream of the stores. Parity compares Redis with Parquet; if the pipeline computed the wrong number, both hold the same wrong number and parity passes, as Drill 1 showed. The fix is to keep definition tests for meaning and parity for materialization, and never let one stand in for the other.

Comparing offline and online "as of now" makes the parity check fail whenever the data is older than the TTL, so people learn to ignore it. It happens because "now" feels like the natural reference time. The fix is to compare as of the newest snapshot, and leave staleness to the freshness check, which has its own threshold.

Checking only when materialization last ran reports healthy while the upstream job writes partial or no data, as Drill 2 showed. The fix is to check the newest event time and the number of entities it covers, alongside the materialization time.

Letting training see NaN while serving sends zeros creates skew inside your own code. It happens because tree models accept missing values without complaint. The fix is one DEFAULTS dictionary in contract.py, applied with fillna in training and in the API.

Reporting the invalid latest-join score, or a single PR-AUC without its interval, overstates what the model can do. It happens because higher and more precise numbers look better in a README. The fix is to report the point-in-time score with its bootstrap interval, label the leaky score as a demonstration, and score the test window once.

Publishing Redis on every interface because the default Docker port mapping does it is a quiet security hole on any shared network. The fix is to bind to 127.0.0.1 locally, and in production to use TLS, a password from a secret store and network rules that admit only the materialization job and the serving pods.

What you built and what comes next

You built acme-fraud-feature-store: a written feature contract with tests, an hourly vectorized feature pipeline for two entities, a Feast repository with Parquet and Redis, a point-in-time training set verified value by value against an independent join, a model with an honest score and interval, a FastAPI endpoint reading features at request time with shared defaults, and three checks that each caught a different failure. It runs from one command and explains itself in its README.

With this project, Step 5 is complete. Step 6 continues with Batch Inference and Model Optimization, and Step 7's CI/CD and Continuous Training for ML is where make test, make parity and the final-score guard become gates that run on every change.

Found a mistake, an outdated step or a broken link?