pypricing 0.0.1__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.
pypricing/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from pypricing.data import (
4
+ CrossPairIndex,
5
+ HierarchyIndex,
6
+ PanelColumns,
7
+ PricePanelData,
8
+ floor_censored_fraction,
9
+ )
10
+ from pypricing.model_components.cross_elasticity import CrossElasticitySpec
11
+ from pypricing.models import (
12
+ DemandModel,
13
+ LogLogDemandModel,
14
+ QuadraticLogDemandModel,
15
+ SigmoidSaturationDemandModel,
16
+ )
17
+ from pypricing.optimizer import optimize_prices
18
+ from pypricing.synthetic_data import generate_mock_data
19
+
20
+ try:
21
+ __version__ = version("pypricing")
22
+ except PackageNotFoundError: # pragma: no cover - editable/src path without install
23
+ __version__ = "0.0.1"
24
+
25
+ __all__ = [
26
+ "__version__",
27
+ "CrossElasticitySpec",
28
+ "CrossPairIndex",
29
+ "DemandModel",
30
+ "HierarchyIndex",
31
+ "PanelColumns",
32
+ "PricePanelData",
33
+ "LogLogDemandModel",
34
+ "QuadraticLogDemandModel",
35
+ "SigmoidSaturationDemandModel",
36
+ "floor_censored_fraction",
37
+ "generate_mock_data",
38
+ "optimize_prices",
39
+ ]
@@ -0,0 +1,16 @@
1
+ """Validated tabular data for elasticity models."""
2
+
3
+ from pypricing.data.index import CrossPairIndex, HierarchyIndex
4
+ from pypricing.data.price_panel import (
5
+ PanelColumns,
6
+ PricePanelData,
7
+ floor_censored_fraction,
8
+ )
9
+
10
+ __all__ = [
11
+ "CrossPairIndex",
12
+ "HierarchyIndex",
13
+ "PanelColumns",
14
+ "PricePanelData",
15
+ "floor_censored_fraction",
16
+ ]
@@ -0,0 +1,35 @@
1
+ """Panel index types: hierarchy and directed cross-price pairs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+
10
+ @dataclass
11
+ class HierarchyIndex:
12
+ """Derived SKU→group indices when ``group_columns`` are set on the panel."""
13
+
14
+ group_columns: tuple[str, ...]
15
+ sku_to_level_idx: np.ndarray
16
+ """Shape ``(n_skus, n_levels)``: group index for each SKU at each hierarchy level."""
17
+ n_groups_per_level: tuple[int, ...]
18
+
19
+ @property
20
+ def n_levels(self) -> int:
21
+ return len(self.group_columns)
22
+
23
+
24
+ @dataclass
25
+ class CrossPairIndex:
26
+ """Directed cross-price pairs and pooling indices (post-build / runtime)."""
27
+
28
+ pair_from: np.ndarray
29
+ pair_to: np.ndarray
30
+ pair_pool: np.ndarray
31
+ n_pool: int
32
+
33
+ @property
34
+ def n_pairs(self) -> int:
35
+ return int(len(self.pair_from))
@@ -0,0 +1,414 @@
1
+ """Validated tabular panel for elasticity models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from dataclasses import dataclass, replace
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from pypricing.data.index import CrossPairIndex, HierarchyIndex
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class PanelColumns:
17
+ """Column mapping and panel-build knobs for long-format price data."""
18
+
19
+ sku_col: str = "sku"
20
+ price_col: str = "price"
21
+ quantity_col: str = "quantity"
22
+ quantity_floor: float = 1.0
23
+ control_columns: tuple[str, ...] | None = None
24
+ group_columns: tuple[str, ...] | None = None
25
+ period_col: str = "period"
26
+ region_col: str | None = None
27
+ floor_censoring_warn_threshold: float | None = 0.5
28
+ """Warn when a SKU has this fraction (or more) of observations at/below
29
+ ``quantity_floor`` -- such a SKU carries little to no price-response
30
+ signal, even though nothing in ``run_diagnostics()`` otherwise flags it.
31
+ Set to ``None`` to disable the check."""
32
+
33
+
34
+ def _auto_control_columns(df: pd.DataFrame) -> tuple[str, ...]:
35
+ return tuple(c for c in df.columns if c.startswith("control_"))
36
+
37
+
38
+ def floor_censored_fraction(
39
+ df: pd.DataFrame,
40
+ *,
41
+ panel_columns: PanelColumns | None = None,
42
+ ) -> pd.Series:
43
+ """Per-SKU fraction of observations at or below ``quantity_floor``.
44
+
45
+ A SKU whose quantity sits at the floor for most of its history carries
46
+ little to no price-response signal in this data -- ``elasticity_sku`` for
47
+ that SKU is effectively unidentified, even though nothing in
48
+ ``run_diagnostics()`` (divergences, r-hat) will flag it, since the fit
49
+ itself has no way to see that the censoring happened.
50
+ """
51
+ cols = panel_columns or PanelColumns()
52
+ at_floor = df[cols.quantity_col] <= cols.quantity_floor
53
+ return at_floor.groupby(df[cols.sku_col]).mean()
54
+
55
+
56
+ def _build_hierarchy_index(
57
+ df: pd.DataFrame,
58
+ sku_col: str,
59
+ group_columns: tuple[str, ...],
60
+ sku_levels: pd.Index,
61
+ ) -> HierarchyIndex:
62
+ """One row per SKU in ``sku_levels`` order; group index matrix (n_skus, n_levels)."""
63
+ dup = df.groupby(sku_col)[list(group_columns)].nunique()
64
+ if (dup > 1).any().any():
65
+ raise ValueError(
66
+ "group_columns must be constant within each SKU; conflicting values found"
67
+ )
68
+ one = df.drop_duplicates(subset=[sku_col], keep="first")
69
+ one = one.set_index(sku_col).reindex(sku_levels)
70
+ if one[list(group_columns)].isna().any().any():
71
+ raise ValueError("Missing group column values for some SKU levels")
72
+
73
+ sku_to_level_idx = np.zeros((len(sku_levels), len(group_columns)), dtype=np.int64)
74
+ n_groups: list[int] = []
75
+ for i, col in enumerate(group_columns):
76
+ cat = pd.Categorical(one[col])
77
+ sku_to_level_idx[:, i] = cat.codes.astype(np.int64)
78
+ n_groups.append(int(cat.categories.size))
79
+ return HierarchyIndex(
80
+ group_columns=group_columns,
81
+ sku_to_level_idx=sku_to_level_idx,
82
+ n_groups_per_level=tuple(n_groups),
83
+ )
84
+
85
+
86
+ def _pair_lookup_map(
87
+ pair_from: np.ndarray, pair_to: np.ndarray
88
+ ) -> dict[tuple[int, int], int]:
89
+ out: dict[tuple[int, int], int] = {}
90
+ for p, (i, j) in enumerate(zip(pair_from.tolist(), pair_to.tolist())):
91
+ out[(int(i), int(j))] = p
92
+ return out
93
+
94
+
95
+ def _cell_log_prices_and_rows(
96
+ grp: pd.DataFrame,
97
+ df: pd.DataFrame,
98
+ *,
99
+ sku_col: str,
100
+ price_col: str,
101
+ sku_to_idx: dict[Any, int],
102
+ n_skus: int,
103
+ ) -> tuple[np.ndarray, np.ndarray]:
104
+ """Validate one market cell; return ``(log_p_arr, row_indices)`` of length ``n_skus``."""
105
+ log_p_arr = np.empty(n_skus, dtype=np.float64)
106
+ row_indices = np.empty(n_skus, dtype=np.int64)
107
+ seen = np.zeros(n_skus, dtype=bool)
108
+ for ix in grp.index:
109
+ row = df.loc[ix]
110
+ sku = row[sku_col]
111
+ si = sku_to_idx[sku]
112
+ if seen[si]:
113
+ raise ValueError(
114
+ f"Duplicate (cell, sku) for cross-elasticity: sku index {si}"
115
+ )
116
+ seen[si] = True
117
+ p = float(row[price_col])
118
+ if p <= 0:
119
+ raise ValueError("price must be positive for cross-elasticity")
120
+ log_p_arr[si] = float(np.log(p))
121
+ row_indices[si] = int(df.index.get_loc(ix))
122
+
123
+ if not seen.all():
124
+ raise ValueError("Missing SKUs in a market cell for cross-elasticity")
125
+ return log_p_arr, row_indices
126
+
127
+
128
+ def _fill_cross_matrix_for_cell(
129
+ X: np.ndarray,
130
+ *,
131
+ log_p_arr: np.ndarray,
132
+ row_indices: np.ndarray,
133
+ n_skus: int,
134
+ pair_map: dict[tuple[int, int], int],
135
+ ) -> None:
136
+ """Scatter competitor log prices into ``X`` for this cell's rows."""
137
+ for si in range(n_skus):
138
+ r = int(row_indices[si])
139
+ for j in range(n_skus):
140
+ if j == si:
141
+ continue
142
+ pidx = pair_map.get((si, j))
143
+ if pidx is not None:
144
+ X[r, pidx] = log_p_arr[j]
145
+
146
+
147
+ def _assert_cross_matrix_focal_rows(
148
+ X: np.ndarray,
149
+ *,
150
+ obs_sku_idx: np.ndarray,
151
+ pair_from: np.ndarray,
152
+ ) -> None:
153
+ """Columns must be zero on rows whose focal SKU is not ``pair_from[p]``."""
154
+ n_obs, n_pairs = X.shape
155
+ for r in range(n_obs):
156
+ si = int(obs_sku_idx[r])
157
+ for p in range(n_pairs):
158
+ if pair_from[p] != si and X[r, p] != 0.0:
159
+ raise RuntimeError("internal: cross matrix inconsistency")
160
+
161
+
162
+ @dataclass
163
+ class PricePanelData:
164
+ """
165
+ Long-format panel prepared for PyMC: one row per observation.
166
+
167
+ Expects **price** and **quantity** in natural units; builds ``log_price`` and
168
+ ``log_quantity`` internally for the log-log likelihood.
169
+ """
170
+
171
+ log_price: np.ndarray
172
+ log_quantity: np.ndarray
173
+ obs_sku_idx: np.ndarray
174
+ n_skus: int
175
+ sku_levels: pd.Index
176
+ control_matrix: np.ndarray
177
+ control_names: tuple[str, ...]
178
+ n_obs: int
179
+ hierarchy: HierarchyIndex | None = None
180
+ """Present when ``PanelColumns.group_columns`` were set at build time."""
181
+ cross_pair_log_price_matrix: np.ndarray | None = None
182
+ """Shape ``(n_obs, n_cross_pairs)``: log competitor prices for directed cross pairs."""
183
+ cross_pairs: CrossPairIndex | None = None
184
+ """Directed pair / pool index when cross-elasticity is enabled."""
185
+
186
+ @property
187
+ def n_cross_pairs(self) -> int:
188
+ return 0 if self.cross_pairs is None else self.cross_pairs.n_pairs
189
+
190
+ @property
191
+ def n_cross_pool(self) -> int:
192
+ return 0 if self.cross_pairs is None else self.cross_pairs.n_pool
193
+
194
+ @classmethod
195
+ def from_frame(
196
+ cls,
197
+ df: pd.DataFrame,
198
+ *,
199
+ panel_columns: PanelColumns | None = None,
200
+ ) -> PricePanelData:
201
+ """
202
+ Parameters
203
+ ----------
204
+ panel_columns
205
+ Column mapping and build knobs. When ``None``, uses ``PanelColumns()``
206
+ defaults. ``quantity_floor`` is applied as
207
+ ``log(max(quantity, quantity_floor))``. ``group_columns`` are optional
208
+ hierarchy columns, coarse → fine, constant within each SKU.
209
+ """
210
+ cols = panel_columns or PanelColumns()
211
+ sku_col = cols.sku_col
212
+ price_col = cols.price_col
213
+ quantity_col = cols.quantity_col
214
+ quantity_floor = cols.quantity_floor
215
+ control_columns = cols.control_columns
216
+ group_columns = cols.group_columns
217
+
218
+ if quantity_floor <= 0:
219
+ raise ValueError("quantity_floor must be positive")
220
+
221
+ if control_columns is None:
222
+ control_columns = _auto_control_columns(df)
223
+ else:
224
+ bad = set(control_columns) - set(df.columns)
225
+ if bad:
226
+ raise ValueError(f"control_columns not in frame: {sorted(bad)}")
227
+ overlap_c = set(control_columns) & {sku_col, price_col, quantity_col}
228
+ if overlap_c:
229
+ raise ValueError(
230
+ f"control_columns must not overlap sku/price/quantity: {overlap_c}"
231
+ )
232
+
233
+ if group_columns is not None:
234
+ group_columns = tuple(group_columns)
235
+ if len(group_columns) == 0:
236
+ group_columns = None
237
+ elif len(set(group_columns)) != len(group_columns):
238
+ raise ValueError("group_columns must not contain duplicates")
239
+
240
+ base = (sku_col, price_col, quantity_col)
241
+ missing = set(base) - set(df.columns)
242
+ if missing:
243
+ raise ValueError(f"Missing required columns: {sorted(missing)}")
244
+
245
+ extra = list(control_columns)
246
+ if group_columns is not None:
247
+ bad_g = set(group_columns) - set(df.columns)
248
+ if bad_g:
249
+ raise ValueError(f"group_columns not in frame: {sorted(bad_g)}")
250
+ overlap = set(group_columns) & {sku_col, price_col, quantity_col}
251
+ if overlap:
252
+ raise ValueError(
253
+ f"group_columns must not overlap sku/price/quantity: {overlap}"
254
+ )
255
+ extra = [*group_columns, *extra]
256
+
257
+ if cols.period_col in df.columns:
258
+ dup_counts = df.groupby([sku_col, cols.period_col]).size()
259
+ n_dup_groups = int((dup_counts > 1).sum())
260
+ if n_dup_groups:
261
+ warnings.warn(
262
+ f"Found {n_dup_groups} ({sku_col}, {cols.period_col}) combination(s) "
263
+ "with more than one row; they will be fit as independent "
264
+ "observations. If this is unintentional (e.g. a duplicated "
265
+ "join), deduplicate before fitting.",
266
+ stacklevel=2,
267
+ )
268
+
269
+ sub = df[[*base, *extra]].copy()
270
+ if sub[[sku_col, price_col, quantity_col]].isna().any().any():
271
+ raise ValueError(
272
+ "NaN in sku / price / quantity are not allowed after column selection"
273
+ )
274
+
275
+ price = sub[price_col].to_numpy(dtype=np.float64)
276
+ quantity = sub[quantity_col].to_numpy(dtype=np.float64)
277
+ if (price <= 0).any():
278
+ raise ValueError("price must be strictly positive for log transform")
279
+ if (quantity < 0).any():
280
+ raise ValueError("quantity must be non-negative")
281
+ log_price = np.log(price)
282
+ log_quantity = np.log(np.maximum(quantity, quantity_floor))
283
+
284
+ if len(control_columns) and sub[list(control_columns)].isna().any().any():
285
+ raise ValueError("NaN in control columns are not allowed")
286
+ if group_columns is not None and sub[list(group_columns)].isna().any().any():
287
+ raise ValueError("NaN in group_columns are not allowed")
288
+
289
+ cat = pd.Categorical(sub[sku_col])
290
+ obs_sku_idx = cat.codes.astype(np.int64)
291
+ n_skus = int(cat.categories.size)
292
+ if n_skus < 1:
293
+ raise ValueError("No SKU levels found")
294
+
295
+ sku_levels = pd.Index(cat.categories)
296
+ hierarchy: HierarchyIndex | None = None
297
+ if group_columns is not None:
298
+ hierarchy = _build_hierarchy_index(sub, sku_col, group_columns, sku_levels)
299
+
300
+ if len(control_columns):
301
+ control_matrix = sub[list(control_columns)].to_numpy(dtype=np.float64)
302
+ else:
303
+ control_matrix = np.empty((len(sub), 0), dtype=np.float64)
304
+
305
+ return cls(
306
+ log_price=log_price,
307
+ log_quantity=log_quantity,
308
+ obs_sku_idx=obs_sku_idx,
309
+ n_skus=n_skus,
310
+ sku_levels=sku_levels,
311
+ control_matrix=control_matrix,
312
+ control_names=tuple(control_columns),
313
+ n_obs=int(len(sub)),
314
+ hierarchy=hierarchy,
315
+ )
316
+
317
+ @staticmethod
318
+ def build_cross_log_price_matrix(
319
+ df: pd.DataFrame,
320
+ *,
321
+ panel_columns: PanelColumns,
322
+ sku_levels: pd.Index,
323
+ pair_from_idx: np.ndarray,
324
+ pair_to_idx: np.ndarray,
325
+ obs_sku_idx: np.ndarray,
326
+ ) -> np.ndarray:
327
+ """
328
+ Rows align with ``df`` row order. Column ``p`` is nonzero on rows whose focal SKU
329
+ is ``pair_from[p]``, with value log price of ``pair_to[p]`` in the same market cell.
330
+ """
331
+ sku_col = panel_columns.sku_col
332
+ price_col = panel_columns.price_col
333
+ period_col = panel_columns.period_col
334
+ region_col = panel_columns.region_col
335
+
336
+ n_obs = len(df)
337
+ n_skus = len(sku_levels)
338
+ n_pairs = len(pair_from_idx)
339
+ pair_map = _pair_lookup_map(pair_from_idx, pair_to_idx)
340
+ sku_to_idx = {sku_levels[i]: i for i in range(n_skus)}
341
+
342
+ if period_col not in df.columns:
343
+ raise ValueError(f"Missing period_col {period_col!r} for cross-elasticity")
344
+ if region_col is not None and region_col not in df.columns:
345
+ raise ValueError(f"Missing region_col {region_col!r} for cross-elasticity")
346
+
347
+ cell_cols = [period_col] + ([region_col] if region_col is not None else [])
348
+ X = np.zeros((n_obs, n_pairs), dtype=np.float64)
349
+
350
+ for _cell_key, grp in df.groupby(cell_cols, sort=False):
351
+ if len(grp) != n_skus:
352
+ raise ValueError(
353
+ "cross-elasticity requires a balanced panel: each market cell must "
354
+ f"have exactly one row per SKU; expected {n_skus} rows, got {len(grp)}"
355
+ )
356
+ log_p_arr, row_indices = _cell_log_prices_and_rows(
357
+ grp,
358
+ df,
359
+ sku_col=sku_col,
360
+ price_col=price_col,
361
+ sku_to_idx=sku_to_idx,
362
+ n_skus=n_skus,
363
+ )
364
+ _fill_cross_matrix_for_cell(
365
+ X,
366
+ log_p_arr=log_p_arr,
367
+ row_indices=row_indices,
368
+ n_skus=n_skus,
369
+ pair_map=pair_map,
370
+ )
371
+
372
+ _assert_cross_matrix_focal_rows(
373
+ X, obs_sku_idx=obs_sku_idx, pair_from=pair_from_idx
374
+ )
375
+ return X
376
+
377
+ def with_cross_elasticity(
378
+ self,
379
+ df: pd.DataFrame,
380
+ *,
381
+ panel_columns: PanelColumns,
382
+ cross_pairs: CrossPairIndex,
383
+ ) -> PricePanelData:
384
+ cross_pair_log_price_matrix = self.build_cross_log_price_matrix(
385
+ df=df,
386
+ panel_columns=panel_columns,
387
+ sku_levels=self.sku_levels,
388
+ pair_from_idx=cross_pairs.pair_from,
389
+ pair_to_idx=cross_pairs.pair_to,
390
+ obs_sku_idx=self.obs_sku_idx,
391
+ )
392
+ if int(cross_pair_log_price_matrix.shape[1]) != cross_pairs.n_pairs:
393
+ raise RuntimeError(
394
+ "cross matrix width does not match CrossPairIndex.n_pairs"
395
+ )
396
+
397
+ return replace(
398
+ self,
399
+ cross_pair_log_price_matrix=cross_pair_log_price_matrix,
400
+ cross_pairs=cross_pairs,
401
+ )
402
+
403
+ def coords(self) -> dict[str, Any]:
404
+ out: dict[str, Any] = {
405
+ "sku": np.arange(self.n_skus),
406
+ "obs": np.arange(self.n_obs),
407
+ }
408
+ if self.hierarchy is not None:
409
+ for i, n in enumerate(self.hierarchy.n_groups_per_level):
410
+ out[f"group_{i}"] = np.arange(n)
411
+ if self.n_cross_pairs > 0:
412
+ out["cross_pair"] = np.arange(self.n_cross_pairs, dtype=np.int64)
413
+ out["cross_pool"] = np.arange(self.n_cross_pool, dtype=np.int64)
414
+ return out
@@ -0,0 +1 @@
1
+ """Reusable PyMC building blocks for demand models."""