icdn 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.
- icdn/__init__.py +16 -0
- icdn/api.py +370 -0
- icdn/cli.py +65 -0
- icdn/config.py +144 -0
- icdn/data/__init__.py +16 -0
- icdn/data/dataset.py +100 -0
- icdn/data/encoders.py +56 -0
- icdn/data/features.py +205 -0
- icdn/data/panel.py +228 -0
- icdn/data/splits.py +92 -0
- icdn/model/__init__.py +21 -0
- icdn/model/context.py +99 -0
- icdn/model/demand.py +99 -0
- icdn/model/head.py +169 -0
- icdn/model/icdn.py +73 -0
- icdn/model/loss.py +156 -0
- icdn/model/neighbors.py +262 -0
- icdn/model/splines/__init__.py +4 -0
- icdn/model/splines/builder.py +60 -0
- icdn/model/splines/multi_cubic.py +75 -0
- icdn/training/__init__.py +19 -0
- icdn/training/checkpoints.py +49 -0
- icdn/training/metrics.py +80 -0
- icdn/training/trainer.py +276 -0
- icdn-0.1.0.dist-info/METADATA +325 -0
- icdn-0.1.0.dist-info/RECORD +30 -0
- icdn-0.1.0.dist-info/WHEEL +5 -0
- icdn-0.1.0.dist-info/entry_points.txt +2 -0
- icdn-0.1.0.dist-info/licenses/LICENSE +201 -0
- icdn-0.1.0.dist-info/top_level.txt +1 -0
icdn/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""ICDN: demand and price elasticities from retail panels.
|
|
2
|
+
|
|
3
|
+
Typical use::
|
|
4
|
+
|
|
5
|
+
from icdn import ICDNModel, ICDNConfig, PanelSchema
|
|
6
|
+
|
|
7
|
+
model = ICDNModel(ICDNConfig(n_products=5))
|
|
8
|
+
model.fit(panel)
|
|
9
|
+
elasticities = model.elasticities()
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .api import ICDNModel
|
|
13
|
+
from .config import ICDNConfig, PanelSchema
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__all__ = ["ICDNConfig", "ICDNModel", "PanelSchema", "__version__"]
|
icdn/api.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Public interface of the ICDN library."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
from .config import ICDNConfig
|
|
10
|
+
from .data.dataset import DataLoaderFactory, MultiProductDataset
|
|
11
|
+
from .data.features import FeatureBuilder
|
|
12
|
+
from .data.panel import PanelBuilder, PanelLayout
|
|
13
|
+
from .data.splits import TemporalSplitter
|
|
14
|
+
from .model.context import ProductTokenBuilder
|
|
15
|
+
from .model.head import IntegrableDemandHead
|
|
16
|
+
from .model.icdn import ICDN
|
|
17
|
+
from .model.neighbors import ProductMetadata
|
|
18
|
+
from .model.splines import SplineBuilder
|
|
19
|
+
from .training.checkpoints import load_checkpoint, save_checkpoint
|
|
20
|
+
from .training.metrics import (
|
|
21
|
+
collect_targets,
|
|
22
|
+
predict_demand,
|
|
23
|
+
predict_elasticities,
|
|
24
|
+
regression_metrics,
|
|
25
|
+
)
|
|
26
|
+
from .training.trainer import Trainer, resolve_device
|
|
27
|
+
|
|
28
|
+
# Highest log-demand that still converts to a finite level.
|
|
29
|
+
_MAX_LOG_DEMAND = 700.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ICDNModel:
|
|
33
|
+
"""Estimates demand and price elasticities from a retail panel.
|
|
34
|
+
|
|
35
|
+
The model learns a smooth log-demand surface conditioned on context and
|
|
36
|
+
reads elasticities off its derivatives, so predictions and elasticities
|
|
37
|
+
always come from the same fitted object.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
>>> model = ICDNModel(ICDNConfig(n_products=5))
|
|
41
|
+
>>> model.fit(panel)
|
|
42
|
+
>>> elasticities = model.elasticities()
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
config: pipeline configuration. Defaults reproduce the reference setup.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, config: ICDNConfig | None = None):
|
|
49
|
+
self.config = config or ICDNConfig()
|
|
50
|
+
self.layout: PanelLayout | None = None
|
|
51
|
+
self.history: dict = {}
|
|
52
|
+
self._model: ICDN | None = None
|
|
53
|
+
self._panel_builder: PanelBuilder | None = None
|
|
54
|
+
self._device = resolve_device(self.config.device)
|
|
55
|
+
self._train_panel: pd.DataFrame | None = None
|
|
56
|
+
|
|
57
|
+
# ── Lifecycle ───────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_fitted(self) -> bool:
|
|
61
|
+
return self._model is not None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def products(self) -> list:
|
|
65
|
+
"""Modelled products, in the positional order used by every output."""
|
|
66
|
+
self._require_fitted()
|
|
67
|
+
return list(self.layout.products)
|
|
68
|
+
|
|
69
|
+
def fit(self, panel: pd.DataFrame) -> "ICDNModel":
|
|
70
|
+
"""Fits the model on a long panel of store, product and period rows.
|
|
71
|
+
|
|
72
|
+
The panel only needs identifiers, price, units and a promotional flag.
|
|
73
|
+
Lags, seasonality and competitive context are engineered internally.
|
|
74
|
+
"""
|
|
75
|
+
cfg = self.config
|
|
76
|
+
features = FeatureBuilder(cfg)
|
|
77
|
+
long_df = features.run(panel)
|
|
78
|
+
|
|
79
|
+
self._panel_builder = PanelBuilder(cfg)
|
|
80
|
+
wide = self._panel_builder.fit_transform(
|
|
81
|
+
long_df, features.shared_features, features.product_features
|
|
82
|
+
)
|
|
83
|
+
self.layout = self._panel_builder.layout
|
|
84
|
+
|
|
85
|
+
splitter = TemporalSplitter(period_col=cfg.schema.period)
|
|
86
|
+
train_wide, val_wide = splitter.single_split(wide, train_frac=1.0 - cfg.validation_fraction)
|
|
87
|
+
if val_wide.empty:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"the panel does not have enough periods to build a validation split. "
|
|
90
|
+
"Provide more history or lower validation_fraction."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
self._model = self._build_model(train_wide)
|
|
94
|
+
loaders = self._build_loaders(train_wide, val_wide)
|
|
95
|
+
|
|
96
|
+
trainer = Trainer(cfg)
|
|
97
|
+
self.history = trainer.fit(
|
|
98
|
+
self._model,
|
|
99
|
+
train_loader=loaders["train"],
|
|
100
|
+
val_loader=loaders["val"],
|
|
101
|
+
warmup_train_loader=loaders["warmup_train"],
|
|
102
|
+
warmup_val_loader=loaders["warmup_val"],
|
|
103
|
+
meta=self.product_metadata(),
|
|
104
|
+
)
|
|
105
|
+
self._train_panel = panel
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
# ── Inference ───────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
def predict(self, panel: pd.DataFrame | None = None) -> pd.DataFrame:
|
|
111
|
+
"""Predicts demand for every store, period and product.
|
|
112
|
+
|
|
113
|
+
Returns a long frame with the identifier columns of your schema plus
|
|
114
|
+
``predicted_demand``, ``predicted_log_demand`` and, where available,
|
|
115
|
+
the observed demand.
|
|
116
|
+
"""
|
|
117
|
+
wide, loader = self._prepare(panel)
|
|
118
|
+
y_hat = predict_demand(self._model, loader, self._device, self.product_metadata())
|
|
119
|
+
y_true, mask = collect_targets(loader)
|
|
120
|
+
|
|
121
|
+
frame = self._melt(
|
|
122
|
+
wide,
|
|
123
|
+
{"predicted_log_demand": y_hat, "log_demand": y_true, "observed": mask},
|
|
124
|
+
)
|
|
125
|
+
frame["predicted_demand"] = self._to_levels(frame["predicted_log_demand"])
|
|
126
|
+
frame["demand"] = self._to_levels(frame["log_demand"]).where(frame["observed"] > 0)
|
|
127
|
+
frame = frame.drop(columns=["observed"])
|
|
128
|
+
return frame
|
|
129
|
+
|
|
130
|
+
def elasticities(
|
|
131
|
+
self,
|
|
132
|
+
panel: pd.DataFrame | None = None,
|
|
133
|
+
aggregate: bool = True,
|
|
134
|
+
) -> pd.DataFrame:
|
|
135
|
+
"""Own- and cross-price elasticities.
|
|
136
|
+
|
|
137
|
+
Each row reports how the demand of ``product`` responds to the price of
|
|
138
|
+
``competitor``. Rows where both coincide are own-price elasticities.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
panel: data to evaluate. Defaults to the training panel.
|
|
142
|
+
aggregate: when True, summarises each store and product pair with
|
|
143
|
+
its mean, dispersion and a 95% interval across periods. When
|
|
144
|
+
False, returns one row per observation.
|
|
145
|
+
"""
|
|
146
|
+
wide, loader = self._prepare(panel)
|
|
147
|
+
E = predict_elasticities(self._model, loader, self._device, self.product_metadata())
|
|
148
|
+
_, mask = collect_targets(loader)
|
|
149
|
+
|
|
150
|
+
rows = self._elasticity_rows(wide, E, mask)
|
|
151
|
+
if not aggregate:
|
|
152
|
+
return rows
|
|
153
|
+
|
|
154
|
+
schema = self.config.schema
|
|
155
|
+
grouped = rows.groupby([schema.store, "product", "competitor", "kind"], observed=True)
|
|
156
|
+
summary = grouped["elasticity"].agg(
|
|
157
|
+
elasticity="mean",
|
|
158
|
+
std="std",
|
|
159
|
+
ci_low=lambda s: s.quantile(0.025),
|
|
160
|
+
ci_high=lambda s: s.quantile(0.975),
|
|
161
|
+
n_obs="size",
|
|
162
|
+
)
|
|
163
|
+
return summary.reset_index()
|
|
164
|
+
|
|
165
|
+
def evaluate(self, panel: pd.DataFrame | None = None) -> dict[str, float]:
|
|
166
|
+
"""Masked MAE, RMSE and R2 of log-demand on the given panel."""
|
|
167
|
+
_, loader = self._prepare(panel)
|
|
168
|
+
y_hat = predict_demand(self._model, loader, self._device, self.product_metadata())
|
|
169
|
+
y_true, mask = collect_targets(loader)
|
|
170
|
+
return regression_metrics(y_true, y_hat, mask)
|
|
171
|
+
|
|
172
|
+
# ── Persistence ─────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
def save(self, path: str | Path) -> Path:
|
|
175
|
+
"""Writes weights, configuration, layout and encoders to a single file."""
|
|
176
|
+
self._require_fitted()
|
|
177
|
+
return save_checkpoint(path, self._model, self.config, self.layout)
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def load(cls, path: str | Path) -> "ICDNModel":
|
|
181
|
+
"""Restores a model saved with :meth:`save`."""
|
|
182
|
+
payload = load_checkpoint(path)
|
|
183
|
+
|
|
184
|
+
model = cls(ICDNConfig.from_dict(payload["config"]))
|
|
185
|
+
model.layout = PanelLayout.from_dict(payload["layout"])
|
|
186
|
+
model._panel_builder = PanelBuilder(model.config)
|
|
187
|
+
model._panel_builder.layout = model.layout
|
|
188
|
+
|
|
189
|
+
network = model._instantiate(spline_prices=None)
|
|
190
|
+
selector = network.head.neighbor_selector
|
|
191
|
+
if selector is not None and payload.get("frozen_pairs") is not None:
|
|
192
|
+
selector.set_frozen_graph(payload["frozen_pairs"], payload["frozen_edge_bonus"])
|
|
193
|
+
network.load_state_dict(payload["state_dict"])
|
|
194
|
+
model._model = network.to(model._device)
|
|
195
|
+
return model
|
|
196
|
+
|
|
197
|
+
# ── Internals ───────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def product_metadata(self) -> ProductMetadata:
|
|
200
|
+
"""Static product attributes that bias competitor selection."""
|
|
201
|
+
if self.layout is None:
|
|
202
|
+
raise RuntimeError("the panel layout is unknown, call fit() or load() first")
|
|
203
|
+
layout = self.layout
|
|
204
|
+
|
|
205
|
+
def as_long(values):
|
|
206
|
+
return None if values is None else torch.tensor(values, dtype=torch.long)
|
|
207
|
+
|
|
208
|
+
return ProductMetadata(
|
|
209
|
+
category=as_long(layout.category_codes),
|
|
210
|
+
brand=as_long(layout.brand_codes),
|
|
211
|
+
style=as_long(layout.style_codes),
|
|
212
|
+
size=None if layout.sizes is None else torch.tensor(layout.sizes, dtype=torch.float32),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _build_model(self, train_wide: pd.DataFrame) -> ICDN:
|
|
216
|
+
n = self.layout.n_products
|
|
217
|
+
prices = train_wide[[f"log_price_{i}" for i in range(n)]].to_numpy()
|
|
218
|
+
return self._instantiate(spline_prices=prices).to(self._device)
|
|
219
|
+
|
|
220
|
+
def _instantiate(self, spline_prices: np.ndarray | None) -> ICDN:
|
|
221
|
+
cfg, layout = self.config, self.layout
|
|
222
|
+
n = layout.n_products
|
|
223
|
+
|
|
224
|
+
# When rebuilding from a checkpoint the knots are placeholders: the
|
|
225
|
+
# saved buffers overwrite them on load_state_dict.
|
|
226
|
+
prices = spline_prices if spline_prices is not None else np.zeros((2, n))
|
|
227
|
+
splines = SplineBuilder().build_basis(prices, n_knots=cfg.n_knots)
|
|
228
|
+
|
|
229
|
+
tokens = ProductTokenBuilder(
|
|
230
|
+
n=n,
|
|
231
|
+
n_stores=layout.n_stores,
|
|
232
|
+
n_shared_features=len(layout.shared_features),
|
|
233
|
+
n_product_features=len(layout.product_features),
|
|
234
|
+
d_store=cfg.d_store,
|
|
235
|
+
n_brands=layout.n_brands,
|
|
236
|
+
d_brand=cfg.d_brand,
|
|
237
|
+
n_styles=layout.n_styles,
|
|
238
|
+
d_style=cfg.d_style,
|
|
239
|
+
)
|
|
240
|
+
head = IntegrableDemandHead(
|
|
241
|
+
context_dim=tokens.d_token,
|
|
242
|
+
K_splines=cfg.n_knots,
|
|
243
|
+
n=n,
|
|
244
|
+
k_neighbors=cfg.k_neighbors,
|
|
245
|
+
hidden=cfg.hidden,
|
|
246
|
+
act=cfg.activation,
|
|
247
|
+
dropout=cfg.dropout,
|
|
248
|
+
enforce_negative_beta=True,
|
|
249
|
+
use_cross=cfg.use_cross,
|
|
250
|
+
)
|
|
251
|
+
return ICDN(context_builder=tokens, price_splines=splines, head=head, n=n)
|
|
252
|
+
|
|
253
|
+
def _build_loaders(self, train_wide: pd.DataFrame, val_wide: pd.DataFrame) -> dict:
|
|
254
|
+
cfg = self.config
|
|
255
|
+
factory = DataLoaderFactory(
|
|
256
|
+
batch_size=cfg.batch_size,
|
|
257
|
+
num_workers=cfg.num_workers,
|
|
258
|
+
pin_memory=self._device.type == "cuda",
|
|
259
|
+
)
|
|
260
|
+
period = cfg.schema.period
|
|
261
|
+
|
|
262
|
+
def dataset(frame: pd.DataFrame) -> MultiProductDataset:
|
|
263
|
+
return MultiProductDataset(frame, self.layout, period_col=period)
|
|
264
|
+
|
|
265
|
+
loaders = {
|
|
266
|
+
"train": factory.train(dataset(train_wide)),
|
|
267
|
+
"val": factory.evaluate(dataset(val_wide)),
|
|
268
|
+
"warmup_train": None,
|
|
269
|
+
"warmup_val": None,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if cfg.warmup_epochs > 0:
|
|
273
|
+
loaders["warmup_train"] = factory.train(dataset(self._smooth(train_wide)))
|
|
274
|
+
loaders["warmup_val"] = factory.evaluate(dataset(self._smooth(val_wide)))
|
|
275
|
+
|
|
276
|
+
return loaders
|
|
277
|
+
|
|
278
|
+
def _smooth(self, wide: pd.DataFrame) -> pd.DataFrame:
|
|
279
|
+
"""Replaces demand by a trailing moving average for the warm-up phase."""
|
|
280
|
+
cfg = self.config
|
|
281
|
+
smoothed = wide.copy()
|
|
282
|
+
columns = [f"log_demand_{i}" for i in range(self.layout.n_products)]
|
|
283
|
+
grouped = smoothed.groupby(cfg.schema.store, observed=True)[columns]
|
|
284
|
+
smoothed[columns] = grouped.transform(
|
|
285
|
+
lambda s: s.rolling(cfg.smoothing_window, min_periods=1).mean()
|
|
286
|
+
)
|
|
287
|
+
return smoothed
|
|
288
|
+
|
|
289
|
+
def _prepare(self, panel: pd.DataFrame | None):
|
|
290
|
+
self._require_fitted()
|
|
291
|
+
cfg = self.config
|
|
292
|
+
panel = self._train_panel if panel is None else panel
|
|
293
|
+
if panel is None:
|
|
294
|
+
raise ValueError(
|
|
295
|
+
"no panel supplied and the training panel is unavailable "
|
|
296
|
+
"(it is not stored inside checkpoints). Pass the data explicitly."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
features = FeatureBuilder(cfg)
|
|
300
|
+
wide = self._panel_builder.transform(features.run(panel))
|
|
301
|
+
dataset = MultiProductDataset(wide, self.layout, period_col=cfg.schema.period)
|
|
302
|
+
factory = DataLoaderFactory(
|
|
303
|
+
batch_size=cfg.batch_size,
|
|
304
|
+
num_workers=cfg.num_workers,
|
|
305
|
+
pin_memory=self._device.type == "cuda",
|
|
306
|
+
)
|
|
307
|
+
return wide, factory.evaluate(dataset)
|
|
308
|
+
|
|
309
|
+
def _melt(self, wide: pd.DataFrame, matrices: dict[str, np.ndarray]) -> pd.DataFrame:
|
|
310
|
+
schema = self.config.schema
|
|
311
|
+
products = self.layout.products
|
|
312
|
+
frames = []
|
|
313
|
+
for i, product in enumerate(products):
|
|
314
|
+
block = wide[[schema.store, schema.period]].copy()
|
|
315
|
+
block["product"] = product
|
|
316
|
+
for name, values in matrices.items():
|
|
317
|
+
block[name] = values[:, i]
|
|
318
|
+
frames.append(block)
|
|
319
|
+
return pd.concat(frames, ignore_index=True).sort_values(
|
|
320
|
+
[schema.store, schema.period, "product"], kind="stable"
|
|
321
|
+
).reset_index(drop=True)
|
|
322
|
+
|
|
323
|
+
def _elasticity_rows(
|
|
324
|
+
self,
|
|
325
|
+
wide: pd.DataFrame,
|
|
326
|
+
E: np.ndarray,
|
|
327
|
+
mask: np.ndarray,
|
|
328
|
+
) -> pd.DataFrame:
|
|
329
|
+
schema = self.config.schema
|
|
330
|
+
products = self.layout.products
|
|
331
|
+
n = len(products)
|
|
332
|
+
|
|
333
|
+
selector = self._model.head.neighbor_selector
|
|
334
|
+
if selector is not None and selector.frozen_pairs is not None:
|
|
335
|
+
pairs = selector.frozen_pairs.cpu().numpy()
|
|
336
|
+
cross = list(zip(pairs[0].tolist(), pairs[1].tolist(), strict=True))
|
|
337
|
+
else:
|
|
338
|
+
cross = [(i, j) for i in range(n) for j in range(n) if i != j]
|
|
339
|
+
|
|
340
|
+
entries = [(i, i, "own") for i in range(n)] + [(i, j, "cross") for i, j in cross]
|
|
341
|
+
|
|
342
|
+
frames = []
|
|
343
|
+
for i, j, kind in entries:
|
|
344
|
+
observed = (mask[:, i] > 0) & (mask[:, j] > 0)
|
|
345
|
+
if not observed.any():
|
|
346
|
+
continue
|
|
347
|
+
block = wide.loc[observed, [schema.store, schema.period]].copy()
|
|
348
|
+
block["product"] = products[i]
|
|
349
|
+
block["competitor"] = products[j]
|
|
350
|
+
block["kind"] = kind
|
|
351
|
+
block["elasticity"] = E[observed, i, j]
|
|
352
|
+
frames.append(block)
|
|
353
|
+
|
|
354
|
+
if not frames:
|
|
355
|
+
return pd.DataFrame(
|
|
356
|
+
columns=[schema.store, schema.period, "product", "competitor", "kind", "elasticity"]
|
|
357
|
+
)
|
|
358
|
+
return pd.concat(frames, ignore_index=True)
|
|
359
|
+
|
|
360
|
+
def _to_levels(self, log_values: pd.Series) -> pd.Series:
|
|
361
|
+
if self.config.schema.values_are_log:
|
|
362
|
+
return log_values
|
|
363
|
+
# Clipping keeps the column finite and non-negative even when an
|
|
364
|
+
# undertrained model emits extreme values.
|
|
365
|
+
levels = np.expm1(log_values.astype("float64").clip(upper=_MAX_LOG_DEMAND))
|
|
366
|
+
return levels.clip(lower=0.0)
|
|
367
|
+
|
|
368
|
+
def _require_fitted(self) -> None:
|
|
369
|
+
if self._model is None:
|
|
370
|
+
raise RuntimeError("the model is not fitted yet, call fit() or load() first")
|
icdn/cli.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Command line interface: ``icdn fit | predict | elasticities``."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from .api import ICDNModel
|
|
10
|
+
from .config import ICDNConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> int:
|
|
14
|
+
parser = argparse.ArgumentParser(prog="icdn", description=__doc__)
|
|
15
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
16
|
+
|
|
17
|
+
fit = subcommands.add_parser("fit", help="train a model and save it")
|
|
18
|
+
fit.add_argument("--data", required=True, help="panel in csv or parquet format")
|
|
19
|
+
fit.add_argument("--config", help="yaml configuration file")
|
|
20
|
+
fit.add_argument("--out", required=True, help="destination of the trained model")
|
|
21
|
+
|
|
22
|
+
for name, help_text in [
|
|
23
|
+
("predict", "predict demand with a trained model"),
|
|
24
|
+
("elasticities", "estimate elasticities with a trained model"),
|
|
25
|
+
]:
|
|
26
|
+
command = subcommands.add_parser(name, help=help_text)
|
|
27
|
+
command.add_argument("--model", required=True, help="path to a saved model")
|
|
28
|
+
command.add_argument("--data", required=True, help="panel in csv or parquet format")
|
|
29
|
+
command.add_argument("--out", required=True, help="destination csv or parquet file")
|
|
30
|
+
|
|
31
|
+
args = parser.parse_args(argv)
|
|
32
|
+
panel = read_table(args.data)
|
|
33
|
+
|
|
34
|
+
if args.command == "fit":
|
|
35
|
+
config = ICDNConfig.from_yaml(args.config) if args.config else ICDNConfig()
|
|
36
|
+
model = ICDNModel(config).fit(panel)
|
|
37
|
+
path = model.save(args.out)
|
|
38
|
+
print(f"model saved to {path}")
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
model = ICDNModel.load(args.model)
|
|
42
|
+
result = model.predict(panel) if args.command == "predict" else model.elasticities(panel)
|
|
43
|
+
write_table(result, args.out)
|
|
44
|
+
print(f"{len(result)} rows written to {args.out}")
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_table(path: str) -> pd.DataFrame:
|
|
49
|
+
suffix = Path(path).suffix.lower()
|
|
50
|
+
if suffix == ".parquet":
|
|
51
|
+
return pd.read_parquet(path)
|
|
52
|
+
if suffix in {".csv", ".txt"}:
|
|
53
|
+
return pd.read_csv(path)
|
|
54
|
+
raise ValueError(f"unsupported input format '{suffix}', use csv or parquet")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_table(frame: pd.DataFrame, path: str) -> None:
|
|
58
|
+
if Path(path).suffix.lower() == ".parquet":
|
|
59
|
+
frame.to_parquet(path, index=False)
|
|
60
|
+
else:
|
|
61
|
+
frame.to_csv(path, index=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
sys.exit(main())
|
icdn/config.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""User-facing configuration for the ICDN pipeline."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class PanelSchema:
|
|
10
|
+
"""Maps the columns of your panel to the roles the model expects.
|
|
11
|
+
|
|
12
|
+
The panel is a long table with one row per store, product and period. Only
|
|
13
|
+
the first six fields are required; the rest enrich competitor selection and
|
|
14
|
+
can be left as None.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
store: store or point-of-sale identifier.
|
|
18
|
+
product: product identifier.
|
|
19
|
+
period: period identifier, expected to be sortable (e.g. a week index).
|
|
20
|
+
price: unit price. Set ``values_are_log`` when it is already logged.
|
|
21
|
+
units: units sold, on the same basis as the price.
|
|
22
|
+
promo: binary promotional flag.
|
|
23
|
+
category: products only compete inside the same category.
|
|
24
|
+
brand: brand identifier, biases competitor selection.
|
|
25
|
+
style: sub-segment identifier, biases competitor selection.
|
|
26
|
+
size: pack size, biases competitor selection toward similar formats.
|
|
27
|
+
values_are_log: True when price and units are already in log space.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
store: str = "store_code"
|
|
31
|
+
product: str = "product_code"
|
|
32
|
+
period: str = "week_id"
|
|
33
|
+
price: str = "price"
|
|
34
|
+
units: str = "units"
|
|
35
|
+
promo: str = "on_promo"
|
|
36
|
+
category: str | None = None
|
|
37
|
+
brand: str | None = None
|
|
38
|
+
style: str | None = None
|
|
39
|
+
size: str | None = None
|
|
40
|
+
values_are_log: bool = False
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def required(self) -> list[str]:
|
|
44
|
+
return [self.store, self.product, self.period, self.price, self.units, self.promo]
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def optional(self) -> dict[str, str]:
|
|
48
|
+
candidates = {
|
|
49
|
+
"category": self.category,
|
|
50
|
+
"brand": self.brand,
|
|
51
|
+
"style": self.style,
|
|
52
|
+
"size": self.size,
|
|
53
|
+
}
|
|
54
|
+
return {role: col for role, col in candidates.items() if col is not None}
|
|
55
|
+
|
|
56
|
+
def validate(self, columns) -> None:
|
|
57
|
+
missing = [c for c in self.required if c not in columns]
|
|
58
|
+
if missing:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"the panel is missing required columns {missing}. "
|
|
61
|
+
f"Adjust PanelSchema if your columns have different names."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ICDNConfig:
|
|
67
|
+
"""Everything needed to build and train an ICDN model.
|
|
68
|
+
|
|
69
|
+
Defaults reproduce the configuration selected by the hyperparameter search
|
|
70
|
+
of the original study, so a plain ``ICDNConfig()`` is a sensible starting
|
|
71
|
+
point for weekly retail panels.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# ── Data ────────────────────────────────────────────────────────────────
|
|
75
|
+
schema: PanelSchema = field(default_factory=PanelSchema)
|
|
76
|
+
n_products: int | None = None
|
|
77
|
+
lags: tuple[int, ...] = (1, 2, 4)
|
|
78
|
+
rolling_windows: tuple[int, ...] = (4, 13)
|
|
79
|
+
seasonal_periods: tuple[int, ...] = (52, 26, 13)
|
|
80
|
+
min_coverage: float = 0.5
|
|
81
|
+
min_products: int | None = None
|
|
82
|
+
new_product_periods: int = 13
|
|
83
|
+
|
|
84
|
+
# ── Architecture ────────────────────────────────────────────────────────
|
|
85
|
+
n_knots: int = 3
|
|
86
|
+
hidden: tuple[int, ...] = (256, 128, 64)
|
|
87
|
+
activation: str = "gelu"
|
|
88
|
+
dropout: float = 0.2547
|
|
89
|
+
k_neighbors: int = 4
|
|
90
|
+
d_store: int = 16
|
|
91
|
+
d_brand: int = 8
|
|
92
|
+
d_style: int = 8
|
|
93
|
+
use_cross: bool = True
|
|
94
|
+
|
|
95
|
+
# ── Objective ───────────────────────────────────────────────────────────
|
|
96
|
+
huber_delta: float = 1.0
|
|
97
|
+
lambda_smooth: float = 0.0351
|
|
98
|
+
lambda_elast: float = 0.0445
|
|
99
|
+
own_elasticity_bounds: tuple[float, float] = (-5.0, 0.0)
|
|
100
|
+
cross_elasticity_bounds: tuple[float, float] = (-1.0, 1.0)
|
|
101
|
+
|
|
102
|
+
# ── Training ────────────────────────────────────────────────────────────
|
|
103
|
+
batch_size: int = 256
|
|
104
|
+
warmup_epochs: int = 350
|
|
105
|
+
epochs: int = 400
|
|
106
|
+
warmup_lr: float = 1.6856e-3
|
|
107
|
+
lr: float = 1.6246e-3
|
|
108
|
+
weight_decay: float = 1e-5
|
|
109
|
+
grad_clip: float = 1.0
|
|
110
|
+
lr_patience: int = 30
|
|
111
|
+
early_stopping_patience: int = 70
|
|
112
|
+
smoothing_window: int = 8
|
|
113
|
+
beta_prior: float = -2.0
|
|
114
|
+
validation_fraction: float = 0.2
|
|
115
|
+
seed: int = 42
|
|
116
|
+
device: str = "auto"
|
|
117
|
+
num_workers: int = 0
|
|
118
|
+
verbose: bool = True
|
|
119
|
+
|
|
120
|
+
def __post_init__(self):
|
|
121
|
+
if isinstance(self.schema, dict):
|
|
122
|
+
self.schema = PanelSchema(**self.schema)
|
|
123
|
+
for name in ("lags", "rolling_windows", "seasonal_periods", "hidden"):
|
|
124
|
+
setattr(self, name, tuple(getattr(self, name)))
|
|
125
|
+
for name in ("own_elasticity_bounds", "cross_elasticity_bounds"):
|
|
126
|
+
setattr(self, name, tuple(getattr(self, name)))
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def from_yaml(cls, path: str | Path) -> "ICDNConfig":
|
|
130
|
+
import yaml
|
|
131
|
+
|
|
132
|
+
with open(path) as handle:
|
|
133
|
+
return cls.from_dict(yaml.safe_load(handle) or {})
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_dict(cls, payload: dict[str, Any]) -> "ICDNConfig":
|
|
137
|
+
known = {f.name for f in fields(cls)}
|
|
138
|
+
unknown = set(payload) - known
|
|
139
|
+
if unknown:
|
|
140
|
+
raise ValueError(f"unknown configuration keys: {sorted(unknown)}")
|
|
141
|
+
return cls(**payload)
|
|
142
|
+
|
|
143
|
+
def to_dict(self) -> dict[str, Any]:
|
|
144
|
+
return asdict(self)
|
icdn/data/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .dataset import DataLoaderFactory, MultiProductDataset
|
|
2
|
+
from .encoders import LabelEncoder
|
|
3
|
+
from .features import FeatureBuilder
|
|
4
|
+
from .panel import PanelBuilder, PanelLayout
|
|
5
|
+
from .splits import BlockBootstrapSampler, TemporalSplitter
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BlockBootstrapSampler",
|
|
9
|
+
"DataLoaderFactory",
|
|
10
|
+
"FeatureBuilder",
|
|
11
|
+
"LabelEncoder",
|
|
12
|
+
"MultiProductDataset",
|
|
13
|
+
"PanelBuilder",
|
|
14
|
+
"PanelLayout",
|
|
15
|
+
"TemporalSplitter",
|
|
16
|
+
]
|