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/datasets/split.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""Reproducible random, stable-hash, stratified, group, and similarity splits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import math
|
|
7
|
+
import random
|
|
8
|
+
from collections import deque
|
|
9
|
+
from collections.abc import Hashable, Iterable, Mapping, Sequence
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from dnakit.core import DNARecord, DNASet, Topology
|
|
13
|
+
from dnakit.core._json import FrozenDict
|
|
14
|
+
from dnakit.descriptors import kmer_statistics
|
|
15
|
+
from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
|
|
16
|
+
|
|
17
|
+
from ._metadata import metadata_value_key
|
|
18
|
+
from .config import SplitConfig
|
|
19
|
+
from .results import SplitAssignment, SplitResult, SplitSubset
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _materialize(records: Iterable[DNARecord]) -> tuple[DNARecord, ...]:
|
|
23
|
+
try:
|
|
24
|
+
materialized = tuple(records)
|
|
25
|
+
except TypeError as exc:
|
|
26
|
+
raise ConfigurationError(
|
|
27
|
+
"records must be an iterable of DNARecord objects.",
|
|
28
|
+
code="INVALID_DATASET_RECORDS",
|
|
29
|
+
) from exc
|
|
30
|
+
for index, record in enumerate(materialized):
|
|
31
|
+
if not isinstance(record, DNARecord):
|
|
32
|
+
raise ConfigurationError(
|
|
33
|
+
"Every dataset item must be a DNARecord.",
|
|
34
|
+
code="INVALID_DATASET_RECORD",
|
|
35
|
+
context={"input_index": index, "type": type(record).__name__},
|
|
36
|
+
)
|
|
37
|
+
return materialized
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _materialize_limited(records: Iterable[DNARecord], *, limit: int) -> tuple[DNARecord, ...]:
|
|
41
|
+
"""Materialize no more than ``limit + 1`` items from a pairwise workload."""
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
iterator = iter(records)
|
|
45
|
+
except TypeError as exc:
|
|
46
|
+
raise ConfigurationError(
|
|
47
|
+
"records must be an iterable of DNARecord objects.",
|
|
48
|
+
code="INVALID_DATASET_RECORDS",
|
|
49
|
+
) from exc
|
|
50
|
+
materialized: list[DNARecord] = []
|
|
51
|
+
for index, record in enumerate(iterator):
|
|
52
|
+
if not isinstance(record, DNARecord):
|
|
53
|
+
raise ConfigurationError(
|
|
54
|
+
"Every dataset item must be a DNARecord.",
|
|
55
|
+
code="INVALID_DATASET_RECORD",
|
|
56
|
+
context={"input_index": index, "type": type(record).__name__},
|
|
57
|
+
)
|
|
58
|
+
materialized.append(record)
|
|
59
|
+
if len(materialized) > limit:
|
|
60
|
+
raise ConfigurationError(
|
|
61
|
+
"Similarity split input exceeds max_pairwise_records.",
|
|
62
|
+
code="SIMILARITY_SPLIT_SIZE_LIMIT",
|
|
63
|
+
context={
|
|
64
|
+
"consumed_record_count": len(materialized),
|
|
65
|
+
"limit": limit,
|
|
66
|
+
"record_count_is_lower_bound": True,
|
|
67
|
+
},
|
|
68
|
+
hint="Pre-cluster records externally or explicitly raise the safety limit.",
|
|
69
|
+
)
|
|
70
|
+
return tuple(materialized)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _allocate_counts(size: int, ratios: Mapping[str, float]) -> dict[str, int]:
|
|
74
|
+
exact = {name: size * ratio for name, ratio in ratios.items()}
|
|
75
|
+
allocated = {name: math.floor(value) for name, value in exact.items()}
|
|
76
|
+
remainder = size - sum(allocated.values())
|
|
77
|
+
order = sorted(ratios, key=lambda name: (-(exact[name] - allocated[name]), name))
|
|
78
|
+
for name in order[:remainder]:
|
|
79
|
+
allocated[name] += 1
|
|
80
|
+
return allocated
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _assign_individuals(
|
|
84
|
+
indices: Sequence[int],
|
|
85
|
+
ratios: Mapping[str, float],
|
|
86
|
+
*,
|
|
87
|
+
generator: random.Random,
|
|
88
|
+
shuffle: bool,
|
|
89
|
+
) -> dict[int, str]:
|
|
90
|
+
ordered = list(indices)
|
|
91
|
+
if shuffle:
|
|
92
|
+
generator.shuffle(ordered)
|
|
93
|
+
return _assign_ordered_individuals(ordered, ratios)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _assign_ordered_individuals(
|
|
97
|
+
ordered: Sequence[int], ratios: Mapping[str, float]
|
|
98
|
+
) -> dict[int, str]:
|
|
99
|
+
counts = _allocate_counts(len(ordered), ratios)
|
|
100
|
+
assigned: dict[int, str] = {}
|
|
101
|
+
cursor = 0
|
|
102
|
+
for name in ratios:
|
|
103
|
+
end = cursor + counts[name]
|
|
104
|
+
for index in ordered[cursor:end]:
|
|
105
|
+
assigned[index] = name
|
|
106
|
+
cursor = end
|
|
107
|
+
return assigned
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _stable_hash_digest(seed: int, record_id: str) -> bytes:
|
|
111
|
+
"""Return the versioned, cross-process hash key for one record ID."""
|
|
112
|
+
|
|
113
|
+
payload = (
|
|
114
|
+
b"dnakit.split.hash.v1\0" + str(seed).encode("ascii") + b"\0" + record_id.encode("utf-8")
|
|
115
|
+
)
|
|
116
|
+
return hashlib.sha256(payload).digest()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _assign_hashed_individuals(
|
|
120
|
+
records: Sequence[DNARecord],
|
|
121
|
+
ratios: Mapping[str, float],
|
|
122
|
+
*,
|
|
123
|
+
seed: int,
|
|
124
|
+
shuffle: bool,
|
|
125
|
+
) -> dict[int, str]:
|
|
126
|
+
"""Assign records by stable ID hash, independently of input order."""
|
|
127
|
+
|
|
128
|
+
seen: set[str] = set()
|
|
129
|
+
duplicate_ids: set[str] = set()
|
|
130
|
+
for record in records:
|
|
131
|
+
if record.id in seen:
|
|
132
|
+
duplicate_ids.add(record.id)
|
|
133
|
+
seen.add(record.id)
|
|
134
|
+
if duplicate_ids:
|
|
135
|
+
raise ConfigurationError(
|
|
136
|
+
"Hash split requires unique record IDs.",
|
|
137
|
+
code="HASH_SPLIT_DUPLICATE_RECORD_ID",
|
|
138
|
+
context={"duplicate_record_ids": tuple(sorted(duplicate_ids))},
|
|
139
|
+
hint="Assign a stable unique ID to every record before using method='hash'.",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if shuffle:
|
|
143
|
+
ordered = sorted(
|
|
144
|
+
range(len(records)),
|
|
145
|
+
key=lambda index: (
|
|
146
|
+
_stable_hash_digest(seed, records[index].id),
|
|
147
|
+
records[index].id.encode("utf-8"),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
ordered = sorted(range(len(records)), key=lambda index: records[index].id.encode("utf-8"))
|
|
152
|
+
return _assign_ordered_individuals(ordered, ratios)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _assign_strata(
|
|
156
|
+
strata: Sequence[tuple[int, ...]],
|
|
157
|
+
ratios: Mapping[str, float],
|
|
158
|
+
*,
|
|
159
|
+
total_size: int,
|
|
160
|
+
generator: random.Random,
|
|
161
|
+
shuffle: bool,
|
|
162
|
+
) -> dict[int, str]:
|
|
163
|
+
"""Allocate strata against global quotas instead of rounding each stratum alone."""
|
|
164
|
+
|
|
165
|
+
members = [list(stratum) for stratum in strata]
|
|
166
|
+
if shuffle:
|
|
167
|
+
generator.shuffle(members)
|
|
168
|
+
for stratum in members:
|
|
169
|
+
generator.shuffle(stratum)
|
|
170
|
+
targets = _allocate_counts(total_size, ratios)
|
|
171
|
+
remaining = dict(targets)
|
|
172
|
+
split_order = {name: index for index, name in enumerate(ratios)}
|
|
173
|
+
assigned_per_stratum = [{name: 0 for name in ratios} for _ in members]
|
|
174
|
+
offsets = [0 for _ in members]
|
|
175
|
+
active = deque(index for index, stratum in enumerate(members) if stratum)
|
|
176
|
+
assignment: dict[int, str] = {}
|
|
177
|
+
|
|
178
|
+
while active:
|
|
179
|
+
stratum_index = active.popleft()
|
|
180
|
+
stratum = members[stratum_index]
|
|
181
|
+
local_counts = assigned_per_stratum[stratum_index]
|
|
182
|
+
candidates = [name for name in ratios if remaining[name] > 0]
|
|
183
|
+
if not candidates:
|
|
184
|
+
raise AssertionError("Global split quotas were exhausted before assignment completed.")
|
|
185
|
+
stratum_size = len(stratum)
|
|
186
|
+
|
|
187
|
+
def priority(
|
|
188
|
+
name: str,
|
|
189
|
+
stratum_size: int = stratum_size,
|
|
190
|
+
local_counts: dict[str, int] = local_counts,
|
|
191
|
+
) -> tuple[float, float, int]:
|
|
192
|
+
local_deficit = stratum_size * ratios[name] - local_counts[name]
|
|
193
|
+
target = targets[name]
|
|
194
|
+
global_pressure = remaining[name] / target if target else 0.0
|
|
195
|
+
return local_deficit, global_pressure, -split_order[name]
|
|
196
|
+
|
|
197
|
+
chosen = max(candidates, key=priority)
|
|
198
|
+
record_index = stratum[offsets[stratum_index]]
|
|
199
|
+
assignment[record_index] = chosen
|
|
200
|
+
local_counts[chosen] += 1
|
|
201
|
+
remaining[chosen] -= 1
|
|
202
|
+
offsets[stratum_index] += 1
|
|
203
|
+
if offsets[stratum_index] < len(stratum):
|
|
204
|
+
active.append(stratum_index)
|
|
205
|
+
|
|
206
|
+
if any(remaining.values()):
|
|
207
|
+
raise AssertionError("Global split quotas were not filled by stratified assignment.")
|
|
208
|
+
return assignment
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class _MissingMetadataGroup:
|
|
212
|
+
"""Identity-only key that cannot be represented by user JSON metadata."""
|
|
213
|
+
|
|
214
|
+
__slots__ = ()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _metadata_units(
|
|
218
|
+
records: tuple[DNARecord, ...],
|
|
219
|
+
key: str,
|
|
220
|
+
*,
|
|
221
|
+
missing_policy: str,
|
|
222
|
+
) -> tuple[tuple[int, ...], ...]:
|
|
223
|
+
grouped: dict[Hashable, list[int]] = {}
|
|
224
|
+
for index, record in enumerate(records):
|
|
225
|
+
if key not in record.metadata:
|
|
226
|
+
if missing_policy == "error":
|
|
227
|
+
raise ConfigurationError(
|
|
228
|
+
"A record is missing the required split metadata.",
|
|
229
|
+
code="SPLIT_METADATA_MISSING",
|
|
230
|
+
context={"input_index": index, "record_id": record.id, "metadata_key": key},
|
|
231
|
+
)
|
|
232
|
+
group: Hashable = _MissingMetadataGroup()
|
|
233
|
+
else:
|
|
234
|
+
group = metadata_value_key(record.metadata[key])
|
|
235
|
+
grouped.setdefault(group, []).append(index)
|
|
236
|
+
return tuple(tuple(indices) for indices in grouped.values())
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _assign_units(
|
|
240
|
+
units: Sequence[tuple[int, ...]],
|
|
241
|
+
ratios: Mapping[str, float],
|
|
242
|
+
*,
|
|
243
|
+
total_size: int,
|
|
244
|
+
generator: random.Random,
|
|
245
|
+
shuffle: bool,
|
|
246
|
+
) -> dict[int, str]:
|
|
247
|
+
ordered = list(units)
|
|
248
|
+
if shuffle:
|
|
249
|
+
generator.shuffle(ordered)
|
|
250
|
+
ordered.sort(key=len, reverse=True)
|
|
251
|
+
targets = {name: total_size * ratio for name, ratio in ratios.items()}
|
|
252
|
+
counts = {name: 0 for name in ratios}
|
|
253
|
+
assignment: dict[int, str] = {}
|
|
254
|
+
for unit in ordered:
|
|
255
|
+
unit_size = len(unit)
|
|
256
|
+
|
|
257
|
+
def objective(candidate: str, unit_size: int = unit_size) -> tuple[float, int]:
|
|
258
|
+
score = 0.0
|
|
259
|
+
for name in ratios:
|
|
260
|
+
projected = counts[name] + (unit_size if name == candidate else 0)
|
|
261
|
+
denominator = max(targets[name], 1.0)
|
|
262
|
+
score += ((projected - targets[name]) ** 2) / denominator
|
|
263
|
+
return score, tuple(ratios).index(candidate)
|
|
264
|
+
|
|
265
|
+
chosen = min(ratios, key=objective)
|
|
266
|
+
counts[chosen] += unit_size
|
|
267
|
+
assignment.update((index, chosen) for index in unit)
|
|
268
|
+
return assignment
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class _UnionFind:
|
|
272
|
+
def __init__(self, size: int) -> None:
|
|
273
|
+
self.parent = list(range(size))
|
|
274
|
+
|
|
275
|
+
def find(self, value: int) -> int:
|
|
276
|
+
root = value
|
|
277
|
+
while self.parent[root] != root:
|
|
278
|
+
root = self.parent[root]
|
|
279
|
+
while self.parent[value] != value:
|
|
280
|
+
parent = self.parent[value]
|
|
281
|
+
self.parent[value] = root
|
|
282
|
+
value = parent
|
|
283
|
+
return root
|
|
284
|
+
|
|
285
|
+
def union(self, left: int, right: int) -> None:
|
|
286
|
+
left_root = self.find(left)
|
|
287
|
+
right_root = self.find(right)
|
|
288
|
+
if left_root != right_root:
|
|
289
|
+
self.parent[right_root] = left_root
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _jaccard(left: frozenset[str], right: frozenset[str]) -> float:
|
|
293
|
+
union = left | right
|
|
294
|
+
return len(left & right) / len(union) if union else 1.0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _similarity_units(
|
|
298
|
+
records: tuple[DNARecord, ...], config: SplitConfig
|
|
299
|
+
) -> tuple[tuple[tuple[int, ...], ...], int]:
|
|
300
|
+
if len(records) > config.max_pairwise_records:
|
|
301
|
+
raise ConfigurationError(
|
|
302
|
+
"Similarity split input exceeds max_pairwise_records.",
|
|
303
|
+
code="SIMILARITY_SPLIT_SIZE_LIMIT",
|
|
304
|
+
context={"record_count": len(records), "limit": config.max_pairwise_records},
|
|
305
|
+
hint="Pre-cluster records externally or explicitly raise the safety limit.",
|
|
306
|
+
)
|
|
307
|
+
fingerprints: list[frozenset[str]] = []
|
|
308
|
+
for index, record in enumerate(records):
|
|
309
|
+
if record.sequence.topology is Topology.CIRCULAR:
|
|
310
|
+
raise ConfigurationError(
|
|
311
|
+
"Basic similarity split does not model circular origin wrapping.",
|
|
312
|
+
code="CIRCULAR_SIMILARITY_SPLIT_UNSUPPORTED",
|
|
313
|
+
context={"input_index": index, "record_id": record.id},
|
|
314
|
+
)
|
|
315
|
+
if record.sequence.is_gapped and config.similarity_gap_policy == "error":
|
|
316
|
+
raise UnsupportedGapOperationError(
|
|
317
|
+
"Basic similarity split does not silently omit or cross sequence Gaps.",
|
|
318
|
+
code="SIMILARITY_SPLIT_GAP_NOT_ALLOWED",
|
|
319
|
+
context={"input_index": index, "record_id": record.id},
|
|
320
|
+
hint="Choose similarity_gap_policy='split' to treat fragments independently.",
|
|
321
|
+
)
|
|
322
|
+
statistics = kmer_statistics(
|
|
323
|
+
record.sequence,
|
|
324
|
+
config.similarity_k,
|
|
325
|
+
ambiguity_policy=config.similarity_ambiguity_policy,
|
|
326
|
+
cross_gaps=False,
|
|
327
|
+
)
|
|
328
|
+
fingerprints.append(frozenset(statistics.counts))
|
|
329
|
+
components = _UnionFind(len(records))
|
|
330
|
+
comparisons = 0
|
|
331
|
+
for left in range(len(records)):
|
|
332
|
+
for right in range(left + 1, len(records)):
|
|
333
|
+
comparisons += 1
|
|
334
|
+
if _jaccard(fingerprints[left], fingerprints[right]) >= config.similarity_threshold:
|
|
335
|
+
components.union(left, right)
|
|
336
|
+
grouped: dict[int, list[int]] = {}
|
|
337
|
+
for index in range(len(records)):
|
|
338
|
+
grouped.setdefault(components.find(index), []).append(index)
|
|
339
|
+
return tuple(tuple(indices) for indices in grouped.values()), comparisons
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def split(records: Iterable[DNARecord], *, config: SplitConfig) -> SplitResult:
|
|
343
|
+
"""Split records reproducibly while keeping declared atomic groups intact."""
|
|
344
|
+
|
|
345
|
+
if not isinstance(config, SplitConfig):
|
|
346
|
+
raise ConfigurationError(
|
|
347
|
+
"config must be a SplitConfig.",
|
|
348
|
+
code="INVALID_SPLIT_CONFIG",
|
|
349
|
+
)
|
|
350
|
+
materialized = (
|
|
351
|
+
_materialize_limited(records, limit=config.max_pairwise_records)
|
|
352
|
+
if config.method == "similarity"
|
|
353
|
+
else _materialize(records)
|
|
354
|
+
)
|
|
355
|
+
ratios = config.ratios
|
|
356
|
+
generator = random.Random(config.seed)
|
|
357
|
+
component_count: int | None = None
|
|
358
|
+
comparisons = 0
|
|
359
|
+
if config.method == "random":
|
|
360
|
+
assignment = _assign_individuals(
|
|
361
|
+
tuple(range(len(materialized))),
|
|
362
|
+
ratios,
|
|
363
|
+
generator=generator,
|
|
364
|
+
shuffle=config.shuffle,
|
|
365
|
+
)
|
|
366
|
+
elif config.method == "hash":
|
|
367
|
+
assignment = _assign_hashed_individuals(
|
|
368
|
+
materialized,
|
|
369
|
+
ratios,
|
|
370
|
+
seed=config.seed,
|
|
371
|
+
shuffle=config.shuffle,
|
|
372
|
+
)
|
|
373
|
+
elif config.method == "stratified":
|
|
374
|
+
assert config.metadata_key is not None
|
|
375
|
+
strata = _metadata_units(
|
|
376
|
+
materialized,
|
|
377
|
+
config.metadata_key,
|
|
378
|
+
missing_policy=config.missing_metadata_policy,
|
|
379
|
+
)
|
|
380
|
+
assignment = _assign_strata(
|
|
381
|
+
strata,
|
|
382
|
+
ratios,
|
|
383
|
+
total_size=len(materialized),
|
|
384
|
+
generator=generator,
|
|
385
|
+
shuffle=config.shuffle,
|
|
386
|
+
)
|
|
387
|
+
else:
|
|
388
|
+
if config.method == "group":
|
|
389
|
+
assert config.metadata_key is not None
|
|
390
|
+
units = _metadata_units(
|
|
391
|
+
materialized,
|
|
392
|
+
config.metadata_key,
|
|
393
|
+
missing_policy=config.missing_metadata_policy,
|
|
394
|
+
)
|
|
395
|
+
else:
|
|
396
|
+
units, comparisons = _similarity_units(materialized, config)
|
|
397
|
+
component_count = len(units)
|
|
398
|
+
assignment = _assign_units(
|
|
399
|
+
units,
|
|
400
|
+
ratios,
|
|
401
|
+
total_size=len(materialized),
|
|
402
|
+
generator=generator,
|
|
403
|
+
shuffle=config.shuffle,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
indices_by_split: dict[str, list[int]] = {name: [] for name in ratios}
|
|
407
|
+
for index in range(len(materialized)):
|
|
408
|
+
indices_by_split[assignment[index]].append(index)
|
|
409
|
+
if not config.preserve_order:
|
|
410
|
+
rank = {index: rank for rank, index in enumerate(assignment)}
|
|
411
|
+
for indices in indices_by_split.values():
|
|
412
|
+
indices.sort(key=rank.__getitem__)
|
|
413
|
+
subsets = tuple(
|
|
414
|
+
SplitSubset(name, DNASet(materialized[index] for index in indices_by_split[name]))
|
|
415
|
+
for name in ratios
|
|
416
|
+
)
|
|
417
|
+
assignments = tuple(
|
|
418
|
+
SplitAssignment(index, record.id, assignment[index])
|
|
419
|
+
for index, record in enumerate(materialized)
|
|
420
|
+
)
|
|
421
|
+
counts = FrozenDict({name: len(indices_by_split[name]) for name in ratios})
|
|
422
|
+
return SplitResult(
|
|
423
|
+
subsets=subsets,
|
|
424
|
+
assignments=assignments,
|
|
425
|
+
method=config.method,
|
|
426
|
+
ratios=cast(FrozenDict, config.ratios),
|
|
427
|
+
counts=counts,
|
|
428
|
+
seed=config.seed,
|
|
429
|
+
preserve_order=config.preserve_order,
|
|
430
|
+
metadata_key=config.metadata_key,
|
|
431
|
+
component_count=component_count,
|
|
432
|
+
pairwise_comparison_count=comparisons,
|
|
433
|
+
similarity_method="kmer_jaccard" if config.method == "similarity" else None,
|
|
434
|
+
similarity_threshold=(
|
|
435
|
+
float(config.similarity_threshold) if config.method == "similarity" else None
|
|
436
|
+
),
|
|
437
|
+
shuffle=config.shuffle,
|
|
438
|
+
similarity_k=config.similarity_k,
|
|
439
|
+
max_pairwise_records=config.max_pairwise_records,
|
|
440
|
+
assignment_strategy={
|
|
441
|
+
"random": "global_largest_remainder_individual",
|
|
442
|
+
"hash": "sha256_record_id_rank_v1_largest_remainder",
|
|
443
|
+
"stratified": "global_quota_round_robin_stratified",
|
|
444
|
+
"group": "atomic_unit_greedy_target_error",
|
|
445
|
+
"similarity": "connected_component_atomic_unit_greedy_target_error",
|
|
446
|
+
}[config.method],
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
__all__ = ["split"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Deterministic, dependency-free DNA sequence descriptors."""
|
|
2
|
+
|
|
3
|
+
from dnakit.descriptors._dinucleotide import (
|
|
4
|
+
DEFAULT_MAX_DINUCLEOTIDE_TABLE_BYTES,
|
|
5
|
+
DINUCLEOTIDE_PROPERTY_SPECS,
|
|
6
|
+
DINUCLEOTIDE_TABLE_SCHEMA_VERSION,
|
|
7
|
+
DINUCLEOTIDES,
|
|
8
|
+
DinucleotideProperty,
|
|
9
|
+
DinucleotidePropertySpec,
|
|
10
|
+
DinucleotidePropertyTable,
|
|
11
|
+
load_dinucleotide_property_table,
|
|
12
|
+
)
|
|
13
|
+
from dnakit.descriptors._shared import DescriptorAmbiguityPolicy, SequenceInput
|
|
14
|
+
from dnakit.descriptors.all import all_descriptors
|
|
15
|
+
from dnakit.descriptors.basic import (
|
|
16
|
+
base_composition,
|
|
17
|
+
base_skew,
|
|
18
|
+
cpg_features,
|
|
19
|
+
gc_at_content,
|
|
20
|
+
length_features,
|
|
21
|
+
)
|
|
22
|
+
from dnakit.descriptors.codon import codon_statistics
|
|
23
|
+
from dnakit.descriptors.complexity import exact_repeat_fraction, linguistic_complexity
|
|
24
|
+
from dnakit.descriptors.entropy import shannon_entropy
|
|
25
|
+
from dnakit.descriptors.homopolymer import homopolymer_runs
|
|
26
|
+
from dnakit.descriptors.kmer import canonical_kmer, kmer_statistics
|
|
27
|
+
from dnakit.descriptors.results import (
|
|
28
|
+
AllDescriptorsResult,
|
|
29
|
+
CodonResult,
|
|
30
|
+
ComplexityResult,
|
|
31
|
+
CompositionResult,
|
|
32
|
+
ContentResult,
|
|
33
|
+
CpGResult,
|
|
34
|
+
DescriptorResult,
|
|
35
|
+
EntropyResult,
|
|
36
|
+
ExactRepeatResult,
|
|
37
|
+
HomopolymerResult,
|
|
38
|
+
HomopolymerRun,
|
|
39
|
+
KmerResult,
|
|
40
|
+
LengthResult,
|
|
41
|
+
RepeatRun,
|
|
42
|
+
SkewResult,
|
|
43
|
+
WindowDescriptorResult,
|
|
44
|
+
WindowResult,
|
|
45
|
+
)
|
|
46
|
+
from dnakit.descriptors.schema import (
|
|
47
|
+
DESCRIPTOR_NAMES_V1,
|
|
48
|
+
DESCRIPTOR_SCHEMA_V1,
|
|
49
|
+
DESCRIPTOR_SCHEMA_VERSION,
|
|
50
|
+
DescriptorField,
|
|
51
|
+
descriptor_schema_v1,
|
|
52
|
+
)
|
|
53
|
+
from dnakit.descriptors.window import window_descriptors
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"DEFAULT_MAX_DINUCLEOTIDE_TABLE_BYTES",
|
|
57
|
+
"DESCRIPTOR_NAMES_V1",
|
|
58
|
+
"DESCRIPTOR_SCHEMA_V1",
|
|
59
|
+
"DESCRIPTOR_SCHEMA_VERSION",
|
|
60
|
+
"DINUCLEOTIDES",
|
|
61
|
+
"DINUCLEOTIDE_PROPERTY_SPECS",
|
|
62
|
+
"DINUCLEOTIDE_TABLE_SCHEMA_VERSION",
|
|
63
|
+
"AllDescriptorsResult",
|
|
64
|
+
"CodonResult",
|
|
65
|
+
"ComplexityResult",
|
|
66
|
+
"CompositionResult",
|
|
67
|
+
"ContentResult",
|
|
68
|
+
"CpGResult",
|
|
69
|
+
"DescriptorAmbiguityPolicy",
|
|
70
|
+
"DescriptorField",
|
|
71
|
+
"DescriptorResult",
|
|
72
|
+
"DinucleotideProperty",
|
|
73
|
+
"DinucleotidePropertySpec",
|
|
74
|
+
"DinucleotidePropertyTable",
|
|
75
|
+
"EntropyResult",
|
|
76
|
+
"ExactRepeatResult",
|
|
77
|
+
"HomopolymerResult",
|
|
78
|
+
"HomopolymerRun",
|
|
79
|
+
"KmerResult",
|
|
80
|
+
"LengthResult",
|
|
81
|
+
"RepeatRun",
|
|
82
|
+
"SequenceInput",
|
|
83
|
+
"SkewResult",
|
|
84
|
+
"WindowDescriptorResult",
|
|
85
|
+
"WindowResult",
|
|
86
|
+
"all_descriptors",
|
|
87
|
+
"base_composition",
|
|
88
|
+
"base_skew",
|
|
89
|
+
"canonical_kmer",
|
|
90
|
+
"codon_statistics",
|
|
91
|
+
"cpg_features",
|
|
92
|
+
"descriptor_schema_v1",
|
|
93
|
+
"exact_repeat_fraction",
|
|
94
|
+
"gc_at_content",
|
|
95
|
+
"homopolymer_runs",
|
|
96
|
+
"kmer_statistics",
|
|
97
|
+
"length_features",
|
|
98
|
+
"linguistic_complexity",
|
|
99
|
+
"load_dinucleotide_property_table",
|
|
100
|
+
"shannon_entropy",
|
|
101
|
+
"window_descriptors",
|
|
102
|
+
]
|