Skill Tracer
Skills › Scientific Agent Skills › polars-bio › interval_operations.md

interval_operations.md

151 lines · skills/polars-bio/references/interval_operations.md at 5f2bc03

Open on GitHub
1# Genomic interval operations (polars-bio 0.36.0)
2
3Use the [current API](https://biodatageeks.org/polars-bio/api/operations/) alongside
4[release source](https://github.com/biodatageeks/polars-bio/blob/0.36.0/polars_bio/range_op.py).
5Signatures alone are insufficient: `on_cols` is present but rejected. The examples
6below use small synthetic half-open intervals and were executed on Polars 1.44.2.
7

Genomic interval operations (polars-bio 0.36.0)

Use the current API alongside release source. Signatures alone are insufficient: on_cols is present but rejected. The examples below use small synthetic half-open intervals and were executed on Polars 1.44.2.

Raw lines 1 to 7
8## Prepare inputs
9
10All eight operations accept Polars DataFrames/LazyFrames; supported registered
11tables or file paths can also be supplied. Prefer explicit reader results when
12format options or coordinate conventions matter. Default column names are
13`chrom`, `start`, `end`; use `cols1`/`cols2` for two inputs, `cols` for one input,
14and `view_cols` for complement bounds.
16```python
17import polars as pl
18import polars_bio as pb
20pb.set_option("datafusion.bio.coordinate_system_zero_based", True)
21pb.set_option("datafusion.bio.coordinate_system_check", True)
22a = pl.DataFrame({"chrom": ["chr1", "chr1", "chr2"],
23 "start": [0, 10, 0], "end": [10, 20, 10]})
24b = pl.DataFrame({"chrom": ["chr1", "chr1"],
25 "start": [5, 8], "end": [12, 15]})
26for frame in (a, b):
27 frame.config_meta.set(coordinate_system_zero_based=True)
28```
30Validate assembly and contig aliases independently of coordinate metadata.
31Require integer, non-null bounds, positive-length intervals, and finite genome
32limits. Keep a row ID before a join if duplicate records are meaningful. The
33COITrees backend uses signed Int32 interval positions even when the output
34retains Int64 columns; check upper bounds before indexing.

Prepare inputs

All eight operations accept Polars DataFrames/LazyFrames; supported registered tables or file paths can also be supplied. Prefer explicit reader results when format options or coordinate conventions matter. Default column names are chrom, start, end; use cols1/cols2 for two inputs, cols for one input, and view_cols for complement bounds.

import polars as pl
import polars_bio as pb

pb.set_option("datafusion.bio.coordinate_system_zero_based", True)
pb.set_option("datafusion.bio.coordinate_system_check", True)
a = pl.DataFrame({"chrom": ["chr1", "chr1", "chr2"],
                  "start": [0, 10, 0], "end": [10, 20, 10]})
b = pl.DataFrame({"chrom": ["chr1", "chr1"],
                  "start": [5, 8], "end": [12, 15]})
for frame in (a, b):
    frame.config_meta.set(coordinate_system_zero_based=True)

Validate assembly and contig aliases independently of coordinate metadata. Require integer, non-null bounds, positive-length intervals, and finite genome limits. Keep a row ID before a join if duplicate records are meaningful. The COITrees backend uses signed Int32 interval positions even when the output retains Int64 columns; check upper bounds before indexing.

Raw lines 8 to 35
36## Overlap, hits, counts and covered bases
38```python
39pairs = pb.overlap(a, b, suffixes=("_query", "_target")).collect()
40assert pairs.height == 4
42hits = pb.overlap(a, b, overlap_output="left", distinct_output=True).collect()
43assert hits.height == 2
45counts = pb.count_overlaps(a, b).collect().sort("chrom", "start")
46assert counts["count"].to_list() == [2, 2, 0]
48covered = pb.coverage(a, b).collect().sort("chrom", "start")
49assert covered["coverage"].to_list() == [5, 5, 0]
50```
52`overlap` defaults to an inner pair join, not a left join. Default suffixes are
53`("_1", "_2")`. `overlap_output="left"` retains original left names; without
54`distinct_output=True` the left row repeats per match. Distinct output operates
55on source row identity, not unique coordinate values.
57`count_overlaps` returns original query fields plus Int64 `count` (default
58suffixes `("", "_")`). Default `naive_query=True` uses the native path and
59internally exchanges operands, so a general build/probe memory rule does not
60apply unchanged. Keep this tested default unless separately checking the
61alternative strategy's schema, metadata and boundary behavior.
63`coverage` returns original query fields plus Int64 `coverage`: union length of
64intersections with targets. Overlapping targets do not double count a base.
65For the example, two targets hit each chr1 query but only five bases per query
66are covered. Divide by `end-start` for a half-open coverage fraction, or by
67`end-start+1` for closed intervals. Read depth is a different measurement.

Overlap, hits, counts and covered bases

pairs = pb.overlap(a, b, suffixes=("_query", "_target")).collect()
assert pairs.height == 4

hits = pb.overlap(a, b, overlap_output="left", distinct_output=True).collect()
assert hits.height == 2

counts = pb.count_overlaps(a, b).collect().sort("chrom", "start")
assert counts["count"].to_list() == [2, 2, 0]

covered = pb.coverage(a, b).collect().sort("chrom", "start")
assert covered["coverage"].to_list() == [5, 5, 0]

overlap defaults to an inner pair join, not a left join. Default suffixes are ("_1", "_2"). overlap_output="left" retains original left names; without distinct_output=True the left row repeats per match. Distinct output operates on source row identity, not unique coordinate values.

count_overlaps returns original query fields plus Int64 count (default suffixes ("", "_")). Default naive_query=True uses the native path and internally exchanges operands, so a general build/probe memory rule does not apply unchanged. Keep this tested default unless separately checking the alternative strategy's schema, metadata and boundary behavior.

coverage returns original query fields plus Int64 coverage: union length of intersections with targets. Overlapping targets do not double count a base. For the example, two targets hit each chr1 query but only five bases per query are covered. Divide by end-start for a half-open coverage fraction, or by end-start+1 for closed intervals. Read depth is a different measurement.

Raw lines 36 to 68
69## Nearest
71```python
72nearest = pb.nearest(a, b, k=3).collect()
73nonoverlapping = pb.nearest(a, b, overlap=False).collect()
74without_distance = pb.nearest(a, b, distance=False).collect()
75```
77The defaults are `k=1`, `overlap=True`, `distance=True`, suffixes `("_1", "_2")`.
78`k=3` is implemented in 0.36.0. A query with no target on its contig retains a
79row whose target fields and distance are null. Requesting k larger than the
80candidate set does not fabricate k real neighbors.
82Distance counts intervening bases: `[0,10)` to `[10,20)` has distance 0;
83`[0,10)` to `[12,20)` has distance 2. Closed `[1,10]` to `[11,20]` also has
84zero intervening bases. Thus `distance == 0` is not an overlap test. Recompute
85intersection using the proper inequality if that distinction matters.
86Tie order is not a biological ranking: for reproducible annotation define an
87explicit tie policy and test it. A self-nearest query can select the same row;
88there is no documented `exclude_self` or strand-direction parameter here.

Nearest

nearest = pb.nearest(a, b, k=3).collect()
nonoverlapping = pb.nearest(a, b, overlap=False).collect()
without_distance = pb.nearest(a, b, distance=False).collect()

The defaults are k=1, overlap=True, distance=True, suffixes ("_1", "_2"). k=3 is implemented in 0.36.0. A query with no target on its contig retains a row whose target fields and distance are null. Requesting k larger than the candidate set does not fabricate k real neighbors.

Distance counts intervening bases: [0,10) to [10,20) has distance 0; [0,10) to [12,20) has distance 2. Closed [1,10] to [11,20] also has zero intervening bases. Thus distance == 0 is not an overlap test. Recompute intersection using the proper inequality if that distinction matters. Tie order is not a biological ranking: for reproducible annotation define an explicit tie policy and test it. A self-nearest query can select the same row; there is no documented exclude_self or strand-direction parameter here.

Raw lines 69 to 89
90## Merge and cluster
92```python
93separate = pb.merge(a).collect() # 3 rows: bookends stay separate
94joined = pb.merge(a, min_dist=1).collect() # chr1 becomes [0,20)
95clusters = pb.cluster(a, min_dist=1).collect()
96assert separate.height == 3
97assert joined.height == 2
98assert "n_intervals" in joined.columns
99assert {"cluster", "cluster_start", "cluster_end"} <= set(clusters.columns)
100```
102In 0.36.0, `min_dist=0` merges overlapping intervals; it does not join adjacent
103half-open bookends. For integer half-open bounds, `min_dist=1` joins bookends.
104The effective gap threshold is strict in half-open mode: verify a gap equal to
105and just below the chosen threshold. Closed-coordinate boundary comparisons
106also depend on the coordinate metadata; do not transplant bioframe thresholds.
108`merge` discards additional annotations and returns bounds plus `n_intervals`.
109`cluster` retains rows and adds cluster labels and bounds; labels are execution
110identifiers, not stable biological IDs. It has no `on_cols` parameter.

Merge and cluster

separate = pb.merge(a).collect()                # 3 rows: bookends stay separate
joined = pb.merge(a, min_dist=1).collect()       # chr1 becomes [0,20)
clusters = pb.cluster(a, min_dist=1).collect()
assert separate.height == 3
assert joined.height == 2
assert "n_intervals" in joined.columns
assert {"cluster", "cluster_start", "cluster_end"} <= set(clusters.columns)

In 0.36.0, min_dist=0 merges overlapping intervals; it does not join adjacent half-open bookends. For integer half-open bounds, min_dist=1 joins bookends. The effective gap threshold is strict in half-open mode: verify a gap equal to and just below the chosen threshold. Closed-coordinate boundary comparisons also depend on the coordinate metadata; do not transplant bioframe thresholds.

merge discards additional annotations and returns bounds plus n_intervals. cluster retains rows and adds cluster labels and bounds; labels are execution identifiers, not stable biological IDs. It has no on_cols parameter.

Raw lines 90 to 111
112## Complement and subtract
114```python
115genome = pl.DataFrame({"chrom": ["chr1", "chr2"],
116 "start": [0, 0], "end": [20, 10]})
117genome.config_meta.set(coordinate_system_zero_based=True)
118gaps = pb.complement(b, view_df=genome).collect().sort("chrom", "start")
119fragments = pb.subtract(a, b).collect().sort("chrom", "start")
120assert fragments.select("chrom", "start", "end").rows() == [
121 ("chr1", 0, 5), ("chr1", 15, 20), ("chr2", 0, 10)]
122assert gaps.rows() == fragments.rows()
123```
125Give complement finite, valid, nonoverlapping assembly view bounds; check the
126view's coordinates yourself rather than relying on two-input metadata validation.
127Without a view the implementation extends contigs toward Int64 maximum, which
128is not a genome definition. Subtraction can split one source interval into
129several coordinate fragments and does not carry source annotations: rejoin to
130explicit IDs if provenance matters, accounting for duplicate/overlapping sources.

Complement and subtract

genome = pl.DataFrame({"chrom": ["chr1", "chr2"],
                       "start": [0, 0], "end": [20, 10]})
genome.config_meta.set(coordinate_system_zero_based=True)
gaps = pb.complement(b, view_df=genome).collect().sort("chrom", "start")
fragments = pb.subtract(a, b).collect().sort("chrom", "start")
assert fragments.select("chrom", "start", "end").rows() == [
    ("chr1", 0, 5), ("chr1", 15, 20), ("chr2", 0, 10)]
assert gaps.rows() == fragments.rows()

Give complement finite, valid, nonoverlapping assembly view bounds; check the view's coordinates yourself rather than relying on two-input metadata validation. Without a view the implementation extends contigs toward Int64 maximum, which is not a genome definition. Subtraction can split one source interval into several coordinate fragments and does not carry source annotations: rejoin to explicit IDs if provenance matters, accounting for duplicate/overlapping sources.

Raw lines 112 to 131
132## Grouping, output and execution
134Non-None `on_cols` currently raises `AssertionError` for overlap, nearest,
135coverage, count_overlaps and merge. For same-strand or within-sample operations,
136partition both inputs by the grouping values, operate only on corresponding
137partitions, and restore those keys in the output. Filtering an unstranded count
138or nearest result after computation does not fix its semantics.
140Most calls return LazyFrames; eager Polars, pandas (optional extra), and
141`datafusion.DataFrame` output are supported by range operations. Avoid assuming
142this `output_type` set applies to `depth` or `sql`. Interval method chaining is
143`a.lazy().pb.overlap(b)`, not `a.pb.overlap(b)`. Projection after pair joins must
144use suffixed names before feeding a new single-interval operation.
146Output batch streaming does not mean constant memory: many operations retain
147an index, sort or aggregate input, and `.collect()` materializes the final
148result. Do not swap arguments solely to optimize memory when query semantics
149must stay fixed. Benchmark a representative subset and use sinks for large
150outputs. Parallel partitioning can alter output order; sort explicitly for
151comparisons and reproducible exports.

Grouping, output and execution

Non-None on_cols currently raises AssertionError for overlap, nearest, coverage, count_overlaps and merge. For same-strand or within-sample operations, partition both inputs by the grouping values, operate only on corresponding partitions, and restore those keys in the output. Filtering an unstranded count or nearest result after computation does not fix its semantics.

Most calls return LazyFrames; eager Polars, pandas (optional extra), and datafusion.DataFrame output are supported by range operations. Avoid assuming this output_type set applies to depth or sql. Interval method chaining is a.lazy().pb.overlap(b), not a.pb.overlap(b). Projection after pair joins must use suffixed names before feeding a new single-interval operation.

Output batch streaming does not mean constant memory: many operations retain an index, sort or aggregate input, and .collect() materializes the final result. Do not swap arguments solely to optimize memory when query semantics must stay fixed. Benchmark a representative subset and use sinks for large outputs. Parallel partitioning can alter output order; sort explicitly for comparisons and reproducible exports.

Raw lines 132 to 151