mhcflow 0.2.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.
mhcflow/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from mhcflow.mhcflow import run_mhcflow
2
+
3
+ from ._version import version as __version__
4
+
5
+ __all__ = ["run_mhcflow", "__version__"]
mhcflow/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ import mhcflow
4
+
5
+ sys.exit(mhcflow.run_mhcflow())
mhcflow/_version.py ADDED
@@ -0,0 +1,21 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
6
+ TYPE_CHECKING = False
7
+ if TYPE_CHECKING:
8
+ from typing import Tuple
9
+ from typing import Union
10
+
11
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
12
+ else:
13
+ VERSION_TUPLE = object
14
+
15
+ version: str
16
+ __version__: str
17
+ __version_tuple__: VERSION_TUPLE
18
+ version_tuple: VERSION_TUPLE
19
+
20
+ __version__ = version = '0.2.0'
21
+ __version_tuple__ = version_tuple = (0, 2, 0)
mhcflow/cli.py ADDED
@@ -0,0 +1,75 @@
1
+ import argparse
2
+
3
+ from tinyscibio import parse_path
4
+
5
+
6
+ def parse_cmd() -> argparse.ArgumentParser:
7
+ parser = argparse.ArgumentParser()
8
+ parser.add_argument(
9
+ "--bam",
10
+ metavar="FILE",
11
+ type=parse_path,
12
+ required=True,
13
+ help="specify path to BAM file",
14
+ )
15
+ parser.add_argument(
16
+ "--ref",
17
+ metavar="FILE",
18
+ type=parse_path,
19
+ required=True,
20
+ help="specify path to HLA reference in Fasta format.",
21
+ )
22
+ parser.add_argument(
23
+ "--tag",
24
+ metavar="FILE",
25
+ type=parse_path,
26
+ required=True,
27
+ help="specify path to HLA tag sequence file.",
28
+ )
29
+ parser.add_argument(
30
+ "--bed",
31
+ metavar="FILE",
32
+ type=parse_path,
33
+ required=True,
34
+ help="specify path to HLA region in BED format.",
35
+ )
36
+ parser.add_argument(
37
+ "--freq",
38
+ metavar="FILE",
39
+ type=parse_path,
40
+ required=True,
41
+ help="specify path to HLA frequency file.",
42
+ )
43
+ parser.add_argument(
44
+ "--outdir",
45
+ metavar="DIR",
46
+ type=parse_path,
47
+ required=True,
48
+ help="specify path to output folder.",
49
+ )
50
+ parser.add_argument(
51
+ "--min-ecnt",
52
+ metavar="INT",
53
+ type=int,
54
+ default=999,
55
+ help="specify minimum # of mm events (999).",
56
+ )
57
+ parser.add_argument(
58
+ "--nproc",
59
+ metavar="INT",
60
+ type=int,
61
+ default=8,
62
+ help="specify # processes to use (8).",
63
+ )
64
+ parser.add_argument(
65
+ "--overwrite", action="store_true", help="specify to overwrite scores."
66
+ )
67
+ parser.add_argument(
68
+ "--no-clean",
69
+ action="store_true",
70
+ help="specify to NOT clean intermediate files.",
71
+ )
72
+ parser.add_argument(
73
+ "--debug", action="store_true", help="specify to enter debug mode."
74
+ )
75
+ return parser
mhcflow/dumper.py ADDED
@@ -0,0 +1,83 @@
1
+ import multiprocessing as mp
2
+ from functools import partial
3
+
4
+ import numpy as np
5
+ import polars as pl
6
+ from tinyscibio import BAMetadata, _PathLike, make_dir, parse_path
7
+
8
+ from .helper import (
9
+ FileManifest,
10
+ _check_rg_exists,
11
+ _check_single_rg,
12
+ _get_sm,
13
+ _verify_prev_run,
14
+ )
15
+ from .logger import logger
16
+ from .runnable import _samtools_fastq
17
+
18
+
19
+ def _bam2fq_from_idx(
20
+ idx_fspath: _PathLike,
21
+ bametadata: BAMetadata,
22
+ outdir: _PathLike,
23
+ nproc: int = 1,
24
+ overwrite: bool = False,
25
+ ) -> FileManifest:
26
+ logger.info(f"Start to dump reads to fastq given ids: {idx_fspath}")
27
+ # set up output dir
28
+ outdir = parse_path(outdir)
29
+ make_dir(outdir, parents=True, exist_ok=True)
30
+
31
+ # get sm field from rg
32
+ _check_rg_exists(bametadata)
33
+ _check_single_rg(bametadata)
34
+ rg = bametadata.read_groups[0]
35
+ sm = _get_sm(rg)
36
+
37
+ fm_json = outdir / f"{sm}.dumper.file_manifest.json"
38
+ if fm_json.exists():
39
+ logger.info(
40
+ f"Detected file manifest from previous dumper run: {str(fm_json)}"
41
+ )
42
+ fm = FileManifest._from_json(fm_json)
43
+ if _verify_prev_run(fm, overwrite):
44
+ return fm
45
+
46
+ fm = FileManifest()
47
+ logdir = outdir / "log"
48
+ make_dir(logdir, parents=True, exist_ok=True)
49
+ dumper_done = logdir / f"{sm}.dumper.done"
50
+ idx_df = pl.read_csv(idx_fspath, separator="\t")
51
+ if "qnames" not in idx_df.columns:
52
+ raise pl.exceptions.ColumnNotFoundError()
53
+
54
+ # split read ids into batches and output each batch to file
55
+ # each idx file will be input to samtools view -N
56
+ qnames = idx_df["qnames"].unique().to_numpy()
57
+ qnames_batches = np.array_split(qnames, nproc)
58
+ idxs = []
59
+ for i in range(len(qnames_batches)):
60
+ qname_batch_fspath = outdir / f"{sm}.fisher.idxs.{i}.txt"
61
+ pl.DataFrame({"qnames": qnames_batches[i]}).write_csv(
62
+ qname_batch_fspath, include_header=False
63
+ )
64
+ idxs.append(qname_batch_fspath)
65
+ # run samtools fastq to extract read and dump to fq
66
+ r1s, r2s = [], []
67
+ dones, logs = [], []
68
+ with mp.Pool(processes=nproc) as pool:
69
+ for res in pool.imap_unordered(
70
+ partial(_samtools_fastq, bam_fspath=bametadata.fspath), idxs
71
+ ):
72
+ r1, r2, done, log = res
73
+ r1s.append(r1)
74
+ r2s.append(r2)
75
+ dones.append(done)
76
+ logs.append(log)
77
+ dumper_done.touch()
78
+ fm._register_inputs(idx=idx_fspath, bam=bametadata.fspath)
79
+ fm._register_outputs(r1s=r1s, r2s=r2s, idxs=idxs)
80
+ fm._register_aux(done=dumper_done)
81
+ fm._register_intermediate_aux(dones=dones, logs=logs)
82
+ fm._to_json(fm_json)
83
+ return fm
mhcflow/finalizer.py ADDED
@@ -0,0 +1,100 @@
1
+ from pathlib import Path
2
+
3
+ import polars as pl
4
+ from pyfaidx import Faidx
5
+ from tinyscibio import BAMetadata, _PathLike, make_dir, parse_path
6
+
7
+ from .helper import (
8
+ FileManifest,
9
+ _check_rg_exists,
10
+ _check_single_rg,
11
+ _get_sm,
12
+ _verify_prev_run,
13
+ )
14
+ from .logger import logger
15
+ from .realigner import _run_realigner
16
+ from .runnable import _novoindex
17
+
18
+
19
+ def _dump_seq(allele: str, fa: Faidx, out: Path) -> None:
20
+ with open(out, "a") as f:
21
+ sequence = fa.fetch(allele, 1, fa.index[allele].rlen)
22
+ f.write(f">{allele}\n{str(sequence)}\n")
23
+
24
+
25
+ def _run_finalizer(
26
+ bam_fspath: _PathLike,
27
+ ref: _PathLike,
28
+ fisher_fm_json: _PathLike,
29
+ typer_res_fspath: _PathLike,
30
+ outdir: _PathLike,
31
+ nproc: int = 1,
32
+ overwrite: bool = False,
33
+ ) -> FileManifest:
34
+ logger.info("Finalize sample HLA reference and realignemnt.")
35
+ bametadata = BAMetadata(str(bam_fspath))
36
+ _check_rg_exists(bametadata)
37
+ _check_single_rg(bametadata)
38
+ rg = bametadata.read_groups[0]
39
+ sm = _get_sm(rg)
40
+
41
+ outdir = parse_path(outdir)
42
+ make_dir(outdir, parents=True, exist_ok=True)
43
+
44
+ fm_json = outdir / f"{sm}.finalizer.file_manifest.json"
45
+ if fm_json.exists():
46
+ logger.info(
47
+ f"Detected file manifest from previous run: {str(fm_json)}"
48
+ )
49
+ finalizer_fm = FileManifest._from_json(fm_json)
50
+ if _verify_prev_run(finalizer_fm, overwrite):
51
+ return finalizer_fm
52
+
53
+ finalizer_fm = FileManifest()
54
+ logdir = outdir / "log"
55
+ make_dir(logdir, parents=True, exist_ok=True)
56
+ finalizer_done = logdir / f"{sm}.finalizer.done"
57
+
58
+ ref = parse_path(ref)
59
+ fai = ref.parent / parse_path(f"{ref.name}.fai")
60
+ if not fai.exists():
61
+ raise FileNotFoundError
62
+
63
+ typer_res = pl.read_csv(typer_res_fspath, separator="\t")
64
+ # 3 locus * 2 alleles = 6
65
+ if typer_res.shape[0] != 6:
66
+ raise ValueError
67
+
68
+ fa_out = outdir / f"{sm}.hla.fasta"
69
+ fa = Faidx(ref)
70
+ # do not forget to take unique for homozygous genotype of HLA gene.
71
+ # I dont want duplicated sequences in the fasta.
72
+ _ = list(
73
+ map(
74
+ lambda x: _dump_seq(x, fa, fa_out),
75
+ typer_res["allele"].unique().to_list(),
76
+ )
77
+ )
78
+ nix, index_log, index_done = _novoindex(fa_out)
79
+
80
+ realigner_fm = _run_realigner(
81
+ bam_fspath, nix, fisher_fm_json, outdir, nproc, overwrite
82
+ )
83
+ finalizer_done.touch()
84
+
85
+ # record file manifest for finalizer
86
+ finalizer_fm._register_inputs(hlaref=str(ref), typer_res=typer_res_fspath)
87
+ finalizer_fm._register_aux(done=finalizer_done, myself=fm_json)
88
+ finalizer_fm._register_outputs(
89
+ sample_hlaref=str(fa_out),
90
+ **realigner_fm.outputs,
91
+ )
92
+ finalizer_fm._register_intermediate(**realigner_fm.intermediates)
93
+ finalizer_fm._register_intermediate_aux(
94
+ index_log=index_log,
95
+ index_done=index_done,
96
+ **realigner_fm.intermediate_aux,
97
+ )
98
+ finalizer_fm._to_json(fm_json)
99
+
100
+ return finalizer_fm
mhcflow/fisher.py ADDED
@@ -0,0 +1,287 @@
1
+ import multiprocessing as mp
2
+ import time
3
+ from functools import partial
4
+
5
+ import numpy as np
6
+ import polars as pl
7
+ import pysam
8
+ from tinyscibio import (
9
+ BAMetadata,
10
+ _PathLike,
11
+ bed_to_df,
12
+ make_dir,
13
+ parse_path,
14
+ walk_bam,
15
+ )
16
+
17
+ from .dumper import _bam2fq_from_idx
18
+ from .helper import (
19
+ FileManifest,
20
+ _check_rg_exists,
21
+ _check_single_rg,
22
+ _get_sm,
23
+ _verify_prev_run,
24
+ )
25
+ from .logger import logger
26
+ from .tag_builder import PREBUILD_TYPE, build
27
+
28
+ CHR6 = ["6", "chr6", "NC00006", "CM000668"]
29
+
30
+
31
+ def _fish_unplaced(
32
+ bam_fspath: _PathLike, prebuilt_tag: PREBUILD_TYPE, out: _PathLike
33
+ ) -> tuple[pl.DataFrame, _PathLike]:
34
+ logger.info("Fish unplaced sequence with tag pattern")
35
+ out = parse_path(out)
36
+ logdir = out.parent / "log"
37
+ make_dir(logdir, parents=True, exist_ok=True)
38
+ done = logdir / f"{out.stem}.done"
39
+ if done.exists():
40
+ logger.info(
41
+ "Found result of fished sequences with tag pattern "
42
+ f"from previous run: {done}. Skip."
43
+ )
44
+ df = pl.read_csv(out, separator="\t")
45
+ return (df, done)
46
+ qnames = []
47
+ start_t = time.time()
48
+ with pysam.AlignmentFile(str(bam_fspath), "rb") as bamf:
49
+ for aln in bamf.fetch(until_eof=True):
50
+ if not aln.is_unmapped:
51
+ continue
52
+ assert aln.query_sequence is not None
53
+ match = list(prebuilt_tag.iter(aln.query_sequence))
54
+ if match:
55
+ qnames += [aln.query_name]
56
+ logger.info(
57
+ f"Fish unplaced sequence with tag pattern: {time.time() - start_t} sec"
58
+ )
59
+ logger.info(f"Fished {len(qnames)} unplaced sequence with tag pattern")
60
+ merged_qnames = (
61
+ pl.DataFrame({"qnames": qnames}) if qnames else pl.DataFrame()
62
+ )
63
+ merged_qnames.write_csv(out, separator="\t")
64
+ done.touch()
65
+ return merged_qnames, done
66
+
67
+
68
+ def _fish_one_hla(region: str, bam_fspath: str) -> pl.DataFrame:
69
+ return walk_bam(bam_fspath, region, exclude=0, return_qname=True)
70
+
71
+
72
+ def _fish_multi_hla(
73
+ bed_fsapth: _PathLike,
74
+ bam_fspath: _PathLike,
75
+ out: _PathLike,
76
+ nproc: int = 4,
77
+ ) -> tuple[pl.DataFrame, _PathLike]:
78
+ logger.info(f"Fish sequence mapped to regions defined in {bed_fsapth}.")
79
+ out = parse_path(out)
80
+ logdir = out.parent / "log"
81
+ make_dir(logdir, parents=True, exist_ok=True)
82
+ done = logdir / f"{out.stem}.done"
83
+ if done.exists():
84
+ logger.info(
85
+ "Found result of fished sequences with tag pattern "
86
+ f"from previous run: {done}. Skip."
87
+ )
88
+ df = pl.read_csv(out, separator="\t")
89
+ return (df, done)
90
+ df = bed_to_df(bed_fsapth)
91
+ regions = [f"{row[0]}:{row[1]}-{row[2]}" for row in df.rows()]
92
+ nproc = min(len(regions), nproc) # nproc set to minimum of these 2 values
93
+ qnames: list[pl.DataFrame] = []
94
+ start_t = time.time()
95
+ with mp.get_context("spawn").Pool(processes=nproc) as pool:
96
+ for res in pool.imap_unordered(
97
+ partial(_fish_one_hla, bam_fspath=str(bam_fspath)),
98
+ regions,
99
+ ):
100
+ if res is not None:
101
+ qnames += [res.select("qnames")]
102
+ logger.info(
103
+ "Fished sequence mapped to regions defined in BED file: "
104
+ f"{time.time() - start_t} sec."
105
+ )
106
+ logger.info(
107
+ f"Fished {len(qnames)} mapped to HLA regions defined in BED file"
108
+ )
109
+ merged_qnames = pl.concat(qnames) if qnames else pl.DataFrame()
110
+ merged_qnames.write_csv(out, separator="\t")
111
+ done.touch()
112
+ return (merged_qnames, done)
113
+
114
+
115
+ def _fish_one_region(
116
+ region: tuple[str, int, int],
117
+ bam_fspath: _PathLike,
118
+ prebuilt_tag: PREBUILD_TYPE,
119
+ ) -> pl.DataFrame | None:
120
+ qnames = []
121
+ sn, start, end = region
122
+ with pysam.AlignmentFile(str(bam_fspath), "rb") as bamf:
123
+ for aln in bamf.fetch(contig=sn, start=start, stop=end):
124
+ assert aln.query_sequence is not None
125
+ match = list(prebuilt_tag.iter(aln.query_sequence))
126
+ if match:
127
+ qnames += [aln.query_name]
128
+ return pl.DataFrame({"qnames": qnames}) if qnames else None
129
+
130
+
131
+ def _fish_multi_regions(
132
+ split_regions: list[tuple[str, int, int]],
133
+ bam_fspath: _PathLike,
134
+ prebuilt_tag: PREBUILD_TYPE,
135
+ out: _PathLike,
136
+ nproc: int = 4,
137
+ ) -> tuple[pl.DataFrame, _PathLike]:
138
+ logger.info("Fish sequence with tag pattern.")
139
+ out = parse_path(out)
140
+ logdir = out.parent / "log"
141
+ make_dir(logdir, parents=True, exist_ok=True)
142
+ done = logdir / f"{out.stem}.done"
143
+ if done.exists():
144
+ logger.info(
145
+ "Found result of fished sequences with tag pattern "
146
+ f"from previous run: {done}. Skip."
147
+ )
148
+ df = pl.read_csv(out, separator="\t")
149
+ return (df, done)
150
+ start_t = time.time()
151
+ qnames: list[pl.DataFrame] = []
152
+ with mp.Pool(processes=nproc) as pool:
153
+ for res in pool.imap_unordered(
154
+ partial(
155
+ _fish_one_region,
156
+ bam_fspath=bam_fspath,
157
+ prebuilt_tag=prebuilt_tag,
158
+ ),
159
+ split_regions,
160
+ ):
161
+ if res is not None:
162
+ qnames += [res]
163
+ logger.info(f"Fish sequence with tag pattern: {time.time() - start_t} sec")
164
+ logger.info(
165
+ f"Fished {sum([df.shape[0] for df in qnames])} readss with tag pattern."
166
+ )
167
+ # merged_qnames will be empty dataframe if there were no df returned
168
+ merged_qnames = pl.concat(qnames) if qnames else pl.DataFrame()
169
+ # empty file will be generated when merged_qnames is empty df
170
+ merged_qnames.write_csv(out, separator="\t")
171
+ done.touch()
172
+ return (merged_qnames, done)
173
+
174
+
175
+ def _split_regions(
176
+ regions: dict[str, list[int]],
177
+ by: str,
178
+ n_splits: int = 4,
179
+ split_size: int = 500_000,
180
+ ) -> list[tuple[str, int, int]]:
181
+ if by not in ["len", "num"]:
182
+ raise ValueError(f"Unsupported split by method {by=}")
183
+ logger.info("Split regions into smaller intevals.")
184
+ splits = []
185
+ for sn, region in regions.items():
186
+ start, end = region
187
+ n_split = (
188
+ ((end - start + 1) // split_size) + 1 if by == "len" else n_splits
189
+ )
190
+ s = np.array_split(np.arange(end), n_split)
191
+ splits += [(sn, int(split[0]), int(split[-1])) for split in s]
192
+
193
+ logger.info(f"Split regions into {len(splits)} intevals.")
194
+ return splits
195
+
196
+
197
+ def _run_fisher(
198
+ bam_fspath: _PathLike,
199
+ tag_fspath: _PathLike,
200
+ bed_fspath: _PathLike,
201
+ outdir: _PathLike,
202
+ prebuild_method: str = "ahocorasick",
203
+ nproc: int = 4,
204
+ overwrite: bool = False,
205
+ ) -> FileManifest:
206
+ logger.info("Start to fish HLA-relevant reads.")
207
+ outdir = parse_path(outdir)
208
+ make_dir(outdir, parents=True, exist_ok=True)
209
+
210
+ bametadata = BAMetadata(str(bam_fspath))
211
+ _check_rg_exists(bametadata)
212
+ _check_single_rg(bametadata)
213
+ rg = bametadata.read_groups[0]
214
+ sm = _get_sm(rg)
215
+
216
+ fm_json = outdir / f"{sm}.fisher.file_manifest.json"
217
+ if fm_json.exists():
218
+ logger.info(
219
+ f"Detected file manifest from previous fisher run: {str(fm_json)}"
220
+ )
221
+ fisher_fm = FileManifest._from_json(fm_json)
222
+ if _verify_prev_run(fisher_fm, overwrite):
223
+ return fisher_fm
224
+
225
+ fisher_fm = FileManifest()
226
+ logdir = outdir / "log"
227
+ make_dir(logdir, parents=True, exist_ok=True)
228
+ fisher_done = logdir / f"{sm}.fisher.done"
229
+
230
+ prebuilt_tag = build(tag_fspath, method=prebuild_method)
231
+
232
+ bam_fspath = bametadata.fspath
233
+ hla_qname_out = outdir / f"{sm}.fisher.hla_bed.idx"
234
+ hla_bed_qnames, hla_bed_done = _fish_multi_hla(
235
+ bed_fspath, bam_fspath, hla_qname_out
236
+ )
237
+
238
+ regions = {
239
+ sn: [1, ln] for sn, ln in bametadata.seqmap().items() if sn in CHR6
240
+ }
241
+ splits = _split_regions(regions, n_splits=nproc, by="num")
242
+ chr6_qname_out = outdir / f"{sm}.fisher.chr6.idx"
243
+ chr6_qnames, chr6_done = _fish_multi_regions(
244
+ splits, bam_fspath, prebuilt_tag, chr6_qname_out, nproc
245
+ )
246
+ unplaced_qname_out = outdir / f"{sm}.fisher.unplaced.idx"
247
+ unplaced_qnames, unplaced_done = _fish_unplaced(
248
+ bam_fspath, prebuilt_tag, unplaced_qname_out
249
+ )
250
+
251
+ merged_qnames = pl.concat(
252
+ [hla_bed_qnames, chr6_qnames, unplaced_qnames]
253
+ ).unique()
254
+ fisher_idx_out = outdir / f"{sm}.fisher.idx.final.tsv"
255
+ merged_qnames.write_csv(fisher_idx_out, separator="\t")
256
+
257
+ dumper_fm = _bam2fq_from_idx(fisher_idx_out, bametadata, outdir, nproc)
258
+
259
+ logger.info(f"Fished {merged_qnames.shape[0]} in total.")
260
+ fisher_done.touch()
261
+
262
+ logger.info("Register all relevant files to manifest.")
263
+ # register all relevant files to manifest
264
+ fisher_fm._register_inputs(
265
+ bam=bametadata.fspath, tag=tag_fspath, bed=bed_fspath
266
+ )
267
+ fisher_fm._register_aux(done=fisher_done, myself=fm_json)
268
+ fisher_fm._register_outputs(
269
+ **dumper_fm.outputs,
270
+ hla_fished_idx=hla_qname_out,
271
+ chr6_fished_idx=chr6_qname_out,
272
+ unplaced_fished_idx=unplaced_qname_out,
273
+ fished_all_idx=fisher_idx_out,
274
+ )
275
+ fisher_fm._register_intermediate(**dumper_fm.outputs)
276
+ fisher_fm._register_intermediate_aux(
277
+ **dumper_fm.intermediate_aux,
278
+ **dumper_fm.aux,
279
+ hla_bed_done=hla_bed_done,
280
+ chr6_done=chr6_done,
281
+ unplaced_done=unplaced_done,
282
+ )
283
+ # dump manifest to disk in json format.
284
+ logger.info(f"Dump manifest to {fm_json}")
285
+ fisher_fm._to_json(json_out=fm_json)
286
+
287
+ return fisher_fm