Last updated: 2026-09-10
Checks: 7 0
Knit directory:
immgenT-GP-analysis/analysis/
This reproducible R Markdown analysis was created with workflowr (version 1.7.2). The Checks tab describes the reproducibility checks that were applied when the results were created. The Past versions tab lists the development history.
Great! Since the R Markdown file has been committed to the Git repository, you know the exact version of the code that produced these results.
Great job! The global environment was empty. Objects defined in the global environment can affect the analysis in your R Markdown file in unknown ways. For reproduciblity it’s best to always run the code in an empty environment.
The command set.seed(1) was run prior to running the
code in the R Markdown file. Setting a seed ensures that any results
that rely on randomness, e.g. subsampling or permutations, are
reproducible.
Great job! Recording the operating system, R version, and package versions is critical for reproducibility.
Nice! There were no cached chunks for this analysis, so you can be confident that you successfully produced the results during this run.
Great job! Using relative paths to the files within your workflowr project makes it easier to run your code on other machines.
Great! You are using Git for version control. Tracking code development and connecting the code version to the results is critical for reproducibility.
The results in this page were generated with repository version 59146f9. See the Past versions tab to see a history of the changes made to the R Markdown and HTML files.
Note that you need to be careful to ensure that all relevant files for
the analysis have been committed to Git prior to generating the results
(you can use wflow_publish or
wflow_git_commit). workflowr only checks the R Markdown
file, but you know if there are other scripts or data files that it
depends on. Below is the status of the Git repository when the results
were generated:
Ignored files:
Ignored: .DS_Store
Ignored: .claude/
Ignored: analysis/.DS_Store
Ignored: analysis/.Rhistory
Ignored: analysis/assets/.DS_Store
Ignored: captions/
Ignored: code/.DS_Store
Ignored: code/other/topic_flashier_20250212.R
Ignored: code/other/topic_wrapper_20250215_alldata_backfit.sh
Ignored: data
Ignored: experiments/
Ignored: figures/.DS_Store
Ignored: figures/final-selected/.DS_Store
Ignored: figures/final-selected/Figure 1/.DS_Store
Ignored: figures/final-selected/Figure 2/.DS_Store
Ignored: figures/final-selected/Figure 5/.DS_Store
Ignored: figures/final-selected/Figure S1/.DS_Store
Ignored: internal/
Ignored: log/
Ignored: output/.DS_Store
Ignored: output/Figure2/
Ignored: plan/
Ignored: tables/
Ignored: tmp/
Note that any generated files, e.g. HTML, png, CSS, etc., are not included in this status report because it is ok for generated content to have uncommitted changes.
These are the previous versions of the repository in which changes were
made to the R Markdown (analysis/FigureS1.Rmd) and HTML
(docs/FigureS1.html) files. If you’ve configured a remote
Git repository (see ?wflow_git_remote), click on the
hyperlinks in the table below to view the files as they were in that
past version.
| File | Version | Author | Date | Message |
|---|---|---|---|---|
| html | 60125af | Ziang Zhang | 2026-09-10 | Build site: Figure 4b on the shared column colours |
| html | 88ee847 | Ziang Zhang | 2026-09-10 | Build site: Figure 4b without the miniverse clusters |
| html | 5874416 | Ziang Zhang | 2026-09-10 | Build site: main Figure 4 inserted, Extended Data back to 1-7 |
| html | a7a481f | Ziang Zhang | 2026-09-09 | Build site: the rebuilt Figure 1d and the Extended Data renumbering |
| html | 1e88d7e | Ziang Zhang | 2026-09-04 | Build site: published captions and titles across all 24 pages |
| Rmd | 0267e5b | Ziang Zhang | 2026-09-04 | Captions from the published manuscript; trim editor notes off the page code |
| html | 19c977f | Ziang Zhang | 2026-09-02 | Build site: Extended Data 5-7 renumbered, Figure S5 page rebuilt |
| html | ae03072 | Ziang Zhang | 2026-08-19 | Build site: six pages rebuilt after the prose cleanup |
| Rmd | adc2327 | Ziang Zhang | 2026-08-19 | Site prose: finish taking internal notes off the pages |
| html | eeca07b | Ziang Zhang | 2026-08-05 | Keep pre-refactor provenance in panel comments off the published pages |
| html | 18f28a6 | Ziang Zhang | 2026-08-05 | Build site: Figure S1e GP1-only, no size or colour encoding |
| Rmd | 4d526e2 | Ziang Zhang | 2026-08-05 | Figure S1e: GP1 only, and stop encoding dataset size |
| html | 218a0ff | Ziang Zhang | 2026-08-04 | Build site: Figure S1e per-dataset depth vs GP1/GP171 loading |
| Rmd | df45005 | Ziang Zhang | 2026-08-04 | Figure S1e: per-dataset sequencing depth vs GP1/GP171 loading |
| html | 1db9951 | Ziang Zhang | 2026-07-31 | Build site: Figure S1e over all sixteen samples |
| Rmd | 2e75e8e | Ziang Zhang | 2026-07-31 | Figure S1e: show all sixteen samples of IGT13/IGT14, not just the spleen four |
| html | cbcec52 | Ziang Zhang | 2026-07-30 | Build site: Extended Data Figure naming |
| Rmd | 66aa029 | Ziang Zhang | 2026-07-30 | Name the Extended Data figures as published on the site |
| html | ac650a0 | Ziang Zhang | 2026-07-30 | Build site: Figure S5 (ex-S6a) and Figure S6 as a-f |
| html | 253a0be | Ziang Zhang | 2026-07-30 | Build site: Figure S1 with the new S1c-S1f |
| Rmd | 8c2f7c9 | Ziang Zhang | 2026-07-30 | Figure S1: replace S1c/S1d, add S1e, old S1e becomes S1f |
| html | ae21d37 | Ziang Zhang | 2026-07-28 | Build site: republish after the reorder commits |
| html | d538aa2 | Ziang Zhang | 2026-07-28 | Build site: reordered Figures 6 / S6 / S3 and the new Figure 7b page |
| html | 029b0ae | Ziang Zhang | 2026-07-28 | Build site. |
| Rmd | 0f5b5da | Ziang Zhang | 2026-07-28 | Align all figure captions with captions_20260728_final.docx |
| html | 3fc3789 | Ziang Zhang | 2026-07-27 | Republish all 24 pages |
| html | 1390a03 | Ziang Zhang | 2026-07-27 | Republish all 24 pages |
| html | 8a62cc5 | Ziang Zhang | 2026-07-27 | Build site: S1C/S1D on the shared 18-IGT basis |
| Rmd | 39de9e0 | Ziang Zhang | 2026-07-27 | Compute S1C and S1D from one matrix over the 18 >=500-cell IGTs |
| html | 6c1a613 | Ziang Zhang | 2026-07-27 | Build site: S1D now follows S1C |
| Rmd | bf7afcf | Ziang Zhang | 2026-07-27 | Make S1D consistent with S1C, and drop the legacy pages from docs/ |
| html | adaef21 | Ziang Zhang | 2026-07-27 | Build site: panel fixes and PDF-derived assets |
| Rmd | 9e032a7 | Ziang Zhang | 2026-07-27 | Fix four panels that diverged from the published figures; make renders reproducible |
| html | 5b19858 | Ziang Zhang | 2026-07-27 | Build site. |
| Rmd | 91ee059 | Ziang Zhang | 2026-07-26 | Select each panel’s code block by name, not by line number |
| html | 91ee059 | Ziang Zhang | 2026-07-26 | Select each panel’s code block by name, not by line number |
| Rmd | c655a83 | Ziang Zhang | 2026-07-16 | Fig S1E: use empirical active-gene/cell counts (match Figure 2) |
| html | c655a83 | Ziang Zhang | 2026-07-16 | Fig S1E: use empirical active-gene/cell counts (match Figure 2) |
| Rmd | c2b3360 | Ziang Zhang | 2026-07-13 | Use log-scale axes for Fig 1E/1F and add Fig S1E gene-vs-cell sparsity scatter |
| html | c2b3360 | Ziang Zhang | 2026-07-13 | Use log-scale axes for Fig 1E/1F and add Fig S1E gene-vs-cell sparsity scatter |
| html | 92021bf | Ziang Zhang | 2026-07-02 | Build site. |
| html | 827c89b | Ziang Zhang | 2026-07-02 | Build site. |
| Rmd | 8ac7f9f | Ziang Zhang | 2026-07-02 | Add data provenance notes to each script; remove conversational |
| Rmd | f9db962 | Ziang Zhang | 2026-07-02 | Simplify layout: drop old code/script folders, rename |
| html | c6e5086 | Ziang Zhang | 2026-07-02 | Build site. |
| html | 5a79883 | Ziang Zhang | 2026-07-02 | Build site. |
| Rmd | 2b0e445 | Ziang Zhang | 2026-07-02 | Fix GitHub source links to point at the new |
| html | cf1d0ac | Ziang Zhang | 2026-07-02 | Build site. |
| Rmd | 06b2461 | Ziang Zhang | 2026-07-02 | Initial commit: immgenT-GP-analysis |
| html | 06b2461 | Ziang Zhang | 2026-07-02 | Initial commit: immgenT-GP-analysis |
All panels are produced by script/FigureS1.R.
The code below is shown for reference (not re-executed on this page);
the images are its pre-rendered output. Panels A/B reuse a cached
per-IGT cosine-similarity score matrix
(data/igt_specific_cosine_scores.csv); see
code/pipeline/05_igt_validation.R for how that matrix
itself is produced (a much heavier, cluster-scale computation).
# Figure S1. GP reproducibility across IGTs.
#
# Panels produced:
# S1A Cumulative number of GPs validated (cosine >= threshold, thresholds
# 0.2-0.8) as IGTs are added one at a time, in IGT index order.
# S1B Number of GPs validated by at least X IGTs, vs X (log-log), for the
# same thresholds.
# S1C Per-GP proportion of loading variance explained by IGT (x) vs. by
# cell type / annotation_level2 (y), one-way ANOVA eta^2, over the
# standard-spleen cells.
# S1D GP9 loading across IGTs (standard-spleen cells) -- the shape of the
# most extreme x-axis point in S1C.
# S1E Per-IGT mean nCount_RNA (x) vs per-IGT mean GP1 loading (y), over the
# standard-spleen cells -- a GP whose loading tracks sequencing depth,
# as opposed to S1D's run-confined GP9.
# S1F Scatter of the NUMBER of active genes (x) vs. proportion of active
# cells (y) per GP, using the same hard-threshold definitions as Figure 2
# (|normalized score| > 0.25 for genes; normalized loading > 0.1 for
# cells), over non-thymocyte cells -- not the EBMF sparsity prior. One
# dot per GP.
#
# S1A/S1B reuse the per-IGT cosine-matching score matrix
# (data/igt_specific_cosine_scores.csv) rather than recomputing it here --
# recomputing requires Hungarian-matching each of the ~80 per-IGT
# refactorizations in data/igt_specific/*.qs against the full model, which is
# the job of code/pipeline/05_igt_validation.R (run once upstream).
#
# Required inputs (data/) -- see code/README.md's "Data provenance" table
# for the full picture:
# igt_specific_cosine_scores.csv [code/pipeline/05_igt_validation.R]
# L_pm_filtered.rds [code/pipeline/01b_filter_cells.R]
# igt1_96_..._ADTonly.Rds [primary input Seurat object]
library(dplyr)
library(tidyr)
library(ggplot2)
Panels A and B both use the cached per-IGT cosine score matrix:
data_path <- "data/"
figure_path <- "figures/final-selected/Figure S1/"
gp_label <- function(x) sub("^K(\\d+)$", "GP\\1", x)
# ============================================================
# S1A/S1B: load the cached per-IGT cosine score matrix
# (GPs x IGTs; produced by code/pipeline/05_igt_validation.R)
# ============================================================
score_mat <- as.matrix(read.csv(paste0(data_path, "igt_specific_cosine_scores.csv"), row.names = 1, check.names = FALSE))
# ============================================================
# S1A: cumulative number of GPs validated as IGTs are added, in IGT-index order
# ============================================================
igt_idx <- as.integer(gsub("^IGT", "", colnames(score_mat)))
o <- order(igt_idx)
score_mat_ord <- score_mat[, o, drop = FALSE]
cum_validated_counts <- function(score_mat_ord, threshold) {
validated <- score_mat_ord >= threshold
ever_validated <- t(apply(validated, 1, cummax)) # 200 x nIGT logical
colSums(ever_validated)
}
plot_df_a <- lapply(thresholds, function(t) {
y <- cum_validated_counts(score_mat_ord, t)
data.frame(n_IGTs_included = seq_along(y), validated_GPs = y, threshold = factor(t))
}) %>% bind_rows()
p_S1A <- ggplot(plot_df_a, aes(x = n_IGTs_included, y = validated_GPs, color = threshold)) +
geom_line(linewidth = 1) +
geom_point(size = 1) +
labs(x = "Number of IGTs included (in IGT index order)", y = "Number of validated GPs (cumulative union)", color = "Threshold") +
theme_minimal() +
scale_color_brewer(palette = "Set1") +
scale_x_continuous(breaks = seq(0, ncol(score_mat_ord), by = 5)) +
scale_y_continuous(breaks = seq(0, max(plot_df_a$validated_GPs), by = 20))
ggsave(paste0(figure_path, "S1A.pdf"), plot = p_S1A, width = 6, height = 4)

