Skip to content

Gene-expression signatures

TCRsift has two related registries:

  • T_CELL_SIGNATURES: compact functional programs used for exploratory per-cell/per-clone scoring and TIL selection.
  • NEOANTIGEN_SIGNATURES: published neoantigen-reactivity signatures with their own scoring methods, input units, and citations.

Scores describe cell state. They do not establish which antigen a TCR binds. Interpret the same program in its source compartment: AIM is most useful after in-vitro stimulation, while tumor-reactive/exhaustion programs are most useful in fresh TIL.

Compact functional programs

All symbols are HGNC human gene names.

Name Genes / contrast Intended use
effector IFNG, GZMB, PRF1, GNLY, NKG7 Cytotoxic-effector state
activation Deprecated alias of effector Backward compatibility only
naive_stem TCF7, LEF1, CCR7, SELL, IL7R, CD27, CD28 Naïve/stem-memory state
antigen_response TNFRSF9, MKI67 Focal recent-response/proliferation readout
aim TNFRSF9, TNFRSF4, IL2RA, MKI67 Activation-induced-marker program in stimulated culture
cytolytic PRF1, GZMB Minimal cytolytic readout
exhaustion PDCD1, TOX, LAG3, HAVCR2, TIGIT Chronic-antigen/exhaustion state in TIL
tumor_reactive CXCL13, ENTPD1 Compact tumor-reactive TIL phenotype
expansion_core MKI67, TNFRSF9, EGR2, IFNG, CXCL13, HAVCR2 Cross-compartment expansion-associated panel

MARKER_PANEL_HGNC is the union extracted by til-select; it is a display and scoring panel, not another biological signature.

Published neoantigen-reactivity registry

score_by_name dispatches each entry according to its registered method instead of treating unlike signatures as interchangeable weighted sums.

Name Source TCRsift implementation
MANAscore Zeng/Smith, Nature Communications 2025 (PMID 39900903) Transparent signed-z proxy: (z(CXCL13) + z(ENTPD1) - z(IL7R)) / sqrt(3). The paper's trained RF+linear ensemble is not shipped and this proxy must not be described as the original fitted model.
NeoTCR8 Lowery/Rosenberg, Science 2022 (PMID 35113651) Published 243-gene CD8 set, unweighted gene-set enrichment
NeoTCR4 Lowery/Rosenberg, Science 2022 (PMID 35113651) Published 40-gene CD4 set, unweighted gene-set enrichment
NeoTCR_PBL Yossef/Rosenberg, Cancer Cell 2023 (PMID 38039963) Published 151-gene circulating CD8 set, unweighted gene-set enrichment; use for blood, not as the default TIL signature

For NeoTCR4, NeoTCR8, and NeoTCR_PBL, pass an AnnData object containing the full log-normalized gene universe. A bare DataFrame cannot reproduce rank-enrichment controls, so TCRsift warns and falls back to a mean-z proxy.

Usage

Compact unsigned programs:

from tcrsift import compute_signature_scores_per_cell

compute_signature_scores_per_cell(adata)
print([column for column in adata.obs if column.startswith("signature_")])

Published/signed methods:

import scanpy as sc
from tcrsift import score_by_name

sc.pp.normalize_total(adata, target_sum=10_000)
sc.pp.log1p(adata)

for name in ("MANAscore", "NeoTCR8", "NeoTCR4"):
    # X is already log1p(CP10K), so do not log it again.
    score_by_name(adata, name, log1p=False, key_added=f"signature_{name}")

For multiple samples, score within each sample or donor before aggregating clones; otherwise sample-level expression shifts can dominate the result. See Multi-sample TIL prioritization for a complete example.

Cell-type and state registries

The per-cell annotator (tcrsift.annotate_cells) also exposes:

Constant What it contains
CELL_TYPE_SIGNATURES Blood/PBMC-oriented default cell-type registry
PBMC_CELL_TYPE_SIGNATURES Explicit alias for that default
SOLID_TUMOR_CELL_TYPE_SIGNATURES Default plus solid-tissue lineages
T_STATE_SIGNATURES, B_STATE_SIGNATURES T- and B-cell sub-state registries
PBMC_CULTURE_TYPES Type allow-list for PBMC/culture annotation

Malignant-cell calls are handled by MarkerCountOverride/tumor_override, not by adding a “malignant” expression signature to this registry.

signatures

