Skip to content

Commit df1a369

Browse files
committed
Infer logprob of join_dims and split_dims
Inverted by applying the opposite operation to the value and re-applying the original to the logp, mirroring MeasurableDimShuffle, including its truncation of support dimensions consumed by the logp. Claims preserve the assumption that support axes are the rightmost positions: splits only inflate a single axis and joins contained in the batch axes (or in the support axes, which deflate into fewer rightmost axes) are always fine, so both are claimed unconditionally, using the elemwise root's ndim_supp to locate the batch/support boundary. Joins that straddle the boundary merge batch and support axes into one and are only claimed when directly valued (#6360). The local_join_dims/local_split_dims canonicalizations are excluded from the logprob IR pipeline: they unconditionally lower to Reshape, which would erase the ops before the measurable rewrites see them (and erase the measurable subclasses after). Once pytensor canonicalizes provable Reshapes into JoinDims/SplitDims (pymc-devs/pytensor#2280), reshape, ravel and flatten of measurable variables will be covered by the same rewrite.
1 parent bd39777 commit df1a369

3 files changed

Lines changed: 240 additions & 15 deletions

File tree

pymc/logprob/rewriting.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,19 @@ def remove_DiracDelta(fgraph, node):
167167
"basic",
168168
position=0.9,
169169
)
170+
# local_join_dims/local_split_dims are excluded so that JoinDims/SplitDims survive
171+
# until the measurable rewrites (and their measurable subclasses survive after);
172+
# non-measurable ones are still lowered to Reshape when the logp graph is compiled
173+
CANONICALIZE_IR_QUERY_ARGS = (
174+
"+canonicalize",
175+
"-local_eager_useless_unbatched_blockwise",
176+
"-local_join_dims",
177+
"-local_split_dims",
178+
)
179+
170180
logprob_rewrites_db.register(
171181
"pre-canonicalize",
172-
optdb.query("+canonicalize", "-local_eager_useless_unbatched_blockwise"),
182+
optdb.query(*CANONICALIZE_IR_QUERY_ARGS),
173183
"basic",
174184
position=1,
175185
)
@@ -205,7 +215,7 @@ def remove_DiracDelta(fgraph, node):
205215

206216
logprob_rewrites_db.register(
207217
"post-canonicalize",
208-
optdb.query("+canonicalize", "-local_eager_useless_unbatched_blockwise"),
218+
optdb.query(*CANONICALIZE_IR_QUERY_ARGS),
209219
"basic",
210220
position=4,
211221
)

pymc/logprob/tensor.py

Lines changed: 105 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from pytensor.tensor.random.rewriting import (
5353
local_dimshuffle_rv_lift,
5454
)
55+
from pytensor.tensor.reshape import JoinDims, SplitDims, join_dims, split_dims
5556
from pytensor.tensor.rewriting.basic import elemwise_of
5657

5758
from pymc.logprob.abstract import (
@@ -79,7 +80,7 @@
7980
get_related_valued_nodes,
8081
replace_rvs_by_values,
8182
)
82-
from pymc.pytensorf import constant_fold
83+
from pymc.pytensorf import constant_fold, get_symbolic_rv_shapes
8384

8485

8586
class MeasurableMakeVector(MeasurableOp, MakeVector):
@@ -294,24 +295,26 @@ def logprob_dimshuffle(op: MeasurableDimShuffle, values, base_var, **kwargs):
294295
return raw_logp.dimshuffle(redo_ds)
295296

296297

297-
def _elemwise_univariate_chain(fgraph, node) -> bool:
298-
# Check whether only Elemwise operations connect a base univariate RV to the valued node through var.
298+
def _elemwise_root(var: TensorVariable) -> TensorVariable | None:
299+
"""Walk through dimension-preserving measurable operations to the root variable."""
299300
from pymc.distributions.distribution import SymbolicRandomVariable
300301
from pymc.logprob.transforms import MeasurableTransform
301302

302-
[inp] = node.inputs
303-
[out] = node.outputs
303+
if isinstance(var.owner.op, RandomVariable | SymbolicRandomVariable):
304+
return var
305+
elif isinstance(var.owner.op, MeasurableTransform):
306+
return _elemwise_root(var.owner.inputs[var.owner.op.measurable_input_idx])
307+
else:
308+
return None
304309

