plastree 0.1.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.
- plastree/__init__.py +1 -0
- plastree/dotplotree.py +306 -0
- plastree/kmertree.py +174 -0
- plastree/pangtree.py +108 -0
- plastree/plasmid_tree_tool.py +263 -0
- plastree-0.1.0.dist-info/METADATA +196 -0
- plastree-0.1.0.dist-info/RECORD +11 -0
- plastree-0.1.0.dist-info/WHEEL +5 -0
- plastree-0.1.0.dist-info/entry_points.txt +2 -0
- plastree-0.1.0.dist-info/licenses/LICENSE +21 -0
- plastree-0.1.0.dist-info/top_level.txt +1 -0
plastree/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
plastree/dotplotree.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# dotplotree.py
|
|
3
|
+
# Tree construction using dotplot-based similarity between DNA sequences
|
|
4
|
+
|
|
5
|
+
from Bio.Seq import Seq
|
|
6
|
+
from Bio.Phylo.TreeConstruction import DistanceMatrix
|
|
7
|
+
from Bio.Phylo.TreeConstruction import DistanceTreeConstructor
|
|
8
|
+
import numpy as np
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import matplotlib.pyplot as plt
|
|
12
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _replace_with_mask(sequence, mask, replacement_char='N'):
|
|
16
|
+
return Seq(''.join(replacement_char if mask_bit == '0' else char for char, mask_bit in zip(sequence, mask)))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _find_long_segments(points, window, gap):
|
|
20
|
+
segments = []
|
|
21
|
+
if not points:
|
|
22
|
+
return segments
|
|
23
|
+
|
|
24
|
+
current_segment = [points[0]]
|
|
25
|
+
half_window = (window) / 2
|
|
26
|
+
|
|
27
|
+
for i in range(1, len(points)):
|
|
28
|
+
distance_x = abs(points[i][0] - current_segment[-1][0])
|
|
29
|
+
distance_y = abs(points[i][1] - current_segment[-1][1])
|
|
30
|
+
|
|
31
|
+
if distance_x <= half_window and distance_y <= half_window and distance_x == distance_y:
|
|
32
|
+
current_segment.append(points[i])
|
|
33
|
+
else:
|
|
34
|
+
if len(current_segment) >= window:
|
|
35
|
+
segments.append(current_segment)
|
|
36
|
+
current_segment = [points[i]]
|
|
37
|
+
|
|
38
|
+
if len(current_segment) >= window:
|
|
39
|
+
segments.append(current_segment)
|
|
40
|
+
|
|
41
|
+
final_segments = []
|
|
42
|
+
current_final_segment = []
|
|
43
|
+
|
|
44
|
+
for segment in segments:
|
|
45
|
+
if not current_final_segment:
|
|
46
|
+
current_final_segment = segment
|
|
47
|
+
else:
|
|
48
|
+
distance_x = abs(current_final_segment[-1][0] - segment[0][0])
|
|
49
|
+
distance_y = abs(current_final_segment[-1][1] - segment[0][1])
|
|
50
|
+
|
|
51
|
+
if distance_x <= gap and distance_y <= gap and abs(distance_x - distance_y) <= round(window / 2):
|
|
52
|
+
current_final_segment.extend(segment[1:])
|
|
53
|
+
else:
|
|
54
|
+
if len(current_final_segment) >= 2 * window:
|
|
55
|
+
final_segments.append(current_final_segment)
|
|
56
|
+
current_final_segment = segment
|
|
57
|
+
|
|
58
|
+
if current_final_segment and len(current_final_segment) >= 2 * window:
|
|
59
|
+
final_segments.append(current_final_segment)
|
|
60
|
+
|
|
61
|
+
return final_segments
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _dot_mat_distance_core(seq1_str, seq2_str, window, gap):
|
|
65
|
+
if window == 22:
|
|
66
|
+
mask = '1110101100110010101111'
|
|
67
|
+
else:
|
|
68
|
+
mask = '1110100110010101111'
|
|
69
|
+
|
|
70
|
+
seq1 = seq1_str.upper()
|
|
71
|
+
seq2 = seq2_str.upper()
|
|
72
|
+
seq2rc = str(Seq(seq2).reverse_complement())
|
|
73
|
+
dict_one, dict_two, dict_two2 = {}, {}, {}
|
|
74
|
+
|
|
75
|
+
for seq, section_dict in [(seq1, dict_one), (seq2, dict_two), (seq2rc, dict_two2)]:
|
|
76
|
+
for i in range(len(seq) - window + 1):
|
|
77
|
+
section = _replace_with_mask(seq[i: i + window], mask)
|
|
78
|
+
section_dict.setdefault(section, []).append(i)
|
|
79
|
+
|
|
80
|
+
def get_match_coordinates(matches, one, two):
|
|
81
|
+
x, y = [], []
|
|
82
|
+
for section in matches:
|
|
83
|
+
for i in one[section]:
|
|
84
|
+
for j in two[section]:
|
|
85
|
+
x.append(i)
|
|
86
|
+
y.append(j)
|
|
87
|
+
return x, y
|
|
88
|
+
|
|
89
|
+
matches = set(dict_one).intersection(dict_two)
|
|
90
|
+
matches2 = set(dict_one).intersection(dict_two2)
|
|
91
|
+
x, y = get_match_coordinates(matches, dict_one, dict_two)
|
|
92
|
+
x2, y2 = get_match_coordinates(matches2, dict_one, dict_two2)
|
|
93
|
+
|
|
94
|
+
segments_direct = _find_long_segments(
|
|
95
|
+
sorted(zip(x, y), key=lambda p: (p[1] - p[0], p[0])), window, gap)
|
|
96
|
+
segments_reverse = _find_long_segments(
|
|
97
|
+
sorted(zip(x2, y2), key=lambda p: (p[1] - p[0], p[0])), window, gap)
|
|
98
|
+
|
|
99
|
+
bin_dir1 = np.zeros(len(seq1), dtype=int)
|
|
100
|
+
bin_dir2 = np.zeros(len(seq2), dtype=int)
|
|
101
|
+
bin_rev1 = np.zeros(len(seq1), dtype=int)
|
|
102
|
+
bin_rev2 = np.zeros(len(seq2), dtype=int)
|
|
103
|
+
|
|
104
|
+
for segment in segments_direct:
|
|
105
|
+
bin_dir1[segment[0][0]:segment[-1][0] + window] = 1
|
|
106
|
+
bin_dir2[segment[0][1]:segment[-1][1] + window] = 1
|
|
107
|
+
|
|
108
|
+
for segment in segments_reverse:
|
|
109
|
+
bin_rev1[segment[0][0]:segment[-1][0] + window] = 1
|
|
110
|
+
bin_rev2[segment[0][1]:segment[-1][1] + window] = 1
|
|
111
|
+
|
|
112
|
+
bin_rrev2 = bin_rev2[::-1]
|
|
113
|
+
merged_bin1 = np.logical_or(bin_dir1, bin_rev1).astype(int)
|
|
114
|
+
merged_bin2 = np.logical_or(bin_dir2, bin_rrev2).astype(int)
|
|
115
|
+
|
|
116
|
+
bin_match = np.sum(merged_bin1) + np.sum(merged_bin2)
|
|
117
|
+
return 1 - (bin_match / (len(seq1) + len(seq2)))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def dotplot_tree(sequences, window=22, dotplot_view=False, output_dir=None, threads=1):
|
|
121
|
+
"""
|
|
122
|
+
Construct a UPGMA tree based on dotplot-derived similarity between sequences.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
sequences (list of SeqRecord): Input sequences.
|
|
126
|
+
window (int): Size of the sliding window for dotplot comparison.
|
|
127
|
+
dotplot_view (bool): Whether to render dotplots for each pair.
|
|
128
|
+
threads (int): Number of parallel worker processes for pairwise distance calculation.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
tuple: (Phylogenetic tree, Distance matrix)
|
|
132
|
+
"""
|
|
133
|
+
def get_unique_filename(base_name, ext, max_digits=3):
|
|
134
|
+
"""
|
|
135
|
+
Generate a unique filename by appending a numeric suffix if file exists.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
base_name (str): Base filename without extension.
|
|
139
|
+
ext (str): File extension (e.g. 'tsv', 'newick').
|
|
140
|
+
max_digits (int): Number of digits for numbering.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
str: Unique filename with extension.
|
|
144
|
+
"""
|
|
145
|
+
if not os.path.exists(f"{base_name}.{ext}"):
|
|
146
|
+
return f"{base_name}.{ext}"
|
|
147
|
+
i = 1
|
|
148
|
+
while True:
|
|
149
|
+
candidate = f"{base_name}{str(i).zfill(max_digits)}.{ext}"
|
|
150
|
+
if not os.path.exists(candidate):
|
|
151
|
+
return candidate
|
|
152
|
+
i += 1
|
|
153
|
+
|
|
154
|
+
def dotplotxy_segments(x, y, x2, y2, segments, segments2, window, mask):
|
|
155
|
+
"""
|
|
156
|
+
Visualize the dotplot and matching segments.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
x, y, x2, y2 (list): Match coordinates.
|
|
160
|
+
segments, segments2 (list): Direct and reverse matching segments.
|
|
161
|
+
window (int): Window size.
|
|
162
|
+
mask (str): Binary mask used for filtering.
|
|
163
|
+
"""
|
|
164
|
+
plt.scatter(x, y, color='black', marker='.', linewidths=0.05)
|
|
165
|
+
plt.scatter(x2, len(rec_two)-np.array(y2), color='gray', marker='.', linewidths=0.05)
|
|
166
|
+
|
|
167
|
+
for segment in segments:
|
|
168
|
+
x_segment = [point[0] for point in segment]
|
|
169
|
+
y_segment = [point[1] for point in segment]
|
|
170
|
+
plt.plot(x_segment, y_segment, color='red')
|
|
171
|
+
|
|
172
|
+
for segment in segments2:
|
|
173
|
+
x_segment = [point[0] for point in segment]
|
|
174
|
+
y_segment = [point[1] for point in segment]
|
|
175
|
+
plt.plot(x_segment, len(rec_two)-np.array(y_segment), color='#00FF00')
|
|
176
|
+
|
|
177
|
+
plt.xlabel("%s (length %i bp)" % (rec_one.id, len(rec_one)))
|
|
178
|
+
plt.ylabel("%s (length %i bp)" % (rec_two.id, len(rec_two)))
|
|
179
|
+
plt.grid(True)
|
|
180
|
+
plt.xlim(0, len(rec_one) - window)
|
|
181
|
+
plt.ylim(0, len(rec_two) - window)
|
|
182
|
+
plt.gca().set_aspect('equal', adjustable='box')
|
|
183
|
+
plt.tight_layout()
|
|
184
|
+
plt.savefig(get_unique_filename(os.path.join(output_dir, "dotplot"), "png"), dpi=150)
|
|
185
|
+
plt.close()
|
|
186
|
+
|
|
187
|
+
def dot_mat_distance(seq1, seq2, window, gap, dotplot):
|
|
188
|
+
"""
|
|
189
|
+
Calculate dotplot-based distance between two sequences.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
seq1, seq2 (SeqRecord): DNA sequences.
|
|
193
|
+
window (int): Size of sliding window.
|
|
194
|
+
gap (int): Gap threshold for segment merging.
|
|
195
|
+
dotplot (bool): Whether to visualize.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
float: Dotplot-based distance.
|
|
199
|
+
"""
|
|
200
|
+
if window == 22:
|
|
201
|
+
mask = '1110101100110010101111'
|
|
202
|
+
else:
|
|
203
|
+
mask = '1110100110010101111'
|
|
204
|
+
|
|
205
|
+
seq2rc = seq2.seq.reverse_complement()
|
|
206
|
+
dict_one, dict_two, dict_two2 = {}, {}, {}
|
|
207
|
+
|
|
208
|
+
for seq, section_dict in [(seq1.seq.upper(), dict_one),
|
|
209
|
+
(seq2.seq.upper(), dict_two),
|
|
210
|
+
(seq2rc, dict_two2)]:
|
|
211
|
+
for i in range(len(seq) - window + 1):
|
|
212
|
+
section = _replace_with_mask(seq[i: i + window], mask)
|
|
213
|
+
section_dict.setdefault(section, []).append(i)
|
|
214
|
+
|
|
215
|
+
def get_match_coordinates(matches, dict_one, dict_two):
|
|
216
|
+
x, y = [], []
|
|
217
|
+
for section in matches:
|
|
218
|
+
for i in dict_one[section]:
|
|
219
|
+
for j in dict_two[section]:
|
|
220
|
+
x.append(i)
|
|
221
|
+
y.append(j)
|
|
222
|
+
return x, y
|
|
223
|
+
|
|
224
|
+
matches = set(dict_one).intersection(dict_two)
|
|
225
|
+
matches2 = set(dict_one).intersection(dict_two2)
|
|
226
|
+
x, y = get_match_coordinates(matches, dict_one, dict_two)
|
|
227
|
+
x2, y2 = get_match_coordinates(matches2, dict_one, dict_two2)
|
|
228
|
+
|
|
229
|
+
segments_direct = _find_long_segments(
|
|
230
|
+
sorted(zip(x, y), key=lambda p: (p[1] - p[0], p[0])), window, gap)
|
|
231
|
+
segments_reverse = _find_long_segments(
|
|
232
|
+
sorted(zip(x2, y2), key=lambda p: (p[1] - p[0], p[0])), window, gap)
|
|
233
|
+
|
|
234
|
+
if dotplot:
|
|
235
|
+
dotplotxy_segments(x, y, x2, y2, segments_direct, segments_reverse, window, mask)
|
|
236
|
+
|
|
237
|
+
bin_dir1 = np.zeros(len(seq1), dtype=int)
|
|
238
|
+
bin_dir2 = np.zeros(len(seq2), dtype=int)
|
|
239
|
+
bin_rev1 = np.zeros(len(seq1), dtype=int)
|
|
240
|
+
bin_rev2 = np.zeros(len(seq2), dtype=int)
|
|
241
|
+
|
|
242
|
+
for segment in segments_direct:
|
|
243
|
+
bin_dir1[segment[0][0]:segment[-1][0]+window] = 1
|
|
244
|
+
bin_dir2[segment[0][1]:segment[-1][1]+window] = 1
|
|
245
|
+
|
|
246
|
+
for segment in segments_reverse:
|
|
247
|
+
bin_rev1[segment[0][0]:segment[-1][0]+window] = 1
|
|
248
|
+
bin_rev2[segment[0][1]:segment[-1][1]+window] = 1
|
|
249
|
+
|
|
250
|
+
bin_rrev2 = bin_rev2[::-1]
|
|
251
|
+
merged_bin1 = np.logical_or(bin_dir1, bin_rev1).astype(int)
|
|
252
|
+
merged_bin2 = np.logical_or(bin_dir2, bin_rrev2).astype(int)
|
|
253
|
+
|
|
254
|
+
bin_match = np.sum(merged_bin1) + np.sum(merged_bin2)
|
|
255
|
+
bin_dist = 1 - (bin_match / (len(seq1) + len(seq2)))
|
|
256
|
+
return bin_dist
|
|
257
|
+
|
|
258
|
+
gap = 2 * window
|
|
259
|
+
names = [s.id for s in sequences]
|
|
260
|
+
dist_mat = DistanceMatrix(names)
|
|
261
|
+
n = len(sequences)
|
|
262
|
+
total_pairs = n * (n - 1) // 2
|
|
263
|
+
done_pairs = 0
|
|
264
|
+
progress_step = max(1, math.ceil(total_pairs / 100)) if total_pairs else 1
|
|
265
|
+
|
|
266
|
+
if threads < 1:
|
|
267
|
+
raise ValueError("threads must be >= 1")
|
|
268
|
+
|
|
269
|
+
if dotplot_view and len(sequences) > 20:
|
|
270
|
+
dotplot_view = False
|
|
271
|
+
print("Too many sequences. Dot plot rendering has been disabled. Maximum 20 sequences.")
|
|
272
|
+
|
|
273
|
+
if threads > 1 and dotplot_view:
|
|
274
|
+
dotplot_view = False
|
|
275
|
+
print("Dot plot rendering has been disabled for parallel execution (threads > 1).")
|
|
276
|
+
|
|
277
|
+
if total_pairs:
|
|
278
|
+
print(f"Dotplot progress: 0/{total_pairs} pairs", flush=True)
|
|
279
|
+
|
|
280
|
+
if threads == 1:
|
|
281
|
+
for i, rec_one in enumerate(sequences):
|
|
282
|
+
for j in range(i + 1, len(sequences)):
|
|
283
|
+
rec_two = sequences[j]
|
|
284
|
+
dist_mat[i, j] = dot_mat_distance(rec_one, rec_two, window, gap, dotplot_view)
|
|
285
|
+
done_pairs += 1
|
|
286
|
+
if done_pairs % progress_step == 0 or done_pairs == total_pairs:
|
|
287
|
+
print(f"Dotplot progress: {done_pairs}/{total_pairs} pairs", flush=True)
|
|
288
|
+
else:
|
|
289
|
+
future_to_pair = {}
|
|
290
|
+
with ProcessPoolExecutor(max_workers=threads) as executor:
|
|
291
|
+
for i, rec_one in enumerate(sequences):
|
|
292
|
+
for j in range(i + 1, len(sequences)):
|
|
293
|
+
rec_two = sequences[j]
|
|
294
|
+
future = executor.submit(_dot_mat_distance_core, str(rec_one.seq), str(rec_two.seq), window, gap)
|
|
295
|
+
future_to_pair[future] = (i, j)
|
|
296
|
+
|
|
297
|
+
for future in as_completed(future_to_pair):
|
|
298
|
+
i, j = future_to_pair[future]
|
|
299
|
+
dist_mat[i, j] = future.result()
|
|
300
|
+
done_pairs += 1
|
|
301
|
+
if done_pairs % progress_step == 0 or done_pairs == total_pairs:
|
|
302
|
+
print(f"Dotplot progress: {done_pairs}/{total_pairs} pairs", flush=True)
|
|
303
|
+
|
|
304
|
+
constructor = DistanceTreeConstructor()
|
|
305
|
+
tree = constructor.upgma(dist_mat)
|
|
306
|
+
return tree, dist_mat
|
plastree/kmertree.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# kmertree.py
|
|
2
|
+
# Tree construction based on ranked k-mer frequencies
|
|
3
|
+
|
|
4
|
+
from itertools import product, combinations
|
|
5
|
+
from Bio.Phylo.TreeConstruction import DistanceMatrix, DistanceTreeConstructor
|
|
6
|
+
import numpy as np
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
def kmer_tree(sequences):
|
|
10
|
+
"""
|
|
11
|
+
Constructs a phylogenetic tree based on ranked k-mer frequency profiles
|
|
12
|
+
averaged across multiple k-mer lengths (3 to 8).
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
sequences (list): List of Bio.SeqRecord objects representing the plasmid sequences.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
tuple: A tuple (tree, distance_matrix), where tree is a UPGMA phylogenetic tree,
|
|
19
|
+
and distance_matrix is a Bio.Phylo DistanceMatrix object.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def generate_kmers(k):
|
|
23
|
+
"""
|
|
24
|
+
Generate all possible k-mers of length k composed of A, C, G, T.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
k (int): Length of the k-mers.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
list: All possible k-mers of given length.
|
|
31
|
+
"""
|
|
32
|
+
nucleotides = ['A', 'C', 'G', 'T']
|
|
33
|
+
kmers = [''.join(p) for p in product(nucleotides, repeat=k)]
|
|
34
|
+
return kmers
|
|
35
|
+
|
|
36
|
+
def count_kmers(k, seq, kmers):
|
|
37
|
+
"""
|
|
38
|
+
Count k-mer occurrences in a sequence and rank them by frequency.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
k (int): Length of the k-mers.
|
|
42
|
+
seq (str): Nucleotide sequence.
|
|
43
|
+
kmers (list): Ordered list of all valid k-mers.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
list: Normalized rank values for each k-mer in original order.
|
|
47
|
+
"""
|
|
48
|
+
kmer_counts = {kmer: 0 for kmer in kmers}
|
|
49
|
+
seq_len = len(seq)
|
|
50
|
+
for i in range(seq_len - k + 1):
|
|
51
|
+
kmer = seq[i: i + k]
|
|
52
|
+
if kmer in kmer_counts:
|
|
53
|
+
kmer_counts[kmer] += 1
|
|
54
|
+
|
|
55
|
+
sorted_kmer_counts = sorted(kmer_counts.items(), key=lambda item: item[1], reverse=True)
|
|
56
|
+
ranks = {}
|
|
57
|
+
current_rank = 1
|
|
58
|
+
previous_count = None
|
|
59
|
+
|
|
60
|
+
for kmer, count in sorted_kmer_counts:
|
|
61
|
+
if count != previous_count:
|
|
62
|
+
ranks[kmer] = current_rank
|
|
63
|
+
current_rank += 1
|
|
64
|
+
else:
|
|
65
|
+
ranks[kmer] = current_rank - 1
|
|
66
|
+
previous_count = count
|
|
67
|
+
|
|
68
|
+
num_kmers = len(kmers)
|
|
69
|
+
normalized_ranks = {kmer: rank / num_kmers for kmer, rank in ranks.items()}
|
|
70
|
+
output_ranks = [normalized_ranks[kmer] for kmer in kmers]
|
|
71
|
+
return output_ranks
|
|
72
|
+
|
|
73
|
+
def compute_all_kmer_ranks(sequences, k):
|
|
74
|
+
"""
|
|
75
|
+
Compute normalized k-mer frequency ranks for all sequences.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
sequences (list): List of Bio.SeqRecord objects.
|
|
79
|
+
k (int): Length of k-mers.
|
|
80
|
+
Returns:
|
|
81
|
+
dict: Mapping from sequence ID to rank vector.
|
|
82
|
+
"""
|
|
83
|
+
kmers = generate_kmers(k)
|
|
84
|
+
kmer_ranks = {}
|
|
85
|
+
total = len(sequences)
|
|
86
|
+
step = max(1, math.ceil(total / 20)) if total else 1
|
|
87
|
+
for idx, seq in enumerate(sequences, start=1):
|
|
88
|
+
kmer_ranks[seq.id] = count_kmers(k, str(seq.seq) + str(seq.seq.reverse_complement()), kmers)
|
|
89
|
+
if idx % step == 0 or idx == total:
|
|
90
|
+
print(f"K-mer ranks (k={k}): {idx}/{total} sequences", flush=True)
|
|
91
|
+
return kmer_ranks
|
|
92
|
+
|
|
93
|
+
def pairwise_distance(ranks1, ranks2):
|
|
94
|
+
"""
|
|
95
|
+
Compute Euclidean distance between two k-mer rank vectors.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
ranks1 (list): Rank vector of first sequence.
|
|
99
|
+
ranks2 (list): Rank vector of second sequence.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
float: Euclidean distance.
|
|
103
|
+
"""
|
|
104
|
+
return sum((r1 - r2) ** 2 for r1, r2 in zip(ranks1, ranks2)) ** 0.5
|
|
105
|
+
|
|
106
|
+
def distance_matrix(sequences, k):
|
|
107
|
+
"""
|
|
108
|
+
Construct a distance matrix using k-mer rank vectors.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
sequences (list): List of Bio.SeqRecord objects.
|
|
112
|
+
k (int): Length of k-mers.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
DistanceMatrix: Lower triangular distance matrix.
|
|
116
|
+
"""
|
|
117
|
+
names = [s.id for s in sequences]
|
|
118
|
+
matrix = DistanceMatrix(names)
|
|
119
|
+
kmer_ranks = compute_all_kmer_ranks(sequences, k)
|
|
120
|
+
total_pairs = len(names) * (len(names) - 1) // 2
|
|
121
|
+
done_pairs = 0
|
|
122
|
+
step = max(1, math.ceil(total_pairs / 100)) if total_pairs else 1
|
|
123
|
+
if total_pairs:
|
|
124
|
+
print(f"K-mer distance (k={k}): 0/{total_pairs} pairs", flush=True)
|
|
125
|
+
for (tax1, tax2) in combinations(names, 2):
|
|
126
|
+
matrix[tax1, tax2] = pairwise_distance(kmer_ranks[tax1], kmer_ranks[tax2])
|
|
127
|
+
done_pairs += 1
|
|
128
|
+
if done_pairs % step == 0 or done_pairs == total_pairs:
|
|
129
|
+
print(f"K-mer distance (k={k}): {done_pairs}/{total_pairs} pairs", flush=True)
|
|
130
|
+
return matrix
|
|
131
|
+
|
|
132
|
+
def distance_matrix_to_full(dm):
|
|
133
|
+
"""
|
|
134
|
+
Convert a Bio.Phylo DistanceMatrix to a symmetric NumPy matrix.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
dm (DistanceMatrix): Input distance matrix.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
np.ndarray: Symmetric matrix of distances.
|
|
141
|
+
"""
|
|
142
|
+
full = np.zeros((n, n))
|
|
143
|
+
for i in range(n):
|
|
144
|
+
for j in range(i):
|
|
145
|
+
dist = dm[i, j]
|
|
146
|
+
full[i][j] = dist
|
|
147
|
+
full[j][i] = dist
|
|
148
|
+
return full
|
|
149
|
+
|
|
150
|
+
normalized_matrices = []
|
|
151
|
+
names = [s.id for s in sequences]
|
|
152
|
+
n = len(names)
|
|
153
|
+
|
|
154
|
+
# Compute distance matrices for k from 3 to 8 and normalize them
|
|
155
|
+
for k in range(3, 9):
|
|
156
|
+
print(f"K-mer progress: starting k={k}", flush=True)
|
|
157
|
+
dm = distance_matrix(sequences, k)
|
|
158
|
+
full_matrix = distance_matrix_to_full(dm)
|
|
159
|
+
max_val = np.max(full_matrix)
|
|
160
|
+
normalized = full_matrix / max_val if max_val != 0 else full_matrix
|
|
161
|
+
normalized_matrices.append(normalized)
|
|
162
|
+
print(f"K-mer progress: finished k={k}", flush=True)
|
|
163
|
+
|
|
164
|
+
# Average the normalized matrices
|
|
165
|
+
average_matrix = np.mean(normalized_matrices, axis=0)
|
|
166
|
+
lower_triangle = [
|
|
167
|
+
[average_matrix[i][j] for j in range(i)] + [0]
|
|
168
|
+
for i in range(n)
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
dist_mat = DistanceMatrix(names, lower_triangle)
|
|
172
|
+
constructor = DistanceTreeConstructor()
|
|
173
|
+
tree = constructor.upgma(dist_mat)
|
|
174
|
+
return tree, dist_mat
|
plastree/pangtree.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# pangtree.py
|
|
2
|
+
# Tree construction based on binary pangenome presence/absence matrix
|
|
3
|
+
# Input is a CSV file in Roary or Panaroo output format
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from Bio.Phylo.TreeConstruction import DistanceTreeConstructor, DistanceMatrix
|
|
7
|
+
from scipy.spatial.distance import hamming
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
def pang_tree(input_file, expected_samples=None, expected_format=None):
|
|
11
|
+
"""
|
|
12
|
+
Construct a UPGMA tree from pangenome presence/absence matrix.
|
|
13
|
+
|
|
14
|
+
This function reads a pangenome CSV file (Roary or Panaroo format),
|
|
15
|
+
extracts the binary matrix of gene presence across samples, calculates pairwise
|
|
16
|
+
Hamming distances between samples, and builds a UPGMA phylogenetic tree.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
input_file (str): Path to pangenome CSV (Roary 'gene_presence_absence_roary.csv'
|
|
20
|
+
or Panaroo 'gene_presence_absence.csv').
|
|
21
|
+
expected_samples (list[str] | None): Optional ordered list of sample names
|
|
22
|
+
expected in output (typically FASTA IDs). Missing samples are added
|
|
23
|
+
as all-zero columns.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
tuple: (Bio.Phylo.BaseTree.Tree, DistanceMatrix)
|
|
27
|
+
- The UPGMA phylogenetic tree.
|
|
28
|
+
- The corresponding distance matrix.
|
|
29
|
+
"""
|
|
30
|
+
# Load the CSV file
|
|
31
|
+
csv_file = pd.read_csv(input_file, low_memory=False)
|
|
32
|
+
|
|
33
|
+
# Identify format (Roary vs Panaroo) and find presence/absence start
|
|
34
|
+
if 'Avg group size nuc' in csv_file.columns:
|
|
35
|
+
# Roary format: find 'Avg group size nuc' and start after it
|
|
36
|
+
presence_start_idx = csv_file.columns.get_loc('Avg group size nuc') + 1
|
|
37
|
+
actual_format = 'roary'
|
|
38
|
+
elif 'Annotation' in csv_file.columns:
|
|
39
|
+
# Panaroo format: find 'Annotation' and start after it
|
|
40
|
+
presence_start_idx = csv_file.columns.get_loc('Annotation') + 1
|
|
41
|
+
actual_format = 'panaroo'
|
|
42
|
+
else:
|
|
43
|
+
raise ValueError("Could not determine pangenome format. Neither 'Avg group size nuc' (Roary) nor 'Annotation' (Panaroo) column found.")
|
|
44
|
+
|
|
45
|
+
if expected_format is not None and expected_format != actual_format:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Pangenome format mismatch: expected '{expected_format}' but the CSV looks like '{actual_format}'."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Extract presence/absence matrix: columns after the metadata section
|
|
51
|
+
df = csv_file.iloc[:, presence_start_idx:]
|
|
52
|
+
|
|
53
|
+
# Convert all non-empty cells (presence) to 1, and missing values (absence) to 0
|
|
54
|
+
df = df.notna().astype(int)
|
|
55
|
+
|
|
56
|
+
if expected_samples is not None:
|
|
57
|
+
# Map Roary columns like "sample_bakta" back to FASTA IDs when possible.
|
|
58
|
+
renamed_columns = {}
|
|
59
|
+
for col in df.columns:
|
|
60
|
+
if col.endswith("_bakta"):
|
|
61
|
+
base = col[:-6]
|
|
62
|
+
if base in expected_samples:
|
|
63
|
+
renamed_columns[col] = base
|
|
64
|
+
if renamed_columns:
|
|
65
|
+
df = df.rename(columns=renamed_columns)
|
|
66
|
+
|
|
67
|
+
# Add missing expected samples as all-zero presence/absence columns.
|
|
68
|
+
missing_samples = [name for name in expected_samples if name not in df.columns]
|
|
69
|
+
for name in missing_samples:
|
|
70
|
+
df[name] = 0
|
|
71
|
+
|
|
72
|
+
# Keep only expected samples and in FASTA order for cross-method consistency.
|
|
73
|
+
df = df[[name for name in expected_samples]]
|
|
74
|
+
|
|
75
|
+
# Extract sample names
|
|
76
|
+
names = df.columns.tolist()
|
|
77
|
+
total_pairs = len(names) * (len(names) - 1) // 2
|
|
78
|
+
done_pairs = 0
|
|
79
|
+
progress_step = max(1, math.ceil(total_pairs / 100)) if total_pairs else 1
|
|
80
|
+
|
|
81
|
+
if total_pairs:
|
|
82
|
+
print(f"Pang progress: 0/{total_pairs} pairs", flush=True)
|
|
83
|
+
|
|
84
|
+
# Compute lower-triangular Hamming distance matrix
|
|
85
|
+
matrix = []
|
|
86
|
+
for i in range(len(names)):
|
|
87
|
+
row = []
|
|
88
|
+
for j in range(i):
|
|
89
|
+
vec1 = df[names[i]].values
|
|
90
|
+
vec2 = df[names[j]].values
|
|
91
|
+
dist = hamming(vec1, vec2)
|
|
92
|
+
row.append(dist)
|
|
93
|
+
done_pairs += 1
|
|
94
|
+
if done_pairs % progress_step == 0 or done_pairs == total_pairs:
|
|
95
|
+
print(f"Pang progress: {done_pairs}/{total_pairs} pairs", flush=True)
|
|
96
|
+
matrix.append(row)
|
|
97
|
+
|
|
98
|
+
# Add diagonal (0s)
|
|
99
|
+
lower_triangle = [
|
|
100
|
+
[matrix[i][j] for j in range(i)] + [0] # Diagonal value = 0
|
|
101
|
+
for i in range(len(names))
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
# Build and return the tree
|
|
105
|
+
dist_mat = DistanceMatrix(names, lower_triangle)
|
|
106
|
+
constructor = DistanceTreeConstructor()
|
|
107
|
+
tree = constructor.upgma(dist_mat)
|
|
108
|
+
return tree, dist_mat
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# plasmid_tree_tool.py
|
|
2
|
+
# Unified script to compute plasmid phylogenetic trees using dotplot, k-mer, and pangenome presence/absence methods
|
|
3
|
+
# Generates individual and consensus trees and identifies clusters
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import argparse
|
|
7
|
+
import matplotlib
|
|
8
|
+
matplotlib.use('Agg') # Use non-interactive backend for headless environments
|
|
9
|
+
import matplotlib.pyplot as plt
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from Bio import SeqIO, Phylo
|
|
12
|
+
from Bio.Phylo.TreeConstruction import DistanceMatrix
|
|
13
|
+
from Bio.Phylo.Consensus import majority_consensus
|
|
14
|
+
from .dotplotree import dotplot_tree
|
|
15
|
+
from .pangtree import pang_tree
|
|
16
|
+
from .kmertree import kmer_tree
|
|
17
|
+
|
|
18
|
+
def get_unique_filename(base_name, ext, max_digits=2):
|
|
19
|
+
"""
|
|
20
|
+
Generate a unique filename by appending a numeric suffix if file exists.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
base_name (str): Base filename without extension.
|
|
24
|
+
ext (str): File extension (e.g. 'tsv', 'newick').
|
|
25
|
+
max_digits (int): Number of digits for numbering.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
str: Unique filename with extension.
|
|
29
|
+
"""
|
|
30
|
+
if not os.path.exists(f"{base_name}.{ext}"):
|
|
31
|
+
return f"{base_name}.{ext}"
|
|
32
|
+
i = 1
|
|
33
|
+
while True:
|
|
34
|
+
candidate = f"{base_name}{str(i).zfill(max_digits)}.{ext}"
|
|
35
|
+
if not os.path.exists(candidate):
|
|
36
|
+
return candidate
|
|
37
|
+
i += 1
|
|
38
|
+
|
|
39
|
+
def get_sequences(fasta_path):
|
|
40
|
+
"""
|
|
41
|
+
Load sequences from FASTA file.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
fasta_path (str): Path to FASTA file.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
list: List of SeqRecord objects.
|
|
48
|
+
"""
|
|
49
|
+
return list(SeqIO.parse(fasta_path, "fasta"))
|
|
50
|
+
|
|
51
|
+
def save_tree(tree, filename):
|
|
52
|
+
"""
|
|
53
|
+
Save phylogenetic tree to Newick file.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
tree (Tree): Bio.Phylo tree object.
|
|
57
|
+
filename (str): Path to output file.
|
|
58
|
+
"""
|
|
59
|
+
Phylo.write(tree, filename, "newick")
|
|
60
|
+
|
|
61
|
+
def save_distance_matrix(dist_mat, filename):
|
|
62
|
+
"""
|
|
63
|
+
Save distance matrix to TSV file.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
dist_mat (DistanceMatrix): Bio.Phylo DistanceMatrix object.
|
|
67
|
+
filename (str): Path to output file.
|
|
68
|
+
"""
|
|
69
|
+
with open(filename, "w") as f:
|
|
70
|
+
f.write("\t" + "\t".join(dist_mat.names) + "\n")
|
|
71
|
+
for i, row_name in enumerate(dist_mat.names):
|
|
72
|
+
row = [str(dist_mat[i, j]) if j < i else "" for j in range(len(dist_mat.names))]
|
|
73
|
+
f.write(row_name + "\t" + "\t".join(row) + "\n")
|
|
74
|
+
|
|
75
|
+
def largest_distant_nodes_with_table(tree, max_branches_in_cluster=None, max_branch_length=0.2):
|
|
76
|
+
"""
|
|
77
|
+
Identify and group most distinct clusters from a tree.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
tree (Tree): Bio.Phylo tree object.
|
|
81
|
+
max_branches_in_cluster (int): Max OTUs per cluster.
|
|
82
|
+
max_branch_length (float): Maximum allowed branch length within a cluster (0–1). None disables the check.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
tuple: (DataFrame with cluster IDs, list of cluster OTUs)
|
|
86
|
+
"""
|
|
87
|
+
def get_sorted_branches(tree):
|
|
88
|
+
branches = []
|
|
89
|
+
for node in tree.get_nonterminals():
|
|
90
|
+
branch = [leaf.name for leaf in node.get_terminals()]
|
|
91
|
+
branch.sort()
|
|
92
|
+
branches.append(branch)
|
|
93
|
+
return set(tuple(branch) for branch in branches)
|
|
94
|
+
|
|
95
|
+
def max_branch_length_in_cluster(tree, cluster):
|
|
96
|
+
leaves = set(cluster)
|
|
97
|
+
for clade in tree.get_nonterminals(order="preorder"):
|
|
98
|
+
if {leaf.name for leaf in clade.get_terminals()} == leaves:
|
|
99
|
+
branches = [c.branch_length for c in clade.find_clades() if c.branch_length and c is not clade]
|
|
100
|
+
return max(branches) if branches else 0.0
|
|
101
|
+
return 0.0
|
|
102
|
+
|
|
103
|
+
branches_t1 = get_sorted_branches(tree)
|
|
104
|
+
total_branches = len(branches_t1) + 1
|
|
105
|
+
|
|
106
|
+
if max_branches_in_cluster is None:
|
|
107
|
+
max_branches_in_cluster = total_branches // 2
|
|
108
|
+
|
|
109
|
+
sorted_clusters = sorted(branches_t1, key=len, reverse=True)
|
|
110
|
+
selected_clusters = []
|
|
111
|
+
selected_branches = set()
|
|
112
|
+
cluster_to_branch_map = {}
|
|
113
|
+
cluster_id = 1
|
|
114
|
+
|
|
115
|
+
for cluster in sorted_clusters:
|
|
116
|
+
if (
|
|
117
|
+
len(cluster) <= max_branches_in_cluster
|
|
118
|
+
and not selected_branches.intersection(cluster)
|
|
119
|
+
and (max_branch_length is None or max_branch_length_in_cluster(tree, cluster) <= max_branch_length)
|
|
120
|
+
):
|
|
121
|
+
selected_clusters.append(cluster)
|
|
122
|
+
selected_branches.update(cluster)
|
|
123
|
+
for branch in cluster:
|
|
124
|
+
cluster_to_branch_map[branch] = cluster_id
|
|
125
|
+
cluster_id += 1
|
|
126
|
+
|
|
127
|
+
all_branches = {branch for cluster in branches_t1 for branch in cluster}
|
|
128
|
+
unassigned_branches = all_branches - selected_branches
|
|
129
|
+
for branch in unassigned_branches:
|
|
130
|
+
cluster_to_branch_map[branch] = 0
|
|
131
|
+
|
|
132
|
+
table = [{"OTU": branch, "Cluster ID": cluster_to_branch_map[branch]} for branch in all_branches]
|
|
133
|
+
df = pd.DataFrame(table).sort_values(by="Cluster ID")
|
|
134
|
+
return df, list(selected_clusters)
|
|
135
|
+
|
|
136
|
+
def main():
|
|
137
|
+
parser = argparse.ArgumentParser(
|
|
138
|
+
description="Plasmid Tree Tool: Build phylogenetic trees from plasmid sequences using dotplot, pangenome, and k-mer methods."
|
|
139
|
+
)
|
|
140
|
+
parser.add_argument("--fasta", required=True, help="Path to input FASTA file with plasmid sequences.")
|
|
141
|
+
parser.add_argument("--pang_format", choices=["roary", "panaroo"], default=None, help="Format expected in the pangenome CSV: 'roary' or 'panaroo'. Required when using --methods pang.")
|
|
142
|
+
parser.add_argument("--pang_csv", default=None, help="Path to the pangenome presence/absence CSV file. Required when using --methods pang.")
|
|
143
|
+
parser.add_argument("--methods", nargs="*", choices=["dotplot", "pang", "kmer"], default=["dotplot"], help="Tree construction methods to apply. Default: dotplot only.")
|
|
144
|
+
parser.add_argument("--outdir", default="output", help="Directory to store output files. Default: /output.")
|
|
145
|
+
parser.add_argument("--max_cluster_size", type=int, default=None, help="Maximum OTUs per cluster in consensus tree. Default: 1/2 number of sequences.")
|
|
146
|
+
parser.add_argument("--max_branch_length", type=float, nargs="+", default=[0.2], help="Maximum allowed branch length(s) within a cluster (0–1). Accepts one or more values (e.g. --max_branch_length 0.1 0.2 0.05); one plasmid_clusters TSV is produced per value. Default: 0.2.")
|
|
147
|
+
parser.add_argument("--dotplot_window", type=int, default=22, help="Size of the sliding window for dotplot comparison. Supported values: 19 or 22. Default: 22.")
|
|
148
|
+
parser.add_argument("--dotplot_threads", type=int, default=1, help="Number of parallel worker processes for dotplot pairwise distance computation. Default: 1.")
|
|
149
|
+
parser.add_argument("--dotplot_png", action='store_true', help="Save dotplots for all pairwise comparisons as PNGs. Only for ≤20 sequences. Default: off.")
|
|
150
|
+
parser.add_argument("--result_tree_png", action='store_true', help="Save consensus/result tree as PNG with node support and cluster coloring. Default: off.")
|
|
151
|
+
args = parser.parse_args()
|
|
152
|
+
|
|
153
|
+
if "pang" in args.methods:
|
|
154
|
+
if args.pang_format is None:
|
|
155
|
+
parser.error("When using --methods pang, you must specify --pang_format (roary or panaroo).")
|
|
156
|
+
if args.pang_csv is None:
|
|
157
|
+
parser.error("When using --methods pang, you must specify --pang_csv.")
|
|
158
|
+
if not os.path.exists(args.pang_csv):
|
|
159
|
+
parser.error(f"Pangenome file not found: {args.pang_csv}.")
|
|
160
|
+
|
|
161
|
+
if args.dotplot_window not in (19, 22):
|
|
162
|
+
parser.error("Unsupported value for --dotplot_window. Use 19 or 22.")
|
|
163
|
+
|
|
164
|
+
if args.dotplot_threads < 1:
|
|
165
|
+
parser.error("Unsupported value for --dotplot_threads. Use an integer >= 1.")
|
|
166
|
+
|
|
167
|
+
os.makedirs(args.outdir, exist_ok=True)
|
|
168
|
+
sequences = get_sequences(args.fasta)
|
|
169
|
+
trees = []
|
|
170
|
+
|
|
171
|
+
def color_cluster_tree(tree, common_clusters, filename_suffix=None):
|
|
172
|
+
"""
|
|
173
|
+
Render and save tree with colored clusters.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
tree (Tree): Bio.Phylo tree object.
|
|
177
|
+
common_clusters (list): List of clusters (lists of OTU names).
|
|
178
|
+
filename_suffix (str): Optional suffix to distinguish output files (e.g. per max_branch_length value).
|
|
179
|
+
"""
|
|
180
|
+
# colors = ["#FF0000", "#0000FF", "#00FF00", "#800080", "#008080", "#FFA500", "#00FF85", "#FF00A5"]
|
|
181
|
+
colors = ["#FF0000", "#0000FF", "#00FF00", "#800080", "#008080", "#FFA500", "#00FF85", "#FF00A5", "#A52A2A", "#FFFF00", "#00CED1", "#FF1493", "#7FFF00", "#DC143C", "#000080", "#FFD700", "#20B2AA", "#C71585", "#ADFF2F", "#FF6347", "#1E90FF", "#B22222", "#DA70D6", "#5F9EA0", "#40E0D0", "#FF4500", "#9ACD32", "#BA55D3", "#3CB371", "#8B4513"]
|
|
182
|
+
for i, cluster in enumerate(common_clusters):
|
|
183
|
+
if len(cluster) > 1:
|
|
184
|
+
nodes = [{"name": name} for name in cluster]
|
|
185
|
+
mrca = tree.common_ancestor(*nodes)
|
|
186
|
+
mrca.color = colors[i % len(colors)]
|
|
187
|
+
import warnings
|
|
188
|
+
warnings.filterwarnings("ignore", category=UserWarning, module="Bio.Phylo._utils")
|
|
189
|
+
num_leaves = tree.count_terminals()
|
|
190
|
+
fig_height = max(15, num_leaves * 0.25)
|
|
191
|
+
fig_width = max(10, num_leaves * 0.06)
|
|
192
|
+
fig = plt.figure(figsize=(fig_width, fig_height))
|
|
193
|
+
axes = fig.add_subplot(1, 1, 1)
|
|
194
|
+
Phylo.draw(tree, axes=axes, branch_labels=lambda c: "{:.2f}".format(c.confidence) if c.confidence else '')
|
|
195
|
+
# Save to PNG instead of showing
|
|
196
|
+
plt.tight_layout()
|
|
197
|
+
base_name = "cluster_tree" if filename_suffix is None else f"cluster_tree_{filename_suffix}"
|
|
198
|
+
plt.savefig(get_unique_filename(os.path.join(args.outdir, base_name), "png"), dpi=300)
|
|
199
|
+
plt.close()
|
|
200
|
+
|
|
201
|
+
# DOTPLOT METHOD
|
|
202
|
+
if "dotplot" in args.methods:
|
|
203
|
+
print("Running dotplot method...")
|
|
204
|
+
dotmat_tree, dotmat_dist = dotplot_tree(sequences, args.dotplot_window, args.dotplot_png, args.outdir, args.dotplot_threads)
|
|
205
|
+
trees.append(dotmat_tree)
|
|
206
|
+
save_tree(dotmat_tree, get_unique_filename(os.path.join(args.outdir, "dotplot_tree"), "newick"))
|
|
207
|
+
save_distance_matrix(dotmat_dist, get_unique_filename(os.path.join(args.outdir, "dotplot_distmat"), "tsv"))
|
|
208
|
+
|
|
209
|
+
# PANGTREE METHOD
|
|
210
|
+
if "pang" in args.methods:
|
|
211
|
+
if args.pang_csv:
|
|
212
|
+
print("Running pangenome binary method...")
|
|
213
|
+
pang_tree_result, pang_dist = pang_tree(args.pang_csv, expected_samples=[s.id for s in sequences], expected_format=args.pang_format)
|
|
214
|
+
trees.append(pang_tree_result)
|
|
215
|
+
if 'dotmat_tree' in locals() and dotmat_tree is not None:
|
|
216
|
+
trees.append(dotmat_tree)
|
|
217
|
+
save_tree(pang_tree_result, get_unique_filename(os.path.join(args.outdir, "pang_tree"), "newick"))
|
|
218
|
+
save_distance_matrix(pang_dist, get_unique_filename(os.path.join(args.outdir, "pang_distmat"), "tsv"))
|
|
219
|
+
else:
|
|
220
|
+
print("Warning: Method 'pang' was selected but no --pang_csv provided. Skipping pangtree method.")
|
|
221
|
+
|
|
222
|
+
# K-MER METHOD
|
|
223
|
+
if "kmer" in args.methods:
|
|
224
|
+
print("Running k-mer method...")
|
|
225
|
+
kmer_result_tree, kmer_dist = kmer_tree(sequences)
|
|
226
|
+
trees.append(kmer_result_tree)
|
|
227
|
+
if 'dotmat_tree' in locals() and dotmat_tree is not None:
|
|
228
|
+
trees.append(dotmat_tree)
|
|
229
|
+
if 'pang_tree_result' in locals() and pang_tree_result is not None:
|
|
230
|
+
trees.append(pang_tree_result)
|
|
231
|
+
save_tree(kmer_result_tree, get_unique_filename(os.path.join(args.outdir, "kmer_tree"), "newick"))
|
|
232
|
+
save_distance_matrix(kmer_dist, get_unique_filename(os.path.join(args.outdir, "kmer_distmat"), "tsv"))
|
|
233
|
+
|
|
234
|
+
# CONSENSUS TREE
|
|
235
|
+
if len(trees) > 1:
|
|
236
|
+
print("Computing consensus tree...")
|
|
237
|
+
result_tree = majority_consensus(trees)
|
|
238
|
+
save_tree(result_tree, get_unique_filename(os.path.join(args.outdir, "consensus_tree"), "newick"))
|
|
239
|
+
print("Consensus tree:")
|
|
240
|
+
else:
|
|
241
|
+
print("Only one method used — consensus tree not computed.")
|
|
242
|
+
print(", ".join(args.methods) + " tree:")
|
|
243
|
+
result_tree = trees[0]
|
|
244
|
+
|
|
245
|
+
Phylo.draw_ascii(result_tree)
|
|
246
|
+
|
|
247
|
+
# CLUSTERING + SAVE
|
|
248
|
+
multiple_thresholds = len(args.max_branch_length) > 1
|
|
249
|
+
for max_branch_length in args.max_branch_length:
|
|
250
|
+
cluster_table, clusters = largest_distant_nodes_with_table(result_tree, args.max_cluster_size, max_branch_length)
|
|
251
|
+
if multiple_thresholds:
|
|
252
|
+
suffix = str(max_branch_length).replace(".", "_")
|
|
253
|
+
base_name = f"plasmid_clusters_bl{suffix}"
|
|
254
|
+
else:
|
|
255
|
+
suffix = None
|
|
256
|
+
base_name = "plasmid_clusters"
|
|
257
|
+
cluster_table.to_csv(get_unique_filename(os.path.join(args.outdir, base_name), "tsv"), sep="\t", index=False)
|
|
258
|
+
|
|
259
|
+
if args.result_tree_png:
|
|
260
|
+
color_cluster_tree(result_tree, clusters, filename_suffix=suffix)
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
main()
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plastree
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build plasmid phylogenetic trees from dotplot, pangenome presence/absence, and k-mer methods, with cluster detection.
|
|
5
|
+
Author: Helena Vitkova, Matej Bezdicek, Ema Holubova, Martina Lengerova
|
|
6
|
+
Author-email: Marketa Jakubickova <jakubickova@vut.cz>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/BioSys-BUT/PlasTree
|
|
9
|
+
Project-URL: Repository, https://github.com/BioSys-BUT/PlasTree
|
|
10
|
+
Project-URL: Issues, https://github.com/BioSys-BUT/PlasTree/issues
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: biopython==1.87
|
|
19
|
+
Requires-Dist: numpy==2.4.6
|
|
20
|
+
Requires-Dist: pandas==2.3.3
|
|
21
|
+
Requires-Dist: scipy==1.17.1
|
|
22
|
+
Requires-Dist: matplotlib==3.10.9
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# PlasTree
|
|
26
|
+
|
|
27
|
+
A tree-based pipeline for clustering complete bacterial plasmids from long-read assemblies, combining structural, k-mer, and gene-content similarity into a single weighted consensus tree.
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
## Overview
|
|
32
|
+
|
|
33
|
+
PlasTree integrates three complementary similarity methods, DotPloTree (pairwise dotplot-based structural similarity), KmerTree (ranked k-mer frequency similarity), and PanGTree (pangenome presence/absence similarity), and combines them into a single weighted consensus tree. Plasmids are then automatically grouped into clusters based on branch length and cluster size thresholds. PlasTree is intended for complete plasmid assemblies from long-read sequencing (e.g. Oxford Nanopore Technologies), where rearrangements, recombination, and accessory gene turnover can obscure relationships found by a single similarity measure.
|
|
34
|
+
|
|
35
|
+
## Content
|
|
36
|
+
|
|
37
|
+
- [Key Features](#key-features)
|
|
38
|
+
- [Requirements](#requirements)
|
|
39
|
+
- [Installation](#installation)
|
|
40
|
+
- [Usage](#usage)
|
|
41
|
+
- [Parameters](#parameters)
|
|
42
|
+
- [Example](#example)
|
|
43
|
+
- [Output](#output)
|
|
44
|
+
- [Generating pangenome input (Bakta + Roary/Panaroo)](#generating-pangenome-input-bakta--roarypanaroo)
|
|
45
|
+
- [License](#license)
|
|
46
|
+
- [Citation](#citation)
|
|
47
|
+
- [Contact](#contact)
|
|
48
|
+
|
|
49
|
+
## Key Features
|
|
50
|
+
|
|
51
|
+
- **Three independent tree-building methods**: DotPloTree (structural), KmerTree (nucleotide composition), and PanGTree (gene content, Roary or Panaroo format)
|
|
52
|
+
- **Weighted consensus tree** (3:2:1 DotPloTree:PanGTree:KmerTree) when two or more methods are combined
|
|
53
|
+
- **Automatic clustering** of plasmids by branch length, with support for multiple thresholds computed in a single run
|
|
54
|
+
- **Installable as a standard Python package**, exposing a single `plastree` command
|
|
55
|
+
- Optional PNG rendering of pairwise dotplots and the final cluster-colored consensus tree
|
|
56
|
+
|
|
57
|
+
## Requirements
|
|
58
|
+
|
|
59
|
+
- Python 3.10+
|
|
60
|
+
- All other dependencies (Biopython, NumPy, Pandas, SciPy, Matplotlib) are installed automatically with the package.
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
First create an isolated environment named `plastree` (pick one option below), then install PlasTree into it.
|
|
65
|
+
|
|
66
|
+
**Option A: venv**
|
|
67
|
+
```bash
|
|
68
|
+
python3 -m venv plastree
|
|
69
|
+
source plastree/bin/activate
|
|
70
|
+
pip install --upgrade pip
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Option B: conda / miniforge**
|
|
74
|
+
```bash
|
|
75
|
+
conda create -n plastree python=3.12
|
|
76
|
+
conda activate plastree
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
With the environment active, install PlasTree directly from GitHub:
|
|
80
|
+
```bash
|
|
81
|
+
pip install git+https://github.com/BioSys-BUT/PlasTree.git
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Or, if you want a local copy of the source:
|
|
85
|
+
```bash
|
|
86
|
+
git clone https://github.com/BioSys-BUT/PlasTree.git
|
|
87
|
+
cd PlasTree
|
|
88
|
+
pip install .
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Verify the install:
|
|
92
|
+
```bash
|
|
93
|
+
plastree --help
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Usage
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
plastree --fasta <fasta_path> [--pang_format {roary,panaroo}] [--pang_csv <csv_path>] [--methods dotplot kmer pang] [--outdir <out_dir>] [--max_cluster_size <int>] [--max_branch_length <float> [<float> ...]] [--dotplot_window {19,22}] [--dotplot_threads <int>] [--dotplot_png] [--result_tree_png]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Parameters
|
|
103
|
+
|
|
104
|
+
- `--fasta`: Path to a single FASTA file containing all plasmid sequences (one record per plasmid). **Required.**
|
|
105
|
+
- `--pang_format {roary,panaroo}`: Format of the pangenome CSV. Required when using `--methods pang`.
|
|
106
|
+
- `--pang_csv`: Path to the pangenome presence/absence CSV file. Required when using `--methods pang`.
|
|
107
|
+
- `--methods [dotplot pang kmer ...]`: Tree construction methods to apply. Default: `dotplot` only.
|
|
108
|
+
- `--outdir`: Directory to store output files. Default: `output`.
|
|
109
|
+
- `--max_cluster_size`: Maximum OTUs (plasmids) per cluster. Default: half the number of input sequences.
|
|
110
|
+
- `--max_branch_length`: Maximum allowed branch length within a cluster (0-1). Accepts one or more values (e.g. `0.1 0.2 0.05`); one cluster-assignment table is produced per value. Default: `0.2`.
|
|
111
|
+
- `--dotplot_window {19,22}`: Sliding window size for dotplot comparison. Default: `22`.
|
|
112
|
+
- `--dotplot_threads`: Number of parallel worker processes for dotplot pairwise distance computation. Default: `1`.
|
|
113
|
+
- `--dotplot_png`: Save pairwise dotplots as PNGs. Only for <=20 sequences. Default: off.
|
|
114
|
+
- `--result_tree_png`: Save the consensus tree as a PNG with node support and cluster coloring. Default: off.
|
|
115
|
+
|
|
116
|
+
### Example
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
plastree \
|
|
120
|
+
--fasta sequences.fasta \
|
|
121
|
+
--methods dotplot kmer pang \
|
|
122
|
+
--pang_format panaroo \
|
|
123
|
+
--pang_csv gene_presence_absence.csv \
|
|
124
|
+
--dotplot_window 22 \
|
|
125
|
+
--dotplot_threads 8 \
|
|
126
|
+
--max_cluster_size 30 \
|
|
127
|
+
--max_branch_length 0.05 0.1 0.2 0.4 \
|
|
128
|
+
--result_tree_png \
|
|
129
|
+
--outdir output
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
with example data (8 small real plasmids from *Enterococcus faecium*, included in [`examples/`](examples)):
|
|
133
|
+
```bash
|
|
134
|
+
plastree \
|
|
135
|
+
--fasta examples/plasmids.fasta \
|
|
136
|
+
--methods dotplot kmer pang \
|
|
137
|
+
--pang_format panaroo \
|
|
138
|
+
--pang_csv examples/gene_presence_absence.csv \
|
|
139
|
+
--result_tree_png \
|
|
140
|
+
--outdir examples/output
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Output
|
|
144
|
+
|
|
145
|
+
All output files are written to `--outdir`.
|
|
146
|
+
|
|
147
|
+
| File | Description |
|
|
148
|
+
|---|---|
|
|
149
|
+
| `dotplot_tree.newick` | DotPloTree structural similarity tree (`--methods dotplot`) |
|
|
150
|
+
| `dotplot_distmat.tsv` | DotPloTree distance matrix |
|
|
151
|
+
| `kmer_tree.newick` | KmerTree nucleotide composition tree (`--methods kmer`) |
|
|
152
|
+
| `kmer_distmat.tsv` | KmerTree distance matrix |
|
|
153
|
+
| `pang_tree.newick` | PanGTree gene content tree (`--methods pang`) |
|
|
154
|
+
| `pang_distmat.tsv` | PanGTree distance matrix |
|
|
155
|
+
| `consensus_tree.newick` | weighted consensus tree (only when 2+ methods are used) |
|
|
156
|
+
| `plasmid_clusters.tsv` | cluster assignment per plasmid (cluster 0 = outlier) |
|
|
157
|
+
| `cluster_tree.png` | consensus tree rendered with clusters color-coded (only with `--result_tree_png`) |
|
|
158
|
+
|
|
159
|
+
If multiple `--max_branch_length` values are given, `plasmid_clusters.tsv` and `cluster_tree.png` are produced once per threshold, suffixed with the value (dots replaced by underscores), e.g. `plasmid_clusters_bl0_2.tsv` and `cluster_tree_0_2.png` for threshold `0.2`.
|
|
160
|
+
|
|
161
|
+
## Generating pangenome input (Bakta + Roary/Panaroo)
|
|
162
|
+
|
|
163
|
+
The `--pang_csv` file used by `--methods pang` is not produced by PlasTree itself. It comes from annotating the same plasmid FASTA files and clustering the predicted genes into orthologous groups, using external tools:
|
|
164
|
+
|
|
165
|
+
1. Annotate each plasmid with [Bakta](https://github.com/oschwengers/bakta) (or another tool that outputs GFF3 annotations), producing one GFF3 file per plasmid.
|
|
166
|
+
2. Collect all resulting GFF3 files into one directory.
|
|
167
|
+
3. Run [Roary](https://sanger-pathogens.github.io/Roary/) and/or [Panaroo](https://github.com/gtonkinhill/panaroo) on the collected GFF3 files:
|
|
168
|
+
```bash
|
|
169
|
+
roary *.gff3 -f roary_out
|
|
170
|
+
```
|
|
171
|
+
```bash
|
|
172
|
+
panaroo -i *.gff3 -o panaroo_out
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Both produce a `gene_presence_absence.csv`. Pass this file as `--pang_csv`, together with the matching `--pang_format roary` or `--pang_format panaroo`. See each tool's own documentation for annotation database setup, threading, and other options. Bakta, Roary, and Panaroo have their own, sometimes conflicting, dependencies, so it is common to install them into separate environments (e.g. separate conda environments).
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
See [LICENSE](LICENSE) for details (MIT).
|
|
180
|
+
|
|
181
|
+
## Citation
|
|
182
|
+
|
|
183
|
+
The manuscript describing PlasTree is currently in submission. Until it is published, if you use PlasTree in your research, please cite this repository:
|
|
184
|
+
|
|
185
|
+
```bibtex
|
|
186
|
+
@software{plastree2026,
|
|
187
|
+
title={PlasTree},
|
|
188
|
+
author={Vitkova, Helena and Jakubickova, Marketa and Bezdicek, Matej and Holubova, Ema and Lengerova, Martina},
|
|
189
|
+
year={2026},
|
|
190
|
+
url={https://github.com/BioSys-BUT/PlasTree}
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Contact
|
|
195
|
+
|
|
196
|
+
For questions and feedback, please open an issue on GitHub or contact us via email at [jakubickova@vut.cz](mailto:jakubickova@vut.cz).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
plastree/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
plastree/dotplotree.py,sha256=wDbfbcao-aiP7P0ZecPS1hH9_BTQrqOUU3trl8QMWbk,11879
|
|
3
|
+
plastree/kmertree.py,sha256=JqneV2ZpAhoysLucpynLZKFampE9mfyHog2ZoPNslB8,6010
|
|
4
|
+
plastree/pangtree.py,sha256=S-vDcneDzUKVQZwGHAVO2Y3vfzJqgAVnp8-XRj0YgeQ,4381
|
|
5
|
+
plastree/plasmid_tree_tool.py,sha256=5gfqwIaDRLnfBF-hfbdi3kGevmz6J4iKGD4gfVqcEEw,12529
|
|
6
|
+
plastree-0.1.0.dist-info/licenses/LICENSE,sha256=f-nJkRhjOdWF0SVNgtzYXZvBR2cwFHGN3L7MnjQa4-g,1067
|
|
7
|
+
plastree-0.1.0.dist-info/METADATA,sha256=EkOf_f9j0SL5o1NhfHdAxE7QerZ0EDpIwcpbQ7vdOrQ,8683
|
|
8
|
+
plastree-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
9
|
+
plastree-0.1.0.dist-info/entry_points.txt,sha256=EJsbiOY2O2-zU9w695xPtHIZKvALcxXgC4p0Q1FdL0E,61
|
|
10
|
+
plastree-0.1.0.dist-info/top_level.txt,sha256=fAC2aiwSxp6sJTktiWTgVAuqQLc49xaGydfquBc3Pso,9
|
|
11
|
+
plastree-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BioSys_BUT
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
plastree
|