Skip to content

refactor(bass): migrate dim handling to pymc.dims - #2771

Open
anevolbap wants to merge 58 commits into
pymc-labs:mainfrom
anevolbap:feat/2598-bass-pmd-dims
Open

refactor(bass): migrate dim handling to pymc.dims#2771
anevolbap wants to merge 58 commits into
pymc-labs:mainfrom
anevolbap:feat/2598-bass-pmd-dims

Conversation

@anevolbap

@anevolbap anevolbap commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Closes #2598

Migrates the Bass model's dimension handling from create_dim_handler (pymc_extras.prior) to pymc.dims, for consistency with the rest of the codebase.

Reopens #2724, which GitHub auto-closed when the v1.0.0 branch was deleted and merged into main. Same work, retargeted to main.

In create_bass_model:

  • m, p, q and the likelihood via create_variable(xdist=True); deterministics via pmd.Deterministic.
  • The public F and f take xtensor inputs, so there is no second copy of the formulas for the model graph.
  • t and y_obs stay pm.Data wrapped with pmd.as_xtensor, so the data-setter and out-of-sample forecasting keep working.
  • Deterministics carry the dims their parameters have, transposed to (T, ...). They are not broadcast up to the likelihood's dims: pooled parameters give pooled curves, and a per-product curve is the user's to build outside the model.
  • This is a user-visible change: on main the deterministics carried the union of the p, q and m dims.
  • Without data (prior predictive), the outcome variable is built with create_variable and mu set, keeping the UnsupportedDistributionError and MuAlreadyExistsError guards, since upstream refuses observed=None (Prior.create_likelihood_variable(observed=None) fails on the xdist path pymc-devs/pymc-extras#731).
  • Priors must use distributions pymc.dims implements, and a custom VariableFactory must take the xdist keyword.

DiracDelta is native in pymc.dims on the pymc>=6.2 floor, so #2726 has nothing left to remove and can be closed.

Verified: full bass test suite, mlflow autolog, adopters/peak matching the closed-form formula to machine precision, and the bass notebook mock runner.


📚 Documentation preview 📚: https://pymc-marketing--2771.org.readthedocs.build/en/2771/

BassModel.load left model_config as plain dicts after the JSON round-trip,
so build_model crashed on any loaded model. Parse the config in __init__
like CLV does, and register Scaled in the TypeRegistry so the scaled
market-potential prior round-trips too.
_data_setter filled y_obs with 1-D zeros, so out-of-sample prediction
failed for multi-product models.
Rebuilt on the BassModel workflow and re-executed on the v1 stack
(pymc 6, pytensor 3, arviz 1.2, arviz-plots 1.2). Migrates the arviz
plotting calls: plot_trace -> azp.plot_trace_dist, plot_forest and
plot_posterior -> arviz_plots PlotCollection idioms, az.hdi ci_bound
coords, and the sample_posterior_predictive DataArray return. Drops the
trailing empty cell so the watermark is last.
- plot_peak: pass col_wrap=3 so multi-product peaks wrap into a grid
  instead of one squished row (williambdean)
- re-executed with ipywidgets installed, so the sampler progress bars
  render as widgets and the 'install ipywidgets' warning is gone; matches
  the other v1 re-run notebooks (williambdean)

plot_decomposition legend now sits outside the axes via the fix in the
stacked plotting PR.
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

@github-actions github-actions Bot added docs Improvements or additions to documentation tests Bass model Dealing with the Bass Defusion model labels Jul 28, 2026
@anevolbap
anevolbap marked this pull request as draft July 28, 2026 16:49
@anevolbap
anevolbap marked this pull request as ready for review July 28, 2026 16:53
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.83%. Comparing base (66d575d) to head (18a2e78).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2771      +/-   ##
==========================================
+ Coverage   94.81%   94.83%   +0.01%     
==========================================
  Files         111      111              
  Lines       17623    17665      +42     
==========================================
+ Hits        16710    16752      +42     
  Misses        913      913              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

- p prior mu=0.02 so constrain(0.01, 0.03) is satisfiable, clearing the preliz mass warning and the leaked local path
- draw observed cumulative as reference lines since plot_dist has no ref_val argument
- forecast window np.arange(len(T) + 26), 26 points past the last observation
- drop the %autoreload dev magics; save/load and mlflow write to temp dirs
- re-executed top to bottom on the v1 stack (pymc 6.0.1, arviz 1.2)
@anevolbap
anevolbap force-pushed the feat/2598-bass-pmd-dims branch from a4a743b to 79fa653 Compare July 29, 2026 10:39
- _data_setter builds the no-observed placeholder from y_obs's declared dims instead of assuming T is axis 0
- to_bass_dataset transposes an xr.Dataset so T leads, so a (product, T) dataset fits and forecasts
- tests: transposed-dataset forecast, and assert model_config equality on the save/load round-trip
- sample_prior_bass_data returns the full draw so the true m, p, q are kept
- the p/q forests and the m plot mark the true per-product values instead of prior means
- show the restored model_config on load; note plot_peak's col_wrap/figure_kwargs API
- re-executed on the v1 stack
Replace create_dim_handler (pymc_extras.prior) with pymc.dims, closing
the last major create_dim_handler usage outside the Prior class (pymc-labs#2598).

- m/p/q created with xdist=True; deterministics via pmd.Deterministic
- inline xtensor _f_xt/_F_xt for the model graph; public F/f stay on
  PyTensor for the float/NumPy API and tests
- keep t and y_obs as pm.Data wrapped with pmd.as_xtensor so the
  data-setter and out-of-sample forecasting keep working
- transpose deterministics back to the historical (T, ...) dim order
- DiracDelta is not yet in pymc.dims: fall back to a regular variable
  wrapped with pmd.as_xtensor for that distribution only

Verified: 78 bass tests, autolog_bass, machine-precision adopters/peak
vs the closed-form formula, and the bass notebook mock runner.
- consolidate F/f: a single _exp helper dispatches pmd.math.exp for xtensor
  and pt.exp otherwise, so F/f serve both the float/NumPy API and the model
  graph; drop the duplicate _f_xt/_F_xt (williambdean)
- drop the _ordered transpose; let pymc.dims order the deterministic dims,
  consumers select by name (williambdean)
@anevolbap
anevolbap force-pushed the feat/2598-bass-pmd-dims branch from 79fa653 to 3f639e1 Compare July 29, 2026 11:02
@williambdean

Copy link
Copy Markdown
Contributor

Updating the base branch. Should work now with pymc version

BassModel.load left model_config as plain dicts after the JSON round-trip,
so build_model crashed on any loaded model. Parse the config in __init__
like CLV does, and register Scaled in the TypeRegistry so the scaled
market-potential prior round-trips too.
_data_setter filled y_obs with 1-D zeros, so out-of-sample prediction
failed for multi-product models.
Rebuilt on the BassModel workflow and re-executed on the v1 stack
(pymc 6, pytensor 3, arviz 1.2, arviz-plots 1.2). Migrates the arviz
plotting calls: plot_trace -> azp.plot_trace_dist, plot_forest and
plot_posterior -> arviz_plots PlotCollection idioms, az.hdi ci_bound
coords, and the sample_posterior_predictive DataArray return. Drops the
trailing empty cell so the watermark is last.
- plot_peak: pass col_wrap=3 so multi-product peaks wrap into a grid
  instead of one squished row (williambdean)
- re-executed with ipywidgets installed, so the sampler progress bars
  render as widgets and the 'install ipywidgets' warning is gone; matches
  the other v1 re-run notebooks (williambdean)

plot_decomposition legend now sits outside the axes via the fix in the
stacked plotting PR.
- p prior mu=0.02 so constrain(0.01, 0.03) is satisfiable, clearing the preliz mass warning and the leaked local path
- draw observed cumulative as reference lines since plot_dist has no ref_val argument
- forecast window np.arange(len(T) + 26), 26 points past the last observation
- drop the %autoreload dev magics; save/load and mlflow write to temp dirs
- re-executed top to bottom on the v1 stack (pymc 6.0.1, arviz 1.2)
- _data_setter builds the no-observed placeholder from y_obs's declared dims instead of assuming T is axis 0
- to_bass_dataset transposes an xr.Dataset so T leads, so a (product, T) dataset fits and forecasts
- tests: transposed-dataset forecast, and assert model_config equality on the save/load round-trip
- sample_prior_bass_data returns the full draw so the true m, p, q are kept
- the p/q forests and the m plot mark the true per-product values instead of prior means
- show the restored model_config on load; note plot_peak's col_wrap/figure_kwargs API
- re-executed on the v1 stack
@williambdean

Copy link
Copy Markdown
Contributor

Looking at the CI, the oldest-deps shard installs pymc==6.0.0 which lacks pmd.Poisson and pmd.NegativeBinomial. Bump the floor to 6.0.1 in pyproject.toml and it should be green.

The pymc.dims migration uses pmd.Poisson/NegativeBinomial, which landed in pymc 6.0.1, so the oldest-deps CI shard must resolve 6.0.1 instead of 6.0.0.
- test_save_load_round_trip_scaled_priors: p prior mu=0.02, drops the preliz mass warning (3 of the suite's 4)
- _from_xarray transposes before adding the T coord, so a (product, T) Dataset with no T coord gets the right T length; regression test added
@anevolbap

Copy link
Copy Markdown
Contributor Author

Addressed in 1bc024c:

  1. Added a bullet to the PR description flagging the peak shape change (dims narrowed to what p and q declare, m-only indexing stops working).
  2. Added test_observed_data_without_dims_is_labelled_positionally, checking that a pm.Data without dims gets the same labelling and logp as one registered with explicit dims, plus a note in the observed docstring about the positional fallback.
  3. Added a sentence to the _supports_xdist docstring: a factory that hides xdist behind **kwargs takes the wrapping path, declaring the parameter explicitly takes the native one.

9f7af31 merges main to resolve the pyproject.toml/uv.lock conflict from #2883; the dependency files now match main and dropped out of the diff.

@daimon-pymclabs

Copy link
Copy Markdown
Contributor

Checked 1bc024c and 9f7af31. All three notes are closed.

The PR description now flags the peak dim narrowing and the loss of m-only indexing, which was the only item anyone downstream needed warning about. test_observed_data_without_dims_is_labelled_positionally pins the fallback the way I wanted it pinned, comparing labelling and logp against the explicitly-dimmed pm.Data, and the observed docstring now states where labels are read from and what the positional (T, ...) order means. The _supports_xdist docstring says the check reads the literal signature, so **kwargs gets the wrapping path.

The merge drops pyproject.toml and uv.lock out of the diff and CI is green apart from the two skipped PyPI upload jobs. Nothing further from me.

anevolbap and others added 3 commits August 20, 2026 14:08
Address review findings:

- The unobserved path now raises UnsupportedDistributionError for a
  mu-less likelihood, same as create_likelihood_variable, instead of a
  raw TypeError.
- A dim declared on a prior but missing from the model coords raises a
  clear ValueError instead of a bare KeyError out of dim_lengths.
- Docstring fixes: name the real positional labelling order (p, q, m,
  likelihood), drop the stale DiracDelta example, drop the dead
  hasattr(pmd, "Censored") check (always true at the pinned floor).
Comment thread pymc_marketing/bass/model.py Outdated
# 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.

anevolbap and others added 4 commits August 20, 2026 08:51
…d dims

- _supports_xdist now recurses into nested priors, so a distribution
  missing from pymc.dims anywhere in the tree takes the fallback path
  instead of raising UnsupportedDistributionError.
- An observed dim the model does not know raises a clear ValueError
  instead of a KeyError from inside pymc.dims.
Comment thread pymc_marketing/mlflow.py
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.

@williambdean

williambdean commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Thanks for all of the iteraetion on this, @anevolbap! I think we are over complicating a bit of this. We can leverage xtensor and the checks that come from pymc, xtensor, pymc_extras, etc. Here are some items:

All 6 helper functions are redundant with existing APIs.

_supports_xdist (inspect.signature check) — Prior, Censored, and VariableFactory all accept xdist: bool = False. pymc_extras handles the introspection internally. Just pass xdist=True.

_expptx.math.exp works on floats, xtensor, and xtensor scalars. No dispatch needed.

_create_dim_variableprior.create_variable(name, xdist=True) is one line.

_align_to_dimspmd.Deterministic(name, value, dims=...) already broadcasts and transposes.

_check_dims_knownpmd.Deterministic / pmd.Data raise ValueError for missing dims.

_create_likelihood_variablePrior.create_likelihood_variable already has all guards (UnsupportedDistributionError, MuAlreadyExistsError). For observed: prior.create_likelihood_variable(xdist=True). For unobserved: attach mu manually + create_variable (recommended workaround for pymc-extras#731, future-proof for upcoming deprecation).

The MMM codebase uses Prior.create_variable(xdist=True), ptx.math.*, and pmd.Deterministic with dims= throughout (transformers.py, link.py, fourier.py, additive_effect.py). The Bass model should match.

F() and f() need ptx.math.exp and ptx.math.squarept.exp / pt.square raise TypeError on xtensor variables.

test_derivativepytensor.grad cannot work on xtensor graphs. @pytest.mark.xfail until pytensor#2337 merges.

~6 tests in TestBassModelXdistFallbacks exercise removed code paths and should be dropped.

combined_dims declaration-order fix, peak dims change, core_op unwrap in mlflow, and all TestBassModelDims tests are correct and should stay.

@anevolbap

anevolbap commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed pass, it made me run every item against the released stack (pymc 6.2.0, pymc-extras 0.14.0). Good news first: the observed path does collapse to create_likelihood_variable(xdist=True), _check_dims_known partly goes: 6.2 raises ValueError at dims= registration (m/p/q), though the observed path and the deterministic broadcast still need it, the bare failure there is a KeyError from inside pymc, and the unobserved branch is already the #731 mu-attach workaround. Three items break when run, though:

  • Prior("Wald", mu=10, lam=1).create_variable("w", xdist=True) raises UnsupportedDistributionError (nested case too), and a VariableFactory without the kwarg raises TypeError. 0.14 has no internal fallback; that is what _supports_xdist covers.
  • ptx.math.exp raises TypeError on numpy arrays and dim-less tensor vectors, and pytensor.grad fails on the result. _exp keeps the public F/f working on the inputs they accept today, which also keeps test_derivative green with no xfail.
  • pmd.Deterministic(dims=...) transposes but does not broadcast: a value on ("T",) with dims=("T", "product") raises ValueError. The broadcast is what _align_to_dims adds.

So the full cleanup means narrowing the contract to pymc.dims-supported distributions and xdist-aware factories, same as MMM. Is that the intent? If yes, the fallback helpers and the ~6 tests go and Wald-style priors stop working; happy to push that version.

@williambdean

Copy link
Copy Markdown
Contributor

We don't need to support everything. We want to leverage xtensor mainly like with the MMM implementation.
No need to "broadcast" a Deterministic. If it doesn't have that dimension, we can leave it to the user to do what they would like with it outside of the model.

The model block should be lean.

@anevolbap

Copy link
Copy Markdown
Contributor Author

Pushed the lean version (42039a8): _supports_xdist, _create_dim_variable, _check_dims_known and _align_to_dims are gone, m/p/q and the likelihood go straight through create_variable(xdist=True), and the deterministics are no longer broadcast. 108 bass tests green, model block down ~90 lines.

Two things stayed, with the reason:

  • _exp in F/f. ptx.math.exp raises TypeError: Cannot convert [...] to XTensorType on a plain ndarray, so f(0.03, 0.38, np.arange(5)) and every fixture built on it stops working, and pytensor.grad then fails on the xtensor graph. Keeping the dispatch is also what avoids the test_derivative xfail.
  • The deterministics still get a transpose into (T, ...). Without it m * f(p, q, t) comes out as ('product', 'T') and the stored curves flip layout. No broadcasting, just the order.

One tradeoff to flag: without _check_dims_known, an unknown dim on observed now surfaces as KeyError: 'geo' from inside pymc_extras instead of a message naming the coord. The likelihood-dims case is unaffected, pymc's own error there is already clear. Happy to inline a two-line guard if that message is worth keeping.

@williambdean

williambdean commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Why the _exp 🥲

Just update the fixtures 😅

@anevolbap

anevolbap commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

As Claude usually says, "You're absolutely right!" — _exp is gone (cbaed2f).

On test_derivative: skipped the xfail. Lowering the graph first (rewrite_graph(..., include=("lower_xtensor",))) lets pytensor.grad walk it, so the dF/dt against f check still runs instead of going dormant until pytensor#2337 lands.

@daimon-pymclabs

Copy link
Copy Markdown
Contributor

Review notes. Two of these I'd want closed before merge, two are cheap and belong in this PR rather than a follow-up. Everything else I found is listed at the bottom as non-blocking.


1. copy.deepcopy(priors["likelihood"]) detaches tensor-valued parameters (blocker)

model.py:409. The comment above it is right about the problem (mutating the caller's prior leaks this model's dims into a reused config) but deepcopy is a heavier instrument than the problem needs, and it has a side effect.

Prior.__deepcopy__ does copy.deepcopy(self.parameters), so any pytensor variable sitting in a parameter is cloned. Confirmed on pytensor 3.3.0:

>>> x = pt.scalar("x"); copy.deepcopy(x) is x
False
>>> copy.deepcopy(x * 2).owner.inputs[0] is x
False

So for a likelihood like Prior("Normal", sigma=some_pm_data), the y that gets built is wired to a clone of some_pm_data that is not in the model. pm.set_data on the original then silently stops affecting y — no error, just a posterior that ignores the update. Prior.deepcopy() is not an escape hatch, it is copy.deepcopy(self).

The mutation being guarded against is exactly one attribute, so scope it to that attribute. Censored.dims is a property whose setter forwards to self.distribution.dims, so the same code covers both types:

from contextlib import contextmanager

@contextmanager
def _borrow_dims(prior: Prior | Censored, dims: tuple[str, ...]):
    """Set ``dims`` for the duration of the block, leaving the caller's prior as found.

    A copy is not usable here: ``Prior.__deepcopy__`` deep-copies ``parameters``,
    which clones any pytensor variable it finds and detaches it from the model
    graph, so a later ``set_data`` would no longer reach the likelihood.
    """
    original = prior.dims
    prior.dims = dims
    try:
        yield prior
    finally:
        prior.dims = original

and at the call site:

with _borrow_dims(priors["likelihood"], combined_dims) as likelihood:
    observed_xt = ...  # unchanged
    _create_likelihood_variable(likelihood, "y", mu=adopters, observed=observed_xt)

test_likelihood_prior_is_untouched passes either way, which is why it did not catch this. The regression test that would:

def test_shared_likelihood_parameter_stays_connected():
    """A tensor in a likelihood parameter must remain the model's own node."""
    with pm.Model(coords={"T": np.arange(5)}) as model:
        sigma = pm.Data("sigma", 1.0)
        create_bass_model(
            t=np.arange(5),
            observed=np.arange(5),
            priors={..., "likelihood": Prior("Normal", sigma=sigma, dims="T")},
            coords={"T": np.arange(5)},
            model=model,
        )
    assert sigma in pytensor.graph.basic.ancestors([model["y"]])

Same hazard applies to inner.deepcopy() at model.py:284. There the copy is genuinely needed (you are adding mu to parameters), so it cannot just be dropped — but it means the observed=None path detaches tensor parameters too. Minimum viable: copy the container and rebind the original parameter objects,

unobserved = inner.deepcopy()
unobserved.parameters = {**inner.parameters, "mu": mu}

which keeps every original parameter object identical while still leaving inner untouched.

2. The observed dim lookup can pick up an unrelated variable's dims (blocker)

model.py:417-420:

observed_dims = getattr(observed, "dims", None) or (
    model.named_vars_to_dims.get(getattr(observed, "name", None), combined_dims)
)

By the time this runs, named_vars_to_dims holds adopters, innovators, imitators, peak, m, p, q. A plain pt.TensorVariable that happens to be named peak — not registered with the model, just named — gets labelled with peak's dims and transposed without complaint. Name equality is being used as a proxy for identity.

Also, pm.Data can be registered with a partially-None dims tuple, and pmd.as_xtensor(obs, dims=(None, "product")) fails from deep inside with an error that does not point back here.

Both are covered by one helper:

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

    name = getattr(observed, "name", None)
    # Name equality is not identity: an unregistered variable may share a name
    # with a model variable, and would otherwise borrow its dims.
    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

Test:

def test_unregistered_variable_sharing_a_name_is_labelled_positionally():
    """Name equality must not be mistaken for identity."""
    # a bare variable named "peak", never registered on the model
    ...
    assert model["y"].dims == ("T", "product")  # not peak's dims

3. F / f lost plain-array support with no deprecation path (should fix here)

These are public and documented as array-like. F(p, q, np.arange(10)) worked on main; now every call site needs pmd.as_xtensor, and every test in this diff was updated to do exactly that. The module docstring records the change, but nothing in the code does, and there is no test pinning what a user hitting this actually sees.

The signatures also disagree with the tests: t: XTensorVariable, but test_derivative still passes pt.scalar.

Two options, either is fine, but pick one explicitly:

a. Accept array-like and wrap — no break, one line each:

def _as_time(t) -> XTensorVariable:
    """Accept an xtensor, or label a 1-d array-like positionally as ``("T",)``."""
    if isinstance(t, XTensorVariable):
        return t
    return pmd.as_xtensor(pt.as_tensor(t), dims=("T",))

b. Keep the break and make it legible — raise at the boundary and test the message:

if not isinstance(t, XTensorVariable):
    raise TypeError(
        f"`t` must be an XTensorVariable, got {type(t).__name__}. "
        "Wrap plain arrays with `pymc.dims.as_xtensor(t, dims=('T',))`."
    )

plus pytest.raises(TypeError, match="as_xtensor"), and fix the annotations so the 0-d/scalar cases the tests rely on are actually covered by the declared type.

4. The deterministics dim change needs to be in the release notes (should fix here)

Dropping the broadcast so pooled p/q/m produce pooled curves is the right call and is well tested, but it changes posterior shapes and turns plot_* output for existing multi-series users from N subplots into one. The PR body says so; nothing in the repo does. With no CHANGELOG file, that means the release notes explicitly, along the lines of:

Breaking: adopters, innovators, imitators and peak now carry only the dims their parameters carry. Previously they were broadcast to all combined dims, so a pooled p/q/m produced N identical curves; they now produce one. Posterior shapes and plot_* subplot counts change accordingly for models where any of p, q, m is pooled. Custom VariableFactory implementations for m/p/q must now accept an xdist keyword.

Worth an eyeball on the bass example notebook to confirm its narrative still matches the new shapes.


Non-blocking

  • _create_likelihood_variable reimplements pymc-extras internals (signature(inner.pymc_distribution.dist).parameters). The docstring cites pymc-extras#731; add a # TODO(pymc-extras#731): drop once observed=None is supported upstream at the code so it gets deleted rather than fossilised.
  • observed: pt.TensorLike | None (model.py:305) should include xr.DataArray in the annotation and docstring — several new tests depend on that path.
  • The deterministic() closure builds order by filtering combined_dims, so a dim on value that is not in combined_dims is dropped from the label rather than raising. Unreachable today; assert set(value.dims) <= set(combined_dims) keeps it that way.
  • BassPriors types m/p/q as VariableFactory, but factories must now accept xdist. One that does not gets a bare TypeError from deep inside; a checked error naming the parameter would be kinder.
  • Plotting error text: when the user passes an explicit unknown dim, "give p, q or m that dim" is the wrong advice for what is usually a typo'd dim name.
  • Test gaps: a Censored likelihood with extra dims and observed=None never exercises dim propagation through the reconstructed Censored(unobserved, ...) — the existing test only covers combined_dims == ("T",). And there is no coverage of the custom-VariableFactory path this PR flags as breaking.
  • lower() depends on the internal rewrite name "lower_xtensor". Annotated with pytensor#2337, which is the right thing to do; just noting it is a fragile hook.

Nice work overall — the migration is clean, and test_observed_is_labelled_with_its_own_dims and test_combined_dims_follow_declaration_order are exactly the right regression guards for the two subtle bugs this touches.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bass model Dealing with the Bass Defusion model docs Improvements or additions to documentation enhancement New feature or request maintenance mlflow priority: medium tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Migrate Bass Model dimension handling from create_dim_handler to pymc.dims

4 participants