Extended Data Fig. 1a. Cumulative number of GPs, out of the 200 identified in the full immgenT solution, reproduced in at least one dataset (IGT). For each dataset, an EBMF factorization was computed and its factors were matched to the 200 immgenT GPs by Hungarian assignment using the cosine similarity of gene-score vectors. A GP was considered reproduced in a dataset when this cosine similarity exceeded the threshold indicated for each curve.
# ============================================================
# S1B: number of GPs validated by at least X IGTs, vs X (log-log)
# ============================================================
thresholds <- seq(0.2, 0.8, by = 0.1)
X_grid <- 1:50
plot_df_b <- tidyr::crossing(threshold = thresholds, X = X_grid) %>%
mutate(n_GP = purrr::map2_int(threshold, X, \(t, x) {
rowSums(score_mat >= t, na.rm = TRUE) |> (\(v) sum(v >= x))()
}))
p_S1B <- ggplot(plot_df_b, aes(x = X, y = n_GP, group = factor(threshold))) +
geom_line() +
geom_point(size = 1) +
scale_y_log10() +
scale_x_log10() +
labs(x = "X (validated by at least X IGTs)", y = "Number of GPs", color = "Threshold") +
aes(color = factor(threshold)) +
theme_minimal() +
scale_color_brewer(palette = "Set1")
ggsave(paste0(figure_path, "S1B.pdf"), plot = p_S1B, width = 6, height = 4)

