Skip to main content

Single-Cell CNV Inference Plot

Author

[Editor] ZCode (GLM) โ€” AI-generated, independently AI-reviewed;

Modified

2026-11-05

๐Ÿค– AI Skill โ€” Download the Bizard unified skill for your AI assistant โฌ‡๏ธ Download Skill ZIP
๐Ÿ”Œ MCP Server โ€” Let AI agents (Claude Code, Cursor, โ€ฆ) query Bizard live: claude mcp add bizard -- npx @openbiox/bizard-mcp โ€” or the free hosted instance (no install, faster from mainland China): claude mcp add --transport http bizard http://biotree.top:38123/bizard-mcp/mcp ๐Ÿ“– MCP Docs
๐Ÿ“– Cite Bizard โ€” if Bizard helps your research, please cite: Li, K., Zheng, H., Huang, K., et al. (2026). Bizard: A Community-Driven Platform for Accelerating and Enhancing Biomedical Data Visualization. iMetaMed, e70038. ๐Ÿ”— DOI / BibTeX

Large-scale copy-number aberrations can be inferred from single-cell RNA-seq: order genes by genomic position, smooth expression over 100-gene windows, median-centre every cell, and the residual waves are the CNV landscape [1]. Plotting cells (rows) against chromosomal positions (columns) separates malignant from non-malignant cells, with a per-cell CNV score as the ordering statistic.

Example

ScCnvPlot DEMO

Setup

  • System Requirements: Cross-platform (Linux/MacOS/Windows)

  • Programming Language: R

  • Dependent packages: ComplexHeatmap; circlize; data.table; dplyr

# Installing necessary packages
if (!requireNamespace("ComplexHeatmap", quietly = TRUE)) {
  BiocManager::install("ComplexHeatmap")
}
if (!requireNamespace("circlize", quietly = TRUE)) {
  install.packages("circlize")
}
if (!requireNamespace("data.table", quietly = TRUE)) {
  install.packages("data.table")
}
if (!requireNamespace("dplyr", quietly = TRUE)) {
  install.packages("dplyr")
}

# Load packages
library(ComplexHeatmap)
library(circlize)
library(data.table)
library(ggplot2)
library(dplyr)
sessioninfo::session_info("attached")
โ”€ Session info โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 setting  value
 version  R version 4.6.2 (2026-10-29)
 os       Ubuntu 26.04.1 LTS
 system   x86_64, linux-gnu
 ui       X11
 language (EN)
 collate  C.UTF-8
 ctype    C.UTF-8
 tz       UTC
 date     2026-11-05
 pandoc   3.7.0.2 @ /usr/bin/ (via rmarkdown)
 quarto   1.11.9 @ /usr/local/bin/quarto

โ”€ Packages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 ! package        * version    date (UTC) lib source
 P circlize       * 0.4.18     2026-04-04 [?] RSPM
 P ComplexHeatmap * 2.28.0     2026-04-28 [?] Bioconductor 3.23 (R 4.6.2)
 P data.table     * 1.18.6.1   2026-08-24 [?] RSPM
 P dplyr          * 1.2.1      2026-04-03 [?] RSPM
 P ggplot2        * 4.0.3.9000 2026-11-03 [?] Github (tidyverse/ggplot2@b638d65)

 [1] /home/runner/.cache/R/renv/library/Bizard-5140bdc6/linux-ubuntu-resolute/R-4.6/x86_64-pc-linux-gnu
 [2] /home/runner/work/_temp/Library
 [3] /opt/R/4.6.2/lib/R/library

 * โ”€โ”€ Packages attached to the search path.
 P โ”€โ”€ Loaded and on-disk path mismatch.

โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

Data Preparation

The real input is a log2-TPM single-cell matrix with cell metadata (the reference uses GSE103322 HNSCC). We simulate 400 genes ordered along 22 chromosomes and 80 cells: 40 malignant cells carrying broad CNV waves and 40 non-malignant cells without them.

set.seed(1401)
n_chr <- 22; genes_per_chr <- 50; n_gene <- n_chr * genes_per_chr
n_mal <- 40; n_nor <- 40

gene_pos <- expand.grid(chr = seq_len(n_chr), idx = seq_len(genes_per_chr))
gene_pos <- gene_pos[order(gene_pos$chr, gene_pos$idx), ]
genes <- paste0("G", sprintf("%04d", seq_len(n_gene)))

cell_type <- factor(c(rep("Malignant", n_mal), rep("Non-malignant", n_nor)),
                    levels = c("Malignant", "Non-malignant"))
cells <- paste0(substr(cell_type, 1, 3), "_", sprintf("%03d", seq_len(2 * n_mal)))

