ArraySplitter 1.3.0__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.
- ArraySplitter/__init__.py +10 -0
- ArraySplitter/classify.py +519 -0
- ArraySplitter/core_functions/__init__.py +0 -0
- ArraySplitter/core_functions/io/__init__.py +0 -0
- ArraySplitter/core_functions/io/block_file.py +156 -0
- ArraySplitter/core_functions/io/fasta_reader.py +51 -0
- ArraySplitter/core_functions/io/satellome_reader.py +20 -0
- ArraySplitter/core_functions/io/tab_file.py +200 -0
- ArraySplitter/core_functions/io/trf_reader.py +79 -0
- ArraySplitter/core_functions/models/__init__.py +0 -0
- ArraySplitter/core_functions/models/trf_model.py +332 -0
- ArraySplitter/core_functions/tools/__init__.py +0 -0
- ArraySplitter/core_functions/tools/anchor_graph.py +726 -0
- ArraySplitter/core_functions/tools/fs_tree.py +210 -0
- ArraySplitter/core_functions/tools/parsers.py +230 -0
- ArraySplitter/core_functions/tools/rotation.py +99 -0
- ArraySplitter/core_functions/tools/sequences.py +20 -0
- ArraySplitter/decompose.py +1784 -0
- ArraySplitter/extract.py +54 -0
- ArraySplitter/main.py +191 -0
- ArraySplitter/rotate.py +35 -0
- arraysplitter-1.3.0.dist-info/METADATA +272 -0
- arraysplitter-1.3.0.dist-info/RECORD +27 -0
- arraysplitter-1.3.0.dist-info/WHEEL +5 -0
- arraysplitter-1.3.0.dist-info/entry_points.txt +2 -0
- arraysplitter-1.3.0.dist-info/licenses/LICENSE +21 -0
- arraysplitter-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Anchor Graph for monomer decomposition.
|
|
3
|
+
|
|
4
|
+
Key idea: Build a graph where:
|
|
5
|
+
- Nodes = conserved parts (anchors) from FS-tree hints
|
|
6
|
+
- Edges = transitions between anchors with support (copy number)
|
|
7
|
+
|
|
8
|
+
This solves two problems:
|
|
9
|
+
1. Multiple conserved parts per monomer (overcutting)
|
|
10
|
+
2. Mutations in anchor (missing monomers) - handled by using shorter anchors
|
|
11
|
+
where longer ones are absent
|
|
12
|
+
|
|
13
|
+
The graph structure reveals the monomer architecture.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from collections import defaultdict, Counter
|
|
17
|
+
from typing import List, Tuple, Dict, Set, Optional
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from statistics import median
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class AnchorHit:
|
|
24
|
+
"""Single occurrence of an anchor in sequence."""
|
|
25
|
+
anchor: str
|
|
26
|
+
position: int
|
|
27
|
+
length: int
|
|
28
|
+
|
|
29
|
+
def __lt__(self, other):
|
|
30
|
+
return self.position < other.position
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_top_candidates(candidates: List[Dict], top_k: int = 10) -> List[Dict]:
|
|
34
|
+
"""
|
|
35
|
+
Get top-k candidates from compute_cuts output.
|
|
36
|
+
|
|
37
|
+
Candidates are already scored by compute_cuts logic.
|
|
38
|
+
"""
|
|
39
|
+
# Sort by adjusted_score (or base_score if adjusted not available)
|
|
40
|
+
sorted_candidates = sorted(
|
|
41
|
+
candidates,
|
|
42
|
+
key=lambda x: x.get('adjusted_score', x.get('base_score', 0)),
|
|
43
|
+
reverse=True
|
|
44
|
+
)
|
|
45
|
+
return sorted_candidates[:top_k]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# =============================================================================
|
|
49
|
+
# Distance Clustering Functions
|
|
50
|
+
# =============================================================================
|
|
51
|
+
|
|
52
|
+
def simple_kmeans(data: List[float], k: int, max_iter: int = 100) -> Tuple[List[List[float]], List[float]]:
|
|
53
|
+
"""
|
|
54
|
+
Simple k-means clustering without external dependencies.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
data: List of values to cluster
|
|
58
|
+
k: Number of clusters
|
|
59
|
+
max_iter: Maximum iterations
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
(clusters, centers) - list of clusters and their centers
|
|
63
|
+
"""
|
|
64
|
+
if not data or k <= 0:
|
|
65
|
+
return [[]], [0]
|
|
66
|
+
|
|
67
|
+
if k == 1:
|
|
68
|
+
return [data], [sum(data) / len(data)]
|
|
69
|
+
|
|
70
|
+
# Initialize centers evenly across the data range
|
|
71
|
+
data_sorted = sorted(data)
|
|
72
|
+
n = len(data_sorted)
|
|
73
|
+
centers = [data_sorted[min(i * n // k, n - 1)] for i in range(k)]
|
|
74
|
+
|
|
75
|
+
for _ in range(max_iter):
|
|
76
|
+
# Assign points to nearest center
|
|
77
|
+
clusters = [[] for _ in range(k)]
|
|
78
|
+
for x in data:
|
|
79
|
+
nearest = min(range(k), key=lambda i: abs(x - centers[i]))
|
|
80
|
+
clusters[nearest].append(x)
|
|
81
|
+
|
|
82
|
+
# Update centers
|
|
83
|
+
new_centers = []
|
|
84
|
+
for i, c in enumerate(clusters):
|
|
85
|
+
if c:
|
|
86
|
+
new_centers.append(sum(c) / len(c))
|
|
87
|
+
else:
|
|
88
|
+
# Empty cluster - keep old center
|
|
89
|
+
new_centers.append(centers[i])
|
|
90
|
+
|
|
91
|
+
if new_centers == centers:
|
|
92
|
+
break
|
|
93
|
+
centers = new_centers
|
|
94
|
+
|
|
95
|
+
return clusters, centers
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def find_optimal_clusters(data: List[float], max_k: int = 4) -> Tuple[int, List[List[float]], List[float]]:
|
|
99
|
+
"""
|
|
100
|
+
Find optimal number of clusters (1 to max_k).
|
|
101
|
+
|
|
102
|
+
Criteria:
|
|
103
|
+
- All clusters must be significant (>15% of data)
|
|
104
|
+
- Cluster centers must be well separated (gap > 20% of max center)
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
(n_clusters, clusters, centers)
|
|
108
|
+
"""
|
|
109
|
+
if len(data) < 4:
|
|
110
|
+
return 1, [data], [median(data)]
|
|
111
|
+
|
|
112
|
+
best_k = 1
|
|
113
|
+
best_score = 0
|
|
114
|
+
best_clusters = [data]
|
|
115
|
+
best_centers = [median(data)]
|
|
116
|
+
|
|
117
|
+
for k in range(2, min(max_k + 1, len(data) // 2 + 1)):
|
|
118
|
+
clusters, centers = simple_kmeans(data, k)
|
|
119
|
+
|
|
120
|
+
# Filter out empty clusters
|
|
121
|
+
non_empty = [(c, ctr) for c, ctr in zip(clusters, centers) if c]
|
|
122
|
+
if len(non_empty) < k:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
clusters = [x[0] for x in non_empty]
|
|
126
|
+
centers = [x[1] for x in non_empty]
|
|
127
|
+
|
|
128
|
+
# Check that all clusters are significant (>15% of data)
|
|
129
|
+
min_size = min(len(c) for c in clusters)
|
|
130
|
+
if min_size < len(data) * 0.15:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# Check that centers are well separated
|
|
134
|
+
centers_sorted = sorted(centers)
|
|
135
|
+
if len(centers_sorted) < 2:
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
min_gap = min(centers_sorted[i + 1] - centers_sorted[i]
|
|
139
|
+
for i in range(len(centers_sorted) - 1))
|
|
140
|
+
max_center = max(centers_sorted)
|
|
141
|
+
|
|
142
|
+
if min_gap < 0.2 * max_center:
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
# Score: separation quality
|
|
146
|
+
score = min_gap / max_center
|
|
147
|
+
if score > best_score:
|
|
148
|
+
best_score = score
|
|
149
|
+
best_k = k
|
|
150
|
+
best_clusters = clusters
|
|
151
|
+
best_centers = centers
|
|
152
|
+
|
|
153
|
+
return best_k, best_clusters, best_centers
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def analyze_distance_distribution(distances: List[float], verbose: bool = False) -> Tuple[int, List[float], float]:
|
|
157
|
+
"""
|
|
158
|
+
Analyze distance distribution to detect multi-region patterns.
|
|
159
|
+
|
|
160
|
+
If anchor appears N times per monomer, distances will have N clusters.
|
|
161
|
+
True period = sum of cluster centers.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
distances: List of distances between consecutive anchor occurrences
|
|
165
|
+
verbose: Print debug info
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
(n_clusters, cluster_centers, true_period)
|
|
169
|
+
"""
|
|
170
|
+
if len(distances) < 4:
|
|
171
|
+
med = median(distances) if distances else 0
|
|
172
|
+
return 1, [med], med
|
|
173
|
+
|
|
174
|
+
n_clusters, clusters, centers = find_optimal_clusters(distances, max_k=4)
|
|
175
|
+
|
|
176
|
+
# Sort centers by value for consistent ordering
|
|
177
|
+
centers_sorted = sorted(centers)
|
|
178
|
+
true_period = sum(centers_sorted)
|
|
179
|
+
|
|
180
|
+
if verbose and n_clusters > 1:
|
|
181
|
+
print(f" Distance clustering: {n_clusters} clusters")
|
|
182
|
+
for i, (c, ctr) in enumerate(sorted(zip(clusters, centers), key=lambda x: x[1])):
|
|
183
|
+
print(f" Cluster {i}: center={ctr:.0f}, count={len(c)}")
|
|
184
|
+
print(f" True period: {true_period:.0f}")
|
|
185
|
+
|
|
186
|
+
return n_clusters, centers_sorted, true_period
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def classify_positions_by_distance(
|
|
190
|
+
positions: List[int],
|
|
191
|
+
centers: List[float],
|
|
192
|
+
verbose: bool = False
|
|
193
|
+
) -> List[int]:
|
|
194
|
+
"""
|
|
195
|
+
Classify each position by the cluster of its distance to the next position.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
positions: Sorted list of anchor positions
|
|
199
|
+
centers: Cluster centers from analyze_distance_distribution
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
List of cluster labels (0, 1, 2, ...) for each position.
|
|
203
|
+
Last position gets label -1 (no next position).
|
|
204
|
+
"""
|
|
205
|
+
if len(positions) < 2:
|
|
206
|
+
return [-1] * len(positions)
|
|
207
|
+
|
|
208
|
+
labels = []
|
|
209
|
+
for i in range(len(positions) - 1):
|
|
210
|
+
dist = positions[i + 1] - positions[i]
|
|
211
|
+
# Find nearest cluster center
|
|
212
|
+
nearest = min(range(len(centers)), key=lambda j: abs(dist - centers[j]))
|
|
213
|
+
labels.append(nearest)
|
|
214
|
+
|
|
215
|
+
# Last position has no "next", mark with -1
|
|
216
|
+
labels.append(-1)
|
|
217
|
+
|
|
218
|
+
if verbose:
|
|
219
|
+
label_counts = Counter(l for l in labels if l >= 0)
|
|
220
|
+
print(f" Position labels: {dict(label_counts)}")
|
|
221
|
+
|
|
222
|
+
return labels
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def select_cut_positions_by_cluster(
|
|
226
|
+
positions: List[int],
|
|
227
|
+
labels: List[int],
|
|
228
|
+
verbose: bool = False
|
|
229
|
+
) -> List[int]:
|
|
230
|
+
"""
|
|
231
|
+
Select cut positions based on cluster analysis.
|
|
232
|
+
|
|
233
|
+
Strategy: Cut at positions with the same label as the first position.
|
|
234
|
+
This preserves the monomer structure starting from the left flank.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
positions: Sorted list of anchor positions
|
|
238
|
+
labels: Cluster labels for each position
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
List of positions to cut at
|
|
242
|
+
"""
|
|
243
|
+
if not positions or not labels:
|
|
244
|
+
return positions
|
|
245
|
+
|
|
246
|
+
# Use the label of the first position as the "start" label
|
|
247
|
+
# This defines which region (A, B, C...) is the monomer start
|
|
248
|
+
start_label = labels[0]
|
|
249
|
+
|
|
250
|
+
if start_label < 0:
|
|
251
|
+
# Only one position, return it
|
|
252
|
+
return positions
|
|
253
|
+
|
|
254
|
+
# Select all positions with the start label
|
|
255
|
+
cut_positions = [pos for pos, label in zip(positions, labels) if label == start_label]
|
|
256
|
+
|
|
257
|
+
# Also include the last position if it wasn't included
|
|
258
|
+
# (to capture the final partial monomer)
|
|
259
|
+
if positions[-1] not in cut_positions:
|
|
260
|
+
cut_positions.append(positions[-1])
|
|
261
|
+
|
|
262
|
+
if verbose:
|
|
263
|
+
print(f" Start label: {start_label}")
|
|
264
|
+
print(f" Cut positions: {len(cut_positions)} of {len(positions)} total")
|
|
265
|
+
|
|
266
|
+
return sorted(cut_positions)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def find_anchor_hits_hierarchical(
|
|
270
|
+
sequence: str,
|
|
271
|
+
anchors: List[str],
|
|
272
|
+
verbose: bool = False
|
|
273
|
+
) -> List[AnchorHit]:
|
|
274
|
+
"""
|
|
275
|
+
Find all anchor occurrences, using longer anchors first.
|
|
276
|
+
|
|
277
|
+
Logic:
|
|
278
|
+
1. Sort anchors by length (longest first)
|
|
279
|
+
2. Find all positions of longest anchor
|
|
280
|
+
3. For shorter anchors - only find in regions NOT covered by longer ones
|
|
281
|
+
4. This handles mutations: if long anchor has mutation, short variant will be found
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
sequence: The array sequence
|
|
285
|
+
anchors: List of anchor sequences (will be sorted by length)
|
|
286
|
+
verbose: Print debug info
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
List of AnchorHit sorted by position
|
|
290
|
+
"""
|
|
291
|
+
if not anchors:
|
|
292
|
+
return []
|
|
293
|
+
|
|
294
|
+
# Sort by length descending
|
|
295
|
+
sorted_anchors = sorted(anchors, key=len, reverse=True)
|
|
296
|
+
|
|
297
|
+
# Track covered positions
|
|
298
|
+
covered = set()
|
|
299
|
+
all_hits = []
|
|
300
|
+
|
|
301
|
+
for anchor in sorted_anchors:
|
|
302
|
+
anchor_len = len(anchor)
|
|
303
|
+
|
|
304
|
+
# Find all occurrences
|
|
305
|
+
start = 0
|
|
306
|
+
hits_for_anchor = 0
|
|
307
|
+
|
|
308
|
+
while True:
|
|
309
|
+
pos = sequence.find(anchor, start)
|
|
310
|
+
if pos == -1:
|
|
311
|
+
break
|
|
312
|
+
|
|
313
|
+
# Check if this position is already covered by a longer anchor
|
|
314
|
+
# A position is "covered" if ANY part of this anchor overlaps with covered region
|
|
315
|
+
is_covered = any(p in covered for p in range(pos, pos + anchor_len))
|
|
316
|
+
|
|
317
|
+
if not is_covered:
|
|
318
|
+
all_hits.append(AnchorHit(anchor=anchor, position=pos, length=anchor_len))
|
|
319
|
+
# Mark positions as covered
|
|
320
|
+
for p in range(pos, pos + anchor_len):
|
|
321
|
+
covered.add(p)
|
|
322
|
+
hits_for_anchor += 1
|
|
323
|
+
|
|
324
|
+
start = pos + 1
|
|
325
|
+
|
|
326
|
+
if verbose and hits_for_anchor > 0:
|
|
327
|
+
print(f" '{anchor}' (len={anchor_len}): {hits_for_anchor} hits")
|
|
328
|
+
|
|
329
|
+
# Sort by position
|
|
330
|
+
all_hits.sort()
|
|
331
|
+
|
|
332
|
+
return all_hits
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def build_transition_graph(hits: List[AnchorHit]) -> Dict:
|
|
336
|
+
"""
|
|
337
|
+
Build graph of transitions between consecutive anchor hits.
|
|
338
|
+
|
|
339
|
+
Returns dict with:
|
|
340
|
+
- edges: {(from_anchor, to_anchor): count}
|
|
341
|
+
- distances: {(from_anchor, to_anchor): [list of distances]}
|
|
342
|
+
- sequences: {(from_anchor, to_anchor): [list of sequences between]}
|
|
343
|
+
"""
|
|
344
|
+
edges = Counter()
|
|
345
|
+
distances = defaultdict(list)
|
|
346
|
+
|
|
347
|
+
for i in range(len(hits) - 1):
|
|
348
|
+
curr = hits[i]
|
|
349
|
+
next_hit = hits[i + 1]
|
|
350
|
+
|
|
351
|
+
edge = (curr.anchor, next_hit.anchor)
|
|
352
|
+
distance = next_hit.position - curr.position
|
|
353
|
+
|
|
354
|
+
edges[edge] += 1
|
|
355
|
+
distances[edge].append(distance)
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
"edges": dict(edges),
|
|
359
|
+
"distances": dict(distances),
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def find_monomer_cycle(graph: Dict, hits: List[AnchorHit], sequence: str, verbose: bool = False) -> Tuple[List[str], float, float, int, List[float]]:
|
|
364
|
+
"""
|
|
365
|
+
Find the best anchor for decomposition using distance clustering.
|
|
366
|
+
|
|
367
|
+
Strategy:
|
|
368
|
+
1. For each anchor with self-loop, analyze distance distribution
|
|
369
|
+
2. Cluster distances to detect multi-region patterns (A-B, A-B-C, etc.)
|
|
370
|
+
3. True period = sum of cluster centers
|
|
371
|
+
4. Select anchor with lowest CV after cluster-based correction
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Tuple of (cycle, mean_period, cv, n_clusters, cluster_centers)
|
|
375
|
+
"""
|
|
376
|
+
edges = graph["edges"]
|
|
377
|
+
distances = graph.get("distances", {})
|
|
378
|
+
|
|
379
|
+
if not edges:
|
|
380
|
+
return [], 0, float('inf'), 1, []
|
|
381
|
+
|
|
382
|
+
# Collect candidate anchors with cluster analysis
|
|
383
|
+
# Format: (cycle, cv, mean_period, count, n_clusters, centers)
|
|
384
|
+
candidates = []
|
|
385
|
+
|
|
386
|
+
# Get unique anchors from graph with self-loops
|
|
387
|
+
anchors_to_try = set()
|
|
388
|
+
for (from_a, to_a), count in edges.items():
|
|
389
|
+
if count >= 3: # Need enough occurrences
|
|
390
|
+
anchors_to_try.add(from_a)
|
|
391
|
+
if from_a != to_a:
|
|
392
|
+
anchors_to_try.add(to_a)
|
|
393
|
+
|
|
394
|
+
for anchor in anchors_to_try:
|
|
395
|
+
# Find ALL positions for this anchor (not just self-loop)
|
|
396
|
+
positions = [hit.position for hit in hits if hit.anchor == anchor]
|
|
397
|
+
if len(positions) < 3:
|
|
398
|
+
continue
|
|
399
|
+
positions.sort()
|
|
400
|
+
|
|
401
|
+
# Calculate distances between ALL consecutive positions of this anchor
|
|
402
|
+
# This captures the full pattern even if other anchors appear in between
|
|
403
|
+
dist_list = []
|
|
404
|
+
for i in range(len(positions) - 1):
|
|
405
|
+
dist_list.append(positions[i + 1] - positions[i])
|
|
406
|
+
|
|
407
|
+
if len(dist_list) < 3:
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
# Analyze distance distribution - detect multi-region patterns
|
|
411
|
+
n_clusters, centers, true_period = analyze_distance_distribution(dist_list, verbose=False)
|
|
412
|
+
|
|
413
|
+
# Classify positions and get cut positions
|
|
414
|
+
labels = classify_positions_by_distance(positions, centers)
|
|
415
|
+
cut_positions = select_cut_positions_by_cluster(positions, labels)
|
|
416
|
+
|
|
417
|
+
if len(cut_positions) < 2:
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
# Calculate CV of resulting monomer lengths
|
|
421
|
+
lengths = []
|
|
422
|
+
for i in range(len(cut_positions) - 1):
|
|
423
|
+
lengths.append(cut_positions[i + 1] - cut_positions[i])
|
|
424
|
+
|
|
425
|
+
if len(lengths) < 2:
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
mean_len = sum(lengths) / len(lengths)
|
|
429
|
+
variance = sum((x - mean_len) ** 2 for x in lengths) / len(lengths)
|
|
430
|
+
cv = (variance ** 0.5) / mean_len if mean_len > 0 else float('inf')
|
|
431
|
+
|
|
432
|
+
candidates.append(([anchor], cv, true_period, len(cut_positions), n_clusters, centers))
|
|
433
|
+
|
|
434
|
+
if verbose:
|
|
435
|
+
cluster_info = f" ({n_clusters} clusters)" if n_clusters > 1 else ""
|
|
436
|
+
print(f" Candidate: {anchor[:20]}... period={true_period:.0f}, CV={cv:.3f}, cuts={len(cut_positions)}{cluster_info}")
|
|
437
|
+
|
|
438
|
+
# Select best candidate by CV
|
|
439
|
+
if not candidates:
|
|
440
|
+
# Fallback: most frequent edge
|
|
441
|
+
if edges:
|
|
442
|
+
start_edge = max(edges.items(), key=lambda x: x[1])[0]
|
|
443
|
+
if verbose:
|
|
444
|
+
print(f" Fallback to most frequent: {start_edge[0][:20]}...")
|
|
445
|
+
return [start_edge[0]], 0, float('inf'), 1, []
|
|
446
|
+
return [], 0, float('inf'), 1, []
|
|
447
|
+
|
|
448
|
+
# Sort by CV (ascending), then by n_clusters (ascending) - prefer simpler patterns
|
|
449
|
+
candidates.sort(key=lambda x: (x[1], x[4]))
|
|
450
|
+
|
|
451
|
+
best_cycle, best_cv, best_period, best_count, best_n_clusters, best_centers = candidates[0]
|
|
452
|
+
|
|
453
|
+
if verbose:
|
|
454
|
+
cluster_info = f" ({best_n_clusters} clusters)" if best_n_clusters > 1 else ""
|
|
455
|
+
print(f" Selected: {best_cycle[0][:20]}... period={best_period:.0f}, CV={best_cv:.3f}{cluster_info}")
|
|
456
|
+
if len(candidates) > 1:
|
|
457
|
+
print(f" (rejected {len(candidates)-1} other candidates)")
|
|
458
|
+
|
|
459
|
+
return best_cycle, best_period, best_cv, best_n_clusters, best_centers
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _calculate_cycle_distances(cycle: List[str], distances: Dict) -> List[float]:
|
|
463
|
+
"""Calculate full cycle distances from edge distances."""
|
|
464
|
+
if len(cycle) == 1:
|
|
465
|
+
edge = (cycle[0], cycle[0])
|
|
466
|
+
return distances.get(edge, [])
|
|
467
|
+
|
|
468
|
+
# For multi-anchor cycle, sum distances along the cycle
|
|
469
|
+
# This is approximate - we'd need to track actual cycle instances
|
|
470
|
+
cycle_dists = []
|
|
471
|
+
edge = (cycle[0], cycle[1])
|
|
472
|
+
if edge in distances:
|
|
473
|
+
# Use first edge distances as proxy
|
|
474
|
+
# TODO: properly track full cycle instances
|
|
475
|
+
cycle_dists = distances[edge]
|
|
476
|
+
|
|
477
|
+
return cycle_dists
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def estimate_monomer_length(graph: Dict, cycle: List[str]) -> Tuple[float, float]:
|
|
481
|
+
"""
|
|
482
|
+
Estimate monomer length from cycle and transition distances.
|
|
483
|
+
|
|
484
|
+
Returns (mean_length, variance)
|
|
485
|
+
"""
|
|
486
|
+
if not cycle:
|
|
487
|
+
return 0, 0
|
|
488
|
+
|
|
489
|
+
distances = graph["distances"]
|
|
490
|
+
|
|
491
|
+
# Sum distances along the cycle
|
|
492
|
+
cycle_lengths = []
|
|
493
|
+
|
|
494
|
+
# We need to find instances where the full cycle occurs
|
|
495
|
+
# For now, estimate from individual edge distances
|
|
496
|
+
total_distances = []
|
|
497
|
+
|
|
498
|
+
for i in range(len(cycle)):
|
|
499
|
+
from_anchor = cycle[i]
|
|
500
|
+
to_anchor = cycle[(i + 1) % len(cycle)]
|
|
501
|
+
edge = (from_anchor, to_anchor)
|
|
502
|
+
|
|
503
|
+
if edge in distances:
|
|
504
|
+
total_distances.extend(distances[edge])
|
|
505
|
+
|
|
506
|
+
if not total_distances:
|
|
507
|
+
return 0, 0
|
|
508
|
+
|
|
509
|
+
# Rough estimate: sum of mean distances along cycle
|
|
510
|
+
edge_means = []
|
|
511
|
+
for i in range(len(cycle)):
|
|
512
|
+
from_anchor = cycle[i]
|
|
513
|
+
to_anchor = cycle[(i + 1) % len(cycle)]
|
|
514
|
+
edge = (from_anchor, to_anchor)
|
|
515
|
+
|
|
516
|
+
if edge in distances:
|
|
517
|
+
edge_means.append(sum(distances[edge]) / len(distances[edge]))
|
|
518
|
+
|
|
519
|
+
if edge_means:
|
|
520
|
+
estimated_length = sum(edge_means)
|
|
521
|
+
variance = sum((d - estimated_length/len(cycle))**2 for d in total_distances) / len(total_distances)
|
|
522
|
+
return estimated_length, variance
|
|
523
|
+
|
|
524
|
+
return 0, 0
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def decompose_by_cycle(
|
|
528
|
+
sequence: str,
|
|
529
|
+
hits: List[AnchorHit],
|
|
530
|
+
cycle: List[str],
|
|
531
|
+
cluster_centers: List[float] = None,
|
|
532
|
+
verbose: bool = False
|
|
533
|
+
) -> List[str]:
|
|
534
|
+
"""
|
|
535
|
+
Decompose sequence into monomers based on the anchor cycle.
|
|
536
|
+
|
|
537
|
+
Strategy:
|
|
538
|
+
1. Find all positions of the anchor
|
|
539
|
+
2. If cluster_centers provided (multi-region pattern), use cluster analysis
|
|
540
|
+
to select only the "start" positions
|
|
541
|
+
3. Cut at selected positions
|
|
542
|
+
|
|
543
|
+
Args:
|
|
544
|
+
sequence: The array sequence
|
|
545
|
+
hits: Sorted list of AnchorHit
|
|
546
|
+
cycle: List of anchors representing one monomer
|
|
547
|
+
cluster_centers: If multi-region pattern detected, the cluster centers
|
|
548
|
+
for classifying positions. None = cut at every position.
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
List of monomer sequences
|
|
552
|
+
"""
|
|
553
|
+
if not cycle or not hits:
|
|
554
|
+
return [sequence] if sequence else []
|
|
555
|
+
|
|
556
|
+
start_anchor = cycle[0]
|
|
557
|
+
|
|
558
|
+
# Find all positions where anchor appears
|
|
559
|
+
all_positions = []
|
|
560
|
+
for hit in hits:
|
|
561
|
+
if hit.anchor == start_anchor:
|
|
562
|
+
all_positions.append(hit.position)
|
|
563
|
+
|
|
564
|
+
if not all_positions:
|
|
565
|
+
return [sequence]
|
|
566
|
+
|
|
567
|
+
all_positions.sort()
|
|
568
|
+
|
|
569
|
+
# Determine cut positions based on cluster analysis
|
|
570
|
+
if cluster_centers and len(cluster_centers) > 1:
|
|
571
|
+
# Multi-region pattern: use cluster-based selection
|
|
572
|
+
labels = classify_positions_by_distance(all_positions, cluster_centers)
|
|
573
|
+
cut_positions = select_cut_positions_by_cluster(all_positions, labels, verbose)
|
|
574
|
+
|
|
575
|
+
if verbose:
|
|
576
|
+
print(f" Multi-region pattern: {len(cluster_centers)} clusters")
|
|
577
|
+
print(f" All positions: {len(all_positions)}, cut positions: {len(cut_positions)}")
|
|
578
|
+
else:
|
|
579
|
+
# Single region: cut at every position
|
|
580
|
+
cut_positions = all_positions
|
|
581
|
+
|
|
582
|
+
if verbose:
|
|
583
|
+
print(f" Cut positions: {cut_positions[:10]}{'...' if len(cut_positions) > 10 else ''}")
|
|
584
|
+
|
|
585
|
+
if not cut_positions:
|
|
586
|
+
return [sequence]
|
|
587
|
+
|
|
588
|
+
# Cut sequence
|
|
589
|
+
monomers = []
|
|
590
|
+
|
|
591
|
+
# Left flank
|
|
592
|
+
if cut_positions[0] > 0:
|
|
593
|
+
monomers.append(sequence[:cut_positions[0]])
|
|
594
|
+
|
|
595
|
+
# Monomers
|
|
596
|
+
for i in range(len(cut_positions) - 1):
|
|
597
|
+
monomers.append(sequence[cut_positions[i]:cut_positions[i + 1]])
|
|
598
|
+
|
|
599
|
+
# Last part (may be partial)
|
|
600
|
+
if cut_positions[-1] < len(sequence):
|
|
601
|
+
monomers.append(sequence[cut_positions[-1]:])
|
|
602
|
+
|
|
603
|
+
return monomers
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
class AnchorGraphDecomposer:
|
|
607
|
+
"""
|
|
608
|
+
Main class for anchor graph-based decomposition.
|
|
609
|
+
|
|
610
|
+
Usage:
|
|
611
|
+
decomposer = AnchorGraphDecomposer()
|
|
612
|
+
decomposer.build_from_candidates(sequence, candidates)
|
|
613
|
+
monomers = decomposer.decompose()
|
|
614
|
+
"""
|
|
615
|
+
|
|
616
|
+
def __init__(self):
|
|
617
|
+
self.sequence: Optional[str] = None
|
|
618
|
+
self.anchors: List[str] = []
|
|
619
|
+
self.hits: List[AnchorHit] = []
|
|
620
|
+
self.graph: Dict = {}
|
|
621
|
+
self.cycle: List[str] = []
|
|
622
|
+
self.estimated_period: float = 0
|
|
623
|
+
self.estimated_cv: float = float('inf')
|
|
624
|
+
self.n_clusters: int = 1 # Number of distance clusters (1 = normal, >1 = multi-region)
|
|
625
|
+
self.cluster_centers: List[float] = [] # Cluster centers for multi-region patterns
|
|
626
|
+
|
|
627
|
+
def build_from_candidates(
|
|
628
|
+
self,
|
|
629
|
+
sequence: str,
|
|
630
|
+
candidates: List[Dict],
|
|
631
|
+
top_k: int = 10,
|
|
632
|
+
verbose: bool = False
|
|
633
|
+
) -> None:
|
|
634
|
+
"""
|
|
635
|
+
Build anchor graph from compute_cuts candidates.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
sequence: The array sequence
|
|
639
|
+
candidates: List of candidate dicts from compute_cuts
|
|
640
|
+
top_k: Number of top candidates to use
|
|
641
|
+
verbose: Print debug info
|
|
642
|
+
"""
|
|
643
|
+
self.sequence = sequence
|
|
644
|
+
|
|
645
|
+
# Get top candidates, filtering by max anchor length
|
|
646
|
+
# Anchors should be short (<=11bp) - they are graph nodes, not sequences
|
|
647
|
+
MAX_ANCHOR_LENGTH = 11
|
|
648
|
+
top = get_top_candidates(candidates, top_k)
|
|
649
|
+
self.anchors = [c['cut'] for c in top if len(c['cut']) <= MAX_ANCHOR_LENGTH]
|
|
650
|
+
|
|
651
|
+
if verbose:
|
|
652
|
+
print(f"Top {len(self.anchors)} anchors (max length {MAX_ANCHOR_LENGTH}bp):")
|
|
653
|
+
for c in top:
|
|
654
|
+
if len(c['cut']) <= MAX_ANCHOR_LENGTH:
|
|
655
|
+
print(f" '{c['cut']}' score={c.get('adjusted_score', c.get('base_score', 0)):.3f}")
|
|
656
|
+
|
|
657
|
+
# Find hits hierarchically (longer first)
|
|
658
|
+
if verbose:
|
|
659
|
+
print(f"\nFinding anchor hits (longer anchors first):")
|
|
660
|
+
self.hits = find_anchor_hits_hierarchical(sequence, self.anchors, verbose)
|
|
661
|
+
|
|
662
|
+
if verbose:
|
|
663
|
+
print(f"\nTotal hits: {len(self.hits)}")
|
|
664
|
+
|
|
665
|
+
# Build transition graph
|
|
666
|
+
self.graph = build_transition_graph(self.hits)
|
|
667
|
+
|
|
668
|
+
if verbose:
|
|
669
|
+
print(f"\nTransition graph:")
|
|
670
|
+
for edge, count in sorted(self.graph["edges"].items(), key=lambda x: x[1], reverse=True)[:10]:
|
|
671
|
+
dists = self.graph["distances"][edge]
|
|
672
|
+
mean_dist = sum(dists) / len(dists)
|
|
673
|
+
print(f" {edge[0][:15]}... -> {edge[1][:15]}...: count={count}, mean_dist={mean_dist:.0f}")
|
|
674
|
+
|
|
675
|
+
# Find monomer cycle with cluster analysis
|
|
676
|
+
if verbose:
|
|
677
|
+
print(f"\nFinding monomer cycle:")
|
|
678
|
+
self.cycle, self.estimated_period, self.estimated_cv, self.n_clusters, self.cluster_centers = find_monomer_cycle(
|
|
679
|
+
self.graph, self.hits, sequence, verbose
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
if verbose and self.cycle:
|
|
683
|
+
cluster_info = f", {self.n_clusters} clusters" if self.n_clusters > 1 else ""
|
|
684
|
+
print(f"\nEstimated monomer length: {self.estimated_period:.0f} bp (CV={self.estimated_cv:.3f}{cluster_info})")
|
|
685
|
+
|
|
686
|
+
def decompose(self, verbose: bool = False) -> List[str]:
|
|
687
|
+
"""
|
|
688
|
+
Decompose sequence into monomers.
|
|
689
|
+
|
|
690
|
+
Returns:
|
|
691
|
+
List of monomer sequences
|
|
692
|
+
"""
|
|
693
|
+
if not self.cycle:
|
|
694
|
+
if verbose:
|
|
695
|
+
print("No cycle found, returning whole sequence")
|
|
696
|
+
return [self.sequence] if self.sequence else []
|
|
697
|
+
|
|
698
|
+
if verbose:
|
|
699
|
+
cluster_info = f" ({self.n_clusters} clusters)" if self.n_clusters > 1 else ""
|
|
700
|
+
print(f"\nDecomposing by cycle: {' -> '.join(a[:10]+'...' for a in self.cycle)}{cluster_info}")
|
|
701
|
+
|
|
702
|
+
monomers = decompose_by_cycle(self.sequence, self.hits, self.cycle, self.cluster_centers, verbose)
|
|
703
|
+
|
|
704
|
+
# Verify reconstruction
|
|
705
|
+
reconstructed = "".join(monomers)
|
|
706
|
+
if reconstructed != self.sequence:
|
|
707
|
+
if verbose:
|
|
708
|
+
print(f"WARNING: Reconstruction mismatch! {len(self.sequence)} vs {len(reconstructed)}")
|
|
709
|
+
elif verbose:
|
|
710
|
+
print(f"Reconstruction: PERFECT")
|
|
711
|
+
|
|
712
|
+
return monomers
|
|
713
|
+
|
|
714
|
+
def get_stats(self) -> Dict:
|
|
715
|
+
"""Get decomposer statistics."""
|
|
716
|
+
return {
|
|
717
|
+
"num_anchors": len(self.anchors),
|
|
718
|
+
"anchors": self.anchors,
|
|
719
|
+
"num_hits": len(self.hits),
|
|
720
|
+
"cycle": self.cycle,
|
|
721
|
+
"estimated_monomer_length": self.estimated_period,
|
|
722
|
+
"estimated_cv": self.estimated_cv,
|
|
723
|
+
"n_clusters": self.n_clusters,
|
|
724
|
+
"cluster_centers": self.cluster_centers,
|
|
725
|
+
"edge_counts": self.graph.get("edges", {}),
|
|
726
|
+
}
|