HGVS Nomenclature10/11
Starting sandbox…
Beginner·4 graded tests

HGVS Validator

hgvs Validator

Core Principle: HGVSValidator conducts intrinsic syntax checks and extrinsic reference-sequence validation to confirm that variant coordinates and reference alleles agree with underlying biological models.

Parsing tells you a string is well-formed. Validation tells you it describes something that exists.

The two levels

import hgvs.validator

hv = hgvs.validator.Validator(hdp)
hv.validate(var)                      # both levels; raises on failure
hv.validate(var, strict=False)        # returns False instead of raising
Validator Needs data? Checks
IntrinsicValidator no internal consistency: start ≤ end, ref present where required, sensible edit for the type
ExtrinsicValidator yes the accession exists, positions are within the sequence, ref bases match the actual sequence
Validator yes both
from hgvs.validator import IntrinsicValidator, ExtrinsicValidator

iv = IntrinsicValidator()             # fast, offline
ev = ExtrinsicValidator(hdp)          # needs UTA + SeqRepo

Running IntrinsicValidator first as a cheap filter is worthwhile on large batches: it catches malformed variants without a database round-trip.

What extrinsic validation catches

hv.validate(hp.parse_hgvs_variant("NM_000546.6:c.215A>G"))
# HGVSInvalidVariantError: Variant reference (A) does not agree with
# reference sequence (C)

That single check catches the four most common and most dangerous data errors:

  1. Wrong assembly. A GRCh37 coordinate interpreted as GRCh38 lands on a different base.
  2. Wrong accession version. NM_000546.5 and .6 can number differently.
  3. Strand error. A minus-strand gene where someone forgot to reverse-complement: the ref base comes out as its complement.
  4. A genuinely corrupt record. Transcription errors, Excel damage, copy-paste across rows.

All four are silent without validation, and all four produce results that look completely normal downstream.

The exception types

[ ]python
out1ms

This code completed without producing output.

Classifying by exception type is what turns "1,200 variants failed" into "1,150 are on transcripts absent from our UTA snapshot, 40 have ref mismatches, 10 are out of range": three different problems with three different fixes.

Where validation belongs

var = hp.parse_hgvs_variant(s)        # 1. syntax
hv.validate(var)                       # 2. reality  ← HERE
var = hn.normalize(var)                # 3. canonical form
var_g = am.c_to_g(var)                 # 4. projection

Validate before normalizing and projecting. Both of those depend on the reference sequence, so if the ref base is wrong you get a confidently-computed wrong answer rather than an error.

Note that Normalizer(validate=True) and AssemblyMapper(prevalidation_level=...) do some of this for you, but an explicit validation step gives you a clean place to catch and classify failures.

Batch validation

def validate_batch(df, hp, hv, col="hgvs"):
    rows = []
    for s in df[col]:
        try:
            var = hp.parse_hgvs_variant(s)
        except HGVSParseError as e:
            rows.append({"status": "parse_error", "detail": str(e)})
            continue
        status, detail = validate_one(hv, var)
        rows.append({"status": status, "detail": detail})

    out = df.join(pd.DataFrame(rows, index=df.index))
    print(out["status"].value_counts())
    return out

checked = validate_batch(variants, hp, hv)
valid = checked.query("status == 'valid'")
print(f"{len(valid):,} of {len(checked):,} usable ({len(valid)/len(checked):.1%})")

Report the counts, and look at the failure patterns. Failures almost never scatter randomly: they cluster around one systematic cause, and the pattern tells you what it is:

  • Nearly all ref_mismatch → wrong assembly, or wrong accession versions throughout
  • Nearly all no_data → your UTA snapshot predates the transcript set the file uses
  • A few out_of_range → likely genuine data errors in those rows

Validation is not interpretation

The Validator answers: does this variant exist as described?

It does not answer:

  • Is this variant pathogenic? (→ ACMG criteria, ClinVar, InterVar)
  • Is it real, or a sequencing artefact? (→ variant caller quality metrics, IGV)
  • Is it the best transcript to report on? (→ MANE Select, clinical convention)
  • Does the described protein consequence occur? (→ functional data)

A variant can be perfectly valid HGVS, perfectly matched to the reference, and complete nonsense biologically. Validation is a data-integrity check, not a biological one.

Custom checks worth adding

Beyond what the library does:

def extra_checks(var, am, hdp):
    warnings = []

    # is the accession the MANE Select transcript for its gene?
    if var.type == "c" and var.ac not in MANE_TRANSCRIPTS:
        warnings.append(f"{var.ac} is not MANE Select")

    # is the variant deep intronic? (likely low interpretive value)
    off = getattr(var.posedit.pos.start, "offset", 0)
    if abs(off) > 20:
        warnings.append(f"deep intronic ({off:+d} from the splice site)")

    # very large indel: check it is intended
    edit = var.posedit.edit
    if edit.type in ("del", "delins", "dup"):
        span = var.posedit.pos.end.base - var.posedit.pos.start.base + 1
        if span > 100:
            warnings.append(f"large event ({span} bp)")

    return warnings

