Pure-Python and Hail-expression implementations of the echtvar var32 and kmer16 variant encodings
Project description
echtvar-hail
echtvar's variant encodings in Hail, and an export that writes them to Parquet instead of echtvar's zip container.
Two things live here:
var32/kmer16— Python ports ofsrc/lib/{var32,kmer16}.rs, each with anhl_*twin so the encodings run inside a Hail pipeline rather than by collecting rows to Python.parquet— an export in echtvar's per-(chrom, chunk)shape, as standard Parquet. The archive becomes a self-describing columnar file that DuckDB, Polars, pandas or Arrow can read without echtvar.
Install
pip install echtvar-hail
Python 3.11 or 3.12. Hail brings PySpark, which needs a JVM — Hail is built and tested against Java 11.
Quick start
One Hail table, one field config, covering every shape Field supports —
copied as-is, quantized, renamed, and a nested struct — plus the routing that
splits variants across the two output tables.
import hail as hl
import pyarrow.parquet as pq
from echtvar_hail import var32
from echtvar_hail.parquet import Field, check_precisions, export_parquet, finalize_parquet
hl.init()
ht = hl.Table.parallelize(
[
{"locus": hl.Locus("chr1", 100, "GRCh38"), "alleles": ["A", "T"],
"filters": "PASS", "af": 0.0031, "cadd_raw": 3.21,
"vep": [hl.Struct(consequence="missense_variant", biotype="protein_coding", polyphen=0.91),
hl.Struct(consequence="intron_variant", biotype="lncRNA", polyphen=0.05)]},
{"locus": hl.Locus("chr1", 200, "GRCh38"), "alleles": ["AC", "A"],
"filters": "PASS", "af": 0.41, "cadd_raw": -1.44,
"vep": [hl.Struct(consequence="intron_variant", biotype="protein_coding", polyphen=0.01)]},
{"locus": hl.Locus("chr1", 300, "GRCh38"), "alleles": ["A", "ACGTACGT"],
"filters": "low_qual", "af": 0.0001, "cadd_raw": 22.6,
"vep": [hl.Struct(consequence="stop_gained", biotype="protein_coding", polyphen=1.0)]},
],
hl.tstruct(
locus=hl.tlocus("GRCh38"), alleles=hl.tarray(hl.tstr),
filters=hl.tstr, af=hl.tfloat64, cadd_raw=hl.tfloat64,
vep=hl.tarray(hl.tstruct(consequence=hl.tstr, biotype=hl.tstr, polyphen=hl.tfloat64)),
),
)
fields = [
Field("filters"), # plain: copied as-is
Field("af", precision=1e-6), # quantized: to a stated accuracy
Field("cadd_raw", alias="cadd", precision=0.01), # quantized + renamed
Field("vep", fields=( # struct: member-by-member
Field("consequence", categorical=True),
Field("biotype", categorical=True),
Field("polyphen", precision=1e-3),
)),
]
Every Hail column not named in fields is dropped — locus/alleles become
the key, and anything else absent from this list simply doesn't reach the
file. What each Field does to its column:
| Hail column | Field |
on disk |
|---|---|---|
filters |
Field("filters") |
copied through unchanged |
af |
Field("af", precision=1e-6) |
float64 → int64, 0.0031 → 3100 |
cadd_raw |
Field("cadd_raw", alias="cadd", precision=0.01) |
renamed cadd, -1.44 → -144 |
vep |
Field("vep", fields=(...)) |
stays array<struct<...>>, each member its own leaf |
# One aggregation pass. Checks each precision against the range it will be
# applied to, and reports what it will do.
for report in check_precisions(ht, fields):
print(report)
# {'column': 'af', 'multiplier': 1000000, 'min': 0.0001, 'max': 0.41, 'steps_used': 410000, ...}
# {'column': 'cadd', 'multiplier': 100, 'min': -1.44, 'max': 22.6, 'steps_used': 2260, ...}
# {'column': 'vep.polyphen', 'multiplier': 1000, 'min': 0.01, 'max': 1.0, 'steps_used': 1000, ...}
# Stage 1 (Hail + Spark, distributed): route short/long, encode, partition.
short, long = export_parquet(ht, "/tmp/staging", fields=fields)
# Stage 2 (pyarrow, one node): choose encodings, consolidate.
finalize_parquet(short, "/tmp/echtvar/short", fields=fields)
finalize_parquet(long, "/tmp/echtvar/long", fields=fields)
Reading it back — a key plus its chrom/chunk is a whole variant, quantized
values divide back out by their multiplier, and the struct comes back as a
list of dicts:
t = pq.read_table("/tmp/echtvar/short/chr1.parquet")
print(t.schema.names)
# ['key', 'filters', 'af', 'cadd', 'vep', 'chrom', 'chunk']
for i in range(t.num_rows):
d = var32.decode(t["key"][i].as_py(), t["chrom"][i].as_py(), t["chunk"][i].as_py())
print(f"{d.chrom}:{d.position + 1} {d.reference}>{d.alternate}")
print(f" filters={t['filters'][i].as_py()!r} af={t['af'][i].as_py() / 1_000_000}"
f" cadd={t['cadd'][i].as_py() / 100}")
print(f" vep={t['vep'][i].as_py()}")
# chr1:100 A>T
# filters='PASS' af=0.0031 cadd=3.21
# vep=[{'consequence': 'missense_variant', 'biotype': 'protein_coding', 'polyphen': 910},
# {'consequence': 'intron_variant', 'biotype': 'lncRNA', 'polyphen': 50}]
# chr1:200 AC>A
# filters='PASS' af=0.41 cadd=-1.44
# vep=[{'consequence': 'intron_variant', 'biotype': 'protein_coding', 'polyphen': 10}]
The third variant is missing from that output because A>ACGTACGT exceeds
var32's four-base budget, so it routed to the long table instead:
t = pq.read_table("/tmp/echtvar/long/chr1.parquet")
print(t.schema.field("key").type, t.num_rows)
# list<element: uint32> 1
Why two stages
No single tool can write this file. Hail and Spark are needed for the encoding and the shuffle, at scale. But Spark writes Parquet through parquet-mr, which cannot be told a column's encoding at all — it infers one from a writer version and a dictionary setting. The choices that make the format worth having are unavailable there.
stage 1 — export_parquet |
stage 2 — finalize_parquet |
|
|---|---|---|
| engine | Hail + Spark | pyarrow |
| runs | distributed | one node, streaming a chunk at a time |
| does | routes short/long, encodes keys, applies value transforms, partitions, sorts | picks encodings, narrows the key to uint32, writes the footer config, consolidates |
| output | staging/short/chrom=chr1/chunk=0/… |
out/short/chr1.parquet |
Stage 2 output is one file per chromosome, one row group per chunk, so a
chunk lookup is read_row_group(i) rather than opening a file. Measured over
400 chunks: same bytes to within 1%, and 2.9× faster on random chunk reads
locally — the gap widens on object storage, where opening a file is a round
trip.
API
Field
How one annotation is carried. Three shapes, and any of them may be array-valued — VEP-style annotations usually are, and an array stays an array rather than being flattened or joined into a delimited string.
Field("af") # carried as-is
Field("af", precision=1e-6) # float -> int, to a stated accuracy
Field("consequence", categorical=True) # dictionary-encoded (echtvar's string table)
Field("cadd_raw", alias="cadd", precision=0.01) # renamed on the way out
Nested fields
The vep field above is the common case — nest fields to describe a
struct's members, an array of them being one per transcript. Here's why it's
shaped that way:
Field("csq", fields=(
Field("consequence", categorical=True),
Field("biotype", alias="tx_biotype", categorical=True),
Field("polyphen", precision=1e-3),
))
Given [hl.struct(consequence="missense_variant", biotype="protein_coding", polyphen=0.912), ...]
that writes one Parquet leaf per member:
csq.list.element.consequence BYTE_ARRAY RLE_DICTIONARY
csq.list.element.tx_biotype BYTE_ARRAY RLE_DICTIONARY
csq.list.element.polyphen INT64 PLAIN # 0.912 -> 912
Three things follow from each member being its own column:
-
Each takes its own encoding. The two string members get their own dictionaries; the quantized one correctly gets none.
-
fieldsis an allowlist. Members you don't name are dropped, which is how you take three columns out of a VEP annotation with forty. -
precisionapplies per member, andcheck_precisionsreaches inside:check_precisions(ht, fields) # [{'column': 'csq.polyphen', 'multiplier': 1000, 'min': 0.05, 'max': 1.0, ...}]
The struct survives to disk as a struct — nothing is flattened or exploded, so a row reads back as a list of dicts:
t["csq"][0].as_py()
# [{'consequence': 'missense_variant', 'tx_biotype': 'protein_coding', 'polyphen': 912},
# {'consequence': 'intron_variant', 'tx_biotype': 'lncRNA', 'polyphen': 50}]
The array is incidental. A plain struct nests the same way, to any depth —
fields describes the shape, and whether it sits inside a list only changes
the leaf paths:
Field("a", fields=(
Field("b", fields=(Field("c", categorical=True), Field("score", precision=1e-3))),
Field("n"),
))
a.b.c BYTE_ARRAY RLE_DICTIONARY
a.b.score INT64 PLAIN # 0.125 -> 125
a.n INT32 PLAIN
# arrow type: struct<b: struct<c: string, score: int64>, n: int32>
# check_precisions -> {'column': 'a.b.score', 'multiplier': 1000, 'min': 0.125, 'max': 0.5}
precision is an accuracy budget, not a performance knob. Quantization
shrinks a column by making values repeat, so what you keep is what you don't
save. On 300k gnomAD-like allele frequencies, against 1.09MB as float32:
| precision | size | vs float32 | distinct values |
|---|---|---|---|
| 1e-8 | 753KB | 1.45× | 112,963 |
| 1e-7 | 628KB | 1.74× | 58,461 |
| 1e-6 | 520KB | 2.10× | 12,943 |
| 1e-5 | 362KB | 3.02× | 1,338 |
| 1e-4 | 230KB | 4.75× | 135 |
An allele frequency is AC/AN, so it cannot mean anything below 1/AN — about
6.7e-7 at AN≈1.5M. Asking for finer spends bytes on the denominator wobbling
between sites.
check_precisions(ht, fields) -> list[dict]
One aggregation pass. Raises if a precision cannot span its field's range without reaching the missing sentinel; otherwise reports the multiplier, observed min/max, and steps used. Advisory — nothing forces you to call it, but a precision that overflows silently turns real values into missing ones.
export_parquet(ht, base_path, *, locus="locus", alleles="alleles", fields=(), mode="overwrite")
Stage 1. Returns (short_path, long_path). Routes each variant on
len(ref) + len(alt) > 4: short variants take a 32-bit var32 key, long ones a
kmer16 array. Multi-allelic rows contribute only their first alternate — split
them (hl.split_multi_hts) first.
finalize_parquet(staging_path, output_path, *, fields=(), filesystem=None)
Stage 2. Pass the same fields. On GCS, hand it a pyarrow.fs.GcsFileSystem
— pyarrow does not use Hadoop's GCS connector.
Decoding
var32.decode(key, chrom, chunk) and kmer16.decode(key, chrom) return a
PRA(chrom, chunk, position, reference, alternate), 0-based. A var32 key stores
position truncated to 20 bits, so it needs its chunk to reconstruct an
absolute coordinate — which is why chunk is a column.
Output layout
out/short/chr1.parquet key: uint32 row group i == chunk i
out/long/chr1.parquet key: list<uint32>
Columns are key, chrom, chunk, and one per field. Position and alleles are
not stored — the key plus chunk reconstructs them.
chunk is the index into the row groups. chrom identifies rows once several
files are read together (ds.dataset(root) concatenates them and does not
surface filenames). Both are constant over their scope, so RLE reduces them to
almost nothing.
Quantized columns are unreadable without their multiplier, so it is written to
the Parquet footer under the fields key:
import json
md = pq.ParquetFile("/tmp/echtvar/short/chr1.parquet").metadata
json.loads(md.metadata[b"fields"])
# [{'name': 'filters', 'column': 'filters'},
# {'name': 'af', 'column': 'af', 'multiplier': 1000000, 'missing': 4294967295},
# {'name': 'cadd_raw', 'column': 'cadd', 'multiplier': 100, 'missing': 4294967295},
# {'name': 'vep', 'column': 'vep', 'fields': [
# {'name': 'consequence', 'column': 'consequence'},
# {'name': 'biotype', 'column': 'biotype'},
# {'name': 'polyphen', 'column': 'polyphen', 'multiplier': 1000, 'missing': 4294967295}]}]
A struct field's own entry has no multiplier — it's a container, so the
multiplier sits on the member that needs one, and the nesting in the config is
mirrored in the footer.
Dictionary encoding is deliberately not recorded there — Parquet already reports each column chunk's encoding, and a footer note would be the less reliable of the two.
Development
uv sync # dev tools are a PEP 735 dependency group
uv run pytest
uv run ruff check
The suite starts a real Hail/Spark session; it needs a JVM.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file echtvar_hail-0.2.1.tar.gz.
File metadata
- Download URL: echtvar_hail-0.2.1.tar.gz
- Upload date:
- Size: 37.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fbc22afe09269579966cd46031773b8ae9a7d017102976d11d3c6a15dc6b9634
|
|
| MD5 |
ab2aaf79548c6dbf4613e479c51cadbf
|
|
| BLAKE2b-256 |
e92148a72b87656be82270cbbe089db6e0e76f4bec8e9c97d84bcc38303d6364
|
Provenance
The following attestation bundles were made for echtvar_hail-0.2.1.tar.gz:
Publisher:
publish.yml on broadinstitute/echtvar-hail
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
echtvar_hail-0.2.1.tar.gz -
Subject digest:
fbc22afe09269579966cd46031773b8ae9a7d017102976d11d3c6a15dc6b9634 - Sigstore transparency entry: 2279426296
- Sigstore integration time:
-
Permalink:
broadinstitute/echtvar-hail@497203cb838ea5d63f6436b07d193fd7178173f5 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/broadinstitute
-
Access:
internal
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@497203cb838ea5d63f6436b07d193fd7178173f5 -
Trigger Event:
push
-
Statement type:
File details
Details for the file echtvar_hail-0.2.1-py3-none-any.whl.
File metadata
- Download URL: echtvar_hail-0.2.1-py3-none-any.whl
- Upload date:
- Size: 28.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3574b53c86858b6d335f4ede59ffefff447eabafa1445968e0a665f87e6368da
|
|
| MD5 |
a371cd0c80abeb1363766a55a16a8be7
|
|
| BLAKE2b-256 |
028197888a3233667ebece6b563c19ed5535fb61d3aee5e8a08658b9a78d85f8
|
Provenance
The following attestation bundles were made for echtvar_hail-0.2.1-py3-none-any.whl:
Publisher:
publish.yml on broadinstitute/echtvar-hail
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
echtvar_hail-0.2.1-py3-none-any.whl -
Subject digest:
3574b53c86858b6d335f4ede59ffefff447eabafa1445968e0a665f87e6368da - Sigstore transparency entry: 2279426302
- Sigstore integration time:
-
Permalink:
broadinstitute/echtvar-hail@497203cb838ea5d63f6436b07d193fd7178173f5 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/broadinstitute
-
Access:
internal
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@497203cb838ea5d63f6436b07d193fd7178173f5 -
Trigger Event:
push
-
Statement type: