Skip to content

Annotation API

Module for annotating clonotypes with public TCR databases.

annotate

TCR annotation using public databases for TCRsift.

Matches TCRs against VDJdb, IEDB, and CEDAR to identify known specificities.

VIRAL_SPECIES_PATTERNS module-attribute

VIRAL_SPECIES_PATTERNS = ['cmv', 'cytomegalovirus', 'ebv', 'epstein-barr', 'hiv', 'human immunodeficiency', 'flu', 'influenza', 'sars', 'coronavirus', 'herpes', 'hsv', 'hpv', 'papilloma', 'hepatitis', 'hbv', 'hcv', 'dengue', 'zika', 'yellow fever']

load_vdjdb

load_vdjdb(path: str | Path, verbose: bool = True) -> pd.DataFrame

Load VDJdb database.

VDJdb ships two on-disk shapes with different row semantics:

  • vdjdb_full.txtpaired-chain format: one row per clonotype, with cdr3.alpha and cdr3.beta as separate columns. This is what αβ matching needs.
  • vdjdb.txtlong/slim format: one row per chain, keyed by complex.id and gene (TRA/TRB). The cdr3 column holds an α-CDR3 when gene == TRA and a β-CDR3 when gene == TRB. A naive cdr3 → cdr3_beta rename mixes the two and matches alpha sequences as if they were beta. To avoid that, the long-format path is filtered to gene == TRB rows so cdr3_beta only carries actual β-CDR3s; α rows are dropped (would need a complex.id pivot to recover αβ pairs, which is best deferred to the full file).

When given a directory, looks for one of three canonical filenames in priority order: vdjdb_full.txt, vdjdb.txt, vdjdb.slim.txt. Other files in the directory (metadata sidecars, processed views, etc.) are ignored — a directory that has none of these names raises with a hint to re-run tcrsift data download or pass the file path explicitly.

Parameters:

Name Type Description Default
path str or Path

Path to VDJdb directory or single file.

required
verbose bool

Print progress information.

True

Returns:

Type Description
DataFrame

VDJdb entries with standardized columns (cdr3_alpha, cdr3_beta, epitope, antigen_gene, species, mhc_allele, database, is_viral). The cdr3_alpha column is empty when loaded from the long/slim format.

Source code in tcrsift/annotate.py
def load_vdjdb(path: str | Path, verbose: bool = True) -> pd.DataFrame:
    """
    Load VDJdb database.

    VDJdb ships two on-disk shapes with **different row semantics**:

    - ``vdjdb_full.txt`` — *paired-chain* format: one row per
      clonotype, with ``cdr3.alpha`` and ``cdr3.beta`` as separate
      columns. This is what αβ matching needs.
    - ``vdjdb.txt`` — *long/slim* format: one row per **chain**, keyed
      by ``complex.id`` and ``gene`` (``TRA``/``TRB``). The ``cdr3``
      column holds an α-CDR3 when ``gene == TRA`` and a β-CDR3 when
      ``gene == TRB``. A naive ``cdr3 → cdr3_beta`` rename mixes the
      two and matches alpha sequences as if they were beta. To avoid
      that, the long-format path is filtered to ``gene == TRB`` rows
      so ``cdr3_beta`` only carries actual β-CDR3s; α rows are
      dropped (would need a ``complex.id`` pivot to recover αβ pairs,
      which is best deferred to the full file).

    When given a directory, looks for one of three canonical filenames
    in priority order: ``vdjdb_full.txt``, ``vdjdb.txt``,
    ``vdjdb.slim.txt``. Other files in the directory (metadata
    sidecars, processed views, etc.) are ignored — a directory that
    has none of these names raises with a hint to re-run
    ``tcrsift data download`` or pass the file path explicitly.

    Parameters
    ----------
    path : str or Path
        Path to VDJdb directory or single file.
    verbose : bool
        Print progress information.

    Returns
    -------
    pd.DataFrame
        VDJdb entries with standardized columns (``cdr3_alpha``,
        ``cdr3_beta``, ``epitope``, ``antigen_gene``, ``species``,
        ``mhc_allele``, ``database``, ``is_viral``). The
        ``cdr3_alpha`` column is empty when loaded from the
        long/slim format.
    """
    path = Path(path)

    if path.is_dir():
        db_file = _pick_vdjdb_file(path)
    else:
        db_file = validate_file_exists(path, "VDJdb database file")

    if verbose:
        logger.info(f"Loading VDJdb from {db_file}")

    try:
        df = pd.read_csv(db_file, sep="\t", low_memory=False)
    except Exception as e:
        raise TCRsiftValidationError(
            f"Failed to read VDJdb file: {db_file}",
            hint=f"Error: {e}. Make sure the file is a valid TSV file.",
        )

    if len(df) == 0:
        raise TCRsiftValidationError(
            f"VDJdb file is empty: {db_file}",
            hint="Download a fresh copy from https://vdjdb.cdr3.net/",
        )

    # Format-specific normalization, dispatched by column presence:
    # the paired file has ``cdr3.beta``, the long file has
    # ``cdr3`` + ``gene``. ``cdr3.alpha`` alone (no β) doesn't occur
    # in real VDJdb files and isn't useful for matching — falls
    # through to the unrecognized-schema error.
    if "cdr3.beta" in df.columns:
        df = _normalize_vdjdb_paired(df)
    elif "cdr3" in df.columns and "gene" in df.columns:
        n_total = len(df)
        df = _normalize_vdjdb_long(df)
        if verbose:
            logger.info(
                f"  Long/slim VDJdb format: kept {len(df):,} TRB rows "
                f"out of {n_total:,} total (α rows discarded — load "
                f"vdjdb_full.txt for αβ matching)."
            )
    else:
        raise TCRsiftValidationError(
            f"Unrecognized VDJdb schema in {db_file}",
            hint=(
                "Expected either paired-chain columns "
                "(`cdr3.alpha` + `cdr3.beta`) or long-format columns "
                "(`cdr3` + `gene`). Got: "
                f"{list(df.columns)[:10]}{'...' if len(df.columns) > 10 else ''}"
            ),
        )

    df["database"] = "VDJdb"
    df["is_viral"] = _flag_viral(df)

    if verbose:
        logger.info(f"  Loaded {len(df):,} VDJdb entries ({df['is_viral'].sum():,} viral)")
    return df

