captnseq-toolkit 0.1.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.
- captnseq_toolkit-0.1.1/.github/workflows/ci.yml +18 -0
- captnseq_toolkit-0.1.1/.gitignore +6 -0
- captnseq_toolkit-0.1.1/CHANGELOG.md +6 -0
- captnseq_toolkit-0.1.1/LICENSE +21 -0
- captnseq_toolkit-0.1.1/PKG-INFO +49 -0
- captnseq_toolkit-0.1.1/README.md +30 -0
- captnseq_toolkit-0.1.1/pyproject.toml +36 -0
- captnseq_toolkit-0.1.1/recipe/meta.yaml +47 -0
- captnseq_toolkit-0.1.1/setup.cfg +4 -0
- captnseq_toolkit-0.1.1/src/captnseq_toolkit/__init__.py +6 -0
- captnseq_toolkit-0.1.1/src/captnseq_toolkit/__main__.py +5 -0
- captnseq_toolkit-0.1.1/src/captnseq_toolkit/cli.py +42 -0
- captnseq_toolkit-0.1.1/src/captnseq_toolkit/essentiality.py +209 -0
- captnseq_toolkit-0.1.1/src/captnseq_toolkit/sam2sites.py +347 -0
- captnseq_toolkit-0.1.1/src/captnseq_toolkit/sites2genes.py +126 -0
- captnseq_toolkit-0.1.1/src/captnseq_toolkit.egg-info/PKG-INFO +49 -0
- captnseq_toolkit-0.1.1/src/captnseq_toolkit.egg-info/SOURCES.txt +23 -0
- captnseq_toolkit-0.1.1/src/captnseq_toolkit.egg-info/dependency_links.txt +1 -0
- captnseq_toolkit-0.1.1/src/captnseq_toolkit.egg-info/entry_points.txt +2 -0
- captnseq_toolkit-0.1.1/src/captnseq_toolkit.egg-info/requires.txt +7 -0
- captnseq_toolkit-0.1.1/src/captnseq_toolkit.egg-info/scm_file_list.json +19 -0
- captnseq_toolkit-0.1.1/src/captnseq_toolkit.egg-info/scm_version.json +8 -0
- captnseq_toolkit-0.1.1/src/captnseq_toolkit.egg-info/top_level.txt +1 -0
- captnseq_toolkit-0.1.1/tests/test_cli.py +72 -0
- captnseq_toolkit-0.1.1/tests/test_sites2genes.py +20 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on: [push, pull_request]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
strategy:
|
|
7
|
+
matrix:
|
|
8
|
+
python: ["3.9", "3.12"]
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
with:
|
|
12
|
+
fetch-depth: 0 # setuptools-scm needs tags
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: ${{ matrix.python }}
|
|
16
|
+
- run: pip install -e ".[test]"
|
|
17
|
+
- run: pytest -q
|
|
18
|
+
- run: captnseq-toolkit --version
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Initial import of `sam2sites`, `sites2genes` and `essentiality` from the nf-core/captnseq pipeline `bin/` (logic unchanged).
|
|
6
|
+
- The `captnseq-toolkit` command is now a standard argparse CLI with subparsers. `--help`, `--version` and error handling come from argparse, so running with no subcommand or an unknown one exits with status 2 and argparse's usage message. Each tool's options are unchanged.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) The nf-core/captnseq team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: captnseq-toolkit
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Analysis tools for CAPTn-seq transposon-insertion sequencing data (used by nf-core/captnseq)
|
|
5
|
+
Author: Simon Jeanneau
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/simonjeanneau/captnseq-toolkit
|
|
8
|
+
Project-URL: Pipeline, https://github.com/nf-core/proposals/issues/173
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: numpy
|
|
13
|
+
Requires-Dist: pandas
|
|
14
|
+
Requires-Dist: scipy
|
|
15
|
+
Requires-Dist: matplotlib
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest; extra == "test"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# captnseq-toolkit
|
|
21
|
+
|
|
22
|
+
Analysis tools for CAPTn-seq (transposon-insertion sequencing) data. This package holds the
|
|
23
|
+
scientific logic used by the [nf-core/captnseq](https://github.com/nf-core/proposals/issues/173)
|
|
24
|
+
pipeline, so the pipeline itself only contains Nextflow code.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install captnseq-toolkit # once released on PyPI
|
|
30
|
+
conda install -c bioconda captnseq-toolkit # once the Bioconda recipe is merged
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
captnseq-toolkit --help
|
|
37
|
+
captnseq-toolkit sam2sites --help # SAM -> insertion sites (BED + bedGraph)
|
|
38
|
+
captnseq-toolkit sites2genes --help # insertion sites -> per-gene counts
|
|
39
|
+
captnseq-toolkit essentiality --help # per-gene counts -> essential / ambiguous / non-essential
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Development
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install -e ".[test]"
|
|
46
|
+
pytest
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Releases are tagged `vX.Y.Z` (version comes from git tags via setuptools-scm).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# captnseq-toolkit
|
|
2
|
+
|
|
3
|
+
Analysis tools for CAPTn-seq (transposon-insertion sequencing) data. This package holds the
|
|
4
|
+
scientific logic used by the [nf-core/captnseq](https://github.com/nf-core/proposals/issues/173)
|
|
5
|
+
pipeline, so the pipeline itself only contains Nextflow code.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install captnseq-toolkit # once released on PyPI
|
|
11
|
+
conda install -c bioconda captnseq-toolkit # once the Bioconda recipe is merged
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
captnseq-toolkit --help
|
|
18
|
+
captnseq-toolkit sam2sites --help # SAM -> insertion sites (BED + bedGraph)
|
|
19
|
+
captnseq-toolkit sites2genes --help # insertion sites -> per-gene counts
|
|
20
|
+
captnseq-toolkit essentiality --help # per-gene counts -> essential / ambiguous / non-essential
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Development
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e ".[test]"
|
|
27
|
+
pytest
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Releases are tagged `vX.Y.Z` (version comes from git tags via setuptools-scm).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "setuptools-scm>=8"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "captnseq-toolkit"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Analysis tools for CAPTn-seq transposon-insertion sequencing data (used by nf-core/captnseq)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Simon Jeanneau" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"numpy",
|
|
15
|
+
"pandas",
|
|
16
|
+
"scipy",
|
|
17
|
+
"matplotlib",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
test = ["pytest"]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
captnseq-toolkit = "captnseq_toolkit.cli:main"
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/simonjeanneau/captnseq-toolkit"
|
|
28
|
+
Pipeline = "https://github.com/nf-core/proposals/issues/173"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools_scm]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Draft Bioconda recipe - copy to bioconda/bioconda-recipes/recipes/captnseq-toolkit/meta.yaml
|
|
2
|
+
# once a release exists on PyPI, filling in the sha256 of the sdist.
|
|
3
|
+
{% set name = "captnseq-toolkit" %}
|
|
4
|
+
{% set version = "0.1.0" %}
|
|
5
|
+
|
|
6
|
+
package:
|
|
7
|
+
name: {{ name|lower }}
|
|
8
|
+
version: {{ version }}
|
|
9
|
+
|
|
10
|
+
source:
|
|
11
|
+
url: https://pypi.io/packages/source/{{ name[0] }}/{{ name }}/captnseq_toolkit-{{ version }}.tar.gz
|
|
12
|
+
sha256: REPLACE_ME
|
|
13
|
+
|
|
14
|
+
build:
|
|
15
|
+
noarch: python
|
|
16
|
+
number: 0
|
|
17
|
+
script: {{ PYTHON }} -m pip install . -vvv --no-deps --no-build-isolation
|
|
18
|
+
entry_points:
|
|
19
|
+
- captnseq-toolkit = captnseq_toolkit.cli:main
|
|
20
|
+
|
|
21
|
+
requirements:
|
|
22
|
+
host:
|
|
23
|
+
- python >=3.9
|
|
24
|
+
- pip
|
|
25
|
+
- setuptools >=64
|
|
26
|
+
- setuptools-scm >=8
|
|
27
|
+
run:
|
|
28
|
+
- python >=3.9
|
|
29
|
+
- numpy
|
|
30
|
+
- pandas
|
|
31
|
+
- scipy
|
|
32
|
+
- matplotlib-base
|
|
33
|
+
|
|
34
|
+
test:
|
|
35
|
+
imports:
|
|
36
|
+
- captnseq_toolkit
|
|
37
|
+
commands:
|
|
38
|
+
- captnseq-toolkit --version
|
|
39
|
+
- captnseq-toolkit sam2sites --help
|
|
40
|
+
- captnseq-toolkit sites2genes --help
|
|
41
|
+
- captnseq-toolkit essentiality --help
|
|
42
|
+
|
|
43
|
+
about:
|
|
44
|
+
home: https://github.com/simonjeanneau/captnseq-toolkit
|
|
45
|
+
license: MIT
|
|
46
|
+
license_file: LICENSE
|
|
47
|
+
summary: Analysis tools for CAPTn-seq transposon-insertion sequencing data
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Top-level `captnseq-toolkit` command: one argparse subcommand per tool."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from captnseq_toolkit import __version__, essentiality, sam2sites, sites2genes
|
|
7
|
+
|
|
8
|
+
# name -> (module, one-line help). Each module provides add_arguments(parser) and run_args(args).
|
|
9
|
+
SUBCOMMANDS = {
|
|
10
|
+
"sam2sites": (sam2sites, "SAM -> insertion sites (BED + bedGraph tracks)"),
|
|
11
|
+
"sites2genes": (sites2genes, "insertion sites -> per-gene insertion counts / index"),
|
|
12
|
+
"essentiality": (essentiality, "per-gene counts -> essential / ambiguous / non-essential calls"),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser():
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="captnseq-toolkit",
|
|
19
|
+
description="Analysis tools for CAPTn-seq transposon-insertion sequencing data.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
22
|
+
subparsers = parser.add_subparsers(dest="command", metavar="<subcommand>", required=True)
|
|
23
|
+
for name, (module, help_text) in SUBCOMMANDS.items():
|
|
24
|
+
sub = subparsers.add_parser(
|
|
25
|
+
name,
|
|
26
|
+
help=help_text,
|
|
27
|
+
description=module.__doc__,
|
|
28
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
29
|
+
)
|
|
30
|
+
module.add_arguments(sub)
|
|
31
|
+
sub.set_defaults(func=module.run_args)
|
|
32
|
+
return parser
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main(argv=None):
|
|
36
|
+
args = build_parser().parse_args(argv)
|
|
37
|
+
args.func(args)
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
sys.exit(main())
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Classify genes as essential / ambiguous / non-essential from sites2genes output
|
|
2
|
+
(Python re-port of tradis_essentiality_wscore.R). Writes QC / changepoint plots as PDF.
|
|
3
|
+
|
|
4
|
+
Ported verbatim from the CAPTn-seq Snakemake pipeline; only the entrypoint changed, plus an
|
|
5
|
+
explicit Agg backend so it runs headless in containers / HPC jobs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Faithful re-port of tradis_essentiality_wscore.R
|
|
9
|
+
#
|
|
10
|
+
# Fixes vs. the previous python port (tradis_essentiality_wscore.py):
|
|
11
|
+
# 1. lower/upper threshold indexing was off by one bin (0-based numpy index
|
|
12
|
+
# divided directly by 10000, instead of mapped through the same 1-indexed
|
|
13
|
+
# x-grid R uses) -> thresholds were systematically ~0.0001 too low.
|
|
14
|
+
# 2. loess() (local weighted quadratic regression) was replaced by a global
|
|
15
|
+
# degree-3 np.polyfit, which finds a different minimum -> different m.
|
|
16
|
+
# 3. scipy's expon.fit / gamma.fit float the `loc` parameter by default;
|
|
17
|
+
# R's fitdistr assumes loc=0 for both. Now fit with floc=0 to match.
|
|
18
|
+
# 4. First histogram (finding the second maxima) used bins='fd' where R
|
|
19
|
+
# uses breaks=200 equal-width bins.
|
|
20
|
+
# 5. Second histogram (finding the inter-mode minimum m) used a fixed
|
|
21
|
+
# 99-bin linspace where R uses r bins of fixed width 1/2000 (r depends
|
|
22
|
+
# on the data via maxval).
|
|
23
|
+
#
|
|
24
|
+
# ValueError guards (abort_and_output early exits) and the epsilon in the
|
|
25
|
+
# log-odds ratio are kept from the previous python port; R has no equivalent
|
|
26
|
+
# and would simply error on this pathological input.
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
|
|
30
|
+
import pandas as pd
|
|
31
|
+
import numpy as np
|
|
32
|
+
from scipy import stats
|
|
33
|
+
import matplotlib
|
|
34
|
+
matplotlib.use('Agg')
|
|
35
|
+
import matplotlib.pyplot as plt
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def loess_predict(x, y, span=0.75, degree=2):
|
|
39
|
+
"""Local weighted polynomial regression (R's loess default: span=0.75,
|
|
40
|
+
degree=2, tricube weights), evaluated at the original x points -
|
|
41
|
+
equivalent to R's predict(loess(y ~ x)) with no newdata."""
|
|
42
|
+
x = np.asarray(x, dtype=float)
|
|
43
|
+
y = np.asarray(y, dtype=float)
|
|
44
|
+
n = len(x)
|
|
45
|
+
k = min(max(int(np.floor(span * n)), degree + 1), n)
|
|
46
|
+
fitted = np.empty(n)
|
|
47
|
+
for i in range(n):
|
|
48
|
+
d = np.abs(x - x[i])
|
|
49
|
+
order = np.argsort(d, kind="mergesort")[:k]
|
|
50
|
+
dmax = d[order].max()
|
|
51
|
+
w = np.ones(k) if dmax == 0 else np.clip(1 - (d[order] / dmax) ** 3, 0, None) ** 3
|
|
52
|
+
dx = x[order] - x[i]
|
|
53
|
+
X = np.vstack([dx ** p for p in range(degree + 1)]).T
|
|
54
|
+
sw = np.sqrt(w)
|
|
55
|
+
beta, *_ = np.linalg.lstsq(X * sw[:, None], y[order] * sw, rcond=None)
|
|
56
|
+
fitted[i] = beta[0]
|
|
57
|
+
return fitted
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run(input_file, output_all, output_essen, output_ambig, output_nonessen):
|
|
61
|
+
STM_baseline = pd.read_csv(input_file, sep="\t", header=0)
|
|
62
|
+
input_base = input_file.split(".")[0]
|
|
63
|
+
ii = STM_baseline['ins_index'].values
|
|
64
|
+
STM_baseline['read_index'] = STM_baseline['read_count'] / STM_baseline['gene_length']
|
|
65
|
+
|
|
66
|
+
def abort_and_output(score_E=0, lower=1, upper=99):
|
|
67
|
+
STM_baseline['score_E'] = score_E
|
|
68
|
+
STM_baseline['F_threshold'] = lower
|
|
69
|
+
STM_baseline['N_threshold'] = upper
|
|
70
|
+
STM_baseline.to_csv(output_all, index=False)
|
|
71
|
+
STM_baseline[STM_baseline['ins_index'] < lower].to_csv(output_essen, index=False)
|
|
72
|
+
STM_baseline[STM_baseline['ins_index'] >= upper].to_csv(output_nonessen, index=False)
|
|
73
|
+
if lower < upper:
|
|
74
|
+
STM_baseline[(STM_baseline['ins_index'] >= lower) & (STM_baseline['ins_index'] < upper)].to_csv(output_ambig, index=False)
|
|
75
|
+
else:
|
|
76
|
+
STM_baseline[STM_baseline['ins_index'] >= upper].to_csv(output_ambig, index=False)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
# Identify second maxima (R: hist(ii, breaks=200))
|
|
80
|
+
h, bin_edges = np.histogram(ii, bins=200, density=True)
|
|
81
|
+
if len(h) < 10:
|
|
82
|
+
abort_and_output()
|
|
83
|
+
return
|
|
84
|
+
maxindex = np.argmax(h[10:])
|
|
85
|
+
midpoints = (bin_edges[:-1] + bin_edges[1:]) / 2
|
|
86
|
+
maxval = midpoints[maxindex + 3]
|
|
87
|
+
|
|
88
|
+
nG = len(STM_baseline['read_count'])
|
|
89
|
+
r = int(np.floor(maxval * 2000))
|
|
90
|
+
if r <= 0:
|
|
91
|
+
abort_and_output()
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
fig1 = plt.figure()
|
|
95
|
+
|
|
96
|
+
# R: hist(ii[I], breaks=(0:r/2000)) -> r bins of fixed width 1/2000
|
|
97
|
+
I = ii < r / 2000
|
|
98
|
+
h1, edges1 = np.histogram(ii[I], bins=np.linspace(0, r / 2000, r + 1))
|
|
99
|
+
loess_curve = loess_predict(np.arange(1, len(h1) + 1), h1)
|
|
100
|
+
|
|
101
|
+
plt.plot(h1, label="Density")
|
|
102
|
+
plt.plot(loess_curve, color='red', lw=2, label="Loess Curve")
|
|
103
|
+
plt.legend()
|
|
104
|
+
plt.title("Density")
|
|
105
|
+
|
|
106
|
+
mid1 = (edges1[:-1] + edges1[1:]) / 2
|
|
107
|
+
m = mid1[np.argmin(loess_curve)]
|
|
108
|
+
I1 = (ii < m) & (ii >= 0)
|
|
109
|
+
|
|
110
|
+
h_full, edges_full = np.histogram(ii, bins='fd')
|
|
111
|
+
I2 = (ii >= m) & (ii < edges_full[np.max(np.where(h_full > 5))])
|
|
112
|
+
|
|
113
|
+
f1 = (np.sum(I1) + np.sum(ii == 0)) / nG
|
|
114
|
+
f2 = np.sum(I2) / nG
|
|
115
|
+
|
|
116
|
+
# Fit exponential and gamma distributions, anchored at loc=0 like R's fitdistr
|
|
117
|
+
try:
|
|
118
|
+
d1 = stats.expon.fit(ii[I1], floc=0)
|
|
119
|
+
d2 = stats.gamma.fit(ii[I2], floc=0)
|
|
120
|
+
except ValueError:
|
|
121
|
+
abort_and_output()
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Generate and save the plot
|
|
125
|
+
x_vals = np.linspace(0, max(ii), 500)
|
|
126
|
+
fig2 = plt.figure()
|
|
127
|
+
plt.hist(ii, bins='fd', density=True, label="Data")
|
|
128
|
+
plt.plot(x_vals, f1 * stats.expon.pdf(x_vals, *d1), label="Exponential fit")
|
|
129
|
+
plt.plot(x_vals, f2 * stats.gamma.pdf(x_vals, *d2), label="Gamma fit")
|
|
130
|
+
|
|
131
|
+
# Calculate log-odds ratios (x grid matches R's 1:1000/10000 exactly)
|
|
132
|
+
epsilon = 1e-10 # prevents log(0)/log(inf) on pathological input; no R equivalent
|
|
133
|
+
x_grid = np.arange(1, 1001) / 10000.0
|
|
134
|
+
log_odds = np.log2(
|
|
135
|
+
((stats.gamma.cdf(x_grid, *d2) *
|
|
136
|
+
(1 - stats.expon.sf(x_grid, *d1))) + epsilon) /
|
|
137
|
+
((stats.expon.sf(x_grid, *d1) *
|
|
138
|
+
(1 - stats.gamma.cdf(x_grid, *d2))) + epsilon)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
where_lower = np.where(log_odds < -2)[0]
|
|
142
|
+
where_upper = np.where(log_odds > 2)[0]
|
|
143
|
+
if len(where_lower) == 0 or len(where_upper) == 0:
|
|
144
|
+
abort_and_output()
|
|
145
|
+
return
|
|
146
|
+
lower = x_grid[np.max(where_lower)]
|
|
147
|
+
upper = x_grid[np.min(where_upper)]
|
|
148
|
+
|
|
149
|
+
# Adding lines to the plot for thresholds
|
|
150
|
+
plt.legend()
|
|
151
|
+
plt.axvline(lower, color='red', label="Lower Threshold")
|
|
152
|
+
plt.axvline(upper, color='red', label="Upper Threshold")
|
|
153
|
+
plt.text(lower, 30, f"Essential changepoint: {lower:.4f}", color='red')
|
|
154
|
+
plt.text(upper, 10, f"Ambiguous changepoint: {upper:.4f}", color='red')
|
|
155
|
+
plt.title("Gamma fits")
|
|
156
|
+
|
|
157
|
+
with plt.rc_context():
|
|
158
|
+
from matplotlib.backends.backend_pdf import PdfPages
|
|
159
|
+
with PdfPages(f"{input_base}_QC_and_changepoint_plots.pdf") as pdf:
|
|
160
|
+
pdf.savefig(fig1)
|
|
161
|
+
pdf.savefig(fig2)
|
|
162
|
+
plt.close(fig1)
|
|
163
|
+
plt.close(fig2)
|
|
164
|
+
|
|
165
|
+
# Calculate 'read_index' and 'score_E'
|
|
166
|
+
score_E = []
|
|
167
|
+
for iindex in STM_baseline['ins_index']:
|
|
168
|
+
if iindex <= lower:
|
|
169
|
+
score_E.append(iindex / lower)
|
|
170
|
+
elif lower < iindex <= upper:
|
|
171
|
+
score_E.append((iindex - lower) / lower + 1)
|
|
172
|
+
else:
|
|
173
|
+
score_E.append((iindex - upper) / upper + 2)
|
|
174
|
+
|
|
175
|
+
abort_and_output(score_E, lower, upper)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def add_arguments(parser):
|
|
179
|
+
"""Register this subcommand's options on *parser*."""
|
|
180
|
+
parser.add_argument("--input", required=True, help="sites2genes output (genes_insertions.tsv)")
|
|
181
|
+
parser.add_argument("--output-all", required=True)
|
|
182
|
+
parser.add_argument("--output-essen", required=True)
|
|
183
|
+
parser.add_argument("--output-ambig", required=True)
|
|
184
|
+
parser.add_argument("--output-nonessen", required=True)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def run_args(args):
|
|
188
|
+
"""Run the subcommand from an ``argparse.Namespace``."""
|
|
189
|
+
run(
|
|
190
|
+
input_file=args.input,
|
|
191
|
+
output_all=args.output_all,
|
|
192
|
+
output_essen=args.output_essen,
|
|
193
|
+
output_ambig=args.output_ambig,
|
|
194
|
+
output_nonessen=args.output_nonessen,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main(argv=None):
|
|
199
|
+
parser = argparse.ArgumentParser(
|
|
200
|
+
prog="captnseq-toolkit essentiality",
|
|
201
|
+
description=__doc__,
|
|
202
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
203
|
+
)
|
|
204
|
+
add_arguments(parser)
|
|
205
|
+
run_args(parser.parse_args(argv))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
if __name__ == "__main__":
|
|
209
|
+
main()
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Call transposon insertion sites from a SAM file and write BED / bedGraph tracks.
|
|
2
|
+
|
|
3
|
+
Ported verbatim from the CAPTn-seq Snakemake pipeline (pipeline/scripts/sam2sites.py);
|
|
4
|
+
only the entrypoint changed (argparse instead of the injected `snakemake` object).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import csv
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_sam_file(samfile: str, aligned_length_threshold: int = 21, pos_offset: tuple = (3, -6)) -> pd.DataFrame:
|
|
15
|
+
"""
|
|
16
|
+
Parses a SAM-like file and retains only first-in-pair reads
|
|
17
|
+
with aligned read length above threshold.
|
|
18
|
+
|
|
19
|
+
Determines strand from the SAM flag using bitwise logic.
|
|
20
|
+
|
|
21
|
+
pos_offset: (forward_offset, reverse_offset) position-correction offset,
|
|
22
|
+
empirically determined per transposon/library-prep system - passed in
|
|
23
|
+
from config (sam2sites_params.pos_offset_fwd/pos_offset_rev) rather than
|
|
24
|
+
hardcoded here, since a different experiment's transposon may need
|
|
25
|
+
different values.
|
|
26
|
+
"""
|
|
27
|
+
df = pd.read_csv(
|
|
28
|
+
samfile,
|
|
29
|
+
sep="\t",
|
|
30
|
+
header=None,
|
|
31
|
+
usecols=range(6),
|
|
32
|
+
names=["name", "flag", "chr_name", "pos", "score", "cigar"],
|
|
33
|
+
dtype={
|
|
34
|
+
"name": str,
|
|
35
|
+
"flag": int,
|
|
36
|
+
"chr_name": str,
|
|
37
|
+
"pos": int,
|
|
38
|
+
"score": int,
|
|
39
|
+
"cigar": str
|
|
40
|
+
},
|
|
41
|
+
engine="python",
|
|
42
|
+
quoting=csv.QUOTE_NONE,
|
|
43
|
+
on_bad_lines="warn"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Determine strand and filter to first-in-pair reads only
|
|
47
|
+
df["strand"] = df["flag"].apply(lambda x: "-" if x & 0x10 else "+") # 0x10 == True is mapped to the reverse strand
|
|
48
|
+
df["first_in_pair"] = df["flag"].apply(lambda x: bool(x & 0x40)) # 0x40 == True is the first read in a pair
|
|
49
|
+
df = df[df["first_in_pair"]].copy()
|
|
50
|
+
|
|
51
|
+
# use regex to extract each int/marker and build a secondary df holding these info.
|
|
52
|
+
#
|
|
53
|
+
# typic input:
|
|
54
|
+
# ------------
|
|
55
|
+
#
|
|
56
|
+
# 20 42M
|
|
57
|
+
# 25 72M3S
|
|
58
|
+
# Name: cigar
|
|
59
|
+
#
|
|
60
|
+
# typic output:
|
|
61
|
+
# -------------
|
|
62
|
+
#
|
|
63
|
+
# size marker
|
|
64
|
+
# match
|
|
65
|
+
# 20 0 42 M
|
|
66
|
+
# 25 0 72 M
|
|
67
|
+
# 1 3 S
|
|
68
|
+
|
|
69
|
+
# Extract cigar operations
|
|
70
|
+
cigar_components = df["cigar"].str.extractall(r"(?P<size>\d+)(?P<op>[A-Z])")
|
|
71
|
+
cigar_components["size"] = cigar_components["size"].astype(int)
|
|
72
|
+
|
|
73
|
+
# Group cigar operations by index
|
|
74
|
+
cigar_group = cigar_components.groupby(level=0)
|
|
75
|
+
|
|
76
|
+
# First and last CIGAR op per read (used for validity check)
|
|
77
|
+
cigar_first_op = cigar_group["op"].first()
|
|
78
|
+
cigar_last_op = cigar_group["op"].last()
|
|
79
|
+
|
|
80
|
+
# Aligned length = sum of sizes for relevant ops (M, D, N, =, X)
|
|
81
|
+
aligned_ops = cigar_components[cigar_components["op"].isin(["M", "D", "N", "=", "X"])]
|
|
82
|
+
aligned_length = aligned_ops.groupby(level=0)["size"].sum()
|
|
83
|
+
|
|
84
|
+
# Inject back into main df using .map()
|
|
85
|
+
df["cigar_first_op"] = df.index.map(cigar_first_op)
|
|
86
|
+
df["cigar_last_op"] = df.index.map(cigar_last_op)
|
|
87
|
+
df["aligned_read_length"] = df.index.map(aligned_length).fillna(0).astype(int)
|
|
88
|
+
|
|
89
|
+
# Define valid reads based on strand and expected CIGAR structure
|
|
90
|
+
valid_positive = (df["strand"] == "+") & (df["cigar_first_op"] == "M")
|
|
91
|
+
valid_negative = (df["strand"] == "-") & (df["cigar_last_op"] == "M")
|
|
92
|
+
df["valid"] = (valid_positive | valid_negative) & (df["aligned_read_length"] > aligned_length_threshold)
|
|
93
|
+
|
|
94
|
+
# Position correction offset (empirically determined; not derived from a
|
|
95
|
+
# documented formula), passed in from config. (+offset[0]) shifts a
|
|
96
|
+
# forward-strand read's aligned start to the actual insertion site;
|
|
97
|
+
# (offset[1]) does the same for a reverse-strand read's aligned end.
|
|
98
|
+
|
|
99
|
+
# Compute adjusted start position
|
|
100
|
+
valid_df = df[df["valid"]].copy()
|
|
101
|
+
valid_df["start"] = np.where(
|
|
102
|
+
valid_df["strand"] == "+",
|
|
103
|
+
valid_df["pos"] + pos_offset[0],
|
|
104
|
+
valid_df["pos"] + valid_df["aligned_read_length"] + pos_offset[1]
|
|
105
|
+
)
|
|
106
|
+
return valid_df
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def filter_reads_by_site_support(sam_df: pd.DataFrame, score_threshold: int) -> pd.DataFrame:
|
|
110
|
+
"""
|
|
111
|
+
Retain only reads whose (chr_name, start) occurs with at least `score_threshold` other reads.
|
|
112
|
+
Keeps all columns including QNAMEs (name) and strand info.
|
|
113
|
+
"""
|
|
114
|
+
group_keys = ["chr_name", "start"]
|
|
115
|
+
# Count number of reads per position per strand
|
|
116
|
+
site_counts = (
|
|
117
|
+
sam_df.groupby(group_keys + ["strand"])["name"]
|
|
118
|
+
.count()
|
|
119
|
+
.unstack(fill_value=0)
|
|
120
|
+
# CHANGE: unstack() only produces a column for strands actually
|
|
121
|
+
# present in sam_df. If every surviving read happens to be on
|
|
122
|
+
# one strand (plausible for a low-coverage/failing sample),
|
|
123
|
+
# "+" or "-" would be missing entirely and the score_pos/
|
|
124
|
+
# score_neg lookups below would raise a KeyError. Reindexing
|
|
125
|
+
# guarantees both columns always exist, filled with 0.
|
|
126
|
+
.reindex(columns=["+", "-"], fill_value=0)
|
|
127
|
+
.rename(columns={"+": "score_pos", "-": "score_neg"})
|
|
128
|
+
.reset_index()
|
|
129
|
+
)
|
|
130
|
+
# Keep only positions with sufficient read support on either strand
|
|
131
|
+
supported_sites = site_counts[
|
|
132
|
+
(site_counts["score_pos"] > score_threshold) |
|
|
133
|
+
(site_counts["score_neg"] > score_threshold)
|
|
134
|
+
][["chr_name", "start"]]
|
|
135
|
+
# Inner join back with reads
|
|
136
|
+
filtered_reads = sam_df.merge(supported_sites, on=["chr_name", "start"], how="inner")
|
|
137
|
+
return filtered_reads
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def sam_to_site(filtered_dedup_reads: pd.DataFrame) -> pd.DataFrame:
|
|
141
|
+
"""
|
|
142
|
+
Convert a filtered and deduplicated read set into strand-specific insertion site counts.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
filtered_dedup_reads : pd.DataFrame
|
|
147
|
+
DataFrame with at least the following columns:
|
|
148
|
+
- 'chr_name': reference sequence
|
|
149
|
+
- 'start': insertion position (already corrected)
|
|
150
|
+
- 'strand': '+' or '-'
|
|
151
|
+
- 'name': QNAME (used for counting insertions)
|
|
152
|
+
|
|
153
|
+
Returns
|
|
154
|
+
-------
|
|
155
|
+
pd.DataFrame
|
|
156
|
+
A DataFrame with one row per (chr_name, start), containing:
|
|
157
|
+
- 'chr_name'
|
|
158
|
+
- 'start'
|
|
159
|
+
- 'end': start + 1 (BED-style interval)
|
|
160
|
+
- 'name': fixed to 'i' (placeholder for BED)
|
|
161
|
+
- 'score_pos': insertion count on + strand
|
|
162
|
+
- 'score_neg': insertion count on - strand
|
|
163
|
+
"""
|
|
164
|
+
group_keys = ["chr_name", "start"]
|
|
165
|
+
score_pos = (
|
|
166
|
+
filtered_dedup_reads[filtered_dedup_reads["strand"] == "+"]
|
|
167
|
+
.groupby(group_keys)["name"]
|
|
168
|
+
.count()
|
|
169
|
+
.rename("score_pos")
|
|
170
|
+
)
|
|
171
|
+
score_neg = (
|
|
172
|
+
filtered_dedup_reads[filtered_dedup_reads["strand"] == "-"]
|
|
173
|
+
.groupby(group_keys)["name"]
|
|
174
|
+
.count()
|
|
175
|
+
.rename("score_neg")
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
site_df = pd.concat([score_pos, score_neg], axis=1).reset_index()
|
|
179
|
+
site_df["end"] = site_df["start"] + 1
|
|
180
|
+
site_df["name"] = "i"
|
|
181
|
+
site_df = site_df.fillna({"score_pos": 0, "score_neg": 0})
|
|
182
|
+
site_df = site_df[["chr_name", "start", "end", "name", "score_pos", "score_neg"]]
|
|
183
|
+
site_df = site_df.sort_values(by=["chr_name", "start"])
|
|
184
|
+
return site_df
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def site_to_interval(site_df: pd.DataFrame, *, normalization_value: int) -> pd.DataFrame:
|
|
188
|
+
total_reads = site_df["score_pos"].sum() + site_df["score_neg"].sum()
|
|
189
|
+
pos_interval_df = (
|
|
190
|
+
site_df[["chr_name", "start", "end", "name", "score_pos"]]
|
|
191
|
+
.rename(columns={"score_pos": "score"})
|
|
192
|
+
)
|
|
193
|
+
pos_interval_df["strand"] = "+"
|
|
194
|
+
neg_interval_df = (
|
|
195
|
+
site_df[["chr_name", "start", "end", "name", "score_neg"]]
|
|
196
|
+
.rename(columns={"score_neg": "score"})
|
|
197
|
+
)
|
|
198
|
+
neg_interval_df["strand"] = "-"
|
|
199
|
+
interval_df = pd.concat([pos_interval_df, neg_interval_df], ignore_index=True)
|
|
200
|
+
# Normalize scores to target total, clip at 1 to ensure non-zero presence
|
|
201
|
+
interval_df["normalized_score"] = (
|
|
202
|
+
(interval_df["score"] / total_reads * normalization_value)
|
|
203
|
+
.astype(int)
|
|
204
|
+
.clip(lower=1)
|
|
205
|
+
)
|
|
206
|
+
interval_df["fake_score"] = 999 # Placeholder fixed score
|
|
207
|
+
interval_df = interval_df.sort_values(by=["chr_name", "start"])
|
|
208
|
+
interval_df = interval_df[interval_df["score"] != 0]
|
|
209
|
+
return interval_df
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def build_bed_file(interval_df: pd.DataFrame, filename):
|
|
213
|
+
"""
|
|
214
|
+
bed_file
|
|
215
|
+
--------
|
|
216
|
+
|
|
217
|
+
"chr_name", "start", "end", "name", "fake_score", "strand"
|
|
218
|
+
|
|
219
|
+
pVCR94deltaX3deltaacr2FRT 26 27 i 999 +
|
|
220
|
+
pVCR94deltaX3deltaacr2FRT 47 48 i 999 +
|
|
221
|
+
pVCR94deltaX3deltaacr2FRT 68 69 i 999 +
|
|
222
|
+
pVCR94deltaX3deltaacr2FRT 97 98 i 999 -
|
|
223
|
+
pVCR94deltaX3deltaacr2FRT 245 246 i 999 -
|
|
224
|
+
"""
|
|
225
|
+
interval_df[["chr_name", "start", "end", "name", "fake_score", "strand"]].to_csv(
|
|
226
|
+
filename, header=False, index=False, sep="\t"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def build_stranded_unnormalized_bedgraph_file(
|
|
231
|
+
interval_df: pd.DataFrame, filename):
|
|
232
|
+
"""
|
|
233
|
+
stranded_unnormalized_bedgraph_file
|
|
234
|
+
--------
|
|
235
|
+
|
|
236
|
+
"chr_name", "start", "end", "score"
|
|
237
|
+
|
|
238
|
+
pVCR94deltaX3deltaacr2FRT 26 27 2400
|
|
239
|
+
pVCR94deltaX3deltaacr2FRT 47 48 60
|
|
240
|
+
pVCR94deltaX3deltaacr2FRT 68 69 60
|
|
241
|
+
pVCR94deltaX3deltaacr2FRT 97 98 1380
|
|
242
|
+
pVCR94deltaX3deltaacr2FRT 245 246 3780
|
|
243
|
+
"""
|
|
244
|
+
interval_df[["chr_name", "start", "end", "score"]].to_csv(
|
|
245
|
+
filename, header=False, index=False, sep="\t"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def build_stranded_bedgraph_file(interval_df: pd.DataFrame, filename):
|
|
250
|
+
"""
|
|
251
|
+
stranded_bedgraph_file
|
|
252
|
+
--------
|
|
253
|
+
|
|
254
|
+
"chr_name", "start", "end", "normalized_score"
|
|
255
|
+
|
|
256
|
+
pVCR94deltaX3deltaacr2FRT 26 27 2189
|
|
257
|
+
pVCR94deltaX3deltaacr2FRT 47 48 54
|
|
258
|
+
pVCR94deltaX3deltaacr2FRT 47 48 54
|
|
259
|
+
pVCR94deltaX3deltaacr2FRT 68 69 54
|
|
260
|
+
pVCR94deltaX3deltaacr2FRT 97 98 1258
|
|
261
|
+
pVCR94deltaX3deltaacr2FRT 245 246 3448
|
|
262
|
+
"""
|
|
263
|
+
interval_df[["chr_name", "start", "end", "normalized_score"]].to_csv(
|
|
264
|
+
filename, header=False, index=False, sep="\t"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def build_unstranded_bedgraph_file(interval_df: pd.DataFrame, filename):
|
|
269
|
+
"""
|
|
270
|
+
unstranded_bedgraph_file
|
|
271
|
+
--------
|
|
272
|
+
|
|
273
|
+
"chr_name", "start", "end", "normalized_score"
|
|
274
|
+
|
|
275
|
+
pVCR94deltaX3deltaacr2FRT 26 27 2189
|
|
276
|
+
pVCR94deltaX3deltaacr2FRT 47 48 108<-(score for insertions on the same position, but different strands are merged)
|
|
277
|
+
pVCR94deltaX3deltaacr2FRT 68 69 54
|
|
278
|
+
pVCR94deltaX3deltaacr2FRT 97 98 1258
|
|
279
|
+
pVCR94deltaX3deltaacr2FRT 245 246 3448
|
|
280
|
+
"""
|
|
281
|
+
interval_df = interval_df.groupby(["chr_name", "start", "end"], sort=False).agg({'normalized_score': 'sum'}).reset_index()
|
|
282
|
+
interval_df[["chr_name", "start", "end", "normalized_score"]].to_csv(
|
|
283
|
+
filename, header=False, index=False, sep="\t"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def build_unstranded_unnormalized_bedgraph_file(interval_df: pd.DataFrame, filename):
|
|
288
|
+
"""
|
|
289
|
+
unstranded_bedgraph_file
|
|
290
|
+
--------
|
|
291
|
+
|
|
292
|
+
"chr_name", "start", "end", "score"
|
|
293
|
+
|
|
294
|
+
pVCR94deltaX3deltaacr2FRT 26 27 2400
|
|
295
|
+
pVCR94deltaX3deltaacr2FRT 47 48 60
|
|
296
|
+
pVCR94deltaX3deltaacr2FRT 68 69 60<-(score for insertions on the same position, but different strands are merged)
|
|
297
|
+
pVCR94deltaX3deltaacr2FRT 97 98 1380
|
|
298
|
+
pVCR94deltaX3deltaacr2FRT 245 246 3780
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
interval_df = interval_df.groupby(["chr_name", "start", "end"], sort=False).agg({'score': 'sum'}).reset_index()
|
|
302
|
+
interval_df[["chr_name", "start", "end", "score"]].to_csv(
|
|
303
|
+
filename, header=False, index=False, sep="\t"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def add_arguments(parser):
|
|
308
|
+
"""Register this subcommand's options on *parser*."""
|
|
309
|
+
parser.add_argument("--sam", required=True)
|
|
310
|
+
parser.add_argument("--norm-value", type=int, required=True)
|
|
311
|
+
parser.add_argument("--read-len-threshold", type=int, required=True)
|
|
312
|
+
parser.add_argument("--score-threshold", type=int, required=True)
|
|
313
|
+
parser.add_argument("--pos-offset-fwd", type=int, required=True)
|
|
314
|
+
parser.add_argument("--pos-offset-rev", type=int, required=True)
|
|
315
|
+
parser.add_argument("--bed-output", required=True)
|
|
316
|
+
parser.add_argument("--normalized-output", required=True)
|
|
317
|
+
parser.add_argument("--unstranded-output", required=True)
|
|
318
|
+
parser.add_argument("--unnormalized-output", required=True)
|
|
319
|
+
parser.add_argument("--unstranded-unnormalized-output", required=True)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def run_args(args):
|
|
323
|
+
"""Run the subcommand from an ``argparse.Namespace``."""
|
|
324
|
+
pos_offset = (args.pos_offset_fwd, args.pos_offset_rev)
|
|
325
|
+
sam_df = parse_sam_file(args.sam, args.read_len_threshold, pos_offset)
|
|
326
|
+
filtered_reads = filter_reads_by_site_support(sam_df, args.score_threshold)
|
|
327
|
+
site_df = sam_to_site(filtered_reads)
|
|
328
|
+
interval_df = site_to_interval(site_df, normalization_value=args.norm_value)
|
|
329
|
+
build_bed_file(interval_df, args.bed_output)
|
|
330
|
+
build_stranded_bedgraph_file(interval_df, args.normalized_output)
|
|
331
|
+
build_unstranded_bedgraph_file(interval_df, args.unstranded_output)
|
|
332
|
+
build_stranded_unnormalized_bedgraph_file(interval_df, args.unnormalized_output)
|
|
333
|
+
build_unstranded_unnormalized_bedgraph_file(interval_df, args.unstranded_unnormalized_output)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main(argv=None):
|
|
337
|
+
parser = argparse.ArgumentParser(
|
|
338
|
+
prog="captnseq-toolkit sam2sites",
|
|
339
|
+
description=__doc__,
|
|
340
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
341
|
+
)
|
|
342
|
+
add_arguments(parser)
|
|
343
|
+
run_args(parser.parse_args(argv))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
if __name__ == "__main__":
|
|
347
|
+
main()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Count insertions per gene from a BED/intersect file, with optional trimming of the
|
|
2
|
+
informative gene region (--length-pc-start / --length-pc-stop).
|
|
3
|
+
|
|
4
|
+
Usage: captnseq-toolkit sites2genes --features CDS.bed --sample intersect.bed
|
|
5
|
+
--length-pc-start 0 --length-pc-stop 100 --output genes_insertions.tsv
|
|
6
|
+
|
|
7
|
+
Ported verbatim from the CAPTn-seq Snakemake pipeline (pipeline_ref/scripts/sites2genes.py);
|
|
8
|
+
only the entrypoint changed.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def compute_gene_insertions(features_path, sample_path, length_pc_start, length_pc_stop, output_path):
|
|
16
|
+
# Read files
|
|
17
|
+
gene_df = pd.read_csv(
|
|
18
|
+
str(features_path),
|
|
19
|
+
dtype={
|
|
20
|
+
"chr_name": str,
|
|
21
|
+
"start": "Int64",
|
|
22
|
+
"end": "Int64",
|
|
23
|
+
"name": str,
|
|
24
|
+
"score": "Int64",
|
|
25
|
+
"strand": str
|
|
26
|
+
},
|
|
27
|
+
sep="\t",
|
|
28
|
+
header=None,
|
|
29
|
+
names=["chr_name", "start", "end", "name", "score", "strand"],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
intersect_df = pd.read_csv(
|
|
33
|
+
str(sample_path),
|
|
34
|
+
dtype={
|
|
35
|
+
"chr_name_gene": str,
|
|
36
|
+
"start_gene": "Int64",
|
|
37
|
+
"end_gene": "Int64",
|
|
38
|
+
"name_gene": str,
|
|
39
|
+
"score_gene": "Int64",
|
|
40
|
+
"strand_gene": str,
|
|
41
|
+
"chr_name_insertion": str,
|
|
42
|
+
"start_insertion": "Int64",
|
|
43
|
+
"end_insertion": "Int64",
|
|
44
|
+
"score_insertion": "Int64"
|
|
45
|
+
},
|
|
46
|
+
sep="\t",
|
|
47
|
+
header=None,
|
|
48
|
+
usecols=range(10),
|
|
49
|
+
names=["chr_name_gene", "start_gene", "end_gene", "name_gene", "score_gene", "strand_gene", "chr_name_insertion", "start_insertion", "end_insertion", "score_insertion"],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# filter insertions outside of genes (due to insertion duplication and excluding length_pc_start length_pc_end)
|
|
53
|
+
intersect_df.replace({'strand_gene': {"+": "1", "-": "-1"}}, inplace=True)
|
|
54
|
+
gene_df.replace({'strand': {"+": "1", "-": "-1"}}, inplace=True)
|
|
55
|
+
|
|
56
|
+
intersect_df = intersect_df.loc[
|
|
57
|
+
(
|
|
58
|
+
(intersect_df['strand_gene'] == "1") &
|
|
59
|
+
((intersect_df['start_insertion'] - 5) >= (intersect_df['start_gene'] + ((intersect_df['end_gene'] - intersect_df['start_gene']) * length_pc_start / 100.0))) &
|
|
60
|
+
((intersect_df['start_insertion'] + 5) <= (intersect_df['end_gene'] - 1 - ((intersect_df['end_gene'] - intersect_df['start_gene']) * (100 - length_pc_stop) / 100.0)))
|
|
61
|
+
)
|
|
62
|
+
|
|
|
63
|
+
(
|
|
64
|
+
(intersect_df['strand_gene'] == "-1") &
|
|
65
|
+
((intersect_df['start_insertion'] + 5) <= (intersect_df['end_gene'] - 1 - ((intersect_df['end_gene'] - intersect_df['start_gene']) * length_pc_start / 100.0))) &
|
|
66
|
+
((intersect_df['start_insertion'] - 5) >= (intersect_df['start_gene'] + ((intersect_df['end_gene'] - intersect_df['start_gene']) * (100 - length_pc_stop) / 100.0)))
|
|
67
|
+
)
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
# make columns required for biotradis toolkit (almost, usually no chr_name)
|
|
71
|
+
gene_df["locus_tag"] = gene_df["name"]
|
|
72
|
+
gene_df["gene_name"] = gene_df["name"]
|
|
73
|
+
gene_df["ncrna"] = 0
|
|
74
|
+
gene_df["read_count"] = 0
|
|
75
|
+
gene_df["ins_index"] = 0.0
|
|
76
|
+
gene_df["gene_length"] = gene_df["end"] - gene_df["start"]
|
|
77
|
+
gene_df["ins_count"] = 0
|
|
78
|
+
gene_df["fcn"] = "NA"
|
|
79
|
+
gene_df = gene_df.set_index('name')
|
|
80
|
+
|
|
81
|
+
# Compute insertion index and read_count
|
|
82
|
+
# Perform groupby and specify the aggregation dictionary
|
|
83
|
+
intersect_df = intersect_df.groupby('name_gene', sort=False).agg({'score_insertion': ['sum', 'mean', 'count']})
|
|
84
|
+
# This creates a MultiIndex in columns, so you need to flatten these for easier access
|
|
85
|
+
intersect_df.columns = ['_'.join(col).strip() for col in intersect_df.columns.values]
|
|
86
|
+
for index, row in intersect_df.iterrows():
|
|
87
|
+
gene_df.loc[index, "read_count"] = row['score_insertion_sum']
|
|
88
|
+
gene_df.loc[index, "ins_count"] = row['score_insertion_count']
|
|
89
|
+
gene_df["ins_index"] = gene_df["ins_count"] / gene_df["gene_length"]
|
|
90
|
+
gene_df[["chr_name", "locus_tag", "gene_name", "ncrna", "start", "end", "strand", "read_count", "ins_index", "gene_length", "ins_count", "fcn"]].to_csv(
|
|
91
|
+
str(output_path), header=True, index=False, sep="\t"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def add_arguments(parser):
|
|
96
|
+
"""Register this subcommand's options on *parser*."""
|
|
97
|
+
parser.add_argument("--features", required=True)
|
|
98
|
+
parser.add_argument("--sample", required=True)
|
|
99
|
+
parser.add_argument("--length-pc-start", type=float, required=True)
|
|
100
|
+
parser.add_argument("--length-pc-stop", type=float, required=True)
|
|
101
|
+
parser.add_argument("--output", required=True)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_args(args):
|
|
105
|
+
"""Run the subcommand from an ``argparse.Namespace``."""
|
|
106
|
+
compute_gene_insertions(
|
|
107
|
+
features_path=args.features,
|
|
108
|
+
sample_path=args.sample,
|
|
109
|
+
length_pc_start=args.length_pc_start,
|
|
110
|
+
length_pc_stop=args.length_pc_stop,
|
|
111
|
+
output_path=args.output,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main(argv=None):
|
|
116
|
+
parser = argparse.ArgumentParser(
|
|
117
|
+
prog="captnseq-toolkit sites2genes",
|
|
118
|
+
description=__doc__,
|
|
119
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
120
|
+
)
|
|
121
|
+
add_arguments(parser)
|
|
122
|
+
run_args(parser.parse_args(argv))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
main()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: captnseq-toolkit
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Analysis tools for CAPTn-seq transposon-insertion sequencing data (used by nf-core/captnseq)
|
|
5
|
+
Author: Simon Jeanneau
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/simonjeanneau/captnseq-toolkit
|
|
8
|
+
Project-URL: Pipeline, https://github.com/nf-core/proposals/issues/173
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: numpy
|
|
13
|
+
Requires-Dist: pandas
|
|
14
|
+
Requires-Dist: scipy
|
|
15
|
+
Requires-Dist: matplotlib
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest; extra == "test"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# captnseq-toolkit
|
|
21
|
+
|
|
22
|
+
Analysis tools for CAPTn-seq (transposon-insertion sequencing) data. This package holds the
|
|
23
|
+
scientific logic used by the [nf-core/captnseq](https://github.com/nf-core/proposals/issues/173)
|
|
24
|
+
pipeline, so the pipeline itself only contains Nextflow code.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install captnseq-toolkit # once released on PyPI
|
|
30
|
+
conda install -c bioconda captnseq-toolkit # once the Bioconda recipe is merged
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
captnseq-toolkit --help
|
|
37
|
+
captnseq-toolkit sam2sites --help # SAM -> insertion sites (BED + bedGraph)
|
|
38
|
+
captnseq-toolkit sites2genes --help # insertion sites -> per-gene counts
|
|
39
|
+
captnseq-toolkit essentiality --help # per-gene counts -> essential / ambiguous / non-essential
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Development
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install -e ".[test]"
|
|
46
|
+
pytest
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Releases are tagged `vX.Y.Z` (version comes from git tags via setuptools-scm).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
.gitignore
|
|
2
|
+
CHANGELOG.md
|
|
3
|
+
LICENSE
|
|
4
|
+
README.md
|
|
5
|
+
pyproject.toml
|
|
6
|
+
.github/workflows/ci.yml
|
|
7
|
+
recipe/meta.yaml
|
|
8
|
+
src/captnseq_toolkit/__init__.py
|
|
9
|
+
src/captnseq_toolkit/__main__.py
|
|
10
|
+
src/captnseq_toolkit/cli.py
|
|
11
|
+
src/captnseq_toolkit/essentiality.py
|
|
12
|
+
src/captnseq_toolkit/sam2sites.py
|
|
13
|
+
src/captnseq_toolkit/sites2genes.py
|
|
14
|
+
src/captnseq_toolkit.egg-info/PKG-INFO
|
|
15
|
+
src/captnseq_toolkit.egg-info/SOURCES.txt
|
|
16
|
+
src/captnseq_toolkit.egg-info/dependency_links.txt
|
|
17
|
+
src/captnseq_toolkit.egg-info/entry_points.txt
|
|
18
|
+
src/captnseq_toolkit.egg-info/requires.txt
|
|
19
|
+
src/captnseq_toolkit.egg-info/scm_file_list.json
|
|
20
|
+
src/captnseq_toolkit.egg-info/scm_version.json
|
|
21
|
+
src/captnseq_toolkit.egg-info/top_level.txt
|
|
22
|
+
tests/test_cli.py
|
|
23
|
+
tests/test_sites2genes.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": [
|
|
3
|
+
".github/workflows/ci.yml",
|
|
4
|
+
".gitignore",
|
|
5
|
+
"CHANGELOG.md",
|
|
6
|
+
"LICENSE",
|
|
7
|
+
"README.md",
|
|
8
|
+
"pyproject.toml",
|
|
9
|
+
"recipe/meta.yaml",
|
|
10
|
+
"src/captnseq_toolkit/__init__.py",
|
|
11
|
+
"src/captnseq_toolkit/__main__.py",
|
|
12
|
+
"src/captnseq_toolkit/cli.py",
|
|
13
|
+
"src/captnseq_toolkit/essentiality.py",
|
|
14
|
+
"src/captnseq_toolkit/sam2sites.py",
|
|
15
|
+
"src/captnseq_toolkit/sites2genes.py",
|
|
16
|
+
"tests/test_cli.py",
|
|
17
|
+
"tests/test_sites2genes.py"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
captnseq_toolkit
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from captnseq_toolkit import __version__
|
|
8
|
+
from captnseq_toolkit.cli import SUBCOMMANDS, main
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_help_lists_all_subcommands(capsys):
|
|
12
|
+
with pytest.raises(SystemExit) as exc:
|
|
13
|
+
main(["--help"])
|
|
14
|
+
assert exc.value.code == 0
|
|
15
|
+
out = capsys.readouterr().out
|
|
16
|
+
for name in SUBCOMMANDS:
|
|
17
|
+
assert name in out
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_version(capsys):
|
|
21
|
+
with pytest.raises(SystemExit) as exc:
|
|
22
|
+
main(["--version"])
|
|
23
|
+
assert exc.value.code == 0
|
|
24
|
+
assert capsys.readouterr().out.strip() == f"captnseq-toolkit {__version__}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_no_subcommand_is_an_error(capsys):
|
|
28
|
+
with pytest.raises(SystemExit) as exc:
|
|
29
|
+
main([])
|
|
30
|
+
assert exc.value.code == 2
|
|
31
|
+
assert "required" in capsys.readouterr().err
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_unknown_subcommand_is_an_error(capsys):
|
|
35
|
+
with pytest.raises(SystemExit) as exc:
|
|
36
|
+
main(["nope"])
|
|
37
|
+
assert exc.value.code == 2
|
|
38
|
+
assert "invalid choice" in capsys.readouterr().err
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.parametrize("name", list(SUBCOMMANDS))
|
|
42
|
+
def test_subcommand_help(name):
|
|
43
|
+
r = subprocess.run([sys.executable, "-m", "captnseq_toolkit", name, "--help"], capture_output=True, text=True)
|
|
44
|
+
assert r.returncode == 0
|
|
45
|
+
assert f"captnseq-toolkit {name}" in r.stdout
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize("name", list(SUBCOMMANDS))
|
|
49
|
+
def test_subcommand_missing_required_options(name, capsys):
|
|
50
|
+
with pytest.raises(SystemExit) as exc:
|
|
51
|
+
main([name])
|
|
52
|
+
assert exc.value.code == 2
|
|
53
|
+
assert "required" in capsys.readouterr().err
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_sites2genes_runs_through_top_level_cli(tmp_path):
|
|
57
|
+
features = tmp_path / "cds.bed"
|
|
58
|
+
features.write_text("chr\t0\t1000\tgeneA\t0\t1\n")
|
|
59
|
+
sample = tmp_path / "intersect.bed"
|
|
60
|
+
sample.write_text("chr\t0\t1000\tgeneA\t0\t1\tchr\t500\t501\t10\n")
|
|
61
|
+
out = tmp_path / "out.tsv"
|
|
62
|
+
rc = main([
|
|
63
|
+
"sites2genes",
|
|
64
|
+
"--features", str(features),
|
|
65
|
+
"--sample", str(sample),
|
|
66
|
+
"--length-pc-start", "0",
|
|
67
|
+
"--length-pc-stop", "100",
|
|
68
|
+
"--output", str(out),
|
|
69
|
+
])
|
|
70
|
+
assert rc == 0
|
|
71
|
+
df = pd.read_csv(out, sep="\t").set_index("gene_name")
|
|
72
|
+
assert df.loc["geneA", "ins_count"] == 1
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
|
|
3
|
+
from captnseq_toolkit.sites2genes import compute_gene_insertions
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_counts_insertions_inside_gene(tmp_path):
|
|
7
|
+
features = tmp_path / "cds.bed"
|
|
8
|
+
features.write_text("chr\t0\t1000\tgeneA\t0\t1\nchr\t2000\t3000\tgeneB\t0\t1\n")
|
|
9
|
+
# 10 columns: gene BED6 (chr,start,end,name,score,strand) + insertion (chr,start,end,score)
|
|
10
|
+
sample = tmp_path / "intersect.bed"
|
|
11
|
+
sample.write_text(
|
|
12
|
+
"chr\t0\t1000\tgeneA\t0\t1\tchr\t500\t501\t10\n"
|
|
13
|
+
"chr\t0\t1000\tgeneA\t0\t1\tchr\t600\t601\t5\n"
|
|
14
|
+
)
|
|
15
|
+
out = tmp_path / "out.tsv"
|
|
16
|
+
compute_gene_insertions(features, sample, 0, 100, out)
|
|
17
|
+
df = pd.read_csv(out, sep="\t").set_index("gene_name")
|
|
18
|
+
assert df.loc["geneA", "ins_count"] == 2
|
|
19
|
+
assert df.loc["geneA", "read_count"] == 15
|
|
20
|
+
assert df.loc["geneB", "ins_count"] == 0
|