vdjmatch 0.0.1__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.
vdjmatch/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """vdjmatch — fast, control-calibrated annotation of TCR antigen specificity.
2
+
3
+ Built on the ``seqtree`` fuzzy-search core: annotates AIRR clonotypes against VDJdb,
4
+ reporting control-calibrated E-values and enriched antigen labels. See ROADMAP in the
5
+ seqtree repo and the project README for the design.
6
+ """
7
+
8
+ __version__ = "0.0.1"
@@ -0,0 +1,4 @@
1
+ """Epitope-level enrichment aggregation."""
2
+ from .enrichment import epitope_summary, best_call
3
+
4
+ __all__ = ["epitope_summary", "best_call"]
@@ -0,0 +1,40 @@
1
+ """Aggregate per-hit annotations into epitope-level enrichment and per-query best calls."""
2
+ from __future__ import annotations
3
+
4
+ import polars as pl
5
+
6
+
7
+ def epitope_summary(hits: pl.DataFrame) -> pl.DataFrame:
8
+ """Epitope-level summary: unique matched query clonotypes, reads, and best alignment score,
9
+ grouped by (epitope, mhc_class, antigen_species). Sorted by unique clonotypes desc."""
10
+ if hits.height == 0:
11
+ return pl.DataFrame(schema={"epitope": pl.Utf8, "mhc_class": pl.Utf8,
12
+ "antigen_species": pl.Utf8, "unique": pl.UInt32,
13
+ "reads": pl.Int64, "best_score": pl.Int32})
14
+ per_q = (hits.group_by("epitope", "mhc_class", "antigen_species", "query_cdr3")
15
+ .agg(pl.col("count").first(), pl.col("score").min().alias("best_score")))
16
+ return (per_q.group_by("epitope", "mhc_class", "antigen_species")
17
+ .agg(pl.col("query_cdr3").n_unique().alias("unique"),
18
+ pl.col("count").sum().alias("reads"),
19
+ pl.col("best_score").min().alias("best_score"))
20
+ .sort("unique", descending=True))
21
+
22
+
23
+ def best_call(hits: pl.DataFrame, evals: pl.DataFrame | None = None) -> pl.DataFrame:
24
+ """Per query clonotype, the predicted epitope = most-supported (most DB records, then highest
25
+ VDJdb confidence, then best alignment). If ``evals`` (query_evalues output) is supplied, attach
26
+ the query's E / p_enrichment as the annotation confidence."""
27
+ if hits.height == 0:
28
+ return hits
29
+ support = (hits.group_by("query_cdr3", "epitope")
30
+ .agg(pl.len().alias("n_records"),
31
+ pl.col("vdjdb_score").max().alias("db_score"),
32
+ pl.col("score").min().alias("best_score"),
33
+ pl.col("mhc_class").first(), pl.col("antigen_species").first()))
34
+ best = (support.sort(["query_cdr3", "n_records", "db_score", "best_score"],
35
+ descending=[False, True, True, False])
36
+ .group_by("query_cdr3", maintain_order=True).first())
37
+ if evals is not None:
38
+ best = best.join(evals.select("query_cdr3", "E", "p_enrichment", "rule_of_three"),
39
+ on="query_cdr3", how="left")
40
+ return best
File without changes
@@ -0,0 +1,79 @@
1
+ """vdjmatch command-line interface.
2
+
3
+ Subcommands:
4
+ update fetch/cache the latest VDJdb release
5
+ match annotate query sample(s) against VDJdb (E-values + ranked hits + epitope summary)
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from .. import db, match
14
+ from ..runner.multisample import annotate_sample
15
+
16
+
17
+ def _cmd_update(a: argparse.Namespace) -> int:
18
+ path = db.fetch_latest(asset=a.asset, pin=a.pin, force=a.force)
19
+ print(f"VDJdb {a.asset} cached at {path}")
20
+ return 0
21
+
22
+
23
+ def _cmd_match(a: argparse.Namespace) -> int:
24
+ vdj = db.load(a.vdjdb, asset=a.asset, species=a.species, min_score=a.min_score, pin=a.pin)
25
+ print(f"VDJdb: {vdj.height:,} records "
26
+ f"(species={a.species}, min_score={a.min_score})", file=sys.stderr)
27
+ index = match.VdjdbIndex.build(vdj, species=a.species)
28
+ print(f"indexed genes: {index.genes}", file=sys.stderr)
29
+ matrix = match.load_vdjam() if a.matrix == "vdjam" else ""
30
+ outdir = Path(a.output_prefix).parent
31
+ outdir.mkdir(parents=True, exist_ok=True)
32
+ for sample in a.samples:
33
+ name = Path(sample).name.split(".")[0]
34
+ print(f"[{name}] annotating ...", file=sys.stderr)
35
+ res = annotate_sample(index, sample, scope=a.scope, matrix=matrix or None,
36
+ species=a.species, with_evalue=not a.no_evalue,
37
+ match_v=a.match_v, match_j=a.match_j, align=not a.no_align,
38
+ threads=a.threads)
39
+ for kind, frame in res.items():
40
+ out = f"{a.output_prefix}.{name}.{kind}.txt"
41
+ frame.write_csv(out, separator="\t")
42
+ print(f"[{name}] {res['hits'].height} hit rows, "
43
+ f"{res['summary'].height} epitopes -> {a.output_prefix}.{name}.*.txt", file=sys.stderr)
44
+ return 0
45
+
46
+
47
+ def main(argv: list[str] | None = None) -> int:
48
+ p = argparse.ArgumentParser(prog="vdjmatch", description=__doc__)
49
+ sub = p.add_subparsers(dest="cmd", required=True)
50
+
51
+ up = sub.add_parser("update", help="fetch/cache the latest VDJdb release")
52
+ up.add_argument("--asset", default="slim", choices=["slim", "full", "default"])
53
+ up.add_argument("--pin", default=None, help="pin a release tag")
54
+ up.add_argument("--force", action="store_true")
55
+ up.set_defaults(func=_cmd_update)
56
+
57
+ m = sub.add_parser("match", help="annotate sample(s) against VDJdb")
58
+ m.add_argument("samples", nargs="+", help="AIRR rearrangement sample file(s)")
59
+ m.add_argument("-o", "--output-prefix", default="vdjmatch_out")
60
+ m.add_argument("--vdjdb", default=None, help="VDJdb table path (default: fetch latest)")
61
+ m.add_argument("--asset", default="full", choices=["slim", "full", "default"])
62
+ m.add_argument("--pin", default=None)
63
+ m.add_argument("--species", default="HomoSapiens")
64
+ m.add_argument("--scope", default="1,0,0,1", help="s,i,d,t search budget")
65
+ m.add_argument("--matrix", default="vdjam", choices=["vdjam", "none"])
66
+ m.add_argument("--min-score", type=int, default=0, help="min VDJdb confidence")
67
+ m.add_argument("--match-v", action="store_true")
68
+ m.add_argument("--match-j", action="store_true")
69
+ m.add_argument("--no-evalue", action="store_true")
70
+ m.add_argument("--no-align", action="store_true")
71
+ m.add_argument("--threads", type=int, default=0)
72
+ m.set_defaults(func=_cmd_match)
73
+
74
+ a = p.parse_args(argv)
75
+ return a.func(a)
76
+
77
+
78
+ if __name__ == "__main__":
79
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ """Pairwise sample overlap."""
2
+ from .overlap import overlap, overlap_metrics
3
+ __all__ = ["overlap", "overlap_metrics"]
@@ -0,0 +1,36 @@
1
+ """Pairwise sample overlap via fuzzy CDR3 matching (ports the legacy ``cluster`` routine)."""
2
+ from __future__ import annotations
3
+
4
+ import polars as pl
5
+ from seqtree import pairwise_batch
6
+
7
+ from ..match.scope import search_params
8
+
9
+
10
+ def overlap(a: list[str], b: list[str] | None = None, scope: str = "1,0,0,1",
11
+ matrix=None, threads: int = 0) -> pl.DataFrame:
12
+ """Fuzzy-matching pairs between two CDR3 sets (or within one if ``b`` is None).
13
+
14
+ Returns a long frame ``a_idx, a_cdr3, b_idx, b_cdr3, score, n_subs`` of all within-scope
15
+ pairs. Within-set mode (``b is None``) drops the trivial self-pairs (i == j).
16
+ """
17
+ within = b is None
18
+ bb = a if within else b
19
+ params = search_params(scope, engine="seqtm", matrix=matrix or "")
20
+ res = pairwise_batch(a, bb, params, "aa", threads)
21
+ rows = [(i, a[i], h.ref_id, bb[h.ref_id], h.score, h.n_subs)
22
+ for i, hl in enumerate(res) for h in hl if not (within and i == h.ref_id)]
23
+ return pl.DataFrame(rows, orient="row",
24
+ schema=["a_idx", "a_cdr3", "b_idx", "b_cdr3", "score", "n_subs"])
25
+
26
+
27
+ def overlap_metrics(a: list[str], b: list[str], scope: str = "1,0,0,1",
28
+ threads: int = 0) -> dict[str, float]:
29
+ """Summary overlap metrics between two repertoires: number of matched pairs, and the
30
+ fraction of each set with at least one fuzzy match in the other."""
31
+ pairs = overlap(a, b, scope=scope, threads=threads)
32
+ a_hit = pairs["a_idx"].n_unique() if pairs.height else 0
33
+ b_hit = pairs["b_idx"].n_unique() if pairs.height else 0
34
+ return {"pairs": float(pairs.height),
35
+ "frac_a_matched": a_hit / len(a) if a else 0.0,
36
+ "frac_b_matched": b_hit / len(b) if b else 0.0}
@@ -0,0 +1,5 @@
1
+ """VDJdb fetch, cache, and parse."""
2
+ from .vdjdb import fetch_latest, load
3
+ from . import schema
4
+
5
+ __all__ = ["fetch_latest", "load", "schema"]
vdjmatch/db/cache.py ADDED
@@ -0,0 +1,16 @@
1
+ """Cache directory resolution for downloaded VDJdb releases."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+
7
+
8
+ def cache_dir(override: str | os.PathLike | None = None) -> Path:
9
+ """Return (and create) the vdjmatch cache directory.
10
+
11
+ Order: explicit ``override`` → ``$VDJMATCH_CACHE`` → ``~/.cache/vdjmatch``.
12
+ """
13
+ base = override or os.environ.get("VDJMATCH_CACHE") or (Path.home() / ".cache" / "vdjmatch")
14
+ p = Path(base)
15
+ p.mkdir(parents=True, exist_ok=True)
16
+ return p
vdjmatch/db/schema.py ADDED
@@ -0,0 +1,50 @@
1
+ """Canonical VDJdb schema and column normalization.
2
+
3
+ VDJdb ships dot-named columns (``v.segm``, ``antigen.epitope``, ``mhc.a`` …) in both the
4
+ ``full`` and ``slim`` exports. We normalize to snake_case canonical names so the rest of the
5
+ package never special-cases the source format.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import polars as pl
10
+
11
+ # canonical name -> set of accepted source headers (VDJdb full + slim variants)
12
+ _VDJDB_MAP: dict[str, tuple[str, ...]] = {
13
+ "complex_id": ("complex.id",),
14
+ "gene": ("gene",),
15
+ "cdr3": ("cdr3",),
16
+ "v": ("v.segm", "v.end", "v"),
17
+ "j": ("j.segm", "j.start", "j"),
18
+ "species": ("species",),
19
+ "mhc_a": ("mhc.a",),
20
+ "mhc_b": ("mhc.b",),
21
+ "mhc_class": ("mhc.class",),
22
+ "epitope": ("antigen.epitope",),
23
+ "antigen_gene": ("antigen.gene",),
24
+ "antigen_species": ("antigen.species",),
25
+ "reference_id": ("reference.id",),
26
+ "vdjdb_score": ("vdjdb.score",),
27
+ }
28
+
29
+ # the columns vdjmatch actually uses downstream (others are dropped on load)
30
+ CANONICAL: tuple[str, ...] = tuple(_VDJDB_MAP.keys())
31
+
32
+
33
+ def normalize(df: pl.DataFrame) -> pl.DataFrame:
34
+ """Rename VDJdb source columns to canonical names; keep only recognized columns.
35
+
36
+ Missing canonical columns are filled with nulls so downstream code can rely on the
37
+ schema regardless of whether a ``slim`` or ``full`` export was loaded.
38
+ """
39
+ present = set(df.columns)
40
+ renames = {src: canon for canon, srcs in _VDJDB_MAP.items()
41
+ for src in srcs if src in present}
42
+ df = df.rename(renames)
43
+ have = set(df.columns)
44
+ out_cols = []
45
+ for canon in CANONICAL:
46
+ if canon in have:
47
+ out_cols.append(pl.col(canon))
48
+ else:
49
+ out_cols.append(pl.lit(None).alias(canon))
50
+ return df.select(out_cols)
vdjmatch/db/vdjdb.py ADDED
@@ -0,0 +1,108 @@
1
+ """Fetch and parse VDJdb releases (github.com/antigenomics/vdjdb-db).
2
+
3
+ The latest release ships a single ZIP (e.g. ``vdjdb-2026-06-03.zip``) that contains the
4
+ ``vdjdb.txt`` / ``vdjdb.slim.txt`` / ``vdjdb_full.txt`` tables. ``fetch_latest`` downloads and
5
+ caches the ZIP by release tag and extracts the requested table; ``load`` parses any VDJdb table
6
+ (downloaded or a local snapshot) into a normalized polars frame.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import json
12
+ import os
13
+ import urllib.request
14
+ import zipfile
15
+ from pathlib import Path
16
+
17
+ import polars as pl
18
+
19
+ from . import schema
20
+ from .cache import cache_dir
21
+
22
+ _REPO = "antigenomics/vdjdb-db"
23
+ _UA = {"User-Agent": "vdjmatch", "Accept": "application/vnd.github+json"}
24
+ # requested table -> substring matched against ZIP member basenames (most specific first)
25
+ _MEMBER = {"full": "vdjdb_full.txt", "slim": "vdjdb.slim.txt", "default": "vdjdb.txt"}
26
+
27
+
28
+ def _release_json(pin: str | None) -> dict:
29
+ url = (f"https://api.github.com/repos/{_REPO}/releases/tags/{pin}" if pin
30
+ else f"https://api.github.com/repos/{_REPO}/releases/latest")
31
+ req = urllib.request.Request(url, headers=_UA)
32
+ with urllib.request.urlopen(req, timeout=60) as r: # noqa: S310 (trusted host)
33
+ return json.load(r)
34
+
35
+
36
+ def _zip_asset(rel: dict) -> str:
37
+ for a in rel.get("assets", []):
38
+ if a["name"].endswith(".zip"):
39
+ return a["browser_download_url"]
40
+ raise RuntimeError(f"no .zip asset in VDJdb release {rel.get('tag_name')!r}")
41
+
42
+
43
+ def fetch_latest(asset: str = "slim", cache: str | os.PathLike | None = None,
44
+ pin: str | None = None, force: bool = False) -> Path:
45
+ """Download (and cache) a VDJdb table from the latest release (or ``pin`` tag).
46
+
47
+ Args:
48
+ asset: which table to extract — ``"slim"`` (default), ``"full"``, or ``"default"``.
49
+ cache: cache directory (default ``~/.cache/vdjmatch`` or ``$VDJMATCH_CACHE``).
50
+ pin: pin a specific release tag for reproducibility; ``None`` = latest.
51
+ force: re-download even if cached.
52
+
53
+ Returns:
54
+ Path to the extracted table file, named ``vdjdb-<tag>.<asset>.txt`` in the cache.
55
+ """
56
+ cdir = cache_dir(cache)
57
+ rel = _release_json(pin)
58
+ tag = rel["tag_name"]
59
+ out = cdir / f"vdjdb-{tag}.{asset}.txt"
60
+ if out.exists() and not force:
61
+ return out
62
+ zip_path = cdir / f"vdjdb-{tag}.zip"
63
+ if not zip_path.exists() or force:
64
+ req = urllib.request.Request(_zip_asset(rel), headers=_UA)
65
+ with urllib.request.urlopen(req, timeout=300) as r: # noqa: S310
66
+ data = r.read()
67
+ zip_path.write_bytes(data)
68
+ want = _MEMBER.get(asset, _MEMBER["default"])
69
+ with zipfile.ZipFile(zip_path) as zf:
70
+ names = zf.namelist()
71
+ member = next((n for n in names if Path(n).name == want), None)
72
+ if member is None: # fall back to substring match
73
+ key = want.replace("vdjdb", "").strip("._") or "vdjdb.txt"
74
+ member = next((n for n in names if key in Path(n).name), None)
75
+ if member is None:
76
+ raise RuntimeError(f"{want!r} not found in {zip_path.name}; members={names}")
77
+ out.write_bytes(zf.read(member))
78
+ return out
79
+
80
+
81
+ def load(source: str | os.PathLike | None = None, *, asset: str = "slim",
82
+ species: str | None = None, gene: str | None = None, mhc_class: str | None = None,
83
+ min_score: int = 0, paired_only: bool = False, pin: str | None = None) -> pl.DataFrame:
84
+ """Parse a VDJdb table into a normalized polars frame (see ``schema.CANONICAL``).
85
+
86
+ ``source`` is a path to an existing table (e.g. a local snapshot); if ``None`` the latest
87
+ release is fetched (``asset``/``pin``). Optional filters: ``species`` (e.g. ``"HomoSapiens"``),
88
+ ``gene`` (``"TRA"``/``"TRB"``), ``mhc_class`` (``"MHCI"``/``"MHCII"``), ``min_score`` (VDJdb
89
+ confidence), ``paired_only`` (keep only rows with a non-zero ``complex_id``).
90
+ """
91
+ path = Path(source) if source is not None else fetch_latest(asset=asset, pin=pin)
92
+ df = pl.read_csv(path, separator="\t", quote_char=None, infer_schema_length=0) # all str
93
+ df = schema.normalize(df)
94
+ df = df.with_columns(
95
+ pl.col("vdjdb_score").cast(pl.Int64, strict=False).fill_null(0),
96
+ pl.col("complex_id").cast(pl.Int64, strict=False).fill_null(0),
97
+ )
98
+ if species is not None:
99
+ df = df.filter(pl.col("species") == species)
100
+ if gene is not None:
101
+ df = df.filter(pl.col("gene") == gene)
102
+ if mhc_class is not None:
103
+ df = df.filter(pl.col("mhc_class") == mhc_class)
104
+ if min_score > 0:
105
+ df = df.filter(pl.col("vdjdb_score") >= min_score)
106
+ if paired_only:
107
+ df = df.filter(pl.col("complex_id") != 0)
108
+ return df
@@ -0,0 +1,5 @@
1
+ """Control-calibrated E-values (single-chain now; paired later)."""
2
+ from .single import query_evalues
3
+ from .control import background
4
+
5
+ __all__ = ["query_evalues", "background"]
@@ -0,0 +1,25 @@
1
+ """Background control repertoires for E-value calibration (thin wrapper over seqtree)."""
2
+ from __future__ import annotations
3
+
4
+ from seqtree import Index
5
+ from seqtree.control import load_control
6
+
7
+ # query locus -> seqtree control name (bundled human_trb_aa; others via HuggingFace)
8
+ _CONTROL = {
9
+ "TRB": "human_trb_aa", "human:TRB": "human_trb_aa",
10
+ "TRA": "human_tra_aa", "human:TRA": "human_tra_aa",
11
+ "mouse:TRB": "mouse_trb_aa", "mouse:TRA": "mouse_tra_aa",
12
+ }
13
+
14
+
15
+ def background(locus: str = "TRB", species: str = "human", size: int | None = None,
16
+ cache_dir: str | None = None) -> Index:
17
+ """Load a deduplicated background repertoire ``Index`` for the given locus/species.
18
+
19
+ Bundled: human TRB. Others (human TRA, mouse TRA/TRB) download via ``seqtree[control]``.
20
+ """
21
+ key = f"{species.lower()}:{locus}"
22
+ name = _CONTROL.get(key) or _CONTROL.get(locus)
23
+ if name is None:
24
+ raise ValueError(f"no control for locus={locus!r} species={species!r}")
25
+ return load_control(name, size=size, cache_dir=cache_dir)
@@ -0,0 +1,32 @@
1
+ """Single-chain control-calibrated E-values (thin polars wrapper over ``seqtree.evalues``).
2
+
3
+ For each query CDR3 we count neighbours in the target (VDJdb) and in a matched background control
4
+ within the search ball; ``E = (N/M)·n_control`` is the number of VDJdb neighbours expected by the
5
+ generative/background process, and ``p_enrichment`` is the Poisson-tail significance that the query
6
+ has *more* VDJdb neighbours than chance — the hallmark of antigen-driven convergence. Theory:
7
+ seqtree ``appendix/evalue.tex``.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import polars as pl
12
+ from seqtree import Index, SearchParams
13
+ from seqtree.evalue import evalues
14
+
15
+
16
+ def query_evalues(target: Index, control: Index, queries: list[str], params: SearchParams,
17
+ threads: int = 0, exclude_exact: bool = False) -> pl.DataFrame:
18
+ """Per-query E-values → polars frame with columns ``query_cdr3, n_target, n_control, E,
19
+ p_any, p_enrichment, rule_of_three`` (one row per input query, input order preserved).
20
+
21
+ ``exclude_exact=True`` punctures distance-0 (self/duplicate) hits on both sides — use when
22
+ queries may be members of the target/control (e.g. VDJdb-vs-VDJdb verification)."""
23
+ rows = evalues(target, control, queries, params, threads=threads, exclude_exact=exclude_exact)
24
+ return pl.DataFrame({
25
+ "query_cdr3": queries,
26
+ "n_target": [r["n_target"] for r in rows],
27
+ "n_control": [r["n_control"] for r in rows],
28
+ "E": [r["E"] for r in rows],
29
+ "p_any": [r["p_any"] for r in rows],
30
+ "p_enrichment": [r["p_enrichment"] for r in rows],
31
+ "rule_of_three": [r["rule_of_three"] for r in rows],
32
+ })
@@ -0,0 +1,5 @@
1
+ """Query repertoire I/O (AIRR rearrangement + paired cell / TCRvdb)."""
2
+ from .airr import read_rearrangement, read_cell, read_tcrvdb
3
+ from . import columns
4
+
5
+ __all__ = ["read_rearrangement", "read_cell", "read_tcrvdb", "columns"]
vdjmatch/io/airr.py ADDED
@@ -0,0 +1,70 @@
1
+ """Read query repertoires: single-chain (AIRR Rearrangement / VDJtools) and paired (AIRR Cell
2
+ long-form linked by cell/clone id, or TCRvdb wide-form with α/β columns in one row).
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from pathlib import Path
8
+
9
+ import polars as pl
10
+
11
+ from . import columns
12
+
13
+ _SEP = {".csv": ",", ".tsv": "\t", ".txt": "\t"}
14
+
15
+
16
+ def _read_table(path: str | os.PathLike) -> pl.DataFrame:
17
+ p = Path(path)
18
+ sep = _SEP.get(p.suffix.lower(), "\t")
19
+ return pl.read_csv(p, separator=sep, quote_char='"', infer_schema_length=0)
20
+
21
+
22
+ def read_rearrangement(path: str | os.PathLike, dedup: bool = True,
23
+ valid_aa: bool = True) -> pl.DataFrame:
24
+ """Single-chain query → schema ``cdr3, v, j, locus, count, pair_id``. If ``dedup`` (default),
25
+ collapse to unique (cdr3, v, j, locus) summing ``count`` (required for E-values)."""
26
+ df = columns.normalize_query(_read_table(path), valid_aa=valid_aa)
27
+ if dedup:
28
+ df = (df.group_by("cdr3", "v", "j", "locus")
29
+ .agg(pl.col("count").sum(), pl.col("pair_id").first()))
30
+ return df
31
+
32
+
33
+ def read_cell(path: str | os.PathLike, link: str | None = None,
34
+ valid_aa: bool = True) -> pl.DataFrame:
35
+ """Paired long-form: one row per chain, α/β linked by ``link`` (default: auto = cell_id /
36
+ clone_id, resolved to ``pair_id``). Pivots to one row per cell with ``cdr3a/va/ja`` and
37
+ ``cdr3b/vb/jb`` (locus prefix TRA→a, TRB→b)."""
38
+ df = columns.normalize_query(_read_table(path), valid_aa=valid_aa)
39
+ if df["pair_id"].null_count() == df.height:
40
+ raise ValueError("no cell/clone link column found for paired (cell) input")
41
+ df = df.with_columns(
42
+ pl.when(pl.col("locus") == "TRA").then(pl.lit("a"))
43
+ .when(pl.col("locus") == "TRB").then(pl.lit("b"))
44
+ .otherwise(pl.col("locus").str.slice(2, 1).str.to_lowercase()).alias("ch"))
45
+ wide = df.pivot(values=["cdr3", "v", "j"], index="pair_id", on="ch",
46
+ aggregate_function="first")
47
+ rename = {f"{c}_{ch}": f"{c}{ch}" for c in ("cdr3", "v", "j") for ch in ("a", "b")}
48
+ return wide.rename({k: v for k, v in rename.items() if k in wide.columns})
49
+
50
+
51
+ def read_tcrvdb(path: str | os.PathLike, valid_aa: bool = True) -> pl.DataFrame:
52
+ """TCRvdb wide-form CSV: α/β CDR3s in one row. → ``cdr3a, va, ja, cdr3b, vb, jb,
53
+ epitope, mhc`` (epitope/mhc kept as ground-truth labels for benchmarking)."""
54
+ df = _read_table(path)
55
+ cols = {c.lower(): c for c in df.columns}
56
+ g = lambda *names: next((cols[n] for n in names if n in cols), None) # noqa: E731
57
+ sel = {
58
+ "cdr3a": g("cdr3_alpha_aa"), "va": g("trav"), "ja": g("traj"),
59
+ "cdr3b": g("cdr3_beta_aa"), "vb": g("trbv"), "jb": g("trbj"),
60
+ "epitope": g("epitope_aa", "antigen.epitope", "epitope"),
61
+ "mhc": g("hla_short", "hla_long", "mhc.a"),
62
+ }
63
+ out = df.select([pl.col(src).alias(dst) for dst, src in sel.items() if src is not None])
64
+ out = out.with_columns(
65
+ pl.col("cdr3a").str.strip_chars().str.to_uppercase(),
66
+ pl.col("cdr3b").str.strip_chars().str.to_uppercase())
67
+ if valid_aa:
68
+ rx = r"^[ACDEFGHIKLMNPQRSTVWY]+$"
69
+ out = out.filter(pl.col("cdr3a").str.contains(rx) & pl.col("cdr3b").str.contains(rx))
70
+ return out
vdjmatch/io/columns.py ADDED
@@ -0,0 +1,61 @@
1
+ """Column alias resolution for query repertoire files.
2
+
3
+ Accepts AIRR Rearrangement canonical headers and the VDJtools / short forms seen in the wild
4
+ (``v_gene``, ``j_gene``, ``junction_aa``, ``clone_id`` …). Normalizes to the vdjmatch query
5
+ schema: ``cdr3, v, j, locus, count, pair_id``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import polars as pl
10
+
11
+ AA = set("ACDEFGHIKLMNPQRSTVWY")
12
+
13
+ # canonical query column -> accepted source headers (case-insensitive), most specific first
14
+ ALIASES: dict[str, tuple[str, ...]] = {
15
+ "cdr3": ("cdr3_aa", "junction_aa", "cdr3"), # note: AIRR `junction`/`cdr3` (nt) excluded by suffix
16
+ "v": ("v_call", "v_gene", "v.segm", "v"),
17
+ "j": ("j_call", "j_gene", "j.segm", "j"),
18
+ "locus": ("locus", "chain"),
19
+ "count": ("duplicate_count", "reads", "count"),
20
+ "pair_id": ("cell_id", "clone_id", "complex.id"),
21
+ }
22
+
23
+
24
+ def _resolve(df: pl.DataFrame) -> dict[str, str]:
25
+ lower = {c.lower(): c for c in df.columns}
26
+ out = {}
27
+ for canon, srcs in ALIASES.items():
28
+ for s in srcs:
29
+ if s in lower:
30
+ out[canon] = lower[s]
31
+ break
32
+ return out
33
+
34
+
35
+ def normalize_query(df: pl.DataFrame, valid_aa: bool = True) -> pl.DataFrame:
36
+ """Rename to the query schema, derive ``locus`` from the V gene when absent, default
37
+ ``count`` to 1, and (if ``valid_aa``) drop CDR3s containing non-standard amino acids."""
38
+ found = _resolve(df)
39
+ if "cdr3" not in found:
40
+ raise ValueError(f"no CDR3 column found; have {df.columns}")
41
+ df = df.rename({src: canon for canon, src in found.items()})
42
+ df = df.with_columns(pl.col("cdr3").str.strip_chars().str.to_uppercase())
43
+
44
+ for col in ("v", "j"):
45
+ if col not in df.columns:
46
+ df = df.with_columns(pl.lit(None, dtype=pl.Utf8).alias(col))
47
+ if "locus" not in df.columns:
48
+ df = df.with_columns(pl.col("v").str.slice(0, 3).alias("locus"))
49
+ if "count" not in df.columns:
50
+ df = df.with_columns(pl.lit(1, dtype=pl.Int64).alias("count"))
51
+ else:
52
+ df = df.with_columns(pl.col("count").cast(pl.Int64, strict=False).fill_null(1))
53
+ if "pair_id" not in df.columns:
54
+ df = df.with_columns(pl.lit(None, dtype=pl.Utf8).alias("pair_id"))
55
+ else:
56
+ df = df.with_columns(pl.col("pair_id").cast(pl.Utf8))
57
+
58
+ df = df.filter(pl.col("cdr3").is_not_null() & (pl.col("cdr3").str.len_chars() > 0))
59
+ if valid_aa:
60
+ df = df.filter(pl.col("cdr3").str.contains(r"^[ACDEFGHIKLMNPQRSTVWY]+$"))
61
+ return df.select("cdr3", "v", "j", "locus", "count", "pair_id")
@@ -0,0 +1,7 @@
1
+ """Matching engine, scope parsing, scoring, CIGAR."""
2
+ from .engine import VdjdbIndex
3
+ from .scope import parse_scope, search_params
4
+ from .scoring import load_vdjam
5
+ from . import cigar
6
+
7
+ __all__ = ["VdjdbIndex", "parse_scope", "search_params", "load_vdjam", "cigar"]
@@ -0,0 +1,35 @@
1
+ """Convert seqtree alignment ops into CIGAR + a human-readable match line.
2
+
3
+ ``seqtree.Alignment.ops`` is one char per alignment column: ``M`` match, ``S`` substitution,
4
+ ``I`` insertion (in query), ``D`` deletion (from query). We emit an extended CIGAR using ``=``
5
+ for matches and ``X`` for substitutions (``I``/``D`` unchanged), and a BLAST-style midline.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ _CIGAR = {"M": "=", "S": "X", "I": "I", "D": "D"}
10
+
11
+
12
+ def to_cigar(ops: str) -> str:
13
+ """Run-length-encode ops into extended CIGAR, e.g. ``"5=1X3="``."""
14
+ if not ops:
15
+ return ""
16
+ out, run, prev = [], 0, ops[0]
17
+ for c in ops:
18
+ if c == prev:
19
+ run += 1
20
+ else:
21
+ out.append(f"{run}{_CIGAR[prev]}")
22
+ run, prev = 1, c
23
+ out.append(f"{run}{_CIGAR[prev]}")
24
+ return "".join(out)
25
+
26
+
27
+ def match_line(ops: str) -> str:
28
+ """BLAST-style midline: ``|`` under a match, space under a substitution/gap."""
29
+ return "".join("|" if c == "M" else " " for c in ops)
30
+
31
+
32
+ def counts(ops: str) -> dict[str, int]:
33
+ """Per-op counts: matches, subs, ins, dels."""
34
+ return {"matches": ops.count("M"), "subs": ops.count("S"),
35
+ "ins": ops.count("I"), "dels": ops.count("D")}