305-
def elemwise_root(var: TensorVariable) -> TensorVariable | None:
306-
if isinstance(var.owner.op, RandomVariable | SymbolicRandomVariable):
307-
return var
308-
elif isinstance(var.owner.op, MeasurableTransform):
309-
return elemwise_root(var.owner.inputs[var.owner.op.measurable_input_idx])
310-
else:
311-
return None
310+
311+
def _elemwise_univariate_chain(fgraph, node) -> bool:
312+
# Check whether only Elemwise operations connect a base univariate RV to the valued node through var.
313+
inp = node.inputs[0]
314+
[out] = node.outputs
312315

313316
# Check that the root is a univariate distribution linked by only elemwise operations
314-
root = elemwise_root(inp)
317+
root = _elemwise_root(inp)
315318
if root is None:
316319
return False
317320
elif root.owner.op.ndim_supp != 0:
@@ -590,6 +593,91 @@ def identity_icdf(op, value, base_var, **kwargs):
590593
return _icdf_helper(base_var, value)
591594

592595

596+
class MeasurableJoinDims(MeasurableOp, JoinDims):
597+
"""A placeholder used to specify a log-likelihood for a join_dims sub-graph."""
598+
599+
600+
class MeasurableSplitDims(MeasurableOp, SplitDims):
601+
"""A placeholder used to specify a log-likelihood for a split_dims sub-graph."""
602+
603+
604+
@node_rewriter([JoinDims, SplitDims])
605+
def find_measurable_join_split_dims(fgraph, node) -> list[TensorVariable] | None:
606+
r"""Find `JoinDims` and `SplitDims` for which a `logprob` can be computed."""
607+
if isinstance(node.op, MeasurableOp):
608+
return None
609+
610+
base_var, *other_inputs = node.inputs
611+
612+
if not filter_measurable_variables([base_var]):
613+
return None
614+
615+
if check_potential_measurability(other_inputs):
616+
return None
617+
618+
if isinstance(node.op, JoinDims):
619+
# A join that straddles the batch/support boundary merges batch and support
620+
# axes into a single one, breaking the assumption that support axes are the
621+
# rightmost positions, which other rewrites rely on. Such joins are only valid
622+
# when directly valued. Joins contained in the batch axes (or in the support
623+
# axes, which simply become fewer rightmost axes) preserve the assumption and
624+
# need no restriction; splitting never straddles, since it only inflates a
625+
# single axis.
626+
# TODO: When we include the support axis as meta information in each intermediate
627+
# MeasurableVariable, the root walk becomes unnecessary (see https://github.com/pymc-devs/pymc/issues/6360)
628+
start_axis, n_axes = node.op.start_axis, node.op.n_axes
629+
root = _elemwise_root(base_var)
630+
if root is None:
631+
straddles = True
632+
else:
633+
batch_ndim = base_var.type.ndim - root.owner.op.ndim_supp
634+
straddles = start_axis < batch_ndim < start_axis + n_axes
635+
if straddles and not any(get_related_valued_nodes(fgraph, node)):
636+
return None
637+
measurable_op: MeasurableOp = MeasurableJoinDims(start_axis, n_axes)
638+
else:
639+
measurable_op = MeasurableSplitDims(node.op.axis)
640+
641+
return [measurable_op(base_var, *other_inputs)] # type: ignore[operator]
642+
643+
644+
@_logprob.register(MeasurableJoinDims)
645+
def logprob_join_dims(op, values, base_var, **kwargs):
646+
"""Compute the log-likelihood graph for a `MeasurableJoinDims`."""
647+
(value,) = values
648+
649+
[base_shape] = get_symbolic_rv_shapes([base_var])
650+
unjoined_shape = [base_shape[i] for i in op.axis_range]
651+
unjoined_value = split_dims(value, shape=unjoined_shape, axis=op.start_axis)
652+
653+
raw_logp = _logprob_helper(base_var, unjoined_value)
654+
655+
# Re-join the value dimensions, ignoring any support dimensions consumed by the
656+
# logprob function (assumed to be the rightmost positions). A join lying entirely
657+
# within consumed support dimensions leaves nothing to re-join, but a length-zero
658+
# join (expand_dims) of remaining dimensions is still re-applied.
659+
if op.start_axis > raw_logp.ndim or (op.n_axes > 0 and op.start_axis >= raw_logp.ndim):
660+
return raw_logp
661+
return join_dims(raw_logp, op.start_axis, min(op.n_axes, raw_logp.ndim - op.start_axis))
662+
663+
664+
@_logprob.register(MeasurableSplitDims)
665+
def logprob_split_dims(op, values, base_var, shape, **kwargs):
666+
"""Compute the log-likelihood graph for a `MeasurableSplitDims`."""
667+
(value,) = values
668+
669+
n_axes = value.type.ndim - base_var.type.ndim + 1
670+
joined_value = join_dims(value, start_axis=op.axis, n_axes=n_axes)
671+
672+
raw_logp = _logprob_helper(base_var, joined_value)
673+
674+
# Re-split the value dimensions, unless the split dimension was a support dimension
675+
# consumed by the logprob function (assumed to be the rightmost positions)
676+
if op.axis >= raw_logp.ndim:
677+
return raw_logp
678+
return split_dims(raw_logp, shape=shape, axis=op.axis)
679+
680+
593681
measurable_ir_rewrites_db.register(
594682
"find_measurable_casts", find_measurable_casts, "basic", "tensor"
595683
)
@@ -600,6 +688,10 @@ def identity_icdf(op, value, base_var, **kwargs):
600688
"find_measurable_identity_ops", find_measurable_identity_ops, "basic", "tensor"
601689
)
602690

