Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
2ce0db0
fix(bass): restore Prior objects when loading a saved model
anevolbap Jun 12, 2026
95d1de7
fix(bass): keep product dimension when forecasting without observed data
anevolbap Jun 12, 2026
3475f4d
docs(bass): update notebook to the BassModel workflow
anevolbap Jul 5, 2026
2783236
docs(bass): address notebook review
anevolbap Jul 9, 2026
530aa9f
docs(bass): address notebook review
anevolbap Jul 29, 2026
5f9552b
fix(bass): derive forecast shape from dims and keep T leading
anevolbap Jul 29, 2026
f966803
docs(bass): parameter-recovery check and review follow-ups
anevolbap Jul 29, 2026
ee90470
refactor(bass): migrate dim handling to pymc.dims
anevolbap Jul 6, 2026
3f639e1
refactor(bass): address review on the pymc.dims migration
anevolbap Jul 9, 2026
ec47c69
Merge branch 'main' into feat/2598-bass-pmd-dims
williambdean Jul 30, 2026
a835470
fix(bass): restore Prior objects when loading a saved model
anevolbap Jun 12, 2026
6629493
fix(bass): keep product dimension when forecasting without observed data
anevolbap Jun 12, 2026
767db95
docs(bass): update notebook to the BassModel workflow
anevolbap Jul 5, 2026
460eb5c
docs(bass): address notebook review
anevolbap Jul 9, 2026
992eef3
docs(bass): address notebook review
anevolbap Jul 29, 2026
86c3b27
fix(bass): derive forecast shape from dims and keep T leading
anevolbap Jul 29, 2026
03c5129
docs(bass): parameter-recovery check and review follow-ups
anevolbap Jul 29, 2026
01f0ee8
chore(deps): bump pymc floor to 6.0.1
anevolbap Jul 30, 2026
eb36008
fix(bass): satisfiable test prior and transpose without a T coord
anevolbap Jul 30, 2026
0006e47
Merge remote-tracking branch 'upstream/main' into rebase/2590-notebook
anevolbap Jul 30, 2026
921def6
Merge remote-tracking branch 'upstream/main' into rebase/2598-pmd
anevolbap Jul 30, 2026
439abc2
docs(bass): plot_dist for the p/q recovery plots
anevolbap Jul 31, 2026
b21f253
Merge notebook branch (#2770 review fixes) into the pymc.dims branch
anevolbap Jul 31, 2026
d18953e
Merge branch 'main' into feat/2598-bass-pmd-dims
juanitorduz Jul 31, 2026
58b23eb
chore(bass): drop the accidental data.py change (merge artifact)
anevolbap Jul 31, 2026
3be28d5
Merge remote-tracking branch 'upstream/main' into sync/2598-pmd-dims
anevolbap Aug 4, 2026
a5eb3ae
Merge remote-tracking branch 'upstream/main' into sync/2598-pmd-dims
anevolbap Aug 9, 2026
2753c23
fix(bass): close the regressions in the pymc.dims migration
anevolbap Aug 9, 2026
39e943f
docs(bass): drop the review-defence line from _create_dim_variable
anevolbap Aug 9, 2026
6fb94b3
docs: link XTensorVariable in numpydoc type xrefs
anevolbap Aug 9, 2026
9b1f9c8
Merge branch 'main' into feat/2598-bass-pmd-dims
anevolbap Aug 11, 2026
4d3c971
fix(bass): address review on the pymc.dims fallbacks and dim order
anevolbap Aug 11, 2026
21a8ff6
docs(bass): link the upstream issue for the observed=None xdist branch
anevolbap Aug 11, 2026
da2b538
Merge branch 'main' into feat/2598-bass-pmd-dims
anevolbap Aug 11, 2026
4b1d11a
docs(bass): name the real gate for dropping the observed=None branch
anevolbap Aug 11, 2026
7552d90
refactor(bass): build the unobserved outcome node without create_like…
anevolbap Aug 13, 2026
c6e43e8
Merge branch 'main' into feat/2598-bass-pmd-dims
anevolbap Aug 13, 2026
b7e1bd8
Merge branch 'main' into feat/2598-bass-pmd-dims
anevolbap Aug 17, 2026
74445ed
Merge branch 'main' into feat/2598-bass-pmd-dims
anevolbap Aug 17, 2026
1bc024c
docs(bass): document positional labelling and the xdist signature check
anevolbap Aug 18, 2026
9f7af31
Merge remote-tracking branch 'upstream/main' into sync/2598-pmd-dims
anevolbap Aug 18, 2026
f79c607
Merge branch 'main' into feat/2598-bass-pmd-dims
anevolbap Aug 20, 2026
327e44e
fix(bass): mirror the likelihood guards on the unobserved path
anevolbap Aug 20, 2026
4f2e799
Merge remote-tracking branch 'origin/feat/2598-bass-pmd-dims' into sy…
anevolbap Aug 20, 2026
4e57c17
fix(bass): check nested priors for xdist support and validate observe…
anevolbap Aug 20, 2026
7e1d173
Merge branch 'main' into feat/2598-bass-pmd-dims
anevolbap Aug 22, 2026
c5f24ad
refactor(bass): dedupe dim validation and test priors, fix docstring …
anevolbap Aug 22, 2026
f311ebd
chore(mlflow): drop stale provenance comment in _get_random_variable_…
anevolbap Aug 22, 2026
2d98fb3
Merge branch 'main' into feat/2598-bass-pmd-dims
anevolbap Aug 24, 2026
fb0fe15
fix(bass): do not mutate the caller's likelihood prior
anevolbap Aug 24, 2026
42039a8
refactor(bass): lean model block, drop the xdist fallbacks
anevolbap Aug 24, 2026
f77ec57
Merge branch 'main' into feat/2598-bass-pmd-dims
anevolbap Aug 24, 2026
cbaed2f
refactor(bass): drop _exp, F and f take xtensor
anevolbap Aug 24, 2026
39d4365
fix(bass): F and f accept float p and q
anevolbap Aug 24, 2026
b6e2d1f
docs(bass): plotting facets the plotted variable, not the data
anevolbap Aug 24, 2026
ad20905
Merge branch 'main' into feat/2598-bass-pmd-dims
juanitorduz Aug 26, 2026
eb0e2c4
fix(bass): borrow likelihood dims and match observed by identity
anevolbap Aug 26, 2026
18a2e78
feat(bass): point F and f at as_xtensor when t is unlabelled
anevolbap Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@
# fmt: on
numpydoc_xref_aliases = {
"TensorVariable": ":class:`~pytensor.tensor.TensorVariable`",
"XTensorVariable": ":class:`~pytensor.xtensor.type.XTensorVariable`",
"RandomVariable": ":class:`~pytensor.tensor.random.RandomVariable`",
"ndarray": ":class:`~numpy.ndarray`",
"DataTree": ":class:`~xarray.DataTree`",
Expand Down
240 changes: 180 additions & 60 deletions pymc_marketing/bass/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
access to the PyMC model object.

The standalone functions :func:`F`, :func:`f`, and :func:`create_bass_model`
are still exposed for direct use.
are still exposed for direct use. :func:`F` and :func:`f` take xtensor
inputs; wrap plain arrays with :func:`pymc.dims.as_xtensor` to call them
outside a model.

Adapted from Wiki: https://en.wikipedia.org/wiki/Bass_diffusion_model

Expand Down Expand Up @@ -134,6 +136,8 @@

"""

from contextlib import contextmanager
from inspect import signature
from typing import Any, TypedDict, cast

import arviz as az
Expand All @@ -142,6 +146,7 @@
import numpy.typing as npt
import pandas as pd
import pymc as pm
import pymc.dims as pmd
import pytensor.tensor as pt
import xarray as xr
from matplotlib.axes import Axes
Expand All @@ -150,7 +155,14 @@
)
from pymc.model import Model
from pymc.util import RandomState
from pymc_extras.prior import Censored, Prior, VariableFactory, create_dim_handler
from pymc_extras.prior import (
Censored,
MuAlreadyExistsError,
Prior,
UnsupportedDistributionError,
VariableFactory,
)
from pytensor.xtensor.type import XTensorVariable

from pymc_marketing.bass import plotting
from pymc_marketing.bass.data import to_bass_dataset
Expand All @@ -159,28 +171,44 @@
from pymc_marketing.version import __version__


def _check_time(t: object) -> None:
"""Reject a ``t`` that ``pymc.dims`` cannot label on its own.

A scalar tensor converts cleanly, an array or a dim-less vector does not,
and the conversion error it raises does not say what to do about it.
"""
if isinstance(t, XTensorVariable) or (
isinstance(t, pt.TensorVariable) and t.ndim == 0
):
return
raise TypeError(
f"`t` must be an XTensorVariable, got {type(t).__name__}. "
"Wrap plain arrays with `pymc.dims.as_xtensor(t, dims=('T',))`."
)


def F(
p: float | pt.TensorVariable,
q: float | pt.TensorVariable,
t: float | pt.TensorVariable,
) -> pt.TensorVariable:
p: float | XTensorVariable,
q: float | XTensorVariable,
t: XTensorVariable | pt.TensorVariable,
) -> XTensorVariable:
r"""Installed base fraction (cumulative adoption proportion).

This function calculates the cumulative proportion of adopters at time t,
representing the fraction of the potential market that has adopted the product.

Parameters
----------
p : float or TensorVariable
p : float or XTensorVariable
Coefficient of innovation (external influence)
q : float or TensorVariable
q : float or XTensorVariable
Coefficient of imitation (internal influence)
t : array-like or TensorVariable
t : XTensorVariable or scalar TensorVariable
Time points

Returns
-------
TensorVariable
XTensorVariable
The cumulative proportion of adopters at each time point

Notes
Expand All @@ -193,14 +221,15 @@ def F(

When :math:`t=0`, :math:`F(t)=0`, and as :math:`t` approaches infinity, :math:`F(t)` approaches 1.
"""
return (1 - pt.exp(-(p + q) * t)) / (1 + (q / p) * pt.exp(-(p + q) * t))
_check_time(t)
return (1 - pmd.math.exp(-(p + q) * t)) / (1 + (q / p) * pmd.math.exp(-(p + q) * t))


def f(
p: float | pt.TensorVariable,
q: float | pt.TensorVariable,
t: float | pt.TensorVariable,
) -> pt.TensorVariable:
p: float | XTensorVariable,
q: float | XTensorVariable,
t: XTensorVariable | pt.TensorVariable,
) -> XTensorVariable:
r"""Installed base fraction rate of change (adoption rate).

This function calculates the rate of new adoptions at time t as a
Expand All @@ -209,16 +238,16 @@ def f(

Parameters
----------
p : float or TensorVariable
p : float or XTensorVariable
Coefficient of innovation (external influence)
q : float or TensorVariable
q : float or XTensorVariable
Coefficient of imitation (internal influence)
t : array-like or TensorVariable
t : XTensorVariable or scalar TensorVariable
Time points

Returns
-------
TensorVariable
XTensorVariable
The adoption rate at each time point as a fraction of potential market

Notes
Expand All @@ -237,9 +266,93 @@ def f(

The peak adoption rate occurs at time :math:`t^* = \frac{\ln(q/p)}{p+q}`
"""
return (p * pt.square(p + q) * pt.exp(t * (p + q))) / pt.square(
p * pt.exp(t * (p + q)) + q
_check_time(t)
exp_t = pmd.math.exp(t * (p + q))
return (p * (p + q) ** 2 * exp_t) / (p * exp_t + q) ** 2


def _create_likelihood_variable(
prior: Prior | Censored,
name: str,
mu: XTensorVariable,
observed: XTensorVariable | None,
) -> XTensorVariable:
"""Create the outcome variable, observed or not.

``create_likelihood_variable`` is for the observed case only: a
likelihood needs data, so pymc_extras refuses ``observed=None`` there
(pymc-devs/pymc-extras#731). Prior predictive still needs the outcome
node, so build it with ``create_variable`` and ``mu`` attached, keeping
the same guards the pymc_extras method applies.
"""
if observed is not None:
return prior.create_likelihood_variable(
name, mu=mu, observed=observed, xdist=True
)

# Censored keeps its parameters on the wrapped distribution.
inner = prior.distribution if isinstance(prior, Censored) else prior
if "mu" not in signature(inner.pymc_distribution.dist).parameters:
raise UnsupportedDistributionError(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_likelihood_variable is only called with data: pymc-extras refuses observed=None there since pymc-devs/pymc-extras#732. The unobserved node goes through create_variable with mu attached instead, so the two guards that method applies (UnsupportedDistributionError for a mu-less distribution, MuAlreadyExistsError) are mirrored here to keep both paths raising the same typed errors. Covered by test_mu_less_likelihood_without_observed_raises and test_unobserved_skips_create_likelihood_variable.

f"Likelihood distribution {inner.distribution!r} is not supported."
)
if "mu" in inner.parameters:
raise MuAlreadyExistsError(inner)

# TODO(pymc-devs/pymc-extras#731): drop this branch once observed=None is
# supported upstream. Rebind rather than mutate the copy's parameters, so
# the model keeps the caller's own tensors instead of deepcopied clones.
unobserved = inner.deepcopy()
unobserved.parameters = {**inner.parameters, "mu": mu}
outcome: Prior | Censored = (
Censored(unobserved, lower=prior.lower, upper=prior.upper)
if isinstance(prior, Censored)
else unobserved
)
return outcome.create_variable(name, xdist=True)


@contextmanager
def _borrow_dims(prior: Prior | Censored, dims: tuple[str, ...]):
"""Lend ``dims`` to ``prior`` for the block, leaving it as it was found.

Setting ``dims`` outright would leave the caller's prior carrying this
model's dims, so a config reused for a second model fails on dims that
model does not have. A copy is not used instead: ``Prior.__deepcopy__``
also copies ``parameters``, which would hand the model clones of any
tensor the caller passed in. ``Censored.dims`` forwards to the wrapped
distribution, so both types are covered.
"""
original = prior.dims
prior.dims = dims
try:
yield prior
finally:
prior.dims = original


def _observed_dims(
observed: Any, model: Model, combined_dims: tuple[str, ...]
) -> tuple[str, ...]:
"""Axis labels for ``observed``: its own, else the model's, else positional.

An ``xr.DataArray`` carries its labels; a registered ``pm.Data`` has them
on the model. Anything else is labelled positionally in ``combined_dims``
order.
"""
own = getattr(observed, "dims", None)
if own:
return tuple(own)

# Name equality is not identity: a variable that never reached the model
# can share a name with one that did, and would borrow its dims.
name = getattr(observed, "name", None)
if name is not None and model.named_vars.get(name) is observed:
registered = model.named_vars_to_dims.get(name)
if registered and all(dim is not None for dim in registered):
return tuple(registered)

return combined_dims


class BassPriors(TypedDict):
Expand All @@ -253,7 +366,7 @@ class BassPriors(TypedDict):

def create_bass_model(
t: pt.TensorLike,
observed: pt.TensorLike | None,
observed: pt.TensorLike | xr.DataArray | None,
priors: BassPriors,
coords: dict[str, Any],
model: Model | None = None,
Expand All @@ -279,9 +392,15 @@ def create_bass_model(
----------
t : pt.TensorLike
Time points for which the adoption is modeled.
observed : pt.TensorLike | None
observed : pt.TensorLike or xr.DataArray or None
Observed adoption data at each time point. If None, only
prior predictive sampling is possible.
prior predictive sampling is possible. Axis labels are read from
the data itself (an ``xr.DataArray``) or from the model (a
``pm.Data`` registered with dims); anything else, such as a plain
array or a ``pm.Data`` without dims, is labelled positionally in
``(T, ...)`` order with the extra dims following their first
appearance across the ``p``, ``q``, ``m`` and ``likelihood``
priors, in that order.
priors : BassPriors
Dictionary containing priors for:
- 'm': Market potential prior
Expand Down Expand Up @@ -319,48 +438,49 @@ def create_bass_model(
"""
model = model or pm.Model(coords=coords)
with model:
parameter_dims = (
set(priors["p"].dims or ())
.union(priors["q"].dims or ())
.union(priors["m"].dims or ())
# Declaration order, not set order: `combined_dims` labels the axes of
# `observed` positionally, so an order that varies between processes
# would silently mislabel the data.
declared_dims = (
*(priors["p"].dims or ()),
*(priors["q"].dims or ()),
*(priors["m"].dims or ()),
*(getattr(priors["likelihood"], "dims", ()) or ()),
)
likelihood_dims = set(getattr(priors["likelihood"], "dims", ()) or ())

combined_dims = (
"T",
*tuple(parameter_dims.union(likelihood_dims).difference(["T"])),
)
dim_handler = create_dim_handler(combined_dims)

m = dim_handler(priors["m"].create_variable("m"), priors["m"].dims)
p = dim_handler(priors["p"].create_variable("p"), priors["p"].dims)
q = dim_handler(priors["q"].create_variable("q"), priors["q"].dims)

time = dim_handler(t, "T")

adopters = pm.Deterministic("adopters", m * f(p, q, time), dims=combined_dims)

pm.Deterministic(
"innovators",
m * p * (1 - F(p, q, time)),
dims=combined_dims,
)
pm.Deterministic(
"imitators",
m * q * F(p, q, time) * (1 - F(p, q, time)),
dims=combined_dims,
*(dim for dim in dict.fromkeys(declared_dims) if dim != "T"),
)

peak = (pt.log(q) - pt.log(p)) / (p + q)
peak_dims = tuple(parameter_dims) if parameter_dims else None
pm.Deterministic("peak", peak, dims=peak_dims)

priors["likelihood"].dims = combined_dims
priors["likelihood"].create_likelihood_variable( # type: ignore
"y",
mu=adopters,
observed=observed,
time = pmd.as_xtensor(t, dims=("T",))
m = priors["m"].create_variable("m", xdist=True)
p = priors["p"].create_variable("p", xdist=True)
q = priors["q"].create_variable("q", xdist=True)

def deterministic(name: str, value: XTensorVariable) -> XTensorVariable:
"""Store ``value`` with the dims it has, in ``combined_dims`` order."""
order = tuple(dim for dim in combined_dims if dim in value.dims)
return pmd.Deterministic(name, value, dims=order)

adopters = deterministic("adopters", m * f(p, q, time))
deterministic("innovators", m * p * (1 - F(p, q, time)))
deterministic("imitators", m * q * F(p, q, time) * (1 - F(p, q, time)))
deterministic("peak", (pmd.math.log(q) - pmd.math.log(p)) / (p + q))

observed_xt = (
None
if observed is None
else pmd.as_xtensor(
observed, dims=_observed_dims(observed, model, combined_dims)
)
)
with _borrow_dims(priors["likelihood"], combined_dims) as likelihood:
_create_likelihood_variable(
likelihood,
"y",
mu=adopters,
observed=observed_xt,
)

return model

Expand Down
Loading
Loading