Repository: scverse/scanpy

Issue:
scale max value only cuts high values
### 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?

Shouldn't max Value for `scale` and zero_center also clip the negative values?

### Minimal code sample

```python
bdata = sc.datasets.pbmc3k()
sc.pp.scale(bdata,max_value= 1)
print(bdata.X.min(),bdata.X.max())
```


### Error output

```pytb
-2.62718 1.0

shouldn't this be -1,1
```


### Versions

scanpy build from github main branch

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/scverse/scanpy/pull/2913?dropdown=coverage&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 `66.66667%` with `1 lines` in your changes are missing coverage. Please review.
> Project coverage is 73.14%. Comparing base [(`7449672`)](https://app.codecov.io/gh/scverse/scanpy/commit/7449672ea8ac7c7da07e505c58052f76f5358682?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) to head [(`0e91f42`)](https://app.codecov.io/gh/scverse/scanpy/pull/2913?dropdown=coverage&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    #2913      +/-   ##
==========================================
- Coverage   74.49%   73.14%   -1.36%     
==========================================
  Files         116      116              
  Lines       12811    12776      -35     
==========================================
- Hits         9544     9345     -199     
- Misses       3267     3431     +164     
```


| [Files](https://app.codecov.io/gh/scverse/scanpy/pull/2913?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) | Coverage Δ | |
|---|---|---|
| [scanpy/preprocessing/\_simple.py](https://app.codecov.io/gh/scverse/scanpy/pull/2913?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse#diff-c2NhbnB5L3ByZXByb2Nlc3NpbmcvX3NpbXBsZS5weQ==) | `80.41% <66.66%> (-2.49%)` | :arrow_down: |

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

</details>
Intron7: That should solve it

Patch:
diff --git a/docs/release-notes/1.10.0.md b/docs/release-notes/1.10.0.md
index afe72f87..9373c273 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.pp.scale` now clips `np.ndarray` also at `- max_value` for zero-centering {pr}`2913` {smaller}`S Dicks`
 
 ```{rubric} Docs
 ```
diff --git a/scanpy/preprocessing/_simple.py b/scanpy/preprocessing/_simple.py
index 7fba1a71..8b215886 100644
--- a/scanpy/preprocessing/_simple.py
+++ b/scanpy/preprocessing/_simple.py
@@ -871,7 +871,10 @@ def scale_array(
     # do the clipping
     if max_value is not None:
         logg.debug(f"... clipping at max_value {max_value}")
-        X[X > max_value] = max_value
+        if zero_center:
+            X = np.clip(X, a_min=-max_value, a_max=max_value)
+        else:
+            X[X > max_value] = max_value
     if return_mean_std:
         return X, mean, std
     else:


Test patch:
diff --git a/scanpy/tests/test_scaling.py b/scanpy/tests/test_scaling.py
index d6b004c1..ba425e44 100644
--- a/scanpy/tests/test_scaling.py
+++ b/scanpy/tests/test_scaling.py
@@ -104,3 +104,12 @@ def test_mask_string():
     sc.pp.scale(adata, mask_obs="some cells")
     assert np.array_equal(adata.X, X_centered_for_mask)
     assert "mean of some cells" in adata.var.keys()
+
+
+@pytest.mark.parametrize("zero_center", [True, False])
+def test_clip(zero_center):
+    adata = sc.datasets.pbmc3k()
+    sc.pp.scale(adata, max_value=1, zero_center=zero_center)
+    if zero_center:
+        assert adata.X.min() >= -1
+    assert adata.X.max() <= 1