ymlin105 commited on
Commit
798c69b
·
1 Parent(s): 0269b4b

feat: add offline drift checks and inference logging

Browse files
Makefile CHANGED
@@ -1,4 +1,4 @@
1
- .PHONY: help install train evaluate test lint typecheck run docker-build docker-run clean
2
 
3
  help:
4
  @echo "Rossmann Sales Prediction - Make Commands"
@@ -6,6 +6,7 @@ help:
6
  @echo " make install Install dependencies"
7
  @echo " make train Run training pipeline"
8
  @echo " make evaluate Run holdout and backtesting evaluation"
 
9
  @echo " make test Run tests"
10
  @echo " make lint Run linting"
11
  @echo " make typecheck Run type checking"
@@ -23,6 +24,9 @@ train:
23
  evaluate:
24
  python scripts/evaluate_model.py
25
 
 
 
 
26
  test:
27
  python scripts/run_tests.py
28
 
 
1
+ .PHONY: help install train evaluate drift-check test lint typecheck run docker-build docker-run clean
2
 
3
  help:
4
  @echo "Rossmann Sales Prediction - Make Commands"
 
6
  @echo " make install Install dependencies"
7
  @echo " make train Run training pipeline"
8
  @echo " make evaluate Run holdout and backtesting evaluation"
9
+ @echo " make drift-check Build an offline drift report from inference logs"
10
  @echo " make test Run tests"
11
  @echo " make lint Run linting"
12
  @echo " make typecheck Run type checking"
 
24
  evaluate:
25
  python scripts/evaluate_model.py
26
 
27
+ drift-check:
28
+ python scripts/check_drift.py
29
+
30
  test:
31
  python scripts/run_tests.py
32
 
README.md CHANGED
@@ -28,7 +28,7 @@ Main ideas:
28
  src/training/ data loading, feature engineering, split helpers, model training
29
  src/serving/ FastAPI prediction service
30
  src/shared/ config, MLflow helper, and request/response schemas
31
- scripts/ evaluation and test runner
32
  web/ minimal HTML demo page
33
  reports/metrics/ generated training and evaluation outputs
34
  tests/ unit tests for training, serving, and split logic
@@ -65,6 +65,14 @@ This writes:
65
  If `mlflow` is installed, both commands also create local experiment runs under `mlruns/`
66
  by default. You can override that with `MLFLOW_TRACKING_URI`.
67
 
 
 
 
 
 
 
 
 
68
  Start the API demo:
69
 
70
  ```bash
@@ -117,6 +125,8 @@ curl -X POST http://localhost:7860/predict \
117
  ```
118
 
119
  The API looks up static store metadata from `store.csv`, so the request stays small.
 
 
120
 
121
  ## Example Results
122
 
@@ -154,5 +164,6 @@ The repository also includes `test.csv`, `sample_submission.csv`, and `train_sch
154
  - saved metrics may become stale if code or data changes
155
  - MLflow tracking is local and file-based; there is no remote tracking server or registry
156
  - CI validates the codebase but does not deploy artifacts or publish models
 
157
  - the explanation output is only a model contribution view, not a causal interpretation
158
  - the API assumes the requested store exists in `store.csv`
 
28
  src/training/ data loading, feature engineering, split helpers, model training
29
  src/serving/ FastAPI prediction service
30
  src/shared/ config, MLflow helper, and request/response schemas
31
+ scripts/ evaluation, drift check, and test runner
32
  web/ minimal HTML demo page
33
  reports/metrics/ generated training and evaluation outputs
34
  tests/ unit tests for training, serving, and split logic
 
65
  If `mlflow` is installed, both commands also create local experiment runs under `mlruns/`
66
  by default. You can override that with `MLFLOW_TRACKING_URI`.
67
 
68
+ Build an offline drift report from logged inference requests:
69
+
70
+ ```bash
71
+ make drift-check
72
+ ```
73
+
74
+ This writes `reports/metrics/drift_report.json` when inference logs are available.
75
+
76
  Start the API demo:
77
 
78
  ```bash
 
