dnakit 0.1.0.dev0__py3-none-any.whl
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.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
dnakit/chunking.py
ADDED
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
"""Coordinate-aware DNA sequence chunking strategies.
|
|
2
|
+
|
|
3
|
+
The module keeps sequence extraction lazy and records enough provenance to
|
|
4
|
+
trace every chunk back to its source record and interval. A BED file is
|
|
5
|
+
optional: without one, every input FASTA record is treated as a ``train``
|
|
6
|
+
region by default. BED intervals use the standard zero-based, half-open
|
|
7
|
+
coordinate convention already used by DNAKit.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import random
|
|
13
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from itertools import pairwise
|
|
16
|
+
from typing import Any, Literal, TypeAlias
|
|
17
|
+
|
|
18
|
+
from dnakit.core.coordinates import Interval
|
|
19
|
+
from dnakit.core.facade import DNA, resolve_single_dna
|
|
20
|
+
from dnakit.core.record import DNARecord
|
|
21
|
+
from dnakit.core.sequence import DNASequence
|
|
22
|
+
from dnakit.exceptions import ConfigurationError, CoordinateError, InputFormatError
|
|
23
|
+
from dnakit.io.annotations import AnnotationDocument, AnnotationSource, read_bed
|
|
24
|
+
from dnakit.io.api import ReadableSource, read
|
|
25
|
+
from dnakit.io.config import ReadConfig
|
|
26
|
+
from dnakit.ops.edit import subsequence
|
|
27
|
+
|
|
28
|
+
ChunkStrategy = Literal["fixed", "sliding", "random", "multiscale", "curriculum"]
|
|
29
|
+
ChunkProgressStatus = Literal["started", "yielded", "completed"]
|
|
30
|
+
ChunkProgressCallback: TypeAlias = Callable[["ChunkProgress"], None]
|
|
31
|
+
ChunkBedSource: TypeAlias = AnnotationDocument | AnnotationSource
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _positive_int(value: object, field_name: str) -> None:
|
|
35
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
36
|
+
raise ConfigurationError(
|
|
37
|
+
f"{field_name} must be a positive integer.",
|
|
38
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
39
|
+
context={"field": field_name, "value": value},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _non_negative_int(value: object, field_name: str) -> None:
|
|
44
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
45
|
+
raise ConfigurationError(
|
|
46
|
+
f"{field_name} must be a non-negative integer.",
|
|
47
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
48
|
+
context={"field": field_name, "value": value},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _coerce_int_tuple(value: Sequence[int], field_name: str) -> tuple[int, ...]:
|
|
53
|
+
if isinstance(value, (str, bytes)):
|
|
54
|
+
raise ConfigurationError(
|
|
55
|
+
f"{field_name} must be a sequence of integers.",
|
|
56
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
57
|
+
context={"field": field_name},
|
|
58
|
+
)
|
|
59
|
+
try:
|
|
60
|
+
resolved = tuple(value)
|
|
61
|
+
except TypeError as exc:
|
|
62
|
+
raise ConfigurationError(
|
|
63
|
+
f"{field_name} must be a sequence of integers.",
|
|
64
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
65
|
+
context={"field": field_name},
|
|
66
|
+
) from exc
|
|
67
|
+
for index, item in enumerate(resolved):
|
|
68
|
+
_positive_int(item, f"{field_name}[{index}]")
|
|
69
|
+
return resolved
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _validate_text(value: object, field_name: str) -> None:
|
|
73
|
+
if not isinstance(value, str) or not value.strip() or any(char.isspace() for char in value):
|
|
74
|
+
raise ConfigurationError(
|
|
75
|
+
f"{field_name} must be non-empty text without whitespace.",
|
|
76
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
77
|
+
context={"field": field_name, "value": value},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class ChunkingConfig:
|
|
83
|
+
"""Validate one sequence chunking strategy.
|
|
84
|
+
|
|
85
|
+
``fixed`` creates non-overlapping windows. ``sliding`` uses ``step`` and
|
|
86
|
+
defaults to a one-base step. ``random`` requires a length range and a
|
|
87
|
+
sample count. ``multiscale`` creates windows for every value in
|
|
88
|
+
``lengths``. ``curriculum`` has the same window behavior as multiscale,
|
|
89
|
+
while its optional ``stage_steps`` records how long a training stage is
|
|
90
|
+
intended to run.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
strategy: ChunkStrategy = "fixed"
|
|
94
|
+
length: int = 1024
|
|
95
|
+
step: int | None = None
|
|
96
|
+
min_length: int | None = None
|
|
97
|
+
max_length: int | None = None
|
|
98
|
+
num_samples: int | None = None
|
|
99
|
+
lengths: tuple[int, ...] = ()
|
|
100
|
+
steps: tuple[int, ...] | None = None
|
|
101
|
+
stage_steps: tuple[int, ...] | None = None
|
|
102
|
+
include_partial: bool = False
|
|
103
|
+
seed: int | None = None
|
|
104
|
+
split: str = "train"
|
|
105
|
+
allow_gaps: bool = False
|
|
106
|
+
|
|
107
|
+
def __post_init__(self) -> None:
|
|
108
|
+
if not isinstance(self.strategy, str) or self.strategy not in {
|
|
109
|
+
"fixed",
|
|
110
|
+
"sliding",
|
|
111
|
+
"random",
|
|
112
|
+
"multiscale",
|
|
113
|
+
"curriculum",
|
|
114
|
+
}:
|
|
115
|
+
raise ConfigurationError(
|
|
116
|
+
"Unknown sequence chunking strategy.",
|
|
117
|
+
code="INVALID_CHUNKING_STRATEGY",
|
|
118
|
+
context={"strategy": self.strategy},
|
|
119
|
+
)
|
|
120
|
+
_positive_int(self.length, "length")
|
|
121
|
+
if self.step is not None:
|
|
122
|
+
_positive_int(self.step, "step")
|
|
123
|
+
for field_name, value in (
|
|
124
|
+
("min_length", self.min_length),
|
|
125
|
+
("max_length", self.max_length),
|
|
126
|
+
("num_samples", self.num_samples),
|
|
127
|
+
):
|
|
128
|
+
if value is not None:
|
|
129
|
+
_positive_int(value, field_name)
|
|
130
|
+
if self.seed is not None and (
|
|
131
|
+
isinstance(self.seed, bool) or not isinstance(self.seed, int)
|
|
132
|
+
):
|
|
133
|
+
raise ConfigurationError(
|
|
134
|
+
"seed must be an integer or None.",
|
|
135
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
136
|
+
context={"field": "seed", "value": self.seed},
|
|
137
|
+
)
|
|
138
|
+
if not isinstance(self.include_partial, bool):
|
|
139
|
+
raise ConfigurationError(
|
|
140
|
+
"include_partial must be boolean.",
|
|
141
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
142
|
+
context={"field": "include_partial", "value": self.include_partial},
|
|
143
|
+
)
|
|
144
|
+
if not isinstance(self.allow_gaps, bool):
|
|
145
|
+
raise ConfigurationError(
|
|
146
|
+
"allow_gaps must be boolean.",
|
|
147
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
148
|
+
context={"field": "allow_gaps", "value": self.allow_gaps},
|
|
149
|
+
)
|
|
150
|
+
_validate_text(self.split, "split")
|
|
151
|
+
|
|
152
|
+
resolved_lengths = _coerce_int_tuple(self.lengths, "lengths")
|
|
153
|
+
object.__setattr__(self, "lengths", resolved_lengths)
|
|
154
|
+
resolved_steps = None if self.steps is None else _coerce_int_tuple(self.steps, "steps")
|
|
155
|
+
object.__setattr__(self, "steps", resolved_steps)
|
|
156
|
+
resolved_stage_steps = (
|
|
157
|
+
None if self.stage_steps is None else _coerce_int_tuple(self.stage_steps, "stage_steps")
|
|
158
|
+
)
|
|
159
|
+
object.__setattr__(self, "stage_steps", resolved_stage_steps)
|
|
160
|
+
|
|
161
|
+
if self.strategy == "fixed":
|
|
162
|
+
if self.step is not None:
|
|
163
|
+
raise ConfigurationError(
|
|
164
|
+
"fixed strategy is non-overlapping; omit step.",
|
|
165
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
166
|
+
)
|
|
167
|
+
if resolved_lengths or resolved_steps or resolved_stage_steps:
|
|
168
|
+
raise ConfigurationError(
|
|
169
|
+
"lengths, steps, and stage_steps are only for multiscale or curriculum.",
|
|
170
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
171
|
+
)
|
|
172
|
+
elif self.strategy == "sliding":
|
|
173
|
+
resolved_step = 1 if self.step is None else self.step
|
|
174
|
+
if resolved_step > self.length:
|
|
175
|
+
raise ConfigurationError(
|
|
176
|
+
"sliding step cannot exceed window length; that would create gaps.",
|
|
177
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
178
|
+
context={"length": self.length, "step": resolved_step},
|
|
179
|
+
)
|
|
180
|
+
object.__setattr__(self, "step", resolved_step)
|
|
181
|
+
if resolved_lengths or resolved_steps or resolved_stage_steps:
|
|
182
|
+
raise ConfigurationError(
|
|
183
|
+
"lengths, steps, and stage_steps are only for multiscale or curriculum.",
|
|
184
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
185
|
+
)
|
|
186
|
+
elif self.strategy == "random":
|
|
187
|
+
if self.min_length is None or self.max_length is None or self.num_samples is None:
|
|
188
|
+
raise ConfigurationError(
|
|
189
|
+
"random strategy requires min_length, max_length, and num_samples.",
|
|
190
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
191
|
+
)
|
|
192
|
+
if self.min_length > self.max_length:
|
|
193
|
+
raise ConfigurationError(
|
|
194
|
+
"min_length cannot exceed max_length.",
|
|
195
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
196
|
+
context={
|
|
197
|
+
"min_length": self.min_length,
|
|
198
|
+
"max_length": self.max_length,
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
if resolved_lengths or resolved_steps or resolved_stage_steps:
|
|
202
|
+
raise ConfigurationError(
|
|
203
|
+
"lengths, steps, and stage_steps are only for multiscale or curriculum.",
|
|
204
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
if not resolved_lengths:
|
|
208
|
+
raise ConfigurationError(
|
|
209
|
+
f"{self.strategy} strategy requires at least one length.",
|
|
210
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
211
|
+
)
|
|
212
|
+
if any(left >= right for left, right in pairwise(resolved_lengths)):
|
|
213
|
+
raise ConfigurationError(
|
|
214
|
+
"multiscale and curriculum lengths must be strictly increasing.",
|
|
215
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
216
|
+
)
|
|
217
|
+
if resolved_steps is not None and len(resolved_steps) != len(resolved_lengths):
|
|
218
|
+
raise ConfigurationError(
|
|
219
|
+
"steps must have the same number of values as lengths.",
|
|
220
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
221
|
+
)
|
|
222
|
+
if resolved_steps is not None and any(
|
|
223
|
+
window_step > window_length
|
|
224
|
+
for window_step, window_length in zip(resolved_steps, resolved_lengths, strict=True)
|
|
225
|
+
):
|
|
226
|
+
raise ConfigurationError(
|
|
227
|
+
"multiscale or curriculum steps cannot exceed their window lengths.",
|
|
228
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
229
|
+
)
|
|
230
|
+
if self.strategy == "multiscale" and resolved_stage_steps is not None:
|
|
231
|
+
raise ConfigurationError(
|
|
232
|
+
"stage_steps are only valid for curriculum.",
|
|
233
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
234
|
+
)
|
|
235
|
+
if (
|
|
236
|
+
self.strategy == "curriculum"
|
|
237
|
+
and resolved_stage_steps is not None
|
|
238
|
+
and len(resolved_stage_steps) != len(resolved_lengths)
|
|
239
|
+
):
|
|
240
|
+
raise ConfigurationError(
|
|
241
|
+
"stage_steps must have the same number of values as lengths.",
|
|
242
|
+
code="INVALID_CHUNKING_CONFIG",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def to_dict(self) -> dict[str, object]:
|
|
246
|
+
"""Return a JSON-friendly configuration summary."""
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
"strategy": self.strategy,
|
|
250
|
+
"length": self.length,
|
|
251
|
+
"step": self.step,
|
|
252
|
+
"min_length": self.min_length,
|
|
253
|
+
"max_length": self.max_length,
|
|
254
|
+
"num_samples": self.num_samples,
|
|
255
|
+
"lengths": list(self.lengths),
|
|
256
|
+
"steps": None if self.steps is None else list(self.steps),
|
|
257
|
+
"stage_steps": None if self.stage_steps is None else list(self.stage_steps),
|
|
258
|
+
"include_partial": self.include_partial,
|
|
259
|
+
"seed": self.seed,
|
|
260
|
+
"split": self.split,
|
|
261
|
+
"allow_gaps": self.allow_gaps,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(frozen=True, slots=True)
|
|
266
|
+
class LengthCurriculumStage:
|
|
267
|
+
"""One stage in a short-to-long training schedule."""
|
|
268
|
+
|
|
269
|
+
index: int
|
|
270
|
+
length: int
|
|
271
|
+
training_steps: int | None = None
|
|
272
|
+
|
|
273
|
+
def __post_init__(self) -> None:
|
|
274
|
+
_non_negative_int(self.index, "index")
|
|
275
|
+
_positive_int(self.length, "length")
|
|
276
|
+
if self.training_steps is not None:
|
|
277
|
+
_positive_int(self.training_steps, "training_steps")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True, slots=True)
|
|
281
|
+
class LengthCurriculum:
|
|
282
|
+
"""A validated short-to-long sequence length schedule.
|
|
283
|
+
|
|
284
|
+
The schedule itself does not start training. Use :meth:`to_config` to
|
|
285
|
+
obtain a ``curriculum`` chunking configuration, then pass that config to
|
|
286
|
+
:func:`iter_fasta_chunks` or :func:`iter_sequence_chunks`.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
lengths: tuple[int, ...]
|
|
290
|
+
stage_steps: tuple[int, ...] | None = None
|
|
291
|
+
|
|
292
|
+
def __post_init__(self) -> None:
|
|
293
|
+
resolved_lengths = _coerce_int_tuple(self.lengths, "lengths")
|
|
294
|
+
if not resolved_lengths:
|
|
295
|
+
raise ConfigurationError(
|
|
296
|
+
"A length curriculum requires at least one length.",
|
|
297
|
+
code="INVALID_LENGTH_CURRICULUM",
|
|
298
|
+
)
|
|
299
|
+
if any(left >= right for left, right in pairwise(resolved_lengths)):
|
|
300
|
+
raise ConfigurationError(
|
|
301
|
+
"Curriculum lengths must be strictly increasing.",
|
|
302
|
+
code="INVALID_LENGTH_CURRICULUM",
|
|
303
|
+
)
|
|
304
|
+
object.__setattr__(self, "lengths", resolved_lengths)
|
|
305
|
+
resolved_stage_steps = (
|
|
306
|
+
None if self.stage_steps is None else _coerce_int_tuple(self.stage_steps, "stage_steps")
|
|
307
|
+
)
|
|
308
|
+
if resolved_stage_steps is not None and len(resolved_stage_steps) != len(resolved_lengths):
|
|
309
|
+
raise ConfigurationError(
|
|
310
|
+
"stage_steps must have the same number of values as lengths.",
|
|
311
|
+
code="INVALID_LENGTH_CURRICULUM",
|
|
312
|
+
)
|
|
313
|
+
object.__setattr__(self, "stage_steps", resolved_stage_steps)
|
|
314
|
+
|
|
315
|
+
@property
|
|
316
|
+
def stages(self) -> tuple[LengthCurriculumStage, ...]:
|
|
317
|
+
"""Return stages in the order in which they should be trained."""
|
|
318
|
+
|
|
319
|
+
return tuple(
|
|
320
|
+
LengthCurriculumStage(
|
|
321
|
+
index=index,
|
|
322
|
+
length=length,
|
|
323
|
+
training_steps=None if self.stage_steps is None else self.stage_steps[index],
|
|
324
|
+
)
|
|
325
|
+
for index, length in enumerate(self.lengths)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def to_config(
|
|
329
|
+
self,
|
|
330
|
+
*,
|
|
331
|
+
window_step: int | None = None,
|
|
332
|
+
include_partial: bool = False,
|
|
333
|
+
seed: int | None = None,
|
|
334
|
+
split: str = "train",
|
|
335
|
+
allow_gaps: bool = False,
|
|
336
|
+
) -> ChunkingConfig:
|
|
337
|
+
"""Convert the schedule into a chunking configuration."""
|
|
338
|
+
|
|
339
|
+
resolved_steps = None if window_step is None else tuple(window_step for _ in self.lengths)
|
|
340
|
+
return ChunkingConfig(
|
|
341
|
+
strategy="curriculum",
|
|
342
|
+
lengths=self.lengths,
|
|
343
|
+
steps=resolved_steps,
|
|
344
|
+
stage_steps=self.stage_steps,
|
|
345
|
+
include_partial=include_partial,
|
|
346
|
+
seed=seed,
|
|
347
|
+
split=split,
|
|
348
|
+
allow_gaps=allow_gaps,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def to_dict(self) -> dict[str, object]:
|
|
352
|
+
"""Return a JSON-friendly curriculum summary."""
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
"lengths": list(self.lengths),
|
|
356
|
+
"stage_steps": None if self.stage_steps is None else list(self.stage_steps),
|
|
357
|
+
"stages": [
|
|
358
|
+
{
|
|
359
|
+
"index": stage.index,
|
|
360
|
+
"length": stage.length,
|
|
361
|
+
"training_steps": stage.training_steps,
|
|
362
|
+
}
|
|
363
|
+
for stage in self.stages
|
|
364
|
+
],
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def make_length_curriculum(
|
|
369
|
+
lengths: Sequence[int], *, stage_steps: Sequence[int] | None = None
|
|
370
|
+
) -> LengthCurriculum:
|
|
371
|
+
"""Create a validated short-to-long curriculum."""
|
|
372
|
+
|
|
373
|
+
return LengthCurriculum(tuple(lengths), None if stage_steps is None else tuple(stage_steps))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@dataclass(frozen=True, slots=True)
|
|
377
|
+
class ChunkProgress:
|
|
378
|
+
"""Advisory progress event emitted while chunks are generated."""
|
|
379
|
+
|
|
380
|
+
source_id: str
|
|
381
|
+
split: str
|
|
382
|
+
region_index: int
|
|
383
|
+
processed: int
|
|
384
|
+
total: int
|
|
385
|
+
status: ChunkProgressStatus
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@dataclass(frozen=True, slots=True)
|
|
389
|
+
class SequenceChunk:
|
|
390
|
+
"""One extracted sequence and its source-coordinate provenance."""
|
|
391
|
+
|
|
392
|
+
sequence: DNASequence
|
|
393
|
+
id: str
|
|
394
|
+
source_id: str
|
|
395
|
+
split: str
|
|
396
|
+
source_start: int
|
|
397
|
+
source_end: int
|
|
398
|
+
requested_length: int
|
|
399
|
+
window_index: int
|
|
400
|
+
strategy: ChunkStrategy
|
|
401
|
+
region_index: int = 0
|
|
402
|
+
level_index: int | None = None
|
|
403
|
+
|
|
404
|
+
def __post_init__(self) -> None:
|
|
405
|
+
if not isinstance(self.sequence, DNASequence):
|
|
406
|
+
raise ConfigurationError(
|
|
407
|
+
"SequenceChunk.sequence must be a DNASequence.",
|
|
408
|
+
code="INVALID_SEQUENCE_CHUNK",
|
|
409
|
+
)
|
|
410
|
+
for field_name, value in (
|
|
411
|
+
("id", self.id),
|
|
412
|
+
("source_id", self.source_id),
|
|
413
|
+
("split", self.split),
|
|
414
|
+
):
|
|
415
|
+
if not isinstance(value, str) or not value.strip():
|
|
416
|
+
raise ConfigurationError(
|
|
417
|
+
f"SequenceChunk {field_name} must be non-empty text.",
|
|
418
|
+
code="INVALID_SEQUENCE_CHUNK",
|
|
419
|
+
)
|
|
420
|
+
for integer_field_name, integer_value in (
|
|
421
|
+
("source_start", self.source_start),
|
|
422
|
+
("source_end", self.source_end),
|
|
423
|
+
("requested_length", self.requested_length),
|
|
424
|
+
):
|
|
425
|
+
if integer_field_name == "requested_length":
|
|
426
|
+
_positive_int(integer_value, integer_field_name)
|
|
427
|
+
else:
|
|
428
|
+
_non_negative_int(integer_value, integer_field_name)
|
|
429
|
+
if self.source_end <= self.source_start:
|
|
430
|
+
raise ConfigurationError(
|
|
431
|
+
"SequenceChunk source_end must be greater than source_start.",
|
|
432
|
+
code="INVALID_SEQUENCE_CHUNK",
|
|
433
|
+
)
|
|
434
|
+
if self.source_end - self.source_start > self.requested_length:
|
|
435
|
+
raise ConfigurationError(
|
|
436
|
+
"Extracted sequence cannot exceed requested_length.",
|
|
437
|
+
code="INVALID_SEQUENCE_CHUNK",
|
|
438
|
+
)
|
|
439
|
+
sequence_span = self.sequence.coordinate_span
|
|
440
|
+
if sequence_span is not None and sequence_span != self.source_end - self.source_start:
|
|
441
|
+
raise ConfigurationError(
|
|
442
|
+
"SequenceChunk coordinates do not match the extracted sequence span.",
|
|
443
|
+
code="INVALID_SEQUENCE_CHUNK",
|
|
444
|
+
context={
|
|
445
|
+
"sequence_span": sequence_span,
|
|
446
|
+
"source_start": self.source_start,
|
|
447
|
+
"source_end": self.source_end,
|
|
448
|
+
},
|
|
449
|
+
)
|
|
450
|
+
_non_negative_int(self.window_index, "window_index")
|
|
451
|
+
_non_negative_int(self.region_index, "region_index")
|
|
452
|
+
if self.level_index is not None:
|
|
453
|
+
_non_negative_int(self.level_index, "level_index")
|
|
454
|
+
if not isinstance(self.strategy, str) or self.strategy not in {
|
|
455
|
+
"fixed",
|
|
456
|
+
"sliding",
|
|
457
|
+
"random",
|
|
458
|
+
"multiscale",
|
|
459
|
+
"curriculum",
|
|
460
|
+
}:
|
|
461
|
+
raise ConfigurationError(
|
|
462
|
+
"SequenceChunk strategy is not supported.",
|
|
463
|
+
code="INVALID_SEQUENCE_CHUNK",
|
|
464
|
+
context={"strategy": self.strategy},
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def length(self) -> int:
|
|
469
|
+
"""Actual sequence length in coordinates."""
|
|
470
|
+
|
|
471
|
+
return self.source_end - self.source_start
|
|
472
|
+
|
|
473
|
+
def to_record(self) -> DNARecord:
|
|
474
|
+
"""Convert the chunk to a DNARecord while retaining provenance."""
|
|
475
|
+
|
|
476
|
+
return DNARecord(
|
|
477
|
+
self.sequence,
|
|
478
|
+
self.id,
|
|
479
|
+
description=(
|
|
480
|
+
f"{self.source_id}:{self.source_start}-{self.source_end}"
|
|
481
|
+
f" split={self.split} strategy={self.strategy}"
|
|
482
|
+
),
|
|
483
|
+
metadata={
|
|
484
|
+
"chunking": {
|
|
485
|
+
"strategy": self.strategy,
|
|
486
|
+
"split": self.split,
|
|
487
|
+
"source_id": self.source_id,
|
|
488
|
+
"source_start": self.source_start,
|
|
489
|
+
"source_end": self.source_end,
|
|
490
|
+
"requested_length": self.requested_length,
|
|
491
|
+
"window_index": self.window_index,
|
|
492
|
+
"region_index": self.region_index,
|
|
493
|
+
"level_index": self.level_index,
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
def to_dict(self) -> dict[str, Any]:
|
|
499
|
+
"""Return chunk metadata and symbols in a serialization-friendly form."""
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
"id": self.id,
|
|
503
|
+
"source_id": self.source_id,
|
|
504
|
+
"split": self.split,
|
|
505
|
+
"source_start": self.source_start,
|
|
506
|
+
"source_end": self.source_end,
|
|
507
|
+
"length": self.length,
|
|
508
|
+
"requested_length": self.requested_length,
|
|
509
|
+
"window_index": self.window_index,
|
|
510
|
+
"region_index": self.region_index,
|
|
511
|
+
"level_index": self.level_index,
|
|
512
|
+
"strategy": self.strategy,
|
|
513
|
+
"sequence": self.sequence.symbols,
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@dataclass(frozen=True, slots=True)
|
|
518
|
+
class _PlannedWindow:
|
|
519
|
+
start: int
|
|
520
|
+
end: int
|
|
521
|
+
requested_length: int
|
|
522
|
+
level_index: int | None = None
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _regular_windows(
|
|
526
|
+
start: int,
|
|
527
|
+
end: int,
|
|
528
|
+
*,
|
|
529
|
+
length: int,
|
|
530
|
+
step: int,
|
|
531
|
+
include_partial: bool,
|
|
532
|
+
level_index: int | None = None,
|
|
533
|
+
) -> Iterator[_PlannedWindow]:
|
|
534
|
+
if end <= start:
|
|
535
|
+
return
|
|
536
|
+
if include_partial:
|
|
537
|
+
for window_start in range(start, end, step):
|
|
538
|
+
window_end = min(window_start + length, end)
|
|
539
|
+
yield _PlannedWindow(window_start, window_end, length, level_index)
|
|
540
|
+
return
|
|
541
|
+
last_start = end - length
|
|
542
|
+
if last_start < start:
|
|
543
|
+
return
|
|
544
|
+
for window_start in range(start, last_start + 1, step):
|
|
545
|
+
yield _PlannedWindow(window_start, window_start + length, length, level_index)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _random_windows(
|
|
549
|
+
start: int,
|
|
550
|
+
end: int,
|
|
551
|
+
*,
|
|
552
|
+
config: ChunkingConfig,
|
|
553
|
+
rng: random.Random,
|
|
554
|
+
) -> Iterator[_PlannedWindow]:
|
|
555
|
+
assert config.min_length is not None
|
|
556
|
+
assert config.max_length is not None
|
|
557
|
+
assert config.num_samples is not None
|
|
558
|
+
available = end - start
|
|
559
|
+
if available < config.min_length:
|
|
560
|
+
raise ConfigurationError(
|
|
561
|
+
"The requested random minimum length exceeds the input region.",
|
|
562
|
+
code="RANDOM_CHUNK_TOO_LONG",
|
|
563
|
+
context={"region_length": available, "min_length": config.min_length},
|
|
564
|
+
)
|
|
565
|
+
upper_length = min(config.max_length, available)
|
|
566
|
+
for _ in range(config.num_samples):
|
|
567
|
+
window_length = rng.randint(config.min_length, upper_length)
|
|
568
|
+
window_start = rng.randint(start, end - window_length)
|
|
569
|
+
yield _PlannedWindow(window_start, window_start + window_length, window_length)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _planned_windows(
|
|
573
|
+
start: int,
|
|
574
|
+
end: int,
|
|
575
|
+
*,
|
|
576
|
+
config: ChunkingConfig,
|
|
577
|
+
rng: random.Random,
|
|
578
|
+
) -> Iterator[_PlannedWindow]:
|
|
579
|
+
if config.strategy == "fixed":
|
|
580
|
+
yield from _regular_windows(
|
|
581
|
+
start,
|
|
582
|
+
end,
|
|
583
|
+
length=config.length,
|
|
584
|
+
step=config.length,
|
|
585
|
+
include_partial=config.include_partial,
|
|
586
|
+
)
|
|
587
|
+
elif config.strategy == "sliding":
|
|
588
|
+
assert config.step is not None
|
|
589
|
+
yield from _regular_windows(
|
|
590
|
+
start,
|
|
591
|
+
end,
|
|
592
|
+
length=config.length,
|
|
593
|
+
step=config.step,
|
|
594
|
+
include_partial=config.include_partial,
|
|
595
|
+
)
|
|
596
|
+
elif config.strategy == "random":
|
|
597
|
+
yield from _random_windows(start, end, config=config, rng=rng)
|
|
598
|
+
else:
|
|
599
|
+
for level_index, window_length in enumerate(config.lengths):
|
|
600
|
+
window_step = window_length if config.steps is None else config.steps[level_index]
|
|
601
|
+
yield from _regular_windows(
|
|
602
|
+
start,
|
|
603
|
+
end,
|
|
604
|
+
length=window_length,
|
|
605
|
+
step=window_step,
|
|
606
|
+
include_partial=config.include_partial,
|
|
607
|
+
level_index=level_index,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _count_regular_windows(
|
|
612
|
+
region_length: int, *, length: int, step: int, include_partial: bool
|
|
613
|
+
) -> int:
|
|
614
|
+
if region_length <= 0:
|
|
615
|
+
return 0
|
|
616
|
+
if include_partial:
|
|
617
|
+
return (region_length + step - 1) // step
|
|
618
|
+
if region_length < length:
|
|
619
|
+
return 0
|
|
620
|
+
return (region_length - length) // step + 1
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _planned_window_count(start: int, end: int, *, config: ChunkingConfig) -> int:
|
|
624
|
+
region_length = end - start
|
|
625
|
+
if config.strategy == "fixed":
|
|
626
|
+
return _count_regular_windows(
|
|
627
|
+
region_length,
|
|
628
|
+
length=config.length,
|
|
629
|
+
step=config.length,
|
|
630
|
+
include_partial=config.include_partial,
|
|
631
|
+
)
|
|
632
|
+
if config.strategy == "sliding":
|
|
633
|
+
assert config.step is not None
|
|
634
|
+
return _count_regular_windows(
|
|
635
|
+
region_length,
|
|
636
|
+
length=config.length,
|
|
637
|
+
step=config.step,
|
|
638
|
+
include_partial=config.include_partial,
|
|
639
|
+
)
|
|
640
|
+
if config.strategy == "random":
|
|
641
|
+
assert config.min_length is not None
|
|
642
|
+
assert config.num_samples is not None
|
|
643
|
+
if region_length < config.min_length:
|
|
644
|
+
raise ConfigurationError(
|
|
645
|
+
"The requested random minimum length exceeds the input region.",
|
|
646
|
+
code="RANDOM_CHUNK_TOO_LONG",
|
|
647
|
+
context={"region_length": region_length, "min_length": config.min_length},
|
|
648
|
+
)
|
|
649
|
+
return config.num_samples
|
|
650
|
+
total = 0
|
|
651
|
+
for level_index, window_length in enumerate(config.lengths):
|
|
652
|
+
window_step = window_length if config.steps is None else config.steps[level_index]
|
|
653
|
+
total += _count_regular_windows(
|
|
654
|
+
region_length,
|
|
655
|
+
length=window_length,
|
|
656
|
+
step=window_step,
|
|
657
|
+
include_partial=config.include_partial,
|
|
658
|
+
)
|
|
659
|
+
return total
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _emit_progress(callback: ChunkProgressCallback | None, event: ChunkProgress) -> None:
|
|
663
|
+
if callback is None:
|
|
664
|
+
return
|
|
665
|
+
try:
|
|
666
|
+
callback(event)
|
|
667
|
+
except Exception:
|
|
668
|
+
# Progress is advisory and must not change chunk generation semantics.
|
|
669
|
+
return
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _validate_region(sequence: DNASequence, start: int, end: int | None) -> int:
|
|
673
|
+
span = len(sequence)
|
|
674
|
+
if isinstance(start, bool) or not isinstance(start, int):
|
|
675
|
+
raise CoordinateError(
|
|
676
|
+
"Chunk start must be an integer.",
|
|
677
|
+
code="INVALID_CHUNK_COORDINATE",
|
|
678
|
+
context={"start": start},
|
|
679
|
+
)
|
|
680
|
+
resolved_end = span if end is None else end
|
|
681
|
+
if isinstance(resolved_end, bool) or not isinstance(resolved_end, int):
|
|
682
|
+
raise CoordinateError(
|
|
683
|
+
"Chunk end must be an integer or None.",
|
|
684
|
+
code="INVALID_CHUNK_COORDINATE",
|
|
685
|
+
context={"end": resolved_end},
|
|
686
|
+
)
|
|
687
|
+
if start < 0 or resolved_end < start or resolved_end > span:
|
|
688
|
+
raise CoordinateError(
|
|
689
|
+
"Chunk coordinates must be a 0-based half-open interval inside the sequence.",
|
|
690
|
+
code="INVALID_CHUNK_COORDINATE",
|
|
691
|
+
context={"start": start, "end": resolved_end, "sequence_length": span},
|
|
692
|
+
)
|
|
693
|
+
return resolved_end
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _record_identity(
|
|
697
|
+
value: DNA | DNASequence | DNARecord, *, source_id: str | None
|
|
698
|
+
) -> tuple[DNASequence, str]:
|
|
699
|
+
if isinstance(value, (DNA, DNARecord, DNASequence)):
|
|
700
|
+
sequence, record = resolve_single_dna(value)
|
|
701
|
+
default_id = "sequence_1" if record is None else record.id
|
|
702
|
+
return sequence, default_id if source_id is None else source_id
|
|
703
|
+
raise TypeError("value must be DNA, DNASequence, or DNARecord.")
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _chunk_id(
|
|
707
|
+
source_id: str,
|
|
708
|
+
split: str,
|
|
709
|
+
region_index: int,
|
|
710
|
+
window_index: int,
|
|
711
|
+
level_index: int | None,
|
|
712
|
+
) -> str:
|
|
713
|
+
level = "" if level_index is None else f"|level_{level_index}"
|
|
714
|
+
return f"{source_id}|{split}|region_{region_index}{level}|chunk_{window_index}"
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _iter_region_chunks(
|
|
718
|
+
sequence: DNASequence,
|
|
719
|
+
*,
|
|
720
|
+
source_id: str,
|
|
721
|
+
split: str,
|
|
722
|
+
start: int,
|
|
723
|
+
end: int,
|
|
724
|
+
region_index: int,
|
|
725
|
+
config: ChunkingConfig,
|
|
726
|
+
rng: random.Random,
|
|
727
|
+
progress: ChunkProgressCallback | None,
|
|
728
|
+
) -> Iterator[SequenceChunk]:
|
|
729
|
+
total = _planned_window_count(start, end, config=config)
|
|
730
|
+
_emit_progress(
|
|
731
|
+
progress,
|
|
732
|
+
ChunkProgress(source_id, split, region_index, 0, total, "started"),
|
|
733
|
+
)
|
|
734
|
+
processed = 0
|
|
735
|
+
for plan in _planned_windows(start, end, config=config, rng=rng):
|
|
736
|
+
selected = subsequence(sequence, plan.start, plan.end, allow_gaps=config.allow_gaps)
|
|
737
|
+
chunk = SequenceChunk(
|
|
738
|
+
sequence=selected,
|
|
739
|
+
id=_chunk_id(source_id, split, region_index, processed, plan.level_index),
|
|
740
|
+
source_id=source_id,
|
|
741
|
+
split=split,
|
|
742
|
+
source_start=plan.start,
|
|
743
|
+
source_end=plan.end,
|
|
744
|
+
requested_length=plan.requested_length,
|
|
745
|
+
window_index=processed,
|
|
746
|
+
strategy=config.strategy,
|
|
747
|
+
region_index=region_index,
|
|
748
|
+
level_index=plan.level_index,
|
|
749
|
+
)
|
|
750
|
+
processed += 1
|
|
751
|
+
_emit_progress(
|
|
752
|
+
progress,
|
|
753
|
+
ChunkProgress(source_id, split, region_index, processed, total, "yielded"),
|
|
754
|
+
)
|
|
755
|
+
yield chunk
|
|
756
|
+
_emit_progress(
|
|
757
|
+
progress,
|
|
758
|
+
ChunkProgress(source_id, split, region_index, processed, total, "completed"),
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def iter_sequence_chunks(
|
|
763
|
+
value: DNA | DNASequence | DNARecord,
|
|
764
|
+
*,
|
|
765
|
+
config: ChunkingConfig | None = None,
|
|
766
|
+
start: int = 0,
|
|
767
|
+
end: int | None = None,
|
|
768
|
+
source_id: str | None = None,
|
|
769
|
+
split: str | None = None,
|
|
770
|
+
region_index: int = 0,
|
|
771
|
+
progress: ChunkProgressCallback | None = None,
|
|
772
|
+
) -> Iterator[SequenceChunk]:
|
|
773
|
+
"""Lazily split one sequence or record according to ``config``.
|
|
774
|
+
|
|
775
|
+
The default is 1024-base, non-overlapping windows assigned to ``train``.
|
|
776
|
+
Coordinates are zero-based and half-open. Partial windows are discarded
|
|
777
|
+
unless ``ChunkingConfig.include_partial`` is enabled.
|
|
778
|
+
"""
|
|
779
|
+
|
|
780
|
+
resolved_config = ChunkingConfig() if config is None else config
|
|
781
|
+
if not isinstance(resolved_config, ChunkingConfig):
|
|
782
|
+
raise TypeError("config must be ChunkingConfig or None.")
|
|
783
|
+
_non_negative_int(region_index, "region_index")
|
|
784
|
+
sequence, resolved_source_id = _record_identity(value, source_id=source_id)
|
|
785
|
+
resolved_split = resolved_config.split if split is None else split
|
|
786
|
+
_validate_text(resolved_source_id, "source_id")
|
|
787
|
+
_validate_text(resolved_split, "split")
|
|
788
|
+
resolved_end = _validate_region(sequence, start, end)
|
|
789
|
+
yield from _iter_region_chunks(
|
|
790
|
+
sequence,
|
|
791
|
+
source_id=resolved_source_id,
|
|
792
|
+
split=resolved_split,
|
|
793
|
+
start=start,
|
|
794
|
+
end=resolved_end,
|
|
795
|
+
region_index=region_index,
|
|
796
|
+
config=resolved_config,
|
|
797
|
+
rng=random.Random(resolved_config.seed),
|
|
798
|
+
progress=progress,
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _bed_regions(
|
|
803
|
+
document: AnnotationDocument, *, default_split: str
|
|
804
|
+
) -> dict[str, tuple[tuple[int, int, str], ...]]:
|
|
805
|
+
if document.format != "bed":
|
|
806
|
+
raise InputFormatError(
|
|
807
|
+
"Sequence chunking accepts a BED annotation document.",
|
|
808
|
+
code="CHUNKING_BED_REQUIRED",
|
|
809
|
+
context={"format": document.format},
|
|
810
|
+
)
|
|
811
|
+
grouped: dict[str, list[tuple[int, int, str]]] = {}
|
|
812
|
+
for entry in document.entries:
|
|
813
|
+
location = entry.feature.location
|
|
814
|
+
if not isinstance(location, Interval):
|
|
815
|
+
raise InputFormatError(
|
|
816
|
+
"BED chunking requires simple intervals.",
|
|
817
|
+
code="INVALID_BED_INTERVAL",
|
|
818
|
+
context={"sequence_id": entry.sequence_id},
|
|
819
|
+
)
|
|
820
|
+
split = entry.feature.id or entry.feature.label or default_split
|
|
821
|
+
_validate_text(split, "BED split label")
|
|
822
|
+
grouped.setdefault(entry.sequence_id, []).append((location.start, location.end, split))
|
|
823
|
+
return {sequence_id: tuple(regions) for sequence_id, regions in grouped.items()}
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _resolve_bed(bed: ChunkBedSource) -> AnnotationDocument:
|
|
827
|
+
if isinstance(bed, AnnotationDocument):
|
|
828
|
+
return bed
|
|
829
|
+
return read_bed(bed)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def iter_fasta_chunks(
|
|
833
|
+
source: ReadableSource,
|
|
834
|
+
*,
|
|
835
|
+
config: ChunkingConfig | None = None,
|
|
836
|
+
bed: ChunkBedSource | None = None,
|
|
837
|
+
read_config: ReadConfig | None = None,
|
|
838
|
+
progress: ChunkProgressCallback | None = None,
|
|
839
|
+
) -> Iterator[SequenceChunk]:
|
|
840
|
+
"""Lazily split FASTA records, optionally restricted and labelled by BED.
|
|
841
|
+
|
|
842
|
+
Without ``bed``, every FASTA record is processed from coordinate 0 to its
|
|
843
|
+
end and uses ``config.split`` (``train`` by default). With BED, each BED
|
|
844
|
+
row selects one interval; its fourth column becomes the output split label
|
|
845
|
+
(for example ``train``, ``valid``, or ``test``). FASTA IDs and BED column
|
|
846
|
+
1 values must match exactly.
|
|
847
|
+
|
|
848
|
+
This function uses DNAKit's record reader, so one FASTA record is held in
|
|
849
|
+
memory at a time. For chromosome-scale records, pass a ``ReadConfig`` with
|
|
850
|
+
sufficiently high input and sequence limits and plan memory accordingly.
|
|
851
|
+
"""
|
|
852
|
+
|
|
853
|
+
resolved_config = ChunkingConfig() if config is None else config
|
|
854
|
+
if not isinstance(resolved_config, ChunkingConfig):
|
|
855
|
+
raise TypeError("config must be ChunkingConfig or None.")
|
|
856
|
+
if read_config is not None and not isinstance(read_config, ReadConfig):
|
|
857
|
+
raise TypeError("read_config must be ReadConfig or None.")
|
|
858
|
+
grouped_bed = (
|
|
859
|
+
None
|
|
860
|
+
if bed is None
|
|
861
|
+
else _bed_regions(_resolve_bed(bed), default_split=resolved_config.split)
|
|
862
|
+
)
|
|
863
|
+
seen_ids: set[str] = set()
|
|
864
|
+
rng = random.Random(resolved_config.seed)
|
|
865
|
+
with read(source, format="fasta", config=read_config) as records:
|
|
866
|
+
for record in records:
|
|
867
|
+
regions = None if grouped_bed is None else grouped_bed.get(record.id, ())
|
|
868
|
+
if grouped_bed is not None and regions:
|
|
869
|
+
seen_ids.add(record.id)
|
|
870
|
+
if grouped_bed is None:
|
|
871
|
+
resolved_end = _validate_region(record.sequence, 0, None)
|
|
872
|
+
yield from _iter_region_chunks(
|
|
873
|
+
record.sequence,
|
|
874
|
+
source_id=record.id,
|
|
875
|
+
split=resolved_config.split,
|
|
876
|
+
start=0,
|
|
877
|
+
end=resolved_end,
|
|
878
|
+
region_index=0,
|
|
879
|
+
config=resolved_config,
|
|
880
|
+
rng=rng,
|
|
881
|
+
progress=progress,
|
|
882
|
+
)
|
|
883
|
+
continue
|
|
884
|
+
assert regions is not None
|
|
885
|
+
for region_index, (start, end, split) in enumerate(regions):
|
|
886
|
+
resolved_end = _validate_region(record.sequence, start, end)
|
|
887
|
+
yield from _iter_region_chunks(
|
|
888
|
+
record.sequence,
|
|
889
|
+
source_id=record.id,
|
|
890
|
+
split=split,
|
|
891
|
+
start=start,
|
|
892
|
+
end=resolved_end,
|
|
893
|
+
region_index=region_index,
|
|
894
|
+
config=resolved_config,
|
|
895
|
+
rng=rng,
|
|
896
|
+
progress=progress,
|
|
897
|
+
)
|
|
898
|
+
if grouped_bed is not None:
|
|
899
|
+
missing = tuple(sequence_id for sequence_id in grouped_bed if sequence_id not in seen_ids)
|
|
900
|
+
if missing:
|
|
901
|
+
raise InputFormatError(
|
|
902
|
+
"BED contains sequence IDs that were not found in FASTA.",
|
|
903
|
+
code="BED_SEQUENCE_NOT_FOUND",
|
|
904
|
+
context={"missing_sequence_ids": missing},
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
__all__ = [
|
|
909
|
+
"ChunkBedSource",
|
|
910
|
+
"ChunkProgress",
|
|
911
|
+
"ChunkProgressCallback",
|
|
912
|
+
"ChunkStrategy",
|
|
913
|
+
"ChunkingConfig",
|
|
914
|
+
"LengthCurriculum",
|
|
915
|
+
"LengthCurriculumStage",
|
|
916
|
+
"SequenceChunk",
|
|
917
|
+
"iter_fasta_chunks",
|
|
918
|
+
"iter_sequence_chunks",
|
|
919
|
+
"make_length_curriculum",
|
|
920
|
+
]
|