Repository: scikit-hep/hist
Pull request #474 (2023-01-31 21:32:45)

feat: add `fill_flattened` support
This PR draft is based upon https://gist.github.com/agoose77/f755d96061a36795dda592276d63f506, which prototyped what `fill_flattened` might look like in Hist. The purpose of this feature is to make histogramming with different-structure arrays more convenient.

This implementation is fairy opinionated; it should be possible, for example to support pass in axis arrays by destructuring obejcts _and_ keyword argument. However, in the pursuit of simplicity, I've made these mutually exclusive; either a single structured object should be used, or the `*args` and `**kwargs` are used as expected to map onto axes.



agoose77: @henryiii this is a WIP, but I'd appreciate any early feedback!
lgray: This will need corresponding extensions/changes in dask histogram.
lgray: Actually looking at how this is implemented - no change to dask-histogram should be needed.
Could you add some tests to check this?
lgray: Could you add a `hist.dask` test for this as well when you have time?

Thanks!
agoose77: @lgray Is it reasonable _not_ to add dask tests yet? To properly support dask, this PR should implement support for dask arrays, which it currently does not. That is a future goal, but trying to keep things simple for now.
lgray: Ah, I see. That is fine. It can come later.
agoose77: @henryiii I marked this as ready for review, and didn't notice you'd removed that flag. Is it because of the failing tests? I'm hoping they'll pass now.

Or, is it not the policy to include features with partial support for Dask?
henryiii: I added "draft" since you have wip in the title, is that what you mean?
agoose77: > I added "draft" since you have wip in the title, is that what you mean?

Ah, that would generally be a good indicator :facepalm:. I will update it now that it's not a wip.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6332000..a844949 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -29,7 +29,7 @@ repos:
   - id: trailing-whitespace
 
 - repo: https://github.com/charliermarsh/ruff-pre-commit
-  rev: v0.0.270
+  rev: v0.0.272
   hooks:
   - id: ruff
     args: ["--fix", "--show-fixes"]
@@ -48,7 +48,7 @@ repos:
   - id: mypy
     files: ^src
     args: []
-    additional_dependencies: ["numpy~=1.24.0", "matplotlib>=3.4", "boost-histogram~=1.3.1", "uhi~=0.3.1"]
+    additional_dependencies: ["numpy~=1.24.0", "matplotlib>=3.4", "boost-histogram~=1.3.1", "uhi~=0.3.1", "pandas-stubs>=2.0.1.230501"]
 
 - repo: https://github.com/mgedmin/check-manifest
   rev: "0.49"
diff --git a/src/hist/basehist.py b/src/hist/basehist.py
index bfaaa8f..cc008b7 100644
--- a/src/hist/basehist.py
+++ b/src/hist/basehist.py
@@ -12,6 +12,7 @@ import numpy as np
 
 import hist
 
+from . import interop
 from ._compat.typing import ArrayLike, Protocol, Self, SupportsIndex
 from .axestuple import NamedAxesTuple
 from .axis import AxisProtocol
@@ -230,6 +231,72 @@ class BaseHist(bh.Histogram, metaclass=MetaConstructor, family=hist):
 
         return super().fill(*args, *data, weight=weight, sample=sample, threads=threads)
 
