pypricing
Bayesian own-price elasticity estimation with PyMC for long-format panels (one row per SKU–time observation).
This is currently an MVP for log-demand panels (per-SKU intercept + per-SKU elasticity) with:
- optional shared control regressors (
control_* columns) - optional hierarchical / partial pooling across group columns
- optional cross-price elasticities (
CrossElasticitySpec) - simple diagnostics + plotting helpers
- posterior predictive simulation for counterfactual price scenarios
- per-SKU revenue price optimization (
optimize_prices)
Install
pip install pypricing
Optional model-graph rendering (also needs the system Graphviz binaries):
pip install 'pypricing[graphviz]'
Docs: pypricing.readthedocs.io
Development
From a clone of this repository:
uv sync --extra dev
uv run pytest
For local docs builds: uv sync --extra docs.
Quickstart
import numpy as np
from pypricing import LogLogDemandModel, generate_mock_data
# 1) Example data (replace with your own panel)
df = generate_mock_data(
n_periods=20,
n_skus=5,
n_controls=2, # creates control_1, control_2
random_state=0,
)
# 2) Fit
model = LogLogDemandModel()
idata = model.fit(
df,
draws=1000,
tune=1000,
chains=4,
random_seed=42,
)
print(model.run_diagnostics())
print(model.fit_summary().head())
# 3) Counterfactual prediction (quantity column not required)
df_scenario = df.drop(columns=["quantity"]).copy()
df_scenario["price"] = df_scenario["price"] * 1.05 # +5% price scenario
pred = model.predict(df_scenario, hdi_prob=0.9, random_seed=123)
print(pred[["sku", "period", "price", "quantity_mean", "quantity_hdi_lower", "quantity_hdi_upper"]].head())
# 4) Plots
_ = model.plot_elasticity_posterior(hdi_prob=0.9)
sku0 = model.sku_levels_[0]
price_grid = np.linspace(df["price"].min(), df["price"].max(), 25)
controls = {c: float(df.loc[df["sku"] == sku0, c].iloc[0]) for c in model.control_names_}
_ = model.plot_response_curve(sku=sku0, price_grid=price_grid, controls=controls, hdi_prob=0.9)
Data format
LogLogDemandModel.fit(df) expects a pandas DataFrame with level-scale columns:
- Required
sku(configurable viasku_col)price(configurable viaprice_col) — must be strictly positivequantity(configurable viaquantity_col) — must be non-negative
- Optional
control_* columns (or pass an explicitcontrol_columns=(...)viaPanelColumns) — must be numeric, no NaNs- hierarchy columns (e.g.
category_1,category_2) viaPanelColumns(group_columns=...) period(and oftenregion) forfit_train_test()/ cross-elasticity market cells
Internally the model works on logs:
log_price = log(price)log_quantity = log(max(quantity, quantity_floor))
The default quantity_floor=1.0 allows zero quantities without \log(0).
What model is being fit?
At a high level this package fits log-demand with Gaussian noise:
\log Q \sim \mathcal{N}(\mu, \sigma)
Where \mu is a per-SKU demand curve plus optional global control effects.
Demand curve model classes
Pick the model class directly:
LogLogDemandModel(default/simple)QuadraticLogDemandModelSigmoidSaturationDemandModel
log_log (default)
Constant elasticity log-log:
$\mu = \alpha_{\text{sku}} + \epsilon_{\text{sku}} \log P + X\beta$
- Interpretation: \epsilon_{\text{sku}} is own-price elasticity.
- Example: \epsilon=-1.5 implies a 1% price increase → ~1.5% quantity decrease (locally / in expectation).
- Best when: you want a simple constant-elasticity approximation.
quadratic
Allows elasticity to vary with price (curvature in log-price):
$\mu = \alpha_{\text{sku}} + \beta_{1,\text{sku}}\log P + c_{\text{sku}} (\log P)^2 + X\beta$
This parameterization enforces that the elasticity at a per-SKU midpoint price equals elasticity_sku.
The midpoint is computed from the training data as the median log-price per SKU.
- Best when: elasticity changes with price level (e.g., premium vs discount regimes).
sigmoid
Saturating response curve in level price (softplus / log-sigmoid form):
\mu = \alpha_{\text{sku}} - \mathrm{softplus}(b_{\text{sku}}(P - P_{\text{center,sku}})) + X\beta
The per-SKU center P_{\text{center,sku}} = \exp(\texttt{log\_price\_center\_sku}) is a learned parameter.
Its prior is centered at the empirical per-SKU median log-price from training data (log_price_midpoint_sku_), with default sigma=0.5.
The curve is parameterized so the elasticity at P_{\text{center,sku}} equals elasticity_sku (via b_sku = -2 * elasticity_sku / price_center_sku).
- Best when: response “flattens out” at extreme prices (a simple saturation behavior).
Controls (control_*)
If your frame contains control_* columns (or you pass control_columns=(...)), the model includes a shared linear term X\beta:
- one global coefficient vector
beta_controlshared across all SKUs - controls must also be provided at prediction time
Priors and customization (model_config)
You can override priors by passing model_config to each model class constructor.
Each entry uses:
{
"dist": <PyMC distribution constructor>,
"kwargs": { ... },
}
Example:
import pymc as pm
from pypricing import QuadraticLogDemandModel
model = QuadraticLogDemandModel(
model_config={
"alpha_sku": {"dist": pm.Normal, "kwargs": {"mu": 0.0, "sigma": 1.0}},
"elasticity_sku": {"dist": pm.Normal, "kwargs": {"mu": -1.0, "sigma": 0.7}},
"sigma": {"dist": pm.HalfNormal, "kwargs": {"sigma": 0.3}},
# "curvature_sku": ... (only used by QuadraticLogDemandModel)
# "beta_control": ... (only used when you have controls)
}
)
Defaults today:
alpha_sku ~ Normal(mu=6, sigma=2)elasticity_sku ~ Normal(mu=-1, sigma=2)sigma ~ HalfNormal(sigma=0.5)beta_control ~ Normal(mu=0, sigma=0.5)(if controls exist)curvature_sku ~ Normal(mu=0, sigma=0.2)(only forquadratic)log_price_center_sku ~ Normal(mu=log_price_midpoint_sku_, sigma=0.5)(only forsigmoid)
Prediction
sample_posterior_predictive(df)returns anxarray.Datasetwith posterior draws forlog_quantityandquantity.predict(df)returns a DataFrame with:quantity_meanquantity_hdi_lowerquantity_hdi_upper
Prediction requires:
skuandprice- all control columns used during fit (if any)
quantityis not required
Unknown SKUs at prediction time raise an error (no cold-start handling yet).
Train/test evaluation (time-aware)
fit_train_test(df, period_col="period", test_size=0.2, ...):
- holds out the last fraction of unique periods (to avoid time leakage)
- fits on train, predicts on test
- returns:
rmseonquantity_meanhdi_coverage: fraction of true quantities inside the predicted HDItest_predictions: the prediction frame
Save / load
<ModelClass>.save(path) saves the model InferenceData to NetCDF (.nc) with model attrs.
<ModelClass>.load(path) restores the model from that NetCDF artifact and rebuilds the PyMC model.
Interpretation helper
pypricing.posterior.summarize_quantity_multiplier_one_sku(...) converts posterior elasticity draws into a posterior over relative quantity change for a price multiplier m via m^{\epsilon}.
For all SKUs at once, use the model method:
model.quantity_multiplier_summary(price_multiplier=..., hdi_prob=...)- returns one row per SKU with mean and HDI bounds of the quantity multiplier
- optionally,
model.quantity_multiplier_summary(..., return_draws=True)returns(summary_df, draws_da)wheredraws_dahas dims("chain", "draw", "sku")
This is only valid for LogLogDemandModel (constant elasticity).
Price optimization
optimize_prices (also available as model.optimize_prices(...)) chooses one price per SKU that maximizes the posterior mean of revenue price * exp(μ), where μ is the demand-curve mean log-quantity (same convention as prediction; residual σ is not folded into exp(μ)).
from pypricing import LogLogDemandModel, generate_mock_data
df = generate_mock_data(n_periods=20, n_skus=5, n_controls=1, random_state=0)
model = LogLogDemandModel()
model.fit(df, draws=500, tune=500, chains=2, random_seed=0)
price_bounds = {
sku: (float(g["price"].min() * 0.8), float(g["price"].max() * 1.3))
for sku, g in df.groupby("sku")
}
controls_df = (
df.groupby("sku", as_index=False)[model.control_names_].median()
)
opt = model.optimize_prices(price_bounds=price_bounds, controls_df=controls_df)
print(opt.head())
Caveats:
- Optimization is per-SKU and independent (1D SciPy search on log-price within bounds).
- Not supported when
cross_elasticityis enabled (revenue then depends on the full market cell). - For
LogLogDemandModel, expected revenue is proportional top^(1+ε)draw-wise; if elasticity is roughly constant and|ε| ≠ 1, the optimum often sits on a bound. - If the model was fit with controls, pass
controls_dfwith one row per SKU.
See docs/source/notebooks/quickstart.ipynb for plots (plot_revenue_vs_price, plot_optimization_summary).
Hierarchy and cross-elasticity
Pass column mapping via PanelColumns:
from pypricing import CrossElasticitySpec, LogLogDemandModel, PanelColumns
model = LogLogDemandModel(
panel_columns=PanelColumns(group_columns=("category_1", "category_2")),
cross_elasticity=CrossElasticitySpec(mode="within_group", group_level=0),
)
Omit cross_elasticity for own-price only. Use mode="all" for every directed SKU pair (no group_level).
What is NOT implemented (yet)
- Cold-start prediction for unseen SKUs
- Joint / cross-aware price optimization (use counterfactual prediction on a full market cell instead)
Documentation
Hosted docs (API + notebooks): https://pypricing.readthedocs.io
To build HTML locally (Sphinx + notebooks, no notebook re-execution):
uv sync --extra docs
cd docs && uv run make html
# open docs/build/html/index.html
Development
uv sync --extra dev
uv run pytest # unit + integration (default)
uv run pytest -m unit # fast only
uv run pytest -m integration # short MCMC smoke
uv run pytest -m recovery # parameter recovery (slower)
uv run ruff check src tests
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pypricing-0.0.1.tar.gz.
File metadata
- Download URL: pypricing-0.0.1.tar.gz
- Upload date:
- Size: 46.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dc0bd25d4f0b253aa7e9a2d7a6668472c4cd5de5ec8fe780ca826e91a7927085
|
|
| MD5 |
d9b3888ee323d495fd80788673f1aaff
|
|
| BLAKE2b-256 |
9dbeb513b4ed20efd3097c40332dd7ef1b53e276863089487299529d1db6d6e2
|
Provenance
The following attestation bundles were made for pypricing-0.0.1.tar.gz:
Publisher:
release.yml on arthurmello/pypricing
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pypricing-0.0.1.tar.gz -
Subject digest:
dc0bd25d4f0b253aa7e9a2d7a6668472c4cd5de5ec8fe780ca826e91a7927085 - Sigstore transparency entry: 2424359280
- Sigstore integration time:
-
Permalink:
arthurmello/pypricing@13e340a298224af17731091436c2927d3c85d93e -
Branch / Tag:
refs/tags/v0.0.1 - Owner: https://github.com/arthurmello
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@13e340a298224af17731091436c2927d3c85d93e -
Trigger Event:
release
-
Statement type:
File details
Details for the file pypricing-0.0.1-py3-none-any.whl.
File metadata
- Download URL: pypricing-0.0.1-py3-none-any.whl
- Upload date:
- Size: 51.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
208a4cf9954366d3e4ae2cfa0be13d86909e0c29a6de1e9a84afa125c45caf31
|
|
| MD5 |
305481272fa75246bf2743aaccb31dee
|
|
| BLAKE2b-256 |
01520e4f351d267256e2046f9e5f4c4148a323327280783152e3cbda6756df7f
|
Provenance
The following attestation bundles were made for pypricing-0.0.1-py3-none-any.whl:
Publisher:
release.yml on arthurmello/pypricing
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pypricing-0.0.1-py3-none-any.whl -
Subject digest:
208a4cf9954366d3e4ae2cfa0be13d86909e0c29a6de1e9a84afa125c45caf31 - Sigstore transparency entry: 2424359288
- Sigstore integration time:
-
Permalink:
arthurmello/pypricing@13e340a298224af17731091436c2927d3c85d93e -
Branch / Tag:
refs/tags/v0.0.1 - Owner: https://github.com/arthurmello
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@13e340a298224af17731091436c2927d3c85d93e -
Trigger Event:
release
-
Statement type: