From 1e9b21847bc0f0638b6759c3cf0038c876c22815 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Fri, 10 Jul 2026 10:28:56 -0500 Subject: [PATCH 1/2] Add Logistic PCA (LPCA) analysis Adds an analysis.lpca config block (include, k, m, cv, transpose) with a matching LpcaAnalysis schema, an lpca_analysis Snakemake rule restricted to algorithms with multiple parameter combinations, and an LPCA analysis module that builds the binary edge-by-run matrix via summarize_networks and runs the logisticPCA container through run_container_and_log. m is fixed by default; cross-validation and matrix transposition are opt-in. --- Snakefile | 21 ++++++++++ config/config.yaml | 16 +++++++ spras/analysis/lpca.py | 94 ++++++++++++++++++++++++++++++++++++++++++ spras/config/config.py | 5 +++ spras/config/schema.py | 10 +++++ 5 files changed, 146 insertions(+) create mode 100644 spras/analysis/lpca.py diff --git a/Snakefile b/Snakefile index 5ad7aa185..82f208e4f 100644 --- a/Snakefile +++ b/Snakefile @@ -91,6 +91,9 @@ def make_final_input(wildcards): final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}ensemble-pathway.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-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}{algorithm}-lpca-scores.csv',out_dir=out_dir, sep=SEP, dataset=dataset_labels, algorithm=algorithms_mult_param_combos)) 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)) @@ -356,6 +359,7 @@ rule ml_analysis: 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) + # Calculated Jaccard similarity between output pathways for each dataset rule jaccard_similarity: input: @@ -403,6 +407,23 @@ 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) +rule lpca_analysis: + input: + pathways = collect_pathways_per_algo + output: + lpca_scores = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca-scores.csv']) + run: + from spras.analysis import lpca + lpca.run_lpca( + input.pathways, + output.lpca_scores, + k=_config.config.lpca_params.k, + m=_config.config.lpca_params.m, + cv=_config.config.lpca_params.cv, + transpose=_config.config.lpca_params.transpose, + 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..55c8d3afa 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -272,3 +272,19 @@ 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 + # Logistic PCA (LPCA) of the binary edge-by-run matrix, one embedding per algorithm + # only runs for algorithms with multiple parameter combinations chosen + lpca: + # run the LPCA analysis per algorithm + # LPCA needs enough observations to be meaningful + include: false + # number of principal components to compute + k: 2 + # fixed value of the logisticPCA tuning parameter m, used when cv is false + m: 6 + # if true, choose m by cross-validation; if false, use the fixed m above + cv: false + # matrix orientation given to LPCA: + # false = edges x runs (observations are the edges) + # true = runs x edges (observations are the runs, mirroring the classic ml pca analysis) + transpose: false diff --git a/spras/analysis/lpca.py b/spras/analysis/lpca.py new file mode 100644 index 000000000..295fad1ae --- /dev/null +++ b/spras/analysis/lpca.py @@ -0,0 +1,94 @@ +# Logistic PCA analysis for SPRAS +# Runs LPCA on the binary edge x algorithm matrix produced by summarize_networks. +# Configured through the analysis.lpca block (k, m, cv, transpose). + +from os import PathLike +from pathlib import Path +from typing import Iterable, Union + +import pandas as pd + +from spras.analysis.ml import summarize_networks +from spras.config.container_schema import ProcessedContainerSettings +from spras.containers import prepare_volume, run_container_and_log + +# Published as docker.io/reedcompbio/lpca:v1. Only the suffix is given here; +# the registry prefix is resolved from the container settings. +LPCA_CONTAINER_SUFFIX = 'lpca:v1' +LPCA_WORK_DIR = '/app' + + +def run_lpca( + file_paths: Iterable[Union[str, PathLike]], + output_scores: str, + k: int = 2, + m: float = 6, + cv: bool = False, + transpose: bool = False, + container_settings=None +) -> None: + """ + Runs Logistic PCA on the binary edge x algorithm matrix built from SPRAS + algorithm output files. + + @param file_paths: pathway.txt file paths from SPRAS algorithm outputs + @param output_scores: path to write the LPCA PC scores CSV + @param k: number of principal components (default 2) + @param m: fixed logisticPCA tuning parameter, used when cv is False + @param cv: if True, determine m by cross-validation; if False, use the + fixed m directly (default False) + @param transpose: if True, run LPCA on the transposed (runs x edges) matrix + instead of the default (edges x runs) + @param container_settings: configure the container runtime (Docker or Singularity) + """ + if not container_settings: + container_settings = ProcessedContainerSettings() + + # Step 1: build the binary edge x algorithm matrix + print('LPCA: Building binary edge x algorithm matrix...') + matrix = summarize_networks(file_paths) + + # Optionally transpose so the runs become the observations rather than the edges + if transpose: + matrix = matrix.T + print(f'LPCA: Matrix shape: {matrix.shape}') + + # Step 2: write the matrix next to the outputs, namespaced by algorithm + output_dir = Path(output_scores).parent + output_dir.mkdir(parents=True, exist_ok=True) + algo_name = Path(output_scores).name.replace('-lpca-scores.csv', '') + matrix_path = str(output_dir / f'{algo_name}-lpca_binary_matrix.csv') + matrix.to_csv(matrix_path) + print(f'LPCA: Binary matrix saved to {matrix_path}') + + # Step 3: mount the matrix and the scores output + volumes = [] + bind_path, mapped_matrix = prepare_volume(matrix_path, LPCA_WORK_DIR, container_settings) + volumes.append(bind_path) + bind_path, mapped_scores = prepare_volume(output_scores, LPCA_WORK_DIR, container_settings) + volumes.append(bind_path) + + # Step 4: choose m, optionally via cross-validation + if cv: + cv_output_path = str(output_dir / f'{algo_name}-lpca_cv_result.csv') + bind_path, mapped_cv_output = prepare_volume(cv_output_path, LPCA_WORK_DIR, container_settings) + volumes.append(bind_path) + + print(f'LPCA: Running cross-validation with k={k}...') + command_cv = ['Rscript', '/app/run_cv.R', mapped_matrix, mapped_cv_output, str(k)] + run_container_and_log('LPCA-CV', LPCA_CONTAINER_SUFFIX, command_cv, volumes, + LPCA_WORK_DIR, None, container_settings) + + m_used = pd.read_csv(cv_output_path)['best_m'][0] + print(f'LPCA: Best m found by CV: {m_used}') + else: + m_used = m + print(f'LPCA: Using fixed m={m_used}') + + # Step 5: run LPCA with the chosen k and m + print(f'LPCA: Running LPCA with k={k}, m={m_used}...') + command_lpca = ['Rscript', '/app/run_lpca.R', mapped_matrix, mapped_scores, str(k), str(m_used)] + run_container_and_log('LPCA', LPCA_CONTAINER_SUFFIX, command_lpca, volumes, + LPCA_WORK_DIR, None, container_settings) + + print(f'LPCA: Done! Scores saved to {output_scores}') diff --git a/spras/config/config.py b/spras/config/config.py index ebf10faad..e99d965c9 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 diff --git a/spras/config/schema.py b/spras/config/schema.py index 1a965c75c..2a85fe993 100644 --- a/spras/config/schema.py +++ b/spras/config/schema.py @@ -67,10 +67,20 @@ class EvaluationAnalysis(BaseModel): model_config = ConfigDict(extra='forbid') +class LpcaAnalysis(BaseModel): + include: bool + k: int = 2 + m: float = 6 + cv: bool = False + transpose: bool = False + + 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') From bb338a81efa621a66ff22e06fad7009d01f0e078 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Fri, 10 Jul 2026 11:23:10 -0500 Subject: [PATCH 2/2] Add LPCA Docker wrapper Adds docker-wrappers/lpca with a pinned rocker/r-base Dockerfile that installs logisticPCA and its ggplot2 dependencies, the run_lpca.R and run_cv.R scripts under /app, and a README documenting the config options, script contracts, and how to build and publish reedcompbio/lpca:v1. --- docker-wrappers/lpca/Dockerfile | 31 ++++++++++++++++ docker-wrappers/lpca/README.md | 65 +++++++++++++++++++++++++++++++++ docker-wrappers/lpca/run_cv.R | 36 ++++++++++++++++++ docker-wrappers/lpca/run_lpca.R | 30 +++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 docker-wrappers/lpca/Dockerfile create mode 100644 docker-wrappers/lpca/README.md create mode 100644 docker-wrappers/lpca/run_cv.R create mode 100644 docker-wrappers/lpca/run_lpca.R diff --git a/docker-wrappers/lpca/Dockerfile b/docker-wrappers/lpca/Dockerfile new file mode 100644 index 000000000..69691fabd --- /dev/null +++ b/docker-wrappers/lpca/Dockerfile @@ -0,0 +1,31 @@ +# Logistic PCA (logisticPCA) wrapper for SPRAS. +# Invoked by spras/analysis/lpca.py, which supplies the full command +# (Rscript /app/run_lpca.R ... or /app/run_cv.R ...), so 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/* + +# logisticPCA is still on CRAN (last published 2016) and pulls in ggplot2. +RUN Rscript -e "install.packages('logisticPCA', repos='https://cran.r-project.org')" \ + && Rscript -e "library(logisticPCA)" + +COPY run_lpca.R /app/run_lpca.R +COPY run_cv.R /app/run_cv.R + +WORKDIR /app \ No newline at end of file diff --git a/docker-wrappers/lpca/README.md b/docker-wrappers/lpca/README.md new file mode 100644 index 000000000..37dabc71e --- /dev/null +++ b/docker-wrappers/lpca/README.md @@ -0,0 +1,65 @@ +# LPCA (Logistic PCA) wrapper + +This wrapper runs [logisticPCA](https://github.com/andland/logisticPCA) +(Landgraf & Lee, 2020) as a SPRAS analysis step. It reduces the binary +edge-by-run matrix built from a set of pathway reconstruction outputs to a small +number of components and reports the proportion of deviance explained. + +The analysis is driven by the `analysis.lpca` config block and the +`lpca_analysis` Snakemake rule, and is implemented in `spras/analysis/lpca.py`. + +## Configuration + + analysis: + lpca: + include: false # run the LPCA analysis per algorithm + k: 2 # number of principal components + m: 6 # fixed logisticPCA tuning parameter, used when cv is false + cv: false # true: choose m by cross-validation; false: use the fixed m + transpose: false # false: edges x runs; true: runs x edges (mirrors the ml pca analysis) + +LPCA only runs for algorithms with multiple parameter combinations, so that the +binary matrix has more than one column. It also needs a reasonable number of +observations to be meaningful; very small inputs (such as the bundled example +datasets) produce degenerate results, which is why it is disabled by default. + +## Scripts + +The image contains two R scripts under `/app`: + +- `run_lpca.R `: runs logisticPCA with a fixed `m` and + writes the scores CSV plus a sibling `_deviance.txt`. +- `run_cv.R `: cross-validates `m` over 1..20 and writes a + CSV with a `best_m` column (plus a `_curve.csv` with the full CV curve). Only + used when `cv: true`. + +Both read a CSV whose first column holds row labels and whose remaining columns +are binary (0/1) features, and coerce missing values to 0. + +## Dependency note + +`logisticPCA` declares `ggplot2` as a hard `Imports` dependency, so building the +image compiles the ggplot2 stack. The Dockerfile installs the required Debian +system libraries for that. To build from a source CRAN mirror the `repos` +argument already points at `https://cran.r-project.org`. + +## Building and publishing the image + +For the SPRAS default registry to resolve the image, it must be published as +`docker.io/reedcompbio/lpca:v1`, which requires access to the `reedcompbio` +Docker Hub organization: + + docker build -t reedcompbio/lpca:v1 docker-wrappers/lpca/ + docker push reedcompbio/lpca:v1 + +## How SPRAS resolves the image + +SPRAS builds the image reference as `//`. +The default is `docker.io/reedcompbio`, combined with the tag `lpca:v1`, so the +resolved image is `docker.io/reedcompbio/lpca:v1`. The owner is never hardcoded; +it comes from config. + +To test against a local image before it is hosted under `reedcompbio`, tag the +built image with that name locally (Docker uses a local image without pulling): + + docker tag reedcompbio/lpca:v1 \ No newline at end of file diff --git a/docker-wrappers/lpca/run_cv.R b/docker-wrappers/lpca/run_cv.R new file mode 100644 index 000000000..7301d3419 --- /dev/null +++ b/docker-wrappers/lpca/run_cv.R @@ -0,0 +1,36 @@ +# run_cv.R +# Finds the optimal m for a given k using cross-validation + +args = commandArgs(trailingOnly = TRUE) +input_file = args[1] +output_file = args[2] +k = as.integer(args[3]) + +set.seed(42) + +# Load data +library(logisticPCA) +data = read.csv(input_file, row.names = NULL) +data = data[, -1] +data_matrix = as.matrix(data) +data_matrix[is.na(data_matrix)] = 0 + +# Cross-validation over m, fixed k +cv_result = cv.lpca(data_matrix, ks = k, ms = 1:20) +best_m = which.min(cv_result) + +cat("Cross-validation done for k =", k, "\n") +cat("Best m:", best_m, "\n") + +# Save best m +write.csv(data.frame(k = k, best_m = best_m), output_file, row.names = FALSE) + +# Save full CV curve (all m values and their reconstruction error) +cv_curve_file = sub("\\.csv$", "_curve.csv", output_file) +cv_df = data.frame( + m = 1:20, + reconstruction_error = as.numeric(cv_result), + is_best = (1:20) == best_m +) +write.csv(cv_df, cv_curve_file, row.names = FALSE) +cat("CV curve saved to", cv_curve_file, "\n") \ No newline at end of file diff --git a/docker-wrappers/lpca/run_lpca.R b/docker-wrappers/lpca/run_lpca.R new file mode 100644 index 000000000..a0cf095a1 --- /dev/null +++ b/docker-wrappers/lpca/run_lpca.R @@ -0,0 +1,30 @@ +# Read command line arguments +args = commandArgs(trailingOnly = TRUE) +input_file = args[1] +output_file = args[2] +k = as.integer(args[3]) +m = as.numeric(args[4]) + +# Load data +library(logisticPCA) +data = read.csv(input_file, row.names = NULL) +party = data[, 1] +data = data[, -1] +data_matrix = as.matrix(data) +data_matrix[is.na(data_matrix)] = 0 + +# Run LPCA +model = logisticPCA(data_matrix, k = k, m = m) + +# Save scores +scores = model$PCs +rownames(scores) = party +write.csv(scores, output_file, row.names = TRUE) + +# Save deviance explained +deviance_file = sub("\\.csv$", "_deviance.txt", output_file) +writeLines(as.character(model$prop_deviance_expl), deviance_file) + +cat("LPCA done! Scores saved to", output_file, "\n") +cat("Score dimensions:", nrow(scores), "x", ncol(scores), "\n") +cat("Proportion of deviance explained:", model$prop_deviance_expl, "\n") \ No newline at end of file