Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Snakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 31 additions & 0 deletions docker-wrappers/lpca/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions docker-wrappers/lpca/README.md
Original file line number Diff line number Diff line change
@@ -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 <input> <output> <k> <m>`: runs logisticPCA with a fixed `m` and
writes the scores CSV plus a sibling `<output basename>_deviance.txt`.
- `run_cv.R <input> <output> <k>`: 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 `<registry.base_url>/<registry.owner>/<tag>`.
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 <your-image> reedcompbio/lpca:v1
36 changes: 36 additions & 0 deletions docker-wrappers/lpca/run_cv.R
Original file line number Diff line number Diff line change
@@ -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")
30 changes: 30 additions & 0 deletions docker-wrappers/lpca/run_lpca.R
Original file line number Diff line number Diff line change
@@ -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")
94 changes: 94 additions & 0 deletions spras/analysis/lpca.py
Original file line number Diff line number Diff line change
@@ -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}')
5 changes: 5 additions & 0 deletions spras/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions spras/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading