R/trans_multiomics.R

#' @title
#' Multi-omics data integration analysis.
#' 
#' @description
#' This class is a wrapper for microbiome multi-omics data integration analysis based on the \code{mixOmics} package
#' <doi:10.1371/journal.pcbi.1005752>. It integrates multiple \code{\link{microtable}} objects (e.g. microbiome and metabolome data)
#' and provides methods for data preprocessing, supervised/unsupervised multi-block integration (DIABLO, sPLS),
#' parameter tuning, model evaluation, feature extraction and visualization.
#'
#' The core integration models are powered by \code{mixOmics} functions such as
#' \code{block.splsda} (DIABLO), \code{block.spls} and \code{spls}.
#'
#' @export
trans_multiomics <- R6Class(classname = "trans_multiomics",
	public = list(
		#' @description
		#' Create the \code{trans_multiomics} object.
		#'
		#' The function receives multiple \code{\link{microtable}} objects and automatically aligns samples
		#' (intersection of sample names across all microtable objects) to ensure the matrices are compatible
		#' for multi-omics integration.
		#' @param microtables default NULL; a named \code{list} of \code{\link{microtable}} objects (e.g. \code{list(microb = soil_microb, metab = soil_metab)}),
		#' 	 or a single \code{microtable} object. The names of the list are used as the omics block names.
		#' @param group default NULL; a factor or character vector indicating the group membership of each sample.
		#' 	 If NULL, the function extracts the group from \code{group_col} column of \code{sample_table} in the first microtable.
		#' @param group_col default "Group"; the column name in \code{sample_table} used to extract the group factor when \code{group} is NULL.
		#' @return \code{self} (invisible), enabling chained calls; \code{dataset_list}, \code{data_list_names} and \code{Y} are stored in the object.
		#' @examples
		#' \dontrun{
		#' data(soil_microb)
		#' data(soil_metab)
		#' t1 <- trans_multiomics$new(
		#'   microtables = list(microb = soil_microb, metab = soil_metab),
		#'   group_col = "Group"
		#' )
		#' }
		initialize = function(
			microtables = NULL,
			group = NULL,
			group_col = "Group"
			){
			if(is.null(microtables)){
				stop("Please provide microtables parameter: a list of microtable objects or a single microtable object!")
			}
			if(inherits(microtables, "microtable")){
				microtables <- list(microtables)
				names(microtables) <- "block1"
			}
			if(!inherits(microtables, "list")){
				stop("microtables must be a list of microtable objects or a single microtable object!")
			}
			lapply(microtables, function(x){
				if(!inherits(x, "microtable")){
					stop("Each element in microtables must be a microtable object!")
				}
			})
			if(is.null(names(microtables))){
				names(microtables) <- paste0("block", seq_along(microtables))
			}else{
				# fill the empty/NA names of a partially named list (e.g. list(microb = x, y))
				empty_names <- is.na(names(microtables)) | names(microtables) == ""
				if(any(empty_names)){
					names(microtables)[empty_names] <- paste0("block", which(empty_names))
				}
			}
			block_names <- names(microtables)
			# tidy each microtable (clone to protect user's original data from R6 reference semantics)
			microtables <- lapply(microtables, function(x){
				new_x <- clone(x)
				new_x$tidy_dataset()
				new_x
			})
			# align samples: intersection across all microtable objects
			sample_lists <- lapply(microtables, function(x) rownames(x$sample_table))
			common_samples <- Reduce(intersect, sample_lists)
			if(length(common_samples) == 0){
				stop("No common samples found across all microtable objects! Please check the sample names!")
			}
			removed_total <- sum(lengths(sample_lists)) - length(common_samples) * length(microtables)
			if(removed_total > 0){
				message(removed_total, " sample(s) removed to align samples across all blocks (intersection) ...")
			}
			# filter each microtable to common samples
			microtables <- lapply(microtables, function(x){
				if(nrow(x$sample_table) > length(common_samples)){
					x$sample_table <- x$sample_table[common_samples, , drop = FALSE]
					x$tidy_dataset()
				}
				x
			})
			# sort common samples consistently
			common_samples <- sort(common_samples)
			microtables <- lapply(microtables, function(x){
				x$sample_table <- x$sample_table[common_samples, , drop = FALSE]
				x$tidy_dataset()
				x
			})
			# extract Y
			if(is.null(group)){
				if(!is.null(group_col)){
					microeco:::check_table_variable(microtables[[1]]$sample_table, group_col, "group_col", "sample_table")
					group <- microtables[[1]]$sample_table[, group_col]
					# the sample_table has been sorted to common_samples above; attach names directly
					names(group) <- rownames(microtables[[1]]$sample_table)
				}
			}
			if(!is.null(group)){
				if(!is.factor(group)){
					group <- as.factor(group)
				}
				# drop the empty levels possibly left by the sample intersection (or by a named group with extra levels)
				group <- droplevels(group)
				if(is.null(names(group))){
					if(length(group) != length(common_samples)){
						stop("The length of group (", length(group), ") does not match the number of common samples (", length(common_samples), ")!")
					}
					warning("The group vector has no names; samples are matched by order. Please provide a named vector to ensure correct sample alignment!")
					names(group) <- common_samples
				}else{
					if(!setequal(names(group), common_samples)){
						stop("The names of the group vector do not match the common samples! Please check the sample names of the group parameter!")
					}
					# reorder group to match the sorted common samples
					group <- group[common_samples]
				}
				if(nlevels(group) < 2){
					stop("The group factor must have at least 2 levels for supervised analysis!")
				}
			}
			self$dataset_list <- microtables
			self$data_list_names <- block_names
			self$Y <- group
			message("Multi-omics data loaded with ", length(common_samples), " samples and ", length(microtables), " blocks: ", paste(block_names, collapse = ", "), " ...")
			invisible(self)
		},

		#' @description
		#' Preprocess the data: filter low-abundance features and apply normalization/transformation.
		#'
		#' This function reuses the \code{\link{trans_norm}} class to apply normalization methods (e.g. CLR)
		#' to each omics block. The processed matrices (rows are samples, columns are features) are stored
		#' in \code{data_list} for \code{mixOmics} input.
		#' @param method default "clr"; the normalization method passed to \code{trans_norm$norm}.
		#' 	 Available options include \code{"clr"}, \code{"rclr"}, \code{"css"}, \code{"tss"}, \code{"log"}, etc.
		#' 	 See the \code{method} parameter of \code{\link{trans_norm}} class for details.
		#' @param pseudocount default 1; add pseudocount for zero values when \code{method = "clr"}.
		#' @param filter_thres default NULL; the relative abundance threshold for filtering low-abundance features.
		#' 	 Passed to \code{rel_abund} parameter of \code{microtable$filter_taxa}. NULL means no filtering.
		#' @param filter_freq default 1; the occurrence frequency threshold for filtering features.
		#' 	 Passed to \code{freq} parameter of \code{microtable$filter_taxa}.
		#' @param ... other parameters passed to \code{norm} method of \code{\link{trans_norm}} class.
		#' @return \code{self} (invisible), enabling chained calls; \code{data_list} is stored in the object.
		#' @examples
		#' \dontrun{
		#' t1$preprocess_data(method = "clr", filter_thres = 0.0001, filter_freq = 0.2)
		#' }
		preprocess_data = function(
			method = "clr",
			pseudocount = 1,
			filter_thres = NULL,
			filter_freq = 1,
			...
			){
			if(is.null(self$dataset_list)){
				stop("The dataset_list is NULL! Please create the object with microtables first!")
			}
			data_list <- list()
			for(i in seq_along(self$dataset_list)){
				block_name <- self$data_list_names[i]
				tmp_dataset <- clone(self$dataset_list[[i]])
				# filter low-abundance features
				if(!is.null(filter_thres) || filter_freq != 1){
					if(is.null(filter_thres)){
						filter_thres_use <- 0
					}else{
						filter_thres_use <- filter_thres
					}
					tmp_dataset$filter_taxa(rel_abund = filter_thres_use, freq = filter_freq)
				}
				# reuse trans_norm for normalization
				tmp_norm <- trans_norm$new(dataset = tmp_dataset)
				res_dataset <- tmp_norm$norm(method = method, pseudocount = pseudocount, ...)
				# extract transposed otu_table (rows = samples, cols = features)
				if(inherits(res_dataset, "microtable")){
					abund_matrix <- as.matrix(t(res_dataset$otu_table))
				}else{
					abund_matrix <- as.matrix(res_dataset)
				}
				# align rows to Y names if available
				if(!is.null(self$Y)){
					abund_matrix <- abund_matrix[rownames(abund_matrix) %in% names(self$Y), , drop = FALSE]
					abund_matrix <- abund_matrix[names(self$Y), , drop = FALSE]
				}
				data_list[[block_name]] <- abund_matrix
				message("Block '", block_name, "': ", nrow(abund_matrix), " samples x ", ncol(abund_matrix), " features after preprocessing ...")
			}
			self$data_list <- data_list
			message("Preprocessed data is stored in object$data_list ...")
			invisible(self)
		},

		#' @description
		#' Set the design matrix for DIABLO analysis.
		#'
		#' The design matrix defines the weights of correlation between different omics blocks.
		#' A full design (weight = 1) maximizes the covariance between blocks, while a small weight
		#' (e.g. 0.1) favors classification accuracy.
		#' @param design default NULL; a user-specified design matrix. If NULL, a matrix filled with
		#' 	 \code{weight} (with 0 on the diagonal) is created.
		#' @param weight default 0.1; the off-diagonal weight when \code{design} is NULL.
		#' @return \code{self} (invisible), enabling chained calls; \code{design} is stored in the object.
		#' @examples
		#' \dontrun{
		#' t1$set_design(weight = 0.1)
		#' }
		set_design = function(
			design = NULL,
			weight = 0.1
			){
			if(is.null(self$data_list)){
				stop("The data_list is NULL! Please run preprocess_data() first!")
			}
			n_block <- length(self$data_list)
			if(is.null(design)){
				design <- matrix(weight, ncol = n_block, nrow = n_block,
					dimnames = list(self$data_list_names, self$data_list_names))
				diag(design) <- 0
			}else{
				if(!all(dim(design) == c(n_block, n_block))){
					stop("The design matrix must be ", n_block, " x ", n_block, "!")
				}
				if(is.null(rownames(design))){
					rownames(design) <- self$data_list_names
				}
				if(is.null(colnames(design))){
					colnames(design) <- self$data_list_names
				}
			}
			self$design <- design
			message("Design matrix is stored in object$design ...")
			invisible(self)
		},

		#' @description
		#' Run supervised multi-omics integration using DIABLO (block.sPLS-DA).
		#'
		#' This function wraps \code{mixOmics::block.splsda} to perform supervised multi-block sparse PLS-DA.
		#' It is suitable when the user has a clear grouping variable and wants to identify cross-omics
		#' features that discriminate between groups.
		#' @param ncomp default 2; the number of components.
		#' @param keepX default NULL; a list of variable numbers to select per block per component.
		#' 	 If NULL, all features are retained (full model). Example: \code{list(microb = c(10, 10), metab = c(15, 15))}.
		#' @param design default NULL; the design matrix. If NULL, use \code{self$design}; if still NULL, create with \code{set_design()}.
		#' @param scale default TRUE; whether to scale the data.
		#' @param ... other parameters passed to \code{mixOmics::block.splsda}.
		#' @return \code{self} (invisible), enabling chained calls; \code{model} is stored in the object.
		#' @examples
		#' \dontrun{
		#' t1$run_diablo(ncomp = 2, keepX = list(microb = c(10, 10), metab = c(15, 15)))
		#' }
		run_diablo = function(
			ncomp = 2,
			keepX = NULL,
			design = NULL,
			scale = TRUE,
			...
			){
			private$check_data_list()
			if(is.null(self$Y)){
				stop("The Y (group) is NULL! Please provide group when creating the object!")
			}
			if(is.null(design)){
				if(is.null(self$design)){
					self$set_design()
				}
				design <- self$design
			}
			if(is.null(keepX)){
				keepX <- private$build_keepX_default(ncomp = ncomp)
				message("keepX is NULL; using all features (full model). For sparse model, please provide keepX or run tune_model() ...")
			}
			message("Running DIABLO (block.splsda) with ncomp = ", ncomp, " ...")
			self$model <- mixOmics::block.splsda(
				X = self$data_list,
				Y = self$Y,
				ncomp = ncomp,
				keepX = keepX,
				design = design,
				scale = scale,
				...
			)
			message("DIABLO model is stored in object$model ...")
			invisible(self)
		},

		#' @description
		#' Run unsupervised multi-omics integration using block.sPLS.
		#'
		#' This function wraps \code{mixOmics::block.spls} to perform unsupervised multi-block sparse PLS.
		#' It is suitable when there is no grouping variable and the user wants to explore the covariance
		#' structure across multiple omics data sets.
		#' Note that \code{mixOmics::block.spls} requires a numeric response matrix, so one omics block
		#' must be designated as the response block through the \code{Y_block} parameter (passed to \code{indY}).
		#' @param ncomp default 2; the number of components.
		#' @param Y_block default NULL; the index or name of the omics block used as the response (\code{indY}).
		#' 	 If NULL, the last block in \code{data_list} is used.
		#' @param keepX default NULL; a list of variable numbers to select per block per component (including the response block,
		#' 	 as \code{mixOmics} ignores keepY when \code{indY} is used). If NULL, all features are retained (full model).
		#' @param design default NULL; the design matrix. If NULL, use \code{self$design}; if still NULL, create with \code{set_design()}.
		#' @param mode default "regression"; the PLS mode. One of "regression", "canonical", "invariant", "classic".
		#' @param scale default TRUE; whether to scale the data.
		#' @param ... other parameters passed to \code{mixOmics::block.spls}.
		#' @return \code{self} (invisible), enabling chained calls; \code{model} is stored in the object.
		#' @examples
		#' \dontrun{
		#' t1$run_spls(ncomp = 2, Y_block = "metab", mode = "regression")
		#' }
		run_spls = function(
			ncomp = 2,
			Y_block = NULL,
			keepX = NULL,
			design = NULL,
			mode = "regression",
			scale = TRUE,
			...
			){
			private$check_data_list()
			n_block <- length(self$data_list)
			if(n_block < 2){
				stop("run_spls requires at least 2 omics blocks! For two-omics analysis, run_spls_single() is recommended!")
			}
			# mixOmics::block.spls requires a numeric response matrix; designate one omics block as the response via indY
			if(is.null(Y_block)){
				Y_block <- n_block
				message("Y_block is NULL; using the last block '", self$data_list_names[Y_block], "' as the response block (indY) ...")
			}
			if(is.character(Y_block)){
				Y_block_name <- Y_block
				Y_block <- match(Y_block, self$data_list_names)
				if(is.na(Y_block)){
					stop("Y_block '", Y_block_name, "' not found in data_list_names: ", paste(self$data_list_names, collapse = ", "), "!")
				}
			}
			if(!is.numeric(Y_block) || length(Y_block) != 1 || Y_block < 1 || Y_block > n_block){
				stop("Y_block must be a single integer between 1 and ", n_block, " or a valid block name!")
			}
			if(is.null(design)){
				if(is.null(self$design)){
					self$set_design()
				}
				design <- self$design
			}
			if(is.null(keepX)){
				keepX <- private$build_keepX_default(ncomp = ncomp)
				message("keepX is NULL; using all features (full model). For sparse model, please provide keepX ...")
			}
			message("Running block.spls (unsupervised) with response block '", self$data_list_names[Y_block], "', ncomp = ", ncomp, " ...")
			self$model <- mixOmics::block.spls(
				X = self$data_list,
				indY = Y_block,
				ncomp = ncomp,
				keepX = keepX,
				design = design,
				mode = mode,
				scale = scale,
				...
			)
			message("block.spls model is stored in object$model ...")
			invisible(self)
		},

		#' @description
		#' Run two-omics sPLS (sparse PLS) regression for unsupervised association analysis.
		#'
		#' This function wraps \code{mixOmics::spls} to perform sparse PLS between two omics blocks.
		#' It is useful for exploring the covariation between two data sets (e.g. microbiome and metabolome).
		#' @param X_block default 1; the index or name of the X block in \code{data_list}.
		#' @param Y_block default 2; the index or name of the Y block in \code{data_list}.
		#' @param ncomp default 2; the number of components.
		#' @param mode default "regression"; the PLS mode.
		#' @param keepX default NULL; the number of variables to select in X per component.
		#' @param keepY default NULL; the number of variables to select in Y per component.
		#' @param scale default TRUE; whether to scale the data.
		#' @param ... other parameters passed to \code{mixOmics::spls}.
		#' @return \code{self} (invisible), enabling chained calls; \code{model} is stored in the object.
		#' @examples
		#' \dontrun{
		#' t1$run_spls_single(X_block = "microb", Y_block = "metab", ncomp = 2)
		#' }
		run_spls_single = function(
			X_block = 1,
			Y_block = 2,
			ncomp = 2,
			mode = "regression",
			keepX = NULL,
			keepY = NULL,
			scale = TRUE,
			...
			){
			private$check_data_list()
			X_block <- private$resolve_single_block(X_block, "X_block")
			Y_block <- private$resolve_single_block(Y_block, "Y_block")
			if(X_block == Y_block){
				stop("X_block and Y_block must be different blocks!")
			}
			X_data <- self$data_list[[X_block]]
			Y_data <- self$data_list[[Y_block]]
			message("Running spls (two-omics) with X block '", X_block,
				"' and Y block '", Y_block, "', ncomp = ", ncomp, " ...")
			self$model <- mixOmics::spls(
				X = X_data,
				Y = Y_data,
				ncomp = ncomp,
				mode = mode,
				keepX = keepX,
				keepY = keepY,
				scale = scale,
				...
			)
			message("spls model is stored in object$model ...")
			invisible(self)
		},

		#' @description
		#' Tune the DIABLO model to select the optimal number of variables to select (keepX).
		#'
		#' This function wraps \code{mixOmics::tune.block.splsda} (or \code{tune.block.spls}) to perform
		#' cross-validation and identify the optimal \code{keepX} per block per component.
		#' The optimal parameters are stored in \code{best_keepX} for use in the final model.
		#' @param method default "block.splsda"; the tuning method. Currently only "block.splsda" is supported,
		#' 	 as \code{mixOmics} provides no tune function for unsupervised block.spls.
		#' @param ncomp default 2; the number of components to tune.
		#' @param test_keepX default NULL; a list of candidate keepX values per block.
		#' 	 If NULL, an adaptive grid (5\%, 10\% and 20\% of the feature number of each block) is used.
		#' @param folds default 10; the number of folds for cross-validation.
		#' 	 Must not exceed the smallest class size of \code{Y}.
		#' @param nrepeat default 1; the number of repeats. Increase to 10-50 for robust results.
		#' @param dist default "centroids.dist"; the prediction distance for DA methods.
		#' @param seed default NULL; an integer used to set the random seed before cross-validation for reproducible results.
		#' @param ... other parameters passed to \code{mixOmics::tune.block.splsda}.
		#' @return \code{self} (invisible), enabling chained calls; \code{tune_result} and \code{best_keepX} are stored in the object.
		#' @examples
		#' \dontrun{
		#' t1$tune_model(ncomp = 2, test_keepX = list(microb = c(5, 10, 20), 
		#' 		metab = c(5, 10, 20)), seed = 123)
		#' }
		tune_model = function(
			method = "block.splsda",
			ncomp = 2,
			test_keepX = NULL,
			folds = 10,
			nrepeat = 1,
			dist = "centroids.dist",
			seed = NULL,
			...
			){
			private$check_data_list()
			if(!is.null(self$Y)){
				min_class <- min(table(self$Y))
				if(folds > min_class){
					stop("folds (", folds, ") exceeds the smallest class size (", min_class, ") in Y! Please reduce folds!")
				}
			}
			if(is.null(self$design)){
				self$set_design()
			}
			if(is.null(test_keepX)){
				test_keepX <- lapply(self$data_list, function(x){
					cand <- unique(round(ncol(x) * c(0.05, 0.1, 0.2)))
					cand <- cand[cand >= 1 & cand <= ncol(x)]
					if(length(cand) == 0){
						cand <- min(5, ncol(x))
					}
					cand
				})
				message("Using adaptive test_keepX grid: ", paste(unlist(lapply(test_keepX, function(x) paste(x, collapse = ","))), collapse = " | "), " ...")
			}
			if(method != "block.splsda"){
				stop("method must be 'block.splsda'! mixOmics does not provide a tune function for unsupervised block.spls!")
			}
			if(is.null(self$Y)){
				stop("The Y (group) is NULL! tune_model requires a grouping variable for block.splsda tuning!")
			}
			message("Tuning ", method, " with folds = ", folds, ", nrepeat = ", nrepeat, " ...")
			# mixOmics (>= 6.3.2) provides its own seed argument and resets the RNG internally when it is missing;
			# pass seed explicitly when supported, otherwise fall back to set.seed
			seed_supported <- "seed" %in% names(formals(mixOmics::tune.block.splsda))
			if(!is.null(seed) && !seed_supported){
				set.seed(seed)
			}
			tune_args <- list(
				X = self$data_list,
				Y = self$Y,
				ncomp = ncomp,
				test.keepX = test_keepX,
				design = self$design,
				validation = "Mfold",
				folds = folds,
				nrepeat = nrepeat,
				dist = dist,
				...
			)
			if(!is.null(seed) && seed_supported){
				tune_args$seed <- seed
			}
			tune_res <- do.call(mixOmics::tune.block.splsda, tune_args)
			self$tune_result <- tune_res
			if(!is.null(tune_res$choice.keepX)){
				self$best_keepX <- tune_res$choice.keepX
				message("Optimal keepX selected: ", paste(unlist(lapply(tune_res$choice.keepX, function(x) paste(x, collapse = ","))), collapse = " | "), " ...")
			}
			message("Tuning result is stored in object$tune_result, optimal keepX in object$best_keepX ...")
			invisible(self)
		},

		#' @description
		#' Evaluate the performance of the model using cross-validation.
		#'
		#' This function wraps \code{mixOmics::perf} to assess the classification error rate (BER) or
		#' prediction performance of the model.
		#' @param method default "Mfold"; the cross-validation method. "Mfold" or "loo".
		#' @param folds default 10; the number of folds (when method = "Mfold").
		#' 	 Must not exceed the smallest class size of \code{Y}.
		#' @param nrepeat default 1; the number of repeats.
		#' @param seed default NULL; an integer used to set the random seed before cross-validation for reproducible results.
		#' @param ... other parameters passed to \code{mixOmics::perf}.
		#' @return \code{self} (invisible), enabling chained calls; \code{perf_result} is stored in the object.
		#' @examples
		#' \dontrun{
		#' t1$eval_performance(folds = 5, nrepeat = 1, seed = 123)
		#' }
		eval_performance = function(
			method = "Mfold",
			folds = 10,
			nrepeat = 1,
			seed = NULL,
			...
			){
			private$check_model()
			if(method == "Mfold" && !is.null(self$Y)){
				min_class <- min(table(self$Y))
				if(folds > min_class){
					stop("folds (", folds, ") exceeds the smallest class size (", min_class, ") in Y! Please reduce folds!")
				}
			}
			message("Evaluating model performance with ", method, ", folds = ", folds, ", nrepeat = ", nrepeat, " ...")
			# pass seed to the dispatched perf method when supported (mixOmics >= 6.3.2); otherwise fall back to set.seed
			seed_supported <- FALSE
			if(!is.null(seed)){
				for(cl in class(self$model)){
					f <- tryCatch(getFromNamespace(paste0("perf.", cl), "mixOmics"), error = function(e) NULL)
					if(!is.null(f) && "seed" %in% names(formals(f))){
						seed_supported <- TRUE
						break
					}
				}
				if(!seed_supported){
					set.seed(seed)
				}
			}
			perf_args <- list(object = self$model, validation = method, folds = folds, nrepeat = nrepeat, ...)
			if(!is.null(seed) && seed_supported){
				perf_args$seed <- seed
			}
			self$perf_result <- do.call(mixOmics::perf, perf_args)
			message("Performance result is stored in object$perf_result ...")
			message("Tip: run get_stability() to extract the feature selection stability from the cross-validation results ...")
			invisible(self)
		},

		#' @description
		#' Extract the selected features (variables with non-zero loadings) from the model.
		#'
		#' This function wraps \code{mixOmics::selectVar} to extract the selected variables on a given
		#' component, and returns a tidy data.frame with feature names, block, component and loading values.
		#' @param comp default 1; the component of interest.
		#' @param block default NULL; the block(s) to extract. NULL means all blocks. Can be integer index or block name.
		#' @return \code{res_features} stored in the object and returned.
		#' @examples
		#' \dontrun{
		#' features <- t1$get_features(comp = 1)
		#' }
		get_features = function(
			comp = 1,
			block = NULL
			){
			private$check_model()
			private$check_comp(comp)
			sel <- mixOmics::selectVar(object = self$model, comp = comp, block = block)
			# organize into a tidy data.frame
			res_list <- list()
			# newer mixOmics versions append a non-block element (e.g. 'comp') to the output; keep only real blocks
			blocks_available <- names(sel)[vapply(sel, is.list, logical(1))]
			for(b in blocks_available){
				block_sel <- sel[[b]]
				if(!is.null(block_sel$name)){
					feature_names <- block_sel$name
					# the column name of the loading data.frame differs across mixOmics versions (e.g. 'value.var')
					loading_values <- if(is.data.frame(block_sel$value)) block_sel$value[[1]] else as.vector(block_sel$value)
					# align the loading values with feature names
					if(length(loading_values) == length(feature_names)){
						res_list[[b]] <- data.frame(
							Feature = feature_names,
							Block = b,
							Comp = comp,
							Loading_value = loading_values,
							stringsAsFactors = FALSE
						)
					}else{
						warning("Loading values misaligned with feature names in block '", b, "' (comp = ", comp, "); Loading_value set to NA!")
						res_list[[b]] <- data.frame(
							Feature = feature_names,
							Block = b,
							Comp = comp,
							Loading_value = NA_real_,
							stringsAsFactors = FALSE
						)
					}
				}
			}
			if(length(res_list) > 0){
				res_df <- as.data.frame(dplyr::bind_rows(res_list), stringsAsFactors = FALSE)
				rownames(res_df) <- NULL
					res_df <- res_df[order(abs(res_df$Loading_value), decreasing = TRUE), ]
				}else{
					res_df <- data.frame(Feature = character(), Block = character(), Comp = integer(), Loading_value = numeric(), stringsAsFactors = FALSE)
				}
			self$res_features <- res_df
			message("Extracted ", nrow(res_df), " features. Result is stored in object$res_features ...")
			res_df
		},

		#' @description
		#' Extract the correlation/similarity matrix between selected variables from the model.
		#'
		#' This function uses the internal similarity computation of the model to derive a correlation
		#' matrix between selected variables across blocks. The result can be used for external network
		#' analysis or pathway enrichment.
		#' @param comp default 1; the component(s) used to compute the similarity (identical across all blocks).
		#' @param block default NULL; the block(s) to include. NULL means all blocks.
		#' @param cutoff default NULL; only show correlations with absolute value above this threshold.
		#' @return \code{res_correlation} stored in the object and returned.
		#' @examples
		#' \dontrun{
		#' cor_mat <- t1$get_correlation(comp = 1, cutoff = 0.7)
		#' }
		get_correlation = function(
			comp = 1,
			block = NULL,
			cutoff = NULL
			){
			private$check_model()
			private$check_comp(comp)
			# use network function to extract the similarity matrix
			# note: mixOmics >= 6.x uses plot.graph to control plotting (older versions used show),
			# and requires comp as a named list for block models
			comp_fmt <- private$format_network_comp(comp, block)
			if("plot.graph" %in% names(formals(mixOmics::network))){
				net_res <- mixOmics::network(self$model, comp = comp_fmt, blocks = block, plot.graph = FALSE)
			}else{
				net_res <- mixOmics::network(self$model, comp = comp_fmt, blocks = block, show = FALSE)
			}
			if(!is.null(net_res)){
				M <- net_res$M
				if(!is.null(cutoff)){
					M[abs(M) < cutoff] <- 0
				}
				self$res_correlation <- M
				message("Correlation matrix is stored in object$res_correlation ...")
				return(M)
			}
			message("No correlation matrix extracted ...")
			invisible(NULL)
		},

		#' @description
		#' Extract the feature selection stability from the cross-validation results.
		#'
		#' This function summarizes how often each feature was selected across the cross-validation
		#' folds and repeats (stored in \code{perf_result$features$stable} of \code{mixOmics::perf}).
		#' Features with high stability (close to 1) are robust biomarker candidates, while features
		#' with low stability may be artifacts of a particular data split.
		#' @param comp default NULL; the component(s) to extract. NULL means all components.
		#' @return \code{res_stability} stored in the object and returned; a tidy data.frame with columns
		#' 	 Feature, Block, Comp and Stability (mean selection frequency across folds and repeats).
		#' @examples
		#' \dontrun{
		#' t1$eval_performance(folds = 5, nrepeat = 10, seed = 123)
		#' stab <- t1$get_stability()
		#' }
		get_stability = function(
			comp = NULL
			){
			private$check_model()
			if(is.null(self$perf_result)){
				stop("The perf_result is NULL! Please run eval_performance() first!")
			}
			stable <- self$perf_result$features$stable
			if(is.null(stable)){
				stop("No feature stability information found in perf_result! ",
					"Feature stability requires a sparse supervised model (e.g. block.splsda with keepX) evaluated with eval_performance()!")
			}
			# structure of stable: stable[[nrep]][[block]][[compX]] = named frequency table
			res_list <- list()
			nreps <- names(stable)
			blocks <- unique(unlist(lapply(stable, names)))
			for(b in blocks){
				comps <- unique(unlist(lapply(stable, function(x){
					if(!is.null(x[[b]])) names(x[[b]]) else NULL
				})))
				for(cp in comps){
					comp_num <- as.integer(gsub("comp", "", cp))
					if(!is.null(comp) && !(comp_num %in% comp)){
						next
					}
					# collect the frequency tables across repeats
					tabs <- lapply(nreps, function(nr){
						stable[[nr]][[b]][[cp]]
					})
					tabs <- tabs[!vapply(tabs, is.null, logical(1))]
					if(length(tabs) == 0){
						next
					}
					# a feature absent in one repeat was never selected there: fill with 0 and average
					all_feats <- unique(unlist(lapply(tabs, names)))
					freq_mat <- vapply(tabs, function(tb){
						v <- rep(0, length(all_feats))
						names(v) <- all_feats
						v[names(tb)] <- as.numeric(tb)
						v
					}, numeric(length(all_feats)))
					res_list[[paste(b, cp, sep = "_")]] <- data.frame(
						Feature = all_feats,
						Block = b,
						Comp = comp_num,
						Stability = as.numeric(rowMeans(freq_mat)),
						stringsAsFactors = FALSE
					)
				}
			}
			if(length(res_list) > 0){
				res_df <- as.data.frame(dplyr::bind_rows(res_list), stringsAsFactors = FALSE)
				rownames(res_df) <- NULL
				res_df <- res_df[order(res_df$Comp, -res_df$Stability), ]
			}else{
				res_df <- data.frame(Feature = character(), Block = character(), Comp = integer(), Stability = numeric(), stringsAsFactors = FALSE)
			}
			self$res_stability <- res_df
			message("Extracted stability for ", nrow(res_df), " features. Result is stored in object$res_stability ...")
			res_df
		},

		#' @description
		#' Plot the samples projected onto the latent components.
		#'
		#' This function wraps \code{mixOmics::plotIndiv} with \code{style = "ggplot2"} to visualize
		#' the clustering of samples in the latent space. The returned object is a ggplot object that
		#' can be further customized with \code{+ theme_xx()}.
		#' @param comp default c(1, 2); the components on the x and y axes.
		#' @param group default NULL; the group factor for coloring. If NULL, use \code{self$Y}.
		#' @param blocks default "average"; which blocks to display. "average" for consensus, or block index/name.
		#' @param ellipse default FALSE; whether to add ellipses.
		#' @param legend default TRUE; whether to show the legend.
		#' @param ... other parameters passed to \code{mixOmics::plotIndiv}.
		#' @return a ggplot object.
		#' @examples
		#' \dontrun{
		#' p <- t1$plot_samples(comp = c(1, 2), ellipse = TRUE)
		#' }
		plot_samples = function(
			comp = c(1, 2),
			group = NULL,
			blocks = "average",
			ellipse = FALSE,
			legend = TRUE,
			...
			){
			private$check_model()
			private$check_comp(comp)
			if(is.null(group)){
				group <- self$Y
			}
			# note: plotIndiv treats an explicitly passed group = NULL differently from a missing group
			# (missingness check internally), so only include group when it is not NULL
			plotindiv_args <- list(object = self$model, comp = comp, blocks = blocks, style = "ggplot2",
				ellipse = ellipse, legend = legend, ...)
			if(!is.null(group)){
				plotindiv_args$group <- group
			}
			p <- tryCatch(do.call(mixOmics::plotIndiv, plotindiv_args), error = function(e) e)
			# plotIndiv with blocks = "average" fails for block.spls (indY) models in some mixOmics versions;
			# fall back to the first block in that case
			if(inherits(p, "error")){
				if(identical(blocks, "average")){
					warning("plotIndiv with blocks = 'average' failed for this model (", conditionMessage(p), "); falling back to the first block!")
					plotindiv_args$blocks <- 1
					p <- do.call(mixOmics::plotIndiv, plotindiv_args)
				}else{
					stop(p)
				}
			}
			# with style = "ggplot2", plotIndiv (mixOmics >= 6.x) returns a list with the ggplot in element 'graph'
			if(is.list(p) && !inherits(p, "ggplot") && inherits(p$graph, "ggplot")){
				return(p$graph)
			}
			# plotIndiv may return a list of ggplot objects (one per block) for multi-block models
			if(inherits(p, "list") && !inherits(p, "ggplot")){
				message("plotIndiv returned a list of plots (one per block). Use blocks = 'average' or a single block index/name to get a single ggplot object.")
			}
			p
		},

		#' @description
		#' Plot the loading weights of selected variables.
		#'
		#' This function wraps \code{mixOmics::plotLoadings} with \code{style = "ggplot2"} to visualize
		#' the contribution of selected variables on a given component.
		#' @param comp default 1; the component to plot.
		#' @param block default NULL; the block to display. NULL means the first block.
		#' @param ... other parameters passed to \code{mixOmics::plotLoadings}.
		#' @return a ggplot object.
		#' @examples
		#' \dontrun{
		#' p <- t1$plot_loadings(comp = 1, block = "microb")
		#' }
		plot_loadings = function(
			comp = 1,
			block = NULL,
			...
			){
			private$check_model()
			private$check_comp(comp)
			p <- mixOmics::plotLoadings(self$model, comp = comp, block = block, style = "ggplot2", ...)
			p
		},

		#' @description
		#' Plot the relevance network showing correlations between variables across blocks.
		#'
		#' This function wraps \code{mixOmics::network} to display the relevance network of selected
		#' variables. The network data is also stored in \code{res_network} for potential bridging with
		#' the \code{\link{trans_network}} class.
		#' @param comp default 1; the component used to compute the similarity (identical across all blocks).
		#' @param cutoff default 0.7; only show edges with absolute correlation above this threshold.
		#' @param block default NULL; the block(s) to include.
		#' @param ... other parameters passed to \code{mixOmics::network}.
		#' @return the network result (invisibly), with \code{res_network} stored.
		#' @examples
		#' \dontrun{
		#' t1$plot_cor_network(comp = 1, cutoff = 0.7)
		#' }
		plot_cor_network = function(
			comp = 1,
			cutoff = 0.7,
			block = NULL,
			...
			){
			private$check_model()
			private$check_comp(comp)
			# note: mixOmics >= 6.x uses plot.graph to control plotting (older versions used show),
			# and requires comp as a named list for block models
			comp_fmt <- private$format_network_comp(comp, block)
			if("plot.graph" %in% names(formals(mixOmics::network))){
				net_res <- mixOmics::network(self$model, comp = comp_fmt, cutoff = cutoff, blocks = block, plot.graph = TRUE, ...)
			}else{
				net_res <- mixOmics::network(self$model, comp = comp_fmt, cutoff = cutoff, blocks = block, show = TRUE, ...)
			}
			self$res_network <- net_res
			message("Network data is stored in object$res_network ...")
			invisible(net_res)
		},

		#' @description
		#' Plot the circos plot showing correlations between selected variables across blocks.
		#'
		#' This function wraps \code{mixOmics::circosPlot} to display a circos visualization of the
		#' correlations between selected variables from different blocks.
		#' @param comp default 1; the component(s) to display.
		#' @param cutoff default 0.7; only show links with absolute correlation above this threshold.
		#' @param ... other parameters passed to \code{mixOmics::circosPlot}.
		#' @return the circos plot result (invisibly).
		#' @examples
		#' \dontrun{
		#' t1$plot_circos(comp = 1, cutoff = 0.7)
		#' }
		plot_circos = function(
			comp = 1,
			cutoff = 0.7,
			...
			){
			private$check_model()
			private$check_comp(comp)
			res <- mixOmics::circosPlot(self$model, comp = comp, cutoff = cutoff, ...)
			invisible(res)
		},

		#' @description
		#' Plot the diagnostic plot for DIABLO showing the correlation between components of each block.
		#'
		#' This function wraps \code{mixOmics::plotDiablo} to check whether the correlations between
		#' components from each data set were maximized as specified in the design matrix.
		#' @param ncomp default 1; the dimension to be assessed.
		#' @param ... other parameters passed to \code{mixOmics::plotDiablo}.
		#' @return the plot (invisibly).
		#' @examples
		#' \dontrun{
		#' t1$plot_diablo(ncomp = 1)
		#' }
		plot_diablo = function(
			ncomp = 1,
			...
			){
			private$check_model()
			private$check_comp(ncomp)
			res <- mixOmics::plotDiablo(self$model, ncomp = ncomp, ...)
			invisible(res)
		},

		#' @description
		#' Plot the clustered image map (heatmap) of selected variables.
		#'
		#' This function wraps \code{mixOmics::cimDiablo} to display a clustered image map (CIM) of
		#' the selected variables across blocks.
		#' @param comp default 1; the component(s) to display.
		#' @param ... other parameters passed to \code{mixOmics::cimDiablo}.
		#' @return the CIM result (invisibly).
		#' @examples
		#' \dontrun{
		#' t1$plot_heatmap(comp = 1)
		#' }
		plot_heatmap = function(
			comp = 1,
			...
			){
			private$check_model()
			private$check_comp(comp)
			res <- mixOmics::cimDiablo(self$model, comp = comp, ...)
			invisible(res)
		},
		#' @description
		#' Build a trans-kingdom (cross-omics) association network following the TkNA framework.
		#'
		#' This function implements a lightweight Transkingdom Network Analysis (TkNA) workflow:
		#' (1) reduce nodes by differential filtering within each omics block (when \code{Y} is available),
		#' (2) build a cross-block (bipartite) association network from correlations between features of
		#' different blocks, and (3) compute the Bipartite Betweenness Centrality (BiBC) to identify
		#' cross-omics hub nodes that are most likely to mediate the interactions between omics layers.
		#' Confounders (age, BMI, batch, ...) can be regressed out from all features before computing
		#' the associations (MaAsLin2-style marginal models) to reduce spurious cross-layer edges.
		#'
		#' The resulting network provides testable mechanistic hypotheses rather than causal conclusions;
		#' prioritized hubs/edges should be validated with independent cohorts or perturbation experiments.
		#' @param corr_method default "spearman"; the association method, "spearman" or "pearson".
		#' 	 The data in \code{data_list} must be compositional-transformed (e.g. CLR via \code{preprocess_data})
		#' 	 before entering correlation-based network analysis.
		#' @param corr_thres default 0.6; the minimal absolute correlation to retain an edge.
		#' @param p_thres default 0.05; the maximal adjusted p value to retain an edge.
		#' @param p_adjust default "BH"; the p value adjustment method used for both the differential
		#' 	 filtering (within each block) and the correlation testing (across all cross-block pairs).
		#' @param diff_test default "kruskal"; the differential test for node filtering:
		#' 	 "kruskal" (Kruskal-Wallis rank sum test, any number of groups), "aov" (one-way ANOVA) or
		#' 	 "wilcox" (Wilcoxon rank sum test, exactly 2 groups). Use NULL to skip differential filtering.
		#' @param diff_p default 0.05; the adjusted p value threshold of the differential filtering.
		#' @param covariates default NULL; a data.frame of confounders (rows are samples with rownames matching
		#' 	 the sample names) to be regressed out from all features before computing correlations.
		#' 	 NULL means no confounder adjustment.
		#' @param blocks default NULL; the blocks to include (block names or indices). NULL means all blocks.
		#' @param hub_quantile default 0.9; features with a BiBC above this quantile (among features with
		#' 	 BiBC > 0) are flagged as cross-omics hubs.
		#' @return \code{self} (invisible); the network is stored in \code{res_transkingdom}
		#' 	 (a list with \code{graph}, \code{nodes} and \code{edges}).
		#' @examples
		#' \dontrun{
		#' t1$cal_transkingdom(corr_method = "spearman", corr_thres = 0.6, diff_test = "kruskal")
		#' head(t1$res_transkingdom$nodes)
		#' head(t1$res_transkingdom$edges)
		#' }
		cal_transkingdom = function(
			corr_method = "spearman",
			corr_thres = 0.6,
			p_thres = 0.05,
			p_adjust = "BH",
			diff_test = "kruskal",
			diff_p = 0.05,
			covariates = NULL,
			blocks = NULL,
			hub_quantile = 0.9
			){
			private$check_data_ready()
			if(!requireNamespace("igraph", quietly = TRUE)){
				stop("Please install igraph package first: install.packages('igraph')")
			}
			corr_method <- match.arg(corr_method, c("spearman", "pearson"))
			# ---- resolve blocks ----
			blocks_use <- private$resolve_blocks(blocks)
			if(length(blocks_use) < 2){
				stop("cal_transkingdom requires at least 2 blocks!")
			}
			samples_use <- rownames(self$data_list[[blocks_use[1]]])
			# ---- validate covariates ----
			if(!is.null(covariates)){
				if(!is.data.frame(covariates) || is.null(rownames(covariates)) || !setequal(rownames(covariates), samples_use)){
					stop("covariates must be a data.frame whose rownames match the sample names of the blocks!")
				}
				if(anyNA(covariates)){
					stop("covariates contain NA values! Please remove or impute them before confounder adjustment!")
				}
				covariates <- covariates[samples_use, , drop = FALSE]
			}
			# ---- step 1: differential filtering (TkNA node reduction) ----
			do_diff <- !is.null(diff_test) && !is.null(diff_p)
			if(do_diff && is.null(self$Y)){
				message("Y is NULL; differential filtering is skipped and all features are retained ...")
				do_diff <- FALSE
			}
			mat_list <- list()
			node_rows <- list()
			for(b in blocks_use){
				mat <- self$data_list[[b]]
				padj <- setNames(rep(NA_real_, ncol(mat)), colnames(mat))
				if(do_diff){
					pvals <- vapply(seq_len(ncol(mat)), function(j){
						private$diff_test_single(mat[, j], self$Y, diff_test)
					}, numeric(1))
					padj <- stats::p.adjust(pvals, method = p_adjust)
					names(padj) <- colnames(mat)
				}
				keep <- if(do_diff) (!is.na(padj) & padj <= diff_p) else rep(TRUE, ncol(mat))
				if(all(!keep)){
					warning("No feature passed the differential filtering (diff_p = ", diff_p,
						") in block '", b, "'; all features are retained instead!")
					keep <- rep(TRUE, ncol(mat))
				}
				mat_list[[b]] <- mat[, keep, drop = FALSE]
				node_rows[[b]] <- data.frame(Feature = colnames(mat_list[[b]]), Block = b,
					Diff_p = unname(padj[keep]), stringsAsFactors = FALSE)
			}
			nodes_df <- do.call(rbind, node_rows)
			rownames(nodes_df) <- NULL
			if(do_diff){
				message("Differential filtering (", diff_test, ", ", p_adjust, "-adjusted p <= ", diff_p, ") retained ",
					nrow(nodes_df), "/", sum(vapply(self$data_list[blocks_use], ncol, numeric(1))), " features ...")
			}
			# ---- step 2: cross-block correlations (bipartite edges) ----
			block_pairs <- utils::combn(blocks_use, 2, simplify = FALSE)
			# Candidate p values are kept as compact numeric vectors for the multiple-testing
			# correction (across all cross-block pairs); the edge table is assembled only for
			# the pairs passing the correlation threshold to reduce the memory peak.
			p_list <- list()
			keep_list <- list()
			edge_rows <- list()
			for(pr in block_pairs){
				X <- mat_list[[pr[1]]]
				Ymat <- mat_list[[pr[2]]]
				cor_res <- private$cal_block_corr(X, Ymat, corr_method, covariates)
				R <- cor_res$r
				P <- cor_res$p
				pair_id <- paste(pr, collapse = "_")
				p_list[[pair_id]] <- as.vector(P)
				# linear indices follow the column-major order of as.vector()
				keep_lin <- which(!is.na(R) & abs(R) >= corr_thres)
				keep_list[[pair_id]] <- keep_lin
				if(length(keep_lin) > 0){
					edge_rows[[pair_id]] <- data.frame(
						From = rep(rownames(R), times = ncol(R))[keep_lin],
						To = rep(colnames(R), each = nrow(R))[keep_lin],
						Corr = as.vector(R)[keep_lin],
						P = as.vector(P)[keep_lin],
						Block_from = pr[1],
						Block_to = pr[2],
						stringsAsFactors = FALSE
					)
				}
			}
			n_candidate <- sum(lengths(p_list))
			# multiple-testing correction across all cross-block pairs
			p_adj_all <- stats::p.adjust(unlist(p_list, use.names = FALSE), method = p_adjust)
			# map the adjusted p values back onto the retained edges (pairs keep their original order)
			offsets <- cumsum(lengths(p_list)) - lengths(p_list)
			keep_global <- unlist(Map(function(off, kl) off + kl, offsets, keep_list), use.names = FALSE)
			edges_df <- do.call(rbind, edge_rows)
			if(is.null(edges_df)){
				edges_df <- data.frame(From = character(), To = character(), Corr = numeric(), P = numeric(),
					Block_from = character(), Block_to = character(), P_adj = numeric(), stringsAsFactors = FALSE)
			}else{
				rownames(edges_df) <- NULL
				edges_df$P_adj <- unname(p_adj_all[keep_global])
				edges_df <- edges_df[edges_df$P_adj <= p_thres, , drop = FALSE]
			}
			if(nrow(edges_df) == 0){
				warning("No edge passed the thresholds (corr_thres = ", corr_thres, ", p_thres = ", p_thres,
					")! Consider lowering corr_thres or p_thres, or relaxing the differential filtering.")
			}
			message("Cross-block correlation (", corr_method, ", ", p_adjust, "-adjusted p <= ", p_thres,
				", |corr| >= ", corr_thres, ") retained ", nrow(edges_df), "/", n_candidate, " candidate edges ...")
			# ---- step 3: build the bipartite network and compute BiBC ----
			g <- igraph::graph_from_data_frame(
				d = edges_df[, c("From", "To", "Corr", "P", "P_adj")],
				directed = FALSE,
				vertices = nodes_df[, c("Feature", "Block", "Diff_p")]
			)
			# edge distance for path-based centrality: strongly correlated features are close
			igraph::E(g)$weight <- pmax(1 - abs(igraph::E(g)$Corr), 1e-12)
			bipc_vals <- private$cal_bipc(g, layer_vec = nodes_df$Block)
			g <- igraph::set_vertex_attr(g, "BiBC", value = unname(bipc_vals))
			# hub: top BiBC quantile among nodes lying on cross-layer paths
			bipc_pos <- bipc_vals[bipc_vals > 0]
			hub_flag <- rep(FALSE, length(bipc_vals))
			if(length(bipc_pos) > 0){
				hub_cut <- stats::quantile(bipc_pos, probs = hub_quantile)
				hub_flag <- bipc_vals >= hub_cut & bipc_vals > 0
			}
			g <- igraph::set_vertex_attr(g, "Hub", value = hub_flag)
			# ---- tidy output tables ----
			nodes_out <- igraph::as_data_frame(g, what = "vertices")
			colnames(nodes_out)[1] <- "Feature"
			nodes_out$Hub <- as.logical(nodes_out$Hub)
			edges_out <- igraph::as_data_frame(g, what = "edges")
			colnames(edges_out)[1:2] <- c("From", "To")
			rownames(nodes_out) <- NULL
			rownames(edges_out) <- NULL
			self$res_transkingdom <- list(graph = g, nodes = nodes_out, edges = edges_out)
			message("Trans-kingdom network built: ", igraph::vcount(g), " nodes, ", igraph::ecount(g),
				" edges, ", sum(hub_flag), " hub(s). Result is stored in object$res_transkingdom ...")
			invisible(self)
		},
		#' @description
		#' Plot the trans-kingdom (cross-omics) network from \code{cal_transkingdom}.
		#'
		#' Nodes are colored by omics block and sized by BiBC (bipartite betweenness centrality);
		#' edges are colored by the sign and strength of the correlation. Cross-omics hub features
		#' (flagged by \code{cal_transkingdom}) are labeled by default. The returned object is a
		#' ggplot object that can be further customized with \code{+ theme_xx()}.
		#' @param label default "hub"; which nodes to label: "hub" (only cross-omics hubs),
		#' 	 "all" (all nodes) or "none".
		#' @param layout default "fr"; the node layout: "fr" (force-directed Fruchterman-Reingold,
		#' 	 using the absolute correlations as attraction) or "circle".
		#' @param node_size_range default c(3, 8); the size range mapping BiBC to node size.
		#' @param edge_colors default c("#B2182B", "#2166AC"); colors for positive and negative correlations.
		#' @return a ggplot object.
		#' @examples
		#' \dontrun{
		#' t1$cal_transkingdom()
		#' p <- t1$plot_transkingdom(label = "hub")
		#' print(p)
		#' }
		plot_transkingdom = function(
			label = "hub",
			layout = "fr",
			node_size_range = c(3, 8),
			edge_colors = c("#B2182B", "#2166AC")
			){
			label <- match.arg(label, c("hub", "all", "none"))
			layout <- match.arg(layout, c("fr", "circle"))
			if(!requireNamespace("igraph", quietly = TRUE)){
				stop("Please install igraph package first: install.packages('igraph')")
			}
			if(!requireNamespace("ggplot2", quietly = TRUE)){
				stop("Please install ggplot2 package first: install.packages('ggplot2')")
			}
			if(is.null(self$res_transkingdom)){
				stop("The res_transkingdom is NULL! Please run cal_transkingdom() first!")
			}
			g <- self$res_transkingdom$graph
			if(igraph::ecount(g) == 0){
				warning("The trans-kingdom network has no edge; nothing to plot!")
				return(invisible(NULL))
			}
			# ---- layout ----
			if(layout == "fr"){
				coords <- igraph::layout_with_fr(g, weights = abs(igraph::E(g)$Corr))
			}else{
				coords <- igraph::layout_in_circle(g)
			}
			node_df <- self$res_transkingdom$nodes
			node_df$x <- coords[, 1]
			node_df$y <- coords[, 2]
			edge_df <- self$res_transkingdom$edges
			edge_df$x <- node_df$x[match(edge_df$From, node_df$Feature)]
			edge_df$y <- node_df$y[match(edge_df$From, node_df$Feature)]
			edge_df$xend <- node_df$x[match(edge_df$To, node_df$Feature)]
			edge_df$yend <- node_df$y[match(edge_df$To, node_df$Feature)]
			# ---- ggplot assembly ----
			p <- ggplot2::ggplot() +
				ggplot2::geom_segment(data = edge_df,
					ggplot2::aes(x = x, y = y, xend = xend, yend = yend, color = Corr),
					alpha = 0.5, show.legend = TRUE) +
				ggplot2::scale_color_gradient2(low = edge_colors[2], mid = "grey85", high = edge_colors[1],
					midpoint = 0, name = "Correlation") +
				ggplot2::geom_point(data = node_df,
					ggplot2::aes(x = x, y = y, fill = Block, size = BiBC),
					shape = 21, color = "grey30", stroke = 0.3) +
				ggplot2::scale_size_continuous(range = node_size_range, name = "BiBC")
			# RColorBrewer Set2 supports at most 8 groups; fall back to a hue palette for more blocks
			n_block_cols <- length(unique(node_df$Block))
			if(n_block_cols <= 8){
				p <- p + ggplot2::scale_fill_brewer(palette = "Set2", name = "Block")
			}else{
				p <- p + ggplot2::scale_fill_manual(values = scales::hue_pal()(n_block_cols), name = "Block")
			}
			if(label != "none"){
				label_df <- if(label == "hub") node_df[node_df$Hub, , drop = FALSE] else node_df
				if(nrow(label_df) > 0){
					p <- p + ggplot2::geom_text(data = label_df,
						ggplot2::aes(x = x, y = y, label = Feature), vjust = -0.9, size = 3)
				}
			}
			p + ggplot2::theme_classic(base_size = 12) +
				ggplot2::labs(x = NULL, y = NULL, title = "Trans-kingdom network") +
				ggplot2::theme(axis.line = ggplot2::element_blank(), axis.text = ggplot2::element_blank(),
					axis.ticks = ggplot2::element_blank(), plot.title = ggplot2::element_text(hjust = 0.5))
		},

		#' @description
		#' Convert the trans-kingdom network (result of \code{cal_transkingdom}) to a \code{\link{trans_network}} object.
		#'
		#' This function bridges the trans-kingdom (cross-omics) network with the downstream network analyses of the
		#' \code{\link{trans_network}} class, such as module detection (\code{cal_module}), node/edge property tables
		#' (\code{get_node_table}/\code{get_edge_table}), network topological attributes (\code{cal_network_attr}),
		#' sub-network extraction (\code{subset_network}), eigengene analysis (\code{cal_eigen}), random network
		#' comparison (\code{random_network}) and network visualization (\code{plot_network}).
		#'
		#' The conversion follows the conventions of correlation networks built by \code{trans_network$cal_network}:
		#' the edge weight is re-defined as the absolute correlation and the edge label as the correlation sign
		#' ("+"/"-"); note that this differs from the distance-like weight (1 - |Corr|) temporarily used inside
		#' \code{cal_transkingdom} for the BiBC computation. The node attributes (\code{Block}, \code{Diff_p},
		#' \code{BiBC} and \code{Hub}) are preserved, so they are reported in the node table of
		#' \code{get_node_table}. The original (non-transformed) abundances of the network features are extracted
		#' from \code{dataset_list} to fill \code{data_abund} (rows are samples), \code{data_relabund} (relative
		#' abundance within each block, used as the "Abundance" of \code{get_node_table}) and \code{sample_table};
		#' the \code{tax_table} slots of all blocks are merged (union of the columns) to fill \code{tax_table}
		#' when \code{add_taxonomy = TRUE}.
		#'
		#' Note that the taxonomy-dependent functions of \code{trans_network}, e.g. \code{cal_sum_links} and
		#' \code{plot_sum_links}, require a taxonomic column shared by all the involved blocks; for a bipartite
		#' cross-omics network (e.g. microbe vs metabolite) such a column usually does not exist, so those
		#' functions are not applicable unless the blocks use the same feature annotation.
		#' @param delete_unlinked_nodes default TRUE; whether delete the nodes without any link,
		#' 	 same with the default of \code{cal_network} in \code{\link{trans_network}} class.
		#' @param add_taxonomy default TRUE; whether add the taxonomy information of the network features to the
		#' 	 returned object by merging the \code{tax_table} of all blocks (columns are unioned and missing
		#' 	 values are filled with NA). Use FALSE to skip, e.g. when the blocks have no meaningful tax_table.
		#' @return a \code{\link{trans_network}} object; the network is stored in its \code{res_network} slot.
		#' @examples
		#' \dontrun{
		#' t1$cal_transkingdom()
		#' net1 <- t1$convert_transkingdom()
		#' net1$cal_module()
		#' net1$get_node_table()
		#' net1$plot_network(method = "ggraph", node_color = "module")
		#' }
		convert_transkingdom = function(
			delete_unlinked_nodes = TRUE,
			add_taxonomy = TRUE
			){
			if(!requireNamespace("igraph", quietly = TRUE)){
				stop("Please install igraph package first: install.packages('igraph')")
			}
			if(is.null(self$res_transkingdom)){
				stop("The res_transkingdom is NULL! Please run cal_transkingdom() first!")
			}
			edges_df <- self$res_transkingdom$edges
			nodes_df <- self$res_transkingdom$nodes
			if(nrow(edges_df) == 0){
				stop("No edge found in the trans-kingdom network! ",
					"Please re-run cal_transkingdom() with relaxed thresholds (corr_thres/p_thres)!")
			}
			# ---- rebuild the graph with the trans_network conventions ----
			# edge weight = |correlation| (connection strength) and label = correlation sign, same with the
			# correlation networks of trans_network$cal_network; the distance-like weight (1 - |Corr|) used for
			# the BiBC computation is NOT transferred as it has a different semantics
			g <- igraph::graph_from_data_frame(
				d = edges_df[, c("From", "To", "Corr", "P", "P_adj")],
				directed = FALSE,
				vertices = nodes_df[, c("Feature", "Block", "Diff_p", "BiBC", "Hub")]
			)
			igraph::E(g)$weight <- abs(igraph::E(g)$Corr)
			igraph::E(g)$label <- ifelse(igraph::E(g)$Corr > 0, "+", "-")
			igraph::V(g)$taxa <- igraph::V(g)$name
			if(delete_unlinked_nodes){
				g <- igraph::delete_vertices(g, igraph::degree(g) == 0)
			}
			# ---- fill the data slots of trans_network for the downstream functions ----
			# features and blocks of the final network
			use_features <- igraph::V(g)$name
			if(anyDuplicated(use_features)){
				stop("Duplicated feature names found across the blocks! ",
					"Please make the feature names unique across blocks and re-run cal_transkingdom()!")
			}
			block_of <- setNames(as.character(nodes_df$Block), nodes_df$Feature)[use_features]
			blocks_use <- unique(block_of)
			# samples actually entering the trans-kingdom analysis (intersection across the involved blocks)
			samples_use <- rownames(self$data_list[[blocks_use[1]]])
			for(b in blocks_use){
				samples_use <- intersect(samples_use, colnames(self$dataset_list[[b]]$otu_table))
			}
			if(length(samples_use) == 0){
				stop("No common sample found across the original otu_tables of the involved blocks!")
			}
			# original (non-transformed) abundances of the network features: rows are samples, columns are features
			# the blocks are assembled into one matrix directly (NOT via cbind.data.frame of a named list,
			# which would prefix the column names with the block names and break the feature matching)
			data_abund <- matrix(0, nrow = length(samples_use), ncol = length(use_features),
				dimnames = list(samples_use, use_features))
			relabund_all <- c()
			for(b in blocks_use){
				otu <- as.matrix(self$dataset_list[[b]]$otu_table[, samples_use, drop = FALSE])
				feats_b <- use_features[block_of == b]
				data_abund[, feats_b] <- t(otu[feats_b, , drop = FALSE])
				# relative abundance (%) within the block: feature sum / block total across the samples
				total_b <- sum(otu)
				if(total_b > 0){
					relabund_all <- c(relabund_all, apply(otu, 1, sum) / total_b * 100)
				}
			}
			# ---- create the trans_network object ----
			net_obj <- trans_network$new(dataset = NULL)
			if(length(relabund_all) > 0){
				relabund_all <- relabund_all[use_features]
				net_obj$data_relabund <- relabund_all
				g <- igraph::set_vertex_attr(g, "RelativeAbundance", value = unname(relabund_all))
			}
			net_obj$res_network <- g
			net_obj$data_abund <- as.data.frame(data_abund)
			net_obj$sample_table <- self$dataset_list[[blocks_use[1]]]$sample_table[samples_use, , drop = FALSE]
			# ---- merge the tax_table of the blocks (union of the columns) ----
			if(add_taxonomy){
				tax_tables <- lapply(self$dataset_list[blocks_use], function(x) x$tax_table)
				tax_tables <- tax_tables[!vapply(tax_tables, is.null, logical(1))]
				if(length(tax_tables) > 0){
					all_cols <- unique(unlist(lapply(tax_tables, colnames)))
					# unname() is crucial: rbind of NAMED data.frames would prefix the row names
					# with the block names (e.g. "microb.xxx") and break the feature matching
					merged <- do.call(rbind, unname(lapply(tax_tables, function(tt){
						# characters avoid the factor-level conflicts of the blocks during rbind;
						# the columns missing in the block are padded with NA to align all blocks
						tt <- as.data.frame(tt, stringsAsFactors = FALSE)
						tt[] <- lapply(tt, as.character)
						for(cl in setdiff(all_cols, colnames(tt))){
							tt[[cl]] <- NA_character_
						}
						tt[, all_cols, drop = FALSE]
					})))
					net_obj$tax_table <- merged[rownames(merged) %in% use_features, , drop = FALSE]
				}
			}
			message("Converted the trans-kingdom network (", igraph::vcount(g), " nodes, ", igraph::ecount(g),
				" edges) to a trans_network object; the downstream functions of trans_network can be used now ...")
			net_obj
		},

		#' @description
		#' Compute the Area Under the ROC Curve (AUC) for the model.
		#'
		#' This function wraps \code{mixOmics::auroc} to perform ROC analysis for supervised models.
		#' @param comp default 1; the component to use for ROC analysis.
		#' @param ... other parameters passed to \code{mixOmics::auroc}.
		#' @return the AUC result (invisibly).
		#' @examples
		#' \dontrun{
		#' t1$cal_auroc(comp = 1)
		#' }
		cal_auroc = function(
			comp = 1,
			...
			){
			private$check_model()
			private$check_comp(comp)
			# mixOmics::auroc methods use roc.comp instead of comp; detect the dispatched method to stay version-compatible
			auroc_args <- list(object = self$model, ...)
			use_roc_comp <- FALSE
			for(cl in class(self$model)){
				f <- tryCatch(getFromNamespace(paste0("auroc.", cl), "mixOmics"), error = function(e) NULL)
				if(!is.null(f)){
					use_roc_comp <- "roc.comp" %in% names(formals(f))
					break
				}
			}
			if(use_roc_comp){
				auroc_args$roc.comp <- comp
			}else{
				auroc_args$comp <- comp
			}
			res <- do.call(mixOmics::auroc, auroc_args)
			invisible(res)
		},
		#' @description
		#' Run mediation analysis bridging to the \code{multimedia} package.
		#'
		#' This function is a thin wrapper around the \code{multimedia} package (Jiang et al. 2024,
		#' <doi:10.1101/2024.03.27.587024>) to quantify the pathway "exposure -> mediator -> outcome",
		#' e.g. microbes -> metabolites -> host phenotype. The high-dimensional blocks are taken from
		#' \code{data_list} (compositional-transformed by \code{preprocess_data}); the exposure and the
		#' outcome can alternatively be low-dimensional named vectors/factors (e.g. a treatment indicator
		#' or a continuous phenotype). The result provides direct/indirect (mediation) effect estimates
		#' that decompose the total effect, plus optional bootstrap confidence intervals.
		#'
		#' Note that the "causal" interpretation of the mediation effects relies on the assumed ordering
		#' of the chain and remains observational; validation with longitudinal or perturbation data is
		#' recommended. Variable names are replaced internally by syntactically valid names (V1, V2, ...)
		#' because multimedia builds R formulas from the variable names; the original names are restored
		#' in the returned effect tables (the mapping is stored in \code{res_mediation$name_map}).
		#' @param exposure the exposure (treatment): either the name of one omics block in
		#' 	 \code{data_list_names} (high-dimensional), or a named numeric vector/factor whose names match
		#' 	 the sample names (low-dimensional, e.g. the grouping factor).
		#' @param mediator the name of the omics block acting as the mediator (e.g. \code{"metab"}).
		#' @param outcome the outcome: either the name of one omics block in \code{data_list_names}
		#' 	 (e.g. host transcriptome block), or a named numeric vector/2-level factor whose names match
		#' 	 the sample names.
		#' @param model default "glmnet"; the mediation/outcome model engine passed to \code{multimedia}:
		#' 	 "glmnet" (regularized regression, recommended for high-dimensional blocks),
		#' 	 "lm" (linear model, only for low-dimensional exposure) or "rf" (random forest).
		#' @param features default NULL; an optional named list of feature subsets per block
		#' 	 (e.g. \code{list(microb = c("m1", "m2"))}), useful to focus on features selected by
		#' 	 \code{get_features} of a previous DIABLO analysis.
		#' @param max_features default NULL; an optional cap on the number of features per block; when a
		#' 	 block exceeds it, only the most variable features are kept.
		#' @param covariates default NULL; a data.frame of covariates (rows are samples with rownames
		#' 	 matching the sample names) passed to the \code{pretreatments} of \code{multimedia}.
		#' @param n_boot default 0; the number of bootstrap resamples for confidence intervals.
		#' 	 0 skips the bootstrap (recommended for a first exploration; use 1000 for final results).
		#' @return \code{self} (invisible); the result is stored in \code{res_mediation}.
		#' @examples
		#' \dontrun{
		#' # microbes -> metabolites -> host phenotype
		#' t1$run_mediation(exposure = "microb", mediator = "metab", outcome = pheno_vector,
		#'   model = "glmnet", n_boot = 200)
		#' t1$res_mediation$indirect_pathwise
		#' }
		run_mediation = function(
			exposure,
			mediator,
			outcome,
			model = "glmnet",
			features = NULL,
			max_features = NULL,
			covariates = NULL,
			n_boot = 0
			){
			if(!requireNamespace("multimedia", quietly = TRUE)){
				stop("Please install the multimedia package first: install.packages('multimedia')")
			}
			private$check_data_ready()
			samples_use <- rownames(self$data_list[[1]])
			# ---- resolve mediator ----
			if(!is.character(mediator) || length(mediator) != 1 || !(mediator %in% self$data_list_names)){
				stop("mediator must be the name of one omics block in data_list_names: ",
					paste(self$data_list_names, collapse = ", "), "!")
			}
			mediator_df <- as.data.frame(private$mediation_block_matrix(mediator, features, max_features))
			# ---- resolve exposure ----
			if(is.character(exposure) && length(exposure) == 1){
				if(!(exposure %in% self$data_list_names)){
					stop("exposure block '", exposure, "' not found in data_list_names: ",
						paste(self$data_list_names, collapse = ", "), "!")
				}
				if(exposure == mediator){
					stop("exposure and mediator must be different blocks!")
				}
				exposure_df <- as.data.frame(private$mediation_block_matrix(exposure, features, max_features))
				if(model == "lm" && ncol(exposure_df) > 1){
					warning("The exposure block '", exposure, "' is high-dimensional; consider model = 'glmnet' ",
						"because 'lm' may overfit or fail with more predictors than samples!")
				}
			}else{
				exposure_df <- private$mediation_vector(exposure, samples_use, "exposure")
				if(is.factor(exposure_df[[1]])){
					if(nlevels(exposure_df[[1]]) != 2){
						stop("The exposure factor must have exactly 2 levels (treatment vs control)! Please encode a multi-level exposure as numeric indicators first!")
					}
					exposure_df[[1]] <- as.numeric(exposure_df[[1]]) - 1
					message("The exposure factor is converted to 0/1 numeric for the regression models ...")
				}
			}
			# ---- resolve outcome ----
			if(is.character(outcome) && length(outcome) == 1 && outcome %in% self$data_list_names){
				if(outcome == mediator){
					stop("outcome and mediator must be different blocks!")
				}
				outcome_df <- as.data.frame(private$mediation_block_matrix(outcome, features, max_features))
			}else{
				outcome_df <- private$mediation_vector(outcome, samples_use, "outcome")
				if(is.factor(outcome_df[[1]])){
					if(nlevels(outcome_df[[1]]) != 2){
						stop("The outcome factor must have exactly 2 levels! Please encode a multi-level outcome as numeric variables first!")
					}
					outcome_df[[1]] <- as.numeric(outcome_df[[1]]) - 1
					message("The outcome factor is converted to 0/1 numeric for the regression models ...")
				}
			}
			# ---- covariates -> pretreatments ----
			pretreat_names <- NULL
			if(!is.null(covariates)){
				if(!is.data.frame(covariates) || is.null(rownames(covariates)) || !setequal(rownames(covariates), samples_use)){
					stop("covariates must be a data.frame whose rownames match the sample names of the blocks!")
				}
				if(anyNA(covariates)){
					stop("covariates contain NA values! Please remove or impute them before mediation analysis!")
				}
				cov_df <- covariates[samples_use, , drop = FALSE]
				pretreat_names <- colnames(cov_df)
			}else{
				cov_df <- NULL
			}
			# ---- assemble the mediation data.frame ----
			med_df <- cbind(exposure_df, mediator_df, outcome_df)
			if(!is.null(cov_df)){
				med_df <- cbind(med_df, cov_df)
			}
			if(any(duplicated(colnames(med_df)))){
				stop("Duplicated variable names across blocks! Please rename the features before mediation analysis!")
			}
			# ---- sanitize variable names: multimedia engines build R formulas from the variable
			# names, and metabolite names like "trehalose+d-Glucoheptose 1" are not syntactically
			# valid. To be robust against any internal name mangling, plain synthetic names
			# (V1, V2, ...) are used internally and restored in the returned effect tables ----
			n_exp <- ncol(exposure_df)
			n_med <- ncol(mediator_df)
			n_out <- ncol(outcome_df)
			n_cov <- if(is.null(cov_df)) 0 else ncol(cov_df)
			safe_names <- paste0("V", seq_len(ncol(med_df)))
			name_map <- setNames(colnames(med_df), safe_names)  # synthetic -> original
			if(!identical(safe_names, colnames(med_df))){
				message("Variable names are replaced internally by syntactically valid names for the ",
					"regression formulas; the results use the original names (see res_mediation$name_map) ...")
				colnames(med_df) <- safe_names
			}
			exper <- multimedia::mediation_data(med_df,
				outcomes = safe_names[n_exp + n_med + seq_len(n_out)],
				treatments = safe_names[seq_len(n_exp)],
				mediators = safe_names[n_exp + seq_len(n_med)],
				pretreatments = if(n_cov > 0) safe_names[n_exp + n_med + n_out + seq_len(n_cov)] else NULL)
			model_spec <- switch(model,
				lm = multimedia::lm_model(),
				glmnet = multimedia::glmnet_model(),
				rf = multimedia::rf_model(),
				stop("model must be one of 'lm', 'glmnet', 'rf'!")
			)
			message("Fitting the multimedia mediation model (", model, ") with ", nrow(med_df), " samples, ",
				ncol(exposure_df), " exposure, ", ncol(mediator_df), " mediator and ", ncol(outcome_df), " outcome variable(s) ...")
			mm <- multimedia::multimedia(exper, model_spec)
			fit <- multimedia::estimate(mm, exper)
			res <- list(
				fit = fit,
				direct_effect = private$restore_med_names(multimedia::direct_effect(fit), name_map),
				indirect_overall = private$restore_med_names(multimedia::indirect_overall(fit), name_map),
				indirect_pathwise = private$restore_med_names(multimedia::indirect_pathwise(fit), name_map),
				name_map = name_map
			)
			if(n_boot > 0){
				message("Running bootstrap with B = ", n_boot, " resamples (may take a while) ...")
				res$bootstrap <- private$restore_med_names(multimedia::bootstrap(mm, exper, B = n_boot,
					fs = list(multimedia::direct_effect, multimedia::indirect_overall, multimedia::indirect_pathwise)), name_map)
			}
			self$res_mediation <- res
			message("Mediation result is stored in object$res_mediation (direct_effect, indirect_overall, indirect_pathwise) ...")
			invisible(self)
		},

		#' @description
		#' Export feature tables formatted for knowledge-based mechanism tools (MIMOSA2 / AMON / anansi).
		#'
		#' This function writes the abundance tables of the selected blocks into the input formats expected
		#' by external knowledge-constrained tools, bridging layer 1 (data-driven association) with layer 2
		#' (biochemistry-constrained mechanism inference):
		#' \itemize{
		#' \item MIMOSA2: KO (or other microbial feature) abundance table + metabolite abundance table,
		#' 	 both tab-separated with features as rows and samples as columns, used to link community
		#' 	 metabolic potential to measured metabolites.
		#' \item AMON: metabolite KEGG ID list + KO ID list (one ID per line), used to attribute metabolite
		#' 	 origins (microbial vs host) on the KEGG reaction network.
		#' \item anansi: KO abundance table + metabolite abundance table (features as rows, samples as columns)
		#' 	 for constrained KO-metabolite association testing.
		#' }
		#' The exported tables use the original (non-transformed) abundances stored in \code{dataset_list},
		#' because these tools expect raw counts or relative abundances rather than CLR-transformed values.
		#' @param target default "mimosa2"; the target tool: "mimosa2", "amon" or "anansi".
		#' @param file default NULL; the path prefix of the exported files (without extension), e.g.
		#' 	 \code{"./bridge/input"} produces \code{"./bridge/input_ko.txt"} and \code{"./bridge/input_metabolites.txt"}.
		#' @param ko_block default NULL; the block holding the microbial features (KO/gene/taxon abundances).
		#' 	 NULL uses the first block of \code{data_list_names}.
		#' @param metab_block default NULL; the block holding the metabolite abundances. NULL auto-detects
		#' 	 a block whose name contains "metab" (case-insensitive) and falls back to the second block.
		#' @param metab_id_table default NULL; an optional 2-column data.frame mapping the metabolite
		#' 	 names in the data (column 1) to KEGG compound IDs (column 2). If NULL, the original
		#' 	 metabolite names are used and a reminder is printed that MIMOSA2/AMON/anansi expect KEGG IDs.
		#' @param features default NULL; an optional named list of feature subsets per block
		#' 	 (e.g. \code{list(microb = c("K00001", ...), metab = c("C00095", ...))}); useful to export only
		#' 	 features prioritized by a previous DIABLO analysis (see \code{get_features}).
		#' @return a named character vector of the written file paths (invisibly).
		#' @examples
		#' \dontrun{
		#' t1$export_bridge(target = "mimosa2", file = "./bridge/mimosa_input",
		#'   ko_block = "microb", metab_block = "metab")
		#' }
		export_bridge = function(
			target = "mimosa2",
			file = NULL,
			ko_block = NULL,
			metab_block = NULL,
			metab_id_table = NULL,
			features = NULL
			){
			if(is.null(self$dataset_list)){
				stop("The dataset_list is NULL! Please create the object with microtables first!")
			}
			target <- match.arg(target, c("mimosa2", "amon", "anansi"))
			if(is.null(file)){
				stop("Please provide the file path prefix for the exported files!")
			}
			if(!dir.exists(dirname(file)) && dirname(file) != ""){
				dir.create(dirname(file), recursive = TRUE)
			}
			# ---- resolve blocks ----
			if(is.null(ko_block)){
				ko_block <- self$data_list_names[1]
				message("ko_block is NULL; using the first block '", ko_block, "' ...")
			}
			if(is.null(metab_block)){
				cand <- grep("metab", self$data_list_names, value = TRUE, ignore.case = TRUE)
				if(length(cand) > 0){
					metab_block <- cand[1]
				}else{
					metab_block <- self$data_list_names[2]
				}
				message("metab_block is NULL; using block '", metab_block, "' ...")
			}
			ko_block <- private$resolve_single_block(ko_block, "ko_block")
			metab_block <- private$resolve_single_block(metab_block, "metab_block")
			if(ko_block == metab_block){
				stop("ko_block and metab_block must be different blocks!")
			}
			# ---- extract original abundance tables (features x samples) ----
			ko_tab <- private$bridge_block_table(ko_block, features)
			metab_tab <- private$bridge_block_table(metab_block, features)
			if(!is.null(metab_id_table)){
				if(!is.data.frame(metab_id_table) || ncol(metab_id_table) != 2){
					stop("metab_id_table must be a 2-column data.frame: original metabolite names and KEGG compound IDs!")
				}
				id_map <- setNames(as.character(metab_id_table[[2]]), as.character(metab_id_table[[1]]))
				matched <- rownames(metab_tab) %in% names(id_map)
				if(any(matched)){
					rownames(metab_tab)[matched] <- id_map[rownames(metab_tab)[matched]]
				}
				if(sum(matched) < nrow(metab_tab)){
					warning(sum(matched), "/", nrow(metab_tab),
						" metabolites mapped to KEGG IDs; unmapped metabolites keep their original names ...")
				}
			}else{
				message("Note: MIMOSA2/AMON/anansi work best with KEGG compound IDs; provide metab_id_table to map the metabolite names ...")
			}
			files_written <- c()
			if(target %in% c("mimosa2", "anansi")){
				# anansi consumes the same two abundance tables as MIMOSA2
				ko_file <- paste0(file, "_ko.txt")
				metab_file <- paste0(file, "_metabolites.txt")
				utils::write.table(ko_tab, ko_file, sep = "\t", quote = FALSE, col.names = NA)
				utils::write.table(metab_tab, metab_file, sep = "\t", quote = FALSE, col.names = NA)
				files_written <- c(files_written, ko_file, metab_file)
			}
			if(target == "amon"){
				ko_id_file <- paste0(file, "_ko_ids.txt")
				metab_id_file <- paste0(file, "_metabolite_ids.txt")
				writeLines(rownames(ko_tab), ko_id_file)
				writeLines(rownames(metab_tab), metab_id_file)
				files_written <- c(files_written, ko_id_file, metab_id_file)
			}
			names(files_written) <- NULL
			message("Exported files for '", target, "': ", paste(files_written, collapse = ", "),
				"\nPlease check the format requirements of the target tool before uploading ...")
			invisible(files_written)
		}
	),
	private = list(
		# check mixOmics package availability
		check_mixomics = function(){
			if(!requireNamespace("mixOmics", quietly = TRUE)){
				stop("Please install mixOmics package: if (!require('BiocManager', quietly = TRUE)) install.packages('BiocManager'); BiocManager::install('mixOmics')")
			}
		},
		# check mixOmics availability and that data_list is ready
		check_data_list = function(){
			private$check_mixomics()
			if(is.null(self$data_list)){
				stop("The data_list is NULL! Please run preprocess_data() first!")
			}
		},
		# check mixOmics availability and that a model has been fitted
		check_model = function(){
			private$check_mixomics()
			if(is.null(self$model)){
				stop("The model is NULL! Please run run_diablo(), run_spls() or run_spls_single() first!")
			}
		},
		# check that the component index does not exceed the fitted model
		check_comp = function(comp){
			ncomp_model <- self$model$ncomp
			if(is.null(ncomp_model)){
				return(invisible(NULL))
			}
			if(any(comp > max(ncomp_model)) || any(comp < 1)){
				stop("comp (", paste(comp, collapse = ", "), ") is out of range! The model has ", max(ncomp_model), " component(s)!")
			}
		},
		# convert a numeric component vector to the format required by mixOmics::network:
		# block models (block.spls/block.pls) need a named list with identical components per block,
		# while two-block models (mixo_spls) take a plain numeric vector
		format_network_comp = function(comp, block = NULL){
			if(inherits(self$model, c("block.spls", "block.pls"))){
				block_names <- names(self$model$X)
				if(!is.null(block)){
					if(is.character(block)){
						block_use <- intersect(block, block_names)
					}else{
						block_use <- block_names[block]
					}
					if(length(block_use) > 0){
						block_names <- block_use
					}
				}
				comp_list <- lapply(block_names, function(x) comp)
				names(comp_list) <- block_names
				comp_list
			}else{
				comp
			}
		},
		# construct default keepX list (all features retained)
		build_keepX_default = function(ncomp){
			keepX_list <- lapply(self$data_list, function(x){
				rep(ncol(x), ncomp)
			})
			keepX_list
		},
		# check that data_list is ready (without requiring mixOmics, e.g. for cal_transkingdom)
		check_data_ready = function(){
			if(is.null(self$data_list)){
				stop("The data_list is NULL! Please run preprocess_data() first!")
			}
		},
		# resolve a vector of block names/indices into block names
		resolve_blocks = function(blocks){
			if(is.null(blocks)){
				return(self$data_list_names)
			}
			if(is.character(blocks)){
				blocks_idx <- match(blocks, self$data_list_names)
				if(any(is.na(blocks_idx))){
					stop("blocks not found in data_list_names: ",
						paste(blocks[is.na(blocks_idx)], collapse = ", "), "!")
				}
			}else{
				blocks_idx <- blocks
				if(any(is.na(match(blocks_idx, seq_along(self$data_list_names))))){
					stop("blocks must be valid block indices or names!")
				}
			}
			self$data_list_names[blocks_idx]
		},
		# resolve a single block name/index into a block name
		resolve_single_block = function(block, arg_name){
			if(is.character(block)){
				if(!(block %in% self$data_list_names)){
					stop(arg_name, " '", block, "' not found in data_list_names: ",
						paste(self$data_list_names, collapse = ", "), "!")
				}
				return(block)
			}
			if(!is.numeric(block) || length(block) != 1 || block < 1 || block > length(self$data_list_names)){
				stop(arg_name, " must be a single block name or index!")
			}
			self$data_list_names[block]
		},
		# differential test for one feature (TkNA node reduction)
		diff_test_single = function(x, Y, method){
			if(stats::sd(x) == 0){
				return(NA_real_)
			}
			switch(method,
				kruskal = stats::kruskal.test(x, Y)$p.value,
				aov = summary(stats::aov(x ~ Y))[[1]][1, "Pr(>F)"],
				wilcox = {
					if(nlevels(droplevels(Y)) != 2){
						stop("diff_test = 'wilcox' requires exactly 2 groups in Y! Use 'kruskal' or 'aov' for multi-group data!")
					}
					stats::wilcox.test(x ~ droplevels(Y))$p.value
				},
				stop("diff_test must be one of 'kruskal', 'aov', 'wilcox' or NULL!")
			)
		},
		# MaAsLin2-style confounder removal: regress the covariate design matrix out of each feature
		residualize_matrix = function(mat, cov_mat){
			res <- apply(mat, 2, function(y){
				stats::lm.fit(cov_mat, y)$residuals
			})
			# apply(., 2, f) returns a matrix with one column per feature
			rownames(res) <- rownames(mat)
			res
		},
		# batch correlation (with p values) between the features of two blocks;
		# spearman is computed as pearson on ranks; covariates are regressed out first
		cal_block_corr = function(X, Ymat, corr_method, covariates = NULL){
			n_cov <- 0
			if(!is.null(covariates)){
				# build the design matrix once: factors expand to dummy columns, so the degrees of
				# freedom must count the non-intercept columns of the design matrix, not ncol(covariates)
				cov_mat <- stats::model.matrix(stats::as.formula("~ ."), data = covariates)
				n_cov <- ncol(cov_mat) - 1
				X <- private$residualize_matrix(X, cov_mat)
				Ymat <- private$residualize_matrix(Ymat, cov_mat)
			}
			if(corr_method == "spearman"){
				X <- apply(X, 2, rank)
				Ymat <- apply(Ymat, 2, rank)
			}
			R <- suppressWarnings(stats::cor(X, Ymat))
			n <- nrow(X)
			df <- n - 2 - n_cov
			if(df <= 0){
				stop("Insufficient samples (", n, ") to compute correlations with ", n_cov, " covariate(s)!")
			}
			tstat <- abs(R) * sqrt(df / pmax(1 - R^2, 0))
			P <- 2 * stats::pt(-tstat, df)
			P[is.na(R)] <- NA_real_
			list(r = R, p = P)
		},
		# Bipartite betweenness centrality (BiBC) following the TkNA framework:
		# a modified Brandes algorithm (Dijkstra-based) that only counts shortest paths whose two
		# endpoints belong to different blocks, so that nodes bridging omics layers are highlighted.
		# Edge weights are interpreted as distances (strongly correlated features are close).
		cal_bipc = function(g, layer_vec){
			nv <- igraph::vcount(g)
			bipc <- numeric(nv)
			if(nv <= 2){
				return(bipc)
			}
			el <- igraph::as_edgelist(g, names = FALSE)
			if(nrow(el) == 0){
				return(bipc)
			}
			w <- igraph::E(g)$weight
			if(is.null(w)){
				w <- rep(1, nrow(el))
			}
			layer <- as.character(layer_vec)
			# adjacency list (each entry: matrix of [neighbor index, edge weight] rows)
			adj_list <- vector("list", nv)
			for(e in seq_len(nrow(el))){
				a <- el[e, 1]
				b <- el[e, 2]
				adj_list[[a]] <- rbind(adj_list[[a]], c(b, w[e]))
				adj_list[[b]] <- rbind(adj_list[[b]], c(a, w[e]))
			}
			tol <- 1e-9
			for(s in seq_len(nv)){
				dist <- rep(Inf, nv)
				sigma <- numeric(nv)
				pred <- vector("list", nv)
				dist[s] <- 0
				sigma[s] <- 1
				done <- rep(FALSE, nv)
				# ---- Dijkstra from s ----
				repeat{
					cand <- which(!done & is.finite(dist))
					if(length(cand) == 0){
						break
					}
					u <- cand[which.min(dist[cand])]
					done[u] <- TRUE
					nb <- adj_list[[u]]
					if(is.null(nb)){
						next
					}
					for(k in seq_len(nrow(nb))){
						v <- nb[k, 1]
						alt <- dist[u] + nb[k, 2]
						if(alt < dist[v] - tol){
							dist[v] <- alt
							sigma[v] <- sigma[u]
							pred[[v]] <- u
						}else if(abs(alt - dist[v]) <= tol){
							if(!(u %in% pred[[v]])){
								sigma[v] <- sigma[v] + sigma[u]
								pred[[v]] <- c(pred[[v]], u)
							}
						}
					}
				}
				# ---- back-propagate dependencies, counting only cross-layer endpoints ----
				delta <- numeric(nv)
				ord <- order(dist, decreasing = TRUE)
				ord <- ord[is.finite(dist[ord])]
				for(v in ord){
					if(v == s){
						next
					}
					# a cross-layer endpoint v contributes 1 to each of its predecessors;
					# delta[v] holds v's contribution as an intermediate node to further endpoints
					coeff <- delta[v] + (layer[v] != layer[s])
					pv <- pred[[v]]
					if(length(pv) > 0 && sigma[v] > 0){
						delta[pv] <- delta[pv] + (sigma[pv] / sigma[v]) * coeff
					}
				}
				bipc <- bipc + delta
			}
			names(bipc) <- igraph::vertex_attr(g, "name")
			bipc
		},
		# extract (and optionally subset/limit) a block matrix for mediation analysis
		mediation_block_matrix = function(block, features, max_features){
			mat <- self$data_list[[block]]
			if(!is.null(features) && !is.null(features[[block]])){
				keep <- intersect(features[[block]], colnames(mat))
				if(length(keep) == 0){
					stop("No feature of the '", block, "' block matches the provided features subset!")
				}
				mat <- mat[, keep, drop = FALSE]
			}
			if(!is.null(max_features) && ncol(mat) > max_features){
				vars <- apply(mat, 2, var)
				keep <- order(vars, decreasing = TRUE)[seq_len(max_features)]
				mat <- mat[, keep, drop = FALSE]
				message("Block '", block, "' reduced to the ", max_features, " most variable features for the mediation analysis ...")
			}
			mat
		},
		# rename the entries of a character vector through a map (names(x) -> x)
		rename_chr = function(v, map){
			if(is.null(v)){
				return(v)
			}
			idx <- match(v, names(map))
			v[!is.na(idx)] <- unname(map[idx[!is.na(idx)]])
			v
		},
		# recursively restore the original variable names in the effect tables returned by
		# multimedia: names, colnames, rownames, dimnames, character cell values and factor levels
		restore_med_names = function(x, map){
			if(is.matrix(x)){
				dn <- dimnames(x)
				if(!is.null(dn)){
					dimnames(x) <- lapply(dn, private$rename_chr, map)
				}
				return(x)
			}
			if(is.data.frame(x)){
				# variable references may also appear as cell values (e.g. the outcome/mediator
				# columns of the effect tables)
				for(j in seq_len(ncol(x))){
					if(is.character(x[[j]])){
						idx <- match(x[[j]], names(map))
						if(any(!is.na(idx))){
							x[[j]][!is.na(idx)] <- unname(map[idx[!is.na(idx)]])
						}
					}else if(is.factor(x[[j]])){
						lv <- levels(x[[j]])
						idx <- match(lv, names(map))
						if(any(!is.na(idx))){
							levels(x[[j]])[!is.na(idx)] <- unname(map[idx[!is.na(idx)]])
						}
					}
				}
				colnames(x) <- private$rename_chr(colnames(x), map)
				rownames(x) <- private$rename_chr(rownames(x), map)
				return(x)
			}
			if(is.list(x)){
				names(x) <- private$rename_chr(names(x), map)
				for(i in seq_along(x)){
					x[[i]] <- private$restore_med_names(x[[i]], map)
				}
				return(x)
			}
			if(is.atomic(x) && !is.null(names(x))){
				names(x) <- private$rename_chr(names(x), map)
			}
			x
		},
		# align a named exposure/outcome vector to the samples and wrap it into a 1-column data.frame
		mediation_vector = function(vec, samples_use, var_name){
			if(is.null(names(vec))){
				if(length(vec) != length(samples_use)){
					stop("The length of ", var_name, " (", length(vec),
						") does not match the number of samples (", length(samples_use), ")!")
				}
				warning(var_name, " has no names; samples are matched by order. Please provide a named vector to ensure correct alignment!")
				out <- vec
			}else{
				if(!setequal(names(vec), samples_use)){
					stop("The names of ", var_name, " do not match the sample names of the blocks!")
				}
				out <- vec[samples_use]
			}
			res <- data.frame(out, stringsAsFactors = TRUE)
			colnames(res) <- var_name
			rownames(res) <- samples_use
			res
		},
		# extract an original (non-transformed) abundance table (features x samples) for export_bridge
		bridge_block_table = function(block, features){
			tab <- as.matrix(self$dataset_list[[block]]$otu_table)
			if(!is.null(features) && !is.null(features[[block]])){
				keep <- intersect(features[[block]], rownames(tab))
				if(length(keep) == 0){
					stop("No feature of the '", block, "' block matches the provided features subset!")
				}
				tab <- tab[keep, , drop = FALSE]
			}
			tab
		}
	),
	lock_objects = FALSE,
	lock_class = FALSE
)

Try the microeco package in your browser

Any scripts or data that you put into this service are public.

microeco documentation built on Oct. 20, 2026, 9:06 a.m.