diff --git a/DESCRIPTION b/DESCRIPTION index 3f59bac..a5561cf 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -38,6 +38,7 @@ Config/testthat/edition: 3 Imports: arrow, BiocParallel, + BiocFileCache, ComplexHeatmap, DBI, dplyr, diff --git a/NAMESPACE b/NAMESPACE index 19d42ab..66e8744 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -58,6 +58,7 @@ export(runMDRmodels) export(runMLPipeline) export(runMLmodels) export(runModelingPipeline) +export(runModelingPipelineIntense) export(selectBestModel) export(shuffleLabels) export(splitMLInputTibble) @@ -193,6 +194,7 @@ importFrom(tidyr, ) importFrom(tune, control_grid, + extract_fit_parsnip, finalize_workflow, select_best, tune_grid @@ -202,6 +204,10 @@ importFrom(workflows, add_recipe, workflow ) +importFrom(workflowsets, + extract_fit_parsnip, + extract_spec_parsnip +) importFrom(yardstick, bal_accuracy, conf_mat, diff --git a/R/generate_matrices_ml.R b/R/generate_matrices_ml.R index 670f0af..8e219da 100644 --- a/R/generate_matrices_ml.R +++ b/R/generate_matrices_ml.R @@ -94,7 +94,7 @@ NULL #' } #' .generate3ltrCode <- function(directory_name) { - + parts <- stringr::str_split(directory_name, "_")[[1]] if (length(parts) == 1) { # If only one word, use "sp" as the second part @@ -105,10 +105,10 @@ NULL return(abbreviation) } -#' +#' #' For LOO and cross-drug matrices, which are built by removing genomes and so -#' can end up single-class or tiny, checking if they should be skipped. -#' [skipImbalancedMatrix()] guards the matricesbuilt from source metadata +#' can end up single-class or tiny, checking if they should be skipped. +#' [skipImbalancedMatrix()] guards the matricesbuilt from source metadata #' instead, and sizes them per cross-validation fold; #' a test set is scored once, so it only needs enough genomes to define a metric. #' @@ -451,7 +451,7 @@ log( DBI::dbDisconnect(con0, shutdown = TRUE) } }, add = TRUE) - + .register_parquet_views(con0, parquet_dir) bug <- .generate3ltrCode(basename(parquet_dir)) @@ -595,28 +595,33 @@ log( fid <- feature_types[[ftype]]$id_col DBI::dbExecute(con, sprintf(" - CREATE OR REPLACE VIEW %s_binary AS - SELECT genome_id, %s, - CASE WHEN value > 0 THEN 1 ELSE 0 END AS present - FROM %s - ", ftype, fid, fview)) + CREATE OR REPLACE VIEW %s_binary AS + SELECT genome_id, %s, + CASE WHEN value > 0 THEN 1 ELSE 0 END AS present + FROM %s + ", ftype, fid, fview)) if (ftype != "struct") { DBI::dbExecute(con, sprintf(" - CREATE OR REPLACE VIEW %s_counts AS - SELECT genome_id, %s, value - FROM %s - ", ftype, fid, fview)) + CREATE OR REPLACE VIEW %s_counts AS + SELECT genome_id, %s, value + FROM %s + ", ftype, fid, fview)) } for (mtype in names(matrix_types)) { - binary_only <- matrix_types[[mtype]]$binary_only - if (ftype == "struct" && !binary_only) next + if (identical(ftype, "struct")) { + if (!identical(mtype, "struct_binary")) next + } else { + if (identical(mtype, "struct_binary")) next + } mview <- sprintf( - "%s_%s", ftype, + "%s_%s", + ftype, ifelse(grepl("binary", mtype), "binary", "counts") ) + value_col <- matrix_types[[mtype]]$value_col filter_clause <- matrix_types[[mtype]]$filter @@ -732,6 +737,7 @@ log( invisible(tibble::tibble()) } + #' Build leave-one-out (LOO) merged parquet matrices from stratified parquet files. #' #' @param path Character. Base directory containing stratified parquet matrices. @@ -965,8 +971,11 @@ log( fid <- feature_types[[ftype]]$id_col for (mtype in names(matrix_types)) { - binary_only <- matrix_types[[mtype]]$binary_only - if (ftype == "struct" && !binary_only) next + if (identical(ftype, "struct")) { + if (!identical(mtype, "struct_binary")) next + } else { + if (identical(mtype, "struct_binary")) next + } mtype_label <- matrix_types[[mtype]]$label @@ -1447,7 +1456,7 @@ log( #' @param split [numeric] training/validation split specification. Two formats accepted: #' - Shorthand for CV: `split = 0` (converted internally to `c(1, 0)`) #' - Vector form: `c(train_prop, val_prop)` where test_prop = 1 - train - val -#' * For CV: `c(1, 0)` means 80% training data with k-fold CV, 20% stratified testing +#' * For CV: `c(1, 0)` uses the full dataset for k-fold cross-validation #' * For classical splits: all three partitions must be > 0 #' Example: `c(0.7, 0.15)` = 70% train, 15% val, 15% test #' @param min_n [numeric] minimum number of samples for each combination of drug classes for MDR matrix; default is 25 @@ -1486,7 +1495,7 @@ log( #' } #' @export generateMLInputs <- function(parquet_dir = "data/", - out_path = "data/", + out_path = NULL, n_fold = 5, split = c(1, 0), # Default: CV min_n = 25, @@ -1499,8 +1508,8 @@ generateMLInputs <- function(parquet_dir = "data/", stop("Parquet directory not found: ", parquet_dir) } - if (!dir.exists(dirname(out_path))) { - stop("Output directory does not exist: ", dirname(out_path)) + if (is.null(out_path)) { + out_path <- parquet_dir } # Normalize input paths diff --git a/R/manifest_helpers.R b/R/manifest_helpers.R new file mode 100644 index 0000000..16399dc --- /dev/null +++ b/R/manifest_helpers.R @@ -0,0 +1,874 @@ +######################### +# BiocFileCache helpers # +######################### + +#' Shared BFC used across the amR package suite +#' +#' Makes sure BiocFileCache exists, and if it does, opens the cache and +#' sets the behavior to create directories silently without interrupting a user +#' 's lunch to ask if they should create each new directory. +#' +#' @return A `BiocFileCache` object +#' @keywords internal +.amr_bfc <- function() { + if (!requireNamespace("BiocFileCache", quietly = TRUE)) { + stop( + "Package 'BiocFileCache' is required for amR dataset discovery." + ) + } + + BiocFileCache::BiocFileCache(ask = FALSE) +} + +#' Find dataset manifests registered across the amR suite +#' +#' @return BFC-registered amRdata manifests on the system +#' @keywords internal +.amr_registered_manifests <- function() { + bfc <- .amr_bfc() + + BiocFileCache::bfcquery( + bfc, + query = "^amR_dataset_manifest_", + field = "rname", + exact = FALSE + ) +} + +#' Discover completed amRdata datasets available for amRml +#' +#' @return Existing amRdata datasets that are ready for modeling +#' @keywords internal +.discoverAmrDatasets <- function() { + bfc <- .amr_bfc() + hits <- .amr_registered_manifests() + + if (!nrow(hits)) { + return(tibble::tibble()) + } + + datasets <- purrr::map_dfr( + seq_len(nrow(hits)), + function(i) { + manifest_path <- tryCatch( + BiocFileCache::bfcrpath( + bfc, + rids = hits$rid[[i]], + exact = TRUE + ), + error = function(e) NA_character_ + ) + + if ( + length(manifest_path) != 1L || + is.na(manifest_path) || + !file.exists(manifest_path) + ) { + return(NULL) + } + + manifest <- tryCatch( + jsonlite::read_json( + manifest_path, + simplifyVector = FALSE + ), + error = function(e) NULL + ) + + if (is.null(manifest)) { + return(NULL) + } + + ml_input <- tryCatch( + .manifest_ml_input(manifest), + error = function(e) NULL + ) + + if (is.null(ml_input)) { + return(NULL) + } + + artifact <- ml_input$artifact + producer_run <- ml_input$producer_run + + selection <- manifest$dataset$selection$user_bacs + + label <- if ( + is.null(selection) || + !length(selection) + ) { + manifest$dataset_id + } else { + paste( + unlist(selection, use.names = FALSE), + collapse = ", " + ) + } + + tibble::tibble( + dataset_id = manifest$dataset_id, + manifest_id = manifest$manifest_id, + label = label, + completed_at = producer_run$finished_at, + parquet_dir = normalizePath( + artifact$directory, + mustWork = TRUE + ), + parquet_duckdb = normalizePath( + artifact$parquet_duckdb, + mustWork = TRUE + ), + metadata_parquet = normalizePath( + artifact$metadata_parquet, + mustWork = TRUE + ), + manifest_path = normalizePath( + manifest_path, + mustWork = TRUE + ), + bfc_rid = hits$rid[[i]] + ) + } + ) + + if (!nrow(datasets)) { + return(datasets) + } + + # Multiple manifests can describe the same physical dataset, so keep the newest + # usable one for each dataset directory + datasets |> + dplyr::arrange( + dplyr::desc(.data$completed_at) + ) |> + dplyr::distinct( + .data$parquet_dir, + .keep_all = TRUE + ) +} + +#' Choose an amRdata dataset if no explicit path was supplied +#' +#' @return A user-selected dataset choice for modeling. +#' @keywords internal +.selectAmrDataset <- function() { + datasets <- .discoverAmrDatasets() + + if (!nrow(datasets)) { + stop( + "No completed amRdata datasets were found in BiocFileCache.\n", + "Run the amRdata workflow first or provide `parquet_dir` explicitly." + ) + } + + if (nrow(datasets) == 1L) { + return(datasets[1, , drop = FALSE]) + } + + if (!interactive()) { + stop( + "Multiple completed amRdata datasets were found. ", + "You can provide `parquet_dir` explicitly for non-interactive use." + ) + } + + choices <- paste0( + datasets$label, + " | completed ", + datasets$completed_at, + " | ", + datasets$parquet_dir + ) + + choice <- utils::menu( + choices, + title = "Please select an amRdata dataset for modeling:" + ) + + if (choice == 0L) { + stop("No dataset selected.") + } + + datasets[choice, , drop = FALSE] +} + + + +######################### +# Manifest helpers # +######################### + +#' Returns the basics about a file for manifest logging +#' +#' @param path Character vector of file paths. +#' @param hash Logical. If TRUE, calculate SHA-256 checksums. +#' +#' @return A list of file records. +#' @keywords internal +.manifest_file_info <- function(path, hash = FALSE) { + path <- unique(as.character(path)) + path <- path[nzchar(path)] + + if (!length(path)) { + return(list()) + } + + # See what exists + purrr::map(path, function(x) { + exists <- file.exists(x) + + out <- list( + path = x, + exists = exists, + size_bytes = if (exists) file.info(x)$size else NA_real_, + modified_at = if (exists) as.character(file.info(x)$mtime) else NA_character_ + ) + + # Hash what exists, if desired + if (isTRUE(hash) && exists && !dir.exists(x)) { + out$sha256 <- unname(tools::sha256(x)) + } + + out + }) +} + + +#' Capture basic GitHub repo state for manifest provenance +#' +#' @param base_dir Character. Project root. +#' +#' @return A named list. +#' @keywords internal +.manifest_git_info <- function(base_dir = ".") { + base_dir <- normalizePath(base_dir, mustWork = FALSE) + + # Find Git + git <- Sys.which("git") + + if (!nzchar(git)) { + return(list( + available = FALSE + )) + } + + # Run Git through system commands + run_git <- function(args) { + tryCatch( + system2( + git, + args = args, + stdout = TRUE, + stderr = FALSE + ), + error = function(e) character() + ) + } + + inside <- run_git(c("-C", shQuote(base_dir), "rev-parse", "--is-inside-work-tree")) + + if (!length(inside) || !identical(trimws(inside[[1]]), "true")) { + return(list( + available = TRUE, + repository = FALSE + )) + } + + commit <- run_git(c("-C", shQuote(base_dir), "rev-parse", "HEAD")) + branch <- run_git(c("-C", shQuote(base_dir), "rev-parse", "--abbrev-ref", "HEAD")) + dirty <- run_git(c("-C", shQuote(base_dir), "status", "--porcelain")) + + list( + available = TRUE, + repository = TRUE, + commit = if (length(commit)) trimws(commit[[1]]) else NA_character_, + branch = if (length(branch)) trimws(branch[[1]]) else NA_character_, + dirty = length(dirty) > 0L + ) +} + + +#' Capture package versions currently loaded in the R session +#' +#' @return Named character vector of package versions. +#' @keywords internal +.manifest_package_versions <- function() { + pkgs <- sort(loadedNamespaces()) + + stats::setNames( + as.list( + purrr::map_chr( + pkgs, + function(pkg) { + tryCatch( + as.character(utils::packageVersion(pkg)), + error = function(e) NA_character_ + ) + } + ) + ), + pkgs + ) +} + + +#' Generate a unique manifest run identifier +#' +#' @return Character scalar. +#' @keywords internal +.manifest_run_id <- function() { + paste0( + "run_", + format(Sys.time(), "%Y%m%dT%H%M%OS3", tz = "UTC"), + "_pid", + Sys.getpid() + ) |> + gsub("[^A-Za-z0-9_]", "", x = _) +} + + +#' Start or load a dataset provenance manifest +#' +#' @param manifest_path Character. Path to the JSON manifest. +#' @param dataset_id Character scalar. +#' @param duckdb_path Character scalar. +#' @param base_dir Character scalar. +#' @param selection Optional named list describing the dataset selection. +#' @param hash_files Logical. Calculate SHA-256 for manifest-recorded files. +#' +#' @return A manifest object with `path` and `run_index`. +#' @keywords internal +.manifest_start <- function( + manifest_path, + dataset_id, + duckdb_path, + base_dir = ".", + selection = list(), + hash_files = FALSE +) { + if (!requireNamespace("jsonlite", quietly = TRUE)) { + stop("Package 'jsonlite' is required for manifest generation.") + } + + manifest_path <- normalizePath( + manifest_path, + mustWork = FALSE + ) + + dir.create( + dirname(manifest_path), + recursive = TRUE, + showWarnings = FALSE + ) + + manifest_id <- tools::file_path_sans_ext( + basename(manifest_path) + ) + + manifest <- list( + schema_version = 1L, + manifest_type = "amR_dataset", + manifest_id = manifest_id, + manifest_created_at = as.character(Sys.time()), + manifest_updated_at = as.character(Sys.time()), + dataset_id = dataset_id, + dataset = list( + duckdb = duckdb_path, + selection = selection + ), + artifacts = list(), + runs = list() + ) + + run <- list( + run_id = .manifest_run_id(), + status = "running", + started_at = as.character(Sys.time()), + finished_at = NA_character_, + command = commandArgs(trailingOnly = FALSE), + working_directory = getwd(), + host = as.list(Sys.info()), + r = list( + version = R.version.string, + platform = R.version$platform + ), + git = .manifest_git_info(base_dir), + packages = .manifest_package_versions(), + stages = list(), + events = list() + ) + + if (is.null(manifest$runs)) { + manifest$runs <- list() + } + + manifest$runs[[length(manifest$runs) + 1L]] <- run + manifest$manifest_updated_at <- as.character(Sys.time()) + + run_index <- length(manifest$runs) + + jsonlite::write_json( + manifest, + manifest_path, + auto_unbox = TRUE, + pretty = TRUE, + null = "null" + ) + + structure( + list( + manifest = manifest, + path = manifest_path, + run_index = run_index, + hash_files = isTRUE(hash_files) + ), + class = "amr_manifest" + ) +} + + +#' Update a manifest stage +#' +#' @param manifest_state Manifest state returned by [.manifest_start()]. +#' @param name Character stage name. +#' @param status Character stage status. +#' @param parameters Optional named list. +#' @param inputs Optional character vector of input paths. +#' @param outputs Optional character vector of output paths. +#' @param tool Optional named list describing the tool. +#' @param metrics Optional named list of metrics. +#' @param message Optional log message. +#' +#' @return Updated manifest state. +#' @keywords internal +.manifest_stage <- function( + manifest_state, + name, + status = "success", + parameters = list(), + inputs = character(), + outputs = character(), + tool = list(), + metrics = list(), + message = NULL +) { + if (!inherits(manifest_state, "amr_manifest")) { + stop("Invalid manifest state.") + } + + stage_index <- which( + purrr::map_lgl( + manifest_state$manifest$runs[[manifest_state$run_index]]$stages, + ~ identical(.x$name, name) && identical(.x$status, "running") + ) + ) + + stage <- list( + name = name, + status = status, + started_at = as.character(Sys.time()), + parameters = parameters, + inputs = .manifest_file_info(inputs, hash = manifest_state$hash_files), + outputs = .manifest_file_info(outputs, hash = manifest_state$hash_files), + tool = tool, + metrics = metrics + ) + + if (!is.null(message)) { + stage$message <- as.character(message) + } + + if (length(stage_index) == 1L) { + existing <- manifest_state$manifest$runs[[manifest_state$run_index]]$stages[[stage_index]] + + stage$started_at <- existing$started_at + stage$finished_at <- if (status != "running") { + as.character(Sys.time()) + } else { + NULL + } + + manifest_state$manifest$runs[[manifest_state$run_index]]$stages[[stage_index]] <- stage + } else { + if (status != "running") { + stage$finished_at <- as.character(Sys.time()) + } + + manifest_state$manifest$runs[[manifest_state$run_index]]$stages <- + append( + manifest_state$manifest$runs[[manifest_state$run_index]]$stages, + list(stage) + ) + } + + manifest_state$manifest$manifest_updated_at <- as.character(Sys.time()) + + jsonlite::write_json( + manifest_state$manifest, + manifest_state$path, + auto_unbox = TRUE, + pretty = TRUE, + null = "null" + ) + + manifest_state +} + + +#' Validate an amR dataset manifest +#' +#' @param manifest The previously parsed manifest list. +#' @return `TRUE` invisibly if valid, else throws error. +#' @keywords internal +.manifest_validate <- function(manifest) { + if (!is.list(manifest)) { + stop("Manifest must be a list.") + } + + schema_version <- if (is.null(manifest$schema_version)) { + "missing" + } else { + manifest$schema_version + } + + if ( + is.null(manifest$schema_version) || + !identical(as.integer(manifest$schema_version), 1L) + ) { + stop( + "Unsupported amR manifest schema version: ", + schema_version, + ". Expected schema version 1." + ) + } + + if (!identical(manifest$manifest_type, "amR_dataset")) { + stop("Manifest is not an amR dataset manifest.") + } + + required <- c( + "manifest_id", + "dataset_id", + "dataset", + "artifacts", + "runs" + ) + + missing <- setdiff(required, names(manifest)) + + if (length(missing)) { + stop( + "Manifest is missing required field(s): ", + paste(missing, collapse = ", ") + ) + } + + invisible(TRUE) +} + + +#' Append a provenance event to the active manifest run +#' +#' @param manifest_state Manifest state returned by [.manifest_start()]. +#' @param level Character event level. +#' @param message Character message. +#' @param details Optional named list. +#' +#' @return Updated manifest state. +#' @keywords internal +.manifest_event <- function( + manifest_state, + level = "info", + message, + details = list() +) { + manifest_state$manifest$runs[[manifest_state$run_index]]$events <- + append( + manifest_state$manifest$runs[[manifest_state$run_index]]$events, + list( + list( + timestamp = as.character(Sys.time()), + level = level, + message = message, + details = details + ) + ) + ) + + manifest_state$manifest$manifest_updated_at <- as.character(Sys.time()) + + jsonlite::write_json( + manifest_state$manifest, + manifest_state$path, + auto_unbox = TRUE, + pretty = TRUE, + null = "null" + ) + + manifest_state +} + +#' Finish an active provenance manifest run +#' +#' @param manifest_state Manifest state returned by [.manifest_start()]. +#' @param status Final run status. +#' @param error Optional error message. +#' +#' @return Invisibly returns the final manifest state. +#' @keywords internal +.manifest_finish <- function( + manifest_state, + status = "success", + error = NULL +) { + manifest_state$manifest$runs[[manifest_state$run_index]]$status <- status + manifest_state$manifest$runs[[manifest_state$run_index]]$finished_at <- + as.character(Sys.time()) + + # Patching to resolve an indefinite `running` failure state in the manifest + if (identical(status, "failed")) { + stages <- manifest_state$manifest$runs[[manifest_state$run_index]]$stages + running_stage <- which(purrr::map_lgl(stages, ~ identical(.x$status, "running"))) + + if (length(running_stage)) { + stage_error <- if (!is.null(error)) { + as.character(error) + } else { + "Parent run failed before this stage completed." + } + + for (i in running_stage) { + stages[[i]]$status <- "failed" + stages[[i]]$finished_at <- as.character(Sys.time()) + stages[[i]]$error <- stage_error + } + + manifest_state$manifest$runs[[manifest_state$run_index]]$stages <- stages + } + } + + if (!is.null(error)) { + manifest_state$manifest$runs[[manifest_state$run_index]]$error <- as.character(error) + } + + manifest_state$manifest$manifest_updated_at <- as.character(Sys.time()) + + jsonlite::write_json( + manifest_state$manifest, + manifest_state$path, + auto_unbox = TRUE, + pretty = TRUE, + null = "null" + ) + + invisible(manifest_state) +} + +#' Resume provenance logging in an existing manifest +#' +#' Loads an existing manifest and appends a new run. +#' +#' @param manifest_path Character. Path to an existing JSON manifest. +#' @param base_dir Character. Project root. +#' @param hash_files Logical. Calculate SHA-256 checksums for manifest-recorded files. +#' +#' @return A manifest object with `path` and `run_index`. +#' @keywords internal +.manifest_resume <- function( + manifest_path, + base_dir = ".", + hash_files = FALSE +) { + if (!requireNamespace("jsonlite", quietly = TRUE)) { + stop("Package 'jsonlite' is required for manifest generation.") + } + + manifest_path <- normalizePath( + manifest_path, + mustWork = TRUE + ) + + manifest <- jsonlite::read_json( + manifest_path, + simplifyVector = FALSE + ) + + .manifest_validate(manifest) + + if (is.null(manifest$runs)) { + manifest$runs <- list() + } + + run <- list( + run_id = .manifest_run_id(), + status = "running", + started_at = as.character(Sys.time()), + finished_at = NA_character_, + command = commandArgs(trailingOnly = FALSE), + working_directory = getwd(), + host = as.list(Sys.info()), + r = list( + version = R.version.string, + platform = R.version$platform + ), + git = .manifest_git_info(base_dir), + packages = .manifest_package_versions(), + stages = list(), + events = list() + ) + + manifest$runs[[length(manifest$runs) + 1L]] <- run + manifest$manifest_updated_at <- as.character(Sys.time()) + + run_index <- length(manifest$runs) + + jsonlite::write_json( + manifest, + manifest_path, + auto_unbox = TRUE, + pretty = TRUE, + null = "null" + ) + + structure( + list( + manifest = manifest, + path = manifest_path, + run_index = run_index, + hash_files = isTRUE(hash_files) + ), + class = "amr_manifest" + ) +} + +#' Find the newest amRml-ready manifest associated with a parquet directory +#' +#' You could have multiple runs and manifests in a single data directory, so find +#' the most recently completed manifest. +#' +#' @return Path to the most recent successful manifest for a dataset, or yells `NULL` +#' @keywords internal +.manifest_find_latest_ml <- function(parquet_dir) { + parquet_dir <- normalizePath( + parquet_dir, + mustWork = TRUE + ) + + manifests <- list.files( + parquet_dir, + pattern = "^manifest_.*\\.json$", + full.names = TRUE + ) + + if (!length(manifests)) { + return(NULL) + } + + manifests <- manifests[ + order( + file.info(manifests)$mtime, + decreasing = TRUE + ) + ] + + for (manifest_path in manifests) { + manifest <- tryCatch( + jsonlite::read_json( + manifest_path, + simplifyVector = FALSE + ), + error = function(e) NULL + ) + + if (is.null(manifest)) { + next + } + + ml_input <- tryCatch( + .manifest_ml_input(manifest), + error = function(e) NULL + ) + + if (is.null(ml_input)) { + next + } + + artifact_dir <- tryCatch( + normalizePath( + ml_input$artifact$directory, + mustWork = TRUE + ), + error = function(e) NA_character_ + ) + + if ( + length(artifact_dir) == 1L && + !is.na(artifact_dir) && + identical(artifact_dir, parquet_dir) + ) { + return(manifest_path) + } + } + + NULL +} + +#' Validate and extract an amRml-ready manifest artifact +#' +#' @param manifest Parsed amR dataset manifest +#' @return The ready artifact and run it came from, or `NULL` if unusable +#' @keywords internal +.manifest_ml_input <- function(manifest) { + .manifest_validate(manifest) + + artifact <- manifest$artifacts$amRml_input + + if ( + is.null(artifact) || + !identical(artifact$status, "ready") + ) { + return(NULL) + } + + producer_run_id <- artifact$producer_run_id + + if ( + is.null(producer_run_id) || + !length(producer_run_id) || + !nzchar(producer_run_id) + ) { + return(NULL) + } + + producer_runs <- purrr::keep( + manifest$runs, + ~ identical(.x$run_id, producer_run_id) + ) + + if ( + length(producer_runs) != 1L || + !identical(producer_runs[[1]]$status, "success") + ) { + return(NULL) + } + + parquet_dir <- artifact$directory + parquet_duckdb <- artifact$parquet_duckdb + metadata_parquet <- artifact$metadata_parquet + + if ( + is.null(parquet_dir) || + !dir.exists(parquet_dir) || + is.null(parquet_duckdb) || + !file.exists(parquet_duckdb) || + is.null(metadata_parquet) || + !file.exists(metadata_parquet) + ) { + return(NULL) + } + + list( + artifact = artifact, + producer_run = producer_runs[[1]] + ) +} diff --git a/R/run_ML.R b/R/run_ML.R index d001e05..e0a82f1 100644 --- a/R/run_ML.R +++ b/R/run_ML.R @@ -389,14 +389,14 @@ createMLinputList <- function(path, } else if (cross_test && !LOO) { if (is.null(stratify_by)) { # Case A: stratify_by = NULL, pair across abx within same feature + prefix - parsed_drugs <- parsed |> + parsed_drugs <- parsed |> dplyr::filter(!stringr::str_detect(prefix_key, "class")) - - paths$cross_drug_test <- file.path(dirname(paths$matrix_path), "cross_drug_test/") - + + paths$cross_drug_test <- file.path(dirname(paths$matrix_path), "cross_drug_test/") + # List ML test matrix parquet files test_files_vec <- list.files(paths$cross_drug_test, pattern = "\\.parquet$", full.names = TRUE) - + parsed_cross_test <- tibble::tibble( test_file = test_files_vec, fname = basename(test_files_vec), @@ -420,7 +420,7 @@ createMLinputList <- function(path, ) ) |> dplyr::select(test_file, prefix_key, feature, ref_drug, test_drug) - + pairs <- parsed_cross_test |> dplyr::inner_join( parsed_drugs |> @@ -452,7 +452,7 @@ createMLinputList <- function(path, feature ) ) |> - dplyr::select(ref_file, test_file, output_prefix) + dplyr::select(ref_file, test_file, output_prefix) out <- pairs |> dplyr::mutate( @@ -549,9 +549,9 @@ loo_files_vec <- list.files( pattern = "\\.parquet$", full.names = TRUE ) -parsed_drugs <- parsed |> +parsed_drugs <- parsed |> dplyr::filter(!stringr::str_detect(prefix_key, "class")) - + parsed_loo_test <- tibble::tibble( test_file = loo_files_vec, fname = basename(loo_files_vec), @@ -577,7 +577,7 @@ parsed_drugs <- parsed |> feature, test_drug ) - + loo_pairs <- parsed_loo_test |> dplyr::inner_join( parsed_drugs |> @@ -609,7 +609,7 @@ parsed_drugs <- parsed |> feature ) ) - + out <- loo_pairs |> dplyr::mutate( matrix_path = paths$matrix_path, @@ -618,9 +618,8 @@ parsed_drugs <- parsed |> out_models = paths$ML_models, out_pred = paths$ML_prediction ) - - return(out) - } + return(out) + } else { # LOO requires special directory structure resolution test_path <- file.path(path, stringr::str_remove(basename(paths$matrix_path), "^LOO_")) test_path <- normalizePath(test_path) @@ -648,6 +647,7 @@ parsed_drugs <- parsed |> return(out) } + } } # If we ever get here, something wasn't covered @@ -699,11 +699,11 @@ parsed_drugs <- parsed |> #' Run MDR (multi-drug resistance) machine learning models #' #' Executes machine learning pipeline for MDR analysis using logistic regression -#' with parallel processing via the future backend. Trains models on all MDR +#' with parallel processing via the BiocParallel backend. Trains models on all MDR #' parquet files and saves results to designated output directories. #' #' @param path Character scalar. Base directory containing MDR matrix files. -#' @param threads Integer. Number of parallel workers for model training. Default is 16. +#' @param threads Integer. Number of workers for parallel model training. Default is 8. #' @param split Numeric vector of length 2. Train/validation split proportions. #' @param n_fold Integer. Number of cross-validation folds. Default 5. #' @param prop_vi_top_feats Numeric vector of length 2. Proportion range for variable-importance selection. @@ -841,7 +841,7 @@ runMDRmodels <- function(path, } seed_tag <- paste0("_", seed) - + # Final base filename: shuffled_ + + _pcaXX + seed base <- paste0(shuffle_tag, output_prefix, pca_tag, seed_tag) @@ -881,7 +881,7 @@ runMDRmodels <- function(path, message(" ", normalizePath(path)) } - invisible(NULL) + invisible(TRUE) } #' Run machine learning models with multiple configurations @@ -1047,9 +1047,23 @@ runMLmodels <- function(path, cross_test = cross_test ) - if (nrow(files) == 0) { - message("No files found to process. Exiting.") - return(invisible(NULL)) + # A safeguard to catch when data constraints have left us with no usable matrices + if (nrow(files) == 0L) { + if (isTRUE(verbose)) { + analysis <- if (!is.null(stratify_by)) { + paste0(stratify_by, "-stratified") + } else if (isTRUE(LOO)) { + "leave-one-out" + } else if (isTRUE(cross_test)) { + "cross-test" + } else { + "standard" + } + + message("No eligible ", analysis, " ML matrices were found. Skipping this modeling stage.") + } + + return(invisible(FALSE)) } .findNonRanPrefixes <- function(files, @@ -1111,7 +1125,7 @@ runMLmodels <- function(path, setdiff(matrix_prefixes, ran_prefixes) } - + # ---- skip matrices that already ran ---- prefixes_to_run <- .findNonRanPrefixes( files = files, @@ -1289,22 +1303,28 @@ if (nrow(files) == 0) { message(" ", normalizePath(path)) } - invisible(NULL) + invisible(TRUE) } -#' Run the entire AMR ML pipeline from a parquet-backed DuckDB +#' Run the complete amR machine-learning workflow +#' +#' Runs modeling on a completed amRdata dataset. When `parquet_dir` is +#' `NULL`, completed datasets that have been registered through BiocFileCache +#' are discovered automatically and, if necessary (i.e., >1 option), then the +#' user is prompted to select one. #' -#' This function provides a complete end-to-end AMR machine learning workflow. -#' Given a DuckDB file produced by `runDataProcessing()`, it: #' 1. Generates all ML feature matrices (drug, class, year, country, MDR, LOO) #' 2. Creates all ML directory structures #' 3. Prepares ML input lists for every mode #' 4. Runs logistic regression ML models (standard + stratified + cross-test + MDR) #' 5. Saves performance metrics, fitted models, predictions, and top feature rankings #' -#' @param parquet_dir Path to a species-named directory (e.g. `Shigella_flexneri/`) of -#' metadata and feature parquets, as produced by data_processing.R +#' @param parquet_dir Character or `NULL`. Path to a completed amRdata dataset +#' directory containing metadata and feature Parquet files. If `NULL`, amRml +#' discovers completed amRdata datasets registered with BiocFileCache. A +#' single available dataset is selected automatically; if multiple datasets +#' are available in an interactive session, the user is prompted to choose. #' @param threads Number of parallel workers. Default: 16 #' @param n_fold Cross-validation folds (default: 5). Use 0 or NULL for classical splits. #' @param split Training/validation split (default: c(1,0) for CV mode) @@ -1317,7 +1337,7 @@ if (nrow(files) == 0) { #' @return Invisibly returns the output directory used for ML results. #' #' @export -runModelingPipeline <- function(parquet_dir, +runModelingPipeline <- function(parquet_dir = NULL, threads = 8, n_fold = 5, split = c(1, 0), @@ -1326,22 +1346,96 @@ runModelingPipeline <- function(parquet_dir, pca_threshold = 0.99, verbose = TRUE, use_saved_split = TRUE) { - parquet_dir <- normalizePath(parquet_dir) - if (!dir.exists(parquet_dir)) { - stop( - "Parquet directory at ", parquet_dir, " not found.\n", - "Expected a species-named directory (e.g. Shigella_flexneri/) of parquet files." + registered_dataset <- NULL + + if (is.null(parquet_dir)) { + registered_dataset <- .selectAmrDataset() + + parquet_dir <- registered_dataset$parquet_dir[[1]] + manifest_path <- registered_dataset$manifest_path[[1]] + } else { + parquet_dir <- normalizePath( + parquet_dir, + mustWork = FALSE + ) + + if (!dir.exists(parquet_dir)) { + stop( + "Parquet directory at ", + parquet_dir, + " not found.\n", + "Expected a species-named directory of amRdata Parquet files." + ) + } + + parquet_dir <- normalizePath( + parquet_dir, + mustWork = TRUE + ) + + manifest_path <- .manifest_find_latest_ml( + parquet_dir ) } - out_root <- dirname(parquet_dir) + out_root <- parquet_dir if (verbose) { message("\n=== amRml: Full pipeline runner ===") message("Using parquet directory:\n ", parquet_dir) } + if (is.null(manifest_path)) { + stop( + "No amRml-ready provenance manifest found for: ", + parquet_dir, + "\nRun prepareGenomes() and runDataProcessing() from amRdata first." + ) + } + + manifest <- .manifest_resume( + manifest_path = manifest_path, + base_dir = dirname(dirname(parquet_dir)), + hash_files = FALSE + ) + + run_failed <- TRUE + + on.exit( + if (run_failed) { + .manifest_finish( + manifest, + status = "failed", + error = "runModelingPipeline() exited before successful completion." + ) + }, + add = TRUE + ) + + # Record the start of this run + manifest <- .manifest_event( + manifest, + message = "Started modeling run.", + details = list( + parquet_dir = parquet_dir, + output_path = out_root + ) + ) + if (verbose) message("\n[1/4] Generating ML feature matrices.") + + manifest <- .manifest_stage( + manifest, + name = "matrix_generation", + status = "running", + parameters = list( + n_fold = n_fold, + split = split, + min_n = min_n + ), + inputs = parquet_dir + ) + generateMLInputs( parquet_dir = parquet_dir, out_path = out_root, @@ -1351,7 +1445,38 @@ runModelingPipeline <- function(parquet_dir, verbosity = if (verbose) "minimal" else "debug" ) + manifest <- .manifest_stage( + manifest, +name = "matrix_generation", + status = "success", + parameters = list( + n_fold = n_fold, + split = split, + min_n = min_n + ), + inputs = parquet_dir + ) + if (verbose) message("\n[2/4] Running standard ML models.") + + manifest <- .manifest_stage( + manifest, + name = "run_standard_models", + status = "running", + parameters = list( + stratify_by = NULL, + LOO = FALSE, + cross_test = FALSE, + threads = threads, + split = split, + n_fold = n_fold, + prop_vi_top_feats = prop_vi_top_feats, + pca_threshold = pca_threshold, + use_saved_split = use_saved_split + ), + inputs = out_root + ) + runMLmodels( path = out_root, stratify_by = NULL, @@ -1366,7 +1491,44 @@ runModelingPipeline <- function(parquet_dir, use_saved_split = use_saved_split ) + manifest <- .manifest_stage( + manifest, + name = "run_standard_models", + status = "success", + parameters = list( + stratify_by = NULL, + LOO = FALSE, + cross_test = FALSE, + threads = threads, + split = split, + n_fold = n_fold, + prop_vi_top_feats = prop_vi_top_feats, + pca_threshold = pca_threshold, + use_saved_split = use_saved_split + ), + inputs = out_root + ) + if (verbose) message("\n[3/4] Running stratified (year) ML models.") + + manifest <- .manifest_stage( + manifest, + name = "run_year_models", + status = "running", + parameters = list( + stratify_by = "year", + LOO = FALSE, + cross_test = FALSE, + threads = threads, + split = split, + n_fold = n_fold, + prop_vi_top_feats = prop_vi_top_feats, + pca_threshold = pca_threshold, + use_saved_split = use_saved_split + ), + inputs = out_root + ) + runMLmodels( path = out_root, stratify_by = "year", @@ -1381,7 +1543,44 @@ runModelingPipeline <- function(parquet_dir, use_saved_split = use_saved_split ) + manifest <- .manifest_stage( + manifest, + name = "run_year_models", + status = "success", + parameters = list( + stratify_by = "year", + LOO = FALSE, + cross_test = FALSE, + threads = threads, + split = split, + n_fold = n_fold, + prop_vi_top_feats = prop_vi_top_feats, + pca_threshold = pca_threshold, + use_saved_split = use_saved_split + ), + inputs = out_root + ) + if (verbose) message("\n[3/4] Running stratified (country) ML models.") + + manifest <- .manifest_stage( + manifest, + name = "run_country_models", + status = "running", + parameters = list( + stratify_by = "country", + LOO = FALSE, + cross_test = FALSE, + threads = threads, + split = split, + n_fold = n_fold, + prop_vi_top_feats = prop_vi_top_feats, + pca_threshold = pca_threshold, + use_saved_split = use_saved_split + ), + inputs = out_root + ) + runMLmodels( path = out_root, stratify_by = "country", @@ -1396,7 +1595,41 @@ runModelingPipeline <- function(parquet_dir, use_saved_split = use_saved_split ) + manifest <- .manifest_stage( + manifest, + name = "run_country_models", + status = "success", + parameters = list( + stratify_by = "country", + LOO = FALSE, + cross_test = FALSE, + threads = threads, + split = split, + n_fold = n_fold, + prop_vi_top_feats = prop_vi_top_feats, + pca_threshold = pca_threshold, + use_saved_split = use_saved_split + ), + inputs = out_root + ) + if (verbose) message("\n[4/4] Running MDR ML models.") + + manifest <- .manifest_stage( + manifest, + name = "run_MDR_models", + status = "running", + parameters = list( + threads = threads, + split = split, + n_fold = n_fold, + prop_vi_top_feats = prop_vi_top_feats, + pca_threshold = pca_threshold, + use_saved_split = use_saved_split + ), + inputs = out_root + ) + runMDRmodels( path = out_root, threads = threads, @@ -1407,6 +1640,22 @@ runModelingPipeline <- function(parquet_dir, verbose = verbose, use_saved_split = use_saved_split ) + + manifest <- .manifest_stage( + manifest, + name = "run_MDR_models", + status = "success", + parameters = list( + threads = threads, + split = split, + n_fold = n_fold, + prop_vi_top_feats = prop_vi_top_feats, + pca_threshold = pca_threshold, + use_saved_split = use_saved_split + ), + inputs = out_root + ) + # All done! if (verbose) { message("\n=== AMR-ML Pipeline Complete ===") @@ -1418,10 +1667,42 @@ runModelingPipeline <- function(parquet_dir, message(" ML_performance/, ML_models/, ML_prediction/, ML_top_features/") } + manifest <- .manifest_finish( + manifest, + status = "success" + ) + + run_failed <- FALSE + invisible(out_root) } - + +#' Run the entire AMR ML pipeline from a parquets +#' +#' This function provides a complete end-to-end AMR machine learning workflow. +#' Given a DuckDB file produced by `runDataProcessing()`, it: +#' 1. Generates all ML feature matrices (drug, class, year, country, MDR, LOO) +#' 2. Creates all ML directory structures +#' 3. Prepares ML input lists for every mode +#' 4. Runs logistic regression ML models (standard + stratified + cross-test + MDR) +#' 5. Saves performance metrics, fitted models, predictions, and top feature rankings +#' +#' @param parquet_dir Path to a species-named directory (e.g. `Shigella_flexneri/`) of +#' metadata and feature parquets, as produced by data_processing.R +#' @param threads Number of parallel workers. Default: 16 +#' @param n_seeds Number of random seeds to run for each model (default: 3) +#' @param n_fold Cross-validation folds (default: 5). Use 0 or NULL for classical splits. +#' @param split Training/validation split (default: c(1,0) for CV mode) +#' @param min_n Minimum samples per drug class for MDR matrices (default: 25) +#' @param prop_vi_top_feats Proportion of variable importance for top features (default: c(0,1)) +#' @param pca_threshold PCA variance threshold (not used unless `use_pca = TRUE`) +#' @param verbose Print progress updates? Default: TRUE +#' @param use_saved_split Whether to inherit split/seed/n_fold from ml_parameters.json +#' +#' @return Invisibly returns the output directory used for ML results. +#' +#' @export runModelingPipelineIntense <- function(parquet_dir, threads = 8, n_seeds = 3, @@ -1442,7 +1723,7 @@ runModelingPipelineIntense <- function(parquet_dir, ) } - out_root <- dirname(parquet_dir) + out_root <- parquet_dir # ------------------------------- # Helper for safe execution @@ -1704,7 +1985,7 @@ runModelingPipelineIntense <- function(parquet_dir, invisible(out_root) } - + runMultipleMDR <- function(path, threads = 8, n_seeds = 3, @@ -1716,8 +1997,8 @@ runModelingPipelineIntense <- function(parquet_dir, set.seed(123) # reproducible seed sampling seeds <- sample(1:100, n_seeds) - - for(seed in seeds){ + + for(seed in seeds){ if (verbose) message("\n Running MDR ML models.") runMDRmodels( path = path, diff --git a/inst/extdata/Staphylococcus_epidermidis/all_perf.parquet b/inst/extdata/Staphylococcus_epidermidis/all_perf.parquet new file mode 100644 index 0000000..509dc53 Binary files /dev/null and b/inst/extdata/Staphylococcus_epidermidis/all_perf.parquet differ diff --git a/inst/extdata/Staphylococcus_epidermidis/all_top_features.parquet b/inst/extdata/Staphylococcus_epidermidis/all_top_features.parquet new file mode 100644 index 0000000..27942b3 Binary files /dev/null and b/inst/extdata/Staphylococcus_epidermidis/all_top_features.parquet differ diff --git a/inst/extdata/Staphylococcus_epidermidis/dyad_feature.parquet b/inst/extdata/Staphylococcus_epidermidis/dyad_feature.parquet new file mode 100644 index 0000000..f7951e1 Binary files /dev/null and b/inst/extdata/Staphylococcus_epidermidis/dyad_feature.parquet differ diff --git a/man/dot-amr_bfc.Rd b/man/dot-amr_bfc.Rd new file mode 100644 index 0000000..5bdb485 --- /dev/null +++ b/man/dot-amr_bfc.Rd @@ -0,0 +1,17 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.amr_bfc} +\alias{.amr_bfc} +\title{Shared BFC used across the amR package suite} +\usage{ +.amr_bfc() +} +\value{ +A \code{BiocFileCache} object +} +\description{ +Makes sure BiocFileCache exists, and if it does, opens the cache and +sets the behavior to create directories silently without interrupting a user +'s lunch to ask if they should create each new directory. +} +\keyword{internal} diff --git a/man/dot-amr_registered_manifests.Rd b/man/dot-amr_registered_manifests.Rd new file mode 100644 index 0000000..4b9e4a9 --- /dev/null +++ b/man/dot-amr_registered_manifests.Rd @@ -0,0 +1,15 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.amr_registered_manifests} +\alias{.amr_registered_manifests} +\title{Find dataset manifests registered across the amR suite} +\usage{ +.amr_registered_manifests() +} +\value{ +BFC-registered amRdata manifests on the system +} +\description{ +Find dataset manifests registered across the amR suite +} +\keyword{internal} diff --git a/man/dot-discoverAmrDatasets.Rd b/man/dot-discoverAmrDatasets.Rd new file mode 100644 index 0000000..f210bb2 --- /dev/null +++ b/man/dot-discoverAmrDatasets.Rd @@ -0,0 +1,15 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.discoverAmrDatasets} +\alias{.discoverAmrDatasets} +\title{Discover completed amRdata datasets available for amRml} +\usage{ +.discoverAmrDatasets() +} +\value{ +Existing amRdata datasets that are ready for modeling +} +\description{ +Discover completed amRdata datasets available for amRml +} +\keyword{internal} diff --git a/man/dot-manifest_event.Rd b/man/dot-manifest_event.Rd new file mode 100644 index 0000000..bd27eec --- /dev/null +++ b/man/dot-manifest_event.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.manifest_event} +\alias{.manifest_event} +\title{Append a provenance event to the active manifest run} +\usage{ +.manifest_event(manifest_state, level = "info", message, details = list()) +} +\arguments{ +\item{manifest_state}{Manifest state returned by \code{\link[=.manifest_start]{.manifest_start()}}.} + +\item{level}{Character event level.} + +\item{message}{Character message.} + +\item{details}{Optional named list.} +} +\value{ +Updated manifest state. +} +\description{ +Append a provenance event to the active manifest run +} +\keyword{internal} diff --git a/man/dot-manifest_file_info.Rd b/man/dot-manifest_file_info.Rd new file mode 100644 index 0000000..bd7191f --- /dev/null +++ b/man/dot-manifest_file_info.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.manifest_file_info} +\alias{.manifest_file_info} +\title{Returns the basics about a file for manifest logging} +\usage{ +.manifest_file_info(path, hash = FALSE) +} +\arguments{ +\item{path}{Character vector of file paths.} + +\item{hash}{Logical. If TRUE, calculate SHA-256 checksums.} +} +\value{ +A list of file records. +} +\description{ +Returns the basics about a file for manifest logging +} +\keyword{internal} diff --git a/man/dot-manifest_find_latest_ml.Rd b/man/dot-manifest_find_latest_ml.Rd new file mode 100644 index 0000000..43c1709 --- /dev/null +++ b/man/dot-manifest_find_latest_ml.Rd @@ -0,0 +1,16 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.manifest_find_latest_ml} +\alias{.manifest_find_latest_ml} +\title{Find the newest amRml-ready manifest associated with a parquet directory} +\usage{ +.manifest_find_latest_ml(parquet_dir) +} +\value{ +Path to the most recent successful manifest for a dataset, or yells \code{NULL} +} +\description{ +You could have multiple runs and manifests in a single data directory, so find +the most recently completed manifest. +} +\keyword{internal} diff --git a/man/dot-manifest_finish.Rd b/man/dot-manifest_finish.Rd new file mode 100644 index 0000000..e1d4fd5 --- /dev/null +++ b/man/dot-manifest_finish.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.manifest_finish} +\alias{.manifest_finish} +\title{Finish an active provenance manifest run} +\usage{ +.manifest_finish(manifest_state, status = "success", error = NULL) +} +\arguments{ +\item{manifest_state}{Manifest state returned by \code{\link[=.manifest_start]{.manifest_start()}}.} + +\item{status}{Final run status.} + +\item{error}{Optional error message.} +} +\value{ +Invisibly returns the final manifest state. +} +\description{ +Finish an active provenance manifest run +} +\keyword{internal} diff --git a/man/dot-manifest_git_info.Rd b/man/dot-manifest_git_info.Rd new file mode 100644 index 0000000..32001c5 --- /dev/null +++ b/man/dot-manifest_git_info.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.manifest_git_info} +\alias{.manifest_git_info} +\title{Capture basic GitHub repo state for manifest provenance} +\usage{ +.manifest_git_info(base_dir = ".") +} +\arguments{ +\item{base_dir}{Character. Project root.} +} +\value{ +A named list. +} +\description{ +Capture basic GitHub repo state for manifest provenance +} +\keyword{internal} diff --git a/man/dot-manifest_ml_input.Rd b/man/dot-manifest_ml_input.Rd new file mode 100644 index 0000000..513f875 --- /dev/null +++ b/man/dot-manifest_ml_input.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.manifest_ml_input} +\alias{.manifest_ml_input} +\title{Validate and extract an amRml-ready manifest artifact} +\usage{ +.manifest_ml_input(manifest) +} +\arguments{ +\item{manifest}{Parsed amR dataset manifest} +} +\value{ +The ready artifact and run it came from, or \code{NULL} if unusable +} +\description{ +Validate and extract an amRml-ready manifest artifact +} +\keyword{internal} diff --git a/man/dot-manifest_package_versions.Rd b/man/dot-manifest_package_versions.Rd new file mode 100644 index 0000000..3268e71 --- /dev/null +++ b/man/dot-manifest_package_versions.Rd @@ -0,0 +1,15 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.manifest_package_versions} +\alias{.manifest_package_versions} +\title{Capture package versions currently loaded in the R session} +\usage{ +.manifest_package_versions() +} +\value{ +Named character vector of package versions. +} +\description{ +Capture package versions currently loaded in the R session +} +\keyword{internal} diff --git a/man/dot-manifest_resume.Rd b/man/dot-manifest_resume.Rd new file mode 100644 index 0000000..d2d0bfd --- /dev/null +++ b/man/dot-manifest_resume.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.manifest_resume} +\alias{.manifest_resume} +\title{Resume provenance logging in an existing manifest} +\usage{ +.manifest_resume(manifest_path, base_dir = ".", hash_files = FALSE) +} +\arguments{ +\item{manifest_path}{Character. Path to an existing JSON manifest.} + +\item{base_dir}{Character. Project root.} + +\item{hash_files}{Logical. Calculate SHA-256 checksums for manifest-recorded files.} +} +\value{ +A manifest object with \code{path} and \code{run_index}. +} +\description{ +Loads an existing manifest and appends a new run. +} +\keyword{internal} diff --git a/man/dot-manifest_run_id.Rd b/man/dot-manifest_run_id.Rd new file mode 100644 index 0000000..eb3370d --- /dev/null +++ b/man/dot-manifest_run_id.Rd @@ -0,0 +1,15 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.manifest_run_id} +\alias{.manifest_run_id} +\title{Generate a unique manifest run identifier} +\usage{ +.manifest_run_id() +} +\value{ +Character scalar. +} +\description{ +Generate a unique manifest run identifier +} +\keyword{internal} diff --git a/man/dot-manifest_stage.Rd b/man/dot-manifest_stage.Rd new file mode 100644 index 0000000..21c2b92 --- /dev/null +++ b/man/dot-manifest_stage.Rd @@ -0,0 +1,44 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.manifest_stage} +\alias{.manifest_stage} +\title{Update a manifest stage} +\usage{ +.manifest_stage( + manifest_state, + name, + status = "success", + parameters = list(), + inputs = character(), + outputs = character(), + tool = list(), + metrics = list(), + message = NULL +) +} +\arguments{ +\item{manifest_state}{Manifest state returned by \code{\link[=.manifest_start]{.manifest_start()}}.} + +\item{name}{Character stage name.} + +\item{status}{Character stage status.} + +\item{parameters}{Optional named list.} + +\item{inputs}{Optional character vector of input paths.} + +\item{outputs}{Optional character vector of output paths.} + +\item{tool}{Optional named list describing the tool.} + +\item{metrics}{Optional named list of metrics.} + +\item{message}{Optional log message.} +} +\value{ +Updated manifest state. +} +\description{ +Update a manifest stage +} +\keyword{internal} diff --git a/man/dot-manifest_start.Rd b/man/dot-manifest_start.Rd new file mode 100644 index 0000000..7b74565 --- /dev/null +++ b/man/dot-manifest_start.Rd @@ -0,0 +1,35 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.manifest_start} +\alias{.manifest_start} +\title{Start or load a dataset provenance manifest} +\usage{ +.manifest_start( + manifest_path, + dataset_id, + duckdb_path, + base_dir = ".", + selection = list(), + hash_files = FALSE +) +} +\arguments{ +\item{manifest_path}{Character. Path to the JSON manifest.} + +\item{dataset_id}{Character scalar.} + +\item{duckdb_path}{Character scalar.} + +\item{base_dir}{Character scalar.} + +\item{selection}{Optional named list describing the dataset selection.} + +\item{hash_files}{Logical. Calculate SHA-256 for manifest-recorded files.} +} +\value{ +A manifest object with \code{path} and \code{run_index}. +} +\description{ +Start or load a dataset provenance manifest +} +\keyword{internal} diff --git a/man/dot-manifest_validate.Rd b/man/dot-manifest_validate.Rd new file mode 100644 index 0000000..472a303 --- /dev/null +++ b/man/dot-manifest_validate.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.manifest_validate} +\alias{.manifest_validate} +\title{Validate an amR dataset manifest} +\usage{ +.manifest_validate(manifest) +} +\arguments{ +\item{manifest}{The previously parsed manifest list.} +} +\value{ +\code{TRUE} invisibly if valid, else throws error. +} +\description{ +Validate an amR dataset manifest +} +\keyword{internal} diff --git a/man/dot-selectAmrDataset.Rd b/man/dot-selectAmrDataset.Rd new file mode 100644 index 0000000..59fec96 --- /dev/null +++ b/man/dot-selectAmrDataset.Rd @@ -0,0 +1,15 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/manifest_helpers.R +\name{.selectAmrDataset} +\alias{.selectAmrDataset} +\title{Choose an amRdata dataset if no explicit path was supplied} +\usage{ +.selectAmrDataset() +} +\value{ +A user-selected dataset choice for modeling. +} +\description{ +Choose an amRdata dataset if no explicit path was supplied +} +\keyword{internal} diff --git a/man/generateMLInputs.Rd b/man/generateMLInputs.Rd index 629c2b2..21995cf 100644 --- a/man/generateMLInputs.Rd +++ b/man/generateMLInputs.Rd @@ -6,7 +6,7 @@ \usage{ generateMLInputs( parquet_dir = "data/", - out_path = "data/", + out_path = NULL, n_fold = 5, split = c(1, 0), min_n = 25, @@ -25,7 +25,7 @@ generateMLInputs( \item Shorthand for CV: \code{split = 0} (converted internally to \code{c(1, 0)}) \item Vector form: \code{c(train_prop, val_prop)} where test_prop = 1 - train - val \itemize{ -\item For CV: \code{c(1, 0)} means 80\% training data with k-fold CV, 20\% stratified testing +\item For CV: \code{c(1, 0)} uses the full dataset for k-fold cross-validation \item For classical splits: all three partitions must be > 0 Example: \code{c(0.7, 0.15)} = 70\% train, 15\% val, 15\% test } diff --git a/man/runMDRmodels.Rd b/man/runMDRmodels.Rd index eeb5979..732ed99 100644 --- a/man/runMDRmodels.Rd +++ b/man/runMDRmodels.Rd @@ -24,7 +24,7 @@ runMDRmodels( \arguments{ \item{path}{Character scalar. Base directory containing MDR matrix files.} -\item{threads}{Integer. Number of parallel workers for model training. Default is 16.} +\item{threads}{Integer. Number of workers for parallel model training. Default is 8.} \item{split}{Numeric vector of length 2. Train/validation split proportions.} @@ -55,7 +55,7 @@ NULL (invisible). Called for side effects (model training and result saving). } \description{ Executes machine learning pipeline for MDR analysis using logistic regression -with parallel processing via the future backend. Trains models on all MDR +with parallel processing via the BiocParallel backend. Trains models on all MDR parquet files and saves results to designated output directories. } \examples{ diff --git a/man/runModelingPipeline.Rd b/man/runModelingPipeline.Rd index 0c70532..0822944 100644 --- a/man/runModelingPipeline.Rd +++ b/man/runModelingPipeline.Rd @@ -2,10 +2,10 @@ % Please edit documentation in R/run_ML.R \name{runModelingPipeline} \alias{runModelingPipeline} -\title{Run the entire AMR ML pipeline from a parquet-backed DuckDB} +\title{Run the complete amR machine-learning workflow} \usage{ runModelingPipeline( - parquet_dir, + parquet_dir = NULL, threads = 8, n_fold = 5, split = c(1, 0), @@ -17,8 +17,11 @@ runModelingPipeline( ) } \arguments{ -\item{parquet_dir}{Path to a species-named directory (e.g. \verb{Shigella_flexneri/}) of -metadata and feature parquets, as produced by data_processing.R} +\item{parquet_dir}{Character or \code{NULL}. Path to a completed amRdata dataset +directory containing metadata and feature Parquet files. If \code{NULL}, amRml +discovers completed amRdata datasets registered with BiocFileCache. A +single available dataset is selected automatically; if multiple datasets +are available in an interactive session, the user is prompted to choose.} \item{threads}{Number of parallel workers. Default: 16} @@ -40,8 +43,12 @@ metadata and feature parquets, as produced by data_processing.R} Invisibly returns the output directory used for ML results. } \description{ -This function provides a complete end-to-end AMR machine learning workflow. -Given a DuckDB file produced by \code{runDataProcessing()}, it: +Runs modeling on a completed amRdata dataset. When \code{parquet_dir} is +\code{NULL}, completed datasets that have been registered through BiocFileCache +are discovered automatically and, if necessary (i.e., >1 option), then the +user is prompted to select one. +} +\details{ \enumerate{ \item Generates all ML feature matrices (drug, class, year, country, MDR, LOO) \item Creates all ML directory structures diff --git a/man/runModelingPipelineIntense.Rd b/man/runModelingPipelineIntense.Rd new file mode 100644 index 0000000..44ae5da --- /dev/null +++ b/man/runModelingPipelineIntense.Rd @@ -0,0 +1,55 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/run_ML.R +\name{runModelingPipelineIntense} +\alias{runModelingPipelineIntense} +\title{Run the entire AMR ML pipeline from a parquets} +\usage{ +runModelingPipelineIntense( + parquet_dir, + threads = 8, + n_seeds = 3, + n_fold = 5, + split = c(1, 0), + min_n = 25, + prop_vi_top_feats = c(0, 1), + pca_threshold = 0.99, + verbose = TRUE, + use_saved_split = TRUE +) +} +\arguments{ +\item{parquet_dir}{Path to a species-named directory (e.g. \verb{Shigella_flexneri/}) of +metadata and feature parquets, as produced by data_processing.R} + +\item{threads}{Number of parallel workers. Default: 16} + +\item{n_seeds}{Number of random seeds to run for each model (default: 3)} + +\item{n_fold}{Cross-validation folds (default: 5). Use 0 or NULL for classical splits.} + +\item{split}{Training/validation split (default: c(1,0) for CV mode)} + +\item{min_n}{Minimum samples per drug class for MDR matrices (default: 25)} + +\item{prop_vi_top_feats}{Proportion of variable importance for top features (default: c(0,1))} + +\item{pca_threshold}{PCA variance threshold (not used unless \code{use_pca = TRUE})} + +\item{verbose}{Print progress updates? Default: TRUE} + +\item{use_saved_split}{Whether to inherit split/seed/n_fold from ml_parameters.json} +} +\value{ +Invisibly returns the output directory used for ML results. +} +\description{ +This function provides a complete end-to-end AMR machine learning workflow. +Given a DuckDB file produced by \code{runDataProcessing()}, it: +\enumerate{ +\item Generates all ML feature matrices (drug, class, year, country, MDR, LOO) +\item Creates all ML directory structures +\item Prepares ML input lists for every mode +\item Runs logistic regression ML models (standard + stratified + cross-test + MDR) +\item Saves performance metrics, fitted models, predictions, and top feature rankings +} +} diff --git a/tests/testthat/test-run-ml-models.R b/tests/testthat/test-run-ml-models.R index b7851b2..34d1270 100644 --- a/tests/testthat/test-run-ml-models.R +++ b/tests/testthat/test-run-ml-models.R @@ -12,7 +12,7 @@ test_that("runMLmodels() exits cleanly instead of crashing on no files", { result <- runMLmodels( path = tmp, stratify_by = NULL, LOO = FALSE, cross_test = FALSE ), - "No files found" + "No eligible standard ML matrices were found" ) expect_null(result) }) diff --git a/tests/testthat/test-run-modeling-pipeline-intense.R b/tests/testthat/test-run-modeling-pipeline-intense.R index fe196a9..4c3ff98 100644 --- a/tests/testthat/test-run-modeling-pipeline-intense.R +++ b/tests/testthat/test-run-modeling-pipeline-intense.R @@ -29,5 +29,5 @@ test_that("runModelingPipelineIntense() generates matrices before training", { # to work with). expect_length(generate_calls, 1) expect_equal(generate_calls[[1]]$parquet_dir, normalizePath(parquet_dir)) - expect_equal(generate_calls[[1]]$out_path, normalizePath(tmp_root)) + expect_equal(generate_calls[[1]]$out_path, normalizePath(parquet_dir)) })