
Rnaseq Analysis
- 1 installs
- 1 repo stars
- Updated February 8, 2026
- gexijin/vitiligo
Analyzes bulk RNA-seq count data with DESeq2 from GEO accessions or count matrices, covering QC, normalization, differential expression, and gene ID mapping.
About
Guides bulk RNA-seq differential-expression analysis using DESeq2 with numbered R scripts, gene tracking, and progressive documentation. A developer or bioinformatician uses it when analyzing RNA-seq count data.
- Standard numbered R scripts for QC, DESeq2, DE, annotation, and GO enrichment
- Tracks gene counts at each processing step and reports a summary table
Rnaseq Analysis by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,803 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/gexijin/vitiligo --skill rnaseq-analysisAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 1 |
| Last updated | February 8, 2026 |
| Repository | gexijin/vitiligo ↗ |
What it does
Analyzes bulk RNA-seq count data with DESeq2 from GEO accessions or count matrices, covering QC, normalization, differential expression, and gene ID mapping.
Files
Bulk RNA-seq Analysis Skill
This skill guides analysis of bulk RNA-seq gene expression count data using DESeq2, following established patterns from this project.
Quick Start
When asked to analyze RNA-seq data:
1. Check if a CLAUDE.md file exists in the study directory - it contains study-specific metadata 2. Determine the data format (count matrix, featureCounts output, RSEM, etc.) 3. Create numbered scripts in scripts/ directory (01_, 02_, etc.) 4. Update analysis.md after each analysis step 5. Generate all plots in results/plots/ with plots/plots.md documentation
Directory Structure
Create this structure for each analysis:
analyses/[FirstAuthor]_[GSE#]/
├── CLAUDE.md # Study metadata (samples, platform, design)
├── analysis.md # Progressive documentation (update after each step)
├── scripts/
│ ├── 01_load_and_qc.R
│ ├── 02_deseq2_analysis.R
│ ├── 03_differential_expression.R
│ ├── 04_annotate_genes.R
│ └── 05_go_enrichment.R
├── data/ # Raw and processed data (gitignored)
└── results/
├── tables/ # CSV output files
└── plots/ # PNG/PDF figures
└── plots.md # Plot documentationGene Tracking
IMPORTANT: Track gene counts at each processing step and report a summary table at the end of QC and DESeq2 scripts. This provides transparency about data filtering.
Initialize Tracking
# Initialize gene tracking at the start of script
gene_tracking <- data.frame(
step = character(),
n_genes = integer(),
n_removed = integer(),
description = character(),
stringsAsFactors = FALSE
)Track at Each Step
Add tracking after each filtering/processing step:
# After loading raw counts
gene_tracking <- rbind(gene_tracking, data.frame(
step = "1. Raw counts loaded",
n_genes = nrow(counts),
n_removed = 0,
description = "Total genes in count matrix"
))
# After pre-filtering low counts
gene_tracking <- rbind(gene_tracking, data.frame(
step = "2. Pre-filtering",
n_genes = nrow(counts_filtered),
n_removed = nrow(counts) - nrow(counts_filtered),
description = sprintf("Genes with >= %d counts in >= %d samples", min_count, min_samples)
))
# After DESeq2 independent filtering
gene_tracking <- rbind(gene_tracking, data.frame(
step = "3. DESeq2 independent filtering",
n_genes = sum(!is.na(res$padj)),
n_removed = sum(is.na(res$padj)),
description = "Genes with sufficient counts for testing"
))
# After annotation (genes with valid IDs)
gene_tracking <- rbind(gene_tracking, data.frame(
step = "4. Gene annotation",
n_genes = sum(!is.na(res_annotated$gene_symbol)),
n_removed = sum(is.na(res_annotated$gene_symbol)),
description = "Genes with valid gene symbols"
))Report Summary
At the end of analysis script:
# Calculate percentage remaining
gene_tracking$pct_remaining <- round(100 * gene_tracking$n_genes / gene_tracking$n_genes[1], 1)
cat("\n=== Gene Tracking Summary ===\n\n")
print(gene_tracking)
# Save to file
write.csv(gene_tracking, file.path(tables_dir, "gene_tracking.csv"), row.names = FALSE)Expected Output
| Step | Genes | Removed | % Remaining | Description |
|---|---|---|---|---|
| 1. Raw counts loaded | 60,623 | 0 | 100.0% | Total genes in count matrix |
| 2. Pre-filtering | 18,432 | 42,191 | 30.4% | Genes with >= 10 counts in >= 3 samples |
| 3. DESeq2 independent filtering | 15,821 | 2,611 | 26.1% | Genes with sufficient counts for testing |
| 4. Gene annotation | 14,956 | 865 | 24.7% | Genes with valid gene symbols |
Data Loading
Standard Script Header with Relative Paths
Use the here package for portable, relative paths:
.libPaths(c("~/R/library", .libPaths()))
suppressPackageStartupMessages({
library(DESeq2)
library(ggplot2)
library(dplyr)
library(tidyr)
library(pheatmap)
library(RColorBrewer)
library(here)
})
# Source utility functions
source(here("scripts/utils/filter_counts.R"))
# Set analysis directory using here package
analysis_dir <- here("analyses", "Author_GSE#####")
# Create output directories
dir.create(file.path(analysis_dir, "results/tables"),
recursive = TRUE, showWarnings = FALSE)
dir.create(file.path(analysis_dir, "results/plots"),
recursive = TRUE, showWarnings = FALSE)Count Matrix Format
DESeq2 expects raw (non-normalized) integer counts:
# Load count matrix (genes as rows, samples as columns)
counts <- read.csv(file.path(analysis_dir, "data/counts.csv"), row.names = 1)
counts <- as.matrix(counts)
# Ensure integer counts
storage.mode(counts) <- "integer"
# Load sample metadata
sample_info <- read.csv(file.path(analysis_dir, "data/sample_metadata.csv"))
rownames(sample_info) <- sample_info$sample_id
# Ensure sample order matches
counts <- counts[, rownames(sample_info)]From GEO Downloads
GEO data may come in various formats:
# Typical GEO series matrix
geo_data <- read.delim("GSE#####_count_matrix.txt.gz", row.names = 1)
# featureCounts output
fc_data <- read.delim("featureCounts_output.txt", comment.char = "#")
counts <- fc_data[, 7:ncol(fc_data)]
rownames(counts) <- fc_data$Geneid
# RSEM expected counts (round to integers)
rsem_data <- read.delim("rsem_genes.txt", row.names = 1)
counts <- round(rsem_data[, grep("expected_count", colnames(rsem_data))])Quality Control
Pre-filtering
Use the filter_counts() utility function for CPM-based and total count filtering:
# Source the utility function
source(here("scripts/utils/filter_counts.R"))
# Define filtering parameters
FILTER_MIN_CPM <- 2 # Minimum CPM threshold
FILTER_MIN_SAMPLE_PERCENT <- 0.3 # Min % of samples (30%)
FILTER_MIN_TOTAL_COUNTS <- 20 # Min total counts across all samples
# Filter using CPM and total count thresholds
counts_filtered <- filter_counts(
counts,
min_cpm = FILTER_MIN_CPM,
min_sample_percent = FILTER_MIN_SAMPLE_PERCENT,
min_total_counts = FILTER_MIN_TOTAL_COUNTS
)The filter_counts() function (in scripts/utils/filter_counts.R) filters genes that meet both: 1. CPM threshold in a minimum percentage of samples 2. Minimum total counts across all samples
This produces informative output:
Filtering statistics:
Total samples: 10
Min samples required: 3 (30%)
CPM threshold: 2
Total count threshold: 20
Total genes: 60623
Genes passing CPM filter: 15432
Genes passing total counts filter: 18234
Genes passing both filters: 14521
Genes removed: 46102QC Metrics
# Library sizes (total counts per sample)
lib_sizes <- colSums(counts_filtered)
# Number of detected genes per sample
genes_detected <- colSums(counts_filtered > 0)
# Create QC summary
qc_summary <- data.frame(
sample_id = colnames(counts_filtered),
library_size = lib_sizes,
genes_detected = genes_detected,
condition = sample_info$condition
)
# Z-score for outlier detection
qc_summary$lib_size_zscore <- scale(qc_summary$library_size)[, 1]
qc_summary$genes_zscore <- scale(qc_summary$genes_detected)[, 1]
# Flag potential outliers
qc_summary$outlier_flag <- abs(qc_summary$lib_size_zscore) > 2 |
abs(qc_summary$genes_zscore) > 2Quality Control Plots
Generate these QC plots (numbered sequentially):
1. Library size barplot - Total counts per sample 2. Genes detected barplot - Number of genes with >0 counts per sample 3. Count distribution boxplot - Log2(count+1) distribution per sample 4. Count density plot - Overlapping density curves 5. Sample correlation heatmap - Hierarchical clustering 6. PCA plot - PC1 vs PC2 colored by condition 7. Sample distance heatmap - Euclidean distance after VST
Post-normalization visualization:
- Overall expression heatmap (e.g., 08b) - Top 2000 most variable genes
- Use VST-transformed counts
- Median-centered, ±3 SD clipping for color utilization
- 1 - Pearson correlation distance, average linkage for gene clustering
- Samples ordered by condition (not clustered)
- Green-black-red color scheme with condition color bars
- Sample dendrogram (e.g., 08c) - Hierarchical clustering of all samples
- Uses all genes from VST-transformed counts
- 1 - Pearson correlation distance, average linkage
- Horizontal layout with branches and labels colored by condition
Outlier Detection Criteria
| Metric | Threshold | Action |
|---|---|---|
| Library size z-score | z | |
| Genes detected z-score | z | |
| PCA extreme | Visual outlier | Investigate |
| Low correlation | < 0.8 with group | Investigate |
After excluding outliers: Re-create DESeqDataSet with filtered samples.
DESeq2 Analysis
Create DESeqDataSet
library(DESeq2)
# Create DESeqDataSet object
dds <- DESeqDataSetFromMatrix(
countData = counts_filtered,
colData = sample_info,
design = ~ condition # Basic unpaired design
)
# Set reference level
dds$condition <- relevel(dds$condition, ref = "Control")Paired Design (same subjects, multiple conditions)
For matched samples (e.g., lesional vs non-lesional from same patient):
# Ensure factors are properly set
dds$condition <- factor(sample_info$condition, levels = c("NonLesional", "Lesional"))
dds$patient <- factor(sample_info$patient_id)
# Design with patient as blocking factor
design(dds) <- ~ patient + conditionUnpaired Design (independent groups)
For independent samples:
# Simple two-group comparison
dds$condition <- factor(sample_info$condition, levels = c("Control", "Treatment"))
design(dds) <- ~ conditionMulti-factor Design
For multiple variables:
# Treatment and batch effects
dds$treatment <- factor(sample_info$treatment)
dds$batch <- factor(sample_info$batch)
design(dds) <- ~ batch + treatment
# Interaction model
dds$genotype <- factor(sample_info$genotype)
dds$treatment <- factor(sample_info$treatment)
design(dds) <- ~ genotype + treatment + genotype:treatmentRun DESeq2
# Run the differential expression pipeline
dds <- DESeq(dds)
# Check available coefficients
resultsNames(dds)Extracting Results
Basic Results Extraction
# Extract results for a specific contrast
res <- results(dds,
name = "condition_Treatment_vs_Control", # Or use contrast
alpha = 0.05)
# Or use contrast argument
res <- results(dds,
contrast = c("condition", "Treatment", "Control"),
alpha = 0.05)
# Summary
summary(res)
# Convert to data frame and sort
res_df <- as.data.frame(res)
res_df$gene_id <- rownames(res_df)
res_df <- res_df[order(res_df$pvalue), ]Log Fold Change Shrinkage
Always apply LFC shrinkage for better estimates and ranking:
# Use apeglm method (recommended)
library(apeglm)
res_shrunk <- lfcShrink(dds,
coef = "condition_Treatment_vs_Control",
type = "apeglm")
# Alternative: ashr method
res_shrunk <- lfcShrink(dds,
contrast = c("condition", "Treatment", "Control"),
type = "ashr")Multiple Contrasts
# Define all contrasts of interest
contrasts <- list(
Treatment_vs_Control = c("condition", "Treatment", "Control"),
TimePoint2_vs_TimePoint1 = c("timepoint", "T2", "T1")
)
# Extract all results
all_results <- lapply(names(contrasts), function(name) {
res <- results(dds, contrast = contrasts[[name]], alpha = 0.05)
res_df <- as.data.frame(res)
res_df$gene_id <- rownames(res_df)
res_df$contrast <- name
return(res_df)
})
names(all_results) <- names(contrasts)Variance Stabilizing Transformation
Use VST or rlog for visualization (PCA, heatmaps):
# VST (faster, recommended for n > 30)
vsd <- vst(dds, blind = FALSE)
# rlog (better for small sample sizes n < 30)
rld <- rlog(dds, blind = FALSE)
# Extract transformed values
vst_mat <- assay(vsd)Model Diagnostics
Always generate these diagnostic plots:
1. P-value histogram - Should show uniform + spike near 0 2. MA plot - Check for intensity-dependent bias 3. Dispersion plot - Check model fit
# P-value histogram
hist(res$pvalue, breaks = 50,
main = "P-value Distribution", xlab = "P-value")
abline(h = nrow(res) / 50, col = "red", lty = 2)
# MA plot
plotMA(res, main = "MA Plot", ylim = c(-5, 5))
# Dispersion plot
plotDispEsts(dds, main = "Dispersion Estimates")Gene ID Mapping
Using org.Hs.eg.db
library(AnnotationDbi)
library(org.Hs.eg.db)
# Get gene IDs from results
gene_ids <- rownames(res)
# Map Ensembl to symbols (if starting with Ensembl IDs)
# Remove version numbers if present
gene_ids_clean <- gsub("\\.\\d+$", "", gene_ids)
mapping <- AnnotationDbi::select(
org.Hs.eg.db,
keys = gene_ids_clean,
columns = c("SYMBOL", "ENTREZID"),
keytype = "ENSEMBL"
)
colnames(mapping) <- c("ensembl_id", "gene_symbol", "entrez_id")
# Handle duplicates (one Ensembl -> multiple mappings)
mapping_unique <- mapping %>%
group_by(ensembl_id) %>%
summarize(
gene_symbol = first(na.omit(gene_symbol)),
entrez_id = first(na.omit(entrez_id))
)
Using biomaRt
library(biomaRt)
ensembl <- useEnsembl(biomart = "genes", dataset = "hsapiens_gene_ensembl")
mapping <- getBM(
attributes = c("ensembl_gene_id", "hgnc_symbol", "entrezgene_id"),
filters = "ensembl_gene_id",
values = gene_ids_clean,
mart = ensembl
)Output Format
Differential Expression Results
Produce output files with both unshrunken and shrunken log2 fold changes. The shrunken LFC should be used for ranking and visualization, while unshrunken values are retained for reference.
Required columns in output CSV:
| Column | Description |
|---|---|
| gene_id | Original gene ID, Ensembl, or Entrez |
| baseMean | Mean of normalized counts |
| log2FoldChange | Shrunken log2 fold change (use for ranking/visualization) |
| log2FoldChange_unshrunken | Unshrunken log2 fold change (for reference) |
| lfcSE | Standard error of shrunken LFC |
| stat | Wald statistic (from unshrunken results) |
| pvalue | Raw p-value |
| padj | BH-adjusted p-value |
| entrez_id | Entrez gene ID (if annotated) |
| gene_symbol | Gene symbol (if annotated) |
Important: Use type = "normal" for LFC shrinkage when using paired designs, as apeglm may not work with all coefficient names:
# Apply LFC shrinkage
res_shrunk <- lfcShrink(dds,
coef = "condition_Lesional_vs_NonLesional",
type = "normal")
# Combine shrunken and unshrunken results
res_df <- as.data.frame(res_shrunk)
res_df$gene_id <- rownames(res_df)
res_df$log2FoldChange_unshrunken <- res_unshrunken$log2FoldChange
res_df$stat <- res_unshrunken$statFor multiple gene IDs mapped to the same symbol, keep the most significant (lowest P-value). Unmapped genes can be deleted from annotated output files.
Similarily,
File Naming Convention
results/tables/
├── sample_metadata.csv
├── qc_metrics.csv
├── normalized_counts.csv # DESeq2 normalized counts
├── vst_counts.csv # VST transformed counts
├── DE_[Comparison]_full.csv # Full results
├── DE_[Comparison]_annotated.csv # With gene annotation
├── DE_[Comparison]_significant.csv # FDR < 0.05 only
├── genes_upregulated.csv
├── genes_downregulated.csv
├── GO_BP_[Comparison]_UP.csv
├── GO_BP_[Comparison]_DOWN.csv
└── GO_BP_summary.csvProgressive Documentation (analysis.md)
Update analysis.md after EACH analysis step with:
# [GSE#] RNA-seq Analysis Report
## Study Information
| Field | Value |
|-------|-------|
| **GEO Accession** | GSE##### |
| **Data Type** | Bulk RNA-seq |
| **Analysis Date** | YYYY-MM-DD |
## Methods
### Software Environment
| Package | Version | Purpose |
|---------|---------|---------|
| DESeq2 | X.X.X | Differential expression |
| apeglm | X.X.X | LFC shrinkage |
### Key Parameters
- Pre-filtering: Genes with ≥10 counts in ≥3 samples
- FDR threshold: 0.05
- LFC shrinkage method: apeglm
## Results
### Quality Control
- [Summarize QC findings]
- [Document any outliers removed]
### Differential Expression
| Comparison | Design | Total DEGs | Up | Down |
|------------|--------|------------|-----|------|
| ... | ... | ... | ... | ... |
### Key Findings
- [Biological interpretation]
## Output Files
[List all generated files]Plot Documentation (plots/plots.md)
Create results/plots/plots.md with embedded image references:
# Plot Documentation - [GSE#]
## Quality Control
### 01_library_sizes.png
Library size (total counts) per sample.

### 02_genes_detected.png
Number of genes detected (count > 0) per sample.

### 03_count_distribution.png
Boxplot of log2(count+1) distribution per sample.

[Continue for all plots...]
## Differential Expression
### 15_volcano_plot.png
Volcano plot showing -log10(p-value) vs log2 fold change.
- Red: Upregulated (FDR < 0.05, |logFC| > 1)
- Blue: Downregulated (FDR < 0.05, |logFC| > 1)
GO and KEGG Enrichment Analysis
Use clusterProfiler for pathway analysis. Run both GO Biological Process and KEGG pathway enrichment for both upregulated AND downregulated genes in each comparison.
Background Gene List
Important: Use all genes that passed the filtering step as background:
library(clusterProfiler)
library(org.Hs.eg.db)
library(ggplot2)
library(dplyr)
# Background = all genes tested (from annotated results)
background_genes <- res_annotated %>%
filter(!is.na(entrez_id)) %>%
pull(entrez_id) %>%
unique() %>%
as.character()GO Biological Process Enrichment (Both Directions)
# Get Entrez IDs for UPREGULATED genes
sig_up <- res_annotated %>%
filter(padj < 0.05 & log2FoldChange > 0 & !is.na(entrez_id)) %>%
pull(entrez_id) %>%
unique() %>%
as.character()
# Get Entrez IDs for DOWNREGULATED genes
sig_down <- res_annotated %>%
filter(padj < 0.05 & log2FoldChange < 0 & !is.na(entrez_id)) %>%
pull(entrez_id) %>%
unique() %>%
as.character()
# GO enrichment - Upregulated
ego_up <- enrichGO(
gene = sig_up,
universe = background_genes,
OrgDb = org.Hs.eg.db,
keyType = "ENTREZID",
ont = "BP",
pAdjustMethod = "BH",
pvalueCutoff = 0.05,
readable = TRUE
)
ego_up_simp <- simplify(ego_up, cutoff = 0.7, by = "p.adjust")
# GO enrichment - Downregulated
ego_down <- enrichGO(
gene = sig_down,
universe = background_genes,
OrgDb = org.Hs.eg.db,
keyType = "ENTREZID",
ont = "BP",
pAdjustMethod = "BH",
pvalueCutoff = 0.05,
readable = TRUE
)
ego_down_simp <- simplify(ego_down, cutoff = 0.7, by = "p.adjust")KEGG Pathway Enrichment (Both Directions)
# KEGG - Upregulated
kegg_up <- enrichKEGG(
gene = sig_up,
universe = background_genes,
organism = "hsa",
keyType = "ncbi-geneid",
pAdjustMethod = "BH",
pvalueCutoff = 0.05
)
# KEGG - Downregulated
kegg_down <- enrichKEGG(
gene = sig_down,
universe = background_genes,
organism = "hsa",
keyType = "ncbi-geneid",
pAdjustMethod = "BH",
pvalueCutoff = 0.05
)Combined Plots with Fold Enrichment
Create combined diverging plots showing both upregulated and downregulated pathways on the same figure, with fold enrichment on the x-axis:
# Helper function to calculate fold enrichment
calculate_fold_enrichment <- function(enrichResult) {
if (is.null(enrichResult) || nrow(enrichResult@result) == 0) return(NULL)
df <- as.data.frame(enrichResult)
df <- df %>%
mutate(
gene_ratio_num = as.numeric(sub("/.*", "", GeneRatio)),
gene_ratio_denom = as.numeric(sub(".*/", "", GeneRatio)),
bg_ratio_num = as.numeric(sub("/.*", "", BgRatio)),
bg_ratio_denom = as.numeric(sub(".*/", "", BgRatio)),
GeneRatioValue = gene_ratio_num / gene_ratio_denom,
BgRatioValue = bg_ratio_num / bg_ratio_denom,
FoldEnrichment = GeneRatioValue / BgRatioValue
)
return(df)
}
# Helper function to create combined plot
create_combined_enrichment_plot <- function(df_up, df_down, title, n_terms = 10) {
plot_data <- data.frame()
if (!is.null(df_up) && nrow(df_up) > 0) {
up_top <- df_up %>%
filter(p.adjust < 0.05) %>%
arrange(p.adjust) %>%
head(n_terms) %>%
mutate(Direction = "Upregulated")
plot_data <- bind_rows(plot_data, up_top)
}
if (!is.null(df_down) && nrow(df_down) > 0) {
down_top <- df_down %>%
filter(p.adjust < 0.05) %>%
arrange(p.adjust) %>%
head(n_terms) %>%
mutate(Direction = "Downregulated")
plot_data <- bind_rows(plot_data, down_top)
}
if (nrow(plot_data) == 0) return(NULL)
# Truncate descriptions and create signed fold enrichment
plot_data <- plot_data %>%
mutate(
Description_short = ifelse(nchar(Description) > 45,
paste0(substr(Description, 1, 42), "..."),
Description),
FoldEnrichment_signed = ifelse(Direction == "Downregulated",
-FoldEnrichment, FoldEnrichment)
) %>%
arrange(Direction, FoldEnrichment) %>%
mutate(Description_short = factor(Description_short,
levels = unique(Description_short)))
# Create diverging dot plot
ggplot(plot_data, aes(x = FoldEnrichment_signed, y = Description_short,
fill = Direction, size = Count)) +
geom_point(shape = 21, alpha = 0.8) +
geom_vline(xintercept = 0, linetype = "dashed", color = "gray50") +
scale_fill_manual(values = c("Upregulated" = "#E74C3C",
"Downregulated" = "#3498DB")) +
scale_size_continuous(range = c(3, 10), name = "Gene Count") +
labs(title = title,
subtitle = sprintf("Top %d terms per direction (FDR < 0.05)", n_terms),
x = "Fold Enrichment", y = "") +
theme_bw() +
theme(plot.title = element_text(hjust = 0.5, face = "bold"),
axis.text.y = element_text(size = 9))
}
# Generate combined plots
go_up_fe <- calculate_fold_enrichment(ego_up_simp)
go_down_fe <- calculate_fold_enrichment(ego_down_simp)
kegg_up_fe <- calculate_fold_enrichment(kegg_up)
kegg_down_fe <- calculate_fold_enrichment(kegg_down)
p_go <- create_combined_enrichment_plot(go_up_fe, go_down_fe,
"GO Biological Process")
p_kegg <- create_combined_enrichment_plot(kegg_up_fe, kegg_down_fe,
"KEGG Pathways")
ggsave("results/plots/XX_GO_BP_combined.png", p_go, width = 10, height = 8)
ggsave("results/plots/XX_KEGG_combined.png", p_kegg, width = 10, height = 8)Output Files
For each comparison, produce:
- One combined GO BP plot:
[num]_GO_BP_combined.png(up + down on same figure) - One combined KEGG plot:
[num]_KEGG_combined.png(up + down on same figure) - CSV tables: Separate files for UP and DOWN directions
GO_BP_[comparison]_UP.csvGO_BP_[comparison]_DOWN.csvKEGG_[comparison]_UP.csvKEGG_[comparison]_DOWN.csv
GSEA Analysis
For ranked gene set enrichment:
library(fgsea)
library(msigdbr)
# Create ranked gene list (by stat or signed -log10(p))
gene_ranks <- res_annotated %>%
filter(!is.na(gene_symbol)) %>%
arrange(desc(stat)) %>%
distinct(gene_symbol, .keep_all = TRUE)
ranks <- setNames(gene_ranks$stat, gene_ranks$gene_symbol)
# Get gene sets
hallmark <- msigdbr(species = "Homo sapiens", category = "H")
hallmark_list <- split(hallmark$gene_symbol, hallmark$gs_name)
# Run GSEA
fgsea_res <- fgsea(
pathways = hallmark_list,
stats = ranks,
minSize = 15,
maxSize = 500,
nperm = 10000
)Contrast Selection Guidelines
Only extract biologically meaningful contrasts:
Good contrasts:
- Treatment vs Control
- Disease vs Healthy
- Lesional vs Non-Lesional (paired)
- Timepoint comparisons in time series
Avoid:
- Redundant comparisons
- Comparisons without biological interpretation
Common Issues and Solutions
| Issue | Solution |
|---|---|
| Few DEGs with FDR < 0.05 | Check sample variability; consider relaxing threshold for exploratory analysis |
| Batch effects visible in PCA | Add batch to design formula; consider ComBat-seq for severe effects |
| Low mapping rate annotation | Check gene ID format; remove version numbers from Ensembl IDs |
| Cook's distance warnings | Investigate flagged samples for potential outliers |
| Dispersion outliers | May indicate problematic genes or samples |
Reference Files
See these files in the project for complete examples:
analyses/Brunner_bulk/- Pseudobulk RNA-seq with DESeq2 (paired design)analyses/Shiu_bulk/- Pseudobulk from scRNA-seqscripts/utils/filter_counts.R- CPM-based filtering utility function
Code Reference for Bulk RNA-seq Analysis
Standard Script Header
Use the here package for portable, relative paths:
# ==============================================================================
# [GSE#] - [Author] et al. Bulk RNA-seq Analysis
# Script ##: [Description]
# ==============================================================================
# Data Type: Bulk RNA-seq
# Samples: [N] total ([description])
# ==============================================================================
# Load required libraries
.libPaths(c("~/R/library", .libPaths()))
suppressPackageStartupMessages({
library(DESeq2)
library(ggplot2)
library(dplyr)
library(tidyr)
library(pheatmap)
library(RColorBrewer)
library(here)
})
# Source utility functions
source(here("scripts/utils/filter_counts.R"))
# Set analysis directory using here package (relative paths)
analysis_dir <- here("analyses", "[Author]_[GSE#]")
# Create directories if they don't exist
dir.create(file.path(analysis_dir, "results/tables"),
recursive = TRUE, showWarnings = FALSE)
dir.create(file.path(analysis_dir, "results/plots"),
recursive = TRUE, showWarnings = FALSE)
cat("=== [GSE#] [Analysis Type] ===\n\n")Color Palettes
# Two-group comparison
condition_colors <- c(
"Control" = "#2ecc71",
"Treatment" = "#e74c3c"
)
# Multi-group (vitiligo example)
condition_colors <- c(
"Healthy" = "#2ecc71",
"Lesional" = "#e74c3c",
"Non_Lesional" = "#3498db",
"Peri_Lesional" = "#f39c12"
)
# Generic multi-group
n_groups <- length(unique(sample_info$condition))
condition_colors <- setNames(
RColorBrewer::brewer.pal(max(3, n_groups), "Set1")[1:n_groups],
unique(sample_info$condition)
)Gene Tracking
Initialize gene tracking at the start of every analysis script:
# ==============================================================================
# Initialize gene tracking
# ==============================================================================
gene_tracking <- data.frame(
step = character(),
n_genes = integer(),
n_removed = integer(),
description = character(),
stringsAsFactors = FALSE
)
# Helper function to add tracking entry
add_gene_tracking <- function(step, n_genes, n_removed, description) {
gene_tracking <<- rbind(gene_tracking, data.frame(
step = step,
n_genes = n_genes,
n_removed = n_removed,
description = description,
stringsAsFactors = FALSE
))
}Report gene tracking summary at the end of analysis script:
# ==============================================================================
# Gene Tracking Summary
# ==============================================================================
cat("\n=== Gene Tracking Summary ===\n\n")
gene_tracking$pct_remaining <- round(100 * gene_tracking$n_genes / gene_tracking$n_genes[1], 1)
print(gene_tracking)
cat("\n")
# Save gene tracking table
write.csv(gene_tracking, file.path(analysis_dir, "results/tables", "gene_tracking.csv"), row.names = FALSE)
cat(" Saved gene tracking to results/tables/gene_tracking.csv\n")Data Loading
Count Matrix from CSV
# ==============================================================================
# Load count matrix
# ==============================================================================
counts <- read.csv(file.path(analysis_dir, "data/counts.csv"), row.names = 1)
counts <- as.matrix(counts)
storage.mode(counts) <- "integer"
cat(sprintf("Loaded count matrix: %d genes x %d samples\n", nrow(counts), ncol(counts)))
# Track: Raw counts loaded
add_gene_tracking("1. Raw counts loaded", nrow(counts), 0, "Total genes in count matrix")
# Load sample metadata
sample_info <- read.csv(file.path(analysis_dir, "data/sample_metadata.csv"))
rownames(sample_info) <- sample_info$sample_id
# Ensure order matches
stopifnot(all(colnames(counts) == rownames(sample_info)))From GEO Series Matrix
library(GEOquery)
# Download from GEO
gse <- getGEO("GSE#####", destdir = data_dir)
# Extract expression data (usually normalized, may need raw counts separately)
expr_data <- exprs(gse[[1]])
# Get sample metadata
sample_info <- pData(gse[[1]])From featureCounts Output
# featureCounts format has metadata columns before count columns
fc_data <- read.delim(file.path(analysis_dir, "data", "featureCounts_output.txt"),
comment.char = "#")
# Gene info in first 6 columns, counts start at column 7
gene_info <- fc_data[, 1:6]
counts <- as.matrix(fc_data[, 7:ncol(fc_data)])
rownames(counts) <- fc_data$Geneid
# Clean sample names (remove path and .bam extension)
colnames(counts) <- gsub(".*/", "", colnames(counts))
colnames(counts) <- gsub("\\.bam$", "", colnames(counts))From RSEM Output
# RSEM provides expected counts (need to round)
rsem_files <- list.files(data_dir, pattern = "\\.genes\\.results$", full.names = TRUE)
# Read and combine
counts_list <- lapply(rsem_files, function(f) {
d <- read.delim(f)
setNames(round(d$expected_count), d$gene_id)
})
counts <- do.call(cbind, counts_list)
colnames(counts) <- gsub("\\.genes\\.results$", "", basename(rsem_files))Quality Control
Pre-filtering using filter_counts()
Use the filter_counts() utility function for CPM-based and total count filtering:
# ==============================================================================
# Pre-filter low count genes using filter_counts utility
# ==============================================================================
# Define filtering parameters at top of script
FILTER_MIN_CPM <- 2 # Minimum CPM threshold
FILTER_MIN_SAMPLE_PERCENT <- 0.3 # Min % of samples (30%)
FILTER_MIN_TOTAL_COUNTS <- 20 # Min total counts across all samples
# Filter using CPM and total count thresholds
counts_filtered <- filter_counts(
counts,
min_cpm = FILTER_MIN_CPM,
min_sample_percent = FILTER_MIN_SAMPLE_PERCENT,
min_total_counts = FILTER_MIN_TOTAL_COUNTS
)
# Track: Pre-filtering
add_gene_tracking("2. Pre-filtering", nrow(counts_filtered),
nrow(counts) - nrow(counts_filtered),
sprintf("Genes with CPM >= %d in >= %.0f%% samples, total >= %d",
FILTER_MIN_CPM, FILTER_MIN_SAMPLE_PERCENT * 100, FILTER_MIN_TOTAL_COUNTS))The filter_counts() function automatically prints filtering statistics:
Filtering statistics:
Total samples: 10
Min samples required: 3 (30%)
CPM threshold: 2
Total count threshold: 20
Total genes: 60623
Genes passing CPM filter: 15432
Genes passing total counts filter: 18234
Genes passing both filters: 14521
Genes removed: 46102QC Metrics
# ==============================================================================
# Calculate QC metrics
# ==============================================================================
qc_metrics <- data.frame(
sample_id = colnames(counts_filtered),
library_size = colSums(counts_filtered),
genes_detected = colSums(counts_filtered > 0),
median_count = apply(counts_filtered, 2, median),
pct_top_50 = sapply(1:ncol(counts_filtered), function(i) {
100 * sum(sort(counts_filtered[,i], decreasing = TRUE)[1:50]) / sum(counts_filtered[,i])
})
)
# Add sample info
qc_metrics <- qc_metrics %>%
left_join(sample_info, by = "sample_id")
# Calculate z-scores for outlier detection
qc_metrics$lib_size_zscore <- scale(qc_metrics$library_size)[, 1]
qc_metrics$genes_zscore <- scale(qc_metrics$genes_detected)[, 1]
# Flag potential outliers
qc_metrics$outlier_flag <- abs(qc_metrics$lib_size_zscore) > 2 |
abs(qc_metrics$genes_zscore) > 2
cat("\nPotential outliers:\n")
print(qc_metrics[qc_metrics$outlier_flag, c("sample_id", "library_size",
"genes_detected", "lib_size_zscore")])
# Save QC metrics
write.csv(qc_metrics, file.path(analysis_dir, "results/tables/qc_metrics.csv"), row.names = FALSE)QC Plots
# ==============================================================================
# QC Plots
# ==============================================================================
# 1. Library sizes
p1 <- ggplot(qc_metrics, aes(x = reorder(sample_id, -library_size),
y = library_size / 1e6,
fill = condition)) +
geom_bar(stat = "identity") +
scale_fill_manual(values = condition_colors) +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1, size = 8)) +
labs(title = "Library Sizes",
x = "Sample",
y = "Library Size (millions)",
fill = "Condition") +
geom_hline(yintercept = median(qc_metrics$library_size) / 1e6,
linetype = "dashed", color = "red")
ggsave(file.path(analysis_dir, "results/plots/01_library_sizes.png"), p1,
width = 10, height = 6, dpi = 150)
# 2. Genes detected
p2 <- ggplot(qc_metrics, aes(x = reorder(sample_id, -genes_detected),
y = genes_detected / 1000,
fill = condition)) +
geom_bar(stat = "identity") +
scale_fill_manual(values = condition_colors) +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1, size = 8)) +
labs(title = "Genes Detected (count > 0)",
x = "Sample",
y = "Genes (thousands)",
fill = "Condition")
ggsave(file.path(analysis_dir, "results/plots", "02_genes_detected.png"), p2,
width = 10, height = 6, dpi = 150)
# 3. Count distribution boxplot
counts_long <- as.data.frame(log2(counts_filtered + 1)) %>%
tibble::rownames_to_column("gene") %>%
pivot_longer(-gene, names_to = "sample_id", values_to = "log2_count") %>%
left_join(sample_info[, c("sample_id", "condition")], by = "sample_id")
p3 <- ggplot(counts_long, aes(x = sample_id, y = log2_count, fill = condition)) +
geom_boxplot(outlier.size = 0.5) +
scale_fill_manual(values = condition_colors) +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1, size = 8)) +
labs(title = "Count Distribution (log2)",
x = "Sample",
y = "log2(count + 1)")
ggsave(file.path(analysis_dir, "results/plots", "03_count_distribution.png"), p3,
width = 12, height = 6, dpi = 150)
# 4. Density plot
p4 <- ggplot(counts_long, aes(x = log2_count, color = condition, group = sample_id)) +
geom_density(alpha = 0.5) +
scale_color_manual(values = condition_colors) +
theme_minimal() +
labs(title = "Count Density Distribution",
x = "log2(count + 1)",
y = "Density")
ggsave(file.path(analysis_dir, "results/plots", "04_count_density.png"), p4,
width = 8, height = 6, dpi = 150)DESeq2 Analysis
Create DESeqDataSet
library(DESeq2)
# ==============================================================================
# Create DESeqDataSet
# ==============================================================================
# Basic unpaired design
dds <- DESeqDataSetFromMatrix(
countData = counts_filtered,
colData = sample_info,
design = ~ condition
)
# Set reference level (control group)
dds$condition <- relevel(dds$condition, ref = "Control")
cat(sprintf("\nDESeqDataSet created:\n"))
cat(sprintf(" - Samples: %d\n", ncol(dds)))
cat(sprintf(" - Genes: %d\n", nrow(dds)))
cat(sprintf(" - Design: %s\n", paste(as.character(design(dds)), collapse = "")))Paired Design
# ==============================================================================
# Paired design (e.g., lesional vs non-lesional from same patient)
# ==============================================================================
# Ensure factors are properly set
dds$condition <- factor(sample_info$condition, levels = c("NonLesional", "Lesional"))
dds$patient <- factor(sample_info$patient_id)
# Update design
design(dds) <- ~ patient + condition
cat(sprintf("Paired design with %d patients\n", length(unique(dds$patient))))Multi-factor Design
# ==============================================================================
# Multi-factor design (e.g., treatment + batch)
# ==============================================================================
dds$treatment <- factor(sample_info$treatment, levels = c("Control", "Treated"))
dds$batch <- factor(sample_info$batch)
# Include batch as covariate
design(dds) <- ~ batch + treatment
# Or for interaction model
dds$genotype <- factor(sample_info$genotype, levels = c("WT", "Mutant"))
design(dds) <- ~ genotype + treatment + genotype:treatmentRun DESeq2
# ==============================================================================
# Run DESeq2 pipeline
# ==============================================================================
dds <- DESeq(dds)
# Check results names
cat("\nAvailable coefficients:\n")
print(resultsNames(dds))
# Get size factors
size_factors <- sizeFactors(dds)
cat("\nSize factors:\n")
print(round(size_factors, 3))
# Track: After DESeq2 (genes in model)
add_gene_tracking("3. DESeq2 model", nrow(dds), 0,
"Genes in DESeq2 model after independent filtering")Variance Stabilizing Transformation
# ==============================================================================
# VST for visualization
# ==============================================================================
# VST (faster, recommended for n > 30)
vsd <- vst(dds, blind = FALSE)
vst_mat <- assay(vsd)
# Alternative: rlog (better for small sample sizes n < 30)
# rld <- rlog(dds, blind = FALSE)
# rlog_mat <- assay(rld)
# Save transformed counts
write.csv(vst_mat, file.path(analysis_dir, "results/tables", "vst_counts.csv"))PCA Plot
# ==============================================================================
# PCA plot
# ==============================================================================
# Using DESeq2 function
pcaData <- plotPCA(vsd, intgroup = "condition", returnData = TRUE)
percentVar <- round(100 * attr(pcaData, "percentVar"))
p_pca <- ggplot(pcaData, aes(x = PC1, y = PC2, color = condition)) +
geom_point(size = 4) +
scale_color_manual(values = condition_colors) +
xlab(paste0("PC1: ", percentVar[1], "% variance")) +
ylab(paste0("PC2: ", percentVar[2], "% variance")) +
theme_minimal() +
theme(legend.position = "bottom") +
labs(title = "PCA - VST Transformed Counts")
ggsave(file.path(analysis_dir, "results/plots", "06_pca_plot.png"), p_pca,
width = 8, height = 7, dpi = 150)
# With sample labels
p_pca_labeled <- p_pca +
ggrepel::geom_text_repel(aes(label = name), size = 3, max.overlaps = 20)
ggsave(file.path(analysis_dir, "results/plots", "06_pca_plot_labeled.png"), p_pca_labeled,
width = 10, height = 8, dpi = 150)Sample Distance Heatmap
# ==============================================================================
# Sample distance heatmap
# ==============================================================================
library(pheatmap)
library(RColorBrewer)
# Calculate sample distances
sampleDists <- dist(t(vst_mat))
sampleDistMatrix <- as.matrix(sampleDists)
# Annotation
annotation_df <- data.frame(
Condition = sample_info$condition,
row.names = sample_info$sample_id
)
# Color palette
colors <- colorRampPalette(rev(brewer.pal(9, "Blues")))(255)
png(file.path(analysis_dir, "results/plots", "05_sample_correlation_heatmap.png"),
width = 10, height = 8, units = "in", res = 300)
pheatmap(sampleDistMatrix,
clustering_distance_rows = sampleDists,
clustering_distance_cols = sampleDists,
annotation_col = annotation_df,
col = colors,
main = "Sample Distance Matrix (Euclidean)")
dev.off()Extracting Results
Basic Results
# ==============================================================================
# Extract differential expression results
# ==============================================================================
# Using coefficient name
res <- results(dds,
name = "condition_Lesional_vs_NonLesional",
alpha = 0.05)
# Or using contrast
res <- results(dds,
contrast = c("condition", "Lesional", "NonLesional"),
alpha = 0.05)
# Summary
summary(res)LFC Shrinkage
Use type = "normal" for paired designs (apeglm may not work with all coefficient names):
# ==============================================================================
# Log fold change shrinkage (recommended)
# ==============================================================================
# Extract unshrunken results first (for stat column)
res <- results(dds,
name = "condition_Lesional_vs_NonLesional",
alpha = 0.05)
cat("\n=== Results Summary (unshrunken) ===\n")
summary(res)
# Apply LFC shrinkage using "normal" method (works reliably with paired designs)
cat("\n=== Applying LFC Shrinkage (normal) ===\n")
res_shrunk <- lfcShrink(dds,
coef = "condition_Lesional_vs_NonLesional",
type = "normal")
cat("\n=== Results Summary (shrunken) ===\n")
summary(res_shrunk)
# Alternative methods:
# apeglm (recommended for simple designs, but may fail with complex coef names)
# library(apeglm)
# res_shrunk <- lfcShrink(dds, coef = "condition_Treatment_vs_Control", type = "apeglm")
# ashr (works with contrast, good for complex designs)
# library(ashr)
# res_shrunk <- lfcShrink(dds, contrast = c("condition", "Treatment", "Control"), type = "ashr")Format Results
Include both shrunken and unshrunken log2FC in output:
# ==============================================================================
# Format results for output (with shrunken LFC)
# ==============================================================================
# Convert shrunken results to data frame
res_df <- as.data.frame(res_shrunk)
res_df$gene_id <- rownames(res_df)
# Add unshrunken stat (Wald statistic) from original results
res_df$stat <- res$stat
res_df$log2FoldChange_unshrunken <- res$log2FoldChange
# Reorder and sort
res_df <- res_df[order(res_df$pvalue), ]
# Add significance categories (using shrunken LFC)
res_df <- res_df %>%
mutate(
significance = case_when(
is.na(padj) ~ "NS",
padj >= 0.05 ~ "NS",
padj < 0.05 & log2FoldChange > 0 ~ "Up",
padj < 0.05 & log2FoldChange < 0 ~ "Down"
),
significance = factor(significance, levels = c("Up", "Down", "NS"))
)
cat("\n=== Significance Summary ===\n")
print(table(res_df$significance))
# Save full results with both LFC values
write.csv(res_df,
file.path(analysis_dir, "results/tables/DE_Lesional_vs_NonLesional_full.csv"),
row.names = FALSE)
# Track: Genes tested (with valid padj)
n_tested <- sum(!is.na(res_df$padj))
add_gene_tracking("4. Genes tested", n_tested,
nrow(res_df) - n_tested,
"Genes with valid adjusted p-values")Gene Annotation
Using org.Hs.eg.db
# ==============================================================================
# Gene annotation
# ==============================================================================
library(AnnotationDbi)
library(org.Hs.eg.db)
# Remove Ensembl version numbers if present
res_df$ensembl_id <- gsub("\\.\\d+$", "", res_df$gene_id)
# Get annotation
annotation <- AnnotationDbi::select(
org.Hs.eg.db,
keys = unique(res_df$ensembl_id),
columns = c("SYMBOL", "ENTREZID", "GENENAME"),
keytype = "ENSEMBL"
)
colnames(annotation) <- c("ensembl_id", "gene_symbol", "entrez_id", "gene_name")
# Handle duplicates (keep first mapping)
annotation_unique <- annotation %>%
group_by(ensembl_id) %>%
summarize(
gene_symbol = first(na.omit(gene_symbol)),
entrez_id = first(na.omit(entrez_id)),
gene_name = first(na.omit(gene_name))
) %>%
ungroup()
# Join with results
res_annotated <- res_df %>%
left_join(annotation_unique, by = "ensembl_id")
# Check annotation rate
n_annotated <- sum(!is.na(res_annotated$gene_symbol))
cat(sprintf("\nAnnotation rate: %.1f%% (%d/%d genes)\n",
100 * n_annotated / nrow(res_annotated),
n_annotated,
nrow(res_annotated)))
# Track: Gene annotation
add_gene_tracking("5. Gene annotation", n_annotated,
nrow(res_annotated) - n_annotated,
"Genes with valid gene symbols")Using biomaRt
library(biomaRt)
# Connect to Ensembl
ensembl <- useEnsembl(biomart = "genes",
dataset = "hsapiens_gene_ensembl",
version = 110) # Specify version for reproducibility
# Get annotation
annotation <- getBM(
attributes = c("ensembl_gene_id", "hgnc_symbol", "entrezgene_id",
"description", "gene_biotype"),
filters = "ensembl_gene_id",
values = unique(res_df$ensembl_id),
mart = ensembl
)Diagnostic Plots
# ==============================================================================
# DESeq2 diagnostic plots
# ==============================================================================
# Dispersion plot
png(file.path(analysis_dir, "results/plots", "10_dispersion_plot.png"),
width = 8, height = 6, units = "in", res = 300)
plotDispEsts(dds, main = "Dispersion Estimates")
dev.off()
# MA plot
png(file.path(analysis_dir, "results/plots", "16_ma_plot.png"),
width = 8, height = 6, units = "in", res = 300)
plotMA(res_shrunk, main = "MA Plot (LFC Shrinkage)", ylim = c(-5, 5))
dev.off()
# P-value histogram
p_hist <- ggplot(res_df, aes(x = pvalue)) +
geom_histogram(bins = 50, fill = "steelblue", color = "white") +
geom_hline(yintercept = nrow(res_df) / 50, linetype = "dashed", color = "red") +
theme_minimal() +
labs(title = "P-value Distribution",
x = "P-value",
y = "Count")
ggsave(file.path(analysis_dir, "results/plots", "17_pvalue_histogram.png"), p_hist,
width = 8, height = 6, dpi = 150)Visualization Functions
Volcano Plot
# ==============================================================================
# Volcano plot function
# ==============================================================================
create_volcano <- function(results, title, fdr_thresh = 0.05, lfc_thresh = 1) {
results$significance <- case_when(
is.na(results$padj) ~ "Not tested",
results$padj < fdr_thresh & results$log2FoldChange > lfc_thresh ~ "Up",
results$padj < fdr_thresh & results$log2FoldChange < -lfc_thresh ~ "Down",
results$padj < fdr_thresh ~ "Significant",
TRUE ~ "Not significant"
)
# Count significant genes
n_up <- sum(results$significance == "Up", na.rm = TRUE)
n_down <- sum(results$significance == "Down", na.rm = TRUE)
ggplot(results, aes(x = log2FoldChange, y = -log10(pvalue), color = significance)) +
geom_point(alpha = 0.5, size = 1.5) +
scale_color_manual(values = c(
"Up" = "#e74c3c",
"Down" = "#3498db",
"Significant" = "#f39c12",
"Not significant" = "gray70",
"Not tested" = "gray90"
)) +
geom_vline(xintercept = c(-lfc_thresh, lfc_thresh), linetype = "dashed", color = "gray40") +
geom_hline(yintercept = -log10(0.05), linetype = "dashed", color = "gray40") +
theme_minimal() +
labs(title = title,
subtitle = sprintf("Up: %d, Down: %d (FDR < %.2f, |LFC| > %.1f)",
n_up, n_down, fdr_thresh, lfc_thresh),
x = "log2 Fold Change",
y = "-log10(P-value)",
color = "Significance") +
theme(legend.position = "bottom")
}
# Usage
p_volcano <- create_volcano(res_annotated, "Lesional vs Non-Lesional")
ggsave(file.path(analysis_dir, "results/plots", "15_volcano_plot.png"), p_volcano,
width = 8, height = 7, dpi = 150)Comparison-Specific DEG Heatmap (top 60 by |logFC|)
# ==============================================================================
# Heatmap of top DEGs by |logFC| - samples sorted by group, not clustered
# ==============================================================================
library(pheatmap)
create_comparison_heatmap <- function(de_results, vst_mat, sample_info,
conditions_to_include,
filename, title, n_top = 60) {
# Get significant DEGs
sig_results <- de_results %>%
filter(padj < 0.05 & !is.na(padj))
if (nrow(sig_results) == 0) {
cat(sprintf(" Skipping %s (no DEGs)\n", title))
return(NULL)
}
# Sort by absolute log2FoldChange and take top n
sig_results <- sig_results %>%
arrange(desc(abs(log2FoldChange)))
top_genes <- head(sig_results$gene_id, n_top)
cat(sprintf(" %s: %d significant DEGs, showing top %d by |logFC|\n",
title, nrow(sig_results), length(top_genes)))
# Filter samples to relevant conditions
samples_to_include <- sample_info %>%
filter(condition %in% conditions_to_include) %>%
pull(sample_id)
# Filter and order expression data
expr_subset <- vst_mat[top_genes, samples_to_include, drop = FALSE]
# Scale expression (z-score per gene)
expr_scaled <- t(scale(t(expr_subset)))
# Order samples by condition (not clustering)
sample_order <- sample_info %>%
filter(sample_id %in% samples_to_include) %>%
arrange(factor(condition, levels = conditions_to_include)) %>%
pull(sample_id)
expr_scaled <- expr_scaled[, sample_order, drop = FALSE]
# Get gene labels (gene symbols if available)
row_labels <- sig_results %>%
filter(gene_id %in% top_genes) %>%
mutate(label = ifelse(is.na(gene_symbol), gene_id, gene_symbol)) %>%
dplyr::select(gene_id, label)
labels_ordered <- row_labels$label[match(rownames(expr_scaled), row_labels$gene_id)]
labels_ordered[is.na(labels_ordered)] <- rownames(expr_scaled)[is.na(labels_ordered)]
# Column annotation
col_annotation <- data.frame(
Condition = sample_info[match(sample_order, sample_info$sample_id), "condition"],
row.names = sample_order
)
# Create heatmap - cluster genes but NOT samples
png(filename, width = 10, height = 12, units = "in", res = 300)
pheatmap(expr_scaled,
annotation_col = col_annotation,
annotation_colors = list(Condition = condition_colors),
labels_row = labels_ordered,
show_colnames = TRUE,
cluster_cols = FALSE, # Do not cluster samples
cluster_rows = TRUE, # Cluster genes
clustering_method = "ward.D2",
clustering_distance_rows = "correlation",
color = colorRampPalette(c("#3498db", "white", "#e74c3c"))(100),
main = sprintf("%s\nTop %d DEGs by |logFC| (FDR < 0.05)",
title, length(top_genes)),
fontsize_col = 8,
fontsize_row = 7,
border_color = NA)
dev.off()
cat(sprintf(" Saved: %s\n", basename(filename)))
}
# Usage example:
# create_comparison_heatmap(
# res_annotated, vst_mat, sample_info,
# conditions_to_include = c("Lesional", "NonLesional"),
# file.path(analysis_dir, "results/plots", "20_heatmap_LST_vs_NLST.png"),
# "Lesional vs Non-Lesional"
# )Overall Expression Heatmap (top variable genes)
library(pheatmap)
# Order samples by condition (no clustering)
sample_order <- order(sample_info$condition)
vst_ordered <- vst_mat[, sample_order]
sample_info_ordered <- sample_info[sample_order, ]
# Select top 2000 most variable genes by variance
gene_vars <- apply(vst_ordered, 1, var)
top_genes <- names(sort(gene_vars, decreasing = TRUE))[1:2000]
expr_top <- vst_ordered[top_genes, ]
# Median center expression for visualization
expr_centered <- expr_top - apply(expr_top, 1, median)
# Clip values to ±3 SD for better color utilization
mat_mean <- mean(expr_centered)
mat_sd <- sd(expr_centered)
lower_bound <- mat_mean - 3 * mat_sd
upper_bound <- mat_mean + 3 * mat_sd
expr_centered[expr_centered < lower_bound] <- lower_bound
expr_centered[expr_centered > upper_bound] <- upper_bound
# Create custom distance function using 1 - Pearson correlation
cor_dist <- function(x) {
as.dist(1 - cor(t(x), method = "pearson"))
}
# Create annotation for samples
annotation_col <- data.frame(
Condition = sample_info_ordered$condition,
row.names = sample_info_ordered$sample_id
)
# Traditional green-red color palette
heatmap_colors <- colorRampPalette(c("green", "black", "red"))(100)
# Generate heatmap - samples ordered by group, not clustered
png(file.path(analysis_dir, "results/plots", "08b_expression_heatmap_top2000.png"),
width = 6, height = 8, units = "in", res = 150)
pheatmap(expr_centered,
color = heatmap_colors,
clustering_distance_rows = cor_dist(expr_centered),
clustering_method = "average",
cluster_cols = FALSE,
annotation_col = annotation_col,
annotation_colors = list(Condition = condition_colors),
show_rownames = FALSE,
show_colnames = TRUE,
fontsize_col = 6,
main = "Expression Heatmap (Top 2000 Variable Genes)",
treeheight_row = 50)
dev.off()Sample Hierarchical Clustering Dendrogram
library(dendextend)
# Use all genes from VST, 1 - Pearson correlation distance, average linkage
sample_dist <- as.dist(1 - cor(vst_mat, method = "pearson"))
sample_hclust <- hclust(sample_dist, method = "average")
# Create dendrogram
dend <- as.dendrogram(sample_hclust)
# Get labels in dendrogram order and map to conditions
dend_labels <- labels(dend)
label_conditions <- sample_info[dend_labels, "condition"]
label_colors <- condition_colors[label_conditions]
# Color labels and branches by condition
labels_colors(dend) <- label_colors
dend <- branches_attr_by_labels(dend, dend_labels, label_colors, attr = "col")
png(file.path(analysis_dir, "results/plots", "08c_sample_dendrogram.png"),
width = 8, height = 6, units = "in", res = 150)
par(mar = c(4, 2, 2, 8), cex = 0.6)
plot(dend, horiz = TRUE,
main = "Sample Hierarchical Clustering\n(1 - Pearson correlation, average linkage)")
legend("topleft", legend = names(condition_colors),
col = condition_colors, lwd = 2, cex = 0.8, bty = "n")
dev.off()GO Enrichment Analysis
# ==============================================================================
# GO enrichment analysis
# ==============================================================================
library(clusterProfiler)
library(org.Hs.eg.db)
library(enrichplot)
# Get significant genes
sig_up <- res_annotated %>%
filter(padj < 0.05 & log2FoldChange > 0 & !is.na(entrez_id)) %>%
pull(entrez_id) %>%
unique() %>%
as.character()
sig_down <- res_annotated %>%
filter(padj < 0.05 & log2FoldChange < 0 & !is.na(entrez_id)) %>%
pull(entrez_id) %>%
unique() %>%
as.character()
# Background
background <- res_annotated %>%
filter(!is.na(entrez_id)) %>%
pull(entrez_id) %>%
unique() %>%
as.character()
cat(sprintf("Upregulated genes: %d\n", length(sig_up)))
cat(sprintf("Downregulated genes: %d\n", length(sig_down)))
cat(sprintf("Background genes: %d\n", length(background)))
# GO enrichment for upregulated genes
if (length(sig_up) >= 5) {
ego_up <- enrichGO(
gene = sig_up,
universe = background,
OrgDb = org.Hs.eg.db,
keyType = "ENTREZID",
ont = "BP",
pAdjustMethod = "BH",
pvalueCutoff = 0.05,
qvalueCutoff = 0.2,
readable = TRUE
)
# Simplify redundant terms
ego_up_simp <- simplify(ego_up, cutoff = 0.7, by = "p.adjust")
# Filter by FDR
ego_up_sig <- ego_up_simp[ego_up_simp@result$p.adjust < 0.05, ]
# Save
write.csv(as.data.frame(ego_up_sig),
file.path(analysis_dir, "results/tables", "GO_BP_upregulated.csv"),
row.names = FALSE)
# Plot
if (nrow(ego_up_sig) > 0) {
p_go_up <- dotplot(ego_up_sig, showCategory = 20) +
labs(title = "GO BP - Upregulated Genes")
ggsave(file.path(analysis_dir, "results/plots", "25_go_dotplot_up.png"), p_go_up,
width = 10, height = 8, dpi = 150)
}
}
# GO enrichment for downregulated genes
if (length(sig_down) >= 5) {
ego_down <- enrichGO(
gene = sig_down,
universe = background,
OrgDb = org.Hs.eg.db,
keyType = "ENTREZID",
ont = "BP",
pAdjustMethod = "BH",
pvalueCutoff = 0.05,
qvalueCutoff = 0.2,
readable = TRUE
)
ego_down_simp <- simplify(ego_down, cutoff = 0.7, by = "p.adjust")
ego_down_sig <- ego_down_simp[ego_down_simp@result$p.adjust < 0.05, ]
write.csv(as.data.frame(ego_down_sig),
file.path(analysis_dir, "results/tables", "GO_BP_downregulated.csv"),
row.names = FALSE)
if (nrow(ego_down_sig) > 0) {
p_go_down <- dotplot(ego_down_sig, showCategory = 20) +
labs(title = "GO BP - Downregulated Genes")
ggsave(file.path(analysis_dir, "results/plots", "26_go_dotplot_down.png"), p_go_down,
width = 10, height = 8, dpi = 150)
}
}GSEA Analysis
# ==============================================================================
# Gene Set Enrichment Analysis (GSEA)
# ==============================================================================
library(fgsea)
library(msigdbr)
# Create ranked gene list
gene_ranks <- res_annotated %>%
filter(!is.na(gene_symbol) & !is.na(stat)) %>%
arrange(desc(stat)) %>%
distinct(gene_symbol, .keep_all = TRUE)
ranks <- setNames(gene_ranks$stat, gene_ranks$gene_symbol)
# Get gene sets (Hallmark)
hallmark <- msigdbr(species = "Homo sapiens", category = "H")
hallmark_list <- split(hallmark$gene_symbol, hallmark$gs_name)
# Run fGSEA
set.seed(42)
fgsea_res <- fgsea(
pathways = hallmark_list,
stats = ranks,
minSize = 15,
maxSize = 500
)
# Format results
fgsea_df <- fgsea_res %>%
arrange(pval) %>%
mutate(leadingEdge = sapply(leadingEdge, paste, collapse = ", "))
# Save results
write.csv(fgsea_df, file.path(analysis_dir, "results/tables", "GSEA_hallmark.csv"), row.names = FALSE)
# Plot top pathways
top_pathways <- fgsea_res %>%
arrange(padj) %>%
head(20) %>%
pull(pathway)
p_gsea <- ggplot(fgsea_df %>% filter(pathway %in% top_pathways),
aes(x = reorder(pathway, NES), y = NES, fill = padj < 0.05)) +
geom_col() +
coord_flip() +
scale_fill_manual(values = c("TRUE" = "#e74c3c", "FALSE" = "gray70")) +
theme_minimal() +
labs(title = "GSEA - Hallmark Gene Sets",
x = "",
y = "Normalized Enrichment Score",
fill = "FDR < 0.05")
ggsave(file.path(analysis_dir, "results/plots", "29_gsea_barplot.png"), p_gsea,
width = 10, height = 8, dpi = 150)Utility Functions
Extract and Summarize Results
# ==============================================================================
# Utility: Extract results with summary
# ==============================================================================
extract_deseq_results <- function(dds, contrast_name, contrast_vector = NULL,
fdr_threshold = 0.05) {
if (!is.null(contrast_vector)) {
res <- results(dds, contrast = contrast_vector, alpha = fdr_threshold)
# Use ashr for contrast-based shrinkage
res_shrunk <- lfcShrink(dds, contrast = contrast_vector, type = "ashr")
} else {
res <- results(dds, name = contrast_name, alpha = fdr_threshold)
res_shrunk <- lfcShrink(dds, coef = contrast_name, type = "apeglm")
}
res_df <- as.data.frame(res_shrunk)
res_df$gene_id <- rownames(res_df)
# Summary
sig_fdr <- sum(res_df$padj < fdr_threshold, na.rm = TRUE)
up_reg <- sum(res_df$padj < fdr_threshold & res_df$log2FoldChange > 0, na.rm = TRUE)
down_reg <- sum(res_df$padj < fdr_threshold & res_df$log2FoldChange < 0, na.rm = TRUE)
cat(sprintf("\n%s:\n", contrast_name))
cat(sprintf(" Total genes tested: %d\n", nrow(res_df)))
cat(sprintf(" FDR < %.2f: %d\n", fdr_threshold, sig_fdr))
cat(sprintf(" Up-regulated: %d\n", up_reg))
cat(sprintf(" Down-regulated: %d\n", down_reg))
return(res_df)
}Get Package Versions
# ==============================================================================
# Utility: Get package versions
# ==============================================================================
get_package_versions <- function(packages) {
versions <- sapply(packages, function(pkg) {
tryCatch(
as.character(packageVersion(pkg)),
error = function(e) "Not installed"
)
})
data.frame(Package = packages, Version = versions, row.names = NULL)
}
# Usage
packages_used <- c("DESeq2", "apeglm", "ggplot2", "dplyr", "pheatmap",
"clusterProfiler", "org.Hs.eg.db", "fgsea", "msigdbr")
print(get_package_versions(packages_used))Save Normalized Counts
# ==============================================================================
# Save normalized counts
# ==============================================================================
# DESeq2 normalized counts (size factor normalized)
normalized_counts <- counts(dds, normalized = TRUE)
write.csv(normalized_counts, file.path(analysis_dir, "results/tables", "normalized_counts.csv"))
# VST counts (for visualization)
write.csv(vst_mat, file.path(analysis_dir, "results/tables", "vst_counts.csv"))
# Save DESeqDataSet object
saveRDS(dds, file.path(analysis_dir, "data", "deseq2_dds.rds"))Template Files for RNA-seq Analysis
analysis.md Template
Use this template structure for the analysis.md file in each study directory:
# [GSE#] Bulk RNA-seq Analysis Report
## Study Information
| Field | Value |
|-------|-------|
| **GEO Accession** | GSE##### |
| **Publication** | [Author et al. Journal Year] |
| **Title** | [Study title] |
| **Data Type** | Bulk RNA-seq |
| **Organism** | Homo sapiens |
| **Analysis Date** | YYYY-MM-DD |
## Study Design
[Describe the experimental design, sample groups, and biological question]
### Sample Groups
| Group | N | Description |
|-------|---|-------------|
| Control | X | [description] |
| Treatment | X | [description] |
### Experimental Factors
- **Primary comparison:** [e.g., Lesional vs Non-Lesional]
- **Blocking factor:** [e.g., Patient ID for paired designs]
- **Covariates:** [e.g., Batch, Age, Sex]
---
## Methods
### Software Environment
| Component | Version |
|-----------|---------|
| R | X.X.X |
| Bioconductor | X.XX |
### R Packages Used
| Package | Version | Purpose |
|---------|---------|---------|
| DESeq2 | X.XX.X | Differential expression analysis |
| apeglm | X.XX.X | Log fold change shrinkage |
| AnnotationDbi | X.XX.X | Gene ID mapping |
| clusterProfiler | X.XX.X | GO enrichment analysis |
| ggplot2 | X.XX.X | Visualization |
### Data Processing Pipeline
#### 1. Data Loading
- Source: [GEO, local files, etc.]
- Format: [count matrix, featureCounts, RSEM]
- Initial genes: [N]
- Initial samples: [N]
#### 2. Pre-filtering
- Filtering criteria: Genes with CPM ≥ 2 in ≥ 30% of samples AND total counts ≥ 20
- Used `filter_counts()` utility function from `scripts/utils/filter_counts.R`
- Genes after filtering: [N]
#### 3. Quality Control
- Library size range: [min - max]
- Genes detected range: [min - max]
- Outliers identified: [N samples]
- Outliers removed: [list if any]
#### 4. DESeq2 Analysis
- Design formula: `~ [design]`
- Reference level: [level]
- LFC shrinkage method: normal (recommended for paired designs)
---
## Results
### Gene Tracking
| Step | Genes | Removed | % Remaining | Description |
|------|-------|---------|-------------|-------------|
| 1. Raw counts loaded | X | 0 | 100.0% | Total genes in count matrix |
| 2. Pre-filtering | X | X | XX.X% | Genes with CPM >= 2 in >= 30% samples, total >= 20 |
| 3. DESeq2 model | X | 0 | XX.X% | Genes in DESeq2 model |
| 4. Genes tested | X | X | XX.X% | Genes with valid adjusted p-values |
| 5. Gene annotation | X | X | XX.X% | Genes with valid gene symbols |
### Sample Summary
| Group | N | Mean Library Size | Mean Genes Detected |
|-------|---|-------------------|---------------------|
| Control | X | X,XXX,XXX | XX,XXX |
| Treatment | X | X,XXX,XXX | XX,XXX |
### Quality Control Results
#### Outlier Identification
| Sample | Library Size | Genes Detected | Z-score | Action |
|--------|--------------|----------------|---------|--------|
| [sample] | X,XXX,XXX | XX,XXX | X.XX | [keep/remove] |
#### PCA Summary
- PC1 explains: X% variance
- PC2 explains: X% variance
- Samples cluster by: [condition/batch/other]
### Differential Expression Results
| Comparison | Design | Total DEGs | Up | Down | |LFC| > 1 |
|------------|--------|------------|-----|------|------------|
| [comparison] | [paired/unpaired] | X,XXX | XXX | XXX | XXX |
### Top Differentially Expressed Genes
#### Upregulated (top 10)
| Gene | log2FC | padj | Description |
|------|--------|------|-------------|
| [gene] | X.XX | X.XXe-XX | [description] |
#### Downregulated (top 10)
| Gene | log2FC | padj | Description |
|------|--------|------|-------------|
| [gene] | -X.XX | X.XXe-XX | [description] |
### GO Enrichment Summary
#### Upregulated Genes
| GO Term | Gene Count | FDR | Representative Genes |
|---------|------------|-----|---------------------|
| [term] | XX | X.XXe-XX | [genes] |
#### Downregulated Genes
| GO Term | Gene Count | FDR | Representative Genes |
|---------|------------|-----|---------------------|
| [term] | XX | X.XXe-XX | [genes] |
### Key Biological Findings
1. [Major finding 1]
2. [Major finding 2]
3. [Major finding 3]
---
## Output Files
### Tables (results/tables/)
| File | Description | Rows |
|------|-------------|------|
| sample_metadata.csv | Sample information | [N] |
| qc_metrics.csv | QC metrics per sample | [N] |
| gene_tracking.csv | Gene counts at each processing step | 5 |
| normalized_counts.csv | DESeq2 normalized counts | [N genes] |
| vst_counts.csv | VST transformed counts | [N genes] |
| DE_[Comparison]_full.csv | Full DE results | [N genes] |
| DE_[Comparison]_annotated.csv | Annotated DE results | [N genes] |
| DE_[Comparison]_significant.csv | Significant DEGs only | [N genes] |
| GO_BP_[Direction].csv | GO enrichment results | [N terms] |
### Plots (results/plots/)
| File | Description |
|------|-------------|
| 01_library_sizes.png | Library size barplot |
| 02_genes_detected.png | Genes detected barplot |
| 03_count_distribution.png | Count distribution boxplot |
| 04_pca_plot.png | PCA of samples |
| 05_sample_heatmap.png | Sample correlation heatmap |
| 10_dispersion_plot.png | DESeq2 dispersion estimates |
| 15_volcano_[Comparison].png | Volcano plot |
| 16_ma_plot.png | MA plot |
| 17_pvalue_histogram.png | P-value distribution |
| 20_heatmap_top_DEGs.png | Heatmap of top DEGs |
| 25_go_dotplot.png | GO enrichment dotplot |
---
## Next Steps
- [ ] [Planned follow-up analysis]
- [ ] [Integration with other datasets]
- [ ] [Validation experiments]
---
## Session Info
[Paste sessionInfo() output here]
---
## References
1. [Citation for original study]
2. Love MI, Huber W, Anders S (2014). Moderated estimation of fold change and dispersion for RNA-seq data with DESeq2. Genome Biology 15:550.
3. [Other method citations]plots.md Template
Use this template structure for results/plots/plots.md:
# Plot Documentation - [GSE#]
All plots are saved as PNG files at 300 DPI unless otherwise noted.
## Quality Control Plots
### 01_library_sizes.png
**Description:** Bar plot of total read counts (library size) per sample.
**Interpretation:** Samples should have roughly similar library sizes. Large differences may indicate technical issues. Outliers (z-score > 2) are flagged.

