Repository: scverse/scanpy

Issue:
Leiden and louvain params not saved to matching `key_added` key in `uns` dictionary 
### Please make sure these conditions are met

- [X] I have checked that this issue has not already been reported.
- [X] I have confirmed this bug exists on the latest version of scanpy.
- [X] (optional) I have confirmed this bug exists on the main branch of scanpy.

### What happened?

Leiden and Louvain clustering params are not saved to matching `key_added` key in `uns` dictionary but are ovewritten to hardcoded key instead. One use case is that a user may want to run Leiden/Louvain clustering multiple times with different resolutions / parameters. One may specify different keys to store results under. However, if you do so, the metadata for the parameterization of the clustering algorithms are overwritten because the lines below do not respect the user provided `key_added` parameter.

I think the desired behavior is to store data under `adata.uns[key_added]["params"]`

I think I've found the pertinent lines below. Happy to submit a PR if maintainers agree :D.
- https://github.com/scverse/scanpy/blob/91ea0fbb03392795d1506d297d4b4847c646db04/scanpy/tools/_leiden.py#L206
- https://github.com/scverse/scanpy/blob/91ea0fbb03392795d1506d297d4b4847c646db04/scanpy/tools/_louvain.py#L259

### Minimal code sample

```python
sc.tl.leiden(adata, resolution=0.8, key_added="leiden_0.8")
assert "leiden_0.8" not in adata.uns
params = adata.uns["leiden"] 

sc.tl.leiden(adata, resolution=1.2, key_added="leiden_1.2")
assert "leiden_1.2" not in adata.uns
overwritten_params = adata.uns["leiden"] 
assert params == overwritten_params # should fail
```


### Error output

_No response_

### Versions

