🍾 Array-API fallback for sigma_func (MAD) when the namespace is not numpy - #1000
Conversation
sigma_func wraps astropy.stats.median_absolute_deviation, which is numpy-only, so on every other array namespace the data was converted to numpy (and on array-api-strict the call failed outright). This is one of the two remaining astropy.stats call sites behind astropy#929. Keep the numpy path exactly as it was -- same astropy call, same masked CCDData handling, no new work before the is_numpy_namespace check -- and route every other namespace to a new private core._mad_fallback that computes the median absolute deviation purely in terms of the array API on the input's device, using the sort-based medians from _nanfuncs (nanmedian for ignore_nan=True, median otherwise). The fallback promotes integer and boolean input to the namespace's default real floating dtype, flattens for axis=None (background_deviation_box), reduces over tuples of axes by permuting them last and merging them, validates axes, and excludes the masked pixels of a CCDData. sigma_func stays the same function object because median_combine tests uncertainty_func identity. The new tests exercise the fallback directly on every backend (the strict job uploads no coverage) against astropy over the axis and ignore_nan grid, including int/bool/float32 input and all-NaN slices, and check the public entry point against astropy.stats.mad_std, the namespace/device of the result, the fallback branch on numpy via a patched is_numpy_namespace, and the CCDData mask. In test_combiner.py, test_combiner_with_scaling now builds its reference stack with xp.stack instead of xp.asarray on a tuple of arrays, which array-api-strict rejects; the test previously failed earlier, in sigma_func, so this never surfaced. Verified: numpy 616 passed; array-api-strict 5 failed (the three Combiner.sigma_clipping tests, astropy#936, astropy#983 -- down from 13), 0 xpassed; jax 615 passed; dask 609 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XGArZMnLPC1G7YP62vN18V
Add the changelog entry (PR number to be filled in) and bring the "What limitations should I be aware of?" list in docs/array_api.rst up to date: it only mentioned the nanmedian fallback, but the combiner also falls back for nansum/nanmean/nanstd, subtract_overscan for median, and sigma_func now for the median absolute deviation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XGArZMnLPC1G7YP62vN18V
sigma_func no longer converts non-numpy data to numpy, so the escape is not observed any more. Regenerated with a full-suite dask run (CCDPROC_ARRAY_LIBRARY=dask CCDPROC_LOG_ARRAY_ESCAPES=1 CCDPROC_WRITE_ESCAPE_BASELINE=1); the only change is this deleted line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XGArZMnLPC1G7YP62vN18V
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XGArZMnLPC1G7YP62vN18V
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1000 +/- ##
==========================================
+ Coverage 97.78% 97.83% +0.05%
==========================================
Files 9 9
Lines 1808 1850 +42
==========================================
+ Hits 1768 1810 +42
Misses 40 40
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Adds an Array-API-native MAD fallback for sigma_func, avoiding NumPy conversion on non-NumPy backends.
Changes:
- Implements
_mad_fallbackwith axis, mask, NaN, dtype, namespace, and device handling. - Expands MAD tests across backends and fixes strict-array scaling setup.
- Updates Array API documentation, changelog, and escape baseline.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
ccdproc/core.py |
Adds and integrates the MAD fallback. |
ccdproc/tests/test_ccdproc.py |
Adds fallback and public API tests. |
ccdproc/tests/test_combiner.py |
Uses xp.stack for backend compatibility. |
ccdproc/tests/array_escape_baseline.txt |
Removes the resolved NumPy escape. |
docs/array_api.rst |
Documents reduction fallbacks and limitations. |
CHANGES.rst |
Records the new behavior. |
Suppressed comments (1)
ccdproc/core.py:1539
- Do not override the caller's
ignore_nanchoice merely because the CCDData has a mask. Astropy's explicitMaskedArraypath already excludes masked pixels and only additionally masks ordinary NaNs whenignore_nan=True; forcing this flag makes unmasked NaNs get ignored even when the caller requested propagation.
ignore_nan = True
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
mwcraig
left a comment
There was a problem hiding this comment.
Verdict: needs small changes — one low-severity bug, otherwise merge-ready in substance.
What I verified: the numpy path is unchanged for plain arrays and unmasked CCDData (checked against the old np.asarray(median_absolute_deviation(...) * 1.4826...) formula over the axis × ignore_nan grid). _mad_fallback matches astropy.stats.median_absolute_deviation on 300 seeded random cases per backend (ndim 1–4, NaN fractions 0/0.1/0.5, every axis form) on numpy, array-api-strict on device1, jax (X64) and dask, plus the mask variants (all-True, all-False, fully masked column, int mask, numpy mask with foreign data). On dask the result is a lazy dask.array (no eager compute); on jax it traces under jax.jit; float32 is preserved and the result stays on the input device. The removal of the core.py sigma_func numpy.asanyarray baseline line is justified: an enforce run on dask over all sigma_func callers logs no sigma_func site.
Two notes that do not anchor to a diff line:
- The PR description says the numpy path keeps the "same masked-
CCDDatahandling". It does not — the numpy masked path changed deliberately (np.ma.masked_array+ forcedignore_nan), and that is a fix (old behaviour for a masked CCDData with a NaN ataxis=0, ignore_nan=Falsewas[0., nan]; new is[1.48, 2.97]). The second#1000CHANGES bullet describes it correctly; the PR text should not claim the numpy path is untouched. - Possible follow-up rather than for this PR: the
None/tuple axis support (permute the reduced axes last + merge) belongs in_nanfuncs._setup, where it would apply to all five fallbacks at once._mad_fallbackwould shrink to ~6 lines and_median_fallback/the combiner fallbacks would gain tuple axes for free.
Merge with #1001: only textual conflicts, in CHANGES.rst and docs/array_api.rst (adjacent bullet insertions); no code overlap (#1001 does not touch core.py, this PR does not touch combiner.py; the baseline file auto-merges).
Test runs on this branch (test_ccdproc.py + test_combiner.py):
| result = stats.median_absolute_deviation(data, axis=axis, ignore_nan=ignore_nan) | ||
| if np.ma.isMaskedArray(result): | ||
| # Entirely masked slices, which the fallback also reports as NaN. | ||
| result = result.filled(np.nan) |
There was a problem hiding this comment.
Bug (low severity): an entirely masked CCDData with axis=None returns 0.0 here, not NaN. astropy's median_absolute_deviation does result.item() for masked input when axis is None, and np.ma.masked.item() is 0.0, so by the time np.ma.isMaskedArray(result) runs there is nothing left to .filled(np.nan). This contradicts the docstring above ("a slice that is entirely masked gives NaN"), the same call at axis=0 (NaN) and the fallback on every other backend (NaN).
import numpy as np; from astropy.nddata import CCDData
from ccdproc.core import sigma_func, _mad_fallback
d = np.arange(6.).reshape(3, 2); m = np.ones((3, 2), bool)
sigma_func(CCDData(d, unit="adu", mask=m)) # array(0.)
sigma_func(CCDData(d, unit="adu", mask=m), axis=0) # array([nan, nan])
_mad_fallback(d, None, False, mask=m) # array(nan)The simplification suggested on the np.ma.masked_array line fixes this as a side effect.
There was a problem hiding this comment.
Reproduced, including the axis=0 vs axis=None asymmetry. The .filled(np.nan) guard never sees the axis=None case because astropy has already turned np.ma.masked into 0.0 with .item().
Will do: fix it via the mask→NaN substitution in sigma_func proposed on the np.ma.masked_array line, so numpy goes through astropy's nanmedian path and an entirely masked input gives NaN for every axis, and drop the np.ma.isMaskedArray special case. test_sigma_func_ccddata_mask_is_honoured gets an all-masked axis=None case so this cannot come back.
— Written by Claude at @mwcraig's direction.
| # mask of the MaskedArray that CCDData.__array__ produces, which | ||
| # only happens on its small-array path (fewer than 600 elements | ||
| # along ``axis``) and never once bottleneck is installed. | ||
| data = np.ma.masked_array(data, mask=mask) |
There was a problem hiding this comment.
Simplification (≈ −15 lines, also fixes the axis=None bug): do the mask→NaN substitution once in sigma_func, before the numpy/fallback branch, and drop the mask handling from both branches:
if mask is not None:
nan = xp.asarray(xp.nan, dtype=data.dtype, device=device)
data = xp.where(xp.asarray(mask, device=device), nan, data)
ignore_nan = TrueThen _mad_fallback loses its mask= parameter (and the xp.where block plus its docstring), and this branch loses the np.ma.masked_array construction, the np.ma.isMaskedArray/.filled(np.nan) special case and the comment explaining astropy's mask quirks. On numpy astropy then takes its nanmedian (bottleneck when installed) path instead of np.ma.median, which is markedly slower, and an all-NaN slice at axis=None gives NaN. The two branches become structurally identical, and the direct _mad_fallback(..., mask=mask) call in test_sigma_func_ccddata_mask_is_honoured goes away. (Integer CCDData data promotes to float in the where, which astropy would have done anyway.)
There was a problem hiding this comment.
Will do, with two adjustments found while checking it:
- The
whereneeds the promotion first. On array-api-strict,xp.where(mask, float64_nan, int64_data)raisesTypeError: array_api_strict.float64 and array_api_strict.int64 cannot be type promoted together(numpy promotes silently, so an integerCCDDatawould pass on numpy and fail elsewhere). So the promotion helper from the other thread runs insigma_funcwhenmask is not None, before thewhere; unmasked numpy input still reaches astropy untouched. - Warnings: the numpy masked path moves from
np.ma.median(silent on an entirely masked slice) tonp.nanmedian, which emitsRuntimeWarning: All-NaN slice encountered, once per such slice. That is numpy's normal behaviour forsigma_func(arr, ignore_nan=True)on NaN-filled data, so it is acceptable, but the comment above thefilterwarningsmark ontest_sigma_func_ccddata_mask_is_honoured("the masked path itself does not warn") becomes false and will be rewritten.
Along with that: _mad_fallback loses mask=, the numpy branch loses the np.ma construction and the .filled(np.nan) special case, the direct _mad_fallback(..., mask=mask) call in the test goes, and the test gains an all-masked axis=None case for the 0.0 bug.
— Written by Claude at @mwcraig's direction.
| elif isinstance(axis, tuple): | ||
| ndim = data.ndim | ||
| axes = [] | ||
| for ax in axis: | ||
| ax = operator.index(ax) | ||
| if not -ndim <= ax < ndim: | ||
| raise ValueError( | ||
| f"axis {ax} is out of bounds for array of dimension {ndim}" | ||
| ) | ||
| axes.append(ax % ndim) | ||
| if len(set(axes)) != len(axes): |
There was a problem hiding this comment.
Simplification (≈ −10 lines): this is numpy's normalize_axis_tuple, which is pure-Python integer logic (touches no arrays) and numpy is a hard dependency:
axes = list(normalize_axis_tuple(axis, ndim))It raises AxisError (a ValueError subclass) for out-of-bounds and ValueError("repeated axis") for duplicates, accepts numpy ints, and accepts lists — which closes a small inconsistency: sigma_func(a, axis=[0, 1]) works on numpy (astropy accepts it) but raises NotImplementedError from _nanfuncs._setup on every other backend because only isinstance(axis, tuple) is tested here. With it, an int axis can be treated as a 1-tuple and the elif split disappears (permute/reshape of a single axis is a no-op view), and the operator import goes. Caveats: with the numpy>=1.26 pin the import location differs (numpy.lib.array_utils on 2.x, numpy.core.numeric on 1.26), so a 3-line try/except import; and normalize_axis_tuple(True, n) silently gives (1,), so keep the bool rejection if that matters.
There was a problem hiding this comment.
Will do. Checked the details:
- Import:
numpy.lib.array_utilson 2.x;numpy.core.numericstill resolves on numpy 2.5 but with aDeprecationWarning(which pytest'sfilterwarnings = errorwould turn into a failure), so it istry: from numpy.lib.array_utils import normalize_axis_tuple/except ImportError: from numpy.core.numeric import ...for 1.26. AxisErroris aValueError(andIndexError) subclass;(0, -3)givesValueError: repeated axis.test_mad_fallback_rejects_duplicate_axeskeeps itsmatch=strings adjusted.- The inconsistency is real:
sigma_func(a, axis=[0, 1])works on numpy and raisesNotImplementedErroron strict, jax and dask;axis=Trueis silently axis 1 on numpy (astropy accepts it) and raises on the others. The bool rejection stays on the fallback, as aTypeError.
One quibble with the comment: with a single int axis the permute_dims is a transpose, not a no-op, unless that axis is already last. It is a view on numpy and lazy on dask, and the sort has to move data along that axis regardless, so it is not worth a special case; the elif split and the operator import go.
— Written by Claude at @mwcraig's direction.
There was a problem hiding this comment.
Let's assume that the next ccdproc release (or at least the one with the array api stuff) will require numpy 2, so we can drop the 1.26 stuff. Open an issue to remind us to bump the requirement
| if not xp.isdtype(data.dtype, "real floating"): | ||
| info = xp.__array_namespace_info__() | ||
| data = xp.astype(data, info.default_dtypes(device=device)["real floating"]) |
There was a problem hiding this comment.
These three lines duplicate _nanfuncs._setup (_nanfuncs.py:87-92) verbatim. Worth a tiny _promote_to_real(x, xp, device) helper in _nanfuncs used by both (or xpx.default_dtype, if the pinned array_api_extra>=0.7.0 has it — the installed 0.11.1 does).
There was a problem hiding this comment.
Will do: a _promote_to_real(x, xp, device) in _nanfuncs, used by _setup, _mad_fallback, and the mask substitution that moves into sigma_func (which needs the promotion before its where on strict, see that thread). xpx.default_dtype only arrived in array-api-extra 0.8.0 (checked the 0.7.0 and 0.8.0 wheels: absent in 0.7.0), so using it means bumping the >=0.7.0 pin; the helper avoids that. Say if you would rather bump.
— Written by Claude at @mwcraig's direction.
| ) | ||
| axis = -1 | ||
|
|
||
| med = _nanfuncs_nanmedian if ignore_nan else _nanfuncs_median |
There was a problem hiding this comment.
Question: _median_fallback a few functions up and combiner._default_median both try the namespace's native median/nanmedian first and only fall back to the sort-based _nanfuncs versions when there is none; this always sorts, even on jax/dask/cupy/torch, which all have nanmedian (O(n), fused under jit on jax). Two full sorts of the stack per call. Is that intentional — to keep all-NaN slices silent, which native nanmedian on numpy-backed dask would not be? If so, a one-line comment here saying so would stop the next reader from "fixing" it; if not, native-first would match the rest of the module and the docs would no longer need to advertise the sort cost for every non-numpy library.
There was a problem hiding this comment.
Not intentional in the sense of a silence guarantee: the sort path was chosen to have one code path on every backend, and the silence was documented after the fact. Checked what native-first would actually do:
- dask (
array_api_compat.dask.array) and jax both havemedianandnanmedian; array-api-strict has neither, so it would still fall back. dask's need an integer axis (axis=NoneraisesNotImplementedErrorthere), which is exactly what_mad_fallbackpasses after itsNone/tuple handling, so the merge-axes step stays. - The silence buys nothing on dask:
Combiner.median_combineon dask already uses nativexp.nanmedianthrough_default_median, which emitsAll-NaN slice encounteredat compute time for the very same slices; jax'snanmedianis silent.
Will do: try xp.nanmedian/xp.median and fall back on AttributeError, mirroring _median_fallback; the docstring and docs/array_api.rst then claim the sort cost only for libraries without a native version; test_mad_fallback_all_nan_slice_is_silent goes, since silence becomes a property of the _nanfuncs medians (pinned in test_nanfuncs.py) rather than of _mad_fallback.
— Written by Claude at @mwcraig's direction.
| Raises | ||
| ------ | ||
| ValueError | ||
| If ``axis`` is out of bounds or lists an axis twice. |
There was a problem hiding this comment.
Nit: a list or bool axis raises NotImplementedError (from _nanfuncs._setup), which the Raises section does not mention. Moot if normalize_axis_tuple is adopted for the tuple path.
There was a problem hiding this comment.
Agree. Will do: with normalize_axis_tuple handling the int/tuple/list forms (see the thread on the axis loop) a list axis works, a bool one is rejected explicitly, and the Raises section will list exactly what is raised (ValueError for out-of-bounds/duplicate, TypeError for a bool or non-integer entry) instead of leaking NotImplementedError from _nanfuncs._setup.
— Written by Claude at @mwcraig's direction.
|
|
||
| The masked pixels of a masked `~astropy.nddata.CCDData` are excluded | ||
| from the statistics on every backend, which implies ``ignore_nan``; a | ||
| slice that is entirely masked gives NaN. |
There was a problem hiding this comment.
Nit: currently false on numpy for axis=None (see the comment on the .filled(np.nan) line below) — fix the code or the sentence.
There was a problem hiding this comment.
Will do: the code side. After the mask→NaN substitution moves into sigma_func, the numpy branch computes an entirely masked axis=None input through np.nanmedian and returns NaN, so the sentence becomes true as written.
— Written by Claude at @mwcraig's direction.
| - ``sigma_func`` now always excludes the masked pixels of a masked | ||
| ``CCDData``. Previously the mask was only honoured on numpy for small | ||
| arrays with a single integer ``axis`` and ``ignore_nan=True``, and never | ||
| when bottleneck is installed. [#1000] |
There was a problem hiding this comment.
Nit: "never when bottleneck is installed" is only true for float64 data — astropy's _DtypeDispatch sends only f8 arrays to bottleneck, so a masked float32 CCDData still went through numpy's small-array path. Suggest "not for float64 data when bottleneck is installed". The rest of the sentence (fewer than 600 elements along a single integer axis, ignore_nan=True) checks out against numpy's _nanmedian/_nanmedian_small and CCDData.__array__ returning a MaskedArray.
There was a problem hiding this comment.
Agree. Checked the installed astropy (8.0.1): stats.nanfunctions._DtypeDispatch.__call__ routes to bottleneck only when dt.kind == "f" and dt.itemsize == 8, and median_absolute_deviation reaches it only on the ignore_nan=True path. One nuance: the f8-only dispatch arrived in astropy 7.0; astropy 6.0.x (still allowed by the >=6.0.1 pin) hands every dtype to bottleneck, so the original sentence was right there and wrong on anything current.
Will do: reword to "…and, when bottleneck is installed, not for float64 data at all", which is correct on every supported astropy.
— Written by Claude at @mwcraig's direction.
There was a problem hiding this comment.
Assume the minimum Astro is at least 7 for the array api release
| assert xp.all(xpx.isclose(result, expected)) | ||
|
|
||
|
|
||
| _MAD_RNG = np_random.default_rng(929) |
There was a problem hiding this comment.
Test LOC: the new block is 179 lines; about 70 of them (≈ 38%) re-cover paths already pinned elsewhere, mostly in test_nanfuncs.py. What each test uniquely covers:
| Test | LOC | Unique path | Redundant with |
|---|---|---|---|
_MAD_* fixtures + _MAD_CASES (23 cases) |
35 | data | axis -1, np_int64(1), the 6 odd/even lengths, both all-NaN cases and the two 1-D cases are covered by test_nanfuncs.py::test_matches_numpy (_DATA, lines 36-42); (0,1), (0,2), (2,0) add nothing over (-1,0); bool adds nothing over int; the float32 case only asserts isdtype(..., "real floating"), so it tests nothing float32-specific |
test_mad_fallback_matches_astropy (46 params) |
32 | axis None / int / tuple; promotion; median vs nanmedian |
— |
test_mad_fallback_all_nan_slice_is_silent |
10 | nothing new: the silence is _nanfuncs' property, pinned by test_nanfuncs.py::test_no_warning_on_all_nan_slice for both medians; abs(nan - nan) cannot warn |
drop |
test_mad_fallback_rejects_duplicate_axes |
13 | tuple duplicate (incl. negative alias), tuple out-of-bounds | int out-of-bounds is _setup's, covered by test_nanfuncs.py::test_bad_axis |
test_sigma_func_matches_mad_std (3) |
24 | public wrapper × constant; 0-d float() |
— |
test_sigma_func_keeps_namespace_and_device |
11 | namespace/device of result | same computation as above; two asserts |
test_sigma_func_fallback_branch_on_any_backend |
16 | numpy coverage of the one return _mad_fallback(...) * c line |
same test with a monkeypatch |
test_sigma_func_ccddata_mask_is_honoured (4) |
32 | numpy masked path (axis None / axis given); fallback mask path | (1, False) and ((0,1), True) hit no branch not hit by (0, True) / (None, False) |
Consolidated sketch (~110 lines including imports, ≈ −70; −75 if the mask substitution moves into sigma_func):
_MAD_3D = ... # as now
_MAD_CASES = [
*[(_MAD_3D, ax) for ax in (None, 0, (-1, 0), (0, 1, 2))], # flatten, int, neg+unsorted tuple, all axes
(np_array([[1, 4], [2, 3], [5, 6], [4, 1]]), 0), # promotion
(<float32 2x3 array>, 1), # dtype preserved
]
@pytest.mark.filterwarnings("ignore:All-NaN slice encountered:RuntimeWarning")
@pytest.mark.parametrize("ignore_nan", [False, True])
@pytest.mark.parametrize(("data", "axis"), _MAD_CASES)
def test_mad_fallback_matches_astropy(data, axis, ignore_nan):
expected = xp.asarray(np_asarray(median_absolute_deviation(data, axis=axis, ignore_nan=ignore_nan)), device=xp_device)
result = _mad_fallback(xp.asarray(data, device=xp_device), axis, ignore_nan)
assert result.shape == expected.shape
assert result.dtype == expected.dtype # promotion to float64 AND float32 preservation in one line
assert xp.all(xpx.isclose(result, expected, equal_nan=True))
@pytest.mark.parametrize(("axis", "match"), [((0, 0), "duplicate"), ((0, -3), "duplicate"), ((0, 3), "out of bounds")])
def test_mad_fallback_rejects_bad_axis_tuple(axis, match): ...
@pytest.mark.parametrize("force_fallback", [False, True])
@pytest.mark.parametrize(("data", "axis", "ignore_nan"), [...the same 3 params...])
def test_sigma_func_matches_mad_std(data, axis, ignore_nan, force_fallback, monkeypatch):
if force_fallback:
monkeypatch.setattr(array_api_compat, "is_numpy_namespace", lambda _xp: False)
...existing body...
assert array_api_compat.array_namespace(result) is array_api_compat.array_namespace(data)
if xp_device is not None:
assert array_api_compat.device(result) == xp_device
@pytest.mark.filterwarnings("ignore:All-NaN slice encountered:RuntimeWarning")
@pytest.mark.parametrize(("axis", "ignore_nan"), [(0, True), (None, False)])
def test_sigma_func_ccddata_mask_is_honoured(axis, ignore_nan): ... # as now, two params fewerThe negative-tuple normalisation of the astropy reference only matters when bottleneck is installed, which it is not in any tox env; keeping the two lines is fine but the comment could say so.
There was a problem hiding this comment.
Mostly agree; will do the consolidation along the lines sketched: one parametrised test_mad_fallback_matches_astropy over the six cases with result.dtype == expected.dtype (which does pin float32 preservation, unlike the current isdtype check), the bad-axis test reduced to the tuple cases (the int case becomes numpy's own AxisError once normalize_axis_tuple is in), the namespace/device asserts and the force_fallback monkeypatch folded into test_sigma_func_matches_mad_std, and the mask test down to (0, True) and (None, False) plus an all-masked axis=None case for the 0.0 bug.
Two corrections:
- bottleneck is in CI:
ci_tests.ymlrunspy312-test-alldeps-numpy126-bottleneckandpy312-alldeps-dask-bottleneck(the toxbottleneckfactor)._MAD_3Dis float64, so on those jobs astropy's_DtypeDispatchsends the reference through_apply_bottleneck→_move_tuple_axes_last, which buildsother_axeswithi not in axisand then callsnp.transpose(array, (1, 2, -1, 0))for(-1, 0), a four-entry order for a 3-D array (ValueError: axes don't match array). The normalisation and its comment stay as they are; the comment already says why. test_mad_fallback_all_nan_slice_is_silentgoes, but for the reason in the native-first thread: once_mad_fallbackprefers a nativenanmedian, silence on dask is no longer its property at all, so the test would start failing there rather than merely being redundant.
— Written by Claude at @mwcraig's direction.
|
On the two notes in the review body of review 5061397724 that have no diff line:
The Copilot review's suppressed comment on line 1539 (do not force — Written by Claude at @mwcraig's direction. |
|
|
||
| if array_api_compat.is_numpy_namespace(xp): | ||
| if mask is not None: | ||
| # astropy only honours the mask of a numpy.ma.MaskedArray. Passing |
There was a problem hiding this comment.
American spelling throughout please
There was a problem hiding this comment.
Will do. Grepping the added lines of the whole diff (356 lines, five files) for the usual -our/-ise/-yse/-re forms finds four, all fixed in the next push:
ccdproc/core.py:1533—honours→honors(this comment)ccdproc/tests/test_ccdproc.py:451—normalised→normalizedccdproc/tests/test_ccdproc.py:554—test_sigma_func_ccddata_mask_is_honoured→..._honoredCHANGES.rst:130—honoured→honored
Nothing in docs/array_api.rst or test_combiner.py. The subject of b8b0e9f ("Honour ... normalise ...") has the same two words and gets reworded when the branch is rewritten for the test consolidation.
— Written by Claude at @mwcraig's direction.
| # axis=0 with ignore_nan=True is the form median_combine uses. | ||
| [(0, True), (None, False), (1, False), ((0, 1), True)], | ||
| ) | ||
| def test_sigma_func_ccddata_mask_is_honoured(axis, ignore_nan): |
There was a problem hiding this comment.
American spelling please
There was a problem hiding this comment.
Renamed to test_sigma_func_ccddata_mask_is_honored; the other three in the diff (honours at core.py:1533, normalised at test_ccdproc.py:451, honoured at CHANGES.rst:130) are listed in the core.py thread.
— Written by Claude at @mwcraig's direction.
| def test_sigma_func_fallback_branch_on_any_backend(monkeypatch): | ||
| # On numpy sigma_func hands the data to astropy; every other backend | ||
| # takes the fallback branch, and none of them reports coverage, so make | ||
| # numpy take it too and check that the two branches agree. |
There was a problem hiding this comment.
Couldn't we add one of the backends to coverage instead of this?
There was a problem hiding this comment.
Two of them already are, and this test's premise is wrong. Since c2ce2e2 (2026-08-23, before this PR was opened) ci_tests.yml runs py313-jax-cov and py312-alldeps-dask-enforce-cov and uploads both with flags: jax / flags: dask alongside the numpy upload; there is no codecov.yml, so codecov merges the three uploads per commit (that merged report is what codecov/patch is green on here). Only the strict job is uncovered: it sits in the continue-on-error job without a -cov factor, and adding one would not help while it fails by design, because the upload step runs only after a successful tox.
On jax and dask the real branch, return _mad_fallback(...) * 1.482602218505602, is executed by test_sigma_func_matches_mad_std, by test_sigma_func_ccddata_mask_is_honoured (including the mask= path) and by every median_combine test in test_combiner.py, so the monkeypatch buys nothing. Changes:
- this test goes, and with it the
force_fallbackparameter promised in the thread at line 405 (superseded); - the two "none of which report coverage" comments (
test_mad_fallback_matches_astropyand the tail of the mask test) go, and so does the trailing direct_mad_fallback(ccd.data, ..., mask=mask)call in the mask test; - the direct-call
test_mad_fallback_matches_astropystays, because it is the differential check against astropy over the axis forms, not a coverage device; its comment will say that; - the PR description gets the same correction (it says the strict job uploads no coverage, which is true but was the wrong reason).
No CI change needed.
— Written by Claude at @mwcraig's direction.
| assert float(result) == pytest.approx(float(expected_np)) | ||
|
|
||
|
|
||
| def test_sigma_func_keeps_namespace_and_device(): |
There was a problem hiding this comment.
Every test needs a comment explaining what it does...
There was a problem hiding this comment.
Will do. The file uses a leading # comment rather than a docstring (0 docstrings across its 58 tests), so each test in the block gets a one-line comment saying what it checks and, where it is not obvious, why. For the four tests that survive the consolidation:
test_mad_fallback_matches_astropy—_mad_fallbackagrees withastropy.stats.median_absolute_deviationon every backend for each axis formsigma_func's callers use, and handles dtype like the_nanfuncsmedians (int promoted, float32 kept).test_mad_fallback_rejects_bad_axis_tuple— duplicate (including a negative alias) and out-of-bounds entries in a tuple axis raiseValueErrorinstead of silently reducing the wrong axes.test_sigma_func_matches_mad_std— the public entry point matchesastropy.stats.mad_std, stays in the input's namespace and device, and foraxis=Nonegives a 0-d result that converts tofloat(whatbackground_deviation_boxrelies on).test_sigma_func_ccddata_mask_is_honored— maskedCCDDatapixels are excluded on every backend (numpy vianumpy.ma, the rest via the fallback), equivalent to NaN-filling withignore_nanon; an all-masked slice gives NaN, not 0.0.
The existing multi-line comments that explain a non-obvious step (the bottleneck axis normalization, the bool() forcing on lazy backends) stay below those.
— Written by Claude at @mwcraig's direction.
| _MAD_3D[[0, 1, 2, 4], [1, 2, 0, 3], [0, 2, 1, 1]] = np_nan | ||
| _MAD_CLEAN_3D = _MAD_RNG.normal(size=(5, 4, 3)) | ||
|
|
||
| _MAD_CASES = [ |
There was a problem hiding this comment.
Yes; this is the block the consolidation agreed in the thread at line 405 shrinks. After it:
_MAD_RNG = np_random.default_rng(929)
_MAD_3D = _MAD_RNG.normal(size=(5, 4, 3))
_MAD_3D[[0, 1, 2, 4], [1, 2, 0, 3], [0, 2, 1, 1]] = np_nan # scattered NaNs
_MAD_CASES = [
# axis=None (background_deviation_box), an int (median_combine), a tuple
# with a negative, unsorted entry, and every axis.
*[(_MAD_3D, axis) for axis in (None, 0, (-1, 0), (0, 1, 2))],
(np_array([[1, 4], [2, 3], [5, 6], [4, 1]]), 0), # int -> default float
(np_array([[1.0, 2.0, 3.0], [4.0, 5.0, 7.0]], dtype=np_float32), 1), # float32 kept
]35 lines → 11, 23 cases → 6, with _MAD_CLEAN_3D gone: the bad-axis test raises before it computes anything so it can use _MAD_3D, and the one ignore_nan=False case of test_sigma_func_matches_mad_std uses np_nan_to_num(_MAD_3D). The dropped cases are covered in test_nanfuncs.py (-1, np_int64, the six odd/even lengths, both all-NaN inputs, the 1-D inputs) or add nothing over a kept case ((0, 1)/(0, 2)/(2, 0) vs (-1, 0), bool vs int), and result.dtype == expected.dtype in the test replaces the isdtype check so the float32 case pins something.
— Written by Claude at @mwcraig's direction.
…or astropy+bottleneck astropy's median_absolute_deviation only honors the mask of an explicit numpy.ma.MaskedArray, so hand it one when the CCDData has a mask instead of relying on numpy.nanmedian noticing the mask of CCDData.__array__'s output (which only happens on its small-array path and never with bottleneck). The astropy reference in test_mad_fallback_matches_astropy gets a tuple axis with negative entries normalized: astropy's bottleneck dispatch transposes with the tuple as given and raises on a negative entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JnWCg95xE93SbhME52jxGJ
…ogic Move the CCDData mask to NaN substitution out of _mad_fallback and into sigma_func, before the numpy/fallback branch split, so both branches handle a masked array identically: NaN-substituted data with ignore_nan forced on. The promotion to a real floating dtype happens first, since xp.where(mask, float_nan, int_data) raises on array-api-strict instead of promoting silently the way numpy does. On numpy this replaces the numpy.ma.MaskedArray construction with the same NaN-filled array astropy already accepts, so numpy now takes its nanmedian (bottleneck-accelerated, when installed) path instead of np.ma.median, and an entirely masked slice at axis=None gives NaN instead of the previous 0.0. Factor the promote-to-real-dtype step, shared by _nanfuncs._setup, _mad_fallback and the new mask substitution, into a ccdproc._nanfuncs._promote_to_real() helper. In _mad_fallback, replace the hand-rolled tuple-axis validation loop with numpy's normalize_axis_tuple (numpy 2 only; the next ccdproc release is expected to require it), which also accepts a list axis; a bool entry is still rejected explicitly with TypeError, since normalize_axis_tuple treats it as an int. Also prefer the array namespace's own median/nanmedian, falling back to the sort-based ccdproc._nanfuncs implementations only on AttributeError, mirroring _median_fallback's existing pattern; this drops the O(n log n) sort cost and the warning-free silence on all-NaN slices for any backend that provides a native median (dask and jax do; array-api-strict does not). docs/array_api.rst is updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVxnTLrWKStBdPwcmDNhxA
Shrink _MAD_CASES from 23 cases to 6: the odd/even lengths, both all-NaN cases, the two 1-D cases and the numpy-integer axis case are already pinned by test_nanfuncs.py::test_matches_numpy, and (0, 1), (0, 2), (2, 0) and a bool array added nothing over the kept (-1, 0)-with-negative-entry and int-array cases. test_mad_fallback_ matches_astropy now asserts result.dtype == expected.dtype, which pins float32 preservation where the previous isdtype check did not. Reduce the bad-axis coverage to test_mad_fallback_rejects_bad_axis_ tuple, over the tuple forms only; normalize_axis_tuple's own out-of-bounds handling already exercises the int case through test_nanfuncs.py::test_bad_axis. The raised message for a duplicate axis is now numpy's own "repeated axis" rather than the previous hand-written wording. Fold test_sigma_func_keeps_namespace_and_device and test_sigma_func_fallback_branch_on_any_backend into test_sigma_func_matches_mad_std as a force_fallback parameter, and drop test_mad_fallback_all_nan_slice_is_silent: once _mad_fallback prefers a native nanmedian, the silence on an all-NaN slice is a property of ccdproc._nanfuncs (pinned in test_nanfuncs.py) rather than of _mad_fallback, and would fail rather than merely duplicate on a backend with a native, warning nanmedian. Rename test_sigma_func_ccddata_mask_is_honoured to ..._is_honored, trim its parametrization to the (0, True) and (None, False) cases that exercise a distinct branch, and add an all-masked axis=None case that asserts the result is NaN, pinning the 0.0 bug fixed in the previous commit. Drop the trailing direct _mad_fallback(..., mask=mask) call, since _mad_fallback no longer takes a mask argument. Give each surviving test a one-line leading comment, since the file uses that style instead of docstrings, and fix "normalised" to "normalized" in a comment that carries over unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVxnTLrWKStBdPwcmDNhxA
The entry about the mask fix said the mask was never honored on numpy once bottleneck was installed. That was too strong: astropy's bottleneck dispatch (astropy >= 7) routes only float64 data to bottleneck, bypassing the mask; other dtypes still go through numpy.ma even with bottleneck installed. Reword to what is actually true on every astropy this release supports: not honored for float64 data specifically. Also fix "honoured" to "honored". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVxnTLrWKStBdPwcmDNhxA
The endorsed comment wording predates moving the mask->NaN substitution ahead of the namespace split: numpy no longer goes through numpy.ma, and the filterwarnings mark is now for numpy.nanmedian warning on the entirely masked column and on the all-masked input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVxnTLrWKStBdPwcmDNhxA
b8b0e9f to
dd58f75
Compare
Fixes a real bug the review found: _sigma_clip_mask only promoted non-floating input, so float32 data was clipped in float32 while astropy's compiled path always computes in float64, producing a different mask near a bound. It now widens (never narrows) to the namespace default whenever both cenfunc and stdfunc are strings, matching astropy's own promotion (a callable takes astropy's python loop instead, which keeps the data's own dtype, so this leaves a callable's input alone too). masked and return_bounds now raise a clear TypeError on every namespace, including numpy, instead of the accidental "multiple values"/AttributeError they triggered before. Refactors, as sketched in review: - _sigma_clip_mask's axis/dtype/device setup is now _nanfuncs._setup, same as every other NaN-aware fallback. - _resolve_sigma_clip_func/_default_mad_std collapse into a single _resolve() helper. - The pure MAD computation moves to _nanfuncs.nanmad(median=...); _nanmadstd becomes a one-line tiered wrapper around it. The TODO to dedup with astropy#1000's _mad_fallback stays, updated for nanmad. - The manual iteration counter becomes a for loop over range(maxiters) or itertools.count() when maxiters is None. Docs/docstrings: add the floating-point rounding caveat, spell out that axis outside numpy must be a single integer, document that masked/return_bounds are never accepted, and drop the stale "negative value" sentence (that's astropy#1002's question). Tests: drop the _mad_dev_func parametrize (redundant with the combine() callable coverage and the grid); add a float32 dataset; collapse the (cenfunc, stdfunc) x sigma-pair grid to four pairs with (None, 0) replacing a redundant (3, 3), wrapped in simplefilter("error") (skipped on dask) so it also covers silence on NaN; drop three tests whose properties the grid now covers; merge the argument-handling and dispatch tests each into one parametrized test; retarget the _nanmadstd differential test at _nanfuncs.nanmad. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVxnTLrWKStBdPwcmDNhxA
…loors to match The oldestdeps CI job (numpy 1.26) fails on the new numpy-2-only normalize_axis_tuple import; per the review decision on astropy#1000 the fix is to raise the minimum, not to add a 1.26 shim. Closes astropy#1003. numpy >= 2.0 forces the rest: astropy 6.0.* caps numpy below 2, so the oldest astropy becomes 6.1; the reproject 0.9.1 and astroscrappy 1.1.0 wheels are numpy-1 binaries, so the floors move to 0.14 and 1.2, the oldest releases built against numpy 2. The numpy126 tox factor is gone (numpy200 is the oldest now, and the bottleneck CI job uses it) and so is the astroscrappy11 factor, which pinned numpy below 2. Verified locally in a fresh venv with numpy 2.0.2, astropy 6.1.7, reproject 0.14.0 and astroscrappy 1.2.0: test_ccdproc.py and test_nanfuncs.py pass (219 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVxnTLrWKStBdPwcmDNhxA
codecov flagged the four lines the simplification left uncovered: the TypeError for a bool entry in a tuple axis, and the except branch of med(), which only runs naturally on a namespace with no native nanmedian/median (array-api-strict, which uploads no coverage). Hide the native functions behind a delegating proxy namespace, as test_median_fallback_without_native_median already does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVxnTLrWKStBdPwcmDNhxA
Fixes a real bug the review found: _sigma_clip_mask only promoted non-floating input, so float32 data was clipped in float32 while astropy's compiled path always computes in float64, producing a different mask near a bound. It now widens (never narrows) to the namespace default whenever both cenfunc and stdfunc are strings, matching astropy's own promotion (a callable takes astropy's python loop instead, which keeps the data's own dtype, so this leaves a callable's input alone too). masked and return_bounds now raise a clear TypeError on every namespace, including numpy, instead of the accidental "multiple values"/AttributeError they triggered before. Refactors, as sketched in review: - _sigma_clip_mask's axis/dtype/device setup is now _nanfuncs._setup, same as every other NaN-aware fallback. - _resolve_sigma_clip_func/_default_mad_std collapse into a single _resolve() helper. - The pure MAD computation moves to _nanfuncs.nanmad(median=...); _nanmadstd becomes a one-line tiered wrapper around it. The TODO to dedup with astropy#1000's _mad_fallback stays, updated for nanmad. - The manual iteration counter becomes a for loop over range(maxiters) or itertools.count() when maxiters is None. Docs/docstrings: add the floating-point rounding caveat, spell out that axis outside numpy must be a single integer, document that masked/return_bounds are never accepted, and drop the stale "negative value" sentence (that's astropy#1002's question). Tests: drop the _mad_dev_func parametrize (redundant with the combine() callable coverage and the grid); add a float32 dataset; collapse the (cenfunc, stdfunc) x sigma-pair grid to four pairs with (None, 0) replacing a redundant (3, 3), wrapped in simplefilter("error") (skipped on dask) so it also covers silence on NaN; drop three tests whose properties the grid now covers; merge the argument-handling and dispatch tests each into one parametrized test; retarget the _nanmadstd differential test at _nanfuncs.nanmad. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVxnTLrWKStBdPwcmDNhxA
Move the flatten-for-None and permute-and-merge steps from core._mad_fallback into the shared _nanfuncs._setup, as agreed in the review of #1000. _setup now accepts a single integer, None, or a tuple/list of axes, and returns a restore callable that maps a full-shape array back to the caller's layout (the identity for a single integer axis). This gives every reduction fallback in _nanfuncs (nansum, nanmean, nanstd, nanmedian, median, nanmad) tuple-axis and axis=None support for free, shrinks _mad_fallback to the median tier plus a delegation, and lifts the single-integer-axis restriction of Combiner.sigma_clipping's array-API path, which now accepts the same axis forms astropy.stats.sigma_clip does (the restore callable hands the clip mask back in the data's own shape). Bare-bool and non-integer axes now raise TypeError instead of NotImplementedError, matching the TypeError a bool entry in a tuple already raised. Closes #1004 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVxnTLrWKStBdPwcmDNhxA
Part of #929 (the
sigma_funchalf). Companion PR: #1001 (theCombiner.sigma_clippinghalf), built off the samemain.sigma_func— the defaultuncertainty_funcofmedian_combine, also used bybackground_deviation_box/background_deviation_filter— wrappedastropy.stats.median_absolute_deviation, which is numpy-only: on any other array namespace the data was converted to numpy, and onarray-api-strictthe call failed outright (8 of the 13 remaining strict failures).Policy: when the namespace is numpy,
sigma_funcstill calls astropy for the deviation, and the numpy path is unchanged for plain arrays and unmaskedCCDData. A maskedCCDDatanow has its mask honored on every backend — masked pixels are excluded from the statistics via a mask→NaN substitution made before the namespace split, and an all-masked slice gives NaN — as the second CHANGES bullet says. Every other namespace goes to a new privatecore._mad_fallbackthat computes the median absolute deviation purely in terms of the array API, on the input's device, using the sort-based medians in_nanfuncs(nanmedianforignore_nan=True,medianotherwise). The fallback promotes int/bool input to the namespace default float, flattens foraxis=None, reduces over tuples of axes (permute last + merge), and validates axes.sigma_funcstays the same function object becausemedian_combinetestsuncertainty_func is sigma_func.Also:
test_combiner_with_scalingbuilt its reference withxp.asarray((a, b, c))on a tuple of arrays, whicharray-api-strictrejects; it now usesxp.stack. The test never got that far before because it failed first insigma_func.Tests: the fallback is exercised directly on every backend (the strict job uploads no coverage) against astropy over
axis ∈ {None, int, numpy int, tuples incl. negative}×ignore_nan, with int/bool/float32 input and all-NaN slices; the public entry point is checked againstastropy.stats.mad_std, for namespace/device of the result, for the fallback branch on numpy (patchedis_numpy_namespace), and for theCCDDatamask. Design was prototype-verified with zero mismatches vs astropy on numpy, strict (device1), jax and dask.Docs: changelog entry; the "What limitations should I be aware of?" list in
docs/array_api.rstis refreshed (it only mentioned thenanmedianfallback, butnansum/nanmean/nanstd,medianand now the MAD also fall back). Thecore.py sigma_funcline leaves the escape baseline (confirmed by a full-suite dask regeneration; the diff is that one line).Dependencies: the minimum numpy is now 2.0 (
normalize_axis_tuplecomes from its numpy 2 location,numpy.lib.array_utils, with no 1.26 shim — the oldestdeps job caught it), which forces astropy ≥ 6.1 (6.0 caps numpy below 2) and reproject ≥ 0.14 / astroscrappy ≥ 1.2 (the oldest numpy-2 binaries). The oldestdeps env and the bottleneck CI job (numpy126 → numpy200) move with it. Closes #1003.Verified locally
sigma_clippingtests for the companion PR, Array API: units/Quantity handling with non-numpy arrays #936, Consider marray as a uniform masked-array representation across array backends #983), 0 xpassed🤖 Generated with Claude Code
https://claude.ai/code/session_01XGArZMnLPC1G7YP62vN18V