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
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Temporal and explicitly heuristic multi-constraint splitting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
from collections.abc import Iterable, Mapping
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from itertools import pairwise
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from dnakit.core import DNARecord, DNASet
|
|
13
|
+
from dnakit.core._json import FrozenDict
|
|
14
|
+
from dnakit.exceptions import ConfigurationError
|
|
15
|
+
|
|
16
|
+
from ._advanced_shared import (
|
|
17
|
+
UnionFind,
|
|
18
|
+
ensure_pair_limit,
|
|
19
|
+
materialize_limited,
|
|
20
|
+
pair_similarity,
|
|
21
|
+
validate_pairwise_sequences,
|
|
22
|
+
)
|
|
23
|
+
from ._metadata import metadata_value_key
|
|
24
|
+
from .config import JointSplitConfig, TemporalSplitConfig
|
|
25
|
+
from .results import (
|
|
26
|
+
JointSplitResult,
|
|
27
|
+
SplitAssignment,
|
|
28
|
+
SplitSubset,
|
|
29
|
+
TemporalSplitResult,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _allocate_counts(size: int, ratios: Mapping[str, object]) -> dict[str, int]:
|
|
34
|
+
numeric = {name: float(cast(int | float, value)) for name, value in ratios.items()}
|
|
35
|
+
exact = {name: size * ratio for name, ratio in numeric.items()}
|
|
36
|
+
counts = {name: math.floor(value) for name, value in exact.items()}
|
|
37
|
+
remainder = size - sum(counts.values())
|
|
38
|
+
order = sorted(numeric, key=lambda name: (-(exact[name] - counts[name]), name))
|
|
39
|
+
for name in order[:remainder]:
|
|
40
|
+
counts[name] += 1
|
|
41
|
+
return counts
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_iso(value: object, *, index: int, key: str) -> datetime:
|
|
45
|
+
if not isinstance(value, str) or not value.strip():
|
|
46
|
+
raise ConfigurationError(
|
|
47
|
+
"Temporal metadata must be a non-empty ISO-8601 string.",
|
|
48
|
+
code="INVALID_TEMPORAL_METADATA",
|
|
49
|
+
context={"input_index": index, "metadata_key": key},
|
|
50
|
+
)
|
|
51
|
+
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
52
|
+
try:
|
|
53
|
+
result = datetime.fromisoformat(normalized)
|
|
54
|
+
except ValueError as exc:
|
|
55
|
+
raise ConfigurationError(
|
|
56
|
+
"Temporal metadata is not valid ISO-8601.",
|
|
57
|
+
code="INVALID_TEMPORAL_METADATA",
|
|
58
|
+
context={"input_index": index, "value": value},
|
|
59
|
+
) from exc
|
|
60
|
+
if result.tzinfo is not None:
|
|
61
|
+
result = result.astimezone(timezone.utc).replace(tzinfo=None)
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def temporal_split(
|
|
66
|
+
records: Iterable[DNARecord],
|
|
67
|
+
*,
|
|
68
|
+
config: TemporalSplitConfig | None = None,
|
|
69
|
+
) -> TemporalSplitResult:
|
|
70
|
+
"""Assign oldest records first using explicit cutoffs or target chronological ratios."""
|
|
71
|
+
|
|
72
|
+
resolved = TemporalSplitConfig() if config is None else config
|
|
73
|
+
if not isinstance(resolved, TemporalSplitConfig):
|
|
74
|
+
raise ConfigurationError("config must be TemporalSplitConfig.")
|
|
75
|
+
materialized = materialize_limited(records, max_records=resolved.max_records)
|
|
76
|
+
dated: list[tuple[datetime, int]] = []
|
|
77
|
+
for index, record in enumerate(materialized):
|
|
78
|
+
if resolved.metadata_key not in record.metadata:
|
|
79
|
+
raise ConfigurationError(
|
|
80
|
+
"A record is missing temporal metadata.",
|
|
81
|
+
code="TEMPORAL_METADATA_MISSING",
|
|
82
|
+
context={"input_index": index, "record_id": record.id},
|
|
83
|
+
)
|
|
84
|
+
dated.append(
|
|
85
|
+
(
|
|
86
|
+
_parse_iso(
|
|
87
|
+
record.metadata[resolved.metadata_key], index=index, key=resolved.metadata_key
|
|
88
|
+
),
|
|
89
|
+
index,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
dated.sort(key=lambda item: (item[0], item[1]))
|
|
93
|
+
split_names = tuple(resolved.ratios)
|
|
94
|
+
assignment: dict[int, str] = {}
|
|
95
|
+
cutoff_strings: tuple[str, ...]
|
|
96
|
+
if resolved.cutoffs is None:
|
|
97
|
+
counts = _allocate_counts(len(materialized), resolved.ratios)
|
|
98
|
+
cursor = 0
|
|
99
|
+
for name in split_names:
|
|
100
|
+
for _, index in dated[cursor : cursor + counts[name]]:
|
|
101
|
+
assignment[index] = name
|
|
102
|
+
cursor += counts[name]
|
|
103
|
+
cutoff_strings = tuple(
|
|
104
|
+
dated[sum(counts[name] for name in split_names[:position]) - 1][0].isoformat()
|
|
105
|
+
for position in range(1, len(split_names))
|
|
106
|
+
if sum(counts[name] for name in split_names[:position]) > 0
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
parsed_cutoffs = tuple(
|
|
110
|
+
_parse_iso(value, index=index, key="cutoffs")
|
|
111
|
+
for index, value in enumerate(resolved.cutoffs)
|
|
112
|
+
)
|
|
113
|
+
if any(right <= left for left, right in pairwise(parsed_cutoffs)):
|
|
114
|
+
raise ConfigurationError(
|
|
115
|
+
"Temporal cutoffs must be strictly increasing.",
|
|
116
|
+
code="INVALID_TEMPORAL_CUTOFFS",
|
|
117
|
+
)
|
|
118
|
+
for date, index in dated:
|
|
119
|
+
split_position = sum(date > cutoff for cutoff in parsed_cutoffs)
|
|
120
|
+
assignment[index] = split_names[split_position]
|
|
121
|
+
cutoff_strings = resolved.cutoffs
|
|
122
|
+
counts = {name: sum(value == name for value in assignment.values()) for name in split_names}
|
|
123
|
+
indices = {
|
|
124
|
+
name: [index for index in range(len(materialized)) if assignment[index] == name]
|
|
125
|
+
for name in split_names
|
|
126
|
+
}
|
|
127
|
+
if not resolved.preserve_order:
|
|
128
|
+
chronological_rank = {index: rank for rank, (_, index) in enumerate(dated)}
|
|
129
|
+
for values in indices.values():
|
|
130
|
+
values.sort(key=chronological_rank.__getitem__)
|
|
131
|
+
return TemporalSplitResult(
|
|
132
|
+
tuple(
|
|
133
|
+
SplitSubset(name, DNASet(materialized[index] for index in indices[name]))
|
|
134
|
+
for name in split_names
|
|
135
|
+
),
|
|
136
|
+
tuple(
|
|
137
|
+
SplitAssignment(index, record.id, assignment[index])
|
|
138
|
+
for index, record in enumerate(materialized)
|
|
139
|
+
),
|
|
140
|
+
FrozenDict(counts),
|
|
141
|
+
cast(FrozenDict, resolved.ratios),
|
|
142
|
+
resolved.metadata_key,
|
|
143
|
+
cutoff_strings,
|
|
144
|
+
"chronological-explicit-cutoffs" if resolved.cutoffs else "chronological-largest-remainder",
|
|
145
|
+
resolved.preserve_order,
|
|
146
|
+
"naive timestamps are interpreted as UTC; aware timestamps are normalized to UTC",
|
|
147
|
+
resolved.max_records,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _joint_units(
|
|
152
|
+
records: tuple[DNARecord, ...],
|
|
153
|
+
config: JointSplitConfig,
|
|
154
|
+
) -> tuple[tuple[tuple[int, ...], ...], int]:
|
|
155
|
+
union = UnionFind(len(records))
|
|
156
|
+
for key in config.group_keys:
|
|
157
|
+
grouped: dict[object, int] = {}
|
|
158
|
+
for index, record in enumerate(records):
|
|
159
|
+
if key not in record.metadata:
|
|
160
|
+
raise ConfigurationError(
|
|
161
|
+
"Joint split group metadata is missing.",
|
|
162
|
+
code="JOINT_SPLIT_METADATA_MISSING",
|
|
163
|
+
context={"input_index": index, "metadata_key": key},
|
|
164
|
+
)
|
|
165
|
+
typed_key = metadata_value_key(record.metadata[key])
|
|
166
|
+
if typed_key in grouped:
|
|
167
|
+
union.union(grouped[typed_key], index)
|
|
168
|
+
else:
|
|
169
|
+
grouped[typed_key] = index
|
|
170
|
+
comparisons = 0
|
|
171
|
+
if config.similarity_threshold is not None:
|
|
172
|
+
validate_pairwise_sequences(records, operation="joint_split similarity constraint")
|
|
173
|
+
comparisons = ensure_pair_limit(len(records), config.max_pairwise_comparisons)
|
|
174
|
+
for left in range(len(records)):
|
|
175
|
+
for right in range(left + 1, len(records)):
|
|
176
|
+
value = pair_similarity(
|
|
177
|
+
records[left],
|
|
178
|
+
records[right],
|
|
179
|
+
method="kmer",
|
|
180
|
+
k=config.similarity_k,
|
|
181
|
+
canonical=config.similarity_canonical,
|
|
182
|
+
max_alignment_cells=1,
|
|
183
|
+
)
|
|
184
|
+
if value >= config.similarity_threshold:
|
|
185
|
+
union.union(left, right)
|
|
186
|
+
return union.groups(), comparisons
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def joint_split(
|
|
190
|
+
records: Iterable[DNARecord],
|
|
191
|
+
*,
|
|
192
|
+
config: JointSplitConfig,
|
|
193
|
+
) -> JointSplitResult:
|
|
194
|
+
"""Greedily assign atomic constraint components; never claim global optimality."""
|
|
195
|
+
|
|
196
|
+
if not isinstance(config, JointSplitConfig):
|
|
197
|
+
raise ConfigurationError("config must be JointSplitConfig.")
|
|
198
|
+
materialized = materialize_limited(records, max_records=config.max_records)
|
|
199
|
+
units, comparisons = _joint_units(materialized, config)
|
|
200
|
+
targets = _allocate_counts(len(materialized), config.ratios)
|
|
201
|
+
split_names = tuple(config.ratios)
|
|
202
|
+
counts = {name: 0 for name in split_names}
|
|
203
|
+
assignment: dict[int, str] = {}
|
|
204
|
+
generator = random.Random(config.seed)
|
|
205
|
+
ordered = list(units)
|
|
206
|
+
generator.shuffle(ordered)
|
|
207
|
+
ordered.sort(key=lambda unit: -len(unit))
|
|
208
|
+
label_totals: dict[object, int] = {}
|
|
209
|
+
if config.label_key is not None:
|
|
210
|
+
for index, record in enumerate(materialized):
|
|
211
|
+
if config.label_key not in record.metadata:
|
|
212
|
+
raise ConfigurationError(
|
|
213
|
+
"Joint split label metadata is missing.",
|
|
214
|
+
code="JOINT_SPLIT_METADATA_MISSING",
|
|
215
|
+
context={"input_index": index, "metadata_key": config.label_key},
|
|
216
|
+
)
|
|
217
|
+
label = metadata_value_key(record.metadata[config.label_key])
|
|
218
|
+
label_totals[label] = label_totals.get(label, 0) + 1
|
|
219
|
+
label_counts: dict[str, dict[object, int]] = {name: {} for name in split_names}
|
|
220
|
+
for unit in ordered:
|
|
221
|
+
unit_labels: dict[object, int] = {}
|
|
222
|
+
if config.label_key is not None:
|
|
223
|
+
for index in unit:
|
|
224
|
+
label = metadata_value_key(materialized[index].metadata[config.label_key])
|
|
225
|
+
unit_labels[label] = unit_labels.get(label, 0) + 1
|
|
226
|
+
|
|
227
|
+
def objective(
|
|
228
|
+
name: str,
|
|
229
|
+
unit: tuple[int, ...] = unit,
|
|
230
|
+
unit_labels: dict[object, int] = unit_labels,
|
|
231
|
+
) -> tuple[float, int]:
|
|
232
|
+
projected = counts[name] + len(unit)
|
|
233
|
+
ratio_penalty = ((projected - targets[name]) / max(targets[name], 1)) ** 2
|
|
234
|
+
label_penalty = 0.0
|
|
235
|
+
for label, total in label_totals.items():
|
|
236
|
+
desired = total * float(cast(int | float, config.ratios[name]))
|
|
237
|
+
current = label_counts[name].get(label, 0) + unit_labels.get(label, 0)
|
|
238
|
+
label_penalty += ((current - desired) / max(desired, 1.0)) ** 2
|
|
239
|
+
return ratio_penalty + label_penalty, split_names.index(name)
|
|
240
|
+
|
|
241
|
+
chosen = min(split_names, key=objective)
|
|
242
|
+
counts[chosen] += len(unit)
|
|
243
|
+
for label, value in unit_labels.items():
|
|
244
|
+
label_counts[chosen][label] = label_counts[chosen].get(label, 0) + value
|
|
245
|
+
for index in unit:
|
|
246
|
+
assignment[index] = chosen
|
|
247
|
+
total = len(materialized)
|
|
248
|
+
achieved = {name: (counts[name] / total if total else 0.0) for name in split_names}
|
|
249
|
+
deviations = {
|
|
250
|
+
name: abs(achieved[name] - float(cast(int | float, config.ratios[name])))
|
|
251
|
+
for name in split_names
|
|
252
|
+
}
|
|
253
|
+
max_deviation = max(deviations.values(), default=0.0)
|
|
254
|
+
feasible = max_deviation <= config.ratio_tolerance
|
|
255
|
+
if not feasible and config.infeasible_policy == "error":
|
|
256
|
+
raise ConfigurationError(
|
|
257
|
+
"Atomic constraints make the requested split ratios infeasible within tolerance.",
|
|
258
|
+
code="JOINT_SPLIT_INFEASIBLE",
|
|
259
|
+
context={
|
|
260
|
+
"counts": counts,
|
|
261
|
+
"target_counts": targets,
|
|
262
|
+
"max_ratio_deviation": max_deviation,
|
|
263
|
+
"ratio_tolerance": config.ratio_tolerance,
|
|
264
|
+
},
|
|
265
|
+
hint="Choose infeasible_policy='relax' to return the audited greedy assignment.",
|
|
266
|
+
)
|
|
267
|
+
indices = {
|
|
268
|
+
name: [index for index in range(total) if assignment[index] == name] for name in split_names
|
|
269
|
+
}
|
|
270
|
+
return JointSplitResult(
|
|
271
|
+
tuple(
|
|
272
|
+
SplitSubset(name, DNASet(materialized[index] for index in indices[name]))
|
|
273
|
+
for name in split_names
|
|
274
|
+
),
|
|
275
|
+
tuple(
|
|
276
|
+
SplitAssignment(index, record.id, assignment[index])
|
|
277
|
+
for index, record in enumerate(materialized)
|
|
278
|
+
),
|
|
279
|
+
FrozenDict(counts),
|
|
280
|
+
FrozenDict(targets),
|
|
281
|
+
FrozenDict(achieved),
|
|
282
|
+
config.group_keys,
|
|
283
|
+
config.label_key,
|
|
284
|
+
config.similarity_threshold,
|
|
285
|
+
config.similarity_k,
|
|
286
|
+
config.similarity_canonical,
|
|
287
|
+
config.seed,
|
|
288
|
+
"seeded-greedy-atomic-components-ratio-and-label-penalty",
|
|
289
|
+
feasible,
|
|
290
|
+
not feasible,
|
|
291
|
+
("ratio_tolerance",) if not feasible else (),
|
|
292
|
+
max_deviation,
|
|
293
|
+
config.ratio_tolerance,
|
|
294
|
+
len(units),
|
|
295
|
+
comparisons,
|
|
296
|
+
config.max_records,
|
|
297
|
+
config.max_pairwise_comparisons,
|
|
298
|
+
config.infeasible_policy,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
__all__ = ["joint_split", "temporal_split"]
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Bounded threshold and agglomerative clustering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import heapq
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Iterable, Sequence
|
|
8
|
+
|
|
9
|
+
from dnakit.core import DNARecord, DNASet
|
|
10
|
+
from dnakit.exceptions import ConfigurationError
|
|
11
|
+
|
|
12
|
+
from ._advanced_shared import (
|
|
13
|
+
UnionFind,
|
|
14
|
+
ensure_pair_limit,
|
|
15
|
+
materialize_limited,
|
|
16
|
+
quality_score,
|
|
17
|
+
similarity_matrix,
|
|
18
|
+
validate_pairwise_sequences,
|
|
19
|
+
)
|
|
20
|
+
from .config import (
|
|
21
|
+
AdvancedRepresentativePolicy,
|
|
22
|
+
ClusterConfig,
|
|
23
|
+
ClusterMethod,
|
|
24
|
+
HierarchicalClusteringConfig,
|
|
25
|
+
)
|
|
26
|
+
from .results import (
|
|
27
|
+
Cluster,
|
|
28
|
+
ClusteringResult,
|
|
29
|
+
HierarchicalClusteringResult,
|
|
30
|
+
LinkageStep,
|
|
31
|
+
RepresentativeSelectionResult,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _choose_representative(
|
|
36
|
+
members: tuple[int, ...],
|
|
37
|
+
records: tuple[DNARecord, ...],
|
|
38
|
+
*,
|
|
39
|
+
policy: AdvancedRepresentativePolicy,
|
|
40
|
+
similarities: tuple[tuple[float, ...], ...] | None,
|
|
41
|
+
) -> int:
|
|
42
|
+
if policy == "first":
|
|
43
|
+
return members[0]
|
|
44
|
+
if policy == "shortest":
|
|
45
|
+
return min(members, key=lambda index: (records[index].sequence.symbol_length, index))
|
|
46
|
+
if policy == "longest":
|
|
47
|
+
return max(members, key=lambda index: (records[index].sequence.symbol_length, -index))
|
|
48
|
+
if policy == "best_quality":
|
|
49
|
+
return max(members, key=lambda index: (quality_score(records[index]), -index))
|
|
50
|
+
if similarities is None:
|
|
51
|
+
raise ConfigurationError("Medoid selection requires a similarity matrix.")
|
|
52
|
+
return min(
|
|
53
|
+
members,
|
|
54
|
+
key=lambda candidate: (
|
|
55
|
+
math.fsum(1.0 - similarities[candidate][member] for member in members),
|
|
56
|
+
candidate,
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cluster_sequences(
|
|
62
|
+
records: Iterable[DNARecord],
|
|
63
|
+
*,
|
|
64
|
+
config: ClusterConfig | None = None,
|
|
65
|
+
) -> ClusteringResult:
|
|
66
|
+
"""Cluster by connected components of an exhaustive similarity-threshold graph."""
|
|
67
|
+
|
|
68
|
+
resolved = ClusterConfig() if config is None else config
|
|
69
|
+
if not isinstance(resolved, ClusterConfig):
|
|
70
|
+
raise ConfigurationError("config must be ClusterConfig.", code="INVALID_CLUSTER_CONFIG")
|
|
71
|
+
materialized = materialize_limited(records, max_records=resolved.max_records)
|
|
72
|
+
comparisons = ensure_pair_limit(len(materialized), resolved.max_pairwise_comparisons)
|
|
73
|
+
validate_pairwise_sequences(materialized, operation="cluster_sequences")
|
|
74
|
+
similarities = similarity_matrix(
|
|
75
|
+
materialized,
|
|
76
|
+
method=resolved.method,
|
|
77
|
+
k=resolved.k,
|
|
78
|
+
canonical=resolved.canonical,
|
|
79
|
+
max_alignment_cells=resolved.max_alignment_cells,
|
|
80
|
+
)
|
|
81
|
+
components = UnionFind(len(materialized))
|
|
82
|
+
for left in range(len(materialized)):
|
|
83
|
+
for right in range(left + 1, len(materialized)):
|
|
84
|
+
if similarities[left][right] >= resolved.threshold:
|
|
85
|
+
components.union(left, right)
|
|
86
|
+
groups = components.groups()
|
|
87
|
+
labels = [0] * len(materialized)
|
|
88
|
+
clusters: list[Cluster] = []
|
|
89
|
+
representatives: list[DNARecord] = []
|
|
90
|
+
for cluster_index, members in enumerate(groups):
|
|
91
|
+
representative = _choose_representative(
|
|
92
|
+
members,
|
|
93
|
+
materialized,
|
|
94
|
+
policy=resolved.representative_policy,
|
|
95
|
+
similarities=similarities,
|
|
96
|
+
)
|
|
97
|
+
for member in members:
|
|
98
|
+
labels[member] = cluster_index
|
|
99
|
+
representatives.append(materialized[representative])
|
|
100
|
+
clusters.append(
|
|
101
|
+
Cluster(
|
|
102
|
+
cluster_index,
|
|
103
|
+
members,
|
|
104
|
+
tuple(materialized[index].id for index in members),
|
|
105
|
+
representative,
|
|
106
|
+
materialized[representative].id,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
return ClusteringResult(
|
|
110
|
+
tuple(clusters),
|
|
111
|
+
tuple(labels),
|
|
112
|
+
DNASet(representatives),
|
|
113
|
+
resolved.method,
|
|
114
|
+
resolved.threshold,
|
|
115
|
+
resolved.k,
|
|
116
|
+
resolved.canonical,
|
|
117
|
+
resolved.representative_policy,
|
|
118
|
+
"exhaustive-threshold-graph-connected-components",
|
|
119
|
+
resolved.seed,
|
|
120
|
+
False,
|
|
121
|
+
comparisons,
|
|
122
|
+
resolved.max_records,
|
|
123
|
+
resolved.max_pairwise_comparisons,
|
|
124
|
+
resolved.max_alignment_cells,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def hierarchical_cluster(
|
|
129
|
+
records: Iterable[DNARecord],
|
|
130
|
+
*,
|
|
131
|
+
config: HierarchicalClusteringConfig | None = None,
|
|
132
|
+
) -> HierarchicalClusteringResult:
|
|
133
|
+
"""Agglomerate the closest clusters with deterministic linkage tie-breaking."""
|
|
134
|
+
|
|
135
|
+
resolved = HierarchicalClusteringConfig() if config is None else config
|
|
136
|
+
if not isinstance(resolved, HierarchicalClusteringConfig):
|
|
137
|
+
raise ConfigurationError("config must be HierarchicalClusteringConfig.")
|
|
138
|
+
materialized = materialize_limited(records, max_records=resolved.max_records)
|
|
139
|
+
comparisons = ensure_pair_limit(len(materialized), resolved.max_pairwise_comparisons)
|
|
140
|
+
validate_pairwise_sequences(materialized, operation="hierarchical_cluster")
|
|
141
|
+
similarities = similarity_matrix(
|
|
142
|
+
materialized,
|
|
143
|
+
method=resolved.method,
|
|
144
|
+
k=resolved.k,
|
|
145
|
+
canonical=resolved.canonical,
|
|
146
|
+
max_alignment_cells=resolved.max_alignment_cells,
|
|
147
|
+
)
|
|
148
|
+
active = set(range(len(materialized)))
|
|
149
|
+
sizes = {index: 1 for index in active}
|
|
150
|
+
distances: dict[tuple[int, int], float] = {
|
|
151
|
+
(left, right): 1.0 - similarities[left][right]
|
|
152
|
+
for left in range(len(materialized))
|
|
153
|
+
for right in range(left + 1, len(materialized))
|
|
154
|
+
}
|
|
155
|
+
candidates = [(distance, left, right) for (left, right), distance in distances.items()]
|
|
156
|
+
heapq.heapify(candidates)
|
|
157
|
+
next_node = len(materialized)
|
|
158
|
+
linkage: list[LinkageStep] = []
|
|
159
|
+
distance_updates = 0
|
|
160
|
+
|
|
161
|
+
def key(left: int, right: int) -> tuple[int, int]:
|
|
162
|
+
return (left, right) if left < right else (right, left)
|
|
163
|
+
|
|
164
|
+
while len(active) > 1:
|
|
165
|
+
while candidates:
|
|
166
|
+
distance, left, right = heapq.heappop(candidates)
|
|
167
|
+
if left in active and right in active and distances.get((left, right)) == distance:
|
|
168
|
+
break
|
|
169
|
+
else:
|
|
170
|
+
raise AssertionError("Hierarchical clustering candidate heap was exhausted.")
|
|
171
|
+
left_size, right_size = sizes[left], sizes[right]
|
|
172
|
+
others = tuple(sorted(active - {left, right}))
|
|
173
|
+
active.remove(left)
|
|
174
|
+
active.remove(right)
|
|
175
|
+
for other in others:
|
|
176
|
+
left_distance = distances[key(left, other)]
|
|
177
|
+
right_distance = distances[key(right, other)]
|
|
178
|
+
if resolved.linkage == "single":
|
|
179
|
+
new_distance = min(left_distance, right_distance)
|
|
180
|
+
elif resolved.linkage == "complete":
|
|
181
|
+
new_distance = max(left_distance, right_distance)
|
|
182
|
+
else:
|
|
183
|
+
new_distance = (left_size * left_distance + right_size * right_distance) / (
|
|
184
|
+
left_size + right_size
|
|
185
|
+
)
|
|
186
|
+
new_key = key(next_node, other)
|
|
187
|
+
distances[new_key] = new_distance
|
|
188
|
+
heapq.heappush(candidates, (new_distance, *new_key))
|
|
189
|
+
distance_updates += 1
|
|
190
|
+
for other in others:
|
|
191
|
+
distances.pop(key(left, other))
|
|
192
|
+
distances.pop(key(right, other))
|
|
193
|
+
distances.pop(key(left, right))
|
|
194
|
+
sizes[next_node] = left_size + right_size
|
|
195
|
+
active.add(next_node)
|
|
196
|
+
linkage.append(
|
|
197
|
+
LinkageStep(
|
|
198
|
+
len(linkage),
|
|
199
|
+
left,
|
|
200
|
+
right,
|
|
201
|
+
next_node,
|
|
202
|
+
distance,
|
|
203
|
+
sizes[next_node],
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
next_node += 1
|
|
207
|
+
return HierarchicalClusteringResult(
|
|
208
|
+
tuple(record.id for record in materialized),
|
|
209
|
+
tuple(linkage),
|
|
210
|
+
resolved.method,
|
|
211
|
+
resolved.linkage,
|
|
212
|
+
"1 - configured pair similarity",
|
|
213
|
+
resolved.k,
|
|
214
|
+
resolved.canonical,
|
|
215
|
+
comparisons,
|
|
216
|
+
distance_updates,
|
|
217
|
+
resolved.max_records,
|
|
218
|
+
resolved.max_pairwise_comparisons,
|
|
219
|
+
resolved.max_alignment_cells,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def select_representatives(
|
|
224
|
+
records: Iterable[DNARecord],
|
|
225
|
+
labels: Sequence[int],
|
|
226
|
+
*,
|
|
227
|
+
policy: AdvancedRepresentativePolicy = "first",
|
|
228
|
+
medoid_method: ClusterMethod = "identity",
|
|
229
|
+
k: int = 3,
|
|
230
|
+
canonical: bool = False,
|
|
231
|
+
max_records: int = 1_000,
|
|
232
|
+
max_pairwise_comparisons: int = 500_000,
|
|
233
|
+
max_alignment_cells: int = 5_000_000,
|
|
234
|
+
) -> RepresentativeSelectionResult:
|
|
235
|
+
"""Select one stable representative per user-supplied cluster label."""
|
|
236
|
+
|
|
237
|
+
validation = ClusterConfig(
|
|
238
|
+
method=medoid_method,
|
|
239
|
+
k=k,
|
|
240
|
+
canonical=canonical,
|
|
241
|
+
representative_policy=policy,
|
|
242
|
+
max_records=max_records,
|
|
243
|
+
max_pairwise_comparisons=max_pairwise_comparisons,
|
|
244
|
+
max_alignment_cells=max_alignment_cells,
|
|
245
|
+
)
|
|
246
|
+
materialized = materialize_limited(records, max_records=validation.max_records)
|
|
247
|
+
label_tuple = tuple(labels)
|
|
248
|
+
if len(label_tuple) != len(materialized) or any(
|
|
249
|
+
isinstance(label, bool) or not isinstance(label, int) or label < 0 for label in label_tuple
|
|
250
|
+
):
|
|
251
|
+
raise ConfigurationError("labels must be non-negative integers aligned with records.")
|
|
252
|
+
grouped: dict[int, list[int]] = {}
|
|
253
|
+
for index, label in enumerate(label_tuple):
|
|
254
|
+
grouped.setdefault(label, []).append(index)
|
|
255
|
+
comparisons = 0
|
|
256
|
+
similarities: tuple[tuple[float, ...], ...] | None = None
|
|
257
|
+
if policy == "medoid":
|
|
258
|
+
comparisons = ensure_pair_limit(len(materialized), validation.max_pairwise_comparisons)
|
|
259
|
+
validate_pairwise_sequences(materialized, operation="select_representatives")
|
|
260
|
+
similarities = similarity_matrix(
|
|
261
|
+
materialized,
|
|
262
|
+
method=validation.method,
|
|
263
|
+
k=validation.k,
|
|
264
|
+
canonical=validation.canonical,
|
|
265
|
+
max_alignment_cells=validation.max_alignment_cells,
|
|
266
|
+
)
|
|
267
|
+
chosen = tuple(
|
|
268
|
+
_choose_representative(
|
|
269
|
+
tuple(members),
|
|
270
|
+
materialized,
|
|
271
|
+
policy=policy,
|
|
272
|
+
similarities=similarities,
|
|
273
|
+
)
|
|
274
|
+
for _, members in sorted(grouped.items())
|
|
275
|
+
)
|
|
276
|
+
return RepresentativeSelectionResult(
|
|
277
|
+
DNASet(materialized[index] for index in chosen),
|
|
278
|
+
tuple(materialized[index].id for index in chosen),
|
|
279
|
+
policy,
|
|
280
|
+
len(grouped),
|
|
281
|
+
validation.method if policy == "medoid" else None,
|
|
282
|
+
comparisons,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
__all__ = ["cluster_sequences", "hierarchical_cluster", "select_representatives"]
|