691+
measurable_ir_rewrites_db.register(
692+
"find_measurable_join_split_dims", find_measurable_join_split_dims, "basic", "tensor"
693+
)
694+
603695

604696
measurable_ir_rewrites_db.register("dimshuffle_lift", local_dimshuffle_rv_lift, "basic", "tensor")
605697

tests/logprob/test_tensor.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
from pytensor.tensor.random.type import random_generator_type
4646
from scipy import stats as st
4747

48+
import pymc as pm
49+
4850
from pymc.logprob.basic import conditional_logp, icdf, logcdf, logp
4951
from pymc.logprob.rewriting import logprob_rewrites_db
5052
from pymc.testing import assert_no_rvs
@@ -765,3 +767,124 @@ def test_deep_copy(self):
765767
logp(y, y_vv).eval({y_vv: 0.7}),
766768
st.norm(0.5, 1).logpdf(0.7),
767769
)
770+
771+
772+
class TestMeasurableJoinSplitDims:
773+
def test_join_dims(self):
774+
rng = np.random.default_rng(163)
775+
x = pt.random.normal(pt.arange(6).reshape((2, 3)), 1, size=(2, 3))
776+
y = pt.join_dims(x)
777+
y_vv = y.clone()
778+
779+
y_test = rng.normal(size=6)
780+
np.testing.assert_allclose(
781+
logp(y, y_vv).eval({y_vv: y_test}),
782+
st.norm(np.arange(6), 1).logpdf(y_test),
783+
)
784+
785+
def test_split_dims(self):
786+
rng = np.random.default_rng(164)
787+
x = pt.random.normal(pt.arange(6), 1, size=(6,))
788+
y = pt.split_dims(x, shape=(2, 3), axis=0)
789+
y_vv = y.clone()
790+
791+
y_test = rng.normal(size=(2, 3))
792+
np.testing.assert_allclose(
793+
logp(y, y_vv).eval({y_vv: y_test}),
794+
st.norm(np.arange(6).reshape((2, 3)), 1).logpdf(y_test),
795+
)
796+
797+
@pytest.mark.parametrize("transform_first", (False, True))
798+
def test_elemwise_chain(self, transform_first):
799+
rng = np.random.default_rng(165)
800+
x = pt.random.normal(pt.arange(6).reshape((2, 3)), 1, size=(2, 3))
801+
y = pt.join_dims(pt.exp(x)) if transform_first else pt.exp(pt.join_dims(x))
802+
y_vv = y.clone()
803+
804+
y_test = np.abs(rng.normal(size=6)) + 0.1
805+
np.testing.assert_allclose(
806+
logp(y, y_vv).eval({y_vv: y_test}),
807+
st.norm(np.arange(6), 1).logpdf(np.log(y_test)) - np.log(y_test),
808+
)
809+
810+
def test_multivariate_directly_valued(self):
811+
rng = np.random.default_rng(166)
812+
813+
# The joined region extends into the support dimension consumed by the logp,
814+
# so only the remaining batch dimension is re-joined
815+
x = pt.random.dirichlet(pt.ones(3), size=(2,))
816+
y = pt.join_dims(x)
817+
y_vv = y.clone()
818+
y_test = rng.dirichlet(np.ones(3), size=2).ravel()
819+
y_logp = logp(y, y_vv).eval({y_vv: y_test})
820+
assert y_logp.shape == (2,)
821+
np.testing.assert_allclose(
822+
y_logp,
823+
st.dirichlet(np.ones(3)).logpdf(y_test.reshape((2, 3)).T),
824+
)
825+
826+
# The split dimension is the support dimension itself
827+
x2 = pt.random.dirichlet(pt.ones(6))
828+
y2 = pt.split_dims(x2, shape=(2, 3), axis=0)
829+
y2_vv = y2.clone()
830+
y2_test = rng.dirichlet(np.ones(6)).reshape((2, 3))
831+
y2_logp = logp(y2, y2_vv).eval({y2_vv: y2_test})
832+
assert y2_logp.shape == ()
833+
np.testing.assert_allclose(
834+
y2_logp,
835+
st.dirichlet(np.ones(6)).logpdf(y2_test.ravel()),
836+
)
837+
838+
def test_multivariate_indirect_join_within_batch(self):
839+
# A join contained in the batch axes leaves the support axes rightmost,
840+
# so it is measurable even behind other operations
841+
rng = np.random.default_rng(168)
842+
x = pt.random.dirichlet(pt.ones(3), size=(2, 2))
843+
y = pt.exp(pt.join_dims(x, start_axis=0, n_axes=2))
844+
y_vv = y.clone()
845+
y_test = np.exp(rng.dirichlet(np.ones(3), size=(2, 2)).reshape((4, 3)))
846+
y_logp = logp(y, y_vv).eval({y_vv: y_test})
847+
assert y_logp.shape == (4,)
848+
np.testing.assert_allclose(
849+
y_logp,
850+
st.dirichlet(np.ones(3)).logpdf(np.log(y_test).T) - np.log(y_test).sum(-1),
851+
)
852+
853+
def test_multivariate_indirect_join_within_support(self):
854+
# A join contained in the support axes just deflates them into fewer
855+
# rightmost axes, so it is measurable even behind other operations
856+
rng = np.random.default_rng(169)
857+
x = pm.MatrixNormal.dist(mu=np.zeros((2, 3)), rowcov=np.eye(2), colcov=np.eye(3))
858+
y = pt.exp(pt.join_dims(x, start_axis=0, n_axes=2))
859+
y_vv = y.clone()
860+
y_test = np.exp(rng.normal(size=6))
861+
# With identity covariances the matrix normal entries are iid standard normal
862+
np.testing.assert_allclose(
863+
logp(y, y_vv).eval({y_vv: y_test}),
864+
st.norm.logpdf(np.log(y_test)).sum() - np.log(y_test).sum(),
865+
)
866+
867+
# batch size 1 is the treacherous case: if the straddling join were wrongly
868+
# claimed, the truncated logp would silently broadcast against the fused value
869+
# dimension instead of raising a shape error
870+
@pytest.mark.parametrize("batch_size", (1, 2))
871+
def test_multivariate_indirect_straddling_join_not_measurable(self, batch_size):
872+
# A join straddling the batch/support boundary merges batch and support axes;
873+
# it is only measurable when directly valued
874+
x = pt.random.dirichlet(pt.ones(3), size=(batch_size,))
875+
y = pt.exp(pt.join_dims(x))
876+
with pytest.raises(NotImplementedError):
877+
logp(y, y.clone())
878+
879+
def test_multivariate_indirect_split(self):
880+
# Splitting only inflates a single axis, so it is measurable even for
881+
# multivariate variables behind other operations
882+
rng = np.random.default_rng(167)
883+
x = pt.random.dirichlet(pt.ones(6))
884+
y = pt.exp(pt.split_dims(x, shape=(2, 3), axis=0))
885+
y_vv = y.clone()
886+
y_test = np.exp(rng.dirichlet(np.ones(6)).reshape((2, 3)))
887+
np.testing.assert_allclose(
888+
logp(y, y_vv).eval({y_vv: y_test}),
889+
st.dirichlet(np.ones(6)).logpdf(np.log(y_test).ravel()) - np.log(y_test).sum(),
890+
)

0 commit comments

Comments
 (0)