Repository: scikit-hep/awkward

Issue:
ak.array_equal to override np.array_equal
### Description of new feature

It would be nice to have convenience functions for asserting the equivalence of ak arrays.

We can compare an array's:
- values
- type (i.e., datashape description)
- form (including lengths of sub structures, if known)

Currently, tests tend to compare via `.tolist()` (resulting in python lists and dicts) which can compare the array values. The result of ak.type is already comparable. There are, however, well-defined rules of whether an array can be considered equal as a whole to another array.

Note that this is not the same as `__eq__`, which is an element-wise operation.


Discussion:
And one more level: layouts can differ even when the Form is known (a `content` can be longer than than it needs to be, but such "hidden" elements are not considered to impact the equality of two arrays).

For example, these are to be considered the same, even though they have different NumpyArrays (the first counts up to 5 and the second counts up to 10):

```python
>>> ak.Array(ak.layout.ListOffsetArray64(
...     ak.layout.Index64(np.array([0, 3, 3, 5])),
...     ak.layout.NumpyArray(np.arange(5) * 1.1)))
<Array [[0, 1.1, 2.2], [], [3.3, 4.4]] type='3 * var * float64'>

>>> ak.Array(ak.layout.ListOffsetArray64(
...     ak.layout.Index64(np.array([0, 3, 3, 5])),
...     ak.layout.NumpyArray(np.arange(10) * 1.1)))
<Array [[0, 1.1, 2.2], [], [3.3, 4.4]] type='3 * var * float64'>
```