load_iedb

load_iedb(path: str | Path, epitope_path: str | Path | None = None) -> pd.DataFrame

Load IEDB TCR database.

Handles two on-disk shapes:

  1. The current receptor_full_v3.zip export (cached as tcr_full_v3.csv): comma-separated with a two-row hierarchical header — top row is the section (Receptor/Epitope/ Assay/Chain 1/Chain 2…), second row is the field (e.g. CDR3 Curated, Source Organism).
  2. Older flat-TSV exports kept around for compatibility.

The format is sniffed from the first byte of the file rather than the extension, so a user-supplied path is treated correctly regardless of suffix.

Parameters:

Name Type Description Default
path str or Path

Path to IEDB receptor file (v3 CSV or legacy flat TSV).

required
epitope_path str or Path

Path to the IEDB epitope-level CSV (epitope_full_v3.csv — companion to the receptor file, cached by tcrsift data download --db iedb_epitope). When provided, the receptor file's antigen_gene / species fields are overridden with the epitope table's values wherever the epitope table has them. Empirically, the epitope-table names are shorter and more publication-canonical (e.g. Protein Tax-1 vs the receptor file's transcriptional activator Tax), which reduces downstream synonym sprawl (#54).

None

Returns:

Type Description
DataFrame

IEDB entries with standardized columns (cdr3_alpha, cdr3_beta, epitope, antigen_gene, species, mhc_allele, is_viral, database).

Source code in tcrsift/annotate.py
def load_iedb(
    path: str | Path,
    epitope_path: str | Path | None = None,
) -> pd.DataFrame:
    """
    Load IEDB TCR database.

    Handles two on-disk shapes:

    1. The current `receptor_full_v3.zip` export (cached as
       ``tcr_full_v3.csv``): comma-separated with a two-row hierarchical
       header — top row is the section (``Receptor``/``Epitope``/
       ``Assay``/``Chain 1``/``Chain 2``…), second row is the field
       (e.g. ``CDR3 Curated``, ``Source Organism``).
    2. Older flat-TSV exports kept around for compatibility.

    The format is sniffed from the first byte of the file rather than
    the extension, so a user-supplied path is treated correctly
    regardless of suffix.

    Parameters
    ----------
    path : str or Path
        Path to IEDB receptor file (v3 CSV or legacy flat TSV).
    epitope_path : str or Path, optional
        Path to the IEDB epitope-level CSV
        (``epitope_full_v3.csv`` — companion to the receptor file,
        cached by ``tcrsift data download --db iedb_epitope``).
        When provided, the receptor file's ``antigen_gene`` / ``species``
        fields are overridden with the epitope table's values
        wherever the epitope table has them. Empirically, the
        epitope-table names are shorter and more publication-canonical
        (e.g. ``Protein Tax-1`` vs the receptor file's
        ``transcriptional activator Tax``), which reduces downstream
        synonym sprawl (#54).

    Returns
    -------
    pd.DataFrame
        IEDB entries with standardized columns (``cdr3_alpha``,
        ``cdr3_beta``, ``epitope``, ``antigen_gene``, ``species``,
        ``mhc_allele``, ``is_viral``, ``database``).
    """
    path = Path(path)
    logger.info(f"Loading IEDB from {path}")

    if _looks_like_iedb_v3(path):
        df = _load_iedb_v3(path)
    else:
        df = _load_iedb_legacy_tsv(path)

    if epitope_path is not None:
        df = _apply_iedb_epitope_overrides(df, Path(epitope_path))

    df["database"] = "IEDB"
    df["is_viral"] = _flag_viral(df)

    logger.info(f"Loaded {len(df)} IEDB entries ({df['is_viral'].sum()} viral)")
    return df

load_cedar

load_cedar(path: str | Path) -> pd.DataFrame

Load the CEDAR TCR database.

Handles two on-disk shapes, sniffed from the file's first line (not the extension):

  1. The bulk receptor export (doc/receptor_full_v3.ziptcr_full_v3.csv, cached by tcrsift data download --db cedar): comma-separated with the same v3 two-row hierarchical header as IEDB, so it is parsed by the shared :func:_load_iedb_v3.
  2. A manual web-UI export cedar.tsv — tab-separated, flat header with fields like cdr3_b_aa / epitope_sequence (the original supported shape; kept for back-compat).

Parameters:

Name Type Description Default
path str or Path

Path to a CEDAR receptor file (bulk v3 CSV or manual flat TSV).

required

Returns:

Type Description
DataFrame

CEDAR entries with standardized columns (cdr3_alpha, cdr3_beta, epitope, antigen_gene, species, is_viral, database).

Source code in tcrsift/annotate.py
def load_cedar(path: str | Path) -> pd.DataFrame:
    """
    Load the CEDAR TCR database.

    Handles two on-disk shapes, sniffed from the file's first line (not the
    extension):

    1. The **bulk** receptor export (``doc/receptor_full_v3.zip`` →
       ``tcr_full_v3.csv``, cached by ``tcrsift data download --db cedar``):
       comma-separated with the same v3 two-row hierarchical header as IEDB, so
       it is parsed by the shared :func:`_load_iedb_v3`.
    2. A **manual web-UI export** ``cedar.tsv`` — tab-separated, flat header with
       fields like ``cdr3_b_aa`` / ``epitope_sequence`` (the original supported
       shape; kept for back-compat).

    Parameters
    ----------
    path : str or Path
        Path to a CEDAR receptor file (bulk v3 CSV or manual flat TSV).

    Returns
    -------
    pd.DataFrame
        CEDAR entries with standardized columns (``cdr3_alpha``, ``cdr3_beta``,
        ``epitope``, ``antigen_gene``, ``species``, ``is_viral``, ``database``).
    """
    path = Path(path)
    logger.info(f"Loading CEDAR from {path}")

    if _looks_like_iedb_v3(path):
        # CEDAR's bulk export IS the IEDB v3 receptor schema (cancer-scoped).
        df = _load_iedb_v3(path)
    else:
        df = pd.read_csv(path, sep="\t", low_memory=False)
        # Standardize the manual web-export columns.
        column_mapping = {
            "cdr3_b_aa": "cdr3_beta",
            "cdr3_a_aa": "cdr3_alpha",
            "epitope_sequence": "epitope",
            "antigen_name": "antigen_gene",
            "organism": "species",
        }
        for old, new in column_mapping.items():
            if old in df.columns:
                df[new] = df[old]

    df["database"] = "CEDAR"
    df["is_viral"] = _flag_viral(df)

    logger.info(f"Loaded {len(df)} CEDAR entries ({df['is_viral'].sum()} viral)")
    return df

load_databases

load_databases(vdjdb_path: str | Path | None = None, iedb_path: str | Path | None = None, cedar_path: str | Path | None = None, iedb_epitope_path: str | Path | None = None) -> pd.DataFrame

Load and combine multiple TCR databases.

Parameters:

Name Type Description Default
vdjdb_path str or Path

Path to VDJdb

None
iedb_path str or Path

Path to IEDB receptor table

None
cedar_path str or Path

Path to CEDAR

None
iedb_epitope_path str or Path

Path to IEDB epitope-level table (companion to iedb_path). When provided, load_iedb defers to its shorter/more canonical antigen and organism names — see :func:load_iedb.

None

Returns:

Type Description
DataFrame

Combined database with standardized columns

Source code in tcrsift/annotate.py
def load_databases(
    vdjdb_path: str | Path | None = None,
    iedb_path: str | Path | None = None,
    cedar_path: str | Path | None = None,
    iedb_epitope_path: str | Path | None = None,
) -> pd.DataFrame:
    """
    Load and combine multiple TCR databases.

    Parameters
    ----------
    vdjdb_path : str or Path, optional
        Path to VDJdb
    iedb_path : str or Path, optional
        Path to IEDB receptor table
    cedar_path : str or Path, optional
        Path to CEDAR
    iedb_epitope_path : str or Path, optional
        Path to IEDB epitope-level table (companion to ``iedb_path``).
        When provided, ``load_iedb`` defers to its shorter/more
        canonical antigen and organism names — see :func:`load_iedb`.

    Returns
    -------
    pd.DataFrame
        Combined database with standardized columns
    """
    dfs = []

    if vdjdb_path:
        dfs.append(load_vdjdb(vdjdb_path))
    if iedb_path:
        dfs.append(load_iedb(iedb_path, epitope_path=iedb_epitope_path))
    if cedar_path:
        dfs.append(load_cedar(cedar_path))

    if not dfs:
        raise ValueError("At least one database path must be provided")

    # Combine and deduplicate
    combined = pd.concat(dfs, ignore_index=True)

    # Keep only rows with at least a beta CDR3
    combined = combined[combined["cdr3_beta"].notna() & (combined["cdr3_beta"] != "")]

    logger.info(f"Combined database has {len(combined)} entries")
    return combined

match_clonotypes

match_clonotypes(clonotypes: DataFrame, database: DataFrame, match_by: str = 'CDR3ab', match_strictness: str | None = None, match_mode: str = 'exact', max_distance: int = 1, verbose: bool = True, show_progress: bool = True) -> pd.DataFrame

Match clonotypes against public database.

Parameters:

Name Type Description Default
clonotypes DataFrame

Clonotype DataFrame

required
database DataFrame

Combined database from load_databases

required
match_by str

Legacy matching strategy. "CDR3ab" (both chains, β-only fallback) or "CDR3b_only" (beta only). Kept for back-compat; prefer match_strictness for new code.

'CDR3ab'
match_strictness str

Explicit matching strictness. When set, takes precedence over match_by. One of:

  • "strict_ab" — αβ-only, no β-only fallback. Best when you want headline match counts you can trust.
  • "ab_with_partial" — αβ first, β-only fallback per clone (equivalent to legacy match_by="CDR3ab").
  • "b_only" — β-only across the board (equivalent to legacy match_by="CDR3b_only").
None
match_mode str

Distance regime for the β-only match (#57). When "exact", only exact-string β matches are recorded — pre-2.1 behaviour. When "levenshtein", clones that don't hit exactly fall through to a Levenshtein-≤max_distance neighbor search against the database's β CDR3s. αβ matching stays strict-exact in both modes — fuzzy αβ is too noisy biologically.

``"exact"``
max_distance int

Maximum edit distance for fuzzy β matching (only used when match_mode == "levenshtein"). Currently capped at 1 in the implementation; higher values fall back to 1 with a warning.

1
verbose bool

Print progress information

True
show_progress bool

Show progress bar

True

Returns:

Type Description
DataFrame

Clonotypes with match annotations: db_match, db_match_partial (β-only fallback flag — back-compat), db_match_strength ("ab" / "b_only" / "ab_cross" / "b_only_cross" / "b_only_near" / "b_only_near_cross" / None; _cross suffix indicates a non-human host-species match per #83; _near suffix indicates a Levenshtein-≤1 β-only fuzzy match per #57), db_match_distance (int — edit distance to the matched DB β CDR3; 0 for exact, 1 for Levenshtein-1, None when unmatched), db_epitope, db_protein, db_protein_canonical, db_species (canonical antigen source organism), db_host_species (canonical TCR donor organism, new in #83), db_mhc, db_category (viral / bacterial / self / tumor_self / other / unknown / contradictory — last when multiple matches disagree on an informative label per #83), db_category_dominant (most common informative label when unique), db_category_detail (e.g. viral / contradictory / contradictory_dominant_viral), and db_category_counts (label:count audit string), db_database, is_viral.

Source code in tcrsift/annotate.py
def match_clonotypes(
    clonotypes: pd.DataFrame,
    database: pd.DataFrame,
    match_by: str = "CDR3ab",
    match_strictness: str | None = None,
    match_mode: str = "exact",
    max_distance: int = 1,
    verbose: bool = True,
    show_progress: bool = True,
) -> pd.DataFrame:
    """
    Match clonotypes against public database.

    Parameters
    ----------
    clonotypes : pd.DataFrame
        Clonotype DataFrame
    database : pd.DataFrame
        Combined database from load_databases
    match_by : str
        Legacy matching strategy. ``"CDR3ab"`` (both chains, β-only
        fallback) or ``"CDR3b_only"`` (beta only). Kept for back-compat;
        prefer ``match_strictness`` for new code.
    match_strictness : str, optional
        Explicit matching strictness. When set, takes precedence over
        ``match_by``. One of:

        - ``"strict_ab"`` — αβ-only, no β-only fallback. Best when you
          want headline match counts you can trust.
        - ``"ab_with_partial"`` — αβ first, β-only fallback per clone
          (equivalent to legacy ``match_by="CDR3ab"``).
        - ``"b_only"`` — β-only across the board (equivalent to legacy
          ``match_by="CDR3b_only"``).
    match_mode : str, default ``"exact"``
        Distance regime for the β-only match (#57). When ``"exact"``,
        only exact-string β matches are recorded — pre-2.1 behaviour.
        When ``"levenshtein"``, clones that don't hit exactly fall
        through to a Levenshtein-≤``max_distance`` neighbor search
        against the database's β CDR3s. αβ matching stays strict-exact
        in both modes — fuzzy αβ is too noisy biologically.
    max_distance : int, default 1
        Maximum edit distance for fuzzy β matching (only used when
        ``match_mode == "levenshtein"``). Currently capped at 1 in
        the implementation; higher values fall back to 1 with a
        warning.
    verbose : bool
        Print progress information
    show_progress : bool
        Show progress bar

    Returns
    -------
    pd.DataFrame
        Clonotypes with match annotations: ``db_match``,
        ``db_match_partial`` (β-only fallback flag — back-compat),
        ``db_match_strength`` (``"ab"`` / ``"b_only"`` / ``"ab_cross"``
        / ``"b_only_cross"`` / ``"b_only_near"`` /
        ``"b_only_near_cross"`` / None; ``_cross`` suffix indicates a
        non-human host-species match per #83; ``_near`` suffix
        indicates a Levenshtein-≤1 β-only fuzzy match per #57),
        ``db_match_distance`` (int — edit distance to the matched DB
        β CDR3; 0 for exact, 1 for Levenshtein-1, ``None`` when
        unmatched),
        ``db_epitope``, ``db_protein``, ``db_protein_canonical``,
        ``db_species`` (canonical antigen source organism),
        ``db_host_species`` (canonical TCR donor organism, new in #83),
        ``db_mhc``, ``db_category`` (``viral`` / ``bacterial`` /
        ``self`` / ``tumor_self`` / ``other`` / ``unknown`` /
        ``contradictory`` — last when multiple matches disagree on
        an informative label per #83), ``db_category_dominant`` (most
        common informative label when unique), ``db_category_detail``
        (e.g. ``viral`` / ``contradictory`` /
        ``contradictory_dominant_viral``), and
        ``db_category_counts`` (``label:count`` audit string),
        ``db_database``, ``is_viral``.
    """
    # Validate inputs
    clonotypes = validate_clonotype_df(clonotypes, for_annotation=True)
    database = validate_dataframe(database, "database", min_rows=1)

    if match_strictness is not None:
        if match_strictness not in MATCH_STRICTNESS_MODES:
            raise TCRsiftValidationError(
                f"Invalid match_strictness: '{match_strictness}'",
                hint=f"Valid options are: {list(MATCH_STRICTNESS_MODES)}",
            )
        strictness = match_strictness
    else:
        valid_match_by = ["CDR3ab", "CDR3b_only"]
        if match_by not in valid_match_by:
            raise TCRsiftValidationError(
                f"Invalid match_by: '{match_by}'",
                hint=f"Valid options are: {valid_match_by}",
            )
        strictness = "ab_with_partial" if match_by == "CDR3ab" else "b_only"

    # #57: validate fuzzy-match parameters before doing any DB work.
    if match_mode not in MATCH_MODES:
        raise TCRsiftValidationError(
            f"Invalid match_mode: {match_mode!r}",
            hint=f"Valid options are: {list(MATCH_MODES)}",
        )
    validate_numeric_param(max_distance, "max_distance", min_value=1)
    if match_mode == "levenshtein" and max_distance > 1:
        logger.warning(
            "match_mode='levenshtein' currently supports max_distance=1 "
            "only; clamping max_distance=%d → 1.", max_distance,
        )
        max_distance = 1

    if verbose:
        logger.info(
            f"Matching {len(clonotypes):,} clonotypes against "
            f"{len(database):,} database entries "
            f"(strictness={strictness}, mode={match_mode})"
        )

    df = clonotypes.copy()

    # Initialize annotation columns. New columns added in #48; existing
    # ones (db_match, db_match_partial, db_epitope, db_species,
    # db_database, is_viral) kept for back-compat.
    df["db_match"] = False
    df["db_match_partial"] = False
    df["db_match_strength"] = None
    df["db_match_distance"] = None  # #57: edit distance to matched DB β
    df["db_match_score"] = None     # #56: composite confidence ∈ [0, 1]
    df["db_v_gene_agreement"] = None
    df["db_n_studies"] = None
    df["db_category_votes"] = None
    df["db_epitope"] = None
    df["db_protein"] = None
    df["db_protein_canonical"] = None
    df["db_species"] = None
    df["db_host_species"] = None
    df["db_mhc"] = None
    df["db_category"] = None
    df["db_category_dominant"] = None
    df["db_category_detail"] = None
    df["db_category_counts"] = None
    df["db_database"] = None
    df["is_viral"] = False

    # Defensive check: the upstream `load_vdjdb` / `load_iedb` / `load_cedar`
    # loaders standardize column names via mappings keyed on header strings
    # they expect. When a database file's format drifts (e.g. IEDB's v3 CSV
    # export with hierarchical headers vs the older flat-header TSV the
    # loader was written for, #46), the standardization silently does
    # nothing and the resulting `database` lacks `cdr3_alpha`/`cdr3_beta`.
    # Catch that here with a clear warning rather than `KeyError` later.
    required = {"cdr3_beta"} if strictness == "b_only" else {"cdr3_alpha", "cdr3_beta"}
    missing = required - set(database.columns)
    if missing:
        dbs = sorted(set(database.get("database", pd.Series(["unknown"]))))
        logger.warning(
            f"Skipping annotation: database(s) {dbs} are missing required "
            f"columns {sorted(missing)} after standardization. The source "
            "file format may have changed since the loader was written. "
            "Inspect the column list before reporting this as a tcrsift bug."
        )
        return df

    database = _canonicalize_database_species(database)

    # Pre-classify the entire database once so the per-clone match path
    # picks db_category / db_protein_canonical as modes like any other
    # field — avoids invoking the classifiers with a 1-row Series per
    # match (#48 follow-up). `database.assign` returns a copy so the
    # caller's df isn't mutated.
    species_col = (
        database["species"]
        if "species" in database.columns
        else pd.Series([""] * len(database), index=database.index)
    )
    antigen_col = (
        database["antigen_gene"]
        if "antigen_gene" in database.columns
        else pd.Series([""] * len(database), index=database.index)
    )
    new_columns: dict[str, pd.Series] = {}
    if "db_category" not in database.columns:
        new_columns["db_category"] = classify_category(species_col, antigen_col)
    if "db_protein_canonical" not in database.columns:
        new_columns["db_protein_canonical"] = canonicalize_antigens(antigen_col)
    if new_columns:
        database = database.assign(**new_columns)

    # Build lookup sets for fast matching. #57: when match_mode is
    # ``"levenshtein"``, also build the β-only deletion-canonical
    # neighbor index used as a fuzzy fallback for clones that don't hit
    # exactly. αβ matching stays strict-exact regardless of match_mode
    # — fuzzy αβ would inflate spurious cross-paper near-hits.
    beta_neighbor_index: dict[str, set[str]] | None = (
        _build_cdr3_neighbor_index(database["cdr3_beta"])
        if match_mode == "levenshtein"
        else None
    )

    if strictness in ("strict_ab", "ab_with_partial"):
        allow_b_fallback = strictness == "ab_with_partial"
        # Only genuine paired (αβ) DB rows seed the paired-match set. A DB row
        # with an empty/NA alpha is NOT a paired entry — including it would let
        # an alpha-less clone (alpha="") match ("", beta) and be mislabeled as a
        # full-confidence "ab" hit instead of beta-only (and, because the later
        # row-filter `cdr3_alpha == ""` matches no NA rows, get no annotation at
        # all in ab_with_partial). See the `alpha and ...` guard below.
        _db_paired = database[
            database["cdr3_alpha"].notna()
            & (database["cdr3_alpha"].astype(str) != "")
        ]
        db_alpha_beta = set(
            zip(_db_paired["cdr3_alpha"], _db_paired["cdr3_beta"].fillna(""))
        )
        db_beta_values = (
            set(database["cdr3_beta"].dropna()) if allow_b_fallback else set()
        )

        row_iter = df.iterrows()
        if show_progress:
            row_iter = tqdm(
                list(df.iterrows()),
                desc="Matching clonotypes",
                unit="clone",
            )

        for idx, row in row_iter:
            alpha = row.get("CDR3_alpha", "") or ""
            beta = row.get("CDR3_beta", "") or ""

            if alpha and (alpha, beta) in db_alpha_beta:
                matches = database[
                    (database["cdr3_alpha"] == alpha) & (database["cdr3_beta"] == beta)
                ]
                _annotate_match(df, idx, matches, strength="ab", distance=0)
            elif allow_b_fallback and beta and beta in db_beta_values:
                matches = database[database["cdr3_beta"] == beta]
                _annotate_match(
                    df, idx, matches, strength="b_only", partial=True, distance=0,
                )
            elif beta_neighbor_index is not None and beta:
                # #57: Lev-1 β neighbor fallback. Find the nearest
                # database β CDR3 and aggregate matches against it.
                neighbors = _find_lev_neighbors(beta, beta_neighbor_index, max_distance)
                if neighbors:
                    best_d = neighbors[0][1]
                    best_cdr3s = [c for c, d in neighbors if d == best_d]
                    matches = database[database["cdr3_beta"].isin(best_cdr3s)]
                    _annotate_match(
                        df, idx, matches,
                        strength="b_only_near", partial=True, distance=best_d,
                    )

    else:  # strictness == "b_only"
        db_beta_set = set(database["cdr3_beta"].dropna())

        row_iter = df.iterrows()
        if show_progress:
            row_iter = tqdm(
                list(df.iterrows()),
                desc="Matching clonotypes",
                unit="clone",
            )

        for idx, row in row_iter:
            beta = row.get("CDR3_beta", "") or ""
            if beta in db_beta_set:
                matches = database[database["cdr3_beta"] == beta]
                _annotate_match(df, idx, matches, strength="b_only", distance=0)
            elif beta_neighbor_index is not None and beta:
                neighbors = _find_lev_neighbors(beta, beta_neighbor_index, max_distance)
                if neighbors:
                    best_d = neighbors[0][1]
                    best_cdr3s = [c for c, d in neighbors if d == best_d]
                    matches = database[database["cdr3_beta"].isin(best_cdr3s)]
                    _annotate_match(
                        df, idx, matches,
                        strength="b_only_near", distance=best_d,
                    )

    n_matches = df["db_match"].sum()
    n_viral = df["is_viral"].sum()
    if verbose:
        logger.info(f"  Found {n_matches:,} matches ({n_viral:,} viral)")

    return df

annotate_clonotypes

annotate_clonotypes(clonotypes: DataFrame, vdjdb_path: str | Path | None = None, iedb_path: str | Path | None = None, cedar_path: str | Path | None = None, iedb_epitope_path: str | Path | None = None, match_by: str = 'CDR3ab', match_strictness: str | None = None, match_mode: str = 'exact', max_distance: int = 1, exclude_viral: bool = False, flag_only: bool = False, *, add_publicness: bool = False, publicness_cdr3_col: str = 'CDR3_beta', publicness_v_gene_col: str = 'beta_v_gene', publicness_j_gene_col: str = 'beta_j_gene') -> pd.DataFrame

Main annotation function.

Parameters:

Name Type Description Default
clonotypes DataFrame

Clonotype DataFrame

required
vdjdb_path str or Path

Paths to databases

None
iedb_path str or Path

Paths to databases

None
cedar_path str or Path

Paths to databases

None
iedb_epitope_path str or Path

Path to the IEDB epitope-level table (companion to iedb_path). When provided, IEDB receptor-row antigen/species strings are overridden with the epitope-table's typically shorter and more canonical equivalents (#54).

None
match_by str

Legacy matching strategy. Prefer match_strictness for new code.

'CDR3ab'
match_strictness str

Explicit matching strictness — "strict_ab" / "ab_with_partial" / "b_only". Overrides match_by when set. See :func:match_clonotypes.

None
exclude_viral bool

Remove clones matching viral epitopes

False
flag_only bool

Just flag viral, don't remove

False
add_publicness bool

When True, add log10_pgen and publicness columns from :func:tcrsift.pgen.annotate_publicness (#58). High-publicness sequences are likely to be public; downstream callers can discount DB matches against them by multiplying any per-match score by (1 - publicness).

False
publicness_cdr3_col str

Columns Pgen estimator should read.

'CDR3_beta'
publicness_v_gene_col str

Columns Pgen estimator should read.

'CDR3_beta'
publicness_j_gene_col str

Columns Pgen estimator should read.

'CDR3_beta'

Returns:

Type Description
DataFrame

Annotated clonotypes

Source code in tcrsift/annotate.py
def annotate_clonotypes(
    clonotypes: pd.DataFrame,
    vdjdb_path: str | Path | None = None,
    iedb_path: str | Path | None = None,
    cedar_path: str | Path | None = None,
    iedb_epitope_path: str | Path | None = None,
    match_by: str = "CDR3ab",
    match_strictness: str | None = None,
    match_mode: str = "exact",
    max_distance: int = 1,
    exclude_viral: bool = False,
    flag_only: bool = False,
    *,
    add_publicness: bool = False,
    publicness_cdr3_col: str = "CDR3_beta",
    publicness_v_gene_col: str = "beta_v_gene",
    publicness_j_gene_col: str = "beta_j_gene",
) -> pd.DataFrame:
    """
    Main annotation function.

    Parameters
    ----------
    clonotypes : pd.DataFrame
        Clonotype DataFrame
    vdjdb_path, iedb_path, cedar_path : str or Path, optional
        Paths to databases
    iedb_epitope_path : str or Path, optional
        Path to the IEDB epitope-level table (companion to
        ``iedb_path``). When provided, IEDB receptor-row antigen/species
        strings are overridden with the epitope-table's typically
        shorter and more canonical equivalents (#54).
    match_by : str
        Legacy matching strategy. Prefer ``match_strictness`` for new code.
    match_strictness : str, optional
        Explicit matching strictness — ``"strict_ab"`` /
        ``"ab_with_partial"`` / ``"b_only"``. Overrides ``match_by``
        when set. See :func:`match_clonotypes`.
    exclude_viral : bool
        Remove clones matching viral epitopes
    flag_only : bool
        Just flag viral, don't remove
    add_publicness : bool
        When True, add ``log10_pgen`` and ``publicness`` columns from
        :func:`tcrsift.pgen.annotate_publicness` (#58). High-publicness
        sequences are likely to be public; downstream callers can
        discount DB matches against them by multiplying any per-match
        score by ``(1 - publicness)``.
    publicness_cdr3_col, publicness_v_gene_col, publicness_j_gene_col : str
        Columns Pgen estimator should read.

    Returns
    -------
    pd.DataFrame
        Annotated clonotypes
    """
    # Columns that get initialized on the no-database short-circuit.
    # Mirrors the column set produced by match_clonotypes so downstream
    # code can rely on these always being present.
    _DEFAULT_ANNOTATION_COLUMNS = {
        "db_match": False,
        "db_match_partial": False,
        "db_match_strength": None,
        "db_match_distance": None,
        "db_match_score": None,
        "db_v_gene_agreement": None,
        "db_n_studies": None,
        "db_category_votes": None,
        "db_epitope": None,
        "db_protein": None,
        "db_protein_canonical": None,
        "db_species": None,
        "db_host_species": None,
        "db_mhc": None,
        "db_category": None,
        "db_category_dominant": None,
        "db_category_detail": None,
        "db_category_counts": None,
        "db_database": None,
        "is_viral": False,
    }

    # Annotation is optional: if no databases are provided, return input with default annotation columns.
    if not any([vdjdb_path, iedb_path, cedar_path]):
        logger.info("No annotation database paths provided; returning input with empty annotations")
        df = clonotypes.copy()
        for col, default in _DEFAULT_ANNOTATION_COLUMNS.items():
            if col not in df.columns:
                df[col] = default
        if add_publicness:
            df = _apply_publicness(
                df,
                cdr3_col=publicness_cdr3_col,
                v_gene_col=publicness_v_gene_col,
                j_gene_col=publicness_j_gene_col,
                log_summary=False,
            )
        return df

    # Load databases
    database = load_databases(
        vdjdb_path=vdjdb_path,
        iedb_path=iedb_path,
        cedar_path=cedar_path,
        iedb_epitope_path=iedb_epitope_path,
    )

    # Match clonotypes
    df = match_clonotypes(
        clonotypes,
        database,
        match_by=match_by,
        match_strictness=match_strictness,
        match_mode=match_mode,
        max_distance=max_distance,
    )

    # Handle viral exclusion
    if exclude_viral and not flag_only:
        initial = len(df)
        df = df[~df["is_viral"]]
        logger.info(f"Excluded {initial - len(df)} viral clones")

    # Publicness annotation (#58). Computed after match filtering so
    # the resulting Pgen/publicness columns align with the final row
    # set.
    if add_publicness:
        df = _apply_publicness(
            df,
            cdr3_col=publicness_cdr3_col,
            v_gene_col=publicness_v_gene_col,
            j_gene_col=publicness_j_gene_col,
            log_summary=True,
        )

    return df

get_annotation_summary

get_annotation_summary(clonotypes: DataFrame) -> dict

Get summary of annotation results.

Returns:

Type Description
dict

Summary statistics

Source code in tcrsift/annotate.py
def get_annotation_summary(clonotypes: pd.DataFrame) -> dict:
    """
    Get summary of annotation results.

    Returns
    -------
    dict
        Summary statistics
    """
    summary = {
        "total": len(clonotypes),
        "matched": clonotypes["db_match"].sum() if "db_match" in clonotypes.columns else 0,
        "viral": clonotypes["is_viral"].sum() if "is_viral" in clonotypes.columns else 0,
    }

    if "db_database" in clonotypes.columns:
        db_counts = {}
        for db in ["VDJdb", "IEDB", "CEDAR"]:
            db_counts[db] = clonotypes["db_database"].fillna("").str.contains(db).sum()
        summary["database_breakdown"] = db_counts

    if "db_species" in clonotypes.columns:
        species_counts = clonotypes["db_species"].value_counts().head(10).to_dict()
        summary["top_species"] = species_counts

    return summary