### 02_genes_detected.png
**Description:** Bar plot of number of genes with at least one count per sample.
**Interpretation:** Similar numbers expected across samples of same type. Low detection may indicate degraded RNA or sequencing issues.

### 03_count_distribution.png
**Description:** Boxplot of log2(count+1) distribution per sample.
**Interpretation:** Distributions should be similar across samples. Shifted medians may indicate normalization issues or outliers.

### 04_count_density.png
**Description:** Overlapping density curves of log2(count+1), colored by condition.
**Interpretation:** Curves should largely overlap; bimodal shape is normal (unexpressed and expressed genes).

### 05_sample_correlation_heatmap.png
**Description:** Heatmap of pairwise Pearson correlations between samples with hierarchical clustering.
**Interpretation:** Samples should cluster by biological condition. Outliers show lower correlation with their group.

### 06_pca_raw.png
**Description:** Principal Component Analysis of VST-transformed counts, PC1 vs PC2.
**Interpretation:** Samples should separate by primary biological variable. Batch effects appear as clustering by technical factors.

### 07_pca_scree.png
**Description:** Scree plot showing variance explained by each principal component.
**Interpretation:** First few PCs should capture most variance. Many equal PCs may indicate high noise.

### 08_sample_distance_heatmap.png
**Description:** Heatmap of Euclidean distances between samples using VST-transformed data.
**Interpretation:** Smaller distances (darker) between biological replicates expected.

