Skip to content

Raveled NUTS setup fails for scan model with length-one dimensioned vector RV #8337

Description

@drbenvincent

Description

A PyMC model using pytensor.scan fails during PyMC's in-process NUTS setup when a length-one vector RV with named dims is passed into the scan as a non-sequence.

The model's compile_logp() and compile_dlogp() work, and pm.sample(nuts_sampler="numpyro") works. The failure occurs when PyMC's raveled-input NUTS setup calls join_nonshared_inputs(). Nutpie fails with the same error via an analogous flatten/unflatten replacement path, but this reproduces with nuts_sampler="pymc", so I am reporting it here first.

This originally surfaced downstream in pathmc, but the reproducer below uses only PyMC/PyTensor.

Minimal reproducer

from __future__ import annotations

import numpy as np
import pymc as pm
import pytensor
import pytensor.tensor as pt

n_times, n_units = 8, 3
rng = np.random.default_rng(0)
x = rng.normal(size=(n_times, n_units))
y = 0.7 * np.concatenate([x[0][None, :], x[:-1]], axis=0) + rng.normal(
    scale=0.2, size=(n_times, n_units)
)

with pm.Model(coords={"predictors": ["lag(x)"]}) as gen:
    x_data = pm.Data("x", x)
    lagged_x = pt.concatenate(
        [pt.as_tensor_variable(x[0][None, :]), x_data[:-1]], axis=0
    )
    beta = pm.Normal("beta", 0, 1, dims="predictors")
    sigma = pm.HalfNormal("sigma", 1)

    def step(x_t, lag_x_t, prev_y, beta):
        del x_t, prev_y
        return beta[0] * lag_x_t

    results = pytensor.scan(
        fn=step,
        sequences=[x_data, lagged_x],
        outputs_info=[pt.as_tensor_variable(np.zeros(n_units, dtype="float64"))],
        non_sequences=[beta],
        strict=True,
        return_updates=False,
    )
    if not isinstance(results, list):
        results = [results]
    pm.Deterministic("mu", results[0])
    pm.Normal("y", mu=results[0], sigma=sigma, shape=(n_times, n_units))

model = pm.observe(gen, {"y": y})

print("compile_logp", model.compile_logp()(model.initial_point()))
print("compile_dlogp", model.compile_dlogp()(model.initial_point()))

pm.sample(
    draws=1,
    tune=1,
    chains=1,
    cores=1,
    random_seed=1,
    progressbar=False,
    compute_convergence_checks=False,
    nuts_sampler="pymc",
    model=model,
)

Observed error

Only 1 samples per chain. Reliable r-hat and ESS diagnostics require longer chains for accurate estimate.
Initializing NUTS using jitter+adapt_diag...
Traceback (most recent call last):
  File "<stdin>", line 25, in <module>
  File ".../site-packages/pymc/sampling/mcmc.py", line 1022, in sample
    initial_points, step = init_nuts(
  File ".../site-packages/pymc/sampling/mcmc.py", line 1849, in init_nuts
    logp_dlogp_func = model.logp_dlogp_function(ravel_inputs=True, **compile_kwargs)
  File ".../site-packages/pymc/model/core.py", line 585, in logp_dlogp_function
    return ValueGradFunction(
  File ".../site-packages/pymc/model/core.py", line 247, in __init__
    outputs, raveled_grad_vars = join_nonshared_inputs(
  File ".../site-packages/pymc/pytensorf.py", line 598, in join_nonshared_inputs
    new_outputs = [clone_replace(output, replace, rebuild_strict=False) for output in outputs]
  File ".../site-packages/pytensor/scan/op.py", line 1267, in make_node
    raise ValueError(
ValueError: Argument Reshape{1}.0 given to the scan node is not compatible with its corresponding loop function variable i3

Backend comparison

Using the same model:

Path Result
model.compile_logp() OK
model.compile_dlogp() OK
pm.sample(nuts_sampler="numpyro") OK
pm.sample(nuts_sampler="pymc") Fails with the error above
nutpie.compile_pymc_model(model) Fails with the same scan compatibility error
pm.sample(nuts_sampler="nutpie") Fails with the same scan compatibility error

Diagnosis

The issue appears to be the replacement created by the raveled-input path.

The original value variable for beta has non-broadcastable unknown-length vector type:

beta Vector(float64, shape=(?,), broadcastable=(False,))

join_nonshared_inputs() builds a replacement from the raveled point via:

joined_inputs[last_idx : last_idx + arr_len].reshape(shape).astype(var.dtype)

For a length-one vector, this replacement becomes:

Reshape{1}.0 Vector(float64, shape=(1,), broadcastable=(True,))

That replacement is not in the same type class as the scan inner non-sequence variable, so pytensor.scan.op.Scan.make_node() rejects it.

A direct PyTensor check shows the mismatch:

import pytensor.tensor as pt
old = pt.vector("old")
joined = pt.dvector("joined")
repl = joined[:1].reshape((1,))
filtered = old.type.filter_variable(repl)

print(old.type, old.type.shape, old.type.broadcastable)
# Vector(float64, shape=(?,)) (None,) (False,)
print(repl.type, repl.type.shape, repl.type.broadcastable)
# Vector(float64, shape=(1,)) (1,) (True,)
print(filtered.type, filtered.type.shape, filtered.type.broadcastable)
# Vector(float64, shape=(1,)) (1,) (True,)
print(repl.type.in_same_class(old.type))
# False

Constructing a replacement that preserves a non-broadcastable vector axis avoids the direct clone_replace failure, so the fix may be for join_nonshared_inputs() to preserve the original input variable's broadcastability/type when unflattening length-one vector inputs.

Expected behavior

PyMC's in-process NUTS setup should be able to sample this model, or at least the raveled-input replacement should preserve the original value variable's type class so scan non-sequence compatibility is maintained.

Environment

Tested with:

pymc 6.0.1
pytensor 3.0.4
nutpie 0.16.10
numpyro 0.21.0
python 3.12.9
macOS Darwin

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions