Skip to content

Commit 99a5d7d

Browse files
committed
Harden abundance heatmaps
1 parent 7bf7a1d commit 99a5d7d

5 files changed

Lines changed: 174 additions & 45 deletions

File tree

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
- `setup_analysis()` now stops with an informative error (listing the offending keys) when a hierarchy-key/sample combination has more than one observation, instead of silently returning a different-schema count table that crashed downstream. Pass `debug = TRUE` to recover the old behaviour and return the count table for inspection.
1010
- Removed the unused `impute_with_zcomp()`, `estimate_lod_global()`, and `function_lod_quantile()` exports (and the `zCompositions` dependency). For missing-value imputation use `AggregateLimpa$new(lfqdata, impute_only = TRUE)$aggregate()`.
1111
- Hardened `plot_pca()`: errors early on duplicated sample names, an all-missing matrix, or too few samples instead of returning `NULL` (which broke `pca_plotly()`); joins scores to annotation with an explicit `by`; makes `prcomp(center = TRUE, scale. = FALSE)` explicit.
12+
- Hardened abundance heatmaps for sparse significant-feature subsets: when row or column distances are non-finite because of missing values, `plot_heatmap()` now falls back to the input order instead of returning a `ComplexHeatmap` object that fails during drawing.
13+
- `LFQDataPlotter$heatmap()` now shows only the `top_n` most variable features (default 1000), ranked by the prolfqua per-feature statistic (CV for untransformed data, sd for transformed, via `LFQDataStats`). Row clustering uses `stats::hclust`, which errors above 65536 features, so peptide-list / entrapment searches with tens of thousands of degenerate protein groups no longer crash the QC heatmap. Pass `top_n = NULL` (or `Inf`) to keep every feature.
1214

1315
# prolfqua 1.6.1
1416