Extended Data Fig. 1b. Distribution of GP reproducibility across datasets. Curves show the number of GPs reproduced in at least that many datasets, for cosine-similarity thresholds ranging from 0.2 to 0.8.
# ============================================================
# Load data for S1C/S1D/S1E
# ============================================================
L_pm_filtered <- readRDS(paste0(data_path, "L_pm_filtered.rds"))
seurat_meta <- readRDS(paste0(data_path, "igt1_96_withtotalvi20260206_clean_ADTonly.Rds"))@meta.data
seurat_meta_filtered <- seurat_meta[rownames(L_pm_filtered), ]
seurat_meta_filtered_spleen <- seurat_meta_filtered %>% filter(spleen_standard == TRUE)
# All three panels use the same cell set: every standard-spleen cell, with no
# per-group size filter. S1C needs all of them because the two groupings it
# compares have different numbers of groups and dropping small ones would drop
# them asymmetrically; S1D/S1E then show one GP over that same population.
spleen_cells <- intersect(rownames(L_pm_filtered), rownames(seurat_meta_filtered_spleen))
L_spleen <- L_pm_filtered[spleen_cells, , drop = FALSE]
igt_vec <- as.character(seurat_meta_filtered_spleen[spleen_cells, "IGT"])
lv2_vec <- as.character(seurat_meta_filtered_spleen[spleen_cells, "annotation_level2"])
n_spleen <- length(spleen_cells)
# ============================================================
# S1C: proportion of loading variance explained by IGT vs. by cell type
# ============================================================
# One-way ANOVA eta^2 per GP for each grouping, on the same cells:
# eta^2 = SS_between / SS_total, SS_between = sum_g n_g (mean_g - mean)^2
# SS_between weights each group by its cell count and uses the cell-level
# grand mean.
grand_mean <- colMeans(L_spleen)
ss_total <- apply(L_spleen, 2, var) * (n_spleen - 1)
eta2_by <- function(group) {
n_g <- as.numeric(table(group))
group_means <- rowsum(L_spleen, group) / n_g
colSums(n_g * sweep(group_means, 2, grand_mean)^2) / ss_total
}
eta2_igt <- eta2_by(igt_vec)
eta2_lv2 <- eta2_by(lv2_vec)
# eta^2 grows with the number of groups even with no signal: its null
# expectation is (G-1)/(N-1). The two dotted guides mark 5x that floor, which
# differs between the axes because level2 has ~3x as many groups as IGT.
floor_igt <- (length(unique(igt_vec)) - 1) / (n_spleen - 1)
floor_lv2 <- (length(unique(lv2_vec)) - 1) / (n_spleen - 1)
pve_df <- data.frame(GP = colnames(L_spleen), x = eta2_igt, y = eta2_lv2)
top_n <- 12 # label the strongest GPs on each axis
pve_df$label <- ifelse(
seq_len(nrow(pve_df)) %in% union(order(-pve_df$x)[1:top_n], order(-pve_df$y)[1:top_n]),
gp_label(as.character(pve_df$GP)), ""
)
p_S1C <- ggplot(pve_df, aes(x = x, y = y, label = label)) +
geom_abline(slope = 1, intercept = 0, linetype = 2, colour = "grey50") +
geom_vline(xintercept = 5 * floor_igt, linetype = 3, colour = "grey55") +
geom_hline(yintercept = 5 * floor_lv2, linetype = 3, colour = "grey55") +
geom_point(size = 1.6, alpha = 0.75, colour = "steelblue") +
ggrepel::geom_text_repel(seed = 42, size = 2.8, max.overlaps = Inf, segment.color = "grey60") +
cowplot::theme_cowplot(font_size = 11) +
labs(
title = "PVE per GP: IGT vs level2 (control spleen, all cells)",
subtitle = sprintf("all %s cells, %d IGTs, %d level2 types; linear axes; dashed = y=x",
format(n_spleen, big.mark = ","),
length(unique(igt_vec)), length(unique(lv2_vec))),
x = expression("PVE by IGT (" * eta^2 * ")"),
y = expression("PVE by level2 (" * eta^2 * ")")
)
ggsave(paste0(figure_path, "S1C.pdf"), plot = p_S1C, width = 6.5, height = 5.5, dpi = 300)

