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.
Files changed (41) hide show
  1. destriper-0.1.0/.gitignore +19 -0
  2. destriper-0.1.0/LICENSE +21 -0
  3. destriper-0.1.0/PKG-INFO +125 -0
  4. destriper-0.1.0/README.md +96 -0
  5. destriper-0.1.0/destriper/__init__.py +45 -0
  6. destriper-0.1.0/destriper/_adata.py +141 -0
  7. destriper-0.1.0/destriper/_core.py +167 -0
  8. destriper-0.1.0/destriper/_destripe.py +123 -0
  9. destriper-0.1.0/destriper/_df.py +79 -0
  10. destriper-0.1.0/destriper/_fit/__init__.py +0 -0
  11. destriper-0.1.0/destriper/_fit/_pandas_log.py +7 -0
  12. destriper-0.1.0/destriper/_fit/coordinate_descent_glm.py +339 -0
  13. destriper-0.1.0/destriper/_fit/coordinate_descent_solver.py +233 -0
  14. destriper-0.1.0/destriper/_fit/custom_regressors/__init__.py +0 -0
  15. destriper-0.1.0/destriper/_fit/custom_regressors/alternating_theta_cv_regressor.py +204 -0
  16. destriper-0.1.0/destriper/_fit/custom_regressors/cv_regressor.py +84 -0
  17. destriper-0.1.0/destriper/_fit/custom_regressors/helpers.py +60 -0
  18. destriper-0.1.0/destriper/_fit/custom_regressors/iterative_theta_after_cv_regressor.py +130 -0
  19. destriper-0.1.0/destriper/_fit/custom_regressors/iterative_theta_regressor.py +102 -0
  20. destriper-0.1.0/destriper/_fit/custom_regressors/warm_start_wrapper.py +106 -0
  21. destriper-0.1.0/destriper/_fit/cv.py +621 -0
  22. destriper-0.1.0/destriper/_fit/cv_splits.py +36 -0
  23. destriper-0.1.0/destriper/_fit/fit.py +740 -0
  24. destriper-0.1.0/destriper/_fit/glum_nb_helpers.py +21 -0
  25. destriper-0.1.0/destriper/_fit/glum_wrapper.py +202 -0
  26. destriper-0.1.0/destriper/_fit/init.py +140 -0
  27. destriper-0.1.0/destriper/_fit/iterative_theta.py +90 -0
  28. destriper-0.1.0/destriper/_fit/penalties.py +27 -0
  29. destriper-0.1.0/destriper/_fit/sol.py +2 -0
  30. destriper-0.1.0/destriper/_quantile_matching.py +118 -0
  31. destriper-0.1.0/destriper/_result.py +110 -0
  32. destriper-0.1.0/docs/icon/icon.png +0 -0
  33. destriper-0.1.0/docs/icon/icon.svg +208 -0
  34. destriper-0.1.0/docs/method_scheme.png +0 -0
  35. destriper-0.1.0/docs/tutorial.ipynb +866 -0
  36. destriper-0.1.0/pixi.lock +4101 -0
  37. destriper-0.1.0/pixi.toml +61 -0
  38. destriper-0.1.0/pyproject.toml +50 -0
  39. destriper-0.1.0/tests/test_anndata.py +115 -0
  40. destriper-0.1.0/tests/test_destriping_glm.py +199 -0
  41. 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/
@@ -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.
@@ -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
+ )