Both of these arrays have the same Type (`3 * var * float64`) and the same Form (`{"class": "ListOffsetArray64", "offsets": "i64", "content": "float64"}`), but they have different buffers. If you ran them through [ak.to_buffers](https://awkward-array.readthedocs.io/en/latest/_auto/ak.to_buffers.html) and compared the results, those results would be different from each other.

The "lists of rules" of what is considered a valid array are given in the [documentation of each Content subclass](https://awkward-array.readthedocs.io/en/latest/ak.layout.Content.html), in the constructor of each Pythonic illustration. These rules are also checked for a given array in the implementation of [ak.is_valid](https://awkward-array.readthedocs.io/en/latest/_auto/ak.is_valid.html). While there are these explicit lists of what is valid, there aren't any explicit lists of what is equal, though two arrays that have the same `.tolist()` representation and the same type _and are both valid_ are equal to each other.
If I were to call `ak.to_buffers(ak.to_packed(awk))` and compare the results of those for equality, are there any big edge cases left beyond `Union[Option[X], Y] != Union[Option[X], Option[Y]]` (https://github.com/scikit-hep/awkward/issues/2182#issuecomment-1412318839)?

E.g.:

```python
import awkward as ak, numpy as np


def equal_awkward(a: ak.Array, b: ak.Array) -> bool:
    if len(a) != len(b):
        return False

    a_form, _, a_buffers = ak.to_buffers(ak.to_packed(a))
    b_form, _, b_buffers = ak.to_buffers(ak.to_packed(b))

    if a_form != b_form:
        return False
    if set(a_buffers.keys()) != set(b_buffers.keys()):
        return False
    for k in a_buffers.keys():
        if not np.array_equal(a_buffers[k], b_buffers[k], equal_nan=True):
            return False    

    return True
```
It depends upon how you define equality. If you mean at the `ak.type(array)` + buffers level, then `packed` doesn't produce a totally unique layout. We have a [helper function](https://github.com/scikit-hep/awkward/blob/4cc61df3fb08fff965b132f8a5411aed7a3b77ef/src/awkward/_util.py#L767-L867) internally to address that.

For example, an `IndexedArray` can wrap a `RecordArray`, but this is not visible at the `type` level. Additionally, there are different option types that are type-level equivalent, but form-level distinct. Finally, there is `RegularArray` and 2D+ `NumpyArray` that are both regular. That's all off the top of my head.


> It depends upon how you define equality. 

I think I'd say "interchangeable at the high level API". So I could pass either to computations and get the same result (within floating point accuracy).

The function you point out looks great for this use-case. Are there barriers to exporting it?
```python
y = ak.packed(x)
```

should satisfy:

  * `x.tolist() == y.tolist()`
  * `x.type == y.type` (or `str(x.type) == str(y.type)`; there are different levels on which types can be compared, but and I _think_ that the equivalence classes of `Type.__eq__` are the same as those of `Type.__str__`)

I'm also pretty sure that `ak._util.arrays_approx_equal` checks the equivalent of the above (faster than actually calling `tolist`, though).

The thing that `ak.packed` changes is the layout/form.

> The function you point out looks great for this use-case. Are there barriers to exporting it?

NumPy has a `np.testing` submodule. Maybe we should have an `ak.testing` submodule and put this there?
Ah, I have confused two separate thought processes when writing my reply, which is something picked up on. Indeed, the `ak.type` matches. I meant to say that it's not trivial to get the `type` and projected buffers of the layouts, which is what we want for fast comparison!

I agree that it might make sense. I *think* dask-awkward roll something similar for their test suite.
> NumPy has a np.testing submodule. Maybe we should have an ak.testing submodule and put this there?

I typically expect functions under a `.testing` module to throw exceptions when things aren't equal, and to trade efficiency for helpful reporting in those exceptions. I think this is closer to `np.allclose`, `np.array_equal`, `pd.DataFrame.equals`. My preference would be for something in the main namespace.

----

Also, if I wanted to use this function right now: how safe do you think calling `ak._utils.arrays_approx_equal` is? Should I vendor it?

Resolving patch:
diff --git a/src/awkward/_util.py b/src/awkward/_util.py
--- a/src/awkward/_util.py
+++ b/src/awkward/_util.py
@@ -715,109 +715,6 @@ def maybe_posaxis(layout, axis, depth):
             return None
 
 
-def arrays_approx_equal(
-    left,
-    right,
-    rtol: float = 1e-5,
-    atol: float = 1e-8,
-    dtype_exact: bool = True,
-    check_parameters=True,
-    check_regular=True,
-) -> bool:
-    # TODO: this should not be needed after refactoring nplike mechanism
-    import awkward.forms.form
-
-    left_behavior = ak._util.behavior_of(left)
-    right_behavior = ak._util.behavior_of(right)
-
-    left = ak.to_packed(ak.to_layout(left, allow_record=False), highlevel=False)
-    right = ak.to_packed(ak.to_layout(right, allow_record=False), highlevel=False)
-
-    nplike = nplike_of(left, right)
-
-    def is_approx_dtype(left, right) -> bool:
-        if not dtype_exact:
-            for family in np.integer, np.floating:
-                if np.issubdtype(left, family):
-                    return np.issubdtype(right, family)
-        return left == right
-
-    def visitor(left, right) -> bool:
-        # Enforce super-canonicalisation rules
-        if left.is_option:
-            left = left.to_IndexedOptionArray64()
-        if right.is_option:
-            right = right.to_IndexedOptionArray64()
-
-        if type(left) is not type(right):
-            if not check_regular and (
-                left.is_list and right.is_regular or left.is_regular and right.is_list
-            ):
-                left = left.to_ListOffsetArray64()
-                right = right.to_ListOffsetArray64()
-            else:
-                return False
-
-        if left.length != right.length:
-            return False
-
-        if check_parameters and not awkward.forms.form._parameters_equal(
-            left.parameters, right.parameters
-        ):
-            return False
-
-        # Require that the arrays have the same evaluated types
-        if not (
-            arrayclass(left, left_behavior) is arrayclass(right, right_behavior)
-            or not check_parameters
-        ):
-            return False
-
-        if left.is_list:
-            return (
-                nplike.array_equal(left.starts, right.starts)
-                and nplike.array_equal(left.stops, right.stops)
-                and visitor(left.content, right.content)
-            )
-        elif left.is_regular:
-            return (left.size == right.size) and visitor(left.content, right.content)
-        elif left.is_numpy:
-            return is_approx_dtype(left.dtype, right.dtype) and nplike.all(
-                nplike.isclose(
-                    left.data, right.data, rtol=rtol, atol=atol, equal_nan=False
-                )
-            )
-        elif left.is_option:
-            return nplike.array_equal(
-                left.index.data < 0, right.index.data < 0
-            ) and visitor(left.project(), right.project())
-        elif left.is_union:
-            return (len(left.contents) == len(right.contents)) and all(
-                [
-                    visitor(left.project(i).to_packed(), right.project(i).to_packed())
-                    for i, _ in enumerate(left.contents)
-                ]
-            )
-        elif left.is_record:
-            return (
-                (
-                    recordclass(left, left_behavior)
-                    is recordclass(right, right_behavior)
-                    or not check_parameters
-                )
-                and (left.fields == right.fields)
-                and (left.is_tuple == right.is_tuple)
-                and all([visitor(x, y) for x, y in zip(left.contents, right.contents)])
-            )
-        elif left.is_unknown:
-            return True
-
-        else:
-            raise ak._errors.wrap_error(AssertionError)
-
-    return visitor(left, right)
-
-
 try:
     import numpy  # noqa: TID251
 
diff --git a/src/awkward/operations/__init__.py b/src/awkward/operations/__init__.py
--- a/src/awkward/operations/__init__.py
+++ b/src/awkward/operations/__init__.py
@@ -1,6 +1,7 @@
 # BSD 3-Clause License; see https://github.com/scikit-hep/awkward-1.0/blob/main/LICENSE
 
 from awkward.operations.ak_all import all
+from awkward.operations.ak_almost_equal import almost_equal
 from awkward.operations.ak_any import any
 from awkward.operations.ak_argcartesian import argcartesian
 from awkward.operations.ak_argcombinations import argcombinations
diff --git a/src/awkward/operations/ak_almost_equal.py b/src/awkward/operations/ak_almost_equal.py
new file mode 100644
--- /dev/null
+++ b/src/awkward/operations/ak_almost_equal.py
@@ -0,0 +1,134 @@
+from __future__ import annotations
+
+__all__ = ["almost_equal"]
+
+from awkward._backends import backend_of
+from awkward._errors import wrap_error
+from awkward._nplikes.numpylike import NumpyMetadata
+from awkward._util import arrayclass, behavior_of, recordclass
+from awkward.forms.form import _parameters_equal
+from awkward.operations.ak_to_layout import to_layout
+
+np = NumpyMetadata.instance()
+
+
+def almost_equal(
+    left,
+    right,
+    *,
+    rtol: float = 1e-5,
+    atol: float = 1e-8,
+    dtype_exact: bool = True,
+    check_parameters: bool = True,
+    check_regular: bool = True,
+) -> bool:
+    """
+    Args:
+        left: Array-like data (anything #ak.to_layout recognizes).
+        right: Array-like data (anything #ak.to_layout recognizes).
+        rtol: the relative tolerance parameter (see below).
+        atol: the absolute tolerance parameter (see below).
+        dtype_exact: whether the dtypes must be exactly the same, or just the
+            same family.
+        check_parameters: whether to compare parameters.
+        check_regular: whether to consider ragged and regular dimensions as
+            unequal.
+
+    Return True if the two array-like arguments are considered equal for the
+    given options. Otherwise, return False.
+
+    The relative difference (`rtol * abs(b)`) and the absolute difference `atol`
+    are added together to compare against the absolute difference between `left`
+    and `right`.
+    """
+    left_behavior = behavior_of(left)
+    right_behavior = behavior_of(right)
+
+    left = to_layout(left, allow_record=False).to_packed()
+    right = to_layout(right, allow_record=False).to_packed()
+
+    backend = backend_of(left, right)
+
+    def is_approx_dtype(left, right) -> bool:
+        if not dtype_exact:
+            for family in np.integer, np.floating:
+                if np.issubdtype(left, family):
+                    return np.issubdtype(right, family)
+        return left == right
+
+    def visitor(left, right) -> bool:
+        # Enforce super-canonicalisation rules
+        if left.is_option:
+            left = left.to_IndexedOptionArray64()
+        if right.is_option:
+            right = right.to_IndexedOptionArray64()
+
+        if type(left) is not type(right):
+            if not check_regular and (
+                left.is_list and right.is_regular or left.is_regular and right.is_list
+            ):
+                left = left.to_ListOffsetArray64()
+                right = right.to_ListOffsetArray64()
+            else:
+                return False
+
+        if left.length != right.length:
+            return False
+
+        if check_parameters and not _parameters_equal(
+            left.parameters, right.parameters
+        ):
+            return False
+
+        # Require that the arrays have the same evaluated types
+        if not (
+            arrayclass(left, left_behavior) is arrayclass(right, right_behavior)
+            or not check_parameters
+        ):
+            return False
+
+        if left.is_list:
+            return (
+                backend.index_nplike.array_equal(left.starts, right.starts)
+                and backend.index_nplike.array_equal(left.stops, right.stops)
+                and visitor(
+                    left.content[: left.stops[-1]], right.content[: right.stops[-1]]
+                )
+            )
+        elif left.is_regular:
+            return (left.size == right.size) and visitor(left.content, right.content)
+        elif left.is_numpy:
+            return is_approx_dtype(left.dtype, right.dtype) and backend.nplike.all(
+                backend.nplike.isclose(
+                    left.data, right.data, rtol=rtol, atol=atol, equal_nan=False
+                )
+            )
+        elif left.is_option:
+            return backend.index_nplike.array_equal(
+                left.index.data < 0, right.index.data < 0
+            ) and visitor(left.project(), right.project())
+        elif left.is_union:
+            return (len(left.contents) == len(right.contents)) and all(
+                [
+                    visitor(left.project(i).to_packed(), right.project(i).to_packed())
+                    for i, _ in enumerate(left.contents)
+                ]
+            )
+        elif left.is_record:
+            return (
+                (
+                    recordclass(left, left_behavior)
+                    is recordclass(right, right_behavior)
+                    or not check_parameters
+                )
+                and (left.fields == right.fields)
+                and (left.is_tuple == right.is_tuple)
+                and all([visitor(x, y) for x, y in zip(left.contents, right.contents)])
+            )
+        elif left.is_unknown:
+            return True
+
+        else:
+            raise wrap_error(AssertionError)
+
+    return visitor(left, right)


Test patch:
diff --git a/tests/test_0355_mixins.py b/tests/test_0355_mixins.py
--- a/tests/test_0355_mixins.py
+++ b/tests/test_0355_mixins.py
@@ -77,7 +77,7 @@ def weighted_add(self, other):
         with_name="WeightedPoint",
         behavior=behavior,
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         one + wone,
         ak.Array(
             [
@@ -90,7 +90,7 @@ def weighted_add(self, other):
         ),
         dtype_exact=False,
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         wone + wtwo,
         ak.Array(
             [
@@ -122,7 +122,7 @@ def weighted_add(self, other):
         ),
         dtype_exact=False,
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         abs(one),
         ak.Array(
             [
@@ -135,7 +135,7 @@ def weighted_add(self, other):
         ),
         dtype_exact=False,
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         one.distance(wtwo),
         [
             [0.14142135623730953, 0.0, 0.31622776601683783],
diff --git a/tests/test_1318_array_function_types.py b/tests/test_1318_array_function_types.py
--- a/tests/test_1318_array_function_types.py
+++ b/tests/test_1318_array_function_types.py
@@ -10,7 +10,7 @@ def test_tuple():
         (ak.from_numpy(data[0]), ak.from_numpy(data[1])), (2, 2)
     )
     assert isinstance(result, ak.Array)
-    assert ak._util.arrays_approx_equal(result, np.ravel_multi_index(data, (2, 2)))
+    assert ak.almost_equal(result, np.ravel_multi_index(data, (2, 2)))
 
 
 def test_list():
@@ -23,7 +23,7 @@ def test_list():
         ]
     )
     assert isinstance(result, ak.Array)
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         result, np.block([[A, np.zeros((2, 3))], [np.ones((3, 2)), B]])
     )
 
@@ -33,14 +33,14 @@ def test_array():
     needle = np.array([5, 0, 2], dtype=np.int64)
     result = np.searchsorted(ak.from_numpy(haystack), ak.from_numpy(needle))
     assert isinstance(result, ak.Array)
-    assert ak._util.arrays_approx_equal(result, np.searchsorted(haystack, needle))
+    assert ak.almost_equal(result, np.searchsorted(haystack, needle))
 
 
 def test_scalar():
     data = np.array([1, 2, 3, 4, 3, 2, 1, 2], dtype=np.int64)
     result = np.partition(ak.from_numpy(data), 4)
     assert isinstance(result, ak.Array)
-    assert ak._util.arrays_approx_equal(result, np.partition(data, 4))
+    assert ak.almost_equal(result, np.partition(data, 4))
 
 
 def test_tuple_of_array():
@@ -50,4 +50,4 @@ def test_tuple_of_array():
     )
     result = np.lexsort((ak.from_numpy(data[0]), ak.from_numpy(data[1])))
     assert isinstance(result, ak.Array)
-    assert ak._util.arrays_approx_equal(result, np.lexsort(data))
+    assert ak.almost_equal(result, np.lexsort(data))
diff --git a/tests/test_2020_reduce_axis_none.py b/tests/test_2020_reduce_axis_none.py
--- a/tests/test_2020_reduce_axis_none.py
+++ b/tests/test_2020_reduce_axis_none.py
@@ -10,10 +10,10 @@
 
 def test_sum():
     assert ak.sum(array, axis=None) == pytest.approx(63.0)
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.sum(array, axis=None, keepdims=True), ak.to_regular([[63.0]])
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.sum(array, axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[63.0]]).mask[[[True]]]),
     )
