methplotlib 0.21.2__tar.gz → 0.23.1__tar.gz

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.
Files changed (35) hide show
  1. {methplotlib-0.21.2/methplotlib.egg-info → methplotlib-0.23.1}/PKG-INFO +34 -10
  2. {methplotlib-0.21.2 → methplotlib-0.23.1}/README.md +3 -2
  3. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib/annotation.py +54 -20
  4. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib/import_methylation.py +171 -69
  5. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib/methplotlib.py +27 -41
  6. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib/plots.py +25 -21
  7. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib/qc.py +4 -1
  8. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib/utils.py +128 -34
  9. methplotlib-0.23.1/methplotlib/version.py +1 -0
  10. {methplotlib-0.21.2 → methplotlib-0.23.1/methplotlib.egg-info}/PKG-INFO +34 -10
  11. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib.egg-info/SOURCES.txt +1 -8
  12. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib.egg-info/top_level.txt +0 -1
  13. {methplotlib-0.21.2 → methplotlib-0.23.1}/setup.py +10 -8
  14. methplotlib-0.23.1/tests/test_differential.py +123 -0
  15. methplotlib-0.21.2/README.rst +0 -98
  16. methplotlib-0.21.2/methplotlib/version.py +0 -1
  17. methplotlib-0.21.2/scripts/__init__.py +0 -0
  18. methplotlib-0.21.2/scripts/annotate_calls_by_phase.py +0 -42
  19. methplotlib-0.21.2/scripts/calculate_methylation_frequency.py +0 -144
  20. methplotlib-0.21.2/scripts/genome-wide-QC.py +0 -38
  21. methplotlib-0.21.2/scripts/sorting_and_multiple_testing_correction.py +0 -33
  22. methplotlib-0.21.2/scripts/split_calls_by_longshot_phase.py +0 -38
  23. methplotlib-0.21.2/scripts/split_calls_by_phase.py +0 -50
  24. {methplotlib-0.21.2 → methplotlib-0.23.1}/LICENSE +0 -0
  25. {methplotlib-0.21.2 → methplotlib-0.23.1}/MANIFEST.in +0 -0
  26. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib/__init__.py +0 -0
  27. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib/differential/__init__.py +0 -0
  28. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib/differential/differential.py +0 -0
  29. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib/helpers.py +0 -0
  30. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib.egg-info/dependency_links.txt +0 -0
  31. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib.egg-info/entry_points.txt +0 -0
  32. {methplotlib-0.21.2 → methplotlib-0.23.1}/methplotlib.egg-info/requires.txt +0 -0
  33. {methplotlib-0.21.2 → methplotlib-0.23.1}/scripts/allele_specific_modification +0 -0
  34. {methplotlib-0.21.2 → methplotlib-0.23.1}/scripts/differential_modification +0 -0
  35. {methplotlib-0.21.2 → methplotlib-0.23.1}/setup.cfg +0 -0
@@ -1,23 +1,46 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: methplotlib
3
- Version: 0.21.2
4
- Summary: Plot methylation data obtained from nanopolish
3
+ Version: 0.23.1
4
+ Summary: Plot nucleotide modification data from nanopore sequencing
5
5
  Home-page: https://github.com/wdecoster/methplotlib
6
6
  Author: Wouter De Coster
7
7
  Author-email: decosterwouter@gmail.com
8
8
  License: MIT
9
- Keywords: nanopore
9
+ Keywords: nanopore methylation modification
10
10
  Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Intended Audience :: Science/Research
12
12
  Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
13
13
  Classifier: License :: OSI Approved :: MIT License
14
14
  Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.3
16
- Classifier: Programming Language :: Python :: 3.4
17
- Classifier: Programming Language :: Python :: 3.5
18
- Requires-Python: >=3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Requires-Python: >=3.9
19
21
  Description-Content-Type: text/markdown
20
22
  License-File: LICENSE
