library(LongOmicsTraj)
library(SummarizedExperiment)
library(MultiAssayExperiment)
library(S4Vectors)
library(dplyr)
library(tidyr)
library(tibble)
library(ggplot2)
library(scales)
knitr::opts_chunk$set(
collapse = TRUE,
comment = "#>",
fig.align = "center",
message = FALSE,
warning = FALSE
)LongOmicsTraj converts repeated molecular measurements into discrete longitudinal trajectory labels.
This example uses a selected GLUCOLD transcriptomic dataset containing 71 genes measured across 221 longitudinal samples at Baseline, 6 Months and 30 Months.
The workflow is:
Expression matrix + clinical metadata
↓
LongOmicsTraj object
↓
Estimate trajectories
↓
Compute adjacent changes
↓
Map changes to OTS
↓
Query and visualise
The package includes two objects:
# Load the example GLUCOLD expression matrix included with the package.
# The demo contains 71 selected genes measured across 221 longitudinal samples.
# The reduced feature set keeps the example fast to run.
data(
"lot_glucold_expression",
package = "LongOmicsTraj"
)
# Load the matching clinical metadata.
# There is one metadata row for each expression-matrix sample.
#
# Required columns:
# sample_id = unique sample identifier; must match colnames(expr_use)
# subject_id = participant identifier; repeated across longitudinal visits
# visit = visit label, for example Baseline, 6 Months, 30 Months
# group = treatment or clinical group
#
# A numeric time column may also be added later for models such as GAM:
# time = numerical visit time, for example 0, 6, 30
data(
"lot_glucold_clinical",
package = "LongOmicsTraj"
)
# Replace these two objects with your own data.
#
# Expression matrix:
# rows = genes or molecular features
# columns = samples
expr_use <- lot_glucold_expression
# Clinical metadata:
# rows = samples
# required columns = sample_id, subject_id, visit, group
# sample_id values must match the expression-matrix column names
meta_use <- lot_glucold_clinicalRows are genes and columns are samples.
dim(expr_use)
#> [1] 71 221
expr_use[1:5, 1:5]
#> GSM883944 GSM883945 GSM883946 GSM883947 GSM883948
#> ATP2A3 5.192163 6.380106 6.577776 6.476847 7.101043
#> CAT 5.923509 7.669481 6.749482 7.565173 7.622587
#> CCL24 3.508556 3.551733 4.088679 3.479406 3.416885
#> CCL26 3.237542 2.702321 3.492727 2.623864 2.967484
#> CCL3 4.990029 4.475904 5.198765 4.551517 5.196277The metadata contain one row per sample.
dim(meta_use)
#> [1] 221 4
head(meta_use)
#> sample_id subject_id visit group
#> GSM883944 GSM883944 1076 Baseline Placebo
#> GSM883945 GSM883945 1076 30 Months Placebo
#> GSM883946 GSM883946 1092 Baseline ICS 30 months
#> GSM883947 GSM883947 1092 30 Months ICS 30 months
#> GSM883948 GSM883948 2017 Baseline ICS 30 months
#> GSM883949 GSM883949 2017 30 Months ICS 30 monthsThe required metadata columns are:
Users analysing their own data only need to replace:
The expression matrix should have genes in rows and samples in
columns. The metadata should contain sample_id,
subject_id, visit and group.
expr_use <- as.matrix(expr_use)
storage.mode(expr_use) <- "numeric"
meta_use <- as.data.frame(meta_use)
required_columns <- c(
"sample_id",
"subject_id",
"visit",
"group"
)
missing_columns <- setdiff(
required_columns,
colnames(meta_use)
)
if (length(missing_columns) > 0L) {
stop(
"Clinical metadata are missing: ",
paste(missing_columns, collapse = ", ")
)
}
if (is.null(colnames(expr_use))) {
stop(
"The expression matrix must have sample IDs as column names."
)
}
if (!setequal(
colnames(expr_use),
meta_use$sample_id
)) {
stop(
"Expression sample names do not match metadata sample IDs."
)
}
meta_use <- meta_use[
match(
colnames(expr_use),
meta_use$sample_id
),
,
drop = FALSE
]
rownames(meta_use) <- meta_use$sample_id
stopifnot(
identical(
colnames(expr_use),
meta_use$sample_id
)
)clinical_visits <- c(
"Baseline",
"6 Months",
"30 Months"
)
meta_use$visit <- factor(
meta_use$visit,
levels = clinical_visits,
ordered = TRUE
)
time_lookup <- c(
"Baseline" = 0,
"6 Months" = 6,
"30 Months" = 30
)
meta_use$time <- unname(
time_lookup[
as.character(meta_use$visit)
]
)
if (anyNA(meta_use$time)) {
stop(
"One or more visit labels could not be converted to time."
)
}
table(meta_use$visit)
#>
#> Baseline 6 Months 30 Months
#> 79 77 65
table(meta_use$group)
#>
#> ICS 30 months ICS 6 months then withdrawal
#> 61 59
#> ICS/LABA 30 months Placebo
#> 46 55First combine the expression matrix and metadata in a
SummarizedExperiment.
se <- SummarizedExperiment::SummarizedExperiment(
assays = list(
transcriptomics = expr_use
),
colData = S4Vectors::DataFrame(
meta_use,
row.names = meta_use$sample_id
)
)
se
#> class: SummarizedExperiment
#> dim: 71 221
#> metadata(0):
#> assays(1): transcriptomics
#> rownames(71): ATP2A3 CAT ... XBP1 ZBTB16
#> rowData names(0):
#> colnames(221): GSM883944 GSM883945 ... GSM884163 GSM884164
#> colData names(5): sample_id subject_id visit group timePlace the assay in a MultiAssayExperiment.
mae <- MultiAssayExperiment::MultiAssayExperiment(
experiments =
MultiAssayExperiment::ExperimentList(
transcriptomics = se
)
)
mae
#> A MultiAssayExperiment object of 1 listed
#> experiment with a user-defined name and respective class.
#> Containing an ExperimentList class object of length 1:
#> [1] transcriptomics: SummarizedExperiment with 71 rows and 221 columns
#> Functionality:
#> experiments() - obtain the ExperimentList instance
#> colData() - the primary/phenotype DataFrame
#> sampleMap() - the sample coordination DataFrame
#> `$`, `[`, `[[` - extract colData columns, subset, or experiment
#> *Format() - convert into a long or wide DataFrame
#> assays() - convert ExperimentList to a SimpleList of matrices
#> exportClass() - save data to flat filesCreate the analysis object.
lot_object <- methods::new(
"LongOmicsTraj",
mae = mae
)
methods::validObject(lot_object)
#> [1] TRUE
lot_object
#>
#> LongOmicsTraj object
#> ────────────────────────────────────────────
#>
#> Assays
#> transcriptomics: 71 features × 221 samples
#>
#> Dimensions
#> Features : 71
#> Samples : 221
#> Subjects : 90
#>
#> Study design
#> Visits : Baseline → 6 Months → 30 Months
#> Groups : 4
#> Placebo, ICS 30 months, ICS/LABA 30 months, ICS 6 months then withdrawal
#>
#> Computed results
#> trajectories: –
#> deltas : –
#> thresholds : –
#> OTS : –
#>
#> Estimators
#> None recordedA value of TRUE confirms that the object is valid.
The empirical estimator calculates visit-level summaries directly from the observed data.
lot_emp <- lot_from_empirical(
object = lot_object,
assay = "transcriptomics",
visits = clinical_visits,
overwrite = TRUE
)
lot_emp <- lot_compute_deltas(
object = lot_emp,
assay = "transcriptomics",
visits = clinical_visits,
overwrite = TRUE
)
lot_emp <- lot_compute_thresholds(
object = lot_emp,
assay = "transcriptomics",
method = "hybrid",
delta_min = 0.15,
k = 0.5,
visits = clinical_visits,
overwrite = TRUE
)
lot_emp <- lot_map_to_ots(
object = lot_emp,
visits = clinical_visits,
overwrite = TRUE
)
head(
as.data.frame(
lot_ots(
lot_emp
)
)
)
#> object_id object_type assay group estimator
#> 1 ATP2A3 gene transcriptomics ICS 30 months empirical
#> 2 ATP2A3 gene transcriptomics ICS 6 months then withdrawal empirical
#> 3 ATP2A3 gene transcriptomics ICS/LABA 30 months empirical
#> 4 ATP2A3 gene transcriptomics Placebo empirical
#> 5 CAT gene transcriptomics ICS 30 months empirical
#> 6 CAT gene transcriptomics ICS 6 months then withdrawal empirical
#> topology_label n_transitions
#> 1 flat_down 2
#> 2 flat_flat 2
#> 3 down_up 2
#> 4 flat_down 2
#> 5 flat_flat 2
#> 6 flat_down 2Each gene is assigned an Omics Trajectory Signature such as:
flat_flat: stable;up_up: sustained increase;down_down: sustained decrease;up_down: transient increase;down_up: rebound.Here we also fit polynomial trajectories using the maSigPro estimator.
lot_mas <- lot_from_masigpro(
object = lot_object,
assay = "transcriptomics",
visits = clinical_visits,
overwrite = TRUE
)
lot_mas <- lot_compute_deltas(
object = lot_mas,
assay = "transcriptomics",
visits = clinical_visits,
overwrite = TRUE
)
lot_mas <- lot_compute_thresholds(
object = lot_mas,
assay = "transcriptomics",
method = "hybrid",
delta_min = 0.15,
k = 0.5,
visits = clinical_visits,
overwrite = TRUE
)
lot_mas <- lot_map_to_ots(
object = lot_mas,
visits = clinical_visits,
overwrite = TRUE
)
head(
as.data.frame(
lot_ots(
lot_mas
)
)
)
#> object_id object_type assay group estimator
#> 1 ATP2A3 gene transcriptomics ICS 30 months masigpro
#> 2 ATP2A3 gene transcriptomics ICS 6 months then withdrawal masigpro
#> 3 ATP2A3 gene transcriptomics ICS/LABA 30 months masigpro
#> 4 ATP2A3 gene transcriptomics Placebo masigpro
#> 5 CAT gene transcriptomics ICS 30 months masigpro
#> 6 CAT gene transcriptomics ICS 6 months then withdrawal masigpro
#> topology_label n_transitions
#> 1 flat_down 2
#> 2 flat_flat 2
#> 3 down_up 2
#> 4 flat_down 2
#> 5 flat_flat 2
#> 6 flat_down 2The first summary shows how trajectory labels are distributed across treatment groups.
ots_results <- as.data.frame(
lot_ots(
lot_mas
)
) |>
dplyr::filter(
estimator == "masigpro",
!is.na(group),
!is.na(topology_label)
)
ots_distribution <- ots_results |>
dplyr::count(
group,
topology_label,
name = "n"
) |>
dplyr::group_by(group) |>
dplyr::mutate(
proportion = n / sum(n)
) |>
dplyr::ungroup()p_ots_distribution <- ggplot2::ggplot(
ots_distribution,
ggplot2::aes(
x = group,
y = proportion,
fill = topology_label
)
) +
ggplot2::geom_col(
width = 0.72,
colour = "white",
linewidth = 0.25
) +
ggplot2::scale_y_continuous(
labels = scales::percent_format(),
expand = ggplot2::expansion(
mult = c(0, 0.02)
)
) +
ggplot2::scale_fill_brewer(
palette = "Set3",
name = "OTS"
) +
ggplot2::labs(
title = "Distribution of Omics Trajectory Signatures",
subtitle = "maSigPro estimates across GLUCOLD treatment groups",
x = "Treatment group",
y = "Proportion of genes"
) +
ggplot2::theme_bw(base_size = 11) +
ggplot2::theme(
plot.title = ggplot2::element_text(
face = "bold"
),
axis.text.x = ggplot2::element_text(
angle = 35,
hjust = 1
),
panel.grid.minor = ggplot2::element_blank(),
legend.position = "bottom"
)
p_ots_distributionThe figure provides a compact comparison of longitudinal response patterns across treatment groups.
The next query searches for genes that:
withdrawal_specific <- lot_query(
lot_mas,
estimator = "masigpro",
conditions = list(
"ICS 6 months then withdrawal" = c(
"up_down",
"down_up"
),
"ICS 30 months" = c(
"up_up",
"up_flat",
"down_down",
"down_flat"
),
"Placebo" = NOT(
c(
"up_down",
"down_up"
)
)
)
)
withdrawal_specific <- as.data.frame(
withdrawal_specific
)
head(withdrawal_specific)
#> object_id object_type assay group estimator
#> 1 CCL5 gene transcriptomics ICS 30 months masigpro
#> 2 CCL5 gene transcriptomics ICS 6 months then withdrawal masigpro
#> 3 CCL5 gene transcriptomics ICS/LABA 30 months masigpro
#> 4 CCL5 gene transcriptomics Placebo masigpro
#> 5 CD79A gene transcriptomics ICS 30 months masigpro
#> 6 CD79A gene transcriptomics ICS 6 months then withdrawal masigpro
#> topology_label n_transitions
#> 1 down_down 2
#> 2 down_up 2
#> 3 down_flat 2
#> 4 flat_flat 2
#> 5 down_down 2
#> 6 down_up 2p_query <- ggplot2::ggplot(
query_distribution,
ggplot2::aes(
x = topology_label,
y = n,
fill = group
)
) +
ggplot2::geom_col(
position = "dodge",
width = 0.72
) +
ggplot2::labs(
title = "Treatment-specific trajectory query",
subtitle = "Genes satisfying the predefined GLUCOLD trajectory conditions",
x = "Omics Trajectory Signature",
y = "Number of genes",
fill = "Treatment group"
) +
ggplot2::theme_bw(base_size = 11) +
ggplot2::theme(
plot.title = ggplot2::element_text(
face = "bold"
),
axis.text.x = ggplot2::element_text(
angle = 35,
hjust = 1
),
panel.grid.minor = ggplot2::element_blank(),
legend.position = "bottom"
)
p_querySelect genes representing different OTS classes where possible.
example_genes <- withdrawal_specific |>
dplyr::distinct(
object_id,
topology_label
) |>
dplyr::group_by(topology_label) |>
dplyr::slice_head(n = 1) |>
dplyr::ungroup() |>
dplyr::slice_head(n = 6) |>
dplyr::pull(object_id) |>
unique()
example_genes <- intersect(
example_genes,
rownames(expr_use)
)
if (length(example_genes) == 0L) {
stop(
"No query-selected genes were found in the expression matrix."
)
}
example_genes
#> [1] "CCL5" "COL1A1" "IL1RL1"expr <- expr_use
clinical_data <- meta_use
expression_long <- as.data.frame(
expr[
example_genes,
,
drop = FALSE
]
) |>
tibble::rownames_to_column(
"gene"
) |>
tidyr::pivot_longer(
cols = -gene,
names_to = "sample_id",
values_to = "expression"
) |>
dplyr::left_join(
clinical_data |>
dplyr::select(
sample_id,
subject_id,
visit,
group
),
by = "sample_id"
)
selected_groups <- c(
"ICS 30 months",
"ICS 6 months then withdrawal",
"Placebo"
)
expression_long <- expression_long |>
dplyr::filter(
group %in% selected_groups
) |>
dplyr::mutate(
group = factor(
group,
levels = selected_groups
),
visit = factor(
visit,
levels = clinical_visits,
ordered = TRUE
)
)
expression_summary <- expression_long |>
dplyr::group_by(
gene,
group,
visit
) |>
dplyr::summarise(
n = sum(!is.na(expression)),
mean_expression = mean(
expression,
na.rm = TRUE
),
sd_expression = stats::sd(
expression,
na.rm = TRUE
),
se_expression = dplyr::if_else(
n > 1,
sd_expression / sqrt(n),
0
),
.groups = "drop"
)The facet label shows the OTS assigned to the ICS-withdrawal group.
gene_ots_labels <- ots_results |>
dplyr::filter(
object_id %in% example_genes,
group == "ICS 6 months then withdrawal"
) |>
dplyr::distinct(
object_id,
topology_label
) |>
dplyr::mutate(
facet_label = paste0(
object_id,
"\nWithdrawal OTS: ",
topology_label
)
)
facet_labels <- stats::setNames(
gene_ots_labels$facet_label,
gene_ots_labels$object_id
)
missing_labels <- setdiff(
example_genes,
names(facet_labels)
)
facet_labels[missing_labels] <- missing_labelsFaint lines show individual participants. Solid lines show group means. Error bars show the standard error.
p_gene_trajectories <- ggplot2::ggplot() +
ggplot2::geom_line(
data = expression_long,
ggplot2::aes(
x = visit,
y = expression,
group = interaction(
group,
subject_id
),
colour = group
),
alpha = 0.10,
linewidth = 0.25,
show.legend = FALSE
) +
ggplot2::geom_errorbar(
data = expression_summary,
ggplot2::aes(
x = visit,
ymin = mean_expression - se_expression,
ymax = mean_expression + se_expression,
colour = group
),
width = 0.08,
linewidth = 0.45
) +
ggplot2::geom_line(
data = expression_summary,
ggplot2::aes(
x = visit,
y = mean_expression,
group = group,
colour = group
),
linewidth = 1
) +
ggplot2::geom_point(
data = expression_summary,
ggplot2::aes(
x = visit,
y = mean_expression,
colour = group
),
size = 2.2
) +
ggplot2::facet_wrap(
~ gene,
scales = "free_y",
ncol = 3,
labeller = ggplot2::as_labeller(
facet_labels
)
) +
ggplot2::labs(
title = "Representative longitudinal gene trajectories",
subtitle = "Genes selected from the treatment-specific maSigPro query",
x = "Visit",
y = "Expression",
colour = "Treatment group",
caption = paste0(
"Faint lines show individual participants. ",
"Solid lines show group means; error bars show ±SE. ",
"Panels use independent y-axis ranges."
)
) +
ggplot2::theme_bw(base_size = 11) +
ggplot2::theme(
plot.title = ggplot2::element_text(
face = "bold"
),
strip.text = ggplot2::element_text(
face = "bold"
),
axis.text.x = ggplot2::element_text(
angle = 30,
hjust = 1
),
plot.caption = ggplot2::element_text(
hjust = 0
),
panel.grid.minor = ggplot2::element_blank(),
legend.position = "bottom"
)
p_gene_trajectoriesThe gene-level profiles connect the discrete OTS labels to the
observed expression data. For example, a gene labelled
down_up decreases during the first interval and then
increases during the second interval.
The same workflow applies to another longitudinal dataset.
#expr_use <- your_expression_matrix
#meta_use <- your_clinical_metadata
meta_use <- meta_use[
match(
colnames(expr_use),
meta_use$sample_id
),
,
drop = FALSE
]
rownames(meta_use) <- meta_use$sample_id
se <- SummarizedExperiment(
assays = list(
transcriptomics = expr_use
),
colData = DataFrame(meta_use)
)
mae <- MultiAssayExperiment(
experiments = ExperimentList(
transcriptomics = se
)
)
lot_object <- new(
"LongOmicsTraj",
mae = mae
)The assay name may be changed to match the data, for example:
or:
LongOmicsTraj starts from an expression matrix and clinical metadata. It estimates longitudinal profiles, computes adjacent changes and maps each feature to an interpretable trajectory signature. These signatures can then be compared across groups, queried using biological conditions and linked back to the observed molecular profiles.
# Load data
data("lot_glucold_expression")
data("lot_glucold_clinical")
# Create object
se <- SummarizedExperiment(
assays = list(
transcriptomics = lot_glucold_expression
),
colData = DataFrame(
lot_glucold_clinical
)
)
mae <- MultiAssayExperiment(
experiments = ExperimentList(
transcriptomics = se
)
)
lot <- new(
"LongOmicsTraj",
mae = mae
)
# Fit trajectories
lot <- lot_from_masigpro(
lot,
assay = "transcriptomics",
visits = c(
"Baseline",
"6 Months",
"30 Months"
),
overwrite = TRUE
)
# Compute OTS
lot <- lot_compute_deltas(
lot,
assay = "transcriptomics",
visits = c(
"Baseline",
"6 Months",
"30 Months"
)
)
lot <- lot_compute_thresholds(
lot,
assay = "transcriptomics",
method = "hybrid",
delta_min = 0.15,
k = 0.5,
visits = c(
"Baseline",
"6 Months",
"30 Months"
)
)
lot <- lot_map_to_ots(
lot,
visits = c(
"Baseline",
"6 Months",
"30 Months"
)
)
# Inspect results
head(
as.data.frame(
lot_ots(
lot
)
)
)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] stats4 stats graphics grDevices utils datasets methods
#> [8] base
#>
#> other attached packages:
#> [1] scales_1.4.0 ggplot2_4.0.3
#> [3] tibble_3.3.1 tidyr_1.3.2
#> [5] dplyr_1.2.1 LongOmicsTraj_0.99.0
#> [7] MultiAssayExperiment_1.39.1 SummarizedExperiment_1.43.0
#> [9] Biobase_2.73.2 GenomicRanges_1.65.4
#> [11] Seqinfo_1.3.2 IRanges_2.47.5
#> [13] S4Vectors_0.51.10 BiocGenerics_0.59.12
#> [15] generics_0.1.4 MatrixGenerics_1.25.0
#> [17] matrixStats_1.5.0 rmarkdown_2.32
#>
#> loaded via a namespace (and not attached):
#> [1] sass_0.4.10 SparseArray_1.13.4 lattice_0.23-1
#> [4] digest_0.6.39 magrittr_2.0.5 RColorBrewer_1.1-3
#> [7] evaluate_1.0.5 grid_4.6.1 fastmap_1.2.0
#> [10] jsonlite_2.0.0 Matrix_1.7-6 purrr_1.2.2
#> [13] jquerylib_0.1.4 abind_1.4-8 cli_3.6.6
#> [16] rlang_1.3.0 XVector_0.53.0 withr_3.0.3
#> [19] cachem_1.1.0 DelayedArray_0.39.8 yaml_2.3.12
#> [22] otel_0.2.0 S4Arrays_1.13.2 tools_4.6.1
#> [25] buildtools_1.0.0 vctrs_0.7.3 R6_2.6.1
#> [28] lifecycle_1.0.5 pkgconfig_2.0.3 gtable_0.3.6
#> [31] bslib_0.12.0 pillar_1.11.1 data.table_1.18.6.1
#> [34] glue_1.8.1 xfun_0.61 tidyselect_1.2.1
#> [37] sys_3.4.3 knitr_1.52 farver_2.1.2
#> [40] htmltools_0.5.9 labeling_0.4.3 maketools_1.3.2
#> [43] compiler_4.6.1 S7_0.2.2