Skip to content

Commit 2ec422f

Browse files
authored
feat: fingerprint --extendReads uses fragsize from respective bamfile (order independence) (#1483)
1 parent 1143e6d commit 2ec422f

5 files changed

Lines changed: 559 additions & 52 deletions

File tree

‎pydeeptools/deeptools/countReadsPerBin.py‎

Lines changed: 95 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import contextlib
2+
import functools
23
import multiprocessing
34
import os
45
import shutil
@@ -198,35 +199,17 @@ def __init__(self, bamFilesList, binLength=50, numberOfSamples=None, numberOfPro
198199
self.bed_and_bin = bed_and_bin
199200
self.genomeChunkSize = genomeChunkSize
200201

201-
if extendReads and len(bamFilesList):
202-
from deeptools.getFragmentAndReadSize import get_read_and_fragment_length
203-
frag_len_dict, read_len_dict = get_read_and_fragment_length(bamFilesList[0],
204-
return_lengths=False,
205-
blackListFileName=blackListFileName,
206-
numberOfProcessors=numberOfProcessors,
207-
verbose=verbose)
208-
if extendReads is True:
209-
# try to guess fragment length if the bam file contains paired end reads
210-
if frag_len_dict:
211-
self.defaultFragmentLength = int(frag_len_dict['median'])
212-
else:
213-
sys.exit("*ERROR*: library is not paired-end. Please provide an extension length.")
214-
if verbose:
215-
print("Fragment length based on paired en data "
216-
"estimated to be {}".format(frag_len_dict['median']))
217-
218-
elif extendReads < read_len_dict['median']:
219-
sys.stderr.write("*WARNING*: read extension is smaller than read length (read length = {}). "
220-
"Reads will not be extended.\n".format(int(read_len_dict['median'])))
221-
self.defaultFragmentLength = 'read length'
222-
223-
elif extendReads > 2000:
224-
sys.exit(f"*ERROR*: read extension must be smaller that 2000. Value give: {extendReads} ")
225-
else:
226-
self.defaultFragmentLength = int(extendReads)
227-
202+
if extendReads:
203+
self.defaultFragmentLengths = [self.get_default_fragment_length(fname, extendReads,
204+
blackListFileName=blackListFileName,
205+
numberOfProcessors=numberOfProcessors,
206+
verbose=verbose)
207+
for fname in bamFilesList]
228208
else:
229-
self.defaultFragmentLength = 'read length'
209+
self.defaultFragmentLengths = ['read length'] * len(bamFilesList)
210+
211+
# direct call fallback
212+
self.defaultFragmentLength = self.defaultFragmentLengths[0] if self.defaultFragmentLengths else 'read length'
230213

231214
self.numberOfProcessors = numberOfProcessors
232215
self.verbose = verbose
@@ -255,12 +238,8 @@ def __init__(self, bamFilesList, binLength=50, numberOfSamples=None, numberOfPro
255238
if numberOfSamples is None and stepSize is None and bedFile is None:
256239
raise ValueError("either stepSize, numberOfSamples or bedFile have to be set")
257240

258-
if self.defaultFragmentLength != 'read length':
259-
self.maxPairedFragmentLength = 4 * self.defaultFragmentLength
260-
else:
261-
self.maxPairedFragmentLength = 1000
262-
if self.maxFragmentLength > 0:
263-
self.maxPairedFragmentLength = self.maxFragmentLength
241+
self.maxPairedFragmentLengths = [self.get_max_paired_fragment_length(x) for x in self.defaultFragmentLengths]
242+
self.maxPairedFragmentLength = self.get_max_paired_fragment_length(self.defaultFragmentLength)
264243

265244
if len(self.mappedList) == 0:
266245
try:
@@ -273,6 +252,50 @@ def __init__(self, bamFilesList, binLength=50, numberOfSamples=None, numberOfPro
273252
self.mappedList = []
274253
self.statsList = []
275254

255+
@staticmethod
256+
def get_default_fragment_length(bamFile, extendReads, blackListFileName=None,
257+
numberOfProcessors=1, verbose=False):
258+
"""
259+
Returns the length to which reads of the given file are extended,
260+
or 'read length' if reads are not to be extended.
261+
If extendReads is True, the median fragment length of the file is used.
262+
"""
263+
from deeptools.getFragmentAndReadSize import get_read_and_fragment_length
264+
frag_len_dict, read_len_dict = get_read_and_fragment_length(bamFile,
265+
return_lengths=False,
266+
blackListFileName=blackListFileName,
267+
numberOfProcessors=numberOfProcessors,
268+
verbose=verbose)
269+
if extendReads is True:
270+
# try to guess fragment length if the bam file contains paired end reads
271+
if not frag_len_dict:
272+
sys.exit(f"*ERROR*: library {bamFile} is not paired-end. Please provide an extension length.")
273+
if verbose:
274+
print(f"Fragment length of {bamFile} based on paired en data "
275+
f"estimated to be {frag_len_dict['median']}")
276+
return int(frag_len_dict['median'])
277+
278+
if extendReads < read_len_dict['median']:
279+
sys.stderr.write(f"*WARNING*: read extension is smaller than read length (read length = {int(read_len_dict['median'])}) "
280+
f"for {bamFile}. Reads will not be extended.\n")
281+
return 'read length'
282+
283+
if extendReads > 2000:
284+
sys.exit(f"*ERROR*: read extension must be smaller that 2000. Value give: {extendReads} ")
285+
286+
return int(extendReads)
287+
288+
def get_max_paired_fragment_length(self, defaultFragmentLength):
289+
"""
290+
Returns the maximum fragment length for which read pairs are
291+
considered proper pairs, given the default fragment length of a file.
292+
"""
293+
if self.maxFragmentLength > 0:
294+
return self.maxFragmentLength
295+
if defaultFragmentLength != 'read length':
296+
return 4 * defaultFragmentLength
297+
return 1000
298+
276299
def get_chunk_length(self, bamFilesHandles, genomeSize, chromSizes, chrLengths):
277300
# Try to determine an optimal fraction of the genome (chunkSize) that is sent to
278301
# workers for analysis. If too short, too much time is spent loading the files
@@ -499,9 +522,11 @@ def count_reads_in_region(self, chrom, start, end, bed_regions_list=None):
499522
else:
500523
_file_name = ''
501524

502-
for bam in bam_handles:
525+
for bam_idx, bam in enumerate(bam_handles):
503526
for trans in transcriptsToConsider:
504-
tcov = self.get_coverage_of_region(bam, chrom, trans)
527+
tcov = self.get_coverage_of_region(bam, chrom, trans,
528+
defaultFragmentLength=self.defaultFragmentLengths[bam_idx],
529+
maxPairedFragmentLength=self.maxPairedFragmentLengths[bam_idx])
505530
if bed_regions_list is not None and not self.bed_and_bin:
506531
subnum_reads_per_bin.append(np.sum(tcov))
507532
else:
@@ -537,11 +562,17 @@ def count_reads_in_region(self, chrom, start, end, bed_regions_list=None):
537562
return subnum_reads_per_bin, _file_name
538563

539564
def get_coverage_of_region(self, bamHandle, chrom, regions,
540-
fragmentFromRead_func=None):
565+
fragmentFromRead_func=None,
566+
defaultFragmentLength=None,
567+
maxPairedFragmentLength=None):
541568
"""
542569
Returns a numpy array that corresponds to the number of reads
543570
that overlap with each tile.
544571
572+
defaultFragmentLength and maxPairedFragmentLength are the values for
573+
the file of bamHandle. If not given, self.defaultFragmentLength and
574+
self.maxPairedFragmentLength are used.
575+
545576
>>> test = Tester()
546577
>>> import pysam
547578
>>> c = CountReadsPerBin([], stepSize=1, extendReads=300)
@@ -572,8 +603,14 @@ def get_coverage_of_region(self, bamHandle, chrom, regions,
572603
573604
574605
"""
606+
if defaultFragmentLength is None:
607+
defaultFragmentLength = self.defaultFragmentLength
608+
if maxPairedFragmentLength is None:
609+
maxPairedFragmentLength = self.maxPairedFragmentLength
575610
if not fragmentFromRead_func:
576-
fragmentFromRead_func = self.get_fragment_from_read
611+
fragmentFromRead_func = functools.partial(self.get_fragment_from_read,
612+
defaultFragmentLength=defaultFragmentLength,
613+
maxPairedFragmentLength=maxPairedFragmentLength)
577614
nbins = len(regions)
578615
if len(regions[0]) == 3:
579616
nbins = 0
@@ -583,10 +620,10 @@ def get_coverage_of_region(self, bamHandle, chrom, regions,
583620
nbins += 1
584621
coverages = np.zeros(nbins, dtype='float64')
585622

586-
if self.defaultFragmentLength == 'read length':
623+
if defaultFragmentLength == 'read length':
587624
extension = 0
588625
else:
589-
extension = self.maxPairedFragmentLength
626+
extension = maxPairedFragmentLength
590627

591628
blackList = None
592629
if self.blackListFileName is not None:
@@ -779,7 +816,7 @@ def is_proper_pair(read, maxPairedFragmentLength):
779816
return True
780817
return False
781818

782-
def get_fragment_from_read(self, read):
819+
def get_fragment_from_read(self, read, defaultFragmentLength=None, maxPairedFragmentLength=None):
783820
"""Get read start and end position of a read.
784821
If given, the reads are extended as follows:
785822
If reads are paired end, each read mate is extended to match
@@ -823,6 +860,14 @@ def get_fragment_from_read(self, read):
823860
----------
824861
read : pysam read object
825862
863+
defaultFragmentLength : int or 'read length'
864+
The fragment length used to extend reads of the file of `read`.
865+
If None, self.defaultFragmentLength is used.
866+
867+
maxPairedFragmentLength : int
868+
Pairs with a larger fragment length are extended as single reads.
869+
If None, self.maxPairedFragmentLength is used.
870+
826871
827872
Returns
828873
-------
@@ -865,11 +910,16 @@ def get_fragment_from_read(self, read):
865910
# E.g for a cigar of 40M260N22M
866911
# get blocks return two elements for the first 40 matches
867912
# and the for the last 22 matches.
868-
if self.defaultFragmentLength == 'read length':
913+
if defaultFragmentLength is None:
914+
defaultFragmentLength = self.defaultFragmentLength
915+
if maxPairedFragmentLength is None:
916+
maxPairedFragmentLength = self.maxPairedFragmentLength
917+
918+
if defaultFragmentLength == 'read length':
869919
return read.get_blocks()
870920

871921
else:
872-
if self.is_proper_pair(read, self.maxPairedFragmentLength):
922+
if self.is_proper_pair(read, maxPairedFragmentLength):
873923
if read.is_reverse:
874924
fragmentStart = read.next_reference_start
875925
fragmentEnd = read.reference_end
@@ -882,11 +932,11 @@ def get_fragment_from_read(self, read):
882932
# Extend using the default fragment length
883933
else:
884934
if read.is_reverse:
885-
fragmentStart = read.reference_end - self.defaultFragmentLength
935+
fragmentStart = read.reference_end - defaultFragmentLength
886936
fragmentEnd = read.reference_end
887937
else:
888938
fragmentStart = read.reference_start
889-
fragmentEnd = read.reference_start + self.defaultFragmentLength
939+
fragmentEnd = read.reference_start + defaultFragmentLength
890940

891941
if self.center_read:
892942
fragmentCenter = fragmentEnd - (fragmentEnd - fragmentStart) / 2

‎pydeeptools/deeptools/sumCoveragePerBin.py‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import functools
12
import multiprocessing
23
import time
34

@@ -13,11 +14,17 @@ class SumCoveragePerBin(countReadsPerBin.CountReadsPerBin):
1314
There, we need to sum the per-base coverage.
1415
"""
1516
def get_coverage_of_region(self, bamHandle, chrom, regions,
16-
fragmentFromRead_func=None):
17+
fragmentFromRead_func=None,
18+
defaultFragmentLength=None,
19+
maxPairedFragmentLength=None):
1720
"""
1821
Returns a numpy array that corresponds to the number of reads
1922
that overlap with each tile.
2023
24+
defaultFragmentLength and maxPairedFragmentLength are the values for
25+
the file of bamHandle. If not given, self.defaultFragmentLength and
26+
self.maxPairedFragmentLength are used.
27+
2128
>>> test = Tester()
2229
>>> import pysam
2330
>>> c = SumCoveragePerBin([], stepSize=1, extendReads=300)
@@ -38,19 +45,25 @@ def get_coverage_of_region(self, bamHandle, chrom, regions,
3845
3946
4047
"""
48+
if defaultFragmentLength is None:
49+
defaultFragmentLength = self.defaultFragmentLength
50+
if maxPairedFragmentLength is None:
51+
maxPairedFragmentLength = self.maxPairedFragmentLength
4152
if not fragmentFromRead_func:
42-
fragmentFromRead_func = self.get_fragment_from_read
53+
fragmentFromRead_func = functools.partial(self.get_fragment_from_read,
54+
defaultFragmentLength=defaultFragmentLength,
55+
maxPairedFragmentLength=maxPairedFragmentLength)
4356
nbins = len(regions)
4457
if len(regions[0]) == 3:
4558
nbins = 0
4659
for reg in regions:
4760
nbins += (reg[1] - reg[0]) // reg[2]
4861
coverages = np.zeros(nbins, dtype='float64')
4962

50-
if self.defaultFragmentLength == 'read length':
63+
if defaultFragmentLength == 'read length':
5164
extension = 0
5265
else:
53-
extension = self.maxPairedFragmentLength
66+
extension = maxPairedFragmentLength
5467

5568
blackList = None
5669
if self.blackListFileName is not None:

‎pydeeptools/deeptools/test/test_plotFingerprint.py‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import os
22
from tempfile import NamedTemporaryFile
33

4+
import numpy as np
45
from matplotlib.testing.compare import compare_images
56

67
import deeptools.plotFingerprint
78

89
TEST_DATA = os.path.dirname(os.path.abspath(__file__)) + "/test_data/"
910
ROOT = os.path.dirname(os.path.abspath(__file__)) + "/test_plotFingerprint/"
10-
1111
tolerance = 13
1212

1313

@@ -135,4 +135,46 @@ def test_plotFingerprint_quality_metrics_and_JSD():
135135
assert abs(float(rows["test2"][jsd]) - 0.078613413909822) < 1e-4
136136

137137
finally:
138-
cleanup(plotfile.name, qcfile.name)
138+
cleanup(plotfile.name, qcfile.name)
139+
140+
141+
def run_plotFingerprint_extendReads(bamfiles, labels, rawfile, qcfile):
142+
args = (
143+
["-b"] + [TEST_DATA + x for x in bamfiles]
144+
+ ["-l"] + labels
145+
+ ["--extendReads",
146+
"--region", "chr2:4999000:5003000", "--binSize", "10", "--numberOfSamples", "400", "-p", "1",
147+
"--outRawCounts", rawfile, "--outQualityMetrics", qcfile]
148+
)
149+
deeptools.plotFingerprint.main(args)
150+
151+
counts = np.loadtxt(rawfile, skiprows=2, ndmin=2)
152+
raw = {label: counts[:, idx] for idx, label in enumerate(labels)}
153+
with open(qcfile) as _foo:
154+
lines = [line.rstrip("\n").split("\t") for line in _foo]
155+
qc = {row[0]: np.array(row[1:], dtype=float) for row in lines[1:]}
156+
157+
return raw, qc
158+
159+
160+
def test_plotFingerprint_extendReads_single_bam(tmp_path):
161+
raw, qc = run_plotFingerprint_extendReads(["test_paired2.bam"], ["test_paired2"],
162+
str(tmp_path / "raw.txt"), str(tmp_path / "qc.txt"))
163+
expected_counts = np.loadtxt(ROOT + "test_plotFingerprint_extendReads_raw.txt", skiprows=2)
164+
np.testing.assert_array_equal(raw["test_paired2"], expected_counts)
165+
expected_qc = [0.10334787097692963, 0.4761528725688839, 0.595, 2.0396151481800195e-14,
166+
0.745, 0.5373923764988261, 0.324362640621008]
167+
np.testing.assert_allclose(qc["test_paired2"], expected_qc, rtol=1e-6, atol=1e-12)
168+
169+
170+
def test_plotFingerprint_extendReads_order_independent(tmp_path):
171+
bamfiles = ["test_paired.bam", "test_paired2.bam", "test_paired2.cram"]
172+
labels = ["paired", "paired2", "paired2_cram"]
173+
raw1, qc1 = run_plotFingerprint_extendReads(bamfiles, labels,
174+
str(tmp_path / "raw1.txt"), str(tmp_path / "qc1.txt"))
175+
raw2, qc2 = run_plotFingerprint_extendReads(bamfiles[::-1], labels[::-1],
176+
str(tmp_path / "raw2.txt"), str(tmp_path / "qc2.txt"))
177+
178+
for label in labels:
179+
np.testing.assert_array_equal(raw1[label], raw2[label])
180+
np.testing.assert_array_equal(qc1[label], qc2[label])

0 commit comments

Comments
 (0)