Repository: scverse/scanpy

Issue:
Inconsistent array types from sc.get.aggregate
### 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?

cc: @Intron7 

The array types returned for the various aggregations in `sc.get.aggregate` are different (see example)

This can lead to somewhat confusing behavior downstream, especially while we are using the sparse matrix classes.

I would suggest we default to a dense result and consider adding an argument `array_type` that determines the type of the arrays added to `layers`.

### Minimal code sample

```python
import scanpy as sc

adata = sc.datasets.pbmc3k_processed().raw.to_adata()

aggregated = sc.get.aggregate(adata, "louvain", ["sum", "count_nonzero"])
type(aggregated.layers["sum"])
# numpy.ndarray

type(aggregated.layers["count_nonzero"])
# scipy.sparse._csr.csr_matrix
```


### Error output

_No response_

### Versions

<details>

```
-----
anndata     0.10.5.post1
scanpy      1.10.0.dev315+gf6d5ac94
-----
IPython             8.20.0
PIL                 10.2.0
asciitree           NA
asttokens           NA
cloudpickle         3.0.0
cycler              0.12.1
cython_runtime      NA
dask                2024.1.1
dateutil            2.8.2
decorator           5.1.1
executing           2.0.1
fasteners           0.19
h5py                3.10.0
igraph              0.11.3
jedi                0.19.1
jinja2              3.1.3
joblib              1.3.2
kiwisolver          1.4.5
legacy_api_wrap     NA
leidenalg           0.10.2
llvmlite            0.41.1
markupsafe          2.1.4
matplotlib          3.8.2
mpl_toolkits        NA
msgpack             1.0.7
natsort             8.4.0
numba               0.58.1
numcodecs           0.12.1
numpy               1.26.3
packaging           23.2
pandas              2.2.0
parso               0.8.3
pexpect             4.9.0
prompt_toolkit      3.0.43
psutil              5.9.8
ptyprocess          0.7.0
pure_eval           0.2.2
pygments            2.17.2
pyparsing           3.1.1
pytz                2023.4
scipy               1.12.0
session_info        1.0.0
six                 1.16.0
sklearn             1.4.0
sparse              0.15.1
stack_data          0.6.3
tblib               3.0.0
texttable           1.7.0
threadpoolctl       3.2.0
tlz                 0.12.1
toolz               0.12.1
traitlets           5.14.1
wcwidth             0.2.13
yaml                6.0.1
zarr                2.16.1
zipp                NA
-----
Python 3.11.7 | packaged by conda-forge | (main, Dec 23 2023, 14:43:09) [GCC 12.3.0]
Linux-5.15.0-87-generic-x86_64-with-glibc2.35
-----
Session information updated at 2024-03-04 13:41
```

</details>


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/scverse/scanpy/pull/2893?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 73.14%. Comparing base [(`14555ba`)](https://app.codecov.io/gh/scverse/scanpy/commit/14555ba48537995acaa381b8b6ad5fc41e612510?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) to head [(`371d346`)](https://app.codecov.io/gh/scverse/scanpy/pull/2893?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    #2893      +/-   ##
==========================================
- Coverage   74.82%   73.14%   -1.68%     
==========================================
  Files         116      116              
  Lines       12809    12772      -37     
==========================================
- Hits         9584     9342     -242     
- Misses       3225     3430     +205     
```


| [Files](https://app.codecov.io/gh/scverse/scanpy/pull/2893?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) | Coverage Δ | |
|---|---|---|
| [scanpy/get/\_aggregated.py](https://app.codecov.io/gh/scverse/scanpy/pull/2893?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse#diff-c2NhbnB5L2dldC9fYWdncmVnYXRlZC5weQ==) | `95.04% <100.00%> (ø)` | |

... and [24 files with indirect coverage changes](https://app.codecov.io/gh/scverse/scanpy/pull/2893/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 1b338fd5..72214037 100644
--- a/docs/release-notes/1.10.0.md
+++ b/docs/release-notes/1.10.0.md
@@ -44,6 +44,7 @@
 * Fix some annoying plotting warnings around violin plots {pr}`2844` {smaller}`P Angerer`
 * Scanpy now has a test job which tests against the minumum versions of the dependencies. In the process of implementing this, many bugs associated with using older versions of `pandas`, `anndata`, `numpy`, and `matplotlib` were fixed. {pr}`2816` {smaller}`I Virshup`
 * Fix warnings caused by internal usage of `pandas.DataFrame.stack` with `pandas>=2.1` {pr}`2864`{smaller}`I Virshup`
+* {func}`scanpy.get.aggregate` now always returns {class}`numpy.ndarray` {pr}`2893` {smaller}`S Dicks`
 
 ```{rubric} Development
 ```
diff --git a/scanpy/get/_aggregated.py b/scanpy/get/_aggregated.py
index 159f7572..9f50575f 100644
--- a/scanpy/get/_aggregated.py
+++ b/scanpy/get/_aggregated.py
@@ -71,7 +71,7 @@ class Aggregate:
         """
         # pattern = self.data._with_data(np.broadcast_to(1, len(self.data.data)))
         # return self.indicator_matrix @ pattern
-        return self.indicator_matrix @ (self.data != 0)
+        return utils.asarray(self.indicator_matrix @ (self.data != 0))
 
     def sum(self) -> Array:
         """\


Test patch:
diff --git a/scanpy/tests/test_aggregated.py b/scanpy/tests/test_aggregated.py
index 0b3e14b1..c3b5331a 100644
--- a/scanpy/tests/test_aggregated.py
+++ b/scanpy/tests/test_aggregated.py
@@ -378,3 +378,15 @@ def test_combine_categories(label_cols, cols, expected):
         [x.split("_") for x in result], columns=cols, index=result.astype(str)
     ).astype("category")
     pd.testing.assert_frame_equal(reconstructed_df, result_label_df)
+
+
+@pytest.mark.parametrize("array_type", ARRAY_TYPES_MEM)
+@pytest.mark.parametrize("metric", ["sum", "mean", "var", "count_nonzero"])
+def test_aggregate_arraytype(array_type, metric):
+    adata = pbmc3k_processed().raw.to_adata()
+    adata = adata[
+        adata.obs["louvain"].isin(adata.obs["louvain"].cat.categories[:5]), :1_000
+    ].copy()
+    adata.X = array_type(adata.X)
+    aggregate = sc.get.aggregate(adata, ["louvain"], metric)
+    assert isinstance(aggregate.layers[metric], np.ndarray)