dienr 0.1.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.
- dienr/__init__.py +121 -0
- dienr/annotation.py +268 -0
- dienr/cli.py +769 -0
- dienr/contigs.py +209 -0
- dienr/counts.py +585 -0
- dienr/design.py +696 -0
- dienr/diagnostics.py +113 -0
- dienr/loci.py +104 -0
- dienr/pipeline.py +464 -0
- dienr/results.py +456 -0
- dienr/runon.py +486 -0
- dienr/shape.py +823 -0
- dienr/solvers/__init__.py +39 -0
- dienr/solvers/_glm.py +177 -0
- dienr/solvers/_lbfgsb.py +180 -0
- dienr/solvers/inference.py +243 -0
- dienr/solvers/legacy.py +387 -0
- dienr/solvers/penalties.py +129 -0
- dienr/solvers/poisson.py +350 -0
- dienr/status.py +69 -0
- dienr/strand.py +195 -0
- dienr-0.1.0.dist-info/METADATA +144 -0
- dienr-0.1.0.dist-info/RECORD +26 -0
- dienr-0.1.0.dist-info/WHEEL +4 -0
- dienr-0.1.0.dist-info/entry_points.txt +2 -0
- dienr-0.1.0.dist-info/licenses/LICENSE +21 -0
dienr/__init__.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""dienr: Deconvolution of Isoform Expression from Nascent RNAs.
|
|
2
|
+
|
|
3
|
+
A Python reimplementation of the DENR method, which explains observed nascent
|
|
4
|
+
RNA signal at a locus as a nonnegative mixture of pre-RNA isoform models.
|
|
5
|
+
|
|
6
|
+
Typical use::
|
|
7
|
+
|
|
8
|
+
import dienr
|
|
9
|
+
|
|
10
|
+
annotation = dienr.read_annotation("gencode.gtf")
|
|
11
|
+
design = dienr.build_design(annotation, bin_size=250, mask_start_bins=(4, 4))
|
|
12
|
+
counts = dienr.count_sample(design, "sample1", (plus, minus))
|
|
13
|
+
result = dienr.fit(design, counts)
|
|
14
|
+
result.write("results/")
|
|
15
|
+
|
|
16
|
+
Three likelihoods are available through `fit`. ``"legacy"`` is the default and
|
|
17
|
+
the only one checked against R DENR. The count likelihoods --- ``"poisson"``
|
|
18
|
+
and ``"quasi_poisson"`` --- require count-equivalent
|
|
19
|
+
input, so counts must be extracted with ``summary="sum"`` rather than the
|
|
20
|
+
parity default of ``"mean"``; `fit` rejects the mismatch rather than quietly
|
|
21
|
+
fitting a density.
|
|
22
|
+
|
|
23
|
+
``"quasi_poisson"`` returns the same abundances as ``"poisson"`` and differs
|
|
24
|
+
only in the reported standard errors. It is the recommended choice for
|
|
25
|
+
overdispersed counts: a Poisson standard error understates the true spread by
|
|
26
|
+
23% at dispersion 0.1 and 64% at 1.0, and quasi-Poisson matched a negative
|
|
27
|
+
binomial's errors to within a few percent, which is why there is no NB solver
|
|
28
|
+
(plan Gate D).
|
|
29
|
+
|
|
30
|
+
Coordinates are 0-based half-open throughout; see `dienr.annotation` for the
|
|
31
|
+
single conversion point.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from importlib import metadata as _metadata
|
|
35
|
+
|
|
36
|
+
from .annotation import Annotation, read_annotation
|
|
37
|
+
from .contigs import ContigMap, check_contigs, rename_contigs
|
|
38
|
+
from .counts import (
|
|
39
|
+
SampleCounts,
|
|
40
|
+
SignalFile,
|
|
41
|
+
count_sample,
|
|
42
|
+
declared_contigs,
|
|
43
|
+
read_binned,
|
|
44
|
+
read_loci_binned,
|
|
45
|
+
)
|
|
46
|
+
from .design import Design, DesignConfig, build_design
|
|
47
|
+
from .diagnostics import LocusConditioning, assess
|
|
48
|
+
from .loci import assign_loci, locus_table
|
|
49
|
+
from .pipeline import fit, status_report
|
|
50
|
+
from .results import SCHEMA_VERSION, FitResult, RunSummary, collect_results
|
|
51
|
+
from .runon import RunonConfig, RunonTail, apply_runon_tail, fit_runon_tail
|
|
52
|
+
from .shape import (
|
|
53
|
+
ShapeConfig,
|
|
54
|
+
ShapeProfile,
|
|
55
|
+
applied_dynamic_range,
|
|
56
|
+
apply_shape_profile,
|
|
57
|
+
fit_shape_profile,
|
|
58
|
+
rescale_position,
|
|
59
|
+
)
|
|
60
|
+
from .solvers import inference, penalties, poisson
|
|
61
|
+
from .solvers._glm import Exposure
|
|
62
|
+
from .solvers.legacy import LocusFit, fit_locus
|
|
63
|
+
from .solvers.penalties import NO_PENALTY, Penalty
|
|
64
|
+
from .status import FitStatus, is_failure
|
|
65
|
+
from .strand import StrandCheck, check_strand_orientation
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
# pyproject.toml is the only place the version is written.
|
|
69
|
+
__version__ = _metadata.version("dienr")
|
|
70
|
+
except _metadata.PackageNotFoundError: # imported from a source tree, not installed
|
|
71
|
+
__version__ = "0+unknown"
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"SCHEMA_VERSION",
|
|
75
|
+
"Annotation",
|
|
76
|
+
"Design",
|
|
77
|
+
"DesignConfig",
|
|
78
|
+
"NO_PENALTY",
|
|
79
|
+
"Exposure",
|
|
80
|
+
"FitResult",
|
|
81
|
+
"FitStatus",
|
|
82
|
+
"LocusConditioning",
|
|
83
|
+
"LocusFit",
|
|
84
|
+
"Penalty",
|
|
85
|
+
"RunSummary",
|
|
86
|
+
"RunonConfig",
|
|
87
|
+
"RunonTail",
|
|
88
|
+
"SampleCounts",
|
|
89
|
+
"SignalFile",
|
|
90
|
+
"StrandCheck",
|
|
91
|
+
"ContigMap",
|
|
92
|
+
"check_contigs",
|
|
93
|
+
"rename_contigs",
|
|
94
|
+
"ShapeConfig",
|
|
95
|
+
"ShapeProfile",
|
|
96
|
+
"__version__",
|
|
97
|
+
"applied_dynamic_range",
|
|
98
|
+
"apply_runon_tail",
|
|
99
|
+
"apply_shape_profile",
|
|
100
|
+
"assess",
|
|
101
|
+
"assign_loci",
|
|
102
|
+
"build_design",
|
|
103
|
+
"check_strand_orientation",
|
|
104
|
+
"collect_results",
|
|
105
|
+
"count_sample",
|
|
106
|
+
"declared_contigs",
|
|
107
|
+
"fit",
|
|
108
|
+
"fit_locus",
|
|
109
|
+
"fit_runon_tail",
|
|
110
|
+
"fit_shape_profile",
|
|
111
|
+
"is_failure",
|
|
112
|
+
"inference",
|
|
113
|
+
"locus_table",
|
|
114
|
+
"penalties",
|
|
115
|
+
"poisson",
|
|
116
|
+
"read_annotation",
|
|
117
|
+
"read_binned",
|
|
118
|
+
"read_loci_binned",
|
|
119
|
+
"rescale_position",
|
|
120
|
+
"status_report",
|
|
121
|
+
]
|
dienr/annotation.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""GTF/GFF parsing and pre-RNA span construction.
|
|
2
|
+
|
|
3
|
+
Coordinate policy (plan section 6): coordinates are 0-based half-open
|
|
4
|
+
everywhere inside this package. GTF/GFF is 1-based inclusive, and the single
|
|
5
|
+
``start - 1`` conversion happens here, at the parser boundary, and nowhere else.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import polars as pl
|
|
15
|
+
|
|
16
|
+
#: The nine fixed GTF/GFF columns, in file order.
|
|
17
|
+
_GXF_COLUMNS = [
|
|
18
|
+
"contig",
|
|
19
|
+
"source",
|
|
20
|
+
"feature",
|
|
21
|
+
"start",
|
|
22
|
+
"end",
|
|
23
|
+
"score",
|
|
24
|
+
"strand",
|
|
25
|
+
"frame",
|
|
26
|
+
"attributes",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
#: Schema of a normalized transcript table. Order is part of the contract.
|
|
30
|
+
TRANSCRIPT_SCHEMA = {
|
|
31
|
+
"transcript_id": pl.String,
|
|
32
|
+
"gene_id": pl.String,
|
|
33
|
+
"contig": pl.String,
|
|
34
|
+
"start": pl.Int64,
|
|
35
|
+
"end": pl.Int64,
|
|
36
|
+
"strand": pl.String,
|
|
37
|
+
"coord_source": pl.String,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_VALID_STRANDS = ("+", "-")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _attribute(column: str, key: str) -> pl.Expr:
|
|
44
|
+
"""Extract `key` from a GTF or GFF3 attribute string.
|
|
45
|
+
|
|
46
|
+
GTF writes ``key "value";`` and GFF3 writes ``key=value;``. Both forms are
|
|
47
|
+
tried so a single parser handles either dialect.
|
|
48
|
+
"""
|
|
49
|
+
gtf = pl.col(column).str.extract(rf'{key}\s+"([^"]*)"', 1)
|
|
50
|
+
gff3 = pl.col(column).str.extract(rf"{key}=([^;]*)", 1)
|
|
51
|
+
return pl.coalesce(gtf, gff3)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Annotation:
|
|
56
|
+
"""Validated transcript records defining continuous pre-RNA spans.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
transcripts: One row per transcript, conforming to `TRANSCRIPT_SCHEMA`.
|
|
60
|
+
``start``/``end`` are 0-based half-open.
|
|
61
|
+
digest: Content-derived identifier. Any change to the transcript table
|
|
62
|
+
produces a new digest, which in turn invalidates a `Design`.
|
|
63
|
+
source: Provenance string, normally the input path.
|
|
64
|
+
contig_map: The ``(old, new)`` contig renaming applied by
|
|
65
|
+
`dienr.contigs.rename_contigs`, if any, so it can be recorded
|
|
66
|
+
downstream. Renamed contigs are already in `transcripts`.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
transcripts: pl.DataFrame
|
|
70
|
+
digest: str
|
|
71
|
+
source: str
|
|
72
|
+
contig_map: tuple[tuple[str, str], ...] | None = None
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
got = self.transcripts.schema
|
|
76
|
+
for name, dtype in TRANSCRIPT_SCHEMA.items():
|
|
77
|
+
if name not in got:
|
|
78
|
+
raise ValueError(f"transcript table is missing required column {name!r}")
|
|
79
|
+
if got[name] != dtype:
|
|
80
|
+
raise ValueError(f"column {name!r} has dtype {got[name]}, expected {dtype}")
|
|
81
|
+
|
|
82
|
+
def __len__(self) -> int:
|
|
83
|
+
return self.transcripts.height
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def contigs(self) -> list[str]:
|
|
87
|
+
"""Sorted contig names referenced by the annotation."""
|
|
88
|
+
return sorted(self.transcripts["contig"].unique().to_list())
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_frame(cls, frame: pl.DataFrame, *, source: str = "in-memory") -> Annotation:
|
|
92
|
+
"""Build an `Annotation` from an already 0-based half-open table.
|
|
93
|
+
|
|
94
|
+
Intended for tests and programmatic construction. Input must carry
|
|
95
|
+
``transcript_id``, ``contig``, ``start``, ``end`` and ``strand``;
|
|
96
|
+
``gene_id`` and ``coord_source`` are filled with nulls and
|
|
97
|
+
``"provided"`` respectively when absent.
|
|
98
|
+
"""
|
|
99
|
+
frame = frame.with_columns(
|
|
100
|
+
gene_id=pl.col("gene_id") if "gene_id" in frame.columns else pl.lit(None, pl.String),
|
|
101
|
+
coord_source=(
|
|
102
|
+
pl.col("coord_source")
|
|
103
|
+
if "coord_source" in frame.columns
|
|
104
|
+
else pl.lit("provided", pl.String)
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
table = _finalize(frame.lazy())
|
|
108
|
+
return cls(transcripts=table, digest=_digest(table, source), source=source)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_annotation(
|
|
112
|
+
path: str | Path,
|
|
113
|
+
*,
|
|
114
|
+
transcript_key: str = "transcript_id",
|
|
115
|
+
gene_key: str = "gene_id",
|
|
116
|
+
span_features: tuple[str, ...] = ("transcript", "exon"),
|
|
117
|
+
) -> Annotation:
|
|
118
|
+
"""Read a GTF/GFF file into pre-RNA transcript spans.
|
|
119
|
+
|
|
120
|
+
A pre-RNA model is the continuous span from a transcript's annotated TSS to
|
|
121
|
+
its PAS, so each transcript's span is the minimum start and maximum end over
|
|
122
|
+
all of its records. Exonic structure is deliberately discarded: nascent
|
|
123
|
+
transcription covers introns.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
path: Path to a GTF or GFF file. ``.gz`` is handled by Polars.
|
|
127
|
+
transcript_key: Attribute key holding the transcript identifier.
|
|
128
|
+
gene_key: Attribute key holding the gene identifier.
|
|
129
|
+
span_features: Feature types contributing to a span. ``exon`` is
|
|
130
|
+
included so annotations lacking explicit ``transcript`` rows still
|
|
131
|
+
yield correct spans.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
An `Annotation` with 0-based half-open coordinates.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ValueError: If no usable records are found, or if any transcript has an
|
|
138
|
+
unusable strand or spans more than one contig or strand.
|
|
139
|
+
"""
|
|
140
|
+
path = Path(path)
|
|
141
|
+
lazy = (
|
|
142
|
+
pl.scan_csv(
|
|
143
|
+
path,
|
|
144
|
+
separator="\t",
|
|
145
|
+
comment_prefix="#",
|
|
146
|
+
has_header=False,
|
|
147
|
+
new_columns=_GXF_COLUMNS,
|
|
148
|
+
# Contig must be a string: Ensembl names ("1", "2", ...) would
|
|
149
|
+
# otherwise be inferred as integers and fail at "X".
|
|
150
|
+
schema_overrides={"contig": pl.String, "start": pl.Int64, "end": pl.Int64},
|
|
151
|
+
truncate_ragged_lines=False,
|
|
152
|
+
)
|
|
153
|
+
.filter(pl.col("feature").is_in(span_features))
|
|
154
|
+
.select(
|
|
155
|
+
pl.col("contig").cast(pl.String),
|
|
156
|
+
# The one and only 1-based inclusive -> 0-based half-open conversion.
|
|
157
|
+
(pl.col("start") - 1).alias("start"),
|
|
158
|
+
pl.col("end"),
|
|
159
|
+
pl.col("strand").cast(pl.String),
|
|
160
|
+
pl.col("feature"),
|
|
161
|
+
_attribute("attributes", transcript_key).alias("transcript_id"),
|
|
162
|
+
_attribute("attributes", gene_key).alias("gene_id"),
|
|
163
|
+
)
|
|
164
|
+
.filter(pl.col("transcript_id").is_not_null())
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
spans = lazy.group_by("transcript_id").agg(
|
|
168
|
+
contig=pl.col("contig").unique().sort(),
|
|
169
|
+
strand=pl.col("strand").unique().sort(),
|
|
170
|
+
start=pl.col("start").min(),
|
|
171
|
+
end=pl.col("end").max(),
|
|
172
|
+
gene_id=pl.col("gene_id").drop_nulls().first(),
|
|
173
|
+
# Record whether the span came from a real transcript feature or was
|
|
174
|
+
# reconstructed from subfeatures (plan section 8).
|
|
175
|
+
has_transcript_feature=(pl.col("feature") == "transcript").any(),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
frame = spans.collect()
|
|
179
|
+
if frame.height == 0:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"{path}: no records with attribute {transcript_key!r} among features {span_features}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
_reject_split_transcripts(frame)
|
|
185
|
+
|
|
186
|
+
frame = frame.with_columns(
|
|
187
|
+
contig=pl.col("contig").list.first(),
|
|
188
|
+
strand=pl.col("strand").list.first(),
|
|
189
|
+
coord_source=pl.when(pl.col("has_transcript_feature"))
|
|
190
|
+
.then(pl.lit("transcript_feature"))
|
|
191
|
+
.otherwise(pl.lit("exon_reconstructed")),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
table = _finalize(frame.lazy())
|
|
195
|
+
return Annotation(transcripts=table, digest=_digest(table, str(path)), source=str(path))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _reject_split_transcripts(frame: pl.DataFrame) -> None:
|
|
199
|
+
"""Fail loudly if a transcript_id appears on multiple contigs or strands.
|
|
200
|
+
|
|
201
|
+
Silently taking the first would fabricate a span across unrelated loci.
|
|
202
|
+
"""
|
|
203
|
+
for column in ("contig", "strand"):
|
|
204
|
+
bad = frame.filter(pl.col(column).list.len() > 1)
|
|
205
|
+
if bad.height:
|
|
206
|
+
offenders = bad["transcript_id"].head(5).to_list()
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"{bad.height} transcript(s) span more than one {column}, "
|
|
209
|
+
f"e.g. {offenders}. Transcript identifiers must be unique per "
|
|
210
|
+
f"{column}."
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _finalize(lazy: pl.LazyFrame) -> pl.DataFrame:
|
|
215
|
+
"""Validate, order and sort a transcript table into `TRANSCRIPT_SCHEMA`."""
|
|
216
|
+
table = (
|
|
217
|
+
lazy.select(
|
|
218
|
+
pl.col("transcript_id").cast(pl.String),
|
|
219
|
+
pl.col("gene_id").cast(pl.String),
|
|
220
|
+
pl.col("contig").cast(pl.String),
|
|
221
|
+
pl.col("start").cast(pl.Int64),
|
|
222
|
+
pl.col("end").cast(pl.Int64),
|
|
223
|
+
pl.col("strand").cast(pl.String),
|
|
224
|
+
pl.col("coord_source").cast(pl.String),
|
|
225
|
+
)
|
|
226
|
+
.sort("contig", "strand", "start", "end", "transcript_id")
|
|
227
|
+
.collect()
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
bad_strand = table.filter(~pl.col("strand").is_in(_VALID_STRANDS))
|
|
231
|
+
if bad_strand.height:
|
|
232
|
+
seen = sorted(set(bad_strand["strand"].to_list()))
|
|
233
|
+
raise ValueError(
|
|
234
|
+
f"{bad_strand.height} transcript(s) have unusable strand {seen}. "
|
|
235
|
+
"Quantification requires '+' or '-'."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
empty = table.filter(pl.col("end") <= pl.col("start"))
|
|
239
|
+
if empty.height:
|
|
240
|
+
offenders = empty["transcript_id"].head(5).to_list()
|
|
241
|
+
raise ValueError(f"{empty.height} transcript(s) have empty spans, e.g. {offenders}")
|
|
242
|
+
|
|
243
|
+
duplicated = table["transcript_id"].is_duplicated()
|
|
244
|
+
if duplicated.any():
|
|
245
|
+
offenders = table.filter(duplicated)["transcript_id"].head(5).to_list()
|
|
246
|
+
raise ValueError(f"transcript identifiers must be unique, e.g. {offenders}")
|
|
247
|
+
|
|
248
|
+
return table
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _digest(table: pl.DataFrame, source: str) -> str:
|
|
252
|
+
"""Content-derived digest of a transcript table.
|
|
253
|
+
|
|
254
|
+
Depends only on the normalized coordinates and identifiers, so two runs over
|
|
255
|
+
the same annotation agree regardless of input path or row order.
|
|
256
|
+
"""
|
|
257
|
+
hasher = hashlib.sha256()
|
|
258
|
+
hasher.update(b"dienr.annotation.v1\x00")
|
|
259
|
+
for column in ("transcript_id", "gene_id", "contig", "start", "end", "strand"):
|
|
260
|
+
hasher.update(column.encode())
|
|
261
|
+
hasher.update(b"\x00")
|
|
262
|
+
series = table[column]
|
|
263
|
+
if series.dtype == pl.String:
|
|
264
|
+
hasher.update("\x00".join("" if v is None else v for v in series).encode())
|
|
265
|
+
else:
|
|
266
|
+
hasher.update(series.to_numpy().tobytes())
|
|
267
|
+
hasher.update(b"\x01")
|
|
268
|
+
return hasher.hexdigest()[:16]
|