hcocena builds co-expression networks
and reads biology off their structure. The idea is that genes which vary
together across samples tend to belong to the same programme, so a
gene-gene correlation network, cut into communities, gives you a handful
of modules instead of thousands of individual genes. Each
module can then be characterised – which conditions drive it, which
functions it is enriched for, which genes sit at its centre.
What makes it hCoCena (“horizontal CoCena”) is that several datasets can be analysed as one network. Different tissues, platforms or cohorts each contribute their own correlation network; those are merged, and the modules are found on the merged graph. A module is therefore a programme that exists across layers, and its behaviour can be compared between them.
This vignette runs the whole analysis end to end on a small simulated dataset, so every number and figure below is produced when the vignette is built. The steps that need an external service – ChEA3, Enrichr, DoRothEA, Cytoscape, an LLM endpoint – cannot run here; see Beyond this vignette for the full templates that cover them.
hcocena is distributed through Bioconductor and is
installed with BiocManager:
if (!requireNamespace("BiocManager", quietly = TRUE)) {
install.packages("BiocManager")
}
BiocManager::install("hcocena")Several optional steps rely on packages listed under
Suggests (for example limma,
decoupleR, RCy3 or ellmer); the
functions that need them say so and name the package to install.
Two layers of 40 genes and 32 samples each. The genes are real marker symbols for four blood cell types, and the simulation makes each set co-vary as a block, so the analysis should recover four modules that correspond to the four cell types. That is the point of using a simulation here: we know the answer in advance and can check that the pipeline finds it.
The samples are 16 donors measured at two timepoints, split across
three conditions, with a batch label and an age covariate – enough
structure for the statistics later on.
inst/scripts/make-fixtures.R in the installed package
contains the generating code.
extdir <- normalizePath(system.file("extdata", package = "hcocena"), winslash = "/")
counts_preview <- read.delim(file.path(extdir, "toy_layer1_counts.tsv"), nrows = 3)
counts_preview[, 1:5]
#> SYMBOL L1_D01_T1 L1_D01_T2 L1_D02_T1 L1_D02_T2
#> 1 CD3D 22.303 25.599 26.800 22.430
#> 2 CD3E 23.631 25.702 27.998 24.200
#> 3 CD3G 22.243 26.985 25.278 21.879
anno_preview <- read.delim(file.path(extdir, "toy_layer1_anno.tsv"))
head(anno_preview, 3)
#> SampleID group batch donor timepoint age
#> 1 L1_D01_T1 control B1 D01 T1 22
#> 2 L1_D01_T2 mild B1 D01 T2 22
#> 3 L1_D02_T1 severe B2 D02 T1 41Every analysis starts from an empty HCoCenaExperiment
and is configured by passing it through a series of functions. The
object carries the data, the settings and every intermediate result, so
there is no hidden state to keep track of.
outdir <- file.path(tempdir(), "hcocena-vignette-output")
dir.create(outdir, recursive = TRUE, showWarnings = FALSE)
hc <- hc_init()
hc <- hc_set_paths(
hc,
dir_count_data = paste0(extdir, "/"),
dir_annotation = paste0(extdir, "/"),
dir_reference_files = paste0(extdir, "/"),
dir_output = outdir
)
hc <- hc_init_save_folder(hc, name = "")
#> Using output directory directly (no additional save subfolder): /tmp/Rtmp1kBIuz/hcocena-vignette-outputhc_define_layers() names the layers and says which files
belong to them. The names are used in file names and plot labels from
here on.
hc <- hc_define_layers(
hc,
data_sets = list(
Layer1 = c("toy_layer1_counts.tsv", "toy_layer1_anno.tsv"),
Layer2 = c("toy_layer2_counts.tsv", "toy_layer2_anno.tsv")
)
)
hc <- hc_read_data(
hc,
sep_counts = "\t",
sep_anno = "\t",
gene_symbol_col = "SYMBOL",
sample_col = "SampleID",
count_has_rn = FALSE,
anno_has_rn = FALSE,
auto_setup_output = FALSE
)variable_of_interest is the annotation column that
defines the groups everything is later compared across;
control_keyword names the reference level.
range_GFC caps the group-fold-change scale introduced
below.
top_var is the number of most variable genes per layer
that enter the correlation. Genes that barely move across samples carry
no co-expression signal but do add noise and computation, so the network
is built on the variable ones. In a real analysis this is typically a
few thousand; here the dataset only has 40 genes, so we take all of
them.
min_corr and range_cutoff_length define the
grid of correlation cutoffs that the next step scans.
hc_run_expression_analysis_1() computes all pairwise
gene-gene correlations per layer and then, for every candidate cutoff on
the grid, records what the resulting network would look like.
Why a cutoff has to be chosen at all. A correlation matrix is a complete graph – every gene is connected to every other one, just with different weights. To get a network with structure you keep only edges above some correlation. Set it too low and everything is connected to everything, so there are no modules to find; set it too high and the network falls apart into fragments and you lose most of your genes.
hcocena picks the cutoff by two criteria at once:
The statistics behind the choice can be inspected per layer – R², node count and mean degree along the cutoff grid:
#> An object of class "HCoCenaExperiment"
#> Slot "mae":
#> A MultiAssayExperiment object of 2 listed
#> experiments with user-defined names and respective classes.
#> Containing an ExperimentList class object of length 2:
#> [1] set1: SummarizedExperiment with 40 rows and 32 columns
#> [2] set2: SummarizedExperiment with 40 rows and 32 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 files
#>
#> Slot "config":
#> An object of class "HCoCenaConfig"
#> Slot "global":
#> DataFrame with 1 row and 9 columns
#> save_folder organism control voi min_nodes_number_for_network
#> <character> <character> <character> <character> <numeric>
#> 1 human control group 1
#> min_nodes_number_for_cluster range_GFC layout_algorithm data_in_log
#> <numeric> <numeric> <character> <logical>
#> 1 1 2 layout_with_stress FALSE
#>
#> Slot "layer":
#> DataFrame with 2 rows and 9 columns
#> layer_id layer_name count_source annotation_source top_var
#> <character> <character> <character> <character> <numeric>
#> 1 set1 Layer1 toy_layer1_counts.tsv toy_layer1_anno.tsv 40
#> 2 set2 Layer2 toy_layer2_counts.tsv toy_layer2_anno.tsv 40
#> min_corr range_cutoff_length print_distribution_plots cutoff
#> <numeric> <numeric> <logical> <numeric>
#> 1 0.5 50 FALSE 0.604
#> 2 0.5 50 FALSE 0.809
#>
#> Slot "paths":
#> DataFrame with 1 row and 4 columns
#> dir_count_data dir_annotation dir_reference_files
#> <character> <character> <character>
#> 1 /tmp/RtmpHAEeNr/Rins.. /tmp/RtmpHAEeNr/Rins.. /tmp/RtmpHAEeNr/Rins..
#> dir_output
#> <character>
#> 1 /tmp/Rtmp1kBIuz/hcoc..
#>
#>
#> Slot "references":
#> An object of class "HCoCenaReferences"
#> Slot "registry":
#> DataFrame with 0 rows and 0 columns
#>
#> Slot "data":
#> List of length 0
#>
#>
#> Slot "layer_results":
#> List of length 2
#> names(2): set1 set2
#>
#> Slot "integration":
#> An object of class "HCoCenaIntegration"
#> Slot "combined_edgelist":
#> DataFrame with 0 rows and 0 columns
#>
#> Slot "graph":
#> NULL
#>
#> Slot "gfc":
#> DataFrame with 0 rows and 0 columns
#>
#> Slot "cluster":
#> List of length 0
#>
#>
#> Slot "satellite":
#> List of length 1
#> names(1): cutoff_selection
#>
#> Slot "provenance":
#> DataFrame with 1 row and 2 columns
#> created_at source
#> <character> <character>
#> 1 2026-10-04 00:55:58... legacy_hcobject
hc_run_expression_analysis_2() filters each layer’s
network to the chosen cutoff and computes group fold
changes (GFCs).
A GFC summarises, for one gene and one group of samples, how far that
group sits from the overall mean of the gene, on a scale capped at
range_GFC. It is what makes modules comparable across
layers: absolute expression differs between platforms and tissues, but
“up in severe relative to this gene’s own average” is the same quantity
everywhere.
Each layer now has its own filtered network.
hc_build_integrated_network() merges them into one
graph.
mode = "u" (union) keeps an edge present in
any layer – the inclusive choice, and the usual one.mode = "i" (intersection) keeps only edges present in
every layer, which is stricter and needs the layers to measure
largely the same genes.multi_edges says what to do when an edge exists in
several layers with different weights: "min" is
conservative, "mean" and "max" are the
alternatives.Communities are found with the Leiden algorithm.
resolution controls granularity: higher values give more,
smaller modules.
Leiden is stochastic, so hcocena runs it
no_of_iterations times and keeps the consensus. A gene ends
up in the module it was assigned to most often, and genes that never
settle are dropped – which is why a module assignment from this package
is more stable than a single Leiden run.
hc <- hc_cluster_calculation(
hc,
cluster_algo = "cluster_leiden",
no_of_iterations = 5,
resolution = 1
)Four modules of ten genes, as the simulation intended. At this point
the modules are identified by colour; the readable M1-style
labels are assigned when the module heatmap is drawn, which also shows
their GFC profile across the groups of both layers:
Module membership per gene is the table most downstream steps work from:
Modules are tested for over-representation of gene sets. In a normal
analysis these come from GO, KEGG, Hallmark or Reactome via
gene_sets =; here we write a small GMT of the four marker
panels instead, so that the vignette stays offline and
self-contained.
The background matters.
universe = "all_genes" (the default) tests each module
against every measured gene, answering “is this module enriched relative
to the transcriptome?”. universe = "network" tests against
the genes that entered the network – the genes that could have landed in
a module at all – answering “is this module enriched relative to the
other modules?”. The second removes the bias that top-variance selection
and the correlation cutoff introduce, and is the stricter question.
gmt <- file.path(tempdir(), "celltype_markers.gmt")
writeLines(c(
paste(c("T cells", "-", "CD3D", "CD3E", "CD3G", "CD2", "CD28",
"LCK", "ZAP70", "IL7R", "CD7", "TRAC"), collapse = "\t"),
paste(c("B cells", "-", "CD19", "MS4A1", "CD79A", "CD79B", "BLNK",
"PAX5", "CR2", "FCRL1", "TNFRSF13B", "VPREB3"), collapse = "\t"),
paste(c("Monocytes", "-", "CD14", "LYZ", "FCN1", "VCAN", "S100A8",
"S100A9", "CSF1R", "ITGAM", "CD68", "FCGR3A"), collapse = "\t"),
paste(c("NK cells", "-", "NKG7", "GNLY", "KLRD1", "KLRF1", "PRF1",
"GZMB", "NCR1", "KLRC1", "FGFBP2", "SPON2"), collapse = "\t")
), gmt)
hc <- hc_functional_enrichment(
hc,
gene_sets = character(0),
custom_gmt_files = c(CellTypes = gmt),
universe = "network"
)
#> Warning in calculate_qvalue(ora_res$pvalue): qvalue::qvalue() failed, returning
#> NA for qvalue. Error: missing values and NaN's not allowed if 'na.rm' is FALSE
#> Warning in calculate_qvalue(ora_res$pvalue): qvalue::qvalue() failed, returning
#> NA for qvalue. Error: missing values and NaN's not allowed if 'na.rm' is FALSE
#> Warning in calculate_qvalue(ora_res$pvalue): qvalue::qvalue() failed, returning
#> NA for qvalue. Error: missing values and NaN's not allowed if 'na.rm' is FALSE
#> Warning in calculate_qvalue(ora_res$pvalue): qvalue::qvalue() failed, returning
#> NA for qvalue. Error: missing values and NaN's not allowed if 'na.rm' is FALSEenr <- hc_satellite(hc, "enrichments")$significant_enrichments_all_dbs
as.data.frame(enr)[, c("module_label", "term", "GeneRatio", "qvalue")]
#> module_label term GeneRatio qvalue
#> 1 M1 NK cells 10/10 1.179718e-09
#> 2 M2 B cells 10/10 1.179718e-09
#> 3 M3 T cells 10/10 1.179718e-09
#> 4 M4 Monocytes 10/10 1.179718e-09Each module recovers exactly one cell type with all ten of its markers, which is the answer the simulation was built to have.
hc_module_condition_significance() reduces each module
to one value per sample – the mean expression of its genes – and tests
that against the conditions. Wilcoxon (or Kruskal-Wallis for more than
two groups) is rank-based, limma fits a linear model with
pairwise contrasts, and an optional mixed model handles repeated
measures via a donor random intercept.
standardize_modules = TRUE z-scores each module within a
layer first. Raw means carry the module’s absolute expression level,
which distorts limma’s variance moderation and makes effect sizes
incomparable between modules. Applied per layer it also removes the
offset between layers, which matters because the model formula itself
carries no layer term.
hc <- hc_module_condition_significance(
hc,
condition_col = "group",
run_limma = FALSE,
standardize_modules = TRUE,
export_excel = FALSE
)sig <- hc_satellite(hc, "module_condition_significance")$wilcox
as.data.frame(sig)[, c("cluster", "test", "p", "p_adj")]
#> cluster test p p_adj
#> 1 gold kruskal_fallback 0.8188524 0.8188524
#> 2 coral kruskal_fallback 0.6230004 0.8188524
#> 3 lightgreen kruskal_fallback 0.2910916 0.8188524
#> 4 steelblue kruskal_fallback 0.5062805 0.8188524Nothing is significant here, and that is the correct answer: the simulation assigned the condition labels at random, so there is no group effect to find.
Numeric covariates are correlated with module expression directly; categorical ones are correlated across the groups of the variable of interest, which needs at least three of them.
The numbers behind the module heatmap, per group and per sample:
Hubs are the genes most central to their own module, by weighted degree, closeness and betweenness. They are the natural candidates when a module has to be reduced to a few representatives.
vapply(hc_satellite(hc, "hub_out"), function(x) paste(x[[1]], collapse = ", "),
character(1))
#> M2 M4
#> "CD79A, VPREB3, FCRL1, CD79B, BLNK" "VCAN, CD68, S100A8, S100A9, CD14"
#> M1 M3
#> "SPON2, NKG7, KLRC1, FGFBP2, KLRD1" "CD3D, LCK, CD28, ZAP70, CD7"The hubs of each module are exactly the markers of the corresponding cell type, which is another way of seeing that the modules are clean.
Everything above is stored in the same
HCoCenaExperiment. The assays live in a
MultiAssayExperiment:
names(experiments(hc_mae(hc)))
#> [1] "set1" "set2"
vapply(experiments(hc_mae(hc)), function(x) dim(x)[[1]], integer(1))
#> set1 set2
#> 40 40Settings stay attached, so an analysis can be reconstructed from the object alone:
as.data.frame(methods::slot(hc_config(hc), "global"))[, c("organism", "voi", "range_GFC")]
#> organism voi range_GFC
#> 1 human group 2Downstream results are collected in the satellite slot, which
hc_satellite() returns:
Several parts of hcocena need services that cannot be
reached while a vignette is being built, and are therefore not shown
above:
hc_tf_overrep_module(), hc_check_tf()),hc_celltype_annotation(),
hc_celltype_activity_decoupler()),hc_upstream_inference()),hc_export_to_cytoscape()),hc_llm_enrichment())
through the API of Claude, OpenAI, Gemini or a local vLLM server – or,
without an API key, by pasting the request into any provider’s web chat
and reading the answer back with hc_llm_import().The complete walkthroughs are installed with the package as R Markdown templates:
dir(system.file("scripts", "workflows", package = "hcocena"), pattern = "[.]Rmd$")
#> [1] "hcocena_main.Rmd" "hcocena_satellite.Rmd"hcocena_main.Rmd is the full analysis;
hcocena_satellite.Rmd collects the optional downstream
steps. Copy one next to your data and edit the paths at the top:
file.copy(
system.file("scripts", "workflows", "hcocena_main.Rmd", package = "hcocena"),
"hcocena_main.Rmd"
)They are templates rather than reproducible documents: they point at your own count and annotation files and contact the services listed above.
The method – per-layer co-expression networks, group fold changes,
cutoff selection by scale-free topology, and integration of the layer
networks – is described in Oestreich et al. (2022),
Bioinformatics 38(20):4727-4734, doi:10.1093/bioinformatics/btac589.
A step-by-step protocol for a full analysis is given in Holsten et al.
(2024), STAR Protocols 5(1):102922, doi:10.1016/j.xpro.2024.102922.
Both papers describe the earlier function names without the
hc_ prefix; each of those functions has an hc_
counterpart here (for example functional_enrichment() is
now hc_functional_enrichment()).
citation("hcocena")
#> To cite hcocena, please use the method paper and, where the protocol
#> was followed, the STAR Protocols paper:
#>
#> Oestreich M, Holsten L, Agrawal S, Dahm K, Koch P, Jin H, Becker M,
#> Ulas T (2022). hCoCena: horizontal integration and analysis of
#> transcriptomics datasets. Bioinformatics 38(20):4727-4734.
#> doi:10.1093/bioinformatics/btac589
#>
#> Holsten L, Dahm K, Oestreich M, Becker M, Ulas T (2024). hCoCena: A
#> toolbox for network-based co-expression analysis and horizontal
#> integration of transcriptomic datasets. STAR Protocols 5(1):102922.
#> doi:10.1016/j.xpro.2024.102922
#>
#> To see these entries in BibTeX format, use 'print(<citation>,
#> bibtex=TRUE)', 'toBibtex(.)', or set
#> 'options(citation.bibtex.max=999)'.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] MultiAssayExperiment_1.39.1 SummarizedExperiment_1.43.0
#> [3] Biobase_2.73.2 GenomicRanges_1.65.4
#> [5] Seqinfo_1.3.2 IRanges_2.47.5
#> [7] S4Vectors_0.51.10 BiocGenerics_0.59.12
#> [9] generics_0.1.4 MatrixGenerics_1.25.0
#> [11] matrixStats_1.5.0 hcocena_0.99.12
#> [13] BiocStyle_2.41.0
#>
#> loaded via a namespace (and not attached):
#> [1] RColorBrewer_1.1-3 sys_3.4.3 jsonlite_2.0.0
#> [4] shape_1.4.6.1 tidydr_0.0.6 magrittr_2.0.5
#> [7] ggtangle_0.1.3 magick_2.9.1 farver_2.1.2
#> [10] rmarkdown_2.32 GlobalOptions_0.1.4 fs_2.1.0
#> [13] vctrs_0.7.3 memoise_2.0.1 Cairo_1.7-0
#> [16] ggtree_4.3.0 htmltools_0.5.9 S4Arrays_1.13.2
#> [19] BiocBaseUtils_1.15.1 SparseArray_1.13.4 gridGraphics_0.5-1
#> [22] sass_0.4.10 bslib_0.12.0 htmlwidgets_1.6.4
#> [25] plyr_1.8.9 httr2_1.3.0 cachem_1.1.0
#> [28] buildtools_1.0.0 igraph_2.3.4 lifecycle_1.0.5
#> [31] iterators_1.0.14 pkgconfig_2.0.3 gson_0.2.1
#> [34] Matrix_1.7-6 R6_2.6.1 fastmap_1.2.0
#> [37] clue_0.3-68 digest_0.6.39 aplot_0.3.2
#> [40] enrichplot_1.99.7 colorspace_2.1-3 ggnewscale_0.5.2
#> [43] patchwork_1.3.2 AnnotationDbi_1.75.2 aisdk_1.4.12
#> [46] ps_1.9.3 RSQLite_3.53.3 labeling_0.4.3
#> [49] polyclip_1.10-7 httr_1.4.9 abind_1.4-8
#> [52] compiler_4.6.1 fontquiver_0.2.1 bit64_4.8.6
#> [55] withr_3.0.3 doParallel_1.0.17 S7_0.2.2
#> [58] DBI_1.3.0 ggforce_0.5.0 MASS_7.3-66
#> [61] rappdirs_0.3.4 DelayedArray_0.39.8 rjson_0.2.23
#> [64] ggsci_5.2.0 gtools_3.9.5 tools_4.6.1
#> [67] otel_0.2.0 scatterpie_0.2.6 ape_5.8-1
#> [70] zip_3.0.2 glue_1.8.1 callr_3.8.0
#> [73] nlme_3.1-171 GOSemSim_2.39.3 grid_4.6.1
#> [76] cluster_2.1.8.3 reshape2_1.4.5 gtable_0.3.6
#> [79] tidyr_1.3.2 data.table_1.18.6.1 XVector_0.53.0
#> [82] ggrepel_0.9.8 foreach_1.5.2 pillar_1.11.1
#> [85] stringr_1.6.0 yulab.utils_0.2.5 splines_4.6.1
#> [88] circlize_0.4.18 tweenr_2.0.3 dplyr_1.2.1
#> [91] treeio_1.37.1 lattice_0.23-1 bit_4.6.0
#> [94] tidyselect_1.2.1 fontLiberation_0.1.0 GO.db_3.23.1
#> [97] ComplexHeatmap_2.29.0 maketools_1.3.2 Biostrings_2.81.9
#> [100] knitr_1.52 fontBitstreamVera_0.1.1 xfun_0.61
#> [103] graphlayouts_1.2.5 leidenbase_0.1.37 stringi_1.8.9
#> [106] lazyeval_0.2.3 ggfun_0.2.1 yaml_2.3.12
#> [109] evaluate_1.0.5 codetools_0.2-20 qvalue_2.45.0
#> [112] gdtools_0.5.1 tibble_3.3.1 BiocManager_1.30.27
#> [115] ggplotify_0.1.3 cli_3.6.6 systemfonts_1.3.2
#> [118] processx_3.9.0 jquerylib_0.1.4 Rcpp_1.1.2
#> [121] png_0.1-9 parallel_4.6.1 ggplot2_4.0.3
#> [124] blob_1.3.0 clusterProfiler_4.21.3 DOSE_4.7.3
#> [127] viridisLite_0.4.3 tidytree_0.4.8 ggiraph_0.9.6
#> [130] enrichit_0.2.5 rlist_0.4.6.2 scales_1.4.0
#> [133] openxlsx_4.2.9 purrr_1.2.2 crayon_1.5.3
#> [136] GetoptLong_1.1.1 rlang_1.3.0 cowplot_1.2.0
#> [139] KEGGREST_1.53.6