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.
- methplotlib/__init__.py +0 -0
- methplotlib/annotation.py +183 -0
- methplotlib/differential/__init__.py +0 -0
- methplotlib/differential/differential.py +62 -0
- methplotlib/helpers.py +12 -0
- methplotlib/import_methylation.py +583 -0
- methplotlib/methplotlib.py +153 -0
- methplotlib/plots.py +459 -0
- methplotlib/qc.py +133 -0
- methplotlib/utils.py +409 -0
- methplotlib/version.py +1 -0
- methplotlib-0.23.0.data/scripts/allele_specific_modification +42 -0
- methplotlib-0.23.0.data/scripts/differential_modification +43 -0
- methplotlib-0.23.0.dist-info/METADATA +117 -0
- methplotlib-0.23.0.dist-info/RECORD +19 -0
- methplotlib-0.23.0.dist-info/WHEEL +5 -0
- methplotlib-0.23.0.dist-info/entry_points.txt +2 -0
- methplotlib-0.23.0.dist-info/licenses/LICENSE +21 -0
- methplotlib-0.23.0.dist-info/top_level.txt +1 -0
|
@@ -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]
|