| name | bio-temporal-genomics-trajectory-modeling |
| description | Models continuous temporal trajectories from BULK or time-resolved omics where the x-axis is measured experimental time: penalized GAMs (mgcv) for smooth trends and changepoint detection (segmented, ruptures) for abrupt regime shifts. Use when deciding between a smooth GAM and a changepoint model; choosing the GAM distribution (nb() plus a library-size offset for raw counts vs Gaussian on vst/log-CPM); setting the basis-dimension ceiling k below the number of timepoints and letting REML pick wiggliness; handling residual autocorrelation across timepoints with corAR1/bam(rho=); testing whether two conditions' trajectories diverge with an ordered-factor difference smooth; and choosing a changepoint search/cost/penalty (Pelt/Binseg, l2/rbf). Not for single-cell pseudotime (see single-cell/trajectory-inference). |
Version Compatibility
Reference examples tested with: mgcv 1.9+, tradeSeq 1.16+, segmented 2.0+, ruptures 1.1+, numpy 1.26+, pandas 2.2+
Before using code patterns, verify installed versions match. If versions differ:
- Python:
pip show <package> then help(module.function) to check signatures
- R:
packageVersion('<pkg>') then ?function_name to verify parameters
If code throws ImportError, AttributeError, or TypeError, introspect the installed
package and adapt the example to match the actual API rather than retrying.
Note: the modeled quantity must be on a scale the family assumes. A Gaussian GAM is valid only on variance-stabilized/log-transformed expression (vst, rlog, log-CPM); raw RNA-seq counts require family=nb() with a offset(log(library_size)). Successive timepoints are correlated, so a plain gam() (which assumes independent residuals) inflates smooth-term significance unless the AR structure is modeled or the residual ACF is checked.
Temporal Trajectory Modeling
"Fit smooth curves to my gene expression over real time, compare trajectories, and find abrupt shifts" -> model a continuous function of MEASURED time f(time), test whether it changes / differs between conditions, and locate discrete regime changes.
- R:
mgcv::gam()/gamm()/bam() for penalized-spline GAMs; segmented::segmented() for slope breaks
- Python:
ruptures for level/distribution changepoints
The governing principle: measured time is not pseudotime, and the model class must match the mechanism
This skill models trajectories where the x-axis is ACTUAL experimental time (hours, days, developmental stage) or a pseudobulk value aggregated over real time. Time is measured and shared across every sample at a timepoint, so replicates are exchangeable, timepoints are few and fixed, and residual autocorrelation across ordered timepoints is real. This is categorically different from single-cell pseudotime, which is a latent per-cell ordering estimated with error and belongs to single-cell/trajectory-inference (and to tradeSeq's native use case). Conflating the two is the deepest error in the area.
Three decisions dominate correctness before any p-value is read:
- Distribution. A Gaussian GAM on raw counts gives wrong SEs, wrong p-values, and can predict negatives. Model counts with
nb() and a library-size offset, or fit Gaussian on a variance-stabilized scale.
- Autocorrelation. Positive residual correlation across timepoints shrinks the effective sample size, so a plain
gam() under-estimates SEs and over-calls temporal trends. Name the assumption and model it (corAR1/bam(rho=)) or at least inspect the residual ACF.
- Mechanism. A smooth GAM assumes a gradually curving process; a changepoint model assumes a genuinely abrupt regime shift. Imposing changepoints on smooth data invents regime shifts; smoothing a true step Gibbs-rings over it. Match the model to the biology.
Smooth GAM vs changepoint: choosing the model class
| Question | Model | Use when | Do NOT use when |
|---|
| Does expression change / curve over time? | GAM s(time) (mgcv) | process is gradual (induction/decay kinetics, developmental ramps) | the process is a discrete switch -> a smooth smears the discontinuity |
| Do two conditions' trajectories diverge? | ordered-factor difference smooth (mgcv) | testing whether treated shape departs from control | groups have no shared reference / only a constant offset differs (use a parametric term) |
| When does the regime shift (slope break)? | segmented (broken-line) | continuous piecewise-LINEAR change in slope | the shift is a level jump, not a slope change (use ruptures l2) |
| When does the regime shift (level/distribution)? | ruptures Pelt/Binseg | step change in mean (l2) or distribution (rbf) | the curve is smooth -> any liberal penalty fabricates breaks |
| Is a curve even warranted? | AIC/edf of s(time) vs linear | deciding non-linear vs linear is enough | over-interpreting edf near 1 as a real curve |
Methodology evolves; before committing verify current defaults and recommendations against the latest mgcv/ruptures/segmented documentation.
mgcv GAM (R)
Goal: Fit a smooth non-linear curve to expression over measured time and test whether it changes, on the correct distributional scale.
Approach: Use a penalized regression spline s(time); set the basis-dimension ceiling k generously but below the number of unique timepoints and let REML choose the realized wiggliness; use nb() + a library-size offset for raw counts, or Gaussian on a variance-stabilized scale.
library(mgcv)
fit <- gam(expression ~ s(time, k = 6, bs = 'tp'), data = gene_df, method = 'REML')
summary(fit)
Raw counts: NB family with a library-size offset
Goal: Model overdispersed RNA-seq counts on the count scale without violating the Gaussian assumption.
Approach: Fit family=nb() (mgcv estimates theta by REML) with offset(log(library_size)) so the smooth describes rate, not depth.
fit_nb <- gam(counts ~ s(time, k = 6) + offset(log(library_size)),
data = gene_df, family = nb(), method = 'REML')
summary(fit_nb)$s.table
Residual autocorrelation across timepoints
Goal: Prevent inflated smooth-term significance caused by correlation between successive timepoints.
Approach: Model a lag-1 AR structure grouped by the replication unit with gamm(correlation=corAR1()), or fix rho in bam() for genome-wide fits after reading the lag-1 residual ACF.
fit_ar <- gamm(expression ~ s(time, k = 6),
correlation = corAR1(form = ~ time | subject),
data = gene_df, method = 'REML')
fit_bam <- bam(expression ~ s(time, k = 6), data = gene_df,
rho = 0.4, AR.start = series_start, method = 'fREML')
With independent biological replicates AT EACH timepoint the correlation is often weak or unidentifiable and plain gam() is defensible; a single series sampled repeatedly over many timepoints is where AR bites hardest. Always inspect the residual ACF before trusting the smooth p-value.
Comparing conditions with an ordered-factor difference smooth
Goal: Directly test whether the treated trajectory's shape diverges from control, with its own p-value.
Approach: Make the grouping an ORDERED factor so s(time, by=grp) becomes a difference smooth (level minus reference); keep the reference global smooth AND the parametric main effect.
gene_df$condition <- as.ordered(gene_df$condition)
fit_diff <- gam(expression ~ condition + s(time, k = 6) + s(time, k = 6, by = condition),
data = gene_df, method = 'REML')
summary(fit_diff)
A numeric 0/1 by=is_treated indicator is a valid shortcut for a single 2-level contrast (the second smooth is the treatment deviation), but the ordered-factor form is the general, canonical idiom.
Diagnostics: gam.check, k-index, concurvity
Goal: Decide whether the basis is adequate and whether smooth terms are mutually identifiable.
Approach: Read gam.check()/k.check(); respond to a low k-index by doubling k and refitting, not by reflexively cranking k; use concurvity() only for multi-smooth models.
gam.check(fit)
concurvity(fit_diff, full = TRUE)
Prediction and pointwise intervals
Goal: Visualize the fitted trajectory with an uncertainty band, within the sampled range only.
Approach: Predict on a fine grid with se.fit=TRUE; band = fit +/- 1.96*SE (pointwise, not simultaneous); never extrapolate.
grid <- data.frame(time = seq(min(gene_df$time), max(gene_df$time), length.out = 200))
pred <- predict(fit, newdata = grid, se.fit = TRUE)
grid$fitted <- pred$fit
grid$lower <- pred$fit - 1.96 * pred$se.fit
grid$upper <- pred$fit + 1.96 * pred$se.fit
Genome-wide GAM fitting + FDR
Goal: Rank genes by temporal significance across the transcriptome.
Approach: Fit s(time) per gene, collect the smooth p-value, apply BH across genes (the per-gene p-values are approximate, so the FDR is approximate; permutation calibration is the gold standard for strong claims).
results <- data.frame()
for (gene in rownames(expr_mat)) {
df <- data.frame(expression = as.numeric(expr_mat[gene, ]), time = timepoints)
fit <- gam(expression ~ s(time, k = 6), data = df, method = 'REML')
s_tab <- summary(fit)$s.table
results <- rbind(results, data.frame(gene = gene, edf = s_tab[,
p_value s_tab
resultsq_value p.adjustresultsp_value method
temporal_genes resultsresultsq_value
tradeSeq (R/Bioconductor) -- off-label for bulk
tradeSeq is BUILT for single-cell pseudotime lineages, not bulk real-time. fitGAM(counts, pseudotime, cellWeights, nknots) expects a gene x cell count matrix, a cell x lineage pseudotime matrix, and cell x lineage soft-assignment weights, and fits an NB GAM per gene per lineage. Its tests (associationTest, startVsEndTest, conditionTest, patternTest) are keyed to pseudotime lineages.
library(tradeSeq)
sce <- fitGAM(counts = count_mat, pseudotime = pt_mat, cellWeights = cw_mat, nknots = 6)
assoc_res <- associationTest(sce)
segmented (R) -- broken-line slope break
Goal: Locate a continuous change in SLOPE and test whether a break exists at all.
Approach: Pre-test with davies.test before fitting a break; estimate the breakpoint with segmented() from a starting value; limit to one break unless the data are dense.
library(segmented)
lm_fit <- lm(expression ~ time, data = gene_df)
davies.test(lm_fit, seg.Z = ~time)
seg_fit <- segmented(lm_fit, seg.Z = ~time, psi = NA)
summary(seg_fit)$psi
ruptures (Python) -- level/distribution changepoints
Goal: Detect discrete times where the mean (or whole distribution) shifts.
Approach: Factorize as (search method) x (cost model) x (penalty); the penalty choice IS the number-of-changepoints choice; match the cost model to the shift type and estimate the noise variance, not the total variance.
import numpy as np
import ruptures as rpt
signal = np.asarray(expression_values)
n = len(signal)
sigma2 = np.var(np.diff(signal)) / 2.0
penalty = np.log(n) * sigma2
bkps = rpt.Pelt(model='l2', min_size=2).fit(signal).predict(pen=penalty)
n_changepoints = len(bkps) - 1
bkps_binseg = rpt.Binseg(model='l2', min_size=2).fit(signal).predict(n_bkps=2)
Guard against fabricated breaks: a liberal penalty always "finds" changepoints in a smooth ramp. Require a pre-test (a break exists) or compare a piecewise fit against a smooth-GAM fit by AIC -- if the smooth wins, the "changepoint" is a sampling-noise artifact. With few timepoints, be extremely skeptical of more than one break.
Common Errors
| Symptom | Cause | Fix |
|---|
| p-values wrong / fitted curve predicts negative expression | Gaussian GAM on raw overdispersed counts | family=nb() + offset(log(library_size)), or fit Gaussian on vst/log-CPM |
| Many genes "significantly change over time" implausibly | residual autocorrelation inflates smooth-term significance | `gamm(..., correlation=corAR1(form=~time |
| Treating k as "the number of bends I want" | k is the flexibility CEILING, not realized complexity | set k generously (< #timepoints), let REML pick lambda; read edf, not k |
| Cranking k whenever k-index < 1 | low k-index can mean autocorrelation/heteroscedasticity, not low basis | double k and refit -- if edf jumps, raise k; if not, look at correlation/distribution |
| Reading p=1e-30 as thirty orders of certainty | smooth p-values are approximate (ignore full lambda uncertainty) | treat as categorical significant/not; apply BH FDR across genes |
Unordered by= used "to test if curves differ" | it gives each group vs zero, not a divergence test | as.ordered(condition) -> the difference smooth's p-value IS the divergence test |
by= smooth without the parametric main effect | centered smooths cannot carry the group level | include condition + alongside s(time, by=condition) |
tradeSeq::fitGAM on a plain bulk time-course | tradeSeq is single-cell pseudotime machinery (needs cellWeights) | use mgcv directly for bulk real-time; reserve tradeSeq for pseudobulk lineages |
| ruptures under-detects real changepoints | pen=log(n)*np.var(signal) uses TOTAL variance -> penalty too large | estimate noise from np.var(np.diff(signal))/2; sweep the penalty |
model='rbf' with a BIC (log n * var) penalty | BIC penalty is derived for the l2/Gaussian-mean cost | use model='l2' with BIC, or calibrate the rbf penalty empirically |
| Changepoints "found" in a clearly smooth ramp | a liberal penalty fabricates breaks in gradual data |
Related Skills
- temporal-clustering - group genes by trajectory shape after fitting
- circadian-rhythms - periodic (known-period) trajectory models rather than smooth trends
- periodicity-detection - discover unknown-period oscillation instead of a smooth trend
- differential-expression/timeseries-de - linear/spline model alternatives for temporal DE
- single-cell/trajectory-inference - single-cell pseudotime (latent inferred ordering), the case tradeSeq is built for
References
- Wood SN. 2011. Fast stable restricted maximum likelihood and marginal likelihood estimation of semiparametric generalized linear models. J R Stat Soc B 73(1):3-36. doi:10.1111/j.1467-9868.2010.00749.x. (REML smoothing-parameter selection, better-behaved than GCV.)
- Wood SN. 2013. On p-values for smooth components of an extended generalized additive model. Biometrika 100(1):221-228. doi:10.1093/biomet/ass048. (Smooth-term p-values are approximate; pointwise interval coverage.)
- Wood SN. 2017. Generalized Additive Models: An Introduction with R, 2nd ed. Chapman & Hall/CRC. ISBN 9781498728331. (Basis-penalty framework, gam.check, concurvity.)
- Pedersen EJ, Miller DL, Simpson GL, Ross N. 2019. Hierarchical generalized additive models in ecology: an introduction with mgcv. PeerJ 7:e6876. doi:10.7717/peerj.6876. (Global-plus-difference-smooth and factor-smooth condition-comparison structure.)
- Van den Berge K, Roux de Bezieux H, Street K, Saelens W, Cannoodt R, Saeys Y, Dudoit S, Clement L. 2020. Trajectory-based differential expression analysis for single-cell sequencing data. Nat Commun 11(1):1201. doi:10.1038/s41467-020-14766-3. (tradeSeq: NB-GAM DE along pseudotime lineages, hence off-label for bulk.)
- Muggeo VMR. 2003. Estimating regression models with unknown break-points. Stat Med 22(19):3055-3071. doi:10.1002/sim.1545. (Broken-line estimator behind segmented and davies.test.)
- Killick R, Fearnhead P, Eckley IA. 2012. Optimal detection of changepoints with a linear computational cost. J Am Stat Assoc 107(500):1590-1598. doi:10.1080/01621459.2012.737745. (PELT exact O(n) penalized algorithm behind rpt.Pelt.)
- Truong C, Oudre L, Vayatis N. 2020. Selective review of offline change point detection methods. Signal Processing 167:107299. doi:10.1016/j.sigpro.2019.107299. (The cost x search x constraint taxonomy; the ruptures reference paper.)