postexportKinetics fits a compartment-resolved kinetic
model to time courses of nuclear and cytoplasmic, unprocessed and
processed RNA. It asks whether an additional post-export conversion
component improves the fit to the data beyond what the model explains
without it.
The package implements the method of the accompanying manuscript. Its
numerical core is ported verbatim from the frozen manuscript
implementation (tag manuscript-revision-v1.0) and is
regression-tested against it.
This vignette works through one small synthetic dataset:
Once the package has been accepted and released through Bioconductor,
it will be installed with BiocManager. This route does not
work before acceptance:
if (!requireNamespace("BiocManager", quietly = TRUE))
install.packages("BiocManager")
BiocManager::install("postexportKinetics")In the current development state, install from GitHub, either the released version 0.1.0 or the development line:
# install.packages("remotes")
remotes::install_github("bioinformatics-sannio/[email protected]")
remotes::install_github("bioinformatics-sannio/postexportKinetics",
build_vignettes = TRUE)Load the package:
Each event (for example a retained-intron event) is described by four states:
| State | Compartment | Processing |
|---|---|---|
N |
nuclear | unprocessed |
N_s |
nuclear | processed |
C |
cytoplasmic | unprocessed |
C_s |
cytoplasmic | processed |
with dynamics
dN/dt = R(t) - (sigma_n + tau) N
dN_s/dt = sigma_n N - tau_s N_s
dC/dt = tau N - (sigma_c + alpha) C
dC_s/dt = tau_s N_s + sigma_c C - alpha_s C_s
The rates are:
R(t): transcription;sigma_n: nuclear processing;tau and tau_s: export of unprocessed and
processed RNA;alpha and alpha_s: cytoplasmic decay;sigma_c: the post-export conversion
rate from C to C_s.Two nested models are compared:
sigma_c = 0: no
post-export conversion component;sigma_c >= 0:
a non-negative post-export conversion rate.sigma_c is a phenomenological parameter. A
positive estimate, or a small p-value, indicates kinetic patterns
consistent with an additional post-export conversion component
within this model. It does not identify a unique molecular
mechanism, and it is not by itself evidence of cytoplasmic splicing.
Transcription follows a schedule with a transcriptional onset and a
common intervention time t_star. After t_star,
transcription stops completely (shutoff) in the analyses shown here.
The package ships a small synthetic dataset,
postexport_example. It was simulated with the package’s
simulator (see ?postexport_example and the generation
script generate_postexport_example.R, installed in the
package’s scripts directory):
sigma_c > 0
(alt_*) and four with sigma_c = 0
(null_*);t_star = 332 min;The design was selected from the manuscript benchmark table before any data were generated. It is the calibrated design (Type-I error interval containing 0.05) with the highest benchmark power among small shutoff designs, and that power is only about 0.24. The simulation seed was fixed a priori; neither the design nor the data were chosen by searching over seeds. The example therefore shows realistic uncertainty and does not guarantee detection.
data(postexport_example)
head(postexport_example)
#> event time replicate N N_s C C_s
#> 1 alt_1 322 1 544.8247 6097.662 42.9393 1593.651
#> 2 alt_1 322 2 547.0577 6909.916 46.0974 1531.072
#> 3 alt_1 322 3 490.6547 6817.219 37.9880 1724.369
#> 4 alt_1 322 4 502.5323 6538.311 40.1591 1714.128
#> 5 alt_1 322 5 519.2125 7049.748 43.8828 1456.415
#> 6 alt_1 332 1 547.7388 6940.421 48.4467 1632.381The canonical wide format has one row per
destructive sample, with columns event, time,
replicate, N, N_s, C
and C_s.
time_unit is required, because all rates, including
sigma_c, are per this unit.x <- postexport_data(postexport_example, time_unit = "min")
x
#> <postexport_data> 200 samples, 8 event(s); time unit: min
#> alt_1: 5 time points (322..362), 5 replicates per time
#> alt_2: 5 time points (322..362), 5 replicates per time
#> alt_3: 5 time points (322..362), 5 replicates per time
#> alt_4: 5 time points (322..362), 5 replicates per time
#> null_1: 5 time points (322..362), 5 replicates per time
#> ... 3 more event(s)The long format has one row per sample and state,
with columns compartment
(nuclear/cytoplasmic), state
(unprocessed/processed) and
abundance. The same observations are installed as a CSV
file:
path <- system.file("extdata", "postexport_example_long.csv",
package = "postexportKinetics")
long <- read.csv(path)
head(long, 4)
#> event time replicate compartment state abundance
#> 1 alt_1 322 1 nuclear unprocessed 544.8247
#> 2 alt_1 322 1 nuclear processed 6097.6621
#> 3 alt_1 322 1 cytoplasmic unprocessed 42.9393
#> 4 alt_1 322 1 cytoplasmic processed 1593.6515
x_long <- postexport_data(long, time_unit = "min", format = "long")Different column names can be declared with columns, for
example
columns = c(event = "event_id", time = "minutes").
For retained-intron events, the mapping of inclusion to unprocessed and skipping to processed is the user’s responsibility. It is not inferred.
Data already held in a SummarizedExperiment can be
converted with postexport_data_from_se(). The object must
already be state-resolved (layout A):
colData holds time and
replicate;N, N_s,
C and C_s.The converter builds the wide table and passes it to
postexport_data(), so the result is exactly the canonical
object:
events <- unique(postexport_example$event)
samples <- unique(postexport_example[, c("time", "replicate")])
rownames(samples) <- sprintf("t%g_r%d", samples$time, samples$replicate)
cell <- cbind(match(postexport_example$event, events),
match(paste(postexport_example$time,
postexport_example$replicate),
paste(samples$time, samples$replicate)))
state_matrix <- function(state) {
m <- matrix(NA_real_, length(events), nrow(samples),
dimnames = list(events, rownames(samples)))
m[cell] <- postexport_example[[state]]
m
}
se <- SummarizedExperiment::SummarizedExperiment(
assays = list(N = state_matrix("N"), N_s = state_matrix("N_s"),
C = state_matrix("C"), C_s = state_matrix("C_s")),
colData = samples)
se
#> class: SummarizedExperiment
#> dim: 8 25
#> metadata(0):
#> assays(4): N N_s C C_s
#> rownames(8): alt_1 alt_2 ... null_3 null_4
#> rowData names(0):
#> colnames(25): t322_r1 t322_r2 ... t362_r4 t362_r5
#> colData names(2): time replicate
x_se <- postexport_data_from_se(se, time_unit = "min")
identical(x_se, x)
#> [1] TRUEOther assay or colData names can be given with the
assays, time and replicate
arguments.
The converter does not do any of the following:
These choices must be made before building the object. Missing values remain errors.
postexport_data() validates its input.
validate_postexport_data() reports the findings without
constructing an object, and never modifies the data.
validate_postexport_data(postexport_example, t_star = 332)
#> <postexport_validation> 0 error(s), 0 warning(s), 0 note(s)fit_postexport_model() fits both models without
bootstrap. The estimation uses:
The intervention time t_star is always an explicit
argument.
sel <- postexport_example$event == "alt_3"
one <- postexport_data(postexport_example[sel, ], time_unit = "min")
fit <- fit_postexport_model(one, t_star = 332)
fit
#> <postexport_fit> event: alt_3
#> Status : ok
#> Design : shutoff at t_star = 332 min (complete shutoff assumed); 5 time points (322..362 min), 5 replicate(s) per time
#> Effect size : sigma_c = 0.6746 min^-1 (phenomenological post-export conversion rate)
#> Fit : IR = 0.06411 (relative RSS improvement over the sigma_c = 0 null); T_obs = 19.77
#> Boundary : no
#> No bootstrap inference; see test_postexport_conversion().summary() shows the full and null coefficients, the
residual sums of squares, the boundary information and numerical
diagnostics such as condition numbers and ranks.
summary(fit)
#> Post-export kinetic model fit - event: alt_3
#> Status: ok
#>
#> Design
#> shutoff at t_star = 332 min (complete shutoff assumed); 5 time points (322..362 min), 5 replicate(s) per time
#> times: 322, 332, 342, 352, 362
#> replicates per time: 5, 5, 5, 5, 5
#>
#> Estimates (rates per min)
#> parameter full null
#> R 53.02 54.28
#> tau 0.03975 0.03908
#> tau_s 0.006482 0.006365
#> sigma_c 0.6746 0
#> sigma_n 0.1059 0.1097
#> alpha 0 0.665
#> alpha_s 0.01204 0.009443
#> sigma_c (effect-size estimate): 0.6746
#>
#> Fit (whitened scale)
#> RSS null (sigma_c = 0): 308.3
#> RSS full (sigma_c >= 0): 288.6
#> T_obs = max(0, RSS0 - RSS1): 19.77
#> IR (relative RSS improvement): 0.06411
#>
#> Boundary
#> frozen tolerance 1e-10 * max(1, |RSS0|, |RSS1|): 3.083e-08
#> at boundary (T_obs <= tolerance): no
#> sigma_c exactly zero (|sigma_c| < 1e-12): no
#>
#> Diagnostics
#> condition number (whitened full design): 533.9
#> minimum singular value: 0.005951
#> rank full / null: 7 / 6 (of 7 / 6 columns)
#> structurally zero design columns: none
#> condition number over non-zero columns (diagnostic only): 533.9
#>
#> Control: frozen manuscript defaults
#> Frozen reference: manuscript-revision-v1.0 65c3b7368fb7
#>
#> sigma_c is a phenomenological post-export conversion rate within the
#> four-state model. A small bootstrap p-value indicates kinetic evidence
#> consistent with an additional post-export conversion component within the
#> model; it does not identify a molecular mechanism.sigma_c = 0test_postexport_conversion() compares the models with
the statistic T = max(0, RSS0 - RSS1), a difference of
whitened non-negative least-squares residual sums of squares. This is
not a likelihood-ratio test.
The null distribution of T comes from a replicate-level
generative bootstrap under the fitted null model. The interval system is
rebuilt in every bootstrap replicate, and the p-value uses the add-one
rule (1 + #{T* >= T_obs}) / (B + 1). The alternative is
one-sided, sigma_c > 0.
Several events can be tested in one call. They are processed sequentially, and each event gets its own named seed:
events <- unique(x$event)
seeds <- setNames(seq_along(events), events)
res <- test_postexport_conversion(
x, t_star = 332,
control = postexport_control(B = 499, seed = seeds))
res
#> <postexport_test_set> 8 events; t_star = 332 min
#> event status sigma_c IR at_boundary p_value
#> alt_1 ok 0.21162 2.123e-02 FALSE 0.164
#> alt_2 ok 0.00000 1.540e-15 TRUE 1.000
#> alt_3 ok 0.67460 6.411e-02 FALSE 0.008
#> alt_4 ok 0.40954 1.934e-02 FALSE 0.224
#> null_1 ok 0.00000 0.000e+00 TRUE 1.000
#> null_2 ok 0.12841 1.452e-02 FALSE 0.366
#> null_3 ok 0.08349 7.957e-02 FALSE 0.140
#> null_4 ok 0.07204 9.958e-03 FALSE 0.436
#> p_value is the per-event bootstrap p-value (no multiple-testing adjustment).B = 499 is used only to keep this vignette fast and is
not recommended for final scientific analysis. The smallest attainable
p-value is 1 / (B + 1), here 0.002. The package default is
B = 1999, and the manuscript’s final real-data analyses
used larger values.
With an explicit seed, the caller’s random-number state is restored
afterwards. With seed = NULL, the global stream is used and
consumed.
The result table is easiest to read with the simulation truth next to it (available here only because the data are synthetic):
data(postexport_example_truth)
tab <- as.data.frame(res)
cmp <- merge(postexport_example_truth[, c("event", "truth", "sigma_c")],
tab[, c("event", "sigma_c", "IR", "p_value", "at_boundary")],
by = "event", suffixes = c("_true", "_est"))
cmp
#> event truth sigma_c_true sigma_c_est IR p_value at_boundary
#> 1 alt_1 alternative 0.12657949 0.21161806 2.123188e-02 0.164 FALSE
#> 2 alt_2 alternative 0.05599857 0.00000000 1.539549e-15 1.000 TRUE
#> 3 alt_3 alternative 0.17854245 0.67460087 6.411106e-02 0.008 FALSE
#> 4 alt_4 alternative 0.15047768 0.40954462 1.934045e-02 0.224 FALSE
#> 5 null_1 null 0.00000000 0.00000000 0.000000e+00 1.000 TRUE
#> 6 null_2 null 0.00000000 0.12840808 1.452157e-02 0.366 FALSE
#> 7 null_3 null 0.00000000 0.08349130 7.956793e-02 0.140 FALSE
#> 8 null_4 null 0.00000000 0.07203508 9.958088e-03 0.436 FALSEsigma_c is the full-model estimate of
the post-export conversion rate, per minute. It is an effect-size
estimate, not a test.
alt_3, for example, the full fit trades
sigma_c against the cytoplasmic decay rate
alpha, since both remove C. The manuscript’s
practical identifiability analysis examines this.IR, (RSS0 - RSS1) / RSS0,
is the relative improvement in fit from adding
sigma_c.p_value is the per-event bootstrap
p-value, before any multiple-testing adjustment.T_obs at or below the frozen tolerance
1e-10 * max(1, |RSS0|, |RSS1|)), the event is at the
boundary.
sigma_c is then typically estimated as 0.status reports numerical failures,
such as a failed observed fit or an unstable bootstrap. Only events with
status "ok" are valid tests.In this design only one of the four simulated alternatives has a small p-value. That is expected: in the manuscript benchmark, this design had empirical Type-I error close to 0.05 but power of only about 0.24 (see the design diagnostics below).
Adjustment is an explicit step. It is never performed inside the
test. adjust_postexport_pvalues():
stats::p.adjust();"ok" and a finite
p-value);q_value and leaves raw p-values unchanged.adj <- adjust_postexport_pvalues(res)
adj$summary[, c("event", "status", "p_value", "q_value")]
#> event status p_value q_value
#> 1 alt_1 ok 0.164 0.4373333
#> 2 alt_2 ok 1.000 1.0000000
#> 3 alt_3 ok 0.008 0.0640000
#> 4 alt_4 ok 0.224 0.4480000
#> 5 null_1 ok 1.000 1.0000000
#> 6 null_2 ok 0.366 0.5813333
#> 7 null_3 ok 0.140 0.4373333
#> 8 null_4 ok 0.436 0.5813333The testing family should follow the analysis plan. The manuscript
adjusted each dataset separately; groups gives one family
per label. A single test can also be adjusted, as a family of size
one.
rank_postexport_candidates() orders the tested events by
the manuscript’s exploratory composite score
score = sigma_c * IR * min(-log10(max(q, 1e-10)), 6)
The ranking is a prioritisation aid for follow-up. It is not an inferential quantity and not an optimised discrimination score.
rank_postexport_candidates(adj)
#> <postexport_ranking> exploratory prioritization, 8 events ( 8 ranked )
#> Exploratory prioritization only: score = sigma_c * IR * min(-log10(max(q,
#> 1e-10)), 6), the composite of the manuscript. It is not an inferential
#> quantity and not an optimized discrimination score; p- and q-values are
#> the statistical evidence, sigma_c is the effect-size estimate and IR the
#> fit improvement.
#> rank event status score q_value sigma_c IR at_boundary
#> 1 alt_3 ok 0.0516320 0.0640 0.67460 6.411e-02 FALSE
#> 2 alt_4 ok 0.0027621 0.4480 0.40954 1.934e-02 FALSE
#> 3 null_3 ok 0.0023862 0.4373 0.08349 7.957e-02 FALSE
#> 4 alt_1 ok 0.0016138 0.4373 0.21162 2.123e-02 FALSE
#> 5 null_2 ok 0.0004393 0.5813 0.12841 1.452e-02 FALSE
#> 6 null_4 ok 0.0001690 0.5813 0.07204 9.958e-03 FALSE
#> 7 alt_2 ok 0.0000000 1.0000 0.00000 1.540e-15 TRUE
#> 7 null_1 ok 0.0000000 1.0000 0.00000 0.000e+00 TRUEAll plots are ggplot2 objects. They never refit or
modify results.
The fit plot shows the observations, time-point means, sampling
times, t_star, and the fitted full and null models. The
fitted curves are display reconstructions, propagated with the ODE model
from the observed replicate-mean state at the first sampled time.
Inference does not use them; it uses the interval-balance /
Crank-Nicolson machinery.
The bootstrap plot shows the bootstrap distribution of
T, the observed statistic and the fraction of bootstrap
replicates at the boundary:
Set-level plots are descriptive:
"status", "boundary" and
"sigma_IR";"sigma_q", "IR_q" and
"score".simulate_postexport_kinetics() exposes the manuscript
simulator. It supports:
"NONE", "SHUTOFF" and
"PSEUDO_SHUTOFF" regimes;p <- c(R = 100, tau = 0.03, tau_s = 0.015, sigma_c = 0.1, sigma_n = 0.1,
alpha = 0.2, alpha_s = 0.08)
sim <- simulate_postexport_kinetics(
p, times = c(90, 100, 110, 120, 130), onset_time = 0, t_star = 100,
regime = "SHUTOFF", time_unit = "min", n_replicates = 3,
param_cv = 0.05, noise = list(platform = "rnaseq", level = "low"),
seed = 1)
sim
#> <postexport_simulation> event: simulated
#> Regime : SHUTOFF
#> Timing : onset_time = 0, t_star = 100 min
#> Sampling : 5 time points (90, 100, 110, 120, 130), 3 replicate(s), param_cv = 0.05
#> Noise : rnaseq, low
#> sigma_c : 0.1 min^-1 (phenomenological; simulated data do not establish a mechanism)
#> Tables : $latent (393 rows), $sampled (15 rows), $observed (15 rows)
plot(sim)sim$observed has the wide input format and can be passed
directly to postexport_data(). Simulating with
sigma_c > 0 generates data from the kinetic model; it
does not represent any molecular mechanism.
Calibration of the bootstrap test depends on the design: the regime,
sampling times, replicates and noise.
check_operational_domain() compares a design with the
designs of the manuscript’s corrected factorial benchmark and reports
their empirical Type-I error and power.
dom <- check_operational_domain(x, regime = "SHUTOFF", t_star = 332,
platform = "rnaseq", noise_level = "very_low")
dom
#> <postexport_domain_check> diagnostic comparison with the manuscript benchmark
#>
#> Design 1: regime SHUTOFF; 5 time points; 5 replicates; interval 10; events: alt_1, alt_2, alt_3, alt_4, null_1, null_2, null_3, null_4
#> Match: exact
#> In the manuscript benchmark, the 1 configuration(s) matching this design
#> (rnaseq platform(s), very_low noise level(s)) had empirical Type-I error
#> at alpha = 0.05 of 0.0424 (Wilson 95% intervals from 0.0336 to 0.0534);
#> the manuscript's operational criterion (Wilson 95% interval containing
#> 0.05) was met in 1 of 1 of these benchmark configurations; power at alpha
#> = 0.05 was 0.242.
#> Caveats: 2 (see summary())
plot(dom)The check is diagnostic only. Similarity to a benchmark design is not a calibration guarantee for any real dataset, and the check never blocks inference.
Continuous transcription was strongly anti-conservative in the benchmark:
check_operational_domain(regime = "NONE", n_time_points = 5, n_replicates = 5,
sampling_interval = 10, time_unit = "min")
#> <postexport_domain_check> diagnostic comparison with the manuscript benchmark
#>
#> Design 1: regime NONE; 5 time points; 5 replicates; interval 10
#> Match: exact
#> In the manuscript benchmark, the 48 configuration(s) matching this design
#> (all platform(s), all noise level(s)) had empirical Type-I error at alpha
#> = 0.05 of 0.293 to 0.388 (Wilson 95% intervals from 0.271 to 0.412); the
#> manuscript's operational criterion (Wilson 95% interval containing 0.05)
#> was met in 0 of 48 of these benchmark configurations; power at alpha =
#> 0.05 was 0.354 to 0.583.
#> In the manuscript benchmark, continuous-transcription (NONE) designs were
#> strongly anti-conservative across all tested configurations: the
#> operational criterion was met in 0 of 576 NONE configurations (median
#> empirical Type-I error 0.394 at alpha = 0.05).
#> Caveats: 4 (see summary())sigma_c is
phenomenological. Statistical support for sigma_c > 0
indicates kinetic patterns consistent with additional post-export
conversion within the four-state model. It does not establish
cytoplasmic splicing or any unique molecular mechanism.t_star
(complete shutoff here). Residual transcription after the intervention,
or misassigned fractions, can bias the test. Pseudo-shutoff designs are
reported as not_benchmarked by the operational-domain
check.The numerical core reproduces the frozen manuscript implementation:
manuscript-revision-v1.0, commit
65c3b7368fb7686bfde3dab857f98c393bb534c5, in https://github.com/bioinformatics-sannio/postexport-kinetics;Results are bitwise reproducible only within a matched numerical
environment. The bootstrap draws use MASS::mvrnorm(), whose
output depends on the LAPACK/BLAS build. Across platforms, bootstrap
p-values are different Monte-Carlo realisations of the same bootstrap
distribution. The deterministic fit quantities agree to rounding
level.
Use citation("postexportKinetics") to cite the package
and the manuscript.
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] postexportKinetics_0.99.0 rmarkdown_2.32
#>
#> loaded via a namespace (and not attached):
#> [1] sass_0.4.10 generics_0.1.4
#> [3] SparseArray_1.13.3 lattice_0.23-1
#> [5] digest_0.6.39 evaluate_1.0.5
#> [7] grid_4.6.1 RColorBrewer_1.1-3
#> [9] fastmap_1.2.0 jsonlite_2.0.0
#> [11] Matrix_1.7-6 deSolve_1.42
#> [13] scales_1.4.0 jquerylib_0.1.4
#> [15] abind_1.4-8 cli_3.6.6
#> [17] rlang_1.3.0 XVector_0.53.0
#> [19] Biobase_2.73.2 withr_3.0.3
#> [21] cachem_1.1.0 DelayedArray_0.39.7
#> [23] yaml_2.3.12 otel_0.2.0
#> [25] S4Arrays_1.13.1 tools_4.6.1
#> [27] ggplot2_4.0.3 SummarizedExperiment_1.43.0
#> [29] BiocGenerics_0.59.12 buildtools_1.0.0
#> [31] vctrs_0.7.3 R6_2.6.1
#> [33] matrixStats_1.5.0 stats4_4.6.1
#> [35] lifecycle_1.0.5 Seqinfo_1.3.2
#> [37] S4Vectors_0.51.10 IRanges_2.47.5
#> [39] MASS_7.3-66 bslib_0.12.0
#> [41] gtable_0.3.6 glue_1.8.1
#> [43] xfun_0.61 GenomicRanges_1.65.4
#> [45] sys_3.4.3 MatrixGenerics_1.25.0
#> [47] knitr_1.52 farver_2.1.2
#> [49] htmltools_0.5.9 maketools_1.3.2
#> [51] labeling_0.4.3 compiler_4.6.1
#> [53] S7_0.2.2 nnls_1.6sigma_c = 0