Skip to content

Add initial to max and min - #2444

Merged
ricardoV94 merged 14 commits into
pymc-devs:mainfrom
anishmehta24:feat/max-min-initial
Oct 15, 2026
Merged

ricardoV94 merged 14 commits into
pymc-devs:mainfrom
anishmehta24:feat/max-min-initial

Conversation

@anishmehta24

@anishmehta24 anishmehta24 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Description

Adds initial to pt.max/pt.min (and Max/Min), as discussed in #2439. For now it only accepts None or the identity (-inf for max, inf for min); the Op stores it as a boolean prop. With it set, reducing an empty axis returns initial (the dtype's lowest/highest value for ints and bools) instead of raising, the same in the Python, C, Numba and JAX backends. MLX checks the (concrete) input shape and fills empty reductions; PyTorch leaves it to torch.max, which raises on an empty axis.

Rewrites that could drop or change it:

  • local_reduce_chain keeps initial if either reduction has it
  • local_careduce_of_alloc still applies, and returns initial when a broadcast axis is empty at runtime
  • local_max_to_min and clone (used by vectorize) keep it

LogSumExp now uses max(..., initial=-inf), so logsumexp of an empty array is -inf in every backend.

While testing I found Numba returned True for max over an empty bool axis, because -inf was cast straight to bool. Both identity helpers now map it like the C backend does, and their cache versions are bumped.

Tests: test_initial in TestMinMax (float/int/uint/bool, empty and non-empty), logsumexp zero-size, the two rewrites, JAX and Numba empty cases. Ran the new tests under NUMBA, CVM and FAST_COMPILE.

Related Issue

Checklist

Type of change

  • New feature / enhancement
  • Bug fix
  • Documentation
  • Maintenance
  • Other (please specify):

Comment thread pytensor/link/mlx/dispatch/math.py Outdated

@mlx_funcify.register(Max)
def mlx_funcify_Max(op, node=None, **kwargs):
if op.initial is not None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will break logsumexp completely for mlx? The dispatch can look at x shape, it's always statically known for mlx

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, fixed. It checks x.shape now and returns the filled array when a reduced axis is empty, otherwise mx.max as before.

Comment thread pytensor/link/pytorch/dispatch/elemwise.py Outdated
Comment thread pytensor/tensor/rewriting/math.py Outdated
return None

# Merging would change what an empty inner or outer reduction returns
if getattr(outer_op, "initial", None) != getattr(inner_op, "initial", None):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can keep the one with more specific initial? Gotta think about it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's safe: whichever side has initial, merging only turns an empty-axis error into initial and never changes a real value. Changed it to keep initial if either side has it, with tests for all four combinations.

Comment thread pytensor/tensor/rewriting/math.py Outdated
case _:
return None

# A broadcast axis may be empty at runtime, where the result is `initial`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what did this rewrite do before to runtime empty maxes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before, max(alloc(v, n, 3), axis=0) with n=0 at runtime became just v, so an error (C/Python) or -inf (Numba) turned into v's values. With initial set, the right answer is -inf, so that would be a wrong value, not just a lost error. That's why it bails. It could bail only when the broadcast shape isn't known to be non-zero, if you'd prefer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bailing out seems wrong. Either we still reduce axis, so the original Op is fine.

Or we don't need the max/min at all (all reduced values were broadcasted). In that case if the alloc shapes are 0 we'd return initial, otherwise the value? A max/min on shapes 0 without initial is undefined, we can leave doing the same thing as before

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, it doesn't bail anymore. With initial it returns initial when a broadcast axis is empty at runtime, otherwise the value; without initial nothing changes.

Comment thread pytensor/link/jax/dispatch/math.py Outdated

if initial is not None:

def max(x):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does initial=None fail? If not we don't need two branches

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't, so it's one branch now.

Comment thread pytensor/link/mlx/dispatch/math.py Outdated
if op.initial is not None:
# MLX shapes are concrete, so an empty reduction can be filled directly
reduced = range(x.ndim) if reduce_axes is None else reduce_axes
if any(x.shape[a] == 0 for a in reduced):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

x.size == 0?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, switched to x.size == 0.

if outer_op.scalar_op != inner_op.scalar_op:
return None

# Keeping `initial` from either side only turns an empty-axis error into it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, this is more complex than it should be. Maybe the default should be some sentinel Unset value, which can be converted to None. Then clone can distinguish "not specified, stay same" from override

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With initial as a bool, clone keeps the current value unless it's passed, so no sentinel needed. The rewrite only passes initial=True when the inner op has it.

Comment thread pytensor/tensor/rewriting/math.py Outdated
case _:
return None

# A broadcast axis may be empty at runtime, where the result is `initial`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bailing out seems wrong. Either we still reduce axis, so the original Op is fine.

Or we don't need the max/min at all (all reduced values were broadcasted). In that case if the alloc shapes are 0 we'd return initial, otherwise the value? A max/min on shapes 0 without initial is undefined, we can leave doing the same thing as before

Comment thread pytensor/tensor/math.py Outdated
def __init__(self, scalar_op, axis, initial=None):
if initial is not None:
initial = float(initial)
if initial != scalar_op.identity:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this is the only value we support the internal Op argument should be a boolean flag, not an arbitrary value. That seems fine to me.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, it's a boolean prop now. pt.max/pt.min still take initial=None or -inf/inf and check it.

