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.
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 |
TipA 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.
mkdir -p acme-fraud-feature-store && cd acme-fraud-feature-storegit initmkdir -p src/featurekit feature_repo tests data results modelstouch src/featurekit/__init__.pypython3.12 -m venv .venv && source .venv/bin/activateSave requirements.txt:
feast[redis]==0.67.0scikit-learn==1.9.1joblib==1.6.0fastapi==0.162.0uvicorn==0.54.0httpx==0.28.1pytest==9.2.0Save pyproject.toml, which makes featurekit an installable package and tells pytest where the source lives:
[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"]pip install -r requirements.txtpip install -e .pip freeze --exclude-editable > requirements.lockfeast versionFeast SDK Version: "0.67.0"Note
feast[redis]installs Feast plus the Redis client it needs; plainfeastcannot 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 inrequirements.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:
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"]docker compose up -ddocker compose exec redis redis-cli pingPONGSecurityThis Redis has no password, which is acceptable only because the port is bound to
127.0.0.1. Never publish it as6379: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 noturn 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:
project: acme_fraud_fsregistry: data/registry.dbprovider: localonline_store: type: redis connection_string: "localhost:6379"entity_key_serialization_version: 3The 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 MistakeInstalling plain
feastinstead offeast[redis]. Everything works until the firstfeast applyor 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.
# 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 his 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 noneacct_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.NoteThe 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:
"""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 bucketJOB_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:
"""Each test pins one rule from FEATURES.md."""import pandas as pd from featurekit.contract import DEFAULTSfrom 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.25NoteEach 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 MistakeWriting 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:
"""Seeded raw payments: 300 accounts, 40 merchants, 30 days."""import numpy as npimport 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)NoteThe data ends one hour before the current hour, and every timestamp is timezone-aware UTC because
nowis. 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:
"""Hourly account and merchant snapshots, per FEATURES.md."""import pandas as pd from featurekit.contract import CB_DELAY_HOURS, DATA, JOB_DELAYfrom 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 bucketskiphours later before the rolling sum, so the value at hour h only sums buckets that ended before h. Withskip=1that is the "strictly before h" rule; withskip=73it 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.
python -m pytest -qpython -m featurekit.pipelineExpected output (the payment count and rate depend on library versions; the shape should match):
.... [100%]4 passed in 1.92s4402 payments, fraud rate 0.071, 216000 account rows,28800 merchant rows300 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 MistakeMixing 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:
"""Feast definitions: account and merchant features, one service."""from datetime import timedeltafrom 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])NoteThe
contracttag links each feature view to the version ofFEATURES.mdit 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.
cd feature_repo && feast apply && cd ..Expected output, similar to:
Created entity accountCreated entity merchantCreated feature view account_statsCreated feature view merchant_statsCreated feature service fraud_v2Why 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 MistakeChanging 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.
"""Point-in-time training set, verified, and a time-split model."""import argparseimport jsonfrom pathlib import Path import joblibimport numpy as npimport pandas as pdfrom feast import FeatureStorefrom sklearn.ensemble import HistGradientBoostingClassifierfrom sklearn.metrics import average_precision_score from featurekit.contract import (DATA, DEFAULTS, FEATURE_SERVICE, REPO, X_COLS) SEED = 42WARMUP = pd.Timedelta(days=7) # 7-day windows not yet fullVIEWS = {"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()NoteA 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.
python -m featurekit.trainIllustrative output (your numbers will differ; the shape should match):
joined on created: 4402 payments, 0 values differ from referencerows 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 MistakeChoosing 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:
"""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()TipThis 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.
python -m featurekit.materializedocker compose exec redis redis-cli dbsizematerialized up to 2026-11-04 10:42 UTC(integer) 340340 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.
"""Materialization parity for every account and merchant."""import sys import pandas as pdfrom 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()NoteThe 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.
python -m featurekit.parity300/300 pairs match (300 accounts, 40 merchants)Common MistakeTreating 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:
"""Fraud scoring API that reads account and merchant features."""import timefrom contextlib import asynccontextmanager import joblibimport pandas as pdfrom fastapi import FastAPI, Requestfrom feast import FeatureStorefrom 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) @asynccontextmanagerasync 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_msis 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 fromcontract.py, the same dictionarytrain.pypassed tofillna, 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:
"""Lookup latency percentiles for the online feature read."""import numpy as npfrom 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}")python -m featurekit.benchIllustrative output from a laptop:
lookup ms p50 1.84 p95 3.10 p99 5.62This 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 MistakeCreating the
FeatureStoreobject 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:
"""Materialization and data freshness, with entity coverage."""import argparseimport sysfrom datetime import datetime, timedelta, timezone import pandas as pdfrom 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()python -m featurekit.freshnessaccount_stats: {'materialized': True, 'data_fresh': True, 'coverage': True} newest=09:00 covered=300/300merchant_stats: {'materialized': True, 'data_fresh': True, 'coverage': True} newest=09:00 covered=40/40If 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.
python -m pytest -qF... [100%]FAILED tests/test_definitions.py::test_window_is_half_open assert 3 == 2That 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:
python - <<'PY'import pandas as pdpath = "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])PYpython -m featurekit.materializepython -m featurekit.paritypython -m featurekit.freshnessIllustrative output (trimmed):
wrote 20 rows for 2026-11-04 10:00:00+00:00300/300 pairs match (300 accounts, 40 merchants)account_stats: {'materialized': True, 'data_fresh': True, 'coverage': False} newest=10:00 covered=20/300Parity 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.
docker compose restart redispython -m featurekit.paritypython -m featurekit.freshness0/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/300This 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 MistakeKeeping 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.
.PHONY: up data test apply train materialize parity fresh \ serve bench final all up: ; docker compose up -ddata: ; python -m featurekit.pipelinetest: ; python -m pytest -qapply: ; cd feature_repo && feast applytrain: ; python -m featurekit.trainmaterialize: ; python -m featurekit.materializeparity: ; python -m featurekit.parityfresh: ; python -m featurekit.freshnessserve: ; uvicorn featurekit.api:app --port 8000bench: ; python -m featurekit.benchfinal: ; python -m featurekit.train --final all: up data test apply train materialize parity fresh benchNoteThe
target: ; commandform puts a one-line recipe on the same line as the target, so no tab characters are needed.make allruns 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 fromrequirements.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.
- The problem: a fraud model that looked good offline and failed live because a feature meant different things in training and serving.
- The design: one feature contract, hourly features from one pipeline, Feast with Parquet offline and Redis online, two entities in one feature service.
- The proofs: definition tests for meaning, an independent as-of join for leakage, parity and two-part freshness for the online store.
- A failure you caught: pick one drill and say which check caught it and why the others could not.
- 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
INCRper payment foracct_txn_24h, and run the same definition tests against it. This is the acme-shop incident built properly. - Schedule
pipeline,materialize,parityandfreshnesshourly with an Airflow DAG, using the patterns from Automating ML Training Pipelines with Airflow. - Run
make testand a reducedmake allin 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 MistakeListing 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.
docker compose downrm -rf data/* feature_repo/data models/* results/validation.jsonmkdir -p data models resultsmake allExpected output, trimmed to the key lines (numbers illustrative):
4 passed in 1.92s4402 payments, fraud rate 0.071, 216000 account rows,28800 merchant rowsCreated feature service fraud_v2joined on created: 4402 payments, 0 values differ from referencematerialized up to 2026-11-04 10:42 UTC300/300 pairs match (300 accounts, 40 merchants)account_stats: {'materialized': True, 'data_fresh': True, 'coverage': True} newest=09:00 covered=300/300lookup ms p50 1.84 p95 3.10 p99 5.62Validation 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:
make serve &sleep 5curl -s localhost:8000/v1/score -H 'content-type: application/json' \ -d '{"payment_id":"p1","account_id":99999, "merchant_id":999,"amount":2500.0}'{"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.
python -m featurekit.train --finalIllustrative output:
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.
kill %1 2>/dev/null ## stop the API started with make servedocker compose downdeactivateCommit 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.