R/LFQDataPlotter.R

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,31 @@
1+
# Keep the `top_n` most variable features for the intensity heatmap.
2+
#
3+
# Ranks features by their prolfqua variability statistic (CV for untransformed
4+
# data, sd for transformed data; see LFQDataStats) and keeps the most variable.
5+
# The heatmap clusters rows with stats::hclust, which errors above 65536
6+
# features, so this keeps clustering feasible (and the plot legible) for
7+
# peptide-list / entrapment searches with tens of thousands of degenerate
8+
# protein groups. `top_n = NULL` / non-finite keeps all features.
9+
# Returns a subset of wide$data in the original row order.
10+
.select_most_variable_features <- function(lfqdata, wide, top_n) {
11+
mat <- wide$data
12+
if (is.null(top_n) || !is.finite(top_n) || nrow(mat) <= top_n) {
13+
return(mat)
14+
}
15+
lfqstats <- lfqdata$get_Stats(stats = "all")
16+
metric <- lfqstats$stat # "CV" (untransformed) or "sd" (transformed)
17+
keys <- c(lfqdata$hierarchy_keys(), lfqdata$isotope_label())
18+
# wide$rowdata is in the same row order as the matrix, so joining the
19+
# per-feature statistic onto it aligns the ranking with the matrix rows.
20+
ranked <- dplyr::left_join(
21+
wide$rowdata,
22+
dplyr::select(lfqstats$stats(), dplyr::all_of(c(keys, metric))),
23+
by = keys
24+
)
25+
keep <- utils::head(order(ranked[[metric]], decreasing = TRUE, na.last = TRUE), top_n)
26+
mat[sort(keep), , drop = FALSE]
27+
}
28+
129
#' LFQDataPlotter ----
230
#' Create various visualization of the LFQdata
331
#' @return An R6 class generator.
@@ -97,16 +125,32 @@ LFQDataPlotter <- R6::R6Class(
97125
#' Without the z-scoring, the proteins would group according
98126
#' to their abundance, e.g., high abundant proteins would be one cluster.
99127
#'
128+
#' Only the \code{top_n} most variable features are shown. Row clustering
129+
#' uses \code{stats::hclust}, which errors above 65536 features, so the rows
130+
#' are ranked by their variability statistic (CV for untransformed data, sd
131+
#' for transformed data; see \code{\link{LFQDataStats}}) and the most
132+
#' variable are kept. This keeps the heatmap feasible and legible for
133+
#' peptide-list / entrapment searches with tens of thousands of features.
134+
#'
100135
#' @param na_fraction max fraction of NA's per row
101136
#' @param rownames show rownames (default FALSE - do not show.)
102137
#' @param max_rownames_chars maximum displayed row label length
103138
#' @param max_sample_label_chars maximum displayed sample label length.
104139
#' Labels keep their suffix because sample prefixes are often shared.
140+
#' @param top_n keep the \code{top_n} most variable features (default 1000);
141+
#' \code{NULL} or \code{Inf} keeps all features.
105142
#' @return ComplexHeatmap::Heatmap
106-
heatmap = function(na_fraction = 0.3, rownames = FALSE, max_rownames_chars = 60, max_sample_label_chars = 20) {
143+
heatmap = function(
144+
na_fraction = 0.3,
145+
rownames = FALSE,
146+
max_rownames_chars = 60,
147+
max_sample_label_chars = 20,
148+
top_n = 1000
149+
) {
107150
wide <- self$lfq$data_wide(as.matrix = TRUE)
151+
data <- .select_most_variable_features(self$lfq, wide, top_n)
108152
fig <- prolfqua::plot_heatmap(
109-
wide$data,
153+
data,
110154
wide$annotation,
111155
self$lfq$factor_keys(),
112156
self$lfq$sample_name(),

R/tidyMS_plotting.R

Lines changed: 56 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,37 @@
115115
circlize::colorRamp2(c(-m, 0, m), c("green", "black", "red"))
116116
}
117117

118+
.finite_heatmap_dist <- function(matrix) {
119+
tryCatch(
120+
{
121+
distance <- stats::dist(matrix)
122+
if (any(!is.finite(distance))) {
123+
return(NULL)
124+
}
125+
distance
126+
},
127+
error = function(e) NULL
128+
)
129+
}
130+
131+
.can_cluster_heatmap_columns <- function(matrix) {
132+
if (ncol(matrix) < 2) {
133+
return(FALSE)
134+
}
135+
!is.null(.finite_heatmap_dist(t(matrix)))
136+
}
137+
138+
.cluster_heatmap_rows <- function(matrix) {
139+
if (nrow(matrix) < 3) {
140+
return(matrix)
141+
}
142+
distance <- .finite_heatmap_dist(matrix)
143+
if (is.null(distance)) {
144+
return(matrix)
145+
}
146+
matrix[stats::hclust(distance)$order, , drop = FALSE]
147+
}
148+
118149
# White-to-red color function for correlation heatmaps, ranging over the
119150
# observed values (R-squared lives in [0, 1]).
120151
.cor_col_fun <- function(cres, R2 = FALSE) {
@@ -500,50 +531,33 @@ plot_heatmap <- function(
500531
}
501532

502533
resdata <- t(scale(t(matrix)))
503-
resdataf <- prolfqua::remove_na_rows(resdata, floor(ncol(resdata) * na_fraction))
504-
505-
if (nrow(resdataf) >= 3) {
506-
gg <- stats::hclust(stats::dist(resdataf))
507-
plot_data <- resdataf[gg$order, ]
508-
res <- ComplexHeatmap::Heatmap(
509-
plot_data,
510-
name = "row z-score",
511-
col = .abundance_col_fun(plot_data),
512-
na_col = .HEATMAP_NA_COL,
513-
cluster_rows = FALSE,
514-
cluster_columns = TRUE,
515-
top_annotation = .heatmap_top_annotation(annotation, factor_keys, sample_name, colnames(plot_data)),
516-
show_row_names = show_rownames,
517-
show_column_names = TRUE,
518-
row_labels = .truncate_plot_labels(rownames(plot_data), max_rownames_chars),
519-
column_labels = .suffix_plot_labels(colnames(plot_data), max_sample_label_chars),
520-
border = FALSE,
521-
use_raster = FALSE,
522-
heatmap_legend_param = list(title = "row z-score"),
523-
... = ...
524-
)
534+
na_threshold <- floor(ncol(resdata) * na_fraction)
535+
keep_rows <- rowSums(is.na(resdata)) <= na_threshold
536+
resdataf <- resdata[keep_rows, , drop = FALSE]
537+
plot_data <- if (nrow(resdataf) >= 3) {
538+
.cluster_heatmap_rows(resdataf)
525539
} else {
526-
res <- tryCatch(
527-
ComplexHeatmap::Heatmap(
528-
resdata,
529-
name = "row z-score",
530-
col = .abundance_col_fun(resdata),
531-
na_col = .HEATMAP_NA_COL,
532-
cluster_rows = FALSE,
533-
cluster_columns = TRUE,
534-
top_annotation = .heatmap_top_annotation(annotation, factor_keys, sample_name, colnames(resdata)),
535-
show_row_names = show_rownames,
536-
show_column_names = TRUE,
537-
row_labels = .truncate_plot_labels(rownames(resdata), max_rownames_chars),
538-
column_labels = .suffix_plot_labels(colnames(resdata), max_sample_label_chars),
539-
border = FALSE,
540-
use_raster = FALSE,
541-
heatmap_legend_param = list(title = "row z-score"),
542-
... = ...
543-
),
544-
error = .error_handler # nolint object_usage_linter. defined in utilities.R
545-
)
540+
resdata
546541
}
542+
cluster_columns <- .can_cluster_heatmap_columns(plot_data)
543+
544+
res <- ComplexHeatmap::Heatmap(
545+
plot_data,
546+
name = "row z-score",
547+
col = .abundance_col_fun(plot_data),
548+
na_col = .HEATMAP_NA_COL,
549+
cluster_rows = FALSE,
550+
cluster_columns = cluster_columns,
551+
top_annotation = .heatmap_top_annotation(annotation, factor_keys, sample_name, colnames(plot_data)),
552+
show_row_names = show_rownames,
553+
show_column_names = TRUE,
554+
row_labels = .truncate_plot_labels(rownames(plot_data), max_rownames_chars),
555+
column_labels = .suffix_plot_labels(colnames(plot_data), max_sample_label_chars),
556+
border = FALSE,
557+
use_raster = FALSE,
558+
heatmap_legend_param = list(title = "row z-score"),
559+
... = ...
560+
)
547561
invisible(res)
548562
}
549563

man/LFQDataPlotter.Rd

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/test-plotting_functions.R

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,29 @@ test_that("abundance heatmap shows missing values in light gray", {
268268
)
269269
})
270270

271+
test_that("abundance heatmap draws sparse one-row matrices with all-NA columns", {
272+
matrix <- matrix(
273+
c(NA, 12, 13, NA, 12.5, 13.5),
274+
nrow = 1,
275+
dimnames = list("P1", c("C1", "T1", "T2", "C2", "T3", "T4"))
276+
)
277+
annotation <- data.frame(
278+
sample = colnames(matrix),
279+
group = c("control", "treated", "treated", "control", "treated", "treated")
280+
)
281+
282+
p <- plot_heatmap(
283+
matrix,
284+
annotation,
285+
factor_keys = "group",
286+
sample_name = "sample",
287+
show_rownames = TRUE
288+
)
289+
290+
expect_s4_class(p, "Heatmap")
291+
expect_no_error(ComplexHeatmap::draw(p))
292+
})
293+
271294
test_that("abundance color mapping spans green, black, and red", {
272295
col_fun <- prolfqua:::.abundance_col_fun(matrix(c(-2, 0, 2), nrow = 1))
273296
cols <- grDevices::col2rgb(col_fun(c(-2, 0, 2)))
@@ -345,3 +368,38 @@ test_that("volcano_plotly does not cap small non-zero FDR values by default", {
345368

346369
expect_equal(max(capped_y), 4)
347370
})
371+
372+
test_that("heatmap keeps only the top_n most variable features", {
373+
istar <- sim_lfq_data_protein_config()
374+
lfq <- LFQData$new(istar$data, istar$config)
375+
wide <- lfq$data_wide(as.matrix = TRUE)
376+
n_all <- nrow(wide$data)
377+
expect_gt(n_all, 3)
378+
379+
# top_n >= feature count (or NULL / Inf) keeps every feature.
380+
expect_equal(nrow(prolfqua:::.select_most_variable_features(lfq, wide, top_n = n_all + 10)), n_all)
381+
expect_equal(nrow(prolfqua:::.select_most_variable_features(lfq, wide, top_n = NULL)), n_all)
382+
expect_equal(nrow(prolfqua:::.select_most_variable_features(lfq, wide, top_n = Inf)), n_all)
383+
384+
# top_n < feature count keeps exactly top_n rows, and they are the ones with
385+
# the highest variability statistic (CV here — untransformed data).
386+
k <- 3L
387+
sub <- prolfqua:::.select_most_variable_features(lfq, wide, top_n = k)
388+
expect_equal(nrow(sub), k)
389+
390+
st <- lfq$get_Stats(stats = "all")
391+
keys <- c(lfq$hierarchy_keys(), lfq$isotope_label())
392+
ranked <- dplyr::left_join(
393+
wide$rowdata,
394+
dplyr::select(st$stats(), dplyr::all_of(c(keys, st$stat))),
395+
by = keys
396+
)
397+
metric <- stats::setNames(ranked[[st$stat]], rownames(wide$data))
398+
kept <- metric[rownames(sub)]
399+
dropped <- metric[setdiff(rownames(wide$data), rownames(sub))]
400+
expect_gte(min(kept, na.rm = TRUE), max(dropped, na.rm = TRUE))
401+
402+
# end-to-end through the plotter: no hclust blow-up, returns a Heatmap.
403+
p <- lfq$get_Plotter()$heatmap(top_n = k)
404+
expect_s4_class(p, "Heatmap")
405+
})

0 commit comments

Comments
 (0)