Batch-effects-leaderboard / scripts /prepare_geo_batch_datasets.R
spell0's picture
Deploy latest leaderboard app
abb519c
Raw History Blame Contribute Delete
15.4 kB
#!/usr/bin/env Rscript
# Reconstruct the GEO batch-effect benchmark cohorts used in the BE_leaderboard
# GEO audit. The script downloads the paper supplementary tables, extracts the
# exact GSM accessions listed by the authors, downloads the contributing GEO
# Series matrices, and exports BERNN-ready CSVs with the contract:
# label,batch,sample_id,<expression features>
#
# The resulting datasets are deliberately uncorrected. They are meant to be fed
# into BERNN or into the batch-effect audit script before any correction step.
options(stringsAsFactors = FALSE)
options(timeout = max(600, getOption("timeout")))
install_if_missing <- function(pkg, bioc = FALSE) {
if (requireNamespace(pkg, quietly = TRUE)) {
return(invisible(TRUE))
}
if (bioc) {
if (!requireNamespace("BiocManager", quietly = TRUE)) {
install.packages("BiocManager", repos = "https://cloud.r-project.org")
}
BiocManager::install(pkg, ask = FALSE, update = FALSE)
} else {
install.packages(pkg, repos = "https://cloud.r-project.org")
}
invisible(TRUE)
}
for (pkg in c("jsonlite", "readxl", "data.table")) install_if_missing(pkg)
for (pkg in c("GEOquery", "Biobase")) install_if_missing(pkg, bioc = TRUE)
suppressPackageStartupMessages({
library(jsonlite)
library(readxl)
library(data.table)
library(GEOquery)
library(Biobase)
})
get_script_dir <- function() {
cmd_args <- commandArgs(trailingOnly = FALSE)
file_arg <- grep("^--file=", cmd_args)
if (length(file_arg) > 0) {
script_path <- sub("^--file=", "", cmd_args[file_arg[1]])
return(dirname(normalizePath(script_path, winslash = "/", mustWork = FALSE)))
}
getwd()
}
script_dir <- get_script_dir()
ROOT <- normalizePath(file.path(script_dir, ".."), winslash = "/", mustWork = FALSE)
if (!nzchar(ROOT) || is.na(ROOT)) {
ROOT <- getwd()
}
if (!file.exists(file.path(ROOT, "README.md")) && file.exists(file.path(ROOT, "..", "README.md"))) {
ROOT <- normalizePath(file.path(ROOT, ".."), winslash = "/", mustWork = TRUE)
}
FIGSHARE_ARTICLE_ID <- 12105336
FIGSHARE_API <- sprintf("https://api.figshare.com/v2/articles/%s", FIGSHARE_ARTICLE_ID)
DEFAULT_OUTDIR <- file.path(ROOT, "data", "geo_batch")
DEFAULT_CACHE <- file.path(DEFAULT_OUTDIR, "cache")
expected_specs <- list(
normal = list(
dataset_id = "normal_tissue_878",
table_number = 9L,
allowed_labels = c("blood", "colon", "lung"),
expected_total = 878L,
expected_counts = c(blood = 532L, colon = 228L, lung = 118L),
expected_gse = 41L
),
colon = list(
dataset_id = "colon_3041",
table_number = 10L,
allowed_labels = c("normal", "cancer"),
expected_total = 3041L,
expected_counts = c(normal = 476L, cancer = 2565L),
expected_gse = 60L
)
)
parse_args <- function() {
args <- commandArgs(trailingOnly = TRUE)
if (length(args) == 0) {
return(list(dataset = "both", outdir = DEFAULT_OUTDIR))
}
dataset <- "both"
outdir <- DEFAULT_OUTDIR
for (arg in args) {
if (grepl("^--dataset=", arg)) {
dataset <- sub("^--dataset=", "", arg)
} else if (grepl("^--outdir=", arg)) {
outdir <- sub("^--outdir=", "", arg)
} else if (identical(arg, "--help") || identical(arg, "-h")) {
cat("Usage: Rscript scripts/prepare_geo_batch_datasets.R --dataset=both --outdir=data/geo_batch\n")
quit(status = 0)
} else {
stop(sprintf("Unknown argument: %s", arg))
}
}
if (!dataset %in% c("normal", "colon", "both")) {
stop("--dataset must be one of: normal, colon, both")
}
list(dataset = dataset, outdir = normalizePath(outdir, winslash = "/", mustWork = FALSE))
}
ensure_dir <- function(path) {
dir.create(path, recursive = TRUE, showWarnings = FALSE)
invisible(path)
}
read_figshare_article <- function() {
tmp <- tempfile(fileext = ".json")
download.file(FIGSHARE_API, tmp, quiet = TRUE, mode = "wb")
jsonlite::fromJSON(tmp, simplifyDataFrame = TRUE)
}
select_figshare_file <- function(files, table_number) {
if (nrow(files) == 0) {
stop("No files were returned by the figshare API")
}
pattern <- sprintf("s%03d", table_number)
hits <- files[grepl(pattern, files$name, ignore.case = TRUE), , drop = FALSE]
if (nrow(hits) == 0) {
pattern2 <- sprintf("s0?%d", table_number)
hits <- files[grepl(pattern2, files$name, ignore.case = TRUE), , drop = FALSE]
}
if (nrow(hits) == 0) {
stop(sprintf("Could not locate supplementary table %d in figshare files", table_number))
}
hits[1, , drop = FALSE]
}
cache_download <- function(url, target) {
if (!file.exists(target)) {
message(sprintf("Downloading %s", basename(target)))
download.file(url, target, quiet = TRUE, mode = "wb")
}
target
}
extract_rows_from_workbook <- function(path, allowed_labels) {
sheets <- readxl::excel_sheets(path)
rows <- list()
current_batch <- NA_character_
current_label <- NA_character_
find_exact_label <- function(cells) {
cells <- trimws(cells)
cells <- cells[nzchar(cells)]
if (!length(cells)) return(NA_character_)
lower <- tolower(cells)
for (label in allowed_labels) {
if (any(lower == label)) {
return(label)
}
}
for (label in allowed_labels) {
if (any(grepl(sprintf("\\b%s\\b", label), lower, perl = TRUE))) {
return(label)
}
}
NA_character_
}
for (sheet in sheets) {
message(sprintf("Reading %s [%s]", basename(path), sheet))
raw <- suppressMessages(readxl::read_excel(path, sheet = sheet, col_names = FALSE, .name_repair = "minimal"))
if (nrow(raw) == 0) next
for (i in seq_len(nrow(raw))) {
cells <- as.character(unlist(raw[i, ], use.names = FALSE))
cells <- cells[!is.na(cells) & nzchar(trimws(cells))]
if (!length(cells)) next
row_text <- paste(cells, collapse = " | ")
gse_hits <- unique(unlist(regmatches(row_text, gregexpr("GSE\\d+", row_text, perl = TRUE))))
if (length(gse_hits) > 0) {
current_batch <- gse_hits[1]
}
label_hit <- find_exact_label(cells)
if (!is.na(label_hit)) {
current_label <- label_hit
}
gsm_hits <- unique(unlist(regmatches(row_text, gregexpr("GSM\\d+", row_text, perl = TRUE))))
if (length(gsm_hits) == 0) next
batch <- if (length(gse_hits) > 0) gse_hits[1] else current_batch
label <- if (!is.na(label_hit)) label_hit else current_label
msi_status <- NA_character_
if (grepl("MSI", row_text, ignore.case = TRUE)) {
msi_status <- "MSI"
} else if (grepl("MSS", row_text, ignore.case = TRUE)) {
msi_status <- "MSS"
}
for (gsm in gsm_hits) {
rows[[length(rows) + 1]] <- data.frame(
sample_id = gsm,
batch = batch,
label = label,
msi_status = msi_status,
source_sheet = sheet,
source_row = i,
stringsAsFactors = FALSE
)
}
}
}
table_df <- if (length(rows)) do.call(rbind, rows) else data.frame()
if (nrow(table_df) == 0) {
stop(sprintf("No GSM rows were found in %s", basename(path)))
}
table_df <- table_df[!is.na(table_df$sample_id) & nzchar(table_df$sample_id), , drop = FALSE]
table_df <- table_df[!duplicated(table_df$sample_id), , drop = FALSE]
table_df$batch <- as.character(table_df$batch)
table_df$label <- as.character(table_df$label)
table_df
}
assert_sample_table <- function(table_df, spec) {
if (anyNA(table_df$batch)) {
stop(sprintf("%s has rows without a batch assignment", spec$dataset_id))
}
if (anyNA(table_df$label)) {
stop(sprintf("%s has rows without a label assignment", spec$dataset_id))
}
label_counts <- table(tolower(table_df$label))
observed_labels <- label_counts[names(spec$expected_counts)]
observed_values <- as.integer(observed_labels)
expected_values <- as.integer(spec$expected_counts)
if (length(observed_values) != length(expected_values) || anyNA(observed_values) || !isTRUE(all(observed_values == expected_values))) {
stop(sprintf(
"%s label counts mismatch. Expected %s, got %s",
spec$dataset_id,
paste(sprintf("%s=%d", names(spec$expected_counts), spec$expected_counts), collapse = ", "),
paste(sprintf("%s=%d", names(label_counts), as.integer(label_counts)), collapse = ", ")
))
}
if (nrow(table_df) != spec$expected_total) {
stop(sprintf("%s total sample count mismatch: expected %d, got %d", spec$dataset_id, spec$expected_total, nrow(table_df)))
}
gse_count <- length(unique(table_df$batch))
if (gse_count != spec$expected_gse) {
stop(sprintf("%s GEO collection count mismatch: expected %d, got %d", spec$dataset_id, spec$expected_gse, gse_count))
}
}
load_gse_matrix <- function(gse_id, sample_ids, cache_dir) {
rds_path <- file.path(cache_dir, paste0(gse_id, "_gpl570.rds"))
if (file.exists(rds_path)) {
cached <- readRDS(rds_path)
if (!is.null(cached) && !is.null(cached$expr) && nrow(cached$expr) > 0) {
return(cached)
}
warning(sprintf("%s has an empty cached matrix; refreshing", gse_id))
unlink(rds_path)
}
message(sprintf("Fetching %s", gse_id))
eset <- GEOquery::getGEO(gse_id, GSEMatrix = TRUE, getGPL = FALSE)
if (inherits(eset, "ExpressionSet")) {
eset <- list(eset)
}
if (!length(eset)) {
stop(sprintf("%s did not return any ExpressionSet", gse_id))
}
platform_ids <- vapply(eset, Biobase::annotation, character(1))
idx <- which(platform_ids == "GPL570")
if (length(idx) == 0) {
warning(sprintf("%s did not expose GPL570 explicitly; skipping this study", gse_id))
return(NULL)
}
eset <- eset[[idx[1]]]
expr <- Biobase::exprs(eset)
if (nrow(expr) == 0 || ncol(expr) == 0) {
warning(sprintf("%s returned an empty expression matrix; skipping", gse_id))
return(NULL)
}
sample_names <- colnames(expr)
keep <- intersect(sample_ids, sample_names)
if (length(keep) == 0) {
warning(sprintf("%s has no overlapping GSM accessions after loading the matrix; skipping", gse_id))
return(NULL)
}
expr <- expr[, keep, drop = FALSE]
expr <- expr[!duplicated(rownames(expr)), , drop = FALSE]
storage.mode(expr) <- "numeric"
result <- list(expr = expr, sample_names = colnames(expr), platform = Biobase::annotation(eset))
saveRDS(result, rds_path)
result
}
build_dataset <- function(dataset_key, spec, outdir, cache_dir) {
article <- read_figshare_article()
files <- as.data.frame(article$files, stringsAsFactors = FALSE)
table_file <- select_figshare_file(files, spec$table_number)
table_path <- file.path(cache_dir, basename(table_file$name))
cache_download(table_file$download_url, table_path)
sample_table <- extract_rows_from_workbook(table_path, spec$allowed_labels)
sample_table$label <- tolower(sample_table$label)
sample_table$batch <- as.character(sample_table$batch)
sample_table$sample_id <- as.character(sample_table$sample_id)
sample_table$dataset <- spec$dataset_id
assert_sample_table(sample_table, spec)
gse_ids <- sort(unique(sample_table$batch))
sample_tables_by_gse <- split(sample_table, sample_table$batch)
study_results <- list()
for (gse_id in gse_ids) {
study_table <- sample_tables_by_gse[[gse_id]]
study_ids <- study_table$sample_id
study_results[[gse_id]] <- load_gse_matrix(gse_id, study_ids, cache_dir)
}
study_results <- study_results[!vapply(study_results, is.null, logical(1))]
if (!length(study_results)) {
stop(sprintf("%s did not return any GPL570 studies", spec$dataset_id))
}
common_probes <- Reduce(intersect, lapply(study_results, function(item) rownames(item$expr)))
if (!length(common_probes)) {
stop(sprintf("%s did not produce any common probes across studies", spec$dataset_id))
}
message(sprintf("%s: %d common probes across %d studies", spec$dataset_id, length(common_probes), length(study_results)))
loaded_gse_ids <- names(study_results)
combined_rows <- list()
for (gse_id in loaded_gse_ids) {
study_table <- sample_tables_by_gse[[gse_id]]
result <- study_results[[gse_id]]
expr <- result$expr[common_probes, , drop = FALSE]
expr_df <- as.data.frame(t(expr), check.names = FALSE)
expr_df$sample_id <- rownames(expr_df)
merged <- merge(
study_table[, c("sample_id", "batch", "label", "msi_status", "source_sheet", "source_row")],
expr_df,
by = "sample_id",
all.x = TRUE,
sort = FALSE
)
combined_rows[[gse_id]] <- merged
}
combined <- as.data.frame(data.table::rbindlist(combined_rows, use.names = TRUE, fill = TRUE))
feature_cols <- setdiff(colnames(combined), c("sample_id", "batch", "label", "msi_status", "source_sheet", "source_row"))
if (!length(feature_cols)) {
stop(sprintf("%s did not produce any expression feature columns", spec$dataset_id))
}
feature_keep <- vapply(combined[, feature_cols, drop = FALSE], function(x) {
x_num <- suppressWarnings(as.numeric(as.character(x)))
any(is.finite(x_num))
}, logical(1))
feature_cols <- feature_cols[feature_keep]
if (!length(feature_cols)) {
stop(sprintf("%s lost all feature columns after numeric validation", spec$dataset_id))
}
combined[, feature_cols] <- lapply(combined[, feature_cols, drop = FALSE], function(x) {
suppressWarnings(as.numeric(as.character(x)))
})
combined <- combined[, c("sample_id", "batch", "label", "msi_status", "source_sheet", "source_row", feature_cols), drop = FALSE]
rownames(combined) <- NULL
expression <- combined[, c("sample_id", feature_cols), drop = FALSE]
metadata <- combined[, c("sample_id", "batch", "label", "msi_status", "source_sheet", "source_row"), drop = FALSE]
combined_out <- combined[, c("label", "batch", "sample_id", feature_cols), drop = FALSE]
target_dir <- file.path(outdir, spec$dataset_id)
ensure_dir(target_dir)
data.table::fwrite(combined_out, file.path(outdir, paste0(spec$dataset_id, ".csv")))
data.table::fwrite(expression, file.path(outdir, paste0(spec$dataset_id, "_expression.csv")))
data.table::fwrite(metadata, file.path(outdir, paste0(spec$dataset_id, "_metadata.csv")))
summary <- list(
dataset = spec$dataset_id,
source_table = table_path,
n_samples = nrow(combined_out),
n_features = length(feature_cols),
n_batches = length(unique(combined_out$batch)),
label_counts = as.list(table(combined_out$label)),
batch_counts = as.list(table(combined_out$batch))
)
writeLines(jsonlite::toJSON(summary, auto_unbox = TRUE, pretty = TRUE), con = file.path(target_dir, "summary.json"))
message(sprintf("Wrote %s (%d samples, %d features)", file.path(outdir, paste0(spec$dataset_id, ".csv")), nrow(combined_out), length(feature_cols)))
invisible(summary)
}
main <- function() {
args <- parse_args()
ensure_dir(args$outdir)
ensure_dir(file.path(args$outdir, "cache"))
if (args$dataset == "normal") {
datasets <- c("normal")
} else if (args$dataset == "colon") {
datasets <- c("colon")
} else {
datasets <- c("normal", "colon")
}
summaries <- list()
for (dataset_key in datasets) {
spec <- expected_specs[[dataset_key]]
summaries[[dataset_key]] <- build_dataset(dataset_key, spec, args$outdir, file.path(args$outdir, "cache"))
}
writeLines(jsonlite::toJSON(summaries, auto_unbox = TRUE, pretty = TRUE), con = file.path(args$outdir, "summary_all.json"))
message("Done.")
}
if (identical(sys.nframe(), 0L)) {
main()
}