Extended Data Fig. 1c. Batch-effect evaluation using spleen standards (T cells in 6-8 week old mice, unchallenged). For each GP, the proportion of loading variance explained by datasets (IGT) (x-axis) versus the proportion explained by T cell clusters (level-2 cluster, y-axis), computed over all spleen-standard cells, identifies batch-effect in GPs.
# ============================================================
# S1D: GP9 loading across IGTs (standard spleen)
# ============================================================
# GP9 is the extreme point on S1C's x-axis (eta^2 = 0.91 by IGT vs 0.08 by
# level2). Drawn over the IGTs with >= 100 standard-spleen cells, so that no box
# summarises a handful of cells; ordered by median loading, which puts the two
# IGTs carrying the effect at the top rather than assuming where they land.
gp_focus <- "K9"
igt_keep <- names(table(igt_vec))[table(igt_vec) >= 100]
box_igt <- data.frame(igt = igt_vec, loading = L_spleen[, gp_focus]) %>%
filter(igt %in% igt_keep)
igt_order <- names(sort(tapply(box_igt$loading, box_igt$igt, median)))
box_igt$igt <- factor(box_igt$igt, levels = igt_order)
p_S1D <- ggplot(box_igt, aes(x = loading, y = igt)) +
geom_boxplot(outlier.size = 0.3, outlier.alpha = 0.25, fill = "grey92", linewidth = 0.35) +
cowplot::theme_cowplot(font_size = 11) +
labs(
title = paste0(gp_label(gp_focus), " loading across IGTs (control spleen)"),
subtitle = sprintf("%d IGTs with >= 100 standard-spleen cells, ordered by median loading",
length(igt_keep)),
x = paste0(gp_label(gp_focus), " loading"), y = NULL
)
ggsave(paste0(figure_path, "S1D.pdf"), plot = p_S1D, width = 5.5, height = 6, dpi = 300)