Canonical T-cell gene-set signatures.

Single source of truth for the gene sets used across TIL selection (til_select.py) and the per-sample signature scatter (plots.py). Pulled out of til_select.py so they can be reused for selecting other kinds of T cells — antigen-response screens, exhaustion-state phenotyping, healthy-donor panels — without inheriting the TIL-specific scoring code.

All gene symbols are HGNC (human). When working with Ensembl IDs, translate via the 10x features.tsv.gz mapping.

Signatures grouped by intent:

  • :data:EFFECTOR_GENES_HGNC — cytotoxic-effector panel (IFNG, GZMB, PRF1, GNLY, NKG7). Used in cytotoxicity readouts. ACTIVATION_GENES_HGNC is a deprecated alias (#142): this set is effector differentiation, not immediate-early activation (CD69, NR4A1-3, EGR1/2, FOS, JUN) — the old name invited that conflation.
  • :data:NAIVE_STEM_GENES_HGNC — naïve / stem-memory program (TCF7, LEF1, CCR7, SELL, IL7R, CD27, CD28). The "down" pole of the effector−naïve differentiation contrast (#141), the best axis for separating antigen-expanded clones from naïve-like bystanders.
  • :data:ANTIGEN_RESPONSE_GENES_HGNC — focal 2-gene recent-antigen marker: TNFRSF9 (4-1BB / CD137, the AIM-assay marker per Wölfl 2007, Frentsch 2005, Bacher 2013) + MKI67 (Ki-67, proliferation). Kept as the back-compat subset of :data:AIM_GENES_HGNC. Per the expansion-correlation analysis (#303), both members positively track culture clonal expansion (TNFRSF9 ρ≈0.28, MKI67 ρ≈0.46) despite TNFRSF9's sparsity — sparse-but-specific, not a reason to demote it.
  • :data:AIM_GENES_HGNC — activation-induced-marker panel for the in-vitro restimulation / antigen-response axis: TNFRSF9 (4-1BB), TNFRSF4 (OX40), IL2RA (CD25) + MKI67 (proliferation). These track culture expansion but sit near zero in TIL (#303), so this is the culture-compartment activation readout, not an in-vivo state.
  • :data:CYTOLYTIC_GENES_HGNC — minimal canonical cytotoxic effector pair (PRF1 perforin, GZMB granzyme B). Used as the effector readout in Caushi 2021, Krishna 2021, Hanada 2022. Tracks TIL expansion (ρ≈0.29–0.34) but is flat/negative in culture (#303).
  • :data:EXHAUSTION_GENES_HGNC — canonical co-inhibitory / chronic-antigen exhausted-T-cell set (PDCD1, TOX, LAG3, HAVCR2, TIGIT). CTLA4 was dropped (#303): it is an activation/Treg marker, not specific to the exhausted state. Every member positively tracks TIL expansion in the H37 tumor — the expanded intratumoral clones are the chronically-stimulated ones. Residency/tumor-reactive extension: ENTPD1 (CD39), CXCL13, ITGAE (CD103).
  • :data:TUMOR_REACTIVE_GENES_HGNC — TIL-resident tumor-reactive phenotype: CXCL13 (Workel 2019, Cohen 2022, Veatch 2022) + ENTPD1 / CD39 (Duhen 2018, Simoni 2018, Thommen 2018).
  • :data:EXPANSION_CORE_GENES_HGNC — cross-compartment expansion core (MKI67, TNFRSF9, EGR2, IFNG, CXCL13, HAVCR2): the markers whose clone-level expression tracks expansion in both the peptide-culture and TIL compartments (#303), so the one panel usable to score "expanding & antigen-engaged" regardless of compartment.
  • :data:MARKER_PANEL_HGNC — the default per-clone GEX scoring panel. Widened (#303) to the union of the functional panels above (plus CD4/CD8 lineage) so every panel is computable from a single extraction pass. Not a single-intent signature (so it is intentionally absent from :data:T_CELL_SIGNATURES); it is the union display panel shown per clone. til_select re-exports it as MARKER_GENES_DEFAULT for back-compat.

Compartment note (#303): the panels mean different things by compartment. AIM markers track in-vitro culture expansion; cytolytic and exhaustion track in-vivo TIL state/expansion; the expansion core tracks both. Detectability (% of cells expressing) is not the right yardstick — CD69 is the most detectable activation marker yet anti-correlates with expansion, while sparse TNFRSF9 works.

:data:T_CELL_SIGNATURES is a snake-case name → tuple dict for convenient iteration.

EFFECTOR_GENES_HGNC module-attribute

EFFECTOR_GENES_HGNC: tuple[str, ...] = ('IFNG', 'GZMB', 'PRF1', 'GNLY', 'NKG7')

ACTIVATION_GENES_HGNC module-attribute

ACTIVATION_GENES_HGNC: tuple[str, ...] = EFFECTOR_GENES_HGNC

NAIVE_STEM_GENES_HGNC module-attribute

NAIVE_STEM_GENES_HGNC: tuple[str, ...] = ('TCF7', 'LEF1', 'CCR7', 'SELL', 'IL7R', 'CD27', 'CD28')

ANTIGEN_RESPONSE_GENES_HGNC module-attribute

ANTIGEN_RESPONSE_GENES_HGNC: tuple[str, ...] = ('TNFRSF9', 'MKI67')

AIM_GENES_HGNC module-attribute

AIM_GENES_HGNC: tuple[str, ...] = ('TNFRSF9', 'TNFRSF4', 'IL2RA', 'MKI67')

CYTOLYTIC_GENES_HGNC module-attribute

CYTOLYTIC_GENES_HGNC: tuple[str, ...] = ('PRF1', 'GZMB')

EXHAUSTION_GENES_HGNC module-attribute

EXHAUSTION_GENES_HGNC: tuple[str, ...] = ('PDCD1', 'TOX', 'LAG3', 'HAVCR2', 'TIGIT')

TUMOR_REACTIVE_GENES_HGNC module-attribute

TUMOR_REACTIVE_GENES_HGNC: tuple[str, ...] = ('CXCL13', 'ENTPD1')

EXPANSION_CORE_GENES_HGNC module-attribute

EXPANSION_CORE_GENES_HGNC: tuple[str, ...] = ('MKI67', 'TNFRSF9', 'EGR2', 'IFNG', 'CXCL13', 'HAVCR2')

MARKER_PANEL_HGNC module-attribute

MARKER_PANEL_HGNC: tuple[str, ...] = ('CD4', 'CD8A', 'CD8B', 'MKI67', 'TNFRSF9', 'TNFRSF4', 'IL2RA', 'TNFRSF18', 'NR4A1', 'NR4A2', 'EGR2', 'CTLA4', 'PRF1', 'GZMB', 'GZMK', 'NKG7', 'GNLY', 'IFNG', 'PDCD1', 'TOX', 'LAG3', 'HAVCR2', 'TIGIT', 'ENTPD1', 'CXCL13', 'ITGAE')

T_CELL_SIGNATURES module-attribute

T_CELL_SIGNATURES: dict[str, tuple[str, ...]] = {'effector': EFFECTOR_GENES_HGNC, 'activation': ACTIVATION_GENES_HGNC, 'naive_stem': NAIVE_STEM_GENES_HGNC, 'antigen_response': ANTIGEN_RESPONSE_GENES_HGNC, 'aim': AIM_GENES_HGNC, 'cytolytic': CYTOLYTIC_GENES_HGNC, 'exhaustion': EXHAUSTION_GENES_HGNC, 'tumor_reactive': TUMOR_REACTIVE_GENES_HGNC, 'expansion_core': EXPANSION_CORE_GENES_HGNC}

signature_methods

Gene-expression signatures as synthetic selection methods.

The selection language (:mod:tcrsift.selection) is method-agnostic — a "method" is just a value in the per-(clone, sample) table carrying a tier + frequency. A method need not be a physical sort: this module scores cells on a gene set, calls signature-positive cells, and emits them as a synthetic method so the rules can use a signature like a sort.

Signatures are signed (genes_upgenes_down, so loss-of-naive axes work). Canonical scoring (defaults): per gene, the z-score across cells of log(1 + TPM), then the mean of those z-scores across the signature (combine="mean" instead averages the values directly). Small "focal" and larger "broad" panels are offered per axis.

NOTE: a signature's meaning still depends on the sample source — e.g. TumorReactive (CD39/CD103/TOX) only reads "chronic in-situ antigen exposure" in fresh tissue TILs, not in PBMC culture. That caveat lives in each signature's description; it is documentation, not an enforced guard, so you can still score any signature on any data (and should, when exploring). Gene memberships are literature-backed defaults for tuning.

SIGNATURES module-attribute

SIGNATURES: dict[str, Signature] = {(s.name): s for s in (_INVARIANT_SIGNATURES + _COMPOSITE_SIGNATURES)}

NEOANTIGEN_SIGNATURES module-attribute

NEOANTIGEN_SIGNATURES: dict[str, Signature] = {'MANAscore': Signature('MANAscore', MANASCORE_UP_HGNC, MANASCORE_DOWN_HGNC, panel='focal', units='log1p', method='weighted_z', citation='Zeng/Smith, Nat Commun 2025 (PMID 39900903)', description='Transparent signed-z proxy for MANAscore: (z(CXCL13)+z(ENTPD1)-z(IL7R))/sqrt(3) on log1p CP10K. The published model is a trained RF+linear ensemble with NO closed-form per-gene weights; only the gene directions (+CXCL13,+ENTPD1,-IL7R) and input units (log-normalized) are reproducible, and that is what this scores. The faithful pickled ensemble is deliberately NOT shipped (deserializing a third-party pickle is an arbitrary-code-execution risk).'), 'NeoTCR8': Signature('NeoTCR8', NEOTCR8_GENES_HGNC, panel='broad', units='ranks', method='geneset_enrichment', citation='Lowery/Rosenberg, Science 2022 (PMID 35113651)', description="243-gene CD8 neoantigen-reactive set (Table S10). An UNWEIGHTED gene set scored by rank enrichment (scGSEA/score_genes) — 'per-gene weight' is not a meaningful question for it."), 'NeoTCR4': Signature('NeoTCR4', NEOTCR4_GENES_HGNC, panel='broad', units='ranks', method='geneset_enrichment', citation='Lowery/Rosenberg, Science 2022 (PMID 35113651)', description='40-gene CD4 neoantigen-reactive set (Table S10). An UNWEIGHTED gene set scored by rank enrichment (scGSEA/score_genes).'), 'NeoTCR_PBL': Signature('NeoTCR_PBL', NEOTCRPBL_GENES_HGNC, panel='broad', units='ranks', method='geneset_enrichment', citation='Yossef/Rosenberg, Cancer Cell 2023 (PMID 38039963)', description="151-gene signature of circulating (peripheral-blood) neoantigen-reactive CD8 T cells (cluster C9, avg_log2FC>=0.5; Table S2D). An UNWEIGHTED gene set scored by rank enrichment. Distinct from Lowery's TIL-derived NeoTCR8/4 — a separate blood-derived signature.")}

Signature dataclass

A named, optionally-signed gene signature.

genes_down subtracts (loss-of-naive etc.). panel is a "focal"/"broad" label for the small-vs-large variants. description carries the biological caveat (e.g. which sample source it's meaningful in) as prose.

units, method and citation (#309) record how a published signature is meant to be scored, so heterogeneous signatures (MANAscore-style signed-z proxies vs. unweighted rank-enrichment gene sets) can live in one registry and be dispatched correctly by :func:score_by_name rather than all being treated as weighted sums:

  • units — the input space the score is defined on: "log1p" (log1p CP10K, the default), "cp10k", "scaled" or "ranks".
  • method — the scoring rule: "zscore" (mean of per-gene z-scores, the default and what :func:score_signature computes), "weighted_z" (signed-sum of per-gene z-scores / sqrt(n) — the transparent MANAscore proxy), or "geneset_enrichment" (rank-based set score for unweighted published sets like NeoTCR4/8).
  • citation — the source paper (free text, incl. PMID).

Defaults keep every pre-existing signature a plain zscore / log1p signature, so this is backward compatible.

Source code in tcrsift/signature_methods.py
@dataclass(frozen=True)
class Signature:
    """A named, optionally-signed gene signature.

    ``genes_down`` subtracts (loss-of-naive etc.). ``panel`` is a
    "focal"/"broad" label for the small-vs-large variants. ``description``
    carries the biological caveat (e.g. which sample source it's meaningful
    in) as prose.

    ``units``, ``method`` and ``citation`` (#309) record how a *published*
    signature is meant to be scored, so heterogeneous signatures
    (MANAscore-style signed-z proxies vs. unweighted rank-enrichment gene
    sets) can live in one registry and be dispatched correctly by
    :func:`score_by_name` rather than all being treated as weighted sums:

    - ``units`` — the input space the score is defined on: ``"log1p"``
      (log1p CP10K, the default), ``"cp10k"``, ``"scaled"`` or ``"ranks"``.
    - ``method`` — the scoring rule: ``"zscore"`` (mean of per-gene
      z-scores, the default and what :func:`score_signature` computes),
      ``"weighted_z"`` (signed-sum of per-gene z-scores / ``sqrt(n)`` — the
      transparent MANAscore proxy), or ``"geneset_enrichment"`` (rank-based
      set score for unweighted published sets like NeoTCR4/8).
    - ``citation`` — the source paper (free text, incl. PMID).

    Defaults keep every pre-existing signature a plain ``zscore`` /
    ``log1p`` signature, so this is backward compatible.
    """

    name: str
    genes_up: tuple[str, ...]
    genes_down: tuple[str, ...] = ()
    panel: str = "focal"
    description: str = ""
    units: str = "log1p"
    method: str = "zscore"
    citation: str = ""

    @property
    def all_genes(self) -> tuple[str, ...]:
        return tuple(dict.fromkeys(self.genes_up + self.genes_down))

score_by_name

score_by_name(data, name, *, registry: dict[str, Signature] | None = None, log1p: bool | None = None, background: DataFrame | None = None, groups: Series | None = None, on_missing: str = 'warn', layer: str | None = None, key_added: str | None = None) -> pd.Series

Score a registered signature on cells, dispatched by its method (#309).

data is either an AnnData (per-cell scores from .X / layer, symbol resolution via the shared resolver) or a cells × genes symbol-keyed DataFrame. name is a registry key (case-insensitive) or a :class:Signature. Routing:

  • weighted_z → :func:score_weighted_z (MANAscore-style signed-z),
  • zscore / mean → :func:score_signature,
  • geneset_enrichment → scanpy score_genes on an AnnData (faithful rank enrichment), or a mean-z proxy on a bare frame.

log1p defaults from the signature's units (applied for log1p/cp10k inputs, skipped for scaled/ranks); pass a bool to override. key_added (AnnData only) writes the score to adata.obs[key_added] in addition to returning it.

Source code in tcrsift/signature_methods.py
def score_by_name(
    data,
    name,
    *,
    registry: dict[str, Signature] | None = None,
    log1p: bool | None = None,
    background: pd.DataFrame | None = None,
    groups: pd.Series | None = None,
    on_missing: str = "warn",
    layer: str | None = None,
    key_added: str | None = None,
) -> pd.Series:
    """Score a registered signature on cells, dispatched by its ``method`` (#309).

    ``data`` is either an ``AnnData`` (per-cell scores from ``.X`` / ``layer``,
    symbol resolution via the shared resolver) or a cells × genes symbol-keyed
    ``DataFrame``. ``name`` is a registry key (case-insensitive) or a
    :class:`Signature`. Routing:

    - ``weighted_z`` → :func:`score_weighted_z` (MANAscore-style signed-z),
    - ``zscore`` / ``mean`` → :func:`score_signature`,
    - ``geneset_enrichment`` → scanpy ``score_genes`` on an AnnData (faithful
      rank enrichment), or a mean-z proxy on a bare frame.

    ``log1p`` defaults from the signature's ``units`` (applied for
    ``log1p``/``cp10k`` inputs, skipped for ``scaled``/``ranks``); pass a bool
    to override. ``key_added`` (AnnData only) writes the score to
    ``adata.obs[key_added]`` in addition to returning it.
    """
    sig = _lookup_signature(name, registry)
    apply_log1p = (sig.units in ("log1p", "cp10k")) if log1p is None else log1p
    is_adata = hasattr(data, "obs") and hasattr(data, "var_names")
    if is_adata:
        index = data.obs_names.astype(str)
        if sig.method == "geneset_enrichment":
            series = _score_genes_adata(data, sig.all_genes, on_missing=on_missing)
        else:
            expr = expression_frame_from_adata(
                data, sig.all_genes, layer=layer, on_missing=on_missing
            )
            series = _score_frame(
                expr, sig, log1p=apply_log1p, background=background,
                groups=groups, on_missing=on_missing,
            )
        series = series.reindex(index)
        if key_added is not None:
            data.obs[key_added] = series.to_numpy()
        return series
    return _score_frame(
        data, sig, log1p=apply_log1p, background=background, groups=groups,
        on_missing=on_missing,
    )