### 08b_expression_heatmap_top2000.png
**Description:** Overall expression heatmap of top 2000 most variable genes.
**Methods:**
- Uses VST-transformed counts
- Genes selected by highest variance across samples
- Median-centered expression values, clipped to ±3 SD
- Gene clustering: 1 - Pearson correlation distance, average linkage
- Samples ordered by condition (not clustered)
- Green-black-red color scheme
**Interpretation:** Reveals global expression patterns; samples should show condition-specific clustering patterns among genes.

### 08c_sample_dendrogram.png
**Description:** Hierarchical clustering dendrogram of all samples.
**Methods:**
- Uses all genes from VST-transformed counts
- Distance: 1 - Pearson correlation coefficient
- Linkage: Average (UPGMA)
- Horizontal layout with branches/labels colored by condition
**Interpretation:** Shows sample relationships; samples from same condition should cluster together. Mixed clustering suggests biological heterogeneity or batch effects.

### 09_qc_by_condition.png
**Description:** QC metrics (library size, genes detected) compared across conditions.
**Interpretation:** Technical metrics should not differ systematically between biological conditions.

## DESeq2 Diagnostic Plots
### 10_dispersion_plot.png
**Description:** DESeq2 dispersion estimates showing gene-wise, fitted, and final estimates.
**Interpretation:** Points should cluster around the fitted line. Outlier genes appear above/below the cloud.

