Skip to content

Commit 7fd1c82

Browse files
committed
Infer logprob of casts that discretize a continuous variable
A float -> int cast rounds towards zero, so it is the `trunc` a user could have written themselves composed with a cast that only relabels the dtype. Rewriting it to that form leaves the judgement of whether the base may be truncated to `find_measurable_roundings`, and the relabelling cast is then claimed as measure-preserving. The other narrowing casts are not truncations and stay unclaimed: casting to an unsigned int wraps around for negative values (-2.7 -> 254 for uint8), and casting to bool tests `x != 0`, which collapses the support onto two points rather than partitioning it. `round_logprob` snaps the value onto the cell whose mass it asks for, and the Op it uses to do so rejects the integer value that a cast to int hands it. The snapping is an identity on such a value, so only its dtype needs adjusting.
1 parent a24c022 commit 7fd1c82

3 files changed

Lines changed: 62 additions & 7 deletions

File tree

pymc/logprob/censoring.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,13 @@ def round_logprob(op, values, base_rv, **kwargs):
365365
"""
366366
(value,) = values
367367

368+
if not value.type.dtype.startswith("float"):
369+
# The Ops below snap the value onto the cell whose mass it asks for, and only
370+
# accept float inputs. An integral value already sits on a cell, so the snapping
371+
# is an identity and only the dtype needs adjusting (as when the rounding is
372+
# followed by a cast to int).
373+
value = pt.cast(value, "float64")
374+
368375
if isinstance(op.scalar_op, RoundHalfToEven | RoundHalfAwayFromZero):
369376
# The tie-breaking rule only matters on a measure-zero set of the
370377
# continuous base variable, so both variants share the same intervals

pymc/logprob/tensor.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
_logprob_helper,
6868
promised_valued_rv,
6969
)
70+
from pymc.logprob.censoring import MeasurableRound
7071
from pymc.logprob.rewriting import (
7172
assume_valued_outputs,
7273
early_measurable_ir_rewrites_db,
@@ -487,16 +488,33 @@ def find_measurable_casts(fgraph, node) -> list[TensorVariable] | None:
487488

488489
[out] = node.outputs
489490
# bool < integer < float; casting to a lower kind discretizes the base variable,
490-
# which is not a measure-preserving identity (float -> int would need to be
491-
# treated as a form of censoring)
491+
# which is not a measure-preserving identity
492492
kind_order = {"b": 0, "u": 1, "i": 1, "f": 2}
493-
in_kind = kind_order.get(np.dtype(base_var.type.dtype).kind)
494-
out_kind = kind_order.get(np.dtype(out.type.dtype).kind)
493+
in_dtype = np.dtype(base_var.type.dtype)
494+
out_dtype = np.dtype(out.type.dtype)
495+
in_kind = kind_order.get(in_dtype.kind)
496+
out_kind = kind_order.get(out_dtype.kind)
495497
if in_kind is None or out_kind is None:
496498
# Kinds we can't order, such as complex
497499
return None
498500
if out_kind < in_kind:
499-
return None
501+
# A float -> signed int cast rounds towards zero, so it is a `trunc` composed
502+
# with a cast that merely relabels the dtype. Introduce the `trunc` explicitly
503+
# and let `find_measurable_roundings` claim it; the relabelling cast is then
504+
# measure-preserving and claimed on a later pass.
505+
# The other narrowing casts are not truncations: unsigned ints wrap around for
506+
# negative values (-2.7 -> 254 for uint8), and bool tests `x != 0`, which
507+
# collapses the support onto two points instead of partitioning it.
508+
if in_dtype.kind != "f" or out_dtype.kind != "i":
509+
return None
510+
# Rewriting to the `trunc` the user could have written themselves leaves the
511+
# judgement of whether the base may be truncated to `find_measurable_roundings`,
512+
# which declines the bases whose support it cannot establish, rather than
513+
# duplicating that reasoning here. Once the `trunc` is in place the cast only
514+
# relabels the dtype and is claimed as measure-preserving below; skipping it for
515+
# an already rounded base is what brings the rewrite to a fixpoint.
516+
if not isinstance(base_var.owner.op, MeasurableRound):
517+
return [pt.cast(pt.trunc(base_var), out_dtype.name)]
500518

501519
if in_kind < kind_order["f"] and out_kind == kind_order["f"]:
502520
# Casting a discrete variable to float hides its discreteness from other

tests/logprob/test_tensor.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -712,8 +712,38 @@ def test_bool_to_int(self):
712712
st.bernoulli(0.3).logpmf(1),
713713
)
714714

715-
@pytest.mark.parametrize("out_dtype", ["int64", "bool"])
716-
def test_discretizing_cast_not_measurable(self, out_dtype):
715+
@pytest.mark.parametrize(
716+
"value, lower, upper",
717+
[(1, 1, 2), (0, -1, 1), (-1, -2, -1)],
718+
ids=["positive", "zero", "negative"],
719+
)
720+
def test_float_to_int(self, value, lower, upper):
721+
# The cast rounds towards zero, pooling (-1, 1) at zero
722+
y = pt.cast(pt.random.normal(0.5, 1), "int64")
723+
y_vv = y.clone()
724+
725+
np.testing.assert_allclose(
726+
logp(y, y_vv).eval({y_vv: value}),
727+
np.log(st.norm(0.5, 1).cdf(upper) - st.norm(0.5, 1).cdf(lower)),
728+
)
729+
730+
@pytest.mark.parametrize("rounding_fn", [pt.trunc, pt.floor, pt.ceil, pt.round])
731+
def test_rounded_float_to_int(self, rounding_fn):
732+
# The base variable is already supported on the integers, so the cast only
733+
# relabels the dtype and must not introduce a second truncation
734+
x = pt.random.normal(0.5, 1)
735+
y = pt.cast(rounding_fn(x), "int64")
736+
y_vv = y.clone()
737+
738+
np.testing.assert_allclose(
739+
logp(y, y_vv).eval({y_vv: 1}),
740+
logp(rounding_fn(x), pt.constant(1.0)).eval(),
741+
)
742+
743+
@pytest.mark.parametrize("out_dtype", ["bool", "uint8"])
744+
def test_non_truncating_discretizing_cast_not_measurable(self, out_dtype):
745+
# Casting to bool tests `x != 0` and casting to an unsigned int wraps around
746+
# for negative values; neither is the truncation that float -> int performs
717747
y = pt.cast(pt.random.normal(), out_dtype)
718748
with pytest.raises(NotImplementedError):
719749
logp(y, y.clone())

0 commit comments

Comments
 (0)