diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index 811b8e034..8bebdabdd 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -73,3 +73,8 @@ jobs: # of detected changes. always_build: true context: ./ + build-and-remove-lpca: + uses: "./.github/workflows/build-and-remove-template.yml" + with: + path: docker-wrappers/LPCA + container: reedcompbio/lpca diff --git a/Snakefile b/Snakefile index 5ad7aa185..660b2e5ef 100644 --- a/Snakefile +++ b/Snakefile @@ -4,7 +4,7 @@ import shutil import yaml from spras.dataset import Dataset from spras.evaluation import Evaluation -from spras.analysis import ml, summary, cytoscape +from spras.analysis import ml, summary, cytoscape, lpca from spras.config.revision import detach_spras_revision import spras.config.config as _config @@ -24,6 +24,7 @@ _config.init_global(config) out_dir = _config.config.out_dir algorithm_params = _config.config.algorithm_params pca_params = _config.config.pca_params +lpca_params = _config.config.lpca_params hac_params = _config.config.hac_params container_settings = _config.config.container_settings include_aggregate_algo_eval = _config.config.analysis_include_evaluation_aggregate_algo @@ -43,6 +44,8 @@ def algo_has_mult_param_combos(algo): return len(algorithm_params.get(algo, {})) > 1 algorithms_mult_param_combos = [algo for algo in algorithms if algo_has_mult_param_combos(algo)] +# LPCA requires at least three runs; keep the PCA eligibility threshold unchanged. +algorithms_lpca = [algo for algo in algorithms if len(algorithm_params[algo]) >= 3] # Get the parameter dictionary for the specified # algorithm and parameter combination hash @@ -92,6 +95,19 @@ def make_final_input(wildcards): final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}jaccard-matrix.txt',out_dir=out_dir,sep=SEP,dataset=dataset_labels,algorithm_params=algorithms_with_params)) final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}jaccard-heatmap.png',out_dir=out_dir,sep=SEP,dataset=dataset_labels,algorithm_params=algorithms_with_params)) + if _config.config.analysis_include_lpca: + final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}lpca.png',out_dir=out_dir, sep=SEP, dataset=dataset_labels)) + final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}lpca-deviance.txt',out_dir=out_dir, sep=SEP, dataset=dataset_labels)) + final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}lpca-coordinates.txt',out_dir=out_dir, sep=SEP, dataset=dataset_labels)) + final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}lpca-binary-matrix.csv',out_dir=out_dir, sep=SEP, dataset=dataset_labels)) + + # Only run LPCA per algorithm for the algorithms collected in algorithms_lpca + if _config.config.analysis_include_lpca_aggregate_algo: + final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}{algorithm}-lpca-deviance.txt',out_dir=out_dir, sep=SEP, dataset=dataset_labels, algorithm=algorithms_lpca)) + final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}{algorithm}-lpca.png',out_dir=out_dir, sep=SEP,dataset=dataset_labels,algorithm=algorithms_lpca)) + final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}{algorithm}-lpca-coordinates.txt',out_dir=out_dir, sep=SEP, dataset=dataset_labels,algorithm=algorithms_lpca)) + final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}{algorithm}-lpca-binary-matrix.csv',out_dir=out_dir, sep=SEP, dataset=dataset_labels,algorithm=algorithms_lpca)) + if _config.config.analysis_include_ml_aggregate_algo: final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}{algorithm}-pca.png',out_dir=out_dir,sep=SEP,dataset=dataset_labels,algorithm=algorithms_mult_param_combos)) final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}{algorithm}-pca-variance.txt',out_dir=out_dir,sep=SEP,dataset=dataset_labels,algorithm=algorithms_mult_param_combos)) @@ -403,6 +419,59 @@ rule ml_analysis_aggregate_algo: ml.hac_horizontal(summary_df, output.hac_image_horizontal, output.hac_clusters_horizontal, **hac_params) ml.pca(summary_df, output.pca_image, output.pca_variance, output.pca_coordinates, **pca_params) +# Track fit and plot settings so changes schedule a new fit. +rule lpca_analysis_all: + input: + pathways = expand('{out_dir}{sep}{{dataset}}-{algorithm_params}{sep}pathway.txt', out_dir=out_dir, sep=SEP, algorithm_params=algorithms_with_params) + output: + lpca_png = SEP.join([out_dir, '{dataset}-ml', 'lpca.png']), + lpca_deviance = SEP.join([out_dir, '{dataset}-ml', 'lpca-deviance.txt']), + lpca_coord = SEP.join([out_dir, '{dataset}-ml', 'lpca-coordinates.txt']), + lpca_matrix = SEP.join([out_dir, '{dataset}-ml', 'lpca-binary-matrix.csv']) + params: + k = lpca_params.k, + m = lpca_params.m, + labels = lpca_params.labels, + run: + summary_df = ml.summarize_networks(input.pathways) + lpca.run_lpca( + summary_df, + output_png=output.lpca_png, + output_deviance=output.lpca_deviance, + output_coord=output.lpca_coord, + output_matrix=output.lpca_matrix, + k=params.k, + m=params.m, + labels=params.labels, + container_settings=container_settings + ) + +rule lpca_analysis_aggregate_algo: + input: + pathways = collect_pathways_per_algo + output: + lpca_png = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca.png']), + lpca_deviance = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca-deviance.txt']), + lpca_coord = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca-coordinates.txt']), + lpca_matrix = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca-binary-matrix.csv']) + params: + k = lpca_params.k, + m = lpca_params.m, + labels = lpca_params.labels, + run: + summary_df = ml.summarize_networks(input.pathways) + lpca.run_lpca( + summary_df, + output_png=output.lpca_png, + output_deviance=output.lpca_deviance, + output_coord=output.lpca_coord, + output_matrix=output.lpca_matrix, + k=params.k, + m=params.m, + labels=params.labels, + container_settings=container_settings + ) + # Ensemble the output pathways for each dataset per algorithm rule ensemble_per_algo: input: diff --git a/config/config.yaml b/config/config.yaml index ef51de739..b2cee93f9 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -50,29 +50,29 @@ containers: # requirements = versionGE(split(Target.CondorVersion)[1], "24.8.0") && (isenforcingdiskusage =!= true) enable_profiling: false - # Override the default container image for specific algorithms. - # Keys are algorithm names (as they appear in the algorithms list below). - # Values are interpreted based on the container framework: - # - # Image reference (e.g., "pathlinker:v3"): - # Prepends the registry prefix. Works with both Docker and Apptainer. - # - # Full image reference with registry (e.g., "ghcr.io/myorg/pathlinker:v3"): - # Used as-is (prefix NOT prepended). Works with both Docker and Apptainer. - # - # Local .sif file path (e.g., "images/pathlinker_v2.sif"): - # Apptainer/Singularity only. Skips pulling from registry and uses the - # pre-built .sif directly. When running via HTCondor with shared-fs-usage: none (set - # via the spras_profile config when running SPRAS against HTCondor), .sif paths listed - # here are automatically included in htcondor_transfer_input_files. - # Ignored with a warning if the framework is Docker. - # - # Example (one of each type): - # images: - # omicsintegrator1: "images/omics-integrator-1_v2.sif" # local .sif (Apptainer only) - # pathlinker: "pathlinker:v1234" # image name only (base_url/owner prepended) - # omicsintegrator2: "some-other-owner/oi2:latest" # owner/image (base_url prepended) - # mincostflow: "ghcr.io/reed-compbio/mincostflow:v2" # full registry reference (used as-is) +# Override the default container image for specific algorithms. +# Keys are algorithm names (as they appear in the algorithms list below). +# Values are interpreted based on the container framework: +# +# Image reference (e.g., "pathlinker:v3"): +# Prepends the registry prefix. Works with both Docker and Apptainer. +# +# Full image reference with registry (e.g., "ghcr.io/myorg/pathlinker:v3"): +# Used as-is (prefix NOT prepended). Works with both Docker and Apptainer. +# +# Local .sif file path (e.g., "images/pathlinker_v2.sif"): +# Apptainer/Singularity only. Skips pulling from registry and uses the +# pre-built .sif directly. When running via HTCondor with shared-fs-usage: none (set +# via the spras_profile config when running SPRAS against HTCondor), .sif paths listed +# here are automatically included in htcondor_transfer_input_files. +# Ignored with a warning if the framework is Docker. +# +# Example (one of each type): +# images: +# omicsintegrator1: "images/omics-integrator-1_v2.sif" # local .sif (Apptainer only) +# pathlinker: "pathlinker:v1234" # image name only (base_url/owner prepended) +# omicsintegrator2: "some-other-owner/oi2:latest" # owner/image (base_url prepended) +# mincostflow: "ghcr.io/reed-compbio/mincostflow:v2" # full registry reference (used as-is) # This list of algorithms should be generated by a script which checks the filesystem for installs. # It shouldn't be changed by mere mortals. (alternatively, we could add a path to executable for each algorithm @@ -272,3 +272,18 @@ analysis: # adds evaluation per algorithm per dataset-goldstandard pair # evaluation per algorithm will not run unless ml include and ml aggregate_per_algorithm are set to true aggregate_per_algorithm: true + lpca: + # Compare pathway graphs across all algorithms, independently of ml.include + # LPCA allocates dense edge-by-edge matrices, requiring quadratic memory. + # Partial decomposition does not remove them. + # LPCA fits may fail due to out-of-memory errors. + include: false + # Also fit each algorithm that has at least three configured parameter combinations. + # Requires lpca.include: true; algorithms with fewer combinations are omitted. + aggregate_per_algorithm: false + # Only a two-component fit is supported. Keep k=2. + k: 2 + # Fixed, finite, strictly positive tuning parameter; no automatic selection + m: 6 + # Show run identifiers on the LPCA plot + labels: true diff --git a/config/egfr.yaml b/config/egfr.yaml index b93c593c4..3b48d7a9c 100644 --- a/config/egfr.yaml +++ b/config/egfr.yaml @@ -134,6 +134,12 @@ analysis: labels: true kde: true remove_empty_pathways: true + lpca: + include: false + aggregate_per_algorithm: false + k: 2 + m: 6 + labels: true evaluation: include: true aggregate_per_algorithm: true diff --git a/docker-wrappers/LPCA/Dockerfile b/docker-wrappers/LPCA/Dockerfile new file mode 100644 index 000000000..241ba6dd5 --- /dev/null +++ b/docker-wrappers/LPCA/Dockerfile @@ -0,0 +1,40 @@ +# Logistic PCA (logisticPCA) wrapper for SPRAS. +# The caller supplies Rscript /app/run_lpca.R and its arguments. +# Cross-validation is not supported, and no ENTRYPOINT is set. + +# Pinned R version for reproducibility. Bump deliberately, not to :latest. +FROM rocker/r-base:4.4.2 + +LABEL org.opencontainers.image.source="https://github.com/Reed-CompBio/spras" +LABEL org.opencontainers.image.description="Logistic PCA (logisticPCA) wrapper for SPRAS" + +# System libraries needed to compile ggplot2 (a hard Import of logisticPCA) +# and its dependency stack from source on Debian. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libcurl4-openssl-dev \ + libssl-dev \ + libxml2-dev \ + libfontconfig1-dev \ + libfreetype6-dev \ + libpng-dev \ + libtiff5-dev \ + libjpeg-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install tested versions of required R packages including rARPACK's RSpectra backend. +# remotes finds these versions in CRAN or its archive. +# Install only required dependencies and do not upgrade previously installed ones. +# Transitive and system dependencies are not fully pinned by this Dockerfile. +RUN Rscript -e "options(repos = c(CRAN = 'https://cran.r-project.org')); \ + install.packages('remotes'); \ + versions <- c(RSpectra = '0.16-2', rARPACK = '0.11-0', logisticPCA = '0.2'); \ + for (pkg in names(versions)) { \ + remotes::install_version(pkg, version = versions[[pkg]], \ + dependencies = NA, upgrade = 'never'); \ + stopifnot(packageVersion(pkg) == package_version(versions[[pkg]])); \ + }; \ + library(logisticPCA); library(rARPACK); library(RSpectra)" + +COPY run_lpca.R /app/run_lpca.R + +WORKDIR /app diff --git a/docker-wrappers/LPCA/README.md b/docker-wrappers/LPCA/README.md new file mode 100644 index 000000000..d302deacc --- /dev/null +++ b/docker-wrappers/LPCA/README.md @@ -0,0 +1,99 @@ +# LPCA (Logistic PCA) wrapper + +Docker image: https://hub.docker.com/r/reedcompbio/lpca + +This wrapper uses [logisticPCA](https://github.com/andland/logisticPCA) +([Landgraf & Lee, 2020](https://doi.org/10.1016/j.jmva.2020.104668)) for an +exploratory, two-dimensional comparison of reconstructed networks. It calls +`logisticPCA`, not `logisticSVD`. It supports only `k = 2` components and a fixed, +finite, strictly positive `m`. Cross-validation for automatic selection of `m` +and modifying `k` are not supported. + +## Container interface + +The image contains one R script, invoked explicitly by the caller: + +```text +Rscript /app/run_lpca.R +``` + +### Input + +The CSV has a header row, a first column of nonempty, unique run identifiers, +and one column per binary edge feature. Rows are runs, not edges. The caller +must transpose the edge-by-run matrix returned by `summarize_networks` before +writing it. + +This wrapper deliberately requires at least three runs, three edge features, +and three distinct binary network profiles. All feature values must be finite +numeric zeros or ones. Missing, nonnumeric, and nonbinary entries are errors. +Duplicate profiles and constant features are +retained when the input otherwise satisfies these requirements. + +`k` must equal 2. `m` must be finite and strictly positive. + +The default `m=6` is based on the exploratory +[LPCA-SPRAS experiments](https://github.com/Jeebjean/lpca-spras). +No single value of `m` was universally best. + +### Outputs and numerical checks + +The raw scores CSV contains `datapoint_labels`, `PC1`, and `PC2`, with one row +per input run and no synthetic centroid. It is an intermediate for Python's +PCA-compatible plotting and coordinate formatting, not a second public +coordinate table. + +The deviance summary is a small text file: + +```text +components: 2 +m: +percent_deviance_explained: <100 times the package's whole-model statistic> +``` + +## SPRAS configuration + +The `analysis.lpca` config settings are: + +```yaml +analysis: + lpca: + include: false + aggregate_per_algorithm: false + k: 2 + m: 6 + labels: true +``` + +## Decomposition + +`partial_decomp=TRUE` is always set. It uses +`rARPACK` (backed by `RSpectra`) for partial decompositions where supported. The +package can fall back to full eigendecomposition. It still constructs dense +edge-by-edge matrices, so memory use can grow quadratically with the number of +edge features. For example, the `egfr.yaml` configuration (19 runs, 18,851 edge features, `k=2`, `m=6`) +requires nearly 20GB memory. Completing this run may require modifying the memory +provided to the running container. + +## Building and publishing the image + +Replace `` below with the tag from `LPCA_CONTAINER_SUFFIX` in +`spras/analysis/lpca.py`. Publishing to the default registry requires access +to the `reedcompbio` Docker Hub organization: + + docker build -t "reedcompbio/lpca:" docker-wrappers/LPCA/ + docker push "reedcompbio/lpca:" + +## Testing +Run the test Python script to test the Docker image +```commandline +python docker-wrappers/LPCA/test_container.py +``` +Expected output will end with +```commandline +=== RESULT: 28 passed; 0 failed === +Container exit status: 0 +``` + +## AI +GPT 6 Astra was used to refactor these files and write the test code. diff --git a/docker-wrappers/LPCA/run_lpca.R b/docker-wrappers/LPCA/run_lpca.R new file mode 100644 index 000000000..42e6c0b72 --- /dev/null +++ b/docker-wrappers/LPCA/run_lpca.R @@ -0,0 +1,95 @@ +# Fixed-m, two-component logistic PCA for SPRAS. + +# Read command-line arguments. +args <- commandArgs(trailingOnly = TRUE) +if (length(args) != 5L) { + stop("Usage: run_lpca.R ") +} +input_file <- args[1] +output_file <- args[2] +k <- as.numeric(args[3]) +m <- as.numeric(args[4]) +deviance_file <- args[5] +if (!is.finite(k) || k != 2) { + stop("k must be exactly 2.") +} +if (!is.finite(m) || m <= 0) { + stop("m must be finite and positive; m=0 requests estimation.") +} + +# Read labels as text and convert only the binary feature columns to numbers. +data <- read.csv(input_file, row.names = NULL, check.names = FALSE, + colClasses = "character", na.strings = character(), + fill = FALSE, blank.lines.skip = FALSE) +# Require at least 3 runs and 3 edge features, plus the run-label column. +if (nrow(data) < 3L || ncol(data) < 4L) { + stop("LPCA requires at least 3 runs and 3 binary edge features.") +} +row_labels <- data[[1]] +if (anyNA(row_labels) || any(!nzchar(trimws(row_labels))) || + anyDuplicated(row_labels)) { + stop("Run labels must be nonempty and unique.") +} +data_matrix <- as.matrix(data[, -1, drop = FALSE]) +storage.mode(data_matrix) <- "double" +if (any(!is.finite(data_matrix)) || any(!data_matrix %in% c(0, 1))) { + stop("Edge features must be complete, finite numeric zeros or ones.") +} +if (nrow(unique(data_matrix)) < 3L) { + stop("LPCA requires at least 3 distinct binary network profiles.") +} + +# Fit LPCA. The package can roll back and truncate the loss trace on failure, +# so retain the specific deviance-increase warning check. +max_iters <- 1000L +conv_criteria <- 1e-5 +set.seed(42) +model <- withCallingHandlers( + logisticPCA::logisticPCA( + data_matrix, k = k, m = m, main_effects = TRUE, + partial_decomp = TRUE, random_start = FALSE, + max_iters = max_iters, conv_criteria = conv_criteria + ), + warning = function(w) { + if (grepl("Algorithm stopped because deviance increased", + conditionMessage(w), fixed = TRUE)) { + stop("LPCA deviance increased during optimization.") + } + } +) + +# Check the results and convergence before writing outputs. +scores <- model$PCs +percent_deviance <- 100 * model$prop_deviance_expl +if (!identical(dim(scores), c(nrow(data_matrix), 2L)) || + any(!is.finite(scores)) || !is.finite(percent_deviance)) { + stop("LPCA returned invalid scores or deviance explained.") +} +loss <- model$loss_trace +if (length(loss) < 2L || any(!is.finite(loss))) { + stop("LPCA returned an invalid loss trace.") +} +changes <- diff(loss) +if (any(changes > 1e-10)) { + stop("LPCA deviance increased during optimization.") +} +if (abs(tail(changes, 1L)) >= conv_criteria) { + stop(sprintf("LPCA did not converge within %d iterations.", max_iters)) +} + +# Python formats the public coordinate table. +score_table <- data.frame(datapoint_labels = row_labels, + PC1 = scores[, 1], PC2 = scores[, 2]) +summary <- c( + sprintf("components: %d", k), + sprintf("m: %.17g", m), + sprintf("percent_deviance_explained: %.17g", percent_deviance) +) +write.csv(score_table, output_file, row.names = FALSE) +writeLines(summary, deviance_file) + +cat("LPCA done! Scores saved to", output_file, "\n") +cat("Score dimensions:", nrow(scores), "x", k, "\n") +cat("Percent deviance explained:", percent_deviance, "\n") +cat("Iterations:", model$iters, "\n") +cat("logisticPCA version:", as.character(packageVersion("logisticPCA")), "\n") diff --git a/docker-wrappers/LPCA/test_container.py b/docker-wrappers/LPCA/test_container.py new file mode 100644 index 000000000..d7f6ffd87 --- /dev/null +++ b/docker-wrappers/LPCA/test_container.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +"""Run standalone regression tests for the SPRAS LPCA container. + +Build the image as described in README.md, then run the tests from the +repository root with Python >= 3.9 and Docker: + python docker-wrappers/LPCA/test_container.py + +The wrapper is located relative to this file, not the working directory. +Results go to the terminal; --log PATH also saves them to a new UTF-8 file. +Use --image TAG to select a different already-built local image. Tests never +pull an image or require a registry login. + +The runner checks the local/container wrapper match (ignoring CRLF vs LF), +package versions, numerical regressions, and input/output behavior. All +fixtures are created inside one disposable Linux container, with no network +or host mounts. Local R, pytest, and an installed SPRAS package are not needed. +Each fit runs the real five-argument CLI in a separate Rscript process; the +production wrapper needs no testing hooks. Scope: fixed positive m, exactly +two components, and whole-model deviance, without CV or per-axis variance. + +The process exits with zero only after a completed, passing test run. This +suite does not test SPRAS volume mapping, plotting, or Snakemake integration. +""" + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import uuid +from contextlib import nullcontext +from pathlib import Path + +R_TESTS = r''' +options(warn = 1) +cat("=== Environment and wrapper identity ===\n") +print(sessionInfo()) +versions <- c(logisticPCA = "0.2", rARPACK = "0.11-0", RSpectra = "0.16-2") +for (pkg in names(versions)) { + actual <- packageVersion(pkg) + cat(pkg, as.character(actual), "\n") + if (actual != package_version(versions[[pkg]])) { + stop(paste("Unexpected package version:", pkg)) + } +} + +# Compare exact source bytes except for Windows CRLF versus Unix LF newlines. +source_file <- "/app/run_lpca.R" +source_bytes <- readBin(source_file, "raw", n = file.info(source_file)$size) +source_text <- gsub("\r\n", "\n", rawToChar(source_bytes), fixed = TRUE) +normalized <- tempfile() +writeBin(charToRaw(source_text), normalized) +actual_md5 <- unname(tools::md5sum(normalized)) +unlink(normalized) +cat("Container wrapper MD5 (LF normalized):", actual_md5, "\n") +cat("=== Wrapper source being tested ===\n", source_text, "\n", sep = "") +if (!identical(actual_md5, expected_md5)) { + stop("The image does not contain the adjacent run_lpca.R. Rebuild the selected image.") +} +cat("Local/container wrapper match: PASS\n") + +work <- tempfile(pattern = "LPCA numerical checks ") +dir.create(work) +passed <- 0L +failed <- 0L +need <- function(condition, message) { + if (!isTRUE(condition)) stop(message, call. = FALSE) +} +check <- function(name, action) { + cat("\n===", name, "===\n") + tryCatch({ + action() + passed <<- passed + 1L + cat("PASS:", name, "\n") + }, error = function(e) { + failed <<- failed + 1L + cat("FAIL:", name, "|", conditionMessage(e), "\n") + }) +} +near <- function(actual, expected, tolerance, label) { + need(length(actual) == length(expected), paste(label, "length mismatch")) + delta <- max(abs(as.numeric(actual) - as.numeric(expected))) + cat(label, "maximum absolute difference:", format(delta, digits = 10), + "(tolerance", tolerance, ")\n") + need(is.finite(delta) && delta < tolerance, paste(label, "differs from reference")) +} + +# Regression reference provenance: +# The binary patterns below correspond to pathway-params-1.txt through +# pathway-params-4.txt in test/analysis/input/lpca/. The reference scores come +# from test/analysis/test_lpca.py at SPRAS commit +# 5b64162a4b93691f02615696e62f7542ddda753d. +# The deviance references were recorded on the same patterns using R 4.4.2, +# logisticPCA 0.2, rARPACK 0.11-0, and RSpectra 0.16-2 on x86_64 Linux: +# k=2, m=4: proportion 0.9192537 (recorded console precision) +# k=2, m=6: proportion 0.976137532256003 (recorded output-file precision) +# Both fits used main effects and partial_decomp=TRUE. These are observed +# regression results, not an independent proof of numerical correctness. +# Distances allow a global orthogonal change of axes. Absolute tolerances are +# 1e-3 for reference distances and deviance percentages and 1e-6 for repeated +# fits. Investigate changes before updating references or loosening tolerances, +# especially when updating the numerical package versions above. +x <- rbind( + c(1,1,1,1,1,0,0,0,0,0,0), + c(1,1,0,0,0,1,1,1,0,0,0), + c(1,0,0,1,1,0,0,0,1,1,0), + c(0,1,1,0,0,0,1,1,0,0,1) +) +labels <- c("001", "NA", "run,three", "run four") +fixture <- function(mat = x, ids = paste0("run", seq_len(nrow(mat))), + k = "2", m = "4", blank_header = FALSE) { + directory <- tempfile(pattern = "case ", tmpdir = work) + dir.create(directory) + input <- file.path(directory, "matrix input.csv") + scores <- file.path(directory, "raw scores.csv") + deviance <- file.path(directory, "separate fit summary.txt") + write.csv(data.frame(datapoint_labels = ids, mat), input, + row.names = FALSE, na = "") + if (blank_header) { + lines <- readLines(input) + lines[1] <- sub('^"datapoint_labels"', '', lines[1]) + writeLines(lines, input) + } + list(input = input, scores = scores, deviance = deviance, + args = c(input, scores, k, m, deviance), ids = ids, mat = mat) +} +invoke <- function(f, expected_error = NULL, arguments = f$args) { + before <- tools::md5sum(f$input) + # The subprocess runs inside Linux; shQuote protects file paths with spaces. + output <- suppressWarnings(system2( + file.path(R.home("bin"), "Rscript"), + c("--vanilla", shQuote(c(source_file, arguments))), + stdout = TRUE, stderr = TRUE, timeout = 90 + )) + status <- attr(output, "status") + if (is.null(status)) status <- 0L + cat(paste(output, collapse = "\n"), "\n") + cat("Wrapper exit status:", status, "\n") + need(identical(before, tools::md5sum(f$input)), "The input CSV was modified") + need(status != 124L, "The wrapper timed out; this is not an expected rejection") + if (is.null(expected_error)) { + need(status == 0L, "The valid-input fit failed") + return(invisible(NULL)) + } + need(status != 0L, "Invalid input unexpectedly succeeded") + need(any(grepl(expected_error, output, fixed = TRUE)), + paste("Missing expected error text:", expected_error)) + need(!file.exists(f$scores) && !file.exists(f$deviance), + "A rejected fit left a scores or deviance output") +} +read_results <- function(f) { + need(file.exists(f$scores) && file.exists(f$deviance), "A required output is missing") + table <- read.csv(f$scores, row.names = NULL, colClasses = "character", + na.strings = character(), check.names = FALSE) + need(identical(names(table), c("datapoint_labels", "PC1", "PC2")), + "Unexpected scores CSV columns") + need(nrow(table) == nrow(f$mat), "Wrong score-row count (no centroid belongs here)") + need(identical(table$datapoint_labels, f$ids), "Run labels/order were not preserved") + scores <- as.matrix(table[, c("PC1", "PC2"), drop = FALSE]) + storage.mode(scores) <- "double" + need(all(is.finite(scores)), "Nonfinite score value") + lines <- readLines(f$deviance) + keys <- c("components", "m", "percent_deviance_explained") + need(identical(sub(":.*$", "", lines), keys), "Unexpected fit-summary fields") + values <- as.numeric(sub("^[^:]+: *", "", lines)) + need(all(is.finite(values)) && values[1] == 2 && + values[2] == as.numeric(f$args[4]), "Invalid summary values or wrong m/k") + print(table, row.names = FALSE) + cat(paste(lines, collapse = "\n"), "\n") + list(scores = scores, percent = values[3]) +} +fit <- function(f) { + invoke(f) + read_results(f) +} + +baseline <- NULL +check("Reference fixture: m=4, geometry, deviance, labels and output schemas", function() { + f <- fixture(ids = labels, blank_header = TRUE) + result <- fit(f) + # Reference score provenance is documented above the binary fixture. + # Comparing distances permits one global orthogonal change of plotted axes, + # but does not permit arbitrary sign changes on individual observations. + expected <- cbind( + c(4.96756989310632, -7.42875819587666, 12.7488840407735, -11.2979872320273), + c(-7.80927209388169, 7.21294742051829, 1.711517188498, -5.51380214217161) + ) + near(dist(result$scores), dist(expected), 1e-3, "Reference distances") + # Convert the recorded m=4 proportion to a percentage. + near(result$percent, 91.92537, 1e-3, "Reference deviance percentage") + baseline <<- result +}) +check("Repeatability in a fresh R process; named CSV label header", function() { + need(!is.null(baseline), "Reference fixture did not pass") + result <- fit(fixture(ids = labels)) + near(dist(result$scores), dist(baseline$scores), 1e-6, "Repeated distances") + near(result$percent, baseline$percent, 1e-6, "Repeated deviance percentage") +}) +check("Second known configuration: fixed m=6", function() { + result <- fit(fixture(m = "6")) + # Convert the recorded m=6 proportion to a percentage. + near(result$percent, 97.6137532256003, 1e-3, "m=6 reference deviance percentage") +}) +check("Boundary-sized valid input: three runs and three features", function() { + fit(fixture(diag(3))) +}) +check("Constant columns with otherwise informative data", function() { + fit(fixture(cbind(x, 1, 0))) +}) +check("Duplicate profiles retained when at least three distinct profiles exist", function() { + result <- fit(fixture(rbind(x, x[1, , drop = FALSE]))) + near(result$scores[1, ], result$scores[5, ], 1e-6, "Identical-profile scores") +}) +check("Finite positive fractional m", function() { + fit(fixture(m = "4.5")) +}) + +check("Reject two runs before reaching the decomposition backend", function() { + invoke(fixture(x[1:2, , drop = FALSE]), "at least 3 runs and 3 binary edge features") +}) +for (count in 1:2) { + check(paste("Reject", count, "feature column(s)"), function() { + invoke(fixture(x[, seq_len(count), drop = FALSE]), + "at least 3 runs and 3 binary edge features") + }) +} +check("Reject identical profiles instead of exporting infinite deviance", function() { + invoke(fixture(matrix(1, nrow = 4, ncol = 5)), "at least 3 distinct") +}) +check("Reject only two distinct profiles despite four runs", function() { + invoke(fixture(x[c(1, 2, 1, 2), , drop = FALSE]), "at least 3 distinct") +}) +check("Reject duplicate run identifiers", function() { + invoke(fixture(ids = rep("same", 4)), "Run labels must be nonempty and unique") +}) +check("Reject blank run identifiers", function() { + invoke(fixture(ids = c(" ", "b", "c", "d")), "Run labels must be nonempty and unique") +}) +for (value in c("1", "3", "2.9")) { + check(paste("Reject k =", value), function() { + invoke(fixture(k = value), "k must be exactly 2") + }) +} +for (value in c("0", "-1", "Inf", "bad")) { + check(paste("Reject m =", value), function() { + invoke(fixture(m = value), "m must be finite and positive") + }) +} +for (value in c("", "NA", "Inf", "bad", "2", "0.5")) { + check(paste("Reject invalid feature value:", dQuote(value)), function() { + mat <- x + mat[1, 1] <- value + invoke(fixture(mat), "Edge features must be complete, finite numeric zeros or ones") + }) +} +check("Reject old four-argument interface without producing outputs", function() { + f <- fixture() + invoke(f, "Usage:", f$args[1:4]) +}) + +cat("\n=== RESULT:", passed, "passed;", failed, "failed ===\n") +unlink(work, recursive = TRUE) +quit(save = "no", status = if (failed == 0L) 0L else 1L) +''' + + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("--image", default="reedcompbio/lpca:v1", + help="Local image to test (default: %(default)s)") + parser.add_argument("--log", type=Path, + help="Also save output to a new UTF-8 file; never overwrite") + parser.add_argument("--timeout", type=int, default=600, + help="Total container timeout in seconds (default: 600)") + args = parser.parse_args() + if args.timeout <= 0: + parser.error("--timeout must be positive") + source = Path(__file__).resolve().with_name("run_lpca.R") + if not source.is_file(): + parser.error("Place test_container.py beside run_lpca.R in docker-wrappers/LPCA") + docker = shutil.which("docker") + if docker is None: + parser.error("Docker CLI was not found on PATH") + + env = os.environ.copy() + env.update({"MSYS_NO_PATHCONV": "1", "MSYS2_ARG_CONV_EXCL": "*"}) + normalized_source = source.read_bytes().replace(b"\r\n", b"\n") + expected_md5 = hashlib.md5(normalized_source, usedforsecurity=False).hexdigest() + name = "spras-lpca-tests-" + uuid.uuid4().hex[:12] + log_path = args.log.expanduser() if args.log is not None else None + # Logging is optional; an existing file is never overwritten. + log_context = (log_path.open("x", encoding="utf-8", newline="\n") + if log_path is not None else nullcontext(None)) + with log_context as log: + def emit(text: str) -> None: + if not text.endswith("\n"): + text += "\n" + print(text, end="", flush=True) + if log is not None: + log.write(text) + log.flush() + + if log_path is not None: + emit(f"Results log: {log_path}") + emit(f"Local wrapper: {source}") + emit(f"Local wrapper MD5 (LF normalized): {expected_md5}") + try: + inspected = subprocess.run( + [docker, "image", "inspect", args.image], env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding="utf-8", errors="replace", timeout=30, + ) + if inspected.returncode: + emit(inspected.stderr) + emit("Image inspection failed. Build the selected image first; no image is pulled.") + return 1 + image = json.loads(inspected.stdout)[0] + emit(f"Image tag: {args.image}\nImage ID: {image['Id']}") + emit(f"Image platform: {image.get('Os')} / {image.get('Architecture')}") + # Run the inspected ID, not the mutable tag, to identify exactly what was tested. + command = [docker, "run", "--name", name, "--rm", "--pull=never", + "--network=none", "-i", "--entrypoint", "Rscript", + image["Id"], "--vanilla", "/dev/stdin"] + payload = f'expected_md5 <- "{expected_md5}"\n' + R_TESTS + emit("Running container CLI tests. Their output is buffered until completion.") + try: + result = subprocess.run( + command, input=payload, env=env, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, encoding="utf-8", + errors="replace", timeout=args.timeout, + ) + finally: + # On timeout/interrupt, remove only the uniquely named test container. + # After an ordinary --rm exit, this is a harmless no-op. + try: + subprocess.run([docker, "rm", "--force", name], env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=30, check=False) + except (OSError, subprocess.SubprocessError) as cleanup_error: + emit(f"WARNING: Could not confirm cleanup of {name}: {cleanup_error}") + emit(result.stdout) + emit(f"Container exit status: {result.returncode}") + complete = "=== RESULT:" in result.stdout + if not complete: + emit("The test suite did not reach its summary; this run is not passing.") + return 0 if result.returncode == 0 and complete else 1 + except subprocess.TimeoutExpired as exc: + partial = exc.stdout or b"" + emit(partial.decode("utf-8", "replace") if isinstance(partial, bytes) else partial) + emit(f"ERROR: Command timed out after {exc.timeout} seconds; the run is incomplete.") + return 1 + except KeyboardInterrupt: + emit("Interrupted; the run is incomplete.") + return 130 + except (OSError, ValueError, KeyError, IndexError) as exc: + emit(f"ERROR: {exc}") + return 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except FileExistsError as exc: + print(f"ERROR: Log already exists: {exc.filename}\n" + "Choose a different --log path, or omit --log for terminal output only.", + file=sys.stderr) + sys.exit(1) + except OSError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/docs/fordevs/spras.analysis.rst b/docs/fordevs/spras.analysis.rst index fae5b75de..970538ad3 100644 --- a/docs/fordevs/spras.analysis.rst +++ b/docs/fordevs/spras.analysis.rst @@ -15,6 +15,15 @@ :undoc-members: :show-inheritance: +**************************** + spras.analysis.lpca module +**************************** + +.. automodule:: spras.analysis.lpca + :members: + :undoc-members: + :show-inheritance: + ************************** spras.analysis.ml module ************************** diff --git a/spras/analysis/lpca.py b/spras/analysis/lpca.py new file mode 100644 index 000000000..78160b00a --- /dev/null +++ b/spras/analysis/lpca.py @@ -0,0 +1,100 @@ +"""Fixed-m, two-component LPCA of pathway graphs from one or more algorithms.""" + +from os import PathLike +from pathlib import Path +from tempfile import TemporaryDirectory + +import matplotlib.pyplot as plt +import pandas as pd +import seaborn as sns +from adjustText import adjust_text + +from spras.analysis.ml import DPI, create_palette +from spras.config.container_schema import ProcessedContainerSettings +from spras.containers import prepare_volume, run_container_and_log +from spras.util import make_required_dirs + +# The registry prefix is resolved from the container settings. +LPCA_CONTAINER_SUFFIX = 'lpca:v1' +LPCA_WORK_DIR = '/app' + + +def run_lpca( + dataframe: pd.DataFrame, + output_png: str | PathLike, + output_deviance: str | PathLike, + output_coord: str | PathLike, + output_matrix: str | PathLike, + k: int = 2, + m: float = 6, + labels: bool = True, + container_settings: ProcessedContainerSettings | None = None, +) -> None: + """Fit and plot the graphs in the edges-by-runs summarize_networks dataframe. + + The caller selects runs from one algorithm or across algorithms. Containerized R validates + the inputs and numerical fit. Python writes the input matrix, calls R, and + produces a plot and tab-separated coordinates with one row per input graph. + The deviance summary is written by R; its raw scores are temporary. + """ + if container_settings is None: + container_settings = ProcessedContainerSettings() + for path in (output_png, output_deviance, output_coord, output_matrix): + make_required_dirs(path) + output_dir = Path(output_png).parent + matrix = dataframe.T + matrix.to_csv(output_matrix, index_label='datapoint_labels') + print(f'LPCA: Matrix shape: {matrix.shape}; k={k}, m={m}') + + with TemporaryDirectory(prefix='.lpca-', dir=output_dir) as temp_dir: + scores_file = Path(temp_dir) / 'scores.csv' + matrix_volume, mapped_matrix = prepare_volume(output_matrix, LPCA_WORK_DIR, container_settings) + scores_volume, mapped_scores = prepare_volume(scores_file, LPCA_WORK_DIR, container_settings) + deviance_volume, mapped_deviance = prepare_volume(output_deviance, LPCA_WORK_DIR, container_settings) + volumes = [matrix_volume, scores_volume, deviance_volume] + command = ['Rscript', '/app/run_lpca.R', mapped_matrix, mapped_scores, + str(k), str(m), mapped_deviance] + run_container_and_log('LPCA', LPCA_CONTAINER_SUFFIX, command, volumes, + LPCA_WORK_DIR, output_dir, container_settings) + + # Preserve identifiers and require one pair of scores per input graph. + scores = pd.read_csv(scores_file, index_col='datapoint_labels', + dtype={'datapoint_labels': str}, keep_default_na=False) + if (list(scores.columns) != ['PC1', 'PC2'] or + scores.index.tolist() != matrix.index.tolist()): + raise ValueError('LPCA scores must contain PC1/PC2 and the input run labels in order.') + + with open(output_deviance) as summary_file: + summary = dict(line.strip().split(': ', 1) for line in summary_file) + percent_deviance = float(summary['percent_deviance_explained']) + plot_lpca(scores, output_png, output_coord, percent_deviance, labels=labels) + + +def plot_lpca( + scores: pd.DataFrame, + output_png: str | PathLike, + output_coord: str | PathLike, + percent_deviance: float, + labels: bool = True, +) -> None: + """Plot run scores and save their coordinates; output directories must exist.""" + scores.rename_axis('datapoint_labels').round(8).to_csv(output_coord, sep='\t') + column_names = [name.split('-')[-3] if name.count('-') >= 2 else name + for name in scores.index] + points = scores.to_numpy() + fig, ax = plt.subplots(figsize=(10, 7)) + try: + sns.scatterplot(x=points[:, 0], y=points[:, 1], hue=column_names, + palette=create_palette(column_names), s=70, ax=ax) + ax.set_xlabel('PC1') + ax.set_ylabel('PC2') + ax.set_title(f'Logistic PCA ({percent_deviance:.1f}% deviance explained)') + fig.tight_layout() + if labels: + texts = [ax.text(x, y, name, size=10) + for (x, y), name in zip(points, scores.index, strict=True)] + adjust_text(texts, ax=ax, force_points=(5.0, 5.0), + arrowprops=dict(arrowstyle='->', color='black')) + fig.savefig(output_png, dpi=DPI) + finally: + plt.close(fig) diff --git a/spras/analysis/ml.py b/spras/analysis/ml.py index 55abae5a8..2b847e87f 100644 --- a/spras/analysis/ml.py +++ b/spras/analysis/ml.py @@ -154,9 +154,9 @@ def pca(dataframe: pd.DataFrame, output_png: str | PathLike, output_var: str | P if not isinstance(labels, bool): raise ValueError(f"labels={labels} must be True or False") - # center binary data by subtracting the column-wise mean + # For the logistic PCA alternative, see spras.analysis.lpca.run_lpca. + # Center binary data by subtracting the column-wise mean # allows PCA to focus on edge inclusion patterns across runs rather than raw output volume. - # TODO: replace PCA https://github.com/Reed-CompBio/spras/issues/271 scaler = StandardScaler(with_std=False) scaler.fit(X) # compute mean inclusion rate per edge X_scaled = scaler.transform(X) diff --git a/spras/config/config.py b/spras/config/config.py index ebf10faad..f29bc3bef 100644 --- a/spras/config/config.py +++ b/spras/config/config.py @@ -86,6 +86,8 @@ def __init__(self, raw_config: dict[str, Any]): self.evaluation_params = self.analysis_params.evaluation # A dict with the ML settings self.ml_params = self.analysis_params.ml + # A dict with the LPCA settings + self.lpca_params = self.analysis_params.lpca # A Boolean specifying whether to run ML analysis for individual algorithms self.analysis_include_ml_aggregate_algo = None # A dict with the PCA settings @@ -96,6 +98,8 @@ def __init__(self, raw_config: dict[str, Any]): self.analysis_include_summary = None # A Boolean specifying whether to run the Cytoscape analysis self.analysis_include_cytoscape = None + # A Boolean specifying whether to run the LPCA analysis + self.analysis_include_lpca = None # A Boolean specifying whether to run the ML analysis self.analysis_include_ml = None # A Boolean specifying whether to run the Evaluation analysis @@ -254,6 +258,7 @@ def process_analysis(self, raw_config: RawConfig): self.analysis_include_summary = raw_config.analysis.summary.include self.analysis_include_cytoscape = raw_config.analysis.cytoscape.include self.analysis_include_ml = raw_config.analysis.ml.include + self.analysis_include_lpca = raw_config.analysis.lpca.include self.analysis_include_evaluation = raw_config.analysis.evaluation.include # Only run ML aggregate per algorithm if analysis include ML is set to True @@ -262,6 +267,12 @@ def process_analysis(self, raw_config: RawConfig): else: self.analysis_include_ml_aggregate_algo = False + # Only run LPCA aggregate per algorithm if analysis include LPCA is set to True + if self.analysis_include_lpca and raw_config.analysis.lpca.aggregate_per_algorithm: + self.analysis_include_lpca_aggregate_algo = raw_config.analysis.lpca.aggregate_per_algorithm + else: + self.analysis_include_lpca_aggregate_algo = False + # Raises an error if Evaluation is enabled but no gold standard data is provided if self.gold_standards == {} and self.analysis_include_evaluation: raise ValueError("Evaluation analysis cannot run as gold standard data not provided. " diff --git a/spras/config/schema.py b/spras/config/schema.py index 1a965c75c..36e26db10 100644 --- a/spras/config/schema.py +++ b/spras/config/schema.py @@ -10,9 +10,9 @@ - `CaseInsensitiveEnum` (see ./util.py) """ -from typing import Annotated +from typing import Annotated, Literal -from pydantic import AfterValidator, BaseModel, ConfigDict +from pydantic import AfterValidator, BaseModel, ConfigDict, Field from spras.config.algorithms import AlgorithmUnion from spras.config.container_schema import ContainerSettings @@ -67,10 +67,20 @@ class EvaluationAnalysis(BaseModel): model_config = ConfigDict(extra='forbid') +class LpcaAnalysis(BaseModel): + include: bool + aggregate_per_algorithm: bool = False + k: Literal[2] = 2 # only support k=2 currently + m: float = Field(default=6, gt=0, allow_inf_nan=False) + labels: bool = True + + model_config = ConfigDict(extra='forbid') + class Analysis(BaseModel): summary: SummaryAnalysis = SummaryAnalysis(include=False) cytoscape: CytoscapeAnalysis = CytoscapeAnalysis(include=False) ml: MlAnalysis = MlAnalysis(include=False) + lpca: LpcaAnalysis = LpcaAnalysis(include=False) evaluation: EvaluationAnalysis = EvaluationAnalysis(include=False) model_config = ConfigDict(extra='forbid') diff --git a/spras/containers.py b/spras/containers.py index c30697f3f..15573dc8d 100644 --- a/spras/containers.py +++ b/spras/containers.py @@ -359,7 +359,7 @@ def run_container_docker(container: str, command: List[str], volumes: List[Tuple # Initialize a Docker client using environment variables try: - client = docker.from_env() + client = docker.from_env(timeout=600) except Exception as err: err.add_note("An error occurred when fetching the docker daemon: is docker installed and is dockerd running?") raise err diff --git a/test/analysis/input/lpca/pathway-params-1.txt b/test/analysis/input/lpca/pathway-params-1.txt new file mode 100644 index 000000000..affde9164 --- /dev/null +++ b/test/analysis/input/lpca/pathway-params-1.txt @@ -0,0 +1,6 @@ +Node1 Node2 Rank Direction +A B 1 U +B C 1 U +C D 1 U +D E 1 U +E F 1 U diff --git a/test/analysis/input/lpca/pathway-params-2.txt b/test/analysis/input/lpca/pathway-params-2.txt new file mode 100644 index 000000000..b1c72f8b4 --- /dev/null +++ b/test/analysis/input/lpca/pathway-params-2.txt @@ -0,0 +1,6 @@ +Node1 Node2 Rank Direction +A B 1 U +B C 1 U +C G 1 U +G H 1 U +H I 1 U diff --git a/test/analysis/input/lpca/pathway-params-3.txt b/test/analysis/input/lpca/pathway-params-3.txt new file mode 100644 index 000000000..8211a5a56 --- /dev/null +++ b/test/analysis/input/lpca/pathway-params-3.txt @@ -0,0 +1,6 @@ +Node1 Node2 Rank Direction +A B 1 U +D E 1 U +E F 1 U +F J 1 U +J K 1 U diff --git a/test/analysis/input/lpca/pathway-params-4.txt b/test/analysis/input/lpca/pathway-params-4.txt new file mode 100644 index 000000000..67f1cf554 --- /dev/null +++ b/test/analysis/input/lpca/pathway-params-4.txt @@ -0,0 +1,6 @@ +Node1 Node2 Rank Direction +B C 1 U +C D 1 U +G H 1 U +H I 1 U +I L 1 U diff --git a/test/analysis/test_lpca.py b/test/analysis/test_lpca.py new file mode 100644 index 000000000..42ff48285 --- /dev/null +++ b/test/analysis/test_lpca.py @@ -0,0 +1,121 @@ +"""Test SPRAS integration; numerical edge cases belong in docker-wrappers/LPCA/test_container.py.""" + +import shutil +from pathlib import Path +from unittest.mock import Mock + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import pytest +from scipy.spatial.distance import pdist + +from spras.analysis import lpca +from spras.analysis.ml import summarize_networks + +INPUT_DIR = Path(__file__).parent / 'input' / 'lpca' +# Existing four-graph fixture, logisticPCA 0.2, k=2, m=4. Distances allow a +# common change of axis orientation, not independent sign changes per graph. +REFERENCE_SCORES = np.array([ + [4.96756989310632, -7.80927209388169], + [-7.42875819587666, 7.21294742051829], + [12.7488840407735, 1.711517188498], + [-11.2979872320273, -5.51380214217161], +]) +REFERENCE_DEVIANCE = 91.92536834497237 + + +@pytest.fixture +def outputs(tmp_path): + return { + 'output_png': tmp_path / 'lpca.png', + 'output_deviance': tmp_path / 'lpca-deviance.txt', + 'output_coord': tmp_path / 'lpca-coordinates.txt', + 'output_matrix': tmp_path / 'lpca-binary-matrix.csv', + } + + +@pytest.mark.parametrize('per_algorithm', [False, True], ids=['all_algorithms', 'per_algorithm']) +def test_lpca_container(tmp_path, outputs, per_algorithm): + algorithms = ['allpairs'] * 4 if per_algorithm else ['allpairs', 'allpairs', 'meo', 'meo'] + paths = [] + for i, algorithm in enumerate(algorithms, start=1): + # summarize_networks derives run identifiers from parent directories. + path = tmp_path / 'input graphs' / f'dataset-{algorithm}-params-{i:07d}' / 'pathway.txt' + path.parent.mkdir(parents=True) + shutil.copyfile(INPUT_DIR / f'pathway-params-{i}.txt', path) + paths.append(path) + networks = summarize_networks(paths) + lpca.run_lpca(networks, **outputs, m=4, labels=not per_algorithm) + + assert all(path.is_file() for path in outputs.values()) + assert outputs['output_png'].read_bytes().startswith(b'\x89PNG\r\n\x1a\n') + matrix = pd.read_csv(outputs['output_matrix'], index_col='datapoint_labels') + pd.testing.assert_frame_equal(matrix, networks.T.rename_axis('datapoint_labels')) + coordinates = pd.read_csv(outputs['output_coord'], sep='\t') + assert list(coordinates.columns) == ['datapoint_labels', 'PC1', 'PC2'] + assert coordinates['datapoint_labels'].tolist() == list(networks.columns) + points = coordinates[['PC1', 'PC2']].to_numpy() + assert points.shape == (4, 2) + assert np.isfinite(points).all() + np.testing.assert_allclose(pdist(points), pdist(REFERENCE_SCORES), atol=1e-3, rtol=0) + summary = dict(line.split(': ', 1) + for line in outputs['output_deviance'].read_text().splitlines()) + assert float(summary['components']) == 2 + assert float(summary['m']) == 4 + assert float(summary['percent_deviance_explained']) == pytest.approx( + REFERENCE_DEVIANCE, abs=1e-3, rel=0) + assert not list(tmp_path.glob('.lpca-*')) + + +@pytest.mark.parametrize('labels', [True, False]) +def test_plot_lpca(outputs, monkeypatch, labels): + names = ['001', 'NA', 'dataset-meo-params-CCCCCCC', 'run-four'] + scores = pd.DataFrame(REFERENCE_SCORES, columns=['PC1', 'PC2'], index=names) + # Mock records calls without moving labels, so we can inspect what our + # plotting code passes to the label-placement function. + adjust = Mock() + # Replace the name used by lpca only for this test. The monkeypatch + # fixture restores the original function afterward, even on failure. + monkeypatch.setattr(lpca, 'adjust_text', adjust) + lpca.plot_lpca(scores, outputs['output_png'], outputs['output_coord'], + REFERENCE_DEVIANCE, labels=labels) + + coordinates = pd.read_csv(outputs['output_coord'], sep='\t', + dtype={'datapoint_labels': str}, keep_default_na=False) + assert list(coordinates.columns) == ['datapoint_labels', 'PC1', 'PC2'] + assert coordinates['datapoint_labels'].tolist() == names + np.testing.assert_allclose(coordinates[['PC1', 'PC2']], REFERENCE_SCORES, + atol=1e-8, rtol=0) + assert outputs['output_png'].read_bytes().startswith(b'\x89PNG\r\n\x1a\n') + if labels: + adjust.assert_called_once() + # call_args stores the last call; kwargs contains named arguments. + ax = adjust.call_args.kwargs['ax'] + assert len(ax.collections) == 1 # Only graph coordinates; no extra plotted points. + np.testing.assert_allclose(ax.collections[0].get_offsets(), REFERENCE_SCORES) + assert ax.get_xlabel() == 'PC1' + assert ax.get_ylabel() == 'PC2' + assert ax.get_title() == 'Logistic PCA (91.9% deviance explained)' + # args[0] is the first positional argument: the list of text labels. + assert [text.get_text() for text in adjust.call_args.args[0]] == names + assert not plt.fignum_exists(ax.figure.number) + else: + adjust.assert_not_called() + + +def test_container_failure_propagates(tmp_path, outputs, monkeypatch): + # side_effect raises this error when the mock is called, simulating a + # failed container without starting Docker. The call is still recorded. + failure = Mock(side_effect=RuntimeError('container failed')) + # Replace lpca's imported function; pytest restores it after this test. + monkeypatch.setattr(lpca, 'run_container_and_log', failure) + networks = pd.DataFrame(np.eye(3), columns=['a', 'b', 'c']) + with pytest.raises(RuntimeError, match='container failed'): + lpca.run_lpca(networks, **outputs) + failure.assert_called_once() + # The sixth positional argument is the container helper's output directory. + assert failure.call_args.args[5] == tmp_path + assert not outputs['output_png'].exists() + assert not outputs['output_coord'].exists() + assert not list(tmp_path.glob('.lpca-*')) diff --git a/test/test_config.py b/test/test_config.py index a7231a93e..bc428a1a5 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -460,3 +460,31 @@ def test_eval_summary_coupling(self, eval_include, summary_include, expected_eva assert config.config.analysis_include_evaluation == expected_eval assert config.config.analysis_include_summary == expected_summary + + @pytest.mark.parametrize("include, aggregate", [ + (False, False), (False, True), (True, False), (True, True) + ]) + def test_lpca_options(self, include, aggregate): + test_config = get_test_config() + test_config["analysis"]["lpca"] = { + "include": include, "aggregate_per_algorithm": aggregate, + "m": 4.5, "labels": False, + } + parsed = config.Config(test_config) + assert parsed.analysis_include_lpca == include + assert parsed.analysis_include_lpca_aggregate_algo == (include and aggregate) + assert not parsed.analysis_include_ml + assert parsed.lpca_params.k == 2 + assert parsed.lpca_params.m == 4.5 + assert not parsed.lpca_params.labels + + @pytest.mark.parametrize("options", [ + {"k": 1}, {"k": 3}, {"k": 2.5}, + {"m": 0}, {"m": -1}, {"m": float("inf")}, {"m": float("nan")}, + {"cv": False}, {"cv": True}, {"kde": True}, + ]) + def test_lpca_invalid_options(self, options): + test_config = get_test_config() + test_config["analysis"]["lpca"] = {"include": True, **options} + with pytest.raises(ValueError): + config.Config(test_config) diff --git a/test/test_lpca_workflow.py b/test/test_lpca_workflow.py new file mode 100644 index 000000000..987de1548 --- /dev/null +++ b/test/test_lpca_workflow.py @@ -0,0 +1,167 @@ +"""Test LPCA scheduling and reruns using existing graph fixtures, not reconstruction.""" + +import csv +import shutil +import subprocess +import sys +from pathlib import Path + +import pandas as pd +import pytest +import yaml + +from spras.config.config import Config + +REPO = Path(__file__).resolve().parents[1] +LPCA_FILES = ('lpca.png', 'lpca-deviance.txt', 'lpca-coordinates.txt', 'lpca-binary-matrix.csv') + + +@pytest.fixture +def workflow_config(tmp_path): + raw = { + 'containers': {'registry': {}}, + 'immutable_files': False, + 'datasets': [{'label': 'toy', 'data_dir': '.', + 'node_files': [], 'edge_files': [], 'other_files': []}], + 'algorithms': [ + {'name': 'pathlinker', 'include': True, 'runs': {'test': {'k': [1, 2, 3, 4]}}}, + {'name': 'meo', 'include': True, 'runs': {'test': {'max_path_length': [1, 2]}}}, + ], + 'reconstruction_settings': {'locations': {'reconstruction_dir': 'output'}}, + 'analysis': { + # Disable PCA/HAC explicitly; LPCA does not depend on them. + 'ml': {'include': False}, + 'lpca': {'include': True, 'aggregate_per_algorithm': True, + 'm': 4, 'labels': False}, + }, + } + parsed = Config(raw) + logs_dir = tmp_path / 'output' / 'logs' + logs_dir.mkdir(parents=True) + # Seed completed reconstruction inputs. Only LPCA and the final-target rule + # are allowed below, so no reconstruction container or input dataset is needed. + for algorithm, combinations in parsed.algorithm_params.items(): + for i, params_hash in enumerate(combinations, start=1): + run = f'{algorithm}-params-{params_hash}' + pathway_file = tmp_path / 'output' / f'toy-{run}' / 'pathway.txt' + fixture_file = REPO / 'test/analysis/input/lpca' / f'pathway-params-{i}.txt' + pathway_file.parent.mkdir() + shutil.copyfile(fixture_file, pathway_file) + # The final target requires these logs, but LPCA does not read them. + parameter_log = logs_dir / f'parameters-{run}.yaml' + parameter_log.write_text('{}\n', encoding='utf-8') + dataset_log = logs_dir / 'datasets-toy.yaml' + dataset_log.write_text('{}\n', encoding='utf-8') + return raw + + +def run_workflow(tmp_path, raw, *options): + config_file = tmp_path / 'config.yaml' + config_file.write_text(yaml.safe_dump(raw), encoding='utf-8') + # Run the actual Snakefile in an isolated directory, including its metadata. + snakefile = REPO / 'Snakefile' + result = subprocess.run( + [sys.executable, '-m', 'snakemake', 'all', '--snakefile', str(snakefile), + '--configfile', str(config_file), '--cores', '1', '--nocolor', *options, + '--allowed-rules', 'all', 'lpca_analysis_all', 'lpca_analysis_aggregate_algo'], + cwd=tmp_path, capture_output=True, text=True, timeout=300, + ) + print(result.stdout) + print(result.stderr) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout + + +def lpca_summary(tmp_path, raw): + # --summary builds the real DAG without executing it and reports each output's + # status and update plan. It can test scheduling without a running Docker daemon. + text = run_workflow(tmp_path, raw, '--summary') + summary_rows = csv.DictReader(text.splitlines(), delimiter='\t') + lpca_rows = {} + for row in summary_rows: + if row['rule'].startswith('lpca_analysis_'): + output_file = row['output_file'].replace('\\', '/') + lpca_rows[output_file] = row + return lpca_rows + + +def expected_outputs(aggregate): + prefixes = [''] # The combined analysis has no algorithm prefix. + if aggregate: + prefixes.append('pathlinker-') + + outputs = set() + for prefix in prefixes: + for name in LPCA_FILES: + output_file = f'output/toy-ml/{prefix}{name}' + outputs.add(output_file) + return outputs + + +@pytest.mark.parametrize('include, aggregate', [ + (False, False), (False, True), (True, False), (True, True), +]) +def test_lpca_targets(tmp_path, workflow_config, include, aggregate): + workflow_config['analysis']['lpca'].update( + include=include, aggregate_per_algorithm=aggregate) + rows = lpca_summary(tmp_path, workflow_config) + assert set(rows) == (expected_outputs(aggregate) if include else set()) + # The two-run algorithm contributes to the combined fit but is not eligible + # for its own fit. The fixture explicitly sets analysis.ml.include=False. + assert not any('meo-lpca' in name for name in rows) + + +def test_lpca_workflow_reruns(tmp_path, workflow_config): + """Requires Docker and the LPCA image; never forces a rerun.""" + # Reuse the same directory so Snakemake retains the first run's metadata. + # Create the outputs at m=4, then change only m to verify automatic reruns. + for m in (4, 6): + workflow_config['analysis']['lpca']['m'] = m + before = lpca_summary(tmp_path, workflow_config) + assert set(before) == expected_outputs(True) + assert all(row['plan'] == 'update pending' for row in before.values()) + if m == 6: + # These outputs already exist, so the reason must be changed params. + assert all(row['status'] == 'params changed' for row in before.values()) + + # Execute normally, then check that another invocation needs no work. + run_workflow(tmp_path, workflow_config) + after = lpca_summary(tmp_path, workflow_config) + assert set(after) == expected_outputs(True) + assert all(row['plan'] == 'no update' for row in after.values()) + + # Combined: four PathLinker and two MEO graphs. Separate: PathLinker only. + output_dir = tmp_path / 'output' / 'toy-ml' + for prefix, count in [('', 6), ('pathlinker-', 4)]: + coordinates_file = output_dir / f'{prefix}lpca-coordinates.txt' + matrix_file = output_dir / f'{prefix}lpca-binary-matrix.csv' + deviance_file = output_dir / f'{prefix}lpca-deviance.txt' + coordinates = pd.read_csv(coordinates_file, sep='\t') + matrix = pd.read_csv(matrix_file) + assert len(coordinates) == count + assert coordinates['datapoint_labels'].tolist() == matrix['datapoint_labels'].tolist() + if prefix: + assert coordinates['datapoint_labels'].str.contains('-pathlinker-', regex=False).all() + + # Verify the actual fit used the new m, not just that it was scheduled. + summary = {} + for line in deviance_file.read_text().splitlines(): + key, value = line.split(': ', 1) + summary[key] = value + assert float(summary['m']) == m + + # Changing labels should schedule an update; no extra fit is needed to check this. + workflow_config['analysis']['lpca']['labels'] = True + rows = lpca_summary(tmp_path, workflow_config) + assert set(rows) == expected_outputs(True) + assert all(row['status'] == 'params changed' for row in rows.values()) + # Restore the executed setting so the next check isolates a missing output. + workflow_config['analysis']['lpca']['labels'] = False + + # A missing fit summary must schedule its producer even when the plot exists. + missing_output = 'output/toy-ml/lpca-deviance.txt' + missing_file = tmp_path / missing_output + missing_file.unlink() + rows = lpca_summary(tmp_path, workflow_config) + assert rows[missing_output]['status'] == 'missing' + assert rows[missing_output]['plan'] == 'update pending'