### 11_size_factors.png
**Description:** Bar plot of DESeq2 size factors per sample.
**Interpretation:** Size factors normalize for library size. Values far from 1 indicate large library size differences.

### 12_cooks_distance.png
**Description:** Boxplot of Cook's distances per sample.
**Interpretation:** High Cook's distances indicate samples with strong influence on results. May flag outliers.

## Differential Expression Plots
### 15_volcano_[comparison].png
**Description:** Volcano plot showing -log10(p-value) vs log2 fold change.
**Color coding:**
- Red: Significantly upregulated (FDR < 0.05, logFC > 1)
- Blue: Significantly downregulated (FDR < 0.05, logFC < -1)
- Orange: FDR < 0.05 but |logFC| < 1
- Gray: Not significant

### 16_ma_plot.png
**Description:** MA plot showing log2 fold change vs mean expression.
**Interpretation:** Points should be centered at y=0. Funnel shape expected (higher variance at low expression). Blue points are significant.

### 17_pvalue_histogram.png
**Description:** Distribution of raw p-values from DESeq2.
**Interpretation:** Should show uniform distribution (null) plus spike near 0 (true positives). Spike at p=1 is normal for low-count genes.

### 18_lfc_histogram.png
**Description:** Distribution of log2 fold changes for significant genes.
**Interpretation:** Shows balance and magnitude of up vs down regulation.

