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 +39 -0
- pypricing/data/__init__.py +16 -0
- pypricing/data/index.py +35 -0
- pypricing/data/price_panel.py +414 -0
- pypricing/model_components/__init__.py +1 -0
- pypricing/model_components/cross_elasticity.py +196 -0
- pypricing/model_components/global_terms.py +30 -0
- pypricing/model_components/hierarchical_effects.py +76 -0
- pypricing/model_components/posterior_mu.py +17 -0
- pypricing/model_components/priors.py +46 -0
- pypricing/model_components/sku_effects.py +52 -0
- pypricing/models/__init__.py +11 -0
- pypricing/models/basic.py +738 -0
- pypricing/models/log_log.py +86 -0
- pypricing/models/quadratic_log.py +115 -0
- pypricing/models/sigmoid_saturation.py +126 -0
- pypricing/optimizer.py +258 -0
- pypricing/plotting.py +837 -0
- pypricing/posterior.py +98 -0
- pypricing/py.typed +0 -0
- pypricing/synthetic_data.py +734 -0
- pypricing-0.0.1.dist-info/METADATA +371 -0
- pypricing-0.0.1.dist-info/RECORD +26 -0
- pypricing-0.0.1.dist-info/WHEEL +5 -0
- pypricing-0.0.1.dist-info/licenses/LICENSE +202 -0
- pypricing-0.0.1.dist-info/top_level.txt +1 -0
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
|
+
]
|
pypricing/data/index.py
ADDED
|
@@ -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."""
|