This file reruns the TF activity tests from
Decoupler analysis.Rmd twice on the same DESeq2 signature:
once with all genes (before), and once with
the Hallmark E2F_TARGETS ∪ G2M_CHECKPOINT genes removed
(after). It then compares the two runs TF by TF, to see which
TF calls hold up once the proliferation genes are gone.
deseq_file in the YAML
params). The signature is the DESeq2 Wald
stat, filtered as in Decoupler analysis.Rmd.
Positive = higher in tFL (see the sign check under
Input signature).Decoupler MYC regulators.Rmd. MLM and msVIPER have no null,
because it would take hours to compute.params.suppressPackageStartupMessages({
library(decoupleR)
library(viper)
library(bcellViper)
library(dplyr)
library(tidyr)
library(ggplot2)
library(ggrepel)
library(patchwork)
library(openxlsx)
})
proj_dir <- "C:/Users/User/Desktop/MM Work/Main Work/fl_tFL analysis R"
deseq_file <- if (grepl("^([A-Za-z]:)?[/\\\\]", params$deseq_file)) params$deseq_file else
file.path(proj_dir, params$deseq_file)
collectri_file <- "C:/Users/User/Desktop/MM Work/collectri_human.csv"
out_xlsx <- if (grepl("^([A-Za-z]:)?[/\\\\]", params$out_xlsx)) params$out_xlsx else
file.path(proj_dir, params$out_xlsx)
if (!file.exists(deseq_file))
stop(deseq_file, " not found. Knit 'Deseq analysis.Rmd' first, or set deseq_file: \"DESeq2_mraz.tsv\".")
# plot tokens: three categorical slots (validated all-pairs for scatter), greys for context
col_survive <- "#2a78d6"
col_atten <- "#eb6834"
col_lost <- "#1baf7a"
col_context <- "#c3c2b7"
col_grid <- "#e1e0d9"
col_surface <- "#fcfcfb"
ink_primary <- "#0b0b0b"
ink_second <- "#52514e"
ink_muted <- "#898781"
group_cols <- c("Survives" = col_survive,
"Attenuated (still significant)" = col_atten,
"Lost / reversed" = col_lost,
"Not significant before" = col_context)
theme_sens <- function(base_size = 11) {
theme_minimal(base_size = base_size) +
theme(plot.background = element_rect(fill = col_surface, colour = NA),
panel.grid.major = element_line(colour = col_grid, linewidth = 0.3),
panel.grid.minor = element_blank(),
axis.text = element_text(colour = ink_muted),
axis.title = element_text(colour = ink_second),
plot.title = element_text(colour = ink_primary, face = "bold"),
plot.subtitle = element_text(colour = ink_second),
plot.caption = element_text(colour = ink_muted, hjust = 0),
plot.title.position = "plot",
plot.caption.position = "plot",
legend.position = "top",
legend.justification = "left",
legend.title = element_blank(),
legend.text = element_text(colour = ink_second))
}
Data prep is identical to sections 1 and 3 of
Decoupler analysis.Rmd.
deseq_unfiltered <- read.table(deseq_file, sep = "\t", header = TRUE)
deseq <- deseq_unfiltered |> dplyr::filter(baseMean > 4.99)
# decoupleR signature: stat as a genes x 1 matrix (first row per gene_name).
# Labelled "FL_vs_tFL" in Decoupler analysis.Rmd; the label does not affect any result.
deseq_stats <- deseq[!duplicated(deseq$gene_name), c("gene_name", "stat")]
deseq_stats <- deseq_stats[!is.na(deseq_stats$stat), ]
mat_stat <- matrix(deseq_stats$stat, ncol = 1,
dimnames = list(deseq_stats$gene_name, "tFL_vs_FL"))
# msVIPER signature: baseMean > 10, one row per gene (largest |stat|)
deg <- deseq_unfiltered |>
dplyr::filter(!is.na(gene_name) & !is.na(stat) & baseMean > 10) |>
dplyr::arrange(dplyr::desc(abs(stat))) |>
dplyr::distinct(gene_name, .keep_all = TRUE)
sign_C <- setNames(deg$stat, deg$gene_name)
# each TF's own DESeq2 results, for context
deseq_lookup <- deseq_unfiltered |>
dplyr::filter(!duplicated(gene_name)) |>
dplyr::select(gene_name, deseq_baseMean = baseMean,
deseq_log2FoldChange = log2FoldChange, deseq_stat = stat,
deseq_pvalue = pvalue, deseq_padj = padj)
Sign check. Proliferation markers should be higher
in tFL, and their stat should be positive.
norm_fl <- grep("^FL_.*_normCounts$", colnames(deseq_unfiltered), value = TRUE)
norm_tfl <- grep("^tFL_.*_normCounts$", colnames(deseq_unfiltered), value = TRUE)
sign_check <- deseq_unfiltered |>
dplyr::filter(gene_name %in% c("MKI67", "TOP2A", "CDK1", "MYC")) |>
dplyr::mutate(mean_norm_FL = rowMeans(dplyr::pick(dplyr::all_of(norm_fl))),
mean_norm_tFL = rowMeans(dplyr::pick(dplyr::all_of(norm_tfl)))) |>
dplyr::transmute(gene_name, stat, mean_norm_FL, mean_norm_tFL,
higher_in = ifelse(mean_norm_tFL > mean_norm_FL, "tFL", "FL"))
knitr::kable(sign_check, digits = 2,
caption = sprintf("%d FL and %d tFL samples", length(norm_fl), length(norm_tfl)))
| gene_name | stat | mean_norm_FL | mean_norm_tFL | higher_in |
|---|---|---|---|---|
| CDK1 | 7.07 | 45.32 | 195.16 | tFL |
| TOP2A | 6.77 | 198.41 | 766.19 | tFL |
| MKI67 | 5.56 | 143.44 | 484.68 | tFL |
| MYC | 3.67 | 20.65 | 87.95 | tFL |
if (!all((sign_check$stat > 0) == (sign_check$higher_in == "tFL")))
warning("DESeq2 stat does not follow tFL > FL for these genes; check the contrast direction.")
The lists below are the MSigDB Hallmark gene sets E2F_TARGETS and G2M_CHECKPOINT (200 genes each), human release v2026.1.Hs, copied from the gene set pages on gsea-msigdb.org on 2026-09-24. Gene names are current HGNC symbols. Your DESeq2 annotation still uses some older names (e.g. H2AFZ, PAPD7), so each renamed gene is also matched on the original symbol that MSigDB gives for it.
This list replaces the 327-symbol vector in
Dano_Data/prolif_residual_screen.R, which lacked KIF23 and
SS18 and had two clone IDs (AC027237.1, AC091021.1) in their place.
words <- function(x) strsplit(trimws(x), "\\s+")[[1]]
hallmark_e2f <- words("
AK2 ANP32E ASF1A ASF1B ATAD2 AURKA AURKB BARD1 BIRC5 BRCA1 BRCA2 BRMS1L BUB1B CBX5 CCNB2
CCNE1 CCP110 CDC20 CDC25A CDC25B CDCA3 CDCA8 CDK1 CDK4 CDKN1A CDKN1B CDKN2A CDKN2C CDKN3
CENPE CENPM CHEK1 CHEK2 CIT CKS1B CKS2 CNOT9 CSE1L CTCF CTPS1 DCK DCLRE1B DCTPP1 DDX39A
DEK DEPDC1 DIAPH3 DLGAP5 DNMT1 DONSON DSCC1 DUT E2F8 EED EIF2S1 ESPL1 EXOSC8 EZH2 GINS1
GINS3 GINS4 GSPT1 H2AX H2AZ1 HELLS HMGA1 HMGB2 HMGB3 HMMR HNRNPD HUS1 ILF3 ING3 IPO7
JPT1 KIF18B KIF22 KIF2C KIF4A KPNA2 LBR LIG1 LMNB1 LUC7L3 LYAR MAD2L1 MCM2 MCM3 MCM4
MCM5 MCM6 MCM7 MELK MKI67 MLH1 MMS22L MRE11 MSH2 MTHFD2 MXD3 MYBL2 MYC NAA38 NAP1L1 NASP
NBN NCAPD2 NME1 NOLC1 NOP56 NUDT21 NUP107 NUP153 NUP205 ORC2 ORC6 PA2G4 PAICS PAN2 PCNA
PDS5B PHF5A PLK1 PLK4 PMS2 PNN POLA2 POLD1 POLD2 POLD3 POLE POLE4 POP7 PPM1D PPP1R8
PRDX4 PRIM2 PRKDC PRPS1 PSIP1 PSMC3IP PTTG1 RACGAP1 RAD1 RAD21 RAD50 RAD51AP1 RAD51C RAN
RANBP1 RBBP7 RFC1 RFC2 RFC3 RNASEH2A RPA1 RPA2 RPA3 RRM2 SHMT1 SLBP SMC1A SMC3 SMC4 SMC6
SNRPB SPAG5 SPC24 SPC25 SRSF1 SRSF2 SSRP1 STAG1 STMN1 SUV39H1 SYNCRIP TACC3 TBRG4 TCF19
TFRC TIMELESS TIPIN TK1 TMPO TOP2A TP53 TRA2B TRIP13 TUBB TUBG1 UBE2S UBE2T UBR7 UNG
USP1 WDR90 WEE1 XPO1 XRCC6 ZW10")
hallmark_g2m <- words("
ABL1 AMD1 ARID4A ATF5 ATRX AURKA AURKB BARD1 BCL3 BIRC5 BRCA2 BUB1 BUB3 CASP8AP2 CBX1
CCNA2 CCNB2 CCND1 CCNF CCNT1 CDC20 CDC25A CDC25B CDC27 CDC45 CDC6 CDC7 CDK1 CDK4 CDKN1B
CDKN2C CDKN3 CENPA CENPE CENPF CHAF1A CHEK1 CHMP1A CKS1B CKS2 CTCF CUL1 CUL3 CUL4A CUL5
DBF4 DDX39A DKC1 DMD DR1 DTYMK E2F1 E2F2 E2F3 E2F4 EFNA5 EGF ESPL1 EWSR1 EXO1 EZH2 FANCC
FBXO5 FOXN3 G3BP1 GINS2 GSPT1 H2AX H2AZ1 H2AZ2 H2BC12 HIF1A HIRA HMGA1 HMGB3 HMGN2 HMMR
HNRNPD HNRNPU HOXC10 HSPA8 HUS1 ILF3 INCENP JPT1 KATNA1 KIF11 KIF15 KIF20B KIF22 KIF23
KIF2C KIF4A KIF5B KMT5A KNL1 KPNA2 KPNB1 LBR LIG3 LMNB1 MAD2L1 MAP3K20 MAPK14 MARCKS
MCM2 MCM3 MCM5 MCM6 MEIS1 MEIS2 MKI67 MNAT1 MT2A MTF2 MYBL2 MYC NASP NCL NDC80 NEK2
NOLC1 NOTCH2 NSD2 NUMA1 NUP50 NUP98 NUSAP1 ODC1 ODF2 ORC5 ORC6 PAFAH1B1 PBK PDS5B PLK1
PLK4 PML POLA2 POLE POLQ PRC1 PRIM2 PRMT5 PRP4K PTTG1 PTTG3P PURA RACGAP1 RAD21 RAD23B
RAD54L RASAL2 RBL1 RBM14 RPA2 RPS6KA5 SAP30 SFPQ SLC12A2 SLC38A1 SLC7A1 SLC7A5 SMAD3
SMARCC1 SMC1A SMC2 SMC4 SNRPD1 SQLE SRSF1 SRSF10 SRSF2 SS18 STAG1 STIL STMN1 SUV39H1
SYNCRIP TACC3 TENT4A TFDP1 TGFB1 TLE3 TMPO TNPO2 TOP1 TOP2A TPX2 TRA2B TRAIP TROAP TTK
UBE2C UBE2S UCK2 UPF1 WRN XPO1 YTHDC1")
stopifnot(length(unique(hallmark_e2f)) == 200, length(unique(hallmark_g2m)) == 200)
# current HGNC symbol -> original MSigDB source symbol, for renamed genes
msigdb_alias <- c(CTPS1 = "CTPS", H2AX = "H2AFX", H2AZ1 = "H2AFZ", H2AZ2 = "H2AFV",
H2BC12 = "HIST1H2BK", JPT1 = "HN1", MRE11 = "MRE11A", CNOT9 = "RQCD1",
KNL1 = "CASC5", TENT4A = "PAPD7", PRP4K = "PRPF4B", KMT5A = "SETD8",
NSD2 = "WHSC1", MAP3K20 = "ZAK")
to_data_symbol <- function(g, pool) {
hit <- ifelse(g %in% pool, g, NA_character_)
alt <- unname(msigdb_alias[g])
ifelse(is.na(hit) & !is.na(alt) & alt %in% pool, alt, hit)
}
prolif_tbl <- tibble::tibble(msigdb_symbol = union(hallmark_e2f, hallmark_g2m)) |>
dplyr::mutate(in_E2F_TARGETS = msigdb_symbol %in% hallmark_e2f,
in_G2M_CHECKPOINT = msigdb_symbol %in% hallmark_g2m,
data_symbol = to_data_symbol(msigdb_symbol, deseq_unfiltered$gene_name),
in_decoupler_signature = !is.na(data_symbol) & data_symbol %in% rownames(mat_stat),
in_msviper_signature = !is.na(data_symbol) & data_symbol %in% names(sign_C)) |>
dplyr::left_join(deseq_lookup |> dplyr::select(gene_name, deseq_baseMean, deseq_stat),
by = c("data_symbol" = "gene_name")) |>
dplyr::arrange(msigdb_symbol)
prolif_genes <- sort(unique(stats::na.omit(prolif_tbl$data_symbol)))
tibble::tibble(
gene_set = c("E2F_TARGETS", "G2M_CHECKPOINT", "Union", " found in DESeq2 file",
" in decoupleR signature (baseMean > 4.99)", " in msVIPER signature (baseMean > 10)"),
genes = c(length(hallmark_e2f), length(hallmark_g2m), nrow(prolif_tbl), length(prolif_genes),
sum(prolif_tbl$in_decoupler_signature), sum(prolif_tbl$in_msviper_signature))) |>
knitr::kable()
| gene_set | genes |
|---|---|
| E2F_TARGETS | 200 |
| G2M_CHECKPOINT | 200 |
| Union | 327 |
| found in DESeq2 file | 323 |
| in decoupleR signature (baseMean > 4.99) | 319 |
| in msVIPER signature (baseMean > 10) | 316 |
renamed <- prolif_tbl |> dplyr::filter(!is.na(data_symbol), data_symbol != msigdb_symbol)
cat("Matched on the older symbol:",
paste(renamed$msigdb_symbol, renamed$data_symbol, sep = " -> ", collapse = ", "), "\n")
## Matched on the older symbol: H2AX -> H2AFX, H2AZ1 -> H2AFZ, H2AZ2 -> H2AFV, H2BC12 -> HIST1H2BK, PRP4K -> PRPF4B, TENT4A -> PAPD7
cat("Not in the DESeq2 file:",
paste(prolif_tbl$msigdb_symbol[is.na(prolif_tbl$data_symbol)], collapse = ", "), "\n")
## Not in the DESeq2 file: HOXC10, NME1, RBM14, SPAG5
stat_df <- tibble::tibble(gene = rownames(mat_stat), stat = mat_stat[, 1]) |>
dplyr::mutate(group = ifelse(gene %in% prolif_genes, "E2F/G2M genes", "All other genes"))
stat_means <- stat_df |>
dplyr::group_by(group) |>
dplyr::summarise(mean = mean(stat), n = dplyr::n(), .groups = "drop")
ggplot(stat_df, aes(stat, colour = group)) +
geom_vline(xintercept = 0, colour = col_context, linewidth = 0.3) +
geom_density(linewidth = 0.8, key_glyph = "path") +
geom_vline(data = stat_means, aes(xintercept = mean, colour = group), linewidth = 0.5,
show.legend = FALSE) +
scale_colour_manual(values = c("All other genes" = ink_muted, "E2F/G2M genes" = col_survive)) +
labs(title = "Proliferation genes are shifted up in tFL",
subtitle = paste(sprintf("%s: n = %d, mean stat %+.2f", stat_means$group, stat_means$n,
stat_means$mean), collapse = " | "),
x = "DESeq2 stat (positive = higher in tFL)", y = "Density",
caption = "Vertical lines: group means. decoupleR signature (baseMean > 4.99).") +
theme_sens()
mat_stat_ex <- mat_stat[!rownames(mat_stat) %in% prolif_genes, , drop = FALSE]
sign_C_ex <- sign_C[!names(sign_C) %in% prolif_genes]
tibble::tibble(signature = c("decoupleR (CollecTRI)", "msVIPER (bcellViper)"),
genes_before = c(nrow(mat_stat), length(sign_C)),
prolif_removed = c(nrow(mat_stat) - nrow(mat_stat_ex), length(sign_C) - length(sign_C_ex)),
genes_after = c(nrow(mat_stat_ex), length(sign_C_ex))) |>
knitr::kable()
| signature | genes_before | prolif_removed | genes_after |
|---|---|---|---|
| decoupleR (CollecTRI) | 19627 | 319 | 19308 |
| msVIPER (bcellViper) | 17093 | 316 | 16777 |
The decouple() call is identical to section 2a of
Decoupler analysis.Rmd. It runs once on each signature.
net_collectri <- read.csv(collectri_file)
net_collectri <- net_collectri |> dplyr::select(source, target, mor = weight)
run_collectri <- function(mat) {
decoupleR::decouple(
mat = mat, network = net_collectri,
.source = "source", .target = "target",
statistics = c("mlm", "ulm"),
args = list(mlm = list(.mor = "mor"),
ulm = list(.mor = "mor")),
consensus_score = FALSE,
minsize = params$minsize
)
}
res_collectri_before <- run_collectri(mat_stat)
res_collectri_after <- run_collectri(mat_stat_ex)
dplyr::bind_rows(before = res_collectri_before, after = res_collectri_after, .id = "run") |>
dplyr::count(statistic, run) |>
tidyr::pivot_wider(names_from = run, values_from = n) |>
knitr::kable(caption = "TFs scored (regulons with >= minsize targets in the signature)")
| statistic | after | before |
|---|---|---|
| mlm | 662 | 700 |
| ulm | 662 | 700 |
Regulon composition: how many of each TF’s CollecTRI targets (within the signature) are proliferation genes.
collectri_comp <- net_collectri |>
dplyr::filter(target %in% rownames(mat_stat)) |>
dplyr::distinct(source, target) |>
dplyr::group_by(source) |>
dplyr::summarise(n_targets_before = dplyr::n(),
n_prolif_targets = sum(target %in% prolif_genes),
prolif_targets = paste(sort(target[target %in% prolif_genes]), collapse = ", "),
.groups = "drop") |>
dplyr::mutate(n_targets_after = n_targets_before - n_prolif_targets,
prolif_frac = n_prolif_targets / n_targets_before)
When a regulon loses targets, its ULM t-value falls even if those targets were ordinary, because t grows with regulon size. So “significant before, weaker after” is not evidence on its own.
For each TF with m proliferation targets out of k, the null removes m random targets of that TF instead, 1,000 times. Everything else matches the real exclusion. In particular, every proliferation gene that is not a target of this TF is removed from the background in each permutation too, so the only difference is which targets are removed. If the retained score falls below the null’s 2.5th percentile, the TF’s proliferation targets carried more signal than its average target.
The null uses a closed-form ULM. It computes the same t-value as
decoupleR::run_ulm, which is checked against the decoupleR
results before the null is run.
y <- mat_stat[, 1]
genes <- rownames(mat_stat)
is_p <- genes %in% prolif_genes
net_u <- net_collectri |>
dplyr::filter(target %in% genes) |>
dplyr::distinct(source, target, .keep_all = TRUE)
tf_keep <- net_u |> dplyr::count(source) |> dplyr::filter(n >= params$minsize) |> dplyr::pull(source)
net_u <- net_u |> dplyr::filter(source %in% tf_keep)
tfs <- sort(unique(net_u$source))
W <- Matrix::sparseMatrix(i = match(net_u$target, genes), j = match(net_u$source, tfs),
x = net_u$mor, dims = c(length(genes), length(tfs)),
dimnames = list(genes, tfs))
# ULM t-value of the slope in y ~ w (with intercept), from sufficient statistics
ulm_t <- function(n, sy, syy, sw, sww, swy) {
sxx <- sww - sw^2 / n
sxy <- swy - sw * sy / n
syc <- syy - sy^2 / n
r <- sxy / sqrt(sxx * syc)
r * sqrt((n - 2) / (1 - r^2))
}
ulm_closed <- function(y, W) {
ulm_t(length(y), sum(y), sum(y^2), Matrix::colSums(W), Matrix::colSums(W^2),
as.numeric(Matrix::crossprod(W, y)))
}
t_cf_before <- setNames(ulm_closed(y, W), tfs)
t_cf_after <- setNames(ulm_closed(y[!is_p], W[!is_p, , drop = FALSE]), tfs)
t_cf_after[Matrix::colSums(W[!is_p, , drop = FALSE] != 0) < params$minsize] <- NA
ulm_dc_before <- res_collectri_before |> dplyr::filter(statistic == "ulm")
ulm_dc_after <- res_collectri_after |> dplyr::filter(statistic == "ulm")
validation <- tibble::tibble(
run = c("before", "after"),
tfs_decoupleR = c(nrow(ulm_dc_before), nrow(ulm_dc_after)),
tfs_closed_form = c(sum(!is.na(t_cf_before)), sum(!is.na(t_cf_after))),
same_tfs = c(setequal(ulm_dc_before$source, names(t_cf_before)[!is.na(t_cf_before)]),
setequal(ulm_dc_after$source, names(t_cf_after)[!is.na(t_cf_after)])),
max_abs_diff = c(max(abs(ulm_dc_before$score - t_cf_before[ulm_dc_before$source])),
max(abs(ulm_dc_after$score - t_cf_after[ulm_dc_after$source]))))
knitr::kable(validation, digits = 16, caption = "Closed-form ULM vs decoupleR::run_ulm")
| run | tfs_decoupleR | tfs_closed_form | same_tfs | max_abs_diff |
|---|---|---|---|---|
| before | 700 | 700 | TRUE | 1.6e-14 |
| after | 662 | 662 | TRUE | 6.2e-15 |
if (!all(validation$same_tfs) || any(validation$max_abs_diff > 1e-6))
stop("Closed-form ULM does not reproduce decoupleR; the random-target null would not be valid.")
set.seed(params$seed)
n_all <- length(y); sy_all <- sum(y); syy_all <- sum(y^2)
n_p <- sum(is_p); sy_p <- sum(y[is_p]); syy_p <- sum(y[is_p]^2)
null_cols <- c("k", "m", "t_before", "t_after", "retention",
"null_lo", "null_med", "null_hi", "p_null")
null_one <- function(j) {
out <- setNames(rep(NA_real_, length(null_cols)), null_cols)
rng <- if (W@p[j + 1] > W@p[j]) (W@p[j] + 1):W@p[j + 1] else integer(0)
idx <- W@i[rng] + 1L
w <- W@x[rng]
yj <- y[idx]
pj <- is_p[idx]
k <- length(idx); m <- sum(pj)
sw <- sum(w); sww <- sum(w^2); swy <- sum(w * yj)
out[c("k", "m")] <- c(k, m)
out["t_before"] <- ulm_t(n_all, sy_all, syy_all, sw, sww, swy)
if (k - m < params$minsize) return(out)
# universe with every proliferation gene that is NOT a target of this TF removed
nA <- n_all - (n_p - m)
syA <- sy_all - (sy_p - sum(yj[pj]))
syyA <- syy_all - (syy_p - sum(yj[pj]^2))
t_drop <- function(s_y, s_yy, s_w, s_ww, s_wy)
ulm_t(nA - m, syA - s_y, syyA - s_yy, sw - s_w, sww - s_ww, swy - s_wy)
out["t_after"] <- t_drop(sum(yj[pj]), sum(yj[pj]^2), sum(w[pj]), sum(w[pj]^2), sum(w[pj] * yj[pj]))
out["retention"] <- out[["t_after"]] / out[["t_before"]]
if (m == 0) return(out)
R <- matrix(replicate(params$n_perm, sample.int(k, m)), nrow = m) # m x n_perm
cs <- function(v) colSums(matrix(v[R], nrow = m))
ret_null <- t_drop(cs(yj), cs(yj^2), cs(w), cs(w^2), cs(w * yj)) / out[["t_before"]]
out[c("null_lo", "null_med", "null_hi")] <- stats::quantile(ret_null, c(0.025, 0.5, 0.975),
names = FALSE)
out["p_null"] <- (1 + sum(ret_null <= out[["retention"]])) / (params$n_perm + 1)
out
}
ulm_null <- do.call(rbind, lapply(seq_along(tfs), null_one)) |>
tibble::as_tibble() |>
dplyr::mutate(source = tfs, .before = 1)
# the real exclusion inside null_one must equal the decoupleR 'after' run
chk <- ulm_null |>
dplyr::inner_join(ulm_dc_after |> dplyr::select(source, score), by = "source")
stopifnot(max(abs(chk$t_after - chk$score)) < 1e-6)
Same call as section 3b of Decoupler analysis.Rmd. Both
runs use the same regulon set (TFs expressed at baseMean > 10), so a
TF whose own gene is a proliferation gene is still scored after
exclusion. msVIPER uses its default minimum of 25 targets.
data(bcellViper, package = "bcellViper", envir = environment())
regulon_filtered <- regulon[intersect(names(regulon), deg$gene_name)]
mrs_before <- viper::msviper(sign_C, regulon_filtered, pleiotropy = TRUE, verbose = FALSE)
mrs_after <- viper::msviper(sign_C_ex, regulon_filtered, pleiotropy = TRUE, verbose = FALSE)
viper_es <- function(mrs) {
tibble::tibble(source = names(mrs$es$nes),
score = as.numeric(mrs$es$nes),
p_value = as.numeric(mrs$es$p.value),
size = as.numeric(mrs$es$size))
}
viper_before <- viper_es(mrs_before)
viper_after <- viper_es(mrs_after)
viper_comp <- tibble::tibble(
source = names(regulon_filtered),
n_targets_before = vapply(regulon_filtered, function(r) sum(names(r$tfmode) %in% names(sign_C)), integer(1)),
n_prolif_targets = vapply(regulon_filtered, function(r) {
g <- names(r$tfmode); sum(g %in% names(sign_C) & g %in% prolif_genes)
}, integer(1)),
prolif_targets = vapply(regulon_filtered, function(r) {
g <- names(r$tfmode); paste(sort(g[g %in% names(sign_C) & g %in% prolif_genes]), collapse = ", ")
}, character(1))) |>
dplyr::mutate(n_targets_after = n_targets_before - n_prolif_targets,
prolif_frac = n_prolif_targets / n_targets_before)
tibble::tibble(run = c("before", "after"),
regulons_scored = c(nrow(viper_before), nrow(viper_after))) |>
knitr::kable(caption = sprintf("bcellViper regulons expressed: %d; median share of proliferation targets: %.1f%%",
length(regulon_filtered), 100 * median(viper_comp$prolif_frac)))
| run | regulons_scored |
|---|---|
| before | 529 |
| after | 529 |
Each TF gets 3 cells: ULM, MLM and msVIPER. A cell counts if the TF
is still active there, meaning significant after exclusion with the same
sign as on all genes (Attenuated included), and points in the
TF’s overall direction, taken from ULM if the TF is significant there,
otherwise MLM, otherwise msVIPER. TFs are ranked by how many cells
count. This is the same summary as in
Decoupler MYC regulators.Rmd.
is_sig <- function(p) {
q <- if (params$p_adjust == "BH") stats::p.adjust(p, "BH") else p
!is.na(q) & q < params$p_cut
}
classify <- function(score_b, sig_b, score_a, sig_a) {
testable_a <- !is.na(score_a)
dplyr::case_when(
is.na(score_b) ~ NA_character_, # TF not in this network
sig_b & !testable_a ~ "Untestable after exclusion",
sig_b & sig_a & sign(score_a) == sign(score_b) ~ "Survives",
sig_b & sig_a ~ "Lost (direction reversed)",
sig_b ~ "Lost",
testable_a & sig_a ~ "Emerged after exclusion",
TRUE ~ "Not significant")
}
# one row per TF: <method>_score, <method>_p, <method>_fdr (FDR across all TFs in the run)
to_wide <- function(res, suffix = "") {
res |>
dplyr::select(source, statistic, score, p_value) |>
dplyr::group_by(statistic) |>
dplyr::mutate(fdr = stats::p.adjust(p_value, "BH")) |>
dplyr::ungroup() |>
tidyr::pivot_wider(names_from = statistic, values_from = c(score, p_value, fdr),
names_glue = paste0("{statistic}_{.value}", suffix)) |>
dplyr::rename_with(~ sub("_p_value", "_p", .x))
}
viper_wide <- function(v, suffix = "") {
v |>
dplyr::transmute(source, msviper_nes = score, msviper_p = p_value,
msviper_fdr = stats::p.adjust(p_value, "BH")) |>
dplyr::rename_with(~ paste0(.x, suffix), -source)
}
# TFs whose canonical program IS proliferation. Removing E2F/G2M targets removes the genes
# that define their activity, so a loss does not mean the call was an artifact. Flagged only.
prolif_program_tfs <- c(paste0("E2F", 1:8), "TFDP1", "TFDP2", "TFDP3", "FOXM1", "MYBL1", "MYBL2",
"LIN9", "LIN54", "RB1", "RBL1", "RBL2", "HCFC1", "MYC", "MYCN", "MYCL")
show_table <- function(df, caption = NULL) {
num <- which(vapply(df, is.double, logical(1)))
DT::datatable(df, rownames = FALSE, caption = caption, filter = "top",
options = list(pageLength = 15, scrollX = TRUE)) |>
DT::formatSignif(columns = num, digits = 3)
}
# one row per TF, on all genes and without the proliferation genes, every method side by side
tf_tab <- to_wide(res_collectri_before) |>
dplyr::full_join(viper_wide(viper_before), by = "source") |>
dplyr::left_join(to_wide(res_collectri_after, "_after"), by = "source") |>
dplyr::left_join(viper_wide(viper_after, "_after"), by = "source") |>
dplyr::left_join(collectri_comp |> dplyr::select(source, n_targets = n_targets_before,
n_targets_removed = n_prolif_targets, prolif_targets),
by = "source") |>
dplyr::left_join(ulm_null |> dplyr::select(source, retention, null_lo, null_med, null_hi, p_null), by = "source") |>
dplyr::left_join(viper_comp |> dplyr::select(source, bcellviper_n_targets = n_targets_before,
bcellviper_n_removed = n_prolif_targets), by = "source") |>
dplyr::mutate(
ulm_sig = is_sig(ulm_p),
mlm_sig = is_sig(mlm_p),
msviper_sig = is_sig(msviper_p),
direction = dplyr::case_when(ulm_sig ~ sign(ulm_score),
mlm_sig ~ sign(mlm_score),
msviper_sig ~ sign(msviper_nes)),
direction = dplyr::case_when(direction > 0 ~ "up in tFL", direction < 0 ~ "down in tFL"),
ulm_call = classify(ulm_score, ulm_sig, ulm_score_after, is_sig(ulm_p_after)),
ulm_verdict = dplyr::case_when(
ulm_call == "Survives" & n_targets_removed == 0 ~ "Survives (no proliferation targets)",
ulm_call == "Survives" & retention >= null_lo ~ "Survives: robust",
ulm_call == "Survives" ~ "Attenuated: still significant, proliferation-weighted",
ulm_call == "Lost (direction reversed)" ~ "Reversed: significant in the opposite direction after exclusion",
ulm_call == "Lost" & n_targets_removed == 0 ~ "Lost: background shift only (no proliferation targets)",
ulm_call == "Lost" & retention < null_lo ~ "Lost: proliferation-driven",
ulm_call == "Lost" ~ "Lost: no worse than random target loss",
TRUE ~ ulm_call),
still_active = dplyr::if_else(ulm_sig, ulm_call == "Survives", NA), # ULM, Attenuated included
mlm_call = classify(mlm_score, mlm_sig, mlm_score_after, is_sig(mlm_p_after)),
msviper_call = classify(msviper_nes, msviper_sig, msviper_nes_after, is_sig(msviper_p_after)),
prolif_program_tf = source %in% prolif_program_tfs,
tf_in_prolif_set = source %in% prolif_genes) |>
dplyr::left_join(deseq_lookup, by = c("source" = "gene_name")) |>
dplyr::rename(TF = source) |>
dplyr::select(TF, prolif_program_tf, tf_in_prolif_set, direction, n_targets,
ulm_score, ulm_p, ulm_fdr, ulm_sig, mlm_score, mlm_p, mlm_fdr, mlm_sig,
msviper_nes, msviper_p, msviper_fdr, msviper_sig,
n_targets_removed, prolif_targets, ulm_score_after, ulm_p_after, ulm_fdr_after,
retention, null_lo, null_med, null_hi, p_null, ulm_verdict, still_active,
mlm_score_after, mlm_p_after, mlm_fdr_after, mlm_call,
msviper_nes_after, msviper_p_after, msviper_fdr_after, msviper_call,
bcellviper_n_targets, bcellviper_n_removed, dplyr::starts_with("deseq_"))
# one row per TF and method; a cell counts if still active in the TF's overall direction
method_levels <- c("ULM", "MLM", "msVIPER")
cells <- dplyr::bind_rows(
tf_tab |> dplyr::transmute(TF, method = "ULM", outcome = ulm_verdict,
sig_before = ulm_sig, sign_before = sign(ulm_score)),
tf_tab |> dplyr::transmute(TF, method = "MLM", outcome = mlm_call,
sig_before = mlm_sig, sign_before = sign(mlm_score)),
tf_tab |> dplyr::transmute(TF, method = "msVIPER", outcome = msviper_call,
sig_before = msviper_sig, sign_before = sign(msviper_nes))) |>
dplyr::left_join(tf_tab |> dplyr::select(TF, direction), by = "TF") |>
dplyr::mutate(
dir_sign = dplyr::case_when(direction == "up in tFL" ~ 1, direction == "down in tFL" ~ -1),
agrees = dplyr::coalesce(sign_before == dir_sign, FALSE),
code = dplyr::case_when(
is.na(outcome) ~ "Not in network",
sig_before & !agrees ~ "Opposite direction",
grepl("^Survives", outcome) ~ "Active",
grepl("^Attenuated", outcome) ~ "Active, proliferation-weighted",
grepl("^Lost|^Reversed", outcome) ~ "Lost",
outcome == "Untestable after exclusion" ~ "Untestable",
grepl("^Emerged", outcome) ~ "Emerged",
TRUE ~ "Not significant on all genes"),
active = code %in% c("Active", "Active, proliferation-weighted"),
eligible = sig_before & agrees,
cell = paste0("prolif_", method))
cell_cols <- paste0("prolif_", method_levels)
summary_all <- cells |>
dplyr::group_by(TF) |>
dplyr::summarise(n_active = sum(active), n_eligible = sum(eligible),
direction_conflict = any(code == "Opposite direction"), .groups = "drop") |>
dplyr::left_join(cells |>
dplyr::select(TF, cell, code) |>
tidyr::pivot_wider(names_from = cell, values_from = code), by = "TF") |>
dplyr::left_join(tf_tab, by = "TF") |>
dplyr::arrange(dplyr::desc(n_active), ulm_p, msviper_p)
# TFs significant on all genes in at least one method, most active cells first (ties: ULM p, then msVIPER p)
summary_tf <- summary_all |>
dplyr::filter(ulm_sig | mlm_sig | msviper_sig) |>
dplyr::mutate(rank = dplyr::row_number()) |>
dplyr::select(rank, TF, prolif_program_tf, direction, n_active, n_eligible, direction_conflict,
dplyr::all_of(cell_cols), n_targets, n_targets_removed,
ulm_score, ulm_p, mlm_score, mlm_p, msviper_nes, msviper_p)
cells |>
dplyr::filter(!is.na(outcome)) |>
dplyr::mutate(method = factor(method, levels = method_levels)) |>
dplyr::group_by(method) |>
dplyr::summarise(significant_on_all_genes = sum(sig_before),
still_active = sum(sig_before & grepl("^Survives|^Attenuated", outcome)),
of_which_proliferation_weighted = sum(grepl("^Attenuated", outcome)),
lost = sum(sig_before & grepl("^Lost|^Reversed", outcome)),
too_few_targets_left = sum(outcome == "Untestable after exclusion"),
emerged = sum(grepl("^Emerged", outcome)), .groups = "drop") |>
knitr::kable(caption = "All TFs: outcome in each method, with each method's own direction")
| method | significant_on_all_genes | still_active | of_which_proliferation_weighted | lost | too_few_targets_left | emerged |
|---|---|---|---|---|---|---|
| ULM | 109 | 50 | 17 | 50 | 9 | 13 |
| MLM | 68 | 48 | 0 | 17 | 3 | 14 |
| msVIPER | 309 | 286 | 0 | 23 | 0 | 0 |
tf_list <- function(tf, direction, max_n = 30) {
lab <- paste0(tf, ifelse(direction == "up in tFL", " (up)", " (down)"))
paste0(paste(head(lab, max_n), collapse = ", "),
if (length(lab) > max_n) sprintf(", ... (+%d more)", length(lab) - max_n) else "")
}
ulm_levels <- c("Survives: robust", "Survives (no proliferation targets)",
"Attenuated: still significant, proliferation-weighted",
"Lost: proliferation-driven", "Lost: no worse than random target loss",
"Lost: background shift only (no proliferation targets)",
"Reversed: significant in the opposite direction after exclusion",
"Untestable after exclusion")
call_levels <- c("Survives", "Lost", "Lost (direction reversed)", "Untestable after exclusion")
dplyr::bind_rows(
"ULM (CollecTRI)" = tf_tab |> dplyr::filter(ulm_sig) |>
dplyr::transmute(TF, outcome = factor(ulm_verdict, levels = ulm_levels), p = ulm_p,
dir = ifelse(ulm_score > 0, "up in tFL", "down in tFL")),
"MLM (CollecTRI)" = tf_tab |> dplyr::filter(mlm_sig) |>
dplyr::transmute(TF, outcome = factor(mlm_call, levels = call_levels), p = mlm_p,
dir = ifelse(mlm_score > 0, "up in tFL", "down in tFL")),
"msVIPER (bcellViper)" = tf_tab |> dplyr::filter(msviper_sig) |>
dplyr::transmute(TF, outcome = factor(msviper_call, levels = call_levels), p = msviper_p,
dir = ifelse(msviper_nes > 0, "up in tFL", "down in tFL")),
.id = "method") |>
dplyr::arrange(method, outcome, p) |>
dplyr::group_by(method, outcome) |>
dplyr::summarise(n = dplyr::n(), TFs = tf_list(TF, dir), .groups = "drop") |>
knitr::kable(caption = "Outcome of every TF significant on all genes, per method (most significant first)")
| method | outcome | n | TFs |
|---|---|---|---|
| MLM (CollecTRI) | Untestable after exclusion | 3 | TOX3 (down), MTF2 (up), ARID1A (down) |
| MLM (CollecTRI) | Survives | 48 | MYC (up), E2F4 (up), E2F1 (up), FOXO3 (down), MYCN (up), ASCL1 (up), GRHL3 (down), ATF6 (up), NANOG (down), GATA3 (down), AP1 (down), RUNX1 (down), POU4F1 (down), NFIL3 (down), TP63 (down), FOXC1 (down), IRF1 (down), ZKSCAN7 (up), NPM1 (down), MSX1 (down), SRSF2 (up), SOX6 (up), IKZF1 (down), NONO (up), NKX2-2 (down), STAT5B (up), FOXE1 (down), BHLHE41 (up), HOXA9 (down), IRF2 (up), … (+18 more) |
| MLM (CollecTRI) | Lost | 17 | OLIG2 (up), KLF10 (down), TFDP1 (up), AR (up), SMARCC1 (up), TCF3 (down), HES1 (up), E2F2 (up), ZNF143 (up), PGR (down), TLX1 (down), SSRP1 (down), BATF (down), FOXM1 (up), ZFPM1 (up), RBPJ (down), ELK4 (up) |
| ULM (CollecTRI) | Survives: robust | 25 | FOXO3 (down), IKZF1 (down), TFAM (up), GATA3 (down), ATF6 (up), HSF1 (up), NFE2L2 (up), DDIT3 (up), SREBF2 (up), FOXP3 (down), RUNX1 (down), HIF1A (up), TBX21 (down), PPARGC1A (up), NFYA (up), ID4 (down), SREBF1 (up), CEBPD (up), THRB (up), HSF4 (up), TBX1 (down), ELK1 (up), KLF7 (down), NFYC (up), FOXN1 (up) |
| ULM (CollecTRI) | Survives (no proliferation targets) | 8 | BATF (down), NFIL3 (down), MSX1 (down), ZFPM2 (down), HOPX (up), CREBZF (up), NRL (down), MTA3 (down) |
| ULM (CollecTRI) | Attenuated: still significant, proliferation-weighted | 17 | MYC (up), E2F1 (up), E2F4 (up), E2F3 (up), TFDP1 (up), FOXM1 (up), MYCN (up), ZNF143 (up), SRSF2 (up), OLIG2 (up), IRF1 (down), TBP (up), SP1 (up), JUN (up), NKX2-1 (up), AR (up), NRF1 (up) |
| ULM (CollecTRI) | Lost: proliferation-driven | 28 | E2F2 (up), ZHX2 (down), KLF10 (down), STAT3 (up), MSC (down), KCNIP3 (up), KLF5 (up), EWSR1 (up), NCOA3 (up), ESR1 (up), SMARCA1 (up), HDAC1 (down), EZH2 (up), MYBL2 (up), KAT5 (up), ATF1 (up), SP3 (up), NKX6-1 (up), CREBBP (up), AHR (up), HMGA2 (up), KMT2B (up), MYB (up), HR (up), POU2F1 (up), NFKB (up), DNMT1 (up), GLI2 (up) |
| ULM (CollecTRI) | Lost: no worse than random target loss | 22 | ETV3 (down), RB1 (down), ZNF331 (down), NFATC2 (down), HMGB2 (up), GTF3A (up), NR5A2 (up), SPI1 (down), ASCL1 (up), NONO (up), STAT6 (down), TBX15 (up), HES6 (up), FOSB (up), BHLHE41 (up), KLF3 (down), PATZ1 (up), NFIB (down), NKX2-2 (down), TP63 (down), E2F5 (up), CTCFL (up) |
| ULM (CollecTRI) | Untestable after exclusion | 9 | HCFC1 (up), ARID3A (up), STOX1 (up), TFDP2 (up), TFDP3 (down), E2F7 (down), TRIM28 (down), TOX3 (down), CTBP1 (down) |
| msVIPER (bcellViper) | Survives | 286 | HNRNPAB (up), MRPL28 (up), PHF1 (down), TOP2A (up), FOXM1 (up), PRKDC (up), ILF2 (up), NR1D2 (down), NOLC1 (up), FOXJ2 (down), TFEB (down), RBL2 (down), ZFP36L2 (down), STAT5A (down), ZNF862 (down), SP100 (down), ZFP36L1 (down), HMGA1 (up), MYBL2 (up), HDGF (up), ZNF264 (down), ZNF510 (down), TFAP4 (up), ZNF266 (down), ZBTB20 (down), PLAGL1 (down), MEF2A (down), CREBBP (down), STAT3 (down), TAF5 (up), … (+256 more) |
| msVIPER (bcellViper) | Lost | 23 | KLF10 (up), AEBP1 (down), BATF (down), ARNT2 (down), NFIB (down), DRAP1 (down), NR2F2 (down), MEOX1 (down), CNOT8 (down), TRAFD1 (down), HIF1A (down), ZNF467 (down), ATF2 (down), ZFX (down), ZNF24 (down), SPEN (down), CEBPD (down), FOXC1 (up), SNAI2 (down), MLXIP (down), THOC1 (up), ZSCAN12 (down), ZNF354A (down) |
summary_tf |>
dplyr::count(n_active, name = "TFs") |>
dplyr::left_join(summary_tf |> dplyr::filter(prolif_program_tf) |> dplyr::count(n_active, name = "prolif_program_TFs"),
by = "n_active") |>
dplyr::mutate(prolif_program_TFs = dplyr::coalesce(prolif_program_TFs, 0L)) |>
dplyr::arrange(dplyr::desc(n_active)) |>
knitr::kable(col.names = c(sprintf("Cells still active (of %d)", length(cell_cols)), "TFs",
"of which proliferation-program TFs"),
caption = sprintf("%d TFs significant on all genes in at least one method", nrow(summary_tf)))
| Cells still active (of 3) | TFs | of which proliferation-program TFs |
|---|---|---|
| 3 | 5 | 1 |
| 2 | 20 | 5 |
| 1 | 321 | 8 |
| 0 | 70 | 4 |
lab_dir <- function(d) if (nrow(d)) paste0(d$TF, ifelse(d$direction == "up in tFL", " (up)", " (down)"),
collapse = ", ") else "none"
n_still <- function(sig, outcome) sum(tf_tab[[sig]] & grepl("^Survives|^Attenuated", tf_tab[[outcome]]))
top_tf <- summary_tf |> dplyr::filter(n_active == max(n_active))
prog_act <- summary_tf |> dplyr::filter(prolif_program_tf, n_active > 0)
n_conflict <- sum(summary_tf$direction_conflict)
At a glance
tile_cols <- c("Active" = col_survive, "Active, proliferation-weighted" = col_atten, "Lost" = col_lost,
"Untestable" = ink_muted, "Opposite direction" = col_grid, "Not significant on all genes" = col_grid,
"Emerged" = col_grid, "Not in network" = col_surface)
tile_txt <- c("Active" = "S", "Active, proliferation-weighted" = "A", "Lost" = "L", "Untestable" = "U",
"Opposite direction" = "O", "Not significant on all genes" = "", "Emerged" = "E",
"Not in network" = "-")
plot_tiles <- function(tf_rows, title, subtitle) {
mark <- !all(tf_rows$prolif_program_tf) # star proliferation-program TFs unless every row is one
lab <- tf_rows |>
dplyr::mutate(label = sprintf("%s (%s) %d/%d%s", TF, sub(" in tFL", "", direction), n_active, n_eligible,
ifelse(mark & prolif_program_tf, " *", "")))
df <- cells |>
dplyr::inner_join(lab |> dplyr::select(TF, label), by = "TF") |>
dplyr::mutate(label = factor(label, levels = rev(lab$label)),
method = factor(method, levels = method_levels),
set = "Without E2F_TARGETS + G2M_CHECKPOINT genes",
txt = unname(tile_txt[code]),
txt_col = ifelse(code %in% c("Active", "Active, proliferation-weighted", "Lost", "Untestable"),
"white", ink_second))
ggplot(df, aes(method, label)) +
geom_tile(aes(fill = code), colour = col_surface, linewidth = 0.8) +
geom_text(aes(label = txt, colour = txt_col), size = 2.8) +
facet_grid(~ set) +
scale_fill_manual(values = tile_cols,
breaks = c("Active", "Active, proliferation-weighted", "Lost", "Untestable",
"Not significant on all genes")) +
scale_colour_identity() +
scale_x_discrete(position = "top") +
guides(fill = guide_legend(nrow = 2, byrow = TRUE)) +
labs(title = title, subtitle = subtitle, x = NULL, y = NULL,
caption = paste0("S still active (significant, same sign); A still active but leaning on proliferation genes (ULM only);\n",
"L lost; U too few targets left; O significant on all genes in the other direction (does not count);\n",
"E significant only after the change; - not in that network.\n",
"Label: TF (overall direction) cells that count / cells where it was significant on all genes ",
"in that direction.", if (mark) "\n* proliferation-program TF." else "")) +
theme_sens() +
theme(panel.grid.major = element_blank(), strip.placement = "outside")
}
plot_tiles(head(summary_tf, params$n_top),
"Which TFs are still active without the E2F/G2M genes?",
sprintf("Top %d of %d TFs significant on all genes in at least one method, most cells first",
params$n_top, nrow(summary_tf)))
prog_rows <- summary_tf |> dplyr::filter(prolif_program_tf)
plot_tiles(prog_rows,
"Proliferation-program TFs without the E2F/G2M genes",
sprintf("All %d proliferation-program TFs significant on all genes in at least one method", nrow(prog_rows)))
summary_tf |>
dplyr::select(rank, TF, prolif_program_tf, direction, n_active, n_eligible, direction_conflict,
dplyr::all_of(cell_cols), ulm_score, ulm_p, mlm_score, mlm_p, msviper_nes, msviper_p) |>
show_table("TFs significant on all genes in at least one method, ranked by cells still active without the E2F/G2M genes")
The 40 TFs with the largest |ULM score| without the E2F/G2M genes,
sorted by that score, drawn like the figures in
Decoupler MYC regulators.Rmd. Each panel shows one method’s
score without the E2F/G2M genes, filled if the TF is still active, with
a grey dot at its score on all genes. A TF that becomes significant in
ULM only without the E2F/G2M genes can make the list; its ULM grey dot
is hollow. Proliferation-program TFs are blue, other TFs dark grey. The
ULM verdicts against the random-target null are in the table below.
tf_cols <- c("proliferation-program TF" = col_survive, "other TF" = ink_second)
# TFs ranked by |ULM score without the E2F/G2M genes|, among those significant in ULM on all genes
# or without the E2F/G2M genes; the per-sample plots use the same order
ranked_after <- tf_tab |>
dplyr::filter(ulm_sig | is_sig(ulm_p_after)) |>
dplyr::arrange(dplyr::desc(abs(ulm_score_after))) |> # untestable after exclusion (NA) last
dplyr::pull(TF)
fig_df <- tf_tab |>
dplyr::filter(TF %in% head(ranked_after, params$n_top)) |>
dplyr::mutate(colour_group = ifelse(prolif_program_tf, "proliferation-program TF", "other TF"),
ulm_active = still_active %in% TRUE,
mlm_active = mlm_call %in% "Survives",
vip_active = msviper_call %in% "Survives",
label = sprintf("%s (%d/%d)", TF, as.integer(n_targets_removed), as.integer(n_targets)))
fig_df$label <- factor(fig_df$label, levels = fig_df$label[order(fig_df$ulm_score_after, na.last = FALSE)])
# one method, drawn like the ranked figures of the MYC report: a lollipop from zero to the score
# without the E2F/G2M genes (filled = still active: significant on all genes and without them, same
# sign), plus a grey dot at the score on all genes (hollow if not significant there)
without_panel <- function(d, before, after, sig_before, active, title, xlab, first = FALSE) {
d <- d |>
dplyr::mutate(b = .data[[before]], a = .data[[after]], sb = .data[[sig_before]], act = .data[[active]])
p <- ggplot(d, aes(y = label)) +
geom_vline(xintercept = 0, colour = col_context, linewidth = 0.3) +
geom_segment(data = ~ dplyr::filter(.x, !is.na(a)), aes(x = 0, xend = a, yend = label),
colour = col_context, linewidth = 0.4) +
geom_point(data = ~ dplyr::filter(.x, !is.na(b), sb), aes(x = b),
shape = 21, size = 1.6, fill = col_context, colour = col_surface, stroke = 0.3) +
geom_point(data = ~ dplyr::filter(.x, !is.na(b), !sb), aes(x = b),
shape = 21, size = 1.4, fill = col_surface, colour = col_context, stroke = 0.6) +
geom_point(data = ~ dplyr::filter(.x, !is.na(a), act), aes(x = a, fill = colour_group),
shape = 21, size = 2.6, colour = col_surface, stroke = 0.5) +
geom_point(data = ~ dplyr::filter(.x, !is.na(a), !act), aes(x = a, colour = colour_group),
shape = 21, size = 2.4, fill = col_surface, stroke = 1) +
scale_y_discrete(limits = levels(d$label)) + # the same rows in every panel
scale_fill_manual(values = tf_cols, breaks = names(tf_cols), guide = if (first) "legend" else "none") +
scale_colour_manual(values = tf_cols, guide = "none") +
labs(title = title, x = xlab, y = NULL) +
theme_sens()
if (first) p else p + theme(axis.text.y = element_blank())
}
without_panel(fig_df, "ulm_score", "ulm_score_after", "ulm_sig", "ulm_active", "ULM", "ULM score", first = TRUE) +
without_panel(fig_df, "mlm_score", "mlm_score_after", "mlm_sig", "mlm_active", "MLM", "MLM score") +
without_panel(fig_df, "msviper_nes", "msviper_nes_after", "msviper_sig", "vip_active",
"msVIPER", "msVIPER NES (bcellViper)") +
patchwork::plot_layout(widths = c(1.3, 1, 1)) +
patchwork::plot_annotation(
title = sprintf("Top %d TFs by ULM score without the E2F/G2M genes", params$n_top),
subtitle = "Score without the E2F_TARGETS + G2M_CHECKPOINT genes (positive = more active in tFL); grey dot: score on all genes",
caption = paste0("Filled points: still active (significant on all genes and without the E2F/G2M genes, same sign); ",
"hollow: not. Hollow grey dot: not significant on all genes.\n",
"In the ULM panel, a hollow grey dot marks a TF that is significant only without the E2F/G2M genes. ",
"Rows sorted by ULM score without the E2F/G2M genes.\n",
"No coloured point: too few targets left without the E2F/G2M genes, or (msVIPER) no bcellViper regulon. ",
"Label: TF (E2F/G2M genes among its CollecTRI targets / targets).\n",
"ULM verdicts against the random-target null are in the table below and the without_prolif_genes sheet."),
theme = theme_sens())
tf_tab |>
dplyr::filter(ulm_sig) |>
dplyr::select(TF, prolif_program_tf, direction, still_active, ulm_verdict, ulm_score, ulm_score_after,
ulm_p, ulm_p_after, retention, null_lo, null_hi, n_targets, n_targets_removed,
mlm_call, msviper_call) |>
show_table("ULM: TFs significant on all genes")
plot_group <- function(outcome, sig_before) {
dplyr::case_when(grepl("^Survives", outcome) ~ "Survives",
grepl("^Attenuated", outcome) ~ "Attenuated (still significant)",
sig_before ~ "Lost / reversed",
TRUE ~ "Not significant before")
}
# one method's columns from tf_tab, in the shape the scatter plot needs
method_tab <- function(score, score_after, sig, outcome) {
tf_tab |>
dplyr::filter(!is.na(.data[[score]])) |>
dplyr::transmute(source = TF, score_before = .data[[score]], score_after = .data[[score_after]],
sig_before = .data[[sig]], call = .data[[outcome]])
}
plot_before_after <- function(tab, method_label, score_label) {
df <- tab |>
dplyr::filter(!is.na(score_after)) |>
dplyr::mutate(group = factor(plot_group(call, sig_before), levels = names(group_cols)))
lab <- dplyr::bind_rows(
df |> dplyr::filter(sig_before) |> dplyr::slice_max(abs(score_before), n = params$n_label),
df |> dplyr::filter(call == "Emerged after exclusion") |> dplyr::slice_max(abs(score_after), n = 5)) |>
dplyr::distinct(source, .keep_all = TRUE)
n_untestable <- sum(tab$call == "Untestable after exclusion", na.rm = TRUE)
ggplot(df, aes(score_before, score_after)) +
geom_hline(yintercept = 0, colour = col_context, linewidth = 0.3) +
geom_vline(xintercept = 0, colour = col_context, linewidth = 0.3) +
geom_abline(slope = 1, intercept = 0, colour = ink_muted, linewidth = 0.3) +
geom_point(data = ~ dplyr::filter(.x, group == "Not significant before"),
aes(fill = group), shape = 21, size = 1.6, colour = col_surface, stroke = 0.3) +
geom_point(data = ~ dplyr::filter(.x, group != "Not significant before"),
aes(fill = group), shape = 21, size = 2.6, colour = col_surface, stroke = 0.5) +
ggrepel::geom_text_repel(data = lab, aes(label = source), size = 3, colour = ink_second,
segment.colour = col_context, segment.size = 0.3, max.overlaps = Inf,
min.segment.length = 0, box.padding = 0.4, point.padding = 0.25,
seed = params$seed) +
scale_fill_manual(values = group_cols, breaks = names(group_cols)) +
coord_equal() +
labs(title = paste0(method_label, ": before vs after removing E2F/G2M genes"),
x = paste(score_label, "before exclusion (all genes)"),
y = paste(score_label, "after exclusion"),
caption = paste0("Diagonal: unchanged score. Positive = more active in tFL. Labels: top ",
params$n_label, " by |score| before, plus up to 5 TFs that emerged after exclusion.\n",
n_untestable, " TF(s) significant before cannot be scored after exclusion and are not shown.")) +
theme_sens()
}
plot_before_after(method_tab("ulm_score", "ulm_score_after", "ulm_sig", "ulm_verdict"), "ULM (CollecTRI)", "ULM score")
plot_before_after(method_tab("mlm_score", "mlm_score_after", "mlm_sig", "mlm_call"), "MLM (CollecTRI)", "MLM score")
plot_before_after(method_tab("msviper_nes", "msviper_nes_after", "msviper_sig", "msviper_call"), "msVIPER (bcellViper)", "NES")
TFs that emerged after exclusion (not significant on all genes, significant without the E2F/G2M genes):
dplyr::bind_rows(
"ULM" = tf_tab |> dplyr::filter(ulm_verdict %in% "Emerged after exclusion") |>
dplyr::transmute(TF, score = ulm_score, score_after = ulm_score_after, p = ulm_p, p_after = ulm_p_after),
"MLM" = tf_tab |> dplyr::filter(mlm_call %in% "Emerged after exclusion") |>
dplyr::transmute(TF, score = mlm_score, score_after = mlm_score_after, p = mlm_p, p_after = mlm_p_after),
"msVIPER" = tf_tab |> dplyr::filter(msviper_call %in% "Emerged after exclusion") |>
dplyr::transmute(TF, score = msviper_nes, score_after = msviper_nes_after, p = msviper_p,
p_after = msviper_p_after),
.id = "method") |>
dplyr::left_join(tf_tab |> dplyr::select(TF, n_targets, n_targets_removed), by = "TF") |>
show_table()
One figure per TF, FL to tFL, with a line per patient:
Choose the TFs with per_sample_tfs: in the YAML header:
a number such as 40 (the top N by |ULM score| without the
E2F/G2M genes, as in Top TFs in detail), "all"
with the quotes (every TF significant in ULM on all genes or without the
E2F/G2M genes, in the same order), or a list of names such as
["GATA3", "RUNX1"].
ps_nc <- grep("normCounts$", colnames(deseq_unfiltered), value = TRUE)
ps_rows <- deseq[!duplicated(deseq$gene_name), ]
ps_rows <- ps_rows[!is.na(ps_rows$stat), ]
ps_X <- log2(as.matrix(ps_rows[, ps_nc]) + 1)
rownames(ps_X) <- ps_rows$gene_name
ps_X <- ps_X[apply(ps_X, 1, sd) > 0 & rowSums(ps_X > 0) >= 3, ]
ps_samples <- tibble::tibble(sample = colnames(ps_X),
group = ifelse(grepl("^tFL_", colnames(ps_X)), "tFL", "FL"),
patient = sub("^t?FL_(\\d+)_normCounts$", "\\1", colnames(ps_X)))
# activity per sample: ULM on each sample's centred log2 expression
ps_act <- decoupleR::run_ulm(ps_X - rowMeans(ps_X), network = net_collectri,
.source = "source", .target = "target", .mor = "mor",
minsize = params$minsize) |>
dplyr::select(TF = source, sample = condition, activity = score) |>
dplyr::left_join(ps_samples, by = "sample")
# which TFs to plot: a number = top N by |ULM score without the E2F/G2M genes| (ranked_after, as in
# Top TFs in detail), "all" = every one of them, or TF names (any TF scored per sample)
sel <- params$per_sample_tfs
ps_tfs <- if (is.numeric(sel)) head(ranked_after, sel) else if (identical(sel, "all")) ranked_after else
intersect(sel, unique(ps_act$TF))
ps_cols <- c(FL = "#898781", tFL = "#1baf7a") # the FL / tFL colours of the MYC report
# a TF's outcome without the E2F/G2M genes, in a few words
short_call <- function(x) dplyr::case_when(
is.na(x) ~ "n/a",
grepl("^Survives", x) ~ "still active",
grepl("^Attenuated", x) ~ "still active (proliferation-weighted)",
x == "Untestable after exclusion" ~ "too few targets left",
grepl("^Emerged", x) ~ "newly significant",
grepl("^Lost|^Reversed", x) ~ "lost",
TRUE ~ "not significant") # neither on all genes nor without them
# FL to tFL, one line per patient
paired_panel <- function(df, y, title) {
w <- tidyr::pivot_wider(df, id_cols = patient, names_from = group, values_from = dplyr::all_of(y))
ggplot(df, aes(group, .data[[y]])) +
geom_line(aes(group = patient), colour = col_context, linewidth = 0.3) +
geom_point(aes(fill = group), shape = 21, size = 2.4, colour = col_surface, stroke = 0.4) +
scale_fill_manual(values = ps_cols) +
scale_x_discrete(expand = expansion(add = 0.4)) +
labs(title = title,
subtitle = sprintf("Higher in tFL in %d of %d patients", sum(w$tFL > w$FL, na.rm = TRUE),
sum(!is.na(w$tFL - w$FL))),
x = NULL, y = NULL) +
theme_sens()
}
plot_per_sample <- function(tf) {
d <- ps_act |> dplyr::filter(TF == tf)
row <- match(tf, deseq_unfiltered$gene_name) # first row per gene, as in deseq_lookup
de <- deseq_lookup[match(tf, deseq_lookup$gene_name), ]
tt <- tf_tab[match(tf, tf_tab$TF), ]
p_act <- paired_panel(d, "activity", "Activity per sample (ULM t-value)")
p_mrna <- if (is.na(row)) {
ggplot() +
annotate("text", x = 0, y = 0, label = paste(tf, "is a CollecTRI complex: no single gene"),
colour = ink_muted, size = 3.2) +
theme_void() +
theme(plot.background = element_rect(fill = col_surface, colour = NA))
} else {
tibble::tibble(sample = ps_nc, tf_mrna = log2(as.numeric(unlist(deseq_unfiltered[row, ps_nc])) + 1)) |>
dplyr::left_join(ps_samples, by = "sample") |>
paired_panel("tf_mrna", paste(tf, "mRNA, log2(normalized count + 1)")) +
guides(fill = "none")
}
mrna_txt <- if (is.na(row) || is.na(de$deseq_log2FoldChange)) "n/a" else
sprintf("log2FC %+.2f (DESeq2, shrunken), padj %s", de$deseq_log2FoldChange,
if (is.na(de$deseq_padj)) "n/a" else sprintf("%.2g", de$deseq_padj))
scores <- sprintf("ULM score %+.1f on all genes, %s without the E2F/G2M genes.", tt$ulm_score,
if (is.na(tt$ulm_score_after)) "n/a" else sprintf("%+.1f", tt$ulm_score_after))
outcomes <- sprintf("Without the E2F/G2M genes: ULM %s, MLM %s, msVIPER %s.", short_call(tt$ulm_verdict),
short_call(tt$mlm_call), short_call(tt$msviper_call))
p_act + p_mrna +
patchwork::plot_annotation(
title = if (isTRUE(tt$prolif_program_tf)) paste(tf, "(proliferation-program TF)") else tf,
subtitle = paste(c(scores, strwrap(outcomes, 110), paste("mRNA:", mrna_txt)), collapse = "\n"), # wrapped to fit
theme = theme_sens())
}
for (tf in ps_tfs) print(plot_per_sample(tf))
readme <- tibble::tribble(
~column, ~meaning,
"prolif_summary sheet", "One row per TF significant on all genes in at least one method, ranked by n_active (ties: ULM p, then msVIPER p).",
"direction", "The TF's overall direction: from ULM if significant there, otherwise MLM, otherwise msVIPER.",
"prolif_ULM / prolif_MLM / prolif_msVIPER", "Outcome per cell: Active (still significant, same sign) / Active, proliferation-weighted (ULM, below the random-target null) / Lost / Untestable (too few targets left) / Opposite direction (significant on all genes against the TF's overall direction; does not count) / Emerged (significant only after the exclusion) / Not significant on all genes / Not in network.",
"n_active / n_eligible", "Cells the TF is still active in, in its overall direction, and cells where it was significant on all genes in that direction.",
"direction_conflict", "Significant on all genes in the opposite direction in another method.",
"without_prolif_genes sheet", "One row per TF (CollecTRI and bcellViper), every method side by side.",
"ulm_* / mlm_* / msviper_*", "Scores on all genes (ULM, MLM: t-value; msVIPER: NES), nominal p and BH-FDR across all TFs scored in that run.",
"*_after", "The same scores with the E2F_TARGETS / G2M_CHECKPOINT genes removed from the signature.",
"*_sig", paste0(if (params$p_adjust == "BH") "BH-FDR" else "nominal p", " < ", params$p_cut, ", across all TFs."),
"n_targets / n_targets_removed / prolif_targets", "The TF's CollecTRI targets in the signature, how many of them are E2F/G2M genes, and which.",
"retention / null_lo / null_med / null_hi / p_null", "ULM only: score kept after / before the exclusion, and the 2.5th / 50th / 97.5th percentile of the score kept when the same number of random targets is removed (E2F/G2M genes that are not targets of the TF are removed in every permutation). p_null: share of random removals that keep as little or less.",
"ulm_verdict", "Survives: robust / Survives (no proliferation targets) / Attenuated (still significant, but its proliferation targets carried more signal than random targets) / Lost: proliferation-driven / Lost: no worse than random target loss / Lost: background shift only / Reversed / Untestable after exclusion / Emerged / Not significant.",
"still_active", "TFs significant in ULM only: TRUE if still significant in ULM with the same sign after the exclusion (Attenuated included).",
"mlm_call / msviper_call", "Survives (same sign, still significant) / Lost / Lost (direction reversed) / Untestable after exclusion / Emerged / Not significant; empty if the TF is not in that network.",
"prolif_program_tf", "TF whose canonical program is proliferation (E2F, TFDP, FOXM1, MYBL, MuvB, RB family, HCFC1, MYC family). Losing these after exclusion is expected and does not mean the call was an artifact.",
"tf_in_prolif_set", "The TF's own gene is an E2F/G2M gene. This does not affect its activity score, which comes from its targets.",
"bcellviper_n_targets / bcellviper_n_removed", "The TF's bcellViper targets in the msVIPER signature, and how many of them are E2F/G2M genes.",
"deseq_*", "The TF's own DESeq2 result (tFL vs FL).",
"prolif_genes sheet", "The E2F_TARGETS / G2M_CHECKPOINT genes, the symbol matched in the data, and whether each is in the signatures.",
"per_sample_activity sheet", "Every TF's activity in every sample (ULM t-value on log2(normalized count + 1), each gene centred across the 22 samples, all genes kept), with patient and FL/tFL."
)
run_info <- tibble::tibble(
item = c("date", "DESeq2 input", "p_cut", "p_adjust", "minsize (decoupleR)", "minsize (msVIPER)", "n_perm", "seed", "n_top",
"decoupleR signature genes before / after", "msVIPER signature genes before / after",
"proliferation gene source", "decoupleR", "viper", "bcellViper", "R"),
value = c(format(Sys.time()), deseq_file, params$p_cut, params$p_adjust, params$minsize, 25, params$n_perm, params$seed,
params$n_top, paste(nrow(mat_stat), "/", nrow(mat_stat_ex)), paste(length(sign_C), "/", length(sign_C_ex)),
"MSigDB Hallmark E2F_TARGETS + G2M_CHECKPOINT, v2026.1.Hs",
as.character(packageVersion("decoupleR")), as.character(packageVersion("viper")),
as.character(packageVersion("bcellViper")), R.version.string))
openxlsx::write.xlsx(
list(README = readme,
prolif_summary = summary_tf,
without_prolif_genes = tf_tab,
prolif_genes = prolif_tbl,
per_sample_activity = ps_act |>
dplyr::mutate(sample = sub("_normCounts$", "", sample)) |>
dplyr::select(TF, sample, patient, group, activity),
run_info = run_info),
file = out_xlsx, overwrite = TRUE, firstRow = TRUE
)
cat("Wrote", out_xlsx, "\n")
## Wrote C:/Users/User/Desktop/MM Work/Main Work/fl_tFL analysis R/FL_TFL_TF_activity_prolif_sensitivity.xlsx
prolif_program_tf): their canonical targets are the
removed genes. If one is lost, the test cannot tell “confounded by
proliferation” apart from “regulates proliferation”. If one stays
active, its signal reaches beyond the cell-cycle program.n_active next to
n_eligible.p_adjust: "BH"
for a stricter cut.sessionInfo()
## R version 4.4.0 (2024-04-24 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 22000)
##
## Matrix products: default
##
##
## locale:
## [1] LC_COLLATE=English_United States.utf8
## [2] LC_CTYPE=English_United States.utf8
## [3] LC_MONETARY=English_United States.utf8
## [4] LC_NUMERIC=C
## [5] LC_TIME=English_United States.utf8
##
## time zone: Europe/Budapest
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] openxlsx_4.2.9 patchwork_1.3.2 ggrepel_0.9.8
## [4] ggplot2_4.0.3 tidyr_1.3.2 dplyr_1.2.1
## [7] bcellViper_1.42.0 viper_1.40.0 Biobase_2.66.0
## [10] BiocGenerics_0.52.0 decoupleR_2.12.0
##
## loaded via a namespace (and not attached):
## [1] gtable_0.3.6 xfun_0.61 bslib_0.12.0
## [4] htmlwidgets_1.6.4 lattice_0.22-6 crosstalk_1.2.2
## [7] vctrs_0.7.3 tools_4.4.0 generics_0.1.4
## [10] parallel_4.4.0 tibble_3.3.1 proxy_0.4-29
## [13] pkgconfig_2.0.3 Matrix_1.7-6 KernSmooth_2.23-22
## [16] data.table_1.17.4 RColorBrewer_1.1-3 S7_0.2.1
## [19] lifecycle_1.0.5 compiler_4.4.0 farver_2.1.2
## [22] stringr_1.6.0 mixtools_2.0.0.1 codetools_0.2-20
## [25] htmltools_0.5.8.1 class_7.3-22 sass_0.4.10
## [28] yaml_2.3.12 plotly_4.12.1 pillar_1.11.1
## [31] jquerylib_0.1.4 MASS_7.3-60.2 DT_0.34.0
## [34] BiocParallel_1.40.2 cachem_1.1.0 nlme_3.1-164
## [37] parallelly_1.48.0 zip_2.3.3 tidyselect_1.2.1
## [40] digest_0.6.35 stringi_1.8.9 purrr_1.2.2
## [43] kernlab_0.9-33 labeling_0.4.3 splines_4.4.0
## [46] fastmap_1.2.0 grid_4.4.0 cli_3.6.6
## [49] magrittr_2.0.5 survival_3.5-8 e1071_1.7-17
## [52] withr_3.0.3 scales_1.4.0 segmented_2.2-2
## [55] rmarkdown_2.32 httr_1.4.9 otel_0.2.0
## [58] evaluate_1.0.5 knitr_1.52 viridisLite_0.4.3
## [61] rlang_1.3.0 Rcpp_1.0.12 glue_1.8.0
## [64] rstudioapi_0.19.0 jsonlite_2.0.0 R6_2.6.1