variant-calling/variant-normalization/SKILL.md
Left-align and trim indels to parsimonious canonical form, decompose MNPs (atomize), and split multiallelic variants with bcftools norm. Use when comparing variants across callers or cohorts, preparing a VCF for database annotation or ClinVar/dbSNP matching, merging VCFs, reconciling vt-vs-bcftools representation discordance, or resolving the VCF-left-align vs HGVS-3'-rule clash.
npx skillsauth add GPTomics/bioSkills bio-variant-normalizationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Reference examples tested with: bcftools 1.19+, cyvcf2 0.30+
Before using code patterns, verify installed versions match. If versions differ:
pip show <package> then help(module.function) to check signatures<tool> --version then <tool> --help to confirm flagsThe --atomize/--old-rec-tag flags require bcftools 1.12+ (--keep-sum requires 1.11+). Earlier versions require vt decompose_blocksub as an alternative.
If code throws ImportError, AttributeError, or TypeError, introspect the installed package and adapt the example to match the actual API rather than retrying.
Left-align indels, decompose MNPs, and split multiallelic sites using bcftools norm.
"Put my VCF in canonical form before comparing, annotating, or merging" -> Enforce one representation per biological event so tuple-keyed operations recognize the same variant across sources.
bcftools norm -m-any -f ref.fa (split multiallelic, then left-align + parsimony each biallelic)vt normalize + vt decompose; GATK LeftAlignAndTrimVariantsA variant is normalized if and only if it is BOTH (Tan, Abecasis & Kang 2015):
A is written REF=CA ALT=C, not REF=A ALT=.Both are required: parsimony alone leaves an indel positionally ambiguous inside a repeat; left-alignment alone can leave redundant shared bases. Together they define a single canonical (CHROM, POS, REF, ALT) key, and normalization is idempotent -- normalizing an already-normalized VCF changes nothing. That idempotence is what makes the key safe to join, merge, and match on.
The algorithm (as in vt normalize and bcftools norm):
The right-trim-then-left-roll loop is exactly what shifts an indel through a repeat to its leftmost equivalent position.
Not normalizing before certain operations leads to missed matches and false discordance. Normalization is required:
bcftools merge matches on CHROM/POS/REF/ALT; different representations of the same variant produce duplicate entries instead of a single merged record.bcftools isec), complement, and union operations all rely on exact positional matching. Non-normalized variants silently fall through set comparisons.Normalization is generally safe to skip only when a single caller produced all variants and no cross-file comparison or database lookup is needed.
The same variant can be written multiple ways:
chr1 100 ATCG A (right-aligned)
chr1 100 ATC A (left-aligned, parsimonious -- the canonical form)
chr1 101 TCG T (shifted position, different anchor base)
The ambiguity is worst in homopolymers and tandem repeats. In reference ...AAAAAAA..., a single-base A deletion is positionally ambiguous: deleting ANY one of the seven A's yields the identical alternate haplotype, so different callers/aligners emit it at different POS. Left-alignment defines the canonical (leftmost) position, giving the one biological event one representation.
The load-bearing consequence: a one-base-off (non-left-aligned) indel in a homopolymer is a valid VCF line that produces a different (CHROM, POS, REF, ALT) tuple. Annotation and matching keyed on that tuple then silently miss the dbSNP/ClinVar/gnomAD entry that sits at the canonical coordinate -- a clinically actionable variant is reported as absent or novel, with no error thrown. The VCF is structurally valid; the numbers are wrong. bcftools merge of a normalized and an un-normalized cohort likewise emits duplicate rows for the same event, inflating counts and splitting allele frequencies.
Decision: always left-align + parsimony (bcftools norm -f ref.fa) against the EXACT downstream reference before annotation, database matching, set operations, or merging -- never rely on callers to emit canonical form.
The order of operations matters. Performing these steps out of order can produce incorrect results (e.g., left-aligning a multiallelic record may normalize differently than splitting first, then left-aligning each biallelic record independently).
The correct order:
--atomize)-m-)-f reference.fa)Combined as a piped pipeline:
bcftools norm --atomize input.vcf.gz | \
bcftools norm -m- | \
bcftools norm -f reference.fa -Oz -o normalized.vcf.gz
bcftools index normalized.vcf.gz
For VCFs without MNPs (e.g., GATK HaplotypeCaller output, which does not emit MNPs), the atomize step can be skipped:
bcftools norm -m- input.vcf.gz | \
bcftools norm -f reference.fa -Oz -o normalized.vcf.gz
A single-pass bcftools norm -f ref.fa -m-any is acceptable for basic use cases but does not control the decomposition order and skips MNP atomization.
All three implement the same Tan-2015 left-align + parsimony core and agree on simple biallelic indels. They diverge on decomposition:
| Behavior | vt | bcftools norm | GATK LeftAlignAndTrimVariants |
|----------|------|-----------------|--------------------------------|
| Left-align + parsimony (biallelic) | yes (normalize) | yes (-f) | yes |
| Split multiallelic | vt decompose (separate step) | -m-any | --split-multi-allelics |
| Decompose MNP -> SNPs | vt decompose_blocksub splits MNPs by default (vt decompose only splits multiallelics) | only with --atomize (bcftools >= 1.12); NOT by default | does not decompose MNPs into SNPs |
| Block substitution / complex | vt decompose_blocksub | --atomize | limited |
The discordance that burns people: vt decompose_blocksub splits MNPs/block substitutions into SNPs by default, bcftools norm does not (it needs --atomize). Running the same normalization with the two tools yields a different variant count (bcftools keeps an MNP as one record; vt emits separate SNPs). If cohort A is atomized with vt and cohort B is left un-atomized with bcftools, every MNP is a systematic representation mismatch, manufacturing spurious cohort-private "variants" in any cross-cohort comparison.
Decision: standardize on ONE normalization tool + exact flag set across every cohort intended for comparison, and record the command. bcftools norm is the de-facto production standard (htslib-maintained; integrates split, atomize, and left-align in one pass). Note GATK's left-alignment window (--max-leading-bases) means indels inside repeats longer than the window may not fully left-align -- verify the installed default with gatk LeftAlignAndTrimVariants --help before trusting STR-embedded indels.
"Normalize my VCF before comparing callers" -> Left-align indel representations and split multiallelic sites for consistent variant comparison.
bcftools norm -f reference.fa input.vcf.gz -Oz -o normalized.vcf.gz
Left-alignment cannot roll an indel leftward without the reference bases to its left, so -f/--fasta-ref is non-negotiable. Two traps make the reference choice load-bearing:
chr1-vs-1 contig naming, or a masked-vs-unmasked sequence yields wrong left-aligned coordinates. On a REF mismatch bcftools norm errors and exits non-zero by default (-c e) rather than warning -- do not paper over it with -c w; a single off-by-one in a contig shifts every indel.bcftools norm -f reference.fa -c w input.vcf.gz > /dev/null
Check modes (-c):
e - Error and exit on mismatch (default)w - Warn on mismatch and continue (use this to enumerate all mismatches)x - Exclude mismatchess - Set correct REF from referencebcftools norm -m-any input.vcf.gz -Oz -o split.vcf.gz
Before:
chr1 100 . A G,T 30 PASS . GT 1/2
After:
chr1 100 . A G 30 PASS . GT 1/0
chr1 100 . A T 30 PASS . GT 0/1
Splitting creates artificial missing information. A sample with genotype 1/2 (compound heterozygous for two different ALT alleles) becomes 0/1 in both split records. The information that both alleles were present at the same site in the same individual is lost. This has consequences for:
Decision guidance:
| Downstream tool | Splitting required? | Rationale | |----------------|-------------------|-----------| | PLINK, PLINK2 | Yes | PLINK requires biallelic records | | Most GWAS tools | Yes | Expect biallelic sites | | Hail | No | Handles multiallelics natively; splitting loses information | | bcftools csq | No | Supports multiallelic consequence calling | | VEP | Either | Handles both; multiallelic may give richer output | | ClinVar matching | Yes | ClinVar entries are biallelic |
When a downstream tool does not require splitting, prefer keeping multiallelic sites intact to preserve genotype relationships.
| Option | Description |
|--------|-------------|
| -m-any | Split all multiallelic sites |
| -m-snps | Split multiallelic SNPs only |
| -m-indels | Split multiallelic indels only |
| -m-both | Split SNPs and indels separately |
| -m+any | Join biallelic sites into multiallelic |
| -m+snps | Join biallelic SNPs |
| -m+indels | Join biallelic indels |
| -m+both | Join SNPs and indels separately |
bcftools norm -m+any input.vcf.gz -Oz -o merged.vcf.gz
Rejoining after analysis can restore compound heterozygosity context, but only if the split records were not independently filtered (removing one allele of a 1/2 site makes the remaining record misleading).
Splitting a multiallelic is NOT information-lossless. Per-allele fields must be reapportioned according to their header Number code, and bcftools norm -m- uses those codes to subset correctly:
Number=A (one value per ALT, e.g. AF, AC) -> take the k-th element for the k-th ALT.Number=R (one per allele including REF, e.g. AD -> ref depth first) -> off-by-one relative to A.Number=G (one per genotype, e.g. PL, GL) -> subsetting needs the genotype-index formula.Number=. -> variable count; parsers cannot auto-subset, so these fields are silently carried whole onto every split record.The trap: a custom INFO/FORMAT field mis-declared Number=. when it is really A is NOT subset on split, so every biallelic record keeps the full multiallelic vector and downstream tools read the wrong allele's value. Joining back (-m+) cannot always reconstruct the original PLs exactly. Rule: split once, early, and stay biallelic; join only for final delivery if a consumer requires it.
Spanning-deletion * alleles (VCF: "allele missing due to overlapping deletion") are meaningful only relative to the overlapping deletion recorded on another line. Splitting can strand a * allele from the deletion it references; bcftools handles this, but naive third-party splitters corrupt it. Use --keep-sum AD when the summed allele depth must be preserved across split records.
Multi-nucleotide polymorphisms (MNPs) are adjacent substitutions reported as a single record (e.g., ATG->GCA). Not all callers emit MNPs:
| Caller | Emits MNPs? | Notes | |--------|------------|-------| | FreeBayes | Yes | Reports MNPs and complex events natively | | Octopus | Yes | Local haplotype-aware, emits block substitutions | | GATK HaplotypeCaller | No | Decomposes variants during calling; may emit nearby SNPs in the same haplotype block | | DeepVariant | Rarely | Primarily emits SNPs and indels |
Decomposing MNPs is necessary when comparing output from callers that represent them differently. Without atomization, an MNP from FreeBayes will not match the equivalent individual SNPs from GATK.
bcftools norm --atomize input.vcf.gz -Oz -o atomized.vcf.gz
Before:
chr1 100 . ATG GCA 30 PASS
After:
chr1 100 . A G 30 PASS
chr1 101 . T C 30 PASS
chr1 102 . G A 30 PASS
Caveat -- decomposition destroys phase needed for functional annotation. The original MNP record guarantees that its substitutions occur on the SAME haplotype. Atomization discards that guarantee. The concrete failure: two adjacent SNVs falling in one codon, annotated independently after decomposition, can each look synonymous while the true MNV (the haplotype) is missense or nonsense (or the reverse). VEP/SnpEff give the wrong amino-acid consequence on decomposed alleles because they no longer see the codon change.
This is the unresolved decompose-vs-atomic tension: decompose for variant matching (dbSNP/ClinVar/gnomAD lookup, allele-frequency comparison), but compute functional consequence on the haplotype-resolved (undecomposed / phased) representation -- run bcftools csq on the un-atomized VCF, which is codon-aware. Keep the atomized copy for matching and the un-atomized copy for annotation; do not feed atomized alleles to a per-record consequence caller. See variant-calling/variant-annotation.
bcftools norm --atomize --old-rec-tag ORIGINAL input.vcf.gz -Oz -o atomized.vcf.gz
Preserves the original record as an INFO annotation, enabling traceability back to the pre-atomized variant.
VCF normalization and HGVS nomenclature shift indels in opposite directions, so a correctly normalized VCF and a correct HGVS string for the same indel can name different repeat units:
For a plus-strand gene, transcript-3' equals genomic-rightward -- the OPPOSITE end from VCF left-alignment. For a minus-strand gene, transcript-3' points genomic-leftward and can coincide with left-alignment, but only by accident of strand. The result: the VCF POS and the HGVS c. position for one deletion legitimately disagree, and a tool that generates HGVS by naively translating the left-aligned POS without re-shifting 3' on the transcript emits a non-compliant string. This is a leading cause of "two labs reported different c. positions for the same deletion."
Decision: never hand-derive HGVS from POS. Left-align + normalize the VCF for matching/merging (idempotent, reproducible), and rely on the annotation engine's HGVS generator to apply the transcript 3'-shift (VEP does this; verify SnpEff/ANNOVAR per version). When matching a patient variant to a ClinVar or literature HGVS string, normalize BOTH to the same representation first -- two strings that look different can be the same variant. See variant-calling/variant-annotation and variant-calling/clinical-interpretation.
Goal: Correct or remove variants whose REF allele does not match the reference genome.
Approach: Use bcftools norm -c with mode s (set correct REF) or x (exclude mismatches).
bcftools norm -f reference.fa -c s input.vcf.gz -Oz -o fixed.vcf.gz
This sets REF alleles to match the reference genome. Use with caution: REF mismatches often indicate a genome build mismatch, and silently "fixing" REF may mask a liftover error rather than correcting a trivial typo.
bcftools norm -f reference.fa -c x input.vcf.gz -Oz -o clean.vcf.gz
Removes variants where REF does not match the reference. Safer than -c s when the cause of mismatch is unknown.
bcftools norm -d exact input.vcf.gz -Oz -o deduped.vcf.gz
Duplicate removal options (-d):
exact - Remove exact duplicates (same CHROM, POS, REF, ALT)snps - Remove duplicate SNPs onlyindels - Remove duplicate indels onlyboth - Remove duplicate SNPs and indelsall - Remove all duplicates at the same positionnone - Keep duplicates (default)Goal: Make VCFs from different callers directly comparable.
Approach: Apply the same three-step normalization pipeline to each VCF, then use set operations.
for vcf in gatk.vcf.gz freebayes.vcf.gz; do
base=$(basename "$vcf" .vcf.gz)
bcftools norm --atomize "$vcf" | \
bcftools norm -m- | \
bcftools norm -f reference.fa -Oz -o "${base}.norm.vcf.gz"
bcftools index "${base}.norm.vcf.gz"
done
bcftools isec -p comparison gatk.norm.vcf.gz freebayes.norm.vcf.gz
The isec output directories: 0000.vcf = private to first file, 0001.vcf = private to second, 0002.vcf/0003.vcf = shared variants from each file.
bcftools norm --atomize variants.vcf.gz | \
bcftools norm -m- | \
bcftools norm -f reference.fa -Oz -o for_annotation.vcf.gz
bcftools index for_annotation.vcf.gz
Goal: Produce a biallelic, SNP-only, deduplicated VCF suitable for PLINK import.
Approach: Normalize, split, restrict to SNPs, and remove duplicates.
bcftools norm -f reference.fa -m- input.vcf.gz | \
bcftools view -v snps | \
bcftools norm -d exact -Oz -o gwas_ready.vcf.gz
bcftools index gwas_ready.vcf.gz
Goal: Assess how many variants require normalization before running bcftools norm.
Approach: Iterate with cyvcf2 and count multiallelic sites and complex (MNP) variants.
from cyvcf2 import VCF
def needs_normalization(variant):
if len(variant.ALT) > 1:
return True
ref, alt = variant.REF, variant.ALT[0]
if len(ref) > 1 and len(alt) > 1 and len(ref) == len(alt):
return True
return False
total, needs_norm, multiallelic, mnps = 0, 0, 0, 0
for variant in VCF('input.vcf.gz'):
total += 1
if len(variant.ALT) > 1:
multiallelic += 1
ref, alt = variant.REF, variant.ALT[0]
if len(ref) > 1 and len(alt) > 1 and len(ref) == len(alt):
mnps += 1
if needs_normalization(variant):
needs_norm += 1
print(f'Total variants: {total}')
print(f'Needing normalization: {needs_norm} ({needs_norm/total*100:.1f}%)')
print(f' Multiallelic sites: {multiallelic}')
print(f' MNPs: {mnps}')
Note: this check does not detect indels requiring left-alignment, since that requires reference context. The count is a lower bound.
| Task | Command |
|------|---------|
| Left-align indels | bcftools norm -f ref.fa in.vcf.gz |
| Split multiallelic | bcftools norm -m-any in.vcf.gz |
| Join to multiallelic | bcftools norm -m+any in.vcf.gz |
| Atomize MNPs | bcftools norm --atomize in.vcf.gz |
| Fix REF alleles | bcftools norm -f ref.fa -c s in.vcf.gz |
| Remove duplicates | bcftools norm -d exact in.vcf.gz |
| Full pipeline | bcftools norm --atomize \| bcftools norm -m- \| bcftools norm -f ref.fa |
| Error | Cause | Solution |
|-------|-------|----------|
| REF does not match | Wrong reference or genome build mismatch | Verify the reference FASTA matches the build used during calling |
| not sorted | Unsorted input | Run bcftools sort first |
| duplicate records | Same position twice after splitting | Use -d exact to remove |
| --atomize unrecognized | bcftools < 1.12 | Upgrade bcftools, or use vt decompose_blocksub as alternative |
| Split records carry wrong per-allele AF/AD | custom field mis-declared Number=. | Fix the header Number to A/R/G so bcftools subsets it on split |
| HGVS c. position disagrees with VCF POS | left-align (5' genomic) vs HGVS 3'-rule (transcript) | Expected; let the annotation engine emit HGVS, never hand-derive from POS |
vt normalize algorithm)development
Installs 425 bioinformatics skills covering sequence analysis, RNA-seq, single-cell, variant calling, metagenomics, structural biology, and 56 more categories. Use when setting up bioinformatics capabilities or when a bioinformatics task requires specialized skills not yet installed.
testing
Chains a somatic (tumor-normal) SNV/indel and structural-variant pipeline end to end with GATK Mutect2 (or Strelka2), wiring the somatic-specific machinery - panel-of-normals and gnomAD germline-resource priors, GetPileupSummaries/CalculateContamination, and LearnReadOrientationModel FFPE/oxoG orientation-bias filtering fed into FilterMutectCalls. Use when calling somatic mutations from a tumor-normal pair (or tumor-only with PoN caveats), deciding which artifact filter removes which class of false positive, reasoning about VAF/purity/ploidy and clonal-vs-subclonal detection, adding somatic SV/CNV or TMB/MSI/signatures, or routing variants to AMP/ASCO/CAP tier and oncogenicity interpretation (never germline ACMG).
development
End-to-end pooled and single-cell CRISPR screen analysis from FASTQ to hit genes. Orchestrates library design QC, guide counting, six-stage screen QC (plasmid Gini, replicate Pearson, CEGv2 PR-AUC, copy-number artifact), method-appropriate hit calling across MAGeCK RRA/MLE, BAGEL2, drugZ, JACKS, and Chronos, cancer-cell-line copy-number correction (CRISPRcleanR / Chronos), batch correction for multi-batch screens, and the specialized branches for combinatorial paralog screens, single-cell Perturb-seq, base-editor variant-function screens, prime-editor screens, and in vivo bottleneck-aware screens. Use when analyzing any pooled CRISPR screen end-to-end, matching the hit-calling method to the experimental design, integrating copy-number correction into the pipeline, or branching the workflow for single-cell, combinatorial, base-editor, prime-editor, or in vivo variants.
development
Transcribe DNA to RNA and translate to protein using Biopython, with NCBI codon-table selection, CDS validation, and six-frame ORF finding. Use when converting a CDS or ORF to its amino-acid sequence, selecting a non-standard (mitochondrial, bacterial, ciliate) genetic code, validating a coding sequence, or scanning all reading frames.