### 19_de_summary.png
**Description:** Bar chart summarizing number of up/down regulated genes per comparison.
**Interpretation:** Quick overview of effect sizes across contrasts.

### 20_heatmap_[comparison].png (e.g., 20_heatmap_LST_vs_NLST.png)
**Description:** Heatmap of top 60 DEGs by |logFC| for a specific comparison.
**Methods:**
- Genes selected: Top 60 significant DEGs (FDR < 0.05) ranked by absolute log2 fold change
- Expression values: VST-transformed, Z-score normalized per gene
- Samples: Only the two conditions being compared, sorted by group (not clustered)
- Genes: Hierarchically clustered (Ward.D2, correlation distance)
- Color scheme: Blue-white-red
**Interpretation:** Shows the strongest expression differences between compared conditions. Samples sorted by group allow visual assessment of within-group consistency.

## GO and KEGG Enrichment Plots
### [num]_GO_BP_[comparison].png (e.g., 25_GO_BP_Lesional_vs_NonLesional.png)
**Description:** Gene Ontology Biological Process enrichment for a specific comparison.
**Layout:** Two panels if both directions have significant terms (upregulated top, downregulated bottom), single panel otherwise.
**Visualization:** Dot plot with -log10(adj. p-value) on x-axis, terms on y-axis, color and size by gene count.
**Methods:**
- Background: All genes that passed filtering
- GO terms simplified to reduce redundancy (cutoff = 0.7)
- FDR < 0.05 threshold

