Skip to content

Commit 64e51bf

Browse files
committed
Infer logprob of trunc and round_half_away_from_zero rounding
Truncation towards zero is the interval [x, x+1) for positive values, (x-1, x] for negative ones, and (-1, 1) pooled at zero. Rounding half away from zero shares the intervals of round half to even, since the tie-breaking rule only differs on a measure-zero set of the continuous base variable.
1 parent 0a19184 commit 64e51bf

2 files changed

Lines changed: 43 additions & 7 deletions

File tree

pymc/logprob/censoring.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,27 @@
4141
from pytensor.graph.basic import Apply
4242
from pytensor.graph.fg import FunctionGraph
4343
from 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+
)
4553
from pytensor.scalar.basic import clip as scalar_clip
4654
from 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+
)
4865
from pytensor.tensor.variable import TensorConstant
4966

5067
from pymc.logprob.abstract import (
@@ -267,10 +284,10 @@ def clip_icdf(op, value, base_rv, lower_bound, upper_bound, **kwargs):
267284
class 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])
274291
def 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

tests/logprob/test_censoring.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,9 @@ def test_clip_transform():
235235
assert np.isclose(obs_logp, exp_logp)
236236

237237

238-
@pytest.mark.parametrize("rounding_op", (pt.round, pt.floor, pt.ceil))
238+
@pytest.mark.parametrize(
239+
"rounding_op", (pt.round, pt.round_half_away_from_zero, pt.floor, pt.ceil, pt.trunc)
240+
)
239241
def test_rounding(rounding_op):
240242
loc = 1
241243
scale = 2
@@ -250,12 +252,16 @@ def test_rounding(rounding_op):
250252
assert logprob is not None
251253

252254
x_sp = st.norm(loc, scale)
253-
if rounding_op == pt.round:
255+
if rounding_op in (pt.round, pt.round_half_away_from_zero):
254256
expected_logp = np.log(x_sp.cdf(test_value + 0.5) - x_sp.cdf(test_value - 0.5))
255257
elif rounding_op == pt.floor:
256258
expected_logp = np.log(x_sp.cdf(test_value + 1.0) - x_sp.cdf(test_value))
257259
elif rounding_op == pt.ceil:
258260
expected_logp = np.log(x_sp.cdf(test_value) - x_sp.cdf(test_value - 1.0))
261+
elif rounding_op == pt.trunc:
262+
expected_logp = np.log(
263+
x_sp.cdf(test_value + (test_value >= 0)) - x_sp.cdf(test_value - (test_value <= 0))
264+
)
259265
else:
260266
raise NotImplementedError()
261267

0 commit comments

Comments
 (0)