Skip to content
Open
Show file tree
Hide file tree
Changes from 39 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
221 changes: 173 additions & 48 deletions pymc_marketing/bass/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@

"""

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

import arviz as az
Expand All @@ -142,6 +143,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 +152,13 @@
)
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,
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 +167,39 @@
from pymc_marketing.version import __version__


def _exp(
x: float | pt.TensorVariable | XTensorVariable,
) -> pt.TensorVariable | XTensorVariable:
"""``exp`` that works for floats, PyTensor tensors, and xtensor variables.

Lets :func:`F` and :func:`f` be used both with NumPy/float inputs and with
the named-dims (xtensor) variables of the ``pymc.dims`` model graph.
"""
return pmd.math.exp(x) if isinstance(x, XTensorVariable) else pt.exp(x)


def F(
p: float | pt.TensorVariable,
q: float | pt.TensorVariable,
t: float | pt.TensorVariable,
) -> pt.TensorVariable:
p: float | pt.TensorVariable | XTensorVariable,
q: float | pt.TensorVariable | XTensorVariable,
t: float | pt.TensorVariable | 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, TensorVariable or XTensorVariable
Coefficient of innovation (external influence)
q : float or TensorVariable
q : float, TensorVariable or XTensorVariable
Coefficient of imitation (internal influence)
t : array-like or TensorVariable
t : array-like, TensorVariable or XTensorVariable
Time points

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

Notes
Expand All @@ -193,14 +212,14 @@ 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))
return (1 - _exp(-(p + q) * t)) / (1 + (q / p) * _exp(-(p + q) * t))


def f(
p: float | pt.TensorVariable,
q: float | pt.TensorVariable,
t: float | pt.TensorVariable,
) -> pt.TensorVariable:
p: float | pt.TensorVariable | XTensorVariable,
q: float | pt.TensorVariable | XTensorVariable,
t: float | pt.TensorVariable | 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 +228,16 @@ def f(

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

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

Notes
Expand All @@ -237,9 +256,103 @@ 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
exp_t = _exp(t * (p + q))
return (p * (p + q) ** 2 * exp_t) / (p * exp_t + q) ** 2


def _supports_xdist(prior: Prior | Censored | VariableFactory) -> bool:
"""Whether ``prior`` can build itself directly in ``pymc.dims`` space."""
if "xdist" not in signature(prior.create_variable).parameters:
# A VariableFactory written against the ``create_variable(name)``
# signature the protocol documents.
return False

if isinstance(prior, Censored):
return hasattr(pmd, "Censored") and _supports_xdist(prior.distribution)

if isinstance(prior, Prior):
return hasattr(pmd, prior.distribution)

# A factory that takes the kwarg handles its own dispatch.
return True


def _create_dim_variable(
prior: Prior | Censored | VariableFactory, name: str
) -> XTensorVariable:
"""Create a named-dims (xtensor) variable for a prior.

Uses ``xdist=True`` so the variable lives in xtensor space from the start.
Whatever cannot take that route -- a distribution ``pymc.dims`` does not
implement (``DiracDelta`` before pymc 6.2, ``Wald``, ...) or a custom
factory written against the ``create_variable(name)`` signature -- gets a
regular variable wrapped with ``pmd.as_xtensor``.
"""
if not _supports_xdist(prior):
return pmd.as_xtensor(prior.create_variable(name), dims=prior.dims or ())
return prior.create_variable(name, xdist=True)


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

A distribution ``pymc.dims`` does not implement gets the regular pymc
path, on the underlying tensors in ``dims`` order.

``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 ``mu`` guard the pymc_extras method applies.
"""
xdist = _supports_xdist(prior)

if observed is not None:
if xdist:
return prior.create_likelihood_variable(
name, mu=mu, observed=observed, xdist=True
)
return prior.create_likelihood_variable(
name,
mu=mu.transpose(*dims).values,
observed=observed.transpose(*dims).values,
)

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

unobserved = inner.deepcopy()
unobserved.parameters["mu"] = mu if xdist else mu.transpose(*dims).values
outcome: Prior | Censored = (
Censored(unobserved, lower=prior.lower, upper=prior.upper)
if isinstance(prior, Censored)
else unobserved
)
return outcome.create_variable(name, xdist=xdist)


def _align_to_dims(
var: XTensorVariable, dims: tuple[str, ...], model: Model
) -> XTensorVariable:
"""Broadcast ``var`` up to ``dims``, in that order.

``pmd.Deterministic`` takes the dims off the graph and only transposes
them, so a quantity built from parameters that do not carry every model
dim would be stored without those dims. Sizes come from the model, which
knows every dim it has registered, coords argument or not.
"""
missing = {dim: model.dim_lengths[dim] for dim in dims if dim not in var.dims}
if missing:
var = var.expand_dims(missing)
return var.transpose(*dims)


class BassPriors(TypedDict):
Expand Down Expand Up @@ -319,47 +432,59 @@ 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 for dim in dict.fromkeys(declared_dims) if dim != "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 = pmd.as_xtensor(t, dims=("T",))
m = _create_dim_variable(priors["m"], "m")
p = _create_dim_variable(priors["p"], "p")
q = _create_dim_variable(priors["q"], "q")

time = dim_handler(t, "T")
def deterministic(name: str, value: XTensorVariable) -> XTensorVariable:
return pmd.Deterministic(name, _align_to_dims(value, combined_dims, model))

adopters = pm.Deterministic("adopters", m * f(p, q, time), dims=combined_dims)
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)))

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,
# `peak` carries only the parameter dims, but in `combined_dims` order
# so it agrees with the deterministics above.
peak = (pmd.math.log(q) - pmd.math.log(p)) / (p + q)
pmd.Deterministic(
"peak", peak.transpose(*(dim for dim in combined_dims if dim in peak.dims))
)

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
if observed is None:
observed_xt = None
else:
# The data knows its own axis labels, which `combined_dims` need
# not match: an `xr.DataArray` carries them, a registered
# `pm.Data` has them on the model.
observed_dims = getattr(observed, "dims", None) or (
model.named_vars_to_dims.get(
getattr(observed, "name", None), combined_dims
)
)
observed_xt = pmd.as_xtensor(observed, dims=tuple(observed_dims))

_create_likelihood_variable(
priors["likelihood"],
"y",
mu=adopters,
observed=observed,
observed=observed_xt,
dims=combined_dims,
)

return model
Expand Down
6 changes: 5 additions & 1 deletion pymc_marketing/mlflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,11 @@ def log_model_graph(model: Model, path: str | Path) -> None:

def _get_random_variable_name(rv) -> str:
# Taken from new version of pymc/model_graph.py
symbol = rv.owner.op.__class__.__name__
op = rv.owner.op

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.

Removed the # Taken from new version of pymc/model_graph.py line here: it dated to a900f97 (2024) and pymc's random_variable_symbol has since diverged (it now prefers op.name with a class-name fallback, and has no core_op unwrap), so the reference no longer described this helper. The core_op line is local to this PR.

# A `pymc.dims` variable wraps the real RV in a generic `XRV`, which would
# otherwise be reported as "X". The distribution is on the wrapped op.
op = getattr(op, "core_op", op)
symbol = op.__class__.__name__

if symbol.endswith("RV"):
symbol = symbol[:-2]
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ dependencies = [
"pandas",
"patsy",
"pydantic>=2.1.0",
"pymc>=6.0.0,<6.1.0",
"pymc>=6.0.1,<6.1.0",
"pytensor>=3.0.0,<4.0",
"scikit-learn>=1.1.1",
"seaborn>=0.12.2",
Expand Down
Loading