Zeitpunktbezogener Merkmalsabruf und Konsistenz von Training und Einsatz
Zusammenfassung
Dieses Dokument veranschaulicht Feature-Store-Konzepte direkt anhand von Parquet-Daten aus einer Fallstudie. Eine Feature-Ansicht legt den Entitätsschlüssel, den Ereigniszeitstempel, die Gültigkeitsdauer und die Merkmalsspalten fest. Beim Modelltraining werden historische Werte mit dem Entscheidungszeitpunkt verknüpft, zu dem sie bekannt waren; beim Live-Einsatz wird der zuletzt zum Entscheidungszeitpunkt verfügbare Wert abgerufen. Die Einhaltung dieser Regeln hilft, Abweichungen zwischen Training und Einsatz zu vermeiden.
Der Ablauf leitet Trainings- und Bereitstellungsdaten aus den Fold-Grenzen der Fallstudie ab, verhindert, dass das Trainingsfenster in den Holdout hineinreicht, und vergleicht den korrekten Abruf mit einer absichtlich verschobenen Zeitstempelregel. Das Beispiel zeigt, dass ein um eine Sitzung vorgezogener Zugriff einen gesamten Merkmalsvektor beeinflussen kann. Außerdem dokumentiert es die Merkmalsherkunft und Schlüsselanzahlen. Zu den Einschränkungen gehören die Messung von Änderungen an Merkmalswerten statt nachgelagerter Modellergebnisse, die Stichprobe von nur acht Namen an einem Datum und die Festlegung einer Gültigkeitsdauer ohne Prüfung veralteter Werte bei diesen direkten Dateizugriffen. Ein produktiver Online-Store bringt zusätzliche Konsistenzprobleme mit sich, die diese Demonstration nicht abbildet.
Kernaussagen
- Zeitpunktbezogene Verknüpfungen benötigen sowohl eine Entitätskennung als auch den Zeitpunkt, ab dem jedes Merkmal gültig war.
- Beim Trainingsabruf werden zum jeweiligen historischen Entscheidungszeitpunkt bekannte Werte verknüpft; im Einsatz wird der zu diesem Zeitpunkt zuletzt bekannte Wert verwendet.
- Begrenzen Sie das Trainingsfenster, damit es keine Daten nach dem für das Modell zulässigen Informationszeitpunkt enthält.
- Ein Zeitstempelfehler kann viele Merkmale gleichzeitig verschieben; messen Sie diese Abweichung direkt.
- Das Beispiel quantifiziert weder die Auswirkungen auf nachgelagerte Modelle noch setzt es den Ablauf der Gültigkeitsdauer durch.
Schlagwörter
Volltext
# Feature Store Patterns on Real Case-Study Artifacts
# Feature Store Patterns on Real Case-Study Artifacts
**Chapter 26: MLOps and Governance**
**Docker image**: `ml4t`
**Book Reference**: Chapter 26, Section 26.6
**Prerequisites**: Familiarity with feature engineering and Chapter 25 deployment verification.
**Learning Objectives**:
- Describe a table of features the way a feature store does: which column identifies the
thing being described, which column says when the value was true, and how long a value
stays usable.
- Assemble a training set by joining each feature to the moment it was known, and add a
check that refuses to build one that reaches past the date the model may not see.
- Assemble the same feature vector the way a live system would, from the latest value known
at the moment of the decision.
- Measure what changes when the timestamp rule is wrong by one session.
A **feature store** is the component that answers one question in two places: what were this
thing's features at this moment. Training asks it for millions of past moments at once;
a live system asks it for one moment, now. When the two answers are produced by different
code, they drift apart, and a model trained on one is served the other. The drift is called
**training-serving skew**, and it is the failure this whole component exists to prevent.
Nothing below imports a feature store. The tasks are done directly against the case study's
own Parquet files, so what a store automates stays visible. `05b_feast_live` runs the same
workflow through Feast.
```python
"""Feature Store Patterns on Real Case-Study Artifacts: point-in-time joins, as-of serving, and the skew between them."""
```
## Settings
`TRAINING_START`, `TRAINING_END` and `AS_OF_DATE` bound the two retrievals: the offline
training window, and the single moment the live system is asked about. Left unset they are
derived from the case study's own fold geometry - the training window is the tail of the
last validation window, and the serving date is the first session inside the holdout, so the
arrangement is "the model was fitted up to here, and today is after that". They are derived
rather than typed because a date typed here is a claim about where a fold boundary falls,
and rebuilding the case study with a different number of folds moves every boundary while
the literal does not. Set any of them to a date string to pin it by hand.
`TRAINING_LOOKBACK_DAYS` is how far back from `TRAINING_END` the offline join reaches, when
derived. A quarter is enough rows to make the join's behaviour visible and small enough to
read.
`N_SAMPLE_ASSETS` is how many names the online retrieval asks about. A live system asks
about the book it holds; eight is enough to show the shape of the answer.
`FEATURE_TTL_DAYS` is how long a feature value stays servable after its timestamp. One
session, because these are daily features: a value from two sessions ago is stale, and a
store that served it would be answering a question about today with yesterday's data.
`LIQUIDITY_WINDOW_DAYS` and `LIQUIDITY_RANK_DAYS` govern which names the sample picks, by
average dollar volume over the tail of the training window.
`SKEW_SEARCH_DAYS` is how far forward the deliberately wrong retrieval in section 4 is
allowed to look for the next snapshot.
```python
CASE_STUDY_ID = "us_equities_panel"
PRIMARY_LABEL = "fwd_ret_1d"
TRAINING_START = None
TRAINING_END = None
AS_OF_DATE = None
TRAINING_LOOKBACK_DAYS = 91
N_SAMPLE_ASSETS = 8
FEATURE_TTL_DAYS = 1
LIQUIDITY_WINDOW_DAYS = 21
LIQUIDITY_RANK_DAYS = 30
SKEW_SEARCH_DAYS = 7
```
```python
import warnings
from dataclasses import dataclass
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import polars as pl
import yaml
from IPython.display import Markdown, display
from utils.cv_splits import generate_cv_splits
from utils.paths import get_case_study_dir
from utils.style import COLORS, FIGSIZE, add_message_title, show_with_alt
# Named, not blanket: a bare ignore would also hide the convergence and numerical
# warnings a reader needs to see.
warnings.filterwarnings("ignore", category=FutureWarning, module="polars")
CASE_DIR = get_case_study_dir(CASE_STUDY_ID)
CODE_ROOT = CASE_DIR.parent.parent # repo root (case_studies lives at repo root)
SETUP_PATH = CASE_DIR / "config" / "setup.yaml"
print("Feature Store Patterns on Real Case-Study Artifacts")
print("=" * 60)
```
## 1. Describe the feature tables the way a store would
A **feature view** is a store's description of one table of features. Four things have to be
said about it, and they are the four fields below.
The **entity key** is the column naming the thing each row is about, here `symbol`. The
**event timestamp** is the column saying when the row's values were true, here `timestamp`.
Those two together are what makes a point-in-time join possible: without them a store can
find a row but cannot say whether it was knowable yet.
The **time to live** is how long a value stays usable after its timestamp. It is what turns
a missing row into an error rather than a silently stale answer: ask for a name whose last
feature row is a week old and a store with a one-session TTL says it has nothing, while a
store without one hands back last week's value as if it were today's.
The **feature columns** are what the view actually serves. Two tables here: the financial
features computed directly from prices, and the model-based ones, which are outputs of
models fitted per fold - a conditional volatility from a GARCH fit, and two fractionally
differenced price and volume series.
```python
@dataclass
class FeatureViewSpec:
name: str
source_path: Path
entity_key: str
event_timestamp: str
ttl_days: int
feature_columns: list[str]
FINANCIAL_FEATURES = ["past_ret_21d", "vol_21d", "rsi_14", "sharpe_21d"]
MODEL_FEATURES = ["garch_cond_vol", "ffd_log_price", "ffd_log_volume"]
feature_views = [
FeatureViewSpec(
name="financial_features",
source_path=CASE_DIR / "features" / "financial.parquet",
entity_key="symbol",
event_timestamp="timestamp",
ttl_days=FEATURE_TTL_DAYS,
feature_columns=FINANCIAL_FEATURES,
),
FeatureViewSpec(
name="model_based_features",
source_path=CASE_DIR / "features" / "model_based.parquet",
entity_key="symbol",
event_timestamp="timestamp",
ttl_days=FEATURE_TTL_DAYS,
feature_columns=MODEL_FEATURES,
),
]
```
```python
feature_registry = pd.DataFrame(
[
{
"feature_view": spec.name,
"source_path": spec.source_path.relative_to(CODE_ROOT),
"entity_key": spec.entity_key,
"event_timestamp": spec.event_timestamp,
"ttl_days": spec.ttl_days,
"n_features": len(spec.feature_columns),
}
for spec in feature_views
]
)
feature_registry
```
```python
setup = yaml.safe_load(SETUP_PATH.read_text())
holdout_start = pd.Timestamp(setup["evaluation"]["holdout_start"])
holdout_end = pd.Timestamp(setup["evaluation"]["holdout_end"])
print(f"Holdout starts on {holdout_start.date()}")
```
```python
timeline = pl.scan_parquet(feature_views[0].source_path).select("timestamp").unique().collect()
cv_splits = generate_cv_splits(timeline, case_study_id=CASE_STUDY_ID, label_buffer="1D")
```
### Which sessions a feature value may be served for
A feature value is servable for a session the model was actually evaluated on, so the store's
date range is the union of the walk-forward validation windows and the holdout.
Where the model-based table carries a `fold` column, each stock-date appears once per fold
with the value that fold's own fit produced, and the window and the fold have to be applied
together. Where it does not, one stock-date is one row and the date range alone selects it.
Both shapes are handled below because both exist on disk.
```python
def folds_by_coverage(path, windows: list[tuple]) -> list[object]:
"""Pair each evaluation window with the artifact fold that covers it, by date.
A fold id is a label the writer chose, not a property of the data, and two tools can
number the same folds in opposite directions. Joining a stored id to a freshly
generated one then pairs each date with the wrong fitted state and still succeeds,
because both ids exist. Ordering both sides by the dates they cover is the pairing
that holds whatever numbering wrote the file.
"""
coverage = (
pl.scan_parquet(path)
.group_by("fold")
.agg(pl.max("timestamp").alias("last_covered"))
.collect()
.sort("last_covered")
)
stored = coverage["fold"].to_list()
if len(stored) != len(windows):
raise ValueError(
f"model_based.parquet carries {len(stored)} folds and this notebook derived "
f"{len(windows)} evaluation windows. They cannot be paired by date, so the "
"artifact does not describe the geometry this case study now declares; "
"regenerate it against the current stage 04."
)
order = sorted(range(len(windows)), key=lambda i: windows[i])
paired: list[object] = [None] * len(windows)
for position, window_index in enumerate(order):
paired[window_index] = stored[position]
return paired
MODEL_BASED_PATH = feature_views[1].source_path
FOLD_KEYED_ARTIFACT = "fold" in pl.scan_parquet(MODEL_BASED_PATH).collect_schema().names()
validation_spans = [
(pd.Timestamp(split["val_start"]).date(), pd.Timestamp(split["val_end"]).date())
for split in cv_splits
]
model_windows = [*validation_spans, (holdout_start.date(), holdout_end.date())]
model_folds = (
folds_by_coverage(MODEL_BASED_PATH, model_windows)
if FOLD_KEYED_ARTIFACT
else [None] * len(model_windows)
)
# By date rather than by list position: which end of the list holds the latest window
# depends on the numbering convention, and the dates do not.
last_validation_span = max(validation_spans)
training_end = TRAINING_END or str(last_validation_span[1])
training_start = TRAINING_START or str(
max(
last_validation_span[0],
pd.Timestamp(training_end).date() - pd.Timedelta(days=TRAINING_LOOKBACK_DAYS),
)
)
as_of_date = AS_OF_DATE or str(
pl.scan_parquet(feature_views[0].source_path)
.filter(pl.col("timestamp") >= holdout_start.date())
.select(pl.min("timestamp"))
.collect()
.item()
)
assert pd.Timestamp(training_end) < holdout_start, (
f"training window ends {training_end}, on or after the holdout opens "
f"{holdout_start.date()}; the offline join would mix data the model may not see"
)
assert pd.Timestamp(training_start) <= pd.Timestamp(training_end), (
f"training window runs {training_start} to {training_end}, which is backwards"
)
print(f"Offline training window {training_start} to {training_end}; serving as of {as_of_date}")
```
## 2. Offline training retrieval with point-in-time correctness
The offline join uses feature values observed on the decision date and the
forward-return label generated after that date. This is the contract a feature
store must preserve. Here features are available after the close on session
$t$, the label is the next-session return, and any position acts no earlier
than the next tradable bar.
```python
def window_terms(clipped: list[tuple]) -> list[pl.Expr]:
"""One predicate per evaluation window, to be OR-ed into a single filter.
Where the artifact carries no fold column a window is a date range and nothing else.
Where it does, the same window also names the fold whose fitted state that range is
evaluated under, and the two are applied together: each fold holds its own value for
the same stock-date, so a date range alone returns one row per fold with no rule for
choosing between them.
"""
if FOLD_KEYED_ARTIFACT:
return [
(pl.col("fold") == fold) & pl.col("timestamp").is_between(start, end)
for fold, start, end in clipped
]
return [pl.col("timestamp").is_between(start, end) for _, start, end in clipped]
```
```python
def load_training_events(start: str, end: str) -> pl.DataFrame:
labels = (
pl.scan_parquet(CASE_DIR / "labels" / f"{PRIMARY_LABEL}.parquet")
.filter(
(pl.col("timestamp") >= pl.lit(pd.Timestamp(start).date()))
& (pl.col("timestamp") <= pl.lit(pd.Timestamp(end).date()))
)
.select(
pl.col("timestamp").cast(pl.Date).alias("timestamp"),
"symbol",
pl.col(PRIMARY_LABEL).alias("label"),
)
.collect()
)
return labels
```
```python
def load_model_vintage(
start: str | object,
end: str | object,
columns: list[str],
assets: list[str] | None = None,
) -> pl.DataFrame:
"""Load the model features servable for each decision date in the range.
One filter over the union of the evaluation windows rather than one frame per window
concatenated: a concat would double-count any date two windows cover, and it would do
it silently. Under a single filter the only duplication that can reach the result is
the artifact's own, which the assertion below refuses.
"""
start_date = pd.Timestamp(start).date()
end_date = pd.Timestamp(end).date()
clipped = [
(fold, max(start_date, window_start), min(end_date, window_end))
for fold, (window_start, window_end) in zip(model_folds, model_windows, strict=True)
if window_start <= end_date and window_end >= start_date
]
if not clipped:
raise ValueError(
f"No validation window and not the holdout covers {start}..{end}; "
"the requested range lies outside every window this model was evaluated on"
)
frame = pl.scan_parquet(MODEL_BASED_PATH).filter(pl.any_horizontal(*window_terms(clipped)))
if assets is not None:
frame = frame.filter(pl.col("symbol").is_in(assets))
result = frame.select(["symbol", "timestamp", *columns]).collect().sort(["timestamp", "symbol"])
assert not result.select(pl.struct("symbol", "timestamp").is_duplicated().any()).item(), (
"a stock-date resolved to more than one model-based row; the evaluation windows "
"this notebook derived are not disjoint over the requested range"
)
return result
```
### The two assertions above are the governance guard
The training window must end before the holdout opens, and it must run forwards. They sit
after the derivation rather than before it so that they also cover a hand-pinned override,
which is the case they exist for: a derived window cannot reach past the holdout, and a
typed one can.
A guard that refuses to build the training set is the right shape here. The alternative
failure is silent: an offline join that quietly includes post-holdout sessions produces a
training set that looks ordinary and a model whose evaluation means nothing.
### Point-in-time join
Each training event is a (symbol, date) pair with its label. The join attaches the feature
values carrying exactly that date - the values known after that session's close - and the
label is the return from that session to the next. Rows missing any feature are dropped
rather than filled, and the count of what was dropped is printed, because a feature store
that silently imputes is a feature store that hides its own gaps.
```python
def offline_join(events: pl.DataFrame) -> pl.DataFrame:
assert not events.select(pl.struct("symbol", "timestamp").is_duplicated().any()).item()
financial = (
pl.scan_parquet(feature_views[0].source_path)
.select(["symbol", "timestamp", *FINANCIAL_FEATURES])
.join(
events.lazy().select(["symbol", "timestamp"]),
on=["symbol", "timestamp"],
how="inner",
)
.collect()
)
model_based = load_model_vintage(training_start, training_end, MODEL_FEATURES).join(
events.select(["symbol", "timestamp"]), on=["symbol", "timestamp"], how="inner"
)
joined = (
events.join(financial, on=["symbol", "timestamp"], how="left")
.join(model_based, on=["symbol", "timestamp"], how="left")
.sort(["timestamp", "symbol"])
)
assert joined.height == events.height
assert not joined.select(pl.struct("symbol", "timestamp").is_duplicated().any()).item()
return joined.drop_nulls(FINANCIAL_FEATURES + MODEL_FEATURES)
training_events = load_training_events(training_start, training_end)
offline_training_set = offline_join(training_events)
print(f"Training events: {training_events.height:,}")
print(f"Offline joined rows: {offline_training_set.height:,}")
print(f"Excluded incomplete: {training_events.height - offline_training_set.height:,}")
offline_training_set.head(5)
```
## 3. Retrieve the same features the way a live system would
The offline join above asked for exact dates, because it knew which sessions it wanted. A
live system does not: it has a moment, and it needs each name's most recent value at or
before it. That is the **as-of** retrieval, and it is a different query against the same
tables.
It has to be a different query and it has to give the same answer. Ask for a date the
offline join covered and the as-of retrieval must return exactly what the join attached; if
it does not, the model is being served something it was never trained on. Section 4 measures
what that costs when the rule is wrong by one session.
```python
def sample_assets(n_assets: int) -> list[str]:
from data import load_us_equities
rank_from = pd.Timestamp(training_end).date() - pd.Timedelta(days=LIQUIDITY_RANK_DAYS)
prices = load_us_equities(start_date=training_start, end_date=training_end)
universe = (
prices.lazy()
.sort("symbol", "timestamp")
.with_columns((pl.col("adj_close") * pl.col("adj_volume")).alias("dollar_volume"))
.with_columns(
pl.col("dollar_volume")
.rolling_mean(LIQUIDITY_WINDOW_DAYS)
.over("symbol")
.alias("avg_dollar_volume")
)
.filter(
(pl.col("timestamp") >= rank_from)
& (pl.col("timestamp") <= pd.Timestamp(training_end).date())
)
.group_by("symbol")
.agg(pl.col("avg_dollar_volume").mean().alias("mean_dollar_volume"))
.sort("mean_dollar_volume", descending=True)
.head(n_assets)
.collect()
)
return universe.get_column("symbol").to_list()
```
### Latest-known snapshot
Everything at or before the cut-off, sorted, last row per name. The `<=` is the whole
control: a `<` would drop the current session's own values, and a `>` is section 4.
```python
def latest_snapshot(
source_path: Path, as_of_date: str, assets: list[str], columns: list[str]
) -> pl.DataFrame:
cutoff = pd.Timestamp(as_of_date).date()
return (
pl.scan_parquet(source_path)
.filter((pl.col("timestamp") <= pl.lit(cutoff)) & pl.col("symbol").is_in(assets))
.select(["symbol", "timestamp", *columns])
.sort(["symbol", "timestamp"])
.group_by("symbol")
.tail(1)
.collect()
)
```
```python
def latest_model_snapshot(as_of_date: str, assets: list[str]) -> pl.DataFrame:
start = holdout_start if pd.Timestamp(as_of_date) >= holdout_start else pd.Timestamp(as_of_date)
panel = load_model_vintage(start, as_of_date, MODEL_FEATURES, assets)
return panel.group_by("symbol").tail(1).sort("symbol")
sampled_assets = sample_assets(N_SAMPLE_ASSETS)
online_financial = latest_snapshot(
feature_views[0].source_path, as_of_date, sampled_assets, FINANCIAL_FEATURES
)
online_model = latest_model_snapshot(as_of_date, sampled_assets)
online_snapshot = online_financial.join(online_model, on=["symbol", "timestamp"], how="inner").sort(
"symbol"
)
```
### Online snapshot
```python
online_snapshot
```
## 4. Measure what one session of look-ahead costs
The failure being measured is one comparison operator. `latest_snapshot` above takes the
last row at or before the cut-off; `leaked_snapshot` below takes the first row after it. In
code that is `<=` against `>`, and in production it is a serving path that reads the newest
row in the table without checking whether it was knowable yet - which is what happens when
a batch job writes tomorrow's features tonight and the serving query does not filter.
One session is the smallest version of this mistake. The comparison below is what it does to
the feature vector.
```python
def leaked_snapshot(as_of_date: str, assets: list[str]) -> pl.DataFrame:
cutoff = pd.Timestamp(as_of_date).date()
future_end = min(
(pd.Timestamp(cutoff) + pd.Timedelta(days=SKEW_SEARCH_DAYS)).date(), holdout_end.date()
)
financial = (
pl.scan_parquet(feature_views[0].source_path)
.filter((pl.col("timestamp") > pl.lit(cutoff)) & pl.col("symbol").is_in(assets))
.select(["symbol", "timestamp", *FINANCIAL_FEATURES])
.sort(["symbol", "timestamp"])
.group_by("symbol")
.head(1)
)
model_based = (
load_model_vintage(cutoff, future_end, MODEL_FEATURES, assets)
.lazy()
.filter(pl.col("timestamp") > pl.lit(cutoff))
.sort(["symbol", "timestamp"])
.group_by("symbol")
.head(1)
)
return (
financial.join(model_based, on=["symbol", "timestamp"], how="inner")
.collect()
.sort("symbol")
)
future_snapshot = leaked_snapshot(as_of_date, sampled_assets)
comparison = (
online_snapshot.rename({col: f"{col}_correct" for col in FINANCIAL_FEATURES + MODEL_FEATURES})
.join(
future_snapshot.rename(
{col: f"{col}_leaked" for col in FINANCIAL_FEATURES + MODEL_FEATURES}
),
on="symbol",
how="inner",
)
.to_pandas()
)
```
```python
skew_rows = []
for column in FINANCIAL_FEATURES + MODEL_FEATURES:
skew_rows.append(
{
"feature": column,
"mean_abs_delta": np.abs(
comparison[f"{column}_leaked"] - comparison[f"{column}_correct"]
).mean(),
"max_abs_delta": np.abs(
comparison[f"{column}_leaked"] - comparison[f"{column}_correct"]
).max(),
}
)
skew_table = pd.DataFrame(skew_rows).sort_values("mean_abs_delta", ascending=False)
skew_table
```
```python
largest_skew = skew_table.iloc[0]
display(
Markdown(
f"Serving one session late moves every feature. `{largest_skew['feature']}` moves "
f"most, by {largest_skew['mean_abs_delta']:.4g} on average across the sampled names. "
"Whether a given size matters depends on the model; that any of them move is the "
"point, because a model trained on the first vector is being served the second."
)
)
```
## 5. Record where each view came from
A registry that lists feature names says what exists. What an operator needs when a served
value looks wrong is where it came from: which file, how many rows, over what dates, and how
many distinct entity-timestamp keys - the last of these because a count of rows above the
count of keys means the table holds more than one value per key, and the store's answer then
depends on which one it picked.
```python
lineage_rows = []
for spec in feature_views:
stats = (
pl.scan_parquet(spec.source_path)
.select(
pl.len().alias("rows"),
pl.min("timestamp").alias("min_date"),
pl.max("timestamp").alias("max_date"),
pl.struct("symbol", "timestamp").n_unique().alias("unique_keys"),
)
.collect()
.row(0)
)
lineage_rows.append(
{
"feature_view": spec.name,
"rows": stats[0],
"start": pd.Timestamp(stats[1]).date(),
"end": pd.Timestamp(stats[2]).date(),
"unique_keys": stats[3],
"features": ", ".join(spec.feature_columns),
}
)
lineage_table = pd.DataFrame(lineage_rows)
lineage_table
```
```python
fig, ax = plt.subplots(figsize=FIGSIZE["single"])
ordered = skew_table.iloc[::-1]
ax.barh(ordered["feature"], ordered["mean_abs_delta"], color=COLORS["negative"])
add_message_title(
ax,
"Feature change under a one-session serving error",
subtitle=f"Mean absolute difference across {N_SAMPLE_ASSETS} names, as of {as_of_date}",
)
ax.set_xlabel("Absolute difference in feature value")
show_with_alt(
fig,
"Horizontal bar chart, one bar per feature, sorted with the largest at the top. Each "
"bar is the mean absolute difference between the feature value a correct as-of "
"retrieval returns and the value the next-session retrieval returns.",
)
```
The bars are on different scales because the features are: a relative strength index runs
from 0 to 100 while an annualized volatility is a small decimal, so the chart ranks how far
each moved and does not compare them with each other. The bars near the axis are not zero -
the table above gives their sizes - and that is the point worth taking: one wrong comparison
operator moves the whole vector, not one feature of it.
The contract a store enforces is three sentences. The offline join takes the feature values
known at each decision timestamp. The online retrieval takes the last values known at the
moment of the decision, by the same rule. The registry says where both came from. Feast
automates all three, which is what `05b_feast_live` shows, and the reason to know what they
are is that a store configured with the wrong timestamp column automates the wrong one.
## Key Takeaways
1. A feature table needs an entity key, an event timestamp and a time to live before it can
be served correctly. The first two make a point-in-time join possible; the third is what
turns a missing row into an error instead of a stale answer.
2. Training and serving ask different queries of the same data - a join on exact dates, and
a latest-at-or-before lookup - and the whole discipline is making them agree. Skew is what
it is called when they do not.
3. Measure the skew rather than reasoning about it. One session of look-ahead is the smallest
version of the mistake and it still moves every feature; a larger one is not visibly
different in the code.
4. Guard the holdout boundary where the training set is built, and make the guard refuse. A
join that quietly includes sessions the model may not see produces a training set that
looks ordinary and an evaluation that means nothing.
5. Record lineage per view, including the count of distinct entity-timestamp keys. Rows above
keys means the table holds more than one value per key, and the store's answer then
depends on which one it happened to pick.
**Known limitations**
- The skew here is measured on the feature vectors, not on what a model does with them. A
large move in a feature the model barely uses matters less than a small move in one it
leans on, and nothing here weights them.
- Eight names on one date is enough to show that the error reaches every feature. It is not
an estimate of the size of the error on any other date.
- The time to live is declared and not enforced: these retrievals filter on dates rather
than checking staleness against the TTL. A real store rejects the stale read.
- The offline store here is two Parquet files read directly. A production store adds an
online key-value tier for serving, and the consistency between the two tiers is its own
problem, not covered here.
**Next**: See `05b_feast_live` for the same workflow automated with Feast, or `06_mlflow_experiments` for experiment tracking.
Vollständig mit Quellenangabe unter der Lizenz der Quelle angezeigt. Lizenz: MIT
Diese Zusammenfassung wurde vom Research-Agenten von Stratmill anhand des Originals verfasst; sie ist keine Kopie der Quelle.