<details>
Confirmed that params are overwritten in source in main branch. (see permalinks)
</details>


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/scverse/scanpy/pull/2889?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) Report
Attention: Patch coverage is `50.00000%` with `2 lines` in your changes are missing coverage. Please review.
> Project coverage is 73.15%. Comparing base [(`91ea0fb`)](https://app.codecov.io/gh/scverse/scanpy/commit/91ea0fbb03392795d1506d297d4b4847c646db04?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) to head [(`2103f4c`)](https://app.codecov.io/gh/scverse/scanpy/pull/2889?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2889      +/-   ##
==========================================
- Coverage   74.83%   73.15%   -1.68%     
==========================================
  Files         116      116              
  Lines       12814    12777      -37     
==========================================
- Hits         9589     9347     -242     
- Misses       3225     3430     +205     
```


| [Files](https://app.codecov.io/gh/scverse/scanpy/pull/2889?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) | Coverage Δ | |
|---|---|---|
| [scanpy/tools/\_leiden.py](https://app.codecov.io/gh/scverse/scanpy/pull/2889?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse#diff-c2NhbnB5L3Rvb2xzL19sZWlkZW4ucHk=) | `88.40% <100.00%> (ø)` | |
| [scanpy/tools/\_louvain.py](https://app.codecov.io/gh/scverse/scanpy/pull/2889?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse#diff-c2NhbnB5L3Rvb2xzL19sb3V2YWluLnB5) | `21.42% <0.00%> (ø)` | |

... and [24 files with indirect coverage changes](https://app.codecov.io/gh/scverse/scanpy/pull/2889/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse)

</details>

Patch:
diff --git a/docs/release-notes/1.10.0.md b/docs/release-notes/1.10.0.md
index 9373c273..d1d83b34 100644
--- a/docs/release-notes/1.10.0.md
+++ b/docs/release-notes/1.10.0.md
@@ -25,6 +25,7 @@
 * {func}`scanpy.external.pp.harmony_integrate` now runs with 64 bit floats improving reproducibility {pr}`2655` {smaller}`S Dicks`
 * {func}`scanpy.tl.rank_genes_groups` no longer warns that it's default was changed from t-test_overestim_var to t-test {pr}`2798` {smaller}`L Heumos`
 * `scanpy.pp.calculate_qc_metrics` now allows `qc_vars` to be passed as a string {pr}`2859` {smaller}`N Teyssier`
+* {func}`scanpy.tl.leiden` and {func}`scanpy.tl.louvain` now store clustering parameters in the key provided by the `key_added` parameter instead of always writing to (or overwriting) a default key {pr}`2864` {smaller}`J Fan`
 * {func}`scanpy.pp.scale` now clips `np.ndarray` also at `- max_value` for zero-centering {pr}`2913` {smaller}`S Dicks`
 
 ```{rubric} Docs
diff --git a/pyproject.toml b/pyproject.toml
index e1c80492..b7a00384 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -100,8 +100,8 @@ test = [
 ]
 test-full = [
     "scanpy[test]",
-    # tested algorithms
-    "scanpy[leiden]",
+    # additional tested algorithms
+    "scanpy[louvain]",
     "scanpy[magic]",
     "scanpy[skmisc]",
     "scanpy[harmony]",
diff --git a/scanpy/tools/_leiden.py b/scanpy/tools/_leiden.py
index 5345e356..a9a9c6e8 100644
--- a/scanpy/tools/_leiden.py
+++ b/scanpy/tools/_leiden.py
@@ -114,7 +114,7 @@ def leiden(
         Array of dim (number of samples) that stores the subgroup id
         (``'0'``, ``'1'``, ...) for each cell.
 
-    `adata.uns['leiden']['params']` : :class:`dict`
+    `adata.uns['leiden' | key_added]['params']` : :class:`dict`
         A dict with the values for the parameters `resolution`, `random_state`,
         and `n_iterations`.
     """
@@ -202,8 +202,8 @@ def leiden(
         categories=natsorted(map(str, np.unique(groups))),
     )
     # store information on the clustering parameters
-    adata.uns["leiden"] = {}
-    adata.uns["leiden"]["params"] = dict(
+    adata.uns[key_added] = {}
+    adata.uns[key_added]["params"] = dict(
         resolution=resolution,
         random_state=random_state,
         n_iterations=n_iterations,
diff --git a/scanpy/tools/_louvain.py b/scanpy/tools/_louvain.py
index 51f3cb5b..06de3bdc 100644
--- a/scanpy/tools/_louvain.py
+++ b/scanpy/tools/_louvain.py
@@ -133,7 +133,7 @@ def louvain(
         Array of dim (number of samples) that stores the subgroup id
         (``'0'``, ``'1'``, ...) for each cell.
 
-    `adata.uns['louvain']['params']` : :class:`dict`
+    `adata.uns['louvain' | key_added]['params']` : :class:`dict`
         A dict with the values for the parameters `resolution`, `random_state`,
         and `n_iterations`.
     """
@@ -255,8 +255,8 @@ def louvain(
         values=groups.astype("U"),
         categories=natsorted(map(str, np.unique(groups))),
     )
-    adata.uns["louvain"] = {}
-    adata.uns["louvain"]["params"] = dict(
+    adata.uns[key_added] = {}
+    adata.uns[key_added]["params"] = dict(
         resolution=resolution,
         random_state=random_state,
     )


Test patch:
diff --git a/scanpy/tests/external/test_hashsolo.py b/scanpy/tests/external/test_hashsolo.py
index 499a204b..a6f0a799 100644
--- a/scanpy/tests/external/test_hashsolo.py
+++ b/scanpy/tests/external/test_hashsolo.py
@@ -32,4 +32,4 @@ def test_cell_demultiplexing():
     )
     negatives = ["Negative"] * 10
     classification = doublets + classes + negatives
-    assert all(test_data.obs["Classification"].astype(str) == classification)
+    assert test_data.obs["Classification"].astype(str).tolist() == classification
diff --git a/scanpy/tests/notebooks/test_paga_paul15_subsampled.py b/scanpy/tests/notebooks/test_paga_paul15_subsampled.py
index 1aa683d7..8460e7d5 100644
--- a/scanpy/tests/notebooks/test_paga_paul15_subsampled.py
+++ b/scanpy/tests/notebooks/test_paga_paul15_subsampled.py
@@ -8,10 +8,9 @@ from functools import partial
 from pathlib import Path
 
 import numpy as np
+import pytest
 from matplotlib.testing import setup
 
-setup()
-
 import scanpy as sc
 from scanpy.testing._helpers.data import paul15
 from scanpy.testing._pytest.marks import needs
@@ -20,9 +19,11 @@ HERE: Path = Path(__file__).parent
 ROOT = HERE / "_images_paga_paul15_subsampled"
 
 
+@pytest.mark.skip(reason="Broken, needs fixing")
 @needs.igraph
 @needs.louvain
 def test_paga_paul15_subsampled(image_comparer, plt):
+    setup()
     save_and_compare_images = partial(image_comparer, ROOT, tol=25)
 
     adata = paul15()
@@ -59,16 +60,8 @@ def test_paga_paul15_subsampled(image_comparer, plt):
         [
             [0.0, 0.128553, 0.0, 0.07825, 0.0, 0.0, 0.238741, 0.0, 0.0, 0.657049],
             [
-                0.128553,
-                0.0,
-                0.480676,
-                0.257505,
-                0.533036,
-                0.043871,
-                0.0,
-                0.032903,
-                0.0,
-                0.087743,
+                *[0.128553, 0.0, 0.480676, 0.257505, 0.533036],
+                *[0.043871, 0.0, 0.032903, 0.0, 0.087743],
             ],
         ]
     )
diff --git a/scanpy/tests/test_clustering.py b/scanpy/tests/test_clustering.py
index d2005d96..2b757fa4 100644
--- a/scanpy/tests/test_clustering.py
+++ b/scanpy/tests/test_clustering.py
@@ -31,9 +31,12 @@ def test_leiden_basic(adata_neighbors, flavor, resolution, n_iterations):
         resolution=resolution,
         n_iterations=n_iterations,
         directed=(flavor == "leidenalg"),
+        key_added="leiden_custom",
+    )
+    assert adata_neighbors.uns["leiden_custom"]["params"]["resolution"] == resolution
+    assert (
+        adata_neighbors.uns["leiden_custom"]["params"]["n_iterations"] == n_iterations
     )
-    assert adata_neighbors.uns["leiden"]["params"]["resolution"] == resolution
-    assert adata_neighbors.uns["leiden"]["params"]["n_iterations"] == n_iterations
 
 
 @needs.leidenalg
@@ -186,6 +189,22 @@ def test_louvain_basic(adata_neighbors):
     sc.tl.louvain(adata_neighbors, flavor="igraph")
 
 
+@needs.louvain
+@pytest.mark.parametrize("random_state", [10, 999])
+@pytest.mark.parametrize("resolution", [0.9, 1.1])
+def test_louvain_custom_key(adata_neighbors, resolution, random_state):
+    sc.tl.louvain(
+        adata_neighbors,
+        key_added="louvain_custom",
+        random_state=random_state,
+        resolution=resolution,
+    )
+    assert (
+        adata_neighbors.uns["louvain_custom"]["params"]["random_state"] == random_state
+    )
+    assert adata_neighbors.uns["louvain_custom"]["params"]["resolution"] == resolution
+
+
 @needs.louvain
 @needs.igraph
 def test_partition_type(adata_neighbors):
@@ -193,3 +212,26 @@ def test_partition_type(adata_neighbors):
 
     sc.tl.louvain(adata_neighbors, partition_type=louvain.RBERVertexPartition)
     sc.tl.louvain(adata_neighbors, partition_type=louvain.SurpriseVertexPartition)
+
+
+@pytest.mark.parametrize(
+    "clustering,default_key,default_res,custom_resolutions",
+    [
+        pytest.param(sc.tl.leiden, "leiden", 0.8, [0.9, 1.1], marks=needs.leidenalg),
+        pytest.param(sc.tl.louvain, "louvain", 0.8, [0.9, 1.1], marks=needs.louvain),
+    ],
+)
+def test_clustering_custom_key(
+    adata_neighbors, clustering, default_key, default_res, custom_resolutions
+):
+    custom_keys = [f"{default_key}_{res}" for res in custom_resolutions]
+
+    # Run clustering with default key, then custom keys
+    clustering(adata_neighbors, resolution=default_res)
+    for key, res in zip(custom_keys, custom_resolutions):
+        clustering(adata_neighbors, resolution=res, key_added=key)
+
+    # ensure that all clustering parameters are added to user provided keys and not overwritten
+    assert adata_neighbors.uns[default_key]["params"]["resolution"] == default_res
+    for key, res in zip(custom_keys, custom_resolutions):
+        assert adata_neighbors.uns[key]["params"]["resolution"] == res