Case Study 4 (Student Version): CoGAPS Bayesian NMF for IFN-β Responses in PBMC scRNA-seq

Author

Oshane Thomas

1 How to use this notebook

This notebook is designed to be used inside the provided Docker image (RStudio Server + Quarto).

You can work in either mode:

  • Interactive mode (recommended for learning): run code chunks one-by-one while you read the narrative.
  • Render mode (recommended for a complete report): render the full notebook to HTML.

1.1 Step 0 — Launch the container

docker pull --platform linux/amd64 othomas2/ottr-ocs-pycogaps:latest

docker run --platform linux/amd64 -it --rm \
  -p 8787:8787 \
  -e PASSWORD="Password12" \
  othomas2/ottr-ocs-pycogaps:latest

Open:

  • URL: http://localhost:8787
  • User: rstudio
  • Password: whatever you set in PASSWORD=...

If you’re on an Apple Silicon Mac, the --platform linux/amd64 flag is required.

1.2 Step 1 — One-time setup inside the container

In RStudio → Terminal:

cd /home/rstudio/project
bash setup_venv.sh

(Optional sanity check)

cd /home/rstudio/project
source _environment
quarto check jupyter

1.3 Step 2 — Render (optional)

cd /home/rstudio/project
source _environment
quarto render cogaps_case_study4_student.qmd

1.4 Important note about distributed / multiprocessing

Do not run distributed CoGAPS during Quarto render.
Quarto executes every code chunk when rendering, and long-running / multi-process steps can fail or make renders painfully slow.

If you want distributed CoGAPS, run it from Terminal using the provided script (see the optional section near the end), then come back and load the result in this notebook.


2 Overview

This case study applies CoGAPS (Bayesian NMF) to single-cell RNA-seq PBMCs under control vs IFN-β stimulation (Kang et al., 2018; GEO: GSE96583).

Learning goal: use a latent factor model to separate:

  • Cell identity programs (stable lineage modules; “what a cell is”)
  • Cellular activity programs (stimulus-induced modules; “what a cell is doing”)

2.1 Research questions

Q1: What transcriptional programs respond to IFN-β stimulation across PBMCs?
Q2: Can we separate “cell type identity programs” from the “interferon response program”?
Q3: How does interferon-response pattern strength vary across immune cell types?


3 1. Environment check (run this first)

import os
import sys
import platform

print("Python:", sys.version)
print("Executable:", sys.executable)
print("Platform:", platform.platform())

# Verify PyCoGAPS is importable (this confirms setup worked)
import pycogaps
from PyCoGAPS.parameters import CoParams, setParams
from PyCoGAPS.pycogaps_main import CoGAPS

print("✅ pycogaps + PyCoGAPS imports OK")
Python: 3.10.12 (main, Jan 26 2026, 14:55:28) [GCC 11.4.0]
Executable: /home/rstudio/project/.venv/bin/python
Platform: Linux-6.12.67-linuxkit-x86_64-with-glibc2.35