Extended Data Fig. 1d. In some instances, GP captures specific batches, such as GP9, specific to IGT13-14. Boxplots showing the distribution of GP9 activity in the spleen-standard cells across datasets.
# ============================================================
# S1E: per-IGT mean sequencing depth vs per-IGT mean GP1 loading
# ============================================================
# The other kind of between-dataset structure: not a program confined to one run
# (S1D), but a GP whose loading tracks how deeply the dataset was sequenced. GP1
# is the most nCount_RNA-correlated GP of the 200 at every level of aggregation
# -- per cell (Spearman rho +0.78 over the standard-spleen cells), per IGT (this
# panel), and per sample (+0.81). It correlates more strongly with nFeature_RNA
# (+0.89) than with nCount_RNA and is unchanged by conditioning on
# annotation_level2 (+0.78), and its top genes are housekeeping, so it reads as
# a detection-rate axis that the library-size normalization upstream of the fit
# does not remove. GP171 is second on the same ranking; only GP1 is drawn here.
#
# Every one of the 35 IGTs is drawn identically -- same size, same colour, one
# fit over all of them. 35 is not a threshold: the dataset has 80 IGTs, and the
# other 45 contributed no standard-spleen cell at all.
depth_gp <- "K1"
depth_vec <- seurat_meta_filtered_spleen[spleen_cells, "nCount_RNA"]
depth_df <- data.frame(IGT = igt_vec, nCount = depth_vec,
loading = L_spleen[, depth_gp]) %>%
group_by(IGT) %>%
summarise(n_cells = n(), mean_nCount = mean(nCount),
mean_loading = mean(loading), .groups = "drop")
n_igt_depth <- nrow(depth_df)
rho_depth <- cor(depth_df$mean_nCount, depth_df$mean_loading, method = "spearman")
p_S1E <- ggplot(depth_df, aes(x = mean_nCount, y = mean_loading)) +
geom_smooth(method = "lm", formula = y ~ x, se = FALSE, colour = "grey55",
linewidth = 0.6) +
geom_point(size = 1.9, alpha = 0.85, colour = "steelblue") +
ggrepel::geom_text_repel(aes(label = sub("^IGT", "", IGT)), size = 2.5, seed = 42,
max.overlaps = Inf, segment.color = "grey60",
min.segment.length = 0.2) +
annotate("text", x = Inf, y = -Inf, hjust = 1.05, vjust = -0.8, size = 3.4,
label = sprintf("Spearman rho = %+.2f", rho_depth)) +
expand_limits(y = 0) +
cowplot::theme_cowplot(font_size = 11) +
labs(
title = paste0("Per-dataset sequencing depth vs ", gp_label(depth_gp), " loading"),
subtitle = sprintf("one point per IGT, label = IGT number; all %d IGTs contributing standard-spleen cells; grey = OLS fit",
n_igt_depth),
x = "mean nCount_RNA per IGT",
y = paste0("mean ", gp_label(depth_gp), " loading per IGT")
)
# y is anchored at 0 so the reader can see that the between-dataset spread
# (0.24-0.41) is a large fraction of GP1's normalized [0, 1] loading scale, not a
# zoomed-in wiggle; the height is trimmed so that band does not dominate.
ggsave(paste0(figure_path, "S1E.pdf"), plot = p_S1E, width = 6.5, height = 4.2, dpi = 300)

