breakpoint / features /build_feature_matrix.py
eliasteikari's picture
Add AMRFinderPlus mapping and prediction features
19fdb7b
Raw
History Blame Contribute Delete
6.33 kB
#!/usr/bin/env python3
"""build_feature_matrix.py — AMRFinderPlus v4 TSVs -> binary feature matrix.
Per CONTRACT.md (features/feature_matrix.csv): first column ``genome_id``,
remaining columns binary (0/1) presence of AMRFinderPlus "Element symbol"
entries, with the three evidence tiers as separate column families:
- ``<symbol>`` full/called hit (EXACT / ALLELE / BLAST methods)
- ``POINT:<symbol>`` curated point mutation (Subtype POINT / POINT_DISRUPT)
- ``DEG:<symbol>`` degraded evidence (PARTIAL / PARTIAL_CONTIG_END /
INTERNAL_STOP / HMM methods)
All scopes/types are kept (the batch runs ``amrfinder -O Escherichia --plus``,
so plus-scope STRESS/VIRULENCE elements are present); per-type counts are
recorded in metadata.json so downstream can filter if desired.
Method suffixes (X/P/N search-type) are stripped before tiering, matching
features/map_evidence.py:method_base.
Usage:
python features/build_feature_matrix.py \
--file-list /tmp/amrfinder_snapshot.txt \
--out features/feature_matrix.csv --metadata features/metadata.json
Without --file-list, every *.tsv in --tsv-dir is used (glob order).
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
import pandas as pd
POINT_SUBTYPES = {"POINT", "POINT_DISRUPT"}
DEGRADED_METHODS = {"PARTIAL", "PARTIAL_CONTIG_END", "INTERNAL_STOP", "HMM"}
# Batch provenance (docker ps, 2026-07-19): staphb/ncbi-amrfinderplus image,
# command `amrfinder -n <genome>.fna -O Escherichia --plus --threads 2`.
TOOL_PROVENANCE = {
"tool": "NCBI AMRFinderPlus",
"tool_version": "4.2.7",
"database_version": "2026-03-24.1",
"docker_image": "staphb/ncbi-amrfinderplus:4.2.7-2026-03-24.1",
"organism_option": "Escherichia",
"plus_scope": True,
"command_template": "amrfinder -n {genome}.fna -O Escherichia --plus -o {genome}.tsv",
}
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 (
{"EXACT", "ALLELE", "BLAST", "POINT"} | DEGRADED_METHODS
):
return m[:-1]
return m
def feature_name(symbol: str, subtype: str, method: str) -> str:
"""Column name for one hit: tier prefix + Element symbol."""
symbol = symbol.strip()
if subtype.strip().upper() in POINT_SUBTYPES or method_base(method) == "POINT":
return f"POINT:{symbol}"
if method_base(method) in DEGRADED_METHODS:
return f"DEG:{symbol}"
return symbol
def parse_tsv(path: Path, counts: Counter) -> set[str]:
"""One TSV -> set of feature names. Updates running ``counts``."""
feats: set[str] = set()
with open(path, newline="") as fh:
reader = csv.DictReader(fh, delimiter="\t")
missing = {"Element symbol", "Subtype", "Method", "Type"} - set(
reader.fieldnames or []
)
if missing:
raise ValueError(f"{path}: not an AMRFinderPlus v4 TSV — missing {sorted(missing)}")
for row in reader:
symbol = (row.get("Element symbol") or "").strip()
if not symbol:
continue
f = feature_name(symbol, row.get("Subtype", ""), row.get("Method", ""))
feats.add(f)
counts[f"type:{(row.get('Type') or '').strip()}"] += 1
counts[f"subtype:{(row.get('Subtype') or '').strip()}"] += 1
counts[f"method:{method_base(row.get('Method', ''))}"] += 1
return feats
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--tsv-dir", default="features/amrfinder",
help="dir of AMRFinderPlus TSVs (default: features/amrfinder)")
ap.add_argument("--file-list", default=None,
help="text file with one TSV path per line (snapshot); "
"default: all *.tsv in --tsv-dir")
ap.add_argument("--out", default="features/feature_matrix.csv")
ap.add_argument("--metadata", default="features/metadata.json")
args = ap.parse_args(argv)
if args.file_list:
paths = [Path(line.strip()) for line in
Path(args.file_list).read_text().splitlines() if line.strip()]
else:
paths = sorted(Path(args.tsv_dir).glob("*.tsv"))
if not paths:
print("error: no TSVs found", file=sys.stderr)
return 2
counts: Counter = Counter()
rows: dict[str, set[str]] = {}
skipped: list[str] = []
for p in paths:
gid = p.stem
try:
rows[gid] = parse_tsv(p, counts)
except (OSError, ValueError) as e:
print(f"warning: skipping {p}: {e}", file=sys.stderr)
skipped.append(str(p))
all_feats = sorted({f for feats in rows.values() for f in feats})
mat = pd.DataFrame(
[[1 if f in rows[g] else 0 for f in all_feats] for g in sorted(rows)],
index=pd.Index(sorted(rows), name="genome_id"),
columns=all_feats,
dtype="uint8",
)
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
mat.to_csv(out)
n_point = sum(1 for f in all_feats if f.startswith("POINT:"))
n_deg = sum(1 for f in all_feats if f.startswith("DEG:"))
metadata = {
**TOOL_PROVENANCE,
"built_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"snapshot_file_list": args.file_list,
"n_genomes": int(len(mat)),
"n_features": int(len(all_feats)),
"n_plain_features": len(all_feats) - n_point - n_deg,
"n_point_features": n_point,
"n_degraded_features": n_deg,
"n_genomes_zero_hits": int((mat.sum(axis=1) == 0).sum()),
"skipped_files": skipped,
"hit_counts": {k: v for k, v in sorted(counts.items())},
}
meta_path = Path(args.metadata)
meta_path.parent.mkdir(parents=True, exist_ok=True)
meta_path.write_text(json.dumps(metadata, indent=1) + "\n")
print(f"wrote {out}: {mat.shape[0]} genomes x {mat.shape[1]} features "
f"({n_point} POINT, {n_deg} DEG); metadata -> {meta_path}")
return 0
if __name__ == "__main__":
sys.exit(main())