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,519 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
ArraySplitter Classify - Group arrays into families based on cut sequences and decomposition patterns.
|
|
5
|
+
|
|
6
|
+
The hypothesis: Arrays with the same cut sequence and similar decomposition patterns
|
|
7
|
+
likely belong to the same repeat family.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
from collections import Counter, defaultdict
|
|
13
|
+
from statistics import mean, median, stdev
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
from tqdm import tqdm
|
|
17
|
+
import editdistance as ed
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def extract_pattern_features_from_lengths_file(header, lengths_line):
|
|
21
|
+
"""
|
|
22
|
+
Extract features from lengths file format.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
header: Header line with metadata (e.g., ">seq_id cut=ATG orientation=fwd n_monomers=50 range=165-175 avg=171.2")
|
|
26
|
+
lengths_line: Space-separated fragment lengths
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
dict: Features including length distribution, variability metrics
|
|
30
|
+
"""
|
|
31
|
+
# Parse header
|
|
32
|
+
parts = header.split()
|
|
33
|
+
seq_id = parts[0][1:] # Remove '>'
|
|
34
|
+
|
|
35
|
+
# Extract metadata from header
|
|
36
|
+
metadata = {}
|
|
37
|
+
for part in parts[1:]:
|
|
38
|
+
if '=' in part:
|
|
39
|
+
key, value = part.split('=', 1)
|
|
40
|
+
metadata[key] = value
|
|
41
|
+
|
|
42
|
+
cut_seq = metadata.get('cut', '')
|
|
43
|
+
n_monomers = int(metadata.get('n_monomers', 0))
|
|
44
|
+
|
|
45
|
+
if not cut_seq or n_monomers == 0:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
# Parse lengths
|
|
49
|
+
all_lengths = [int(x) for x in lengths_line.strip().split()]
|
|
50
|
+
|
|
51
|
+
# Extract monomer lengths based on n_monomers count
|
|
52
|
+
# The header tells us how many internal monomers there are
|
|
53
|
+
# Typical structure: [left_flank] [monomer1] [monomer2] ... [monomerN] [right_flank]
|
|
54
|
+
|
|
55
|
+
if n_monomers == len(all_lengths):
|
|
56
|
+
# All are monomers, no flanks
|
|
57
|
+
monomer_lengths = all_lengths
|
|
58
|
+
elif n_monomers < len(all_lengths):
|
|
59
|
+
# Determine which are monomers vs flanks
|
|
60
|
+
# Use the average length from header to identify likely flanks
|
|
61
|
+
avg_length = float(metadata.get('avg', 0))
|
|
62
|
+
|
|
63
|
+
# Simple heuristic: monomers are closer to average length
|
|
64
|
+
# First fragment is likely left flank if much different from average
|
|
65
|
+
# Last fragment is likely right flank if much shorter than average
|
|
66
|
+
|
|
67
|
+
if len(all_lengths) == n_monomers + 1:
|
|
68
|
+
# One flank (either left or right)
|
|
69
|
+
if abs(all_lengths[0] - avg_length) > abs(all_lengths[-1] - avg_length):
|
|
70
|
+
# First is flank
|
|
71
|
+
monomer_lengths = all_lengths[1:]
|
|
72
|
+
else:
|
|
73
|
+
# Last is flank
|
|
74
|
+
monomer_lengths = all_lengths[:-1]
|
|
75
|
+
elif len(all_lengths) == n_monomers + 2:
|
|
76
|
+
# Both flanks present
|
|
77
|
+
monomer_lengths = all_lengths[1:-1]
|
|
78
|
+
else:
|
|
79
|
+
# Fallback: take middle n_monomers elements
|
|
80
|
+
start = (len(all_lengths) - n_monomers) // 2
|
|
81
|
+
monomer_lengths = all_lengths[start:start + n_monomers]
|
|
82
|
+
else:
|
|
83
|
+
# Shouldn't happen, but handle gracefully
|
|
84
|
+
monomer_lengths = all_lengths
|
|
85
|
+
|
|
86
|
+
# Calculate statistics
|
|
87
|
+
features = {
|
|
88
|
+
'cut_sequence': cut_seq if cut_seq else 'UNKNOWN',
|
|
89
|
+
'num_monomers': len(monomer_lengths),
|
|
90
|
+
'mean_length': mean(monomer_lengths),
|
|
91
|
+
'median_length': median(monomer_lengths),
|
|
92
|
+
'min_length': min(monomer_lengths),
|
|
93
|
+
'max_length': max(monomer_lengths),
|
|
94
|
+
'length_variability': stdev(monomer_lengths) if len(monomer_lengths) > 1 else 0,
|
|
95
|
+
'length_range': max(monomer_lengths) - min(monomer_lengths),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Add length distribution
|
|
99
|
+
length_counts = Counter(monomer_lengths)
|
|
100
|
+
features['length_distribution'] = dict(length_counts)
|
|
101
|
+
features['unique_lengths'] = len(length_counts)
|
|
102
|
+
|
|
103
|
+
# Calculate step differences between consecutive monomers
|
|
104
|
+
if len(monomer_lengths) > 1:
|
|
105
|
+
step_diffs = []
|
|
106
|
+
for i in range(1, len(monomer_lengths)):
|
|
107
|
+
step_diff = abs(monomer_lengths[i] - monomer_lengths[i-1])
|
|
108
|
+
step_diffs.append(step_diff)
|
|
109
|
+
|
|
110
|
+
features['step_mean'] = mean(step_diffs) if step_diffs else 0
|
|
111
|
+
features['step_std'] = stdev(step_diffs) if len(step_diffs) > 1 else 0
|
|
112
|
+
features['step_max'] = max(step_diffs) if step_diffs else 0
|
|
113
|
+
features['step_stability'] = 1.0 - (features['step_std'] / features['mean_length']) if features['mean_length'] > 0 else 0
|
|
114
|
+
else:
|
|
115
|
+
features['step_mean'] = 0
|
|
116
|
+
features['step_std'] = 0
|
|
117
|
+
features['step_max'] = 0
|
|
118
|
+
features['step_stability'] = 1.0
|
|
119
|
+
|
|
120
|
+
# Calculate local variability using sliding window (10kb ~ 50-60 monomers for typical 171bp)
|
|
121
|
+
window_size = max(10, int(10000 / features['mean_length'])) # Adaptive window based on monomer size
|
|
122
|
+
if len(monomer_lengths) >= window_size:
|
|
123
|
+
window_variabilities = []
|
|
124
|
+
for i in range(len(monomer_lengths) - window_size + 1):
|
|
125
|
+
window = monomer_lengths[i:i + window_size]
|
|
126
|
+
window_std = stdev(window) if len(window) > 1 else 0
|
|
127
|
+
window_variabilities.append(window_std)
|
|
128
|
+
|
|
129
|
+
features['local_var_mean'] = mean(window_variabilities)
|
|
130
|
+
features['local_var_min'] = min(window_variabilities)
|
|
131
|
+
features['local_var_max'] = max(window_variabilities)
|
|
132
|
+
features['most_stable_region_var'] = features['local_var_min']
|
|
133
|
+
else:
|
|
134
|
+
# Too few monomers for windowed analysis
|
|
135
|
+
features['local_var_mean'] = features['length_variability']
|
|
136
|
+
features['local_var_min'] = features['length_variability']
|
|
137
|
+
features['local_var_max'] = features['length_variability']
|
|
138
|
+
features['most_stable_region_var'] = features['length_variability']
|
|
139
|
+
|
|
140
|
+
return features
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def calculate_pattern_similarity(features1, features2):
|
|
144
|
+
"""
|
|
145
|
+
Calculate similarity score between two decomposition patterns.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
float: Similarity score (0-1, where 1 is identical)
|
|
149
|
+
"""
|
|
150
|
+
if not features1 or not features2:
|
|
151
|
+
return 0.0
|
|
152
|
+
|
|
153
|
+
# Different cut sequences = different families
|
|
154
|
+
if features1['cut_sequence'] != features2['cut_sequence']:
|
|
155
|
+
return 0.0
|
|
156
|
+
|
|
157
|
+
scores = []
|
|
158
|
+
|
|
159
|
+
# Compare mean lengths (most important)
|
|
160
|
+
length_diff = abs(features1['mean_length'] - features2['mean_length'])
|
|
161
|
+
mean_avg = (features1['mean_length'] + features2['mean_length']) / 2
|
|
162
|
+
length_score = 1 - min(length_diff / mean_avg, 1.0)
|
|
163
|
+
scores.append(length_score * 2) # Weight this more
|
|
164
|
+
|
|
165
|
+
# Compare variability
|
|
166
|
+
var_diff = abs(features1['length_variability'] - features2['length_variability'])
|
|
167
|
+
var_avg = (features1['length_variability'] + features2['length_variability']) / 2
|
|
168
|
+
if var_avg > 0:
|
|
169
|
+
var_score = 1 - min(var_diff / var_avg, 1.0)
|
|
170
|
+
scores.append(var_score)
|
|
171
|
+
|
|
172
|
+
# Compare length range
|
|
173
|
+
range_diff = abs(features1['length_range'] - features2['length_range'])
|
|
174
|
+
range_avg = (features1['length_range'] + features2['length_range']) / 2
|
|
175
|
+
if range_avg > 0:
|
|
176
|
+
range_score = 1 - min(range_diff / range_avg, 1.0)
|
|
177
|
+
scores.append(range_score)
|
|
178
|
+
|
|
179
|
+
# Compare number of unique lengths
|
|
180
|
+
unique_diff = abs(features1['unique_lengths'] - features2['unique_lengths'])
|
|
181
|
+
unique_avg = (features1['unique_lengths'] + features2['unique_lengths']) / 2
|
|
182
|
+
if unique_avg > 0:
|
|
183
|
+
unique_score = 1 - min(unique_diff / unique_avg, 1.0)
|
|
184
|
+
scores.append(unique_score * 0.5) # Weight this less
|
|
185
|
+
|
|
186
|
+
return sum(scores) / len(scores) if scores else 0.0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cluster_arrays(array_features, similarity_threshold=0.8):
|
|
190
|
+
"""
|
|
191
|
+
Cluster arrays into families based on pattern similarity.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
dict: Family assignments {array_id: family_id}
|
|
195
|
+
"""
|
|
196
|
+
# Group by cut sequence first
|
|
197
|
+
cut_groups = defaultdict(list)
|
|
198
|
+
for array_id, features in array_features.items():
|
|
199
|
+
if features:
|
|
200
|
+
cut_groups[features['cut_sequence']].append(array_id)
|
|
201
|
+
|
|
202
|
+
# Within each cut group, cluster by pattern similarity
|
|
203
|
+
family_assignments = {}
|
|
204
|
+
family_id = 0
|
|
205
|
+
|
|
206
|
+
for cut_seq, array_ids in cut_groups.items():
|
|
207
|
+
if len(array_ids) == 1:
|
|
208
|
+
# Single array with this cut
|
|
209
|
+
family_assignments[array_ids[0]] = f"family_{family_id:04d}"
|
|
210
|
+
family_id += 1
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
# Cluster arrays with same cut by pattern similarity
|
|
214
|
+
clusters = []
|
|
215
|
+
|
|
216
|
+
for array_id in array_ids:
|
|
217
|
+
assigned = False
|
|
218
|
+
features = array_features[array_id]
|
|
219
|
+
|
|
220
|
+
# Try to assign to existing cluster
|
|
221
|
+
for cluster in clusters:
|
|
222
|
+
# Compare with representative (first member)
|
|
223
|
+
rep_id = cluster[0]
|
|
224
|
+
rep_features = array_features[rep_id]
|
|
225
|
+
|
|
226
|
+
similarity = calculate_pattern_similarity(features, rep_features)
|
|
227
|
+
|
|
228
|
+
if similarity >= similarity_threshold:
|
|
229
|
+
cluster.append(array_id)
|
|
230
|
+
assigned = True
|
|
231
|
+
break
|
|
232
|
+
|
|
233
|
+
if not assigned:
|
|
234
|
+
# Create new cluster
|
|
235
|
+
clusters.append([array_id])
|
|
236
|
+
|
|
237
|
+
# Assign family IDs to clusters
|
|
238
|
+
for cluster in clusters:
|
|
239
|
+
current_family = f"family_{family_id:04d}"
|
|
240
|
+
for array_id in cluster:
|
|
241
|
+
family_assignments[array_id] = current_family
|
|
242
|
+
family_id += 1
|
|
243
|
+
|
|
244
|
+
return family_assignments
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def classify_arrays(input_file, output_prefix, similarity_threshold=0.8, verbose=False):
|
|
248
|
+
"""
|
|
249
|
+
Main classification function using lengths file.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
input_file: Path to .lengths file from decomposition
|
|
253
|
+
output_prefix: Prefix for output files
|
|
254
|
+
similarity_threshold: Threshold for clustering (0-1)
|
|
255
|
+
verbose: Verbose output
|
|
256
|
+
"""
|
|
257
|
+
print("ArraySplitter Classify")
|
|
258
|
+
print("="*60)
|
|
259
|
+
print(f"Input file: {input_file}")
|
|
260
|
+
print(f"Similarity threshold: {similarity_threshold}")
|
|
261
|
+
|
|
262
|
+
# Check if input is a lengths file
|
|
263
|
+
if not input_file.endswith('.lengths'):
|
|
264
|
+
print("Warning: Input file should be a .lengths file from decomposition step")
|
|
265
|
+
|
|
266
|
+
# Read lengths file
|
|
267
|
+
array_features = {}
|
|
268
|
+
array_info = {}
|
|
269
|
+
|
|
270
|
+
print("\nReading lengths file...")
|
|
271
|
+
with open(input_file, 'r') as f:
|
|
272
|
+
lines = f.readlines()
|
|
273
|
+
|
|
274
|
+
# Process pairs of lines (header + lengths)
|
|
275
|
+
i = 0
|
|
276
|
+
while i < len(lines):
|
|
277
|
+
if lines[i].startswith('>'):
|
|
278
|
+
header = lines[i].strip()
|
|
279
|
+
if i + 1 < len(lines):
|
|
280
|
+
lengths_line = lines[i + 1].strip()
|
|
281
|
+
|
|
282
|
+
# Extract sequence ID
|
|
283
|
+
seq_id = header.split()[0][1:] # Remove '>'
|
|
284
|
+
|
|
285
|
+
# Extract features
|
|
286
|
+
features = extract_pattern_features_from_lengths_file(header, lengths_line)
|
|
287
|
+
|
|
288
|
+
if features:
|
|
289
|
+
array_features[seq_id] = features
|
|
290
|
+
|
|
291
|
+
# Parse additional info from header
|
|
292
|
+
parts = header.split()
|
|
293
|
+
metadata = {}
|
|
294
|
+
for part in parts[1:]:
|
|
295
|
+
if '=' in part:
|
|
296
|
+
key, value = part.split('=', 1)
|
|
297
|
+
metadata[key] = value
|
|
298
|
+
|
|
299
|
+
# Calculate total length from all fragments
|
|
300
|
+
all_lengths = [int(x) for x in lengths_line.strip().split()]
|
|
301
|
+
total_length = sum(all_lengths)
|
|
302
|
+
|
|
303
|
+
array_info[seq_id] = {
|
|
304
|
+
'length': total_length,
|
|
305
|
+
'cut_sequence': metadata.get('cut', ''),
|
|
306
|
+
'orientation': metadata.get('orientation', ''),
|
|
307
|
+
'n_monomers': int(metadata.get('n_monomers', 0)),
|
|
308
|
+
'avg_length': float(metadata.get('avg', 0))
|
|
309
|
+
}
|
|
310
|
+
else:
|
|
311
|
+
if verbose:
|
|
312
|
+
print(f"Warning: Could not extract features for {seq_id}")
|
|
313
|
+
|
|
314
|
+
i += 2
|
|
315
|
+
else:
|
|
316
|
+
i += 1
|
|
317
|
+
else:
|
|
318
|
+
i += 1
|
|
319
|
+
|
|
320
|
+
print(f"Found {len(array_features)} arrays")
|
|
321
|
+
print(f"\nSuccessfully analyzed {len(array_features)} arrays")
|
|
322
|
+
|
|
323
|
+
# Cluster arrays
|
|
324
|
+
print("\nClustering arrays into families...")
|
|
325
|
+
family_assignments = cluster_arrays(array_features, similarity_threshold)
|
|
326
|
+
|
|
327
|
+
# Count families
|
|
328
|
+
family_counts = Counter(family_assignments.values())
|
|
329
|
+
print(f"\nFound {len(family_counts)} families")
|
|
330
|
+
|
|
331
|
+
# Write results
|
|
332
|
+
output_file = f"{output_prefix}.families.tsv"
|
|
333
|
+
stats_file = f"{output_prefix}.family_stats.tsv"
|
|
334
|
+
summary_file = f"{output_prefix}.family_summary.tsv"
|
|
335
|
+
json_file = f"{output_prefix}.features.json"
|
|
336
|
+
|
|
337
|
+
print(f"\nWriting results to:")
|
|
338
|
+
print(f" {output_file}")
|
|
339
|
+
print(f" {stats_file}")
|
|
340
|
+
print(f" {summary_file}")
|
|
341
|
+
print(f" {json_file}")
|
|
342
|
+
|
|
343
|
+
# Write family assignments with step variability
|
|
344
|
+
with open(output_file, 'w') as f:
|
|
345
|
+
f.write("array_id\tfamily\tcut_sequence\tlength\tmean_monomer_length\tnum_monomers\t")
|
|
346
|
+
f.write("step_stability\tstep_std\tlocal_var_min\tmost_stable_region_var\n")
|
|
347
|
+
|
|
348
|
+
for array_id in sorted(family_assignments.keys()):
|
|
349
|
+
family = family_assignments[array_id]
|
|
350
|
+
info = array_info.get(array_id, {})
|
|
351
|
+
features = array_features.get(array_id, {})
|
|
352
|
+
|
|
353
|
+
f.write(f"{array_id}\t{family}\t{info.get('cut_sequence', 'NA')}\t")
|
|
354
|
+
f.write(f"{info.get('length', 0)}\t")
|
|
355
|
+
f.write(f"{features.get('mean_length', 0):.1f}\t")
|
|
356
|
+
f.write(f"{features.get('num_monomers', 0)}\t")
|
|
357
|
+
f.write(f"{features.get('step_stability', 0):.3f}\t")
|
|
358
|
+
f.write(f"{features.get('step_std', 0):.1f}\t")
|
|
359
|
+
f.write(f"{features.get('local_var_min', 0):.1f}\t")
|
|
360
|
+
f.write(f"{features.get('most_stable_region_var', 0):.1f}\n")
|
|
361
|
+
|
|
362
|
+
# Write family statistics
|
|
363
|
+
with open(stats_file, 'w') as f:
|
|
364
|
+
f.write("family\tnum_arrays\tcut_sequence\tmean_length\tstd_length\tmean_monomers\n")
|
|
365
|
+
|
|
366
|
+
for family in sorted(family_counts.keys()):
|
|
367
|
+
# Get all arrays in this family
|
|
368
|
+
family_arrays = [aid for aid, fam in family_assignments.items() if fam == family]
|
|
369
|
+
|
|
370
|
+
# Calculate family statistics
|
|
371
|
+
cut_seq = array_features[family_arrays[0]]['cut_sequence']
|
|
372
|
+
lengths = [array_info[aid]['length'] for aid in family_arrays]
|
|
373
|
+
monomer_counts = [array_features[aid]['num_monomers'] for aid in family_arrays]
|
|
374
|
+
|
|
375
|
+
f.write(f"{family}\t{len(family_arrays)}\t{cut_seq}\t")
|
|
376
|
+
f.write(f"{mean(lengths):.1f}\t")
|
|
377
|
+
f.write(f"{stdev(lengths) if len(lengths) > 1 else 0:.1f}\t")
|
|
378
|
+
f.write(f"{mean(monomer_counts):.1f}\n")
|
|
379
|
+
|
|
380
|
+
# Write family summary with detailed variability analysis
|
|
381
|
+
with open(summary_file, 'w') as f:
|
|
382
|
+
f.write("family\tcut_sequence\tnum_arrays\ttotal_monomers\tmean_monomer_length\tstd_monomer_length\t")
|
|
383
|
+
f.write("mean_step_stability\tmin_step_stability\tmax_step_stability\t")
|
|
384
|
+
f.write("mean_local_var_min\tmost_stable_array\tmost_stable_array_var\t")
|
|
385
|
+
f.write("structural_importance_score\n")
|
|
386
|
+
|
|
387
|
+
for family in sorted(family_counts.keys()):
|
|
388
|
+
# Get all arrays in this family
|
|
389
|
+
family_arrays = [aid for aid, fam in family_assignments.items() if fam == family]
|
|
390
|
+
|
|
391
|
+
# Aggregate statistics
|
|
392
|
+
cut_seq = array_features[family_arrays[0]]['cut_sequence']
|
|
393
|
+
all_monomer_lengths = []
|
|
394
|
+
step_stabilities = []
|
|
395
|
+
local_var_mins = []
|
|
396
|
+
|
|
397
|
+
for aid in family_arrays:
|
|
398
|
+
features = array_features[aid]
|
|
399
|
+
all_monomer_lengths.append(features['mean_length'])
|
|
400
|
+
step_stabilities.append(features['step_stability'])
|
|
401
|
+
local_var_mins.append(features['local_var_min'])
|
|
402
|
+
|
|
403
|
+
# Find most stable array in family
|
|
404
|
+
most_stable_idx = local_var_mins.index(min(local_var_mins))
|
|
405
|
+
most_stable_array = family_arrays[most_stable_idx]
|
|
406
|
+
most_stable_var = local_var_mins[most_stable_idx]
|
|
407
|
+
|
|
408
|
+
# Calculate structural importance score (0-1, higher = more structurally important)
|
|
409
|
+
# Based on: high step stability, low local variability, consistency across arrays
|
|
410
|
+
avg_step_stability = mean(step_stabilities)
|
|
411
|
+
avg_local_var_min = mean(local_var_mins)
|
|
412
|
+
consistency = 1.0 - (stdev(step_stabilities) if len(step_stabilities) > 1 else 0)
|
|
413
|
+
|
|
414
|
+
# Normalize variability (inverse, so low var = high score)
|
|
415
|
+
var_score = 1.0 / (1.0 + avg_local_var_min / 10.0) # Scale by 10 for typical values
|
|
416
|
+
|
|
417
|
+
structural_score = (avg_step_stability * 0.4 + var_score * 0.4 + consistency * 0.2)
|
|
418
|
+
|
|
419
|
+
# Total monomers in family
|
|
420
|
+
total_monomers = sum(array_features[aid]['num_monomers'] for aid in family_arrays)
|
|
421
|
+
|
|
422
|
+
f.write(f"{family}\t{cut_seq}\t{len(family_arrays)}\t{total_monomers}\t")
|
|
423
|
+
f.write(f"{mean(all_monomer_lengths):.1f}\t")
|
|
424
|
+
f.write(f"{stdev(all_monomer_lengths) if len(all_monomer_lengths) > 1 else 0:.1f}\t")
|
|
425
|
+
f.write(f"{avg_step_stability:.3f}\t")
|
|
426
|
+
f.write(f"{min(step_stabilities):.3f}\t")
|
|
427
|
+
f.write(f"{max(step_stabilities):.3f}\t")
|
|
428
|
+
f.write(f"{avg_local_var_min:.1f}\t")
|
|
429
|
+
f.write(f"{most_stable_array}\t")
|
|
430
|
+
f.write(f"{most_stable_var:.1f}\t")
|
|
431
|
+
f.write(f"{structural_score:.3f}\n")
|
|
432
|
+
|
|
433
|
+
# Write detailed features as JSON
|
|
434
|
+
with open(json_file, 'w') as f:
|
|
435
|
+
output_data = {
|
|
436
|
+
'array_features': array_features,
|
|
437
|
+
'family_assignments': family_assignments,
|
|
438
|
+
'array_info': array_info
|
|
439
|
+
}
|
|
440
|
+
json.dump(output_data, f, indent=2)
|
|
441
|
+
|
|
442
|
+
# Print summary
|
|
443
|
+
print("\nClassification summary:")
|
|
444
|
+
print(f" Total arrays: {len(array_features)}")
|
|
445
|
+
print(f" Successfully classified: {len(family_assignments)}")
|
|
446
|
+
print(f" Number of families: {len(family_counts)}")
|
|
447
|
+
|
|
448
|
+
# Show top families
|
|
449
|
+
print("\nTop 10 families by size:")
|
|
450
|
+
for family, count in family_counts.most_common(10):
|
|
451
|
+
family_arrays = [aid for aid, fam in family_assignments.items() if fam == family]
|
|
452
|
+
cut_seq = array_features[family_arrays[0]]['cut_sequence']
|
|
453
|
+
print(f" {family}: {count} arrays (cut: {cut_seq})")
|
|
454
|
+
|
|
455
|
+
# Show structurally important families
|
|
456
|
+
print("\nStructurally important families (high stability, low variability):")
|
|
457
|
+
|
|
458
|
+
# Calculate structural scores for display
|
|
459
|
+
family_scores = []
|
|
460
|
+
for family in family_counts.keys():
|
|
461
|
+
family_arrays = [aid for aid, fam in family_assignments.items() if fam == family]
|
|
462
|
+
|
|
463
|
+
step_stabilities = [array_features[aid]['step_stability'] for aid in family_arrays]
|
|
464
|
+
local_var_mins = [array_features[aid]['local_var_min'] for aid in family_arrays]
|
|
465
|
+
|
|
466
|
+
avg_step_stability = mean(step_stabilities)
|
|
467
|
+
avg_local_var_min = mean(local_var_mins)
|
|
468
|
+
consistency = 1.0 - (stdev(step_stabilities) if len(step_stabilities) > 1 else 0)
|
|
469
|
+
var_score = 1.0 / (1.0 + avg_local_var_min / 10.0)
|
|
470
|
+
structural_score = (avg_step_stability * 0.4 + var_score * 0.4 + consistency * 0.2)
|
|
471
|
+
|
|
472
|
+
family_scores.append((family, structural_score, array_features[family_arrays[0]]['cut_sequence'], len(family_arrays)))
|
|
473
|
+
|
|
474
|
+
# Sort by structural score
|
|
475
|
+
family_scores.sort(key=lambda x: x[1], reverse=True)
|
|
476
|
+
|
|
477
|
+
# Show top structurally important families
|
|
478
|
+
for i, (family, score, cut_seq, count) in enumerate(family_scores[:5]):
|
|
479
|
+
print(f" {i+1}. {family}: score={score:.3f}, {count} arrays (cut: {cut_seq})")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def run_it():
|
|
483
|
+
"""Command line interface."""
|
|
484
|
+
parser = argparse.ArgumentParser(
|
|
485
|
+
description="Classify satellite DNA arrays into families based on decomposition patterns"
|
|
486
|
+
)
|
|
487
|
+
parser.add_argument("-i", "--input", help="Input .monomers.tsv file from decomposition", required=True)
|
|
488
|
+
parser.add_argument("-o", "--output", help="Output prefix", required=True)
|
|
489
|
+
parser.add_argument(
|
|
490
|
+
"-s", "--similarity",
|
|
491
|
+
help="Similarity threshold for clustering (0-1, default: 0.8)",
|
|
492
|
+
type=float,
|
|
493
|
+
default=0.8
|
|
494
|
+
)
|
|
495
|
+
parser.add_argument(
|
|
496
|
+
"-v", "--verbose",
|
|
497
|
+
help="Verbose output",
|
|
498
|
+
action="store_true"
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
args = parser.parse_args()
|
|
502
|
+
|
|
503
|
+
if not os.path.isfile(args.input):
|
|
504
|
+
print(f"Error: Input file {args.input} not found")
|
|
505
|
+
exit(1)
|
|
506
|
+
|
|
507
|
+
if not args.input.endswith('.monomers.tsv'):
|
|
508
|
+
print(f"Warning: Input file should be a .monomers.tsv file from ArraySplitter decomposition")
|
|
509
|
+
|
|
510
|
+
classify_arrays(
|
|
511
|
+
args.input,
|
|
512
|
+
args.output,
|
|
513
|
+
similarity_threshold=args.similarity,
|
|
514
|
+
verbose=args.verbose
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
if __name__ == "__main__":
|
|
519
|
+
run_it()
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
#
|
|
4
|
+
# @created: 05.06.2011
|
|
5
|
+
# @author: Aleksey Komissarov
|
|
6
|
+
# @contact: ad3002@gmail.com
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
Classes:
|
|
10
|
+
|
|
11
|
+
- AbstractBlockFileIO(AbstractFileIO)
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from PyExp import AbstractFileIO
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AbstractBlockFileIO(AbstractFileIO):
|
|
19
|
+
"""Working with file with data organized in block, where each block starts with same token.
|
|
20
|
+
|
|
21
|
+
Public methods:
|
|
22
|
+
|
|
23
|
+
- get_block_sequence(self, head_start, next_head, fh)
|
|
24
|
+
- get_blocks(self, token, fh)
|
|
25
|
+
- gen_block_sequences(self, token, fh)
|
|
26
|
+
|
|
27
|
+
Inherited public properties:
|
|
28
|
+
|
|
29
|
+
- data - iterable data, each item is tuple (head, body)
|
|
30
|
+
- N - a number of items in data
|
|
31
|
+
|
|
32
|
+
Overrided public methods:
|
|
33
|
+
|
|
34
|
+
- __init__(self, token)
|
|
35
|
+
- read_from_file(self, input_file)
|
|
36
|
+
- read_online(self, input_file) ~> item
|
|
37
|
+
|
|
38
|
+
Inherited public methods:
|
|
39
|
+
|
|
40
|
+
- read_from_db(self, db_cursor)
|
|
41
|
+
- write_to_file(self, output_file)
|
|
42
|
+
- write_to_db(self, db_cursor)
|
|
43
|
+
- read_as_iter(self, source)
|
|
44
|
+
- iterate(self) ~> item of data
|
|
45
|
+
- do(self, cf, args) -> result
|
|
46
|
+
- process(self, cf, args)
|
|
47
|
+
- clear(self)
|
|
48
|
+
- do_with_iter(self, cf, args) -> [result,]
|
|
49
|
+
- process_with_iter(self, cf, args)
|
|
50
|
+
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, token, **args):
|
|
54
|
+
"""Overrided. Set token velue."""
|
|
55
|
+
super(AbstractBlockFileIO, self).__init__(**args)
|
|
56
|
+
self.token = token
|
|
57
|
+
self.wise_opener = self.get_opener()
|
|
58
|
+
|
|
59
|
+
def read_from_file(self, input_file):
|
|
60
|
+
"""Overrided. Read data from given input_file."""
|
|
61
|
+
with self.wise_opener(input_file, "r") as fh:
|
|
62
|
+
for head, body, start, next in self.gen_block_sequences(self.token, fh):
|
|
63
|
+
self.data.append((head, body, start, next))
|
|
64
|
+
|
|
65
|
+
def read_online(self, input_file):
|
|
66
|
+
"""Overrided. Yield items from data online from input_file."""
|
|
67
|
+
with self.wise_opener(input_file, "r") as fh:
|
|
68
|
+
for head, body, start, next in self.gen_block_sequences(self.token, fh):
|
|
69
|
+
yield (head, body, start, next)
|
|
70
|
+
|
|
71
|
+
def get_block_sequence(self, head_start, next_head, fh):
|
|
72
|
+
"""Get a data block (head, seq, head_start, head_end).
|
|
73
|
+
|
|
74
|
+
Arguments:
|
|
75
|
+
|
|
76
|
+
- head_start -- a head starting position in a file
|
|
77
|
+
- next_head -- a next to head starting position in a file
|
|
78
|
+
- fh -- an open file handler
|
|
79
|
+
|
|
80
|
+
Return format:
|
|
81
|
+
|
|
82
|
+
- head -- a block head
|
|
83
|
+
- seq -- a block body
|
|
84
|
+
- head_start -- a file pointer to block start
|
|
85
|
+
- head_end -- a file pointer to next block start or 0
|
|
86
|
+
"""
|
|
87
|
+
head_start = int(head_start)
|
|
88
|
+
next_head = int(next_head)
|
|
89
|
+
sequence = ""
|
|
90
|
+
fh.seek(head_start)
|
|
91
|
+
pos = head_start
|
|
92
|
+
if next_head:
|
|
93
|
+
head = fh.readline()
|
|
94
|
+
pos = fh.tell()
|
|
95
|
+
while pos != next_head:
|
|
96
|
+
temp_seq = fh.readline()
|
|
97
|
+
pos = fh.tell()
|
|
98
|
+
if pos != next_head:
|
|
99
|
+
sequence += temp_seq
|
|
100
|
+
sequence += temp_seq
|
|
101
|
+
else:
|
|
102
|
+
head = fh.readline()
|
|
103
|
+
fasta_list = fh.readlines()
|
|
104
|
+
sequence = "".join(fasta_list)
|
|
105
|
+
return (head, sequence, head_start, next_head)
|
|
106
|
+
|
|
107
|
+
def get_blocks(self, token, fh):
|
|
108
|
+
"""Get a list of the token positions in given file (first, next).
|
|
109
|
+
For the last string the function returns (last string, 0).
|
|
110
|
+
|
|
111
|
+
Arguments:
|
|
112
|
+
|
|
113
|
+
- token -- the token indicating a block start
|
|
114
|
+
- fh -- an open file handler
|
|
115
|
+
|
|
116
|
+
Return format: a list of (start, next start) tuples
|
|
117
|
+
"""
|
|
118
|
+
fh.seek(0)
|
|
119
|
+
header_start_list = []
|
|
120
|
+
here = 0
|
|
121
|
+
wrong = 0
|
|
122
|
+
start = -1
|
|
123
|
+
while fh:
|
|
124
|
+
here = fh.tell()
|
|
125
|
+
line = fh.readline()
|
|
126
|
+
if len(line) == 0:
|
|
127
|
+
header_start_list.append((start, 0))
|
|
128
|
+
break
|
|
129
|
+
if line.startswith(token):
|
|
130
|
+
if start == -1:
|
|
131
|
+
start = here
|
|
132
|
+
else:
|
|
133
|
+
header_start_list.append((start, here))
|
|
134
|
+
start = here
|
|
135
|
+
return header_start_list
|
|
136
|
+
|
|
137
|
+
def gen_block_sequences(self, token, fh):
|
|
138
|
+
"""Yield (head, seq, head_start, head_end) tuplefor given fh for open file.
|
|
139
|
+
|
|
140
|
+
Arguments:
|
|
141
|
+
|
|
142
|
+
- token -- the token indicating a block start
|
|
143
|
+
- fh -- an open file handler
|
|
144
|
+
|
|
145
|
+
Return format:
|
|
146
|
+
|
|
147
|
+
- head -- a block head
|
|
148
|
+
- seq -- a block body
|
|
149
|
+
- head_start -- a file pointer to block start
|
|
150
|
+
- head_end -- a file pointer to next block start or 0
|
|
151
|
+
|
|
152
|
+
"""
|
|
153
|
+
header_start_list = self.get_blocks(token, fh)
|
|
154
|
+
for x, y in header_start_list:
|
|
155
|
+
# yeild sequence by coordinates
|
|
156
|
+
yield self.get_block_sequence(x, y, fh)
|