______      _____       _____   ___  ______  _____ 
| ___ \    /  __ \     |  __ \ / _ \ | ___ \/  ___|
| |_/ /   _| /  \/ ___ | |  \// /_\ \| |_/ /\ `--. 
|  __/ | | | |    / _ \| | __ |  _  ||  __/  `--. |
| |  | |_| | \__/\ (_) | |_\ \| | | || |    /\__/ /
\_|   \__, |\____/\___/ \____/\_| |_/\_|    \____/ 
       __/ |                                       
      |___/             
                                 
                    
✅ pycogaps + PyCoGAPS imports OK

4 2. Imports and global settings

import time
import numpy as np
import pandas as pd
import scanpy as sc

import matplotlib.pyplot as plt
import seaborn as sns

from IPython.display import display

np.random.seed(42)
plt.rcParams["figure.dpi"] = 110

5 3. Load data

The dataset is baked into the Docker image at:

  • /opt/data/kang_counts_25k.h5ad

If it is not found, we fall back to downloading via Figshare.

path_img = "/opt/data/kang_counts_25k.h5ad"
path_local = "kang_counts_25k.h5ad"

if os.path.exists(path_img):
    print(f"Loading dataset from image: {path_img}")
    adata = sc.read(path_img)
else:
    print("Image dataset not found; using local path or downloading via backup_url.")
    adata = sc.read(
        path_local,
        backup_url="https://figshare.com/ndownloader/files/34464122",
    )

adata
Loading dataset from image: /opt/data/kang_counts_25k.h5ad
AnnData object with n_obs × n_vars = 24673 × 15706
    obs: 'nCount_RNA', 'nFeature_RNA', 'tsne1', 'tsne2', 'label', 'cluster', 'cell_type', 'replicate', 'nCount_SCT', 'nFeature_SCT', 'integrated_snn_res.0.4', 'seurat_clusters'
    var: 'name'
    obsm: 'X_pca', 'X_umap'

5.1 Confirm required metadata

if "condition" not in adata.obs and "label" in adata.obs:
    adata.obs["condition"] = adata.obs["label"]

required = ["condition", "cell_type"]
missing = [c for c in required if c not in adata.obs.columns]
assert not missing, f"Missing required columns in adata.obs: {missing}"

adata.obs[["condition", "cell_type"]].head()
condition cell_type
index
AAACATACATTTCC-1 ctrl CD14+ Monocytes
AAACATACCAGAAA-1 ctrl CD14+ Monocytes
AAACATACCATGCA-1 ctrl CD4 T cells
AAACATACCTCGCT-1 ctrl CD14+ Monocytes
AAACATACCTGGTA-1 ctrl Dendritic cells

6 4. Dataset overview

from textwrap import fill

def wrap(text, width=90):
    return fill(text, width=width)

overview = pd.DataFrame({
    "Item": [
        "Number of cells",
        "Number of genes",
        "Cell-level metadata (obs)",
        "Gene-level metadata (var)",
        "Embeddings available"
    ],
    "Summary": [
        f"{adata.n_obs:,}",
        f"{adata.n_vars:,}",
        wrap(", ".join(adata.obs.columns), width=90),
        wrap(", ".join(adata.var.columns), width=90),
        ", ".join(adata.obsm.keys())
    ]
})
overview
Item Summary
0 Number of cells 24,673
1 Number of genes 15,706
2 Cell-level metadata (obs) nCount_RNA, nFeature_RNA, tsne1, tsne2, label,...
3 Gene-level metadata (var) name
4 Embeddings available X_pca, X_umap

6.1 Categorical distributions

def summarize_categorical(adata, col):
    vc = adata.obs[col].value_counts()
    return vc.reset_index().rename(columns={"index": col, col: "n_cells"})

for col in ["condition", "cell_type", "replicate"]:
    if col in adata.obs:
        display(summarize_categorical(adata, col).head(50))
n_cells count
0 stim 12358
1 ctrl 12315
n_cells count
0 CD4 T cells 11238
1 CD14+ Monocytes 5697
2 B cells 2651
3 NK cells 1716
4 CD8 T cells 1621
5 FCGR3A+ Monocytes 1089
6 Dendritic cells 529
7 Megakaryocytes 132
n_cells count
0 patient_1015 5090
1 patient_1488 4580
2 patient_1256 4134
3 patient_1016 3358
4 patient_1244 3343
5 patient_101 2024
6 patient_1039 1102
7 patient_107 1042

7 5. Preprocessing and gene selection (HVGs)

MIN_CELLS = 3
TARGET_SUM = 1e4
N_TOP_GENES = 3000
HVG_FLAVOR = "seurat_v3"

print("Preprocessing choices:")
print(f"  min_cells: {MIN_CELLS}")
print(f"  target_sum: {TARGET_SUM}")
print(f"  n_top_genes (HVGs): {N_TOP_GENES}")
print(f"  HVG flavor: {HVG_FLAVOR}")
Preprocessing choices:
  min_cells: 3
  target_sum: 10000.0
  n_top_genes (HVGs): 3000
  HVG flavor: seurat_v3
adata.layers["counts"] = adata.X.copy()
before = adata.n_vars
sc.pp.filter_genes(adata, min_cells=MIN_CELLS)
after = adata.n_vars
print(f"Genes: {before} -> {after} after min_cells={MIN_CELLS}")
Genes: 15706 -> 15706 after min_cells=3
sc.pp.normalize_total(adata, target_sum=TARGET_SUM)
sc.pp.log1p(adata)
sc.pp.highly_variable_genes(
    adata,
    n_top_genes=N_TOP_GENES,
    flavor=HVG_FLAVOR,
    layer="counts",
)

print("HVGs selected:", int(adata.var["highly_variable"].sum()))
adata = adata[:, adata.var["highly_variable"]].copy()
adata
HVGs selected: 3000
AnnData object with n_obs × n_vars = 24673 × 3000
    obs: 'nCount_RNA', 'nFeature_RNA', 'tsne1', 'tsne2', 'label', 'cluster', 'cell_type', 'replicate', 'nCount_SCT', 'nFeature_SCT', 'integrated_snn_res.0.4', 'seurat_clusters', 'condition'
    var: 'name', 'n_cells', 'highly_variable', 'highly_variable_rank', 'means', 'variances', 'variances_norm'
    uns: 'log1p', 'hvg'
    obsm: 'X_pca', 'X_umap'
    layers: 'counts'

8 6. Prepare data for CoGAPS

adata_cogaps = adata.T.copy()  # genes × cells
adata_cogaps.X = np.asarray(adata_cogaps.X.toarray(), dtype=np.float64)

print(adata_cogaps)
print("Matrix dtype:", adata_cogaps.X.dtype)
AnnData object with n_obs × n_vars = 3000 × 24673
    obs: 'name', 'n_cells', 'highly_variable', 'highly_variable_rank', 'means', 'variances', 'variances_norm'
    var: 'nCount_RNA', 'nFeature_RNA', 'tsne1', 'tsne2', 'label', 'cluster', 'cell_type', 'replicate', 'nCount_SCT', 'nFeature_SCT', 'integrated_snn_res.0.4', 'seurat_clusters', 'condition'
    uns: 'log1p', 'hvg'
    varm: 'X_pca', 'X_umap'
    layers: 'counts'
Matrix dtype: float64

9 7. Run CoGAPS (single-process; safe for Quarto render)

N_PATTERNS = 5
N_ITER = 2000          # classroom default; increase for final analysis runs
SEED = 42
USE_SPARSE_OPT = True

print("CoGAPS choices:")
print(f"  nPatterns: {N_PATTERNS}")
print(f"  nIterations: {N_ITER}")
print(f"  seed: {SEED}")
print(f"  useSparseOptimization: {USE_SPARSE_OPT}")
CoGAPS choices:
  nPatterns: 5
  nIterations: 2000
  seed: 42
  useSparseOptimization: True
os.makedirs("data", exist_ok=True)
result_path = "data/cogaps_result.h5ad"

if os.path.exists(result_path):
    print(f"Loading existing CoGAPS result: {result_path}")
    result = sc.read_h5ad(result_path)
else:
    params = CoParams(adata=adata_cogaps)

    setParams(
        params,
        {
            "nPatterns": N_PATTERNS,
            "nIterations": N_ITER,
            "seed": SEED,
            "useSparseOptimization": USE_SPARSE_OPT,
            # NOTE: Do NOT set "distributed" inside this notebook.
        },
    )

    params.printParams()

    start = time.time()
    result = CoGAPS(adata_cogaps, params)
    elapsed = time.time() - start

    print(f"CoGAPS finished in {elapsed/60:.2f} minutes")
    result.write_h5ad(result_path)
    print(f"Saved: {result_path}")

result

-- Standard Parameters --
nPatterns:  5
nIterations:  2000
seed:  42
sparseOptimization:  True


-- Sparsity Parameters --
alpha: 0.01
maxGibbsMass:  100.0



This is pycogaps version  0.0.1
Running Standard CoGAPS on provided data object ( 3000 genes and 24673 samples) with parameters: 

-- Standard Parameters --
nPatterns:  5
nIterations:  2000
seed:  42
sparseOptimization:  True


-- Sparsity Parameters --
alpha: 0.01
maxGibbsMass:  100.0


Data Model: Sparse, Normal
Sampler Type: Sequential
Loading Data...Done! (00:00:05)
-- Equilibration Phase --
1000 of 2000, Atoms: 7037(A), 81009(P), ChiSq: 224104224, Time: 00:13:47 / 01:08:06
2000 of 2000, Atoms: 7170(A), 84420(P), ChiSq: 224088480, Time: 00:39:07 / 01:26:28
-- Sampling Phase --
1000 of 2000, Atoms: 7133(A), 84867(P), ChiSq: 224078848, Time: 01:05:47 / 01:31:20
2000 of 2000, Atoms: 7156(A), 85486(P), ChiSq: 224084368, Time: 01:32:28 / 01:32:29

GapsResult result object with 3000 features and 24673 samples
5 patterns were learned

CoGAPS finished in 96.71 minutes
Saved: data/cogaps_result.h5ad
AnnData object with n_obs × n_vars = 3000 × 24673
    obs: 'Pattern1', 'Pattern2', 'Pattern3', 'Pattern4', 'Pattern5'
    var: 'Pattern1', 'Pattern2', 'Pattern3', 'Pattern4', 'Pattern5'
    uns: 'log1p', 'hvg', 'asd', 'psd', 'atomhistoryA', 'atomhistoryP', 'averageQueueLengthA', 'averageQueueLengthP', 'chisqHistory', 'equilibrationSnapshotsA', 'equilibrationSnapshotsP', 'meanChiSq', 'meanPatternAssignment', 'pumpMatrix', 'samplingSnapshotsA', 'samplingSnapshotsP', 'seed', 'totalRunningTime', 'totalUpdates'
    varm: 'X_pca', 'X_umap'
    layers: 'counts'

10 8. Extract matrices A and P

A = result.obs.filter(regex="Pattern")   # genes × patterns
P = result.var.filter(regex="Pattern")   # cells × patterns

print("A:", A.shape, "| P:", P.shape)
A.head()
A: (3000, 5) | P: (24673, 5)
Pattern1 Pattern2 Pattern3 Pattern4 Pattern5
index
HES4 8.869731e-01 0.000066 0.034495 4.080607e-07 0.119681
ISG15 7.786111e+00 0.193520 9.729788 1.386710e-01 0.087393
TNFRSF18 7.651962e-07 0.089479 0.012613 1.092226e-06 0.060679
TNFRSF4 4.344390e-07 0.099483 0.012404 3.774059e-07 0.053363
FAM132A 1.638925e-04 0.000007 0.000008 2.965539e-05 0.000421

11 9. Attach pattern activities back to cells

P_cells = P.copy()
P_cells.index = adata.obs_names

for col in P_cells.columns:
    adata.obs[col] = P_cells[col].values

pattern_names = list(P_cells.columns)
pattern_names[:5], adata.obs[pattern_names].head()
(['Pattern1', 'Pattern2', 'Pattern3', 'Pattern4', 'Pattern5'],
                   Pattern1  Pattern2  Pattern3  Pattern4  Pattern5
 index                                                             
 AAACATACATTTCC-1  0.021165  0.000324  0.000010  0.603660  0.354534
 AAACATACCAGAAA-1  0.000168  0.062531  0.000055  0.535007  0.052376
 AAACATACCATGCA-1  0.000536  0.105730  0.000097  0.000041  0.000028
 AAACATACCTCGCT-1  0.000000  0.000031  0.029962  0.535511  0.202357
 AAACATACCTGGTA-1  0.000023  0.100462  0.055379  0.091730  0.548743)

12 10. Q1 — Which programs respond to IFN-β stimulation?

cond = (adata.obs["condition"] == "stim").astype(int)

corrs = {pat: np.corrcoef(adata.obs[pat], cond)[0, 1] for pat in pattern_names}
corr_series = pd.Series(corrs).sort_values(ascending=False)

corr_table = (
    corr_series.rename("Correlation with IFN-β stimulation (stim=1, ctrl=0)")
    .to_frame()
    .round(3)
)
corr_table
Correlation with IFN-β stimulation (stim=1, ctrl=0)
Pattern3 0.754
Pattern1 0.368
Pattern5 0.003
Pattern2 -0.222
Pattern4 -0.282
isg_pattern = corr_series.index[0]
print("Top IFN-associated pattern:", isg_pattern, "corr =", round(float(corr_series.iloc[0]), 3))
Top IFN-associated pattern: Pattern3 corr = 0.754
top_isg_genes = A[isg_pattern].sort_values(ascending=False).head(30)
top_isg_genes
index
ISG15      9.729788
ISG20      8.411927
IFI6       6.574230
IFIT3      4.840949
LY6E       4.476941
IFIT1      3.927450
MX1        3.811948
FTH1       3.136555
SAT1       1.564118
IFIT2      1.552952
IRF7       1.501965
TNFSF10    1.358840
OAS1       1.305752
PLSCR1     1.143213
BST2       1.059330
MT2A       0.979870
EPSTI1     0.919135
RSAD2      0.875062
OASL       0.850032
IFITM2     0.835703
DYNLT1     0.789838
IFI35      0.768422
HSPA8      0.672058
GBP1       0.671257
SAMD9L     0.618101
DNAJA1     0.592183
SOCS1      0.578768
NT5C3A     0.544956
SELL       0.539262
CARD16     0.493254
Name: Pattern3, dtype: float64

13 11. Q2 — Identity vs activity programs

mean_by_cond = adata.obs.groupby("condition")[pattern_names].mean()

plt.figure(figsize=(9, 3))
sns.heatmap(mean_by_cond, annot=True, fmt=".2f")
plt.title("Mean CoGAPS pattern activity by condition")
plt.ylabel("Condition")
plt.xlabel("Pattern")
plt.tight_layout()
plt.show()

mean_by_type = adata.obs.groupby("cell_type")[pattern_names].mean()

sns.clustermap(mean_by_type, figsize=(10, 8), standard_scale=0)
plt.suptitle("Pattern activity by immune cell type", y=1.02)
plt.show()

sc.pl.stacked_violin(
    adata,
    pattern_names,
    groupby="cell_type",
    figsize=(14, 10),
    stripplot=False,
    scale="width",
    yticklabels=True,
    use_raw=False,
)

sc.pl.stacked_violin(
    adata,
    pattern_names,
    groupby="condition",
    figsize=(12, 6),
    stripplot=False,
    scale="width",
    yticklabels=True,
    use_raw=False,
)


14 12. Visualize programs on UMAP

if "X_umap" not in adata.obsm:
    sc.pp.pca(adata)
    sc.pp.neighbors(adata)
    sc.tl.umap(adata)

sc.pl.umap(adata, color=["condition", isg_pattern], color_map="viridis", wspace=0.4)

sc.pl.umap(adata, color=pattern_names, color_map="viridis", ncols=3)


15 13. Q3 — IFN program strength across immune cell types

df_isg = adata.obs[["cell_type", "condition", isg_pattern]].copy()

summary_by_type = (
    df_isg.groupby("cell_type")[isg_pattern]
    .agg(["mean", "median", "std", "count"])
    .sort_values("mean", ascending=False)
)
summary_by_type
mean median std count
cell_type
NK cells 0.212108 0.095603 0.234213 1716
B cells 0.209827 0.098393 0.232936 2651
Dendritic cells 0.198690 0.139271 0.201105 529
FCGR3A+ Monocytes 0.176960 0.115439 0.184994 1089
CD8 T cells 0.166573 0.060829 0.197121 1621
CD4 T cells 0.158124 0.054270 0.186064 11238
CD14+ Monocytes 0.092249 0.013830 0.125468 5697
Megakaryocytes 0.028124 0.000174 0.074185 132
plt.figure(figsize=(12, 5))
sns.boxplot(data=df_isg, x="cell_type", y=isg_pattern, hue="condition")
plt.xticks(rotation=45, ha="right")
plt.ylabel(f"{isg_pattern} activity")
plt.title("IFN-response program activity by cell type and condition")
plt.tight_layout()
plt.show()


16 14. Pattern gene summaries (interpretability)

for pat in pattern_names:
    print(f"\n=== {pat}: top genes ===")
    display(A[pat].sort_values(ascending=False).head(12))

=== Pattern1: top genes ===
index
CXCL10      7.965004
ISG15       7.786111
APOBEC3A    7.378877
IFITM3      7.223546
FTL         6.594434
FTH1        5.790112
TNFSF10     5.739016
SAT1        5.732905
SOD2        5.609220
RSAD2       5.306476
TYMP        5.133949
TYROBP      5.057149
Name: Pattern1, dtype: float64

=== Pattern2: top genes ===
index
FTH1        12.957394
FTL          5.636801
ACTB         5.237462
PFN1         3.592000
SH3BGRL3     2.987683
VIM          1.967796
ANXA1        1.788872
GAPDH        1.497803
CCR7         1.295715
HSPA8        1.277352
EMP3         1.265737
LTB          1.167527
Name: Pattern2, dtype: float64

=== Pattern3: top genes ===
index
ISG15      9.729788
ISG20      8.411927
IFI6       6.574230
IFIT3      4.840949
LY6E       4.476941
IFIT1      3.927450
MX1        3.811948
FTH1       3.136555
SAT1       1.564118
IFIT2      1.552952
IRF7       1.501965
TNFSF10    1.358840
Name: Pattern3, dtype: float64

=== Pattern4: top genes ===
index
FTH1        10.834475
FTL          9.953378
TIMP1        8.715867
S100A11      6.825833
SH3BGRL3     6.351716
TYROBP       6.247885
S100A10      6.144288
ACTB         6.050305
FCER1G       6.038208
GAPDH        5.932955
CD63         5.825633
S100A6       5.682121
Name: Pattern4, dtype: float64

=== Pattern5: top genes ===
index
CD74        13.175068
HLA-DRA     11.386052
HLA-DRB1     9.628648
HLA-DPA1     8.524743
HLA-DPB1     7.596040
FTL          5.570769
ACTB         4.928213
FTH1         3.757450
PFN1         3.315407
HLA-DQB1     2.512053
HLA-DQA1     1.977432
HSP90AA1     1.966502
Name: Pattern5, dtype: float64

17 15. Optional: distributed CoGAPS (advanced)

Your error happened because the distributed script is currently trying to read kang_counts_25k.h5ad from the project directory, then downloading a file that ends up corrupt/empty (0 bytes), which causes:

OSError: Unable to open file (file signature not found)

Then run distributed from Terminal:

cd /home/rstudio/project
source _environment
./.venv/bin/python run_pycogaps_distributed.py

If you ran the script successfully and it created data/cogaps_result_distributed.h5ad, you can load it below.

dist_path = "data/cogaps_result_distributed.h5ad"
if os.path.exists(dist_path):
    dist_result = sc.read_h5ad(dist_path)
    dist_result
else:
    print("Distributed result not found. Run run_pycogaps_distributed.py from Terminal first.")
Distributed result not found. Run run_pycogaps_distributed.py from Terminal first.

18 Final answers (summary)

  • Q1: Identify the pattern most correlated with stimulation; validate using top genes (expected ISG enrichment).
  • Q2: Identity patterns localize by cell type; the IFN response pattern shows condition association across cell types.
  • Q3: Compare IFN pattern activity by immune cell type (and split by condition) to quantify heterogeneity.

19 References

  • Kang, H. M., Subramaniam, M., Targ, S., Nguyen, M., Maliskova, L., McCarthy, E., … Ye, C. J. (2018). Multiplexed droplet single-cell RNA-sequencing using natural genetic variation. Nature Biotechnology, 36(1), 89–94. https://doi.org/10.1038/nbt.4042
  • Kotliar, D., Veres, A., Nagy, M. A., Tabrizi, S., Hodis, E., Melton, D. A., & Sabeti, P. C. (2019). Identifying gene expression programs of cell-type identity and cellular activity with single-cell RNA-Seq. eLife, 8, e43803. https://doi.org/10.7554/eLife.43803
  • Stein-O’Brien, G. L., Clark, B. S., Sherman, T., Zibetti, C., Hu, Q., Sealfon, R., … Fertig, E. J. (2019). Decomposing cell identity for transfer learning across cellular measurements, platforms, tissues, and species. Cell Systems, 8(5), 395–411.e8. https://doi.org/10.1016/j.cels.2019.04.004
  • Fertig, E. J., Ding, J., Favorov, A. V., Parmigiani, G., & Ochs, M. F. (2010). CoGAPS: An R/C++ package to identify patterns and biological process activity in transcriptomic data. Bioinformatics, 26(21), 2792–2793. https://doi.org/10.1093/bioinformatics/btq503