4141from pytensor .graph .basic import Apply
4242from pytensor .graph .fg import FunctionGraph
4343from pytensor .graph .rewriting .basic import node_rewriter
44- from pytensor .scalar .basic import Ceil , Clip , Floor , Maximum , RoundHalfToEven
44+ from pytensor .scalar .basic import (
45+ Ceil ,
46+ Clip ,
47+ Floor ,
48+ Maximum ,
49+ RoundHalfAwayFromZero ,
50+ RoundHalfToEven ,
51+ Trunc ,
52+ )
4553from pytensor .scalar .basic import clip as scalar_clip
4654from pytensor .tensor import TensorVariable
47- from pytensor .tensor .math import ceil , clip , floor , maximum , minimum , round_half_to_even
55+ from pytensor .tensor .math import (
56+ ceil ,
57+ clip ,
58+ floor ,
59+ maximum ,
60+ minimum ,
61+ round_half_away_from_zero ,
62+ round_half_to_even ,
63+ trunc ,
64+ )
4865from pytensor .tensor .variable import TensorConstant
4966
5067from pymc .logprob .abstract import (
@@ -267,10 +284,10 @@ def clip_icdf(op, value, base_rv, lower_bound, upper_bound, **kwargs):
267284class MeasurableRound (MeasurableElemwise ):
268285 """A placeholder used to specify a log-likelihood for a clipped RV sub-graph."""
269286
270- valid_scalar_types = (RoundHalfToEven , Floor , Ceil )
287+ valid_scalar_types = (RoundHalfToEven , RoundHalfAwayFromZero , Floor , Ceil , Trunc )
271288
272289
273- @node_rewriter (tracks = [ceil , floor , round_half_to_even ])
290+ @node_rewriter (tracks = [ceil , floor , round_half_to_even , round_half_away_from_zero , trunc ])
274291def find_measurable_roundings (fgraph : FunctionGraph , node : Apply ) -> list [TensorVariable ] | None :
275292 if not filter_measurable_variables (node .inputs ):
276293 return None
@@ -315,10 +332,16 @@ def round_logprob(op, values, base_rv, **kwargs):
315332 0 & \text{otherwise},
316333 \end{cases}
317334
335+ The probability of a distribution rounded towards zero is given by the
336+ rounded-down probability for positive values and the rounded-up probability
337+ for negative values, with both intervals pooled at zero.
338+
318339 """
319340 (value ,) = values
320341
321- if isinstance (op .scalar_op , RoundHalfToEven ):
342+ if isinstance (op .scalar_op , RoundHalfToEven | RoundHalfAwayFromZero ):
343+ # The tie-breaking rule only matters on a measure-zero set of the
344+ # continuous base variable, so both variants share the same intervals
322345 value = pt .round (value )
323346 value_upper = value + 0.5
324347 value_lower = value - 0.5
@@ -330,6 +353,13 @@ def round_logprob(op, values, base_rv, **kwargs):
330353 value = pt .ceil (value )
331354 value_upper = value
332355 value_lower = value - 1.0
356+ elif isinstance (op .scalar_op , Trunc ):
357+ # Truncation rounds towards zero: [x, x+1) for x >= 0, (x-1, x] for x < 0,
358+ # and (-1, 1) for x == 0 (open/closed bounds are equivalent for the
359+ # continuous base variables this rewrite applies to)
360+ value = pt .trunc (value )
361+ value_upper = value + (value >= 0 )
362+ value_lower = value - (value <= 0 )
333363 else :
334364 raise TypeError (f"Unsupported scalar_op { op .scalar_op } " ) # pragma: no cover
335365
0 commit comments