@@ -23,13 +23,13 @@ def test_sum():
 def test_prod():
     assert ak.prod(array[1:], axis=None) == pytest.approx(4838400.0)
     assert ak.prod(array, axis=None) == 0
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.prod(array, axis=None, keepdims=True), ak.to_regular([[0.0]])
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.prod(array[1:], axis=None, keepdims=True), ak.to_regular([[4838400.0]])
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.prod(array[1:], axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[4838400.0]]).mask[[[True]]]),
     )
@@ -38,20 +38,20 @@ def test_prod():
 
 def test_min():
     assert ak.min(array, axis=None) == pytest.approx(0.0)
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.min(array, axis=None, keepdims=True, mask_identity=False),
         ak.to_regular([[0.0]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.min(array, axis=None, keepdims=True, initial=-100, mask_identity=False),
         ak.to_regular([[-100.0]]),
     )
 
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.min(array, axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[0.0]]).mask[[[True]]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.min(array[-1:], axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[np.inf]]).mask[[[False]]]),
     )
@@ -60,19 +60,19 @@ def test_min():
 
 def test_max():
     assert ak.max(array, axis=None) == pytest.approx(10.0)
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.max(array, axis=None, keepdims=True, mask_identity=False),
         ak.to_regular([[10.0]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.max(array, axis=None, keepdims=True, initial=100, mask_identity=False),
         ak.to_regular([[100.0]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.max(array, axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[10.0]]).mask[[[True]]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.max(array[-1:], axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[np.inf]]).mask[[[False]]]),
     )
@@ -81,15 +81,15 @@ def test_max():
 
 def test_count():
     assert ak.count(array, axis=None) == 12
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.count(array, axis=None, keepdims=True, mask_identity=False),
         ak.to_regular([[12]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.count(array, axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[12]]).mask[[[True]]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.count(array[-1:], axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[0]]).mask[[[False]]]),
     )
