Single-Cell Atlas Path¶
Alongside the TCR-selection pipeline, TCRsift ships a gene-expression atlas
path that QCs, embeds, and annotates an AnnData of single cells:
cell_qc_funnel → embed_cells → annotate_cells / annotate_clusters
(per-cell QC (Pearson-residual (per-cluster cell-type +
+ doublet PCA → Harmony → T/B-state argmax with
funnel) UMAP → Leiden) biology-aware gates)
These are library functions; on the command line they are the
tcrsift cells qc / cells embed / cells annotate
subcommands. Everything below runs on a raw-count AnnData — for example the
object returned by tcrsift.load_sample.
End to end¶
import scanpy as sc
from tcrsift import cell_qc_funnel, embed_cells, annotate_cells
from tcrsift.config import LoadConfig, EmbedConfig
# adata.X is RAW COUNTS (or provide adata.layers["counts"]).
# 1) Per-cell QC + doublet funnel. Bare defaults are gentle (only the
# near-universal low-quality floors); the opinionated PBMC preset is one
# explicit config away. A returned waterfall makes the cull visible.
adata, waterfall = cell_qc_funnel(adata, config=LoadConfig())
print(waterfall) # [step, reason, removed, remaining] per QC/doublet class
# 2) Variance-stabilized embedding → Leiden clusters.
atlas = embed_cells(adata, EmbedConfig(batch_key="sample"))
# 3) Annotate: per-cluster cell-type + T/B-state argmax, written to
# obs["phenotype_base"] and obs["phenotype"].
annotate_cells(atlas)
print(atlas.obs["phenotype"].value_counts())
The default registries are blood/PBMC-oriented¶
annotate_cells argmaxes each cluster over CELL_TYPE_SIGNATURES,
T_STATE_SIGNATURES, and B_STATE_SIGNATURES from tcrsift.signatures. These
defaults are calibrated for blood / PBMC data. CELL_TYPE_SIGNATURES covers
the immune lineages and common stroma, but omits solid-tissue types a tumor
atlas needs (mesothelial, osteoclast, skeletal muscle, adipocyte, Schwann/nerve),
and its shared-type gene lists are PBMC-tuned. Running a solid-tumor atlas against
the default gives confidently wrong calls (e.g. tumor cells argmax'd to
Fibroblast) with no signal that the reference is blood-shaped.
Override reference= (and, for tissue-specific states, t_state_reference= /
b_state_reference=) — they are threaded to both the scorer and the
per-cluster reader, so a caller-defined type or state is consistently scored and
used rather than silently dropped:
from tcrsift.signatures import SOLID_TUMOR_CELL_TYPE_SIGNATURES
from tcrsift.qc import SOLID_TUMOR_LINEAGE_PROGRAMS
# QC doublet gate for solid tumor (folds osteoclasts into myeloid, keeps
# fibroblast as the tumor+immune doublet handle):
adata, wf = cell_qc_funnel(adata, config=LoadConfig(),
lineage_sets=SOLID_TUMOR_LINEAGE_PROGRAMS)
# Annotation with the solid-tumor cell-type registry:
annotate_cells(atlas, reference=SOLID_TUMOR_CELL_TYPE_SIGNATURES)
PBMC_CELL_TYPE_SIGNATURES is an explicit alias for the default, so an override
reads symmetrically against SOLID_TUMOR_CELL_TYPE_SIGNATURES.
Typing malignant cells (any cancer type)¶
Malignant cells are not a signature in either registry — a tumor cluster
often shares collagen with fibroblasts and loses the argmax, and there is no
reliable pan-cancer tumor expression program. Type it on positive marker-count
evidence instead. tumor_override wires the h37-style rule in one line:
- primary — relabel
Tumorif a fraction of cells each express ≥2 distinct markers from your panel; or - rescue — a lineage-TF is high and ≥1 marker shows in a fraction of cells (catches low-coverage tumor whose sparse markers dropped below the primary bar).
The marker panel defaults to oncoref's curated pan-cancer CTA set —
oncoref.CTA_gene_names() unioned with oncoref.cta_clinical_target_gene_names()
(canonical clinically-expressed CTAs like NY-ESO-1/CTAG2 and MAGEA11 that
testis-restriction filtering drops but are real tumor markers). CTAs are shared
across solid tumors, so the panel is reused across cancers and only the
lineage-TF rescue is tissue-specific:
from tcrsift.annotate_cells import tumor_override
osteosarcoma = tumor_override(lineage_tfs=["RUNX2", "SATB2"]) # panel auto-filled
annotate_cells(atlas, reference=SOLID_TUMOR_CELL_TYPE_SIGNATURES,
overrides=[osteosarcoma])
Adapting to another cancer is usually just swapping the rescue TFs (omit
lineage_tfs for the primary bar alone); override gene_set when you want a
tighter or tuned oncoref panel:
tumor_override(lineage_tfs=["MITF", "SOX10"]) # melanoma
tumor_override() # no rescue signal
# a tighter, testis-restricted panel:
import oncoref
tumor_override(oncoref.CTA_testis_restricted_gene_names(), lineage_tfs=["MITF"])
tumor_override returns a MarkerCountOverride, so you can still reach for the
class directly when you need finer control (custom min_expr, count_col, …).
Recovering sub-structure with refine_cluster¶
annotate_clusters labels clusters over the global embedding, whose axes are
dominated by whole-atlas structure. Biology that lives within a compartment —
rare pDC/cDC1 co-embedded in myeloid, genuine γδ vs ambient-αβ CD8 sharing one
cytotoxic cluster, tumor sub-states — doesn't resolve there. refine_cluster is
the general recovery pass: it isolates a compartment, re-embeds it alone on
its own Pearson residuals, sub-clusters it, and hands each sub-cluster to a
caller callback that names it. The discriminating logic lives entirely in
relabel_fn — the same primitive drives γδ/αβ-CD8 disaggregation, DC recovery,
and tumor sub-state splitting.
The re-embed is a fixed, exposed recipe: counts → normalize_pearson_residuals
→ nan_to_num → pca(n_comps=min(n_pcs, n_obs-1, n_vars-1)) → neighbors(n_neighbors)
→ leiden(resolution, flavor="igraph", n_iterations=2, directed=False), all on
random_state, no UMAP. When you pass informative_genes the clustering panel
is used verbatim — no MT/receptor/min-cells curation — so a hand-rolled pass
running the identical steps and seeds reproduces the sub-partition byte-for-byte
and can be retired in favor of this primitive.
γδ/αβ-CD8 disaggregation ships as a ready relabel_fn, gd_cd8_relabel:
from tcrsift import refine_cluster, gd_cd8_relabel
# has_ab_contig is a caller-populated bool obs column — materialize it however
# your VDJ capture is encoded. For a "α/β" paired-CDR3 string:
s = atlas.obs["CDR3a/b"].astype(str)
atlas.obs["has_ab_contig"] = (s != "/") & (s != "") & s.str.contains("[A-Z]", regex=True)
refine_cluster(
atlas, # mutated in place
"CD8 T", # compartment: label / bool mask / index / predicate
relabel_fn=gd_cd8_relabel(), # TRDC>=0.9 (log1p CP10K) AND αβ-capture<0.25 → γδ
informative_genes=panel, # used verbatim
resolution=0.5,
n_pcs=30,
min_cells=51, # skip compartments too small to re-embed
label_col="phenotype_base",
final_cluster_col="final_cluster",
)
gd_cd8_relabel calls a sub-cluster γδ iff both its mean TRDC (read from
.X, so keep it on the log1p-CP10K scale the 0.9 cutoff assumes) clears
trdc_min and its mean has_ab_contig is below ab_max — aggregating per
sub-cluster is what makes it robust to the ~46% per-cell TRDC dropout. It
returns ("gd T", "gdt") or ("CD8 effector/cytotoxic", "gdt_cd8"), and warns
(doesn't fail) when a sub-cluster's mean capture lands between the two regimes.
All of that — gene, obs column, thresholds, labels — is overridable.
Writing your own relabel_fn is the general path: it receives a copy of one
sub-cluster (original-scale .X, obs intact, plus obs["leiden"] = the local
sub-cluster id) and returns a label, a (label, final_cluster) pair, or None
to leave it unchanged. mask selects the compartment (a label matched against
label_col, a boolean mask, a positional-index list, or a predicate adata ->
mask); only those cells' labels are touched. Compartments smaller than
min_cells are skipped.
Notes¶
- Config:
cell_qc_funnel(config=...)accepts a top-levelTCRsiftConfig(unwrapped to its.loadsection) or a flatLoadConfig; explicit kwargs win overconfigwin over the built-in defaults. PassingNonefor any bound disables it. pct_counts_mt: the funnel leaves a caller's pre-existingpct_counts_mt(e.g. fromscanpy.pp.calculate_qc_metrics) untouched and only computes it when absent; the mito gate runs off its own arithmetic either way.- Culture restriction: for a Ficoll-isolated PBMC / peptide-stimulated
culture, pass
allowed_types=tcrsift.signatures.PBMC_CULTURE_TYPESso a cultured moDC/macrophage cluster can't win a stromal signature it shares an activation program with.