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,288 @@
|
|
|
1
|
+
"""Shared bounded primitives for DNA evaluation implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Iterable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TypeAlias, cast
|
|
11
|
+
|
|
12
|
+
from dnakit.alignment import AlignmentConfig, align_pairwise
|
|
13
|
+
from dnakit.core import (
|
|
14
|
+
DNA,
|
|
15
|
+
DNARecord,
|
|
16
|
+
DNASequence,
|
|
17
|
+
DNASet,
|
|
18
|
+
ImplementationInfo,
|
|
19
|
+
ImplementationLabel,
|
|
20
|
+
Issue,
|
|
21
|
+
IssueSeverity,
|
|
22
|
+
Provenance,
|
|
23
|
+
ReferenceInfo,
|
|
24
|
+
)
|
|
25
|
+
from dnakit.core._json import FrozenDict, to_json_compatible
|
|
26
|
+
from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
|
|
27
|
+
from dnakit.similarity import edit_distance, exact_similarity, kmer_similarity
|
|
28
|
+
|
|
29
|
+
from .config import EvaluationLimits, PairSimilarityMethod
|
|
30
|
+
from .results import EvaluationEntry, EvaluationReport, ReferenceLibrary
|
|
31
|
+
|
|
32
|
+
EvaluationInput: TypeAlias = DNA | DNASequence | DNARecord | DNASet | Iterable[DNARecord]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class InputItem:
|
|
37
|
+
sequence: DNASequence
|
|
38
|
+
subject_id: str
|
|
39
|
+
record: DNARecord | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def provenance(*, reference: ReferenceLibrary | None = None) -> Provenance:
|
|
43
|
+
reference_info = None
|
|
44
|
+
if reference is not None:
|
|
45
|
+
reference_info = ReferenceInfo(
|
|
46
|
+
reference.name,
|
|
47
|
+
version=reference.version,
|
|
48
|
+
date=reference.date,
|
|
49
|
+
checksum=reference.digest,
|
|
50
|
+
filters=reference.filters,
|
|
51
|
+
)
|
|
52
|
+
return Provenance(
|
|
53
|
+
implementation=ImplementationInfo(label=ImplementationLabel.NATIVE),
|
|
54
|
+
reference=reference_info,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def issue(code: str, severity: IssueSeverity, message: str, **details: object) -> Issue:
|
|
59
|
+
return Issue(code, severity, message, details=details)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def materialize_input(value: EvaluationInput, *, limits: EvaluationLimits) -> tuple[InputItem, ...]:
|
|
63
|
+
if isinstance(value, DNA) and value.is_single:
|
|
64
|
+
record = value.record
|
|
65
|
+
items: tuple[InputItem, ...] = (InputItem(record.sequence, record.id, record),)
|
|
66
|
+
elif isinstance(value, DNASequence):
|
|
67
|
+
items = (InputItem(value, "sequence", None),)
|
|
68
|
+
elif isinstance(value, DNARecord):
|
|
69
|
+
items = (InputItem(value.sequence, value.id, value),)
|
|
70
|
+
else:
|
|
71
|
+
source = value.records if isinstance(value, (DNA, DNASet)) else value
|
|
72
|
+
try:
|
|
73
|
+
iterator = iter(source)
|
|
74
|
+
except TypeError as exc:
|
|
75
|
+
raise ConfigurationError(
|
|
76
|
+
"Evaluation input must be a DNASequence, DNARecord, DNASet, or record iterable.",
|
|
77
|
+
code="INVALID_EVALUATION_INPUT",
|
|
78
|
+
) from exc
|
|
79
|
+
collected: list[InputItem] = []
|
|
80
|
+
for index, record in enumerate(iterator):
|
|
81
|
+
if not isinstance(record, DNARecord):
|
|
82
|
+
raise ConfigurationError(
|
|
83
|
+
"Evaluation collections must contain DNARecord objects.",
|
|
84
|
+
code="INVALID_EVALUATION_RECORD",
|
|
85
|
+
context={"input_index": index, "type": type(record).__name__},
|
|
86
|
+
)
|
|
87
|
+
if index >= limits.max_records:
|
|
88
|
+
raise ConfigurationError(
|
|
89
|
+
"Evaluation input exceeds max_records.",
|
|
90
|
+
code="EVALUATION_RECORD_LIMIT",
|
|
91
|
+
context={
|
|
92
|
+
"record_count_lower_bound": index + 1,
|
|
93
|
+
"max_records": limits.max_records,
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
collected.append(InputItem(record.sequence, record.id, record))
|
|
97
|
+
items = tuple(collected)
|
|
98
|
+
if len(items) > limits.max_records:
|
|
99
|
+
raise ConfigurationError(
|
|
100
|
+
"Evaluation input exceeds max_records.",
|
|
101
|
+
code="EVALUATION_RECORD_LIMIT",
|
|
102
|
+
context={"record_count": len(items), "max_records": limits.max_records},
|
|
103
|
+
)
|
|
104
|
+
total_symbols = sum(item.sequence.symbol_length for item in items)
|
|
105
|
+
if total_symbols > limits.max_total_symbols:
|
|
106
|
+
raise ConfigurationError(
|
|
107
|
+
"Evaluation input exceeds max_total_symbols.",
|
|
108
|
+
code="EVALUATION_SYMBOL_LIMIT",
|
|
109
|
+
context={"total_symbols": total_symbols, "max_total_symbols": limits.max_total_symbols},
|
|
110
|
+
)
|
|
111
|
+
return items
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def require_nonempty(items: tuple[InputItem, ...], operation: str) -> None:
|
|
115
|
+
if not items:
|
|
116
|
+
raise ConfigurationError(
|
|
117
|
+
f"{operation} requires at least one record.", code="EMPTY_EVALUATION_DATASET"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def pair_count(left_size: int, right_size: int | None = None) -> int:
|
|
122
|
+
return left_size * (left_size - 1) // 2 if right_size is None else left_size * right_size
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def enforce_pair_limit(count: int, limits: EvaluationLimits) -> None:
|
|
126
|
+
if count > limits.max_pairwise_comparisons:
|
|
127
|
+
raise ConfigurationError(
|
|
128
|
+
"Evaluation workload exceeds max_pairwise_comparisons.",
|
|
129
|
+
code="EVALUATION_PAIRWISE_LIMIT",
|
|
130
|
+
context={"pairwise_comparisons": count, "limit": limits.max_pairwise_comparisons},
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def require_pairwise_compatible(sequence: DNASequence, *, role: str) -> None:
|
|
135
|
+
if sequence.is_gapped:
|
|
136
|
+
raise UnsupportedGapOperationError(
|
|
137
|
+
"Pairwise evaluation does not silently omit or bridge Gap objects.",
|
|
138
|
+
code="EVALUATION_GAP_UNSUPPORTED",
|
|
139
|
+
context={"role": role},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def pair_similarity(
|
|
144
|
+
left: DNARecord,
|
|
145
|
+
right: DNARecord,
|
|
146
|
+
*,
|
|
147
|
+
method: PairSimilarityMethod,
|
|
148
|
+
k: int,
|
|
149
|
+
canonical: bool,
|
|
150
|
+
max_alignment_cells: int,
|
|
151
|
+
) -> tuple[float, float | None]:
|
|
152
|
+
require_pairwise_compatible(left.sequence, role="left")
|
|
153
|
+
require_pairwise_compatible(right.sequence, role="right")
|
|
154
|
+
if method == "exact":
|
|
155
|
+
return exact_similarity(left, right).value, 1.0
|
|
156
|
+
if method == "identity":
|
|
157
|
+
result = align_pairwise(
|
|
158
|
+
left,
|
|
159
|
+
right,
|
|
160
|
+
config=AlignmentConfig(mode="global", max_cells=max_alignment_cells),
|
|
161
|
+
)
|
|
162
|
+
return (1.0 if result.identity is None else result.identity), min(
|
|
163
|
+
result.query_coverage, result.target_coverage
|
|
164
|
+
)
|
|
165
|
+
if method == "edit":
|
|
166
|
+
distance_result = edit_distance(left, right, max_cells=max_alignment_cells)
|
|
167
|
+
denominator = max(left.sequence.symbol_length, right.sequence.symbol_length)
|
|
168
|
+
return (1.0 if denominator == 0 else 1.0 - distance_result.distance / denominator), None
|
|
169
|
+
if left.sequence.symbol_length < k or right.sequence.symbol_length < k:
|
|
170
|
+
return (1.0 if left.sequence.symbols == right.sequence.symbols else 0.0), None
|
|
171
|
+
return kmer_similarity(left, right, k=k, canonical=canonical).value, None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def aggregate_numeric(entries: tuple[EvaluationEntry, ...], metric: str) -> FrozenDict:
|
|
175
|
+
values = tuple(
|
|
176
|
+
float(value)
|
|
177
|
+
for entry in entries
|
|
178
|
+
if (value := entry.metrics.get(metric)) is not None
|
|
179
|
+
and not isinstance(value, bool)
|
|
180
|
+
and isinstance(value, (int, float))
|
|
181
|
+
)
|
|
182
|
+
return FrozenDict(
|
|
183
|
+
{
|
|
184
|
+
"count": len(values),
|
|
185
|
+
"mean": math.fsum(values) / len(values) if values else None,
|
|
186
|
+
"minimum": min(values) if values else None,
|
|
187
|
+
"maximum": max(values) if values else None,
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def report(
|
|
193
|
+
*,
|
|
194
|
+
name: str,
|
|
195
|
+
method: str,
|
|
196
|
+
version: str,
|
|
197
|
+
parameters: Mapping[str, object],
|
|
198
|
+
metrics: Mapping[str, object],
|
|
199
|
+
entries: Iterable[EvaluationEntry] = (),
|
|
200
|
+
issues: Iterable[Issue] = (),
|
|
201
|
+
reference: ReferenceLibrary | None = None,
|
|
202
|
+
) -> EvaluationReport:
|
|
203
|
+
return EvaluationReport(
|
|
204
|
+
name,
|
|
205
|
+
method,
|
|
206
|
+
version,
|
|
207
|
+
FrozenDict(cast(Mapping[str, object], to_json_compatible(parameters))),
|
|
208
|
+
FrozenDict(cast(Mapping[str, object], to_json_compatible(metrics))),
|
|
209
|
+
tuple(entries),
|
|
210
|
+
provenance(reference=reference),
|
|
211
|
+
tuple(issues),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def sequence_digest_payload(records: DNASet) -> dict[str, object]:
|
|
216
|
+
return {
|
|
217
|
+
"schema": "dnakit.reference-library-sequences.v1",
|
|
218
|
+
"records": [
|
|
219
|
+
{
|
|
220
|
+
"id": record.id,
|
|
221
|
+
"parts": [
|
|
222
|
+
part
|
|
223
|
+
if isinstance(part, str)
|
|
224
|
+
else {
|
|
225
|
+
"gap": {
|
|
226
|
+
"length": part.length,
|
|
227
|
+
"kind": part.kind.value,
|
|
228
|
+
"crossable": part.crossable,
|
|
229
|
+
"evidence": part.evidence,
|
|
230
|
+
"metadata": part.metadata,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for part in record.sequence.parts
|
|
234
|
+
],
|
|
235
|
+
"alphabet": record.sequence.alphabet.value,
|
|
236
|
+
"topology": record.sequence.topology.value,
|
|
237
|
+
"strandedness": record.sequence.strandedness.value,
|
|
238
|
+
}
|
|
239
|
+
for record in records
|
|
240
|
+
],
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def digest_json(value: object) -> str:
|
|
245
|
+
payload = json.dumps(
|
|
246
|
+
to_json_compatible(value),
|
|
247
|
+
ensure_ascii=False,
|
|
248
|
+
sort_keys=True,
|
|
249
|
+
separators=(",", ":"),
|
|
250
|
+
allow_nan=False,
|
|
251
|
+
).encode("utf-8")
|
|
252
|
+
return hashlib.sha256(payload).hexdigest()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def record_for(item: InputItem) -> DNARecord:
|
|
256
|
+
return item.record or DNARecord(item.sequence, item.subject_id)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def mean(values: Iterable[float]) -> float | None:
|
|
260
|
+
materialized = tuple(values)
|
|
261
|
+
return math.fsum(materialized) / len(materialized) if materialized else None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def as_float(mapping: Mapping[str, object], key: str) -> float:
|
|
265
|
+
value = mapping[key]
|
|
266
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
267
|
+
raise ConfigurationError("Internal evaluation metric is not numeric.")
|
|
268
|
+
return float(value)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
__all__ = [
|
|
272
|
+
"EvaluationInput",
|
|
273
|
+
"InputItem",
|
|
274
|
+
"aggregate_numeric",
|
|
275
|
+
"as_float",
|
|
276
|
+
"digest_json",
|
|
277
|
+
"enforce_pair_limit",
|
|
278
|
+
"issue",
|
|
279
|
+
"materialize_input",
|
|
280
|
+
"mean",
|
|
281
|
+
"pair_count",
|
|
282
|
+
"pair_similarity",
|
|
283
|
+
"provenance",
|
|
284
|
+
"record_for",
|
|
285
|
+
"report",
|
|
286
|
+
"require_nonempty",
|
|
287
|
+
"sequence_digest_payload",
|
|
288
|
+
]
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Collection uniqueness, diversity, and redundancy evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dnakit.core import DNARecord
|
|
6
|
+
from dnakit.datasets import (
|
|
7
|
+
ClusterConfig,
|
|
8
|
+
IUPACDeduplicationConfig,
|
|
9
|
+
cluster_sequences,
|
|
10
|
+
deduplicate,
|
|
11
|
+
deduplicate_iupac,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from ._shared import (
|
|
15
|
+
EvaluationInput,
|
|
16
|
+
enforce_pair_limit,
|
|
17
|
+
materialize_input,
|
|
18
|
+
mean,
|
|
19
|
+
pair_count,
|
|
20
|
+
pair_similarity,
|
|
21
|
+
record_for,
|
|
22
|
+
report,
|
|
23
|
+
)
|
|
24
|
+
from .config import DiversityEvaluationConfig, UniquenessEvaluationConfig
|
|
25
|
+
from .results import EvaluationReport
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _records(value: EvaluationInput, config: UniquenessEvaluationConfig) -> tuple[DNARecord, ...]:
|
|
29
|
+
return tuple(record_for(item) for item in materialize_input(value, limits=config.limits))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def evaluate_uniqueness(
|
|
33
|
+
value: EvaluationInput,
|
|
34
|
+
*,
|
|
35
|
+
config: UniquenessEvaluationConfig | None = None,
|
|
36
|
+
) -> EvaluationReport:
|
|
37
|
+
"""Compute non-duplicate fraction under one explicitly configured equivalence."""
|
|
38
|
+
|
|
39
|
+
resolved = UniquenessEvaluationConfig() if config is None else config
|
|
40
|
+
if not isinstance(resolved, UniquenessEvaluationConfig):
|
|
41
|
+
raise TypeError("config must be UniquenessEvaluationConfig or None.")
|
|
42
|
+
records = _records(value, resolved)
|
|
43
|
+
if resolved.equivalence == "iupac":
|
|
44
|
+
iupac_result = deduplicate_iupac(
|
|
45
|
+
records,
|
|
46
|
+
config=IUPACDeduplicationConfig(
|
|
47
|
+
max_records=resolved.limits.max_records,
|
|
48
|
+
max_pairwise_comparisons=resolved.limits.max_pairwise_comparisons,
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
groups = tuple(group.member_ids for group in iupac_result.groups)
|
|
52
|
+
unique_count = iupac_result.output_count
|
|
53
|
+
comparisons = iupac_result.pairwise_comparison_count
|
|
54
|
+
strategy = iupac_result.grouping_strategy
|
|
55
|
+
elif resolved.equivalence == "approximate":
|
|
56
|
+
cluster_method = (
|
|
57
|
+
"identity" if resolved.approximate_method == "exact" else resolved.approximate_method
|
|
58
|
+
)
|
|
59
|
+
cluster_result = cluster_sequences(
|
|
60
|
+
records,
|
|
61
|
+
config=ClusterConfig(
|
|
62
|
+
method=cluster_method,
|
|
63
|
+
threshold=1.0 if resolved.approximate_method == "exact" else resolved.threshold,
|
|
64
|
+
k=resolved.k,
|
|
65
|
+
canonical=resolved.canonical,
|
|
66
|
+
max_records=resolved.limits.max_records,
|
|
67
|
+
max_pairwise_comparisons=resolved.limits.max_pairwise_comparisons,
|
|
68
|
+
max_alignment_cells=resolved.limits.max_alignment_cells,
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
groups = tuple(cluster.member_ids for cluster in cluster_result.clusters)
|
|
72
|
+
unique_count = len(cluster_result.clusters)
|
|
73
|
+
comparisons = cluster_result.pairwise_comparison_count
|
|
74
|
+
strategy = cluster_result.clustering_strategy
|
|
75
|
+
else:
|
|
76
|
+
dedup_result = deduplicate(records, equivalence=resolved.equivalence)
|
|
77
|
+
groups = tuple(group.member_ids for group in dedup_result.groups)
|
|
78
|
+
unique_count = len(dedup_result.groups)
|
|
79
|
+
comparisons = 0
|
|
80
|
+
strategy = "hash-equivalence-groups"
|
|
81
|
+
duplicate_groups = tuple(group for group in groups if len(group) > 1)
|
|
82
|
+
total = len(records)
|
|
83
|
+
score = unique_count / total if total else 1.0
|
|
84
|
+
return report(
|
|
85
|
+
name="uniqueness",
|
|
86
|
+
method=f"{resolved.equivalence}-equivalence",
|
|
87
|
+
version="eval-uniqueness-v1",
|
|
88
|
+
parameters={
|
|
89
|
+
"equivalence": resolved.equivalence,
|
|
90
|
+
"approximate_method": resolved.approximate_method,
|
|
91
|
+
"threshold": resolved.threshold,
|
|
92
|
+
"k": resolved.k,
|
|
93
|
+
"canonical": resolved.canonical,
|
|
94
|
+
"grouping_strategy": strategy,
|
|
95
|
+
"limits": resolved.limits,
|
|
96
|
+
},
|
|
97
|
+
metrics={
|
|
98
|
+
"score": score,
|
|
99
|
+
"uniqueness_score": score,
|
|
100
|
+
"record_count": total,
|
|
101
|
+
"unique_count": unique_count,
|
|
102
|
+
"duplicate_record_count": total - unique_count,
|
|
103
|
+
"duplicate_group_count": len(duplicate_groups),
|
|
104
|
+
"groups": groups,
|
|
105
|
+
"duplicate_groups": duplicate_groups,
|
|
106
|
+
"pairwise_comparison_count": comparisons,
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _similarity_state(
|
|
112
|
+
records: tuple[DNARecord, ...], config: DiversityEvaluationConfig
|
|
113
|
+
) -> tuple[tuple[tuple[float, ...], ...], int]:
|
|
114
|
+
comparisons = pair_count(len(records))
|
|
115
|
+
enforce_pair_limit(comparisons, config.limits)
|
|
116
|
+
matrix = [
|
|
117
|
+
[1.0 if left == right else 0.0 for right in range(len(records))]
|
|
118
|
+
for left in range(len(records))
|
|
119
|
+
]
|
|
120
|
+
for left in range(len(records)):
|
|
121
|
+
for right in range(left + 1, len(records)):
|
|
122
|
+
similarity, _ = pair_similarity(
|
|
123
|
+
records[left],
|
|
124
|
+
records[right],
|
|
125
|
+
method=config.method,
|
|
126
|
+
k=config.k,
|
|
127
|
+
canonical=config.canonical,
|
|
128
|
+
max_alignment_cells=config.limits.max_alignment_cells,
|
|
129
|
+
)
|
|
130
|
+
matrix[left][right] = matrix[right][left] = similarity
|
|
131
|
+
return tuple(tuple(row) for row in matrix), comparisons
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _component_count(matrix: tuple[tuple[float, ...], ...], threshold: float) -> int:
|
|
135
|
+
parents = list(range(len(matrix)))
|
|
136
|
+
|
|
137
|
+
def find(value: int) -> int:
|
|
138
|
+
while parents[value] != value:
|
|
139
|
+
parents[value] = parents[parents[value]]
|
|
140
|
+
value = parents[value]
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
for left in range(len(matrix)):
|
|
144
|
+
for right in range(left + 1, len(matrix)):
|
|
145
|
+
if matrix[left][right] >= threshold:
|
|
146
|
+
left_root, right_root = find(left), find(right)
|
|
147
|
+
if left_root != right_root:
|
|
148
|
+
parents[right_root] = left_root
|
|
149
|
+
return len({find(index) for index in range(len(matrix))})
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def evaluate_diversity(
|
|
153
|
+
value: EvaluationInput,
|
|
154
|
+
*,
|
|
155
|
+
config: DiversityEvaluationConfig | None = None,
|
|
156
|
+
) -> EvaluationReport:
|
|
157
|
+
"""Report exhaustive mean distance, nearest-neighbor distance, and cluster coverage."""
|
|
158
|
+
|
|
159
|
+
resolved = DiversityEvaluationConfig() if config is None else config
|
|
160
|
+
if not isinstance(resolved, DiversityEvaluationConfig):
|
|
161
|
+
raise TypeError("config must be DiversityEvaluationConfig or None.")
|
|
162
|
+
items = materialize_input(value, limits=resolved.limits)
|
|
163
|
+
records = tuple(record_for(item) for item in items)
|
|
164
|
+
matrix, comparisons = _similarity_state(records, resolved)
|
|
165
|
+
distances = tuple(
|
|
166
|
+
1.0 - matrix[left][right]
|
|
167
|
+
for left in range(len(records))
|
|
168
|
+
for right in range(left + 1, len(records))
|
|
169
|
+
)
|
|
170
|
+
nearest_distances = (
|
|
171
|
+
tuple(
|
|
172
|
+
min(1.0 - matrix[index][other] for other in range(len(records)) if other != index)
|
|
173
|
+
for index in range(len(records))
|
|
174
|
+
)
|
|
175
|
+
if len(records) > 1
|
|
176
|
+
else ()
|
|
177
|
+
)
|
|
178
|
+
cluster_count = _component_count(matrix, resolved.cluster_threshold)
|
|
179
|
+
cluster_coverage = cluster_count / len(records) if records else 1.0
|
|
180
|
+
mean_distance = mean(distances)
|
|
181
|
+
mean_nearest = mean(nearest_distances)
|
|
182
|
+
score = mean_nearest if mean_nearest is not None else (1.0 if len(records) <= 1 else 0.0)
|
|
183
|
+
return report(
|
|
184
|
+
name="diversity",
|
|
185
|
+
method="exhaustive-pair-distance-and-threshold-components",
|
|
186
|
+
version="eval-diversity-v1",
|
|
187
|
+
parameters={
|
|
188
|
+
"similarity_method": resolved.method,
|
|
189
|
+
"distance_definition": "1 - pair similarity",
|
|
190
|
+
"k": resolved.k,
|
|
191
|
+
"canonical": resolved.canonical,
|
|
192
|
+
"cluster_threshold": resolved.cluster_threshold,
|
|
193
|
+
"cluster_strategy": "connected-components-of-threshold-graph",
|
|
194
|
+
"short_k_policy": "literal equality when either sequence is shorter than k",
|
|
195
|
+
"topology_policy": "linearized at stored origin; no circular rotation",
|
|
196
|
+
"singleton_policy": "score=1; pair metrics undefined",
|
|
197
|
+
"limits": resolved.limits,
|
|
198
|
+
},
|
|
199
|
+
metrics={
|
|
200
|
+
"score": score,
|
|
201
|
+
"record_count": len(records),
|
|
202
|
+
"mean_pair_distance": mean_distance,
|
|
203
|
+
"mean_nearest_neighbor_distance": mean_nearest,
|
|
204
|
+
"minimum_nearest_neighbor_distance": min(nearest_distances)
|
|
205
|
+
if nearest_distances
|
|
206
|
+
else None,
|
|
207
|
+
"cluster_count": cluster_count,
|
|
208
|
+
"cluster_coverage": cluster_coverage,
|
|
209
|
+
"pairwise_comparison_count": comparisons,
|
|
210
|
+
},
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def evaluate_redundancy(
|
|
215
|
+
value: EvaluationInput,
|
|
216
|
+
*,
|
|
217
|
+
config: DiversityEvaluationConfig | None = None,
|
|
218
|
+
) -> EvaluationReport:
|
|
219
|
+
"""Report exact, similarity-threshold, and cluster-level redundancy."""
|
|
220
|
+
|
|
221
|
+
resolved = DiversityEvaluationConfig() if config is None else config
|
|
222
|
+
if not isinstance(resolved, DiversityEvaluationConfig):
|
|
223
|
+
raise TypeError("config must be DiversityEvaluationConfig or None.")
|
|
224
|
+
items = materialize_input(value, limits=resolved.limits)
|
|
225
|
+
records = tuple(record_for(item) for item in items)
|
|
226
|
+
matrix, comparisons = _similarity_state(records, resolved)
|
|
227
|
+
exact_unique_count = len(deduplicate(records, equivalence="exact").groups)
|
|
228
|
+
near_pair_count = sum(
|
|
229
|
+
matrix[left][right] >= resolved.cluster_threshold
|
|
230
|
+
for left in range(len(records))
|
|
231
|
+
for right in range(left + 1, len(records))
|
|
232
|
+
)
|
|
233
|
+
total_pairs = pair_count(len(records))
|
|
234
|
+
cluster_count = _component_count(matrix, resolved.cluster_threshold)
|
|
235
|
+
exact_fraction = (len(records) - exact_unique_count) / len(records) if records else 0.0
|
|
236
|
+
cluster_fraction = (len(records) - cluster_count) / len(records) if records else 0.0
|
|
237
|
+
near_pair_fraction = near_pair_count / total_pairs if total_pairs else 0.0
|
|
238
|
+
score = (exact_fraction + near_pair_fraction + cluster_fraction) / 3.0
|
|
239
|
+
return report(
|
|
240
|
+
name="redundancy",
|
|
241
|
+
method="exact-near-pair-threshold-component-mean",
|
|
242
|
+
version="eval-redundancy-v1",
|
|
243
|
+
parameters={
|
|
244
|
+
"similarity_method": resolved.method,
|
|
245
|
+
"threshold": resolved.cluster_threshold,
|
|
246
|
+
"k": resolved.k,
|
|
247
|
+
"canonical": resolved.canonical,
|
|
248
|
+
"score_formula": (
|
|
249
|
+
"mean(exact_duplicate_fraction,near_pair_fraction,cluster_redundancy_fraction)"
|
|
250
|
+
),
|
|
251
|
+
"short_k_policy": "literal equality when either sequence is shorter than k",
|
|
252
|
+
"topology_policy": "linearized at stored origin; no circular rotation",
|
|
253
|
+
"limits": resolved.limits,
|
|
254
|
+
},
|
|
255
|
+
metrics={
|
|
256
|
+
"score": score,
|
|
257
|
+
"record_count": len(records),
|
|
258
|
+
"exact_unique_count": exact_unique_count,
|
|
259
|
+
"exact_duplicate_fraction": exact_fraction,
|
|
260
|
+
"near_pair_count": near_pair_count,
|
|
261
|
+
"pair_count": total_pairs,
|
|
262
|
+
"near_pair_fraction": near_pair_fraction,
|
|
263
|
+
"cluster_count": cluster_count,
|
|
264
|
+
"cluster_redundancy_fraction": cluster_fraction,
|
|
265
|
+
"pairwise_comparison_count": comparisons,
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
__all__ = ["evaluate_diversity", "evaluate_redundancy", "evaluate_uniqueness"]
|