breakpoint / features /map_evidence.py
eliasteikari's picture
Add AMRFinderPlus mapping and prediction features
19fdb7b
Raw
History Blame Contribute Delete
16 kB
#!/usr/bin/env python3
"""map_evidence.py — map AMRFinderPlus hits to per-drug spectrum-confirmed resistance evidence.
Reads an AMRFinderPlus v4 TSV (headers: Element symbol, Element name, Scope,
Type, Subtype, Class, Subclass, Method, ...) and a curated mapping table
(drug_class_map.yaml, same directory by default), and prints the hits that
count as evidence category (i) "spectrum-confirmed known resistance" for the
requested drug, each annotated with:
- the mapping rule that fired and its confidence (confirmed | review)
- evidence tier: full_gene (EXACT/ALLELE/BLAST method), degraded
(PARTIAL/PARTIAL_CONTIG_END/INTERNAL_STOP/HMM), point (curated mutation)
Usage:
python map_evidence.py AMRFINDER.tsv --drug ciprofloxacin
python map_evidence.py AMRFINDER.tsv --drug sxt --confirmed-only --format json
python map_evidence.py --selftest # no inputs needed
Exit codes: 0 ok (also when zero hits), 2 usage/data error, 1 selftest failure.
Stdlib + PyYAML only.
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
from pathlib import Path
import yaml
DEFAULT_MAP = Path(__file__).resolve().parent / "drug_class_map.yaml"
POINT_SUBTYPES = {"POINT", "POINT_DISRUPT"}
FULL_GENE_METHODS = {"EXACT", "ALLELE", "BLAST"}
DEGRADED_METHODS = {"PARTIAL", "PARTIAL_CONTIG_END", "INTERNAL_STOP", "HMM"}
def norm_drug_name(name: str, drugs: dict) -> str:
"""Resolve a drug name/alias to the canonical key in the YAML."""
key = name.strip().lower()
if key in drugs:
return key
for canon, spec in drugs.items():
if key in [a.lower() for a in spec.get("aliases", [])]:
return canon
raise KeyError(name)
def method_base(method: str) -> str:
"""Strip the X/P/N search-type suffix (EXACTX->EXACT, POINTN->POINT)."""
m = method.strip().upper()
if m.endswith(("X", "P", "N")) and m[:-1] in (
FULL_GENE_METHODS | DEGRADED_METHODS | {"POINT"}
):
return m[:-1]
return m
def tier_of(hit: dict) -> str:
subtype = hit.get("Subtype", "").strip().upper()
method = hit.get("Method", "").strip()
if subtype in POINT_SUBTYPES or method_base(method) == "POINT":
return "point"
base = method_base(method)
if base in FULL_GENE_METHODS:
return "full_gene"
if base in DEGRADED_METHODS:
return "degraded"
return "unknown"
class CompiledRule:
def __init__(self, rule: dict):
self.name = rule["name"]
self.confidence = rule.get("confidence", "review")
self.component = rule.get("component")
self.note = rule.get("note", "")
m = rule.get("match", {})
self.subtype = m.get("subtype") # None | "AMR" | "POINT"
self.symbol_re = re.compile(m["symbol_regex"]) if "symbol_regex" in m else None
self.class_re = re.compile(m["class_regex"]) if "class_regex" in m else None
self.subclass_re = (
re.compile(m["subclass_regex"]) if "subclass_regex" in m else None
)
def matches(self, hit: dict) -> bool:
subtype = hit.get("Subtype", "").strip().upper()
if self.subtype == "AMR" and subtype != "AMR":
return False
if self.subtype == "POINT" and subtype not in POINT_SUBTYPES:
return False
if self.symbol_re and not self.symbol_re.search(hit.get("Element symbol", "")):
return False
if self.class_re and not self.class_re.search(hit.get("Class", "")):
return False
if self.subclass_re and not self.subclass_re.search(hit.get("Subclass", "")):
return False
return True
def load_map(path: Path) -> dict:
with open(path) as fh:
return yaml.safe_load(fh)
def map_hits(hits: list[dict], drug_spec: dict) -> list[dict]:
"""Return hits matching any rule of drug_spec, annotated."""
rules = [CompiledRule(r) for r in drug_spec.get("rules", [])]
excl = drug_spec.get("exclude_symbol_regex")
excl_re = re.compile(excl) if excl else None
out = []
for hit in hits:
symbol = hit.get("Element symbol", "")
if excl_re and excl_re.search(symbol):
continue
for rule in rules:
if rule.matches(hit):
row = {
"element_symbol": symbol,
"element_name": hit.get("Element name", ""),
"rule": rule.name,
"confidence": rule.confidence,
"tier": tier_of(hit),
"method": hit.get("Method", ""),
"subtype": hit.get("Subtype", ""),
"class": hit.get("Class", ""),
"subclass": hit.get("Subclass", ""),
"component": rule.component or "",
}
out.append(row)
break # first matching rule wins; rules are ORed
return out
def read_amrfinder_tsv(path: str) -> list[dict]:
with open(path, newline="") as fh:
reader = csv.DictReader(fh, delimiter="\t")
missing = {"Element symbol", "Subtype", "Class", "Subclass", "Method"} - set(
reader.fieldnames or []
)
if missing:
raise ValueError(
f"{path}: not an AMRFinderPlus v4 TSV — missing headers: {sorted(missing)}"
)
return list(reader)
# --------------------------------------------------------------------- selftest
def _hit(symbol, name, subtype, cls, subcls, method):
return {
"Element symbol": symbol,
"Element name": name,
"Subtype": subtype,
"Class": cls,
"Subclass": subcls,
"Method": method,
}
SELFTEST_CASES = [
# (hit, drug, expect_match, expect_confidence, expect_tier)
# -- required case 1: QRDR points -> ciprofloxacin
(_hit("gyrA_S83L", "quinolone resistant GyrA", "POINT", "QUINOLONE", "QUINOLONE", "POINTX"),
"ciprofloxacin", True, "confirmed", "point"),
(_hit("parC_E84V", "quinolone resistant ParC", "POINT", "QUINOLONE", "QUINOLONE", "POINTX"),
"ciprofloxacin", True, "confirmed", "point"),
(_hit("gyrA_D87N", "quinolone resistant GyrA", "POINT", "QUINOLONE", "QUINOLONE", "POINTX"),
"ciprofloxacin", True, "confirmed", "point"),
(_hit("parE_I529L", "quinolone resistant ParE", "POINT", "QUINOLONE", "QUINOLONE", "POINTX"),
"ciprofloxacin", True, "confirmed", "point"),
# -- required case 2: narrow-spectrum blaTEM must NOT map to cefotaxime
(_hit("blaTEM-1", "broad-spectrum class A beta-lactamase TEM-1", "AMR", "BETA-LACTAM", "BETA-LACTAM", "ALLELEX"),
"cefotaxime", False, None, None),
(_hit("blaTEM-1", "broad-spectrum class A beta-lactamase TEM-1", "AMR", "BETA-LACTAM", "BETA-LACTAM", "ALLELEX"),
"ampicillin", True, "confirmed", "full_gene"),
# ESBL TEM allele DOES map (allele-awareness sanity)
(_hit("blaTEM-68", "extended-spectrum class A beta-lactamase TEM-68", "AMR", "BETA-LACTAM", "CEPHALOSPORIN", "ALLELEX"),
"cefotaxime", True, "confirmed", "full_gene"),
# -- CTX-M: 3GC yes, carbapenem no
(_hit("blaCTX-M-15", "extended-spectrum class A beta-lactamase CTX-M-15", "AMR", "BETA-LACTAM", "CEPHALOSPORIN", "ALLELEX"),
"cefotaxime", True, "confirmed", "full_gene"),
(_hit("blaCTX-M-15", "extended-spectrum class A beta-lactamase CTX-M-15", "AMR", "BETA-LACTAM", "CEPHALOSPORIN", "ALLELEX"),
"ceftazidime", True, "confirmed", "full_gene"),
(_hit("blaCTX-M-15", "extended-spectrum class A beta-lactamase CTX-M-15", "AMR", "BETA-LACTAM", "CEPHALOSPORIN", "ALLELEX"),
"meropenem", False, None, None),
# partial CTX-M still maps but as degraded evidence
(_hit("blaCTX-M-15", "extended-spectrum class A beta-lactamase CTX-M-15", "AMR", "BETA-LACTAM", "CEPHALOSPORIN", "PARTIALX"),
"cefotaxime", True, "confirmed", "degraded"),
# -- carbapenemases
(_hit("blaKPC-3", "carbapenem-hydrolyzing class A beta-lactamase KPC-3", "AMR", "BETA-LACTAM", "CARBAPENEM", "ALLELEX"),
"meropenem", True, "confirmed", "full_gene"),
(_hit("blaKPC-3", "carbapenem-hydrolyzing class A beta-lactamase KPC-3", "AMR", "BETA-LACTAM", "CARBAPENEM", "ALLELEX"),
"cefotaxime", True, "confirmed", "full_gene"),
(_hit("blaOXA-48", "carbapenem-hydrolyzing class D beta-lactamase OXA-48", "AMR", "BETA-LACTAM", "CARBAPENEM", "ALLELEX"),
"meropenem", True, "confirmed", "full_gene"),
(_hit("blaOXA-48", "carbapenem-hydrolyzing class D beta-lactamase OXA-48", "AMR", "BETA-LACTAM", "CARBAPENEM", "ALLELEX"),
"cefotaxime", False, None, None), # OXA-48: carbapenems only, NOT 3GC
# -- intrinsic blaEC must map to nothing (every E. coli carries it)
(_hit("blaEC", "BlaEC family class C beta-lactamase", "AMR", "BETA-LACTAM", "BETA-LACTAM", "BLASTX"),
"cefotaxime", False, None, None),
(_hit("blaEC-5", "cephalosporin-hydrolyzing class C beta-lactamase EC-5", "AMR", "BETA-LACTAM", "CEPHALOSPORIN", "ALLELEX"),
"cefotaxime", False, None, None),
(_hit("blaEC", "BlaEC family class C beta-lactamase", "AMR", "BETA-LACTAM", "BETA-LACTAM", "BLASTX"),
"ampicillin", False, None, None),
# -- aminoglycosides: substrate awareness
(_hit("ant(2'')-Ia", "aminoglycoside nucleotidyltransferase ANT(2'')-Ia", "AMR", "AMINOGLYCOSIDE", "GENTAMICIN/KANAMYCIN/TOBRAMYCIN", "EXACTX"),
"gentamicin", True, "confirmed", "full_gene"),
(_hit("aac(3)-IIa", "aminoglycoside N-acetyltransferase AAC(3)-IIa", "AMR", "AMINOGLYCOSIDE", "GENTAMICIN", "EXACTX"),
"gentamicin", True, "confirmed", "full_gene"),
(_hit("aac(6')-Ib", "aminoglycoside N-acetyltransferase AAC(6')-Ib", "AMR", "AMINOGLYCOSIDE", "AMIKACIN/KANAMYCIN/TOBRAMYCIN", "EXACTX"),
"gentamicin", False, None, None), # canonical aac(6')-Ib: tobra/amik only
(_hit("rmtB", "16S rRNA methyltransferase RmtB", "AMR", "AMINOGLYCOSIDE", "AMINOGLYCOSIDE", "EXACTX"),
"gentamicin", True, "confirmed", "full_gene"), # generic subclass -> symbol rule
(_hit("aph(3')-IIa", "aminoglycoside O-phosphotransferase APH(3')-IIa", "AMR", "AMINOGLYCOSIDE", "KANAMYCIN", "EXACTX"),
"gentamicin", True, "review", "full_gene"), # sources conflict -> review
(_hit("aadA5", "ANT(3'')-Ia family aminoglycoside nucleotidyltransferase AadA5", "AMR", "AMINOGLYCOSIDE", "STREPTOMYCIN", "EXACTX"),
"gentamicin", False, None, None),
# -- qnr / aac(6')-Ib-cr -> ciprofloxacin
(_hit("qnrB19", "quinolone resistance pentapeptide repeat protein QnrB19", "AMR", "QUINOLONE", "QUINOLONE", "EXACTX"),
"ciprofloxacin", True, "confirmed", "full_gene"),
(_hit("aac(6')-Ib-cr", "aminoglycoside N-acetyltransferase AAC(6')-Ib-cr", "AMR", "AMINOGLYCOSIDE/QUINOLONE", "AMIKACIN/KANAMYCIN/QUINOLONE/TOBRAMYCIN", "EXACTX"),
"ciprofloxacin", True, "confirmed", "full_gene"),
# -- SXT components
(_hit("sul1", "sulfonamide-resistant dihydropteroate synthase Sul1", "AMR", "SULFONAMIDE", "SULFONAMIDE", "EXACTX"),
"sxt", True, "confirmed", "full_gene"),
(_hit("dfrA17", "trimethoprim-resistant dihydrofolate reductase DfrA17", "AMR", "TRIMETHOPRIM", "TRIMETHOPRIM", "EXACTX"),
"sxt", True, "confirmed", "full_gene"),
(_hit("folP_F28L", "sulfamethoxazole resistant FolP", "POINT", "SULFONAMIDE", "SULFONAMIDE", "POINTX"),
"trimethoprim-sulfamethoxazole", True, "confirmed", "point"),
# degraded INTERNAL_STOP gene still maps, tier degraded
(_hit("sul2", "sulfonamide-resistant dihydropteroate synthase Sul2", "AMR", "SULFONAMIDE", "SULFONAMIDE", "INTERNAL_STOPX"),
"sxt", True, "confirmed", "degraded"),
]
def run_selftest(map_path: Path) -> int:
spec = load_map(map_path)
drugs = spec["drugs"]
failures = []
for hit, drug, want_match, want_conf, want_tier in SELFTEST_CASES:
canon = norm_drug_name(drug, drugs)
res = map_hits([hit], drugs[canon])
got_match = bool(res)
sym = hit["Element symbol"]
if got_match != want_match:
failures.append(
f"{sym} vs {drug}: match={got_match}, want {want_match}"
)
continue
if got_match and (want_conf or want_tier):
row = res[0]
if want_conf and row["confidence"] != want_conf:
failures.append(
f"{sym} vs {drug}: confidence={row['confidence']}, want {want_conf}"
)
if want_tier and row["tier"] != want_tier:
failures.append(
f"{sym} vs {drug}: tier={row['tier']}, want {want_tier}"
)
# SXT component labels
sxt = drugs[norm_drug_name("sxt", drugs)]
comps = {
r["element_symbol"]: r["component"]
for r in map_hits(
[
_hit("sul1", "", "AMR", "SULFONAMIDE", "SULFONAMIDE", "EXACTX"),
_hit("dfrA17", "", "AMR", "TRIMETHOPRIM", "TRIMETHOPRIM", "EXACTX"),
],
sxt,
)
}
if comps.get("sul1") != "sulfamethoxazole" or comps.get("dfrA17") != "trimethoprim":
failures.append(f"SXT component labels wrong: {comps}")
if failures:
print("SELFTEST FAILED:", file=sys.stderr)
for f in failures:
print(f" - {f}", file=sys.stderr)
return 1
print(f"selftest: {len(SELFTEST_CASES) + 1} checks passed", file=sys.stderr)
return 0
def main(argv=None) -> int:
ap = argparse.ArgumentParser(
description="Map AMRFinderPlus v4 TSV hits to per-drug spectrum-confirmed resistance evidence."
)
ap.add_argument("tsv", nargs="?", help="AMRFinderPlus v4 output TSV")
ap.add_argument("--drug", help="drug name or alias (e.g. ciprofloxacin, sxt, meropenem)")
ap.add_argument("--map", default=str(DEFAULT_MAP), help="path to drug_class_map.yaml")
ap.add_argument("--confirmed-only", action="store_true", help="drop confidence=review hits")
ap.add_argument("--format", choices=["tsv", "json"], default="tsv")
ap.add_argument("--list-drugs", action="store_true", help="print known drugs and exit")
ap.add_argument("--selftest", action="store_true", help="run built-in checks and exit")
args = ap.parse_args(argv)
map_path = Path(args.map)
if args.selftest:
return run_selftest(map_path)
spec = load_map(map_path)
drugs = spec["drugs"]
if args.list_drugs:
for canon, d in drugs.items():
print(f"{canon}\t{','.join(d.get('aliases', []))}")
return 0
if not args.tsv or not args.drug:
ap.error("TSV and --drug are required (or use --selftest / --list-drugs)")
try:
canon = norm_drug_name(args.drug, drugs)
except KeyError:
print(f"error: unknown drug '{args.drug}' (try --list-drugs)", file=sys.stderr)
return 2
try:
hits = read_amrfinder_tsv(args.tsv)
except (OSError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
rows = map_hits(hits, drugs[canon])
if args.confirmed_only:
rows = [r for r in rows if r["confidence"] == "confirmed"]
if args.format == "json":
json.dump({"drug": canon, "n_hits": len(rows), "hits": rows}, sys.stdout, indent=2)
sys.stdout.write("\n")
else:
cols = [
"element_symbol", "rule", "confidence", "tier", "method",
"subtype", "class", "subclass", "component", "element_name",
]
w = csv.DictWriter(sys.stdout, fieldnames=cols, delimiter="\t", extrasaction="ignore")
w.writeheader()
w.writerows(rows)
n_conf = sum(1 for r in rows if r["confidence"] == "confirmed")
n_rev = len(rows) - n_conf
tiers = {}
for r in rows:
tiers[r["tier"]] = tiers.get(r["tier"], 0) + 1
tier_s = ", ".join(f"{k}={v}" for k, v in sorted(tiers.items())) or "none"
print(
f"# {canon}: {len(rows)} evidence hits ({n_conf} confirmed, {n_rev} review); tiers: {tier_s}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())