Data Pipeline Code Walkthrough¶
This tutorial walks through each Python module in the Mirabelle data pipeline. Read it alongside the source files in files/.
Pipeline Overview¶
The pipeline processes data in sequential stages, each reading from and writing to the scenario PostgreSQL schema:
Source DB (Neon remote)
↓ ETL (files/etl/etl.py)
scenario.demand_actuals
↓ Characterization (files/characterization/characterization.py)
scenario.series_characteristics
↓ Forecast step (run_pipeline.py --only forecast)
① Outlier Detection (pre-step, embedded)
→ scenario.demand_corrected [corrections per pipeline_id]
② Forecasting (files/forecasting/statistical_models.py + neural/foundation/ml)
scenario.forecast_results
↓ Backtesting (files/evaluation/metrics.py)
scenario.backtest_results
↓ Best Method Selection (files/selection/best_method.py)
scenario.series_best_methods
↓ Distribution Fitting (files/distribution/fitting.py)
scenario.fitted_distributions
↓ MEIO Optimization (files/meio_runner.py + Rust .pyd)
scenario.meio_results
Parallelization for the forecasting stage is handled by the orchestrator (files/utils/orchestrator.py) which distributes series across Dask workers.
ETL — files/etl/etl.py¶
Purpose¶
Extracts demand actuals from a remote source database (Neon PostgreSQL), transforms them to a standardised weekly format, and loads them into the local scenario.demand_actuals table.
Architecture¶
class ETLPipeline:
"""
Two-database ETL.
Source DB: dp_plan.calc_dp_actual (Neon remote)
Target DB: scenario.demand_actuals (local)
"""
If no source DB is configured, the pipeline falls back to self-reload mode (re-aggregates from the local DB itself).
Key Method: run()¶
The main entry point orchestrates the three phases:
def run(self):
# 1. Extract: read from source DB with configurable SQL
raw_df = self._extract()
# 2. Transform: aggregate to target frequency, create unique_id
transformed_df = self._transform(raw_df)
# 3. Load: upsert into scenario.demand_actuals
n_rows = self._load(transformed_df)
return n_rows
Unique ID Construction¶
The unique_id field (e.g. "12_301") is constructed from item_id and site_id:
This convention is still constructed at code edges for legacy paths, but no table
stores a unique_id column — every ClickHouse PIPE_* table (and the migrated
PG tables) is keyed by item_id + site_id (unique_id was dropped in the v6
migration).
Frequency Aggregation¶
FREQ_MAP = {
"D": "D", # daily
"W": "W", # weekly (default)
"M": "MS", # month start
"Q": "QS", # quarter start
"Y": "YS", # year start
}
AGG_MAP = {"sum": "sum", "mean": "mean", "median": "median"}
For weekly data, the ETL groups by (unique_id, week_start_date) and sums quantities. The aggregation.function config key selects the aggregation method.
Series Coverage Safety Gate¶
Before loading, the ETL compares the incoming series list against the existing series in the DB:
class ETLSeriesLossError(RuntimeError):
"""
Raised when the incoming dataset would permanently remove series
that have user-generated data (locked methods, adjustments, overrides).
"""
def __init__(self, message, report):
super().__init__(message)
self.report = report # {existing, new, dropped, loss_pct, at_risk}
If more than a configurable threshold (default 10%) of user-annotated series would be dropped, the ETL raises ETLSeriesLossError and aborts. This prevents accidental data loss from upstream schema changes. Pass --force to bypass this gate.
Outlier Detection — embedded in the Forecast step¶
Purpose¶
Detects statistical outliers in each demand series and writes corrections to scenario.demand_corrected. Outlier detection is not a standalone pipeline step — it runs automatically as the first action inside run_pipeline.py --only forecast, before the statistical/neural models are called. The corrected values are then used as the input to forecasting for that pipeline.
The implementation lives in files/outlier/detection.py but is invoked by run_pipeline.py:step_outlier_detection().
Three Detection Methods¶
The OutlierDetector class supports three methods, applied in combination:
1. IQR (Interquartile Range)¶
def _detect_iqr(self, values, multiplier=1.5):
"""
Classic Tukey fence method.
Fences: Q1 - k*IQR, Q3 + k*IQR
Points outside the fences are flagged.
k = iqr_multiplier (default 1.5, configurable per parameter set)
Lower k → stricter, catches more outliers.
"""
Q1 = np.percentile(values, 25)
Q3 = np.percentile(values, 75)
IQR = Q3 - Q1
lower = Q1 - multiplier * IQR
upper = Q3 + multiplier * IQR
return (values < lower) | (values > upper)
2. Z-Score¶
def _detect_zscore(self, values, threshold=3.0):
"""
Standardise values and flag those beyond the threshold.
z = (x - mean) / std
Outlier if |z| > threshold.
Robust to the choice of location/scale when the series has no trend
but can miss outliers in trended or seasonal series.
"""
mean = np.nanmean(values)
std = np.nanstd(values)
if std == 0:
return np.zeros(len(values), dtype=bool)
z = np.abs((values - mean) / std)
return z > threshold
3. STL (Seasonal-Trend Decomposition)¶
def _detect_stl(self, series, period=52, threshold=3.0):
"""
Decompose the series with STL, then z-score the residuals.
STL separates trend + seasonal components, so the residuals represent
only the irregular component. Outliers in residuals = genuine spikes
not explained by trend or seasonality.
Requires statsmodels. Falls back to IQR if unavailable.
"""
from statsmodels.tsa.seasonal import STL
stl = STL(series, period=period)
result = stl.fit()
residuals = result.resid
return self._detect_zscore(residuals, threshold)
STL is the most accurate method for seasonal series but requires at least min_obs_for_stl observations (default 24). The final outlier mask is the union of all three methods.
Output¶
Corrections are written to scenario.demand_corrected (one row per corrected date per series per pipeline):
| Column | Description |
|---|---|
item_id, site_id |
Series scope |
date |
The corrected week |
pipeline_id |
Isolation key — re-running the same pipeline overwrites only its own rows |
original_value |
Raw demand value before correction |
corrected_value |
Replacement value after applying the correction strategy |
detection_method |
iqr, zscore, or stl |
z_score |
Standardised distance from the series mean |
lower_bound, upper_bound |
Detection fence values used for this data point |
The forecasting step subsequently reads demand_corrected for the active pipeline and merges corrections back into the demand series before running models.
Characterization — files/characterization/characterization.py¶
Purpose¶
Analyses each time series and classifies it along several dimensions:
- Seasonality (presence, periods, strength)
- Trend (direction, strength)
- Intermittency (ADI, CV², zero ratio)
- Stationarity (ADF test)
- Complexity (composite score)
- Data sufficiency (for ML / deep learning)
- Recommended forecasting methods
The SeriesCharacteristics Dataclass¶
@dataclass
class SeriesCharacteristics:
unique_id: str
# Basic stats
n_observations: int = 0
mean: float = np.nan
std: float = np.nan
# Seasonality
has_seasonality: bool = False
seasonal_periods: List[int] = field(default_factory=list)
seasonal_strength: float = 0.0
# Trend
has_trend: bool = False
trend_direction: str = 'none' # 'up', 'down', 'none'
trend_strength: float = 0.0
# Intermittency
is_intermittent: bool = False
zero_ratio: float = 0.0
adi: float = 0.0 # Average Demand Interval
cov: float = 0.0 # Coefficient of Variation
# Stationarity
is_stationary: bool = False
adf_pvalue: float = np.nan
# Complexity
complexity_score: float = 0.0
complexity_level: str = 'low' # 'low', 'medium', 'high'
# Sufficiency
sufficient_for_ml: bool = False
sufficient_for_deep_learning: bool = False
# Output
recommended_methods: List[str] = field(default_factory=list)
analyze_all() — The Entry Point¶
def analyze_all(self, df, id_col='unique_id', date_col='ds', value_col='y', save=True):
"""
Iterate over every unique_id and call analyze_single() for each.
Results are accumulated into a DataFrame and saved to series_characteristics.
"""
unique_ids = df[id_col].unique()
results = []
for uid in unique_ids:
series_df = df[df[id_col] == uid].sort_values(date_col)
try:
chars = self.analyze_single(series_df, uid, date_col, value_col)
except Exception:
chars = SeriesCharacteristics(unique_id=uid) # default fallback
results.append(chars.to_dict())
characteristics_df = pd.DataFrame(results)
if save:
bulk_insert(schema + '.series_characteristics', c_cols, c_rows)
return characteristics_df
analyze_single() — Per-Series Analysis Pipeline¶
def analyze_single(self, series_df, unique_id, date_col, value_col):
chars = SeriesCharacteristics(unique_id=unique_id)
values = series_df[value_col].values.astype(float)
dates = pd.to_datetime(series_df[date_col])
# Basic statistics (always computed)
chars.n_observations = len(values)
chars.date_range_start = str(dates.min())
chars.date_range_end = str(dates.max())
chars.mean = float(np.nanmean(values))
chars.std = float(np.nanstd(values))
# Guard: skip deep analysis for very short series
if chars.n_observations < 4:
chars.recommended_methods = self._recommend_methods(chars)
return chars
self._detect_seasonality(values, chars, dates)
self._detect_trend(values, chars)
self._detect_intermittency(values, chars)
self._detect_stationarity(values, chars)
self._compute_complexity(values, chars)
self._assess_sufficiency(chars)
chars.recommended_methods = self._recommend_methods(chars)
return chars
_detect_seasonality() — ACF-Based Seasonal Detection¶
The method uses the Autocorrelation Function (ACF) to detect seasonality. The core idea: if demand 52 weeks ago is strongly correlated with demand today, the series is seasonal with period 52.
def _detect_seasonality(self, values, chars, dates):
# Gate: require at least 2 years of history to detect annual cycles
date_span_days = (dates.max() - dates.min()).days
if date_span_days < 2 * 365:
chars.has_seasonality = False
return
# Reindex to uniform grid so ACF lags correspond to actual time periods.
# Irregular gaps would shift lag indices and produce false negatives.
# (Reindexing introduces NaN for missing dates; statsmodels handles
# these with missing='conservative')
acf_source = self._uniform_reindex(values, dates)
test_periods = self.seasonality_cfg['test_periods'] # e.g. [4, 13, 26, 52]
min_strength = self.seasonality_cfg['min_strength'] # e.g. 0.3
max_lag = max(test_periods) + 1
acf_values = acf(acf_source, nlags=max_lag, missing='conservative')
detected = []
for period in test_periods:
if period < len(acf_values) and acf_values[period] >= min_strength:
detected.append(period)
chars.has_seasonality = len(detected) > 0
chars.seasonal_periods = detected
chars.seasonal_strength = max((acf_values[p] for p in detected), default=0.0)
_detect_trend() — Mann-Kendall Test¶
def _mann_kendall_test(values, significance_level=0.05):
"""
Non-parametric test for monotonic trend.
S = sum of sign(x_j - x_i) for all j > i
Var(S) is derived under the null hypothesis (no trend).
z = S / sqrt(Var(S)) follows approximately N(0,1).
Returns: (has_trend, direction, strength)
"""
n = len(values)
S = sum(
np.sign(values[j] - values[i])
for i in range(n - 1)
for j in range(i + 1, n)
)
# Variance under null (accounting for ties)
var_S = n * (n - 1) * (2 * n + 5) / 18
if var_S == 0:
return False, 'none', 0.0
z = S / np.sqrt(var_S)
p_value = 2 * (1 - scipy_stats.norm.cdf(abs(z)))
has_trend = p_value < significance_level
direction = 'up' if S > 0 else ('down' if S < 0 else 'none')
strength = abs(z) # larger z = stronger trend
return has_trend, direction, strength
_detect_intermittency() — ADI and CV²¶
def _detect_intermittency(self, values, chars):
"""
Intermittency classification based on ADI and CV².
ADI (Average Demand Interval): mean gap between non-zero periods.
ADI > threshold → demand is too infrequent for standard methods.
CV² (squared Coefficient of Variation): variance of non-zero quantities.
High CV² with high ADI → lumpy demand (Syntetos-Boylan quadrant).
"""
non_zero = values[values > 0]
zeros = values[values == 0]
chars.zero_ratio = len(zeros) / len(values) if len(values) > 0 else 0.0
if len(non_zero) == 0:
chars.is_intermittent = True
chars.adi = float('inf')
return
# ADI: number of periods per non-zero demand event
chars.adi = len(values) / len(non_zero)
# CV²: (std / mean)² of non-zero quantities
mean_nz = np.mean(non_zero)
std_nz = np.std(non_zero)
chars.cov = (std_nz / mean_nz) ** 2 if mean_nz > 0 else 0.0
# Classification threshold from config
adi_threshold = self.intermittency_cfg['adi_threshold'] # default 1.3
chars.is_intermittent = chars.adi > adi_threshold
_recommend_methods() — Method Routing¶
def _recommend_methods(self, chars):
"""
Route a series to appropriate forecasting methods based on its characteristics.
Priority rules:
1. Intermittent → always CrostonOptimized (optionally IMAPA)
2. Multi-seasonal → MSTL
3. Strong trend → AutoARIMA, AutoTheta
4. Seasonal → AutoETS, MSTL
5. All series → AutoARIMA, AutoETS as baseline
6. Sufficient history → optionally add ML / neural models
"""
methods = []
if chars.is_intermittent:
methods.append('CrostonOptimized')
# Do not include MSTL for intermittent demand — it handles zeros poorly
else:
# Always include the two universal baselines
methods.extend(['AutoARIMA', 'AutoETS'])
if chars.has_seasonality:
if len(chars.seasonal_periods) > 1:
methods.append('MSTL') # multiple seasonal periods
else:
methods.append('AutoETS') # single seasonal period
if chars.has_trend:
if 'AutoARIMA' not in methods:
methods.append('AutoARIMA')
methods.append('AutoTheta') # good for trend + seasonal
# Neural models: only if the series has enough history
if chars.sufficient_for_deep_learning:
for m in ['NHITS', 'NBEATS', 'TFT', 'PatchTST', 'DeepAR']:
if self._model_enabled(m):
methods.append(m)
elif chars.sufficient_for_ml:
for m in ['LightGBM', 'XGBoost']:
if self._model_enabled(m):
methods.append(m)
return list(dict.fromkeys(methods)) # deduplicate preserving order
Forecasting — files/forecasting/statistical_models.py¶
Purpose¶
Runs all enabled statistical forecasting models via the statsforecast library and produces point forecasts plus prediction intervals for each model and series.
StatisticalForecaster Class¶
class StatisticalForecaster:
def __init__(self, config_path=None, config_override=None):
# Load config (file → DB fallback → deep merge override)
self.config = self._load_config(config_path, config_override)
self.fc_config = self.config['forecasting']
# Build the list of statsforecast model objects from config
self.models = self._build_models()
_build_models() — Config-Driven Model Construction¶
def _build_models(self):
"""
Inspect config['forecasting']['models'] and instantiate only the
enabled models. Returns a list of statsforecast model objects.
"""
from statsforecast.models import (
AutoARIMA, AutoETS, AutoTheta, MSTL, CrostonOptimized
)
model_defs = self.fc_config.get('models', {})
models = []
for name, cfg in model_defs.items():
if not cfg.get('enabled', False):
continue
season_length = cfg.get('season_length', 52)
if name == 'AutoARIMA':
models.append(AutoARIMA(season_length=season_length))
elif name == 'AutoETS':
models.append(AutoETS(season_length=season_length))
elif name == 'MSTL':
# MSTL accepts a list of seasonal periods
models.append(MSTL(season_length=season_length if isinstance(season_length, list)
else [season_length]))
elif name == 'CrostonOptimized':
models.append(CrostonOptimized())
# ... other models
return models
forecast_multiple_series() — Batch Forecasting¶
def forecast_multiple_series(self, df, characteristics_df, overrides_map=None, show_progress=True):
"""
Run all enabled models on all series in df.
Uses StatsForecast's built-in parallelism (n_jobs=-1) for the
statistical models. The characteristics_df is used to filter
which models are appropriate for each series.
"""
from statsforecast import StatsForecast
# statsforecast expects columns: unique_id, ds, y
sf = StatsForecast(
models=self.models,
freq=self.fc_config.get('frequency', 'W'),
n_jobs=-1, # use all available cores
fallback_model=AutoETS(), # if a model fails, use AutoETS instead
)
horizon = self.fc_config.get('horizon', 24)
# Produce point forecasts + prediction intervals (80% and 90%)
forecasts_df = sf.forecast(
df=df,
h=horizon,
level=[80, 90], # prediction interval levels
)
return forecasts_df
Per-SKU Overrides¶
When overrides_map is provided (from series_hyperparameters_overrides), the forecaster rebuilds the model configuration for those specific series with the override parameters applied:
# overrides_map: {unique_id: {method: {param: value}}}
for uid, method_overrides in overrides_map.items():
for method_name, params in method_overrides.items():
# Rebuild the specific model with overridden params
# and re-forecast just that series
Backtesting / Evaluation — files/evaluation/metrics.py¶
Purpose¶
Implements rolling-window backtesting: for each series, the model is fit on a truncated history and evaluated on the held-out window. This produces unbiased estimates of forecast accuracy.
Rolling Window Logic¶
History: |-------|-------|-------|-------|-------|
Window 1: |fit |eval |
Window 2: |fit |eval |
Window 3: |fit |eval |
For each window (origin date): 1. Truncate the series at the origin. 2. Run the forecaster on the truncated series. 3. Compare the forecast against the actual held-out values. 4. Record metrics for this origin.
Final metrics per series × method = average across all origins.
ForecastEvaluator.evaluate()¶
def evaluate(self, df, forecasts_df, n_windows=3, window_size=12):
"""
Rolling-window backtesting.
Returns a DataFrame with columns:
unique_id, method, forecast_origin, mae, rmse, mase, bias, coverage_90
"""
origins = self._compute_origins(df, n_windows, window_size)
all_metrics = []
for origin_date in origins:
# Truncate actuals to this origin
train = df[df['ds'] <= origin_date]
test = df[(df['ds'] > origin_date) &
(df['ds'] <= origin_date + window_size_periods)]
# Forecast from this origin
fc = self.forecaster.forecast_from_origin(train, horizon=window_size)
# Compute metrics for each method
for method in fc.columns:
metrics = self._compute_metrics(
actual=test['y'].values,
predicted=fc[method].values
)
metrics.update({'unique_id': uid, 'method': method,
'forecast_origin': origin_date})
all_metrics.append(metrics)
return pd.DataFrame(all_metrics)
Metric Formulas¶
def _compute_metrics(self, actual, predicted):
n = len(actual)
errors = actual - predicted
mae = np.mean(np.abs(errors))
rmse = np.sqrt(np.mean(errors ** 2))
bias = np.mean(errors) / (np.mean(actual) + 1e-8) # relative bias
# MASE: normalise by naive forecast error (lag-1)
naive_errors = np.abs(np.diff(actual))
mase = mae / (np.mean(naive_errors) + 1e-8) if len(naive_errors) > 0 else np.nan
# Coverage: what fraction of actuals fall within the 90% prediction interval?
lower = predicted_lower_90
upper = predicted_upper_90
coverage_90 = np.mean((actual >= lower) & (actual <= upper))
return {'mae': mae, 'rmse': rmse, 'mase': mase, 'bias': bias, 'coverage_90': coverage_90}
Best Method Selection — files/selection/best_method.py¶
Purpose¶
After backtesting, ranks all forecasting methods for each series using a weighted composite score. The method with the lowest composite score becomes the "best method" for that series.
_rank_methods_for_series()¶
def _rank_methods_for_series(self, unique_id, group):
"""
Rank methods for one series. Steps:
1. Average metrics across all forecast origins (windows).
2. Min-max normalise each metric to [0, 1] within this series.
3. Transform metrics so that lower = better:
- bias → |bias| (both positive and negative are penalised)
- coverage_90 → |coverage_90 - 0.9| (deviation from target)
4. Compute weighted composite score.
5. Rank methods ascending (lower = better).
"""
# Step 1: average across origins
avg = group.groupby('method')[list(self.weights.keys())].mean()
# Step 3: transform bias and coverage
if 'bias' in avg.columns:
avg['bias'] = avg['bias'].abs()
if 'coverage_90' in avg.columns:
avg['coverage_90'] = (avg['coverage_90'] - 0.9).abs()
# Step 2: min-max normalise each metric within this series
for col in avg.columns:
col_min = avg[col].min()
col_max = avg[col].max()
if col_max > col_min:
avg[col] = (avg[col] - col_min) / (col_max - col_min)
else:
avg[col] = 0.0 # all methods tied on this metric
# Step 4: weighted composite
avg['composite_score'] = sum(
avg[metric] * weight
for metric, weight in self.weights.items()
if metric in avg.columns
)
# Step 5: rank
ranked = avg.sort_values('composite_score')
best = ranked.index[0]
runner = ranked.index[1] if len(ranked) > 1 else None
return {
'unique_id': unique_id,
'best_method': best,
'best_score': ranked.iloc[0]['composite_score'],
'runner_up_method': runner,
'runner_up_score': ranked.iloc[1]['composite_score'] if runner else None,
'all_rankings': ranked['composite_score'].to_dict(),
}
Default Weights¶
_DEFAULT_WEIGHTS = {
"mae": 0.40, # primary accuracy metric
"rmse": 0.20, # penalises large errors more
"bias": 0.15, # systematic over/under-forecasting
"coverage_90": 0.15, # prediction interval calibration
"mase": 0.10, # scale-independent accuracy
}
All weights sum to 1.0. These are overridable via the best_method parameter set in the DB.
Distribution Fitting — files/distribution/fitting.py¶
Purpose¶
Fits parametric probability distributions to each series' demand data. The fitted distribution is used by the MEIO optimizer to compute safety stock at the requested service level.
FittedDistribution Dataclass¶
@dataclass
class FittedDistribution:
unique_id: str
method: str # which forecast method's residuals were used
forecast_horizon: int
distribution_type: str # 'normal', 'gamma', 'negative_binomial',
# 'lognormal', 'weibull', 'poisson'
mean: float
std: float
params: Dict # distribution-specific MLE parameters
ks_statistic: float # Kolmogorov-Smirnov test statistic
ks_pvalue: float # KS p-value (higher = better fit)
service_level_quantiles: Dict[float, float] # {0.9: ..., 0.95: ..., 0.99: ...}
The Fitting Loop¶
def fit_distribution(self, values, unique_id, preferred=None):
"""
Try each candidate distribution in order of preference.
Return the first one that passes the KS goodness-of-fit test.
Candidates: lognormal → gamma → normal → weibull → negative_binomial
"""
preferred = preferred or self.config.get('preferred_distributions',
['lognormal', 'gamma', 'normal', 'weibull'])
for dist_name in preferred:
try:
params, ks_stat, ks_pvalue = self._fit_and_test(values, dist_name)
if ks_pvalue >= self.ks_pvalue_threshold: # good fit
return FittedDistribution(
unique_id=unique_id,
distribution_type=dist_name,
params=params,
ks_statistic=ks_stat,
ks_pvalue=ks_pvalue,
mean=np.mean(values),
std=np.std(values),
service_level_quantiles=self._compute_quantiles(dist_name, params),
)
except Exception:
continue # distribution failed to fit, try next
# Fallback: use normal distribution regardless of fit quality
return self._fit_normal_fallback(values, unique_id)
MLE Fitting and KS Test¶
def _fit_and_test(self, values, dist_name):
"""
Fit by Maximum Likelihood Estimation using scipy.stats,
then test goodness of fit with Kolmogorov-Smirnov.
"""
dist_map = {
'normal': scipy.stats.norm,
'gamma': scipy.stats.gamma,
'lognormal': scipy.stats.lognorm,
'weibull': scipy.stats.weibull_min,
}
dist = dist_map[dist_name]
# MLE: scipy fits all parameters simultaneously
params = dist.fit(values) # returns (shape..., loc, scale) tuple
# KS test: compare empirical CDF with fitted CDF
ks_stat, ks_pvalue = scipy.stats.kstest(values, dist.cdf, args=params)
return params, ks_stat, ks_pvalue
get_quantile() — Service Level Quantiles¶
def get_quantile(self, q):
"""Return the demand level at quantile q from the fitted distribution."""
if self.distribution_type == 'lognormal':
s, loc, scale = self.params['s'], self.params.get('loc', 0), self.params['scale']
return scipy.stats.lognorm.ppf(q, s=s, loc=loc, scale=scale)
elif self.distribution_type == 'gamma':
a, loc, scale = self.params['shape'], self.params.get('loc', 0), self.params['scale']
return scipy.stats.gamma.ppf(q, a=a, loc=loc, scale=scale)
# ... other distributions
The service_level_quantiles dict (e.g. {0.90: 62.1, 0.95: 71.4, 0.99: 89.3}) is passed directly to the MEIO Rust optimizer as the demand uncertainty input.
MEIO Runner — files/meio_runner.py¶
Purpose¶
Orchestrates the MEIO (Multi-Echelon Inventory Optimisation) run. Loads all SKU data from PostgreSQL once, applies per-scenario parameter overrides, then calls the Rust optimizer extension.
Architecture Decision: Python → Rust¶
The heavy optimisation work is done by a compiled Rust extension (meio_optimizer.pyd on Windows). This approach gives:
- Python: data loading, preprocessing, result persistence — benefits from Python's ecosystem.
- Rust: the optimisation algorithm — inner loop runs at native speed with Rayon parallelism.
The boundary is a simple JSON interface:
# Python serialises the batch to JSON
rust_batches = [
{
"skus_json": json.dumps(skus),
"config_json": json.dumps(config),
"group_targets_json": json.dumps(targets),
}
]
# Call Rust — returns JSON string of results
raw_results = json.loads(
meio_optimizer.run_optimization_batch(json.dumps(rust_batches))
)
run_scenarios() — Main Entry Point¶
def run_scenarios(scenario_ids=None, base_only=False, config_path=None, pipeline_id=None):
"""
1. Load scenarios from DB
2. Split into availability (Erlang-B) vs standard (Rust) scenarios
3. Load base SKU data once
4. For each scenario: apply overrides → build batch
5. Submit all batches to the Rust optimizer in one call
6. Parse results → save to meio_results + meio_group_results
"""
Key Load Functions¶
def _load_sku_records(schema, pipeline_id=None):
"""
Load all (item_id, site_id) combinations with:
- demand statistics (mean, std, CV from fitted distributions)
- unit cost (item_site.unit_cost → item.unit_cost fallback)
- lead time (route.lead_time + transit_time + pick_pack_time)
- EOQ: from pipeline-linked inventory_scenario K-Curve results (preferred)
or item.eoq (static fallback) — see EOQ sourcing note below
- j_target_groups (segment membership for fill-rate targeting)
Returns (skus: list[dict], unit_cost_map: dict)
"""
EOQ Sourcing — K-Curve Results Take Priority¶
When a pipeline_id is supplied, _load_sku_records joins against the K-Curve
results stored in inventory_scenario.results (JSONB) via the scenario_pipeline
link table:
-- Simplified CTE added when pipeline_id is known
eoq_scenario AS (
SELECT
(elem->>'unique_id') AS unique_id,
(elem->>'eoq')::double precision AS eoq_qty
FROM scenario.inventory_scenario inv
JOIN scenario.scenario_pipeline sp
ON sp.inventory_scenario_id = inv.scenario_id
AND sp.pipeline_id = %(pipeline_id)s
CROSS JOIN LATERAL jsonb_array_elements(inv.results->'items') AS elem
WHERE inv.results ? 'items'
AND (elem->>'eoq') IS NOT NULL
AND (elem->>'eoq')::double precision > 0
)
-- Final EOQ expression:
COALESCE(eoq_scenario.eoq_qty, NULLIF(item.eoq, 0.0), 0.0)
Priority order:
1. K-Curve portfolio-optimised EOQ from inventory_scenario.results
2. item.eoq — static field seeded from the simple \(\sqrt{2DS/H}\) formula
3. 0.0 — when no ordering cost is configured
This ensures supply planning uses the jointly-optimised order quantities rather than per-item approximations. See K-Curve — Per-item EOQ output for the storage format.
def _load_sku_records(schema, pipeline_id=None):
def _load_meio_config(schema):
"""
Load global MEIO parameters from scenario.parameters.
Also loads meio_group_results targets.
Returns (config: dict, group_targets: list[dict])
"""
def _load_scenario_segment_params(schema, scenario_id):
"""
Load per-segment parameter overrides from meio_scenario_segment.
These override fill_rate_target, max_budget, return_rate, etc.
for specific item groups within this scenario.
"""
Per-Scenario Override Pipeline¶
for scenario in rust_scenarios:
sid = scenario['scenario_id']
overrides = scenario.get('param_overrides') or {}
seg_params = _load_scenario_segment_params(schema, sid)
# Apply overrides to the base SKU data
patched_skus = _apply_sku_overrides(base_skus, overrides, seg_params=seg_params)
patched_config = _apply_config_overrides(base_config, overrides, seg_params=seg_params)
patched_targets = _apply_target_overrides(base_group_targets, overrides, seg_params=seg_params)
batches.append({
"skus_json": json.dumps(patched_skus),
"config_json": json.dumps(patched_config),
"group_targets_json": json.dumps(patched_targets),
})
Availability Scenarios (Erlang-B)¶
When a scenario is flagged as an availability scenario (asset-driven demand for MRO):
avail_scenarios = [s for s in scenarios if _is_availability_scenario(s)]
rust_scenarios = [s for s in scenarios if not _is_availability_scenario(s)]
for s in avail_scenarios:
# Erlang-B pool sizing (Python path, no Rust)
sku_results, group_results = _run_causal_asset_scenario(schema, s, pipeline_id)
Erlang-B calculates the minimum pool size (buffer stock) needed to achieve a given availability service level, accounting for the failure rate distribution and repair cycle time.
Dask Orchestrator — files/utils/orchestrator.py¶
Purpose¶
Parallelizes the forecasting pipeline across multiple CPU cores using Dask distributed. The orchestrator splits the series into chunks and submits each chunk as a Dask task.
The Top-Level run() Function¶
def run(config_path, pipeline_id=None, scenario_id=1, segment_id=None):
"""
Full pipeline run:
1. Load demand actuals from DB
2. Load characterization results
3. Group series by parameter set (ParameterResolver)
4. For each group: create Dask tasks, submit to cluster
5. Gather results, persist to DB
6. Run best method selection
7. Run distribution fitting
"""
Forecast incremental skip — PIPE_forecast_fingerprints¶
A series is re-forecast only when its inputs changed. _get_changed_series()
keeps a series when ANY of:
- No row in
PIPE_forecast_point_valuesfor the currentarchive_date(=planning_monday()) — this week not run yet. - No
data_hashin the demand-only / tenant-globalPIPE_series_hashes(keyed by(item_id, site_id), nopipeline_id) — new series / ETL not run. - The combined input fingerprint differs from the stored value in the
pipeline-scoped
PIPE_forecast_fingerprints(keyed by(pipeline_id, scenario_id, item_id, site_id),ReplacingMergeTree(computed_at), read withFINAL).
The combined fingerprint is MD5(data_hash | param_hash). param_hash
(_compute_param_hash) folds in every forecast parameter surface: forecast
parameter set, hyperparameter set, backtesting/method-selection set, and
scenario-level overrides (confidence_levels, demand_horizon_limits,
method_selection, frequency, horizon). It is memoised per-uid and
cross-series (by assigned set IDs when there are no per-series overrides).
After a successful forecast run, _mark_series_forecast() writes the combined
fingerprint to PIPE_forecast_fingerprints (it no longer touches
PIPE_series_hashes.forecast_hash). Because the skip state is keyed by
pipeline_id, multiple pipelines on one tenant cannot clobber each other's
state; PIPE_series_hashes stays demand-only / tenant-global.
Last-resort Naive fallback¶
Inside StatisticalForecaster.forecast_multiple_series, after eligibility
filtering, if a series has no eligible statistical method for its
observation count (e.g. a 2-observation series routed to AutoETS/AutoARIMA/
AutoTheta/MSTL, all of which need ≥ 10), it is reassigned to Naive (needs
only ≥ 1 observation) rather than aborting the whole pipeline with
"0 forecasts produced". This keeps very short series forecastable.
Dask Task Structure¶
# The module-level function that Dask workers call
def _dask_forecast_batch(config_path, batch_df, batch_chars, config_override=None, overrides_map=None):
"""
Standalone function (NOT a bound method) so it can be pickled and sent
to a Dask worker process. Each worker reconstructs the forecasters from
the config path.
"""
all_forecasts = []
# Statistical forecasting
stat_forecaster = StatisticalForecaster(config_path, config_override=config_override)
stat_forecasts = stat_forecaster.forecast_multiple_series(
df=batch_df,
characteristics_df=batch_chars,
overrides_map=overrides_map,
)
all_forecasts.append(stat_forecasts)
# ML forecasting (if sufficient observations)
if eligible_for_ml:
ml_forecaster = MLForecaster(config_path, config_override=config_override)
ml_forecasts = ml_forecaster.forecast_multiple_series(batch_df, batch_chars)
all_forecasts.append(ml_forecasts)
return pd.concat(all_forecasts, ignore_index=True)
Parameter-Aware Batching¶
The orchestrator uses ParameterResolver.group_series_by_param_set() to group series so that all series using the same parameter set run together — this avoids re-instantiating the forecaster for each series:
resolver = ParameterResolver(config_path)
groups = resolver.group_series_by_param_set(all_unique_ids, 'forecasting')
tasks = []
for param_id, uids in groups.items():
override = resolver.build_group_config_override(param_id, 'forecasting')
# Split this group into chunks of chunk_size
for chunk_uids in chunked(uids, chunk_size):
chunk_df = df[df['unique_id'].isin(chunk_uids)]
chunk_chars = chars_df[chars_df['unique_id'].isin(chunk_uids)]
task = client.submit(
_dask_forecast_batch,
config_path,
chunk_df,
chunk_chars,
override,
overrides_map=sku_overrides_for_chunk,
)
tasks.append(task)
# Gather all results
results = [task.result() for task in as_completed(tasks)]
Dask Cluster Configuration¶
The orchestrator starts a dask.distributed.LocalCluster:
cluster = LocalCluster(
n_workers = config.get('dask', {}).get('n_workers', 4),
threads_per_worker = config.get('dask', {}).get('threads_per_worker', 1),
processes = True, # use separate processes for Python GIL avoidance
)
client = Client(cluster)
threads_per_worker=1 is important: statsforecast already uses its own internal parallelism, so giving each Dask worker one thread prevents over-subscription.
Process Logger — files/utils/process_logger.py¶
Every pipeline step creates a ProcessLogger to track its progress in scenario.process_log:
from utils.process_logger import ProcessLogger, ListHandler
_pl = ProcessLogger(run_id="forecast-2026-04-12", schema="scenario", pipeline_id=42)
step_id = _pl.start_step("forecast")
# ... do the work ...
_pl.end_step(step_id, "success", rows=590, log_tail=_list_handler.get_tail(200))
ListHandler is a Python logging handler that buffers log lines in memory so the API can stream them to the frontend via GET /api/pipeline/jobs/{job_id}/stream (Server-Sent Events).