125
  ```
126
 
127
  The API looks up static store metadata from `store.csv`, so the request stays small.
128
+ Each request also appends one structured JSONL record to `logs/inference_requests.jsonl`
129
+ with timestamp, store id, forecast horizon, model version, and latency.
130
 
131
  ## Example Results
132
 
 
164
  - saved metrics may become stale if code or data changes
165
  - MLflow tracking is local and file-based; there is no remote tracking server or registry
166
  - CI validates the codebase but does not deploy artifacts or publish models
167
+ - Drift checking is offline and based on logged inference requests, not live monitoring
168
  - the explanation output is only a model contribution view, not a causal interpretation
169
  - the API assumes the requested store exists in `store.csv`
scripts/check_drift.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ruff: noqa: E402
2
+ import json
3
+ from pathlib import Path
4
+ import sys
5
+
6
+ import pandas as pd
7
+
8
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
9
+ if str(PROJECT_ROOT) not in sys.path:
10
+ sys.path.insert(0, str(PROJECT_ROOT))
11
+
12
+ from src.serving.monitoring import DEFAULT_INFERENCE_LOG_PATH
13
+ from src.shared.config import settings
14
+ from src.training.data_loader import clean_data, load_raw_data, load_store_data
15
+ from src.training.features import apply_feature_pipeline, build_feature_matrix
16
+
17
+
18
+ def load_recent_inference_rows(log_path: Path) -> pd.DataFrame:
19
+ if not log_path.exists():
20
+ return pd.DataFrame()
21
+
22
+ records = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() if line.strip()]
23
+ if not records:
24
+ return pd.DataFrame()
25
+
26
+ request_df = pd.DataFrame(records)
27
+ store_df = load_store_data(settings.data.store_path)
28
+ request_df = request_df.rename(
29
+ columns={
30
+ "store": "Store",
31
+ "start_date": "Date",
32
+ "promo": "Promo",
33
+ "state_holiday": "StateHoliday",
34
+ "school_holiday": "SchoolHoliday",
35
+ }
36
+ )
37
+ merged = request_df.merge(store_df, on="Store", how="left")
38
+ merged["Open"] = 1
39
+ for col in ["Promo2", "Promo2SinceWeek", "Promo2SinceYear"]:
40
+ merged[col] = merged[col].fillna(0).astype(int)
41
+ return merged
42
+
43
+
44
+ def build_drift_report() -> dict[str, object]:
45
+ train_df = load_raw_data(settings.data.train_path, settings.data.store_path)
46
+ train_df = clean_data(train_df)
47
+ train_df = apply_feature_pipeline(
48
+ train_df,
49
+ fourier_period=settings.pipeline.fourier_period,
50
+ fourier_order=settings.pipeline.fourier_order,
51
+ )
52
+ train_features = build_feature_matrix(train_df, settings.data.features)
53
+
54
+ inference_df = load_recent_inference_rows(DEFAULT_INFERENCE_LOG_PATH)
55
+ if inference_df.empty:
56
+ return {
57
+ "status": "no_inference_logs",
58
+ "log_path": str(DEFAULT_INFERENCE_LOG_PATH),
59
+ }
60
+
61
+ inference_df = apply_feature_pipeline(
62
+ inference_df,
63
+ fourier_period=settings.pipeline.fourier_period,
64
+ fourier_order=settings.pipeline.fourier_order,
65
+ )
66
+ inference_features = build_feature_matrix(inference_df, settings.data.features)
67
+
68
+ drift_rows = []
69
+ for col in inference_features.columns:
70
+ train_mean = float(train_features[col].mean())
71
+ inference_mean = float(inference_features[col].mean())
72
+ train_std = float(train_features[col].std(ddof=0))
73
+ drift_score = abs(inference_mean - train_mean) / max(train_std, 1e-6)
74
+ drift_rows.append(
75
+ {
76
+ "feature": col,
77
+ "train_mean": round(train_mean, 4),
78
+ "inference_mean": round(inference_mean, 4),
79
+ "train_std": round(train_std, 4),
80
+ "normalized_mean_shift": round(float(drift_score), 4),
81
+ }
82
+ )
83
+
84
+ drift_rows.sort(key=lambda row: row["normalized_mean_shift"], reverse=True)
85
+ return {
86
+ "status": "ok",
87
+ "log_path": str(DEFAULT_INFERENCE_LOG_PATH),
88
+ "num_inference_events": int(len(inference_df)),
89
+ "top_drift_features": drift_rows[:10],
90
+ }
91
+
92
+
93
+ def main() -> Path:
94
+ report = build_drift_report()
95
+ output_path = Path("reports/metrics/drift_report.json")
96
+ output_path.parent.mkdir(parents=True, exist_ok=True)
97
+ with output_path.open("w", encoding="utf-8") as f:
98
+ json.dump(report, f, indent=2)
99
+ print(f"Drift report written to {output_path}")
100
+ return output_path
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
src/serving/api.py CHANGED
@@ -8,9 +8,15 @@ import logging
8
  import os
9
  from pathlib import Path
10
  import json
 
11
 
12
  from src.shared.config import DEFAULT_MODEL_METADATA_PATH, DEFAULT_MODEL_PATH, settings
13
  from src.shared.schemas import PredictionRequest, PredictionResponse, ExplanationItem
 
 
 
 
 
14
  from src.training.data_loader import load_store_data
15
  from src.training.features import (
16
  apply_feature_pipeline,
@@ -82,6 +88,7 @@ def predict(request: PredictionRequest):
82
  raise HTTPException(status_code=404, detail=f"Store {request.Store} not found in metadata")
83
 
84
  try:
 
85
  store_meta = store_lookup[request.Store]
86
  start_date = pd.to_datetime(request.Date)
87
  dates = [start_date + pd.Timedelta(days=i) for i in range(request.ForecastDays)]
@@ -137,6 +144,28 @@ def predict(request: PredictionRequest):
137
  "sales": sales_val
138
  })
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  return PredictionResponse(
141
  Store=request.Store,
142
  Date=request.Date,
 
8
  import os
9
  from pathlib import Path
10
  import json
11
+ from time import perf_counter
12
 
13
  from src.shared.config import DEFAULT_MODEL_METADATA_PATH, DEFAULT_MODEL_PATH, settings
14
  from src.shared.schemas import PredictionRequest, PredictionResponse, ExplanationItem
15
+ from src.serving.monitoring import (
16
+ DEFAULT_INFERENCE_LOG_PATH,
17
+ append_jsonl_record,
18
+ build_inference_log_entry,
19
+ )
20
  from src.training.data_loader import load_store_data
21
  from src.training.features import (
22
  apply_feature_pipeline,
 
88
  raise HTTPException(status_code=404, detail=f"Store {request.Store} not found in metadata")
89
 
90
  try:
91
+ started_at = perf_counter()
92
  store_meta = store_lookup[request.Store]
93
  start_date = pd.to_datetime(request.Date)
94
  dates = [start_date + pd.Timedelta(days=i) for i in range(request.ForecastDays)]
 
144
  "sales": sales_val
145
  })
146
 
147
+ latency_ms = (perf_counter() - started_at) * 1000
148
+ append_jsonl_record(
149
+ DEFAULT_INFERENCE_LOG_PATH,
150
+ build_inference_log_entry(
151
+ store=request.Store,
152
+ start_date=request.Date,
153
+ forecast_days=request.ForecastDays,
154
+ promo=request.Promo,
155
+ state_holiday=request.StateHoliday,
156
+ school_holiday=request.SchoolHoliday,
157
+ model_version=model_version,
158
+ latency_ms=latency_ms,
159
+ ),
160
+ )
161
+ logger.info(
162
+ "prediction_complete store=%s horizon=%s model_version=%s latency_ms=%.3f",
163
+ request.Store,
164
+ request.ForecastDays,
165
+ model_version,
166
+ latency_ms,
167
+ )
168
+
169
  return PredictionResponse(
170
  Store=request.Store,
171
  Date=request.Date,
src/serving/monitoring.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from datetime import datetime, timezone
3
+ from pathlib import Path
4
+
5
+ from src.shared.config import PROJECT_ROOT
6
+
7
+ DEFAULT_INFERENCE_LOG_PATH = PROJECT_ROOT / "logs" / "inference_requests.jsonl"
8
+
9
+
10
+ def build_inference_log_entry(
11
+ *,
12
+ store: int,
13
+ start_date: str,
14
+ forecast_days: int,
15
+ promo: int,
16
+ state_holiday: str,
17
+ school_holiday: int,
18
+ model_version: str,
19
+ latency_ms: float,
20
+ ) -> dict[str, object]:
21
+ """Builds a single structured inference log event."""
22
+ return {
23
+ "timestamp_utc": datetime.now(timezone.utc).isoformat(),
24
+ "store": store,
25
+ "start_date": start_date,
26
+ "forecast_days": forecast_days,
27
+ "promo": promo,
28
+ "state_holiday": state_holiday,
29
+ "school_holiday": school_holiday,
30
+ "model_version": model_version,
31
+ "latency_ms": round(latency_ms, 3),
32
+ }
33
+
34
+
35
+ def append_jsonl_record(path: Path, payload: dict[str, object]) -> None:
36
+ """Appends a JSON line to the configured log file."""
37
+ path.parent.mkdir(parents=True, exist_ok=True)
38
+ with path.open("a", encoding="utf-8") as f:
39
+ f.write(json.dumps(payload) + "\n")
tests/test_monitoring.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from src.serving.monitoring import append_jsonl_record, build_inference_log_entry
2
+
3
+
4
+ def test_build_inference_log_entry_contains_expected_fields():
5
+ entry = build_inference_log_entry(
6
+ store=1,
7
+ start_date="2015-07-31",
8
+ forecast_days=7,
9
+ promo=1,
10
+ state_holiday="0",
11
+ school_holiday=1,
12
+ model_version="v1",
13
+ latency_ms=12.3456,
14
+ )
15
+
16
+ assert entry["store"] == 1
17
+ assert entry["forecast_days"] == 7
18
+ assert entry["model_version"] == "v1"
19
+ assert entry["latency_ms"] == 12.346
20
+
21
+
22
+ def test_append_jsonl_record_writes_one_line(tmp_path):
23
+ destination = tmp_path / "logs" / "inference.jsonl"
24
+ append_jsonl_record(destination, {"hello": "world"})
25
+
26
+ assert destination.exists()
27
+ assert destination.read_text(encoding="utf-8").strip() == '{"hello": "world"}'