| Version | Author | Date |
|---|---|---|
| 4d526e2 | Ziang Zhang | 2026-08-05 |
| df45005 | Ziang Zhang | 2026-08-04 |
| 2e75e8e | Ziang Zhang | 2026-07-31 |
| 8c2f7c9 | Ziang Zhang | 2026-07-30 |
| 7102598 | Ziang Zhang | 2026-07-27 |
| 6c1a613 | Ziang Zhang | 2026-07-27 |
| bf7afcf | Ziang Zhang | 2026-07-27 |
| ea3ecc2 | Ziang Zhang | 2026-07-27 |
| c655a83 | Ziang Zhang | 2026-07-16 |
| c2b3360 | Ziang Zhang | 2026-07-13 |
Extended Data Fig. 1e. In other instances, like GP1, GPs capture continuous batch effects such as sequencing depth. Mean RNA count per dataset (x-axis) against mean GP1 loading per dataset (y-axis) in the spleen-standard cells.
# ============================================================
# S1F: active-gene vs active-cell scatter per GP, using the SAME hard-threshold
# definitions as Figure 2 (per-GP-normalized): number of active genes = count of
# genes with |score| > 0.25 of the GP's max; proportion of active cells = fraction
# of cells with loading > 0.1 of the GP's max. Non-thymocyte cells, matching
# Figure 2.
# ============================================================
non_thymo_s1f <- seurat_meta_filtered$cellID[seurat_meta_filtered$annotation_level1 != "thymocyte"]
L_s1f <- L_pm_filtered[non_thymo_s1f, ]
L_norm_s1f <- L_s1f / matrix(apply(L_s1f, 2, max), nrow = nrow(L_s1f), ncol = ncol(L_s1f), byrow = TRUE)
prop_cells <- colSums(L_norm_s1f > 0.1) / nrow(L_norm_s1f) # proportion of active cells per GP
F_s1f <- readRDS(paste0(data_path, "F_pm_filtered.rds"))
F_norm_s1f <- F_s1f / matrix(apply(F_s1f, 2, function(x) max(abs(x))), nrow = nrow(F_s1f), ncol = ncol(F_s1f), byrow = TRUE)
n_genes_act <- colSums(abs(F_norm_s1f) > 0.25) # number of active genes per GP
scatter_df_s1f <- data.frame(n_genes = n_genes_act, prop_cells = prop_cells)
pct_breaks <- c(0.0001, 0.001, 0.01, 0.05, 0.1, 0.3, 0.5, 1)
p_S1F <- ggplot(scatter_df_s1f, aes(x = n_genes, y = prop_cells)) +
geom_point(size = 2, alpha = 0.7, color = "steelblue") +
scale_x_log10(labels = scales::label_comma()) +
scale_y_log10(breaks = pct_breaks, labels = function(x) paste0(x * 100, "%")) +
annotation_logticks(sides = "bl") +
labs(
x = "Number of active genes per GP (log scale)",
y = "Proportion of active cells per GP (log scale)",
title = "Active genes vs. active-cell proportion per GP"
) +
theme_minimal(base_size = 13)
ggsave(paste0(figure_path, "S1F.pdf"), plot = p_S1F, width = 6, height = 5, dpi = 300)

