destriper 0.1.0__py3-none-any.whl
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/__init__.py +45 -0
- destriper/_adata.py +141 -0
- destriper/_core.py +167 -0
- destriper/_destripe.py +123 -0
- destriper/_df.py +79 -0
- destriper/_fit/__init__.py +0 -0
- destriper/_fit/_pandas_log.py +7 -0
- destriper/_fit/coordinate_descent_glm.py +339 -0
- destriper/_fit/coordinate_descent_solver.py +233 -0
- destriper/_fit/custom_regressors/__init__.py +0 -0
- destriper/_fit/custom_regressors/alternating_theta_cv_regressor.py +204 -0
- destriper/_fit/custom_regressors/cv_regressor.py +84 -0
- destriper/_fit/custom_regressors/helpers.py +60 -0
- destriper/_fit/custom_regressors/iterative_theta_after_cv_regressor.py +130 -0
- destriper/_fit/custom_regressors/iterative_theta_regressor.py +102 -0
- destriper/_fit/custom_regressors/warm_start_wrapper.py +106 -0
- destriper/_fit/cv.py +621 -0
- destriper/_fit/cv_splits.py +36 -0
- destriper/_fit/fit.py +740 -0
- destriper/_fit/glum_nb_helpers.py +21 -0
- destriper/_fit/glum_wrapper.py +202 -0
- destriper/_fit/init.py +140 -0
- destriper/_fit/iterative_theta.py +90 -0
- destriper/_fit/penalties.py +27 -0
- destriper/_fit/sol.py +2 -0
- destriper/_quantile_matching.py +118 -0
- destriper/_result.py +110 -0
- destriper-0.1.0.dist-info/METADATA +125 -0
- destriper-0.1.0.dist-info/RECORD +31 -0
- destriper-0.1.0.dist-info/WHEEL +4 -0
- destriper-0.1.0.dist-info/licenses/LICENSE +21 -0
destriper/__init__.py
ADDED
|
@@ -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}")
|
destriper/_adata.py
ADDED
|
@@ -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()
|
destriper/_core.py
ADDED
|
@@ -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
|
+
)
|
destriper/_destripe.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Total-count destriping arithmetic (numpy / scipy.sparse, no AnnData).
|
|
2
|
+
|
|
3
|
+
Reimplements the correction logic from the source ``spatialAdata`` methods
|
|
4
|
+
(``get_destripe_factors``, ``_n_counts_adjusted_dividing``,
|
|
5
|
+
``_n_counts_adjusted_qm``) and ``destripe_counts`` as plain free functions.
|
|
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._quantile_matching import get_qm_fun
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("destriper")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def destripe_factors(row_factors, col_factors, row_indices, column_indices):
|
|
19
|
+
"""Per-bin multiplicative stripe factor ``f = h[row] * w[col]``.
|
|
20
|
+
|
|
21
|
+
Rows/cols absent from the fitted factors get factor ``1.0`` (left
|
|
22
|
+
unchanged). Mirrors the source ``get_destripe_factors``.
|
|
23
|
+
"""
|
|
24
|
+
row_indices = np.asarray(row_indices)
|
|
25
|
+
column_indices = np.asarray(column_indices)
|
|
26
|
+
row_filled = row_factors.reindex(np.unique(row_indices), fill_value=1.0)
|
|
27
|
+
col_filled = col_factors.reindex(np.unique(column_indices), fill_value=1.0)
|
|
28
|
+
return row_filled.loc[row_indices].values * col_filled.loc[column_indices].values
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _adjust_dividing(k, f):
|
|
32
|
+
"""Cytoplasm bins: ``adj = k / f`` with the source's guards."""
|
|
33
|
+
k = np.asarray(k, dtype=float)
|
|
34
|
+
f = np.asarray(f, dtype=float)
|
|
35
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
36
|
+
adj = k / f
|
|
37
|
+
inf_sel = np.isinf(adj) # f == 0 and k > 0 -> keep original count
|
|
38
|
+
adj[inf_sel] = k[inf_sel]
|
|
39
|
+
zero_zero = (k == 0) & (f == 0) # 0 / 0 -> 0
|
|
40
|
+
adj[zero_zero] = 0.0
|
|
41
|
+
return adj
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def destripe_tot_counts(tot_counts, row_indices, column_indices, nucl_labels, result):
|
|
45
|
+
"""Compute per-bin destriped (target) total counts for *all* bins.
|
|
46
|
+
|
|
47
|
+
Nucleus bins — those whose label is in ``result.nucl_concentration`` — are
|
|
48
|
+
corrected by negative-binomial quantile matching against ``mu = c_p`` using
|
|
49
|
+
the fitted dispersion. Every other bin (label unseen in the fit, or null =
|
|
50
|
+
cytoplasm) is corrected by plain division ``k / f``. Labels not seen in the
|
|
51
|
+
fit are logged and treated as non-nuclei.
|
|
52
|
+
"""
|
|
53
|
+
k = np.asarray(tot_counts, dtype=float)
|
|
54
|
+
n = len(k)
|
|
55
|
+
row_indices = np.asarray(row_indices)
|
|
56
|
+
column_indices = np.asarray(column_indices)
|
|
57
|
+
labels = np.asarray(nucl_labels, dtype=object)
|
|
58
|
+
if not (len(row_indices) == len(column_indices) == len(labels) == n):
|
|
59
|
+
raise ValueError(
|
|
60
|
+
"tot_counts, row_indices, column_indices, nucl_labels must have equal length."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
f = destripe_factors(
|
|
64
|
+
result.row_factors, result.col_factors, row_indices, column_indices
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Canonical string labels (None for null); route by membership in fitted nuclei.
|
|
68
|
+
c = result.nucl_concentration
|
|
69
|
+
is_null = pd.isna(labels)
|
|
70
|
+
canon = np.array(
|
|
71
|
+
[None if is_null[i] else str(labels[i]) for i in range(n)], dtype=object
|
|
72
|
+
)
|
|
73
|
+
mu = pd.Series(canon).map(c) # c_p for nucleus bins, NaN otherwise
|
|
74
|
+
is_nucleus = mu.notna().values
|
|
75
|
+
|
|
76
|
+
unseen = sorted(
|
|
77
|
+
{lab for lab, nn in zip(canon, ~is_null) if nn} - set(c.index)
|
|
78
|
+
)
|
|
79
|
+
if unseen:
|
|
80
|
+
shown = ", ".join(repr(u) for u in unseen[:20])
|
|
81
|
+
more = "" if len(unseen) <= 20 else f", ... (+{len(unseen) - 20} more)"
|
|
82
|
+
logger.info(
|
|
83
|
+
"Considering %d label(s) not seen in the fit as non-nuclei (cytoplasm): %s%s",
|
|
84
|
+
len(unseen),
|
|
85
|
+
shown,
|
|
86
|
+
more,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
adj = np.empty(n, dtype=float)
|
|
90
|
+
cyto = ~is_nucleus
|
|
91
|
+
adj[cyto] = _adjust_dividing(k[cyto], f[cyto])
|
|
92
|
+
if is_nucleus.any():
|
|
93
|
+
qm = get_qm_fun("nbinom", dist_params={"r": float(result.dispersion)})
|
|
94
|
+
adj[is_nucleus] = qm(
|
|
95
|
+
k[is_nucleus], mu.values[is_nucleus].astype(float), f[is_nucleus]
|
|
96
|
+
)
|
|
97
|
+
return adj
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def rescale(count_matrix, corrected_tot_counts):
|
|
101
|
+
"""Row-scale a sparse count matrix so each bin sums to its corrected total.
|
|
102
|
+
|
|
103
|
+
Returns ``(corrected_matrix, achieved_tot_counts)``. Originally-empty rows
|
|
104
|
+
stay empty (no scalar can make a zero row sum to a positive target), so
|
|
105
|
+
``achieved_tot_counts`` differs from ``corrected_tot_counts`` exactly on
|
|
106
|
+
those rows. Mirrors the source ``destripe_counts``.
|
|
107
|
+
"""
|
|
108
|
+
if not sp.issparse(count_matrix):
|
|
109
|
+
raise TypeError("count_matrix must be a scipy.sparse matrix.")
|
|
110
|
+
X = count_matrix.tocsr().astype(float)
|
|
111
|
+
corrected = np.asarray(corrected_tot_counts, dtype=float)
|
|
112
|
+
if X.shape[0] != len(corrected):
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"count_matrix has {X.shape[0]} rows but corrected_tot_counts has "
|
|
115
|
+
f"length {len(corrected)}."
|
|
116
|
+
)
|
|
117
|
+
original = np.asarray(X.sum(axis=1)).ravel()
|
|
118
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
119
|
+
scale = corrected / original
|
|
120
|
+
scale[original == 0] = 1.0 # leave empty rows unscaled (they stay zero)
|
|
121
|
+
X_out = sp.diags(scale).dot(X)
|
|
122
|
+
achieved = np.asarray(X_out.sum(axis=1)).ravel()
|
|
123
|
+
return X_out, achieved
|
destriper/_df.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger("destriper")
|
|
7
|
+
|
|
8
|
+
_ID_PREFIX = "id_"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _as_1d(name, arr, n=None):
|
|
12
|
+
arr = np.asarray(arr)
|
|
13
|
+
if arr.ndim != 1:
|
|
14
|
+
raise ValueError(f"{name} must be 1-D, got shape {arr.shape}.")
|
|
15
|
+
if n is not None and len(arr) != n:
|
|
16
|
+
raise ValueError(
|
|
17
|
+
f"{name} has length {len(arr)} but expected {n} (same as counts)."
|
|
18
|
+
)
|
|
19
|
+
return arr
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_fit_df(counts, row_indices, column_indices, nucl_labels):
|
|
23
|
+
"""Assemble ``df[["i", "j", "p", "k"]]`` for :func:`fit_GLM_glum`.
|
|
24
|
+
|
|
25
|
+
- ``i`` = ``row_indices`` cast to int
|
|
26
|
+
- ``j`` = ``column_indices`` cast to int
|
|
27
|
+
- ``p`` = ``"id_" + str(label)`` as an ``object`` column (never NaN)
|
|
28
|
+
- ``k`` = ``counts`` cast to int
|
|
29
|
+
|
|
30
|
+
Every bin is assumed nuclear; a null label raises (pass only nuclear bins).
|
|
31
|
+
"""
|
|
32
|
+
counts = _as_1d("counts", counts)
|
|
33
|
+
n = len(counts)
|
|
34
|
+
row_indices = _as_1d("row_indices", row_indices, n)
|
|
35
|
+
column_indices = _as_1d("column_indices", column_indices, n)
|
|
36
|
+
nucl_labels = _as_1d("nucl_labels", np.asarray(nucl_labels, dtype=object), n)
|
|
37
|
+
|
|
38
|
+
if pd.isna(list(nucl_labels)).any():
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"nucl_labels must not contain NaN/None. fit() expects only nuclear "
|
|
41
|
+
"bins; filter unassigned (cytoplasm) bins out before fitting."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
df = pd.DataFrame(
|
|
45
|
+
{
|
|
46
|
+
"i": row_indices.astype(int),
|
|
47
|
+
"j": column_indices.astype(int),
|
|
48
|
+
"p": np.array([_ID_PREFIX + str(x) for x in nucl_labels], dtype=object),
|
|
49
|
+
"k": counts.astype(int),
|
|
50
|
+
}
|
|
51
|
+
)[["i", "j", "p", "k"]]
|
|
52
|
+
return df
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def warn_large_regions(df, threshold=100):
|
|
56
|
+
"""Warn about nucleus labels with more than ``threshold`` bins.
|
|
57
|
+
|
|
58
|
+
At 2 um bin resolution a nucleus spanning >100 bins usually signals a
|
|
59
|
+
segmentation merge error.
|
|
60
|
+
"""
|
|
61
|
+
sizes = df.groupby("p", observed=True).size()
|
|
62
|
+
big = sizes[sizes > threshold]
|
|
63
|
+
if len(big) > 0:
|
|
64
|
+
labels = [strip_id_prefix(p) for p in big.index[:20].tolist()]
|
|
65
|
+
more = "" if len(big) <= 20 else f" (and {len(big) - 20} more)"
|
|
66
|
+
logger.warning(
|
|
67
|
+
"%d nucleus label(s) have more than %d bins%s: %s; "
|
|
68
|
+
"this often indicates a segmentation merge error.",
|
|
69
|
+
len(big),
|
|
70
|
+
threshold,
|
|
71
|
+
more,
|
|
72
|
+
", ".join(f"{lab!r} ({int(sizes.loc[_ID_PREFIX + str(lab)])})" for lab in labels),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def strip_id_prefix(p):
|
|
77
|
+
"""Inverse of the ``"id_"`` prefixing applied in :func:`build_fit_df`."""
|
|
78
|
+
s = str(p)
|
|
79
|
+
return s[len(_ID_PREFIX):] if s.startswith(_ID_PREFIX) else s
|
|
File without changes
|