-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsequence_logo.py
More file actions
325 lines (270 loc) · 10.4 KB
/
Copy pathsequence_logo.py
File metadata and controls
325 lines (270 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
# --- sequence_logo.py ---
"""
This script contains tools to plot a sequence logo
from an evomd csv report.
"""
from collections import Counter
import argparse
import os
import matplotlib.pyplot as plt
from matplotlib.textpath import TextPath
from matplotlib.textpath import TextPath
from matplotlib.patches import PathPatch
from matplotlib.transforms import Affine2D
from matplotlib.font_manager import FontProperties
from sequence import Sequence
def normalize_array(arr: list[float], min_target: float = 0.05, max_target:float = 1.0):
"""
Normalizes an array to get the max = max_target and min = min_target.
Parameters:
- arr: Array of floats or list.
- min_target: minimum value after normalization.
- max_target: maximum value after normalization.
"""
if not arr:
return []
# Get the original max and min
max_val = max(arr)
min_val = min(arr)
rango_origen = max_val - min_val # original range
# returns max if all the elements are equal.
if rango_origen == 0:
return [max_target] * len(arr)
# target range
rango_destino = max_target - min_target
# return normalized array
#
# (x - min_val) * rango_destino
# min_target + -----------------------------
# rango_origen
#
return [min_target + ((x - min_val) * rango_destino / rango_origen) for x in arr]
def plot_sequence_logo(
sequences: list[(Sequence | str)], color: str = 'steelblue',
outline: bool = True, save: bool = False, out: str = 'sequence_logo.png',
gradient: bool = True, font: (str | None) = None,
exclude: (str|list|None) = None,
) -> None:
"""
Generates a sequence logo from a list of Sequence or list of str. All the elements
of sequence list must have the same lenght.
Parameters:
- sequences: List of Sequence or str.
- color: Letters color.
- outline: Activate or deactivate letter oultine
- save: Save figure in a file.
- out: name of the output file.
- gradient: whether include gradient proportional to the frequency or not.
- font: full path for a font ttf file.
- exclude: List of str with letters to be excluded. It helps when some sequences contain no std aa or -.
"""
if not sequences:
raise RuntimeError("Sequences list is empty!")
# check lenght and transform everything into str
sequences = [str(k) for k in sequences]
all_len = [len(k) for k in sequences]
seq_len = int(sum(all_len)/len(all_len))
if seq_len != int(all_len[0]):
raise RuntimeError("Check the sequences list. At least one sequence has a different lenght!")
# format exclude list
if exclude is not None:
if isinstance(exclude, str):
exclude = [exclude]
else:
exclude = []
# check font
if font is None:
_SCRIPTDIR = os.path.dirname(os.path.abspath(__file__))
funente_path = os.path.join(_SCRIPTDIR, "fonts", "SilkRemington-SBold.ttf")
else:
funente_path = font
# Compute relative frequency per position
freqs_per_pos = []
for i in range(seq_len):
columna = [seq[i] for seq in sequences if seq[i] not in exclude]
conteos = Counter(columna)
# frecuencias is a dictionary
frecuencias = {aa: count / len(columna) for aa, count in conteos.items()}
# reverse=True places the highest freq at the beginning
frecuencias_ordenadas = sorted(frecuencias.items(), key=lambda x: x[1], reverse=True)
freqs_per_pos.append(frecuencias_ordenadas)
# Create figure
fig, ax = plt.subplots(figsize=(max(8, seq_len * 0.5), 4))
# Use the font in fonts/ directory
propiedades_fuente = FontProperties(fname=funente_path)
# Compute the width for each letter
letras_posibles = "ACDEFGHIKLMNPQRSTVWY"
anchos = [] # widths
for letra in letras_posibles:
tp = TextPath((0, 0), letra, size=1, prop=propiedades_fuente)
anchos.append(tp.get_extents().width)
max_width = max(anchos) if anchos else 1.0
# Scale factor (0.8 per column)
scale_x_global = 0.8 / max_width
# Contour configuration
edge_color = 'black' if outline else 'none'
line_width = 0.5 if outline else 0
# 4. Dibujar las letras
for i, pos_freqs in enumerate(freqs_per_pos):
x_pos = i + 1
y_offset = 0.0
if gradient:
alphas = normalize_array([k[1] for k in pos_freqs])
else:
alphas = [1 for k in pos_freqs]
a = 0
for aa, freq in pos_freqs:
if freq == 0:
continue
tp = TextPath((0, 0), aa, size=1, prop=propiedades_fuente)
bbox = tp.get_extents()
if bbox.width == 0 or bbox.height == 0:
continue
# Y scale depends on relative frequency
scale_y = freq / bbox.height
# Center letter
width_escalado = bbox.width * scale_x_global
posicion_x_centro = x_pos - (width_escalado / 2)
transformacion = Affine2D() \
.scale(scale_x_global, scale_y) \
.translate(posicion_x_centro - (bbox.x0 * scale_x_global), y_offset - (bbox.y0 * scale_y))
tp_transformado = tp.transformed(transformacion)
# Add patches
patch = PathPatch(tp_transformado, facecolor=color, edgecolor=edge_color, linewidth=line_width, alpha=alphas[a])
ax.add_patch(patch)
y_offset += freq
a += 1
# Axes
ax.set_xlim(0.5, seq_len + 0.5)
ax.set_ylim(0, 1)
ax.set_xticks(range(1, seq_len + 1))
ax.set_ylabel('Relative frequency')
ax.set_xlabel('Position')
# Clean borders
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
plt.tight_layout()
if save:
plt.savefig(out, dpi=300)
plt.show()
# --- Argument parsing ------------------------------------------------------
def get_arguments() -> argparse.Namespace:
"""Parses command-line arguments for the sequence logo creator."""
parser = argparse.ArgumentParser(
description='Sequence logo creator from csv.'
)
parser.add_argument(
'-rep', '--report',
help='CSV report from evomd.',
default=None,
)
parser.add_argument(
'-evopkl', '--evopkl',
help='Binary (pickle) Evolver file previously created. Ignored if --report is given. Default: evolver.pkl',
default='evolver.pkl'
)
parser.add_argument(
'-r', '--ratio',
help='Ratio of sequences used to create the sequence logo. Default: 1.0',
type=float,
default=1.0,
)
parser.add_argument(
'-gp', '--group',
help='Set group of sequences to be used. Default: max. Only useful if --ratio < 1.0',
default='max',
type=str.lower,
choices=['max', 'min'],
)
parser.add_argument(
'-i', '--ignore-fitness',
help='Ignore the fitness and plot all the sequences. This ignores --group and --ratio.',
action='store_true',
)
parser.add_argument(
'-g', '--gradient',
help='Plot with a alpha-gradient proportional to the relative frequency.',
action='store_true'
)
parser.add_argument(
'-s', '--save',
help='Save the sequence logo as image.',
action='store_true'
)
parser.add_argument(
'-o', '--out',
help='Name of the output image. Used if --save is True.',
default='sequence_logo.png',
)
args = parser.parse_args()
# ignore evopkl if report is not None
if args.report is not None:
args.evopkl = None
# ignore group and ratio if ignore_fitness is True
if args.ignore_fitness:
args.group = None
args.ratio = None
# reverse is used for sorting sequences
args.reverse = (args.group == 'max')
return args
def from_report(args: argparse.Namespace) -> list:
import csv
import math
sequences = []
with open(args.report, newline='') as fh:
reader = csv.DictReader(fh)
for row in reader:
raw_seq = (row.get('sequence') or '').strip()
raw_fit = (row.get('fitness') or '').strip()
if not raw_seq:
continue
# discard rows without a valid fitness
if not args.ignore_fitness:
if raw_fit == '' or raw_fit.lower() == 'none':
print(f'Evolver.read_report: discarding {raw_seq} (no fitness)')
continue
try:
fitness = float(raw_fit)
except ValueError:
print(f'Evolver.read_report: invalid fitness "{raw_fit}" for {raw_seq} --> discarding')
continue
if math.isnan(fitness):
print(f'Evolver.read_report: discarding {raw_seq} (nan fitness)')
continue
# build Sequence; fitness is a read-only property, set it via fitness_list
new_seq = Sequence(raw_seq)
new_seq.fitness_list = [fitness]
sequences.append(new_seq)
return sequences
def from_evoplk(args: argparse.Namespace) -> list:
from evolver import Evolver
import utils
evo_pre = args.evopkl
if not utils.exists(evo_pre):
raise RuntimeError(f"Evolver not found: {evo_pre}")
# Fall back to the default pickle in the working directory
evo = utils.read_pkl(evo_pre)
sequences = evo.parent_sequences + evo.discarded_sequences + evo.sequences
if not args.ignore_fitness:
sequences = [k for k in sequences if len(k.fitness_list)>0]
return sequences
def main():
"""
Function executed as main script.
"""
args = get_arguments()
if args.report:
sequences = from_report(args)
else:
sequences = from_evoplk(args)
if len(sequences) == 0:
raise RuntimeError("No sequences for logo.")
# sort sequences if not ignore_fitness
if not args.ignore_fitness:
sequences.sort(key=lambda x: x.fitness, reverse=args.reverse)
# select the ratio
amount = int(len(sequences)*args.ratio)
sequences = sequences[:amount]
plot_sequence_logo(sequences=sequences, gradient=args.gradient, save=args.save, out=args.out)
if __name__ == '__main__':
main()