From 1e9b21847bc0f0638b6759c3cf0038c876c22815 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Fri, 10 Jul 2026 10:28:56 -0500 Subject: [PATCH 01/18] 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 02/18] 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 From efd10058eeb723b6ec2be8bfb45b3f98b5e41ce5 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Tue, 21 Jul 2026 12:40:30 -0500 Subject: [PATCH 03/18] Add unit tests for LPCA analysis --- test/analysis/test_lpca.py | 83 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 test/analysis/test_lpca.py diff --git a/test/analysis/test_lpca.py b/test/analysis/test_lpca.py new file mode 100644 index 000000000..9b861333f --- /dev/null +++ b/test/analysis/test_lpca.py @@ -0,0 +1,83 @@ +from pathlib import Path + +import pandas as pd + +import spras.config.config as config +from spras.analysis.lpca import run_lpca + +config.init_from_file("config/config.yaml") + +TEST_DIR = Path('test/analysis/') +OUT_DIR = TEST_DIR / 'output' + +# Reuse pathway files from the evaluate test directory +INPUT_FILES = [ + 'test/evaluate/input/data-test-params-123/pathway.txt', + 'test/evaluate/input/data-test-params-456/pathway.txt', + 'test/evaluate/input/data-test-params-789/pathway.txt', +] + +class TestLpca: + """ + Run Logistic PCA (LPCA) analysis tests + """ + @classmethod + def setup_class(cls): + OUT_DIR.mkdir(parents=True, exist_ok=True) + + def test_lpca_output_exists(self): + """Test that LPCA produces an output scores file""" + out_path = OUT_DIR / 'lpca-scores.csv' + out_path.unlink(missing_ok=True) + + run_lpca( + file_paths=INPUT_FILES, + output_scores=str(out_path), + k=2, + m=4, + cv=False, + transpose=False + ) + + assert out_path.exists(), "LPCA scores file was not created" + + def test_lpca_output_shape(self): + """Test that LPCA scores have the correct shape (edges x k)""" + out_path = OUT_DIR / 'lpca-scores-shape.csv' + out_path.unlink(missing_ok=True) + + run_lpca( + file_paths=INPUT_FILES, + output_scores=str(out_path), + k=2, + m=4, + cv=False, + transpose=False + ) + + scores = pd.read_csv(out_path, index_col=0) + # k=2 so should have 2 columns + assert scores.shape[1] == 2, f"Expected 2 PC columns, got {scores.shape[1]}" + # Should have at least 1 row (edge) + assert scores.shape[0] > 0, "Scores file is empty" + + def test_lpca_transposed_shape(self): + """Test that transposed LPCA scores have the correct shape (runs x k)""" + out_path = OUT_DIR / 'lpca-scores-transposed.csv' + out_path.unlink(missing_ok=True) + + run_lpca( + file_paths=INPUT_FILES, + output_scores=str(out_path), + k=2, + m=4, + cv=False, + transpose=True + ) + + scores = pd.read_csv(out_path, index_col=0) + # k=2 so should have 2 columns + assert scores.shape[1] == 2, f"Expected 2 PC columns, got {scores.shape[1]}" + # Should have 3 rows (one per pathway run) + assert scores.shape[0] == len(INPUT_FILES), \ + f"Expected {len(INPUT_FILES)} rows, got {scores.shape[0]}" From 8f891195783869279441348dbfa76dfef41aa64c Mon Sep 17 00:00:00 2001 From: jeebjean Date: Tue, 21 Jul 2026 14:33:48 -0500 Subject: [PATCH 04/18] Add LPCA visualization: generate lpca.png and lpca-coordinates.txt --- Snakefile | 11 ++++++++++- spras/analysis/lpca.py | 44 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/Snakefile b/Snakefile index 82f208e4f..38614bcb5 100644 --- a/Snakefile +++ b/Snakefile @@ -94,6 +94,8 @@ def make_final_input(wildcards): 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)) + final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}{algorithm}-lpca.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}-lpca-coordinates.txt',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)) @@ -411,7 +413,9 @@ rule lpca_analysis: input: pathways = collect_pathways_per_algo output: - lpca_scores = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca-scores.csv']) + lpca_scores = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca-scores.csv']), + lpca_png = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca.png']), + lpca_coord = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca-coordinates.txt']) run: from spras.analysis import lpca lpca.run_lpca( @@ -423,6 +427,11 @@ rule lpca_analysis: transpose=_config.config.lpca_params.transpose, container_settings=container_settings ) + lpca.plot_lpca( + output.lpca_scores, + output.lpca_png, + output.lpca_coord + ) # Ensemble the output pathways for each dataset per algorithm rule ensemble_per_algo: diff --git a/spras/analysis/lpca.py b/spras/analysis/lpca.py index 295fad1ae..ddab3ca4e 100644 --- a/spras/analysis/lpca.py +++ b/spras/analysis/lpca.py @@ -6,7 +6,9 @@ from pathlib import Path from typing import Iterable, Union +import matplotlib.pyplot as plt import pandas as pd +import seaborn as sns from spras.analysis.ml import summarize_networks from spras.config.container_schema import ProcessedContainerSettings @@ -92,3 +94,45 @@ def run_lpca( LPCA_WORK_DIR, None, container_settings) print(f'LPCA: Done! Scores saved to {output_scores}') + +def plot_lpca(scores_file: str, output_png: str, output_coord: str, labels: bool = True) -> None: + """ + Creates a scatterplot of the first two LPCA principal components. + @param scores_file: path to the LPCA scores CSV file + @param output_png: path to save the scatterplot PNG + @param output_coord: path to save the PC coordinates + @param labels: if True, adds algorithm labels to the plot + """ + scores = pd.read_csv(scores_file, index_col=0) + + if scores.empty: + print('LPCA: Scores file is empty, skipping plot.') + return + + # Extract algorithm names from the index + column_names = [idx.split('-')[-3] if '-' in idx else idx for idx in scores.index] + + X = scores.values + fig, ax = plt.subplots(figsize=(10, 8)) + + sns.scatterplot(x=X[:, 0], y=X[:, 1], hue=column_names, s=70, ax=ax) + + if labels: + for i, label in enumerate(scores.index): + ax.annotate(label, (X[i, 0], X[i, 1]), fontsize=6, alpha=0.7) + + ax.set_xlabel('PC1') + ax.set_ylabel('PC2') + ax.set_title('Logistic PCA') + + plt.tight_layout() + + # Save PNG + Path(output_png).parent.mkdir(parents=True, exist_ok=True) + plt.savefig(output_png, dpi=200) + plt.close() + + # Save coordinates + coord_df = pd.DataFrame(X, columns=['PC1', 'PC2'], index=scores.index) + coord_df.to_csv(output_coord) + print(f'LPCA: Plot saved to {output_png}') From 3d9a38fff9dc1d2c26cf753a397bd01aade95302 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Tue, 21 Jul 2026 15:11:11 -0500 Subject: [PATCH 05/18] Increase Docker timeout to 600s for large LPCA matrices --- config/config.yaml | 26 +++++++++++++------------- spras/containers.py | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/config/config.yaml b/config/config.yaml index 55c8d3afa..395b97c0d 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -103,13 +103,13 @@ algorithms: include: true runs: run1: - b: [5, 6] - w: np.linspace(0,5,2) + b: [2, 4, 6, 8, 10] + w: np.linspace(0,5,5) d: 10 dummy_mode: "file" # Or "terminals", "all", "others" - name: "omicsintegrator2" - include: true + include: false runs: run1: b: 4 @@ -119,7 +119,7 @@ algorithms: g: 3 - name: "meo" - include: true + include: false runs: run1: max_path_length: 3 @@ -127,46 +127,46 @@ algorithms: rand_restarts: 10 - name: "mincostflow" - include: true + include: false runs: run1: flow: 1 capacity: 1 - name: "allpairs" - include: true + include: false - name: "domino" - include: true + include: false runs: run1: slice_threshold: 0.3 module_threshold: 0.05 - name: "strwr" - include: true + include: false runs: run1: alpha: [0.85] threshold: [100, 200] - name: "rwr" - include: true + include: false runs: run1: alpha: [0.85] threshold: [100, 200] - name: "bowtiebuilder" - include: true + include: false - name: "responsenet" - include: true + include: false runs: run1: gamma: [10] - name: "diamond" - include: true + include: false runs: run1: n: 1 @@ -277,7 +277,7 @@ analysis: lpca: # run the LPCA analysis per algorithm # LPCA needs enough observations to be meaningful - include: false + include: true # number of principal components to compute k: 2 # fixed value of the logisticPCA tuning parameter m, used when cv is false 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 From e4b46797afbb3dc5a4ec3278fbda7c97615852cb Mon Sep 17 00:00:00 2001 From: jeebjean Date: Tue, 21 Jul 2026 15:35:16 -0500 Subject: [PATCH 06/18] Restore config.yaml to main defaults, keep only lpca block --- config/config.yaml | 54 +++++++++++----------------------------------- 1 file changed, 13 insertions(+), 41 deletions(-) diff --git a/config/config.yaml b/config/config.yaml index 395b97c0d..6cfdb4f40 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -50,30 +50,6 @@ 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) - # 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 # in the list to reduce the number of assumptions of the program at the cost of making the config a little more involved) @@ -103,13 +79,13 @@ algorithms: include: true runs: run1: - b: [2, 4, 6, 8, 10] - w: np.linspace(0,5,5) + b: [5, 6] + w: np.linspace(0,5,2) d: 10 dummy_mode: "file" # Or "terminals", "all", "others" - name: "omicsintegrator2" - include: false + include: true runs: run1: b: 4 @@ -119,7 +95,7 @@ algorithms: g: 3 - name: "meo" - include: false + include: true runs: run1: max_path_length: 3 @@ -127,46 +103,46 @@ algorithms: rand_restarts: 10 - name: "mincostflow" - include: false + include: true runs: run1: flow: 1 capacity: 1 - name: "allpairs" - include: false + include: true - name: "domino" - include: false + include: true runs: run1: slice_threshold: 0.3 module_threshold: 0.05 - name: "strwr" - include: false + include: true runs: run1: alpha: [0.85] threshold: [100, 200] - name: "rwr" - include: false + include: true runs: run1: alpha: [0.85] threshold: [100, 200] - name: "bowtiebuilder" - include: false + include: true - name: "responsenet" - include: false + include: true runs: run1: gamma: [10] - name: "diamond" - include: false + include: true runs: run1: n: 1 @@ -272,12 +248,8 @@ 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: true + include: false # number of principal components to compute k: 2 # fixed value of the logisticPCA tuning parameter m, used when cv is false From 41b64b7187c4a68f0ab0f51533d3a4ff0e9a7c26 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Tue, 21 Jul 2026 15:53:01 -0500 Subject: [PATCH 07/18] Skip LPCA tests if Docker image not available --- test/analysis/test_lpca.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/test/analysis/test_lpca.py b/test/analysis/test_lpca.py index 9b861333f..cf10c87d1 100644 --- a/test/analysis/test_lpca.py +++ b/test/analysis/test_lpca.py @@ -1,6 +1,8 @@ from pathlib import Path +import docker import pandas as pd +import pytest import spras.config.config as config from spras.analysis.lpca import run_lpca @@ -10,13 +12,28 @@ TEST_DIR = Path('test/analysis/') OUT_DIR = TEST_DIR / 'output' -# Reuse pathway files from the evaluate test directory INPUT_FILES = [ 'test/evaluate/input/data-test-params-123/pathway.txt', 'test/evaluate/input/data-test-params-456/pathway.txt', 'test/evaluate/input/data-test-params-789/pathway.txt', ] +def lpca_image_available(): + """Check if the LPCA Docker image is available locally or on Docker Hub""" + try: + client = docker.from_env() + client.images.get('reedcompbio/lpca:v1') + return True + except docker.errors.ImageNotFound: + return False + except Exception: + return False + +skip_if_no_lpca_image = pytest.mark.skipif( + not lpca_image_available(), + reason='reedcompbio/lpca:v1 Docker image not available' +) + class TestLpca: """ Run Logistic PCA (LPCA) analysis tests @@ -25,6 +42,7 @@ class TestLpca: def setup_class(cls): OUT_DIR.mkdir(parents=True, exist_ok=True) + @skip_if_no_lpca_image def test_lpca_output_exists(self): """Test that LPCA produces an output scores file""" out_path = OUT_DIR / 'lpca-scores.csv' @@ -41,6 +59,7 @@ def test_lpca_output_exists(self): assert out_path.exists(), "LPCA scores file was not created" + @skip_if_no_lpca_image def test_lpca_output_shape(self): """Test that LPCA scores have the correct shape (edges x k)""" out_path = OUT_DIR / 'lpca-scores-shape.csv' @@ -56,11 +75,10 @@ def test_lpca_output_shape(self): ) scores = pd.read_csv(out_path, index_col=0) - # k=2 so should have 2 columns assert scores.shape[1] == 2, f"Expected 2 PC columns, got {scores.shape[1]}" - # Should have at least 1 row (edge) assert scores.shape[0] > 0, "Scores file is empty" + @skip_if_no_lpca_image def test_lpca_transposed_shape(self): """Test that transposed LPCA scores have the correct shape (runs x k)""" out_path = OUT_DIR / 'lpca-scores-transposed.csv' @@ -76,8 +94,6 @@ def test_lpca_transposed_shape(self): ) scores = pd.read_csv(out_path, index_col=0) - # k=2 so should have 2 columns assert scores.shape[1] == 2, f"Expected 2 PC columns, got {scores.shape[1]}" - # Should have 3 rows (one per pathway run) assert scores.shape[0] == len(INPUT_FILES), \ f"Expected {len(INPUT_FILES)} rows, got {scores.shape[0]}" From c49362f78a85c4569eaab500cfe14da81f7de546 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Thu, 23 Jul 2026 13:55:31 -0500 Subject: [PATCH 08/18] Add rARPACK for memory-efficient LPCA with partial_decomp=TRUE --- docker-wrappers/lpca/Dockerfile | 4 ++-- docker-wrappers/lpca/run_lpca.R | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-wrappers/lpca/Dockerfile b/docker-wrappers/lpca/Dockerfile index 69691fabd..66b27893a 100644 --- a/docker-wrappers/lpca/Dockerfile +++ b/docker-wrappers/lpca/Dockerfile @@ -22,8 +22,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && 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)" +RUN Rscript -e "install.packages(c('logisticPCA', 'rARPACK'), repos='https://cran.r-project.org')" \ + && Rscript -e "library(logisticPCA); library(rARPACK)" COPY run_lpca.R /app/run_lpca.R COPY run_cv.R /app/run_cv.R diff --git a/docker-wrappers/lpca/run_lpca.R b/docker-wrappers/lpca/run_lpca.R index a0cf095a1..5947d8e4c 100644 --- a/docker-wrappers/lpca/run_lpca.R +++ b/docker-wrappers/lpca/run_lpca.R @@ -14,7 +14,7 @@ data_matrix = as.matrix(data) data_matrix[is.na(data_matrix)] = 0 # Run LPCA -model = logisticPCA(data_matrix, k = k, m = m) +model = logisticPCA(data_matrix, k = k, m = m, partial_decomp = TRUE) # Save scores scores = model$PCs From 7536d8ccd97c75d86cd878397ed6f7a1a383da8d Mon Sep 17 00:00:00 2001 From: jeebjean Date: Mon, 27 Jul 2026 09:58:25 -0500 Subject: [PATCH 09/18] Address PR review: palette, CV check, KDE note, config docs, rename to LPCA, Snakefile cleanup --- Snakefile | 1 - config/config.yaml | 2 ++ docker-wrappers/{lpca => LPCA}/Dockerfile | 0 docker-wrappers/{lpca => LPCA}/README.md | 2 +- docker-wrappers/{lpca => LPCA}/run_cv.R | 0 docker-wrappers/{lpca => LPCA}/run_lpca.R | 0 spras/analysis/lpca.py | 14 +++++++++++--- spras/analysis/ml.py | 3 ++- 8 files changed, 16 insertions(+), 6 deletions(-) rename docker-wrappers/{lpca => LPCA}/Dockerfile (100%) rename docker-wrappers/{lpca => LPCA}/README.md (96%) rename docker-wrappers/{lpca => LPCA}/run_cv.R (100%) rename docker-wrappers/{lpca => LPCA}/run_lpca.R (100%) diff --git a/Snakefile b/Snakefile index 38614bcb5..a842407c0 100644 --- a/Snakefile +++ b/Snakefile @@ -408,7 +408,6 @@ rule ml_analysis_aggregate_algo: ml.hac_vertical(summary_df, output.hac_image_vertical, output.hac_clusters_vertical, **hac_params) 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 diff --git a/config/config.yaml b/config/config.yaml index 6cfdb4f40..4e4802661 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -249,6 +249,8 @@ analysis: # evaluation per algorithm will not run unless ml include and ml aggregate_per_algorithm are set to true aggregate_per_algorithm: true lpca: + # if true, runs LPCA in addition to the existing PCA (both analyses will run in parallel). + # Running both helps compare the two methods on the same data. include: false # number of principal components to compute k: 2 diff --git a/docker-wrappers/lpca/Dockerfile b/docker-wrappers/LPCA/Dockerfile similarity index 100% rename from docker-wrappers/lpca/Dockerfile rename to docker-wrappers/LPCA/Dockerfile diff --git a/docker-wrappers/lpca/README.md b/docker-wrappers/LPCA/README.md similarity index 96% rename from docker-wrappers/lpca/README.md rename to docker-wrappers/LPCA/README.md index 37dabc71e..2db543e1f 100644 --- a/docker-wrappers/lpca/README.md +++ b/docker-wrappers/LPCA/README.md @@ -1,7 +1,7 @@ # 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 +([Landgraf & Lee, 2020](https://doi.org/10.1016/j.jmva.2020.104668)) 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. diff --git a/docker-wrappers/lpca/run_cv.R b/docker-wrappers/LPCA/run_cv.R similarity index 100% rename from docker-wrappers/lpca/run_cv.R rename to docker-wrappers/LPCA/run_cv.R diff --git a/docker-wrappers/lpca/run_lpca.R b/docker-wrappers/LPCA/run_lpca.R similarity index 100% rename from docker-wrappers/lpca/run_lpca.R rename to docker-wrappers/LPCA/run_lpca.R diff --git a/spras/analysis/lpca.py b/spras/analysis/lpca.py index ddab3ca4e..94c3a31c4 100644 --- a/spras/analysis/lpca.py +++ b/spras/analysis/lpca.py @@ -10,7 +10,7 @@ import pandas as pd import seaborn as sns -from spras.analysis.ml import summarize_networks +from spras.analysis.ml import create_palette, summarize_networks from spras.config.container_schema import ProcessedContainerSettings from spras.containers import prepare_volume, run_container_and_log @@ -42,6 +42,9 @@ def run_lpca( @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) + + Note: KDE-based parameter selection (used by PCA) always uses PCA scores, even when LPCA + is also enabled. KDE integration with LPCA may be added in a future update. """ if not container_settings: container_settings = ProcessedContainerSettings() @@ -61,7 +64,6 @@ def run_lpca( 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 = [] @@ -81,6 +83,11 @@ def run_lpca( run_container_and_log('LPCA-CV', LPCA_CONTAINER_SUFFIX, command_cv, volumes, LPCA_WORK_DIR, None, container_settings) + if not Path(cv_output_path).exists(): + raise FileNotFoundError( + f'LPCA: Cross-validation output not found at {cv_output_path}. ' + 'Check the LPCA Docker container logs for errors.' + ) m_used = pd.read_csv(cv_output_path)['best_m'][0] print(f'LPCA: Best m found by CV: {m_used}') else: @@ -115,7 +122,8 @@ def plot_lpca(scores_file: str, output_png: str, output_coord: str, labels: bool X = scores.values fig, ax = plt.subplots(figsize=(10, 8)) - sns.scatterplot(x=X[:, 0], y=X[:, 1], hue=column_names, s=70, ax=ax) + label_color_map = create_palette(column_names) + sns.scatterplot(x=X[:, 0], y=X[:, 1], hue=column_names, palette=label_color_map, s=70, ax=ax) if labels: for i, label in enumerate(scores.index): diff --git a/spras/analysis/ml.py b/spras/analysis/ml.py index 55abae5a8..459852770 100644 --- a/spras/analysis/ml.py +++ b/spras/analysis/ml.py @@ -156,7 +156,8 @@ def pca(dataframe: pd.DataFrame, output_png: str | PathLike, output_var: str | P # 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 + # TODO: consider replacing PCA with LPCA for binary data https://github.com/Reed-CompBio/spras/issues/271 + # LPCA is now available as an alternative analysis (analysis.lpca in config) scaler = StandardScaler(with_std=False) scaler.fit(X) # compute mean inclusion rate per edge X_scaled = scaler.transform(X) From 63d941d3ebda47ca64e0e5d2ef305fba45f8f2f8 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Mon, 27 Jul 2026 10:37:04 -0500 Subject: [PATCH 10/18] Remove transpose option, fix m doc, update README, rename party variable --- Snakefile | 1 - config/config.yaml | 8 +++----- docker-wrappers/LPCA/README.md | 16 ++++------------ docker-wrappers/LPCA/run_lpca.R | 4 ++-- spras/analysis/lpca.py | 9 ++------- spras/config/schema.py | 1 - 6 files changed, 11 insertions(+), 28 deletions(-) diff --git a/Snakefile b/Snakefile index a842407c0..6476caee7 100644 --- a/Snakefile +++ b/Snakefile @@ -423,7 +423,6 @@ rule lpca_analysis: 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 ) lpca.plot_lpca( diff --git a/config/config.yaml b/config/config.yaml index 4e4802661..f7d98d648 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -254,11 +254,9 @@ analysis: include: false # number of principal components to compute k: 2 - # fixed value of the logisticPCA tuning parameter m, used when cv is false + # fixed value of the logisticPCA tuning parameter m, used when cv is false. + # default of 6 was selected based on cross-validation experiments across multiple + # SPRAS algorithms (see https://github.com/Jeebjean/lpca-spras for details) 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/docker-wrappers/LPCA/README.md b/docker-wrappers/LPCA/README.md index 2db543e1f..6a3a17e85 100644 --- a/docker-wrappers/LPCA/README.md +++ b/docker-wrappers/LPCA/README.md @@ -1,5 +1,9 @@ # LPCA (Logistic PCA) wrapper +Docker image: https://hub.docker.com/r/reedcompbio/lpca + +This wrapper runs [logisticPCA](https://github.com/andland/logisticPCA) + This wrapper runs [logisticPCA](https://github.com/andland/logisticPCA) ([Landgraf & Lee, 2020](https://doi.org/10.1016/j.jmva.2020.104668)) as a SPRAS analysis step. It reduces the binary edge-by-run matrix built from a set of pathway reconstruction outputs to a small @@ -16,7 +20,6 @@ The analysis is driven by the `analysis.lpca` config block and the 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 @@ -52,14 +55,3 @@ 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_lpca.R b/docker-wrappers/LPCA/run_lpca.R index 5947d8e4c..1a3049e56 100644 --- a/docker-wrappers/LPCA/run_lpca.R +++ b/docker-wrappers/LPCA/run_lpca.R @@ -8,7 +8,7 @@ m = as.numeric(args[4]) # Load data library(logisticPCA) data = read.csv(input_file, row.names = NULL) -party = data[, 1] +row_labels = data[, 1] data = data[, -1] data_matrix = as.matrix(data) data_matrix[is.na(data_matrix)] = 0 @@ -18,7 +18,7 @@ model = logisticPCA(data_matrix, k = k, m = m, partial_decomp = TRUE) # Save scores scores = model$PCs -rownames(scores) = party +rownames(scores) = row_labels write.csv(scores, output_file, row.names = TRUE) # Save deviance explained diff --git a/spras/analysis/lpca.py b/spras/analysis/lpca.py index 94c3a31c4..4dce4c5eb 100644 --- a/spras/analysis/lpca.py +++ b/spras/analysis/lpca.py @@ -26,7 +26,6 @@ def run_lpca( k: int = 2, m: float = 6, cv: bool = False, - transpose: bool = False, container_settings=None ) -> None: """ @@ -39,8 +38,6 @@ def run_lpca( @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) Note: KDE-based parameter selection (used by PCA) always uses PCA scores, even when LPCA @@ -52,10 +49,8 @@ def run_lpca( # 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 + # Transpose so runs are the observations, mirroring the classic PCA analysis + matrix = matrix.T print(f'LPCA: Matrix shape: {matrix.shape}') # Step 2: write the matrix next to the outputs, namespaced by algorithm diff --git a/spras/config/schema.py b/spras/config/schema.py index 2a85fe993..f2e0ba353 100644 --- a/spras/config/schema.py +++ b/spras/config/schema.py @@ -72,7 +72,6 @@ class LpcaAnalysis(BaseModel): k: int = 2 m: float = 6 cv: bool = False - transpose: bool = False model_config = ConfigDict(extra='forbid') From a44bb50f345b9eb424144d40305de1f3ad7c0752 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Mon, 27 Jul 2026 10:51:57 -0500 Subject: [PATCH 11/18] Restore deleted comments in config.yaml --- config/config.yaml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/config/config.yaml b/config/config.yaml index f7d98d648..9f6b00fad 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -50,6 +50,30 @@ 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) + # 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 # in the list to reduce the number of assumptions of the program at the cost of making the config a little more involved) From 58ceac3dc19534d33d1bc4ad6a09e3ae1a78abb7 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Mon, 27 Jul 2026 11:04:00 -0500 Subject: [PATCH 12/18] Pass summarize_networks output to run_lpca, matching PCA convention --- Snakefile | 3 ++- spras/analysis/lpca.py | 11 ++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Snakefile b/Snakefile index 6476caee7..8d00d3cdb 100644 --- a/Snakefile +++ b/Snakefile @@ -417,8 +417,9 @@ rule lpca_analysis: lpca_coord = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca-coordinates.txt']) run: from spras.analysis import lpca + summary_df = ml.summarize_networks(input.pathways) lpca.run_lpca( - input.pathways, + summary_df, output.lpca_scores, k=_config.config.lpca_params.k, m=_config.config.lpca_params.m, diff --git a/spras/analysis/lpca.py b/spras/analysis/lpca.py index 4dce4c5eb..ce4b3b0b5 100644 --- a/spras/analysis/lpca.py +++ b/spras/analysis/lpca.py @@ -2,15 +2,13 @@ # 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 matplotlib.pyplot as plt import pandas as pd import seaborn as sns -from spras.analysis.ml import create_palette, summarize_networks +from spras.analysis.ml import create_palette from spras.config.container_schema import ProcessedContainerSettings from spras.containers import prepare_volume, run_container_and_log @@ -21,7 +19,7 @@ def run_lpca( - file_paths: Iterable[Union[str, PathLike]], + dataframe: pd.DataFrame, output_scores: str, k: int = 2, m: float = 6, @@ -32,7 +30,7 @@ def run_lpca( 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 dataframe: binary dataframe of edge comparison between algorithms from summarize_networks @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 @@ -47,8 +45,7 @@ def run_lpca( 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) + matrix = dataframe # Transpose so runs are the observations, mirroring the classic PCA analysis matrix = matrix.T print(f'LPCA: Matrix shape: {matrix.shape}') From 33f0435480e114fbdb132c2837ee51eae27a3246 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Mon, 27 Jul 2026 11:15:22 -0500 Subject: [PATCH 13/18] Move binary matrix to Snakefile output, pass path to run_lpca --- Snakefile | 7 +++++-- spras/analysis/lpca.py | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Snakefile b/Snakefile index 8d00d3cdb..a52cfff45 100644 --- a/Snakefile +++ b/Snakefile @@ -96,6 +96,7 @@ def make_final_input(wildcards): 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)) final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}{algorithm}-lpca.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}-lpca-coordinates.txt',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}-lpca-binary-matrix.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)) @@ -408,19 +409,21 @@ rule ml_analysis_aggregate_algo: ml.hac_vertical(summary_df, output.hac_image_vertical, output.hac_clusters_vertical, **hac_params) 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: +rrule lpca_analysis: input: pathways = collect_pathways_per_algo output: lpca_scores = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca-scores.csv']), lpca_png = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca.png']), - lpca_coord = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca-coordinates.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']) run: from spras.analysis import lpca summary_df = ml.summarize_networks(input.pathways) lpca.run_lpca( summary_df, output.lpca_scores, + output.lpca_matrix, k=_config.config.lpca_params.k, m=_config.config.lpca_params.m, cv=_config.config.lpca_params.cv, diff --git a/spras/analysis/lpca.py b/spras/analysis/lpca.py index ce4b3b0b5..68728388c 100644 --- a/spras/analysis/lpca.py +++ b/spras/analysis/lpca.py @@ -21,6 +21,7 @@ def run_lpca( dataframe: pd.DataFrame, output_scores: str, + output_matrix: str, k: int = 2, m: float = 6, cv: bool = False, @@ -31,6 +32,7 @@ def run_lpca( algorithm output files. @param dataframe: binary dataframe of edge comparison between algorithms from summarize_networks + @param output_matrix: path to write the binary matrix CSV (used as Docker volume mount) @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 @@ -50,11 +52,11 @@ def run_lpca( matrix = matrix.T print(f'LPCA: Matrix shape: {matrix.shape}') - # Step 2: write the matrix next to the outputs, namespaced by algorithm + # Step 2: write the binary matrix for Docker volume mount 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') + Path(output_matrix).parent.mkdir(parents=True, exist_ok=True) + matrix_path = output_matrix matrix.to_csv(matrix_path) # Step 3: mount the matrix and the scores output @@ -66,6 +68,7 @@ def run_lpca( # Step 4: choose m, optionally via cross-validation if cv: + algo_name = Path(output_scores).name.replace('-lpca-scores.csv', '') 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) From 90b509470d7275f3103b1c78897c73f45ff9afb4 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Mon, 27 Jul 2026 11:23:45 -0500 Subject: [PATCH 14/18] Update tests: remove skipif, use summarize_networks, remove transpose --- test/analysis/test_lpca.py | 55 +++++++------------------------------- 1 file changed, 10 insertions(+), 45 deletions(-) diff --git a/test/analysis/test_lpca.py b/test/analysis/test_lpca.py index cf10c87d1..ad76f4100 100644 --- a/test/analysis/test_lpca.py +++ b/test/analysis/test_lpca.py @@ -1,11 +1,10 @@ from pathlib import Path -import docker import pandas as pd -import pytest import spras.config.config as config from spras.analysis.lpca import run_lpca +from spras.analysis.ml import summarize_networks config.init_from_file("config/config.yaml") @@ -18,22 +17,6 @@ 'test/evaluate/input/data-test-params-789/pathway.txt', ] -def lpca_image_available(): - """Check if the LPCA Docker image is available locally or on Docker Hub""" - try: - client = docker.from_env() - client.images.get('reedcompbio/lpca:v1') - return True - except docker.errors.ImageNotFound: - return False - except Exception: - return False - -skip_if_no_lpca_image = pytest.mark.skipif( - not lpca_image_available(), - reason='reedcompbio/lpca:v1 Docker image not available' -) - class TestLpca: """ Run Logistic PCA (LPCA) analysis tests @@ -42,58 +25,40 @@ class TestLpca: def setup_class(cls): OUT_DIR.mkdir(parents=True, exist_ok=True) - @skip_if_no_lpca_image def test_lpca_output_exists(self): """Test that LPCA produces an output scores file""" out_path = OUT_DIR / 'lpca-scores.csv' + matrix_path = OUT_DIR / 'lpca-binary-matrix.csv' out_path.unlink(missing_ok=True) + summary_df = summarize_networks(INPUT_FILES) run_lpca( - file_paths=INPUT_FILES, + dataframe=summary_df, output_scores=str(out_path), + output_matrix=str(matrix_path), k=2, m=4, cv=False, - transpose=False ) assert out_path.exists(), "LPCA scores file was not created" - @skip_if_no_lpca_image def test_lpca_output_shape(self): - """Test that LPCA scores have the correct shape (edges x k)""" + """Test that LPCA scores have the correct shape (runs x k)""" out_path = OUT_DIR / 'lpca-scores-shape.csv' + matrix_path = OUT_DIR / 'lpca-binary-matrix-shape.csv' out_path.unlink(missing_ok=True) + summary_df = summarize_networks(INPUT_FILES) run_lpca( - file_paths=INPUT_FILES, + dataframe=summary_df, output_scores=str(out_path), + output_matrix=str(matrix_path), k=2, m=4, cv=False, - transpose=False ) scores = pd.read_csv(out_path, index_col=0) assert scores.shape[1] == 2, f"Expected 2 PC columns, got {scores.shape[1]}" assert scores.shape[0] > 0, "Scores file is empty" - - @skip_if_no_lpca_image - def test_lpca_transposed_shape(self): - """Test that transposed LPCA scores have the correct shape (runs x k)""" - out_path = OUT_DIR / 'lpca-scores-transposed.csv' - out_path.unlink(missing_ok=True) - - run_lpca( - file_paths=INPUT_FILES, - output_scores=str(out_path), - k=2, - m=4, - cv=False, - transpose=True - ) - - scores = pd.read_csv(out_path, index_col=0) - assert scores.shape[1] == 2, f"Expected 2 PC columns, got {scores.shape[1]}" - assert scores.shape[0] == len(INPUT_FILES), \ - f"Expected {len(INPUT_FILES)} rows, got {scores.shape[0]}" From 20ff9018603cd99994320fa99df9335ef906f0e7 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Mon, 27 Jul 2026 12:02:40 -0500 Subject: [PATCH 15/18] Add lpca_analysis_all rule for cross-algorithm LPCA --- Snakefile | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Snakefile b/Snakefile index a52cfff45..c71625db6 100644 --- a/Snakefile +++ b/Snakefile @@ -97,6 +97,10 @@ def make_final_input(wildcards): final_input.extend(expand('{out_dir}{sep}{dataset}-ml{sep}{algorithm}-lpca.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}-lpca-coordinates.txt',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}-lpca-binary-matrix.csv',out_dir=out_dir, sep=SEP, dataset=dataset_labels,algorithm=algorithms_mult_param_combos)) + 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-scores.csv',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)) 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)) @@ -409,14 +413,15 @@ rule ml_analysis_aggregate_algo: ml.hac_vertical(summary_df, output.hac_image_vertical, output.hac_clusters_vertical, **hac_params) 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) -rrule lpca_analysis: + +rule lpca_analysis_all: input: - pathways = collect_pathways_per_algo + pathways = expand('{out_dir}{sep}{{dataset}}-{algorithm_params}{sep}pathway.txt', out_dir=out_dir, sep=SEP, algorithm_params=algorithms_with_params) output: - lpca_scores = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca-scores.csv']), - lpca_png = SEP.join([out_dir, '{dataset}-ml', '{algorithm}-lpca.png']), - 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']) + lpca_scores = SEP.join([out_dir, '{dataset}-ml', 'lpca-scores.csv']), + lpca_png = SEP.join([out_dir, '{dataset}-ml', 'lpca.png']), + lpca_coord = SEP.join([out_dir, '{dataset}-ml', 'lpca-coordinates.txt']), + lpca_matrix = SEP.join([out_dir, '{dataset}-ml', 'lpca-binary-matrix.csv']) run: from spras.analysis import lpca summary_df = ml.summarize_networks(input.pathways) From a16ba198570542889a901f720059b44cc4077ec6 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Mon, 27 Jul 2026 13:19:19 -0500 Subject: [PATCH 16/18] Add dedicated LPCA test inputs and stronger tests --- test/analysis/input/lpca/pathway-params-1.txt | 6 ++++ test/analysis/input/lpca/pathway-params-2.txt | 6 ++++ test/analysis/input/lpca/pathway-params-3.txt | 6 ++++ test/analysis/input/lpca/pathway-params-4.txt | 6 ++++ test/analysis/test_lpca.py | 35 +++++++++++++++---- 5 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 test/analysis/input/lpca/pathway-params-1.txt create mode 100644 test/analysis/input/lpca/pathway-params-2.txt create mode 100644 test/analysis/input/lpca/pathway-params-3.txt create mode 100644 test/analysis/input/lpca/pathway-params-4.txt 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..e8e75157a --- /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..c53dc06f0 --- /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..349ffdfab --- /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..2ae2296a0 --- /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 index ad76f4100..8c3d004d0 100644 --- a/test/analysis/test_lpca.py +++ b/test/analysis/test_lpca.py @@ -3,7 +3,7 @@ import pandas as pd import spras.config.config as config -from spras.analysis.lpca import run_lpca +from spras.analysis.lpca import plot_lpca, run_lpca from spras.analysis.ml import summarize_networks config.init_from_file("config/config.yaml") @@ -12,9 +12,10 @@ OUT_DIR = TEST_DIR / 'output' INPUT_FILES = [ - 'test/evaluate/input/data-test-params-123/pathway.txt', - 'test/evaluate/input/data-test-params-456/pathway.txt', - 'test/evaluate/input/data-test-params-789/pathway.txt', + 'test/analysis/input/lpca/pathway-params-1.txt', + 'test/analysis/input/lpca/pathway-params-2.txt', + 'test/analysis/input/lpca/pathway-params-3.txt', + 'test/analysis/input/lpca/pathway-params-4.txt', ] class TestLpca: @@ -44,7 +45,7 @@ def test_lpca_output_exists(self): assert out_path.exists(), "LPCA scores file was not created" def test_lpca_output_shape(self): - """Test that LPCA scores have the correct shape (runs x k)""" + """Test that LPCA scores have shape (runs x k)""" out_path = OUT_DIR / 'lpca-scores-shape.csv' matrix_path = OUT_DIR / 'lpca-binary-matrix-shape.csv' out_path.unlink(missing_ok=True) @@ -61,4 +62,26 @@ def test_lpca_output_shape(self): scores = pd.read_csv(out_path, index_col=0) assert scores.shape[1] == 2, f"Expected 2 PC columns, got {scores.shape[1]}" - assert scores.shape[0] > 0, "Scores file is empty" + assert scores.shape[0] == len(INPUT_FILES), \ + f"Expected {len(INPUT_FILES)} rows, got {scores.shape[0]}" + + def test_lpca_plot_output(self): + """Test that LPCA plot and coordinates files are created""" + scores_path = OUT_DIR / 'lpca-scores-plot.csv' + matrix_path = OUT_DIR / 'lpca-binary-matrix-plot.csv' + png_path = OUT_DIR / 'lpca-plot.png' + coord_path = OUT_DIR / 'lpca-coordinates.txt' + + summary_df = summarize_networks(INPUT_FILES) + run_lpca( + dataframe=summary_df, + output_scores=str(scores_path), + output_matrix=str(matrix_path), + k=2, + m=4, + cv=False, + ) + plot_lpca(str(scores_path), str(png_path), str(coord_path)) + + assert png_path.exists(), "LPCA plot PNG was not created" + assert coord_path.exists(), "LPCA coordinates file was not created" From b063bbd7db5c37dc361c396076734b5c5313c250 Mon Sep 17 00:00:00 2001 From: jeebjean Date: Mon, 27 Jul 2026 13:27:13 -0500 Subject: [PATCH 17/18] Add LPCA to Docker image build CI workflow --- .github/workflows/build-containers.yml | 5 +++++ 1 file changed, 5 insertions(+) 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 From b1905c04c3553ae8594d2041ce2bc0913104887a Mon Sep 17 00:00:00 2001 From: jeebjean Date: Mon, 27 Jul 2026 13:40:23 -0500 Subject: [PATCH 18/18] Fix test input files: use tab-separated format --- test/analysis/input/lpca/pathway-params-1.txt | 12 ++++++------ test/analysis/input/lpca/pathway-params-2.txt | 12 ++++++------ test/analysis/input/lpca/pathway-params-3.txt | 12 ++++++------ test/analysis/input/lpca/pathway-params-4.txt | 12 ++++++------ 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/test/analysis/input/lpca/pathway-params-1.txt b/test/analysis/input/lpca/pathway-params-1.txt index e8e75157a..affde9164 100644 --- a/test/analysis/input/lpca/pathway-params-1.txt +++ b/test/analysis/input/lpca/pathway-params-1.txt @@ -1,6 +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 +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 index c53dc06f0..b1c72f8b4 100644 --- a/test/analysis/input/lpca/pathway-params-2.txt +++ b/test/analysis/input/lpca/pathway-params-2.txt @@ -1,6 +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 +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 index 349ffdfab..8211a5a56 100644 --- a/test/analysis/input/lpca/pathway-params-3.txt +++ b/test/analysis/input/lpca/pathway-params-3.txt @@ -1,6 +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 +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 index 2ae2296a0..67f1cf554 100644 --- a/test/analysis/input/lpca/pathway-params-4.txt +++ b/test/analysis/input/lpca/pathway-params-4.txt @@ -1,6 +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 +Node1 Node2 Rank Direction +B C 1 U +C D 1 U +G H 1 U +H I 1 U +I L 1 U