SequenceVariant
SequenceVariant
Core Principle:
SequenceVariantis the core object hierarchy representing parsed HGVS expressions, encapsulating the reference accession, coordinate type, position interval, and sequence edit.
The structure
SequenceVariant
├─ ac 'NM_000546.6' the accession, with version
├─ type 'c' g | c | n | r | p | m
├─ gene None optional
└─ posedit PosEdit
├─ pos Interval
│ ├─ start BaseOffsetPosition(base=215, offset=0, datum=CDS_START)
│ └─ end BaseOffsetPosition(base=215, offset=0, datum=CDS_START)
├─ edit NARefAlt(ref='C', alt='G')
└─ uncertain False
var = hp.parse_hgvs_variant("NM_000546.6:c.215C>G")
var.ac # 'NM_000546.6'
var.type # 'c'
var.posedit.pos.start.base # 215
var.posedit.pos.start.offset # 0 (non-zero = intronic)
var.posedit.pos.end.base # 215
var.posedit.edit.ref # 'C'
var.posedit.edit.alt # 'G'
var.posedit.edit.type # 'sub'
str(var) # round-trips to the original string
Position classes
| Class | Used by | Fields |
|---|---|---|
SimplePosition |
g., m., n. |
base |
BaseOffsetPosition |
c., r. |
base, offset, datum |
AAPosition |
p. |
base, aa |
Interval |
all | start, end, uncertain |
BaseOffsetPosition is where the complexity lives:
from hgvs.enums import Datum
pos.base # the coding position
pos.offset # 0 = exonic; ±n = intronic
pos.datum # Datum.CDS_START (normal), CDS_END (the * region), SEQ_START
pos.is_intronic # offset != 0
pos.is_uncertain
Worked examples:
| HGVS | base | offset | datum |
|---|---|---|---|
c.215 |
215 | 0 | CDS_START |
c.-24 |
−24 | 0 | CDS_START |
c.87+3 |
87 | +3 | CDS_START |
c.88-3 |
88 | −3 | CDS_START |
c.*12 |
12 | 0 | CDS_END |
c.*12-8 |
12 | −8 | CDS_END |
base alone is not a position. c.87+3 has base 87, and so does c.87; they are 3 bp apart in the genome and in completely different functional contexts (one intronic, one exonic). Any code doing if var.posedit.pos.start.base == 87 without checking the offset is wrong.
Likewise, datum distinguishes c.12 (the 12th coding base) from c.*12 (the 12th base after the stop): both have base == 12.
Edit classes
| Class | For | Fields |
|---|---|---|
NARefAlt |
sub, del, ins, delins, identity | ref, alt |
Dup |
duplication | ref |
Inv |
inversion | ref |
Repeat |
tandem repeats | ref, min, max |
AARefAlt |
protein sub/del/ins | ref, alt |
AAFs |
frameshift | ref, alt, length |
AAExt |
extension | |
AASub |
protein substitution |
var.posedit.edit.type # 'sub' | 'del' | 'ins' | 'delins' | 'dup' | 'inv' | 'identity'
Dispatching on edit.type is how you write code that handles all variant classes:
This code completed without producing output.
ref is often None. HGVS permits c.76_78del without spelling out the deleted bases. hgvs fills it in from the sequence during normalization: one of the reasons to normalize before analysing. Guard with e.ref or "".
Constructing variants programmatically
'NM_000546.6:c.215C>G'
Verbose, but it is how you build variants from structured data (a VCF row, a database record) without string formatting. Building a variant by string concatenation and then parsing it is fragile: you can easily produce something syntactically valid and semantically wrong (an ins that should be a dup).
Copying and mutating
SequenceVariant objects are effectively value objects. To modify one, copy it:
This code completed without producing output.
Mutating in place risks aliasing bugs if you have kept references. Most hgvs operations (normalize, c_to_g) return new objects rather than mutating, which is the right design.
Extracting to a DataFrame
The bridge into the pandas half of your analysis:
def to_row(var):
pe = var.posedit
return {
"accession": var.ac,
"type": var.type,
"start_base": pe.pos.start.base,
"start_offset": getattr(pe.pos.start, "offset", 0),
"end_base": pe.pos.end.base,
"end_offset": getattr(pe.pos.end, "offset", 0),
"edit_type": pe.edit.type,
"ref": getattr(pe.edit, "ref", None),
"alt": getattr(pe.edit, "alt", None),
"is_intronic": getattr(pe.pos.start, "offset", 0) != 0,
"hgvs": str(var),
}
df = pd.DataFrame([to_row(v) for v in variants])
getattr(..., default) throughout, because g. variants use SimplePosition (no offset) and Dup/Inv edits have no alt. Writing pe.pos.start.offset unconditionally crashes on genomic variants.
Comparison and equality
v1 == v2 # structural equality
str(v1) == str(v2) # string equality
Neither is biological equivalence. c.76_78del and c.77_79del can be the same event in a repeat, and they compare unequal. To compare variants meaningfully:
- Normalize both (hgvs Normalizer).
- Project both to the same reference type and accession (AssemblyMapper).
- Then compare.
Skipping either step gives you false negatives. This is the single most consequential thing to understand about working with variants. See hgvs Pitfalls.
Common mistakes
- Using
basewithoutoffset.c.87+3≠c.87. - Ignoring
datum.c.12≠c.*12. - Assuming
refis populated. Normalize first, or guard withor "". - Assuming
altexists ondupandinvedits. pos.start.offseton ag.variant → AttributeError.- Comparing unnormalized variants and concluding they differ.
- String-building variants instead of constructing objects.
- Stripping the accession version.
See also
hgvs Parser · HGVS Nomenclature · hgvs Normalizer · Projection c to g to p · Coordinate Systems · hgvs Pitfalls
Exercise
The hgvs package is not available in this sandbox, so you
will take a variant string apart with re rather than reading
posedit.pos.start off a parsed object. The fields are the same three the
lesson insists you always read together, and pulling them out by hand is what
makes it obvious why base on its own is not a position.
Write decompose(lines), taking the input lines and returning a list of output
lines. Ignore blank lines. Each remaining line is a variant whose description
begins with a c. position; read only that start position, which may carry a
leading *, a negative base number, and an intronic offset. Emit <variant> base=<b> offset=<o> datum=<d>, where the base keeps its sign, the offset is
0 when exonic and otherwise carries an explicit + or -, and the datum is
CDS_END for a * position and CDS_START for every other. Emit <variant> unparsable for a line that does not begin that way, and leave it out of the
counts.
Finish with three lines: intronic=<n> counting the positions with a non-zero
offset, distinct_positions=<n> counting distinct (base, offset, datum)
triples, and ambiguous_bases=<n> counting the base numbers that appear with
more than one distinct (offset, datum) pair: the count that would be zero if
base alone identified a position. All three lines are printed as zeros when
the input is empty.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | NM_000546.6:c.215C>G NM_000546.6:c.87+3G>A NM_000546.6:c.87A>G NM_000546.6:c.88-3T>C NM_000546.6:c.-24A>G NM_000546.6:c.-24+5G>A NM_000546.6:c.*12A>C NM_000546.6:c.*12-8del NM_000546.6:c.12A>C | NM_000546.6:c.215C>G base=215 offset=0 datum=CDS_START NM_000546.6:c.87+3G>A base=87 offset=+3 datum=CDS_START NM_000546.6:c.87A>G base=87 offset=0 datum=CDS_START NM_000546.6:c.88-3T>C base=88 offset=-3 datum=CDS_START NM_000546.6:c.-24A>G base=-24 offset=0 datum=CDS_START NM_000546.6:c.-24+5G>A base=-24 offset=+5 datum=CDS_START NM_000546.6:c.*12A>C base=12 offset=0 datum=CDS_END NM_000546.6:c.*12-8del base=12 offset=-8 datum=CDS_END NM_000546.6:c.12A>C base=12 offset=0 datum=CDS_START intronic=4 distinct_positions=9 ambiguous_bases=3 |
| 2 | stdin | NM_000546.6:c.76_78del NM_000546.6:c.100-8_100-4del NC_000017.11:g.7676154G>C NM_000546.6:c.76_77insT | NM_000546.6:c.76_78del base=76 offset=0 datum=CDS_START NM_000546.6:c.100-8_100-4del base=100 offset=-8 datum=CDS_START NC_000017.11:g.7676154G>C unparsable NM_000546.6:c.76_77insT base=76 offset=0 datum=CDS_START intronic=1 distinct_positions=2 ambiguous_bases=0 |
| 3 | stdin | NM_000546.6:c.215C>G | NM_000546.6:c.215C>G base=215 offset=0 datum=CDS_START intronic=0 distinct_positions=1 ambiguous_bases=0 |
| 4 | stdin | intronic=0 distinct_positions=0 ambiguous_bases=0 |
Hints · 3
01Hint
re.match(r"[^:]+:c\.(\*?)(-?\d+)([+-]\d+)?", s) reads the start of a range without consuming the rest.
02Hint
Format the offset with f"{o:+d}" only when it is non-zero, otherwise print a plain 0.
03Hint
Collect a dict of base to a set of (offset, datum) pairs, then count the entries whose set holds more than one.