Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1e9b218
Add Logistic PCA (LPCA) analysis
Jeebjean Jul 10, 2026
bb338a8
Add LPCA Docker wrapper
Jeebjean Jul 10, 2026
efd1005
Add unit tests for LPCA analysis
Jeebjean Jul 21, 2026
8f89119
Add LPCA visualization: generate lpca.png and lpca-coordinates.txt
Jeebjean Jul 21, 2026
3d9a38f
Increase Docker timeout to 600s for large LPCA matrices
Jeebjean Jul 21, 2026
e4b4679
Restore config.yaml to main defaults, keep only lpca block
Jeebjean Jul 21, 2026
41b64b7
Skip LPCA tests if Docker image not available
Jeebjean Jul 21, 2026
c49362f
Add rARPACK for memory-efficient LPCA with partial_decomp=TRUE
Jeebjean Jul 23, 2026
7536d8c
Address PR review: palette, CV check, KDE note, config docs, rename t…
Jeebjean Jul 27, 2026
63d941d
Remove transpose option, fix m doc, update README, rename party variable
Jeebjean Jul 27, 2026
a44bb50
Restore deleted comments in config.yaml
Jeebjean Jul 27, 2026
58ceac3
Pass summarize_networks output to run_lpca, matching PCA convention
Jeebjean Jul 27, 2026
33f0435
Move binary matrix to Snakefile output, pass path to run_lpca
Jeebjean Jul 27, 2026
90b5094
Update tests: remove skipif, use summarize_networks, remove transpose
Jeebjean Jul 27, 2026
20ff901
Add lpca_analysis_all rule for cross-algorithm LPCA
Jeebjean Jul 27, 2026
a16ba19
Add dedicated LPCA test inputs and stronger tests
Jeebjean Jul 27, 2026
b063bbd
Add LPCA to Docker image build CI workflow
Jeebjean Jul 27, 2026
b1905c0
Fix test input files: use tab-separated format
Jeebjean Jul 27, 2026
ec14dc8
Add lpca aggregate_per_algorithm option and per-algorithm rule; enabl…
Jeebjean Jul 30, 2026
1704c47
Set lpca include back to false after confirming CI run
Jeebjean Jul 30, 2026
5b64162
Add stronger LPCA test comparing scores to known outputs (sign-robust)
Jeebjean Jul 30, 2026
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
5 changes: 5 additions & 0 deletions .github/workflows/build-containers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
65 changes: 65 additions & 0 deletions Snakefile
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ 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}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_lpca_aggregate_algo:
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))
Expand Down Expand Up @@ -356,6 +368,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)


Comment thread
Jeebjean marked this conversation as resolved.
# Calculated Jaccard similarity between output pathways for each dataset
rule jaccard_similarity:
input:
Expand Down Expand Up @@ -403,6 +416,58 @@ 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_all:
input:
pathways = expand('{out_dir}{sep}{{dataset}}-{algorithm_params}{sep}pathway.txt', out_dir=out_dir, sep=SEP, algorithm_params=algorithms_with_params)
output:
lpca_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)
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,
container_settings=container_settings
)
lpca.plot_lpca(
output.lpca_scores,
output.lpca_png,
output.lpca_coord
)

rule lpca_analysis_aggregate_algo:
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_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,
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:
input:
Expand Down
14 changes: 14 additions & 0 deletions config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,17 @@ analysis:
# adds evaluation per algorithm per dataset-goldstandard pair
# evaluation per algorithm will not run unless ml include and ml aggregate_per_algorithm are set to true
aggregate_per_algorithm: true
lpca:
# 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
# adds separate LPCA analysis for each algorithm that has multiple parameter combinations
aggregate_per_algorithm: false
# number of principal components to compute
k: 2
# 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
Comment thread
Jeebjean marked this conversation as resolved.
31 changes: 31 additions & 0 deletions docker-wrappers/LPCA/Dockerfile
Comment thread
Jeebjean marked this conversation as resolved.
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(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

WORKDIR /app
69 changes: 69 additions & 0 deletions docker-wrappers/LPCA/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# LPCA (Logistic PCA) wrapper

Comment thread
Jeebjean marked this conversation as resolved.
Docker image: https://hub.docker.com/r/reedcompbio/lpca

This wrapper runs [logisticPCA](https://github.com/andland/logisticPCA)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra linebreak here

([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.

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
Comment thread
Jeebjean marked this conversation as resolved.

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

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.

### `partial_decomp`

The LPCA wrapper always runs `logisticSVD` with `partial_decomp = TRUE`, which
uses a truncated (rARPACK-based) decomposition instead of a full one. This is
hardcoded rather than exposed as a parameter:

- On small datasets it has no practical effect on the result.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting that the package documentations suggests that it can slow down decomposition for small datasets. I think it is still fair to say that is harmless, so I don't recommend a change. I'm noting it in case we have future problems.

Since logistic PCA requires iteratively computing the eigendecomposition, it can be slow for large data. When k is small relative to the number of columns, however, the eigendecomposition can be sped up by only solving for the first k eigenvectors. This can be done for all three formulations using the partial_decomp argument. We have implemented this using the RSpectra package. Be careful when using this argument, because it can substantially slow down computation is the number of columns is small (e.g. ~20) or if k is of comparable size to the number of columns. An example of the speed-up is below.

- On large datasets it is required to avoid out-of-memory (OOMKilled) errors
that occur with the full decomposition.

Because it is beneficial on large inputs and harmless on small ones, it is
enabled unconditionally and is not a user-facing configuration option.

## 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

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)
row_labels = 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, partial_decomp = TRUE)
Comment thread
Jeebjean marked this conversation as resolved.

# Save scores
scores = model$PCs
rownames(scores) = row_labels
write.csv(scores, output_file, row.names = TRUE)

# Save deviance explained
deviance_file = sub("\\.csv$", "_deviance.txt", output_file)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm now able to inspect the outputs of LPCA. Does it only write a scalar deviance explained unlike PCA, which provides variance per dimension? Does this account for the k requested or is it the full dimensionality?

LPCA deviance file:

0.996362653717224

PCA variance file:

PC1: 86.36363636
PC2: 13.63636364

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")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be Inf, such as data0 pathlinker_72021389-lpca-scores_deviance.txt in the example config. PCA gives nan PCs on that input.

We may want to create an issue noting this to fix both of them later.

Loading
Loading