Skip to content

Commit 6b84fcb

Browse files
feat(experiments): added actuals evaluator to properly compare actuals costs against forecasted ones
1 parent a938ff4 commit 6b84fcb

3 files changed

Lines changed: 259 additions & 7 deletions

File tree

experiments/actuals_evaluator.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Evaluate a completed experiment's plan against actual demand.
2+
3+
The optimizer only sees forecasted demand, so its plan may over- or under-ship
4+
relative to what actually happens. This module simulates the planned flows
5+
against ground-truth demand to produce realized (as-executed) metrics,
6+
both in aggregate and broken down per destination.
7+
8+
Usage:
9+
from actuals_evaluator import evaluate, save_realized_metrics
10+
metrics = evaluate(output_path)
11+
save_realized_metrics(metrics, output_path)
12+
"""
13+
14+
import json
15+
from dataclasses import asdict, dataclass
16+
from pathlib import Path
17+
18+
import polars as pl
19+
20+
from data.ingestion import Reader
21+
from experiment_config import load_experiment_config
22+
from utils.system_paths import get_project_root
23+
24+
25+
@dataclass
26+
class DestinationActuals:
27+
transport_cost: float
28+
realized_holding_cost: float
29+
realized_total_cost: float
30+
total_actual_demand: float
31+
total_fulfilled: float
32+
total_shortage: float
33+
fill_rate: float
34+
cost_per_unit_demanded: float
35+
cost_per_unit_fulfilled: float
36+
37+
38+
@dataclass
39+
class RealizedMetrics:
40+
transport_cost: float
41+
realized_holding_cost: float
42+
realized_total_cost: float
43+
total_actual_demand: float
44+
total_fulfilled: float
45+
total_shortage: float
46+
fill_rate: float
47+
cost_per_unit_demanded: float
48+
cost_per_unit_fulfilled: float
49+
per_destination: dict[str, DestinationActuals]
50+
51+
52+
def evaluate(experiment_output_path: Path) -> RealizedMetrics:
53+
"""Simulate the experiment's planned flows against actual demand.
54+
55+
Reads flows.parquet and metrics.json from experiment_output_path, plus the
56+
original dataset referenced in config.yaml, and returns realized metrics.
57+
"""
58+
project_root = get_project_root()
59+
config = load_experiment_config(
60+
project_root, experiment_output_path / "config.yaml"
61+
)
62+
63+
flows = pl.read_parquet(experiment_output_path / "flows.parquet")
64+
65+
raw = Reader(config.dataset_path).read()
66+
67+
holding_cost_map: dict[str, float] = {}
68+
if "holding_cost" in raw.destinations.columns:
69+
holding_cost_map = dict(
70+
zip(
71+
raw.destinations["destination_id"].to_list(),
72+
raw.destinations["holding_cost"].to_list(),
73+
)
74+
)
75+
76+
# Per-destination transport cost from planned flows × lane unit costs
77+
lane_cost_map: dict[tuple, float] = {
78+
(row["origin_id"], row["destination_id"]): row["unit_cost"]
79+
for row in raw.lanes.to_dicts()
80+
}
81+
dest_transport_cost: dict[str, float] = {}
82+
for row in flows.to_dicts():
83+
d_id = row["destination_id"]
84+
cost = lane_cost_map.get((row["origin_id"], d_id), 0.0) * row["flow"]
85+
dest_transport_cost[d_id] = dest_transport_cost.get(d_id, 0.0) + cost
86+
87+
forecast_dates = sorted(flows["period"].unique().to_list())
88+
89+
actual_demand = raw.demand_history.filter(pl.col("date").is_in(forecast_dates))
90+
91+
# Aggregate planned inflows per (destination, period)
92+
inflows = (
93+
flows.group_by(["destination_id", "period"])
94+
.agg(pl.col("flow").sum().alias("inflow"))
95+
.rename({"period": "date"})
96+
)
97+
98+
destinations = sorted(raw.destinations["destination_id"].to_list())
99+
100+
total_actual_demand = 0.0
101+
total_fulfilled = 0.0
102+
total_shortage = 0.0
103+
realized_holding_cost = 0.0
104+
per_destination: dict[str, DestinationActuals] = {}
105+
106+
for d_id in destinations:
107+
h_cost = holding_cost_map.get(d_id, 0.0)
108+
109+
inflow_map: dict = {
110+
row["date"]: row["inflow"]
111+
for row in inflows.filter(pl.col("destination_id") == d_id).to_dicts()
112+
}
113+
demand_map: dict = {
114+
row["date"]: row["demand"]
115+
for row in actual_demand.filter(pl.col("destination_id") == d_id).to_dicts()
116+
}
117+
118+
d_fulfilled = 0.0
119+
d_shortage = 0.0
120+
d_holding_cost = 0.0
121+
d_demand = 0.0
122+
inventory = 0.0
123+
124+
for t in forecast_dates:
125+
inflow = inflow_map.get(t, 0.0)
126+
actual_d = demand_map.get(t, 0.0)
127+
128+
available = inventory + inflow
129+
fulfilled = min(actual_d, available)
130+
shortage = max(0.0, actual_d - available)
131+
inventory = available - fulfilled
132+
133+
d_demand += actual_d
134+
d_fulfilled += fulfilled
135+
d_shortage += shortage
136+
d_holding_cost += h_cost * inventory
137+
138+
d_transport = dest_transport_cost.get(d_id, 0.0)
139+
d_realized_total = d_transport + d_holding_cost
140+
d_fill_rate = d_fulfilled / d_demand if d_demand > 0 else 1.0
141+
d_cpu_demanded = d_realized_total / d_demand if d_demand > 0 else 0.0
142+
d_cpu_fulfilled = d_realized_total / d_fulfilled if d_fulfilled > 0 else 0.0
143+
144+
per_destination[d_id] = DestinationActuals(
145+
transport_cost=d_transport,
146+
realized_holding_cost=d_holding_cost,
147+
realized_total_cost=d_realized_total,
148+
total_actual_demand=d_demand,
149+
total_fulfilled=d_fulfilled,
150+
total_shortage=d_shortage,
151+
fill_rate=d_fill_rate,
152+
cost_per_unit_demanded=d_cpu_demanded,
153+
cost_per_unit_fulfilled=d_cpu_fulfilled,
154+
)
155+
156+
total_actual_demand += d_demand
157+
total_fulfilled += d_fulfilled
158+
total_shortage += d_shortage
159+
realized_holding_cost += d_holding_cost
160+
161+
transport_cost = sum(dest_transport_cost.values())
162+
realized_total_cost = transport_cost + realized_holding_cost
163+
fill_rate = total_fulfilled / total_actual_demand if total_actual_demand > 0 else 1.0
164+
cpu_demanded = realized_total_cost / total_actual_demand if total_actual_demand > 0 else 0.0
165+
cpu_fulfilled = realized_total_cost / total_fulfilled if total_fulfilled > 0 else 0.0
166+
167+
return RealizedMetrics(
168+
transport_cost=transport_cost,
169+
realized_holding_cost=realized_holding_cost,
170+
realized_total_cost=realized_total_cost,
171+
total_actual_demand=total_actual_demand,
172+
total_fulfilled=total_fulfilled,
173+
total_shortage=total_shortage,
174+
fill_rate=fill_rate,
175+
cost_per_unit_demanded=cpu_demanded,
176+
cost_per_unit_fulfilled=cpu_fulfilled,
177+
per_destination=per_destination,
178+
)
179+
180+
181+
def save_realized_metrics(metrics: RealizedMetrics, output_path: Path) -> None:
182+
with open(output_path / "realized_metrics.json", "w") as f:
183+
json.dump(asdict(metrics), f, indent=2)

experiments/run_all.py

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
_HERE / "configs" / "baseline_naive.yaml",
2323
_HERE / "configs" / "baseline_global_ets.yaml",
2424
_HERE / "configs" / "model_selection.yaml",
25-
_HERE / "configs" / "scale_test.yaml",
2625
]
2726

2827

@@ -53,37 +52,97 @@ def _print_summary() -> None:
5352
for config_path in CONFIGS:
5453
experiment_name = config_path.stem
5554
metrics_path = results_root / experiment_name / "metrics.json"
55+
realized_path = results_root / experiment_name / "realized_metrics.json"
56+
5657
if not metrics_path.exists():
57-
rows.append((experiment_name, "MISSING", "-", "-", "-", "-"))
58+
rows.append((experiment_name, "MISSING", "-", "-", "-", "-", "-"))
5859
continue
5960

6061
with open(metrics_path) as f:
6162
m = json.load(f)
6263

6364
agg = m.get("aggregated_forecast", {})
6465
costs = m.get("costs", {})
66+
planned_cost = costs.get("total_cost", float("nan"))
67+
68+
if realized_path.exists():
69+
with open(realized_path) as f:
70+
r = json.load(f)
71+
realized_cost = r.get("realized_total_cost", float("nan"))
72+
fill_rate = r.get("fill_rate", float("nan"))
73+
else:
74+
realized_cost = float("nan")
75+
fill_rate = float("nan")
76+
6577
rows.append(
6678
(
6779
experiment_name,
6880
"OK",
6981
f"{agg.get('mean_wape', float('nan')):.4f}",
70-
f"{costs.get('total_cost', float('nan')):.2f}",
71-
f"{costs.get('transportation_cost', float('nan')):.2f}",
72-
f"{costs.get('holding_cost', float('nan')):.2f}",
82+
f"{planned_cost:.2f}",
83+
f"{realized_cost:.2f}",
84+
f"{fill_rate:.4f}",
7385
)
7486
)
7587

76-
header = f"{'Experiment':<30} {'Status':<8} {'WAPE':>8} {'Total':>12} {'Transport':>12} {'Holding':>10}"
88+
header = (
89+
f"{'Experiment':<30} {'Status':<8} {'WAPE':>8}"
90+
f" {'Planned Cost':>14} {'Realized Cost':>14} {'Fill Rate':>10}"
91+
)
7792
separator = "-" * len(header)
7893
print("\n" + separator)
7994
print(header)
8095
print(separator)
8196
for row in rows:
8297
print(
83-
f"{row[0]:<30} {row[1]:<8} {row[2]:>8} {row[3]:>12} {row[4]:>12} {row[5]:>10}"
98+
f"{row[0]:<30} {row[1]:<8} {row[2]:>8}"
99+
f" {row[3]:>14} {row[4]:>14} {row[5]:>10}"
84100
)
85101
print(separator + "\n")
86102

103+
_print_destination_breakdown(results_root)
104+
105+
106+
def _print_destination_breakdown(results_root: Path) -> None:
107+
dest_header = (
108+
f" {'Destination':<14} {'Transport':>12} {'Holding':>10}"
109+
f" {'Realized':>12} {'Demand':>10} {'Shortage':>10}"
110+
f" {'Fill Rate':>10} {'$/unit dem':>11} {'$/unit ful':>11}"
111+
)
112+
dest_separator = " " + "-" * (len(dest_header) - 2)
113+
114+
for config_path in CONFIGS:
115+
experiment_name = config_path.stem
116+
realized_path = results_root / experiment_name / "realized_metrics.json"
117+
if not realized_path.exists():
118+
continue
119+
120+
with open(realized_path) as f:
121+
r = json.load(f)
122+
123+
per_dest = r.get("per_destination", {})
124+
if not per_dest:
125+
continue
126+
127+
print(f" {experiment_name}")
128+
print(dest_separator)
129+
print(dest_header)
130+
print(dest_separator)
131+
for d_id in sorted(per_dest):
132+
d = per_dest[d_id]
133+
print(
134+
f" {d_id:<14}"
135+
f" {d['transport_cost']:>12.2f}"
136+
f" {d['realized_holding_cost']:>10.2f}"
137+
f" {d['realized_total_cost']:>12.2f}"
138+
f" {d['total_actual_demand']:>10.2f}"
139+
f" {d['total_shortage']:>10.2f}"
140+
f" {d['fill_rate']:>10.4f}"
141+
f" {d['cost_per_unit_demanded']:>11.2f}"
142+
f" {d['cost_per_unit_fulfilled']:>11.2f}"
143+
)
144+
print(dest_separator + "\n")
145+
87146

88147
if __name__ == "__main__":
89148
main()

experiments/run_experiment.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from forecasting import create_forecasting_pipeline, AggregatedForecastingResult
2525
from optimization import MultiPeriodOptimizer, MultiPeriodResult
2626
from experiment_config import ExperimentConfig, load_experiment_config
27+
from actuals_evaluator import evaluate as evaluate_realized, save_realized_metrics
2728
from utils.system_paths import get_project_root
2829

2930
logging.basicConfig(
@@ -93,6 +94,15 @@ def run_experiment(config_path: Path) -> None:
9394

9495
logger.info("Artifacts saved to: %s", config.output_path)
9596

97+
# --- Realized evaluation ---
98+
realized = evaluate_realized(config.output_path)
99+
save_realized_metrics(realized, config.output_path)
100+
logger.info(
101+
"Realized evaluation: fill_rate=%.4f, realized_cost=%.2f",
102+
realized.fill_rate,
103+
realized.realized_total_cost,
104+
)
105+
96106

97107
def _save_metrics(
98108
config: ExperimentConfig,

0 commit comments

Comments
 (0)