pyscdblfinder 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.
- pyscdblfinder/__init__.py +52 -0
- pyscdblfinder/artificial.py +240 -0
- pyscdblfinder/classifier.py +171 -0
- pyscdblfinder/core.py +170 -0
- pyscdblfinder/cxds.py +96 -0
- pyscdblfinder/knn_features.py +150 -0
- pyscdblfinder/preprocessing.py +77 -0
- pyscdblfinder/scdblfinder.py +120 -0
- pyscdblfinder/thresholding.py +84 -0
- pyscdblfinder-0.1.0.dist-info/METADATA +174 -0
- pyscdblfinder-0.1.0.dist-info/RECORD +14 -0
- pyscdblfinder-0.1.0.dist-info/WHEEL +5 -0
- pyscdblfinder-0.1.0.dist-info/licenses/LICENSE +7 -0
- pyscdblfinder-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pyscdblfinder: Pure-Python port of R scDblFinder (Germain et al., 2022).
|
|
3
|
+
|
|
4
|
+
scDblFinder detects doublets in single-cell RNA-seq data via a two-stage
|
|
5
|
+
approach: synthesize artificial doublets by combining pairs of real cells,
|
|
6
|
+
train a gradient-boosted classifier on the real-vs-artificial task, and
|
|
7
|
+
predict per-cell doublet probability. This Python port mirrors the
|
|
8
|
+
single-sample path of the R package.
|
|
9
|
+
|
|
10
|
+
Quick-start
|
|
11
|
+
-----------
|
|
12
|
+
>>> from pyscdblfinder import ScDblFinder
|
|
13
|
+
>>> sdf = ScDblFinder(adata)
|
|
14
|
+
>>> sdf.run(dbr=0.07)
|
|
15
|
+
>>> adata.obs["scDblFinder_score"], adata.obs["scDblFinder_class"]
|
|
16
|
+
|
|
17
|
+
Low-level functional API
|
|
18
|
+
------------------------
|
|
19
|
+
>>> from pyscdblfinder import sc_dbl_finder
|
|
20
|
+
>>> result = sc_dbl_finder(counts_genes_by_cells, clusters=None, dbr=0.07)
|
|
21
|
+
>>> result.table.head() # per-cell scores + features
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from .artificial import get_artificial_doublets
|
|
26
|
+
from .classifier import scDbl_score
|
|
27
|
+
from .core import ScDblFinderResult, sc_dbl_finder
|
|
28
|
+
from .cxds import cxds_score
|
|
29
|
+
from .knn_features import default_k_grid, evaluate_knn
|
|
30
|
+
from .preprocessing import default_processing, log_normalize, select_features
|
|
31
|
+
from .scdblfinder import ScDblFinder
|
|
32
|
+
from .thresholding import doublet_thresholding
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# class API
|
|
38
|
+
"ScDblFinder",
|
|
39
|
+
# main functional entry
|
|
40
|
+
"sc_dbl_finder",
|
|
41
|
+
"ScDblFinderResult",
|
|
42
|
+
# building blocks
|
|
43
|
+
"get_artificial_doublets",
|
|
44
|
+
"cxds_score",
|
|
45
|
+
"evaluate_knn",
|
|
46
|
+
"default_k_grid",
|
|
47
|
+
"scDbl_score",
|
|
48
|
+
"doublet_thresholding",
|
|
49
|
+
"default_processing",
|
|
50
|
+
"log_normalize",
|
|
51
|
+
"select_features",
|
|
52
|
+
]
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Artificial-doublet synthesis — port of ``getArtificialDoublets``.
|
|
2
|
+
|
|
3
|
+
Mirrors the R function's pair-selection logic (random vs cluster-aware)
|
|
4
|
+
and the ``createDoublets`` helper's size adjustments (halfSize, resamp,
|
|
5
|
+
adjustSize). Exposes a single ``get_artificial_doublets`` function that
|
|
6
|
+
returns a dict with ``counts`` (genes × n_doublets) and ``origins``
|
|
7
|
+
(labelled "clusterA+clusterB" per doublet, or None when no clusters).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import scipy.sparse as sp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _col_sums(x) -> np.ndarray:
|
|
18
|
+
if sp.issparse(x):
|
|
19
|
+
return np.asarray(x.sum(axis=0)).ravel()
|
|
20
|
+
return np.asarray(x).sum(axis=0)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _trim_by_lsize(ls: np.ndarray, trim_q: tuple[float, float]) -> np.ndarray:
|
|
24
|
+
"""Keep columns whose library size is in the [q_low, q_high] range and > 0."""
|
|
25
|
+
if ls.size == 0:
|
|
26
|
+
return np.array([], dtype=int)
|
|
27
|
+
qlo = float(np.quantile(ls, min(trim_q)))
|
|
28
|
+
qhi = float(np.quantile(ls, max(trim_q)))
|
|
29
|
+
return np.where((ls > 0) & (ls >= qlo) & (ls <= qhi))[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _sample_pairs(n_cells: int, n_doublets: int, rng: np.random.Generator) -> np.ndarray:
|
|
33
|
+
"""Sample ``n_doublets`` cell index pairs (with replacement), removing self-self."""
|
|
34
|
+
if n_cells <= 1:
|
|
35
|
+
raise ValueError("need at least 2 cells to sample doublet pairs")
|
|
36
|
+
# Oversample 2x then drop self-matches so the final count is close to ``n_doublets``
|
|
37
|
+
target = n_doublets * 2
|
|
38
|
+
pairs = rng.integers(0, n_cells, size=(target, 2))
|
|
39
|
+
keep = pairs[:, 0] != pairs[:, 1]
|
|
40
|
+
pairs = pairs[keep]
|
|
41
|
+
if pairs.shape[0] > n_doublets:
|
|
42
|
+
pairs = pairs[:n_doublets]
|
|
43
|
+
while pairs.shape[0] < n_doublets:
|
|
44
|
+
more = rng.integers(0, n_cells, size=(n_doublets - pairs.shape[0], 2))
|
|
45
|
+
more = more[more[:, 0] != more[:, 1]]
|
|
46
|
+
pairs = np.vstack([pairs, more])
|
|
47
|
+
return pairs[:n_doublets]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _create_doublets(
|
|
51
|
+
counts: np.ndarray | sp.spmatrix,
|
|
52
|
+
pairs: np.ndarray,
|
|
53
|
+
*,
|
|
54
|
+
clusters: Optional[np.ndarray] = None,
|
|
55
|
+
resamp: float = 0.25,
|
|
56
|
+
half_size: float = 0.25,
|
|
57
|
+
adjust_size: float = 0.0,
|
|
58
|
+
rng: np.random.Generator,
|
|
59
|
+
) -> np.ndarray:
|
|
60
|
+
"""Port of R ``createDoublets``.
|
|
61
|
+
|
|
62
|
+
- ``adjust_size`` fraction is size-adjusted using cluster median library
|
|
63
|
+
size ratios (requires ``clusters``).
|
|
64
|
+
- ``half_size`` fraction has its library size halved.
|
|
65
|
+
- ``resamp`` fraction is re-sampled from a Poisson around the summed
|
|
66
|
+
counts (the other fraction is just rounded).
|
|
67
|
+
Returns a dense ``(n_genes, n_doublets)`` float array.
|
|
68
|
+
"""
|
|
69
|
+
counts = counts.toarray() if sp.issparse(counts) else np.asarray(counts, dtype=np.float64)
|
|
70
|
+
n_d = pairs.shape[0]
|
|
71
|
+
|
|
72
|
+
if adjust_size > 0 and clusters is not None:
|
|
73
|
+
n_adj = int(round(adjust_size * n_d))
|
|
74
|
+
idx_adj = rng.choice(n_d, size=n_adj, replace=False)
|
|
75
|
+
else:
|
|
76
|
+
n_adj = 0
|
|
77
|
+
idx_adj = np.array([], dtype=int)
|
|
78
|
+
idx_nonadj = np.setdiff1d(np.arange(n_d), idx_adj, assume_unique=False)
|
|
79
|
+
|
|
80
|
+
# Non-adjusted doublets: simple sum
|
|
81
|
+
x1 = counts[:, pairs[idx_nonadj, 0]] + counts[:, pairs[idx_nonadj, 1]]
|
|
82
|
+
|
|
83
|
+
if n_adj > 1 and clusters is not None:
|
|
84
|
+
ls = counts.sum(axis=0)
|
|
85
|
+
# cluster median library sizes
|
|
86
|
+
uniq = np.unique(clusters)
|
|
87
|
+
csz = {u: np.median(ls[clusters == u]) for u in uniq}
|
|
88
|
+
pair_adj = pairs[idx_adj]
|
|
89
|
+
ls1 = ls[pair_adj[:, 0]]
|
|
90
|
+
ls2 = ls[pair_adj[:, 1]]
|
|
91
|
+
ls_ratio = ls1 / (ls1 + ls2 + 1e-12)
|
|
92
|
+
c1 = clusters[pair_adj[:, 0]]
|
|
93
|
+
c2 = clusters[pair_adj[:, 1]]
|
|
94
|
+
csz1 = np.array([csz[c] for c in c1])
|
|
95
|
+
csz2 = np.array([csz[c] for c in c2])
|
|
96
|
+
factor = (ls_ratio + csz1 / (csz1 + csz2 + 1e-12)) / 2
|
|
97
|
+
factor = np.clip(factor, 0.2, 0.8)
|
|
98
|
+
target_ls = ls1 + ls2
|
|
99
|
+
x2 = counts[:, pair_adj[:, 0]] * factor + counts[:, pair_adj[:, 1]] * (1 - factor)
|
|
100
|
+
# Renormalize to the target library size
|
|
101
|
+
cur_ls = x2.sum(axis=0) + 1e-12
|
|
102
|
+
x2 = x2 * (target_ls / cur_ls)[None, :]
|
|
103
|
+
out = np.concatenate([x1, x2], axis=1)
|
|
104
|
+
else:
|
|
105
|
+
out = x1
|
|
106
|
+
|
|
107
|
+
# Half-size a fraction
|
|
108
|
+
if half_size > 0 and out.shape[1] > 0:
|
|
109
|
+
n_h = int(np.ceil(half_size * out.shape[1]))
|
|
110
|
+
h_idx = rng.choice(out.shape[1], size=n_h, replace=False)
|
|
111
|
+
out[:, h_idx] = out[:, h_idx] / 2.0
|
|
112
|
+
|
|
113
|
+
# Resample (Poisson) a fraction, else round
|
|
114
|
+
if resamp > 0 and out.shape[1] > 0:
|
|
115
|
+
n_r = int(np.ceil(resamp * out.shape[1]))
|
|
116
|
+
r_idx = rng.choice(out.shape[1], size=n_r, replace=False)
|
|
117
|
+
out[:, r_idx] = rng.poisson(np.clip(out[:, r_idx], 0, None)).astype(np.float64)
|
|
118
|
+
# Round the rest
|
|
119
|
+
rest = np.setdiff1d(np.arange(out.shape[1]), r_idx, assume_unique=False)
|
|
120
|
+
out[:, rest] = np.round(out[:, rest])
|
|
121
|
+
else:
|
|
122
|
+
out = np.round(out)
|
|
123
|
+
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_artificial_doublets(
|
|
128
|
+
counts: np.ndarray | sp.spmatrix,
|
|
129
|
+
n: int = 3000,
|
|
130
|
+
*,
|
|
131
|
+
clusters: Optional[np.ndarray] = None,
|
|
132
|
+
prop_random: float = 0.1,
|
|
133
|
+
resamp: float = 0.25,
|
|
134
|
+
half_size: float = 0.25,
|
|
135
|
+
adjust_size: float = 0.25,
|
|
136
|
+
trim_q: tuple[float, float] = (0.05, 0.95),
|
|
137
|
+
rng: Optional[np.random.Generator] = None,
|
|
138
|
+
seed: Optional[int] = None,
|
|
139
|
+
) -> dict:
|
|
140
|
+
"""Generate ``n`` artificial doublets from the ``counts`` matrix.
|
|
141
|
+
|
|
142
|
+
Parameters mirror the R ``getArtificialDoublets`` one-to-one. The
|
|
143
|
+
returned dict has two keys:
|
|
144
|
+
|
|
145
|
+
"counts" — dense float ``(n_genes, n_created)`` array (may differ
|
|
146
|
+
from ``n`` by a few due to self-self filtering).
|
|
147
|
+
"origins" — 1-D array of cluster-pair labels (str) or ``None``.
|
|
148
|
+
|
|
149
|
+
``counts`` is expected in **genes × cells** orientation (Seurat style).
|
|
150
|
+
"""
|
|
151
|
+
if rng is None:
|
|
152
|
+
rng = np.random.default_rng(seed)
|
|
153
|
+
|
|
154
|
+
n_genes, n_cells = counts.shape
|
|
155
|
+
ls = _col_sums(counts)
|
|
156
|
+
if clusters is None:
|
|
157
|
+
keep = _trim_by_lsize(ls, trim_q)
|
|
158
|
+
clusters_kept = None
|
|
159
|
+
else:
|
|
160
|
+
clusters = np.asarray(clusters)
|
|
161
|
+
keep_masks = []
|
|
162
|
+
for c in np.unique(clusters):
|
|
163
|
+
idx = np.where(clusters == c)[0]
|
|
164
|
+
ls_c = ls[idx]
|
|
165
|
+
if idx.size < 10:
|
|
166
|
+
keep_masks.append(idx[ls_c > 0])
|
|
167
|
+
continue
|
|
168
|
+
qlo, qhi = np.quantile(ls_c, sorted(trim_q))
|
|
169
|
+
keep_masks.append(idx[(ls_c > 0) & (ls_c >= qlo) & (ls_c <= qhi)])
|
|
170
|
+
keep = np.concatenate(keep_masks) if keep_masks else np.array([], dtype=int)
|
|
171
|
+
keep = np.sort(keep)
|
|
172
|
+
clusters_kept = clusters[keep]
|
|
173
|
+
|
|
174
|
+
x = counts[:, keep].toarray() if sp.issparse(counts) else np.asarray(counts)[:, keep]
|
|
175
|
+
n_usable = x.shape[1]
|
|
176
|
+
|
|
177
|
+
if clusters_kept is None or prop_random >= 1.0:
|
|
178
|
+
pairs = _sample_pairs(n_usable, n, rng)
|
|
179
|
+
ad = _create_doublets(x, pairs, clusters=None,
|
|
180
|
+
resamp=resamp, half_size=half_size,
|
|
181
|
+
adjust_size=0.0, rng=rng)
|
|
182
|
+
origins = np.array([None] * ad.shape[1], dtype=object)
|
|
183
|
+
return {"counts": ad, "origins": origins}
|
|
184
|
+
|
|
185
|
+
# Mixed: some random, rest cluster-paired
|
|
186
|
+
n_random = int(np.ceil(n * prop_random))
|
|
187
|
+
n_cluster = n - n_random
|
|
188
|
+
|
|
189
|
+
out_counts = []
|
|
190
|
+
out_origins = []
|
|
191
|
+
|
|
192
|
+
if n_random > 0:
|
|
193
|
+
pairs_r = _sample_pairs(n_usable, n_random, rng)
|
|
194
|
+
ad_r = _create_doublets(x, pairs_r, clusters=clusters_kept,
|
|
195
|
+
resamp=resamp, half_size=half_size,
|
|
196
|
+
adjust_size=adjust_size, rng=rng)
|
|
197
|
+
# Origins from the random pairs
|
|
198
|
+
c1 = clusters_kept[pairs_r[:, 0]]
|
|
199
|
+
c2 = clusters_kept[pairs_r[:, 1]]
|
|
200
|
+
oc = np.array([f"{a}+{b}" if a != b else None for a, b in zip(c1, c2)], dtype=object)
|
|
201
|
+
# _create_doublets may return more columns than ad_r has per-pair (due to adjust_size split);
|
|
202
|
+
# pad origins to the actual length
|
|
203
|
+
out_counts.append(ad_r)
|
|
204
|
+
if ad_r.shape[1] == oc.size:
|
|
205
|
+
out_origins.append(oc)
|
|
206
|
+
else:
|
|
207
|
+
out_origins.append(np.array([None] * ad_r.shape[1], dtype=object))
|
|
208
|
+
|
|
209
|
+
if n_cluster > 0:
|
|
210
|
+
# inter-cluster pairs, weighted "proportional" to cluster sizes
|
|
211
|
+
cl_counts = np.bincount(np.unique(clusters_kept, return_inverse=True)[1])
|
|
212
|
+
weights = cl_counts / cl_counts.sum()
|
|
213
|
+
uniq = np.unique(clusters_kept)
|
|
214
|
+
# Sample pairs of distinct clusters weighted by proportion^2
|
|
215
|
+
cpairs = []
|
|
216
|
+
for _ in range(n_cluster):
|
|
217
|
+
a, b = rng.choice(uniq.size, size=2, replace=False, p=weights)
|
|
218
|
+
cpairs.append((uniq[a], uniq[b]))
|
|
219
|
+
pair_idx = []
|
|
220
|
+
origins = []
|
|
221
|
+
for ca, cb in cpairs:
|
|
222
|
+
ia = np.random.default_rng(rng.integers(0, 2**31)).choice(
|
|
223
|
+
np.where(clusters_kept == ca)[0]
|
|
224
|
+
)
|
|
225
|
+
ib = np.random.default_rng(rng.integers(0, 2**31)).choice(
|
|
226
|
+
np.where(clusters_kept == cb)[0]
|
|
227
|
+
)
|
|
228
|
+
pair_idx.append([ia, ib])
|
|
229
|
+
origins.append(f"{ca}+{cb}")
|
|
230
|
+
pair_idx = np.asarray(pair_idx)
|
|
231
|
+
ad_c = _create_doublets(x, pair_idx, clusters=clusters_kept,
|
|
232
|
+
resamp=resamp, half_size=half_size,
|
|
233
|
+
adjust_size=adjust_size, rng=rng)
|
|
234
|
+
out_counts.append(ad_c)
|
|
235
|
+
out_origins.append(np.asarray(origins, dtype=object) if ad_c.shape[1] == len(origins)
|
|
236
|
+
else np.array([None] * ad_c.shape[1], dtype=object))
|
|
237
|
+
|
|
238
|
+
combined = np.concatenate(out_counts, axis=1) if out_counts else np.zeros((n_genes, 0))
|
|
239
|
+
origins = np.concatenate(out_origins) if out_origins else np.array([], dtype=object)
|
|
240
|
+
return {"counts": combined, "origins": origins}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""xgboost training loop + iterative doublet scoring.
|
|
2
|
+
|
|
3
|
+
Mirrors R's ``.scDblscore`` with ``scoreType='xgb'``: train a gradient-
|
|
4
|
+
boosted binary classifier (real vs artificial doublet), predict scores
|
|
5
|
+
for all cells, iteratively remove likely-doublet real cells from the
|
|
6
|
+
training set, and retrain. Returns final per-cell score.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_EXCLUDE_COLS = {
|
|
17
|
+
"mostLikelyOrigin", "originAmbiguous", "distanceToNearestDoublet",
|
|
18
|
+
"type", "src", "distanceToNearest", "class", "nearestClass",
|
|
19
|
+
"cluster", "sample", "expected", "include.in.training", "observed",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _default_features(df: pd.DataFrame) -> list[str]:
|
|
24
|
+
return [c for c in df.columns if c not in DEFAULT_EXCLUDE_COLS]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _xgb_train(
|
|
28
|
+
X: np.ndarray,
|
|
29
|
+
y: np.ndarray,
|
|
30
|
+
*,
|
|
31
|
+
nrounds: float | int = 0.25,
|
|
32
|
+
max_depth: int = 4,
|
|
33
|
+
eta: float = 0.3,
|
|
34
|
+
metric: str = "logloss",
|
|
35
|
+
subsample: float = 0.75,
|
|
36
|
+
nfold: int = 5,
|
|
37
|
+
nthreads: int = 1,
|
|
38
|
+
random_state: int = 0,
|
|
39
|
+
):
|
|
40
|
+
"""Train the binary xgboost classifier.
|
|
41
|
+
|
|
42
|
+
If ``nrounds <= 1``, use 5-fold CV to pick a round count, then subtract
|
|
43
|
+
``nrounds * sd(CV error)`` from the best round (matches R behavior).
|
|
44
|
+
Otherwise use ``nrounds`` directly.
|
|
45
|
+
"""
|
|
46
|
+
import xgboost as xgb
|
|
47
|
+
|
|
48
|
+
params = {
|
|
49
|
+
"objective": "binary:logistic",
|
|
50
|
+
"eval_metric": metric,
|
|
51
|
+
"max_depth": int(max_depth),
|
|
52
|
+
"learning_rate": float(eta),
|
|
53
|
+
"subsample": float(subsample),
|
|
54
|
+
"tree_method": "exact",
|
|
55
|
+
"nthread": int(nthreads),
|
|
56
|
+
"verbosity": 0,
|
|
57
|
+
"seed": int(random_state),
|
|
58
|
+
}
|
|
59
|
+
dtrain = xgb.DMatrix(X, label=y.astype(np.float32))
|
|
60
|
+
|
|
61
|
+
if nrounds is None or float(nrounds) <= 1.0:
|
|
62
|
+
cv = xgb.cv(
|
|
63
|
+
params, dtrain, num_boost_round=100, nfold=min(nfold, max(3, X.shape[0] // 10)),
|
|
64
|
+
early_stopping_rounds=10, seed=int(random_state), verbose_eval=False,
|
|
65
|
+
)
|
|
66
|
+
err_col = next(c for c in cv.columns if c.endswith("-mean") and "test" in c)
|
|
67
|
+
sd_col = err_col.replace("-mean", "-std")
|
|
68
|
+
best = int(cv[err_col].idxmin())
|
|
69
|
+
best -= int(round(float(nrounds) * cv[sd_col].iloc[best]))
|
|
70
|
+
nrounds = max(5, best)
|
|
71
|
+
else:
|
|
72
|
+
nrounds = int(nrounds)
|
|
73
|
+
|
|
74
|
+
bst = xgb.train(params, dtrain, num_boost_round=nrounds)
|
|
75
|
+
return bst
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def scDbl_score(
|
|
79
|
+
d: pd.DataFrame,
|
|
80
|
+
*,
|
|
81
|
+
add_vals: Optional[np.ndarray] = None,
|
|
82
|
+
features: Optional[list[str]] = None,
|
|
83
|
+
nrounds: float | int = 0.25,
|
|
84
|
+
max_depth: int = 4,
|
|
85
|
+
iter: int = 3,
|
|
86
|
+
dbr: Optional[float] = None,
|
|
87
|
+
dbr_per1k: float = 0.008,
|
|
88
|
+
unident_th: float = 0.1,
|
|
89
|
+
metric: str = "logloss",
|
|
90
|
+
random_state: int = 0,
|
|
91
|
+
verbose: bool = False,
|
|
92
|
+
) -> pd.DataFrame:
|
|
93
|
+
"""Port of R ``.scDblscore`` (``scoreType='xgb'``).
|
|
94
|
+
|
|
95
|
+
``d`` must have columns ``type`` ("real"/"doublet"), ``src`` ("real"/"artificial"),
|
|
96
|
+
and the numeric features from ``evaluate_knn`` / ``cxds_score``.
|
|
97
|
+
Adds a ``score`` column (predicted doublet probability) to ``d``.
|
|
98
|
+
"""
|
|
99
|
+
import xgboost as xgb
|
|
100
|
+
|
|
101
|
+
d = d.copy()
|
|
102
|
+
if features is None:
|
|
103
|
+
feat_cols = _default_features(d)
|
|
104
|
+
else:
|
|
105
|
+
feat_cols = [c for c in features if c in d.columns]
|
|
106
|
+
X = d[feat_cols].astype(float).values
|
|
107
|
+
if add_vals is not None:
|
|
108
|
+
X = np.concatenate([X, np.asarray(add_vals, dtype=float)], axis=1)
|
|
109
|
+
|
|
110
|
+
y = (d["type"].values == "doublet").astype(np.int32)
|
|
111
|
+
|
|
112
|
+
# Initial score — average of cxds_score and normalized ratio (R line:
|
|
113
|
+
# d$score <- (d$cxds_score + d[[ratio]]/max(d[[ratio]]))/2)
|
|
114
|
+
ratio_cols = [c for c in d.columns if c.startswith("ratio.k")]
|
|
115
|
+
ratio_col = ratio_cols[-1] if ratio_cols else None
|
|
116
|
+
if ratio_col is not None and "cxds_score" in d.columns:
|
|
117
|
+
rat = d[ratio_col].astype(float).values
|
|
118
|
+
rat = rat / (rat.max() if rat.max() > 0 else 1.0)
|
|
119
|
+
d["score"] = (d["cxds_score"].astype(float).values + rat) / 2.0
|
|
120
|
+
elif ratio_col is not None:
|
|
121
|
+
d["score"] = d[ratio_col].astype(float).values
|
|
122
|
+
else:
|
|
123
|
+
d["score"] = 0.5
|
|
124
|
+
|
|
125
|
+
n_real = int((d["type"].values == "real").sum())
|
|
126
|
+
n_dbl = int((d["type"].values == "doublet").sum())
|
|
127
|
+
if dbr is None:
|
|
128
|
+
# Expected doublet rate from dbr.per1k: fraction ≈ dbr_per1k * n_real / 1000
|
|
129
|
+
dbr = dbr_per1k * n_real / 1000.0
|
|
130
|
+
# Deviation budget
|
|
131
|
+
for it in range(int(iter)):
|
|
132
|
+
# Exclude cells that look like doublets (top-dbr-ish fraction) from training
|
|
133
|
+
from .thresholding import doublet_thresholding
|
|
134
|
+
exclude_real = np.where(
|
|
135
|
+
(d["type"].values == "real") &
|
|
136
|
+
(doublet_thresholding(d, dbr=dbr, stringency=0.7, return_type="call") == "doublet")
|
|
137
|
+
)[0]
|
|
138
|
+
# Cap the excluded fraction so we don't starve the training set
|
|
139
|
+
if exclude_real.size > n_real // 3:
|
|
140
|
+
sort_idx = np.argsort(-d["score"].values)
|
|
141
|
+
exclude_real = [i for i in sort_idx if d["type"].values[i] == "real"][:int(0.2 * n_real)]
|
|
142
|
+
exclude_real = np.asarray(exclude_real, dtype=int)
|
|
143
|
+
|
|
144
|
+
exclude_dbl = np.where(
|
|
145
|
+
(d["type"].values == "doublet") & (d["score"].values < unident_th)
|
|
146
|
+
)[0]
|
|
147
|
+
if exclude_dbl.size > n_dbl // 4:
|
|
148
|
+
sort_idx = np.argsort(d["score"].values)
|
|
149
|
+
exclude_dbl = [i for i in sort_idx if d["type"].values[i] == "doublet"][:int(0.1 * n_dbl)]
|
|
150
|
+
exclude_dbl = np.asarray(exclude_dbl, dtype=int)
|
|
151
|
+
|
|
152
|
+
include = np.ones(len(d), dtype=bool)
|
|
153
|
+
include[exclude_real] = False
|
|
154
|
+
include[exclude_dbl] = False
|
|
155
|
+
|
|
156
|
+
if verbose:
|
|
157
|
+
print(f"[scDblscore] iter={it} excluding {(~include).sum()} cells from training")
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
bst = _xgb_train(
|
|
161
|
+
X[include], y[include],
|
|
162
|
+
nrounds=nrounds, max_depth=max_depth, metric=metric,
|
|
163
|
+
random_state=random_state,
|
|
164
|
+
)
|
|
165
|
+
dmat_all = xgb.DMatrix(X)
|
|
166
|
+
d["score"] = bst.predict(dmat_all)
|
|
167
|
+
except Exception as exc: # pragma: no cover
|
|
168
|
+
if verbose:
|
|
169
|
+
print(f"[scDblscore] xgboost failed: {exc}; keeping previous score")
|
|
170
|
+
|
|
171
|
+
return d
|
pyscdblfinder/core.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Top-level scDblFinder pipeline — port of R ``scDblFinder`` (single-sample).
|
|
2
|
+
|
|
3
|
+
Follows the R function's single-sample path (``scDblFinder.R`` lines ~330-520)
|
|
4
|
+
end-to-end: feature selection → optional clustering → artificial-doublet
|
|
5
|
+
synthesis → merged-matrix feature extraction → kNN → xgboost scoring →
|
|
6
|
+
thresholding. Multi-sample (``samples=``), ATAC ``aggregateFeatures``,
|
|
7
|
+
``knownDoublets``, and ``clustCor`` paths are not yet ported — follow-up
|
|
8
|
+
work flagged in the README.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
import scipy.sparse as sp
|
|
18
|
+
|
|
19
|
+
from .artificial import get_artificial_doublets
|
|
20
|
+
from .classifier import scDbl_score
|
|
21
|
+
from .cxds import cxds_score
|
|
22
|
+
from .knn_features import default_k_grid, evaluate_knn
|
|
23
|
+
from .preprocessing import default_processing, select_features
|
|
24
|
+
from .thresholding import doublet_thresholding
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ScDblFinderResult:
|
|
29
|
+
"""Output of :func:`sc_dbl_finder`. Mirrors R's ``returnType='table'``.
|
|
30
|
+
|
|
31
|
+
Attributes
|
|
32
|
+
----------
|
|
33
|
+
table : per-cell DataFrame with everything scDblFinder computes —
|
|
34
|
+
``type`` (real/doublet), ``src`` (real/artificial), ``score``,
|
|
35
|
+
``class``, kNN features, cxds_score, library size, etc.
|
|
36
|
+
score_threshold : the threshold used to produce ``class``.
|
|
37
|
+
n_real : number of real cells (always the *first* rows of ``table``).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
table: pd.DataFrame
|
|
41
|
+
score_threshold: float
|
|
42
|
+
n_real: int
|
|
43
|
+
|
|
44
|
+
def real_cells(self) -> pd.DataFrame:
|
|
45
|
+
"""Rows of ``table`` corresponding to real cells only (not artificial)."""
|
|
46
|
+
return self.table.iloc[: self.n_real]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sc_dbl_finder(
|
|
50
|
+
counts,
|
|
51
|
+
*,
|
|
52
|
+
clusters: Optional[np.ndarray] = None,
|
|
53
|
+
artificial_doublets: Optional[int] = None,
|
|
54
|
+
dbr: Optional[float] = None,
|
|
55
|
+
dbr_sd: Optional[float] = None,
|
|
56
|
+
dbr_per1k: float = 0.008,
|
|
57
|
+
n_features: int = 1352,
|
|
58
|
+
dims: int = 20,
|
|
59
|
+
k: Optional[int | list[int]] = None,
|
|
60
|
+
prop_random: float = 0.0,
|
|
61
|
+
include_pcs: int = 19,
|
|
62
|
+
metric: str = "logloss",
|
|
63
|
+
nrounds: float | int = 0.25,
|
|
64
|
+
max_depth: int = 4,
|
|
65
|
+
iter: int = 3,
|
|
66
|
+
unident_th: Optional[float] = None,
|
|
67
|
+
threshold: bool = True,
|
|
68
|
+
random_state: int = 0,
|
|
69
|
+
verbose: bool = False,
|
|
70
|
+
) -> ScDblFinderResult:
|
|
71
|
+
"""Python port of R ``scDblFinder`` (single-sample path).
|
|
72
|
+
|
|
73
|
+
Parameters largely match the R signature. ``counts`` must be a
|
|
74
|
+
**genes × cells** matrix (dense ndarray or scipy sparse).
|
|
75
|
+
"""
|
|
76
|
+
counts = counts if sp.issparse(counts) else np.asarray(counts, dtype=np.float64)
|
|
77
|
+
n_genes, n_real = counts.shape
|
|
78
|
+
|
|
79
|
+
if unident_th is None:
|
|
80
|
+
unident_th = 0.2 if clusters is None else 0.0
|
|
81
|
+
|
|
82
|
+
# 1) Feature selection (purely variance-based; see R's selFeatures)
|
|
83
|
+
if n_features < n_genes:
|
|
84
|
+
sel = select_features(counts, clusters, n_features=n_features)
|
|
85
|
+
counts_sel = counts[sel] if sp.issparse(counts) else counts[sel, :]
|
|
86
|
+
else:
|
|
87
|
+
counts_sel = counts
|
|
88
|
+
|
|
89
|
+
# 2) Artificial doublet count
|
|
90
|
+
if artificial_doublets is None:
|
|
91
|
+
n_ad = int(min(25000, max(1500, np.ceil(n_real * 0.8),
|
|
92
|
+
10 * (1 if clusters is None else len(np.unique(clusters))) ** 2)))
|
|
93
|
+
else:
|
|
94
|
+
n_ad = int(artificial_doublets)
|
|
95
|
+
if n_ad <= 2:
|
|
96
|
+
n_ad = int(min(round(n_ad * n_real), 25000))
|
|
97
|
+
|
|
98
|
+
if verbose:
|
|
99
|
+
print(f"[scDblFinder] creating ~{n_ad} artificial doublets "
|
|
100
|
+
f"({'random' if clusters is None else 'cluster-aware'})")
|
|
101
|
+
|
|
102
|
+
ad = get_artificial_doublets(
|
|
103
|
+
counts_sel, n=n_ad, clusters=clusters, prop_random=prop_random,
|
|
104
|
+
seed=random_state,
|
|
105
|
+
)
|
|
106
|
+
ad_counts = ad["counts"] # (n_genes_sel, n_ad_actual)
|
|
107
|
+
n_ad_actual = ad_counts.shape[1]
|
|
108
|
+
origins = ad["origins"]
|
|
109
|
+
|
|
110
|
+
# 3) Merge real + artificial
|
|
111
|
+
if sp.issparse(counts_sel):
|
|
112
|
+
merged = sp.hstack([counts_sel, sp.csr_matrix(ad_counts)]).tocsc()
|
|
113
|
+
else:
|
|
114
|
+
merged = np.concatenate([np.asarray(counts_sel), ad_counts], axis=1)
|
|
115
|
+
|
|
116
|
+
ctype = np.concatenate([np.zeros(n_real, dtype=int),
|
|
117
|
+
np.ones(n_ad_actual, dtype=int)])
|
|
118
|
+
all_origins = np.array([None] * n_real + list(origins), dtype=object)
|
|
119
|
+
|
|
120
|
+
# 4) cxds_score, library size, n_features, n_above_2
|
|
121
|
+
if verbose:
|
|
122
|
+
print("[scDblFinder] computing cxds + size features")
|
|
123
|
+
lsizes = np.asarray(merged.sum(axis=0)).ravel() if sp.issparse(merged) else merged.sum(axis=0)
|
|
124
|
+
n_feat = np.asarray((merged > 0).sum(axis=0)).ravel() if sp.issparse(merged) else (merged > 0).sum(axis=0)
|
|
125
|
+
n_above2 = np.asarray((merged > 2).sum(axis=0)).ravel() if sp.issparse(merged) else (merged > 2).sum(axis=0)
|
|
126
|
+
artificial_idx = np.where(ctype == 1)[0]
|
|
127
|
+
cxds = cxds_score(merged, which_dbls=artificial_idx)
|
|
128
|
+
|
|
129
|
+
# 5) PCA
|
|
130
|
+
if verbose:
|
|
131
|
+
print(f"[scDblFinder] PCA (dims={dims})")
|
|
132
|
+
pca = default_processing(merged, dims=dims, random_state=random_state)
|
|
133
|
+
|
|
134
|
+
# 6) kNN features
|
|
135
|
+
if verbose:
|
|
136
|
+
print("[scDblFinder] kNN features")
|
|
137
|
+
k_list = default_k_grid(n_real, k)
|
|
138
|
+
d = evaluate_knn(pca, ctype, k=k_list, origins=all_origins)
|
|
139
|
+
d["type"] = np.where(ctype == 0, "real", "doublet")
|
|
140
|
+
d["src"] = np.where(ctype == 0, "real", "artificial")
|
|
141
|
+
d["cxds_score"] = cxds
|
|
142
|
+
d["lsizes"] = lsizes
|
|
143
|
+
d["nfeatures"] = n_feat
|
|
144
|
+
d["nAbove2"] = n_above2
|
|
145
|
+
|
|
146
|
+
# 7) xgboost classification loop
|
|
147
|
+
if verbose:
|
|
148
|
+
print("[scDblFinder] xgboost scoring")
|
|
149
|
+
# Include the top principal components as additional predictors
|
|
150
|
+
n_pc_use = int(include_pcs) if np.isscalar(include_pcs) else len(include_pcs)
|
|
151
|
+
add_vals = pca[:, : min(n_pc_use, pca.shape[1] - 1)]
|
|
152
|
+
d = scDbl_score(
|
|
153
|
+
d, add_vals=add_vals,
|
|
154
|
+
nrounds=nrounds, max_depth=max_depth, iter=iter,
|
|
155
|
+
dbr=dbr, dbr_per1k=dbr_per1k, unident_th=float(unident_th),
|
|
156
|
+
metric=metric, random_state=random_state, verbose=verbose,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# 8) Threshold
|
|
160
|
+
if threshold:
|
|
161
|
+
th = doublet_thresholding(
|
|
162
|
+
d, dbr=dbr, dbr_sd=dbr_sd, dbr_per1k=dbr_per1k,
|
|
163
|
+
return_type="threshold",
|
|
164
|
+
)
|
|
165
|
+
d["class"] = np.where(d["score"].values >= th, "doublet", "singlet")
|
|
166
|
+
else:
|
|
167
|
+
th = float("nan")
|
|
168
|
+
d["class"] = "singlet"
|
|
169
|
+
|
|
170
|
+
return ScDblFinderResult(table=d, score_threshold=float(th), n_real=n_real)
|