23
+ Requires-Dist: plotly>=4.9.0
24
+ Requires-Dist: numpy>=1.16.5
25
+ Requires-Dist: pandas>=0.23.4
26
+ Requires-Dist: pyranges>=0.0.77
27
+ Requires-Dist: fisher>=0.1.9
28
+ Requires-Dist: scikit-learn
29
+ Requires-Dist: pyfaidx
30
+ Requires-Dist: biopython
31
+ Requires-Dist: pysam
32
+ Dynamic: author
33
+ Dynamic: author-email
34
+ Dynamic: classifier
35
+ Dynamic: description
36
+ Dynamic: description-content-type
37
+ Dynamic: home-page
38
+ Dynamic: keywords
39
+ Dynamic: license
40
+ Dynamic: license-file
41
+ Dynamic: requires-dist
42
+ Dynamic: requires-python
43
+ Dynamic: summary
21
44
 
22
45
  [![Anaconda-Server Badge](https://anaconda.org/bioconda/methplotlib/badges/version.svg)](https://anaconda.org/bioconda/methplotlib)
23
46
  [![Anaconda-Server Badge](https://anaconda.org/bioconda/methplotlib/badges/license.svg)](https://anaconda.org/bioconda/methplotlib)
@@ -25,10 +48,11 @@ License-File: LICENSE
25
48
  # METHPLOTLIB
26
49
 
27
50
  This script generates a browser view on a window using data from
28
- i) [nanopolish](https://github.com/jts/nanopolish), either as methylation calls or methylation frequencies (as processed by calculate_methylation_frequency.py). The methylation calls can additionally be phased using scripts/annotate_calls_by_phase.and scripts/split_calls_by_phase.py
51
+ i) [nanopolish](https://github.com/jts/nanopolish), either as methylation calls or methylation frequencies (as processed by calculate_methylation_frequency.py). The methylation calls can additionally be phased using scripts/annotate_calls_by_phase.py and scripts/split_calls_by_phase.py
29
52
  ii) [nanocompore](https://github.com/tleonardi/nanocompore)
30
53
  iii) in ont-cram format with MM/ML tags according to the SAM specifications
31
- iv) in bedgraph format
54
+ iv) in bedgraph format, with 4 columns (chromosome, start, end, value) or the 5 columns written by `modkit pileup --bedgraph`
55
+ v) in bedMethyl format, as written by [modkit](https://github.com/nanoporetech/modkit) `pileup` (18 columns, with or without `--only-tabs`) or by `modbam2bed --extended` (14 columns). Files containing multiple modifications (e.g. 5mC and 5hmC) do not have to be split up: each modification gets its own track, and `--mods` can be used to restrict to some of them
32
56
 
33
57
  ## INSTALLATION
34
58
 
@@ -4,10 +4,11 @@
4
4
  # METHPLOTLIB
5
5
 
6
6
  This script generates a browser view on a window using data from
7
- i) [nanopolish](https://github.com/jts/nanopolish), either as methylation calls or methylation frequencies (as processed by calculate_methylation_frequency.py). The methylation calls can additionally be phased using scripts/annotate_calls_by_phase.and scripts/split_calls_by_phase.py
7
+ i) [nanopolish](https://github.com/jts/nanopolish), either as methylation calls or methylation frequencies (as processed by calculate_methylation_frequency.py). The methylation calls can additionally be phased using scripts/annotate_calls_by_phase.py and scripts/split_calls_by_phase.py
8
8
  ii) [nanocompore](https://github.com/tleonardi/nanocompore)
9
9
  iii) in ont-cram format with MM/ML tags according to the SAM specifications