@@ -99,15 +99,15 @@ def test_count():
 
 def test_count_nonzero():
     assert ak.count_nonzero(array, axis=None) == 11
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.count_nonzero(array, axis=None, keepdims=True, mask_identity=False),
         ak.to_regular([[11]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.count_nonzero(array, axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[11]]).mask[[[True]]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.count_nonzero(array[-1:], axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[0]]).mask[[[False]]]),
     )
@@ -117,11 +117,11 @@ def test_count_nonzero():
 
 def test_std():
     assert ak.std(array, axis=None) == pytest.approx(3.139134700306227)
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.std(array, axis=None, keepdims=True, mask_identity=False),
         ak.to_regular([[3.139134700306227]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.std(array, axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[3.139134700306227]]).mask[[[True]]]),
     )
@@ -129,7 +129,7 @@ def test_std():
 
 
 def test_std_no_mask_axis_none():
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.std(array[-1:], axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[0.0]]).mask[[[False]]]),
     )
@@ -138,11 +138,11 @@ def test_std_no_mask_axis_none():
 
 def test_var():
     assert ak.var(array, axis=None) == pytest.approx(9.854166666666666)
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.var(array, axis=None, keepdims=True, mask_identity=False),
         ak.to_regular([[9.854166666666666]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.var(array, axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[9.854166666666666]]).mask[[[True]]]),
     )
