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 +29 -0
- metafilm/forecaster.py +1013 -0
- metafilm/py.typed +0 -0
- metafilm-0.1.0.dist-info/METADATA +235 -0
- metafilm-0.1.0.dist-info/RECORD +7 -0
- metafilm-0.1.0.dist-info/WHEEL +4 -0
- metafilm-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
[](https://pypi.org/project/metafilm/)
|
|
45
|
+
[](https://pypi.org/project/metafilm/)
|
|
46
|
+
[](https://github.com/FH-Prevail/MetaFilm/actions/workflows/tests.yml)
|
|
47
|
+
[](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
|
+

|
|
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,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.
|