+    def fill_flattened(
+        self: Self,
+        *args: Any,
+        weight: Any | None = None,
+        sample: Any | None = None,
+        threads: int | None = None,
+        **kwargs: Any,
+    ) -> Self:
+        axis_names = {ax.name for ax in self.axes}
+
+        non_user_kwargs = {}
+        if weight is not None:
+            non_user_kwargs["weight"] = weight
+        if sample is not None:
+            non_user_kwargs["sample"] = sample
+
+        # Single arguments are either arrays for single-dimensional histograms, or
+        # structures for multi-dimensional hists that must first be unpacked
+        if len(args) == 1 and not kwargs:
+            (arg,) = args
+            destructured = interop.destructure(arg)
+            # Try to unpack the array, if it's valid to do so, i.e. is the Awkward Array a record array?
+            if destructured is None:
+                # Can't unpack, fall back on broadcasting single array (to flatten and convert)
+                broadcast = interop.broadcast_and_flatten(
+                    (arg, *non_user_kwargs.values())
+                )
+                # Partition out non-user args
+                user_args_broadcast = broadcast[:1]
+                user_kwargs_broadcast = {}
+                non_user_kwargs_broadcast = dict(
+                    zip(non_user_kwargs.keys(), broadcast[1:])
+                )
+            else:
+                # Result must be broadcast, so unpack and rebuild
+                broadcast = interop.broadcast_and_flatten(
+                    (*destructured.values(), *non_user_kwargs.values())
+                )
+                # Partition into user and non-user args
+                user_args_broadcast = ()
+                user_kwargs_broadcast = {
+                    k: v
+                    for k, v in zip(destructured, broadcast[: len(destructured)])
+                    if k in axis_names
+                }
+                non_user_kwargs_broadcast = dict(
+                    zip(non_user_kwargs, broadcast[len(destructured) :])
+                )
+        # Multiple args: broadcast and flatten!
+        else:
+            inputs = (*args, *kwargs.values(), *non_user_kwargs)
+            broadcast = interop.broadcast_and_flatten(inputs)
+            user_args_broadcast = broadcast[: len(args)]
+            user_kwargs_broadcast = dict(
+                zip(kwargs, broadcast[len(args) : len(args) + len(kwargs)])
+            )
+            non_user_kwargs_broadcast = dict(
+                zip(non_user_kwargs, broadcast[len(args) + len(kwargs) :])
+            )
+        return self.fill(
+            *user_args_broadcast,
+            **user_kwargs_broadcast,
+            threads=threads,
+            **non_user_kwargs_broadcast,
+        )
+
     def sort(
         self,
         axis: int | str,
@@ -527,6 +594,22 @@ class BaseHist(bh.Histogram, metaclass=MetaConstructor, family=hist):
 
         return hist.stack.Stack(*stack_histograms)
 
+    def integrate(
+        self,
+        name: int | str,
+        i_or_list: InnerIndexing | list[str | int] | None = None,
+        j: InnerIndexing | None = None,
+    ) -> Self | float | bh.accumulators.Accumulator:
+        if isinstance(i_or_list, list):
+            with warnings.catch_warnings():
+                warnings.simplefilter("ignore")
+
+                # TODO: We could teach the typing system that list always returns Self type
+                selection: Self = self[{name: i_or_list}]  # type: ignore[assignment, dict-item]
+                return selection[{name: slice(0, len(i_or_list), sum)}]
+
+        return self[{name: slice(i_or_list, j, sum)}]
+
     def sum(self, flow: bool = False) -> float | bh.accumulators.Accumulator:
         """
         Compute the sum over the histogram bins (optionally including the flow bins).
diff --git a/src/hist/interop.py b/src/hist/interop.py
new file mode 100644
index 0000000..9dded1f
--- /dev/null
+++ b/src/hist/interop.py
@@ -0,0 +1,133 @@
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Any, Callable, Iterator, TypeVar, cast
+
+import numpy as np
+
+from ._compat.typing import ArrayLike, Protocol
+
+T_contra = TypeVar("T_contra", contravariant=True)
+U = TypeVar("U")
+V = TypeVar("V")
+
+
+class HistogramModuleProtocol(Protocol[T_contra, U]):
+    def unpack(self, obj: T_contra) -> dict[str, U] | None:
+        ...
+
+    def broadcast_and_flatten(
+        self, objects: Sequence[U | ArrayLike]
+    ) -> tuple[np.typing.NDArray[Any], ...]:
+        ...
+
+
+_histogram_modules: dict[type, HistogramModuleProtocol[Any, Any]] = {}
+
+
+def histogram_module_for(
+    cls: type,
+) -> Callable[[HistogramModuleProtocol[U, V]], HistogramModuleProtocol[U, V]]:
+    """
+    Register a histogram-module object for the given class.
+    """
+
+    def wrapper(obj: HistogramModuleProtocol[U, V]) -> HistogramModuleProtocol[U, V]:
+        _histogram_modules[cls] = obj
+        return obj
+
+    return wrapper
+
+
+def find_histogram_modules(
+    *objects: Any,
+) -> Iterator[HistogramModuleProtocol[Any, Any]]:
+    """
+    Yield histogram-module objects that are known to support any of the given objects.
+    """
+    for arg in objects:
+        try:
+            yield arg._histogram_module_
+        except AttributeError:
+            # Find class exactly, or check subclasses
+            for cls in type(arg).__mro__:
+                try:
+                    yield _histogram_modules[cls]
+                except KeyError:
+                    continue
+
+
+def destructure(obj: Any) -> dict[str, Any] | None:
+    """
+    Pull out named histogram-module arrays from the given structured object as a mapping.
+    The returned arrays should be compatible with `broadcast_and_flatten`.
+    If the argument is not understood as a structured object by any histogram modules,
+    return `None`.
+    """
+    for module in find_histogram_modules(obj):
+        return module.unpack(obj)
+    raise TypeError(f"No histogram module found for {obj!r}")
+
+
+def broadcast_and_flatten(args: Sequence[Any]) -> tuple[np.typing.NDArray[Any], ...]:
+    """
+    Convert the given histogram-module arrays into a set of consistent 1D NumPy arrays
+    for histogram filling. For NumPy this entails broadcasting and flattening.
+    """
+    for module in find_histogram_modules(*args):
+        result = module.broadcast_and_flatten(args)
+        if result is not NotImplemented:
+            return result
+
+    raise TypeError(f"No histogram module found for {args!r}")
+
+
+@histogram_module_for(np.ndarray)
+class NumpyHistogramModule:
+    @staticmethod
+    def unpack(obj: np.typing.NDArray[Any]) -> dict[str, np.typing.NDArray[Any]] | None:
+        if obj.dtype.fields is None:
+            return None
+
+        return {k: obj[k] for k in obj.dtype.fields}
+
+    @staticmethod
+    def broadcast_and_flatten(
+        args: Sequence[np.typing.NDArray[Any] | ArrayLike],
+    ) -> tuple[np.typing.NDArray[Any], ...]:
+        arrays = []
+        for arg in args:
+            # If we can't interpret this argument, it's not NumPy-friendly!
+            try:
+                arrays.append(np.asarray(arg))
+            except (TypeError, ValueError):
+                return NotImplemented
+
+        return tuple(np.ravel(x) for x in np.broadcast_arrays(*arrays))
+
+
+try:
+    import pandas as pd
+except ImportError:
+    ...
+else:
+
+    @histogram_module_for(pd.DataFrame)
+    class PandasHistogramModule:
+        @staticmethod
+        def unpack(obj: pd.DataFrame) -> dict[str, pd.Series[Any]]:
+            return cast(dict[str, pd.Series[Any]], obj.to_dict("series"))
+
+        @staticmethod
+        def broadcast_and_flatten(
+            args: Sequence[pd.Series[Any] | ArrayLike],
+        ) -> tuple[np.typing.NDArray[Any], ...]:
+            arrays = []
+            for arg in args:
+                # If we can't interpret this argument, it's not NumPy-friendly!
+                try:
+                    arrays.append(np.asarray(arg))
+                except (TypeError, ValueError):
+                    return NotImplemented
+
+            return tuple(np.ravel(x) for x in np.broadcast_arrays(*arrays))
diff --git a/src/hist/namedhist.py b/src/hist/namedhist.py
index 43722a0..494864d 100644
--- a/src/hist/namedhist.py
+++ b/src/hist/namedhist.py
@@ -6,6 +6,7 @@ import boost_histogram as bh
 
 import hist
 
+from . import interop
 from ._compat.typing import ArrayLike, Self
 from .basehist import BaseHist, IndexingExpr
 
@@ -55,6 +56,68 @@ class NamedHist(BaseHist, family=hist):
             f"Only fill by names are supported for {self.__class__.__name__}"
         )
 
+    # pylint: disable-next=arguments-differ
+    def fill_flattened(  # type: ignore[override]
+        self: Self,
+        obj: Any = None,
+        *,
+        weight: Any | None = None,
+        sample: Any | None = None,
+        threads: int | None = None,
+        **kwargs: Any,
+    ) -> Self:
+        axis_names = {ax.name for ax in self.axes}
+
+        non_user_kwargs = {}
+        if weight is not None:
+            non_user_kwargs["weight"] = weight
+        if sample is not None:
+            non_user_kwargs["sample"] = sample
+
+        # Single arguments are either arrays for single-dimensional histograms, or
+        # structures for multi-dimensional hists that must first be unpacked
+        if obj is not None:
+            if kwargs:
+                raise TypeError(
+                    "Only explicit keyword arguments, or a single structured object is supported by "
+                    "`fill_flattened`, but not both."
+                )
+
+            destructured = interop.destructure(obj)
+            # Try to unpack the array, if it's valid to do so, i.e. is the Awkward Array a record array?
+            if destructured is None:
+                raise TypeError(
+                    f"Only fill by names are supported for {self.__class__.__name__}. A single object was given to "
+                    "`fill_flattened`, but it could not be destructed into name-array pairs."
+                )
+
+            # Result must be broadcast, so unpack and rebuild
+            broadcast = interop.broadcast_and_flatten(
+                (*destructured.values(), *non_user_kwargs.values())
+            )
+            # Partition into user and non-user args
+            user_kwargs_broadcast = {
+                k: v
+                for k, v in zip(destructured, broadcast[: len(destructured)])
+                if k in axis_names
+            }
+            non_user_kwargs_broadcast = dict(
+                zip(non_user_kwargs, broadcast[len(destructured) :])
+            )
+        # Multiple args: broadcast and flatten!
+        else:
+            inputs = (*kwargs.values(), *non_user_kwargs)
+            broadcast = interop.broadcast_and_flatten(inputs)
+            user_kwargs_broadcast = dict(zip(kwargs, broadcast[: len(kwargs)]))
+            non_user_kwargs_broadcast = dict(
+                zip(non_user_kwargs, broadcast[len(kwargs) :])
+            )
+        return self.fill(
+            **user_kwargs_broadcast,
+            threads=threads,
+            **non_user_kwargs_broadcast,
+        )
+
     def __getitem__(  # type: ignore[override]
         self,
         index: IndexingExpr,


Test patch:
diff --git a/tests/test_general.py b/tests/test_general.py
index 9269ef3..092a326 100644
--- a/tests/test_general.py
+++ b/tests/test_general.py
@@ -923,3 +923,33 @@ def test_quick_construct_direct():
     assert tuple(h.sort(0, key=lambda x: -x).axes[0]) == (4, 2, 1)
     assert tuple(h.sort(1).axes[1]) == ("AB", "BC", "BCC")
     assert tuple(h.sort(1, reverse=True).axes[1]) == ("BCC", "BC", "AB")
+
+
+def test_integrate():
+    h = (
+        hist.new.IntCat([4, 1, 2], name="x")
+        .StrCat(["AB", "BCC", "BC"], name="y")
+        .Int(1, 10, name="z")
+        .Int64()
+    )
+    h.fill(4, "AB", 1)
+    h.fill(4, "BCC", 2)
+    h.fill(4, "BC", 4)
+    h.fill(4, "X", 8)
+
+    h.fill(2, "aAB", 3)
+    h.fill(2, "BCC", 5)
+    h.fill(2, "AB", 2)
+    h.fill(2, "X", 1)
+
+    h.fill(1, "AB", 3)
+    h.fill(1, "BCC", 1)
+    h.fill(1, "BC", 5)
+    h.fill(1, "X", 2)
+
+    h1 = h.integrate("y", ["AB", "BC"]).integrate("z")
+    h2 = h.integrate("y", ["AB", "BC", "BCC"]).integrate("z")
+
+    assert h1[{"x": 4j}] == 2
+    assert h1[{"x": 2j}] == 1
+    assert h2[{"x": 1j}] == 3
diff --git a/tests/test_interop.py b/tests/test_interop.py
new file mode 100644
index 0000000..8e045c4
--- /dev/null
+++ b/tests/test_interop.py
@@ -0,0 +1,385 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+import hist
+from hist import axis
+
+
+def test_structured(unnamed_hist):  # basic
+    h = unnamed_hist(
+        axis.Regular(10, 0, 1, name="x"), axis.Regular(10, 0, 1, name="y")
+    ).fill_flattened(
+        np.array([[0.1, 0.2], [0.5, 0.3], [0.8, 0.2], [0.5, 0.5]]).astype(
+            [("x", np.float64), ("y", np.float64)]
+        )
+    )
+    expected = np.array(
+        [
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+        ],
+        dtype=np.float64,
+    )
+    assert np.allclose(h.values(), expected)
+
+
+def test_positional(unnamed_hist):
+    x = np.array([[0.1], [0.2], [0.3], [0.4], [0.6], [0.7]], dtype=np.float64)
+    y = np.array(
+        [[0.1, 0.2], [0.3, 0.4], [0.6, 0.7], [0.9, 0.8], [0.6, 0.7], [0.4, 0.5]],
+        dtype=np.float64,
+    )
+    expected = np.array(
+        [
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+        ],
+        dtype=np.float64,
+    )
+    h = unnamed_hist(
+        axis.Regular(10, 0, 1, name="x"), axis.Regular(10, 0, 1, name="y")
+    ).fill_flattened(x, y)
+    assert np.allclose(h.values(), expected)
+
+
+def test_positional_keyword(unnamed_hist):
+    x = np.array([[0.1], [0.2], [0.3], [0.4], [0.6], [0.7]], dtype=np.float64)
+    y = np.array(
+        [[0.1, 0.2], [0.3, 0.4], [0.6, 0.7], [0.9, 0.8], [0.6, 0.7], [0.4, 0.5]],
+        dtype=np.float64,
+    )
+    z = np.array([0.8])
+    expected = np.array(
+        [
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 1.0],
+                [0.0, 1.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 1.0],
+                [0.0, 1.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 1.0],
+                [0.0, 1.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 1.0],
+                [0.0, 1.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 1.0],
+                [0.0, 1.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 1.0],
+                [0.0, 1.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+        ],
+        dtype=np.float64,
+    )
+    h = unnamed_hist(
+        axis.Regular(10, 0, 1, name="x"),
+        axis.Regular(10, 0, 1, name="y"),
+        axis.Regular(2, 0, 1, name="z"),
+    ).fill_flattened(x, y, z=z)
+    assert np.allclose(h.values(), expected)
+
+
+def test_named_only_structured():  # basic
+    h = hist.NamedHist(
+        axis.Regular(10, 0, 1, name="x"), axis.Regular(10, 0, 1, name="y")
+    ).fill_flattened(
+        np.array([[0.1, 0.2], [0.5, 0.3], [0.8, 0.2], [0.5, 0.5]]).astype(
+            [("x", np.float64), ("y", np.float64)]
+        )
+    )
+    expected = np.array(
+        [
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+        ],
+        dtype=np.float64,
+    )
+    assert np.allclose(h.values(), expected)
+
+
+def test_named_only_positional():
+    x = np.array([[0.1], [0.2], [0.3], [0.4], [0.6], [0.7]], dtype=np.float64)
+    with pytest.raises(TypeError, match="could not be destructed"):
+        hist.NamedHist(axis.Regular(10, 0, 1, name="x")).fill_flattened(x)
+
+
+def test_named_only_structured_keyword():
+    xy = np.array([[0.1, 0.2], [0.5, 0.3], [0.8, 0.2], [0.5, 0.5]]).astype(
+        [("x", np.float64), ("y", np.float64)]
+    )
+    z = np.array([0.8])
+    with pytest.raises(TypeError, match="but not both"):
+        hist.NamedHist(
+            axis.Regular(10, 0, 1, name="x"),
+            axis.Regular(10, 0, 1, name="y"),
+            axis.Regular(2, 0, 1, name="z"),
+        ).fill_flattened(xy, z=z)
+
+
+def test_named_keyword(unnamed_hist):
+    x = np.array([[0.1], [0.2], [0.3], [0.4], [0.6], [0.7]], dtype=np.float64)
+    y = np.array(
+        [[0.1, 0.2], [0.3, 0.4], [0.6, 0.7], [0.9, 0.8], [0.6, 0.7], [0.4, 0.5]],
+        dtype=np.float64,
+    )
+    z = np.array([0.8])
+    expected = np.array(
+        [
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 1.0],
+                [0.0, 1.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 1.0],
+                [0.0, 1.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 1.0],
+                [0.0, 1.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 1.0],
+                [0.0, 1.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 1.0],
+                [0.0, 1.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 1.0],
+                [0.0, 1.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+            [
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+                [0.0, 0.0],
+            ],
+        ],
+        dtype=np.float64,
+    )
+    h = unnamed_hist(
+        axis.Regular(10, 0, 1, name="x"),
+        axis.Regular(10, 0, 1, name="y"),
+        axis.Regular(2, 0, 1, name="z"),
+    ).fill_flattened(x=x, y=y, z=z)
+    assert np.allclose(h.values(), expected)