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 +8 -0
- vdjmatch/aggregate/__init__.py +4 -0
- vdjmatch/aggregate/enrichment.py +40 -0
- vdjmatch/cli/__init__.py +0 -0
- vdjmatch/cli/__main__.py +79 -0
- vdjmatch/cluster/__init__.py +3 -0
- vdjmatch/cluster/overlap.py +36 -0
- vdjmatch/db/__init__.py +5 -0
- vdjmatch/db/cache.py +16 -0
- vdjmatch/db/schema.py +50 -0
- vdjmatch/db/vdjdb.py +108 -0
- vdjmatch/evalue/__init__.py +5 -0
- vdjmatch/evalue/control.py +25 -0
- vdjmatch/evalue/single.py +32 -0
- vdjmatch/io/__init__.py +5 -0
- vdjmatch/io/airr.py +70 -0
- vdjmatch/io/columns.py +61 -0
- vdjmatch/match/__init__.py +7 -0
- vdjmatch/match/cigar.py +35 -0
- vdjmatch/match/engine.py +108 -0
- vdjmatch/match/scope.py +44 -0
- vdjmatch/match/scoring.py +51 -0
- vdjmatch/resources/VERSIONS.md +15 -0
- vdjmatch/resources/score_coef.txt +5 -0
- vdjmatch/resources/segm_score.txt +24065 -0
- vdjmatch/resources/vdjam.txt +401 -0
- vdjmatch/runner/__init__.py +3 -0
- vdjmatch/runner/multisample.py +48 -0
- vdjmatch-0.0.1.dist-info/METADATA +66 -0
- vdjmatch-0.0.1.dist-info/RECORD +33 -0
- vdjmatch-0.0.1.dist-info/WHEEL +4 -0
- vdjmatch-0.0.1.dist-info/entry_points.txt +2 -0
- vdjmatch-0.0.1.dist-info/licenses/LICENSE +674 -0
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,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
|
vdjmatch/cli/__init__.py
ADDED
|
File without changes
|
vdjmatch/cli/__main__.py
ADDED
|
@@ -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,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}
|
vdjmatch/db/__init__.py
ADDED
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,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
|
+
})
|
vdjmatch/io/__init__.py
ADDED
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"]
|
vdjmatch/match/cigar.py
ADDED
|
@@ -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")}
|