@@ -150,7 +150,7 @@ def test_var():
 
 
 def test_var_no_mask_axis_none():
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.var(array[-1:], axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[0.0]]).mask[[[False]]]),
     )
@@ -159,11 +159,11 @@ def test_var_no_mask_axis_none():
 
 def test_mean():
     assert ak.mean(array, axis=None) == pytest.approx(5.25)
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.mean(array, axis=None, keepdims=True, mask_identity=False),
         ak.to_regular([[5.25]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.mean(array, axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[5.25]]).mask[[[True]]]),
     )
@@ -171,7 +171,7 @@ def test_mean():
 
 
 def test_mean_no_mask_axis_none():
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.mean(array[-1:], axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[0.0]]).mask[[[False]]]),
     )
@@ -180,11 +180,11 @@ def test_mean_no_mask_axis_none():
 
 def test_ptp():
     assert ak.ptp(array, axis=None) == pytest.approx(10.0)
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.ptp(array, axis=None, keepdims=True, mask_identity=False),
         ak.to_regular([[10.0]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.ptp(array, axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[10.0]]).mask[[[True]]]),
     )
@@ -192,7 +192,7 @@ def test_ptp():
 
 
 def test_ptp_no_mask_axis_none():
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.ptp(array[-1:], axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[0.0]]).mask[[[False]]]),
     )
@@ -201,15 +201,15 @@ def test_ptp_no_mask_axis_none():
 
 def test_argmax():
     assert ak.argmax(array, axis=None) == 11
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.argmax(array, axis=None, keepdims=True, mask_identity=False),
         ak.to_regular([[11]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.argmax(array, axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[11]]).mask[[[True]]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.argmax(array[-1:], axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[0]]).mask[[[False]]]),
     )
