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 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
+ ]