Comment thread pytensor/link/jax/dispatch/elemwise.py Outdated
return jax_op(x, axis=axis, initial=op.initial_value(x.dtype)).astype(
acc_dtype
)
return jax_op(x, axis=axis).astype(acc_dtype)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't this be op.initial_value(x.dtype)... if intial_value else None? (just one return?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, one return now (e00d9b4). initial goes in as a kwarg only when set, since jnp.all/jnp.any don't accept it.

@ricardoV94 ricardoV94 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is shaping up nice, left a few comments

Comment thread pytensor/tensor/rewriting/math.py Outdated

# With `initial`, a broadcast axis that is empty at runtime makes the result `initial`
if getattr(node.op, "initial", False):
broadcast_shapes = [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is called size_shapes in the branch above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed. size_shapes is now computed once and used by both branches.

Comment thread pytensor/tensor/math.py Outdated
the dtype is used.
"""

__props__ = (*NonZeroDimsCAReduce.__props__, "initial")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this be part of NonZeroDimsCareduce?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it there: the prop, __init__, perform, initial_value and __str__ now live on NonZeroDimsCAReduce. MaxAndMinCAReduce only keeps clone.

Comment thread tests/tensor/rewriting/test_math.py Outdated
Comment on lines +3475 to +3476
Max(axis=0, initial=True)(alloc_v),
Max(axis=None, initial=True)(alloc_v),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer if you test with the user facing max. Can we also include a third output along axis 1?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to pt.max and added an axis=1 output. That one keeps a 1d alloc of the reduced vector, so the check now only rules out the 2d alloc.

@ricardoV94

Copy link
Copy Markdown
Member

bot found the following bugs:

  • [P2] Broadcast-axis removal drops initial. The rewrite (pytensor/tensor/rewriting/math.py:2250) constructs a fresh Op without preserving it. With shape=(1, None), x.max(axis=(0, 1), initial=-np.inf) raises on (1, 0) input in FAST_COMPILE and CVM. This also breaks empty logsumexp. Use node.op.clone(axis=new_axis).
  • [P2] Nonempty complex logsumexp now fails. The identity helper (pytensor/tensor/math.py:397) calls np.iinfo for complex dtypes. The previous inner graph evaluates [1+2j, 3+4j]; the new graph raises ValueError: Invalid integer data type 'c'. Handle complex identities before the integer branch.
  • [P2] MLX minimum reductions bypass the empty-input handler. The new handling (pytensor/link/mlx/dispatch/math.py:26) covers only Max. Float minima optimize to Min(initial=True), whose generic dispatch still calls mx.min without handling empty axes.

@anishmehta24

Copy link
Copy Markdown
Contributor Author

Fixed all three:

  • local_reduce_broadcastable now uses node.op.clone(axis=new_axis), so initial survives dropping the broadcastable axis (c1b8f53, with a test on the (1, 0) case).
  • Complex dtypes take the identity like floats (b1f1e21). Complex logsumexp works again in FAST_COMPILE. With numba it fails on main as well, so that part isn't from this PR.
  • MLX Min now goes through the same empty-input path as Max (616ebf5). I couldn't run the MLX tests, no Apple machine here.

isinstance(node.op, CAReduce) for node in f.maker.fgraph.toposort()
)

def test_local_reduce_broadcast_some_initial(self):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use RewriteTest (gives you both the graph to check and the function to evaluate)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c72f722, it checks the rewritten graph and evaluates both on a (1, 0) input.

Comment thread pytensor/link/mlx/dispatch/math.py Outdated
@mlx_funcify.register(Max)
def mlx_funcify_Max(op, node=None, **kwargs):
def max_fn(x):
def _mlx_funcify_max_min(op, node, reduce_fn):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, combine the two functions (decorate with both Ops). It's the same except one calls mx.max and the other mx.min

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 007c691, one function registered for both and it picks mx.max or mx.min from the op.

@ricardoV94 ricardoV94 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left two nits, let me know if you want to address. Otherwise good to go

@anishmehta24

Copy link
Copy Markdown
Contributor Author

Merged main to resolve the conflict with the new ProdWithoutZeros handling in local_careduce_of_alloc (kept both). Rewrite and max/min tests pass locally.

@ricardoV94 ricardoV94 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

last nits

Comment thread tests/tensor/rewriting/test_math.py Outdated
Comment on lines +3007 to +3010
fg = FunctionGraph([x], [out], clone=False)
[new_out] = local_reduce_chain.transform(fg, out.owner)
initial = -np.inf if -np.inf in (inner_initial, outer_initial) else None
assert equal_computations([new_out], [x.max(axis=(0, 2), initial=initial)])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use RewriteTest

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a2d69d3

Comment thread tests/tensor/rewriting/test_math.py Outdated
anishmehta24 and others added 2 commits October 15, 2026 03:15
Co-authored-by: Ricardo Vieira <28983449+ricardoV94@users.noreply.github.com>
@ricardoV94
ricardoV94 merged commit ce6df07 into pymc-devs:main Oct 15, 2026
67 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

logsumexp stabilization can make graph fail with zero-sized arrays

2 participants