destriper 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- destriper-0.1.0/.gitignore +19 -0
- destriper-0.1.0/LICENSE +21 -0
- destriper-0.1.0/PKG-INFO +125 -0
- destriper-0.1.0/README.md +96 -0
- destriper-0.1.0/destriper/__init__.py +45 -0
- destriper-0.1.0/destriper/_adata.py +141 -0
- destriper-0.1.0/destriper/_core.py +167 -0
- destriper-0.1.0/destriper/_destripe.py +123 -0
- destriper-0.1.0/destriper/_df.py +79 -0
- destriper-0.1.0/destriper/_fit/__init__.py +0 -0
- destriper-0.1.0/destriper/_fit/_pandas_log.py +7 -0
- destriper-0.1.0/destriper/_fit/coordinate_descent_glm.py +339 -0
- destriper-0.1.0/destriper/_fit/coordinate_descent_solver.py +233 -0
- destriper-0.1.0/destriper/_fit/custom_regressors/__init__.py +0 -0
- destriper-0.1.0/destriper/_fit/custom_regressors/alternating_theta_cv_regressor.py +204 -0
- destriper-0.1.0/destriper/_fit/custom_regressors/cv_regressor.py +84 -0
- destriper-0.1.0/destriper/_fit/custom_regressors/helpers.py +60 -0
- destriper-0.1.0/destriper/_fit/custom_regressors/iterative_theta_after_cv_regressor.py +130 -0
- destriper-0.1.0/destriper/_fit/custom_regressors/iterative_theta_regressor.py +102 -0
- destriper-0.1.0/destriper/_fit/custom_regressors/warm_start_wrapper.py +106 -0
- destriper-0.1.0/destriper/_fit/cv.py +621 -0
- destriper-0.1.0/destriper/_fit/cv_splits.py +36 -0
- destriper-0.1.0/destriper/_fit/fit.py +740 -0
- destriper-0.1.0/destriper/_fit/glum_nb_helpers.py +21 -0
- destriper-0.1.0/destriper/_fit/glum_wrapper.py +202 -0
- destriper-0.1.0/destriper/_fit/init.py +140 -0
- destriper-0.1.0/destriper/_fit/iterative_theta.py +90 -0
- destriper-0.1.0/destriper/_fit/penalties.py +27 -0
- destriper-0.1.0/destriper/_fit/sol.py +2 -0
- destriper-0.1.0/destriper/_quantile_matching.py +118 -0
- destriper-0.1.0/destriper/_result.py +110 -0
- destriper-0.1.0/docs/icon/icon.png +0 -0
- destriper-0.1.0/docs/icon/icon.svg +208 -0
- destriper-0.1.0/docs/method_scheme.png +0 -0
- destriper-0.1.0/docs/tutorial.ipynb +866 -0
- destriper-0.1.0/pixi.lock +4101 -0
- destriper-0.1.0/pixi.toml +61 -0
- destriper-0.1.0/pyproject.toml +50 -0
- destriper-0.1.0/tests/test_anndata.py +115 -0
- destriper-0.1.0/tests/test_destriping_glm.py +199 -0
- destriper-0.1.0/tutorial_funs.py +379 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# macOS
|
|
2
|
+
.DS_Store
|
|
3
|
+
|
|
4
|
+
# Python build / cache
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*.egg-info/
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
.pixi/
|
|
14
|
+
|
|
15
|
+
# Results are experiment outputs, not part of the package
|
|
16
|
+
results/
|
|
17
|
+
|
|
18
|
+
# Downloaded tutorial datasets (multi-GB, fetched on demand)
|
|
19
|
+
data/
|
destriper-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paola Malsot
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
destriper-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: destriper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Estimate and correct row/column multiplicative stripe artifacts in high-resolution spatial transcriptomics count data.
|
|
5
|
+
Project-URL: Homepage, https://github.com/paolamalsot/destriper
|
|
6
|
+
Project-URL: Source, https://github.com/paolamalsot/destriper
|
|
7
|
+
Author: Paola Malsot
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: destriping,glm,negative-binomial,spatial-transcriptomics,visium-hd
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: glum==3.1.2
|
|
16
|
+
Requires-Dist: joblib
|
|
17
|
+
Requires-Dist: numpy<2
|
|
18
|
+
Requires-Dist: pandas<3
|
|
19
|
+
Requires-Dist: scikit-learn
|
|
20
|
+
Requires-Dist: scipy<1.15,>=1.9
|
|
21
|
+
Requires-Dist: tabmat==4.2.1
|
|
22
|
+
Requires-Dist: xxhash<4,>=3.6
|
|
23
|
+
Provides-Extra: anndata
|
|
24
|
+
Requires-Dist: anndata; extra == 'anndata'
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: anndata; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
<h1><img src="docs/icon/icon.png" alt="D" width="45" align="bottom">estriper</h1>
|
|
31
|
+
|
|
32
|
+
**Destriper** corrects striping artefacts in
|
|
33
|
+
high-resolution spatial transcriptomics count data (e.g. Visium HD 2 µm bins).
|
|
34
|
+
|
|
35
|
+
Under the hood, the method fits a negative binomial model to each nucleic bin $ij$ total counts $k_{ij}$: $k_{ij} \sim NB(\text{mean} = c_p h_i w_j, \text{dispersion} = \theta)$, where $h_i$, $w_j$ are row/column stripe
|
|
36
|
+
factors, and $c_p$ a per-nucleus concentration [counts/bin]. The fitted stripe factors are then
|
|
37
|
+
applied to **all** bins to produce destriped counts.
|
|
38
|
+
|
|
39
|
+
<p align="center">
|
|
40
|
+
<img src="docs/method_scheme.png" alt="Method overview" width="700">
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
* The underlying mean-variance relationship reads $\text{var} = \mu + \theta\mu^2$.
|
|
44
|
+
* Fitted stripe factors $h_i$ and $w_j$ equal 1 on average.
|
|
45
|
+
|
|
46
|
+
## Cite
|
|
47
|
+
|
|
48
|
+
To learn more about the method, or to cite, use:
|
|
49
|
+
|
|
50
|
+
> Paola Malsot, Malte Londschien, Valentina Boeva, Gunnar Rätsch, Striping artifact removal in VisiumHD data through nuclear counts modeling, *Bioinformatics*, Volume 42, Issue Supplement_1, July 2026, btag306, https://doi.org/10.1093/bioinformatics/btag306
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install destriper # low-level interface
|
|
56
|
+
pip install "destriper[anndata]" # + AnnData interface
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Tutorial
|
|
60
|
+
|
|
61
|
+
See the [tutorial notebook](docs/tutorial.ipynb) for an example on Visium HD mouse brain data.
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
### Low-level interface
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import destriper as ds
|
|
69
|
+
|
|
70
|
+
# nucleus bins only; counts are per-bin TOTAL counts
|
|
71
|
+
result = ds.fit(
|
|
72
|
+
counts, # 1-D int array
|
|
73
|
+
row_indices, # 1-D int array (array-grid row)
|
|
74
|
+
column_indices, # 1-D int array (array-grid col)
|
|
75
|
+
nucl_labels, # 1-D array of nucleus ids (non-null)
|
|
76
|
+
cv="spatial", # "spatial" | "default" | int (KFold) | per-bin group array
|
|
77
|
+
max_iter_theta=5,
|
|
78
|
+
max_iter=100_000,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
result.row_factors # pandas Series h_i
|
|
82
|
+
result.col_factors # pandas Series w_j
|
|
83
|
+
result.nucl_concentration # pandas Series c_p
|
|
84
|
+
result.dispersion # fitted NB theta
|
|
85
|
+
|
|
86
|
+
# apply to ALL bins (nucleus bins -> quantile matching, others -> division)
|
|
87
|
+
corrected_totals = ds.destripe_tot_counts(
|
|
88
|
+
tot_counts, row_indices, column_indices, nucl_labels, result
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# rescale the sparse count matrix to the corrected totals
|
|
92
|
+
corrected_matrix, achieved_totals = ds.rescale(count_matrix, corrected_totals)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`achieved_totals` can differ from `corrected_totals` on **originally-empty bins**:
|
|
96
|
+
a zero row cannot be rescaled to a positive target, so it stays zero. Both are
|
|
97
|
+
returned so the discrepancy is explicit.
|
|
98
|
+
|
|
99
|
+
### Anndata interface
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import destriper as ds
|
|
103
|
+
|
|
104
|
+
result = ds.fit_adata(
|
|
105
|
+
adata,
|
|
106
|
+
nucl_key="nucleus_id",
|
|
107
|
+
count_key="total_counts", # None -> compute from adata.X
|
|
108
|
+
row_key="array_row",
|
|
109
|
+
col_key="array_col",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
ds.destripe_adata(adata, result, source_layer=None, target_layer="destriped")
|
|
113
|
+
# writes adata.layers["destriped"], adata.obs["ds_destripe_factor"], adata.obs["ds_corrected_counts"]
|
|
114
|
+
# and adata.uns["destriper"]["result"]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Fitting uses only bins with a non-null `nucl_key`; unlabelled (cytoplasm) bins
|
|
118
|
+
are ignored during fitting and corrected by division at destriping time.
|
|
119
|
+
|
|
120
|
+
## Notes
|
|
121
|
+
|
|
122
|
+
- `glum==3.1.2` and `tabmat==4.2.1` are pinned exactly — the coordinate-descent
|
|
123
|
+
solver relies on private internals of those versions.
|
|
124
|
+
- This package is derived from the [destriping-GLM](https://github.com/paolamalsot/destriping-glm)
|
|
125
|
+
repo.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<h1><img src="docs/icon/icon.png" alt="D" width="45" align="bottom">estriper</h1>
|
|
2
|
+
|
|
3
|
+
**Destriper** corrects striping artefacts in
|
|
4
|
+
high-resolution spatial transcriptomics count data (e.g. Visium HD 2 µm bins).
|
|
5
|
+
|
|
6
|
+
Under the hood, the method fits a negative binomial model to each nucleic bin $ij$ total counts $k_{ij}$: $k_{ij} \sim NB(\text{mean} = c_p h_i w_j, \text{dispersion} = \theta)$, where $h_i$, $w_j$ are row/column stripe
|
|
7
|
+
factors, and $c_p$ a per-nucleus concentration [counts/bin]. The fitted stripe factors are then
|
|
8
|
+
applied to **all** bins to produce destriped counts.
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<img src="docs/method_scheme.png" alt="Method overview" width="700">
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
* The underlying mean-variance relationship reads $\text{var} = \mu + \theta\mu^2$.
|
|
15
|
+
* Fitted stripe factors $h_i$ and $w_j$ equal 1 on average.
|
|
16
|
+
|
|
17
|
+
## Cite
|
|
18
|
+
|
|
19
|
+
To learn more about the method, or to cite, use:
|
|
20
|
+
|
|
21
|
+
> Paola Malsot, Malte Londschien, Valentina Boeva, Gunnar Rätsch, Striping artifact removal in VisiumHD data through nuclear counts modeling, *Bioinformatics*, Volume 42, Issue Supplement_1, July 2026, btag306, https://doi.org/10.1093/bioinformatics/btag306
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install destriper # low-level interface
|
|
27
|
+
pip install "destriper[anndata]" # + AnnData interface
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Tutorial
|
|
31
|
+
|
|
32
|
+
See the [tutorial notebook](docs/tutorial.ipynb) for an example on Visium HD mouse brain data.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
### Low-level interface
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import destriper as ds
|
|
40
|
+
|
|
41
|
+
# nucleus bins only; counts are per-bin TOTAL counts
|
|
42
|
+
result = ds.fit(
|
|
43
|
+
counts, # 1-D int array
|
|
44
|
+
row_indices, # 1-D int array (array-grid row)
|
|
45
|
+
column_indices, # 1-D int array (array-grid col)
|
|
46
|
+
nucl_labels, # 1-D array of nucleus ids (non-null)
|
|
47
|
+
cv="spatial", # "spatial" | "default" | int (KFold) | per-bin group array
|
|
48
|
+
max_iter_theta=5,
|
|
49
|
+
max_iter=100_000,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
result.row_factors # pandas Series h_i
|
|
53
|
+
result.col_factors # pandas Series w_j
|
|
54
|
+
result.nucl_concentration # pandas Series c_p
|
|
55
|
+
result.dispersion # fitted NB theta
|
|
56
|
+
|
|
57
|
+
# apply to ALL bins (nucleus bins -> quantile matching, others -> division)
|
|
58
|
+
corrected_totals = ds.destripe_tot_counts(
|
|
59
|
+
tot_counts, row_indices, column_indices, nucl_labels, result
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# rescale the sparse count matrix to the corrected totals
|
|
63
|
+
corrected_matrix, achieved_totals = ds.rescale(count_matrix, corrected_totals)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`achieved_totals` can differ from `corrected_totals` on **originally-empty bins**:
|
|
67
|
+
a zero row cannot be rescaled to a positive target, so it stays zero. Both are
|
|
68
|
+
returned so the discrepancy is explicit.
|
|
69
|
+
|
|
70
|
+
### Anndata interface
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import destriper as ds
|
|
74
|
+
|
|
75
|
+
result = ds.fit_adata(
|
|
76
|
+
adata,
|
|
77
|
+
nucl_key="nucleus_id",
|
|
78
|
+
count_key="total_counts", # None -> compute from adata.X
|
|
79
|
+
row_key="array_row",
|
|
80
|
+
col_key="array_col",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
ds.destripe_adata(adata, result, source_layer=None, target_layer="destriped")
|
|
84
|
+
# writes adata.layers["destriped"], adata.obs["ds_destripe_factor"], adata.obs["ds_corrected_counts"]
|
|
85
|
+
# and adata.uns["destriper"]["result"]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Fitting uses only bins with a non-null `nucl_key`; unlabelled (cytoplasm) bins
|
|
89
|
+
are ignored during fitting and corrected by division at destriping time.
|
|
90
|
+
|
|
91
|
+
## Notes
|
|
92
|
+
|
|
93
|
+
- `glum==3.1.2` and `tabmat==4.2.1` are pinned exactly — the coordinate-descent
|
|
94
|
+
solver relies on private internals of those versions.
|
|
95
|
+
- This package is derived from the [destriping-GLM](https://github.com/paolamalsot/destriping-glm)
|
|
96
|
+
repo.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""destriper — estimate and correct row/column multiplicative stripe
|
|
2
|
+
artifacts in high-resolution spatial transcriptomics count data.
|
|
3
|
+
|
|
4
|
+
Numerical core::
|
|
5
|
+
|
|
6
|
+
import destriper as ds
|
|
7
|
+
result = ds.fit(counts, row_indices, column_indices, nucl_labels)
|
|
8
|
+
corrected_totals = ds.destripe_tot_counts(tot_counts, rows, cols, labels, result)
|
|
9
|
+
corrected_matrix, achieved = ds.rescale(count_matrix, corrected_totals)
|
|
10
|
+
|
|
11
|
+
Optional AnnData layer (``pip install destriper[anndata]``)::
|
|
12
|
+
|
|
13
|
+
result = ds.fit_adata(adata, nucl_key="nucleus_id")
|
|
14
|
+
ds.destripe_adata(adata, result, target_layer="destriped")
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from destriper._core import fit
|
|
21
|
+
from destriper._destripe import destripe_tot_counts, rescale
|
|
22
|
+
from destriper._result import FitResult
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from destriper._adata import destripe_adata, fit_adata
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"fit",
|
|
31
|
+
"destripe_tot_counts",
|
|
32
|
+
"rescale",
|
|
33
|
+
"FitResult",
|
|
34
|
+
"fit_adata",
|
|
35
|
+
"destripe_adata",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def __getattr__(name):
|
|
40
|
+
# Lazy AnnData layer: import (and require) anndata only on first use.
|
|
41
|
+
if name in ("fit_adata", "destripe_adata"):
|
|
42
|
+
from destriper import _adata
|
|
43
|
+
|
|
44
|
+
return getattr(_adata, name)
|
|
45
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Optional AnnData convenience layer.
|
|
2
|
+
|
|
3
|
+
Requires ``anndata`` (``pip install destriper[anndata]``). Imported lazily
|
|
4
|
+
by ``destriper.__getattr__`` so the numerical core has no hard dependency
|
|
5
|
+
on anndata.
|
|
6
|
+
"""
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import scipy.sparse as sp
|
|
12
|
+
|
|
13
|
+
from destriper._core import fit as _fit
|
|
14
|
+
from destriper._destripe import (
|
|
15
|
+
destripe_factors,
|
|
16
|
+
destripe_tot_counts as _destripe_tot_counts,
|
|
17
|
+
rescale as _rescale,
|
|
18
|
+
)
|
|
19
|
+
from destriper._result import FitResult
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("destriper")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _require_anndata():
|
|
25
|
+
try:
|
|
26
|
+
import anndata # noqa: F401
|
|
27
|
+
except ImportError as e: # pragma: no cover
|
|
28
|
+
raise ImportError(
|
|
29
|
+
"The AnnData layer requires `anndata`. "
|
|
30
|
+
"Install it with `pip install destriper[anndata]`."
|
|
31
|
+
) from e
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _totals(adata, count_key):
|
|
35
|
+
if count_key is not None:
|
|
36
|
+
return np.asarray(adata.obs[count_key].to_numpy(), dtype=float)
|
|
37
|
+
return np.asarray(adata.X.sum(axis=1)).ravel().astype(float)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fit_adata(
|
|
41
|
+
adata,
|
|
42
|
+
*,
|
|
43
|
+
nucl_key: str = "nucleus_id",
|
|
44
|
+
count_key: str | None = "total_counts",
|
|
45
|
+
row_key: str = "array_row",
|
|
46
|
+
col_key: str = "array_col",
|
|
47
|
+
cv_group_key: str | None = None,
|
|
48
|
+
max_iter_theta: int = 5,
|
|
49
|
+
max_iter: int = 100_000,
|
|
50
|
+
cv="spatial",
|
|
51
|
+
) -> FitResult:
|
|
52
|
+
"""Fit the stripe GLM from an AnnData.
|
|
53
|
+
|
|
54
|
+
Uses only nuclear bins (rows with a non-null ``obs[nucl_key]``); unlabelled
|
|
55
|
+
bins are ignored. ``count_key`` supplies per-bin totals from ``.obs``; if
|
|
56
|
+
``None`` they are computed from ``adata.X``. See :func:`destriper.fit`
|
|
57
|
+
for the other parameters. ``cv_group_key`` names an ``.obs`` column of CV
|
|
58
|
+
group ids (overrides ``cv``).
|
|
59
|
+
"""
|
|
60
|
+
_require_anndata()
|
|
61
|
+
obs = adata.obs
|
|
62
|
+
labels = obs[nucl_key].to_numpy()
|
|
63
|
+
is_nucl = ~pd.isna(labels)
|
|
64
|
+
n_total = len(labels)
|
|
65
|
+
n_nucl = int(is_nucl.sum())
|
|
66
|
+
if n_nucl == 0:
|
|
67
|
+
raise ValueError(f"No nuclear bins: all values in obs[{nucl_key!r}] are null.")
|
|
68
|
+
if n_nucl < n_total:
|
|
69
|
+
logger.info(
|
|
70
|
+
"Fitting on %d nuclear bins (of %d total); %d unlabelled bins ignored.",
|
|
71
|
+
n_nucl,
|
|
72
|
+
n_total,
|
|
73
|
+
n_total - n_nucl,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
counts = _totals(adata, count_key)[is_nucl]
|
|
77
|
+
rows = np.asarray(obs[row_key].to_numpy())[is_nucl]
|
|
78
|
+
cols = np.asarray(obs[col_key].to_numpy())[is_nucl]
|
|
79
|
+
nucl_labels = labels[is_nucl]
|
|
80
|
+
|
|
81
|
+
cv_arg = cv
|
|
82
|
+
if cv_group_key is not None:
|
|
83
|
+
cv_arg = np.asarray(obs[cv_group_key].to_numpy())[is_nucl]
|
|
84
|
+
|
|
85
|
+
return _fit(
|
|
86
|
+
counts,
|
|
87
|
+
rows,
|
|
88
|
+
cols,
|
|
89
|
+
nucl_labels,
|
|
90
|
+
max_iter_theta=max_iter_theta,
|
|
91
|
+
max_iter=max_iter,
|
|
92
|
+
cv=cv_arg,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def destripe_adata(
|
|
97
|
+
adata,
|
|
98
|
+
result: FitResult,
|
|
99
|
+
*,
|
|
100
|
+
source_layer: str | None = None,
|
|
101
|
+
target_layer: str = "destriped",
|
|
102
|
+
nucl_key: str = "nucleus_id",
|
|
103
|
+
row_key: str = "array_row",
|
|
104
|
+
col_key: str = "array_col",
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Apply a fitted :class:`FitResult` to an AnnData in place.
|
|
107
|
+
|
|
108
|
+
Writes:
|
|
109
|
+
|
|
110
|
+
- ``adata.layers[target_layer]`` — the destriped count matrix
|
|
111
|
+
- ``adata.obs["ds_destripe_factor"]`` — the per-bin stripe factor
|
|
112
|
+
- ``adata.obs["ds_corrected_counts"]`` — the target corrected totals from
|
|
113
|
+
:func:`destriper.destripe_tot_counts`. Note this is the *target*, not
|
|
114
|
+
the rescaled-matrix row-sums (``layers[target_layer].sum(1)``); the two
|
|
115
|
+
differ on originally-empty bins (see :func:`destriper.rescale`).
|
|
116
|
+
- ``adata.uns["destriper"]["result"]`` — the serialized fit
|
|
117
|
+
(:meth:`FitResult.to_uns`): ``row_factors``, ``col_factors`` and
|
|
118
|
+
``nucl_concentration`` as one-column DataFrames plus the scalar fit
|
|
119
|
+
parameters. Rebuild it with :meth:`FitResult.from_uns`.
|
|
120
|
+
"""
|
|
121
|
+
_require_anndata()
|
|
122
|
+
obs = adata.obs
|
|
123
|
+
rows = np.asarray(obs[row_key].to_numpy())
|
|
124
|
+
cols = np.asarray(obs[col_key].to_numpy())
|
|
125
|
+
labels = obs[nucl_key].to_numpy()
|
|
126
|
+
|
|
127
|
+
X = adata.X if source_layer is None else adata.layers[source_layer]
|
|
128
|
+
if not sp.issparse(X):
|
|
129
|
+
X = sp.csr_matrix(X)
|
|
130
|
+
|
|
131
|
+
tot = np.asarray(X.sum(axis=1)).ravel().astype(float)
|
|
132
|
+
corrected = _destripe_tot_counts(tot, rows, cols, labels, result)
|
|
133
|
+
X_corr, _achieved = _rescale(X, corrected)
|
|
134
|
+
|
|
135
|
+
adata.layers[target_layer] = X_corr
|
|
136
|
+
adata.obs["ds_destripe_factor"] = destripe_factors(
|
|
137
|
+
result.row_factors, result.col_factors, rows, cols
|
|
138
|
+
)
|
|
139
|
+
adata.obs["ds_corrected_counts"] = corrected
|
|
140
|
+
uns = adata.uns.setdefault("destriper", {})
|
|
141
|
+
uns["result"] = result.to_uns()
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import warnings
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from destriper._df import build_fit_df, strip_id_prefix, warn_large_regions
|
|
9
|
+
from destriper._result import FitResult
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("destriper")
|
|
12
|
+
|
|
13
|
+
_V6_MODEL_ARGS = dict(
|
|
14
|
+
init_method="ones",
|
|
15
|
+
init_method_args={"c_mean": True},
|
|
16
|
+
family="nbinom",
|
|
17
|
+
family_params={"theta": 1.0},
|
|
18
|
+
n_alphas=10,
|
|
19
|
+
alphas=None,
|
|
20
|
+
P2="hw_only",
|
|
21
|
+
P1="hw_only",
|
|
22
|
+
freeze_c=False,
|
|
23
|
+
sklearnCV=True,
|
|
24
|
+
sklearnCV_one_SE_rule=False,
|
|
25
|
+
warm_start_alpha=True,
|
|
26
|
+
fit_theta_iter=True,
|
|
27
|
+
fit_theta_iter_loc="out",
|
|
28
|
+
selection="random",
|
|
29
|
+
solver="coordinate_descent",
|
|
30
|
+
parallel=True,
|
|
31
|
+
reestimate_c_on_val=True,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@contextmanager
|
|
36
|
+
def _quiet_fit_warnings():
|
|
37
|
+
"""Silence a few known-benign warnings from the vendored CV code.
|
|
38
|
+
|
|
39
|
+
The cross-validation ``c`` re-estimation divides by per-group sums that can
|
|
40
|
+
be zero on some folds (result discarded via ``np.where``), and a pandas
|
|
41
|
+
groupby-apply triggers a deprecation warning. Neither affects results; we
|
|
42
|
+
scope the filters by message so genuine warnings still surface.
|
|
43
|
+
"""
|
|
44
|
+
with warnings.catch_warnings():
|
|
45
|
+
warnings.filterwarnings(
|
|
46
|
+
"ignore", message=".*divide by zero encountered.*", category=RuntimeWarning
|
|
47
|
+
)
|
|
48
|
+
warnings.filterwarnings(
|
|
49
|
+
"ignore", message=".*invalid value encountered.*", category=RuntimeWarning
|
|
50
|
+
)
|
|
51
|
+
warnings.filterwarnings(
|
|
52
|
+
"ignore",
|
|
53
|
+
message=".*DataFrameGroupBy.apply operated on the grouping columns.*",
|
|
54
|
+
category=FutureWarning,
|
|
55
|
+
)
|
|
56
|
+
yield
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _groups_to_splits(groups):
|
|
60
|
+
"""Leave-one-group-out CV splits (positional) from a per-bin group array."""
|
|
61
|
+
groups = np.asarray(groups)
|
|
62
|
+
all_pos = np.arange(len(groups))
|
|
63
|
+
splits = [
|
|
64
|
+
(np.setdiff1d(all_pos, test, assume_unique=False), test)
|
|
65
|
+
for g in pd.unique(groups)
|
|
66
|
+
if len((test := all_pos[groups == g])) > 0
|
|
67
|
+
]
|
|
68
|
+
if len(splits) < 2:
|
|
69
|
+
raise ValueError("cv group array must define at least 2 non-empty groups.")
|
|
70
|
+
return splits
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _resolve_cv(cv, n_bins):
|
|
74
|
+
if isinstance(cv, str):
|
|
75
|
+
if cv not in ("spatial", "default"):
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"cv string must be 'spatial' or 'default', got {cv!r}."
|
|
78
|
+
)
|
|
79
|
+
return cv
|
|
80
|
+
if isinstance(cv, (int, np.integer, bool)) and not isinstance(cv, bool):
|
|
81
|
+
n = int(cv)
|
|
82
|
+
if n < 2:
|
|
83
|
+
raise ValueError("integer cv (number of KFold folds) must be >= 2.")
|
|
84
|
+
return n
|
|
85
|
+
# per-bin group-id array -> leave-one-group-out
|
|
86
|
+
groups = np.asarray(cv)
|
|
87
|
+
if groups.ndim != 1 or len(groups) != n_bins:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"cv group array must be 1-D with one entry per bin "
|
|
90
|
+
f"(expected length {n_bins}, got {groups.shape})."
|
|
91
|
+
)
|
|
92
|
+
return _groups_to_splits(groups)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def fit(
|
|
96
|
+
counts,
|
|
97
|
+
row_indices,
|
|
98
|
+
column_indices,
|
|
99
|
+
nucl_labels,
|
|
100
|
+
*,
|
|
101
|
+
max_iter_theta: int = 5,
|
|
102
|
+
max_iter: int = 100_000,
|
|
103
|
+
cv="spatial",
|
|
104
|
+
) -> FitResult:
|
|
105
|
+
"""Fit the row/column stripe GLM on per-bin total counts of nucleus bins.
|
|
106
|
+
|
|
107
|
+
Fits ``k_ij ~ NB(mean = c_p * h_i * w_j)`` (log link) with the tuned v6
|
|
108
|
+
recipe. Only nucleus (labelled) bins are used; the returned factors are
|
|
109
|
+
later applied to all bins via :func:`destripe_tot_counts`.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
counts : 1-D int array
|
|
114
|
+
Per-bin total counts (nucleus bins only).
|
|
115
|
+
row_indices, column_indices : 1-D int arrays
|
|
116
|
+
Array-grid row / column index per bin.
|
|
117
|
+
nucl_labels : 1-D array
|
|
118
|
+
Nucleus id per bin; must be non-null.
|
|
119
|
+
max_iter_theta : int, default 5
|
|
120
|
+
Cap on the dispersion (theta) iteration.
|
|
121
|
+
max_iter : int, default 100000
|
|
122
|
+
Cap on the GLM solver iterations per fit.
|
|
123
|
+
cv : "spatial" | "default" | int | 1-D array, default "spatial"
|
|
124
|
+
Cross-validation folds for regularization selection: ``"spatial"``
|
|
125
|
+
(4 grid quadrants, leave-one-quadrant-out), ``"default"`` (5-fold
|
|
126
|
+
KFold), an int (KFold with that many folds), or a per-bin group-id
|
|
127
|
+
array (leave-one-group-out).
|
|
128
|
+
|
|
129
|
+
Returns
|
|
130
|
+
-------
|
|
131
|
+
FitResult
|
|
132
|
+
"""
|
|
133
|
+
df = build_fit_df(counts, row_indices, column_indices, nucl_labels)
|
|
134
|
+
warn_large_regions(df, threshold=100)
|
|
135
|
+
cv_split_method = _resolve_cv(cv, n_bins=len(df))
|
|
136
|
+
|
|
137
|
+
# Lazy import: pulls in glum/tabmat only when actually fitting.
|
|
138
|
+
from destriper._fit.glum_wrapper import GlumWrapper
|
|
139
|
+
|
|
140
|
+
model = GlumWrapper(
|
|
141
|
+
data_df=df,
|
|
142
|
+
cv_split_method=cv_split_method,
|
|
143
|
+
fit_theta_max_iter=int(max_iter_theta),
|
|
144
|
+
max_iter=int(max_iter),
|
|
145
|
+
**_V6_MODEL_ARGS,
|
|
146
|
+
)
|
|
147
|
+
with _quiet_fit_warnings():
|
|
148
|
+
model.fit()
|
|
149
|
+
|
|
150
|
+
sol = model.sol_
|
|
151
|
+
status = model.status_dict
|
|
152
|
+
|
|
153
|
+
concentration = sol.c.copy()
|
|
154
|
+
concentration.index = pd.Index(
|
|
155
|
+
[strip_id_prefix(x) for x in concentration.index], name="nucleus"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return FitResult(
|
|
159
|
+
row_factors=sol.h,
|
|
160
|
+
col_factors=sol.w,
|
|
161
|
+
nucl_concentration=concentration,
|
|
162
|
+
dispersion=float(model.theta),
|
|
163
|
+
converged=bool(status["converged"]),
|
|
164
|
+
theta_converged=bool(status["theta_iter_converged_"]),
|
|
165
|
+
theta_iterations=int(status["theta_iter_"]),
|
|
166
|
+
alpha=float(model.alpha),
|
|
167
|
+
)
|