Repository: scverse/scanpy

Issue:
highly_variable_genes changing my data
### Please make sure these conditions are met

- [ ] I have checked that this issue has not already been reported.
- [ ] I have confirmed this bug exists on the latest version of scanpy.
- [ ] (optional) I have confirmed this bug exists on the master branch of scanpy.

### What happened?

I noticed that hvg computation is changing my data. I guess here X is only a reference, not a copy of my data
https://github.com/scverse/scanpy/blob/414092f68b4b40aa99153556377c32839b392636/scanpy/preprocessing/_highly_variable_genes.py#L195

Didn't manage to create a reproducible small example, not sure when anndata objects are copied vs referenced. 

### Minimal code sample

```python
import scanpy as sc

prot = sc.read_h5ad('my_data')

print('before')
print(prot.X)
sc.pp.highly_variable_genes(prot)
print('after')
print(prot.X)
```
Output
```
before
[[ 0.00612707 -0.05152755 -0.29209763 ... -0.30049595 -0.04813165
  -0.05565361]
 [ 0.24357025  0.359029    0.4300756  ...  0.10488246  0.03976884
   0.03547253]
 [ 0.13372123 -0.18821767 -0.20151998 ... -0.36478856 -0.15580176
  -0.05032819]
 ...
 [ 0.22013764  0.1011355  -0.6299254  ... -0.06087471  0.06812771
  -0.01558504]
 [-0.29464224  0.2627981  -0.11072742 ... -0.48136345 -0.23690814
   0.0116325 ]
 [-0.05685111 -0.3491494   0.08344086 ... -0.11356537  0.05052189
   0.02879048]]
after
[[ 0.00424696 -0.03571618 -0.20246665 ... -0.20828792 -0.03336232
  -0.03857614]
 [ 0.16883004  0.24885994  0.2981057  ...  0.07269898  0.02756566
   0.02458768]
 [ 0.09268849 -0.13046254 -0.13968301 ... -0.25285217 -0.10799355
  -0.03488484]
 ...
 [ 0.15258779  0.07010179 -0.436631   ... -0.04219513  0.04722253
  -0.01080273]
 [-0.20423044  0.18215777 -0.0767504  ... -0.33365571 -0.16421221
   0.00806304]
 [-0.03940619 -0.24201192  0.05783679 ... -0.07871751  0.0350191
   0.01995604]]
```


### Error output

_No response_

### Versions

<details>

```
-----
anndata     0.8.0
scanpy      1.9.1
-----
PIL                 9.4.0
asttokens           NA
backcall            0.2.0
beta_ufunc          NA
binom_ufunc         NA
cffi                1.15.1
colorama            0.4.6
comm                0.1.2
cycler              0.10.0
cython_runtime      NA
dateutil            2.8.2
debugpy             1.6.6
decorator           5.1.1
entrypoints         0.4
executing           1.2.0
google              NA
h5py                3.8.0
hypergeom_ufunc     NA
igraph              0.10.3
invgauss_ufunc      NA
ipykernel           6.20.2
jedi                0.18.2
joblib              1.2.0
kiwisolver          1.4.4
leidenalg           0.10.1
llvmlite            0.39.1
matplotlib          3.6.3
mpl_toolkits        NA
natsort             8.2.0
nbinom_ufunc        NA
ncf_ufunc           NA
nct_ufunc           NA
ncx2_ufunc          NA
numba               0.56.4
numpy               1.23.5
packaging           23.0
pandas              1.5.3
parso               0.8.3
pexpect             4.8.0
pickleshare         0.7.5
pkg_resources       NA
platformdirs        2.6.2
prompt_toolkit      3.0.36
psutil              5.9.4
ptyprocess          0.7.0
pure_eval           0.2.2
pydev_ipython       NA
pydevconsole        NA
pydevd              2.9.5
pydevd_file_utils   NA
pydevd_plugins      NA
pydevd_tracing      NA
pygments            2.14.0
pyparsing           3.0.9
pytz                2022.7.1
scipy               1.10.0
session_info        1.0.0
setuptools          66.1.1
six                 1.16.0
skewnorm_ufunc      NA
sklearn             1.2.1
stack_data          0.6.2
texttable           1.6.7
threadpoolctl       3.1.0
tornado             6.2
traitlets           5.8.1
typing_extensions   NA
wcwidth             0.2.6
yaml                6.0
zipp                NA
zmq                 25.0.0
zoneinfo            NA
-----
IPython             8.8.0
jupyter_client      7.4.9
jupyter_core        5.1.5
-----
Python 3.9.15 | packaged by conda-forge | (main, Nov 22 2022, 15:55:03) [GCC 10.4.0]
Linux-4.18.0-425.3.1.el8.x86_64-x86_64-with-glibc2.28
-----
Session information updated at 2023-09-18 17:04
```

</details>



Discussion:
I also observe this in 1.9.3.
So for a fix, we’d simply need to change

https://github.com/scverse/scanpy/blob/414092f68b4b40aa99153556377c32839b392636/scanpy/preprocessing/_highly_variable_genes.py#L197-L199

into

```py
X = X.copy()
if 'log1p' in adata.uns_keys() and adata.uns['log1p'].get('base') is not None:
    X *= np.log(adata.uns['log1p']['base'])
np.expm1(X, out=X)
```
Yep, PR follows

Resolving patch:
diff --git a/scanpy/preprocessing/_highly_variable_genes.py b/scanpy/preprocessing/_highly_variable_genes.py
--- a/scanpy/preprocessing/_highly_variable_genes.py
+++ b/scanpy/preprocessing/_highly_variable_genes.py
@@ -194,9 +194,14 @@ def _highly_variable_genes_single_batch(
     """
     X = adata.layers[layer] if layer is not None else adata.X
     if flavor == 'seurat':
+        X = X.copy()
         if 'log1p' in adata.uns_keys() and adata.uns['log1p'].get('base') is not None:
             X *= np.log(adata.uns['log1p']['base'])
-        X = np.expm1(X)
+        # use out if possible. only possible since we copy X
+        if isinstance(X, np.ndarray):
+            np.expm1(X, out=X)
+        else:
+            X = np.expm1(X)
 
     mean, var = materialize_as_ndarray(_get_mean_var(X))
     # now actually compute the dispersion


Test patch:
diff --git a/scanpy/tests/test_highly_variable_genes.py b/scanpy/tests/test_highly_variable_genes.py
--- a/scanpy/tests/test_highly_variable_genes.py
+++ b/scanpy/tests/test_highly_variable_genes.py
@@ -72,6 +72,26 @@ def test_highly_variable_genes_basic():
     assert np.all(np.isin(colnames, hvg_df.columns))
 
 
+@pytest.mark.parametrize('base', [None, 10])
+@pytest.mark.parametrize('flavor', ['seurat', 'cell_ranger'])
+def test_highly_variable_genes_keep_layer(base, flavor):
+    adata = pbmc3k()
+    # cell_ranger flavor can raise error if many 0 genes
+    sc.pp.filter_genes(adata, min_counts=1)
+
+    sc.pp.log1p(adata, base=base)
+    X_orig = adata.X.copy()
+
+    if flavor == 'seurat':
+        sc.pp.highly_variable_genes(adata, n_top_genes=50, flavor=flavor)
+    elif flavor == 'cell_ranger':
+        sc.pp.highly_variable_genes(adata, flavor=flavor)
+    else:
+        assert False
+
+    assert np.allclose(X_orig.A, adata.X.A)
+
+
 def _check_pearson_hvg_columns(output_df, n_top_genes):
     assert pd.api.types.is_float_dtype(output_df['residual_variances'].dtype)