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.
@@ -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)