methplotlib 0.23.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.
@@ -0,0 +1,153 @@
1
+ import methplotlib.plots as plots
2
+ import methplotlib.utils as utils
3
+ import methplotlib.qc as qc
4
+ from methplotlib.import_methylation import get_data
5
+ import logging
6
+ import sys
7
+
8
+
9
+ def main():
10
+ args = utils.get_args()
11
+ if args.example:
12
+ utils.print_example()
13
+ utils.init_logs(args)
14
+ windows = utils.make_windows(args.window, fasta=args.fasta)
15
+ for window in windows:
16
+ logging.info(f"Processing {window.string}")
17
+ meth_data = get_data(args, window)
18
+ if args.store:
19
+ import pickle
20
+
21
+ pickle.dump(
22
+ obj=meth_data,
23
+ file=open(f"methplotlib-data-{window.string}.pickle", "wb"),
24
+ )
25
+ if not meth_data:
26
+ sys.exit(
27
+ f"\n\nERROR: No data found in {window.chromosome}:{window.begin}-{window.end} "
28
+ "for any of the input files!\n"
29
+ "Please check your --window and the chromosome names in your input.\n"
30
+ )
31
+ logging.info(f"Collected methylation data for {len(meth_data)} datasets")
32
+ qc.qc_plots(meth_data, window, qcpath=args.qcfile, outpath=args.outfile)
33
+ logging.info("Created QC plots")
34
+ meth_browser(meth_data, window, args)
35
+ logging.info("Finished!")
36
+
37
+
38
+ def meth_browser(meth_data, window, args):
39
+ """
40
+ meth_Data is a list of Methylation objects from the import_methylation submodule
41
+ annotation is optional and is a gtf or bed file
42
+
43
+ if the traces are to be --split per sample (or include raw data as flagged in data.split),
44
+ then show one line per sample and one for the annotation, with methrows = number of datasets
45
+ if no splitting is needed,
46
+ then 4/5 of the browser is used for overlayed samples and one for gtf annotation
47
+ the trace to be used for annotation is thus always num_methrows + 1
48
+ """
49
+ meth_traces = plots.methylation(
50
+ meth_data, dotsize=args.dotsize, binary=args.binary, minqual=args.minqual
51
+ )
52
+ logging.info("Prepared methylation traces.")
53
+ if args.split or meth_traces.split:
54
+ num_methrows = len(meth_data)
55
+ logging.info(
56
+ f"Making browser in split mode, with {num_methrows} modification rows."
57
+ )
58
+ annot_row = num_methrows + 1
59
+ annot_axis = f"yaxis{annot_row}"
60
+ fig = utils.create_subplots(
61
+ num_methrows,
62
+ split=True,
63
+ names=meth_traces.names,
64
+ annotation=bool(args.bed or args.gtf),
65
+ )
66
+ for y, (sample_traces, sample_type) in enumerate(meth_traces, start=1):
67
+ logging.info(f"Adding traces of type {sample_type} at height {y}")
68
+ for meth_trace in sample_traces:
69
+ fig.append_trace(trace=meth_trace, row=y, col=1)
70
+ if sample_type == "nanopolish_freq":
71
+ fig["layout"][f"yaxis{y}"].update(
72
+ title="Modified <br> frequency", range=[0, 1]
73
+ )
74
+ fig["layout"].update(showlegend=False)
75
+ fig["layout"].update(legend=dict(orientation="h"))
76
+ elif sample_type in ["nanopolish_call", "nanopolish_phased"]:
77
+ fig["layout"][f"yaxis{y}"].update(title="Reads", tickformat=",d")
78
+ fig["layout"].update(showlegend=False)
79
+ elif sample_type == "nanocompore":
80
+ fig["layout"][f"yaxis{y}"].update(title="-log10(pval)")
81
+ fig["layout"].update(legend=dict(orientation="h"))
82
+ elif sample_type == "ont-cram":
83
+ fig["layout"][f"yaxis{y}"].update(title="Reads", tickformat=",d")
84
+ elif sample_type == "bedmethyl_extended":
85
+ fig["layout"][f"yaxis{y}"].update(
86
+ title="Modified <br> frequency", range=[0, 1]
87
+ )
88
+ elif sample_type == "bedgraph":
89
+ fig["layout"][f"yaxis{y}"].update(title="Value")
90
+ else:
91
+ sys.exit(f"ERROR: unrecognized data type {sample_type}")
92
+ else:
93
+ logging.info("Making browser in overlaying mode.")
94
+ num_methrows = 4
95
+ annot_row = 5
96
+ annot_axis = "yaxis2"
97
+ fig = utils.create_subplots(
98
+ num_methrows, split=False, annotation=bool(args.bed or args.gtf)
99
+ )
100
+ for meth_trace in meth_traces.traces:
101
+ for trace in meth_trace:
102
+ fig.append_trace(trace=trace, row=1, col=1)
103
+ fig["layout"].update(legend=dict(orientation="h"))
104
+ if meth_traces.types[0] in ["nanopolish_freq", "bedmethyl_extended"]:
105
+ fig["layout"]["yaxis"].update(title="Modified <br> frequency", range=[0, 1])
106
+ fig["layout"]["xaxis"].update(exponentformat="none")
107
+ elif meth_traces.types[0] == "bedgraph":
108
+ fig["layout"]["yaxis"].update(title="Value")
109
+ else:
110
+ sys.exit(
111
+ f"ERROR: unexpectedly not splitting for input of type {meth_traces.types[0]}"
112
+ )
113
+ logging.info("Prepared modification plots.")
114
+
115
+ if args.bed:
116
+ for annot_trace in plots.bed_annotation(args.bed, window):
117
+ fig.append_trace(trace=annot_trace, row=annot_row, col=1)
118
+ y_max = -2
119
+ if args.gtf:
120
+ annotation_traces, y_max = plots.gtf_annotation(args.gtf, window, args.simplify)
121
+ for annot_trace in annotation_traces:
122
+ fig.append_trace(trace=annot_trace, row=annot_row, col=1)
123
+ if args.bed or args.gtf:
124
+ fig["layout"][annot_axis].update(
125
+ range=[-2, y_max + 1],
126
+ showgrid=False,
127
+ zeroline=False,
128
+ showline=False,
129
+ ticks="",
130
+ showticklabels=False,
131
+ )
132
+ logging.info("Prepared annotation plots.")
133
+ fig["layout"]["xaxis"].update(
134
+ tickformat="d", separatethousands=True, range=[window.begin, window.end]
135
+ )
136
+ fig["layout"].update(
137
+ barmode="overlay",
138
+ title="Nucleotide modifications",
139
+ hovermode="closest",
140
+ plot_bgcolor="rgba(0,0,0,0)",
141
+ )
142
+ if num_methrows > 10:
143
+ for i in fig["layout"]["annotations"]:
144
+ i["font"]["size"] = 10
145
+ utils.create_browser_output(fig, args.outfile, window)
146
+ if args.static:
147
+ import plotly.io as pio
148
+
149
+ pio.write_image(fig, args.static, engine="kaleido")
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()
methplotlib/plots.py ADDED
@@ -0,0 +1,459 @@
1
+ import plotly.graph_objs as go
2
+ from methplotlib.annotation import parse_annotation, parse_bed
3
+ import sys
4
+ from sklearn.preprocessing import MinMaxScaler
5
+ import pandas as pd
6
+ import numpy as np
7
+
8
+
9
+ legend_made = False
10
+
11
+
12
+ class DataTraces(object):
13
+ def __init__(self, traces, types, names, split):
14
+ self.traces = traces
15
+ self.types = types
16
+ self.names = names
17
+ self.split = split
18
+ self.index = 0
19
+
20
+ def __iter__(self):
21
+ return self
22
+
23
+ def __next__(self):
24
+ if self.index == len(self.traces):
25
+ raise StopIteration
26
+ else:
27
+ self.index += 1
28
+ return self.traces[self.index - 1], self.types[self.index - 1]
29
+
30
+
31
+ def gtf_annotation(gtf, window, simplify=False):
32
+ """
33
+ Return a plotly trace for the annotation
34
+ with a line for the entire gene and triangles for exons,
35
+ indicating direction of transcription
36
+ """
37
+ result = []
38
+ annotation = parse_annotation(gtf, window, simplify)
39
+ if annotation:
40
+ for y_pos, transcript in enumerate(annotation):
41
+ line = make_per_gene_annot_line_trace(transcript, window, y_pos)
42
+ # exons overlapping the window, rather than exons contained in it,
43
+ # so that an exon larger than the window is shown as well
44
+ # the plot is limited to the window by the range of the x axis
45
+ exons = [
46
+ make_per_exon_arrow_trace(transcript, begin, end, y_pos)
47
+ for begin, end in transcript.exon_tuples
48
+ if begin <= window.end and end >= window.begin
49
+ ]
50
+ result.extend([line, *exons])
51
+ return result, y_pos
52
+ else:
53
+ return result, 0
54
+
55
+
56
+ def make_per_gene_annot_line_trace(transcript, window, y_pos):
57
+ """Generate a line trace for the gene
58
+
59
+ Trace can get limited by the window sizes
60
+ """
61
+ return go.Scatter(
62
+ x=[max(transcript.begin, window.begin), min(transcript.end, window.end)],
63
+ y=[y_pos, y_pos],
64
+ mode="lines",
65
+ line=dict(width=2, color=transcript.color),
66
+ name=transcript.transcript,
67
+ text=transcript.gene,
68
+ hoverinfo="text",
69
+ showlegend=False,
70
+ )
71
+
72
+
73
+ def make_per_exon_arrow_trace(transcript, begin, end, y_pos):
74
+ """Generate a line+marker trace for the exon
75
+
76
+ The shape is an arrow, as defined by the strand in transcript.marker
77
+ """
78
+ return go.Scatter(
79
+ x=[begin, end],
80
+ y=[y_pos, y_pos],
81
+ mode="lines+markers",
82
+ line=dict(width=8, color=transcript.color),
83
+ name=transcript.transcript,
84
+ text=transcript.gene,
85
+ hoverinfo="text",
86
+ showlegend=False,
87
+ marker=dict(symbol=transcript.marker, size=8),
88
+ )
89
+
90
+
91
+ def bed_annotation(bed, window):
92
+ return [
93
+ go.Scatter(
94
+ x=[begin, end],
95
+ y=[-2, -2],
96
+ mode="lines",
97
+ line=dict(width=16, color="grey"),
98
+ text=name,
99
+ hoverinfo="text",
100
+ showlegend=False,
101
+ )
102
+ for (begin, end, name) in parse_bed(bed, window)
103
+ ]
104
+
105
+
106
+ def methylation(meth_data, dotsize=4, binary=False, minqual=20):
107
+ """
108
+ Plot methylation traces from various data types
109
+ """
110
+ traces = []
111
+ types = []
112
+ names = []
113
+ split = False
114
+ for meth in meth_data:
115
+ if meth.data_type in ["nanopolish_call", "nanopolish_phased"]:
116
+ traces.append(
117
+ make_per_read_meth_traces_llr(
118
+ table=meth.table,
119
+ phased=meth.data_type == "nanopolish_phased",
120
+ dotsize=dotsize,
121
+ binary=binary,
122
+ )
123
+ )
124
+ split = True
125
+ elif meth.data_type == "nanocompore":
126
+ traces.append(plot_nanocompore(meth.table, dotsize=dotsize))
127
+ split = True
128
+ elif meth.data_type == "nanopolish_freq":
129
+ traces.append(
130
+ [
131
+ go.Scatter(
132
+ x=meth.table.index,
133
+ y=meth.table["methylated_frequency"],
134
+ mode="lines",
135
+ name=meth.name,
136
+ hoverinfo="name",
137
+ )
138
+ ]
139
+ )
140
+ elif meth.data_type == "bedmethyl_extended":
141
+ traces.append(
142
+ [
143
+ go.Scatter(
144
+ x=meth.table["Start"],
145
+ y=meth.table["modified_frequency"],
146
+ mode="lines+markers",
147
+ name=meth.name,
148
+ hoverinfo="name+x",
149
+ )
150
+ ]
151
+ )
152
+ elif meth.data_type == "ont-cram":
153
+ traces.append(
154
+ make_per_read_meth_traces_phred(
155
+ table=meth.table,
156
+ minmax_table=meth.start_end_table,
157
+ dotsize=dotsize,
158
+ minqual=minqual,
159
+ )
160
+ )
161
+ split = True
162
+ elif meth.data_type == "bedgraph":
163
+ starts = meth.table["Start"]
164
+ ends = meth.table["End"]
165
+ vals = meth.table["Value"]
166
+ traces.append(
167
+ [
168
+ go.Scatter(
169
+ x=[val for pair in zip(starts, ends) for val in pair],
170
+ y=[val for pair in zip(vals, vals) for val in pair],
171
+ mode="lines",
172
+ name=meth.name,
173
+ hoverinfo="name",
174
+ )
175
+ ]
176
+ )
177
+ else:
178
+ sys.exit(f"ERROR: unrecognized data type {meth.data_type}")
179
+ types.append(meth.data_type)
180
+ names.append(meth.name)
181
+ return DataTraces(traces=traces, types=types, names=names, split=split)
182
+
183
+
184
+ def make_per_read_meth_traces_phred(
185
+ table, minmax_table, max_cov=100, dotsize=4, minqual=20
186
+ ):
187
+ """Make traces for each read"""
188
+ df_heights = assign_y_height_per_read(minmax_table, max_coverage=max_cov)
189
+ table = table.join(df_heights, on="read_name", how="outer")
190
+ traces = []
191
+ hidden = 0
192
+ for read in table["read_name"].unique():
193
+ strand = table.loc[table["read_name"] == read, "strand"].values[0]
194
+ try:
195
+ traces.append(
196
+ make_per_read_line_trace(
197
+ read_range=minmax_table.loc[read],
198
+ y_pos=df_heights.loc[read, "height"],
199
+ strand=strand,
200
+ )
201
+ )
202
+ except KeyError:
203
+ hidden += 1
204
+ continue
205
+ if hidden:
206
+ sys.stderr.write(
207
+ f"Warning: hiding {hidden} reads because coverage above {max_cov}x.\n"
208
+ )
209
+ traces.append(
210
+ make_per_position_phred_scatter(
211
+ read_table=table[table["quality"] > minqual], dotsize=dotsize
212
+ )
213
+ )
214
+ return traces
215
+
216
+
217
+ def make_per_read_meth_traces_llr(
218
+ table, phased=False, max_cov=100, dotsize=4, binary=False
219
+ ):
220
+ """Make traces for each read"""
221
+ minmax_table = find_min_and_max_pos_per_read(table, phased=phased)
222
+ df_heights = assign_y_height_per_read(
223
+ minmax_table, phased=phased, max_coverage=max_cov
224
+ )
225
+ table = table.join(df_heights, on="read_name")
226
+ if binary:
227
+ table.loc[:, "llr_scaled"] = binarize_log_likelihood_ratio(
228
+ table["log_lik_ratio"].copy()
229
+ )
230
+ else:
231
+ table.loc[:, "llr_scaled"] = rescale_log_likelihood_ratio(
232
+ table["log_lik_ratio"].copy()
233
+ )
234
+ traces = []
235
+ hidden = 0
236
+ for read in table["read_name"].unique():
237
+ strand = table.loc[table["read_name"] == read, "strand"].values[0]
238
+ if phased:
239
+ phase = table.loc[table["read_name"] == read, "HP"].values[0]
240
+ else:
241
+ phase = None
242
+ try:
243
+ traces.append(
244
+ make_per_read_line_trace(
245
+ read_range=minmax_table.loc[read],
246
+ y_pos=df_heights.loc[read, "height"],
247
+ strand=strand,
248
+ phase=phase,
249
+ size=dotsize,
250
+ )
251
+ )
252
+ except KeyError:
253
+ hidden += 1
254
+ continue
255
+ if hidden:
256
+ sys.stderr.write(
257
+ f"Warning: hiding {hidden} reads because coverage above {max_cov}x.\n"
258
+ )
259
+ traces.append(
260
+ make_per_position_likelihood_scatter(read_table=table, dotsize=dotsize)
261
+ )
262
+ return traces
263
+
264
+
265
+ def find_min_and_max_pos_per_read(table, phased=False):
266
+ """Return a table with for every read the minimum and maximum position"""
267
+ mm_table = (
268
+ table.loc[:, ["read_name", "pos"]]
269
+ .groupby("read_name")
270
+ .min()
271
+ .join(
272
+ table.loc[:, ["read_name", "pos"]].groupby("read_name").max(),
273
+ lsuffix="min",
274
+ rsuffix="max",
275
+ )
276
+ )
277
+ if phased:
278
+ return mm_table.join(
279
+ table.loc[:, ["read_name", "HP"]]
280
+ .drop_duplicates(subset="read_name")
281
+ .set_index("read_name")
282
+ )
283
+ else:
284
+ return mm_table
285
+
286
+
287
+ def assign_y_height_per_read(df, phased=False, max_coverage=1000):
288
+ """Assign height of the read in the per read traces
289
+
290
+ Gets a dataframe of read_name, posmin and posmax.
291
+ Sorting by position, and optionally by phase block.
292
+ Determines optimal height (y coordinate) for this read
293
+
294
+ Returns a dictionary mapping read_name to y_coord
295
+ """
296
+ if phased:
297
+ dfs = df.sort_values(
298
+ by=["HP", "posmin", "posmax"], ascending=[True, True, False]
299
+ )
300
+ else:
301
+ dfs = df.sort_values(by=["posmin", "posmax"], ascending=[True, False])
302
+ heights = [[] for i in range(max_coverage)]
303
+ y_pos = dict()
304
+ for read in dfs.itertuples():
305
+ for y, layer in enumerate(heights, start=1):
306
+ if len(layer) == 0:
307
+ layer.append(read.posmax)
308
+ y_pos[read.Index] = y
309
+ break
310
+ if read.posmin > layer[-1]:
311
+ layer.append(read.posmax)
312
+ y_pos[read.Index] = y
313
+ break
314
+ return (
315
+ pd.DataFrame({"read": list(y_pos.keys()), "height": list(y_pos.values())})
316
+ .astype({"read": str})
317
+ .set_index("read")
318
+ )
319
+
320
+
321
+ def rescale_log_likelihood_ratio(llr):
322
+ """
323
+ Rescale log likelihood ratios
324
+
325
+ positive ratios between 0 and 1
326
+ negative ratios between -1 and 0
327
+ """
328
+ scaler = MinMaxScaler(feature_range=(0, 1))
329
+ llr[llr > 0] = scaler.fit_transform(llr[llr > 0].values.reshape(-1, 1)).flatten()
330
+ scaler = MinMaxScaler(feature_range=(-1, 0))
331
+ llr[llr < 0] = scaler.fit_transform(llr[llr < 0].values.reshape(-1, 1)).flatten()
332
+ return llr
333
+
334
+
335
+ def binarize_log_likelihood_ratio(llr, cutoff=2):
336
+ """
337
+ Converts the log likelihood to sorta binary representation,
338
+ with 0 being insufficient evidence (based on cutoff)
339
+ and +1 being probably methylated
340
+ and -1 being probably unmethylated
341
+ """
342
+ llr[(-cutoff < llr) & (llr < cutoff)] = 0
343
+ llr[llr > cutoff] = 1
344
+ llr[llr < -cutoff] = -1
345
+ return llr
346
+
347
+
348
+ def make_per_read_line_trace(read_range, y_pos, strand, phase=None, size=4):
349
+ """Make a grey line trace for a single read,
350
+ with black arrow symbols on the edges indicating strand"""
351
+ symbol = "triangle-right" if strand == "+" else "triangle-left"
352
+ if phase:
353
+ if phase == 1:
354
+ color = "lightgreen"
355
+ elif phase == 2:
356
+ color = "yellow"
357
+ else: # phase is np.nan
358
+ color = "black"
359
+ else:
360
+ color = "black"
361
+ return go.Scatter(
362
+ x=[read_range["posmin"], read_range["posmax"]],
363
+ y=[y_pos, y_pos],
364
+ mode="lines+markers",
365
+ line=dict(width=1, color="lightgrey"),
366
+ showlegend=False,
367
+ marker=dict(
368
+ symbol=symbol,
369
+ size=size * 2,
370
+ color=color,
371
+ line=dict(width=0.5, color="black"),
372
+ ),
373
+ )
374
+
375
+
376
+ def make_per_position_likelihood_scatter(read_table, maxval=0.75, dotsize=4):
377
+ """Make scatter plot per CpG per read
378
+ with the RdBu colorscale from plotly 3.0.0, showing the
379
+ scaled log likelihood of having methylation here"""
380
+ old_RdBu = [
381
+ [0, "rgb(5,10,172)"],
382
+ [0.35, "rgb(106,137,247)"],
383
+ [0.5, "rgb(190,190,190)"],
384
+ [0.6, "rgb(220,170,132)"],
385
+ [0.7, "rgb(230,145,90)"],
386
+ [1, "rgb(178,10,28)"],
387
+ ]
388
+ return go.Scatter(
389
+ x=read_table["pos"],
390
+ y=read_table["height"],
391
+ mode="markers",
392
+ showlegend=False,
393
+ text=read_table["log_lik_ratio"],
394
+ hoverinfo="text",
395
+ marker=dict(
396
+ size=dotsize,
397
+ color=read_table["llr_scaled"],
398
+ cmin=-maxval,
399
+ cmax=maxval,
400
+ colorscale=old_RdBu,
401
+ showscale=True,
402
+ colorbar=dict(
403
+ title=dict(text="Modification likelihood", side="right"),
404
+ tickvals=[-maxval, 0, maxval],
405
+ ticktext=["Likely <br> unmodified", "0", "Likely <br> modified"],
406
+ ticks="outside",
407
+ ),
408
+ ),
409
+ )
410
+
411
+
412
+ def make_per_position_phred_scatter(read_table, dotsize=4):
413
+ """Make scatter plot per CpG per read"""
414
+ global legend_made
415
+ if legend_made or read_table.dropna().empty:
416
+ return go.Scatter(
417
+ x=read_table["pos"],
418
+ y=read_table["height"],
419
+ mode="markers",
420
+ showlegend=False,
421
+ text=read_table["mod"],
422
+ hoverinfo="text",
423
+ marker=dict(size=dotsize, color=read_table["quality"], colorscale="Reds"),
424
+ )
425
+ else:
426
+ legend_made = True
427
+ return go.Scatter(
428
+ x=read_table["pos"],
429
+ y=read_table["height"],
430
+ mode="markers",
431
+ showlegend=False,
432
+ text=read_table["mod"],
433
+ hoverinfo="text",
434
+ marker=dict(
435
+ size=dotsize,
436
+ color=read_table["quality"],
437
+ colorscale="Reds",
438
+ colorbar=dict(
439
+ title=dict(text="Modification probability", side="right"),
440
+ tickvals=[read_table["quality"].min(), read_table["quality"].max()],
441
+ ticktext=["Likely <br> unmodified", "Likely <br> modified"],
442
+ ticks="outside",
443
+ ),
444
+ ),
445
+ )
446
+
447
+
448
+ def plot_nanocompore(table, dotsize=4):
449
+ return [
450
+ go.Scatter(
451
+ x=table["pos"],
452
+ y=-table[pval].apply(np.log10),
453
+ mode="markers+lines",
454
+ name=pval,
455
+ marker=dict(size=dotsize),
456
+ )
457
+ for pval in table.columns
458
+ if not pval == "pos"
459
+ ]