Repository: scikit-hep/hist

Issue:
[FEATURE] Add a function to integrate axes (including over partial ranges)
It would be useful to have an integrate function, which could be used to do the following:
1) Remove a single axis from a histogram, reducing its dimension by 1: `h.integrate("y")`
2) Integrate over a range of an axis: `h.integrate("y", i, j)`
3) Sum certain entries from a category axis: `h.integrate("y", ["cats", "dogs"])`

Currently it is possible to do all of these things, however the syntax is unclear and there are a number of pitfalls:

1) Can reasonably easily be achieved with `h[{"y": sum}]` or `h[{"y": slice(None, None, sum)}]`, though would be nice to add for completeness.
2) Can be achieved with `h[{"y": slice(i, j, sum)}]`, however the more obvious `h[:, i:j]["y": sum]` will give the wrong result, since `sum` includes the overflow as noted here: https://github.com/scikit-hep/boost-histogram/issues/621
3) For this, the corresponding `h[{"y": ["cats", "dogs"]}][{"y": sum}]` almost works, as with this slice any other categories don't seem to be added to the overflow. However, if the overflow already contains entries, these will be added to the sum, so seemingly the only way to get the correct result is to do the sum by hand: `h[{"y": "cats"}]+h[{"y": "dogs"}]` which could quickly become laborious. (Could be done as `h[{"y": ["cats", "dogs"]}][{"y": slice(0, len, sum)}]`)

Linked to this issue, it would be helpful if one could specify whether to include the overflows when projecting out axes using the `project` method, which if adding a new function is not desired, would at least make some other work-arounds easier.

Discussion:

Patch:
diff --git a/src/hist/basehist.py b/src/hist/basehist.py
index bfaaa8f..72c3de3 100644
--- a/src/hist/basehist.py
+++ b/src/hist/basehist.py
@@ -527,6 +527,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).


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