10
- iv) in bedgraph format
10
+ iv) in bedgraph format, with 4 columns (chromosome, start, end, value) or the 5 columns written by `modkit pileup --bedgraph`
11
+ v) in bedMethyl format, as written by [modkit](https://github.com/nanoporetech/modkit) `pileup` (18 columns, with or without `--only-tabs`) or by `modbam2bed --extended` (14 columns). Files containing multiple modifications (e.g. 5mC and 5hmC) do not have to be split up: each modification gets its own track, and `--mods` can be used to restrict to some of them
11
12
 
12
13
  ## INSTALLATION
13
14
 
@@ -27,15 +27,17 @@ def open_gtf(gtff):
27
27
  return gzip.open(gtff, "rt") if gtff.endswith(".gz") else open(gtff)
28
28
 
29
29
 
30
- def good_record(line, chromosome):
30
+ def good_record(fields, chromosome):
31
31
  """
32
32
  Filtering on the gtf lines
33
33
  by checking for right chromosome and right feature type
34
+
35
+ Takes the first fields of a line, as splitting off the long attributes
36
+ field for every line of an annotation is a waste.
37
+ The chromosome has to match the entire field, as a prefix match would
38
+ e.g. also accept chr10 up to chr19 and chr1_KI270706v1_random for chr1
34
39
  """
35
- if line.startswith(str(chromosome)) and line.split("\t")[2] in ["exon", "gene"]:
36
- return True
37
- else:
38
- return False
40
+ return len(fields) > 2 and fields[0] == str(chromosome) and fields[2] in ["exon", "gene"]
39
41
 
40
42
 
41
43
  def get_features(gtfline, type="gtf"):
@@ -54,8 +56,10 @@ def parse_attributes(attributes, type="gtf"):
54
56
  """
55
57
  attribute_delimiter = {"gtf": "; ", "gff": ";"}
56
58
  kv_delimiter = {"gtf": " ", "gff": "="}
59
+ # the last attribute of a record keeps the trailing delimiter,
60
+ # which would otherwise end up in the name shown when hovering
57
61
  info = {
58
- i.split(kv_delimiter[type])[0]: i.split(kv_delimiter[type])[1].replace('"', "")
62
+ i.split(kv_delimiter[type])[0]: i.split(kv_delimiter[type])[1].replace('"', "").rstrip(";")
59
63
  for i in attributes.split(attribute_delimiter[type])
60
64
  if i.startswith(("gene_name", "transcript_id", "locus_tag"))
61
65
  }
@@ -67,17 +71,19 @@ def parse_attributes(attributes, type="gtf"):
67
71
 
68
72
  def transcripts_in_window(df, window, feature="transcript"):
69
73
  """
70
- Return the transcript names for which
71
- either the end or the begin of an exon is within the window
74
+ Return the transcript names of the exons overlapping the window
75
+
76
+ Testing for overlap rather than for a begin or end within the window,
77
+ as otherwise a feature spanning the entire window would be missed
72
78
  """
73
79
  return df.loc[
74
- df["begin"].between(window.begin, window.end) | df["end"].between(window.begin, window.end),
80
+ (df["begin"] <= window.end) & (df["end"] >= window.begin),
75
81
  feature,
76
82
  ].unique()
77
83
 
78
84
 
79
85
  def assign_colors_to_genes(transcripts):
80
- genes = set([t.gene for t in transcripts])
86
+ genes = dict.fromkeys([t.gene for t in transcripts])
81
87
  colordict = {g: c for g, c in zip(genes, plcolors * 100)}
82
88
  for t in transcripts:
83
89
  t.color = colordict[t.gene]
@@ -90,21 +96,42 @@ def parse_annotation(gtff, window, simplify=False):
90
96
  """
91
97
  type = annot_file_sniffer(gtff)
92
98
  logging.info(f"Parsing {type} file...")
99
+ chromosomes_seen = set()
100
+ records = []
101
+ for line in open_gtf(gtff):
102
+ if line.startswith("#") or not line.strip():
103
+ continue
104
+ # only the fields needed to decide, the attributes are split off
105
+ # further down for the few lines that are of interest
106
+ fields = line.split("\t", 3)
107
+ if good_record(fields, window.chromosome):
108
+ records.append(get_features(line, type=type))
109
+ else:
110
+ chromosomes_seen.add(fields[0])
111
+ if not records and chromosomes_seen:
112
+ sys.stderr.write(
113
+ f"\nWARNING: no exons or genes of chromosome '{window.chromosome}' "
114
+ f"in {gtff}.\n"
115
+ f"The annotation contains {len(chromosomes_seen)} chromosomes, "
116
+ f"such as {', '.join(sorted(chromosomes_seen)[:5])}.\n"
117
+ "Do the chromosome names in --window match those in your annotation "
118
+ "(e.g. '15' versus 'chr15')?\n"
119
+ )
93
120
  df = pd.DataFrame(
94
- data=[
95
- get_features(line, type=type)
96
- for line in open_gtf(gtff)
97
- if good_record(line, window.chromosome)
98
- ],
121
+ data=records,
99
122
  columns=["chromosome", "begin", "end", "strand", "gene", "transcript"],
100
123
  )
101
124
  logging.info("Loaded GTF file, processing...")
102
125
  if simplify:
103
126
  df.drop_duplicates(subset=["chromosome", "begin", "end", "gene"], inplace=True)
104
127
  res = []
105
- for g in transcripts_in_window(df, window, feature="gene"):
106
- gtable = df.loc[df["gene"] == g]
107
- if len(gtable):
128
+ genes = transcripts_in_window(df, window, feature="gene")
129
+ # grouping the features of interest once, rather than scanning the
130
+ # entire frame again for every gene
131
+ per_gene = dict(list(df[df["gene"].isin(genes)].groupby("gene")))
132
+ for g in genes:
133
+ gtable = per_gene.get(g)
134
+ if gtable is not None and len(gtable):
108
135
  res.append(
109
136
  Transcript(
110
137
  transcript=gtable["gene"].tolist()[0],
@@ -119,8 +146,15 @@ def parse_annotation(gtff, window, simplify=False):
119
146
  logging.info(f"Found {len(res)} gene(s) in the region.\n")
120
147
  else:
121
148
  res = []
122
- for t in transcripts_in_window(df, window, feature="transcript"):
123
- tr = df.loc[df["transcript"] == t]
149
+ transcripts = transcripts_in_window(df, window, feature="transcript")
150
+ # grouping the features of interest once, rather than scanning the
151
+ # entire frame again for every transcript
152
+ per_transcript = dict(list(df[df["transcript"].isin(transcripts)].groupby("transcript")))
153
+ for t in transcripts:
154
+ tr = per_transcript.get(t)
155
+ if tr is None or not len(tr):
156
+ # an annotation without transcript_id, e.g. some gff flavors
157
+ continue
124
158
  res.append(
125
159
  Transcript(
126
160
  transcript=t,
@@ -3,7 +3,7 @@ import pyranges as pr
3
3
  import numpy as np
4
4
  import sys
5
5
  import logging
6
- from methplotlib.utils import file_sniffer, flatten
6
+ from methplotlib.utils import file_sniffer, flatten, bedmethyl_separator
7
7
  from itertools import repeat
8
8
 
9
9
 
@@ -16,7 +16,7 @@ class Modification(object):
16
16
  self.start_end_table = start_end_table
17
17
 
18
18
 
19
- def get_data(methylation_files, names, window, smoothen=5):
19
+ def get_data(args, window):
20
20
  """
21
21
  Import methylation data from all files in the list methylation_files
22
22
 
@@ -31,10 +31,10 @@ def get_data(methylation_files, names, window, smoothen=5):
31
31
  data is extracted within the window args.window
32
32
  Frequencies are smoothened using a sliding window
33
33
  """
34
- return flatten([read_mods(f, n, window, smoothen) for f, n in zip(methylation_files, names)])
34
+ return flatten([read_mods(f, n, window, args) for f, n in zip(args.methylation, args.names)])
35
35
 
36
36
 
37
- def read_mods(filename, name, window, smoothen=5):
37
+ def read_mods(filename, name, window, args):
38
38
  """
39
39
  converts a file from nanopolish to a pandas dataframe
40
40
  input can be from calculate_methylation_frequency
@@ -48,17 +48,17 @@ def read_mods(filename, name, window, smoothen=5):
48
48
  logging.info(f"File {filename} is of type {file_type}")
49
49
  try:
50
50
  if file_type.startswith("nanopolish"):
51
- return parse_nanopolish(filename, file_type, name, window, smoothen=smoothen)
51
+ return parse_nanopolish(filename, file_type, name, window, smoothen=args.smooth)
52
52
  elif file_type == "nanocompore":
53
53
  return [parse_nanocompore(filename, name, window)]
54
54
  elif file_type in ["cram", "bam"]:
55
- return parse_cram(filename, file_type, name, window)
55
+ return parse_cram(filename, file_type, name, window, args.mods)
56
56
  elif file_type == "bedgraph":
57
- return [parse_bedgraph(filename, name, window)]
57
+ return parse_bedgraph(filename, name, window)
58
58
  elif file_type == "bedmethyl_extended":
59
- return [parse_bedmethyl(filename, name, window, smoothen=smoothen, flavor="modbam2bed")]
59
+ return parse_bedmethyl(filename, name, window, smoothen=args.smooth, flavor="modbam2bed", mods_of_interest=args.mods)
60
60
  elif file_type == "bedmethyl":
61
- return [parse_bedmethyl(filename, name, window, smoothen=smoothen, flavor="modkit")]
61
+ return parse_bedmethyl(filename, name, window, smoothen=args.smooth, flavor="modkit", mods_of_interest=args.mods)
62
62
  except Exception as e:
63
63
  logging.error(f"Error processing {filename}.")
64
64
  logging.error(e, exc_info=True)
@@ -98,7 +98,9 @@ def parse_nanopolish(filename, file_type, name, window, smoothen=5):
98
98
  sys.stderr.write("Please index with 'tabix -S1 -s1 -b3 -e4'.\n")
99
99
  else:
100
100
  sys.stderr.write("Please index with 'tabix -S1 -s1 -b2 -e3'.\n")
101
- iter_csv = pd.read_csv(filename, sep="\t", iterator=True, chunksize=1e6)
101
+ iter_csv = pd.read_csv(
102
+ filename, sep="\t", iterator=True, chunksize=int(1e6), dtype={"chromosome": str}
103
+ )
102
104
  table = pd.concat(
103
105
  [chunk[chunk["chromosome"] == window.chromosome] for chunk in iter_csv]
104
106
  )
@@ -166,6 +168,9 @@ def parse_nanopolish(filename, file_type, name, window, smoothen=5):
166
168
  called_sites = table.called_sites
167
169
  chromosome = table.Chromosome.values[0]
168
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
169
174
  table = table.drop(
170
175
  columns=[
171
176
  "Chromosome",
@@ -175,13 +180,14 @@ def parse_nanopolish(filename, file_type, name, window, smoothen=5):
175
180
  "called_sites",
176
181
  "called_sites_methylated",
177
182
  "group_sequence",
178
- ]
183
+ ],
184
+ errors="ignore",
179
185
  )
180
186
  return [
181
187
  Modification(
182
188
  table=table.sort_values("pos")
183
189
  .groupby("pos")
184
- .mean()
190
+ .mean(numeric_only=True)
185
191
  .rolling(window=smoothen, center=True)
186
192
  .mean()
187
193
  .assign(Chromosome=chromosome),
@@ -204,9 +210,13 @@ def parse_nanocompore(filename, name, window):
204
210
  table = pd.read_csv(filename, sep="\t", usecols=nanocompore_columns_of_interest)
205
211
  if window:
206
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
207
215
  return Modification(
208
- table=table.sort_values("pos")
209
- .append({"pos": window.end}, ignore_index=True)
216
+ table=pd.concat(
217
+ [table.sort_values("pos"), pd.DataFrame({"pos": [window.end]})],
218
+ ignore_index=True,
219
+ )
210
220
  .drop(columns="ref_id")
211
221
  .fillna(1.0),
212
222
  data_type="nanocompore",
@@ -215,7 +225,56 @@ def parse_nanocompore(filename, name, window):
215
225
  )
216
226
 
217
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
+
218
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]
219
278
  if window:
220
279
  from pathlib import Path
221
280
 
@@ -240,46 +299,45 @@ def parse_bedgraph(filename, name, window):
240
299
  tabix_stream.stdout,
241
300
  sep="\t",
242
301
  header=None,
243
- names=["Chromosome", "Start", "End", "Value"],
302
+ usecols=usecols,
303
+ names=colnames,
304
+ dtype={0: str},
244
305
  )
245
306
  else:
246
- logging.info(f"Reading {filename} slowly by splitting the file in chunks.")
247
- sys.stderr.write(
248
- f"\nReading {filename} would be faster with bgzip and 'tabix -p bed'.\n"
249
- )
250
- iter_csv = pd.read_csv(
307
+ table = read_in_chunks(
251
308
  filename,
309
+ window,
252
310
  sep="\t",
253
- iterator=True,
254
- chunksize=1e6,
255
- header=None,
256
- names=["Chromosome", "Start", "End", "Value"],
257
- )
258
- table = pd.concat(
259
- [chunk[chunk["Chromosome"] == window.chromosome] for chunk in iter_csv]
260
- )
311
+ usecols=usecols,
312
+ index_suggestion="tabix -p bed",
313
+ ).rename(columns=dict(zip(usecols, colnames)))
261
314
  else:
262
315
  table = pd.read_csv(
263
316
  filename,
264
317
  sep="\t",
265
318
  header=None,
266
- names=["Chromosome", "Start", "End", "Value"],
319
+ usecols=usecols,
320
+ names=colnames,
321
+ dtype={0: str},
267
322
  )
268
323
  gr = pr.PyRanges(table)
269
324
  logging.info("Read the file in a dataframe.")
270
325
  if window:
271
326
  gr = gr[window.chromosome, window.begin : window.end]
272
327
  if len(gr.df) == 0:
273
- sys.exit(f"No records for {filename} in {window.string}!\n")
274
- return Modification(
275
- table=gr.df.sort_values("Start"),
276
- data_type="bedgraph",
277
- name=name,
278
- called_sites=len(table),
279
- )
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
+ ]
280
338
 
281
339
 
282
- def parse_bedmethyl(filename, name, window, smoothen=5, flavor="modkit"):
340
+ def parse_bedmethyl(filename, name, window, smoothen=5, flavor="modkit", mods_of_interest=None):
283
341
  if not flavor in ["modkit", "modbam2bed"]:
284
342
  sys.exit(f"ERROR: flavor {flavor} not supported for bedmethyl.")
285
343
  if flavor == "modkit":
@@ -287,8 +345,7 @@ def parse_bedmethyl(filename, name, window, smoothen=5, flavor="modkit"):
287
345
  0: "Chromosome",
288
346
  1: "Start",
289
347
  2: "End",
290
- # if multiple types of modification should be supported additionally column 3 has to be read
291
- # 3: "Modification",
348
+ 3: "Modification",
292
349
  10: "Frequency",
293
350
  }
294
351
  else:
@@ -296,12 +353,13 @@ def parse_bedmethyl(filename, name, window, smoothen=5, flavor="modkit"):
296
353
  0: "Chromosome",
297
354
  1: "Start",
298
355
  2: "End",
299
- # if multiple types of modification should be supported additionally column 3 has to be read
300
- # 3: "Modification",
356
+ 3: "Modification",
301
357
  11: "canonical",
302
358
  12: "modified",
303
359
  }
304
- usecols = colnames.keys()
360
+ usecols = list(colnames.keys())
361
+ # modkit separates the columns after BED9 by spaces, unless run with --only-tabs
362
+ sep = bedmethyl_separator(filename)
305
363
  if window:
306
364
  from pathlib import Path
307
365
 
@@ -324,39 +382,34 @@ def parse_bedmethyl(filename, name, window, smoothen=5, flavor="modkit"):
324
382
  raise
325
383
  table = pd.read_csv(
326
384
  tabix_stream.stdout,
327
- sep="\t",
385
+ sep=sep,
328
386
  header=None,
329
387
  usecols=usecols,
388
+ dtype={0: str},
330
389
  ).rename(columns=colnames)
331
390
  else:
332
- logging.info(f"Reading {filename} slowly by splitting the file in chunks.")
333
- sys.stderr.write(
334
- f"\nReading {filename} would be faster with bgzip and 'tabix -p bed'.\n"
335
- )
336
- iter_csv = pd.read_csv(
391
+ table = read_in_chunks(
337
392
  filename,
338
- sep="\t",
339
- iterator=True,
340
- chunksize=1e6,
341
- header=None,
393
+ window,
394
+ sep=sep,
342
395
  usecols=usecols,
343
- )
344
- table = pd.concat([chunk[chunk[0] == window.chromosome] for chunk in iter_csv]).rename(
345
- columns=colnames
346
- )
396
+ index_suggestion="tabix -p bed",
397
+ ).rename(columns=colnames)
347
398
  else:
348
399
  table = pd.read_csv(
349
400
  filename,
350
- sep="\t",
401
+ sep=sep,
351
402
  header=None,
352
403
  usecols=usecols,
404
+ dtype={0: str},
353
405
  ).rename(columns=colnames)
354
406
  gr = pr.PyRanges(table)
355
407
  logging.info("Read the file in a dataframe.")
356
408
  if window:
357
409
  gr = gr[window.chromosome, window.begin : window.end]
358
410
  if len(gr.df) == 0:
359
- sys.exit(f"No records for {filename} in {window.string}!\n")
411
+ no_records_warning(filename, window)
412
+ return []
360
413
  table = gr.df.sort_values("Start")
361
414
  if flavor == "modkit":
362
415
  table["modified_frequency"] = table["Frequency"] / 100
@@ -366,15 +419,54 @@ def parse_bedmethyl(filename, name, window, smoothen=5, flavor="modkit"):
366
419
  table.drop(
367
420
  columns=["Chromosome", "End", "canonical", "modified"], inplace=True, errors="ignore"
368
421
  )
369
- return Modification(
370
- table=table.rolling(window=smoothen, center=True).mean(),
371
- data_type="bedmethyl_extended",
372
- name=name,
373
- called_sites=len(table),
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()
374
456
  )
457
+ return table
375
458
 
376
459
 
377
- def parse_cram(filename, filetype, name, window):
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
+ """
378
470
  import pysam
379
471
 
380
472
  mode = "rc" if filetype == "cram" else "rb"
@@ -393,6 +485,15 @@ def parse_cram(filename, filetype, name, window):
393
485
  .sort_values(["read_name", "pos"])
394
486
  )
395
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
+ )
396
497
  return [
397
498
  Modification(
398
499
  table=sub_df,
@@ -403,7 +504,7 @@ def parse_cram(filename, filetype, name, window):
403
504
  start_stops, columns=["read_name", "posmin", "posmax"]
404
505
  ).set_index("read_name"),
405
506
  )
406
- for mod, sub_df in df.groupby("mod")
507
+ for mod, sub_df in df.groupby("mod") if len(sub_df) > 0
407
508
  ]
408
509
 
409
510
 
@@ -415,7 +516,7 @@ def get_modified_reference_positions(read):
415
516
  offset = 0
416
517
  for context in read.get_tag(modtag).split(";"):
417
518
  if context:
418
- basemod = context.split(",", 1)[0]
519
+ basemod = context.split(",", 1)[0].replace('?', '')
419
520
  if "-" in basemod:
420
521
  sys.exit(
421
522
  "ERROR: modifications on negative strand currently unsupported.\n"
@@ -439,9 +540,10 @@ def get_modified_reference_positions(read):
439
540
  # Nake an array with all read positions for which that nucleotide exists in the read
440
541
  # Oddly, get_forward_sequence() returns the sequence as it came from the sequencer
441
542
  # This is the same direction as the Mm/Ml tags are specified in
442
- base_index = np.array(
443
- [i for i, letter in enumerate(read.get_forward_sequence()) if letter == base]
444
- )
543
+ # the positions of that nucleotide in the read, found with numpy
544
+ # as a python loop over every base of a long read is expensive
545
+ sequence = np.frombuffer(read.get_forward_sequence().encode(), dtype="S1")
546
+ base_index = np.flatnonzero(sequence == base.encode())
445
547
  # Based on both arrays, find those read coordinates that have a modification
446
548
  modified_bases = base_index[locations]
447
549
  # Convert the array of read coordinates to reference coordinates