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.
@@ -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