| Version | Author | Date |
|---|---|---|
| 8c2f7c9 | Ziang Zhang | 2026-07-30 |
Extended Data Fig. 1f. For each GP, the number of active genes versus the fraction of active cells (log-scaled).
sessionInfo()
R version 4.5.1 (2025-06-13)
Platform: aarch64-apple-darwin20
Running under: macOS Sequoia 15.6.1
Matrix products: default
BLAS: /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libBLAS.dylib
LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
locale:
[1] en_CA/en_CA/en_CA/C/en_CA/en_CA
time zone: America/Chicago
tzcode source: internal
attached base packages:
[1] stats graphics grDevices utils datasets methods base
loaded via a namespace (and not attached):
[1] vctrs_0.7.3 cli_3.6.6 knitr_1.50 rlang_1.2.0
[5] xfun_0.55 stringi_1.8.9 otel_0.2.0 promises_1.5.0
[9] jsonlite_2.0.0 workflowr_1.7.2 glue_1.8.1 rprojroot_2.1.1
[13] git2r_0.36.2 htmltools_0.5.9 httpuv_1.6.16 sass_0.4.10
[17] rmarkdown_2.30 evaluate_1.0.5 jquerylib_0.1.4 tibble_3.3.0
[21] fastmap_1.2.0 yaml_2.3.12 lifecycle_1.0.5 whisker_0.4.1
[25] stringr_1.6.0 compiler_4.5.1 fs_1.6.6 Rcpp_1.1.1-1.1
[29] pkgconfig_2.0.3 later_1.4.4 digest_0.6.39 R6_2.6.1
[33] pillar_1.11.1 magrittr_2.0.5 bslib_0.9.0 tools_4.5.1
[37] cachem_1.1.0