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 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]