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,583 @@
1
+ import pandas as pd
2
+ import pyranges as pr
3
+ import numpy as np
4
+ import sys
5
+ import logging
6
+ from methplotlib.utils import file_sniffer, flatten, bedmethyl_separator
7
+ from itertools import repeat
8
+
9
+
10
+ class Modification(object):
11
+ def __init__(self, table, data_type, name, called_sites, start_end_table=None):
12
+ self.table = table
13
+ self.data_type = data_type
14
+ self.name = name
15
+ self.called_sites = called_sites
16
+ self.start_end_table = start_end_table
17
+
18
+
19
+ def get_data(args, window):
20
+ """
21
+ Import methylation data from all files in the list methylation_files
22
+
23
+ Data can in various formats
24
+ - nanopolish raw, phased, frequency
25
+ - cram
26
+ - bam
27
+ - nanocompore
28
+ - bedgraph
29
+ - bedmethyl in the extended form (as obtained from modbam2bed --extended)
30
+
31
+ data is extracted within the window args.window
32
+ Frequencies are smoothened using a sliding window
33
+ """
34
+ return flatten([read_mods(f, n, window, args) for f, n in zip(args.methylation, args.names)])
35
+
36
+
37
+ def read_mods(filename, name, window, args):
38
+ """
39
+ converts a file from nanopolish to a pandas dataframe
40
+ input can be from calculate_methylation_frequency
41
+ which will return a dataframe with 'chromosome', 'pos', 'methylated_frequency'
42
+ smoothening the result by a rolling average
43
+
44
+ input can also be raw data per read, optionally phased
45
+ which will return a dataframe with 'read', 'chromosome', 'pos', 'log_lik_ratio', 'strand'
46
+ """
47
+ file_type = file_sniffer(filename)
48
+ logging.info(f"File {filename} is of type {file_type}")
49
+ try:
50
+ if file_type.startswith("nanopolish"):
51
+ return parse_nanopolish(filename, file_type, name, window, smoothen=args.smooth)
52
+ elif file_type == "nanocompore":
53
+ return [parse_nanocompore(filename, name, window)]
54
+ elif file_type in ["cram", "bam"]:
55
+ return parse_cram(filename, file_type, name, window, args.mods)
56
+ elif file_type == "bedgraph":
57
+ return parse_bedgraph(filename, name, window)
58
+ elif file_type == "bedmethyl_extended":
59
+ return parse_bedmethyl(filename, name, window, smoothen=args.smooth, flavor="modbam2bed", mods_of_interest=args.mods)
60
+ elif file_type == "bedmethyl":
61
+ return parse_bedmethyl(filename, name, window, smoothen=args.smooth, flavor="modkit", mods_of_interest=args.mods)
62
+ except Exception as e:
63
+ logging.error(f"Error processing {filename}.")
64
+ logging.error(e, exc_info=True)
65
+ sys.stderr.write(f"\n\n\nError processing {filename}!\n")
66
+ sys.stderr.write("\n\n\nDetailed error:\n")
67
+ raise
68
+
69
+
70
+ def parse_nanopolish(filename, file_type, name, window, smoothen=5):
71
+ if window:
72
+ from pathlib import Path
73
+
74
+ if Path(filename + ".tbi").is_file():
75
+ import subprocess
76
+ import gzip
77
+
78
+ logging.info(f"Reading {filename} using a tabix stream.")
79
+ region = f"{window.chromosome}:{window.begin}-{window.end}"
80
+ try:
81
+ tabix_stream = subprocess.Popen(
82
+ ["tabix", filename, region],
83
+ stdout=subprocess.PIPE,
84
+ stderr=subprocess.PIPE,
85
+ )
86
+ except FileNotFoundError as e:
87
+ logging.error("Error when opening a tabix stream.")
88
+ logging.error(e, exc_info=True)
89
+ sys.stderr.write("\n\nERROR when opening a tabix stream.\n")
90
+ sys.stderr.write("Is tabix installed and on the PATH?.")
91
+ raise
92
+ header = gzip.open(filename, "rt").readline().rstrip().split("\t")
93
+ table = pd.read_csv(tabix_stream.stdout, sep="\t", header=None, names=header)
94
+ else:
95
+ logging.info(f"Reading {filename} slowly by splitting the file in chunks.")
96
+ sys.stderr.write(f"\nReading {filename} would be faster with bgzip and tabix.\n")
97
+ if file_type in ["nanopolish_call", "nanopolish_phased"]:
98
+ sys.stderr.write("Please index with 'tabix -S1 -s1 -b3 -e4'.\n")
99
+ else:
100
+ sys.stderr.write("Please index with 'tabix -S1 -s1 -b2 -e3'.\n")
101
+ iter_csv = pd.read_csv(
102
+ filename, sep="\t", iterator=True, chunksize=int(1e6), dtype={"chromosome": str}
103
+ )
104
+ table = pd.concat(
105
+ [chunk[chunk["chromosome"] == window.chromosome] for chunk in iter_csv]
106
+ )
107
+ else:
108
+ table = pd.read_csv(filename, sep="\t")
109
+ gr = pr.PyRanges(
110
+ table.rename(
111
+ columns={
112
+ "start": "Start",
113
+ "chromosome": "Chromosome",
114
+ "end": "End",
115
+ "Strand": "strand",
116
+ }
117
+ )
118
+ )
119
+ logging.info("Read the file in a dataframe.")
120
+
121
+ if window:
122
+ gr = gr[str(window.chromosome), window.begin : window.end]
123
+ try:
124
+ gr.pos = np.floor(gr.drop().df[["Start", "End"]].mean(axis=1))
125
+ except KeyError:
126
+ sys.stderr.write(f"\n\n\nProblem parsing nanopolish file {filename}!\n")
127
+ sys.stderr.write(
128
+ "Could it be that there are no calls in your selected window "
129
+ f"{window.chromosome}:{window.begin}-{window.end}?\n"
130
+ )
131
+ sys.stderr.write("\n\n\nDetailed error:\n")
132
+ raise
133
+
134
+ table = gr.df
135
+
136
+ if file_type in ["nanopolish_call", "nanopolish_phased"]:
137
+ table = table.drop(
138
+ columns=[
139
+ "Start",
140
+ "End",
141
+ "log_lik_methylated",
142
+ "log_lik_unmethylated",
143
+ "num_calling_strands",
144
+ "num_motifs",
145
+ "sequence",
146
+ ]
147
+ )
148
+ if "motif" in table:
149
+ return [
150
+ Modification(
151
+ table=sub_df,
152
+ data_type=file_type,
153
+ name=f"{name}_{mod}",
154
+ called_sites=len(sub_df),
155
+ )
156
+ for mod, sub_df in table.groupby("motif")
157
+ ]
158
+ else:
159
+ return [
160
+ Modification(
161
+ table=table.sort_values(["read_name", "pos"]),
162
+ data_type=file_type,
163
+ name=name,
164
+ called_sites=len(table),
165
+ )
166
+ ]
167
+ if file_type == "nanopolish_freq":
168
+ called_sites = table.called_sites
169
+ chromosome = table.Chromosome.values[0]
170
+
171
+ # errors are ignored as a reformatted file may not have all of these,
172
+ # and numeric_only skips any column that was added to the file,
173
+ # which would make the mean below fail
174
+ table = table.drop(
175
+ columns=[
176
+ "Chromosome",
177
+ "Start",
178
+ "End",
179
+ "num_motifs_in_group",
180
+ "called_sites",
181
+ "called_sites_methylated",
182
+ "group_sequence",
183
+ ],
184
+ errors="ignore",
185
+ )
186
+ return [
187
+ Modification(
188
+ table=table.sort_values("pos")
189
+ .groupby("pos")
190
+ .mean(numeric_only=True)
191
+ .rolling(window=smoothen, center=True)
192
+ .mean()
193
+ .assign(Chromosome=chromosome),
194
+ data_type=file_type,
195
+ name=name,
196
+ called_sites=called_sites.sum(),
197
+ )
198
+ ]
199
+
200
+
201
+ def parse_nanocompore(filename, name, window):
202
+ def nanocompore_columns_of_interest(column):
203
+ if column in ["pos", "ref_id"]:
204
+ return True
205
+ elif column.endswith("pvalue_context_2") or column.endswith("pvalue"):
206
+ return True
207
+ else:
208
+ return False
209
+
210
+ table = pd.read_csv(filename, sep="\t", usecols=nanocompore_columns_of_interest)
211
+ if window:
212
+ table = table[table["ref_id"] == window.chromosome]
213
+ # a record at the end of the window extends the line to the edge of the plot,
214
+ # its pvalue of 1.0 is filled in below
215
+ return Modification(
216
+ table=pd.concat(
217
+ [table.sort_values("pos"), pd.DataFrame({"pos": [window.end]})],
218
+ ignore_index=True,
219
+ )
220
+ .drop(columns="ref_id")
221
+ .fillna(1.0),
222
+ data_type="nanocompore",
223
+ name=name,
224
+ called_sites=len(table),
225
+ )
226
+
227
+
228
+ def no_records_warning(filename, window):
229
+ """
230
+ Warn that a file has no data in the window.
231
+ This is not fatal, as other files may still have data here.
232
+ """
233
+ message = f"No records for {filename} in {window.chromosome}:{window.begin}-{window.end}!"
234
+ logging.warning(message)
235
+ sys.stderr.write(f"\nWARNING: {message}\nThis dataset is skipped.\n")
236
+
237
+
238
+ def read_in_chunks(filename, window, sep, usecols, index_suggestion):
239
+ """
240
+ Read a headerless file in chunks, keeping only the lines of the chromosome of interest.
241
+
242
+ This is the slow path, used when no tabix index is available.
243
+ The chromosome is read as a string, as that is how the window stores it,
244
+ and to avoid mixed types when a file has both numeric and non-numeric chromosomes.
245
+ """
246
+ logging.info(f"Reading {filename} slowly by splitting the file in chunks.")
247
+ sys.stderr.write(f"\nReading {filename} would be faster with bgzip and '{index_suggestion}'.\n")
248
+ iter_csv = pd.read_csv(
249
+ filename,
250
+ sep=sep,
251
+ iterator=True,
252
+ chunksize=int(1e6),
253
+ header=None,
254
+ usecols=usecols,
255
+ dtype={0: str},
256
+ )
257
+ chromosomes_seen = set()
258
+ chunks = []
259
+ for chunk in iter_csv:
260
+ chromosomes_seen.update(chunk[0].unique())
261
+ chunks.append(chunk[chunk[0] == window.chromosome])
262
+ if window.chromosome not in chromosomes_seen:
263
+ sys.stderr.write(
264
+ f"\nWARNING: chromosome '{window.chromosome}' not found in {filename}.\n"
265
+ f"The file contains {len(chromosomes_seen)} chromosomes, "
266
+ f"such as {', '.join(sorted(chromosomes_seen)[:5])}.\n"
267
+ "Do the chromosome names in --window match those in your file "
268
+ "(e.g. '1' versus 'chr1')?\n"
269
+ )
270
+ return pd.concat(chunks)
271
+
272
+
273
+ def parse_bedgraph(filename, name, window):
274
+ # a bedgraph from `modkit pileup --bedgraph` has a fifth column with the coverage,
275
+ # which is ignored here
276
+ colnames = ["Chromosome", "Start", "End", "Value"]
277
+ usecols = [0, 1, 2, 3]
278
+ if window:
279
+ from pathlib import Path
280
+
281
+ if Path(filename + ".tbi").is_file():
282
+ import subprocess
283
+
284
+ logging.info(f"Reading {filename} using a tabix stream.")
285
+ region = f"{window.chromosome}:{window.begin}-{window.end}"
286
+ try:
287
+ tabix_stream = subprocess.Popen(
288
+ ["tabix", filename, region],
289
+ stdout=subprocess.PIPE,
290
+ stderr=subprocess.PIPE,
291
+ )
292
+ except FileNotFoundError as e:
293
+ logging.error("Error when opening a tabix stream.")
294
+ logging.error(e, exc_info=True)
295
+ sys.stderr.write("\n\nERROR when opening a tabix stream.\n")
296
+ sys.stderr.write("Is tabix installed and on the PATH?.")
297
+ raise
298
+ table = pd.read_csv(
299
+ tabix_stream.stdout,
300
+ sep="\t",
301
+ header=None,
302
+ usecols=usecols,
303
+ names=colnames,
304
+ dtype={0: str},
305
+ )
306
+ else:
307
+ table = read_in_chunks(
308
+ filename,
309
+ window,
310
+ sep="\t",
311
+ usecols=usecols,
312
+ index_suggestion="tabix -p bed",
313
+ ).rename(columns=dict(zip(usecols, colnames)))
314
+ else:
315
+ table = pd.read_csv(
316
+ filename,
317
+ sep="\t",
318
+ header=None,
319
+ usecols=usecols,
320
+ names=colnames,
321
+ dtype={0: str},
322
+ )
323
+ gr = pr.PyRanges(table)
324
+ logging.info("Read the file in a dataframe.")
325
+ if window:
326
+ gr = gr[window.chromosome, window.begin : window.end]
327
+ if len(gr.df) == 0:
328
+ no_records_warning(filename, window)
329
+ return []
330
+ return [
331
+ Modification(
332
+ table=gr.df.sort_values("Start"),
333
+ data_type="bedgraph",
334
+ name=name,
335
+ called_sites=len(table),
336
+ )
337
+ ]
338
+
339
+
340
+ def parse_bedmethyl(filename, name, window, smoothen=5, flavor="modkit", mods_of_interest=None):
341
+ if not flavor in ["modkit", "modbam2bed"]:
342
+ sys.exit(f"ERROR: flavor {flavor} not supported for bedmethyl.")
343
+ if flavor == "modkit":
344
+ colnames = {
345
+ 0: "Chromosome",
346
+ 1: "Start",
347
+ 2: "End",
348
+ 3: "Modification",
349
+ 10: "Frequency",
350
+ }
351
+ else:
352
+ colnames = {
353
+ 0: "Chromosome",
354
+ 1: "Start",
355
+ 2: "End",
356
+ 3: "Modification",
357
+ 11: "canonical",
358
+ 12: "modified",
359
+ }
360
+ usecols = list(colnames.keys())
361
+ # modkit separates the columns after BED9 by spaces, unless run with --only-tabs
362
+ sep = bedmethyl_separator(filename)
363
+ if window:
364
+ from pathlib import Path
365
+
366
+ if Path(filename + ".tbi").is_file():
367
+ import subprocess
368
+
369
+ logging.info(f"Reading {filename} using a tabix stream.")
370
+ region = f"{window.chromosome}:{window.begin}-{window.end}"
371
+ try:
372
+ tabix_stream = subprocess.Popen(
373
+ ["tabix", filename, region],
374
+ stdout=subprocess.PIPE,
375
+ stderr=subprocess.PIPE,
376
+ )
377
+ except FileNotFoundError as e:
378
+ logging.error("Error when opening a tabix stream.")
379
+ logging.error(e, exc_info=True)
380
+ sys.stderr.write("\n\nERROR when opening a tabix stream.\n")
381
+ sys.stderr.write("Is tabix installed and on the PATH?.")
382
+ raise
383
+ table = pd.read_csv(
384
+ tabix_stream.stdout,
385
+ sep=sep,
386
+ header=None,
387
+ usecols=usecols,
388
+ dtype={0: str},
389
+ ).rename(columns=colnames)
390
+ else:
391
+ table = read_in_chunks(
392
+ filename,
393
+ window,
394
+ sep=sep,
395
+ usecols=usecols,
396
+ index_suggestion="tabix -p bed",
397
+ ).rename(columns=colnames)
398
+ else:
399
+ table = pd.read_csv(
400
+ filename,
401
+ sep=sep,
402
+ header=None,
403
+ usecols=usecols,
404
+ dtype={0: str},
405
+ ).rename(columns=colnames)
406
+ gr = pr.PyRanges(table)
407
+ logging.info("Read the file in a dataframe.")
408
+ if window:
409
+ gr = gr[window.chromosome, window.begin : window.end]
410
+ if len(gr.df) == 0:
411
+ no_records_warning(filename, window)
412
+ return []
413
+ table = gr.df.sort_values("Start")
414
+ if flavor == "modkit":
415
+ table["modified_frequency"] = table["Frequency"] / 100
416
+ table.drop(columns=["Frequency"], inplace=True)
417
+ else:
418
+ table["modified_frequency"] = table["modified"] / (table["canonical"] + table["modified"])
419
+ table.drop(
420
+ columns=["Chromosome", "End", "canonical", "modified"], inplace=True, errors="ignore"
421
+ )
422
+ if mods_of_interest:
423
+ # mods of interest is in the form of C+m or C+h (as in the cram file)
424
+ # however, the bedmethyl file has the modifications in the form of m or h
425
+ # so we need to split the mods in mods_of_interest on the '+' and select the second part
426
+ mods = [m.split("+")[1] if "+" in m else m for m in mods_of_interest.split(",")]
427
+ modifications_found = sorted(table["Modification"].unique())
428
+ table = table[table["Modification"].isin(mods)]
429
+ if len(table) == 0:
430
+ sys.exit(
431
+ f"\n\nERROR: No records left for {filename} after selecting --mods!\n"
432
+ f"Modifications in this file: {', '.join(modifications_found)}\n"
433
+ )
434
+
435
+ return [
436
+ Modification(
437
+ table=smoothen_frequency(sub_df.drop(columns=["Modification"]), smoothen),
438
+ data_type="bedmethyl_extended",
439
+ name=f"{name}_{mod}",
440
+ called_sites=len(sub_df),
441
+ )
442
+ for mod, sub_df in table.groupby("Modification") if len(sub_df) > 0
443
+ ]
444
+
445
+
446
+ def smoothen_frequency(table, smoothen):
447
+ """
448
+ Rolling average of the modification frequency
449
+
450
+ Only the frequency is averaged, as averaging the positions as well would
451
+ plot the records in between the sites they were observed at.
452
+ Averaging over fewer records at the edges avoids losing those records.
453
+ """
454
+ table["modified_frequency"] = (
455
+ table["modified_frequency"].rolling(window=smoothen, center=True, min_periods=1).mean()
456
+ )
457
+ return table
458
+
459
+
460
+ def parse_cram(filename, filetype, name, window, mods_of_interest=None):
461
+ """
462
+ Extracts modified positions from a CRAM file
463
+
464
+ :param filename: str, path to the CRAM file
465
+ :param filetype: str, either 'cram' or 'bam'
466
+ :param name: str, name for the trace/sample
467
+ :param window: Region object to extract data for from the file
468
+ :param mods_of_interest: list of str, optional, list of modifications to extract
469
+ """
470
+ import pysam
471
+
472
+ mode = "rc" if filetype == "cram" else "rb"
473
+ cram = pysam.AlignmentFile(filename, mode)
474
+ data = []
475
+ start_stops = []
476
+ for read in cram.fetch(reference=str(window.chromosome), start=window.begin, end=window.end):
477
+ if not read.is_supplementary and not read.is_secondary:
478
+ start_stops.append((read.query_name, read.reference_start, read.reference_end))
479
+ mod_positions = get_modified_reference_positions(read)
480
+ if mod_positions:
481
+ data.extend(mod_positions)
482
+ df = (
483
+ pd.DataFrame(data, columns=["read_name", "strand", "pos", "quality", "mod"])
484
+ .astype(dtype={"mod": "category", "quality": "float"})
485
+ .sort_values(["read_name", "pos"])
486
+ )
487
+
488
+
489
+ if mods_of_interest:
490
+ mods_found = sorted(df["mod"].astype(str).unique())
491
+ df = df[df["mod"].isin(mods_of_interest.split(","))]
492
+ if len(df) == 0:
493
+ sys.exit(
494
+ f"\n\nERROR: No records left for {filename} after selecting --mods!\n"
495
+ f"Modifications in this file: {', '.join(mods_found)}\n"
496
+ )
497
+ return [
498
+ Modification(
499
+ table=sub_df,
500
+ data_type="ont-cram",
501
+ name=f"{name}_{mod}",
502
+ called_sites=len(sub_df),
503
+ start_end_table=pd.DataFrame(
504
+ start_stops, columns=["read_name", "posmin", "posmax"]
505
+ ).set_index("read_name"),
506
+ )
507
+ for mod, sub_df in df.groupby("mod") if len(sub_df) > 0
508
+ ]
509
+
510
+
511
+ def get_modified_reference_positions(read):
512
+ mod_positions = []
513
+ if read.has_tag("Mm") or read.has_tag("MM"):
514
+ modtag = "Mm" if read.has_tag("Mm") else "MM"
515
+ qualtag = "Ml" if read.has_tag("Ml") else "ML"
516
+ offset = 0
517
+ for context in read.get_tag(modtag).split(";"):
518
+ if context:
519
+ basemod = context.split(",", 1)[0].replace('?', '')
520
+ if "-" in basemod:
521
+ sys.exit(
522
+ "ERROR: modifications on negative strand currently unsupported.\n"
523
+ "Please contact me if this would be of interest for you."
524
+ )
525
+ base, mod = basemod.split("+")
526
+ # code below does not work with for ambiguous N and will search for a literal N
527
+ if base == "N":
528
+ sys.exit(
529
+ "ERROR: modifications of N nucleotides currently unsupported.\n"
530
+ "Please contact me if this would be of interest for you."
531
+ )
532
+ # The positions are encoded by specifying the number of non-modified occurences
533
+ # of that specific bases to skip in the read sequence
534
+ deltas = [int(i) for i in context.split(",")[1:]]
535
+ if not deltas:
536
+ continue
537
+ # Make an array with all positions for which a modification is reported
538
+ # In coordinates relative to occurences of the nucleotide in the read
539
+ locations = np.cumsum(deltas) + np.arange(len(deltas))
540
+ # Nake an array with all read positions for which that nucleotide exists in the read
541
+ # Oddly, get_forward_sequence() returns the sequence as it came from the sequencer
542
+ # This is the same direction as the Mm/Ml tags are specified in
543
+ base_index = np.array(
544
+ [i for i, letter in enumerate(read.get_forward_sequence()) if letter == base]
545
+ )
546
+ # Based on both arrays, find those read coordinates that have a modification
547
+ modified_bases = base_index[locations]
548
+ # Convert the array of read coordinates to reference coordinates
549
+ # full_length=True will make sure the list is of the same length as the read
550
+ # by adding 'None' for softclipped nucleotides
551
+ refpos = np.array(read.get_reference_positions(full_length=True))
552
+ # read.get_reference_positions() returns incrementing coordinates
553
+ # regardless of strand, so reversing the order for reverse_stranded reads
554
+ if read.is_reverse:
555
+ refpos = refpos[::-1]
556
+ # The likelihoods are in an array of length of all Mm/MM deltas,
557
+ # and are not separated by context/modified nucleotide type
558
+ if read.has_tag(qualtag):
559
+ likelihoods = read.get_tag(qualtag).tolist()[offset : offset + len(deltas)]
560
+ else:
561
+ likelihoods = [255] * len(deltas)
562
+ offset += len(deltas)
563
+ mod_positions.extend(
564
+ zip(
565
+ repeat(read.query_name), # read_name
566
+ repeat("-" if read.is_reverse else "+"), # strand
567
+ refpos[modified_bases], # pos
568
+ likelihoods, # quality
569
+ repeat(basemod), # mod
570
+ )
571
+ )
572
+ return mod_positions
573
+ else:
574
+ return None
575
+
576
+
577
+ def errs_tab(n):
578
+ """Generate list of error rates for qualities less than equal than n."""
579
+ return [10 ** (q / -10) for q in range(n + 1)]
580
+
581
+
582
+ def phred_to_probability(quals, tab=errs_tab(128)):
583
+ return [tab[ord(q) - 33] for q in quals]