The MANE check is the most valuable in practice: a variant reported on a non-canonical transcript will not match the literature or ClinVar, even though it is perfectly valid.

Common mistakes

  • Skipping validation and computing on bad data.
  • Catching bare Exception, losing the classification.
  • Validating after normalizing.
  • Treating "valid" as "clinically meaningful".
  • Not reporting failure counts.
  • Not looking at failure patterns.
  • Assuming validation catches assembly mixups. It catches the ones where the ref base happens to differ: roughly 3 in 4. The rest pass silently. Track your assembly explicitly; do not rely on validation to catch it.

That last point deserves emphasis. If a GRCh37 coordinate is misread as GRCh38, the base at the wrong position matches the claimed ref about 25% of the time by chance. Validation will catch 75% of such errors: good, but not a substitute for knowing which assembly your data is in.

See also

hgvs Normalizer · hgvs Parser · SeqRepo · AssemblyMapper · hgvs Pitfalls · Applied - Variant Normalization Pipeline

Exercise

The hgvs package is not available in this sandbox, so you will write the extrinsic check yourself against a small sequence store supplied on stdin. Validator.validate does exactly this: look the accession up, check the interval is inside the sequence, compare the stated reference bases with the real ones: and classifying the failures is the part that turns a batch report into a diagnosis.

Write validate_all(text), taking the whole of stdin and returning a list of output lines. Stdin holds <accession> <sequence> lines, then a line --, then one variant per line. A variant is <accession>:<type>.<posedit> where the type is g or n, the posedit is a single position or start_end range, and the edit is either X>Y or del optionally followed by the deleted bases. Positions index the stored sequence 1-based. Emit <variant> <status> for each variant, where the status is the first of: parse_error if the string does not match that shape or a substitution carries a range; no_data if the accession is absent from the store; out_of_range if the interval falls outside the sequence or ends before it starts; ref_mismatch if stated reference bases disagree with the sequence; otherwise valid. A del with no bases spelled out states no reference and so cannot mismatch. Finish with total=<n> and then one <status>=<count> line per status that occurred, in alphabetical order; total=0 alone is the whole output when no variants follow the --.

exerciseEvery submission runs independently in a fresh namespace.

Test cases · 4

#viainputexpected stdout
1stdinNC_000017.11 GATTACAGGCGTGAGCCACCGCGCCCGGCC NM_000546.6 ATGGAGGAGCCGCAGTCAGATCCTAGCGTC -- NC_000017.11:g.5A>C NM_000546.6:n.4G>A NM_000546.6:n.4C>A NM_000546.6:n.1_3del NM_000546.6:n.1_3delATG NM_000546.6:n.1_3delAAA NM_000546.6:n.99A>C NR_000001.1:n.1A>C NM_000546.6:c.4G>A NM_000546.6:n.4G NC_000017.11:g.5A>C valid NM_000546.6:n.4G>A valid NM_000546.6:n.4C>A ref_mismatch NM_000546.6:n.1_3del valid NM_000546.6:n.1_3delATG valid NM_000546.6:n.1_3delAAA ref_mismatch NM_000546.6:n.99A>C out_of_range NR_000001.1:n.1A>C no_data NM_000546.6:c.4G>A parse_error NM_000546.6:n.4G parse_error total=10 no_data=1 out_of_range=1 parse_error=2 ref_mismatch=2 valid=4
2stdinNC_000017.11 GATTACAGGCGTGAGCCACCG -- NC_000017.11:g.20_21del NC_000017.11:g.20_21delCG NC_000017.11:g.21_22del NC_000017.11:g.10_5del NC_000017.11:g.5_6A>C NC_000017.11:g.20_21del valid NC_000017.11:g.20_21delCG valid NC_000017.11:g.21_22del out_of_range NC_000017.11:g.10_5del out_of_range NC_000017.11:g.5_6A>C parse_error total=5 out_of_range=2 parse_error=1 valid=2
3stdin-- NC_000017.11:g.5A>C NM_000546.6:n.4G>A total=0
4stdinNC_000017.11 GATTACAGGC -- total=0

Hints · 3

01Hint

Convert once at the boundary: the 1-based interval start..end is seq[start - 1:end] in Python.

02Hint

Handle the store as a dict of accession to sequence, built before you touch the variants.

03Hint

Count the statuses in a dict as you go, then emit them with sorted(counts).