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,287 @@
|
|
|
1
|
+
"""Transparent descriptive distribution comparison between two DNA collections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections import Counter
|
|
7
|
+
|
|
8
|
+
from dnakit.core import DNARecord
|
|
9
|
+
from dnakit.descriptors import exact_repeat_fraction, gc_at_content
|
|
10
|
+
|
|
11
|
+
from ._shared import EvaluationInput, materialize_input, mean, record_for, report, require_nonempty
|
|
12
|
+
from .config import DistributionEvaluationConfig
|
|
13
|
+
from .results import EvaluationReport
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ecdf_distance(left: tuple[float, ...], right: tuple[float, ...]) -> float:
|
|
17
|
+
"""Return the exact two-sample Kolmogorov-Smirnov D statistic."""
|
|
18
|
+
|
|
19
|
+
if not left or not right:
|
|
20
|
+
return 0.0
|
|
21
|
+
support = sorted(set(left) | set(right))
|
|
22
|
+
left_sorted = sorted(left)
|
|
23
|
+
right_sorted = sorted(right)
|
|
24
|
+
left_index = 0
|
|
25
|
+
right_index = 0
|
|
26
|
+
distance = 0.0
|
|
27
|
+
for value in support:
|
|
28
|
+
while left_index < len(left_sorted) and left_sorted[left_index] <= value:
|
|
29
|
+
left_index += 1
|
|
30
|
+
while right_index < len(right_sorted) and right_sorted[right_index] <= value:
|
|
31
|
+
right_index += 1
|
|
32
|
+
distance = max(
|
|
33
|
+
distance,
|
|
34
|
+
abs(left_index / len(left_sorted) - right_index / len(right_sorted)),
|
|
35
|
+
)
|
|
36
|
+
return distance
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _total_variation(left: Counter[str], right: Counter[str]) -> float:
|
|
40
|
+
left_total = sum(left.values())
|
|
41
|
+
right_total = sum(right.values())
|
|
42
|
+
if left_total == 0 and right_total == 0:
|
|
43
|
+
return 0.0
|
|
44
|
+
support = set(left) | set(right)
|
|
45
|
+
return 0.5 * math.fsum(
|
|
46
|
+
abs(
|
|
47
|
+
(left[key] / left_total if left_total else 0.0)
|
|
48
|
+
- (right[key] / right_total if right_total else 0.0)
|
|
49
|
+
)
|
|
50
|
+
for key in support
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _kmer_counts(records: tuple[DNARecord, ...], k: int, canonical: bool) -> Counter[str]:
|
|
55
|
+
complement = str.maketrans("ACGT", "TGCA")
|
|
56
|
+
counts: Counter[str] = Counter()
|
|
57
|
+
for record in records:
|
|
58
|
+
for part in record.sequence.parts:
|
|
59
|
+
if not isinstance(part, str):
|
|
60
|
+
continue
|
|
61
|
+
for start in range(max(0, len(part) - k + 1)):
|
|
62
|
+
word = part[start : start + k]
|
|
63
|
+
if set(word) - set("ACGT"):
|
|
64
|
+
continue
|
|
65
|
+
if canonical:
|
|
66
|
+
word = min(word, word.translate(complement)[::-1])
|
|
67
|
+
counts[word] += 1
|
|
68
|
+
return counts
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _kmer_observation_count(
|
|
72
|
+
records: tuple[DNARecord, ...],
|
|
73
|
+
k: int,
|
|
74
|
+
*,
|
|
75
|
+
initial_count: int,
|
|
76
|
+
maximum: int,
|
|
77
|
+
) -> int:
|
|
78
|
+
"""Count eligible k-mers without allocating a frequency table.
|
|
79
|
+
|
|
80
|
+
The scan stops at the first observation beyond ``maximum`` so an
|
|
81
|
+
over-budget input cannot build either collection's ``Counter`` first.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
observation_count = initial_count
|
|
85
|
+
canonical_symbols = frozenset("ACGT")
|
|
86
|
+
for record in records:
|
|
87
|
+
for part in record.sequence.parts:
|
|
88
|
+
if not isinstance(part, str):
|
|
89
|
+
continue
|
|
90
|
+
for start in range(max(0, len(part) - k + 1)):
|
|
91
|
+
if set(part[start : start + k]) - canonical_symbols:
|
|
92
|
+
continue
|
|
93
|
+
observation_count += 1
|
|
94
|
+
if observation_count > maximum:
|
|
95
|
+
from dnakit.exceptions import ConfigurationError
|
|
96
|
+
|
|
97
|
+
raise ConfigurationError(
|
|
98
|
+
"Distribution comparison exceeds max_kmer_observations.",
|
|
99
|
+
code="DISTRIBUTION_KMER_LIMIT",
|
|
100
|
+
context={
|
|
101
|
+
"observation_count": observation_count,
|
|
102
|
+
"max_kmer_observations": maximum,
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
return observation_count
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _motif_rates(records: tuple[DNARecord, ...], motifs: tuple[str, ...]) -> dict[str, float]:
|
|
109
|
+
denominator = sum(record.sequence.symbol_length for record in records)
|
|
110
|
+
return {
|
|
111
|
+
motif: (
|
|
112
|
+
sum(
|
|
113
|
+
part.count(motif)
|
|
114
|
+
for record in records
|
|
115
|
+
for part in record.sequence.parts
|
|
116
|
+
if isinstance(part, str)
|
|
117
|
+
)
|
|
118
|
+
/ denominator
|
|
119
|
+
if denominator
|
|
120
|
+
else 0.0
|
|
121
|
+
)
|
|
122
|
+
for motif in motifs
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _feature_distances(
|
|
127
|
+
left: tuple[DNARecord, ...],
|
|
128
|
+
right: tuple[DNARecord, ...],
|
|
129
|
+
config: DistributionEvaluationConfig,
|
|
130
|
+
) -> tuple[dict[str, float], dict[str, object]]:
|
|
131
|
+
distances: dict[str, float] = {}
|
|
132
|
+
details: dict[str, object] = {}
|
|
133
|
+
if "length" in config.features:
|
|
134
|
+
left_values = tuple(float(record.sequence.symbol_length) for record in left)
|
|
135
|
+
right_values = tuple(float(record.sequence.symbol_length) for record in right)
|
|
136
|
+
distances["length"] = _ecdf_distance(left_values, right_values)
|
|
137
|
+
details["length"] = {
|
|
138
|
+
"distance": distances["length"],
|
|
139
|
+
"method": "two-sample empirical-CDF supremum distance (KS D; no p-value)",
|
|
140
|
+
"left_mean": mean(left_values),
|
|
141
|
+
"right_mean": mean(right_values),
|
|
142
|
+
}
|
|
143
|
+
if "gc" in config.features:
|
|
144
|
+
left_values = tuple(
|
|
145
|
+
result.gc_fraction
|
|
146
|
+
for record in left
|
|
147
|
+
if (result := gc_at_content(record, ambiguity_policy="ignore")).gc_fraction is not None
|
|
148
|
+
)
|
|
149
|
+
right_values = tuple(
|
|
150
|
+
result.gc_fraction
|
|
151
|
+
for record in right
|
|
152
|
+
if (result := gc_at_content(record, ambiguity_policy="ignore")).gc_fraction is not None
|
|
153
|
+
)
|
|
154
|
+
distances["gc"] = _ecdf_distance(left_values, right_values)
|
|
155
|
+
details["gc"] = {
|
|
156
|
+
"distance": distances["gc"],
|
|
157
|
+
"method": "two-sample empirical-CDF supremum distance (KS D; no p-value)",
|
|
158
|
+
"left_mean": mean(left_values),
|
|
159
|
+
"right_mean": mean(right_values),
|
|
160
|
+
"undefined_gc_omitted": (len(left) - len(left_values))
|
|
161
|
+
+ (len(right) - len(right_values)),
|
|
162
|
+
}
|
|
163
|
+
if "kmer" in config.features:
|
|
164
|
+
left_observations = _kmer_observation_count(
|
|
165
|
+
left,
|
|
166
|
+
config.k,
|
|
167
|
+
initial_count=0,
|
|
168
|
+
maximum=config.max_kmer_observations,
|
|
169
|
+
)
|
|
170
|
+
observation_count = _kmer_observation_count(
|
|
171
|
+
right,
|
|
172
|
+
config.k,
|
|
173
|
+
initial_count=left_observations,
|
|
174
|
+
maximum=config.max_kmer_observations,
|
|
175
|
+
)
|
|
176
|
+
left_counts = _kmer_counts(left, config.k, config.canonical)
|
|
177
|
+
right_counts = _kmer_counts(right, config.k, config.canonical)
|
|
178
|
+
distances["kmer"] = _total_variation(left_counts, right_counts)
|
|
179
|
+
details["kmer"] = {
|
|
180
|
+
"distance": distances["kmer"],
|
|
181
|
+
"method": "total variation over pooled exact k-mer frequency vectors",
|
|
182
|
+
"left_observations": left_observations,
|
|
183
|
+
"right_observations": observation_count - left_observations,
|
|
184
|
+
}
|
|
185
|
+
if "motif" in config.features:
|
|
186
|
+
left_rates = _motif_rates(left, config.motifs)
|
|
187
|
+
right_rates = _motif_rates(right, config.motifs)
|
|
188
|
+
per_motif = {motif: abs(left_rates[motif] - right_rates[motif]) for motif in config.motifs}
|
|
189
|
+
distances["motif"] = math.fsum(per_motif.values()) / len(per_motif)
|
|
190
|
+
details["motif"] = {
|
|
191
|
+
"distance": distances["motif"],
|
|
192
|
+
"method": "mean absolute difference of non-overlapping motif counts per symbol",
|
|
193
|
+
"left_rates": left_rates,
|
|
194
|
+
"right_rates": right_rates,
|
|
195
|
+
"absolute_differences": per_motif,
|
|
196
|
+
}
|
|
197
|
+
if "repeat" in config.features:
|
|
198
|
+
left_values = tuple(
|
|
199
|
+
exact_repeat_fraction(
|
|
200
|
+
record,
|
|
201
|
+
ambiguity_policy="ignore",
|
|
202
|
+
max_comparisons=config.max_repeat_comparisons_per_sequence,
|
|
203
|
+
).repeat_fraction
|
|
204
|
+
for record in left
|
|
205
|
+
)
|
|
206
|
+
right_values = tuple(
|
|
207
|
+
exact_repeat_fraction(
|
|
208
|
+
record,
|
|
209
|
+
ambiguity_policy="ignore",
|
|
210
|
+
max_comparisons=config.max_repeat_comparisons_per_sequence,
|
|
211
|
+
).repeat_fraction
|
|
212
|
+
for record in right
|
|
213
|
+
)
|
|
214
|
+
distances["repeat"] = _ecdf_distance(left_values, right_values)
|
|
215
|
+
details["repeat"] = {
|
|
216
|
+
"distance": distances["repeat"],
|
|
217
|
+
"method": "two-sample empirical-CDF supremum distance of exact-repeat union fraction",
|
|
218
|
+
"left_mean": mean(left_values),
|
|
219
|
+
"right_mean": mean(right_values),
|
|
220
|
+
}
|
|
221
|
+
return distances, details
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def evaluate_distribution_similarity(
|
|
225
|
+
left: EvaluationInput,
|
|
226
|
+
right: EvaluationInput,
|
|
227
|
+
*,
|
|
228
|
+
config: DistributionEvaluationConfig | None = None,
|
|
229
|
+
) -> EvaluationReport:
|
|
230
|
+
"""Compare length, GC, k-mer, motif, and repeat distributions without p-values."""
|
|
231
|
+
|
|
232
|
+
resolved = DistributionEvaluationConfig() if config is None else config
|
|
233
|
+
if not isinstance(resolved, DistributionEvaluationConfig):
|
|
234
|
+
raise TypeError("config must be DistributionEvaluationConfig or None.")
|
|
235
|
+
left_items = materialize_input(left, limits=resolved.limits)
|
|
236
|
+
right_items = materialize_input(right, limits=resolved.limits)
|
|
237
|
+
require_nonempty(left_items, "distribution similarity")
|
|
238
|
+
require_nonempty(right_items, "distribution similarity")
|
|
239
|
+
if (
|
|
240
|
+
sum(item.sequence.symbol_length for item in left_items + right_items)
|
|
241
|
+
> resolved.limits.max_total_symbols
|
|
242
|
+
):
|
|
243
|
+
from dnakit.exceptions import ConfigurationError
|
|
244
|
+
|
|
245
|
+
raise ConfigurationError(
|
|
246
|
+
"Combined distribution inputs exceed max_total_symbols.",
|
|
247
|
+
code="EVALUATION_SYMBOL_LIMIT",
|
|
248
|
+
)
|
|
249
|
+
left_records = tuple(record_for(item) for item in left_items)
|
|
250
|
+
right_records = tuple(record_for(item) for item in right_items)
|
|
251
|
+
distances, details = _feature_distances(left_records, right_records, resolved)
|
|
252
|
+
aggregate_distance = math.fsum(distances.values()) / len(distances)
|
|
253
|
+
return report(
|
|
254
|
+
name="distribution_similarity",
|
|
255
|
+
method="mean-of-transparent-feature-distances",
|
|
256
|
+
version="eval-distribution-v1",
|
|
257
|
+
parameters={
|
|
258
|
+
"features": resolved.features,
|
|
259
|
+
"k": resolved.k,
|
|
260
|
+
"canonical": resolved.canonical,
|
|
261
|
+
"motifs": resolved.motifs,
|
|
262
|
+
"aggregation": "1 - arithmetic mean of feature distances",
|
|
263
|
+
"inference": "descriptive distances only; no hypothesis-test p-values",
|
|
264
|
+
"motif_counting": "str.count non-overlapping",
|
|
265
|
+
"gap_policy": "analyze text parts separately; no k-mer or motif crosses a Gap",
|
|
266
|
+
"topology_policy": "linearized at stored origin; no circular-origin wrap",
|
|
267
|
+
"ambiguity_policy": (
|
|
268
|
+
"length retains IUPAC symbols; GC, k-mer, motif, and repeat calculations "
|
|
269
|
+
"exclude affected noncanonical observations"
|
|
270
|
+
),
|
|
271
|
+
"max_kmer_observations": resolved.max_kmer_observations,
|
|
272
|
+
"max_repeat_comparisons_per_sequence": resolved.max_repeat_comparisons_per_sequence,
|
|
273
|
+
"limits": resolved.limits,
|
|
274
|
+
},
|
|
275
|
+
metrics={
|
|
276
|
+
"score": 1.0 - aggregate_distance,
|
|
277
|
+
"distribution_similarity": 1.0 - aggregate_distance,
|
|
278
|
+
"aggregate_distance": aggregate_distance,
|
|
279
|
+
"feature_distances": distances,
|
|
280
|
+
"feature_details": details,
|
|
281
|
+
"left_count": len(left_records),
|
|
282
|
+
"right_count": len(right_records),
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
__all__ = ["evaluate_distribution_similarity"]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Fréchet distance between two DNA representation distributions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from dnakit.exceptions import (
|
|
9
|
+
BackendExecutionError,
|
|
10
|
+
BackendUnavailableError,
|
|
11
|
+
ConfigurationError,
|
|
12
|
+
)
|
|
13
|
+
from dnakit.representations import RepresentationBackend, extract_representations
|
|
14
|
+
|
|
15
|
+
from ._shared import EvaluationInput, materialize_input, record_for, report, require_nonempty
|
|
16
|
+
from .config import FrechetDistanceConfig
|
|
17
|
+
from .results import EvaluationReport
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _require_numpy() -> Any:
|
|
21
|
+
try:
|
|
22
|
+
import numpy as np
|
|
23
|
+
except ImportError as exc:
|
|
24
|
+
raise BackendUnavailableError(
|
|
25
|
+
"Fréchet DNA distance requires NumPy and a representation backend.",
|
|
26
|
+
code="MISSING_NEURAL_DEPENDENCY",
|
|
27
|
+
hint='Install the neural extra with: python -m pip install "dnakit[neural]"',
|
|
28
|
+
) from exc
|
|
29
|
+
return np
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _l2_normalize(matrix: Any, np: Any) -> Any:
|
|
33
|
+
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
|
|
34
|
+
zero_rows = np.flatnonzero(norms[:, 0] == 0)
|
|
35
|
+
if len(zero_rows):
|
|
36
|
+
raise ConfigurationError(
|
|
37
|
+
"Cannot L2-normalize zero-valued model representations.",
|
|
38
|
+
code="ZERO_REPRESENTATION_VECTOR",
|
|
39
|
+
context={"row_indices": tuple(int(value) for value in zero_rows[:20])},
|
|
40
|
+
)
|
|
41
|
+
return matrix / norms
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _frechet_components(left: Any, right: Any, np: Any) -> dict[str, float]:
|
|
45
|
+
"""Compute Gaussian Fréchet terms through an exact sample-space identity.
|
|
46
|
+
|
|
47
|
+
If ``A`` and ``B`` are centered sample matrices scaled by ``sqrt(n - 1)``,
|
|
48
|
+
the covariance cross term equals the nuclear norm of ``A @ B.T``. This
|
|
49
|
+
avoids materializing dense feature-by-feature covariance matrices.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
left_mean = np.mean(left, axis=0, dtype=np.float64)
|
|
53
|
+
right_mean = np.mean(right, axis=0, dtype=np.float64)
|
|
54
|
+
mean_delta = left_mean - right_mean
|
|
55
|
+
mean_component = float(np.dot(mean_delta, mean_delta))
|
|
56
|
+
|
|
57
|
+
left_scaled = (left - left_mean) / math.sqrt(int(left.shape[0]) - 1)
|
|
58
|
+
right_scaled = (right - right_mean) / math.sqrt(int(right.shape[0]) - 1)
|
|
59
|
+
left_covariance_trace = float(np.sum(left_scaled * left_scaled, dtype=np.float64))
|
|
60
|
+
right_covariance_trace = float(np.sum(right_scaled * right_scaled, dtype=np.float64))
|
|
61
|
+
try:
|
|
62
|
+
cross_gram = left_scaled @ right_scaled.T
|
|
63
|
+
singular_values = np.linalg.svd(cross_gram, compute_uv=False)
|
|
64
|
+
except (ValueError, np.linalg.LinAlgError) as exc:
|
|
65
|
+
raise BackendExecutionError(
|
|
66
|
+
"Could not compute the Fréchet covariance cross term.",
|
|
67
|
+
code="FRECHET_DECOMPOSITION_FAILED",
|
|
68
|
+
context={"error_type": type(exc).__name__},
|
|
69
|
+
) from exc
|
|
70
|
+
cross_nuclear_norm = float(np.sum(singular_values, dtype=np.float64))
|
|
71
|
+
raw_covariance_component = (
|
|
72
|
+
left_covariance_trace + right_covariance_trace - 2.0 * cross_nuclear_norm
|
|
73
|
+
)
|
|
74
|
+
tolerance = 1e-10 * max(
|
|
75
|
+
1.0,
|
|
76
|
+
left_covariance_trace + right_covariance_trace,
|
|
77
|
+
cross_nuclear_norm,
|
|
78
|
+
)
|
|
79
|
+
if raw_covariance_component < -tolerance:
|
|
80
|
+
raise BackendExecutionError(
|
|
81
|
+
"The Fréchet covariance term is numerically inconsistent.",
|
|
82
|
+
code="INVALID_FRECHET_COVARIANCE_TERM",
|
|
83
|
+
context={
|
|
84
|
+
"covariance_component": raw_covariance_component,
|
|
85
|
+
"tolerance": tolerance,
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
covariance_component = max(0.0, raw_covariance_component)
|
|
89
|
+
distance = mean_component + covariance_component
|
|
90
|
+
if not math.isfinite(distance):
|
|
91
|
+
raise BackendExecutionError(
|
|
92
|
+
"The Fréchet distance is not finite.",
|
|
93
|
+
code="INVALID_FRECHET_DISTANCE",
|
|
94
|
+
)
|
|
95
|
+
return {
|
|
96
|
+
"frechet_distance": distance,
|
|
97
|
+
"mean_component": mean_component,
|
|
98
|
+
"covariance_component": covariance_component,
|
|
99
|
+
"left_covariance_trace": left_covariance_trace,
|
|
100
|
+
"right_covariance_trace": right_covariance_trace,
|
|
101
|
+
"cross_covariance_nuclear_norm": cross_nuclear_norm,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def evaluate_frechet_distance(
|
|
106
|
+
left: EvaluationInput,
|
|
107
|
+
right: EvaluationInput,
|
|
108
|
+
*,
|
|
109
|
+
config: FrechetDistanceConfig | None = None,
|
|
110
|
+
backend: RepresentationBackend | None = None,
|
|
111
|
+
) -> EvaluationReport:
|
|
112
|
+
"""Compare two DNA collections with a Gaussian Fréchet representation distance.
|
|
113
|
+
|
|
114
|
+
The default representation model is LucaOne, matching ``DATA-027``. A
|
|
115
|
+
standard LucaOne backend still requires explicit ``allow_remote_code=True``
|
|
116
|
+
after the caller reviews its checkpoint code. Passing ``backend`` enables
|
|
117
|
+
controlled custom encoders and tests without a checkpoint download.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
resolved = FrechetDistanceConfig() if config is None else config
|
|
121
|
+
if not isinstance(resolved, FrechetDistanceConfig):
|
|
122
|
+
raise ConfigurationError(
|
|
123
|
+
"config must be FrechetDistanceConfig or None.",
|
|
124
|
+
code="INVALID_FRECHET_DISTANCE_CONFIG",
|
|
125
|
+
)
|
|
126
|
+
left_items = materialize_input(left, limits=resolved.limits)
|
|
127
|
+
right_items = materialize_input(right, limits=resolved.limits)
|
|
128
|
+
require_nonempty(left_items, "Fréchet DNA distance")
|
|
129
|
+
require_nonempty(right_items, "Fréchet DNA distance")
|
|
130
|
+
if len(left_items) < 2 or len(right_items) < 2:
|
|
131
|
+
raise ConfigurationError(
|
|
132
|
+
"Fréchet DNA distance requires at least two records in each collection.",
|
|
133
|
+
code="FRECHET_MINIMUM_SAMPLE_SIZE",
|
|
134
|
+
context={"left_count": len(left_items), "right_count": len(right_items)},
|
|
135
|
+
)
|
|
136
|
+
total_symbols = sum(item.sequence.symbol_length for item in left_items + right_items)
|
|
137
|
+
if total_symbols > resolved.limits.max_total_symbols:
|
|
138
|
+
raise ConfigurationError(
|
|
139
|
+
"Combined Fréchet inputs exceed max_total_symbols.",
|
|
140
|
+
code="EVALUATION_SYMBOL_LIMIT",
|
|
141
|
+
context={
|
|
142
|
+
"total_symbols": total_symbols,
|
|
143
|
+
"max_total_symbols": resolved.limits.max_total_symbols,
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
cross_gram_elements = len(left_items) * len(right_items)
|
|
147
|
+
if cross_gram_elements > resolved.max_cross_gram_elements:
|
|
148
|
+
raise ConfigurationError(
|
|
149
|
+
"Fréchet DNA distance exceeds max_cross_gram_elements.",
|
|
150
|
+
code="FRECHET_CROSS_GRAM_LIMIT",
|
|
151
|
+
context={
|
|
152
|
+
"cross_gram_elements": cross_gram_elements,
|
|
153
|
+
"max_cross_gram_elements": resolved.max_cross_gram_elements,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
records = tuple(record_for(item) for item in left_items + right_items)
|
|
158
|
+
representation_result = extract_representations(
|
|
159
|
+
records,
|
|
160
|
+
config=resolved.representation,
|
|
161
|
+
backend=backend,
|
|
162
|
+
)
|
|
163
|
+
np = _require_numpy()
|
|
164
|
+
matrix = np.asarray(representation_result.representations, dtype=np.float64)
|
|
165
|
+
if resolved.normalize:
|
|
166
|
+
matrix = _l2_normalize(matrix, np)
|
|
167
|
+
left_matrix = matrix[: len(left_items)]
|
|
168
|
+
right_matrix = matrix[len(left_items) :]
|
|
169
|
+
components = _frechet_components(left_matrix, right_matrix, np)
|
|
170
|
+
|
|
171
|
+
return report(
|
|
172
|
+
name="frechet_dna_distance",
|
|
173
|
+
method="gaussian-frechet-distance-over-dna-model-representations",
|
|
174
|
+
version="eval-frechet-dna-v1",
|
|
175
|
+
parameters={
|
|
176
|
+
"formula": (
|
|
177
|
+
"||mu_left-mu_right||_2^2 + trace(Sigma_left + Sigma_right "
|
|
178
|
+
"- 2*(Sigma_left^(1/2)*Sigma_right*Sigma_left^(1/2))^(1/2))"
|
|
179
|
+
),
|
|
180
|
+
"encoder": representation_result.model_name,
|
|
181
|
+
"checkpoint_path": representation_result.checkpoint_path,
|
|
182
|
+
"pooling": representation_result.pooling,
|
|
183
|
+
"representation_config": {
|
|
184
|
+
"model": representation_result.model_name,
|
|
185
|
+
"checkpoint_path": representation_result.checkpoint_path,
|
|
186
|
+
"model_source_path": resolved.representation.model_source_path,
|
|
187
|
+
"pooling": representation_result.pooling,
|
|
188
|
+
"ambiguity_policy": representation_result.ambiguity_policy,
|
|
189
|
+
"device": resolved.representation.device,
|
|
190
|
+
"dtype": resolved.representation.dtype,
|
|
191
|
+
"batch_size": resolved.representation.batch_size,
|
|
192
|
+
"max_length": resolved.representation.max_length,
|
|
193
|
+
"allow_remote_code": resolved.representation.allow_remote_code,
|
|
194
|
+
},
|
|
195
|
+
"normalize": resolved.normalize,
|
|
196
|
+
"sample_covariance_ddof": 1,
|
|
197
|
+
"numerical_method": "exact sample-space cross-Gram nuclear norm",
|
|
198
|
+
"lower_is_better": True,
|
|
199
|
+
"max_cross_gram_elements": resolved.max_cross_gram_elements,
|
|
200
|
+
"limits": resolved.limits,
|
|
201
|
+
"inference": (
|
|
202
|
+
"FCD-analogous descriptive distance; not ChemNet FCD and not an "
|
|
203
|
+
"experimentally validated DNA quality score"
|
|
204
|
+
),
|
|
205
|
+
},
|
|
206
|
+
metrics={
|
|
207
|
+
**components,
|
|
208
|
+
"left_count": len(left_items),
|
|
209
|
+
"right_count": len(right_items),
|
|
210
|
+
"embedding_dimension": representation_result.embedding_dimension,
|
|
211
|
+
"left_covariance_rank_upper_bound": min(
|
|
212
|
+
len(left_items) - 1,
|
|
213
|
+
representation_result.embedding_dimension,
|
|
214
|
+
),
|
|
215
|
+
"right_covariance_rank_upper_bound": min(
|
|
216
|
+
len(right_items) - 1,
|
|
217
|
+
representation_result.embedding_dimension,
|
|
218
|
+
),
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
__all__ = ["evaluate_frechet_distance"]
|