diff --git a/DESCRIPTION b/DESCRIPTION index 4c84366..e075787 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -86,4 +86,4 @@ biocViews: Visualization URL: https://github.com/JRaviLab/amRml BugReports: https://github.com/JRaviLab/amRml/issues -Config/roxygen2/version: 8.0.0 +Config/roxygen2/version: 8.1.0 diff --git a/NAMESPACE b/NAMESPACE index 793c28d..7ebd117 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -34,6 +34,7 @@ export(generateMLInputs) export(getConfusionMatrix) export(getNumFeat) export(loadMLInputTibble) +export(mergeMLresults) export(parse_ml_filename) export(plotBaselineComparison) export(plotCM) @@ -68,114 +69,150 @@ import(ggrepel) import(purrr) import(stringr) import(tibble) -importFrom(DBI,dbConnect) -importFrom(DBI,dbDisconnect) -importFrom(DBI,dbExecute) -importFrom(DBI,dbGetQuery) -importFrom(DBI,dbWriteTable) -importFrom(arrow,read_parquet) -importFrom(arrow,write_parquet) -importFrom(dplyr,all_of) -importFrom(dplyr,arrange) -importFrom(dplyr,bind_rows) -importFrom(dplyr,case_when) -importFrom(dplyr,count) -importFrom(dplyr,desc) -importFrom(dplyr,distinct) -importFrom(dplyr,filter) -importFrom(dplyr,group_by) -importFrom(dplyr,if_any) -importFrom(dplyr,matches) -importFrom(dplyr,mutate) -importFrom(dplyr,n_distinct) -importFrom(dplyr,pull) -importFrom(dplyr,relocate) -importFrom(dplyr,rename) -importFrom(dplyr,row_number) -importFrom(dplyr,rowwise) -importFrom(dplyr,select) -importFrom(dplyr,slice) -importFrom(dplyr,summarise) -importFrom(dplyr,ungroup) +importFrom(DBI, + dbConnect, + dbDisconnect, + dbExecute, + dbGetQuery, + dbWriteTable +) +importFrom(arrow, + read_parquet, + write_parquet +) +importFrom(dplyr, + all_of, + arrange, + bind_rows, + case_when, + count, + desc, + distinct, + filter, + group_by, + if_any, + matches, + mutate, + n_distinct, + pull, + relocate, + rename, + row_number, + rowwise, + select, + slice, + summarise, + ungroup +) importFrom(duckdb,duckdb) -importFrom(ggplot2,aes) -importFrom(ggplot2,element_blank) -importFrom(ggplot2,element_line) -importFrom(ggplot2,element_text) -importFrom(ggplot2,geom_line) -importFrom(ggplot2,geom_path) -importFrom(ggplot2,geom_point) -importFrom(ggplot2,ggplot) -importFrom(ggplot2,labs) -importFrom(ggplot2,theme) -importFrom(ggplot2,xlab) -importFrom(ggplot2,ylim) +importFrom(ggplot2, + aes, + element_blank, + element_line, + element_text, + geom_line, + geom_path, + geom_point, + ggplot, + labs, + theme, + xlab, + ylim +) importFrom(glmnet,glmnet) importFrom(glue,glue) importFrom(grDevices,colorRampPalette) importFrom(graphics,barplot) importFrom(hardhat,tune) -importFrom(jsonlite,fromJSON) -importFrom(jsonlite,write_json) +importFrom(jsonlite, + fromJSON, + write_json +) importFrom(methods,is) -importFrom(parsnip,augment) -importFrom(parsnip,boost_tree) -importFrom(parsnip,extract_fit_engine) -importFrom(parsnip,fit) -importFrom(parsnip,logistic_reg) -importFrom(parsnip,multinom_reg) -importFrom(parsnip,rand_forest) -importFrom(parsnip,set_engine) -importFrom(parsnip,set_mode) -importFrom(purrr,imap_dfr) -importFrom(purrr,map) -importFrom(purrr,map_int) -importFrom(purrr,pmap_chr) -importFrom(purrr,walk) +importFrom(parsnip, + augment, + boost_tree, + extract_fit_engine, + fit, + logistic_reg, + multinom_reg, + rand_forest, + set_engine, + set_mode +) +importFrom(purrr, + imap_dfr, + map, + map_int, + pmap_chr, + walk +) importFrom(readr,write_lines) -importFrom(recipes,all_predictors) -importFrom(recipes,recipe) -importFrom(recipes,step_normalize) -importFrom(recipes,step_pca) -importFrom(recipes,step_zv) -importFrom(recipes,update_role) -importFrom(rlang,":=") -importFrom(rlang,eval_tidy) -importFrom(rlang,sym) -importFrom(rsample,initial_split) -importFrom(rsample,initial_validation_split) -importFrom(rsample,testing) -importFrom(rsample,training) -importFrom(rsample,validation_set) -importFrom(rsample,vfold_cv) -importFrom(stats,coef) -importFrom(stats,fisher.test) -importFrom(stats,median) -importFrom(stats,reformulate) -importFrom(stats,reorder) -importFrom(stats,sd) -importFrom(stringr,str_remove) -importFrom(stringr,str_split) -importFrom(tibble,add_column) -importFrom(tibble,is_tibble) -importFrom(tibble,tibble) -importFrom(tidyr,drop_na) -importFrom(tidyr,pivot_longer) -importFrom(tidyr,pivot_wider) -importFrom(tune,control_grid) -importFrom(tune,extract_fit_parsnip) -importFrom(tune,finalize_workflow) -importFrom(tune,select_best) -importFrom(tune,tune_grid) -importFrom(workflows,add_model) -importFrom(workflows,add_recipe) -importFrom(workflows,workflow) -importFrom(workflowsets,extract_fit_parsnip) -importFrom(workflowsets,extract_spec_parsnip) -importFrom(yardstick,bal_accuracy) -importFrom(yardstick,conf_mat) -importFrom(yardstick,f_meas) -importFrom(yardstick,mcc) -importFrom(yardstick,metric_set) -importFrom(yardstick,pr_auc) -importFrom(yardstick,pr_curve) +importFrom(recipes, + all_predictors, + recipe, + step_normalize, + step_pca, + step_zv, + update_role +) +importFrom(rlang, + ":=", + eval_tidy, + sym +) +importFrom(rsample, + initial_split, + initial_validation_split, + testing, + training, + validation_set, + vfold_cv +) +importFrom(stats, + coef, + fisher.test, + median, + reformulate, + reorder, + sd +) +importFrom(stringr, + str_remove, + str_split +) +importFrom(tibble, + add_column, + is_tibble, + tibble +) +importFrom(tidyr, + drop_na, + pivot_longer, + pivot_wider +) +importFrom(tune, + control_grid, + extract_fit_parsnip, + finalize_workflow, + select_best, + tune_grid +) +importFrom(workflows, + add_model, + add_recipe, + workflow +) +importFrom(workflowsets, + extract_fit_parsnip, + extract_spec_parsnip +) +importFrom(yardstick, + bal_accuracy, + conf_mat, + f_meas, + mcc, + metric_set, + pr_auc, + pr_curve +) diff --git a/R/merge_ml_results.R b/R/merge_ml_results.R index ce32baf..c2d5bd3 100644 --- a/R/merge_ml_results.R +++ b/R/merge_ml_results.R @@ -1,8 +1,13 @@ #' Parse ML result filename into structured metadata #' #' Extracts metadata fields encoded in machine learning result filenames. -#' Supports shuffled runs, drug vs drug_class, optional stratification, -#' feature types, and seed information. +#' Supports shuffled runs, drug vs drug_class, feature types, and seed +#' information. +#' +#' This helper handles only unstratified result filenames. Year/country +#' stratified filenames carry extra tokens and are aggregated by +#' [buildPerfPqYearCountry()] instead; passing one here raises an error +#' rather than returning silently wrong metadata. #' #' @param filename Character. Filename (not full path) to parse. #' @@ -12,20 +17,19 @@ #' \item{species}{Species name} #' \item{drug_label}{"drug" or "drug_class"} #' \item{drug_or_class}{Drug name or class} -#' \item{strat_label}{Stratification type (year/country) or NA} -#' \item{strat_value}{Stratification value or NA} #' \item{feature_type}{Feature category} #' \item{feature_subtype}{binary/counts} #' \item{seed}{Integer seed} #' } #' #' @examples -#' parse_ml_filename("Csp_drug_AMX_genes_binary_42_top_features.tsv") +#' parse_ml_filename("Csp_drug_AMX_genes_binary_42_top_features.parquet") +#' parse_ml_filename("Csp_drug_class_AMG_genes_binary_42_performance.parquet") #' #' @export parse_ml_filename <- function(filename) { # Strip known suffix - base <- sub("_(top_features|performance)\\.tsv$", "", filename) + base <- sub("_(top_features|performance)\\.parquet$", "", filename) xs <- strsplit(base, "_", fixed = TRUE)[[1]] out <- list( @@ -33,8 +37,6 @@ parse_ml_filename <- function(filename) { species = NA_character_, drug_label = NA_character_, drug_or_class = NA_character_, - strat_label = NA_character_, - strat_value = NA_character_, feature_type = NA_character_, feature_subtype = NA_character_, seed = NA_integer_ @@ -63,44 +65,46 @@ parse_ml_filename <- function(filename) { # Case A: drug_class if (xs[i + 1] == "class") { out$drug_label <- "drug_class" - out$drug_or_class <- xs[i + 2] - i <- i + 3 + i <- i + 2 } # Case B: simple drug else { out$drug_label <- "drug" - out$drug_or_class <- xs[i + 1] - i <- i + 2 + i <- i + 1 } } else { stop("ERROR: expected 'drug' token after species") } # --------------------------- - # 4. Stratified? + # 4. Reject stratified filenames + # + # In stratified filenames the strat label ("year"/"country") sits right + # after "drug"/"drug_class", e.g. "..._drug_year_AMX_2010-2015_...". + # Those carry extra tokens this parser does not account for, so bail out + # with a clear message instead of silently mislabelling drug and seed. # --------------------------- if (i <= length(xs) && xs[i] %in% c("year", "country")) { - out$strat_label <- xs[i] - i <- i + 1 - out$strat_value <- xs[i] - i <- i + 1 + stop( + "parse_ml_filename() does not support the '", xs[i], + "' stratified filename '", filename, + "'; use buildPerfPqYearCountry() for stratified results." + ) } # --------------------------- - # 5. Feature type + subtype + # 5. Drug or class value # --------------------------- - out$feature_type <- xs[i] - i <- i + 1 - out$feature_subtype <- xs[i] + out$drug_or_class <- xs[i] i <- i + 1 # --------------------------- - # 6. Trailing strat label (mirror) + # 6. Feature type + subtype # --------------------------- - if (!is.na(out$strat_label)) { - stopifnot(xs[i] == out$strat_label) - i <- i + 1 - } + out$feature_type <- xs[i] + i <- i + 1 + out$feature_subtype <- xs[i] + i <- i + 1 # --------------------------- # 7. Seed @@ -112,10 +116,10 @@ parse_ml_filename <- function(filename) { #' Build Parquet file of top ML features #' -#' Reads all `_top_features.tsv` files, parses metadata from filenames, +#' Reads all `_top_features.parquet` files, parses metadata from filenames, #' combines them into a single table, and writes a Parquet file. #' -#' @param path Base directory containing ML results +#' @param top_feat_dir_path The directory containing ML top features files #' @param out_parquet Output file name #' @param compression Compression method (default: "zstd") #' @param verbose Logical; print progress @@ -124,12 +128,12 @@ parse_ml_filename <- function(filename) { #' #' @examples #' \dontrun{ -#' buildTopFeatsPq("data/Campylobacter") +#' buildTopFeatsPq("data/Campylobacter/ML_top_features/") #' } #' #' @export buildTopFeatsPq <- function( - path, + top_feat_dir_path, # stratify_by = NULL, # LOO = FALSE, # MDR = FALSE, @@ -138,10 +142,10 @@ buildTopFeatsPq <- function( compression = "zstd", verbose = TRUE ) { - # if (!is.character(path) || length(path) != 1 || is.na(path) || nchar(path) == 0) { - # stop("`path` must be a non-empty character scalar.") - # } - # path <- normalizePath(path) + if (!is.character(top_feat_dir_path) || length(top_feat_dir_path) != 1 || is.na(top_feat_dir_path) || nchar(top_feat_dir_path) == 0) { + stop("`path` must be a non-empty character scalar.") + } + top_feat_dir_path <- normalizePath(top_feat_dir_path) # if (!is.null(stratify_by) && !stratify_by %in% c("year", "country")) { # stop("`stratify_by` must be NULL, 'year', or 'country'.") @@ -156,42 +160,42 @@ buildTopFeatsPq <- function( # ----------------------- # Resolve directories (ensures existence) # ----------------------- - paths <- createMLResultDir(path, - stratify_by = NULL, - LOO = FALSE, - MDR = FALSE, - cross_test = FALSE - ) - top_dir <- paths$ML_top_features + # paths <- createMLResultDir(path, + # stratify_by = NULL, + # LOO = FALSE, + # MDR = FALSE, + # cross_test = FALSE + # ) + # top_dir <- paths$ML_top_features files <- list.files( - top_dir, - pattern = "_top_features\\.tsv$", - full.names = TRUE, - recursive = TRUE + top_feat_dir_path, + pattern = "_top_features\\.parquet$", + full.names = TRUE, + recursive = TRUE +) + +if (length(files) == 0) { + stop( + "No files matching '_top_features.parquet' were found in: ", + normalizePath(top_feat_dir_path, mustWork = FALSE) ) +} if (!length(files)) { return(tibble::tibble()) } out <- purrr::map_dfr(files, function(f) { - meta <- parse_ml_filename(basename(f)) - df <- readr::read_tsv(f, - col_types = readr::cols( - Variable = readr::col_character(), - Importance = readr::col_double(), - Sign = readr::col_character() - ), - show_col_types = FALSE, - na = c("NA", "", "NaN") - ) + meta <- parse_ml_filename(basename(f)) - meta_tbl <- tibble::as_tibble(meta) - dplyr::bind_cols(meta_tbl[rep(1, nrow(df)), ], df) - }) + df <- arrow::read_parquet(f) + + meta_tbl <- tibble::as_tibble(meta) + dplyr::bind_cols(meta_tbl[rep(1, nrow(df)), ], df) +}) - out_path <- file.path(top_dir, basename(out_parquet)) + out_path <- file.path(top_feat_dir_path, basename(out_parquet)) arrow::write_parquet(out, out_path, compression = compression) if (verbose) message("Wrote ", out_path) @@ -200,21 +204,24 @@ buildTopFeatsPq <- function( #' Build Parquet file of ML performance results #' -#' Reads all `_performance.tsv` files, parses metadata from filenames, +#' Reads all `_performance.parquet` files, parses metadata from filenames, #' combines them into a single table, and writes a Parquet output. #' -#' @inheritParams buildTopFeatsPq +#' @param perf_dir_path The directory containing ML performance files +#' @param out_parquet Output file name +#' @param compression Compression method (default: "zstd") +#' @param verbose Logical; print progress #' #' @return A tibble with metadata columns + performance metrics #' #' @examples #' \dontrun{ -#' buildPerfPq("data/Campylobacter") +#' buildPerfPq("data/Campylobacter/ML_performance/") #' } #' #' @export buildPerfPq <- function( - path, + perf_dir_path, # stratify_by = NULL, # LOO = FALSE, # MDR = FALSE, @@ -223,10 +230,10 @@ buildPerfPq <- function( compression = "zstd", verbose = TRUE ) { - if (!is.character(path) || length(path) != 1 || is.na(path) || nchar(path) == 0) { - stop("`path` must be a non-empty character scalar.") + if (!is.character(perf_dir_path) || length(perf_dir_path) != 1 || is.na(perf_dir_path) || nchar(perf_dir_path) == 0) { + stop("`perf_dir_path` must be a non-empty character scalar.") } - path <- normalizePath(path) + perf_dir_path <- normalizePath(perf_dir_path) # if (!is.null(stratify_by) && !stratify_by %in% c("year", "country")) { # stop("`stratify_by` must be NULL, 'year', or 'country'.") @@ -241,26 +248,33 @@ buildPerfPq <- function( # ----------------------- # Resolve directories from your function (ensures they exist) # ----------------------- - paths <- createMLResultDir(path, - stratify_by = NULL, LOO = FALSE, - cross_test = FALSE, MDR = FALSE - ) - perf_dir <- paths$ML_performance + # paths <- createMLResultDir(path, + # stratify_by = NULL, LOO = FALSE, + # cross_test = FALSE, MDR = FALSE + # ) + # perf_dir <- paths$ML_performance files <- list.files( - perf_dir, - pattern = "_performance\\.tsv$", + perf_dir_path, + pattern = "_performance\\.parquet$", full.names = TRUE, recursive = TRUE ) +if (length(files) == 0) { + stop( + "No files matching '_performance.parquet' were found in: ", + normalizePath(perf_dir_path, mustWork = FALSE) + ) +} + if (!length(files)) { return(tibble::tibble()) } out <- purrr::map_dfr(files, function(f) { meta <- parse_ml_filename(basename(f)) - df <- readr::read_tsv(f, show_col_types = FALSE) + df <- arrow::read_parquet(f) # ---- SAFETY FIX ---- # If TSV already has seed, drop parsed seed @@ -273,7 +287,7 @@ buildPerfPq <- function( dplyr::bind_cols(meta_tbl[rep(1, nrow(df)), ], df) }) - out_path <- file.path(perf_dir, basename(out_parquet)) + out_path <- file.path(perf_dir_path, basename(out_parquet)) arrow::write_parquet(out, out_path, compression = compression) if (verbose) message("Wrote ", out_path) @@ -297,12 +311,12 @@ buildPerfPq <- function( buildPerfPqYearCountry <- function( perf_dir_path ) { - files <- list.files(perf_dir_path, pattern = "\\.tsv$", full.names = TRUE) + files <- list.files(perf_dir_path, pattern = "\\.parquet$", full.names = TRUE) # Read and combine merged_df <- files |> purrr::set_names() |> # keeps file names attached - purrr::map_dfr(~ readr::read_tsv(.x, show_col_types = FALSE) |> + purrr::map_dfr(~ arrow::read_parquet(.x) |> dplyr::mutate(filename = basename(.x))) |> tidyr::extract( filename, @@ -314,19 +328,20 @@ buildPerfPqYearCountry <- function( "strat_value", "feature_type", "feature_subtype", - "strat_label2", + # "strat_label2", "seed_from_name" ), - regex = "^([^_]+)_((?:[^_]+(?:_[^_]+)?))_([^_]+)_([^_]+)_([^_]+)_([^_]+)_([^_]+)_([^_]+)_([^_]+)_performance\\.tsv$", + regex = "^([^_]+)_((?:[^_]+(?:_[^_]+)?))_([^_]+)_([^_]+)_([^_]+)_([^_]+)_([^_]+)_([^_]+)_performance\\.parquet$", remove = FALSE ) |> - dplyr::select(-c("strat_label2", "seed_from_name")) + dplyr::select(-c("seed_from_name")) strat_label <- merged_df |> dplyr::distinct(strat_label) |> dplyr::pull() arrow::write_parquet(merged_df, file.path(perf_dir_path, paste0(strat_label, "_perf.parquet"))) + merged_df } #' Build Parquet file from cross drug testing ML performances @@ -344,12 +359,12 @@ buildPerfPqYearCountry <- function( buildPerfPqCrossDrug <- function( perf_dir_path ) { - files <- list.files(perf_dir_path, pattern = "\\.tsv$", full.names = TRUE) + files <- list.files(perf_dir_path, pattern = "\\.parquet$", full.names = TRUE) # Read and combine merged_df <- files |> purrr::set_names() |> # keeps file names attached - purrr::map_dfr(~ readr::read_tsv(.x, show_col_types = FALSE) |> + purrr::map_dfr(~ arrow::read_parquet(.x) |> dplyr::mutate(filename = basename(.x))) |> tidyr::extract( filename, @@ -375,12 +390,12 @@ buildPerfPqCrossDrug <- function( buildPerfPqCrossYear <- function( perf_dir_path ) { - files <- list.files(perf_dir_path, pattern = "\\.tsv$", full.names = TRUE) + files <- list.files(perf_dir_path, pattern = "\\.parquet$", full.names = TRUE) # Read and combine merged_df <- files |> purrr::set_names() |> # keeps file names attached - purrr::map_dfr(~ readr::read_tsv(.x, show_col_types = FALSE) |> + purrr::map_dfr(~ arrow::read_parquet(.x) |> dplyr::mutate(filename = basename(.x))) |> tidyr::extract( filename, @@ -406,12 +421,12 @@ buildPerfPqCrossYear <- function( buildPerfPqCrossCountry <- function( perf_dir_path ) { - files <- list.files(perf_dir_path, pattern = "\\.tsv$", full.names = TRUE) + files <- list.files(perf_dir_path, pattern = "\\.parquet$", full.names = TRUE) # Read and combine merged_df <- files |> purrr::set_names() |> # keeps file names attached - purrr::map_dfr(~ readr::read_tsv(.x, show_col_types = FALSE) |> + purrr::map_dfr(~ arrow::read_parquet(.x) |> dplyr::mutate(filename = basename(.x))) |> tidyr::extract( filename, @@ -438,12 +453,12 @@ buildPerfPqCrossCountry <- function( buildPerfPqLOODrug <- function( perf_dir_path ) { - files <- list.files(perf_dir_path, pattern = "\\.tsv$", full.names = TRUE) + files <- list.files(perf_dir_path, pattern = "\\.parquet$", full.names = TRUE) # Read and combine merged_df <- files |> purrr::set_names() |> # keeps file names attached - purrr::map_dfr(~ readr::read_tsv(.x, show_col_types = FALSE) |> + purrr::map_dfr(~ arrow::read_parquet(.x) |> dplyr::mutate(filename = basename(.x))) |> tidyr::extract( filename, @@ -473,12 +488,12 @@ buildPerfPqLOODrug <- function( buildTopFeatsPqYearCountry <- function( top_feat_dir_path ) { - files <- list.files(top_feat_dir_path, pattern = "\\.tsv$", full.names = TRUE) + files <- list.files(top_feat_dir_path, pattern = "\\.parquet$", full.names = TRUE) # Read and combine merged_df <- files |> purrr::set_names() |> - purrr::map_dfr(~ readr::read_tsv(.x, show_col_types = FALSE) |> + purrr::map_dfr(~ arrow::read_parquet(.x) |> dplyr::mutate( Importance = as.numeric(Importance), filename = basename(.x) @@ -493,13 +508,13 @@ buildTopFeatsPqYearCountry <- function( "strat_value", "feature_type", "feature_subtype", - "strat_label2", + # "strat_label2", "seed_from_name" ), - regex = "^([^_]+)_((?:[^_]+(?:_[^_]+)?))_([^_]+)_([^_]+)_([^_]+)_([^_]+)_([^_]+)_([^_]+)_([^_]+)_top_features\\.tsv$", + regex = "^([^_]+)_((?:[^_]+(?:_[^_]+)?))_([^_]+)_([^_]+)_([^_]+)_([^_]+)_([^_]+)_([^_]+)_top_features\\.parquet$", remove = FALSE - ) |> - dplyr::select(-c("strat_label2")) + ) + strat_label <- merged_df |> dplyr::distinct(strat_label) |> @@ -524,12 +539,12 @@ buildTopFeatsPqYearCountry <- function( buildTopFeatsPqLOODrug <- function( top_feat_dir_path ) { - files <- list.files(top_feat_dir_path, pattern = "\\.tsv$", full.names = TRUE) + files <- list.files(top_feat_dir_path, pattern = "\\.parquet$", full.names = TRUE) # Read and combine merged_df <- files |> purrr::set_names() |> # keeps file names attached - purrr::map_dfr(~ readr::read_tsv(.x, show_col_types = FALSE) |> + purrr::map_dfr(~ arrow::read_parquet(.x) |> dplyr::mutate(filename = basename(.x))) |> tidyr::extract( filename, @@ -556,12 +571,12 @@ buildTopFeatsPqLOODrug <- function( buildTopFeatsPqMDR <- function( top_feat_dir_path) { - files <- list.files(top_feat_dir_path, pattern = "\\.tsv$", full.names = TRUE) + files <- list.files(top_feat_dir_path, pattern = "\\.parquet$", full.names = TRUE) # Read and combine merged_df <- files|> purrr::set_names() |> # keeps file names attached - purrr::map_dfr(~ readr::read_tsv(.x, show_col_types = FALSE) |> + purrr::map_dfr(~ arrow::read_parquet(.x) |> dplyr::mutate(filename = basename(.x))) |> tidyr::extract( filename, @@ -590,12 +605,12 @@ buildTopFeatsPqMDR <- function( buildPerfPqMDR <- function( perf_dir_path) { - files <- list.files(perf_dir_path, pattern = "\\.tsv$", full.names = TRUE) + files <- list.files(perf_dir_path, pattern = "\\.parquet$", full.names = TRUE) # Read and combine merged_df <- files|> purrr::set_names() |> # keeps file names attached - purrr::map_dfr(~ readr::read_tsv(.x, show_col_types = FALSE) |> + purrr::map_dfr(~ arrow::read_parquet(.x) |> dplyr::mutate(filename = basename(.x))) |> tidyr::extract( filename, @@ -624,12 +639,12 @@ buildPerfPqMDR <- function( buildPredPqMDR <- function( pred_dir_path) { - files <- list.files(pred_dir_path, pattern = "\\.tsv$", full.names = TRUE) + files <- list.files(pred_dir_path, pattern = "\\.parquet$", full.names = TRUE) # Read and combine merged_df <- files|> purrr::set_names() |> # keeps file names attached - purrr::map_dfr(~ readr::read_tsv(.x, show_col_types = FALSE) |> + purrr::map_dfr(~ arrow::read_parquet(.x) |> dplyr::select(1:resistant_classes)|> dplyr::mutate(filename = basename(.x)) ) |> tidyr::extract( @@ -657,12 +672,12 @@ buildPredPqMDR <- function( buildPerfPqLOOCountry <- function( perf_dir_path ) { - files <- list.files(perf_dir_path, pattern = "\\.tsv$", full.names = TRUE) + files <- list.files(perf_dir_path, pattern = "\\.parquet$", full.names = TRUE) # Read and combine merged_df <- files |> purrr::set_names() |> # keeps file names attached - purrr::map_dfr(~ readr::read_tsv(.x, show_col_types = FALSE) |> + purrr::map_dfr(~ arrow::read_parquet(.x) |> dplyr::mutate(filename = basename(.x))) |> tidyr::extract( filename, @@ -688,12 +703,12 @@ buildPerfPqLOOCountry <- function( buildPerfPqLOOYear <- function( perf_dir_path ) { - files <- list.files(perf_dir_path, pattern = "\\.tsv$", full.names = TRUE) + files <- list.files(perf_dir_path, pattern = "\\.parquet$", full.names = TRUE) # Read and combine merged_df <- files |> purrr::set_names() |> # keeps file names attached - purrr::map_dfr(~ readr::read_tsv(.x, show_col_types = FALSE) |> + purrr::map_dfr(~ arrow::read_parquet(.x) |> dplyr::mutate(filename = basename(.x))) |> tidyr::extract( filename, @@ -721,12 +736,12 @@ buildPerfPqLOOYear <- function( buildTopFeatsPqLOOCountry <- function( top_feat_dir_path ) { - files <- list.files(top_feat_dir_path, pattern = "\\.tsv$", full.names = TRUE) + files <- list.files(top_feat_dir_path, pattern = "\\.parquet$", full.names = TRUE) # Read and combine merged_df <- files |> purrr::set_names() |> # keeps file names attached - purrr::map_dfr(~ readr::read_tsv(.x, show_col_types = FALSE) |> + purrr::map_dfr(~ arrow::read_parquet(.x) |> dplyr::mutate(filename = basename(.x))) |> tidyr::extract( filename, @@ -754,12 +769,12 @@ buildTopFeatsPqLOOCountry <- function( buildTopFeatsPqLOOYear <- function( top_feat_dir_path ) { - files <- list.files(top_feat_dir_path, pattern = "\\.tsv$", full.names = TRUE) + files <- list.files(top_feat_dir_path, pattern = "\\.parquet$", full.names = TRUE) # Read and combine merged_df <- files |> purrr::set_names() |> # keeps file names attached - purrr::map_dfr(~ readr::read_tsv(.x, show_col_types = FALSE) |> + purrr::map_dfr(~ arrow::read_parquet(.x) |> dplyr::mutate(filename = basename(.x))) |> tidyr::extract( filename, @@ -769,3 +784,152 @@ buildTopFeatsPqLOOYear <- function( arrow::write_parquet(merged_df, file.path(top_feat_dir_path, "LOO_year_top_features.parquet")) } + + +#' Merge all ML result files into consolidated parquet files +#' +#' Automatically detects supported ML result directories and +#' builds merged parquet summaries for each. +#' +#' @param path Base analysis directory. +#' +#' @return Invisibly returns NULL. +#' +#' @examples +#' \dontrun{ +#' mergeMLresults("data/Campylobacter") +#' } +#' +#' @export +mergeMLresults <- function(path) { + + path <- normalizePath(path) + + # ----------------------- + # Standard ML + # ----------------------- + if (dir.exists(file.path(path, "ML_performance"))) { + buildPerfPq( + perf_dir_path = file.path(path, "ML_performance") + ) + } + + if (dir.exists(file.path(path, "ML_top_features"))) { + buildTopFeatsPq( + top_feat_dir_path = file.path(path, "ML_top_features") + ) + } + + # ----------------------- + # Year stratified + # ----------------------- + if (dir.exists(file.path(path, "ML_year_performance"))) { + buildPerfPqYearCountry( + perf_dir_path = file.path(path, "ML_year_performance") + ) + } + + if (dir.exists(file.path(path, "ML_year_top_features"))) { + buildTopFeatsPqYearCountry( + top_feat_dir_path = file.path(path, "ML_year_top_features") + ) + } + + # ----------------------- + # Country stratified + # ----------------------- + if (dir.exists(file.path(path, "ML_country_performance"))) { + buildPerfPqYearCountry( + perf_dir_path = file.path(path, "ML_country_performance") + ) + } + + if (dir.exists(file.path(path, "ML_country_top_features"))) { + buildTopFeatsPqYearCountry( + top_feat_dir_path = file.path(path, "ML_country_top_features") + ) + } + + # ----------------------- + # Cross-testing + # ----------------------- + if (dir.exists(file.path(path, "cross_test_ML_performance"))) { + buildPerfPqCrossDrug( + perf_dir_path = file.path(path, "cross_test_ML_performance") + ) + } + + if (dir.exists(file.path(path, "cross_test_ML_year_performance"))) { + buildPerfPqCrossYear( + perf_dir_path = file.path(path, "cross_test_ML_year_performance") + ) + } + + if (dir.exists(file.path(path, "cross_test_ML_country_performance"))) { + buildPerfPqCrossCountry( + perf_dir_path = file.path(path, "cross_test_ML_country_performance") + ) + } + + # ----------------------- + # LOO + # ----------------------- + if (dir.exists(file.path(path, "LOO_ML_performance"))) { + buildPerfPqLOODrug( + perf_dir_path = file.path(path, "LOO_ML_performance") + ) + } + + if (dir.exists(file.path(path, "LOO_ML_top_features"))) { + buildTopFeatsPqLOODrug( + top_feat_dir_path = file.path(path, "LOO_ML_top_features") + ) + } + + if (dir.exists(file.path(path, "LOO_ML_country_performance"))) { + buildPerfPqLOOCountry( + perf_dir_path = file.path(path, "LOO_ML_country_performance") + ) + } + + if (dir.exists(file.path(path, "LOO_ML_country_top_features"))) { + buildTopFeatsPqLOOCountry( + top_feat_dir_path = file.path(path, "LOO_ML_country_top_features") + ) + } + + if (dir.exists(file.path(path, "LOO_ML_year_performance"))) { + buildPerfPqLOOYear( + perf_dir_path = file.path(path, "LOO_ML_year_performance") + ) + } + + if (dir.exists(file.path(path, "LOO_ML_year_top_features"))) { + buildTopFeatsPqLOOYear( + top_feat_dir_path = file.path(path, "LOO_ML_year_top_features") + ) + } + + # ----------------------- + # MDR + # ----------------------- + if (dir.exists(file.path(path, "MDR_ML_performance"))) { + buildPerfPqMDR( + perf_dir_path = file.path(path, "MDR_ML_performance") + ) + } + + if (dir.exists(file.path(path, "MDR_ML_top_features"))) { + buildTopFeatsPqMDR( + top_feat_dir_path = file.path(path, "MDR_ML_top_features") + ) + } + + if (dir.exists(file.path(path, "MDR_ML_pred"))) { + buildPredPqMDR( + pred_dir_path = file.path(path, "MDR_ML_pred") + ) + } + + invisible(NULL) +} \ No newline at end of file diff --git a/R/run_ML.R b/R/run_ML.R index f143a41..a52ae33 100644 --- a/R/run_ML.R +++ b/R/run_ML.R @@ -806,24 +806,28 @@ runMDRmodels <- function(path, base <- paste0(shuffle_tag, output_prefix, pca_tag, seed_tag) if (!is.null(res$performance_tibble)) { - readr::write_tsv( + arrow::write_parquet( res$performance_tibble, - file.path(files$out_perf[i], paste0(base, "_performance.tsv")) + file.path(files$out_perf[i], paste0(base, "_performance.parquet")), + compression = "zstd" ) } if (!is.null(res$top_feat_tibble)) { - readr::write_tsv( + arrow::write_parquet( res$top_feat_tibble, - file.path(files$out_top[i], paste0(base, "_top_features.tsv")) + file.path(files$out_top[i], paste0(base, "_top_features.parquet")), + compression = "zstd" ) } + if (!is.null(res$fit)) { saveRDS(res$fit, file.path(files$out_models[i], paste0(base, "_model_fit.rds"))) } if (!is.null(res$pred)) { - readr::write_tsv( + arrow::write_parquet( res$pred, - file.path(files$out_pred[i], paste0(base, "_prediction.tsv")) + file.path(files$out_pred[i], paste0(base, "_prediction.parquet")), + compression = "zstd" ) } @@ -916,7 +920,7 @@ runMDRmodels <- function(path, #' \item Stratification: Suffixed with \code{"_country"} or \code{"_year"} #' } #' -#' For example: \code{"LOO_cross_test_ML_year_performance.tsv"} +#' For example: \code{"LOO_cross_test_ML_year_performance.parquet"} #' #' @note #' This function requires the following packages: @@ -1005,7 +1009,8 @@ runMLmodels <- function(path, .findNonRanPrefixes <- function(files, seed, - shuffle_labels = FALSE) { + shuffle_labels = FALSE) + { # ---- matrix prefixes ---- matrix_prefixes <- unique( @@ -1015,7 +1020,7 @@ runMLmodels <- function(path, # ---- performance files ---- perf_files <- list.files( path = unique(files$out_perf), - pattern = "_performance\\.tsv$", + pattern = "_performance\\.parquet$", full.names = FALSE ) @@ -1042,7 +1047,7 @@ runMLmodels <- function(path, perf_base <- sub("^cross_test_", "", perf_base) # ---- keep only this seed ---- - seed_pattern <- paste0("_", seed, "_performance\\.tsv$") + seed_pattern <- paste0("_", seed, "_performance\\.parquet$") perf_base <- perf_base[grepl(seed_pattern, perf_base)] if (length(perf_base) == 0) { @@ -1050,8 +1055,8 @@ runMLmodels <- function(path, } # ---- strip stratification BEFORE seed ---- - perf_base <- sub("_(country|year)_([0-9]+)_performance\\.tsv$", - "_\\2_performance.tsv", + perf_base <- sub("_([0-9]+)_performance\\.parquet$", + "_\\2_performance.parquet", perf_base) # ---- final prefixes that ran ---- @@ -1111,15 +1116,15 @@ if (nrow(files) == 0) { ) # Stratification suffix - strat_suffix <- if (is.null(stratify_by) || identical(stratify_by, "")) { - "" - } else { - switch(stratify_by, - "country" = "_country", - "year" = "_year", - stop("`stratify_by` must be NULL, 'year', or 'country'.") - ) - } + # strat_suffix <- if (is.null(stratify_by) || identical(stratify_by, "")) { + # "" + # } else { + # switch(stratify_by, + # "country" = "_country", + # "year" = "_year", + # stop("`stratify_by` must be NULL, 'year', or 'country'.") + # ) + # } # Auto naming for shuffled and PCA shuffle_tag <- if (isTRUE(shuffle_labels)) "shuffled_" else "" @@ -1199,28 +1204,33 @@ if (nrow(files) == 0) { } seed_tag <- paste0("_", seed) - # Final base filename: shuffled_ + [LOO_/cross_test_] + + _pcaXX + _year/_country - base <- paste0(shuffle_tag, config_prefix, output_prefix, pca_tag, strat_suffix, seed_tag) + # Final base filename: shuffled_ + [LOO_/cross_test_] + + _pcaXX + seed + base <- paste0(shuffle_tag, config_prefix, output_prefix, pca_tag, seed_tag) if (!is.null(res$performance_tibble)) { - readr::write_tsv( + arrow::write_parquet( res$performance_tibble, - file.path(files$out_perf[i], paste0(base, "_performance.tsv")) + file.path(files$out_perf[i], paste0(base, "_performance.parquet")), + compression = "zstd" ) } + if (!is.null(res$top_feat_tibble)) { - readr::write_tsv( + arrow::write_parquet( res$top_feat_tibble, - file.path(files$out_top[i], paste0(base, "_top_features.tsv")) + file.path(files$out_top[i], paste0(base, "_top_features.parquet")), + compression = "zstd" ) } + if (!is.null(res$fit)) { saveRDS(res$fit, file.path(files$out_models[i], paste0(base, "_model_fit.rds"))) } if (!is.null(res$pred)) { - readr::write_tsv( + arrow::write_parquet( res$pred, - file.path(files$out_pred[i], paste0(base, "_prediction.tsv")) + file.path(files$out_pred[i], paste0(base, "_prediction.parquet")), + compression = "zstd" ) } diff --git a/man/buildPerfPq.Rd b/man/buildPerfPq.Rd index ed79c36..18cf670 100644 --- a/man/buildPerfPq.Rd +++ b/man/buildPerfPq.Rd @@ -5,14 +5,14 @@ \title{Build Parquet file of ML performance results} \usage{ buildPerfPq( - path, + perf_dir_path, out_parquet = "all_perf.parquet", compression = "zstd", verbose = TRUE ) } \arguments{ -\item{path}{Base directory containing ML results} +\item{perf_dir_path}{The directory containing ML performance files} \item{out_parquet}{Output file name} @@ -24,12 +24,12 @@ buildPerfPq( A tibble with metadata columns + performance metrics } \description{ -Reads all \verb{_performance.tsv} files, parses metadata from filenames, +Reads all \verb{_performance.parquet} files, parses metadata from filenames, combines them into a single table, and writes a Parquet output. } \examples{ \dontrun{ -buildPerfPq("data/Campylobacter") +buildPerfPq("data/Campylobacter/ML_performance/") } } diff --git a/man/buildTopFeatsPq.Rd b/man/buildTopFeatsPq.Rd index fa3d21b..564bc88 100644 --- a/man/buildTopFeatsPq.Rd +++ b/man/buildTopFeatsPq.Rd @@ -5,14 +5,14 @@ \title{Build Parquet file of top ML features} \usage{ buildTopFeatsPq( - path, + top_feat_dir_path, out_parquet = "all_top_features.parquet", compression = "zstd", verbose = TRUE ) } \arguments{ -\item{path}{Base directory containing ML results} +\item{top_feat_dir_path}{The directory containing ML top features files} \item{out_parquet}{Output file name} @@ -24,12 +24,12 @@ buildTopFeatsPq( A tibble with metadata columns + feature importance data } \description{ -Reads all \verb{_top_features.tsv} files, parses metadata from filenames, +Reads all \verb{_top_features.parquet} files, parses metadata from filenames, combines them into a single table, and writes a Parquet file. } \examples{ \dontrun{ -buildTopFeatsPq("data/Campylobacter") +buildTopFeatsPq("data/Campylobacter/ML_top_features/") } } diff --git a/man/mergeMLresults.Rd b/man/mergeMLresults.Rd new file mode 100644 index 0000000..010e169 --- /dev/null +++ b/man/mergeMLresults.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/merge_ml_results.R +\name{mergeMLresults} +\alias{mergeMLresults} +\title{Merge all ML result files into consolidated parquet files} +\usage{ +mergeMLresults(path) +} +\arguments{ +\item{path}{Base analysis directory.} +} +\value{ +Invisibly returns NULL. +} +\description{ +Automatically detects supported ML result directories and +builds merged parquet summaries for each. +} +\examples{ +\dontrun{ +mergeMLresults("data/Campylobacter") +} + +} diff --git a/man/parse_ml_filename.Rd b/man/parse_ml_filename.Rd index 8bd6ec2..80cde3a 100644 --- a/man/parse_ml_filename.Rd +++ b/man/parse_ml_filename.Rd @@ -16,8 +16,6 @@ A named list with elements: \item{species}{Species name} \item{drug_label}{"drug" or "drug_class"} \item{drug_or_class}{Drug name or class} -\item{strat_label}{Stratification type (year/country) or NA} -\item{strat_value}{Stratification value or NA} \item{feature_type}{Feature category} \item{feature_subtype}{binary/counts} \item{seed}{Integer seed} @@ -25,10 +23,17 @@ A named list with elements: } \description{ Extracts metadata fields encoded in machine learning result filenames. -Supports shuffled runs, drug vs drug_class, optional stratification, -feature types, and seed information. +Supports shuffled runs, drug vs drug_class, feature types, and seed +information. +} +\details{ +This helper handles only unstratified result filenames. Year/country +stratified filenames carry extra tokens and are aggregated by +\code{\link[=buildPerfPqYearCountry]{buildPerfPqYearCountry()}} instead; passing one here raises an error +rather than returning silently wrong metadata. } \examples{ -parse_ml_filename("Csp_drug_AMX_genes_binary_42_top_features.tsv") +parse_ml_filename("Csp_drug_AMX_genes_binary_42_top_features.parquet") +parse_ml_filename("Csp_drug_class_AMG_genes_binary_42_performance.parquet") } diff --git a/man/runMLPipeline.Rd b/man/runMLPipeline.Rd index 3d184b3..98a3e9d 100644 --- a/man/runMLPipeline.Rd +++ b/man/runMLPipeline.Rd @@ -14,7 +14,7 @@ runMLPipeline( use_pca = FALSE, pca_threshold = 0.95, penalty_vec = 10^seq(-4, -1, length.out = 10), - mix_vec = 0:5/5, + mix_vec = 0, min_n_vec = c(2, 6, 12), tree_vec = c(100, 500, 1000), select_best_metric = "mcc", diff --git a/man/runMLmodels.Rd b/man/runMLmodels.Rd index 11001da..98c63ed 100644 --- a/man/runMLmodels.Rd +++ b/man/runMLmodels.Rd @@ -119,7 +119,7 @@ Files are saved with prefixes and suffixes indicating the configuration: \item Stratification: Suffixed with \code{"_country"} or \code{"_year"} } -For example: \code{"LOO_cross_test_ML_year_performance.tsv"} +For example: \code{"LOO_cross_test_ML_year_performance.parquet"} } \note{ This function requires the following packages: diff --git a/tests/testthat/test-merge-ml-results.R b/tests/testthat/test-merge-ml-results.R new file mode 100644 index 0000000..f367698 --- /dev/null +++ b/tests/testthat/test-merge-ml-results.R @@ -0,0 +1,130 @@ +# Unit tests for parse_ml_filename() in merge_ml_results.R. +# +# parse_ml_filename() handles only unstratified result filenames. Year/country +# stratified filenames are aggregated by buildPerfPqYearCountry() instead. + +test_that("parse_ml_filename parses an unstratified drug filename", { + out <- parse_ml_filename("Csp_drug_AMX_genes_binary_42_top_features.parquet") + + expect_false(out$shuffled) + expect_equal(out$species, "Csp") + expect_equal(out$drug_label, "drug") + expect_equal(out$drug_or_class, "AMX") + expect_equal(out$feature_type, "genes") + expect_equal(out$feature_subtype, "binary") + expect_equal(out$seed, 42L) +}) + +test_that("parse_ml_filename parses an unstratified drug_class filename", { + out <- parse_ml_filename( + "Csp_drug_class_AMINOGLYCOSIDES_genes_binary_42_performance.parquet" + ) + + expect_equal(out$drug_label, "drug_class") + expect_equal(out$drug_or_class, "AMINOGLYCOSIDES") + expect_equal(out$seed, 42L) +}) + +test_that("parse_ml_filename detects a shuffled run", { + out <- parse_ml_filename( + "shuffled_Csp_drug_AMX_genes_binary_42_top_features.parquet" + ) + + expect_true(out$shuffled) + expect_equal(out$drug_or_class, "AMX") +}) + +test_that("parse_ml_filename errors on a non-'drug' token after species", { + expect_error( + parse_ml_filename("Csp_widget_AMX_genes_binary_42_performance.parquet"), + "expected 'drug' token" + ) +}) + +test_that("parse_ml_filename rejects a year-stratified filename", { + # The strat label sits between "drug" and the drug value in stratified + # filenames, e.g. "_drug_year___...". These are + # handled by buildPerfPqYearCountry(), not this parser. + expect_error( + parse_ml_filename( + "Csp_drug_year_AMX_2010-2015_genes_binary_year_42_performance.parquet" + ), + "does not support the 'year' stratified filename" + ) +}) + +test_that("parse_ml_filename rejects a country-stratified filename", { + expect_error( + parse_ml_filename( + "Csp_drug_class_country_AMINOGLYCOSIDES_USA_genes_binary_country_7_top_features.parquet" + ), + "does not support the 'country' stratified filename" + ) +}) + +# --------------------------------------------------------------------------- +# buildPerfPqYearCountry() / buildTopFeatsPqYearCountry() filename parsing. +# Stratified filenames carry the strat label once, in the prefix: +# ________... +# --------------------------------------------------------------------------- + +test_that("buildPerfPqYearCountry parses year-stratified drug filenames", { + dir <- withr::local_tempdir() + arrow::write_parquet( + tibble::tibble(metric = "roc_auc", estimate = 0.9), + file.path(dir, "Csp_drug_year_AMX_2010-2015_genes_binary_42_performance.parquet") + ) + arrow::write_parquet( + tibble::tibble(metric = "roc_auc", estimate = 0.8), + file.path(dir, "Csp_drug_year_CIP_2016-2020_genes_binary_42_performance.parquet") + ) + + out <- buildPerfPqYearCountry(dir) + + expect_setequal(out$drug_or_class, c("AMX", "CIP")) + expect_setequal(out$strat_value, c("2010-2015", "2016-2020")) + expect_true(all(out$species == "Csp")) + expect_true(all(out$drug_label == "drug")) + expect_true(all(out$strat_label == "year")) + expect_true(all(out$feature_type == "genes")) + expect_true(all(out$feature_subtype == "binary")) + expect_false("seed_from_name" %in% names(out)) + expect_true(file.exists(file.path(dir, "year_perf.parquet"))) +}) + +test_that("buildPerfPqYearCountry parses country-stratified drug_class filenames", { + dir <- withr::local_tempdir() + arrow::write_parquet( + tibble::tibble(metric = "roc_auc", estimate = 0.7), + file.path( + dir, + "Csp_drug_class_country_AMINOGLYCOSIDES_USA_genes_binary_7_performance.parquet" + ) + ) + + out <- buildPerfPqYearCountry(dir) + + expect_equal(out$drug_label, "drug_class") + expect_equal(out$strat_label, "country") + expect_equal(out$drug_or_class, "AMINOGLYCOSIDES") + expect_equal(out$strat_value, "USA") + expect_true(file.exists(file.path(dir, "country_perf.parquet"))) +}) + +test_that("buildTopFeatsPqYearCountry parses stratified filenames", { + dir <- withr::local_tempdir() + arrow::write_parquet( + tibble::tibble(Feature = "gyrA", Importance = "0.42"), + file.path(dir, "Csp_drug_year_AMX_2010-2015_genes_binary_42_top_features.parquet") + ) + + buildTopFeatsPqYearCountry(dir) + + res <- arrow::read_parquet(file.path(dir, "year_top_features.parquet")) + expect_equal(res$species, "Csp") + expect_equal(res$drug_label, "drug") + expect_equal(res$strat_label, "year") + expect_equal(res$drug_or_class, "AMX") + expect_equal(res$strat_value, "2010-2015") + expect_type(res$Importance, "double") +})