Skip to content

Add a spec-only nanmedian fallback for Combiner.median_combine - #978

Open
mwcraig wants to merge 3 commits into
astropy:mainfrom
mwcraig:fix-906-nanmedian-fallback
Open

Add a spec-only nanmedian fallback for Combiner.median_combine#978
mwcraig wants to merge 3 commits into
astropy:mainfrom
mwcraig:fix-906-nanmedian-fallback

Conversation

@mwcraig

@mwcraig mwcraig commented Aug 22, 2026

Copy link
Copy Markdown
Member

Adds ccdproc/_nanmedian.py, a NaN-aware median written only in terms of array-API-standard functions (replace NaNs with +inf, sort the axis, count non-NaN entries, gather and average the two middle elements; all-NaN slices give NaN; integer input is promoted to the namespace's default real floating dtype). Combiner._default_median now returns this fallback (bound to the namespace via functools.partial) when the namespace has no nanmedian, instead of raising RuntimeError. The fallback is O(n log n) along the combination axis; native nanmedian / bottleneck are still preferred when available.

Also:

  • docs/array_api.rst: replace the claim of a bottleneck -> numpy fallback (which did not exist) with the real chain (bottleneck for numpy -> xp.nanmedian -> spec-only fallback).
  • test_combiner.py: drop the numpy-median/.compute() workarounds and the pytest.skip for namespaces without median; test_bottleneck_defaults_respect_array_namespace now checks the fallback is returned when nanmedian is absent.
  • New ccdproc/tests/test_nanmedian.py, two parametrized tests covering odd/even lengths, 1-D/2-D/3-D, partial and all-NaN columns, integer input, axis=1/-1, and rejected axes.

Review follow-ups

  • NaN ordering in sort is implementation-defined. The first version relied on NaNs sorting last, which the standard does not guarantee — it only worked because numpy/strict/jax/dask all happen to agree. NaNs are now replaced by +inf before sorting, so the first n positions hold the non-NaN values whatever the backend does. Genuine +inf entries compare equal to the sentinels and are still counted in n, so the result is unchanged for them. Verified against a namespace whose sort deliberately places NaNs first: the old code returned [1., 2., nan] where the answer is [2., 3., 3.]; the new code is correct.
  • No hardcoded 64-bit dtypes. The non-NaN count is int32 rather than int64, and integer input is promoted via xp.__array_namespace_info__().default_dtypes(device=device)["real floating"] rather than xp.float64. Both tripped jax's x64-truncation UserWarning, which filterwarnings = ["error"] turns into a failure: with JAX_ENABLE_X64 unset, test_nanmedian.py was 12 failed / 3 passed and is now 17 passed. On jax without x64 an integer input therefore yields float32 rather than numpy's float64; every backend that has float64 is unchanged.

Test matrix

backend result
numpy, pytest ccdproc 410 passed, 5 skipped
jax (JAX_ENABLE_X64=True), pytest ccdproc 396 passed, 10 skipped, 2 xfailed, 7 xpassed
jax, JAX_ENABLE_X64 unset, test_nanmedian.py 17 passed
dask (CCDPROC_LOG_ARRAY_ESCAPES=1 CCDPROC_ENFORCE_ESCAPE_BASELINE=1) 399 passed, 16 skipped; no escapes outside the baseline
array-api-strict, test_nanmedian.py 17 passed
array-api-strict, test_combiner.py 68 failed, 23 passed, 1 xfailed — unchanged by this PR

The 7 jax XPASS are identical on upstream/main (astropy/jax DeprecationWarning markers, unrelated).

The strict test_combiner.py failures are pre-existing and tracked in #971; they hit the Combiner constructor ("Nested Arrays are not allowed", being fixed in #976) or other known strict bugs, not the median. An earlier revision of this description quoted "75 failed / 12 passed -> 74 failed / 13 passed"; those numbers were measured under pytest-randomly's random order, which some strict test_combiner.py tests are sensitive to, and are not reproducible. Measured with -p no:randomly the count is a stable 68/23 both before and after this PR.

Fixes #906

🤖 Generated with Claude Code

https://claude.ai/code/session_01DTN9DnPnLKK2u7knnMJ2gA

@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.32%. Comparing base (c2ce2e2) to head (64f5d91).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #978      +/-   ##
==========================================
+ Coverage   96.13%   96.32%   +0.18%     
==========================================
  Files           8        9       +1     
  Lines        1655     1685      +30     
==========================================
+ Hits         1591     1623      +32     
+ Misses         64       62       -2     
Flag Coverage Δ
dask 95.41% <100.00%> (+0.20%) ⬆️
jax 95.53% <100.00%> (+0.19%) ⬆️
numpy 96.20% <100.00%> (+0.18%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Add ccdproc/_nanmedian.py, a NaN-aware median written purely in terms of
the array API standard (sort, count non-NaN, gather the two middle
elements), and use it from _default_median when the selected namespace
has no nanmedian instead of raising RuntimeError. Works on numpy, jax,
dask and array-api-strict.

Fix the docs, which claimed a bottleneck -> numpy fallback that did not
exist, and drop the numpy-median and pytest.skip workarounds in
test_combiner.py now that a median is available on every backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTN9DnPnLKK2u7knnMJ2gA
@mwcraig
mwcraig force-pushed the fix-906-nanmedian-fallback branch from 7c96d93 to c32570b Compare August 23, 2026 14:35
@mwcraig

mwcraig commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

Reviewed for correctness and test size. The algorithm is right: I compared the fallback to np.nanmedian under numpy and array-api-strict on adversarial inputs (±inf mixed with NaN, float32 dtype preservation, NaN-first/NaN-middle, zero-length axis, bool, −0.0, near-float64-max overflow, 3-D with axis=1/-2) and everything matched, including the overflow behaviour numpy itself has.

Two things worth changing before merge (inline): the NaN-ordering assumption in sort, and the hardcoded int64 that breaks jax without x64. The rest is test trimming — the new tests can lose roughly 50 lines without losing any case.

Not attributable to this PR: the 16 remaining strict-job failures in test_combiner.py (.any()/.sum() methods, Unit arithmetic) are pre-existing and tracked in #971.

Comment thread ccdproc/tests/test_nanmedian.py Outdated


@pytest.mark.parametrize("length", [1, 2, 3, 4, 5, 6])
def test_nanmedian_odd_and_even_lengths(length):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Add a short description of the intent of each test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed. If the parametrized rewrite in the other thread on this file is adopted, the per-case intent moves into the trailing comments on each parametrize entry (# odd/even lengths, # all-NaN column, ...) plus a one-line docstring on each of the two tests; otherwise I will add a docstring to each test_* function.

Written by Claude at @mwcraig's direction.

@mwcraig mwcraig left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Inline details for the summary comment above.

Comment thread ccdproc/_nanmedian.py Outdated
axis = axis % ndim

device = array_api_compat.device(x)
s = xp.sort(x, axis=axis)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The docstring (line 21) says NaNs sort to the end "per the array API specification", but the standard leaves NaN ordering in sort implementation-defined. It works today only because strict/jax/dask all mirror numpy's [R, nan] order. A one-line hardening removes the dependency entirely:

Suggested change
s = xp.sort(x, axis=axis)
s = xp.sort(
xp.where(xp.isnan(x), xp.asarray(xp.inf, dtype=x.dtype, device=device), x),
axis=axis,
)

Every position < n is then a real value no matter where the backend puts NaNs (real +inf values still land at the end, and only positions < n are ever read). If you'd rather not, please at least fix the docstring claim.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed: the array API sort spec lists NaN ordering as implementation-defined, so the docstring claim is wrong as written and the current code only works because numpy/strict/jax/dask all happen to put NaN last. I will take the xp.where(isnan, inf, x) hardening — it costs one extra where on an array we already compute isnan for, and real +inf entries are still counted in n and land after every finite value, so positions < n stay correct. Docstring will be updated to say NaNs are replaced by +inf before sorting.

Written by Claude at @mwcraig's direction.

Comment thread ccdproc/_nanmedian.py Outdated
s = xp.sort(x, axis=axis)

# Number of non-NaN values along the axis, kept broadcastable.
n = xp.sum(xp.astype(~xp.isnan(x), xp.int64), axis=axis, keepdims=True)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Requesting int64 explicitly trips jax's x64-truncation UserWarning when JAX_ENABLE_X64 is unset, which filterwarnings = ["error"] turns into a failure — 12/15 of the new tests fail in that configuration. CI only passes because tox.ini sets JAX_ENABLE_X64=True for the jax job. The count never needs 64 bits:

Suggested change
n = xp.sum(xp.astype(~xp.isnan(x), xp.int64), axis=axis, keepdims=True)
n = xp.sum(xp.astype(~xp.isnan(x), xp.int32), axis=axis, keepdims=True)

(The float64 promotion on line 56 has the same fragility for integer input, but that path is much rarer; fine to leave.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Verified locally: with JAX_ENABLE_X64 unset and CCDPROC_ARRAY_LIBRARY=jax, test_nanmedian.py gives 12 failed / 3 passed, all from the x64-truncation UserWarning being promoted to an error. int32 is plenty for a count along one axis, so I will switch to xp.int32. I will leave the float64 promotion for integer input as you suggest (it is also the numpy nanmedian result dtype, so matching it there is deliberate).

Written by Claude at @mwcraig's direction.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Took the int32 change in 64f5d91, and I ended up taking the float64 one too rather than leaving it.

The reason: once the count was int32, the promotion on line 56 was the only thing left failing without x64, so leaving it meant test_nanmedian.py still could not run clean in that configuration.

CCDPROC_ARRAY_LIBRARY=jax, JAX_ENABLE_X64 unset test_nanmedian.py
before either fix 12 failed, 3 passed
int32 only 1 failed, 16 passed — test_nanmedian_matches_numpy[data12-0], the integer-input case
both 17 passed

With JAX_ENABLE_X64=True it is 17 passed either way, so CI would not have caught the difference.

The promotion now asks the namespace instead of hardcoding:

info = xp.__array_namespace_info__()
x = xp.astype(x, info.default_dtypes(device=device)["real floating"])

I checked __array_namespace_info__().default_dtypes() is available on all four backends we test: numpy → float64, array-api-strict → array_api_strict.float64, dask → float64, jax → float32 without x64 and float64 with. This required moving the promotion below the device = array_api_compat.device(x) line, since default_dtypes takes the device.

The trade-off is real and worth stating: on jax without x64, integer input now yields float32 rather than matching numpy.nanmedian's float64. That seems clearly better than requesting a dtype the backend does not have — and on every backend that has float64 the result is unchanged. The docstring now says "promoted to the namespace's default real floating dtype" rather than naming float64.

Happy to revert this half if you would rather keep the numpy dtype match and accept that the file needs JAX_ENABLE_X64=True.

Written by Claude at @mwcraig's direction.

Comment thread ccdproc/_nanmedian.py Outdated
"nanmedian fallback supports only a single integer axis."
)

xp = xp or array_api_compat.array_namespace(x)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Nit: if xp is None: is the idiom used elsewhere in the package.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Will change to if xp is None: to match combiner.py (_ccddata_wrapper_for_array_api.py uses the or form, but the explicit check is the better idiom anyway since a namespace object should never be tested for truthiness).

Written by Claude at @mwcraig's direction.

Comment thread ccdproc/tests/test_combiner.py Outdated
assert default.keywords == {"xp": xp}
return
else:
pytest.skip(f"{xp.__name__} has no {function_name}")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This branch is only reachable for nanmean/nansum/nanstd on a namespace that lacks them — and on the strict job those don't get here, they raise RuntimeError inside _default_average etc. (3 failures currently). The skip is misleading; I'd drop it and let assert default is expected fail honestly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed. _default_average/_default_sum/_default_std raise RuntimeError from inside the except AttributeError branch when the namespace lacks the function, so on a namespace without them the test fails before reaching this elif/else — the skip never actually fires. I will drop it and let assert default is expected fail directly. (Those strict-job failures are the pre-existing gap for nanmean/nansum/nanstd, out of scope for this PR which only adds the nanmedian fallback.)

Written by Claude at @mwcraig's direction.

Comment thread ccdproc/tests/test_combiner.py Outdated
else:
elif hasattr(xp, function_name):
expected = getattr(xp, function_name)
elif function_name == "nanmedian":

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This elif and test_default_median_falls_back_without_nanmedian below test the same thing. The fake-module test runs deterministically on every job (including the coverage job codecov measures), so I'd keep that one and drop this 8-line branch.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed — test_default_median_falls_back_without_nanmedian exercises exactly this path with a stand-in namespace on every job, and every real backend in CI provides nanmedian so this branch never runs there. I will remove it.

Written by Claude at @mwcraig's direction.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Implemented in 64f5d91 — but I kept this branch, because the premise turned out not to hold.

array-api-strict is a CI backend (tox.ini:13, strict: CCDPROC_ARRAY_LIBRARY = array-api-strict) and it has no nanmedian, so this branch does run there — it is the only end-to-end check that a real namespace lacking nanmedian gets the fallback bound to it. The types.ModuleType stand-in in test_default_median_falls_back_without_nanmedian covers the same code path, but it cannot catch a regression in how a real namespace is probed, since it never exercises hasattr against an actual array namespace.

I removed the branch first and measured it. Isolating the change (strict, test_combiner.py):

state result
HEAD, before the edit 68 failed, 23 passed, 1 xfailed
with this branch removed 69 failed, 22 passed, 1 xfailed
new _nanmedian.py, HEAD test_combiner.py 68 failed, 23 passed, 1 xfailed

The third row confirms the flip comes from this edit, not from the implementation changes. The failure mode is also worse than a clean assertion: getattr(xp, "nanmedian") raises AttributeError before the assert is reached.

The pytest.skip from the sibling thread is gone, so what remains is the elif hasattr(...) plus an else that asserts the fallback binding, with a comment noting that nanmean/nansum/nanstd raise RuntimeError inside default_func above and so never reach it.

Unrelated but worth knowing, since it affects numbers quoted across the #971 batch: the strict counts are not stable run-to-run. pytest-randomly reorders the suite and some strict test_combiner.py tests are order-dependent — I saw 75 failed / 12 passed and 68 failed / 23 passed from the same tree. With -p no:randomly it is consistently 68/23. The "75 → 74" figures in this PR's description were measured under a random order and are not comparable; the honest statement is that strict is unchanged by this PR.

Written by Claude at @mwcraig's direction.

Comment thread ccdproc/tests/test_nanmedian.py Outdated


@pytest.mark.parametrize("length", [1, 2, 3, 4, 5, 6])
def test_nanmedian_odd_and_even_lengths(length):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The eight _check wrappers and two error tests can collapse into two parametrized tests — 85 → 43 lines, 15 → 17 cases, same assertions (the all-NaN column goes through the same numpy comparison with equal_nan=True instead of hand-rolled asserts). I ran this version against this branch on numpy, array-api-strict, dask and jax (x64): 17 passed on each.

Proposed test_nanmedian.py
# Licensed under a 3-clause BSD style license - see LICENSE.rst
import array_api_extra as xpx
import numpy as np
import pytest

from ccdproc._nanmedian import nanmedian
from ccdproc.conftest import testing_array_device as xp_device
from ccdproc.conftest import testing_array_library as xp

_rng = np.random.default_rng(906)
_some_nan = _rng.normal(size=(4, 3))
_some_nan[[0, 1, 2, 3], [1, 2, 0, 2]] = np.nan


@pytest.mark.filterwarnings("ignore:All-NaN slice encountered:RuntimeWarning")
@pytest.mark.parametrize(
    ("data", "axis"),
    [
        *[(_rng.normal(size=(n, 7)), 0) for n in range(1, 7)],  # odd/even lengths
        (np.array([3.0, 1.0, 2.0, 4.0]), 0),  # 1-D
        (_rng.normal(size=(5, 4, 3)), 0),  # 3-D
        (_some_nan, 0),
        (_some_nan, 1),
        (_some_nan, -1),
        (np.array([[1.0, np.nan], [2.0, np.nan], [3.0, np.nan]]), 0),  # all-NaN column
        (np.array([[1, 4], [2, 3], [5, 6], [4, 1]]), 0),  # integer input
    ],
)
def test_nanmedian_matches_numpy(data, axis):
    result = nanmedian(xp.asarray(data, device=xp_device), axis=axis)
    expected = xp.asarray(np.nanmedian(data, axis=axis), device=xp_device)
    assert result.shape == expected.shape
    assert xp.isdtype(result.dtype, "real floating")
    assert xp.all(xpx.isclose(result, expected, equal_nan=True))


@pytest.mark.parametrize(
    ("axis", "error"),
    [(None, NotImplementedError), ((0, 1), NotImplementedError), (2, ValueError), (-3, ValueError)],
)
def test_nanmedian_bad_axis(axis, error):
    with pytest.raises(error):
        nanmedian(xp.asarray(np.ones((2, 2)), device=xp_device), axis=axis)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Happy to adopt this. It keeps the same assertions (the all-NaN column now goes through isclose(..., equal_nan=True) against np.nanmedian, which is the same check expressed uniformly) and picks up two extra error cases. Two small notes on the proposed version: the module-level filterwarnings marker becomes a per-test decorator, which is fine since only the matching test can emit the all-NaN warning; and the error-case parametrize line exceeds the line length, so I will wrap it for ruff.

Written by Claude at @mwcraig's direction.

Review feedback on astropy#978:

- The array API leaves NaN ordering in `sort` implementation-defined, so
  relying on NaNs sorting last was not portable. Replace NaNs with `+inf`
  before sorting; genuine `+inf` entries compare equal to the sentinels,
  so the positions below `n` are unaffected. Fix the docstring, which
  claimed the old ordering came from the specification.

- Count non-NaN entries as `int32` rather than `int64`, and promote
  integer input to the namespace's default real dtype rather than
  hardcoding `float64`. Both tripped jax's x64-truncation `UserWarning`
  when `JAX_ENABLE_X64` is unset, which `filterwarnings = ["error"]`
  turns into a failure: `test_nanmedian.py` went 12 failed / 3 passed in
  that configuration, and now passes in full both with and without x64.

- Use the `if xp is None:` idiom for the namespace default.

- Collapse `test_nanmedian.py` into two parametrized tests: 85 -> 50
  lines, 15 -> 17 cases, same assertions.

- Drop the `pytest.skip` for namespaces lacking nanmean/nansum/nanstd in
  `test_bottleneck_defaults_respect_array_namespace`; those raise
  `RuntimeError` inside the default_func call above, so the skip never
  fired and only obscured the real failure.

The nanmedian branch of that test is kept: array-api-strict is a CI
backend and does not provide `nanmedian`, so it is the only end-to-end
check that a real namespace without it gets the fallback bound.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider adding fallback median implementation

1 participant