@@ -219,15 +219,15 @@ def test_argmax():
 
 def test_argmin():
     assert ak.argmin(array, axis=None) == 0
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.argmin(array, axis=None, keepdims=True, mask_identity=False),
         ak.to_regular([[0]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.argmin(array, axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[0]]).mask[[[True]]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.argmin(array[-1:], axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[999]]).mask[[[False]]]),
     )
@@ -237,15 +237,15 @@ def test_argmin():
 
 def test_any():
     assert ak.any(array, axis=None)
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.any(array, axis=None, keepdims=True, mask_identity=False),
         ak.to_regular([[True]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.any(array, axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[True]]).mask[[[True]]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.any(array[-1:], axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[True]]).mask[[[False]]]),
     )
@@ -255,15 +255,15 @@ def test_any():
 
 def test_all():
     assert not ak.all(array, axis=None)
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.all(array, axis=None, keepdims=True, mask_identity=False),
         ak.to_regular([[False]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.all(array, axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[False]]).mask[[[True]]]),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.all(array[-1:], axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(ak.Array([[False]]).mask[[[False]]]),
     )
@@ -276,10 +276,8 @@ def test_prod_numpy():
     result_reg = ak.from_numpy(np.array([[[5460]]], dtype=np.int64))
 
     assert ak.sum(array_reg, axis=None) == 5460
-    assert ak._util.arrays_approx_equal(
-        ak.sum(array_reg, axis=None, keepdims=True), result_reg
-    )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(ak.sum(array_reg, axis=None, keepdims=True), result_reg)
+    assert ak.almost_equal(
         ak.sum(array_reg, axis=None, keepdims=True, mask_identity=True),
         ak.to_regular(result_reg.mask[[[[True]]]], axis=None),
     )
diff --git a/tests/test_2051_arraybuilder_behavior_propagation.py b/tests/test_2051_arraybuilder_behavior_propagation.py
--- a/tests/test_2051_arraybuilder_behavior_propagation.py
+++ b/tests/test_2051_arraybuilder_behavior_propagation.py
@@ -14,4 +14,4 @@ def test():
         builder.field("y").real(2.0)
         builder.field("z").real(3.0)
 
-    assert ak._util.arrays_approx_equal(builder, builder.snapshot())
+    assert ak.almost_equal(builder, builder.snapshot())
diff --git a/tests/test_2055_array_builder_check.py b/tests/test_2055_array_builder_check.py
--- a/tests/test_2055_array_builder_check.py
+++ b/tests/test_2055_array_builder_check.py
@@ -47,7 +47,7 @@ def func(builder):
     func(builder)
 
     result = builder.snapshot()
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         result,
         ak.contents.RecordArray(
             fields=["time"],
diff --git a/tests/test_2070_to_layout_string.py b/tests/test_2070_to_layout_string.py
--- a/tests/test_2070_to_layout_string.py
+++ b/tests/test_2070_to_layout_string.py
@@ -8,7 +8,7 @@
 
 @pytest.mark.parametrize("string", ["hello world!", b"hello world!"])
 def test(string):
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.to_layout(string),
         ak.contents.NumpyArray(
             np.array(
diff --git a/tests/test_2071_unflatten_non_packed_counts.py b/tests/test_2071_unflatten_non_packed_counts.py
--- a/tests/test_2071_unflatten_non_packed_counts.py
+++ b/tests/test_2071_unflatten_non_packed_counts.py
@@ -11,7 +11,7 @@ def test_indexed_counts():
         ak.index.Index64(np.arange(3)),
         ak.contents.NumpyArray(np.array([3, 0, 2], dtype=np.int64)),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.unflatten([1.1, 2.2, 3.3, 4.4, 5.5], counts),
         [[1.1, 2.2, 3.3], [], [4.4, 5.5]],
     )
@@ -22,21 +22,21 @@ def test_indexed_layout():
         ak.index.Index64(np.arange(5)),
         ak.contents.NumpyArray(np.array([1.1, 2.2, 3.3, 4.4, 5.5], dtype=np.float64)),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.unflatten(layout, [3, 0, 2]),
         [[1.1, 2.2, 3.3], [], [4.4, 5.5]],
     )
 
 
 def test_option_counts():
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.unflatten([1.1, 2.2, 3.3, 4.4, 5.5], [None, 3, None, 0, 2]),
         [None, [1.1, 2.2, 3.3], None, [], [4.4, 5.5]],
     )
 
 
 def test_categorical_counts():
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.unflatten([1.1, 2.2, 3.3, 4.4, 5.5], ak.to_categorical([3, 0, 2])),
         [[1.1, 2.2, 3.3], [], [4.4, 5.5]],
     )
