pysccdc 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.
pysccdc/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """pysccdc -- pure-Python port of the R package **scCDC**.
2
+
3
+ scCDC (Wang *et al.*, *Genome Biology* 2024) is an entropy-based,
4
+ *gene-specific* ambient-RNA decontamination method for scRNA-seq and
5
+ snRNA-seq. Instead of correcting every gene, scCDC detects the small
6
+ set of **Global Contamination-causing Genes (GCGs)** and corrects only
7
+ those -- avoiding the over-correction of lowly/non-contaminating genes
8
+ seen in DecontX / SoupX / CellBender.
9
+
10
+ The pipeline:
11
+
12
+ 1. :func:`ContaminationDetection` -- per cluster, compute the Shannon
13
+ entropy of every gene's count distribution, fit the expected
14
+ entropy-vs-expression curve with an outlier-trimmed smoothing spline,
15
+ score the *entropy divergence* (expected - observed) and flag genes
16
+ significant in > ``restriction_factor`` of clusters as GCGs.
17
+ 2. :func:`ContaminationQuantification` -- a dataset-level contamination
18
+ ratio from the GCGs.
19
+ 3. :func:`ContaminationCorrection` -- for each GCG, a Youden-index
20
+ threshold on the per-cell count distributions decides how much to
21
+ subtract (floored at zero); non-GCGs are untouched.
22
+
23
+ All functions are AnnData-friendly and depend only on
24
+ numpy / scipy / pandas / anndata / scikit-learn.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from . import datasets
29
+ from .correction import (
30
+ ContaminationCorrection,
31
+ ContaminationQuantification,
32
+ simple_roc,
33
+ youden_threshold,
34
+ )
35
+ from .detection import ContaminationDetection, generate_curve
36
+ from .entropy import matrix_entropy, vector_entropy
37
+ from .spline import SmoothSpline, smooth_spline
38
+
39
+ __version__ = "0.1.0"
40
+
41
+ __all__ = [
42
+ "ContaminationDetection",
43
+ "ContaminationCorrection",
44
+ "ContaminationQuantification",
45
+ "generate_curve",
46
+ "vector_entropy",
47
+ "matrix_entropy",
48
+ "SmoothSpline",
49
+ "smooth_spline",
50
+ "simple_roc",
51
+ "youden_threshold",
52
+ "datasets",
53
+ "__version__",
54
+ ]
pysccdc/_anndata.py ADDED
@@ -0,0 +1,72 @@
1
+ """AnnData <-> dense-matrix glue used across :mod:`pysccdc`.
2
+
3
+ scCDC reasons in the Seurat layout (genes x cells); AnnData stores
4
+ cells x genes. These helpers convert and pick out the count layer /
5
+ cluster labels so the rest of the package can work with plain NumPy.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional, Tuple
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+ __all__ = ["counts_matrix", "get_clusters", "to_dense"]
15
+
16
+
17
+ def to_dense(x) -> np.ndarray:
18
+ """Return a dense 2-D ``float64`` array from dense or sparse input."""
19
+ if hasattr(x, "toarray"):
20
+ x = x.toarray()
21
+ return np.asarray(x, dtype=np.float64)
22
+
23
+
24
+ def counts_matrix(
25
+ adata, layer: Optional[str] = None
26
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
27
+ """Extract the (genes x cells) count matrix from an AnnData.
28
+
29
+ Parameters
30
+ ----------
31
+ adata
32
+ :class:`anndata.AnnData` with raw counts.
33
+ layer
34
+ ``adata.layers`` key; ``None`` uses ``adata.X``.
35
+
36
+ Returns
37
+ -------
38
+ counts : ndarray
39
+ Dense ``genes x cells`` matrix (transposed from AnnData).
40
+ genes : ndarray
41
+ ``var_names``.
42
+ cells : ndarray
43
+ ``obs_names``.
44
+ """
45
+ mat = adata.layers[layer] if layer is not None else adata.X
46
+ counts = to_dense(mat).T # genes x cells
47
+ genes = np.asarray(adata.var_names)
48
+ cells = np.asarray(adata.obs_names)
49
+ return counts, genes, cells
50
+
51
+
52
+ def get_clusters(adata, cluster_key: Optional[str] = None) -> np.ndarray:
53
+ """Return the per-cell cluster labels as a string array.
54
+
55
+ When ``cluster_key`` is ``None`` the first categorical/object column
56
+ of ``adata.obs`` is used.
57
+ """
58
+ if cluster_key is None:
59
+ for col in adata.obs.columns:
60
+ ser = adata.obs[col]
61
+ if isinstance(ser.dtype, pd.CategoricalDtype) or \
62
+ ser.dtype == object:
63
+ cluster_key = col
64
+ break
65
+ if cluster_key is None:
66
+ raise ValueError(
67
+ "No categorical column found in adata.obs; "
68
+ "pass cluster_key explicitly."
69
+ )
70
+ if cluster_key not in adata.obs.columns:
71
+ raise KeyError(f"cluster_key '{cluster_key}' not in adata.obs.")
72
+ return adata.obs[cluster_key].astype(str).to_numpy()
pysccdc/correction.py ADDED
@@ -0,0 +1,357 @@
1
+ """GCG-specific decontamination -- :func:`ContaminationCorrection`.
2
+
3
+ Port of scCDC's ``Contamination_Correction.R`` (and the contamination
4
+ ratio of ``Contamination_Quantification.R``).
5
+
6
+ For each GCG, scCDC
7
+
8
+ 1. ranks clusters by their mean **log-normalized** expression of the
9
+ gene and takes the lowest-expressing qualified cluster as the first
10
+ *eGCG-negative* reference;
11
+ 2. computes, for every cluster, an **AUROC** of the per-cell counts of
12
+ that cluster vs. the reference cluster (``Cal_AUCs``);
13
+ 3. splits clusters into *eGCG-positive* (AUROC >= ``auc_thres``) and
14
+ *eGCG-negative* (AUROC < ``auc_thres``);
15
+ 4. pools the counts of the eGCG-negative cells with those of the
16
+ **least** eGCG-positive cluster, builds a ROC of that pooled vector
17
+ labelled neg(0)/pos(1), and takes the count value maximising the
18
+ **Youden index** (sensitivity + specificity - 1) as the subtraction
19
+ threshold (``Cal_thres``);
20
+ 5. subtracts ``round(threshold)`` from every cell's count of that gene,
21
+ floored at zero.
22
+
23
+ Only the GCGs are touched; all other genes pass through unchanged --
24
+ scCDC's anti-over-correction design.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from typing import List, Optional, Sequence, Union
29
+
30
+ import numpy as np
31
+ import pandas as pd
32
+
33
+ from ._anndata import counts_matrix, get_clusters
34
+
35
+ __all__ = [
36
+ "simple_roc",
37
+ "youden_threshold",
38
+ "ContaminationCorrection",
39
+ "ContaminationQuantification",
40
+ ]
41
+
42
+
43
+ # ----------------------------------------------------------------------
44
+ def _log_normalize(counts: np.ndarray, scale_factor: float = 1e4) -> np.ndarray:
45
+ """Seurat ``LogNormalize``: ``log1p(count / colsum * scale_factor)``.
46
+
47
+ ``counts`` is genes x cells; column sums are per-cell library sizes.
48
+ """
49
+ lib = counts.sum(axis=0)
50
+ lib[lib == 0] = 1.0
51
+ return np.log1p(counts / lib[None, :] * scale_factor)
52
+
53
+
54
+ def _roc_curve(scores: np.ndarray, labels: np.ndarray):
55
+ """ROC thresholds / sensitivities / specificities (pROC, direction '<').
56
+
57
+ ``direction='<'`` in pROC means the positive class is predicted to
58
+ have *higher* scores. pROC inserts ``+/-Inf`` boundary thresholds
59
+ and uses midpoints between consecutive unique scores; we replicate
60
+ that so the Youden-optimal threshold matches exactly.
61
+ """
62
+ scores = np.asarray(scores, dtype=float)
63
+ labels = np.asarray(labels)
64
+ n_pos = np.sum(labels == 1)
65
+ n_neg = np.sum(labels == 0)
66
+
67
+ uniq = np.unique(scores)
68
+ if uniq.size == 0:
69
+ return np.array([np.inf]), np.array([0.0]), np.array([1.0])
70
+ # pROC threshold grid: -Inf, midpoints of consecutive uniques, +Inf
71
+ if uniq.size == 1:
72
+ thr = np.array([-np.inf, np.inf])
73
+ else:
74
+ mids = (uniq[:-1] + uniq[1:]) / 2.0
75
+ thr = np.concatenate(([-np.inf], mids, [np.inf]))
76
+
77
+ sens = np.empty(thr.size)
78
+ spec = np.empty(thr.size)
79
+ pos_scores = scores[labels == 1]
80
+ neg_scores = scores[labels == 0]
81
+ for i, t in enumerate(thr):
82
+ # predict positive when score >= t
83
+ tp = np.sum(pos_scores >= t)
84
+ tn = np.sum(neg_scores < t)
85
+ sens[i] = tp / n_pos if n_pos > 0 else 0.0
86
+ spec[i] = tn / n_neg if n_neg > 0 else 0.0
87
+ return thr, sens, spec
88
+
89
+
90
+ def simple_roc(exp: np.ndarray, cls: np.ndarray) -> float:
91
+ """AUROC of ``exp`` separating the two classes in ``cls`` (pROC '<').
92
+
93
+ The positive class is the label ``1``. Ties contribute 0.5 each
94
+ (Mann-Whitney U / pROC convention).
95
+ """
96
+ exp = np.asarray(exp, dtype=float)
97
+ cls = np.asarray(cls)
98
+ pos = exp[cls == 1]
99
+ neg = exp[cls == 0]
100
+ n_pos, n_neg = pos.size, neg.size
101
+ if n_pos == 0 or n_neg == 0:
102
+ return float("nan")
103
+ # rank-sum AUC with tie handling
104
+ allv = np.concatenate([pos, neg])
105
+ order = np.argsort(allv, kind="mergesort")
106
+ ranks = np.empty(allv.size, dtype=float)
107
+ sv = allv[order]
108
+ i = 0
109
+ while i < sv.size:
110
+ j = i
111
+ while j + 1 < sv.size and sv[j + 1] == sv[i]:
112
+ j += 1
113
+ ranks[order[i:j + 1]] = (i + j) / 2.0 + 1.0
114
+ i = j + 1
115
+ rank_pos = ranks[:n_pos].sum()
116
+ auc = (rank_pos - n_pos * (n_pos + 1) / 2.0) / (n_pos * n_neg)
117
+ return float(auc)
118
+
119
+
120
+ def youden_threshold(neg: np.ndarray, pos: np.ndarray) -> float:
121
+ """Youden-index optimal cut count separating ``neg`` from ``pos``.
122
+
123
+ Mirrors scCDC's ``Cal_thres`` -- builds the ROC of the pooled counts
124
+ labelled 0(neg)/1(pos) and returns the pROC threshold that maximises
125
+ ``sensitivity + specificity - 1``.
126
+ """
127
+ scores = np.concatenate([neg, pos]).astype(float)
128
+ labels = np.concatenate([
129
+ np.zeros(len(neg), dtype=int), np.ones(len(pos), dtype=int)
130
+ ])
131
+ thr, sens, spec = _roc_curve(scores, labels)
132
+ yd = sens + spec - 1.0
133
+ best = thr[int(np.argmax(yd))]
134
+ return float(best)
135
+
136
+
137
+ # ----------------------------------------------------------------------
138
+ def _cal_aucs(counts, lognorm, labels, gene_idx, qualified):
139
+ """``Cal_AUCs`` -- AUROC of each cluster vs the lowest-expressing one.
140
+
141
+ Returns ``(aucs_by_cluster, eGCG_neg_reference_cluster)``.
142
+ """
143
+ # mean log-normalized expression per cluster (Seurat AverageExpression
144
+ # on the LogNormalize 'data' slot used by Cal_AUCs to *order* clusters)
145
+ clusters = np.unique(labels)
146
+ mean_exp = {}
147
+ for cl in clusters:
148
+ mask = labels == cl
149
+ # AverageExpression = expm1(mean(log-data)) then implicitly compared;
150
+ # ordering by the log-data mean is monotone-equivalent.
151
+ mean_exp[cl] = lognorm[gene_idx, mask].mean()
152
+ order = sorted(clusters, key=lambda c: mean_exp[c])
153
+ qset = set(qualified)
154
+ ref = next((c for c in order if c in qset), order[0])
155
+
156
+ g_counts = counts[gene_idx]
157
+ ref_vals = g_counts[labels == ref]
158
+ aucs = {}
159
+ for cl in order:
160
+ cl_vals = g_counts[labels == cl]
161
+ scores = np.concatenate([cl_vals, ref_vals])
162
+ cls = np.concatenate([
163
+ np.ones(len(cl_vals), dtype=int),
164
+ np.zeros(len(ref_vals), dtype=int),
165
+ ])
166
+ aucs[cl] = simple_roc(scores, cls)
167
+ # preserve the expression-sorted order
168
+ return order, aucs, ref
169
+
170
+
171
+ def _cal_thres(counts, labels, gene_idx, order, aucs, auc_thres):
172
+ """``Cal_thres`` -- the per-gene Youden subtraction threshold."""
173
+ auc_arr = np.array([aucs[c] for c in order])
174
+ if np.sum(auc_arr >= auc_thres) == 0:
175
+ auc_thres = np.nanmax(auc_arr)
176
+
177
+ neg_cls = [c for c in order if aucs[c] < auc_thres]
178
+ pos_cls = [c for c in order if aucs[c] >= auc_thres]
179
+ if len(pos_cls) == 0: # degenerate -> nothing to subtract
180
+ return 0.0
181
+ low_pos = pos_cls[0]
182
+
183
+ g_counts = counts[gene_idx]
184
+ neg_mask = np.isin(labels, neg_cls) if neg_cls else np.zeros(
185
+ labels.shape, dtype=bool
186
+ )
187
+ pos_mask = labels == low_pos
188
+ neg_vals = g_counts[neg_mask]
189
+ pos_vals = g_counts[pos_mask]
190
+ if neg_vals.size == 0 or pos_vals.size == 0:
191
+ return 0.0
192
+ return youden_threshold(neg_vals, pos_vals)
193
+
194
+
195
+ def _cont_level(counts, lognorm, labels, gene_idx, order, aucs, auc_thres):
196
+ """``Cal_Cont_level`` -- per-gene contamination ratio.
197
+
198
+ Total expression of the gene in eGCG-negative cells divided by the
199
+ total expression of *all* genes in those same cells.
200
+ """
201
+ auc_arr = np.array([aucs[c] for c in order])
202
+ if np.sum(auc_arr >= auc_thres) == 0:
203
+ auc_thres = np.nanmax(auc_arr)
204
+ neg_cls = [c for c in order if aucs[c] < auc_thres]
205
+ if not neg_cls:
206
+ return 0.0
207
+ neg_mask = np.isin(labels, neg_cls)
208
+ gene_total = counts[gene_idx, neg_mask].sum()
209
+ all_total = counts[:, neg_mask].sum()
210
+ if all_total == 0:
211
+ return 0.0
212
+ return float(gene_total / all_total)
213
+
214
+
215
+ # ----------------------------------------------------------------------
216
+ def ContaminationCorrection(
217
+ adata,
218
+ cont_genes: Union[Sequence[str], pd.DataFrame],
219
+ cluster_key: Optional[str] = None,
220
+ auc_thres: float = 0.9,
221
+ min_cell: int = 50,
222
+ layer: Optional[str] = None,
223
+ copy: bool = True,
224
+ corrected_layer: str = "Corrected",
225
+ ):
226
+ """Decontaminate the GCG counts of an AnnData (scCDC ``ContaminationCorrection``).
227
+
228
+ Parameters
229
+ ----------
230
+ adata
231
+ AnnData with raw integer counts (in ``X`` or ``layer``).
232
+ cont_genes
233
+ GCG names; either a sequence, or the DataFrame returned by
234
+ :func:`~pysccdc.ContaminationDetection` (its index is used).
235
+ cluster_key
236
+ ``adata.obs`` column with cluster labels (auto-detected if None).
237
+ auc_thres
238
+ AUROC threshold splitting eGCG-positive / -negative clusters.
239
+ min_cell
240
+ Clusters smaller than this are not used as references.
241
+ layer
242
+ Count layer; ``None`` -> ``adata.X``.
243
+ copy
244
+ Return a corrected copy (``True``) or modify ``adata`` in place.
245
+ corrected_layer
246
+ Name of the layer that receives the corrected matrix.
247
+
248
+ Returns
249
+ -------
250
+ anndata.AnnData
251
+ AnnData with a ``layers[corrected_layer]`` holding the corrected
252
+ counts. ``.uns['sccdc']`` records the per-gene thresholds.
253
+ """
254
+ if isinstance(cont_genes, pd.DataFrame):
255
+ cont_genes = list(cont_genes.index)
256
+ cont_genes = list(cont_genes)
257
+
258
+ out = adata.copy() if copy else adata
259
+ counts, genes, _ = counts_matrix(out, layer=layer)
260
+ labels = get_clusters(out, cluster_key)
261
+ labels = np.asarray(labels)
262
+
263
+ gene_pos = {g: i for i, g in enumerate(genes)}
264
+ missing = [g for g in cont_genes if g not in gene_pos]
265
+ if missing:
266
+ raise KeyError(f"cont_genes not in adata.var_names: {missing}")
267
+
268
+ uniq, cnts = np.unique(labels, return_counts=True)
269
+ qualified = [c for c, n in zip(uniq, cnts) if n >= min_cell]
270
+ if not qualified:
271
+ raise ValueError(f"No cluster reaches min_cell={min_cell}.")
272
+
273
+ lognorm = _log_normalize(counts)
274
+
275
+ corrected = counts.copy()
276
+ thresholds = {}
277
+ for g in cont_genes:
278
+ gi = gene_pos[g]
279
+ order, aucs, _ref = _cal_aucs(
280
+ counts, lognorm, labels, gi, qualified
281
+ )
282
+ thr = _cal_thres(counts, labels, gi, order, aucs, auc_thres)
283
+ thresholds[g] = thr
284
+ corrected[gi] = np.maximum(counts[gi] - round(thr), 0.0)
285
+
286
+ # write back in AnnData orientation (cells x genes)
287
+ out.layers[corrected_layer] = corrected.T
288
+ info = out.uns.get("sccdc", {})
289
+ info = dict(info)
290
+ info["thresholds"] = thresholds
291
+ info["GCGs"] = cont_genes
292
+ info["auc_thres"] = auc_thres
293
+ out.uns["sccdc"] = info
294
+ return out
295
+
296
+
297
+ # ----------------------------------------------------------------------
298
+ def ContaminationQuantification(
299
+ adata,
300
+ cont_genes: Union[Sequence[str], pd.DataFrame],
301
+ cluster_key: Optional[str] = None,
302
+ auc_thres: float = 0.9,
303
+ min_cell: int = 50,
304
+ top10_gcg: bool = False,
305
+ layer: Optional[str] = None,
306
+ return_per_gene: bool = False,
307
+ ):
308
+ """Dataset-level contamination ratio from the GCGs (scCDC ``ContaminationQuantification``).
309
+
310
+ For each GCG the contamination level is its total expression in the
311
+ eGCG-negative cells divided by the total expression of all genes in
312
+ those cells; the dataset ratio is the **maximum** across GCGs. A
313
+ ratio above ~3e-4 indicates high contamination (scCDC heuristic).
314
+
315
+ Parameters
316
+ ----------
317
+ adata, cont_genes, cluster_key, auc_thres, min_cell, layer
318
+ As in :func:`ContaminationCorrection`.
319
+ top10_gcg
320
+ Use only the first 10 GCGs (scCDC option).
321
+ return_per_gene
322
+ Also return the per-gene ratio :class:`pandas.Series`.
323
+
324
+ Returns
325
+ -------
326
+ float or tuple
327
+ The maximum contamination ratio, or ``(max_ratio, per_gene)``
328
+ when ``return_per_gene`` is set.
329
+ """
330
+ if isinstance(cont_genes, pd.DataFrame):
331
+ cont_genes = list(cont_genes.index)
332
+ cont_genes = list(cont_genes)
333
+ if top10_gcg and len(cont_genes) >= 10:
334
+ cont_genes = cont_genes[:10]
335
+
336
+ counts, genes, _ = counts_matrix(adata, layer=layer)
337
+ labels = np.asarray(get_clusters(adata, cluster_key))
338
+ gene_pos = {g: i for i, g in enumerate(genes)}
339
+
340
+ uniq, cnts = np.unique(labels, return_counts=True)
341
+ qualified = [c for c, n in zip(uniq, cnts) if n >= min_cell]
342
+ if not qualified:
343
+ raise ValueError(f"No cluster reaches min_cell={min_cell}.")
344
+
345
+ lognorm = _log_normalize(counts)
346
+ ratios = {}
347
+ for g in cont_genes:
348
+ gi = gene_pos[g]
349
+ order, aucs, _ref = _cal_aucs(counts, lognorm, labels, gi, qualified)
350
+ ratios[g] = _cont_level(
351
+ counts, lognorm, labels, gi, order, aucs, auc_thres
352
+ )
353
+ per_gene = pd.Series(ratios, name="contamination_ratio")
354
+ max_ratio = float(per_gene.max()) if len(per_gene) else 0.0
355
+ if return_per_gene:
356
+ return max_ratio, per_gene
357
+ return max_ratio
pysccdc/datasets.py ADDED
@@ -0,0 +1,119 @@
1
+ """Synthetic example data for :mod:`pysccdc`.
2
+
3
+ :func:`simulate_contaminated` builds a small clustered count matrix in
4
+ which a handful of genes are deliberately spiked with *ambient*
5
+ contamination -- each contaminating gene is a marker of one cluster but
6
+ also leaks low counts into every other cluster. This is the input used
7
+ by the R-parity test suite.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional
12
+
13
+ import numpy as np
14
+
15
+ __all__ = ["simulate_contaminated"]
16
+
17
+
18
+ def simulate_contaminated(
19
+ n_clusters: int = 4,
20
+ n_cells_per_cluster: int = 200,
21
+ n_genes: int = 120,
22
+ n_contaminating: int = 4,
23
+ contamination_strength: float = 6.0,
24
+ random_state: Optional[int] = 0,
25
+ ):
26
+ """Simulate a clustered scRNA-seq count matrix with spiked GCGs.
27
+
28
+ Parameters
29
+ ----------
30
+ n_clusters
31
+ Number of cell clusters.
32
+ n_cells_per_cluster
33
+ Cells per cluster.
34
+ n_genes
35
+ Total genes (markers + housekeeping + contaminating).
36
+ n_contaminating
37
+ Number of deliberately-spiked contaminating genes (GCGs).
38
+ contamination_strength
39
+ Mean ambient leakage count of each contaminating gene into
40
+ non-source clusters.
41
+ random_state
42
+ Seed for reproducibility.
43
+
44
+ Returns
45
+ -------
46
+ anndata.AnnData
47
+ ``X`` holds integer counts (cells x genes); ``obs['cluster']``
48
+ the cluster labels; ``uns['true_GCGs']`` the spiked gene names.
49
+ """
50
+ import anndata as ad
51
+ import pandas as pd
52
+
53
+ rng = np.random.default_rng(random_state)
54
+ n_cells = n_clusters * n_cells_per_cluster
55
+
56
+ labels = np.repeat(
57
+ [f"c{i}" for i in range(n_clusters)], n_cells_per_cluster
58
+ )
59
+
60
+ gene_names = [f"gene{i}" for i in range(n_genes)]
61
+ counts = np.zeros((n_cells, n_genes), dtype=np.int64)
62
+
63
+ # ---- housekeeping / background genes -----------------------------
64
+ # genuinely variable expression -> a broad spread of count values,
65
+ # i.e. HIGH entropy for their mean expression (non-contaminating).
66
+ n_bg = n_genes - n_contaminating
67
+ for j in range(n_bg):
68
+ # negative-binomial-like over-dispersion gives a wide count
69
+ # distribution (high entropy) typical of real genes
70
+ rate = rng.gamma(shape=1.2, scale=rng.uniform(1.0, 8.0),
71
+ size=n_cells)
72
+ counts[:, j] = rng.poisson(rate)
73
+
74
+ # add cluster-marker structure to some background genes so clusters
75
+ # are biologically distinct (these are NOT contaminating) -- still a
76
+ # broad (high-entropy) distribution within the marker cluster.
77
+ markers_per_cluster = max(1, n_bg // (n_clusters * 4))
78
+ for ci in range(n_clusters):
79
+ cmask = labels == f"c{ci}"
80
+ for k in range(markers_per_cluster):
81
+ j = ci * markers_per_cluster + k
82
+ if j >= n_bg:
83
+ break
84
+ rate = rng.gamma(shape=2.0, scale=12.0, size=cmask.sum())
85
+ counts[cmask, j] += rng.poisson(rate)
86
+
87
+ # ---- contaminating genes (the true GCGs) -------------------------
88
+ # A GCG is a strong marker of ONE cluster but leaks *ambient* RNA
89
+ # into every other cluster. The ambient leakage is near-constant
90
+ # low counts (it reflects the soup composition, not real biology),
91
+ # so the gene's overall count distribution is unusually CONCENTRATED
92
+ # for its mean expression -> entropy BELOW the fitted curve, the
93
+ # positive entropy divergence scCDC keys on.
94
+ true_gcgs = []
95
+ for k in range(n_contaminating):
96
+ j = n_bg + k
97
+ gene_names[j] = f"CONT{k}"
98
+ true_gcgs.append(f"CONT{k}")
99
+ source = k % n_clusters
100
+ src_mask = labels == f"c{source}"
101
+ # high, fairly uniform expression in the source cluster
102
+ counts[src_mask, j] = rng.poisson(45.0, size=src_mask.sum())
103
+ # ambient leakage everywhere else: a near-constant low count
104
+ # (Bernoulli presence x a tight integer level) -> low entropy
105
+ other = ~src_mask
106
+ lvl = int(round(contamination_strength))
107
+ present = rng.random(other.sum()) < 0.85
108
+ counts[other, j] += np.where(present, lvl, lvl - 1)
109
+
110
+ obs = pd.DataFrame(
111
+ {"cluster": pd.Categorical(labels)},
112
+ index=[f"cell{i}" for i in range(n_cells)],
113
+ )
114
+ var = pd.DataFrame(index=gene_names)
115
+ adata = ad.AnnData(
116
+ X=counts.astype(np.float64), obs=obs, var=var
117
+ )
118
+ adata.uns["true_GCGs"] = true_gcgs
119
+ return adata