### [num]b_KEGG_[comparison].png (e.g., 25b_KEGG_Lesional_vs_NonLesional.png)
**Description:** KEGG pathway enrichment for a specific comparison.
**Layout:** Two panels if both directions have significant pathways, single panel otherwise.
**Visualization:** Same style as GO plot - dot plot with -log10(adj. p-value) on x-axis.
**Methods:**
- Background: All genes that passed filtering
- FDR < 0.05 threshold

### 29_gsea_enrichment.png (optional)
**Description:** GSEA enrichment plots for top gene sets.
**Interpretation:** Running enrichment score shows where gene set members fall in ranked list.

## Comparison Plots
### 30_venn_diagram.png
**Description:** Venn diagram showing overlap of DEGs between comparisons.
**Interpretation:** Shows shared and unique gene signatures across conditions.

### 31_upset_plot.png
**Description:** UpSet plot for multi-way DEG overlaps.
**Interpretation:** More detailed view of set intersections than Venn diagram.
CLAUDE.md Template for Study Metadata
Create this file in each study directory:
# [GSE#]
## Title
[Study title from GEO]
## Abstract
[Abstract/summary from GEO]
## Platform
RNA-seq ([Sequencing platform: Illumina HiSeq, NovaSeq, etc.])
## Overall design
[Experimental design description]
## Features
[Number of genes in count matrix]
## Data Type
[Raw counts / TPM / FPKM / etc.]
## Citation
[Full citation with PMID]
## Samples
| ID | Title | Condition | Subject | Other |
|----|-------|-----------|---------|-------|
| GSM### | [title] | [condition] | [subject_id] | [notes] |
## Data Files
| File | Description |
|------|-------------|
| [filename] | [description] |
## Notes
[Any special considerations for analysis, e.g., batch effects, paired design]
## Analysis Considerations
- **Design type:** [Paired/Unpaired]
- **Primary comparison:** [e.g., Lesional vs Non-Lesional]
- **Paired samples only:** [For paired designs, note which samples have both conditions]
- **Covariates to consider:** [e.g., Age, Sex, Batch]
- **Known issues:** [e.g., Batch effects, missing samples]
## Technical Notes
- Use `here` package for relative paths
- Use `filter_counts()` utility from `scripts/utils/filter_counts.R`
- Use `type = "normal"` for LFC shrinkage with paired designs