diff --git a/tests/test_2082_broadcast_zero_size.py b/tests/test_2082_broadcast_zero_size.py
--- a/tests/test_2082_broadcast_zero_size.py
+++ b/tests/test_2082_broadcast_zero_size.py
@@ -16,4 +16,4 @@ def test_axis(axis):
     y = ak.from_numpy(x)
     result_ak = ak.broadcast_arrays(y[this_slice], y[that_slice])
 
-    assert ak._util.arrays_approx_equal(result, result_ak)
+    assert ak.almost_equal(result, result_ak)
diff --git a/tests/test_2101_pickle_behavior_class.py b/tests/test_2101_pickle_behavior_class.py
--- a/tests/test_2101_pickle_behavior_class.py
+++ b/tests/test_2101_pickle_behavior_class.py
@@ -31,7 +31,7 @@ def test():
         behavior=behavior,
     )
 
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         (vec + vec),
         ak.Array(
             [
@@ -43,4 +43,4 @@ def test():
         ),
     )
 
-    assert ak._util.arrays_approx_equal(pickle.loads(pickle.dumps(vec)), vec)
+    assert ak.almost_equal(pickle.loads(pickle.dumps(vec)), vec)
diff --git a/tests/test_2104_numpy_merge_option.py b/tests/test_2104_numpy_merge_option.py
--- a/tests/test_2104_numpy_merge_option.py
+++ b/tests/test_2104_numpy_merge_option.py
@@ -7,7 +7,7 @@
 
 
 def test():
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.concatenate([ak.Array([1, 2, 3]), ak.Array([1, 2, None])]),
         ak.contents.ByteMaskedArray(
             ak.index.Index8(np.array([False, False, False, False, False, True])),
diff --git a/tests/test_2108_fill_none_indexed.py b/tests/test_2108_fill_none_indexed.py
--- a/tests/test_2108_fill_none_indexed.py
+++ b/tests/test_2108_fill_none_indexed.py
@@ -21,6 +21,6 @@ def test():
             ["x"],
         ),
     )
