isoends 1.0.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.
- isoends/R/limma_moderated_t.R +30 -0
- isoends/__init__.py +12 -0
- isoends/annotation.py +95 -0
- isoends/apa.py +267 -0
- isoends/apa_run.py +366 -0
- isoends/api.py +453 -0
- isoends/cli.py +115 -0
- isoends/config.py +408 -0
- isoends/figures/__init__.py +73 -0
- isoends/figures/apa.py +478 -0
- isoends/figures/isotracks.py +107 -0
- isoends/figures/pubfig.py +5206 -0
- isoends/figures/style.py +203 -0
- isoends/figures/tails.py +348 -0
- isoends/gtf.py +200 -0
- isoends/hist.py +81 -0
- isoends/ingest.py +377 -0
- isoends/pipeline.py +397 -0
- isoends/report.py +356 -0
- isoends/sites.py +328 -0
- isoends/stats/__init__.py +11 -0
- isoends/stats/basic.py +128 -0
- isoends/stats/cmh.py +43 -0
- isoends/stats/limma.py +629 -0
- isoends/stats/readlevel.py +51 -0
- isoends/synthetic.py +447 -0
- isoends/tails.py +646 -0
- isoends/tailsource.py +238 -0
- isoends/templates.py +82 -0
- isoends/tss_run.py +150 -0
- isoends/util.py +115 -0
- isoends-1.0.0.dist-info/METADATA +317 -0
- isoends-1.0.0.dist-info/RECORD +37 -0
- isoends-1.0.0.dist-info/WHEEL +5 -0
- isoends-1.0.0.dist-info/entry_points.txt +2 -0
- isoends-1.0.0.dist-info/licenses/LICENSE +21 -0
- isoends-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env Rscript
|
|
2
|
+
# limma moderated t-test, one value per unit of replication.
|
|
3
|
+
#
|
|
4
|
+
# Usage: Rscript limma_moderated_t.R values.tsv out.tsv ROBUST [GROUPS]
|
|
5
|
+
# values.tsv features x units, first column "feature"; NA for missing
|
|
6
|
+
# ROBUST TRUE or FALSE (eBayes robust = ...)
|
|
7
|
+
# GROUPS optional, one 0 (control) or 1 (treated) per unit, comma-separated: tests treated - control;
|
|
8
|
+
# without it, tests each feature's mean against zero
|
|
9
|
+
suppressPackageStartupMessages(library(limma))
|
|
10
|
+
args <- commandArgs(trailingOnly = TRUE)
|
|
11
|
+
X <- as.matrix(read.delim(args[1], row.names = 1, check.names = FALSE))
|
|
12
|
+
robust <- toupper(args[3]) == "TRUE"
|
|
13
|
+
if (length(args) >= 4) {
|
|
14
|
+
design <- cbind(control = 1, treated = as.numeric(strsplit(args[4], ",")[[1]]))
|
|
15
|
+
coef <- 2
|
|
16
|
+
} else {
|
|
17
|
+
design <- matrix(1, ncol(X), 1, dimnames = list(colnames(X), "mean"))
|
|
18
|
+
coef <- 1
|
|
19
|
+
}
|
|
20
|
+
fit <- eBayes(lmFit(X, design), robust = robust)
|
|
21
|
+
res <- topTable(fit, coef = coef, number = Inf, sort.by = "none")
|
|
22
|
+
res$n_units <- rowSums(!is.na(X))
|
|
23
|
+
res$df_residual <- fit$df.residual
|
|
24
|
+
res$df_prior <- rep_len(fit$df.prior, nrow(X))
|
|
25
|
+
res$df_total <- fit$df.total
|
|
26
|
+
res$s2_prior <- rep_len(fit$s2.prior, nrow(X))
|
|
27
|
+
res$s2_post <- fit$s2.post
|
|
28
|
+
write.table(data.frame(feature = rownames(res), res, check.names = FALSE), args[2], sep = "\t", quote = FALSE,
|
|
29
|
+
row.names = FALSE)
|
|
30
|
+
cat(sprintf("%s (%s)", as.character(packageVersion("limma")), R.version.string))
|
isoends/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""IsoEnds: poly(A) tail length, alternative polyadenylation and transcription start sites from IsoForge runs,
|
|
2
|
+
tested over biological replicates.
|
|
3
|
+
|
|
4
|
+
import isoends as ie
|
|
5
|
+
res = ie.load("isoends_out")
|
|
6
|
+
res.volcano("tandem")
|
|
7
|
+
"""
|
|
8
|
+
__version__ = "1.0.0"
|
|
9
|
+
|
|
10
|
+
from .api import ANALYSES, Experiment, Results, load # noqa: E402
|
|
11
|
+
|
|
12
|
+
__all__ = ["ANALYSES", "Experiment", "Results", "load", "__version__"]
|
isoends/annotation.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Per-run transcript annotation: structure keys, and categories from the reference and the characterised GTF.
|
|
2
|
+
|
|
3
|
+
Categories: protein coding, retained intron, lncRNA, NMD, other, unclassified.
|
|
4
|
+
Annotated models (ENST) take their reference transcript type.
|
|
5
|
+
Novel models take their SQANTI3 class from the characterised GTF of isoforge-characterize, in this order:
|
|
6
|
+
intron retention -> retained intron; predicted NMD -> NMD; coding -> protein coding; associated with a lncRNA ->
|
|
7
|
+
lncRNA; else other. Without a characterised GTF that describes this run they stay unclassified.
|
|
8
|
+
|
|
9
|
+
The characterised GTF is used only when it describes this run: at least `min_present` of the reads lie on models
|
|
10
|
+
present in it and at least `min_chain_agreement` of the shared models have identical exon chains.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from . import gtf as G
|
|
18
|
+
|
|
19
|
+
CATEGORIES = ("protein coding", "retained intron", "lncRNA", "NMD")
|
|
20
|
+
_REFERENCE_TYPES = {"protein_coding": "protein coding", "retained_intron": "retained intron", "lncRNA": "lncRNA",
|
|
21
|
+
"nonsense_mediated_decay": "NMD"}
|
|
22
|
+
ISOFORGE_ATTRS = ["transcript_id", "gene_id", "gene_name", "transcript_type"]
|
|
23
|
+
SQANTI_ATTRS = ["structural_category", "subcategory", "coding", "predicted_NMD", "is_NMD", "is_IR", "is_lncRNA",
|
|
24
|
+
"ref_transcript_type", "associated_gene", "associated_transcript", "ORF_length", "CDS_length",
|
|
25
|
+
"CDS_start", "CDS_end"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _true(s: pd.Series) -> np.ndarray:
|
|
29
|
+
return s.astype("string").str.upper().isin(["TRUE", "T", "1", "YES"]).fillna(False).to_numpy(bool)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is(s: pd.Series, value: str) -> np.ndarray:
|
|
33
|
+
return s.astype("string").eq(value).fillna(False).to_numpy(bool)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_transcripts(isoforge_gtf, characterised_gtf=None, model_read_counts: pd.Series | None = None,
|
|
37
|
+
min_present: float = 0.5, min_chain_agreement: float = 0.95):
|
|
38
|
+
"""(transcripts, check, exons). transcripts: one row per IsoForge model with gene_id, gene_name, n_exons,
|
|
39
|
+
structure_key, category, category_source and the SQANTI3 fields. check: how well the characterised GTF describes
|
|
40
|
+
the run. exons: the models' exons. model_read_counts: reads per transcript_id, weighting the check."""
|
|
41
|
+
exons = G.model_exons(isoforge_gtf)
|
|
42
|
+
tx = G.model_attributes(isoforge_gtf, ISOFORGE_ATTRS).merge(G.structure_table(exons), on="transcript_id",
|
|
43
|
+
how="left")
|
|
44
|
+
tx["gene_id"] = G.strip_version(tx.gene_id)
|
|
45
|
+
tx["annotated"] = tx.transcript_id.astype(str).str.startswith("ENST").to_numpy()
|
|
46
|
+
check: dict = dict(characterised_gtf=str(characterised_gtf) if characterised_gtf else None)
|
|
47
|
+
char = None
|
|
48
|
+
if characterised_gtf is not None:
|
|
49
|
+
char = G.model_attributes(characterised_gtf, ["transcript_id"] + SQANTI_ATTRS)
|
|
50
|
+
chains = G.structure_table(G.model_exons(characterised_gtf))[["transcript_id", "exon_chain"]]
|
|
51
|
+
both = tx[["transcript_id", "exon_chain"]].merge(chains.rename(columns={"exon_chain": "char_chain"}),
|
|
52
|
+
on="transcript_id")
|
|
53
|
+
weights = (model_read_counts if model_read_counts is not None
|
|
54
|
+
else pd.Series(1.0, index=tx.transcript_id)).astype(float)
|
|
55
|
+
with_reads = weights[(weights > 0) & (weights.index != "")]
|
|
56
|
+
total = with_reads.sum()
|
|
57
|
+
|
|
58
|
+
def share(ids) -> float:
|
|
59
|
+
return float(with_reads[with_reads.index.isin(set(ids))].sum() / total) if len(with_reads) else np.nan
|
|
60
|
+
agree = both.exon_chain == both.char_chain
|
|
61
|
+
characterised = char.structural_category.notna()
|
|
62
|
+
check.update(
|
|
63
|
+
share_reads_on_isoforge_gtf_models=share(tx.transcript_id),
|
|
64
|
+
n_models_with_reads=int(len(with_reads)),
|
|
65
|
+
share_models_present=float(with_reads.index.isin(set(char.transcript_id)).mean())
|
|
66
|
+
if len(with_reads) else np.nan,
|
|
67
|
+
share_reads_present=share(char.transcript_id),
|
|
68
|
+
n_models_compared=int(len(both)),
|
|
69
|
+
share_chain_agreement=float(agree.mean()) if len(both) else np.nan,
|
|
70
|
+
n_models_characterised=int(characterised.sum()),
|
|
71
|
+
share_reads_characterised=share(char.transcript_id[characterised]))
|
|
72
|
+
check["in_sync"] = bool(check["share_chain_agreement"] >= min_chain_agreement
|
|
73
|
+
and check["share_reads_present"] >= min_present)
|
|
74
|
+
check["rule"] = (f"used when >= {min_present:.0%} of reads are on models present in it and >= "
|
|
75
|
+
f"{min_chain_agreement:.0%} of the shared models have identical exon chains")
|
|
76
|
+
char = char[~char.transcript_id.isin(set(both.transcript_id[~agree]))] if check["in_sync"] else None
|
|
77
|
+
else:
|
|
78
|
+
check.update(in_sync=False, rule="no characterised GTF: novel models unclassified")
|
|
79
|
+
if char is not None:
|
|
80
|
+
tx = tx.merge(char, on="transcript_id", how="left")
|
|
81
|
+
else:
|
|
82
|
+
for c in SQANTI_ATTRS:
|
|
83
|
+
tx[c] = pd.NA
|
|
84
|
+
reference_type = tx.ref_transcript_type.fillna(tx.transcript_type)
|
|
85
|
+
tx["reference_type"] = np.where(tx.annotated, reference_type.astype("string").fillna(""), "")
|
|
86
|
+
characterised = ~tx.annotated & tx.structural_category.notna()
|
|
87
|
+
ir = _true(tx.is_IR) | _is(tx.subcategory, "intron_retention")
|
|
88
|
+
nmd = _true(tx.predicted_NMD) | _true(tx.is_NMD)
|
|
89
|
+
coding = _is(tx.coding, "coding")
|
|
90
|
+
lnc = _is(tx.transcript_type, "lncRNA") | _is(tx.ref_transcript_type, "lncRNA") | _true(tx.is_lncRNA)
|
|
91
|
+
novel = np.select([ir, nmd, coding, lnc], ["retained intron", "NMD", "protein coding", "lncRNA"], default="other")
|
|
92
|
+
known = pd.Series(tx.reference_type).map(_REFERENCE_TYPES).fillna("other").to_numpy()
|
|
93
|
+
tx["category"] = np.where(tx.annotated, known, np.where(characterised, novel, "unclassified"))
|
|
94
|
+
tx["category_source"] = np.where(tx.annotated, "reference", np.where(characterised, "SQANTI3", "none"))
|
|
95
|
+
return tx, check, exons
|
isoends/apa.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Tests of site usage: a gene's two most used sites (tandem 3'UTR sites, or two promoters) and classes of sites
|
|
2
|
+
against the reference sites.
|
|
3
|
+
|
|
4
|
+
Within a unit, a paired design is tested with the Cochran-Mantel-Haenszel test on the event x condition tables of
|
|
5
|
+
each replicate pair, and an unpaired design with the moderated two-sample t-test on each sample's usage. Across
|
|
6
|
+
units, the per-unit change is tested with the moderated one-sample t-test. q is over the genes tested.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from .sites import INTRONIC, as_mask, distance_from_stop, same_exon, split_by_model_intron
|
|
14
|
+
from .stats import bh, cmh, mantel_haenszel_or, moderated_one_sample, moderated_two_sample
|
|
15
|
+
|
|
16
|
+
APA_CLASSES = {"composite exon": ("composite exon",), "skipped exon": ("skipped exon",),
|
|
17
|
+
"internal exonic": ("internal exonic",)}
|
|
18
|
+
IPA = {"intronic (all)": INTRONIC}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _site_counts(sites: pd.DataFrame, counts: pd.DataFrame, datasets) -> pd.DataFrame:
|
|
22
|
+
"""The clusters with one read-count column per dataset and their total (reads_unit); clusters without reads in
|
|
23
|
+
these datasets are dropped."""
|
|
24
|
+
w = counts[counts.dataset.isin(datasets)].pivot_table(index="cluster", columns="dataset", values="reads",
|
|
25
|
+
aggfunc="sum", fill_value=0)
|
|
26
|
+
s = sites.set_index("cluster").join(w.reindex(columns=list(datasets), fill_value=0), how="inner")
|
|
27
|
+
s["reads_unit"] = s[list(datasets)].sum(axis=1)
|
|
28
|
+
return s[s.reads_unit > 0]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ratio(x, y):
|
|
32
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
33
|
+
return np.where(y > 0, x / np.maximum(y, 1), np.nan)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def usage_test(event: pd.DataFrame, other: pd.DataFrame, design, params, stats_params, stem: str,
|
|
37
|
+
up: str) -> pd.DataFrame:
|
|
38
|
+
"""Test the usage event / (event + other) between the conditions of one unit, one row per feature.
|
|
39
|
+
|
|
40
|
+
event, other: read counts, features x datasets. Returns reads and usage per condition, delta_<stem>, the test's
|
|
41
|
+
statistic and p, and the replicates behind it; per pair for a paired design (n_pairs_<up>, delta_<stem>_pair<k>,
|
|
42
|
+
mh_odds_ratio, chi2), per sample for an unpaired one (t, n_samples_control, n_samples_treated). The unpaired
|
|
43
|
+
test takes the samples with `min_reads_sample` reads, for features with event and other reads and two such
|
|
44
|
+
samples per condition; when the robust variance prior cannot be estimated the non-robust one is used."""
|
|
45
|
+
ctrl, trt = list(design.control), list(design.treated)
|
|
46
|
+
a, b = event[ctrl].to_numpy(float), other[ctrl].to_numpy(float)
|
|
47
|
+
c, d = event[trt].to_numpy(float), other[trt].to_numpy(float)
|
|
48
|
+
rc, rt = (a + b).sum(axis=1), (c + d).sum(axis=1)
|
|
49
|
+
out = pd.DataFrame({"event_reads_control": a.sum(axis=1), "other_reads_control": b.sum(axis=1),
|
|
50
|
+
"event_reads_treated": c.sum(axis=1), "other_reads_treated": d.sum(axis=1),
|
|
51
|
+
"reads_control": rc, "reads_treated": rt,
|
|
52
|
+
f"{stem}_control": _ratio(a.sum(axis=1), rc), f"{stem}_treated": _ratio(c.sum(axis=1), rt)},
|
|
53
|
+
index=event.index)
|
|
54
|
+
out[f"delta_{stem}"] = out[f"{stem}_treated"] - out[f"{stem}_control"]
|
|
55
|
+
out["design"] = "paired" if design.paired else "unpaired"
|
|
56
|
+
if design.paired:
|
|
57
|
+
per_pair = _ratio(c, c + d) - _ratio(a, a + b)
|
|
58
|
+
out["n_pairs"] = len(design.pairs)
|
|
59
|
+
out[f"n_pairs_{up}"] = (per_pair > 0).sum(axis=1)
|
|
60
|
+
out["pairs"] = ";".join(label for label, _, _ in design.pairs)
|
|
61
|
+
for k in range(len(design.pairs)):
|
|
62
|
+
out[f"delta_{stem}_pair{k + 1}"] = per_pair[:, k]
|
|
63
|
+
out["mh_odds_ratio"] = mantel_haenszel_or(a, b, c, d)
|
|
64
|
+
out["chi2"], out["p"] = cmh(a, b, c, d)
|
|
65
|
+
return out
|
|
66
|
+
uc = pd.DataFrame(np.where(a + b >= params.min_reads_sample, _ratio(a, a + b), np.nan), index=event.index)
|
|
67
|
+
ut = pd.DataFrame(np.where(c + d >= params.min_reads_sample, _ratio(c, c + d), np.nan), index=event.index)
|
|
68
|
+
out["n_samples_control"], out["n_samples_treated"] = uc.notna().sum(axis=1), ut.notna().sum(axis=1)
|
|
69
|
+
testable = ((out.n_samples_control >= 2) & (out.n_samples_treated >= 2)
|
|
70
|
+
& (out.event_reads_control + out.event_reads_treated > 0)
|
|
71
|
+
& (out.other_reads_control + out.other_reads_treated > 0))
|
|
72
|
+
kw = dict(backend=stats_params.limma_backend, rscript=stats_params.rscript)
|
|
73
|
+
try:
|
|
74
|
+
res = moderated_two_sample(uc[testable], ut[testable], robust=stats_params.robust, **kw)
|
|
75
|
+
except (ValueError, RuntimeError):
|
|
76
|
+
res = moderated_two_sample(uc[testable], ut[testable], robust=False, **kw)
|
|
77
|
+
out["t"], out["p"] = res.t.reindex(out.index), res.p.reindex(out.index)
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def top_two_within_unit(unit: str, sites: pd.DataFrame, counts: pd.DataFrame, design, params, stats_params,
|
|
82
|
+
candidates: pd.Series, gene_names: pd.Series | None = None) -> pd.DataFrame:
|
|
83
|
+
"""Per gene, its two most used candidate sites in the unit (equally used sites: the lower coordinate first) and
|
|
84
|
+
the test of distal usage between the conditions.
|
|
85
|
+
|
|
86
|
+
sites: the run's classified clusters; counts: cluster, dataset, reads; candidates: boolean per cluster (indexed
|
|
87
|
+
by cluster). PDUI = distal / (proximal + distal) reads. Genes with fewer than `min_reads` reads at the two sites
|
|
88
|
+
in a condition, or without a test, are dropped."""
|
|
89
|
+
s = _site_counts(sites, counts, design.datasets)
|
|
90
|
+
s = s[candidates.reindex(s.index).fillna(False).to_numpy(bool)].copy()
|
|
91
|
+
s["order"] = np.where(s.strand == "+", s.site, -s.site)
|
|
92
|
+
n_sites = s.groupby("gene_id").size()
|
|
93
|
+
s = s[s.gene_id.isin(n_sites.index[n_sites >= 2])].reset_index()
|
|
94
|
+
if s.empty:
|
|
95
|
+
return pd.DataFrame()
|
|
96
|
+
two = (s.sort_values(["gene_id", "reads_unit", "site"], ascending=[True, False, True], kind="stable")
|
|
97
|
+
.groupby("gene_id").head(2).sort_values(["gene_id", "order"], kind="stable"))
|
|
98
|
+
prox = two.groupby("gene_id").head(1).set_index("gene_id")
|
|
99
|
+
dist = two.groupby("gene_id").tail(1).set_index("gene_id").loc[prox.index]
|
|
100
|
+
test = usage_test(dist[design.datasets], prox[design.datasets], design, params, stats_params, "pdui", "distal")
|
|
101
|
+
test = test.rename(columns={"event_reads_control": "reads_distal_control",
|
|
102
|
+
"other_reads_control": "reads_proximal_control",
|
|
103
|
+
"event_reads_treated": "reads_distal_treated",
|
|
104
|
+
"other_reads_treated": "reads_proximal_treated",
|
|
105
|
+
"mh_odds_ratio": "mh_odds_ratio_distal"})
|
|
106
|
+
out = pd.DataFrame({"unit": unit, "gene_id": prox.index, "chrom": prox.chrom.to_numpy(),
|
|
107
|
+
"strand": prox.strand.to_numpy(), "n_utr_sites": n_sites.reindex(prox.index).to_numpy(),
|
|
108
|
+
"proximal_cluster": prox.cluster.to_numpy(), "distal_cluster": dist.cluster.to_numpy(),
|
|
109
|
+
"proximal_site": prox.site.to_numpy(), "distal_site": dist.site.to_numpy(),
|
|
110
|
+
"proximal_hexamer": prox.hexamer.to_numpy(), "distal_hexamer": dist.hexamer.to_numpy()})
|
|
111
|
+
out = pd.concat([out, test.reset_index(drop=True)], axis=1)
|
|
112
|
+
out["utr_span_nt"] = (out.distal_site - out.proximal_site).abs()
|
|
113
|
+
out["length_gained_nt"] = out.delta_pdui * out.utr_span_nt
|
|
114
|
+
if gene_names is not None:
|
|
115
|
+
out.insert(2, "gene_name", out.gene_id.map(gene_names).fillna(""))
|
|
116
|
+
ok = (out.reads_control >= params.min_reads) & (out.reads_treated >= params.min_reads) & out.p.notna()
|
|
117
|
+
return out[ok].reset_index(drop=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def tandem_candidates(sites: pd.DataFrame, params) -> pd.Series:
|
|
121
|
+
"""The clusters a tandem 3'UTR pair is chosen from, as a boolean indexed by cluster."""
|
|
122
|
+
if params.tandem_sites == "terminal":
|
|
123
|
+
chosen = sites.site_class == "terminal"
|
|
124
|
+
elif params.tandem_sites == "three_prime_utr":
|
|
125
|
+
chosen = sites.feature == "three_prime_utr"
|
|
126
|
+
else:
|
|
127
|
+
raise ValueError(f"tandem_sites must be 'terminal' or 'three_prime_utr', not {params.tandem_sites!r}")
|
|
128
|
+
return pd.Series(chosen.to_numpy(), index=sites.cluster)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def annotate_tandem(t: pd.DataFrame, ref: dict, model_exons: pd.DataFrame, terminal: pd.DataFrame,
|
|
132
|
+
params) -> pd.DataFrame:
|
|
133
|
+
"""Add the same-exon check (same_exon, share_distal_models_split, exclusion_reason, is_tandem), q over the
|
|
134
|
+
unit's tandem genes and each site's distance from the stop codon."""
|
|
135
|
+
if t.empty:
|
|
136
|
+
return t
|
|
137
|
+
t = t.copy()
|
|
138
|
+
q = t.rename(columns={"proximal_site": "proximal", "distal_site": "distal"})
|
|
139
|
+
t["same_exon"] = same_exon(q, ref["exons"], params.downstream_nt).array
|
|
140
|
+
t["share_distal_models_split"] = split_by_model_intron(q, model_exons, terminal, params.model_window_nt).to_numpy()
|
|
141
|
+
reason = np.select([as_mask(t.same_exon.eq(False)), as_mask(t.share_distal_models_split > 0.5)],
|
|
142
|
+
["no annotated exon holds both sites", "the distal-site models splice over the proximal site"],
|
|
143
|
+
default="")
|
|
144
|
+
t["exclusion_reason"] = reason if params.same_exon_check else ""
|
|
145
|
+
t["is_tandem"] = t.exclusion_reason == ""
|
|
146
|
+
t["q"] = np.nan
|
|
147
|
+
t.loc[t.is_tandem, "q"] = bh(t.loc[t.is_tandem, "p"])
|
|
148
|
+
for side in ("proximal", "distal"):
|
|
149
|
+
t[f"{side}_from_stop_nt"] = distance_from_stop(t.gene_id, t[f"{side}_site"], ref["canonical"], ref["stop"],
|
|
150
|
+
params.downstream_nt)
|
|
151
|
+
return t
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def site_models(rows: pd.DataFrame, site_cols, model_reads: pd.DataFrame, terminal: pd.DataFrame,
|
|
155
|
+
window_nt: int = 100) -> pd.DataFrame:
|
|
156
|
+
"""For each row and site column: the best-supported model of the gene ending within `window_nt` of the site,
|
|
157
|
+
with its reads per condition. model_reads: gene, transcript, reads_control, reads_treated."""
|
|
158
|
+
out = pd.DataFrame(index=rows.index)
|
|
159
|
+
mr = model_reads.merge(terminal[["transcript_id", "site"]].rename(columns={"transcript_id": "transcript",
|
|
160
|
+
"site": "end3"}), on="transcript")
|
|
161
|
+
mr["total"] = mr.reads_control + mr.reads_treated
|
|
162
|
+
for col in site_cols:
|
|
163
|
+
q = rows[["gene_id", col]].rename(columns={col: "pos"}).assign(row=rows.index)
|
|
164
|
+
m = q.merge(mr, left_on="gene_id", right_on="gene")
|
|
165
|
+
m = m[(m.end3 - m.pos).abs() <= window_nt]
|
|
166
|
+
best = m.sort_values(["row", "total", "transcript"], ascending=[True, False, True]).drop_duplicates("row")
|
|
167
|
+
best = best.set_index("row")
|
|
168
|
+
stem = col.replace("_site", "")
|
|
169
|
+
out[f"{stem}_model"] = best.transcript.reindex(rows.index)
|
|
170
|
+
out[f"{stem}_model_reads_control"] = best.reads_control.reindex(rows.index)
|
|
171
|
+
out[f"{stem}_model_reads_treated"] = best.reads_treated.reindex(rows.index)
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def match_pairs(t: pd.DataFrame, tol: int = 50, valid: str | None = None) -> pd.DataFrame:
|
|
176
|
+
"""Across units: each gene's reference proximal / distal pair, the pair matched within `tol` nt at both ends by
|
|
177
|
+
the most units (ties: more reads), and same_pair for the units whose pair matches it. With `valid`, the
|
|
178
|
+
reference is chosen among the rows where that column is true."""
|
|
179
|
+
t = t.copy()
|
|
180
|
+
t["reference_proximal"] = np.nan
|
|
181
|
+
t["reference_distal"] = np.nan
|
|
182
|
+
t["same_pair"] = False
|
|
183
|
+
reads = (t.reads_control + t.reads_treated).to_numpy()
|
|
184
|
+
usable = t[valid].to_numpy(bool) if valid else np.ones(len(t), bool)
|
|
185
|
+
position = pd.Series(np.arange(len(t)), index=t.index)
|
|
186
|
+
for _, idx in t.groupby("gene_id").groups.items():
|
|
187
|
+
rows = position[idx].to_numpy()
|
|
188
|
+
p, d = t.proximal_site.to_numpy()[rows], t.distal_site.to_numpy()[rows]
|
|
189
|
+
ok = usable[rows] if usable[rows].any() else np.ones(len(rows), bool)
|
|
190
|
+
m = (np.abs(p[:, None] - p[None, :]) <= tol) & (np.abs(d[:, None] - d[None, :]) <= tol)
|
|
191
|
+
score = np.where(ok, (m & ok[None, :]).sum(axis=1), -1)
|
|
192
|
+
best = np.lexsort((-reads[rows], -score))[0]
|
|
193
|
+
t.loc[idx, "reference_proximal"] = p[best]
|
|
194
|
+
t.loc[idx, "reference_distal"] = d[best]
|
|
195
|
+
t.loc[idx, "same_pair"] = m[best]
|
|
196
|
+
return t
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def across_units(table: pd.DataFrame, value: str, min_units: int, stats_params, up_label: str, down_label: str,
|
|
200
|
+
extra: dict | None = None) -> pd.DataFrame:
|
|
201
|
+
"""Moderated one-sample t-test of one value per gene and unit over the units, for genes measured in at least
|
|
202
|
+
`min_units` units. table: gene_id, unit, `value` and optionally q_within. Returns per gene: mean_<value>, t, p,
|
|
203
|
+
q, n_units, median_<value>, the units moving each way and n_units_q_within."""
|
|
204
|
+
if table.empty:
|
|
205
|
+
return pd.DataFrame()
|
|
206
|
+
X = table.pivot_table(index="gene_id", columns="unit", values=value, aggfunc="first")
|
|
207
|
+
X = X[X.notna().sum(axis=1) >= min_units]
|
|
208
|
+
if X.empty:
|
|
209
|
+
return pd.DataFrame()
|
|
210
|
+
res = moderated_one_sample(X, robust=stats_params.robust, backend=stats_params.limma_backend,
|
|
211
|
+
rscript=stats_params.rscript)
|
|
212
|
+
estimator = res.attrs.get("estimator", "")
|
|
213
|
+
res = res.rename(columns={"mean": f"mean_{value}"})
|
|
214
|
+
X = X.reindex(res.index)
|
|
215
|
+
res[f"median_{value}"] = X.median(axis=1)
|
|
216
|
+
res[f"n_units_{up_label}"] = (X > 0).sum(axis=1)
|
|
217
|
+
res[f"n_units_{down_label}"] = (X < 0).sum(axis=1)
|
|
218
|
+
if "q_within" in table:
|
|
219
|
+
sig = table[table.q_within < stats_params.alpha].groupby("gene_id").size()
|
|
220
|
+
res["n_units_q_within"] = sig.reindex(res.index).fillna(0).astype(int)
|
|
221
|
+
for k, v in (extra or {}).items():
|
|
222
|
+
res[k] = v.reindex(res.index)
|
|
223
|
+
res = res.rename_axis("gene_id").reset_index()
|
|
224
|
+
res.attrs["estimator"] = estimator
|
|
225
|
+
return res
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def class_within_unit(unit: str, sites: pd.DataFrame, counts: pd.DataFrame, design, label: str, in_class: pd.Series,
|
|
229
|
+
params, stats_params, gene_names: pd.Series | None = None, reference: str = "terminal",
|
|
230
|
+
class_column: str = "apa_class") -> pd.DataFrame:
|
|
231
|
+
"""Per gene: reads at the sites of one class against reads at the `reference` sites, tested between the
|
|
232
|
+
conditions of the unit; q over the genes tested. in_class: boolean per cluster (indexed by cluster)."""
|
|
233
|
+
s = _site_counts(sites, counts, design.datasets)
|
|
234
|
+
chosen = in_class.reindex(s.index).fillna(False).to_numpy(bool)
|
|
235
|
+
s["side"] = np.where(chosen, "class", np.where(s.site_class == reference, "reference", ""))
|
|
236
|
+
s = s[s.side != ""]
|
|
237
|
+
if s.empty:
|
|
238
|
+
return pd.DataFrame()
|
|
239
|
+
by = s.groupby(["gene_id", "side"])[design.datasets].sum()
|
|
240
|
+
genes = by.index.get_level_values(0).unique()
|
|
241
|
+
by = by.reindex(pd.MultiIndex.from_product([genes, ["class", "reference"]])).fillna(0)
|
|
242
|
+
test = usage_test(by.xs("class", level=1), by.xs("reference", level=1), design, params, stats_params, "share",
|
|
243
|
+
"up")
|
|
244
|
+
test = test.rename(columns={"event_reads_control": "class_reads_control",
|
|
245
|
+
"other_reads_control": "reference_reads_control",
|
|
246
|
+
"event_reads_treated": "class_reads_treated",
|
|
247
|
+
"other_reads_treated": "reference_reads_treated",
|
|
248
|
+
"mh_odds_ratio": "mh_odds_ratio_class"})
|
|
249
|
+
cls = s[s.side == "class"].reset_index()
|
|
250
|
+
main = (cls.sort_values(["gene_id", "reads_unit", "site"], ascending=[True, False, True])
|
|
251
|
+
.drop_duplicates("gene_id").set_index("gene_id").reindex(genes))
|
|
252
|
+
out = pd.DataFrame({"unit": unit, "gene_id": genes, class_column: label})
|
|
253
|
+
out = pd.concat([out, test.reset_index(drop=True)], axis=1)
|
|
254
|
+
out["class_reads"] = out.class_reads_control + out.class_reads_treated
|
|
255
|
+
out["n_class_sites"] = cls.groupby("gene_id").size().reindex(genes).fillna(0).astype(int).to_numpy()
|
|
256
|
+
out["main_site"] = main.site.to_numpy()
|
|
257
|
+
out["main_site_class"] = main.site_class.to_numpy()
|
|
258
|
+
out["main_site_reads"] = main.reads_unit.to_numpy()
|
|
259
|
+
out["main_site_intron"] = main.intron.to_numpy() if "intron" in main else np.nan
|
|
260
|
+
out["main_site_exon_rank"] = main.exon_rank.to_numpy() if "exon_rank" in main else np.nan
|
|
261
|
+
if gene_names is not None:
|
|
262
|
+
out.insert(2, "gene_name", out.gene_id.map(gene_names).fillna(""))
|
|
263
|
+
ok = ((out.reads_control >= params.min_reads) & (out.reads_treated >= params.min_reads) & (out.class_reads > 0)
|
|
264
|
+
& out.p.notna())
|
|
265
|
+
out = out[ok].reset_index(drop=True)
|
|
266
|
+
out["q"] = bh(out.p)
|
|
267
|
+
return out
|