Remove all uses of the disrecommended numpy.testing assertion functions

In the “Missing bits” developer docs, we note that, following the recommendations from NumPy, the functions assert_almost_equal, assert_approx_equal and assert_array_almost_equal should not be used in new code. Our current tests have many uses of these functions, and it is natural for new developers to copy the existing code when writing new tests. We then have to correct that (if we notice it) during review.

I think it is time that we bite the bullet and remove all uses of the disrecommended assertion functions. Any objections?

1 Like

It seems fine to me. It’s a bit of a chore, but given the number of PR with large diffs we’ve had for linting, array types support, and free-threading support, a one-off ~4,000 line diff seems minor in comparison:

$ rg assert_approx_equal | wc -l
191
$ rg assert_almost_equal | wc -l
1270
$ rg assert_array_almost_equal | wc -l
2500

+1 from me, too.

Note that some of assert_almost_equal and assert_array_almost_equal, (in ndimage, signal, interpolate and elsewhere, are wrappers around xp_assert_close, scipy/scipy/_lib/_array_api.py at main · scipy/scipy · GitHub)

And while we’re at it, the “missing bits” testing recommendation might be worth updating, too: we should IMO recommend xp_assert_close: for its Array API compatible code it does the right thing from the onset; for code which can potentially get converted to be Array API compatible at some future time, using xp_assert_close saves churn at conversion time.

Cheers,

Evgeni

@ev-br, that sounds good.

Do you think you (or anyone else deep into the xp_ stuff) could add a docstring to xp_assert_close? An explanation of what all those parameters are for would help current and future devs who haven’t followed the development closely. I think most of the parameters are clear, but I’m not sure why check_0d is there, nor check_namespace.

Absolutely! Am adding it to my TODO list (if someone beats me to it, great).

One thing we should make a decision on is ENH: add testing support utilities · Issue #17 · data-apis/array-api-extra · GitHub — AFAIK array-api-extra has grown its own set of xp_assert_* routines. From the SciPy perspective, I suppose it’d great if we don’t have to maintain ours, so we might want to switch to those from array_api_extra. I’ve to admit I’m not sure what is their status and whether they are drop-in replacements or not.

Meanwhile, let me quickly comment on

why check_0d is there, nor check_namespace

First and foremost, if you do not define SCIPY_ARRAY_API=1 env variable, most of these new arguments are not active, and xp_assert_close is almost the same as npt.assert_allclose , only slightly leaning towards being strict where numpy is lenient.

Assuming the env variable is defined, then

  • check_namespace fixes this:
In [6]: from numpy.testing import assert_allclose

In [7]: assert_allclose(np.ones(3), torch.ones(3, dtype=torch.float64))

What if you want to be able to tell a torch tensor from a numpy array (with numerically identical values):

In [5]: xp_assert_close(np.ones(3), torch.ones(3, dtype=torch.float64), check_namespace=
      ⋮ False)    # passes

In [4]: xp_assert_close(np.ones(3), torch.ones(3, dtype=torch.float64), check_namespace=
      ⋮ True)
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)

... snip

AssertionError: Namespace of actual and desired arrays do not match.
Actual: scipy._lib.array_api_compat.numpy
Desired: scipy._lib.array_api_compat.torch

My opinionated recommendation is to keep the default value and only switch it to False if really hard pressed.

  • check_0d is a painful one. I won’t try summarizing the long-winded discussions about it right now; the gist is whether you want to be able to tell a numpy scalar from a 0D array. Again, my opinionated suggestion would be to keep the default value unless really really hard pressed, but this strongly depends on the subpackage. scipy.stats is IIUC one of those hard cases, where a lot of code historically follows numpy reductions—which return numpy scalars where everything else returns 0D arrays. So in scipy stats you can find an alternative import, from scipy._lib._array_api_0d, which basically makes the default usable for scipy.stats.

A couple of (unsolicited, opinionated) bits from experience porting parts of scipy test suite:

  • just port assert_allclose to use xp_assert_close, without defining SCIPY_ARRAY_API variable. This is mostly mechanical, but not all of it, see below :-). This will generate a large diff, and we’ll have to go through tests twice for the Array API conversion, but all in all I found this easier to do in two steps.
  • Again, without activating the SCIPY_ARRAY_API, change assert_equal into xp_assert_equal. Again, mostly mechanical.
  • Now, non-mechanical parts. These xp_* assertions are for arrays. Therefore, use regular assert for python scalars and other non-array objects:
assert arr.shape == (2, 3)     # not assert_equal(arr.shape, (2, 3))
assert math.isclose(arr.ndim / 2, 2.5, abs_tol=1e-15)
  • change assert_almost_equal etc into their imports from scipy._lib._array_api. This is mechanical.
  • finally, can replace assert_almost_equal by their explicit xp_assert_close analogs. By this time, it’s mechanical if tedious.
1 Like

I think the decision is made — long-term these functions should live in array-api-extra, with docstrings and all. They aren’t drop-in replacements yet, and they may not end up being exact drop-ins for SciPy. The work to be done is:

Note that the functions living in scipy._lib._array_api_no_0d, while seeing heavy use in parts of SciPy due to our history of returning NumPy scalars (see RFC: Ufunc return type for zero dimensional array input. · Issue #18768 · scipy/scipy · GitHub), will probably remain in SciPy as light wrappers rather than being upstreamed.


Indeed, if the functions being tested in your file consistently return either 0D-arrays or NumPy scalars, you should pick the corresponding set of assertions from _lib._array_api{_no_0d}, and reserve check_0d for proper edge-cases. It is particularly important IMO to use the _no_0d versions when appropriate, to avoid silently changing the return type to an array (which is easily done in array API conversions when wrapping return values in xp.asarray).

It gets trickier when one file tests both types of functions. The ugly/low-diff solution is to toggle check_0d for one of the cases, but it is hard to know where to draw the line for which set is appropriate. There was a long discussion in RFC: quo vadis, `xp_assert_*` infrastructure? · Issue #21044 · scipy/scipy · GitHub about the considerations there when we were trying to decide on sensible defaults.

An alternative is to use both sets of assertions in one file, but that is messy IMO.

I think the better solution is to refactor tests for functions which differ in this aspect of behaviour into separate files. However, that is a harder sell if one is already presenting a 1k+ line diff to the reviewer. Hence why we have files like scipy/scipy/ndimage/tests/test_measurements.py at main · scipy/scipy · GitHub.


This all sounds good to me.

All that to say that I would rather wait until we have tried integrating these functions into scikit-learn, and write the docstrings once we have finalised the APIs. I’m happy to review if somebody wants to take a stab at it in the meantime while they are private in SciPy, though, too.

OK, I think I’ll let those of you who are deep into it make the xp-related updates.

To get this started, I’ll update the tests in special, and convert the disrecommended functions to assert_allclose from numpy.testing. Updating those to use an xp function can might be easily automated later.