-    assert ak._util.arrays_approx_equal(
+    assert ak.almost_equal(
         ak.fill_none(layout, 9, axis=0), ak.zip({"x": np.arange(10)})
     )
diff --git a/tests/test_2179_parameer_merging_rules.py b/tests/test_2179_parameter_merging_rules.py
similarity index 94%
rename from tests/test_2179_parameer_merging_rules.py
rename to tests/test_2179_parameter_merging_rules.py
--- a/tests/test_2179_parameer_merging_rules.py
+++ b/tests/test_2179_parameter_merging_rules.py
@@ -40,9 +40,7 @@ def test_merge_optional_strings():
     b = ak.Array([{"a": None}])
 
     result = ak.concatenate([a, b])
-    assert ak._util.arrays_approx_equal(
-        result, [{"a": "foo"}, {"a": "bar"}, {"a": None}]
-    )
+    assert ak.almost_equal(result, [{"a": "foo"}, {"a": "bar"}, {"a": None}])
 
 
 def test_merge_permutations():
diff --git a/tests/test_2198_almost_equal.py b/tests/test_2198_almost_equal.py
new file mode 100644
--- /dev/null
+++ b/tests/test_2198_almost_equal.py
@@ -0,0 +1,131 @@
+# BSD 3-Clause License; see https://github.com/scikit-hep/awkward-1.0/blob/main/LICENSE
+
+import numpy as np  # noqa: F401
+
+import awkward as ak
+
+
+def test_dtype():
+    assert not ak.almost_equal(
+        np.array([1, 2, 3], dtype=np.int64),
+        np.array([1, 2, 3], dtype=np.int8),
+        dtype_exact=True,
+    )
+    assert ak.almost_equal(
+        np.array([1, 2, 3], dtype=np.int64),
+        np.array([1, 2, 3], dtype=np.int8),
+        dtype_exact=False,
+    )
+    assert not ak.almost_equal([1, 2, 3], ["1", "2", "3"], dtype_exact=False)
+    assert not ak.almost_equal([1, 2, 3], ["1", "2", "3"], dtype_exact=True)
+    assert not ak.almost_equal(
+        np.array([1, 2, 3], dtype=np.int64),
+        np.array([1, 2, 3], dtype=np.timedelta64),
+        dtype_exact=True,
+    )
+    assert not ak.almost_equal(
+        np.array([1, 2, 3], dtype=np.dtype("<M8[D]")),
+        np.array([1, 2, 3], dtype=np.dtype("<m8[D]")),
+        dtype_exact=True,
+    )
+    assert not ak.almost_equal(
+        np.array([1, 2, 3], dtype=np.dtype("<M8[D]")),
+        np.array([1, 2, 3], dtype=np.dtype("<m8[D]")),
+        dtype_exact=False,
+    )
+
+
+def test_regular():
+    array = ak.Array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
+    assert ak.almost_equal(array, array, check_regular=True)
+    assert ak.almost_equal(array, array, check_regular=False)
+
+    assert not ak.almost_equal(array, ak.to_regular(array), check_regular=True)
+    assert ak.almost_equal(array, ak.to_regular(array), check_regular=False)
+
+    # Strided to product `ListArray`
+    list_array = array[::1]
+    assert isinstance(list_array.layout, ak.contents.ListArray)
+    assert ak.almost_equal(array, list_array, check_regular=False)
+    assert ak.almost_equal(array, list_array, check_regular=True)
+
+    assert not ak.almost_equal(list_array, ak.to_regular(array), check_regular=True)
+    assert ak.almost_equal(list_array, ak.to_regular(array), check_regular=False)
+
+    numpy_array = ak.from_numpy(ak.to_numpy(array), regulararray=False)
+    assert ak.almost_equal(numpy_array, ak.to_regular(array), check_regular=True)
+    assert ak.almost_equal(numpy_array, ak.to_regular(array), check_regular=False)
+    assert not ak.almost_equal(numpy_array, array, check_regular=True)
+    assert ak.almost_equal(numpy_array, array, check_regular=False)
+
+
+def test_parameters():
+    array = ak.with_parameter([1, 2, 3], "name", "Bob Dylan")
+    assert not ak.almost_equal(array, [1, 2, 3])
+    assert ak.almost_equal(array, [1, 2, 3], check_parameters=False)
+
+    array_other = ak.with_parameter(array, "name", "Emmy Noether")
+    assert not ak.almost_equal(array, array_other)
+    assert ak.almost_equal(array, array_other, check_parameters=False)
+
+
+def test_option():
+    array = ak.Array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
+    masked_array = array.mask[[True, False, False]]
+    alternative_array = ak.Array([[1, 2, 3], [9, 10, 11], [12, 13, 14]])
+    alternative_masked_array = alternative_array.mask[[True, False, False]]
+
+    assert isinstance(masked_array.layout, ak.contents.ByteMaskedArray)
+    assert not ak.almost_equal(array, alternative_array)
+    assert ak.almost_equal(masked_array, alternative_masked_array)
+
+    masked_array_indexed = masked_array.layout.to_IndexedOptionArray64()
+    alternative_masked_array_indexed = (
+        alternative_masked_array.layout.to_IndexedOptionArray64()
+    )
+    assert ak.almost_equal(masked_array, alternative_masked_array_indexed)
+    assert ak.almost_equal(masked_array_indexed, alternative_masked_array_indexed)
+
+
+def test_record():
+    array = ak.Array([{"x": 1}, {"x": 2}])
+
+    assert not ak.almost_equal(array, [{"x": 1}, {"x": 3}])
+    assert not ak.almost_equal(array, [{"x": 1}])
+    assert not ak.almost_equal(array, [{"x": 1}, {"x": 2.0}])
+    assert ak.almost_equal(array, [{"x": 1}, {"x": 2}])
+
+
+def test_union():
+    array = ak.Array([{"x": 1}, {"y": 2}])
+
+    assert not ak.almost_equal(array, [{"x": 1}, {"y": 3}])
+    assert not ak.almost_equal(array, [{"x": 1}])
+    assert not ak.almost_equal(array, [{"x": 1}, {"y": 2.0}])
+    assert ak.almost_equal(array, [{"x": 1}, {"y": 2}])
+
+
+def test_empty():
+    array = ak.Array([])
+
+    assert not ak.almost_equal(array, [{"x": 1}, {"y": 3}])
+    assert not ak.almost_equal(array, [1])
+    assert not ak.almost_equal(array, ["hello"])
+    assert not ak.almost_equal(array, [False, False])
+    assert ak.almost_equal(array, ak.contents.EmptyArray())
+
+
+def test_behavior():
+    class CustomList(ak.Array):
+        ...
+
+    behavior = {"custom_list": CustomList}
+
+    array = ak.with_parameter([1, 2, 3], "__array__", "custom_list", behavior=behavior)
+    other_array = ak.with_parameter([1, 2, 3], "__array__", "custom_list")
+    assert not ak.almost_equal(array, other_array)
+    assert ak.almost_equal(array, other_array, check_parameters=False)
+
+    another_array = ak.Array([1, 2, 3], behavior=behavior)
+    assert not ak.almost_equal(array, another_array)
+    assert not ak.almost_equal(other_array, another_array)