Repository navigation
Conversation
Hoeze
force-pushed
the
feat/variant-seq-batches
branch
from
October 17, 2026 00:02
bd845e6 to
fc4018f
Compare
Hoeze
force-pushed
the
feat/variant-seq-batches
branch
2 times, most recently
from
October 17, 2026 14:08
4f945a1 to
bb14de3
Compare
Hoeze
force-pushed
the
feat/variant-seq-batches
branch
from
October 17, 2026 16:22
bb14de3 to
7691989
Compare
VariantSeqExtractor.extract applied overlapping variants one after the other. Two SNVs at one base gave two bases, and the sequence depended on the order of the variants. extract now raises a ValueError if the REF of two variants share a base, e.g. for two ALT alleles of one VCF record. GenomeKit rejects overlapping variants too. Variants that only touch, such as an SNV next to a deletion, still apply together.
iter_variant_seqs(pairs, reference, *, use_strand, anchor, group_by, encoding, pad_with_n, batch_size) turns interval-variant pairs into polars DataFrame batches. The pairs with equal values in the group_by columns give one row, with all their variants applied to their interval by VariantSeqExtractor.extract. The default group_by gives one row per pair, and group_by=["interval_idx"] applies all variants of an interval together. A batch holds the group keys, chrom, start, end and strand, ref_seq and alt_seq, and the encoded columns ref and alt. A Tokenizer (default Tokenizer()) gives tokens, and an Encoder gives one-hot arrays, both in their dtype. If all sequences have the same length, the encoded columns are pl.Array columns, and to_numpy() returns a (rows, length) or (rows, length, n_columns) array without a copy. Otherwise they are pl.List columns. anchor picks the anchor_pos of VariantSeqExtractor.extract by the rule of Interval.resize, so with use_strand, "start" is the 5' end of "-" intervals. anchor=None lets indels change the length of alt_seq. iter_variant_seqs collects and checks the pairs when it is called. A group with several intervals raises a ValueError. Errors of extract and of the encoding, e.g. for overlapping variants, get a note that names the group. Each pair applies its ALT allele, so pairs with a False in the column variant_carrier raise a ValueError. Such pairs come from scan_vcf_genotypes(carriers_only=False). Within a batch, the reference of each interval is fetched once, and ref_seq is cut from it. All sequences of a column are encoded in one call. _check_int tests for a plain int before it tests for numbers.Integral, which makes Interval and Variant faster to build. With 1 kb windows, iter_variant_seqs is faster than a loop that calls extract, fetches the reference and tokenizes both sequences.
scan_vcf_genotypes(path, haplotypes=True) gives one row per ALT allele, sample and haplotype, with the column haplotype: the position of the allele in GT, so 0 or 1 for a diploid GT. carrier then tells whether this haplotype carries the ALT allele, and carriers_only keeps only the haplotypes that carry it. A missing GT gives one row with a null haplotype. iter_variant_seqs gives the sequence of each haplotype if the genotypes are the variants of scan_variant_pairs and group_by is interval_idx, variant_sample and variant_haplotype. haplotype ignores the phasing, as bcftools consensus -H does: for an unphased GT such as 0/1, the order of the alleles in GT sets the haplotype. GT stays in the frame, so unphased GTs can be filtered.
Tokenizer.tokenize_series(seqs) and Encoder.one_hot_series(seqs) encode
all sequences of a polars String Series at once. iter_variant_seqs uses
them for the columns ref and alt. They build List columns without a
copy, so the List columns of 10,000 sequences of 1 kb encode 3.4 to 6.6
times faster than with _encoded_series.
The result keeps the name of seqs. If all sequences have the same
length, it is an Array column: pl.Array(dtype, length) for tokens, or
pl.Array(dtype, (length, n_columns)) for one-hot arrays. Otherwise it is
a List column: pl.List(dtype) or pl.List(pl.Array(dtype, n_columns)).
An empty Series gives an empty List column, because it has no length
for an Array column.
For letters of single ASCII characters, the methods read the offsets and
the data buffer of seqs.to_arrow(), also of chunked and sliced arrays.
One bytes.translate call converts the data buffer. A List column is
built from the offsets and the values without a copy. Other alphabets,
e.g. codons, use tokenize or one_hot for each sequence.
A Series that is not of strings raises a TypeError, and nulls raise a
ValueError. For the first sequence with an unknown letter, the methods
raise the ValueError of tokenize or one_hot, with a note that names the
row. In one_hot_series, a dtype without a polars type raises a
TypeError, e.g. np.longdouble on x86-64.
iter_variant_seqs drops _encoded_series. If the intervals have
different widths, but the sequences of a batch have the same length by
chance, it casts the Array column to a List column. So all batches
share one schema.
The ranges extra lists pyarrow, because the methods import it.
polars-bio installed it already.
Time to encode one batch into one Series, in ms. Each time is the
median per call, as the mean of two rounds. old is _encoded_series at
b872c8b, with tokenize, one_hot_table(DNA, neutral_value=0,
dtype=np.uint8) and DNA_ONE_HOT. ref is _encoded_series at d7254f8,
with Tokenizer().tokenize, Encoder().one_hot and
Encoder(ambiguous="fractional", dtype=np.float32).one_hot. new is the
Series methods of the same encoders. The sequences are random uppercase
ACGTN. In the List cases, their lengths vary by up to 3 %. All three
give the same values.
64 x 196,608 bases 10,000 x 1 kb
old ref new old ref new
Array tokens 24.0 7.8 6.0 18.7 4.4 4.3
Array one-hot uint8 30.3 26.5 30.5 20.7 19.2 20.7
Array one-hot float32 43.1 37.5 39.7 29.5 27.0 30.0
List tokens 24.4 5.4 8.5 43.8 28.4 4.3
List one-hot uint8 46.2 38.0 25.8 75.6 73.0 21.4
List one-hot float32 98.1 82.3 37.2 112 109 30.4
The Array one-hot cases spend most of their time in the take that
writes the output array. The times of 64 x 196,608 bases swing by up
to 3 ms between processes. Interleaved in one process, ref and new
take the same time for these tokens: 9.7 and 9.2 ms as Array columns,
6.1 and 6.2 ms as List columns. A batch of 64 x 1 kb takes 43 instead
of 30 us, which is the fixed cost of to_arrow and of the checks.
iter_variant_seqs gives 57,000 pairs/s with batch_size=64 and 63,000
pairs/s with batch_size=256 for 20,000 pairs with 1 kb windows. This
is the same as at b872c8b and d7254f8 within the noise of 10 %.
Hoeze
force-pushed
the
feat/variant-seq-batches
branch
from
October 17, 2026 17:19
7691989 to
be8d720
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
tl;dr:
iter_variant_seqsapplies the variants of the pairs ofscan_variant_pairsto the reference, and yields polars DataFrame batches with the reference and alternative sequences, encoded as uint8 tokens or one-hot arrays.Tokenizer.tokenize_seriesandEncoder.one_hot_seriesencode all sequences of a polars Series at once, from its Arrow buffers.scan_vcf_genotypes(path, haplotypes=True)gives one row per haplotype, so the batches can hold the sequence of each haplotype.VariantSeqExtractor.extractraises a ValueError for overlapping variants.iter_variant_seqs(pairs, reference, *, use_strand, anchor="center", group_by=("interval_idx", "variant_idx"), encoding=Tokenizer(), pad_with_n=False, batch_size=64)is in the new modulekipoiseq2.extractors.batches. It takes the pairs as a polars DataFrame or LazyFrame. The pairs with equal values in thegroup_bycolumns form a group, and each group gives one row:VariantSeqExtractor.extractapplies the variants of the group to its interval. The defaultgroup_bygives one row per pair, e.g. for variant effect predictions, andgroup_by=["interval_idx"]applies all variants of an interval together. A group with several intervals raises a ValueError that suggests addinginterval_idxtogroup_by. An interval without variants has no pairs, so it gives no row.A batch has the columns of
group_by, then chrom, start, end and strand unlessgroup_bycontains them, then ref_seq, alt_seq, ref and alt. The rows are sorted by the columns ofgroup_by.encodingis aTokenizer, anEncoderor None. ATokenizergives the tokens ofTokenizer.tokenize_series, e.g.pl.Array(pl.UInt8, length)for the defaultTokenizer(). AnEncodergives the one-hot arrays ofEncoder.one_hot_seriesin the dtype of the encoder, e.g.pl.Array(pl.Float32, (length, 4))forEncoder(ambiguous="fractional", dtype=np.float32).encoding=Nonegives only the sequences. The encoded columns are Array columns ifanchoris not None and all intervals of the pairs have the same width. Thenbatch["alt"].to_numpy()returns an array of shape(rows, length)or(rows, length, n_columns), without a copy unless the dtype is bool. Otherwise they are List columns, also in a batch whose sequences have the same length by chance. So all batches share one schema.anchorpicks theanchor_posofextractby the rule ofInterval.resize. Withuse_strand=True,"start"is the 5' end, which isendon "-" intervals.anchor=Nonelets indels change the length of alt_seq.iter_variant_seqscollects the pairs and checks the arguments, the columns, the nulls and the groups when it is called. Each pair applies its ALT allele, so a False or null in the column variant_carrier raises a ValueError. Such pairs come fromscan_vcf_genotypes(carriers_only=False), and the error suggests the defaultcarriers_only=True. The sequences of a batch are built when the iterator reaches it. During the iteration, the errors ofextractand of the encoding get a note that names the group, e.g. for overlapping variants, a REF that differs from the reference, or a letter thatencodinglacks.Within a batch, the reference of each interval is fetched once, as a window that extends beyond each end of the interval by the largest sum of REF lengths among its groups.
extractand ref_seq cut their sequences from this window. The batch is built from Series without a polars query._check_int, whichIntervalandVariantcall, tests for a plain int first, which skips the slowisinstancecheck againstnumbers.Integralfor most values. On 20,000 pairs with 1 kb windows (80% SNVs, 10% deletions, 10% insertions) and aFastaStringExtractoron a 5 Mb FASTA,iter_variant_seqsgives 57,000 pairs/s withbatch_size=64and 63,000 pairs/s withbatch_size=256. A plain loop that callsextract, fetches the reference and tokenizes both sequences withTokenizer.tokenizegives 70,000 pairs/s, but it builds no DataFrames. A loop that only builds the Interval and Variant objects and callsextractgives 106,000 pairs/s, which is about half of the time per pair ofiter_variant_seqs.Tokenizer.tokenize_series(seqs)andEncoder.one_hot_series(seqs)take a polars Series of strings, and the result keeps its name. If all sequences have the same length, they return an Array column:pl.Array(dtype, length)for tokens, orpl.Array(dtype, (length, n_columns))for one-hot arrays. Otherwise they return a List column:pl.List(dtype)orpl.List(pl.Array(dtype, n_columns)). An empty Series gives an empty List column, because it has no length for an Array column. For letters of single ASCII characters, the methods read the offsets and the data buffer of the Arrow array ofseqs.seqs.to_arrow(compat_level=pl.CompatLevel.oldest())gives this array as one large_string array, also for a Series of several chunks. Onebytes.translatecall converts the data buffer, and a List column is built from the offsets and the values without a copy. Other alphabets, e.g. codons, useTokenizer.tokenizeorEncoder.one_hotfor each sequence. A Series that is not of strings raises a TypeError, and nulls raise a ValueError. For the first sequence with an unknown letter, the methods raise the ValueError ofTokenizer.tokenizeorEncoder.one_hot, with a note that names the row. Therangesextra lists pyarrow, because the methods import it. polars-bio installed it already.The table compares the time to encode one batch into one Series, in ms. The two "joined" columns join the sequences of a column, encode them in one call and split the result, as an earlier version of this PR did. "old API" encodes with the functions that
TokenizerandEncoderreplace, and "new API" withTokenizer.tokenizeandEncoder.one_hot. one-hot uint8 isEncoder(), and one-hot float32 isEncoder(ambiguous="fractional", dtype=np.float32). The sequences are random uppercase ACGTN. In the List rows, their lengths vary by up to 3 %. Each time is the median per call, as the mean of two rounds, and all three give the same values.The Array one-hot rows spend most of their time in the take that writes the output array. The times of 64 x 196,608 bases swing by up to 3 ms between processes. Interleaved in one process, the joined route and the Series methods take the same time for these tokens. A batch of 64 x 1 kb takes 43 instead of 30 us, which is the fixed cost of
to_arrowand of the checks. The pairs/s ofiter_variant_seqsstay the same within the noise of 10 %.scan_vcf_genotypes(path, haplotypes=True)gives one row per ALT allele, sample and haplotype. The new column haplotype is the position of the allele in GT, e.g. 0 or 1 for a diploid GT. carrier then tells whether this haplotype carries the ALT allele, andcarriers_onlykeeps only the haplotypes that carry it. A missing GT gives one row with a null haplotype. haplotype ignores the phasing, asbcftools consensus -Hdoes: for an unphased GT such as 0/1, the order of the alleles in GT sets the haplotype. Unphased GTs raise no error: the frame is lazy, so an error would only appear at collect time as a polars error, and a homozygous unphased GT such as 1/1 is unambiguous. GT stays in the frame, so~pl.col("GT").str.contains("/")keeps the phased and haploid GTs. Passed as the variants ofscan_variant_pairs, the genotypes give the pair columns variant_sample and variant_haplotype. Sogroup_by=["interval_idx", "variant_sample", "variant_haplotype"]gives the sequence of each haplotype that carries variants in the interval. The defaulthaplotypes=Falsekeeps the rows as before.VariantSeqExtractor.extractraises a ValueError if the REF of two variants share a base, e.g. for two ALT alleles of one VCF record. Before, it applied both: two SNVs at one base gave two bases, in the order of the variants. GenomeKit rejects overlapping variants too. Variants that only touch, such as an SNV next to a deletion, still apply together.iter_variant_seqsshares this rule, because it callsextract. The commit is afix, because the old output was wrong. MIGRATING.md lists the change under the breaking changes of 0.2, so the footerBREAKING CHANGEdescribes it.README.md gets a section "Batches of reference and alternative sequences", with a 1 kb window per variant and the haplotypes of genes as examples, and a paragraph on
haplotypes=True. A variant within 500 bases of a chromosome end gets a window that runs past the chromosome, so the window example passespad_with_n=True. The README section "Encoding sequences" shows the Series methods. In MIGRATING.md,iter_variant_seqsreplacesSingleVariantVCFSeqExtractorandSingleSeqVCFSeqExtractor. For theirsample_id, it groups the rows ofscan_vcf_genotypesby sample or by haplotype. The API page "Extractors" listskipoiseq2.extractors.batches.BREAKING CHANGE:
VariantSeqExtractor.extractraises a ValueError for two variants whose REF share a base, e.g. two ALT alleles of one VCF record. In 0.1, it applied both. Apply such variants in separate calls, e.g. with the defaultgroup_byofiter_variant_seqs, which gives one row per pair.fix(extractors): reject overlapping variants in VariantSeqExtractor
feat(vcf): give one row per haplotype in scan_vcf_genotypes
feat(encoders): encode polars Series from their Arrow buffers