Repository: scverse/scanpy

Issue:
log1p with different bases
`log2(TP10K+1)` values are more interpretable than `log(TP10K+1)`, which uses natural logarithm, therefore it'd be great to have an option on the base of the log. It's one of the requests also here #45 .

Since neither of np.log or np.log1p accepts any base arguments(isn't this unbelievable), I did it with simple numba functions here: 

https://nbviewer.ipython.org/gist/gokceneraslan/2744cfeda702fb9b9e48d0216427372c?flush_cache=true

I won't have time to send a PR, but feel free to pursue further, adapt the notebook and merge (if you have time).

PS: Also see https://github.com/numpy/numpy/issues/14969.


Discussion:

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
@@ -43,7 +43,12 @@ def _highly_variable_genes_single_batch(
     if max_mean is None: max_mean = 3
     if max_disp is None: max_disp = np.inf
 
-    X = np.expm1(adata.X) if flavor == 'seurat' else adata.X
+    X = adata.X
+    if flavor == 'seurat':
+        if 'log1p' in adata.uns_keys() and adata.uns['log1p']['base'] is not None:
+            X *= np.log(adata.uns['log1p']['base'])
+        X = np.expm1(X)
+
     mean, var = materialize_as_ndarray(_get_mean_var(X))
     # now actually compute the dispersion
     mean[mean == 0] = 1e-12  # set entries equal to zero to small value
diff --git a/scanpy/preprocessing/_simple.py b/scanpy/preprocessing/_simple.py
--- a/scanpy/preprocessing/_simple.py
+++ b/scanpy/preprocessing/_simple.py
@@ -255,12 +255,13 @@ def log1p(
     copy: bool = False,
     chunked: bool = False,
     chunk_size: Optional[int] = None,
+    base: Optional[float] = None,
 ) -> Optional[AnnData]:
     """\
     Logarithmize the data matrix.
 
     Computes :math:`X = \\log(X + 1)`,
-    where :math:`log` denotes the natural logarithm.
+    where :math:`log` denotes the natural logarithm unless a different base is given.
 
     Parameters
     ----------
@@ -275,11 +276,16 @@ def log1p(
         Applies only to :class:`~anndata.AnnData`.
     chunk_size
         `n_obs` of the chunks to process the data in.
+    base
+        Base of the logarithm. Natural logarithm is used by default.
 
     Returns
     -------
     Returns or updates `data`, depending on `copy`.
     """
+    if 'log1p' in data.uns_keys():
+        logg.warning('adata.X seems to be already log-transformed.')
+
     if copy:
         if not isinstance(data, AnnData):
             data = data.astype(np.floating)
@@ -294,8 +300,12 @@ def log1p(
     def _log1p(X):
         if issparse(X):
             np.log1p(X.data, out=X.data)
+            if base is not None:
+                np.divide(X.data, np.log(base), out=X.data)
         else:
             np.log1p(X, out=X)
+            if base is not None:
+                np.divide(X, np.log(base), out=X)
         return X
 
     if isinstance(data, AnnData):
@@ -309,6 +319,7 @@ def _log1p(X):
     else:
         _log1p(data)
 
+    data.uns['log1p'] = {'base': base}
     return data if copy else None
 
 
diff --git a/scanpy/tools/_rank_genes_groups.py b/scanpy/tools/_rank_genes_groups.py
--- a/scanpy/tools/_rank_genes_groups.py
+++ b/scanpy/tools/_rank_genes_groups.py
@@ -193,6 +193,11 @@ def rank_genes_groups(
     rankings_gene_pvals = []
     rankings_gene_pvals_adj = []
 
+    if 'log1p' in adata.uns_keys() and adata.uns['log1p']['base'] is not None:
+        expm1_func = lambda x: np.expm1(x * np.log(adata.uns['log1p']['base']))
+    else:
+        expm1_func = np.expm1
+
     if method in {'t-test', 't-test_overestim_var'}:
         from scipy import stats
         from statsmodels.stats.multitest import multipletests
@@ -229,7 +234,7 @@ def rank_genes_groups(
                 )
 
             # Fold change
-            foldchanges = (np.expm1(mean_group) + 1e-9) / (np.expm1(mean_rest) + 1e-9)  # add small value to remove 0's
+            foldchanges = (expm1_func(mean_group) + 1e-9) / (expm1_func(mean_rest) + 1e-9)  # add small value to remove 0's
 
             scores[np.isnan(scores)] = 0  # I think it's only nan when means are the same and vars are 0
             pvals[np.isnan(pvals)] = 1  # This also has to happen for Benjamini Hochberg
@@ -347,7 +352,7 @@ def rank_genes_groups(
                     pvals_adj = np.minimum(pvals * n_genes, 1.0)
 
                 # Fold change
-                foldchanges = (np.expm1(means[imask]) + 1e-9) / (np.expm1(mean_rest) + 1e-9)  # add small value to remove 0's
+                foldchanges = (expm1_func(means[imask]) + 1e-9) / (expm1_func(mean_rest) + 1e-9)  # add small value to remove 0's
                 scores_sort = np.abs(scores) if rankby_abs else scores
                 partition = np.argpartition(scores_sort, -n_genes_user)[-n_genes_user:]
                 partial_indices = np.argsort(scores_sort[partition])[::-1]
@@ -405,7 +410,7 @@ def rank_genes_groups(
                     pvals_adj = np.minimum(pvals * n_genes, 1.0)
 
                 # Fold change
-                foldchanges = (np.expm1(means[imask]) + 1e-9) / (np.expm1(mean_rest) + 1e-9)  # add small value to remove 0's
+                foldchanges = (expm1_func(means[imask]) + 1e-9) / (expm1_func(mean_rest) + 1e-9)  # add small value to remove 0's
                 scores_sort = np.abs(scores) if rankby_abs else scores
                 partition = np.argpartition(scores_sort[imask, :], -n_genes_user)[-n_genes_user:]
                 partial_indices = np.argsort(scores_sort[imask, partition])[::-1]


Test patch:
diff --git a/scanpy/tests/test_preprocessing.py b/scanpy/tests/test_preprocessing.py
--- a/scanpy/tests/test_preprocessing.py
+++ b/scanpy/tests/test_preprocessing.py
@@ -12,18 +12,25 @@
 HERE = Path(__file__).parent
 
 
-def test_log1p_chunked():
+def test_log1p():
     A = np.random.rand(200, 10)
+    A_l = np.log1p(A)
     ad = AnnData(A)
     ad2 = AnnData(A)
     ad3 = AnnData(A)
     ad3.filename = HERE / 'test.h5ad'
     sc.pp.log1p(ad)
+    assert np.allclose(ad.X, A_l)
     sc.pp.log1p(ad2, chunked=True)
     assert np.allclose(ad2.X, ad.X)
     sc.pp.log1p(ad3, chunked=True)
     assert np.allclose(ad3.X, ad.X)
 
+    # Test base
+    ad4 = AnnData(A)
+    sc.pp.log1p(ad4, base=2)
+    assert np.allclose(ad4.X, A_l/np.log(2))
+
 
 def test_mean_var_sparse():
     from sklearn.utils.sparsefuncs import mean_variance_axis