Repository: scikit-hep/hist

Issue:
[FEATURE] Reorder categorical axes
I've hit this while implementing https://github.com/scikit-hep/mplhep/issues/303 and realizing the results can be funny if the categorical axis is set with `growth=True`. I think it makes more sense to order on the object, than at plotting time.  API could look like 
```
h.sort('axis_name', key=list | fcn as passed to sorted())
```

Discussion:
sourcery-ai[bot]: # Sourcery Code Quality Report


❌ &nbsp;Merging this PR will **decrease** code quality in the affected files by **1.10%**.

| Quality metrics | Before | After | Change |
| --- | :---: | :---: | :---: |
| [Complexity](https://github.com/sourcery-ai/sourcery/wiki/Quality-Report#complexity) | 6.58 ⭐ | 6.58 ⭐ | 0.00  |
| [Method Length](https://github.com/sourcery-ai/sourcery/wiki/Quality-Report#method-length) | 57.64 ⭐ | 64.48 🙂 | 6.84 👎 |
| [Working memory](https://github.com/sourcery-ai/sourcery/wiki/Quality-Report#working-memory) | 9.12 🙂 | 9.02 🙂 | -0.10 👍 |
| **[Quality](https://github.com/sourcery-ai/sourcery/wiki/Quality-Report#quality-score)** | **65.48%** 🙂| **64.38%** 🙂| **-1.10%** 👎 |



| Other metrics | Before | After | Change |
| --- | :---: | :---: | :---: |
| Lines | 427 | 468 | 41 |



| Changed files | Quality Before | Quality After | Quality Change |
| --- | :---: | :---: | :---: |
| src/hist/basehist.py | 65.48% 🙂 | 64.38% 🙂 | -1.10% 👎|



Here are some functions in these files that still need a tune-up:

| File | Function | Complexity | Length | Working Memory | Quality | Recommendation |
| --- | --- | :---: | :---: | :---: | :---: | --- |
| src/hist/basehist.py | BaseHist.\_\_init\_\_ | 18 🙂 | 176 😞 | 12 😞 | 40.32% 😞 | Try splitting into smaller methods. Extract out complex expressions |
| src/hist/basehist.py | BaseHist.from\_columns | 12 🙂 | 160 😞 | 11 😞 | 48.49% 😞 | Try splitting into smaller methods. Extract out complex expressions |
| src/hist/basehist.py | BaseHist.sort | 8 ⭐ | 246 ⛔ | 9 🙂 | 49.69% 😞 | Try splitting into smaller methods |
| src/hist/basehist.py | BaseHist.profile | 3 ⭐ | 181 😞 | 11 😞 | 55.06% 🙂 | Try splitting into smaller methods. Extract out complex expressions |
| src/hist/basehist.py | BaseHist.fill | 2 ⭐ | 90 🙂 | 12 😞 | 64.77% 🙂 | Extract out complex expressions |


## Legend and Explanation

The emojis denote the absolute quality of the code:

 - ⭐ excellent
 - 🙂 good
 - 😞 poor
 - ⛔ very poor


The 👍 and 👎 indicate whether the quality has improved or gotten worse with this pull request.

***

Please see our documentation [here](https://github.com/sourcery-ai/sourcery/wiki/Quality-Report) for details on how these metrics are calculated.

We are actively working on this report - lots more documentation and extra metrics to come!

Help us [improve](https://research.typeform.com/to/j06Spdfr?type=quality_report&github_login=andrzejnovak&base_repo=https%3A%2F%2Fgithub.com%2Fscikit-hep%2Fhist.git&base_remote_ref=refs%2Fpull%2F243%2Fhead&base_ref=main&base_sha=90729b7be9f6a57135b19b223ea0dffa9a107af6&head_repo=https%3A%2F%2Fgithub.com%2Fscikit-hep%2Fhist.git&head_ref=sourcery%2Fpull-243&base_pr_number=243&base_pr_state=open) this quality report!
sourcery-ai[bot]: # Sourcery Code Quality Report


❌ &nbsp;Merging this PR will **decrease** code quality in the affected files by **1.10%**.

| Quality metrics | Before | After | Change |
| --- | :---: | :---: | :---: |
| [Complexity](https://github.com/sourcery-ai/sourcery/wiki/Quality-Report#complexity) | 6.58 ⭐ | 6.58 ⭐ | 0.00  |
| [Method Length](https://github.com/sourcery-ai/sourcery/wiki/Quality-Report#method-length) | 57.64 ⭐ | 64.48 🙂 | 6.84 👎 |
| [Working memory](https://github.com/sourcery-ai/sourcery/wiki/Quality-Report#working-memory) | 9.12 🙂 | 9.02 🙂 | -0.10 👍 |
| **[Quality](https://github.com/sourcery-ai/sourcery/wiki/Quality-Report#quality-score)** | **65.48%** 🙂| **64.38%** 🙂| **-1.10%** 👎 |



| Other metrics | Before | After | Change |
| --- | :---: | :---: | :---: |
| Lines | 427 | 474 | 47 |



| Changed files | Quality Before | Quality After | Quality Change |
| --- | :---: | :---: | :---: |
| src/hist/basehist.py | 65.48% 🙂 | 64.38% 🙂 | -1.10% 👎|



Here are some functions in these files that still need a tune-up:

| File | Function | Complexity | Length | Working Memory | Quality | Recommendation |
| --- | --- | :---: | :---: | :---: | :---: | --- |
| src/hist/basehist.py | BaseHist.\_\_init\_\_ | 18 🙂 | 176 😞 | 12 😞 | 40.32% 😞 | Try splitting into smaller methods. Extract out complex expressions |
| src/hist/basehist.py | BaseHist.from\_columns | 12 🙂 | 160 😞 | 11 😞 | 48.49% 😞 | Try splitting into smaller methods. Extract out complex expressions |
| src/hist/basehist.py | BaseHist.sort | 8 ⭐ | 246 ⛔ | 9 🙂 | 49.69% 😞 | Try splitting into smaller methods |
| src/hist/basehist.py | BaseHist.profile | 3 ⭐ | 181 😞 | 11 😞 | 55.06% 🙂 | Try splitting into smaller methods. Extract out complex expressions |
| src/hist/basehist.py | BaseHist.fill | 2 ⭐ | 90 🙂 | 12 😞 | 64.77% 🙂 | Extract out complex expressions |


## Legend and Explanation

The emojis denote the absolute quality of the code:

 - ⭐ excellent
 - 🙂 good
 - 😞 poor
 - ⛔ very poor


The 👍 and 👎 indicate whether the quality has improved or gotten worse with this pull request.

***

Please see our documentation [here](https://github.com/sourcery-ai/sourcery/wiki/Quality-Report) for details on how these metrics are calculated.

We are actively working on this report - lots more documentation and extra metrics to come!

Help us [improve](https://research.typeform.com/to/j06Spdfr?type=quality_report&github_login=andrzejnovak&base_repo=https%3A%2F%2Fgithub.com%2Fscikit-hep%2Fhist.git&base_remote_ref=refs%2Fpull%2F243%2Fhead&base_ref=main&base_sha=e390226d70a5ad6ab6e2fa8361128e15f71ef38a&head_repo=https%3A%2F%2Fgithub.com%2Fscikit-hep%2Fhist.git&head_ref=sourcery%2Fpull-243&base_pr_number=243&base_pr_state=open) this quality report!
henryiii: Would https://github.com/scikit-hep/boost-histogram/pull/577 make this easier? `h[{i: sorted(h.axis[i]})]` would do it, I think? (I'm applying hist's UHI+ on top of boost-histogram's PR in this example)
henryiii: > Couldn't easily copy all meta/traits from old axis to new

I think, post refactor, this is now just copying the `__dict__` from the old to the new.

```pycon
>>> h = Hist.new.Reg(10, 0, 1, name="hi", label="ho", __metadata__ = {"this": "that"} ).Double()
>>> h.axes[0].this = "that"
>>> h.axes[0].__dict__
{'name': 'hi', 'label': 'ho', 'this': 'that'}
```
andrzejnovak: Ok yeah, arguably scikit-hep/boost-histogram#577 could make this entire API redundant, though it might still be worth having as a shorthand for the 2 most obvious use cases I see - sort by label, sort by integral.

Btw the bh draft doesn't work for StrCat, I guess you are aware with the `bh.loc` comment. 

Any ETA on that draft? This sorting histograms question comes up quite often for me and it's fairly experience breaking to reply with "export to 1Ds and pass as a list to mplhep".
henryiii: Before PyHEP; but hopefully this week. Probably will be right after the PR above it (scaling).
henryiii: This is going in as an "experimental" feature in boost-histogram 1.1 - scikit-hep/boost-histogram#577 . It does not add removed bins to the flow bin when a selection is not complete, but that should not matter for this PR, so the warning could be silenced.
henryiii: Only have a sort by label currently, but I think this is good enough for a first pass?

Patch:
diff --git a/src/hist/basehist.py b/src/hist/basehist.py
index 3ecf80d..b0fc4a5 100644
--- a/src/hist/basehist.py
+++ b/src/hist/basehist.py
@@ -28,7 +28,7 @@ from .axis import AxisProtocol
 from .quick_construct import MetaConstructor
 from .storage import Storage
 from .svgplots import html_hist, svg_hist_1d, svg_hist_1d_c, svg_hist_2d, svg_hist_nd
-from .typing import ArrayLike, SupportsIndex
+from .typing import ArrayLike, Protocol, SupportsIndex
 
 if typing.TYPE_CHECKING:
     from builtins import ellipsis
@@ -38,6 +38,12 @@ if typing.TYPE_CHECKING:
 
     from .plot import FitResultArtists, MainAxisArtists, RatiolikeArtists
 
+
+class SupportsLessThan(Protocol):
+    def __lt__(self, __other: Any) -> bool:
+        ...
+
+
 InnerIndexing = Union[
     SupportsIndex, str, Callable[[bh.axis.Axis], int], slice, "ellipsis"
 ]
@@ -228,6 +234,24 @@ class BaseHist(bh.Histogram, metaclass=MetaConstructor, family=hist):
         total_data = tuple(args) + tuple(data)  # Python 2 can't unpack twice
         return super().fill(*total_data, weight=weight, sample=sample, threads=threads)
 
+    def sort(
+        self: T,
+        axis: Union[int, str],
+        key: Union[
+            Callable[[int], SupportsLessThan], Callable[[str], SupportsLessThan], None
+        ] = None,
+        reverse: bool = False,
+    ) -> T:
+        """
+        Sort a categorical axis.
+        """
+
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            sorted_cats = sorted(self.axes[axis], key=key, reverse=reverse)
+            # This can only return T, not float, etc., so we ignore the extra types here
+            return self[{axis: [bh.loc(x) for x in sorted_cats]}]  # type: ignore
+
     def _loc_shortcut(self, x: Any) -> Any:
         """
         Convert some specific indices to location.


Test patch:
diff --git a/tests/test_general.py b/tests/test_general.py
index 265f414..f036060 100644
--- a/tests/test_general.py
+++ b/tests/test_general.py
@@ -896,3 +896,11 @@ def test_select_by_index_imag():
 
     assert tuple(h[[2, 1]].axes[0]) == (9, 8)
     assert tuple(h[[8j, 7j]].axes[0]) == (8, 7)
+
+
+def test_sorted_simple():
+    h = Hist.new.IntCat([4, 1, 2]).Double()
+    assert tuple(h.sort(0).axes[0]) == (1, 2, 4)
+    assert tuple(h.sort(0, reverse=True).axes[0]) == (4, 2, 1)
+    assert tuple(h.sort(0, key=lambda x: -x).axes[0]) == (4, 2, 1)
+    assert tuple(h.sort(0, key=lambda x: -x, reverse=True).axes[0]) == (1, 2, 4)