Skip to content

Commit 8aeb765

Browse files
Merge pull request #299 from ncsa/compare-vcfs
Compare vcfs
2 parents 72b6c3b + bf5ab16 commit 8aeb765

23 files changed

Lines changed: 3658 additions & 1 deletion

ChangeLog.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,48 @@
1+
# NEAT v4.5.0
2+
3+
New `neat compare-vcfs` subcommand: compares a downstream variant caller's VCF
4+
against the NEAT-simulated truth VCF and attributes each false negative to the
5+
simulator's own configuration (mutation bed, target bed, simulated contigs).
6+
Issue #297.
7+
8+
Variant equivalence (multi-allelic normalization, haplotype-level matching) is
9+
delegated to Illumina's `hap.py`. NEAT adds the false-negative attribution
10+
layer: each FN is tagged with one or more of `outside_simulated_contigs`,
11+
`outside_mutation_bed`, `outside_target_bed`, or `unknown`.
12+
13+
**Outputs (in `--output-dir`):**
14+
15+
- `comparison_summary.json` — counts, precision/recall/F1, per-reason FN totals
16+
- `comparison_summary.txt` — human-readable rollup
17+
- `FN_with_reasons.vcf` — hap.py's FN records with an added `NEAT_REASON` INFO tag
18+
- `happy.vcf.gz` and siblings — preserved hap.py output
19+
- `fn_attribution.png` — optional bar chart, written when `--plot` is set
20+
21+
**Prerequisite artifact:** Every `neat read-simulator` run now writes a small
22+
`simulation_summary.json` alongside its other outputs, capturing the run's
23+
config echo (coverage, read length, paired-ended, BED paths, contigs simulated)
24+
and delivered counts (total reads, total variants, per-contig variants). The
25+
`compare-vcfs` wrapper reads this file to drive attribution.
26+
27+
**External dependency:** `hap.py` is required at runtime. NEAT does not bundle
28+
it; install via `conda create -n hap_py_env -c bioconda -c conda-forge hap.py`
29+
and pass the absolute path via `--happy-bin`, or put it on `$PATH`. Without
30+
hap.py, the command exits with an install hint.
31+
32+
**Chromosome-name handling:** `compare-vcfs` detects when a BED's chrom names
33+
don't overlap the reference's (e.g., BED uses `1`/`MT` while reference uses
34+
`chr1`/`chrM`) and writes a warning into `comparison_summary.json` suggesting
35+
an alias mapping. Users can apply the mapping via a new `--chrom-aliases TSV`
36+
flag. NEAT does not auto-normalize — silent renaming would mask real bugs.
37+
`simulation_summary.json` now also records `delivered.reference_contigs` (the
38+
full FASTA contig set) alongside `contigs_simulated`.
39+
40+
**Not in this release** (deferred to follow-up issues): full per-region
41+
simulation telemetry (per-chunk coverage, GC-bias map, error rates by position)
42+
for richer FN attribution, and SV-comparison support. The simulator's silent
43+
"skip BED chroms not in reference" warning will be promoted to a fatal error
44+
in a future release with an opt-in `chrom_aliases` config key.
45+
146
# NEAT v4.4.4
247
Follow-up release on top of v4.4.3 bundling three lines of work: another perf
348
pass over the remaining single-thread hot paths (variant overlap checks,

README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ To cite this work, please use both of the following:
4747
* [`neat model-seq-err`](#neat-model-seq-err)
4848
* [`neat model-qual-score`](#neat-model-qual-score)
4949
* [`neat model-gc-bias`](#neat-model-gc-bias)
50+
* [`neat compare-vcfs`](#neat-compare-vcfs)
5051
* [Tests](#tests)
5152
* [Guide to run locally](#guide-to-run-locally)
5253
* [Note on Sensitive Patient Data](#note-on-sensitive-patient-data)
@@ -598,6 +599,75 @@ neat model-gc-bias \
598599

599600
and creates `gc_model.pickle.gz` model in the working directory.
600601

602+
### `neat compare-vcfs`
603+
604+
Compares a downstream variant caller's VCF against a NEAT-simulated truth VCF,
605+
attributing each false negative to the simulator's own configuration. The
606+
intended workflow is: run NEAT to produce a synthetic FASTQ and golden VCF →
607+
run your aligner + caller on the FASTQ → compare the caller's VCF against the
608+
golden with `neat compare-vcfs`.
609+
610+
Variant equivalence (multi-allelic normalization, haplotype-level matching) is
611+
delegated to `hap.py`. The NEAT-specific value-add is the false-negative
612+
attribution: each FN is tagged with one or more reasons drawn from the run
613+
config recorded in `simulation_summary.json`.
614+
615+
```bash
616+
neat compare-vcfs golden.vcf called.vcf \
617+
--neat-run-dir /path/to/neat/output/dir \
618+
--output-dir /path/to/comparison/output \
619+
--reference reference.fa \
620+
[--target-bed target.bed] \
621+
[--happy-bin /abs/path/to/hap.py] \
622+
[--plot] \
623+
[--chrom-aliases aliases.tsv]
624+
```
625+
626+
Outputs (in `--output-dir`):
627+
628+
| File | Purpose |
629+
|------|---------|
630+
| `comparison_summary.json` | Machine-readable: counts (TP/FN/FP), precision/recall/F1, per-reason FN counts |
631+
| `comparison_summary.txt` | Human-readable rollup of the same |
632+
| `FN_with_reasons.vcf` | hap.py's false-negative records, each annotated with a `NEAT_REASON` INFO tag |
633+
| `happy.vcf.gz` (+ siblings) | Raw hap.py output preserved for inspection |
634+
| `fn_attribution.png` | Optional — only written when `--plot` is set |
635+
636+
**Chromosome-name conventions:** NEAT does NOT silently normalize chrom names
637+
across the reference, golden VCF, called VCF, and BED files. If your
638+
`mutation_bed` uses `1`, `2`, … but the reference uses `chr1`, `chr2`, …,
639+
`compare-vcfs` detects the mismatch up front and writes a warning into
640+
`comparison_summary.json` suggesting the rename. Pass `--chrom-aliases
641+
aliases.tsv` (two-column TSV: `bed_name<TAB>canonical_name`) to apply the
642+
rename at load time. This also handles common mitochondrial variants
643+
(`M`/`MT`/`chrM`/`chrMT`).
644+
645+
**False-negative reason categories:**
646+
647+
| Tag | Meaning |
648+
|-----|---------|
649+
| `outside_simulated_contigs` | FN's chromosome wasn't part of the NEAT run at all |
650+
| `outside_mutation_bed` | `mutation_bed` was set in the config and the FN falls outside its regions |
651+
| `outside_target_bed` | `target_bed` was set and the FN falls outside its regions |
652+
| `unknown` | None of the above — NEAT has no specific explanation |
653+
654+
**`simulation_summary.json` prerequisite:** Every `neat read-simulator` run now
655+
emits `simulation_summary.json` into its output dir as part of the standard
656+
artifacts; `compare-vcfs` reads this file from `--neat-run-dir` to drive
657+
attribution. No extra step required when running NEAT yourself; if you're
658+
working with pre-NEAT-4.5 output, re-run the simulator to produce one.
659+
660+
**`hap.py` install:** `hap.py` is an external dependency. The cleanest install
661+
path is a dedicated conda env:
662+
663+
```bash
664+
conda create -n hap_py_env -c bioconda -c conda-forge hap.py -y
665+
```
666+
667+
then point `--happy-bin` at `/path/to/hap_py_env/bin/hap.py` (or put that
668+
directory on `$PATH`). Without `hap.py` available, `neat compare-vcfs` exits
669+
with a clear install hint.
670+
601671
## Tests
602672

603673
We provide unit tests (e.g., mutation and sequencing error models) and basic integration tests for the CLI.

neat/cli/commands/compare_vcfs.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
Command line interface for NEAT's compare-vcfs subcommand (issue #297).
3+
"""
4+
import argparse
5+
6+
from ...compare_vcfs import compare_vcfs_runner
7+
from .base import BaseCommand
8+
9+
10+
class Command(BaseCommand):
11+
"""
12+
Compare a downstream variant-caller VCF against a NEAT-simulated truth VCF
13+
and attribute false negatives to the simulator's configuration.
14+
"""
15+
name = "compare-vcfs"
16+
description = (
17+
"Compare a NEAT-simulated truth VCF (golden) against a downstream variant "
18+
"caller VCF (called), with NEAT-aware false-negative attribution."
19+
)
20+
21+
def add_arguments(self, parser: argparse.ArgumentParser):
22+
parser.add_argument(
23+
"golden_vcf",
24+
type=str, metavar="golden.vcf",
25+
help="NEAT-simulated truth VCF (typically <prefix>_golden.vcf.gz)."
26+
)
27+
parser.add_argument(
28+
"called_vcf",
29+
type=str, metavar="called.vcf",
30+
help="Downstream variant caller's VCF produced from the simulated reads."
31+
)
32+
parser.add_argument(
33+
"--neat-run-dir",
34+
dest="neat_run_dir",
35+
type=str, required=True, metavar="DIR",
36+
help="Directory containing the NEAT simulator output, including simulation_summary.json."
37+
)
38+
parser.add_argument(
39+
"--output-dir",
40+
dest="output_dir",
41+
type=str, required=True, metavar="DIR",
42+
help="Where to write comparison_summary.{json,txt} and FN_with_reasons.vcf. Created if absent."
43+
)
44+
parser.add_argument(
45+
"--reference",
46+
type=str, default=None, metavar="ref.fa",
47+
help="Optional reference FASTA forwarded to hap.py."
48+
)
49+
parser.add_argument(
50+
"--target-bed",
51+
dest="target_bed",
52+
type=str, default=None, metavar="target.bed",
53+
help="Optional BED restricting comparison to these regions."
54+
)
55+
parser.add_argument(
56+
"--happy-bin",
57+
dest="happy_bin",
58+
type=str, default=None, metavar="PATH",
59+
help="Explicit path to the hap.py binary. Defaults to looking on $PATH."
60+
)
61+
parser.add_argument(
62+
"--plot",
63+
dest="plot",
64+
action="store_true",
65+
help="Also write fn_attribution.png — a bar chart of FN reason counts."
66+
)
67+
parser.add_argument(
68+
"--chrom-aliases",
69+
dest="chrom_aliases",
70+
type=str, default=None, metavar="TSV",
71+
help="Two-column TSV mapping BED chrom names to reference-canonical names, "
72+
"applied to mutation_bed and target_bed at load time. Used when the BED "
73+
"uses '1' but the reference uses 'chr1', or similar prefix/mt mismatches."
74+
)
75+
76+
def execute(self, arguments: argparse.Namespace):
77+
compare_vcfs_runner(
78+
golden_vcf=arguments.golden_vcf,
79+
called_vcf=arguments.called_vcf,
80+
neat_run_dir=arguments.neat_run_dir,
81+
output_dir=arguments.output_dir,
82+
reference=arguments.reference,
83+
target_bed=arguments.target_bed,
84+
happy_bin=arguments.happy_bin,
85+
plot=arguments.plot,
86+
chrom_aliases=arguments.chrom_aliases,
87+
)

neat/common/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
from .io import *
44
from .constants_and_defaults import *
55
from .ploid_functions import *
6+
from .chrom_names import *

neat/common/chrom_names.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""
2+
Chromosome-name normalization utilities for cross-checking reference/VCF/BED
3+
conventions.
4+
5+
Genomics tooling ships with multiple incompatible naming conventions:
6+
- UCSC: `chr1`, `chr2`, ..., `chrX`, `chrM`
7+
- Ensembl: `1`, `2`, ..., `X`, `MT`
8+
- NCBI accession: `NC_000001.11`, ...
9+
- Custom: whatever the lab named the scaffolds
10+
11+
A user can run NEAT with a reference using one convention but supply a
12+
`mutation_bed` / `target_bed` using another. NEAT does not silently normalize;
13+
instead, callers detect a mismatch up front and either warn (see
14+
`compare_vcfs`) or apply an explicit user-supplied alias map.
15+
16+
This module is the single source of truth for chrom-name heuristics so the
17+
simulator side and the compare-vcfs side stay consistent.
18+
"""
19+
import logging
20+
from pathlib import Path
21+
22+
__all__ = [
23+
"apply_aliases",
24+
"find_aliases",
25+
"load_chrom_aliases",
26+
"prefix_flip_candidates",
27+
]
28+
29+
_LOG = logging.getLogger(__name__)
30+
31+
# Common mitochondrial name variants. A reference using any of these
32+
# represents the same physical chromosome.
33+
_MITO_VARIANTS = frozenset({"M", "MT", "chrM", "chrMT"})
34+
35+
36+
def prefix_flip_candidates(name: str) -> set[str]:
37+
"""
38+
Return the set of alternative names that probably refer to the same
39+
contig as `name`. Excludes `name` itself.
40+
41+
Two heuristics:
42+
1. `chr` prefix add/strip — `chr1` ↔ `1`, `chrX` ↔ `X`.
43+
2. Mitochondrial variants — names in {M, MT, chrM, chrMT} are all
44+
treated as aliases of each other.
45+
46+
No other heuristic — accession-style names and custom scaffold names
47+
require an explicit user-supplied alias map.
48+
"""
49+
candidates: set[str] = set()
50+
if name.startswith("chr"):
51+
candidates.add(name[3:])
52+
else:
53+
candidates.add(f"chr{name}")
54+
if name in _MITO_VARIANTS:
55+
candidates |= _MITO_VARIANTS
56+
candidates.discard(name)
57+
return candidates
58+
59+
60+
def find_aliases(source: set[str] | frozenset[str], target: set[str] | frozenset[str]) -> dict[str, str]:
61+
"""
62+
For each name in `source` that doesn't appear natively in `target`, propose
63+
a canonical form from `target` if one of the heuristics matches. Returns a
64+
`{source_name: target_name}` dict; names with a native match or no inferrable
65+
candidate are omitted.
66+
"""
67+
mapping: dict[str, str] = {}
68+
for name in source:
69+
if name in target:
70+
continue
71+
for candidate in prefix_flip_candidates(name):
72+
if candidate in target:
73+
mapping[name] = candidate
74+
break
75+
return mapping
76+
77+
78+
def load_chrom_aliases(path: Path | str | None) -> dict[str, str]:
79+
"""
80+
Parse a two-column alias TSV: `source_name<TAB>canonical_name`. Lines
81+
starting with `#` and empty lines are skipped. Returns `{}` when `path`
82+
is None.
83+
84+
Whitespace tolerated as a fallback separator for human-typed files.
85+
"""
86+
if path is None:
87+
return {}
88+
aliases: dict[str, str] = {}
89+
with open(path) as fh:
90+
for lineno, raw in enumerate(fh, start=1):
91+
line = raw.strip()
92+
if not line or line.startswith("#"):
93+
continue
94+
parts = line.split("\t")
95+
if len(parts) < 2:
96+
parts = line.split()
97+
if len(parts) < 2:
98+
_LOG.warning(f"{path}:{lineno}: skipping malformed alias line: {raw!r}")
99+
continue
100+
aliases[parts[0]] = parts[1]
101+
return aliases
102+
103+
104+
def apply_aliases(name: str, aliases: dict[str, str]) -> str:
105+
"""Return the canonical form of `name`, or `name` unchanged if no alias maps it."""
106+
return aliases.get(name, name)

neat/compare_vcfs/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""Load the compare-vcfs runner so the CLI can import it from the package root."""
2+
from .runner import *

0 commit comments

Comments
 (0)