diffbio 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.
- diffbio/__init__.py +39 -0
- diffbio/configs.py +75 -0
- diffbio/constants.py +204 -0
- diffbio/core/__init__.py +127 -0
- diffbio/core/base_operators.py +612 -0
- diffbio/core/data_types.py +260 -0
- diffbio/core/gnn_components.py +629 -0
- diffbio/core/graph_utils.py +149 -0
- diffbio/core/neural_components.py +270 -0
- diffbio/core/optimal_transport.py +133 -0
- diffbio/core/soft_ops/__init__.py +216 -0
- diffbio/core/soft_ops/_projections_permutahedron.py +1864 -0
- diffbio/core/soft_ops/_projections_simplex.py +240 -0
- diffbio/core/soft_ops/_projections_transport.py +508 -0
- diffbio/core/soft_ops/_sorting_network.py +204 -0
- diffbio/core/soft_ops/_types.py +15 -0
- diffbio/core/soft_ops/_utils.py +342 -0
- diffbio/core/soft_ops/autograd_safe.py +120 -0
- diffbio/core/soft_ops/comparison.py +235 -0
- diffbio/core/soft_ops/elementwise.py +309 -0
- diffbio/core/soft_ops/logical.py +146 -0
- diffbio/core/soft_ops/quantile.py +376 -0
- diffbio/core/soft_ops/selection.py +236 -0
- diffbio/core/soft_ops/sorting.py +926 -0
- diffbio/core/soft_ops/straight_through.py +261 -0
- diffbio/core/uncertainty.py +279 -0
- diffbio/evaluation/__init__.py +42 -0
- diffbio/evaluation/adapters.py +409 -0
- diffbio/evaluation/graders.py +223 -0
- diffbio/evaluation/problem.py +157 -0
- diffbio/evaluation/runner.py +277 -0
- diffbio/losses/__init__.py +59 -0
- diffbio/losses/alignment_losses.py +222 -0
- diffbio/losses/biological_regularization.py +288 -0
- diffbio/losses/metric_losses.py +139 -0
- diffbio/losses/singlecell_losses.py +387 -0
- diffbio/losses/statistical_losses.py +345 -0
- diffbio/operators/__init__.py +60 -0
- diffbio/operators/_count_vae.py +197 -0
- diffbio/operators/_loss_balancing.py +65 -0
- diffbio/operators/_masked_gene_transformer.py +118 -0
- diffbio/operators/_transformer_validation.py +50 -0
- diffbio/operators/alignment/__init__.py +51 -0
- diffbio/operators/alignment/profile_hmm.py +350 -0
- diffbio/operators/alignment/scoring.py +127 -0
- diffbio/operators/alignment/smith_waterman.py +261 -0
- diffbio/operators/alignment/soft_msa.py +419 -0
- diffbio/operators/assembly/__init__.py +27 -0
- diffbio/operators/assembly/gnn_assembly.py +252 -0
- diffbio/operators/assembly/metagenomic_binning.py +296 -0
- diffbio/operators/crispr/__init__.py +17 -0
- diffbio/operators/crispr/guide_scoring.py +269 -0
- diffbio/operators/drug_discovery/__init__.py +133 -0
- diffbio/operators/drug_discovery/_graph_utils.py +142 -0
- diffbio/operators/drug_discovery/admet_predictor.py +285 -0
- diffbio/operators/drug_discovery/attentive_fp.py +411 -0
- diffbio/operators/drug_discovery/dti.py +261 -0
- diffbio/operators/drug_discovery/fingerprint.py +490 -0
- diffbio/operators/drug_discovery/maccs_keys.py +267 -0
- diffbio/operators/drug_discovery/message_passing.py +200 -0
- diffbio/operators/drug_discovery/primitives.py +242 -0
- diffbio/operators/drug_discovery/property_predictor.py +163 -0
- diffbio/operators/drug_discovery/similarity.py +193 -0
- diffbio/operators/epigenomics/__init__.py +35 -0
- diffbio/operators/epigenomics/chromatin_state.py +491 -0
- diffbio/operators/epigenomics/contextual.py +288 -0
- diffbio/operators/epigenomics/fno_peak_calling.py +153 -0
- diffbio/operators/epigenomics/peak_calling.py +555 -0
- diffbio/operators/foundation_models/__init__.py +119 -0
- diffbio/operators/foundation_models/adapters.py +114 -0
- diffbio/operators/foundation_models/contracts.py +245 -0
- diffbio/operators/foundation_models/embedding_probe.py +83 -0
- diffbio/operators/foundation_models/experimental.py +128 -0
- diffbio/operators/foundation_models/foundation_model.py +332 -0
- diffbio/operators/foundation_models/frozen.py +59 -0
- diffbio/operators/foundation_models/precomputed.py +270 -0
- diffbio/operators/foundation_models/transformer_encoder.py +564 -0
- diffbio/operators/mapping/__init__.py +17 -0
- diffbio/operators/mapping/neural_mapper.py +493 -0
- diffbio/operators/metabolomics/__init__.py +39 -0
- diffbio/operators/metabolomics/spectral_similarity.py +315 -0
- diffbio/operators/molecular_dynamics/__init__.py +51 -0
- diffbio/operators/molecular_dynamics/force_field.py +265 -0
- diffbio/operators/molecular_dynamics/integrator.py +304 -0
- diffbio/operators/molecular_dynamics/primitives.py +115 -0
- diffbio/operators/multiomics/__init__.py +38 -0
- diffbio/operators/multiomics/hic_contact.py +377 -0
- diffbio/operators/multiomics/multiomics_vae.py +325 -0
- diffbio/operators/multiomics/spatial_deconvolution.py +316 -0
- diffbio/operators/multiomics/spatial_gene_detection.py +493 -0
- diffbio/operators/normalization/__init__.py +42 -0
- diffbio/operators/normalization/embedding.py +222 -0
- diffbio/operators/normalization/phate.py +400 -0
- diffbio/operators/normalization/umap.py +261 -0
- diffbio/operators/normalization/vae_normalizer.py +258 -0
- diffbio/operators/population/__init__.py +17 -0
- diffbio/operators/population/ancestry_estimation.py +274 -0
- diffbio/operators/preprocessing/__init__.py +76 -0
- diffbio/operators/preprocessing/adapter_removal.py +311 -0
- diffbio/operators/preprocessing/duplicate_filter.py +317 -0
- diffbio/operators/preprocessing/error_correction.py +287 -0
- diffbio/operators/protein/__init__.py +31 -0
- diffbio/operators/protein/secondary_structure.py +509 -0
- diffbio/operators/quality_filter.py +128 -0
- diffbio/operators/rna_structure/__init__.py +35 -0
- diffbio/operators/rna_structure/rna_folding.py +509 -0
- diffbio/operators/rnaseq/__init__.py +23 -0
- diffbio/operators/rnaseq/motif_discovery.py +251 -0
- diffbio/operators/rnaseq/splicing_psi.py +216 -0
- diffbio/operators/singlecell/__init__.py +193 -0
- diffbio/operators/singlecell/ambient_removal.py +333 -0
- diffbio/operators/singlecell/archetypes.py +191 -0
- diffbio/operators/singlecell/batch_correction.py +288 -0
- diffbio/operators/singlecell/cell_annotation.py +519 -0
- diffbio/operators/singlecell/communication.py +704 -0
- diffbio/operators/singlecell/differential_distribution.py +243 -0
- diffbio/operators/singlecell/doublet_detection.py +657 -0
- diffbio/operators/singlecell/downsampling.py +166 -0
- diffbio/operators/singlecell/enhanced_batch_correction.py +519 -0
- diffbio/operators/singlecell/grn_inference.py +336 -0
- diffbio/operators/singlecell/imputation.py +429 -0
- diffbio/operators/singlecell/knockdown_filter.py +176 -0
- diffbio/operators/singlecell/ot_trajectory.py +277 -0
- diffbio/operators/singlecell/simulation.py +444 -0
- diffbio/operators/singlecell/sindy_grn.py +247 -0
- diffbio/operators/singlecell/soft_clustering.py +211 -0
- diffbio/operators/singlecell/spatial_domains.py +677 -0
- diffbio/operators/singlecell/switch_de.py +184 -0
- diffbio/operators/singlecell/trajectory.py +447 -0
- diffbio/operators/singlecell/velocity.py +361 -0
- diffbio/operators/statistical/__init__.py +35 -0
- diffbio/operators/statistical/em_quantification.py +260 -0
- diffbio/operators/statistical/hmm.py +234 -0
- diffbio/operators/statistical/nb_glm.py +272 -0
- diffbio/operators/variant/__init__.py +64 -0
- diffbio/operators/variant/classifier.py +333 -0
- diffbio/operators/variant/cnn_classifier.py +255 -0
- diffbio/operators/variant/cnv_segmentation.py +678 -0
- diffbio/operators/variant/deepvariant_pileup.py +426 -0
- diffbio/operators/variant/pileup.py +240 -0
- diffbio/operators/variant/quality_recalibration.py +274 -0
- diffbio/pipelines/__init__.py +65 -0
- diffbio/pipelines/differential_expression.py +279 -0
- diffbio/pipelines/enhanced_variant_calling.py +326 -0
- diffbio/pipelines/perturbation.py +407 -0
- diffbio/pipelines/preprocessing.py +267 -0
- diffbio/pipelines/single_cell.py +366 -0
- diffbio/pipelines/variant_calling.py +490 -0
- diffbio/samplers/__init__.py +9 -0
- diffbio/samplers/perturbation_sampler.py +142 -0
- diffbio/sequences/__init__.py +34 -0
- diffbio/sequences/dna.py +239 -0
- diffbio/sources/__init__.py +149 -0
- diffbio/sources/_anndata_shared.py +89 -0
- diffbio/sources/_batch_iteration.py +37 -0
- diffbio/sources/_benchmark_source.py +152 -0
- diffbio/sources/_indexed_batch_source.py +38 -0
- diffbio/sources/_utils.py +45 -0
- diffbio/sources/anndata_interop.py +387 -0
- diffbio/sources/anndata_source.py +361 -0
- diffbio/sources/archive_ii.py +174 -0
- diffbio/sources/balifam.py +207 -0
- diffbio/sources/bam.py +265 -0
- diffbio/sources/bengrn_ground_truth.py +306 -0
- diffbio/sources/contextual_epigenomics.py +242 -0
- diffbio/sources/dti.py +359 -0
- diffbio/sources/embeddings.py +203 -0
- diffbio/sources/encode_peaks.py +223 -0
- diffbio/sources/fasta.py +226 -0
- diffbio/sources/immune_human.py +172 -0
- diffbio/sources/indexed_embeddings.py +128 -0
- diffbio/sources/indexed_view.py +191 -0
- diffbio/sources/molnet.py +493 -0
- diffbio/sources/multiomics.py +279 -0
- diffbio/sources/pancreas.py +108 -0
- diffbio/sources/perturbation/__init__.py +69 -0
- diffbio/sources/perturbation/_types.py +51 -0
- diffbio/sources/perturbation/_utils.py +125 -0
- diffbio/sources/perturbation/concat_source.py +115 -0
- diffbio/sources/perturbation/control_mapping.py +215 -0
- diffbio/sources/perturbation/experiment_config.py +261 -0
- diffbio/sources/perturbation/h5_metadata_cache.py +218 -0
- diffbio/sources/perturbation/output_space.py +52 -0
- diffbio/sources/perturbation/perturbation_source.py +513 -0
- diffbio/sources/seqfish.py +145 -0
- diffbio/sources/sequence_foundation.py +68 -0
- diffbio/sources/singlecell_foundation.py +68 -0
- diffbio/splitters/__init__.py +63 -0
- diffbio/splitters/base.py +251 -0
- diffbio/splitters/molecular.py +330 -0
- diffbio/splitters/perturbation.py +199 -0
- diffbio/splitters/random.py +217 -0
- diffbio/splitters/sequence.py +201 -0
- diffbio/utils/__init__.py +55 -0
- diffbio/utils/dependency_runtime.py +115 -0
- diffbio/utils/nn_utils.py +157 -0
- diffbio/utils/quality.py +45 -0
- diffbio/utils/training.py +585 -0
- diffbio-0.1.0.dist-info/METADATA +480 -0
- diffbio-0.1.0.dist-info/RECORD +202 -0
- diffbio-0.1.0.dist-info/WHEEL +4 -0
- diffbio-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"""Differentiable doublet detection for single-cell data.
|
|
2
|
+
|
|
3
|
+
This module provides two complementary doublet detection strategies:
|
|
4
|
+
|
|
5
|
+
1. **Scrublet-style (DifferentiableDoubletScorer)**: Generates synthetic
|
|
6
|
+
doublets by summing random cell pairs, then scores real cells via a
|
|
7
|
+
Bayesian k-NN likelihood ratio in PCA space.
|
|
8
|
+
|
|
9
|
+
2. **Solo-style VAE (DifferentiableSoloDetector)**: Encodes cells through a
|
|
10
|
+
VAE into a latent space, generates synthetic doublets, and trains a binary
|
|
11
|
+
classifier on latent representations to distinguish singlets from doublets.
|
|
12
|
+
Based on Bernstein et al., Cell Systems 2020.
|
|
13
|
+
|
|
14
|
+
Key techniques:
|
|
15
|
+
- Soft k-NN neighbor counting with differentiable thresholding (Scrublet)
|
|
16
|
+
- VAE reparameterization + latent-space classifier (Solo)
|
|
17
|
+
|
|
18
|
+
Applications: Identifying doublet artifacts in scRNA-seq data as a
|
|
19
|
+
preprocessing step before downstream analysis (clustering, trajectory, DE).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import jax
|
|
27
|
+
import jax.numpy as jnp
|
|
28
|
+
from artifex.generative_models.core.losses.divergence import gaussian_kl_divergence
|
|
29
|
+
from datarax.core.config import OperatorConfig
|
|
30
|
+
from datarax.core.operator import OperatorModule
|
|
31
|
+
from flax import nnx
|
|
32
|
+
from jaxtyping import Array, Float, PyTree
|
|
33
|
+
|
|
34
|
+
from diffbio.constants import DISTANCE_MASK_SENTINEL
|
|
35
|
+
from diffbio.core import soft_ops
|
|
36
|
+
from diffbio.core.base_operators import EncoderDecoderOperator
|
|
37
|
+
from diffbio.core.graph_utils import compute_pairwise_distances
|
|
38
|
+
from diffbio.operators._count_vae import CountVAEBackboneMixin
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def generate_synthetic_doublets(
|
|
44
|
+
counts: Float[Array, "n_cells n_genes"],
|
|
45
|
+
rng: jax.Array,
|
|
46
|
+
sim_doublet_ratio: float,
|
|
47
|
+
) -> Float[Array, "n_synthetic n_genes"]:
|
|
48
|
+
"""Generate synthetic doublets by summing random cell pairs.
|
|
49
|
+
|
|
50
|
+
The number of synthetic doublets is ``n_cells * sim_doublet_ratio``,
|
|
51
|
+
matching Scrublet's default 2:1 synthetic-to-real ratio.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
counts: Real count matrix of shape ``(n_cells, n_genes)``.
|
|
55
|
+
rng: JAX random key for pair selection.
|
|
56
|
+
sim_doublet_ratio: Ratio of synthetic doublets to real cells.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Synthetic doublet profiles of shape
|
|
60
|
+
``(n_cells * sim_doublet_ratio, n_genes)``.
|
|
61
|
+
"""
|
|
62
|
+
n_cells = counts.shape[0]
|
|
63
|
+
n_synthetic = int(n_cells * sim_doublet_ratio)
|
|
64
|
+
k1, k2 = jax.random.split(rng)
|
|
65
|
+
idx_a = jax.random.randint(k1, (n_synthetic,), 0, n_cells)
|
|
66
|
+
idx_b = jax.random.randint(k2, (n_synthetic,), 0, n_cells)
|
|
67
|
+
return counts[idx_a] + counts[idx_b]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class DoubletScorerConfig(OperatorConfig):
|
|
72
|
+
"""Configuration for Scrublet-style doublet detection.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
n_neighbors: Base number of nearest neighbors for scoring (adjusted
|
|
76
|
+
upward to account for synthetic pool size).
|
|
77
|
+
expected_doublet_rate: Prior expected fraction of doublets (rho in
|
|
78
|
+
the Bayesian likelihood ratio).
|
|
79
|
+
sim_doublet_ratio: Ratio of synthetic doublets to real cells. Scrublet
|
|
80
|
+
default is 2.0, meaning 2x as many synthetics as real cells.
|
|
81
|
+
n_pca_components: Number of PCA components for embedding.
|
|
82
|
+
n_genes: Number of genes in expression profiles.
|
|
83
|
+
threshold_temperature: Temperature for sigmoid doublet thresholding.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
n_neighbors: int = 30
|
|
87
|
+
expected_doublet_rate: float = 0.06
|
|
88
|
+
sim_doublet_ratio: float = 2.0
|
|
89
|
+
n_pca_components: int = 30
|
|
90
|
+
n_genes: int = 2000
|
|
91
|
+
threshold_temperature: float = 10.0
|
|
92
|
+
|
|
93
|
+
def __post_init__(self) -> None:
|
|
94
|
+
"""Set stochastic defaults and validate."""
|
|
95
|
+
object.__setattr__(self, "stochastic", True)
|
|
96
|
+
if self.stream_name is None:
|
|
97
|
+
object.__setattr__(self, "stream_name", "sample")
|
|
98
|
+
super().__post_init__()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class DifferentiableDoubletScorer(OperatorModule):
|
|
102
|
+
"""Differentiable Scrublet-style doublet detection operator.
|
|
103
|
+
|
|
104
|
+
Detects doublets by generating synthetic doublet profiles from random
|
|
105
|
+
cell pairs, embedding real and synthetic cells into PCA space, and
|
|
106
|
+
scoring each real cell via the Bayesian k-NN likelihood ratio from
|
|
107
|
+
Scrublet (Wolock et al., 2019).
|
|
108
|
+
|
|
109
|
+
Algorithm:
|
|
110
|
+
1. Generate ``n_cells * sim_doublet_ratio`` synthetic doublets
|
|
111
|
+
2. Concatenate real and synthetic cells
|
|
112
|
+
3. PCA-embed via truncated SVD
|
|
113
|
+
4. Compute pairwise distances in PCA space
|
|
114
|
+
5. Adjust k upward: ``k_adj = round(k * (1 + n_syn / n_cells))``
|
|
115
|
+
6. Count soft synthetic neighbors in each real cell's k-NN
|
|
116
|
+
7. Compute Laplace-smoothed fraction ``q`` of synthetic neighbors
|
|
117
|
+
8. Bayesian likelihood ratio: ``Ld = q * rho / r / denom``
|
|
118
|
+
9. Apply sigmoid threshold for predicted doublet calls
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
config: DoubletScorerConfig with operator parameters.
|
|
122
|
+
rngs: Flax NNX random number generators.
|
|
123
|
+
name: Optional operator name.
|
|
124
|
+
|
|
125
|
+
Example:
|
|
126
|
+
>>> config = DoubletScorerConfig(n_neighbors=30, n_pca_components=30,
|
|
127
|
+
... n_genes=2000)
|
|
128
|
+
>>> scorer = DifferentiableDoubletScorer(config, rngs=nnx.Rngs(0))
|
|
129
|
+
>>> rng = jax.random.key(0)
|
|
130
|
+
>>> rp = scorer.generate_random_params(rng, {"counts": (500, 2000)})
|
|
131
|
+
>>> result, state, meta = scorer.apply({"counts": counts}, {}, None,
|
|
132
|
+
... random_params=rp)
|
|
133
|
+
>>> result["doublet_scores"].shape
|
|
134
|
+
(500,)
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
config: DoubletScorerConfig,
|
|
140
|
+
*,
|
|
141
|
+
rngs: nnx.Rngs | None = None,
|
|
142
|
+
name: str | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Initialize the doublet scorer.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
config: Doublet scorer configuration.
|
|
148
|
+
rngs: Random number generators for stochastic operations.
|
|
149
|
+
name: Optional operator name.
|
|
150
|
+
"""
|
|
151
|
+
super().__init__(config, rngs=rngs, name=name)
|
|
152
|
+
|
|
153
|
+
def generate_random_params(
|
|
154
|
+
self,
|
|
155
|
+
rng: jax.Array,
|
|
156
|
+
data_shapes: PyTree,
|
|
157
|
+
) -> jax.Array:
|
|
158
|
+
"""Generate random parameters for doublet pair selection.
|
|
159
|
+
|
|
160
|
+
Produces two arrays of random cell indices used to form synthetic
|
|
161
|
+
doublets by pairwise summation.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
rng: JAX random key.
|
|
165
|
+
data_shapes: PyTree with shapes, must contain ``"counts"`` key
|
|
166
|
+
whose first dimension is the number of cells.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
A JAX random key for reproducible pair generation inside apply.
|
|
170
|
+
"""
|
|
171
|
+
return rng
|
|
172
|
+
|
|
173
|
+
def _pca_embed(
|
|
174
|
+
self,
|
|
175
|
+
data: Float[Array, "n_total n_genes"],
|
|
176
|
+
n_components: int,
|
|
177
|
+
) -> Float[Array, "n_total n_components"]:
|
|
178
|
+
"""Embed data into PCA space via truncated SVD.
|
|
179
|
+
|
|
180
|
+
Centers the data, computes SVD, and projects onto the top
|
|
181
|
+
``n_components`` principal components.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
data: Input matrix of shape ``(n_total, n_genes)``.
|
|
185
|
+
n_components: Number of PCA dimensions to keep.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
PCA embeddings of shape ``(n_total, n_components)``.
|
|
189
|
+
"""
|
|
190
|
+
centered = data - jnp.mean(data, axis=0, keepdims=True)
|
|
191
|
+
# Truncated SVD: project onto top-n_components right singular vectors
|
|
192
|
+
_, _, vt = jnp.linalg.svd(centered, full_matrices=False)
|
|
193
|
+
# Clamp n_components to available dimensions
|
|
194
|
+
n_components_eff = min(n_components, vt.shape[0])
|
|
195
|
+
projection = vt[:n_components_eff] # (n_components_eff, n_genes)
|
|
196
|
+
return centered @ projection.T
|
|
197
|
+
|
|
198
|
+
def _compute_soft_knn_synthetic_count(
|
|
199
|
+
self,
|
|
200
|
+
distances: Float[Array, "n_real n_total"],
|
|
201
|
+
n_real: int,
|
|
202
|
+
n_total: int,
|
|
203
|
+
k_adj: int,
|
|
204
|
+
) -> Float[Array, "n_real"]:
|
|
205
|
+
"""Compute soft count of synthetic neighbors in adjusted k-NN.
|
|
206
|
+
|
|
207
|
+
Uses a softmax-weighted membership approach: for each real cell,
|
|
208
|
+
the ``k_adj`` smallest distances are selected, and the weighted
|
|
209
|
+
count of synthetic neighbors is returned for the Bayesian
|
|
210
|
+
likelihood-ratio formula.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
distances: Distance matrix from real cells to all cells,
|
|
214
|
+
shape ``(n_real, n_total)``.
|
|
215
|
+
n_real: Number of real cells.
|
|
216
|
+
n_total: Total number of cells (real + synthetic).
|
|
217
|
+
k_adj: Adjusted number of nearest neighbors (accounts for
|
|
218
|
+
synthetic pool size).
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Soft synthetic neighbor count per real cell, shape ``(n_real,)``.
|
|
222
|
+
"""
|
|
223
|
+
k_eff = min(k_adj, n_total - 1)
|
|
224
|
+
|
|
225
|
+
# Create label vector: 0 for real, 1 for synthetic
|
|
226
|
+
is_synthetic = jnp.concatenate(
|
|
227
|
+
[
|
|
228
|
+
jnp.zeros(n_real),
|
|
229
|
+
jnp.ones(n_total - n_real),
|
|
230
|
+
]
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Mask self-distances for real cells (first n_real columns correspond to real)
|
|
234
|
+
self_mask = jnp.eye(n_real, n_total) * DISTANCE_MASK_SENTINEL
|
|
235
|
+
masked_distances = distances + self_mask
|
|
236
|
+
|
|
237
|
+
# Soft k-NN: convert distances to weights via negative exponential
|
|
238
|
+
# Use temperature scaling for smoother gradients
|
|
239
|
+
sigma = jnp.sort(masked_distances, axis=-1)[:, k_eff - 1 : k_eff]
|
|
240
|
+
sigma = jnp.maximum(sigma, 1e-8)
|
|
241
|
+
weights = jnp.exp(-masked_distances / sigma)
|
|
242
|
+
|
|
243
|
+
# Zero out self-connections
|
|
244
|
+
weights = weights * (1.0 - jnp.eye(n_real, n_total))
|
|
245
|
+
|
|
246
|
+
# Get top-k mask via soft approximation: use sorted threshold
|
|
247
|
+
sorted_dists = jnp.sort(masked_distances, axis=-1)
|
|
248
|
+
kth_dist = sorted_dists[:, k_eff - 1 : k_eff] # (n_real, 1)
|
|
249
|
+
# Soft indicator for being within k-NN (sigmoid approximation)
|
|
250
|
+
temperature = 10.0
|
|
251
|
+
knn_mask = soft_ops.greater(kth_dist, masked_distances, softness=1.0 / temperature)
|
|
252
|
+
|
|
253
|
+
# Weighted synthetic count (not fraction -- the Bayesian formula needs count)
|
|
254
|
+
masked_weights = weights * knn_mask
|
|
255
|
+
synthetic_weight = masked_weights @ is_synthetic
|
|
256
|
+
|
|
257
|
+
return synthetic_weight
|
|
258
|
+
|
|
259
|
+
def apply(
|
|
260
|
+
self,
|
|
261
|
+
data: PyTree,
|
|
262
|
+
state: PyTree,
|
|
263
|
+
metadata: dict[str, Any] | None,
|
|
264
|
+
random_params: Any = None,
|
|
265
|
+
stats: dict[str, Any] | None = None,
|
|
266
|
+
) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
|
|
267
|
+
"""Apply doublet detection to single-cell count data.
|
|
268
|
+
|
|
269
|
+
Implements Scrublet's Bayesian k-NN likelihood-ratio scoring:
|
|
270
|
+
|
|
271
|
+
1. Generate ``n_cells * sim_doublet_ratio`` synthetic doublets
|
|
272
|
+
2. Adjust k upward: ``k_adj = round(k * (1 + n_syn / n_cells))``
|
|
273
|
+
3. For each real cell, count synthetic neighbors in its k-NN
|
|
274
|
+
4. Compute Laplace-smoothed fraction ``q = (syn_count + 1) / (k_adj + 2)``
|
|
275
|
+
5. Bayesian likelihood ratio:
|
|
276
|
+
``Ld = q * rho / r / (1 - rho - q*(1 - rho - rho/r))``
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
data: Dictionary containing:
|
|
280
|
+
- ``"counts"``: Gene expression matrix ``(n_cells, n_genes)``
|
|
281
|
+
state: Element state (passed through unchanged).
|
|
282
|
+
metadata: Element metadata (passed through unchanged).
|
|
283
|
+
random_params: JAX random key for synthetic doublet generation.
|
|
284
|
+
stats: Not used.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Tuple of (transformed_data, state, metadata):
|
|
288
|
+
- transformed_data contains:
|
|
289
|
+
|
|
290
|
+
- ``"counts"``: Original counts
|
|
291
|
+
- ``"doublet_scores"``: Bayesian likelihood ratio per cell
|
|
292
|
+
- ``"predicted_doublets"``: Soft doublet predictions in [0, 1]
|
|
293
|
+
- state is passed through unchanged
|
|
294
|
+
- metadata is passed through unchanged
|
|
295
|
+
"""
|
|
296
|
+
counts = data["counts"]
|
|
297
|
+
n_cells = counts.shape[0]
|
|
298
|
+
config = self.config
|
|
299
|
+
|
|
300
|
+
# Use provided random key or fallback
|
|
301
|
+
rng = random_params if random_params is not None else jax.random.key(0)
|
|
302
|
+
|
|
303
|
+
# Step 1: Generate synthetic doublets (n_cells * sim_doublet_ratio)
|
|
304
|
+
synthetic = generate_synthetic_doublets(counts, rng, config.sim_doublet_ratio)
|
|
305
|
+
n_synthetic = synthetic.shape[0]
|
|
306
|
+
|
|
307
|
+
# Step 2: Combine real + synthetic
|
|
308
|
+
combined = jnp.concatenate([counts, synthetic], axis=0)
|
|
309
|
+
n_total = combined.shape[0]
|
|
310
|
+
|
|
311
|
+
# Step 3: PCA embed
|
|
312
|
+
pca = self._pca_embed(combined, config.n_pca_components)
|
|
313
|
+
|
|
314
|
+
# Step 4: Pairwise distances in PCA space, then extract real-to-all block
|
|
315
|
+
distances = compute_pairwise_distances(pca)
|
|
316
|
+
real_to_all = distances[:n_cells]
|
|
317
|
+
|
|
318
|
+
# Step 5: Adjust k for the enlarged pool (Scrublet convention)
|
|
319
|
+
k_adj = round(config.n_neighbors * (1 + n_synthetic / n_cells))
|
|
320
|
+
|
|
321
|
+
# Step 6: Soft k-NN synthetic neighbor count
|
|
322
|
+
syn_neighbor_count = self._compute_soft_knn_synthetic_count(
|
|
323
|
+
real_to_all, n_cells, n_total, k_adj
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Step 7: Bayesian likelihood-ratio scoring (Scrublet formula)
|
|
327
|
+
# Clip soft synthetic count so it cannot exceed k_adj (soft weights
|
|
328
|
+
# may overshoot the hard budget, making the denominator negative).
|
|
329
|
+
syn_neighbor_count = jnp.clip(syn_neighbor_count, 0.0, float(k_adj))
|
|
330
|
+
|
|
331
|
+
# q = Laplace-smoothed fraction of synthetic neighbors
|
|
332
|
+
q = (syn_neighbor_count + 1) / (k_adj + 2)
|
|
333
|
+
rho = config.expected_doublet_rate
|
|
334
|
+
r = n_synthetic / n_cells # synthetic-to-real ratio
|
|
335
|
+
|
|
336
|
+
# Denominator with numerical guard to avoid division by zero
|
|
337
|
+
denominator = jnp.maximum(1 - rho - q * (1 - rho - rho / r), 1e-8)
|
|
338
|
+
doublet_scores = q * rho / r / denominator
|
|
339
|
+
|
|
340
|
+
# Step 8: Soft threshold for predicted doublets (sigmoid on score)
|
|
341
|
+
predicted_doublets = soft_ops.greater(
|
|
342
|
+
doublet_scores, 0.5, softness=1.0 / config.threshold_temperature
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
transformed_data = {
|
|
346
|
+
**data,
|
|
347
|
+
"doublet_scores": doublet_scores,
|
|
348
|
+
"predicted_doublets": predicted_doublets,
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return transformed_data, state, metadata
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# =============================================================================
|
|
355
|
+
# Solo-style VAE Doublet Detector
|
|
356
|
+
# =============================================================================
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@dataclass(frozen=True)
|
|
360
|
+
class SoloDetectorConfig(OperatorConfig):
|
|
361
|
+
"""Configuration for Solo-style VAE doublet detection.
|
|
362
|
+
|
|
363
|
+
Solo (Bernstein et al., Cell Systems 2020) trains a VAE on real cells,
|
|
364
|
+
generates synthetic doublets, encodes both into latent space, and trains
|
|
365
|
+
a binary classifier to distinguish singlets from doublets.
|
|
366
|
+
|
|
367
|
+
Attributes:
|
|
368
|
+
n_genes: Number of genes in expression profiles.
|
|
369
|
+
latent_dim: Dimension of the VAE latent space.
|
|
370
|
+
hidden_dims: Hidden layer dimensions for encoder/decoder.
|
|
371
|
+
classifier_hidden_dim: Hidden dimension for the latent-space classifier.
|
|
372
|
+
sim_doublet_ratio: Ratio of synthetic doublets to real cells.
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
n_genes: int = 2000
|
|
376
|
+
latent_dim: int = 10
|
|
377
|
+
hidden_dims: list[int] = field(default_factory=lambda: [128, 64])
|
|
378
|
+
classifier_hidden_dim: int = 64
|
|
379
|
+
sim_doublet_ratio: float = 2.0
|
|
380
|
+
|
|
381
|
+
def __post_init__(self) -> None:
|
|
382
|
+
"""Set stochastic defaults and validate."""
|
|
383
|
+
object.__setattr__(self, "stochastic", True)
|
|
384
|
+
if self.stream_name is None:
|
|
385
|
+
object.__setattr__(self, "stream_name", "sample")
|
|
386
|
+
super().__post_init__()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class DifferentiableSoloDetector(CountVAEBackboneMixin, EncoderDecoderOperator):
|
|
390
|
+
"""Solo-style VAE doublet detector.
|
|
391
|
+
|
|
392
|
+
Detects doublets by encoding cells through a VAE, generating synthetic
|
|
393
|
+
doublets in count space, then classifying real vs synthetic cells in the
|
|
394
|
+
VAE latent space.
|
|
395
|
+
|
|
396
|
+
Algorithm:
|
|
397
|
+
1. Generate synthetic doublets by summing random cell pairs
|
|
398
|
+
2. Concatenate real and synthetic counts
|
|
399
|
+
3. Encode all cells through the VAE encoder to obtain (mean, logvar)
|
|
400
|
+
4. Sample latent z via the reparameterization trick
|
|
401
|
+
5. Run a binary classifier on real-cell latents
|
|
402
|
+
6. Return doublet probabilities, labels, and latent representations
|
|
403
|
+
|
|
404
|
+
Architecture:
|
|
405
|
+
- Encoder: counts -> log1p -> hidden layers (ReLU) -> (mean, logvar)
|
|
406
|
+
- Decoder: z -> hidden layers (ReLU) -> log_rate
|
|
407
|
+
- Classifier: z -> Linear -> ReLU -> Linear -> sigmoid
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
config: SoloDetectorConfig with model parameters.
|
|
411
|
+
rngs: Flax NNX random number generators.
|
|
412
|
+
name: Optional operator name.
|
|
413
|
+
|
|
414
|
+
Example:
|
|
415
|
+
>>> config = SoloDetectorConfig(n_genes=2000, latent_dim=10)
|
|
416
|
+
>>> detector = DifferentiableSoloDetector(config, rngs=nnx.Rngs(42))
|
|
417
|
+
>>> rng = jax.random.key(0)
|
|
418
|
+
>>> rp = detector.generate_random_params(rng, {"counts": (500, 2000)})
|
|
419
|
+
>>> result, _, _ = detector.apply({"counts": counts}, {}, None, random_params=rp)
|
|
420
|
+
>>> result["doublet_probabilities"].shape
|
|
421
|
+
(500,)
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
def __init__(
|
|
425
|
+
self,
|
|
426
|
+
config: SoloDetectorConfig,
|
|
427
|
+
*,
|
|
428
|
+
rngs: nnx.Rngs | None = None,
|
|
429
|
+
name: str | None = None,
|
|
430
|
+
) -> None:
|
|
431
|
+
"""Initialize the Solo VAE doublet detector.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
config: Solo detector configuration.
|
|
435
|
+
rngs: Random number generators for initialization and sampling.
|
|
436
|
+
name: Optional operator name.
|
|
437
|
+
"""
|
|
438
|
+
super().__init__(config, rngs=rngs, name=name)
|
|
439
|
+
|
|
440
|
+
safe_rngs = self._init_count_vae_operator(config=config, rngs=rngs)
|
|
441
|
+
|
|
442
|
+
# --- Classifier (operates on latent z) ---
|
|
443
|
+
self.classifier_hidden = nnx.Linear(
|
|
444
|
+
in_features=config.latent_dim,
|
|
445
|
+
out_features=config.classifier_hidden_dim,
|
|
446
|
+
rngs=safe_rngs,
|
|
447
|
+
)
|
|
448
|
+
self.classifier_output = nnx.Linear(
|
|
449
|
+
in_features=config.classifier_hidden_dim,
|
|
450
|
+
out_features=1,
|
|
451
|
+
rngs=safe_rngs,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
def generate_random_params(
|
|
455
|
+
self,
|
|
456
|
+
rng: jax.Array,
|
|
457
|
+
data_shapes: PyTree,
|
|
458
|
+
) -> jax.Array:
|
|
459
|
+
"""Generate random parameters for synthetic doublet pair selection.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
rng: JAX random key.
|
|
463
|
+
data_shapes: PyTree with shapes (must contain ``"counts"`` key).
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
A JAX random key for reproducible pair generation inside apply.
|
|
467
|
+
"""
|
|
468
|
+
return rng
|
|
469
|
+
|
|
470
|
+
def decode(
|
|
471
|
+
self,
|
|
472
|
+
z: Float[Array, "batch latent_dim"],
|
|
473
|
+
) -> Float[Array, "batch n_genes"]:
|
|
474
|
+
"""Decode latent representation to gene expression log-rates.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
z: Latent representation, shape ``(batch, latent_dim)``.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
Log rates for each gene, shape ``(batch, n_genes)``.
|
|
481
|
+
"""
|
|
482
|
+
return self.decode_rates(z)
|
|
483
|
+
|
|
484
|
+
def classify(
|
|
485
|
+
self,
|
|
486
|
+
z: Float[Array, "batch latent_dim"],
|
|
487
|
+
) -> Float[Array, "batch"]:
|
|
488
|
+
"""Classify latent representations as singlet vs doublet.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
z: Latent representations, shape ``(batch, latent_dim)``.
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
Doublet probabilities in [0, 1], shape ``(batch,)``.
|
|
495
|
+
"""
|
|
496
|
+
h = nnx.relu(self.classifier_hidden(z))
|
|
497
|
+
logits = self.classifier_output(h)
|
|
498
|
+
return jax.nn.sigmoid(logits).squeeze(-1)
|
|
499
|
+
|
|
500
|
+
def compute_elbo_loss(
|
|
501
|
+
self,
|
|
502
|
+
counts: Float[Array, "batch n_genes"],
|
|
503
|
+
) -> Float[Array, ""]:
|
|
504
|
+
"""Compute negative ELBO loss for the VAE.
|
|
505
|
+
|
|
506
|
+
Uses gaussian_kl_divergence from artifex for the KL term and
|
|
507
|
+
Poisson NLL for reconstruction.
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
counts: Gene expression counts, shape ``(batch, n_genes)``.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
Negative ELBO (reconstruction loss + KL divergence).
|
|
514
|
+
"""
|
|
515
|
+
mean, logvar = self.encode(counts)
|
|
516
|
+
z = self.reparameterize(mean, logvar)
|
|
517
|
+
log_rate = self.decode(z)
|
|
518
|
+
|
|
519
|
+
# Poisson NLL reconstruction loss
|
|
520
|
+
rate = jnp.exp(log_rate)
|
|
521
|
+
recon_loss = jnp.sum(rate - counts * log_rate)
|
|
522
|
+
|
|
523
|
+
# KL divergence via artifex
|
|
524
|
+
kl = gaussian_kl_divergence(mean, logvar, reduction="sum")
|
|
525
|
+
|
|
526
|
+
return recon_loss + kl
|
|
527
|
+
|
|
528
|
+
def compute_solo_loss(
|
|
529
|
+
self,
|
|
530
|
+
counts: Float[Array, "batch n_genes"],
|
|
531
|
+
random_params: jax.Array,
|
|
532
|
+
classifier_weight: float = 1.0,
|
|
533
|
+
) -> dict[str, Float[Array, ""]]:
|
|
534
|
+
"""Full Solo training loss: VAE ELBO + classifier binary cross-entropy.
|
|
535
|
+
|
|
536
|
+
Generates synthetic doublets, encodes all cells (real + synthetic)
|
|
537
|
+
through the VAE, computes the ELBO on the combined set, and adds
|
|
538
|
+
a binary cross-entropy term from the classifier distinguishing
|
|
539
|
+
singlets from doublets in latent space.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
counts: Real gene expression counts, shape ``(n_real, n_genes)``.
|
|
543
|
+
random_params: JAX random key for synthetic doublet generation.
|
|
544
|
+
classifier_weight: Weight for the classifier BCE term
|
|
545
|
+
(default 1.0).
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
Dictionary with ``"total_loss"``, ``"elbo"``, and
|
|
549
|
+
``"classifier_loss"`` scalar entries.
|
|
550
|
+
"""
|
|
551
|
+
n_real = counts.shape[0]
|
|
552
|
+
|
|
553
|
+
# 1. Generate synthetic doublets
|
|
554
|
+
synthetic = generate_synthetic_doublets(
|
|
555
|
+
counts, random_params, self.config.sim_doublet_ratio
|
|
556
|
+
)
|
|
557
|
+
n_synthetic = synthetic.shape[0]
|
|
558
|
+
|
|
559
|
+
# 2. Combine real + synthetic
|
|
560
|
+
combined = jnp.concatenate([counts, synthetic], axis=0)
|
|
561
|
+
|
|
562
|
+
# 3. Encode all to latent space
|
|
563
|
+
mean, logvar = self.encode(combined)
|
|
564
|
+
z = self.reparameterize(mean, logvar)
|
|
565
|
+
|
|
566
|
+
# 4. VAE ELBO on all cells
|
|
567
|
+
log_rate = self.decode(z)
|
|
568
|
+
rate = jnp.exp(log_rate)
|
|
569
|
+
recon_loss = jnp.sum(rate - combined * log_rate)
|
|
570
|
+
kl = gaussian_kl_divergence(mean, logvar, reduction="sum")
|
|
571
|
+
elbo = recon_loss + kl
|
|
572
|
+
|
|
573
|
+
# 5. Classifier BCE on all cells
|
|
574
|
+
# Labels: 0 for real (first n_real), 1 for synthetic
|
|
575
|
+
labels = jnp.concatenate([jnp.zeros(n_real), jnp.ones(n_synthetic)])
|
|
576
|
+
probs = self.classify(z) # returns sigmoid probabilities
|
|
577
|
+
# classify returns sigmoid(logits), so we use log-sigmoid formulation
|
|
578
|
+
bce = -jnp.mean(
|
|
579
|
+
labels * jnp.log(probs + 1e-8) + (1.0 - labels) * jnp.log(1.0 - probs + 1e-8)
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# 6. Total = ELBO + classifier_weight * BCE
|
|
583
|
+
total = elbo + classifier_weight * bce
|
|
584
|
+
|
|
585
|
+
return {"total_loss": total, "elbo": elbo, "classifier_loss": bce}
|
|
586
|
+
|
|
587
|
+
def apply(
|
|
588
|
+
self,
|
|
589
|
+
data: PyTree,
|
|
590
|
+
state: PyTree,
|
|
591
|
+
metadata: dict[str, Any] | None,
|
|
592
|
+
random_params: Any = None,
|
|
593
|
+
stats: dict[str, Any] | None = None,
|
|
594
|
+
) -> tuple[PyTree, PyTree, dict[str, Any] | None]:
|
|
595
|
+
"""Apply Solo-style VAE doublet detection.
|
|
596
|
+
|
|
597
|
+
Steps:
|
|
598
|
+
1. Generate synthetic doublets from random cell pairs
|
|
599
|
+
2. Concatenate real and synthetic counts
|
|
600
|
+
3. Encode all cells to latent space (mean, logvar)
|
|
601
|
+
4. Sample z via reparameterization trick
|
|
602
|
+
5. Run classifier on real-cell latents
|
|
603
|
+
6. Return probabilities, labels, and latent for real cells only
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
data: Dictionary containing:
|
|
607
|
+
- ``"counts"``: Gene expression matrix ``(n_cells, n_genes)``
|
|
608
|
+
state: Element state (passed through unchanged).
|
|
609
|
+
metadata: Element metadata (passed through unchanged).
|
|
610
|
+
random_params: JAX random key for synthetic doublet generation.
|
|
611
|
+
stats: Not used.
|
|
612
|
+
|
|
613
|
+
Returns:
|
|
614
|
+
Tuple of (transformed_data, state, metadata):
|
|
615
|
+
- transformed_data contains:
|
|
616
|
+
|
|
617
|
+
- ``"counts"``: Original counts
|
|
618
|
+
- ``"doublet_probabilities"``: Per-cell doublet probability
|
|
619
|
+
- ``"doublet_labels"``: Soft binary labels (sigmoid-thresholded)
|
|
620
|
+
- ``"latent"``: Latent representations for real cells
|
|
621
|
+
- state is passed through unchanged
|
|
622
|
+
- metadata is passed through unchanged
|
|
623
|
+
"""
|
|
624
|
+
counts = data["counts"]
|
|
625
|
+
n_cells = counts.shape[0]
|
|
626
|
+
config = self.config
|
|
627
|
+
|
|
628
|
+
# Use provided random key or fallback
|
|
629
|
+
rng = random_params if random_params is not None else jax.random.key(0)
|
|
630
|
+
|
|
631
|
+
# Step 1: Generate synthetic doublets
|
|
632
|
+
synthetic = generate_synthetic_doublets(counts, rng, config.sim_doublet_ratio)
|
|
633
|
+
|
|
634
|
+
# Step 2: Combine real + synthetic counts
|
|
635
|
+
combined = jnp.concatenate([counts, synthetic], axis=0)
|
|
636
|
+
|
|
637
|
+
# Step 3: Encode all to latent space
|
|
638
|
+
mean, logvar = self.encode(combined)
|
|
639
|
+
|
|
640
|
+
# Step 4: Sample z via reparameterization trick
|
|
641
|
+
z = self.reparameterize(mean, logvar)
|
|
642
|
+
|
|
643
|
+
# Step 5: Extract real-cell latents and classify
|
|
644
|
+
z_real = z[:n_cells]
|
|
645
|
+
doublet_probabilities = self.classify(z_real)
|
|
646
|
+
|
|
647
|
+
# Step 6: Soft binary labels via thresholding at 0.5
|
|
648
|
+
doublet_labels = jnp.round(doublet_probabilities)
|
|
649
|
+
|
|
650
|
+
transformed_data = {
|
|
651
|
+
**data,
|
|
652
|
+
"doublet_probabilities": doublet_probabilities,
|
|
653
|
+
"doublet_labels": doublet_labels,
|
|
654
|
+
"latent": z_real,
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return transformed_data, state, metadata
|