expr <- matrix(rnorm(n_gene * length(cells), mean = 5, sd = 0.5),
               nrow = n_gene, dimnames = list(genes, cells))
## broad CNV waves in malignant cells: sinusoid per chromosome,
## amplitude and phase vary per cell
for (i in seq_len(n_mal)) {
  for (chr in seq_len(n_chr)) {
    amp   <- runif(1, 0.4, 1.0) * sample(c(-1, 1), 1)        # del or amp
    phase <- runif(1, 0, 2 * pi)
    idx   <- which(gene_pos$chr == chr)
    expr[idx, i] <- expr[idx, i] +
      amp * sin(seq_along(idx) / length(idx) * 2 * pi + phase)
  }
}

## gene ordering + centring + bounding (the reference recipe)
gene_order <- order(gene_pos$chr, gene_pos$idx)
expr <- expr[gene_order, ]
rela <- sweep(expr, 1, rowMeans(expr))
rela <- pmin(pmax(rela, -3), 3)

Visualization

1. Run-Mean Smoothing (100-Gene Windows)

smoothed <- rela
for (c_idx in seq_len(ncol(rela))) {
  smoothed[, c_idx] <- data.table::frollmean(rela[, c_idx], n = 50, align = "center")
}
smoothed[is.na(smoothed)] <- 0
## per-cell median re-centring after smoothing (Patel et al., 2014)
smoothed <- sweep(smoothed, 2, apply(smoothed, 2, median))

cnv_score <- colMeans(smoothed^2)
col_order <- order(cell_type, -cnv_score)

col_fun <- circlize::colorRamp2(c(-1.5, 0, 1.5), c("#2166AC", "white", "#B2182B"))
row_ha <- HeatmapAnnotation(
  df = data.frame(Type = cell_type[col_order],
                  `CNV score` = cnv_score[col_order]),
  col = list(Type = c(Malignant = "#B2182B", `Non-malignant` = "#4D4D4D")),
  annotation_name_side = "left")

chr_breaks <- cumsum(table(gene_pos$chr[gene_order]))[-n_chr]

Heatmap(smoothed[, col_order], name = "CNV",
        top_annotation = row_ha,
        col = col_fun,
        cluster_rows = FALSE, cluster_columns = FALSE,
        show_column_names = FALSE, show_row_names = FALSE,
        column_split = cell_type[col_order], column_gap = unit(1, "mm"),
        border = TRUE,
        heatmap_legend_param = list(at = c(-1.5, 0, 1.5),
                                    labels = c("del", "", "amp"))) |>
  draw(column_title = "Chromosomal positions")
Figureย 1: CNV heatmap: cells (rows, malignant on top) x chromosomal positions (columns, chromosome boundaries ticked); smoothed over 100-gene windows.

2. Malignant vs Non-Malignant Separation by CNV Score

score_df <- data.frame(type = cell_type, score = cnv_score)
p_w <- wilcox.test(score ~ type, data = score_df)$p.value

ggplot(score_df, aes(type, score, fill = type)) +
  geom_boxplot(outlier.shape = NA, width = 0.55) +
  geom_jitter(width = 0.1, size = 0.8, alpha = 0.5, show.legend = FALSE) +
  scale_fill_manual(values = c(Malignant = "#B2182B", `Non-malignant` = "#4D4D4D")) +
  labs(title = sprintf("CNV score (Wilcoxon %s)",
       ifelse(p_w < 0.001, "p < 0.001", sprintf("p = %.3f", p_w))),
       x = NULL, y = "CNV score", fill = NULL) +
  theme_classic(base_size = 11) +
  theme(legend.position = "none")
Figureย 2: Per-cell CNV score separates malignant from non-malignant cells; the Wilcoxon p value is annotated.
Tip

Practice tips

  • Filter out lowly expressed genes before inferring CNV โ€” near-zero expression distorts the running mean and mutes the waves.
  • Centre per cell (median of all genes), not per gene: the waves are relative to each cellโ€™s baseline.
  • The CNV score doubles as a malignancy classifier โ€” the reference workflow uses it to flag malignant cells that marker genes miss.

Application

Inferred CNV landscapes are the standard quality control and malignancy call for tumour single-cell RNA-seq (HNSCC, melanoma, glioma). The heatmap is usually the first figure of a scRNA-seq tumour study.

Reference

[1] Puram SV, Tirosh I, Parikh AS, et al.ย Single-cell transcriptomic analysis of primary and metastatic tumor ecosystems in head and neck cancer. Cell. 2017;171(7):1611-1624.e24. doi:10.1016/j.cell.2017.10.044

[2] Xiaofan Lu, et al.ย FigureYa: A Standardized Visualization Framework for Enhancing Biomedical Data Interpretation and Research Efficiency. iMetaMed. 2025. doi:10.1002/imm3.70005