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 +54 -0
- pysccdc/_anndata.py +72 -0
- pysccdc/correction.py +357 -0
- pysccdc/datasets.py +119 -0
- pysccdc/detection.py +279 -0
- pysccdc/entropy.py +72 -0
- pysccdc/spline.py +176 -0
- pysccdc-0.1.0.dist-info/METADATA +367 -0
- pysccdc-0.1.0.dist-info/RECORD +12 -0
- pysccdc-0.1.0.dist-info/WHEEL +5 -0
- pysccdc-0.1.0.dist-info/licenses/LICENSE +202 -0
- pysccdc-0.1.0.dist-info/top_level.txt +1 -0
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
|