metafilm 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
metafilm/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """MetaFiLM: metadata-conditioned FiLM modulation for cold-start demand forecasting."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .forecaster import (
6
+ MetaFiLMConfig,
7
+ MetaFiLMForecaster,
8
+ MetaFiLMModel,
9
+ ModelNotFittedError,
10
+ StabilizedFiLMLayer,
11
+ TimeSeriesDataset,
12
+ ValidationError,
13
+ )
14
+
15
+ try:
16
+ __version__ = version("metafilm")
17
+ except PackageNotFoundError: # running from a source checkout
18
+ __version__ = "0.0.0+local"
19
+
20
+ __all__ = [
21
+ "MetaFiLMConfig",
22
+ "MetaFiLMForecaster",
23
+ "MetaFiLMModel",
24
+ "ModelNotFittedError",
25
+ "StabilizedFiLMLayer",
26
+ "TimeSeriesDataset",
27
+ "ValidationError",
28
+ "__version__",
29
+ ]
metafilm/forecaster.py ADDED
@@ -0,0 +1,1013 @@
1
+ """MetaFiLM: metadata-conditioned FiLM modulation for cold-start demand forecasting.
2
+
3
+ MetaFiLM forecasts demand for item-location series that have little or no
4
+ sales history by conditioning a compact sequence model on item and location
5
+ metadata. Metadata enters through two complementary pathways:
6
+
7
+ * a **level head**, a small MLP that predicts a base demand level and adds it
8
+ to every forecast step, and
9
+ * a bounded **FiLM** (Feature-wise Linear Modulation) block that scales and
10
+ shifts the decoder's hidden states feature by feature.
11
+
12
+ A two-layer LSTM encodes whatever history is available (it is skipped for a
13
+ series with no history), and a one-layer LSTM decoder reads the future
14
+ calendar. The encoded history is added to the decoder outputs with weight 0.3
15
+ before the FiLM block.
16
+
17
+ The main entry points are :class:`MetaFiLMConfig` and
18
+ :class:`MetaFiLMForecaster`; :class:`MetaFiLMModel` is the underlying
19
+ ``torch.nn.Module``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from dataclasses import dataclass
26
+ from typing import Any, Dict, List, Optional, Tuple, Union
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
+ import torch
31
+ import torch.nn as nn
32
+ import torch.optim as optim
33
+ from sklearn.base import BaseEstimator
34
+ from sklearn.preprocessing import StandardScaler
35
+ from torch.utils.data import Dataset, DataLoader
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ __all__ = [
40
+ "MetaFiLMForecaster",
41
+ "MetaFiLMConfig",
42
+ "MetaFiLMModel",
43
+ "StabilizedFiLMLayer",
44
+ "TimeSeriesDataset",
45
+ "ValidationError",
46
+ "ModelNotFittedError"
47
+ ]
48
+
49
+
50
+ class ValidationError(Exception):
51
+ """Raised for an invalid configuration or input data frame."""
52
+
53
+
54
+ class ModelNotFittedError(Exception):
55
+ """Raised when predicting with, or saving, a forecaster that is not fitted."""
56
+
57
+
58
+ @dataclass
59
+ class MetaFiLMConfig:
60
+ """Configuration for :class:`MetaFiLMForecaster`.
61
+
62
+ Data
63
+ n_steps_lag: length of the history window fed to the encoder.
64
+ forecast_horizon: number of steps forecast at once.
65
+ context_features: metadata columns (categorical or numeric), constant
66
+ per series. Categorical values unseen at fit time map to a reserved
67
+ "unknown" code, so new items and locations are handled.
68
+ temporal_features: optional extra per-step columns fed to the encoder
69
+ next to the target.
70
+ item_id_col, target_col, date_col: column names of the long-format
71
+ input frame (one row per series and period).
72
+ freq: pandas offset alias of the series, e.g. ``"W-SUN"`` (weekly) or
73
+ ``"MS"`` (monthly); used to build the future calendar.
74
+
75
+ Model
76
+ hidden_dim, num_lstm_layers, dropout: network size.
77
+ use_residual: add ``0.5 * features`` after the FiLM modulation.
78
+ film_mode: ``"bounded"`` (default, gamma = 1 + 0.1 tanh, beta = 0.1 tanh),
79
+ ``"unbounded"`` (classic FiLM), ``"concat"`` (parameter-matched
80
+ concatenation control) or ``"off"`` (no FiLM).
81
+ film_init: ``"default"`` or ``"identity"`` (FiLM generator starts at
82
+ gamma = 1, beta = 0).
83
+ use_level, use_history: switch the level head or the history encoder
84
+ off (used for ablations).
85
+
86
+ Training
87
+ loss: ``"mse"`` (default), ``"l1"`` (mean absolute error) or ``"huber"``.
88
+ When forecasts are evaluated by MAE, ``"l1"`` or ``"huber"``
89
+ usually lowers the error; ``"huber"`` is the more balanced choice
90
+ for intermittent demand.
91
+ batch_size, learning_rate, num_epochs, patience, weight_decay: optimiser
92
+ and early-stopping settings. Weight decay applies to the metadata
93
+ fusion block only.
94
+ restore_best_weights: after early stopping, restore the weights of the
95
+ epoch with the lowest validation loss (default). ``False`` keeps
96
+ the final-epoch weights, which is how the CENTERIS 2026 paper's
97
+ experiments were run.
98
+ random_seed, device: reproducibility and placement.
99
+ """
100
+ # Data configuration
101
+ n_steps_lag: int = 12
102
+ forecast_horizon: int = 12
103
+ context_features: List[str] = None
104
+ temporal_features: List[str] = None
105
+ item_id_col: str = "compoundkey"
106
+ target_col: str = "quantity"
107
+ date_col: str = "orderweek_dt"
108
+ freq: str = "W-SUN" # pandas offset alias; e.g. "W-SUN" weekly, "MS" monthly
109
+
110
+ # Model architecture
111
+ hidden_dim: int = 128
112
+ num_lstm_layers: int = 2
113
+ dropout: float = 0.3
114
+ use_residual: bool = True
115
+ # FiLM ablation knobs:
116
+ # film_mode="bounded" -> gamma=1+0.1*tanh(.), beta=0.1*tanh(.) (default/paper)
117
+ # film_mode="unbounded" -> gamma=1+gamma_raw, beta=beta_raw (classic FiLM)
118
+ # film_mode="concat" -> replace FiLM with parameter-matched concat + MLP fusion
119
+ # film_mode="off" -> identity passthrough (FiLM removed entirely)
120
+ film_mode: str = "bounded"
121
+ film_init: str = "default" # "default" | "identity" (zero-init FiLM generator)
122
+ # Component ablation switches (default True = full model):
123
+ use_level: bool = True # metadata level head adds a base demand offset
124
+ use_history: bool = True # LSTM history encoder feeds the decoder
125
+ # Training objective: "mse" (paper) | "l1" (MAE-aligned) | "huber"
126
+ loss: str = "mse"
127
+ # Restore the best-validation weights after early stopping. The paper's
128
+ # experiments kept the final-epoch weights (experiments/common.py passes
129
+ # False to reproduce them exactly).
130
+ restore_best_weights: bool = True
131
+
132
+ # Training parameters
133
+ batch_size: int = 32
134
+ learning_rate: float = 0.001
135
+ num_epochs: int = 100
136
+ patience: int = 10
137
+ weight_decay: float = 0.01
138
+ random_seed: int = 42
139
+ device: str = "cuda" if torch.cuda.is_available() else "cpu"
140
+
141
+ def __post_init__(self):
142
+ if self.context_features is None:
143
+ self.context_features = []
144
+ if self.temporal_features is None:
145
+ self.temporal_features = []
146
+ if self.n_steps_lag <= 0:
147
+ raise ValidationError("n_steps_lag must be positive")
148
+ if self.forecast_horizon <= 0:
149
+ raise ValidationError("forecast_horizon must be positive")
150
+ if self.film_mode not in ("bounded", "unbounded", "concat", "off"):
151
+ raise ValidationError(
152
+ "film_mode must be 'bounded', 'unbounded', 'concat', or 'off'"
153
+ )
154
+ if self.film_init not in ("default", "identity"):
155
+ raise ValidationError("film_init must be 'default' or 'identity'")
156
+ if self.loss not in ("mse", "l1", "huber"):
157
+ raise ValidationError("loss must be 'mse', 'l1', or 'huber'")
158
+
159
+
160
+ class StabilizedFiLMLayer(nn.Module):
161
+ """Feature-wise linear modulation of decoder states by metadata.
162
+
163
+ In the default ``"bounded"`` mode the generator output is squashed so that
164
+ gamma = 1 + 0.1 tanh(.) and beta = 0.1 tanh(.): conditioning starts close
165
+ to the identity and only grows if training supports it. See
166
+ :class:`MetaFiLMConfig` for the other modes.
167
+ """
168
+
169
+ def __init__(self, feature_dim: int, context_dim: int, use_residual: bool = True,
170
+ film_mode: str = "bounded", film_init: str = "default"):
171
+ super().__init__()
172
+ self.use_residual = use_residual
173
+ self.film_mode = film_mode
174
+
175
+ if film_mode == "concat":
176
+ # Reviewer-requested late-fusion control. Its parameter count is
177
+ # approximately matched to the FiLM generator: concatenate the
178
+ # same context to every decoder state, then map back to feature_dim.
179
+ self.concat_fusion = nn.Sequential(
180
+ nn.Linear(feature_dim + context_dim, feature_dim),
181
+ nn.ReLU(),
182
+ nn.Dropout(0.3),
183
+ nn.Linear(feature_dim, feature_dim),
184
+ )
185
+ self.film_generator = None
186
+ else:
187
+ self.concat_fusion = None
188
+ self.film_generator = nn.Sequential(
189
+ nn.Linear(context_dim, feature_dim),
190
+ nn.ReLU(),
191
+ nn.Dropout(0.3),
192
+ nn.Linear(feature_dim, 2 * feature_dim)
193
+ )
194
+
195
+ # "identity" init: zero the final layer so gamma_raw=beta_raw=0 at start,
196
+ # i.e. gamma=1, beta=0 regardless of film_mode. This lets an *unbounded*
197
+ # FiLM start exactly at identity (matching bounded's starting point), so
198
+ # an unbounded-vs-bounded comparison isn't confounded by initial scale.
199
+ if film_init == "identity" and self.film_generator is not None:
200
+ last_linear = self.film_generator[-1]
201
+ with torch.no_grad():
202
+ last_linear.weight.zero_()
203
+ last_linear.bias.zero_()
204
+
205
+ def forward(self, features: torch.Tensor, context: torch.Tensor) -> torch.Tensor:
206
+ """
207
+ Apply (optionally ablated) FiLM modulation.
208
+
209
+ Args:
210
+ features: (batch, feature_dim) or (batch, seq, feature_dim)
211
+ context: (batch, context_dim)
212
+ """
213
+ # "off": FiLM removed entirely -> identity passthrough (no modulation,
214
+ # no residual). This is the clean "no-FiLM" ablation arm.
215
+ if self.film_mode == "off":
216
+ return features
217
+
218
+ if self.film_mode == "concat":
219
+ if features.dim() == 3:
220
+ expanded_context = context.unsqueeze(1).expand(-1, features.shape[1], -1)
221
+ else:
222
+ expanded_context = context
223
+ return self.concat_fusion(torch.cat([features, expanded_context], dim=-1))
224
+
225
+ film_params = self.film_generator(context)
226
+ gamma_raw, beta_raw = film_params.chunk(2, dim=-1)
227
+
228
+ if self.film_mode == "unbounded":
229
+ # Classic FiLM: unconstrained affine, identity-init friendly.
230
+ gamma = 1.0 + gamma_raw
231
+ beta = beta_raw
232
+ else: # "bounded" (default / paper)
233
+ # Stabilize: anchor near identity
234
+ gamma = 1.0 + 0.1 * torch.tanh(gamma_raw)
235
+ beta = 0.1 * torch.tanh(beta_raw)
236
+
237
+ # Expand for sequence if needed
238
+ if features.dim() == 3:
239
+ gamma = gamma.unsqueeze(1)
240
+ beta = beta.unsqueeze(1)
241
+
242
+ # Apply FiLM
243
+ modulated = gamma * features + beta
244
+
245
+ # Weighted residual
246
+ if self.use_residual:
247
+ modulated = modulated + 0.5 * features
248
+
249
+ return modulated
250
+
251
+
252
+ class MetaFiLMModel(nn.Module):
253
+ """The MetaFiLM network.
254
+
255
+ 1. Level head: base demand level from metadata, added to every step.
256
+ 2. History encoder: two-layer LSTM over the available past (skipped when a
257
+ series has no history).
258
+ 3. Future decoder: one-layer LSTM over the future calendar, zero initial
259
+ state; the encoded history is added to its outputs with weight 0.3.
260
+ 4. FiLM block: metadata scales and shifts the fused decoder states.
261
+ """
262
+
263
+ def __init__(
264
+ self,
265
+ past_feature_dim: int,
266
+ future_feature_dim: int,
267
+ context_dim: int,
268
+ hidden_dim: int = 128,
269
+ num_lstm_layers: int = 2,
270
+ forecast_horizon: int = 12,
271
+ dropout: float = 0.3,
272
+ use_residual: bool = True,
273
+ film_mode: str = "bounded",
274
+ film_init: str = "default",
275
+ use_level: bool = True,
276
+ use_history: bool = True
277
+ ):
278
+ super().__init__()
279
+
280
+ self.hidden_dim = hidden_dim
281
+ self.forecast_horizon = forecast_horizon
282
+ self.use_level = use_level
283
+ self.use_history = use_history
284
+
285
+ # Level prediction from metadata (zero-shot base)
286
+ self.level_head = nn.Sequential(
287
+ nn.Linear(context_dim, hidden_dim),
288
+ nn.ReLU(),
289
+ nn.Dropout(dropout),
290
+ nn.Linear(hidden_dim, 1)
291
+ )
292
+
293
+ # History encoder (optional)
294
+ self.history_encoder = nn.LSTM(
295
+ input_size=past_feature_dim,
296
+ hidden_size=hidden_dim,
297
+ num_layers=num_lstm_layers,
298
+ batch_first=True,
299
+ dropout=dropout if num_lstm_layers > 1 else 0
300
+ )
301
+
302
+ # Future decoder
303
+ self.future_decoder = nn.LSTM(
304
+ input_size=future_feature_dim,
305
+ hidden_size=hidden_dim,
306
+ num_layers=1,
307
+ batch_first=True
308
+ )
309
+
310
+ # FiLM for future patterns
311
+ self.film_layer = StabilizedFiLMLayer(
312
+ feature_dim=hidden_dim,
313
+ context_dim=context_dim,
314
+ use_residual=use_residual,
315
+ film_mode=film_mode,
316
+ film_init=film_init
317
+ )
318
+
319
+ # Vectorized output head
320
+ self.output_head = nn.Linear(hidden_dim, 1)
321
+
322
+ def forward(
323
+ self,
324
+ context: torch.Tensor,
325
+ future_features: torch.Tensor,
326
+ past_sequence: Optional[torch.Tensor] = None
327
+ ) -> torch.Tensor:
328
+ """
329
+ Forward pass with vectorized output.
330
+
331
+ Args:
332
+ context: (batch, context_dim)
333
+ future_features: (batch, horizon, future_feature_dim)
334
+ past_sequence: (batch, seq_len, past_feature_dim) or None
335
+
336
+ Returns:
337
+ predictions: (batch, horizon)
338
+ """
339
+ batch_size = context.shape[0]
340
+
341
+ # 1. Base level from metadata
342
+ if self.use_level:
343
+ base_level = self.level_head(context) # (batch, 1)
344
+ else:
345
+ base_level = torch.zeros(batch_size, 1, device=context.device)
346
+
347
+ # 2. Encode history if available (and enabled)
348
+ if self.use_history and past_sequence is not None:
349
+ _, (h_past, _) = self.history_encoder(past_sequence)
350
+ history_context = h_past[-1] # (batch, hidden_dim)
351
+ else:
352
+ history_context = torch.zeros(batch_size, self.hidden_dim, device=context.device)
353
+
354
+ # 3. Decode future
355
+ future_encoded, _ = self.future_decoder(future_features) # (batch, horizon, hidden_dim)
356
+
357
+ # 4. Combine with history context
358
+ combined = future_encoded + 0.3 * history_context.unsqueeze(1)
359
+
360
+ # 5. Apply FiLM modulation (vectorized)
361
+ modulated = self.film_layer(combined, context) # (batch, horizon, hidden_dim)
362
+
363
+ # 6. Vectorized output
364
+ predictions = self.output_head(modulated).squeeze(-1) # (batch, horizon)
365
+
366
+ # 7. Add base level
367
+ predictions = predictions + base_level
368
+
369
+ return predictions
370
+
371
+
372
+ class TimeSeriesDataset(Dataset):
373
+ """Torch dataset of (context, future calendar, past window, target) samples.
374
+
375
+ The past window may be ``None`` for series without history.
376
+ """
377
+
378
+ def __init__(
379
+ self,
380
+ context: np.ndarray,
381
+ future_features: np.ndarray,
382
+ past_sequences: Optional[List[Optional[np.ndarray]]] = None,
383
+ targets: Optional[np.ndarray] = None
384
+ ):
385
+ self.context = torch.FloatTensor(context)
386
+ self.future_features = torch.FloatTensor(future_features)
387
+ self.past_sequences = past_sequences # Keep as list
388
+ self.targets = torch.FloatTensor(targets) if targets is not None else None
389
+
390
+ def __len__(self):
391
+ return len(self.context)
392
+
393
+ def __getitem__(self, idx):
394
+ items = [self.context[idx], self.future_features[idx]]
395
+
396
+ if self.past_sequences is not None:
397
+ past = self.past_sequences[idx]
398
+ if past is not None:
399
+ items.append(torch.FloatTensor(past))
400
+ else:
401
+ items.append(None)
402
+
403
+ if self.targets is not None:
404
+ items.append(self.targets[idx])
405
+
406
+ return tuple(items)
407
+
408
+
409
+ def _collate_train(batch):
410
+ """Custom collate that asserts past sequences exist for training/validation."""
411
+ ctx, fut, pst, tgt = zip(*batch)
412
+ assert all(p is not None for p in pst), "Training past sequences must be present"
413
+ return (
414
+ torch.stack(ctx, dim=0),
415
+ torch.stack(fut, dim=0),
416
+ torch.stack(pst, dim=0),
417
+ torch.stack(tgt, dim=0),
418
+ )
419
+
420
+
421
+ class MetaFiLMForecaster(BaseEstimator):
422
+ """Fit and apply a MetaFiLM model on a long-format demand panel.
423
+
424
+ The input frame has one row per series and period, with the columns named
425
+ in the config: an item id, a date, the target and the metadata columns
426
+ (``context_features``, constant within a series). Training uses every
427
+ window of ``n_steps_lag + forecast_horizon`` periods; series shorter than
428
+ that are skipped during training but can still be forecast.
429
+
430
+ Example::
431
+
432
+ from metafilm import MetaFiLMConfig, MetaFiLMForecaster
433
+
434
+ config = MetaFiLMConfig(
435
+ n_steps_lag=8, forecast_horizon=4,
436
+ context_features=["category", "store"],
437
+ item_id_col="series_id", date_col="week", target_col="sales",
438
+ freq="W-SUN",
439
+ )
440
+ model = MetaFiLMForecaster(config).fit(train_df)
441
+ forecasts = model.predict(new_items_df) # {series_id: array(horizon)}
442
+ """
443
+
444
+ def __init__(self, config: MetaFiLMConfig):
445
+ self.config = config
446
+
447
+ # Set seeds for reproducibility
448
+ torch.manual_seed(self.config.random_seed)
449
+ np.random.seed(self.config.random_seed)
450
+
451
+ # Reproducibility settings
452
+ torch.backends.cudnn.deterministic = True
453
+ torch.backends.cudnn.benchmark = False
454
+
455
+ self.is_fitted_ = False
456
+ self.model_: Optional[MetaFiLMModel] = None
457
+ self.context_scaler_: Optional[StandardScaler] = None
458
+ self.target_scaler_: Optional[StandardScaler] = None
459
+ self.past_scaler_: Optional[StandardScaler] = None
460
+ self.future_scaler_: Optional[StandardScaler] = None
461
+
462
+ self.train_losses_: List[float] = []
463
+ self.val_losses_: List[float] = []
464
+
465
+ # Persistent categorical categories learned at fit-time
466
+ self._context_categories: Dict[str, List[str]] = {}
467
+
468
+ logger.info(f"Initialized MetaFiLM on device: {self.config.device}")
469
+
470
+ def _create_future_features(
471
+ self,
472
+ start_date: pd.Timestamp,
473
+ horizon: int
474
+ ) -> np.ndarray:
475
+ """Calendar features (sin/cos of month and ISO week) for the horizon."""
476
+ future_dates = pd.date_range(start=start_date, periods=horizon, freq=self.config.freq)
477
+
478
+ features = []
479
+ for date in future_dates:
480
+ week = date.isocalendar().week
481
+ month = date.month
482
+
483
+ feat = [
484
+ np.sin(2 * np.pi * month / 12),
485
+ np.cos(2 * np.pi * month / 12),
486
+ np.sin(2 * np.pi * week / 52),
487
+ np.cos(2 * np.pi * week / 52),
488
+ ]
489
+ features.append(feat)
490
+
491
+ return np.array(features)
492
+
493
+ def _ensure_numeric_context(self, df: pd.DataFrame, learn: bool) -> pd.DataFrame:
494
+ """
495
+ Ensure context features are numeric with PERSISTENT category mapping.
496
+ If learn=True, store categories seen during fit.
497
+ If learn=False, apply stored categories and map unknowns to an 'unknown' index.
498
+ """
499
+ df = df.copy()
500
+
501
+ for col in self.config.context_features:
502
+ if col not in df.columns:
503
+ continue
504
+ dtype = df[col].dtype
505
+ if pd.api.types.is_numeric_dtype(dtype) and not pd.api.types.is_bool_dtype(dtype):
506
+ # Already numeric. (np.issubdtype cannot read pandas extension
507
+ # dtypes such as the pandas 3 string dtype; booleans stay
508
+ # categorical, as before.)
509
+ continue
510
+ if learn:
511
+ # Learn categories from training data
512
+ df[col] = df[col].astype('category')
513
+ cats = list(df[col].cat.categories)
514
+ self._context_categories[col] = cats # persist
515
+ df[col] = df[col].cat.codes.astype('int32')
516
+ else:
517
+ # Apply stored categories; map unknowns to last index
518
+ cats = self._context_categories.get(col, [])
519
+ if len(cats) > 0:
520
+ # Look the codes up directly: casting values that are not
521
+ # among the learned categories is deprecated in pandas 3.
522
+ # Unknown values and NaN map to the reserved last index.
523
+ unknown_idx = len(cats)
524
+ lookup = {c: i for i, c in enumerate(cats)}
525
+ codes = df[col].map(lookup)
526
+ df[col] = codes.fillna(unknown_idx).astype('int32')
527
+ else:
528
+ # No stored categories; fallback to ordinal encode (best effort)
529
+ df[col] = df[col].astype('category').cat.codes.astype('int32')
530
+ return df
531
+
532
+ def _create_sequences(
533
+ self,
534
+ df: pd.DataFrame,
535
+ is_training: bool = False,
536
+ forecast_start_override: Optional[pd.Timestamp] = None
537
+ ) -> Tuple[np.ndarray, np.ndarray, List[Optional[np.ndarray]], Optional[np.ndarray], List[str], List[pd.Timestamp]]:
538
+ """
539
+ Create sequences with proper handling of mixed few/zero-shot.
540
+
541
+ Returns past_sequences as LIST where elements can be None.
542
+ """
543
+ logger.info("Creating sequences...")
544
+
545
+ # Ensure numeric context (learn during training; reuse during predict)
546
+ df = self._ensure_numeric_context(df, learn=is_training)
547
+
548
+ all_context = []
549
+ all_future_features = []
550
+ all_past_sequences = []
551
+ all_targets = []
552
+ all_item_ids = []
553
+ all_forecast_dates = []
554
+
555
+ items_processed = 0
556
+ items_skipped = 0
557
+
558
+ for item_id, group in df.groupby(self.config.item_id_col):
559
+ group_sorted = group.sort_values(self.config.date_col).reset_index(drop=True)
560
+
561
+ # Extract context
562
+ if self.config.context_features:
563
+ context_values = group_sorted[self.config.context_features].iloc[0].values
564
+ else:
565
+ context_values = np.array([0.0])
566
+
567
+ if is_training:
568
+ min_length = self.config.n_steps_lag + self.config.forecast_horizon
569
+ if len(group_sorted) < min_length:
570
+ items_skipped += 1
571
+ continue
572
+
573
+ for i in range(len(group_sorted) - self.config.n_steps_lag - self.config.forecast_horizon + 1):
574
+ # Past sequence
575
+ past_window = group_sorted.iloc[i:i+self.config.n_steps_lag]
576
+ past_seq = past_window[[self.config.target_col] + self.config.temporal_features].values
577
+
578
+ # Future targets
579
+ future_window = group_sorted.iloc[
580
+ i+self.config.n_steps_lag:i+self.config.n_steps_lag+self.config.forecast_horizon
581
+ ]
582
+ future_targets = future_window[self.config.target_col].values
583
+
584
+ if len(future_targets) != self.config.forecast_horizon:
585
+ continue
586
+
587
+ # Future calendar
588
+ forecast_start = future_window.iloc[0][self.config.date_col]
589
+ future_feats = self._create_future_features(forecast_start, self.config.forecast_horizon)
590
+
591
+ all_context.append(context_values)
592
+ all_future_features.append(future_feats)
593
+ all_past_sequences.append(past_seq)
594
+ all_targets.append(future_targets)
595
+ all_item_ids.append(item_id)
596
+ all_forecast_dates.append(forecast_start)
597
+ else:
598
+ # Prediction mode
599
+ if forecast_start_override is not None:
600
+ forecast_start = forecast_start_override
601
+ else:
602
+ last_dt = group_sorted[self.config.date_col].iloc[-1]
603
+ offset = pd.tseries.frequencies.to_offset(self.config.freq)
604
+ forecast_start = last_dt + offset
605
+
606
+ future_feats = self._create_future_features(forecast_start, self.config.forecast_horizon)
607
+
608
+ # Past sequence if available (KEEP AS INDIVIDUAL, NOT ARRAY)
609
+ if len(group_sorted) >= self.config.n_steps_lag:
610
+ past_window = group_sorted.iloc[-self.config.n_steps_lag:]
611
+ past_seq = past_window[[self.config.target_col] + self.config.temporal_features].values
612
+ all_past_sequences.append(past_seq)
613
+ else:
614
+ # Zero-shot: no history
615
+ all_past_sequences.append(None)
616
+
617
+ all_context.append(context_values)
618
+ all_future_features.append(future_feats)
619
+ all_item_ids.append(item_id)
620
+ all_forecast_dates.append(forecast_start)
621
+
622
+ items_processed += 1
623
+
624
+ if items_skipped > 0:
625
+ logger.warning(f"Skipped {items_skipped} items with insufficient data")
626
+
627
+ if not all_context:
628
+ raise ValidationError("No valid sequences created")
629
+
630
+ context = np.array(all_context)
631
+ future_features = np.array(all_future_features)
632
+
633
+ # Return past_sequences as LIST (may contain None)
634
+ past_sequences = all_past_sequences
635
+
636
+ targets = np.array(all_targets) if all_targets else None
637
+
638
+ logger.info(f"Created {len(context)} sequences from {items_processed} items")
639
+ logger.info(f"Context shape: {context.shape}")
640
+ logger.info(f"Future features shape: {future_features.shape}")
641
+ if is_training:
642
+ logger.info(f"Past sequences: {len([p for p in past_sequences if p is not None])} with history")
643
+
644
+ return context, future_features, past_sequences, targets, all_item_ids, all_forecast_dates
645
+
646
+ def fit(self, df: pd.DataFrame, validation_split: float = 0.2) -> 'MetaFiLMForecaster':
647
+ """Train on a long-format panel.
648
+
649
+ Args:
650
+ df: training frame with the columns named in the config.
651
+ validation_split: share of training windows held out for early
652
+ stopping. Scalers are fitted on the remaining windows only.
653
+
654
+ Returns:
655
+ The fitted forecaster (``self``).
656
+ """
657
+ logger.info("Starting MetaFiLM training...")
658
+
659
+ required_cols = [
660
+ self.config.item_id_col,
661
+ self.config.target_col,
662
+ self.config.date_col
663
+ ] + self.config.context_features + self.config.temporal_features
664
+
665
+ missing = [c for c in required_cols if c not in df.columns]
666
+ if missing:
667
+ raise ValidationError(f"Missing columns: {missing}")
668
+
669
+ # Create sequences
670
+ context, future_features, past_sequences_list, targets, _, _ = self._create_sequences(
671
+ df, is_training=True
672
+ )
673
+
674
+ # Convert past_sequences list to array (all have data in training)
675
+ past_sequences = np.array(past_sequences_list)
676
+
677
+ # Handle NaNs before scaling (prevent propagation)
678
+ logger.info("Handling NaNs in features...")
679
+ context = np.nan_to_num(context, nan=0.0)
680
+ targets = np.nan_to_num(targets, nan=0.0)
681
+ future_features = np.nan_to_num(future_features, nan=0.0)
682
+ past_sequences = np.nan_to_num(past_sequences, nan=0.0)
683
+
684
+ # SPLIT FIRST
685
+ n = len(context)
686
+ n_val = int(n * validation_split)
687
+ indices = np.random.permutation(n)
688
+ train_idx = indices[n_val:]
689
+ val_idx = indices[:n_val]
690
+
691
+ # FIT SCALERS ON TRAIN ONLY
692
+ self.context_scaler_ = StandardScaler().fit(context[train_idx])
693
+
694
+ # Flatten target for single scale
695
+ self.target_scaler_ = StandardScaler().fit(targets[train_idx].reshape(-1, 1))
696
+
697
+ # Future features
698
+ future_train_flat = future_features[train_idx].reshape(-1, future_features.shape[-1])
699
+ self.future_scaler_ = StandardScaler().fit(future_train_flat)
700
+
701
+ # Past sequences
702
+ past_train_flat = past_sequences[train_idx].reshape(-1, past_sequences.shape[-1])
703
+ self.past_scaler_ = StandardScaler().fit(past_train_flat)
704
+
705
+ # TRANSFORM SEPARATELY
706
+ def transform_split(idx):
707
+ ctx = self.context_scaler_.transform(context[idx])
708
+
709
+ tgt = self.target_scaler_.transform(targets[idx].reshape(-1, 1))
710
+ tgt = tgt.reshape(len(idx), -1)
711
+
712
+ fut = future_features[idx].reshape(-1, future_features.shape[-1])
713
+ fut = self.future_scaler_.transform(fut)
714
+ fut = fut.reshape(len(idx), *future_features.shape[1:])
715
+
716
+ pst = past_sequences[idx].reshape(-1, past_sequences.shape[-1])
717
+ pst = self.past_scaler_.transform(pst)
718
+ pst = pst.reshape(len(idx), *past_sequences.shape[1:])
719
+
720
+ # Convert to list for dataset
721
+ pst_list = [pst[i] for i in range(len(idx))]
722
+
723
+ return ctx, fut, pst_list, tgt
724
+
725
+ ctx_train, fut_train, pst_train, tgt_train = transform_split(train_idx)
726
+ ctx_val, fut_val, pst_val, tgt_val = transform_split(val_idx)
727
+
728
+ # Create datasets
729
+ train_dataset = TimeSeriesDataset(ctx_train, fut_train, pst_train, tgt_train)
730
+ val_dataset = TimeSeriesDataset(ctx_val, fut_val, pst_val, tgt_val)
731
+
732
+ train_loader = DataLoader(
733
+ train_dataset,
734
+ batch_size=self.config.batch_size,
735
+ shuffle=True,
736
+ collate_fn=_collate_train
737
+ )
738
+ val_loader = DataLoader(
739
+ val_dataset,
740
+ batch_size=self.config.batch_size,
741
+ shuffle=False,
742
+ collate_fn=_collate_train
743
+ )
744
+
745
+ # Initialize model
746
+ past_dim = past_sequences.shape[-1]
747
+ future_dim = future_features.shape[-1]
748
+ context_dim = context.shape[-1]
749
+
750
+ self.model_ = MetaFiLMModel(
751
+ past_feature_dim=past_dim,
752
+ future_feature_dim=future_dim,
753
+ context_dim=context_dim,
754
+ hidden_dim=self.config.hidden_dim,
755
+ num_lstm_layers=self.config.num_lstm_layers,
756
+ forecast_horizon=self.config.forecast_horizon,
757
+ dropout=self.config.dropout,
758
+ use_residual=self.config.use_residual,
759
+ film_mode=self.config.film_mode,
760
+ film_init=self.config.film_init,
761
+ use_level=self.config.use_level,
762
+ use_history=self.config.use_history
763
+ ).to(self.config.device)
764
+
765
+ # Optimizer with weight decay only on the metadata-fusion module. The
766
+ # reviewer-requested concat arm uses the same optimisation treatment as
767
+ # FiLM so the comparison changes the fusion operation, not regularisation.
768
+ fusion_module = (
769
+ self.model_.film_layer.concat_fusion
770
+ if self.config.film_mode == "concat"
771
+ else self.model_.film_layer.film_generator
772
+ )
773
+ fusion_params = list(fusion_module.parameters())
774
+ fusion_param_ids = {id(p) for p in fusion_params}
775
+ other_params = [p for p in self.model_.parameters() if id(p) not in fusion_param_ids]
776
+
777
+ optimizer = optim.Adam([
778
+ {'params': other_params, 'weight_decay': 0.0},
779
+ {'params': fusion_params, 'weight_decay': self.config.weight_decay}
780
+ ], lr=self.config.learning_rate)
781
+
782
+ criterion = {
783
+ "mse": nn.MSELoss,
784
+ "l1": nn.L1Loss,
785
+ "huber": nn.HuberLoss,
786
+ }[self.config.loss]()
787
+
788
+ # Training loop
789
+ best_val_loss = float('inf')
790
+ patience_counter = 0
791
+
792
+ for epoch in range(self.config.num_epochs):
793
+ # Train
794
+ self.model_.train()
795
+ train_loss = 0.0
796
+
797
+ for batch in train_loader:
798
+ ctx, fut, pst, tgt = batch
799
+
800
+ ctx = ctx.to(self.config.device)
801
+ fut = fut.to(self.config.device)
802
+ pst = pst.to(self.config.device) if pst is not None else None
803
+ tgt = tgt.to(self.config.device)
804
+
805
+ optimizer.zero_grad()
806
+ pred = self.model_(ctx, fut, pst)
807
+ loss = criterion(pred, tgt)
808
+ loss.backward()
809
+ # Gradient clipping for stability
810
+ torch.nn.utils.clip_grad_norm_(self.model_.parameters(), max_norm=1.0)
811
+ optimizer.step()
812
+
813
+ train_loss += loss.item()
814
+
815
+ train_batches = max(1, len(train_loader))
816
+ train_loss /= train_batches
817
+ self.train_losses_.append(train_loss)
818
+
819
+ # Validate
820
+ self.model_.eval()
821
+ val_loss = 0.0
822
+
823
+ with torch.no_grad():
824
+ for batch in val_loader:
825
+ ctx, fut, pst, tgt = batch
826
+
827
+ ctx = ctx.to(self.config.device)
828
+ fut = fut.to(self.config.device)
829
+ pst = pst.to(self.config.device) if pst is not None else None
830
+ tgt = tgt.to(self.config.device)
831
+
832
+ pred = self.model_(ctx, fut, pst)
833
+ loss = criterion(pred, tgt)
834
+ val_loss += loss.item()
835
+
836
+ val_batches = max(1, len(val_loader))
837
+ val_loss /= val_batches
838
+ self.val_losses_.append(val_loss)
839
+
840
+ if (epoch + 1) % 10 == 0:
841
+ logger.info(f"Epoch {epoch+1}: Train={train_loss:.4f}, Val={val_loss:.4f}")
842
+
843
+ # Early stopping. The snapshot must be a copy: state_dict() returns
844
+ # tensors that share storage with the live parameters.
845
+ if val_loss < best_val_loss:
846
+ best_val_loss = val_loss
847
+ patience_counter = 0
848
+ if self.config.restore_best_weights:
849
+ self.best_model_state_ = {
850
+ k: v.detach().clone() for k, v in self.model_.state_dict().items()
851
+ }
852
+ else:
853
+ patience_counter += 1
854
+
855
+ if patience_counter >= self.config.patience:
856
+ logger.info(f"Early stopping at epoch {epoch+1}")
857
+ break
858
+
859
+ if self.config.restore_best_weights and hasattr(self, 'best_model_state_'):
860
+ self.model_.load_state_dict(self.best_model_state_)
861
+
862
+ self.is_fitted_ = True
863
+ logger.info("Training completed")
864
+
865
+ return self
866
+
867
+ def predict(
868
+ self,
869
+ df: pd.DataFrame,
870
+ item_id: Optional[str] = None,
871
+ forecast_start_date: Optional[pd.Timestamp] = None
872
+ ) -> Union[Dict[str, np.ndarray], np.ndarray]:
873
+ """Forecast the next ``forecast_horizon`` periods for every series.
874
+
875
+ Series with at least ``n_steps_lag`` rows use their latest history;
876
+ shorter or empty histories are forecast from metadata and calendar
877
+ alone (cold start).
878
+
879
+ Args:
880
+ df: frame with the same columns as at fit time. A cold-start
881
+ series needs at least one row carrying its metadata.
882
+ item_id: forecast only this series and return its array.
883
+ forecast_start_date: first forecast period for all series;
884
+ defaults to the period after each series' last row.
885
+
886
+ Returns:
887
+ ``{item_id: array of shape (forecast_horizon,)}``, or a single
888
+ array when ``item_id`` is given.
889
+ """
890
+ if not self.is_fitted_:
891
+ raise ModelNotFittedError("Model must be fitted first")
892
+
893
+ if item_id is not None:
894
+ df = df[df[self.config.item_id_col] == item_id].copy()
895
+
896
+ # Ensure we apply the SAME categorical mapping as fit-time
897
+ df = self._ensure_numeric_context(df, learn=False)
898
+
899
+ context, future_features, past_sequences_list, _, item_ids, _ = self._create_sequences(
900
+ df, is_training=False, forecast_start_override=forecast_start_date
901
+ )
902
+
903
+ # NaN safety
904
+ context = np.nan_to_num(context, nan=0.0)
905
+ future_features = np.nan_to_num(future_features, nan=0.0)
906
+
907
+ # Transform context and future
908
+ ctx_scaled = self.context_scaler_.transform(context)
909
+ fut_scaled = self.future_scaler_.transform(
910
+ future_features.reshape(-1, future_features.shape[-1])
911
+ ).reshape(future_features.shape)
912
+
913
+ # Scale past sequences individually (handle None)
914
+ past_list = []
915
+ for pst in past_sequences_list:
916
+ if pst is None:
917
+ past_list.append(None)
918
+ else:
919
+ pst = np.nan_to_num(pst, nan=0.0)
920
+ pst_scaled = self.past_scaler_.transform(pst)
921
+ past_list.append(pst_scaled)
922
+
923
+ # Predict per-sample (preserves few-shot where available)
924
+ self.model_.eval()
925
+ predictions = []
926
+
927
+ with torch.no_grad():
928
+ for i in range(len(ctx_scaled)):
929
+ ctx = torch.as_tensor(ctx_scaled[i:i+1], dtype=torch.float32, device=self.config.device)
930
+ fut = torch.as_tensor(fut_scaled[i:i+1], dtype=torch.float32, device=self.config.device)
931
+
932
+ if past_list[i] is not None:
933
+ pst = torch.as_tensor(past_list[i][None, ...], dtype=torch.float32, device=self.config.device)
934
+ else:
935
+ pst = None
936
+
937
+ pred = self.model_(ctx, fut, pst)
938
+ predictions.append(pred.cpu().numpy())
939
+
940
+ predictions = np.vstack(predictions)
941
+
942
+ # Inverse transform with flatten/reshape
943
+ predictions = self.target_scaler_.inverse_transform(
944
+ predictions.reshape(-1, 1)
945
+ ).reshape(predictions.shape)
946
+
947
+ if item_id is not None:
948
+ return predictions[0]
949
+ else:
950
+ return {str(iid): pred for iid, pred in zip(item_ids, predictions)}
951
+
952
+ def save(self, path: str) -> None:
953
+ """Save weights, config, scalers and learned categories to ``path``."""
954
+ if not self.is_fitted_ or self.model_ is None:
955
+ raise ModelNotFittedError("Model must be fitted before saving")
956
+ payload = {
957
+ 'state_dict': self.model_.state_dict(),
958
+ 'config': self.config,
959
+ 'context_scaler': self.context_scaler_,
960
+ 'target_scaler': self.target_scaler_,
961
+ 'past_scaler': self.past_scaler_,
962
+ 'future_scaler': self.future_scaler_,
963
+ 'context_categories': self._context_categories,
964
+ }
965
+ torch.save(payload, path)
966
+ logger.info(f"Saved MetaFiLM forecaster to {path}")
967
+
968
+ @classmethod
969
+ def load(cls, path: str, map_location: Optional[str] = 'cpu') -> "MetaFiLMForecaster":
970
+ """Load a forecaster written by :meth:`save`.
971
+
972
+ The file is a pickle (config and fitted scalers), so only load files
973
+ you trust.
974
+ """
975
+ payload = torch.load(path, map_location=map_location, weights_only=False)
976
+ config: MetaFiLMConfig = payload['config']
977
+ model = cls(config)
978
+ model._context_categories = payload.get('context_categories', {})
979
+ model.context_scaler_ = payload['context_scaler']
980
+ model.target_scaler_ = payload['target_scaler']
981
+ model.past_scaler_ = payload['past_scaler']
982
+ model.future_scaler_ = payload['future_scaler']
983
+ # Initialize network with correct dims
984
+ past_dim = int(model.past_scaler_.mean_.shape[0])
985
+ future_dim = int(model.future_scaler_.mean_.shape[0])
986
+ context_dim = int(model.context_scaler_.mean_.shape[0])
987
+ model.model_ = MetaFiLMModel(
988
+ past_feature_dim=past_dim,
989
+ future_feature_dim=future_dim,
990
+ context_dim=context_dim,
991
+ hidden_dim=config.hidden_dim,
992
+ num_lstm_layers=config.num_lstm_layers,
993
+ forecast_horizon=config.forecast_horizon,
994
+ dropout=config.dropout,
995
+ use_residual=getattr(config, "use_residual", True),
996
+ film_mode=getattr(config, "film_mode", "bounded"),
997
+ film_init=getattr(config, "film_init", "default"),
998
+ use_level=getattr(config, "use_level", True),
999
+ use_history=getattr(config, "use_history", True)
1000
+ ).to(config.device)
1001
+ model.model_.load_state_dict(payload['state_dict'])
1002
+ model.is_fitted_ = True
1003
+ logger.info(f"Loaded MetaFiLM forecaster from {path}")
1004
+ return model
1005
+
1006
+ def get_params(self, deep: bool = True) -> Dict[str, Any]:
1007
+ return {"config": self.config}
1008
+
1009
+ def set_params(self, **params) -> 'MetaFiLMForecaster':
1010
+ if "config" in params:
1011
+ self.config = params["config"]
1012
+ self.is_fitted_ = False
1013
+ return self
metafilm/py.typed ADDED
File without changes
@@ -0,0 +1,235 @@
1
+ Metadata-Version: 2.4
2
+ Name: metafilm
3
+ Version: 0.1.0
4
+ Summary: Metadata-conditioned FiLM modulation for cold-start demand forecasting
5
+ Project-URL: Homepage, https://github.com/FH-Prevail/MetaFilm
6
+ Project-URL: Repository, https://github.com/FH-Prevail/MetaFilm
7
+ Project-URL: Issues, https://github.com/FH-Prevail/MetaFilm/issues
8
+ Project-URL: Changelog, https://github.com/FH-Prevail/MetaFilm/blob/main/CHANGELOG.md
9
+ Author: Taha Falatouri, Patrick Brandtner, Mehran Nasseri, Zuzana Kominkova Oplatkova
10
+ Author-email: Sina Mirshahi <sina.mirshahi@fh-steyr.at>
11
+ Maintainer-email: Sina Mirshahi <sina.mirshahi@fh-steyr.at>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: FiLM,cold start,deep learning,demand forecasting,feature-wise linear modulation,metadata,pytorch,retail,supply chain,time series
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: Science/Research
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
26
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.10
29
+ Requires-Dist: numpy>=1.23
30
+ Requires-Dist: pandas>=1.5
31
+ Requires-Dist: scikit-learn>=1.1
32
+ Requires-Dist: torch>=2.0
33
+ Provides-Extra: dev
34
+ Requires-Dist: build>=1.0; extra == 'dev'
35
+ Requires-Dist: pytest>=7; extra == 'dev'
36
+ Requires-Dist: twine>=6.1; extra == 'dev'
37
+ Provides-Extra: experiments
38
+ Requires-Dist: pyarrow>=10; extra == 'experiments'
39
+ Requires-Dist: xgboost>=1.7; extra == 'experiments'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # MetaFiLM
43
+
44
+ [![PyPI](https://img.shields.io/pypi/v/metafilm.svg)](https://pypi.org/project/metafilm/)
45
+ [![Python](https://img.shields.io/pypi/pyversions/metafilm.svg)](https://pypi.org/project/metafilm/)
46
+ [![Tests](https://github.com/FH-Prevail/MetaFilm/actions/workflows/tests.yml/badge.svg)](https://github.com/FH-Prevail/MetaFilm/actions/workflows/tests.yml)
47
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/FH-Prevail/MetaFilm/blob/main/LICENSE)
48
+
49
+ **Metadata-conditioned FiLM modulation for cold-start demand forecasting.**
50
+
51
+ MetaFiLM forecasts demand for products, stores and item-location pairs that a
52
+ model has never seen, by conditioning a compact neural forecaster on the
53
+ metadata that is known at launch: category, department, store, location. It is
54
+ the reference implementation of the paper *MetaFiLM: Metadata-Conditioned FiLM
55
+ Modulation for Cold-Start Demand Forecasting* (CENTERIS 2026).
56
+
57
+ - **Two metadata pathways.** A level head sets the demand scale; a bounded
58
+ FiLM block modulates how the forecaster reads the calendar and history.
59
+ - **Built for cold start.** Forecasts new series from a short history, or from
60
+ metadata alone. Unseen category values map to a reserved "unknown" code.
61
+ - **Small and fast.** 78,722 parameters in the paper's company configuration;
62
+ trains and serves on a CPU, about 50 microseconds per series in batches on
63
+ one thread.
64
+ - **Familiar API.** `fit`, `predict`, `save` and `load` on a long-format
65
+ pandas frame.
66
+
67
+ ## Installation
68
+
69
+ ```bash
70
+ pip install metafilm
71
+ ```
72
+
73
+ Requires Python 3.10 or newer and PyTorch 2.0 or newer. If you need a specific
74
+ PyTorch build (CPU-only or a particular CUDA version), install it first as
75
+ described on [pytorch.org](https://pytorch.org/get-started/locally/).
76
+
77
+ ## Quickstart
78
+
79
+ The input is a long-format frame with one row per series and period: an id, a
80
+ date, the target and the metadata columns (constant within a series).
81
+
82
+ ```python
83
+ import pandas as pd
84
+ from metafilm import MetaFiLMConfig, MetaFiLMForecaster
85
+
86
+ config = MetaFiLMConfig(
87
+ n_steps_lag=8, # history window
88
+ forecast_horizon=4, # steps forecast at once
89
+ context_features=["category", "store"],
90
+ item_id_col="item", date_col="week", target_col="sales",
91
+ freq="W-SUN", # weekly; use "MS" for monthly data
92
+ )
93
+
94
+ model = MetaFiLMForecaster(config).fit(train_df)
95
+
96
+ forecasts = model.predict(new_items_df) # {item: array of 4 forecasts}
97
+ model.save("metafilm.pt")
98
+ model = MetaFiLMForecaster.load("metafilm.pt")
99
+ ```
100
+
101
+ A series with at least `n_steps_lag` rows is forecast from its latest history;
102
+ a shorter one from its metadata and the calendar. For items with **no sales
103
+ history at all**, train with `use_history=False` so the model learns to forecast
104
+ from metadata alone. [`examples/quickstart.py`](https://github.com/FH-Prevail/MetaFilm/blob/main/examples/quickstart.py)
105
+ runs both cases end to end on synthetic data:
106
+
107
+ ```text
108
+ New items, 8 weeks of history (MAE 1.12)
109
+ item50 forecast [30.9 28.7 26.3 25.4] actual [34.4 27.5 27.9 25.4]
110
+ item52 forecast [9.5 7.3 4.9 4. ] actual [8.8 7.1 5.5 6.8]
111
+ New items, metadata only (MAE 1.42)
112
+ item50 forecast [32.5 31.1 27.1 26.7] actual [34.4 27.5 27.9 25.4]
113
+ item52 forecast [11. 9.6 5.6 5.2] actual [8.8 7.1 5.5 6.8]
114
+ ```
115
+
116
+ ## How it works
117
+
118
+ ![MetaFiLM architecture](https://raw.githubusercontent.com/FH-Prevail/MetaFilm/main/docs/architecture.png)
119
+
120
+ For a series with metadata `c`, recent history `y` and future calendar
121
+ features `phi_k` (k = 1 ... H):
122
+
123
+ ```text
124
+ level l(c) = MLP(c) base demand level
125
+ history h_enc = LSTM_2(y, calendar)[final state] zero if no history
126
+ decoder d_k = LSTM_1(phi_k), zero initial state
127
+ fusion g_k = d_k + 0.3 * h_enc
128
+ FiLM z(c) = ReLU(W_1 c)
129
+ gamma = 1 + 0.1 * tanh(W_g z(c)), beta = 0.1 * tanh(W_b z(c))
130
+ h_k = gamma * g_k + beta + 0.5 * g_k
131
+ output y_hat_k = w . h_k + b + l(c)
132
+ ```
133
+
134
+ FiLM (Feature-wise Linear Modulation) comes from computer vision, where a
135
+ question about an image scales and shifts the image network's features
136
+ ([Perez et al., AAAI 2018](https://doi.org/10.1609/aaai.v32i1.11671)). MetaFiLM
137
+ applies the same mechanism with product metadata in place of the question. The
138
+ modulation is bounded to stay close to the identity, so conditioning starts as
139
+ almost no change and grows only where training supports it.
140
+
141
+ ## Configuration
142
+
143
+ `MetaFiLMConfig` fields, with defaults:
144
+
145
+ | Field | Default | Meaning |
146
+ |---|---|---|
147
+ | `n_steps_lag` | 12 | history window fed to the encoder |
148
+ | `forecast_horizon` | 12 | steps forecast at once |
149
+ | `context_features` | `[]` | metadata columns (categorical or numeric) |
150
+ | `temporal_features` | `[]` | optional extra per-step inputs for the encoder |
151
+ | `item_id_col`, `date_col`, `target_col` | `compoundkey`, `orderweek_dt`, `quantity` | column names |
152
+ | `freq` | `"W-SUN"` | pandas frequency of the series (`"MS"` for monthly) |
153
+ | `hidden_dim`, `num_lstm_layers`, `dropout` | 128, 2, 0.3 | network size |
154
+ | `loss` | `"mse"` | training loss: `"mse"`, `"l1"` (absolute error) or `"huber"` |
155
+ | `film_mode` | `"bounded"` | `"bounded"`, `"unbounded"`, `"concat"` or `"off"` |
156
+ | `use_level`, `use_history`, `use_residual` | `True` | switch individual pathways off |
157
+ | `batch_size`, `learning_rate`, `weight_decay` | 32, 0.001, 0.01 | optimiser settings |
158
+ | `num_epochs`, `patience` | 100, 10 | training length and early stopping |
159
+ | `restore_best_weights` | `True` | keep the best-validation epoch after early stopping |
160
+ | `random_seed`, `device` | 42, CUDA if available | reproducibility and placement |
161
+
162
+ **Match the loss to how you are measured.** If forecasts are judged by mean
163
+ absolute error, train with `loss="l1"` or `loss="huber"`. In the paper this
164
+ lowered cold-start MAE by up to about 5% and moved accuracy more reliably than
165
+ any architectural change; `"huber"` is the more balanced choice for
166
+ intermittent demand, because `"l1"` tends to under-forecast.
167
+
168
+ ## Results from the paper
169
+
170
+ Cold-start MAE on series held out from training, mean ± standard deviation over
171
+ five seeds (lower is better):
172
+
173
+ | Method | M5 weekly (Walmart) | Company partner | Monash CarParts |
174
+ |---|---|---|---|
175
+ | Zero forecast | 61.33 ± 6.13 | 124.75 ± 9.11 | 0.364 ± 0.019 |
176
+ | Persistence | 20.69 ± 2.21 | 95.95 ± 6.68 | 0.512 ± 0.019 |
177
+ | Ridge | 19.19 ± 1.85 | 87.18 ± 5.07 | 0.439 ± 0.025 |
178
+ | XGBoost (global) | 20.69 ± 2.34 | 80.97 ± 5.97 | 0.387 ± 0.020 |
179
+ | **MetaFiLM** | 19.64 ± 2.20 | **79.71 ± 4.37** | **0.377 ± 0.021** |
180
+
181
+ MetaFiLM has the lowest error of all learned models on the company and CarParts
182
+ panels, and is on par with the strongest baseline on M5 and the company panel
183
+ (paired tests over five seeds: p = .172 against Ridge, p = .328 against
184
+ XGBoost). On CarParts, where 81% of the test values are zero, the constant zero
185
+ forecast is hard to beat under squared-error training; trained with the Huber
186
+ loss, MetaFiLM reaches 0.359.
187
+
188
+ The benchmarks: M5 weekly (1,000 Walmart item-store series, 150 held out,
189
+ horizon 4), an industrial company-partner panel (3,000 item-location pairs,
190
+ monthly, horizon 6; proprietary and not distributed) and Monash CarParts
191
+ (2,674 intermittent monthly series, horizon 6, no categorical metadata, used
192
+ as a control).
193
+
194
+ ## Reproducing the paper
195
+
196
+ The experiment scripts, the CarParts data and the results for the public
197
+ benchmarks are in this repository. See
198
+ [docs/reproducing.md](https://github.com/FH-Prevail/MetaFilm/blob/main/docs/reproducing.md).
199
+
200
+ ## Citation
201
+
202
+ If you use MetaFiLM, please cite:
203
+
204
+ ```bibtex
205
+ @inproceedings{mirshahi2026metafilm,
206
+ title = {{MetaFiLM}: Metadata-Conditioned {FiLM} Modulation for Cold-Start Demand Forecasting},
207
+ author = {Mirshahi, Sina and Falatouri, Taha and Brandtner, Patrick and Nasseri, Mehran and Kominkova Oplatkova, Zuzana},
208
+ booktitle = {CENTERIS 2026: International Conference on ENTERprise Information Systems},
209
+ series = {Procedia Computer Science},
210
+ year = {2026},
211
+ note = {To appear}
212
+ }
213
+ ```
214
+
215
+ GitHub also offers this citation through "Cite this repository"
216
+ ([CITATION.cff](https://github.com/FH-Prevail/MetaFilm/blob/main/CITATION.cff)).
217
+
218
+ ## Authors and acknowledgements
219
+
220
+ - Sina Mirshahi: University of Applied Sciences Upper Austria (Steyr), Josef
221
+ Ressel Centre for Predictive Value Network Intelligence, and Tomas Bata
222
+ University in Zlin
223
+ - Taha Falatouri, Patrick Brandtner and Mehran Nasseri: University of Applied
224
+ Sciences Upper Austria (Steyr) and Josef Ressel Centre for Predictive Value
225
+ Network Intelligence
226
+ - Zuzana Kominkova Oplatkova: Tomas Bata University in Zlin
227
+
228
+ This research was funded by the Christian Doppler Research Association as part
229
+ of the Josef Ressel Centre for Predictive Value Network Intelligence (JRC
230
+ PREVAIL), and partially supported by the Internal Grant Agency of Tomas Bata
231
+ University in Zlin under project number IGA/CebiaTech/2026/004.
232
+
233
+ ## License
234
+
235
+ MIT. See [LICENSE](https://github.com/FH-Prevail/MetaFilm/blob/main/LICENSE).
@@ -0,0 +1,7 @@
1
+ metafilm/__init__.py,sha256=5rou-JxjcCImqwjHcyNNelHjiiv38ZAUl31DdcY027Y,680
2
+ metafilm/forecaster.py,sha256=wdqtTuK5QoNDCfCixpnLcCLyOt5HLRf2-seVt3ALnSI,40059
3
+ metafilm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ metafilm-0.1.0.dist-info/METADATA,sha256=PuYkVkK95JSl-15iDQ0cHZsGKVLefbq3VXyc40YsiVY,11010
5
+ metafilm-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
+ metafilm-0.1.0.dist-info/licenses/LICENSE,sha256=EzOUWp2i2fP9svMSAT9JvvyeYYkGcKZoQn1HJwF1zic,1152
7
+ metafilm-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sina Mirshahi, Taha Falatouri, Patrick Brandtner, Mehran Nasseri and Zuzana Kominkova Oplatkova
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.