agglovar 0.0.1.dev17__tar.gz → 0.0.1.dev18__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agglovar-0.0.1.dev17/src/agglovar.egg-info → agglovar-0.0.1.dev18}/PKG-INFO +2 -2
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/pyproject.toml +15 -6
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/__init__.py +1 -1
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/bed/intersect.py +18 -1
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/bed/join.py +72 -10
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/bed/merge.py +9 -2
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/expr/variant.py +38 -1
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/cumulative.py +61 -9
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/base.py +28 -1
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/overlap/__init__.py +36 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/overlap/_const.py +11 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/overlap/_overlap.py +397 -26
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/vcf/_vcf_read.py +52 -21
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18/src/agglovar.egg-info}/PKG-INFO +2 -2
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar.egg-info/requires.txt +1 -1
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/LICENSE +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/README.md +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/setup.cfg +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/align/__init__.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/align/op.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/align/score.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/bed/__init__.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/bed/col.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/dev/__init__.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/dev/imports.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/expr/__init__.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/fa.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/io.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/kmer/__init__.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/kmer/plot.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/kmer/util.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/__init__.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/base.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/config/__init__.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/config/parser.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/config/stage.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/config/strategy.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/om/__init__.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/om/_executor_base.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/om/_executor_nr.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/meta/__init__.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/meta/decorators.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/meta/descriptors.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/__init__.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/overlap/_stage.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/weights.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/schema.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/seqmatch.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/util/__init__.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/util/lazy.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/util/str.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/util/var.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/vcf/__init__.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/vcf/_vcf_const.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/vcf/_vcf_header.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/vcf/_vcf_write.py +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar.egg-info/SOURCES.txt +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar.egg-info/dependency_links.txt +0 -0
- {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agglovar
|
|
3
|
-
Version: 0.0.1.
|
|
3
|
+
Version: 0.0.1.dev18
|
|
4
4
|
Summary: Toolkit for fast genomic variant transformations and intersects
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.12
|
|
@@ -10,7 +10,7 @@ Requires-Dist: biopython>=1.85
|
|
|
10
10
|
Requires-Dist: edlib>=1.3.9.post1
|
|
11
11
|
Requires-Dist: numpy>=2.3.0
|
|
12
12
|
Requires-Dist: ply>=3.11
|
|
13
|
-
Requires-Dist: polars[numpy,pandas,pyarrow]
|
|
13
|
+
Requires-Dist: polars[numpy,pandas,pyarrow]==1.42.1
|
|
14
14
|
Requires-Dist: pysam>=0.23.3
|
|
15
15
|
Provides-Extra: plot
|
|
16
16
|
Requires-Dist: matplotlib>=3.10.3; extra == "plot"
|
|
@@ -15,7 +15,9 @@ dependencies = [
|
|
|
15
15
|
"edlib>=1.3.9.post1",
|
|
16
16
|
"numpy>=2.3.0",
|
|
17
17
|
"ply>=3.11",
|
|
18
|
-
|
|
18
|
+
# 1.41.2: merge_sorted() crashes on List(Struct) columns before this (used by the cumulative
|
|
19
|
+
# merge to carry the complex-variant "seg" column).
|
|
20
|
+
"polars[numpy,pandas,pyarrow]==1.42.1",
|
|
19
21
|
"pysam>=0.23.3",
|
|
20
22
|
]
|
|
21
23
|
|
|
@@ -52,11 +54,10 @@ maintainers = [
|
|
|
52
54
|
|
|
53
55
|
[dependency-groups]
|
|
54
56
|
dev = [
|
|
55
|
-
"arrow[parquet]>=1.4.0",
|
|
56
57
|
"flake8>=7.3.0",
|
|
57
58
|
"flake8-bugbear>=24.12.12",
|
|
58
59
|
"flake8-docstrings>=1.7.0",
|
|
59
|
-
"flake8-dunder-all>=0.
|
|
60
|
+
"flake8-dunder-all>=0.6.0",
|
|
60
61
|
"flake8-mutable>=1.2.0",
|
|
61
62
|
"flake8-pyproject>=1.2.3",
|
|
62
63
|
"flake8-rst-docstrings>=0.3.1",
|
|
@@ -107,9 +108,17 @@ max-line-length = 120
|
|
|
107
108
|
[tool.flake8]
|
|
108
109
|
max-line-length = 120
|
|
109
110
|
docstring-convention = "pep257"
|
|
111
|
+
# DALL100/DALL101 (flake8-dunder-all >= 0.6.0) require a top-level PEP 562 __dir__ in every module.
|
|
112
|
+
# This package has no module-level __getattr__ to justify one, and a __dir__ returning only __all__
|
|
113
|
+
# would break agglovar.dev.imports.librl, which walks dir(module) to build its reload graph.
|
|
114
|
+
#
|
|
115
|
+
# These cannot go in extend-ignore: flake8 validates those codes against '^[A-Z]{1,3}[0-9]{0,3}$'
|
|
116
|
+
# and the 4-letter DALL prefix fails. per-file-ignores skips that check, but selects the single
|
|
117
|
+
# longest matching pattern rather than unioning them, so the codes must be repeated in every entry.
|
|
110
118
|
per-file-ignores = [
|
|
111
|
-
"
|
|
112
|
-
"src/agglovar/
|
|
113
|
-
"src/agglovar/
|
|
119
|
+
"*: DALL100, DALL101",
|
|
120
|
+
"src/agglovar/merge/config/parser.py: DALL100, DALL101, D200, D205, D212, D300, D400, D401, D403, D415",
|
|
121
|
+
"src/agglovar/vcf/__init__.py: DALL100, DALL101, DALL002, F401, F403",
|
|
122
|
+
"src/agglovar/pairwise/overlap/__init__.py: DALL100, DALL101, DALL002, F401, F403",
|
|
114
123
|
]
|
|
115
124
|
rst-roles = ["class", "meth", "const", "ref", "func", "mod", "attr", "data"]
|
|
@@ -14,6 +14,7 @@ from .col import (
|
|
|
14
14
|
get_coord_cols,
|
|
15
15
|
)
|
|
16
16
|
from .join import (
|
|
17
|
+
CHUNK_SIZE,
|
|
17
18
|
pairwise_join,
|
|
18
19
|
pairwise_join_iter,
|
|
19
20
|
)
|
|
@@ -36,6 +37,7 @@ def as_bool(
|
|
|
36
37
|
col_names_a: Optional[CoordCol | Iterable[str] | str] = None,
|
|
37
38
|
col_names_b: Optional[CoordCol | Iterable[str] | str] = None,
|
|
38
39
|
temp_dir: bool | str | Path = False,
|
|
40
|
+
chunk_size: int = CHUNK_SIZE,
|
|
39
41
|
) -> pl.LazyFrame:
|
|
40
42
|
"""Add a boolean column to df_a indicating whether each record intersects with df_b.
|
|
41
43
|
|
|
@@ -48,6 +50,8 @@ def as_bool(
|
|
|
48
50
|
:param col_names_b: Columns in b (chromosome or query ID, pos, end).
|
|
49
51
|
:param temp_dir: How to materialise the prepared tables before iterating. See
|
|
50
52
|
:func:`agglovar.bed.join.pairwise_join`.
|
|
53
|
+
:param chunk_size: Chunk A by this size per chromosome. See
|
|
54
|
+
:func:`agglovar.bed.join.pairwise_join`.
|
|
51
55
|
|
|
52
56
|
:return: A LazyFrame with two columns: ``_index`` and ``name``.
|
|
53
57
|
"""
|
|
@@ -65,12 +69,15 @@ def as_bool(
|
|
|
65
69
|
|
|
66
70
|
hit_val = not negate
|
|
67
71
|
|
|
72
|
+
logger.debug('as_bool: name=%r distance=%d negate=%s' % (name, distance, negate))
|
|
73
|
+
|
|
68
74
|
join_list = []
|
|
69
75
|
|
|
70
76
|
for df_join in pairwise_join_iter(
|
|
71
77
|
df_a=df_a,
|
|
72
78
|
df_b=df_b,
|
|
73
79
|
distance=distance,
|
|
80
|
+
chunk_size=chunk_size,
|
|
74
81
|
col_names_a=col_names_a,
|
|
75
82
|
col_names_b=col_names_b,
|
|
76
83
|
temp_dir=temp_dir,
|
|
@@ -82,6 +89,8 @@ def as_bool(
|
|
|
82
89
|
.lazy()
|
|
83
90
|
)
|
|
84
91
|
|
|
92
|
+
logger.debug('as_bool: collected %d non-empty join chunk(s)' % len(join_list))
|
|
93
|
+
|
|
85
94
|
if '_index' in df_a.collect_schema().names():
|
|
86
95
|
df_a_indexed = df_a
|
|
87
96
|
else:
|
|
@@ -119,6 +128,7 @@ def as_proportion(
|
|
|
119
128
|
col_names_a: Optional[CoordCol | Iterable[str] | str] = None,
|
|
120
129
|
col_names_b: Optional[CoordCol | Iterable[str] | str] = None,
|
|
121
130
|
temp_dir: bool | str | Path = False,
|
|
131
|
+
chunk_size: int = CHUNK_SIZE,
|
|
122
132
|
) -> pl.LazyFrame:
|
|
123
133
|
"""Compute the proportion of each interval in ``df_a`` covered by intervals in ``df_b``.
|
|
124
134
|
|
|
@@ -126,12 +136,14 @@ def as_proportion(
|
|
|
126
136
|
proportion. Zero-length intervals (``pos == end``) produce ``NaN`` (0 / 0).
|
|
127
137
|
|
|
128
138
|
:param df_a: Table a.
|
|
129
|
-
:param df_b: Table b
|
|
139
|
+
:param df_b: Table b.`
|
|
130
140
|
:param name: Name of the column to add.
|
|
131
141
|
:param col_names_a: Columns in a (chromosome or query ID, pos, end).
|
|
132
142
|
:param col_names_b: Columns in b (chromosome or query ID, pos, end).
|
|
133
143
|
:param temp_dir: How to materialise the prepared tables before iterating. See
|
|
134
144
|
:func:`agglovar.bed.join.pairwise_join`.
|
|
145
|
+
:param chunk_size: Chunk A by this size per chromosome. See
|
|
146
|
+
:func:`agglovar.bed.join.pairwise_join`.
|
|
135
147
|
|
|
136
148
|
:return: A LazyFrame with two columns: ``_index`` and ``name``.
|
|
137
149
|
"""
|
|
@@ -143,6 +155,8 @@ def as_proportion(
|
|
|
143
155
|
|
|
144
156
|
col_expr_a = col_names_a.exprs()
|
|
145
157
|
|
|
158
|
+
logger.debug('as_proportion: name=%r' % name)
|
|
159
|
+
|
|
146
160
|
if isinstance(df_a, pl.DataFrame):
|
|
147
161
|
df_a = df_a.lazy()
|
|
148
162
|
|
|
@@ -165,9 +179,12 @@ def as_proportion(
|
|
|
165
179
|
.collect()
|
|
166
180
|
)
|
|
167
181
|
|
|
182
|
+
logger.debug('as_proportion: %d non-redundant B region(s) after merge_depth' % df_b_nr.height)
|
|
183
|
+
|
|
168
184
|
df_join = pairwise_join(
|
|
169
185
|
df_a=df_a_clean,
|
|
170
186
|
df_b=df_b_nr,
|
|
187
|
+
chunk_size=chunk_size,
|
|
171
188
|
col_names_a=col_names_a,
|
|
172
189
|
col_names_b=col_names_b,
|
|
173
190
|
temp_dir=temp_dir,
|
|
@@ -7,6 +7,7 @@ __all__ = [
|
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
9
|
from pathlib import Path
|
|
10
|
+
import time
|
|
10
11
|
from typing import (
|
|
11
12
|
Iterable,
|
|
12
13
|
Iterator,
|
|
@@ -24,8 +25,14 @@ from .col import (
|
|
|
24
25
|
|
|
25
26
|
logger = logging.getLogger(__name__)
|
|
26
27
|
|
|
27
|
-
CHUNK_SIZE: int =
|
|
28
|
-
"""
|
|
28
|
+
CHUNK_SIZE: int = 25_000
|
|
29
|
+
"""
|
|
30
|
+
Default number of df_a records per chunk. Table A is processed one chromosome at a time and split
|
|
31
|
+
into chunks of this size or less; each chunk drives one ``join_where`` (IEJoin) against the B
|
|
32
|
+
records overlapping the chunk's position span. Larger chunks amortize the fixed per-chunk overhead
|
|
33
|
+
(query setup and the per-chunk B pre-filter) over more rows, but widen each chunk's span so more B
|
|
34
|
+
records enter each join. Tune based on available RAM.
|
|
35
|
+
"""
|
|
29
36
|
|
|
30
37
|
|
|
31
38
|
class _JoinResources:
|
|
@@ -194,6 +201,11 @@ def pairwise_join_iter(
|
|
|
194
201
|
col_names_b=col_names_b,
|
|
195
202
|
)
|
|
196
203
|
|
|
204
|
+
logger.debug(
|
|
205
|
+
'Starting bed join: distance=%d chunk_size=%d temp_dir=%r cols_a=%s cols_b=%s'
|
|
206
|
+
% (distance, chunk_size, temp_dir, tuple(resources.col_a), tuple(resources.col_b))
|
|
207
|
+
)
|
|
208
|
+
|
|
197
209
|
with materialize_pair(
|
|
198
210
|
resources.df_a, resources.df_b, temp_dir, prefix='bed_join_prep_',
|
|
199
211
|
) as (df_a_mat, df_b_mat):
|
|
@@ -273,6 +285,10 @@ def _join_chunks(
|
|
|
273
285
|
col_a = join_resources.col_a
|
|
274
286
|
col_b = join_resources.col_b
|
|
275
287
|
|
|
288
|
+
# Per-chunk timing and the df_b survivor count require extra collects, so gate them on DEBUG:
|
|
289
|
+
# an INFO-level run pays nothing and never materializes the diagnostics.
|
|
290
|
+
debug = logger.isEnabledFor(logging.DEBUG)
|
|
291
|
+
|
|
276
292
|
# Restrict the chrom loop to chroms present in both tables. Chroms unique to A
|
|
277
293
|
# would otherwise drive a wasted A chunk loop with no possible matches.
|
|
278
294
|
for chrom, last_index_a in (
|
|
@@ -286,11 +302,30 @@ def _join_chunks(
|
|
|
286
302
|
)
|
|
287
303
|
.sort(col_a.chrom)
|
|
288
304
|
).collect().rows():
|
|
305
|
+
# Collect this chromosome's A and B once into memory. The chunk loop below re-filters
|
|
306
|
+
# df_a_chrom (per index range) and df_b_chrom (per prefilter bounds) many times; leaving
|
|
307
|
+
# them lazy re-runs the chrom filter over the full materialized df_a/df_b on every chunk,
|
|
308
|
+
# which dominates runtime (fixed per-chunk cost independent of the join size). Collecting
|
|
309
|
+
# per chrom makes the per-chunk filters operate on the small per-chrom frame instead.
|
|
310
|
+
# Mirrors agglovar.pairwise.overlap._overlap._join_iter_chunked.
|
|
289
311
|
df_a_chrom = (
|
|
290
312
|
df_a.filter(pl.col(col_a.chrom) == chrom)
|
|
291
313
|
.with_row_index('_index_chrom_a')
|
|
314
|
+
.collect()
|
|
315
|
+
.lazy()
|
|
292
316
|
)
|
|
293
|
-
df_b_chrom =
|
|
317
|
+
df_b_chrom = (
|
|
318
|
+
df_b.filter(pl.col(col_b.chrom) == chrom)
|
|
319
|
+
.collect()
|
|
320
|
+
.lazy()
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
if debug:
|
|
324
|
+
chrom_t0 = time.perf_counter()
|
|
325
|
+
chrom_chunks = 0
|
|
326
|
+
chrom_pairs = 0
|
|
327
|
+
chrom_join_s = 0.0
|
|
328
|
+
chrom_b_max = 0
|
|
294
329
|
|
|
295
330
|
start_index_a = 0
|
|
296
331
|
while start_index_a < last_index_a:
|
|
@@ -323,12 +358,39 @@ def _join_chunks(
|
|
|
323
358
|
pl.col(col_b.end) + distance >= pos_min,
|
|
324
359
|
)
|
|
325
360
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
).collect().lazy()
|
|
361
|
+
# The join itself is computed identically regardless of log level; only the diagnostics
|
|
362
|
+
# below are gated on DEBUG, so raising the log level cannot change the result.
|
|
363
|
+
if debug:
|
|
364
|
+
t0 = time.perf_counter()
|
|
365
|
+
|
|
366
|
+
df_join = _build_join_pair(df_a_chunk, df_b_chunk, distance, col_a, col_b).collect()
|
|
333
367
|
|
|
368
|
+
if debug:
|
|
369
|
+
dt = time.perf_counter() - t0
|
|
370
|
+
# n_b is the count of B records surviving the range prefilter for this chunk. When
|
|
371
|
+
# A is position-local it stays small; when it approaches the chrom's B total the
|
|
372
|
+
# prefilter is not pruning. pos_min-end_max is the chunk's position span.
|
|
373
|
+
n_b = df_b_chunk.select(pl.len()).collect().item()
|
|
374
|
+
|
|
375
|
+
logger.debug(
|
|
376
|
+
'Join chunk: chrom %s [%d:%d] pos=%s-%s n_b=%d pairs=%d in %.3fs'
|
|
377
|
+
% (chrom, start_index_a, end_index_a, pos_min, end_max,
|
|
378
|
+
n_b, df_join.height, dt)
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
chrom_chunks += 1
|
|
382
|
+
chrom_pairs += df_join.height
|
|
383
|
+
chrom_join_s += dt
|
|
384
|
+
if n_b > chrom_b_max:
|
|
385
|
+
chrom_b_max = n_b
|
|
386
|
+
|
|
387
|
+
yield df_join.lazy()
|
|
334
388
|
start_index_a = end_index_a
|
|
389
|
+
|
|
390
|
+
if debug:
|
|
391
|
+
logger.debug(
|
|
392
|
+
'Join chrom done: chrom %s n_a=%d chunks=%d n_b_max=%d pairs=%d '
|
|
393
|
+
'join=%.3fs wall=%.3fs'
|
|
394
|
+
% (chrom, last_index_a, chrom_chunks, chrom_b_max, chrom_pairs,
|
|
395
|
+
chrom_join_s, time.perf_counter() - chrom_t0)
|
|
396
|
+
)
|
|
@@ -26,7 +26,7 @@ def merge_depth(
|
|
|
26
26
|
distance: int = 0,
|
|
27
27
|
col_names: Optional[CoordCol | Iterable[str] | str] = None,
|
|
28
28
|
col_max_depth: Optional[str] = 'max_depth'
|
|
29
|
-
):
|
|
29
|
+
) -> pl.LazyFrame:
|
|
30
30
|
"""Merge overlapping regions in a table.
|
|
31
31
|
|
|
32
32
|
:param df: Table of coordinates.
|
|
@@ -48,6 +48,11 @@ def merge_depth(
|
|
|
48
48
|
if isinstance(df, pl.DataFrame):
|
|
49
49
|
df = df.lazy()
|
|
50
50
|
|
|
51
|
+
logger.debug(
|
|
52
|
+
'merge_depth: distance=%d cols=%s max_depth=%r'
|
|
53
|
+
% (distance, tuple(col_names), col_max_depth)
|
|
54
|
+
)
|
|
55
|
+
|
|
51
56
|
return (
|
|
52
57
|
melt_depth(
|
|
53
58
|
df=df.with_columns(pl.col(col_names.end) + distance),
|
|
@@ -75,7 +80,7 @@ def melt_depth(
|
|
|
75
80
|
col_names: CoordCol,
|
|
76
81
|
col_loc: str = '_loc',
|
|
77
82
|
col_depth: str = '_depth',
|
|
78
|
-
):
|
|
83
|
+
) -> pl.LazyFrame:
|
|
79
84
|
"""Get a table of depths by locations of changes.
|
|
80
85
|
|
|
81
86
|
The table will have one row for each location of change with end positions having two entries (one for the
|
|
@@ -158,6 +163,8 @@ def melt_depth(
|
|
|
158
163
|
|
|
159
164
|
assert col_loc != col_depth, 'Column names "%s" and "%s" are the same' % (col_loc, col_depth)
|
|
160
165
|
|
|
166
|
+
logger.debug('melt_depth: cols=%s loc=%s depth=%s' % (tuple(col_names), col_loc, col_depth))
|
|
167
|
+
|
|
161
168
|
return (
|
|
162
169
|
pl.concat(
|
|
163
170
|
[
|
|
@@ -6,6 +6,7 @@ __all__ = [
|
|
|
6
6
|
'sort_cols',
|
|
7
7
|
'id_expr_noalias',
|
|
8
8
|
'id_expr',
|
|
9
|
+
'id_expr_for_table',
|
|
9
10
|
'lead_src_expr',
|
|
10
11
|
]
|
|
11
12
|
|
|
@@ -14,6 +15,8 @@ from typing import Optional
|
|
|
14
15
|
|
|
15
16
|
import polars as pl
|
|
16
17
|
|
|
18
|
+
import agglovar.schema as _agg_schema
|
|
19
|
+
|
|
17
20
|
logger = logging.getLogger(__name__)
|
|
18
21
|
|
|
19
22
|
id_snv_expr = (
|
|
@@ -64,7 +67,41 @@ id_expr_noalias = (
|
|
|
64
67
|
"""Expression for generating variant IDs."""
|
|
65
68
|
|
|
66
69
|
id_expr = id_expr_noalias.alias('id')
|
|
67
|
-
"""Expression for generating variant IDs including alias "id" for the result (ideal for `with_column()`).
|
|
70
|
+
"""Expression for generating variant IDs including alias "id" for the result (ideal for `with_column()`).
|
|
71
|
+
|
|
72
|
+
Requires both the SNV columns (``ref``, ``alt``) and the non-SNV column (``varlen``) to be present:
|
|
73
|
+
Polars resolves both branches of the expression regardless of which one a row selects. Use
|
|
74
|
+
:func:`id_expr_for_table` for single-type tables, which carry only the columns listed in
|
|
75
|
+
:data:`agglovar.schema.STANDARD_FIELDS` for their type.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def id_expr_for_table(table: str, alias: Optional[str] = 'id') -> pl.Expr:
|
|
80
|
+
"""Get a variant ID expression for a single variant-type table.
|
|
81
|
+
|
|
82
|
+
Single-type tables carry only the columns listed for their type in
|
|
83
|
+
:data:`agglovar.schema.STANDARD_FIELDS`, so :data:`id_expr` cannot be applied to them (see its
|
|
84
|
+
documentation). This routine returns the one branch that matches the table's columns.
|
|
85
|
+
|
|
86
|
+
:param table: Table name from :data:`agglovar.schema.STANDARD_FIELDS` (e.g. "snv", "insdel").
|
|
87
|
+
:param alias: Output column name for the resulting IDs, or `None` to leave it unaliased.
|
|
88
|
+
|
|
89
|
+
:returns: An expression yielding variant IDs for *table*.
|
|
90
|
+
|
|
91
|
+
:raises ValueError: If *table* is not a known variant-type table.
|
|
92
|
+
"""
|
|
93
|
+
fields = _agg_schema.STANDARD_FIELDS.get(table)
|
|
94
|
+
|
|
95
|
+
if fields is None:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f'Unknown variant-type table: {table!r}: '
|
|
98
|
+
f'expected one of {", ".join(sorted(_agg_schema.STANDARD_FIELDS))}'
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Tables without "varlen" (SNV) are identified by their changed bases, all others by length
|
|
102
|
+
expr = id_nonsnv_expr if 'varlen' in fields else id_snv_expr
|
|
103
|
+
|
|
104
|
+
return expr.alias(alias) if alias is not None else expr
|
|
68
105
|
|
|
69
106
|
|
|
70
107
|
def lead_src_expr(
|
|
@@ -30,6 +30,7 @@ __all__ = [
|
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
from collections.abc import Iterable
|
|
33
|
+
from concurrent.futures import Executor
|
|
33
34
|
from enum import Enum
|
|
34
35
|
import logging
|
|
35
36
|
from pathlib import Path
|
|
@@ -176,6 +177,7 @@ class MergeCumulative(MergeBase):
|
|
|
176
177
|
sort: bool = True,
|
|
177
178
|
add_id: bool = True,
|
|
178
179
|
temp_dir: bool | str | Path = False,
|
|
180
|
+
match_threads: Optional[int] = 4,
|
|
179
181
|
) -> pl.LazyFrame:
|
|
180
182
|
"""
|
|
181
183
|
Intersect callsets.
|
|
@@ -187,6 +189,13 @@ class MergeCumulative(MergeBase):
|
|
|
187
189
|
last (after "_index" is set).
|
|
188
190
|
:param temp_dir: Forwarded to the pairwise intersect for each cumulative step. See
|
|
189
191
|
:meth:`agglovar.pairwise.base.PairwiseJoin.join_iter`.
|
|
192
|
+
:param match_threads: Number of worker processes used to score sequence matches
|
|
193
|
+
(``match_prop``) during the merge. A single ``spawn``-context process pool is created
|
|
194
|
+
once and reused across all sources. Defaults to 4. If ``None``, no pool is created and
|
|
195
|
+
match scoring runs serially (turns multiprocessing off entirely). Ignored when the
|
|
196
|
+
pairwise join does no sequence matching. In a spawn-restricted or resource-limited
|
|
197
|
+
environment the pool degrades to serial scoring with a logged warning (see
|
|
198
|
+
:meth:`agglovar.pairwise.overlap.PairwiseOverlap.make_match_pool`).
|
|
190
199
|
|
|
191
200
|
:return: A merged callset table.
|
|
192
201
|
"""
|
|
@@ -336,13 +345,62 @@ class MergeCumulative(MergeBase):
|
|
|
336
345
|
|
|
337
346
|
logger.debug('Merge source %d: Cumulative size %s' % (iter_idx, f'{df_cumulative.height:,}'))
|
|
338
347
|
|
|
348
|
+
# Create one match-scoring pool for the whole merge (spawn context, sized by match_threads;
|
|
349
|
+
# None or a restricted environment keeps scoring serial). The pool is owned here and passed
|
|
350
|
+
# into each per-source join; it is never retained on the pairwise object.
|
|
351
|
+
with self.pairwise_join.make_match_pool(match_threads) as match_pool:
|
|
352
|
+
df_cumulative, df_sources = self._merge_sources(
|
|
353
|
+
callsets_mat=callsets_mat,
|
|
354
|
+
df_cumulative=df_cumulative,
|
|
355
|
+
df_sources=df_sources,
|
|
356
|
+
next_mg_index=next_mg_index,
|
|
357
|
+
merge_stat_cols=merge_stat_cols,
|
|
358
|
+
stat_struct_dtype=stat_struct_dtype,
|
|
359
|
+
sources_schema=sources_schema,
|
|
360
|
+
pairwise_cols=pairwise_cols,
|
|
361
|
+
mg_sort_expr=mg_sort_expr,
|
|
362
|
+
temp_dir=temp_dir,
|
|
363
|
+
match_pool=match_pool,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
return self._finalize(
|
|
367
|
+
df_sources=df_sources,
|
|
368
|
+
callsets_mat=callsets_mat,
|
|
369
|
+
all_col_dict=all_col_dict,
|
|
370
|
+
sort=sort,
|
|
371
|
+
add_id=add_id,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def _merge_sources(
|
|
375
|
+
self,
|
|
376
|
+
callsets_mat: list[dict[str, Any]],
|
|
377
|
+
df_cumulative: pl.DataFrame,
|
|
378
|
+
df_sources: pl.DataFrame,
|
|
379
|
+
next_mg_index: int,
|
|
380
|
+
merge_stat_cols: dict[str, pl.DataType],
|
|
381
|
+
stat_struct_dtype: pl.DataType,
|
|
382
|
+
sources_schema: pl.Schema,
|
|
383
|
+
pairwise_cols: list[str],
|
|
384
|
+
mg_sort_expr: pl.Expr,
|
|
385
|
+
temp_dir: bool | str | Path,
|
|
386
|
+
match_pool: Optional[Executor] = None,
|
|
387
|
+
) -> tuple[pl.DataFrame, pl.DataFrame]:
|
|
388
|
+
"""Merge each remaining callset into the cumulative table, one source at a time.
|
|
389
|
+
|
|
390
|
+
The ``match_pool`` created by :meth:`__call__` is passed to each per-source pairwise join so
|
|
391
|
+
sequence matches score on the shared process pool (or serially when it is ``None``). Returns
|
|
392
|
+
the updated cumulative variant table and the long-form sources sidecar.
|
|
393
|
+
"""
|
|
339
394
|
# Iterate remaining callsets.
|
|
340
395
|
for iter_idx, callset_mat in enumerate(callsets_mat[1:], start=1):
|
|
341
396
|
logger.debug('Merge source %d: Merging "%s"' % (iter_idx, callset_mat['name']))
|
|
342
397
|
|
|
343
398
|
df_join = (
|
|
344
|
-
self.pairwise_join.join(
|
|
345
|
-
|
|
399
|
+
self.pairwise_join.join(
|
|
400
|
+
df_cumulative.lazy(), callset_mat['table'].lazy(),
|
|
401
|
+
temp_dir=temp_dir, match_pool=match_pool,
|
|
402
|
+
)
|
|
403
|
+
.sort('weight', descending=True, maintain_order=True)
|
|
346
404
|
.unique('index_a', keep='first')
|
|
347
405
|
.unique('index_b', keep='first')
|
|
348
406
|
.collect()
|
|
@@ -460,13 +518,7 @@ class MergeCumulative(MergeBase):
|
|
|
460
518
|
|
|
461
519
|
logger.debug('Merge source %d: Cumulative size %s' % (iter_idx, f'{df_cumulative.height:,}'))
|
|
462
520
|
|
|
463
|
-
return
|
|
464
|
-
df_sources=df_sources,
|
|
465
|
-
callsets_mat=callsets_mat,
|
|
466
|
-
all_col_dict=all_col_dict,
|
|
467
|
-
sort=sort,
|
|
468
|
-
add_id=add_id,
|
|
469
|
-
)
|
|
521
|
+
return df_cumulative, df_sources
|
|
470
522
|
|
|
471
523
|
def _finalize(
|
|
472
524
|
self,
|
|
@@ -15,8 +15,11 @@ from collections.abc import (
|
|
|
15
15
|
Iterator,
|
|
16
16
|
Iterable,
|
|
17
17
|
)
|
|
18
|
+
from concurrent.futures import Executor
|
|
19
|
+
from contextlib import contextmanager
|
|
18
20
|
import logging
|
|
19
21
|
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
20
23
|
|
|
21
24
|
import polars as pl
|
|
22
25
|
|
|
@@ -54,6 +57,7 @@ class PairwiseJoin(ABC):
|
|
|
54
57
|
df_b: pl.DataFrame | pl.LazyFrame,
|
|
55
58
|
retain_index: bool = False,
|
|
56
59
|
temp_dir: bool | str | Path = False,
|
|
60
|
+
match_pool: Optional[Executor] = None,
|
|
57
61
|
) -> Iterator[pl.LazyFrame]:
|
|
58
62
|
"""Find all pairs of variants in two sources that meet a set of criteria.
|
|
59
63
|
|
|
@@ -65,6 +69,9 @@ class PairwiseJoin(ABC):
|
|
|
65
69
|
``False`` (default) collects both into memory; ``True`` writes them to the
|
|
66
70
|
system temp directory as parquet files; a ``str``/``Path`` writes them to
|
|
67
71
|
that directory. Temp files are always removed on exit.
|
|
72
|
+
:param match_pool: Optional worker pool for parallel sequence matching (from
|
|
73
|
+
:meth:`make_match_pool`), or ``None`` to score serially. Ignored by strategies that do
|
|
74
|
+
no sequence matching.
|
|
68
75
|
|
|
69
76
|
:yields: A LazyFrame for each chunk.
|
|
70
77
|
"""
|
|
@@ -76,6 +83,7 @@ class PairwiseJoin(ABC):
|
|
|
76
83
|
df_b: pl.DataFrame | pl.LazyFrame,
|
|
77
84
|
retain_index: bool = False,
|
|
78
85
|
temp_dir: bool | str | Path = False,
|
|
86
|
+
match_pool: Optional[Executor] = None,
|
|
79
87
|
) -> pl.LazyFrame:
|
|
80
88
|
"""Find all pairs of variants in two sources that meet a set of criteria.
|
|
81
89
|
|
|
@@ -86,13 +94,32 @@ class PairwiseJoin(ABC):
|
|
|
86
94
|
:param retain_index: If True, do not drop an existing "_index" column in callset tables
|
|
87
95
|
if they exist.
|
|
88
96
|
:param temp_dir: See :meth:`join_iter`.
|
|
97
|
+
:param match_pool: See :meth:`join_iter`.
|
|
89
98
|
|
|
90
99
|
:returns: A join table.
|
|
91
100
|
"""
|
|
92
101
|
return pl.concat(
|
|
93
|
-
self.join_iter(
|
|
102
|
+
self.join_iter(
|
|
103
|
+
df_a, df_b, retain_index=retain_index, temp_dir=temp_dir, match_pool=match_pool,
|
|
104
|
+
)
|
|
94
105
|
)
|
|
95
106
|
|
|
107
|
+
@contextmanager
|
|
108
|
+
def make_match_pool(self, match_threads: Optional[int] = None) -> Iterator[Optional[Executor]]:
|
|
109
|
+
"""Context manager yielding a worker pool for parallel sequence matching, or ``None``.
|
|
110
|
+
|
|
111
|
+
The default is a no-op that always yields ``None`` (serial scoring): strategies that do no
|
|
112
|
+
sequence matching (or do not parallelize it) ignore ``match_threads``. Subclasses that
|
|
113
|
+
parallelize sequence matching override this to create and tear down a worker pool the caller
|
|
114
|
+
passes to :meth:`join`/:meth:`join_iter` via ``match_pool``. See
|
|
115
|
+
:meth:`agglovar.pairwise.overlap.PairwiseOverlap.make_match_pool`.
|
|
116
|
+
|
|
117
|
+
:param match_threads: Requested worker count, or ``None`` to keep scoring serial.
|
|
118
|
+
|
|
119
|
+
:yields: A worker pool, or ``None`` when scoring serially.
|
|
120
|
+
"""
|
|
121
|
+
yield None
|
|
122
|
+
|
|
96
123
|
@property
|
|
97
124
|
@abstractmethod
|
|
98
125
|
def required_cols(self) -> set[str]:
|
|
@@ -219,6 +219,42 @@ but they are different and both have merits. While Agglovar compares *only* vari
|
|
|
219
219
|
if they are shifted, Truvari includes the shift in the sequence match (i.e. the compared sequences
|
|
220
220
|
include the variant and flanking reference bases).
|
|
221
221
|
|
|
222
|
+
Minimum segment reciprocal overlap (seg_ro_min)
|
|
223
|
+
-----------------------------------------------
|
|
224
|
+
|
|
225
|
+
**seg_ro_min**: Minimum segment reciprocal overlap (segment RO) for complex variants.
|
|
226
|
+
|
|
227
|
+
A complex variant is not one contiguous change but a set of *segments*, some aligned to a reference
|
|
228
|
+
locus and some not aligned at all. Comparing two complex variants by their reference span alone is
|
|
229
|
+
crude: two variants may cover the same span while being built from entirely different pieces.
|
|
230
|
+
Segment RO instead compares the pieces.
|
|
231
|
+
|
|
232
|
+
Segment RO is the size of the overlap between the two variants' aligned segments, treating segments
|
|
233
|
+
as a multiset. Both variants' segments are broken into ranges at every segment boundary, and each
|
|
234
|
+
range contributes its length times the *smaller* of the two variants' segment depths over it, where
|
|
235
|
+
depth is the number of segments covering the range. Taking the smaller depth is what keeps a segment
|
|
236
|
+
from being counted more than once when it overlaps several segments on the other side, and it makes
|
|
237
|
+
a duplicated segment count only as many times as both variants duplicate it. Segments overlap only
|
|
238
|
+
within the same chromosome and orientation.
|
|
239
|
+
|
|
240
|
+
Unaligned bases have no reference locus, so there is nothing to intersect them against. The smaller
|
|
241
|
+
of the two variants' unaligned lengths is credited as overlapping, which treats unaligned bases as
|
|
242
|
+
maximally agreeing:
|
|
243
|
+
|
|
244
|
+
.. code-block:: python
|
|
245
|
+
|
|
246
|
+
seg_ro = (overlap + min(unaligned_a, unaligned_b)) / max(total_len_a, total_len_b)
|
|
247
|
+
|
|
248
|
+
Where `total_len` is the length of all of a variant's segments, aligned and unaligned. Segment RO is
|
|
249
|
+
symmetric and is exactly 1.0 for a variant compared against itself.
|
|
250
|
+
|
|
251
|
+
Segment RO requires "seg", "qry_pos", and "qry_end" columns on both input tables. The "seg" column
|
|
252
|
+
is a list of segment structs with "chrom", "pos", "end", "qry_pos", "qry_end", and "is_rev" fields.
|
|
253
|
+
|
|
254
|
+
.. note::
|
|
255
|
+
`seg_ro_min` is applied after the join, so it does not narrow the candidate pairs on its own.
|
|
256
|
+
Pair it with a criterion that does, such as `ro_min`.
|
|
257
|
+
|
|
222
258
|
Reasonable join parameters
|
|
223
259
|
--------------------------
|
|
224
260
|
|
|
@@ -4,6 +4,7 @@ __all__ = [
|
|
|
4
4
|
'RESERVED_COLS',
|
|
5
5
|
'AUTOGEN_COLS',
|
|
6
6
|
'DEFAULT_CHUNK_SIZE',
|
|
7
|
+
'MATCH_CHUNK_SIZE',
|
|
7
8
|
'EXPR_OVERLAP_RO',
|
|
8
9
|
'EXPR_SZRO',
|
|
9
10
|
'EXPR_OFFSET_DIST',
|
|
@@ -22,6 +23,8 @@ import polars as pl
|
|
|
22
23
|
RESERVED_COLS: frozenset[str] = frozenset({
|
|
23
24
|
'_index',
|
|
24
25
|
'_end_ro',
|
|
26
|
+
'_seg_unaligned',
|
|
27
|
+
'_seg_total_len',
|
|
25
28
|
})
|
|
26
29
|
"""Reserved columns are added automatically to input tables."""
|
|
27
30
|
|
|
@@ -38,6 +41,14 @@ size presented to the sequence-alignment thread pool — larger values amortize
|
|
|
38
41
|
better but use more memory. Tune alongside ``n_threads`` based on available RAM and CPU count.
|
|
39
42
|
"""
|
|
40
43
|
|
|
44
|
+
MATCH_CHUNK_SIZE: int = 128
|
|
45
|
+
"""
|
|
46
|
+
Number of ``(seq_a, seq_b)`` pairs scored per worker task when ``match_prop`` is computed on a
|
|
47
|
+
process pool. A small fixed chunk balances the heavy-tailed sequence-length distribution across
|
|
48
|
+
workers; larger chunks starve workers on the long tail. Batches smaller than ``2 * MATCH_CHUNK_SIZE``
|
|
49
|
+
are scored serially because the pool's per-batch overhead outweighs the parallel gain (benchmarked).
|
|
50
|
+
"""
|
|
51
|
+
|
|
41
52
|
EXPR_OVERLAP_RO = (
|
|
42
53
|
(
|
|
43
54
|
(
|