How to write a Dataset#
Depending on your needs, you’ll potentially need to normalize variants and prepare a mapping from sample names to BigWig files before you can write a Dataset.
Preparing input data#
Normalizing variants#
Before passing variants to GenVarLoader, they must be:
left-aligned
bi-allelic
atomic (no MNPs or compound MNP-indels)
for VCFs, indexed with
bcftools indexortabix
In general, any VCF can be preprocessed to meet these requirements using bcftools norm, for example:
bcftools norm -f $reference \
-a --atom-overlaps . \
-m -any --multi-overlaps . \
-O b -o $out $in
Alternatively, if your data is already in the PGEN format you can use PLINK 2.0:
plink2 --make-bpgen --pfile $input --out $intermediate
plink2 --make-pgen --normalize --ref-from-fa --fa reference.fa --bpfile $intermediate --out $normalized
See the PLINK 2.0 documentation for details.
BigWig table#
To process BigWig data, GenVarLoader needs a mapping from sample names to BigWig file paths. This can be provided either as a dictionary using BigWig() or a table using BigWig.from_table(). If using a table, it must at least contain the columns sample and path, for example:
sample |
path |
|---|---|
Aang |
/data/bigwigs/aang.bw |
Katara |
/data/bigwigs/katara.bw |
Sokka |
/data/bigwigs/sokka.bw |
Regions of interest (ROIs)#
Whether you’re working with variants, BigWigs, or both, you will need regions of interest specified in a BED-like table. This means your table must have the columns chrom, chromStart, and chromEnd as 0-based coordinates. You can also include a strand column consisting of +, -, and . (treated as +) which will let you choose to reverse and/or reverse complement data on negative strands. Any extra columns will be kept in the table and exist in Dataset.regions after writing. An example of a BED-like table:
split |
chrom |
chromStart |
chromEnd |
strand |
|---|---|---|---|---|
train |
chr1 |
0 |
5 |
- |
val |
chr2 |
3 |
8 |
+ |
test |
chr3 |
20 |
21 |
. |
Writing data#
Once your data is prepared, you can use gvl.write() to convert the data for regions of interest to a format that gvl.Dataset can open. You can include a single source of variants and/or any number of BigWig files. Some examples:
import genvarloader as gvl
gvl.write(
path="1000_genomes_haplotypes.gvl",
bed="tiling_windows.bed",
variants="all_chroms.bcf",
# OR variants='all_chroms.pgen',
)
This dataset would have haplotypes available for all samples in all_chroms.bcf.
gvl.write(
path="1000_genomes_lncRNA.gvl",
bed="lncRNA.bed", # can be varying length regions
variants="all_chroms.bcf",
tracks=[
gvl.BigWigs.from_table("pos", "pos_strands.tsv"),
gvl.BigWigs.from_table("neg", "pos_strands.tsv"),
],
)
This dataset would have both haplotypes and two tracks (pos and neg) available for samples that exist in both all_chroms.bcf and the BigWig tables (i.e. gvl.write() performs an inner join on samples).
Variants from a genoray sparse store (.svar / .svar2)#
Besides BCF/VCF and PGEN, variants= also accepts a genoray sparse columnar variant store — either the original .svar format or the newer .svar2 format. Build one from a normalized VCF/BCF with genoray:
from genoray import VCF, SparseVar, SparseVar2
# .svar (SVAR1): a VCF reader + a memory budget
SparseVar.from_vcf("all_chroms.svar", VCF("normed.bcf"), max_mem="4g")
# .svar2 (SVAR2): the VCF/BCF path + a reference FASTA (or no_reference=True)
SparseVar2.from_vcf("all_chroms.svar2", "normed.bcf", reference="ref.fa")
Then pass the resulting store to gvl.write:
gvl.write(
path="1000_genomes_haplotypes.gvl",
bed="tiling_windows.bed",
variants="all_chroms.svar2", # or "all_chroms.svar", or a SparseVar/SparseVar2 instance
)
Both formats store a back-reference in the dataset’s metadata.json instead of duplicating per-variant arrays, so the source store must remain accessible when the dataset is later opened with gvl.Dataset.open() (override its location with svar=/svar2= if it has moved).
.svar2 additionally produces a write-time cache under <path>/genotypes/svar2_ranges/ and reads back through an all-Rust, read-bound path with no interval-search-tree build and no dense-union rebuild per read — see the FAQ for the read-path and on-disk-size tradeoffs, and the format reference for the on-disk layout, including the size formula. This cache is small at cohort scale: ~504 MB for the All of Us chr22 grid, since it stores 28 bytes only for the (region, sample, ploid) windows that hold a variant. max_mem bounds the genoray chunk stream gvl.write reads while producing the cache, but not the per-contig entry accumulator: the per-chunk (region int32, cell int32, 24-byte entry) blocks and the merged (cell, entry) output it is sorted into live at the same time, peaking at roughly 60 bytes per entry on the largest contig — about 1.1 GB at All of Us chr22 and ~3.4 GB at chr19. The permanent range cache on disk is governed by disk space, not max_mem (see the format reference). .svar2 currently has a Phase-1 scope: a handful of output combinations (annotated haplotypes, min_af/max_af, spliced variant-window/track outputs, etc.) aren’t wired yet and raise NotImplementedError — see the genvarloader skill or the format reference for the full list. Haplotype and variants output support splicing and var_filter="exonic".
Sample order#
Whether you pass samples= or leave it None, gvl.write stores the selection in
lexicographic order, and Dataset.samples reports
that same order.
Warning
Lexicographic order is not numeric order. A cohort with integer-like IDs of mixed digit
counts sorts as "1000" < "999", whereas the phenotype table you want to join against is
usually in numeric order. Align external tables to Dataset.samples by name, never by
position.
Reusing a variant index across cohorts#
If several cohorts are sample subsets of one parent PGEN, do not pre-split the
PGEN with plink2 --keep. Point gvl.write at the parent and pass samples=:
gvl.write("cohort_A.gvl", bed, "parent.pgen", samples=cohort_A_samples)
gvl.write("cohort_B.gvl", bed, "parent.pgen", samples=cohort_B_samples)
genoray caches the parent PGEN’s variant index on disk (.pvar.gvi, keyed by the .pvar’s
mtime); samples= only subsets at the genotype-extraction level and never touches that index, so
a second gvl.write against the same parent path reuses the cached index instead of rescanning
the PVAR — and gvl hardlinks that cached index file into each dataset’s
genotypes/variants.arrow. Splitting the PGEN first (plink2 --keep) produces a distinct .pvar
per cohort, which forces the expensive per-cohort index rebuild.
Merging datasets#
gvl.concat() merges datasets that were written from one shared
variant source (the same PGEN/VCF variant table, or the same .svar/.svar2 store — merging
datasets built from different variant sources raises), along either axis:
gvl.concat("merged.gvl", ["chr1.gvl", "chr2.gvl"], axis="regions")
gvl.concat("merged.gvl", ["cohortA.gvl", "cohortB.gvl"], axis="samples")
axis="regions" requires identical samples in identical order across every input, and
concatenates their regions. axis="samples" requires identical regions across every input and
disjoint sample sets, and merges the samples into sorted order. Both axes merge tracks and
annotation tracks alongside the genotypes, and require at least two input datasets.
gvl.concat streams data at the byte level rather than re-deriving anything from the source
variants, so its cost is dominated by I/O rather than computation. As an order-of-magnitude
expectation (not a measured figure — this hasn’t been benchmarked), it moves roughly the full
size of the merged dataset at sequential-IO speed, so a merge of a very large dataset (e.g. on the
order of a terabyte) could plausibly take hours rather than minutes. It’s worth doing against the
alternative of re-extracting genotypes from scratch, not as a routine step.
gvl.concat’s planning is streaming: it derives the merge plan from the sorted
merge order rather than building a map over every (region, sample, ploid) slot,
so its memory stays proportional to regions plus samples rather than to their
product. The merged offsets.npy it writes is still one int64 per merged
(region, sample[, ploid]) slot — about 16 GB for per-sample tracks (no ploidy
axis) or 32 GB for diploid genotypes, at a 3,734-region by 535,662-sample grid —
and that is a property of the ragged on-disk format, not of the merge. Peak
resident memory during the copy is roughly double that figure: the copy also
holds each input’s own offsets array for the duration, and since the inputs
partition the merged grid, their combined size is about the same as the merged
array itself — so about 32 GB for per-sample tracks or 64 GB for diploid
genotypes, at the same grid.
For contig-sharded .svar2 workflows there is a cheaper path at the variant-store layer:
genoray.SparseVar2.concat(output, sources, mode="copy") merges disjoint-contig .svar2 stores
that share identical samples/ploidy/fields, then a single gvl.write call over the merged store
only needs to populate .svar2’s small per-(region, sample, ploid) cache ranges rather than
touch the bulk variant data. This is a different operation from gvl.concat, which merges already
written .gvl dataset directories — SparseVar2.concat merges the upstream .svar2 stores
before gvl.write ever runs.