Matching a differential expression signature to perturbation references using perturbMatch
if (!requireNamespace("BiocManager", quietly = TRUE))
install.packages("BiocManager")
BiocManager::install("perturbMatch")
## Or install the development version from GitHub
# install.packages("devtools")
devtools::install_github("soulj/perturbMatch", build_vignettes = TRUE)Gene-identifier mapping depends on AnnotationDbi and
org.Hs.eg.db, and the full reference databases are
retrieved with ExperimentHub; all three are installed
automatically alongside perturbMatch. To reproduce this vignette you
will also need the airway example dataset and
DESeq2 for the worked example, and GO.db for
the regulator GO enrichment:
perturbMatch matches a transcriptomics signature against a reference database of single-gene perturbation signatures, asking what transcriptional regulators could be driving the gene expression changes observed in an experiment.
perturbMatch provides a large collection of single-gene perturbation signatures derived from publicly available RNA-seq datasets. Each signature captures the transcriptional response to overexpression, knockdown, or knockout of a single gene.
The signatures are computed from 4,453 public RNA-seq series in NCBI
GEO, covering 3,571 knockdowns, 1,779 knockouts and 1,452
overexpressions over a common set of 39,376 NCBI genes. Series were
selected on the basis of detected single gene perturbations and
availability of pre-processed count data. The GEO series behind each
signature is recorded in its sig_id and
accession, so any hit can be traced back to its source
experiment.
Assigning a perturbation type, a target gene and case/control sample
groups to each series was automated with an LLM-assisted curation
pipeline. The model used is recorded per signature in
colData(reference)$model. Automated curation is not
error-free, so Verifying
hits before interpretation shows how to check a result before acting
on it.
The reference data are distributed as the
perturbMatchData ExperimentHub package and archived at
Zenodo under doi:10.5281/zenodo.21792179,
released under a CC-BY-4.0 licence.
perturbMatch compares your experimental gene expression signature
against each reference signature using one of several implemented
enrichment methods: SignedJaccard, GSEA,
cosine or cosineExtreme. These are described
in Scoring methods available.
Regulators whose perturbation signatures closely match your query are
ranked as likely upstream drivers.
We start from example RNA-seq data, which is analysed with DESeq2 to identify differentially expressed genes. The differential expression results can be passed directly to perturbMatch to be screened against a large collection of potential regulators.
perturbMatch accepts input as a DESeqResults object, a
topTable() data frame, or any data frame with gene
identifiers and a log2 fold-change column. prepareQuery()
(below) converts any of these into the two-column form
calcEnrichment() expects, and also maps the gene
identifiers to Entrez IDs used by the reference databases.
Correlation-based methods (see Scoring methods available) require the full set of results from a differential expression analysis, i.e. all genes measured in the experiment, not just the statistically significant subset.
The example below uses the airway dataset, which
measures the gene expression response to dexamethasone treatment in
airway smooth muscle cells. The airway dataset is loaded
into the session with data(airway) after
library(airway). We perform differential expression
analysis on this data with DESeq2 to create an example gene expression
response for querying.
library(perturbMatch)
library(SummarizedExperiment)
library(BiocParallel)
library(airway)
library(DESeq2)
# Load example data
data(airway)
# Calculate differential expression using DESeq2
dds_airway <- DESeqDataSet(airway, design = ~ cell + dex) |>
DESeq() |>
results(contrast = c("dex", "trt", "untrt"))We now have a DESeqResults object,
dds_airway, containing the log2 fold changes and adjusted
p-values for every gene measured in the experiment. This is the input we
will pass to perturbMatch.
## log2 fold change (MLE): dex trt vs untrt
## Wald test p-value: dex trt vs untrt
## DataFrame with 6 rows and 6 columns
## baseMean log2FoldChange lfcSE stat pvalue
## <numeric> <numeric> <numeric> <numeric> <numeric>
## ENSG00000000003 708.602170 -0.3812540 0.100654 -3.787752 0.000152016
## ENSG00000000005 0.000000 NA NA NA NA
## ENSG00000000419 520.297901 0.2068126 0.112219 1.842943 0.065337292
## ENSG00000000457 237.163037 0.0379204 0.143445 0.264356 0.791505742
## ENSG00000000460 57.932633 -0.0881682 0.287142 -0.307054 0.758801924
## ENSG00000000938 0.318098 -1.3782270 3.499873 -0.393793 0.693733530
## padj
## <numeric>
## ENSG00000000003 0.00128292
## ENSG00000000005 NA
## ENSG00000000419 0.19646985
## ENSG00000000457 0.91141962
## ENSG00000000460 0.89500478
## ENSG00000000938 NA
Select and load the reference gene expression data to compare against
your experiment with getReferenceDatabase(). The reference
database is returned as a SummarizedExperiment object.
For the purposes of this vignette we use a small bundled
demonstration database of 21 signatures (demo = TRUE). The
demonstration database should not be used for any real analysis: it is a
small subset of the full reference database, chosen to illustrate both
this bulk example and the single-cell workflow, and provided for
illustration only.
## Loaded the bundled DEMONSTRATION database (6500 genes x 21 signatures). It is a small illustrative subset of the limma reference and should not be used for real analysis. Use getReferenceDatabase(demo = FALSE) for a full reference database.
The full reference databases are retrieved with
getReferenceDatabase() (demo = FALSE is the
default) from ExperimentHub, where they are registered by the
perturbMatchData package. They are between 0.7-3.1 GB, but
only need to be downloaded once: ExperimentHub caches them, and
metadata() on the returned object records which source they
came from. Several builds of the reference are available through the
type and batchCorrection arguments:
type = "chrdir" gives signatures
summarised with the characteristic-direction method. This is the
default.type = "limma" gives signatures
derived from a limma differential expression analysis
useful for producing volcano plots of the signatures.Each type comes in two variants, selected with
batchCorrection: the uncorrected signatures
(batchCorrection = FALSE, the default) and signatures
computed with study or batch effects removed
(batchCorrection = TRUE).
# Characteristic-direction reference, without batch-effect correction (default)
ref_chrdir <- getReferenceDatabase(type = "chrdir")
# Characteristic-direction reference, with batch-effect correction
ref_chrdir_bc <- getReferenceDatabase(type = "chrdir", batchCorrection = TRUE)
# limma reference, without batch-effect correction (default)
ref_limma <- getReferenceDatabase(type = "limma")
# limma reference, with batch-effect correction
ref_limma_sva <- getReferenceDatabase(type = "limma", batchCorrection = TRUE)chrdir references store a single
characteristic-direction score, while limma references
store both the log2 fold change (log2fc) and a combined
pi score (log2FC * -log10(padj), the default),
which weights each gene’s fold change by its statistical significance.
calcEnrichment()’s refMetric argument selects
which of a reference database’s stored metrics to score against (for
example refMetric = "log2fc" on a limma
reference). The bundled demonstration database used throughout this
vignette is a small limma subset, so its default metric is
pi.
Data are not loaded into R until required, keeping RAM usage low. The first few rows and columns of the assay can be inspected:
## <3 x 3> DelayedMatrix object of type "double":
## GSE200312_NR3C1_KD_1 GSE197354_AFF4_KD_1 GSE125167_ZBTB16_KD_1
## 643837 0.2355057 0.4235750 0.1854626
## 100130417 -0.2795588 -3.6476560 -1.0390347
## 107985728 0.6614488 -1.6465162 -0.5283873
Metadata for each perturbation experiment are stored in the
colData slot:
coldata_cols <- c(
"sig_id", "pert_symbol", "pert_type", "cell_line", "cell_line_efo_label"
)
head(colData(demo_expression_db)[, coldata_cols], 3)## DataFrame with 3 rows and 5 columns
## sig_id pert_symbol pert_type
## <character> <character> <character>
## GSE200312_NR3C1_KD_1 GSE200312_NR3C1_KD_1 NR3C1 KD
## GSE197354_AFF4_KD_1 GSE197354_AFF4_KD_1 AFF4 KD
## GSE125167_ZBTB16_KD_1 GSE125167_ZBTB16_KD_1 ZBTB16 KD
## cell_line cell_line_efo_label
## <character> <character>
## GSE200312_NR3C1_KD_1 Amnion epithelial ce.. amnion
## GSE197354_AFF4_KD_1 mesenchymal stem cells NA
## GSE125167_ZBTB16_KD_1 Human Mesenchymal St.. NA
Any SummarizedExperiment object containing a measure of
differential expression can be used as a reference database for
perturbMatch, provided colData includes at least the
pert_symbol, sig_id, and
pert_type columns identifying each experiment. The
remaining columns in Table 1 are annotation that the packaged references
also provide: provenance of the source experiment
(accession, control_samples,
case_samples, technology), how the signature
was curated (model), the biological context it was
generated in (cell_line, cell_line_efo_id,
cell_line_efo_label), and per-signature quality information
(entrez_gene_id, target_gene_percentile).
| Column Name | Description |
|---|---|
| sig_id | Unique identifier for the perturbation signature. Matches the signature matrix column names |
| pert_symbol | Official gene symbol of the perturbed gene target |
| pert_id | Internal ID combining the symbol and perturbation type |
| pert_type | Type of perturbation: KD (knockdown),
KO (knockout) or OE (overexpression) |
| accession | NCBI GEO series accession for the source study |
| control_samples | Comma-separated list of raw sample IDs used as the control |
| case_samples | Comma-separated list of raw sample IDs used as the case |
| technology | Profiling platform used to generate the source data,
e.g. RNA-seq |
| cell_line | Cell line or primary cell type the perturbation was
performed in, as free text reported by the source study,
e.g. T47D, iPSC-derived Macrophages |
| model | Curation approach used to annotate the signature:
curated by hand, or the LLM that extracted the
annotation |
| entrez_gene_id | NCBI Entrez ID of the perturbed gene. NA
when the target has no human Entrez entry, e.g. a viral gene. Use it to
filter out hits that cannot be human upstream regulators |
| cell_line_efo_id | Experimental Factor Ontology (EFO) identifier matched
to cell_line, e.g. EFO:0003043.
NA where no confident match was found, so not every
signature is annotated |
| cell_line_efo_label | EFO label for cell_line_efo_id, giving a
standardised name for the free-text cell_line value
(NCI-H1299 for H1299). Use it to group or
select signatures by cell context |
| target_gene_percentile | Percentile position of the perturbed gene within its own signature, from most down-regulated (0) to most up-regulated (100). It is a quality check on whether the perturbation worked |
The airway DESeq2 result uses Ensembl gene IDs, but the
reference database uses Entrez IDs. prepareQuery() maps
between the two, reporting how much of the query mapped. Passing
reference additionally reports how much of the mapped query
is actually present in the reference, a check that the gene IDs line up
before scoring.
By default prepareQuery() also drops genes with a
missing adjusted p-value. DESeq2 leaves padj
as NA for genes it filters out before testing (typically
because they are too lowly expressed to be informative), and these carry
no reliable signal for signature matching. Set
dropLowExpressed = FALSE to keep them.
## Taking gene identifiers from the row names.
## Dropped 15451 genes with a missing adjusted p-value (not tested, typically lowly expressed). Set dropLowExpressed = FALSE to keep them.
## 'select()' returned 1:many mapping between keys and columns
## Mapped 83.5% of query genes to Entrez (15041 / 18018).
## 38.8% of mapped genes are present in the reference database.
## gene log2FC padj
## 1 247 9.505975 1.253044e-17
## 2 7704 7.352626 2.255365e-40
## 3 101928819 6.327383 7.206312e-19
## 4 3000 5.885114 1.000057e-04
## 5 2847 -5.325904 2.378691e-04
## 6 79689 5.207158 5.055589e-24
prepareQuery() also accepts any data.frame,
so you can read a file with read.csv() (or
read.delim() for tab-separated files) to be used as
input.
The table needs:
prepareQuery() infers the type and maps to Entrez.log2FoldChange,
logFC, or log2FC.padj or adj.P.Val, which enables the
low-expression filtering described above.Columns are matched by name, so the fold-change and p-value columns can sit anywhere in the table.
Anything prepareQuery() does not recognise such as raw
p-values, base means, average expression is ignored, and the returned
query always has the same
gene/log2FC/padj columns.
To keep this example self-contained we first write the airway result out to a temporary CSV file, deliberately putting the columns in an awkward order:
de_table <- as.data.frame(dds_airway)
de_table <- data.frame(
baseMean = de_table$baseMean, # extra column, ignored
padj = de_table$padj, # optional adjusted p-value
gene_id = rownames(de_table), # gene ID, not in the first column
pvalue = de_table$pvalue, # extra column, ignored
log2FC = de_table$log2FoldChange # fold-change column
)
csv_path <- tempfile(fileext = ".csv")
write.csv(de_table, csv_path, row.names = FALSE)Reading it back in and preparing it then gives the same query as before:
## baseMean padj gene_id pvalue log2FC
## 1 708.6021697 0.001282918 ENSG00000000003 0.0001520163 -0.38125398
## 2 0.0000000 NA ENSG00000000005 NA NA
## 3 520.2979006 0.196469846 ENSG00000000419 0.0653372915 0.20681260
## 4 237.1630368 0.911419623 ENSG00000000457 0.7915057416 0.03792043
## 5 57.9326331 0.895004783 ENSG00000000460 0.7588019240 -0.08816818
## 6 0.3180984 NA ENSG00000000938 0.6937335303 -1.37822703
## Taking gene identifiers from column 'gene_id'.
## Dropped 15451 genes with a missing adjusted p-value (not tested, typically lowly expressed). Set dropLowExpressed = FALSE to keep them.
## 'select()' returned 1:many mapping between keys and columns
## Mapped 83.5% of query genes to Entrez (15041 / 18018).
## 38.8% of mapped genes are present in the reference database.
## gene log2FC padj
## 1 247 9.505975 1.253044e-17
## 2 7704 7.352626 2.255365e-40
## 3 101928819 6.327383 7.206312e-19
## 4 3000 5.885114 1.000057e-04
## 5 2847 -5.325904 2.378691e-04
## 6 79689 5.207158 5.055589e-24
With the query mapped to the reference, we can score every reference
signature against it. Here we score the full ranked expression matrix
using the cosine method, which is appropriate for
continuous fold-change data (see Scoring methods available for
alternatives suited to gene-set input).
regulatorResults <- calcEnrichment(
demo_expression_db,
expressionMatrix = final_res,
method = "cosine"
)Results are stored in a SummarizedExperiment-like
PerturbMatch object, with enrichment scores in the
enrichment assay and perturbation experiment details in
rowData. Printing it gives a quick summary of both:
## class: PerturbMatch
## dim: 21 2
## metadata(9): up_regulated down_regulated ... demo source
## assays(1): enrichment
## rownames(21): GSE200312_NR3C1_KD_1 GSE197354_AFF4_KD_1 ...
## GSE202869_ORF9b_OE_1 GSE202869_S_OE_1
## rowData names(14): sig_id pert_symbol ... cell_line_efo_label
## target_gene_percentile
## colnames(2): cosine zscore
## colData names(0):
## NOTE: scored against the bundled DEMONSTRATION database (21 signatures), not a full reference
## up_regulated(250): 247, 7704, 101928819, 3000, 79689 ...
## down_regulated(250): 2847, 148145, 1591, 2903, 26045 ...
## background(5834): 247, 7704, 101928819, 3000, 2847 ...
## parameters: topN = 250, topSig = 250
Perturbation details for each reference signature carry through into
rowData (showing a few key columns here):
## DataFrame with 6 rows and 3 columns
## pert_symbol pert_type cell_line
## <character> <character> <character>
## GSE200312_NR3C1_KD_1 NR3C1 KD Amnion epithelial ce..
## GSE197354_AFF4_KD_1 AFF4 KD mesenchymal stem cells
## GSE125167_ZBTB16_KD_1 ZBTB16 KD Human Mesenchymal St..
## GSE179474_SMAD4_KD_1 SMAD4 KD human dental mesench..
## GSE197118_TAPT1_KO_1 TAPT1 KO fibroblasts
## GSE227512_LINC01638_KD_1 LINC01638 KD hMSC-tert20
The up-/down-regulated and background gene sets actually used for scoring are recorded on the result and can be retrieved with the corresponding accessors.
## [1] "247" "7704" "101928819" "3000" "79689" "101928858"
## [1] "2847" "148145" "1591" "2903" "26045" "6563"
## [1] "247" "7704" "101928819" "3000" "2847" "79689"
View the top hits ranked by absolute score with
topRegulators():
cosine_cols <- c("pert_symbol", "pert_type", "score", "zscore")
topRegulators(regulatorResults, n = 5)[, cosine_cols]## pert_symbol pert_type score zscore
## GSE197354_AFF4_KD_1 AFF4 KD -0.3804310 -2.121121
## GSE125167_ZBTB16_KD_1 ZBTB16 KD -0.2939681 -1.596064
## GSE227512_LINC01638_KD_1 LINC01638 KD 0.2183534 1.515073
## GSE179474_SMAD4_KD_1 SMAD4 KD -0.2108919 -1.091574
## GSE197118_TAPT1_KO_1 TAPT1 KO -0.2088128 -1.078948
The airway data measure the response to dexamethasone, a glucocorticoid that acts directly through the glucocorticoid receptor, NR3C1. NR3C1 is therefore the expected upstream regulator, and the small demo database was selected to include a NR3C1 knockdown signature. We rank the reference signatures by absolute score and look up where NR3C1 is ranked:
ranked <- topRegulators(regulatorResults, n = nrow(regulatorResults))
nr3c1_rank <- match("NR3C1", ranked$pert_symbol)
nr3c1_rank## [1] 6
## pert_symbol pert_type score zscore
## GSE200312_NR3C1_KD_1 NR3C1 KD -0.1875777 -0.9499949
The NR3C1 knockdown ranks near the top (rank 6 of 21). Its
raw score is negative: dexamethasone activates NR3C1
while the reference signature knocks it down, so the two run in
opposite directions. The Visualise the
results section below turns this into an activity-level statement
with inferActivity = TRUE.
A note on
zscoreand reference set size.zscorestandardises each method’s raw score across the reference signatures actually scored in that call, so its scale depends on the size and diversity of that background set. This demo database has only 21 signatures, nowhere near enough for these particular z-scores to be reliable. With the full reference database (thousands of signatures) the same scores become much more informative.
Instead of a ranked matrix, calcEnrichment() also
accepts an explicit query as
upRegulated/downRegulated/background
character vectors of gene IDs. Use this format when you only have a
fixed set of significant genes rather than a full ranked differential
expression result. If you do have an expressionMatrix,
SignedJaccard and GSEA will derive an up/down
gene set from it for you (as
cosine/cosineExtreme always require), so there
is no need to manually build the sets in this case. Here we derive a
significant gene set from the same DESeq2 result used above (adjusted
p-value < 0.05), and score it with SignedJaccard:
sig_genes <- final_res[!is.na(final_res$padj) & final_res$padj < 0.05, ]
up_genes <- sig_genes$gene[sig_genes$log2FC > 0]
down_genes <- sig_genes$gene[sig_genes$log2FC < 0]
background_genes <- final_res$gene
results_signedjaccard <- calcEnrichment(
demo_expression_db,
upRegulated = up_genes,
downRegulated = down_genes,
background = background_genes,
method = "SignedJaccard"
)
result_cols <- c("pert_symbol", "pert_type", "score", "overlap", "zscore")
topRegulators(results_signedjaccard, n = 5)[, result_cols]## pert_symbol pert_type score overlap zscore
## GSE227512_LINC01638_KD_1 LINC01638 KD 0.06209347 357 1.860321
## GSE197354_AFF4_KD_1 AFF4 KD -0.05921616 366 -1.608796
## GSE125167_ZBTB16_KD_1 ZBTB16 KD -0.05416928 380 -1.464469
## GSE194080_TBX5_KD_1 TBX5 KD 0.04537609 336 1.382251
## GSE179474_SMAD4_KD_1 SMAD4 KD -0.04368749 305 -1.164719
plotSimilarity() displays the top positive (mimicking)
and negative (reversing) signatures as a lollipop plot:
The fill colour shows the sign of the similarity score, orange for
signatures that mimic the query (positive score) and blue for
those that reverse it (negative score). The point shape shows
the direction of the underlying perturbation: up-pointing triangles for
gain-of-function overexpression (OE) and down-pointing
triangles for loss-of-function knockdown/knockout
(KD/KO). Keeping these separate lets you read
the perturbation type together with the sign of the score. A
reversing (blue) knockdown (down-triangle) signature,
for instance, implies the query behaves as though that gene’s activity
were increased.
Set inferActivity = TRUE to flip the sign of scores for
knockdown/ knockout perturbations, so the plot reflects inferred gene
activity rather than raw transcriptional similarity. A
knockdown/knockout signature that is anti-correlated with the
query (a negative raw score) implies the query behaves as though that
gene’s activity had increased, the opposite of what a negative
score would mean for an overexpression signature.
For example, AFF4_KD has a raw cosine z-score of about
-2.12, placing it on the “Reverse (Negative)” side of the plot above:
the knockdown signature runs opposite to the dexamethasone response.
Because it is a knockdown, inferActivity = TRUE flips its
sign to about 2.12 below, moving it to the “Mimic (Positive)” side: the
data are consistent with dexamethasone increasing AFF4
activity, since suppressing AFF4 produces roughly the opposite
expression pattern to the treatment.
Before trusting a top hit it is worth looking at the reference
signature itself: did the perturbed gene actually move in the source
data, and does the overall signature look like a real, well-behaved
differential expression result? plotVolcano() draws a
volcano plot (log2 fold change against -log10 FDR) for any
single column of a reference database that stores per-gene fold changes
and p-values (i.e. the limma references and the demo
database used here).
Continuing the dexamethasone example, we plot the NR3C1
knockdown signature and highlight the perturbed gene itself with
selectedPoints. The reference is indexed by Entrez ID, so
we relabel the rows with gene symbols first to make the plot
readable.
nr3c1_signature <- demo_expression_db
rownames(nr3c1_signature) <- rowData(nr3c1_signature)$Symbol
plotVolcano(
nr3c1_signature,
signature = "GSE200312_NR3C1_KD_1",
effectAssay = "log2fc",
pvalueAssay = "padj",
selectedPoints = "NR3C1",
truncateInfPvalue = FALSE
)NR3C1 itself sits well down the left-hand (down-regulated) side of its own knockdown signature, confirming the intended perturbation took effect.
target_gene_percentileThe volcano plot gives the differential expression of the perturbed
gene one signature at a time. colData() records the same
information for every reference signature in a single column,
target_gene_percentile: the percentile position of the
perturbed gene within its own signature, ranked from most down-regulated
(0) to most up-regulated (100).
The direction of the perturbation sets the expectation. A knockout or
knockdown (KO/KD) suppresses its target, so
the perturbed gene should sit near the bottom of its signature and score
a low percentile. An overexpression (OE) drives
its target up, so it should sit near the top and score a high
percentile. A value that contradicts the perturbation type is a
potential warning sign that the signature is mislabelled or that the
perturbation did not take effect.
pct_cols <- c("pert_symbol", "pert_type", "target_gene_percentile")
pct <- as.data.frame(colData(demo_expression_db)[, pct_cols])
pct <- pct[order(pct$target_gene_percentile), ]
pct## pert_symbol pert_type target_gene_percentile
## GSE200312_NR3C1_KD_1 NR3C1 KD 0.3185293
## GSE87831_NUP153_KD_2 NUP153 KD 0.5174644
## GSE163705_IFI16_KD_1 IFI16 KD 0.5358568
## GSE125167_ZBTB16_KD_1 ZBTB16 KD 0.6448374
## GSE194080_TBX5_KD_1 TBX5 KD 0.9338521
## GSE197118_TAPT1_KO_1 TAPT1 KO 1.0705348
## GSE129436_STING1_KO_1 STING1 KO 1.8564296
## GSE179474_SMAD4_KD_1 SMAD4 KD 4.2386244
## GSE197354_AFF4_KD_1 AFF4 KD 7.9325079
## GSE137155_STAT2_KD_1 STAT2 KD 13.9997828
## GSE165963_ESRRG_KO_1 ESRRG KO 16.2846348
## GSE215822_CCDC88A_KO_2 CCDC88A KO 54.1382206
## GSE227512_LINC01638_KD_1 LINC01638 KD 83.7233489
## GSE114284_IRF1_KO_1 IRF1 KO 94.9992435
## GSE213710_FOSL2_OE_1 FOSL2 OE 97.7972816
## GSE263122_VGLL4_OE_1 VGLL4 OE 99.9688807
## GSE165910_STING1_OE_1 STING1 OE 100.0000000
## GSE182754_ICP0_OE_1 ICP0 OE NA
## GSE182754_E4ORF3_OE_1 E4ORF3 OE NA
## GSE202869_ORF9b_OE_1 ORF9b OE NA
## GSE202869_S_OE_1 S OE NA
The NR3C1 knockdown from the volcano plot above illustrates the low end:
## pert_symbol pert_type target_gene_percentile
## GSE200312_NR3C1_KD_1 NR3C1 KD 0.3185293
NR3C1 sits in the bottom 1% of its own knockdown signature
(target_gene_percentile of about 0.3), near the bottom as a
KD should be, and matching where it appeared on the left of
the volcano plot. At the other extreme, the overexpression signatures
(OE) place their targets close to the 100th percentile.
Note the CCDC88A is annotated as a knockout, so its target should sit near the bottom, yet it lands around the 54th percentile, the middle of its own signature:
## pert_symbol pert_type target_gene_percentile
## GSE215822_CCDC88A_KO_2 CCDC88A KO 54.13822
Signatures such as this should be treated with caution as the perturbation may have been weak or incomplete, or the signature may be mislabelled. This is why Verifying hits before interpretation is important before any candidate is followed up.
target_gene_percentile is an ordinary
colData column, so signatures can be dropped with standard
SummarizedExperiment subsetting before scoring. Here we
require knockdowns to place their target in the bottom quarter of their
own signature, leaving the other perturbation types untouched:
target_pct <- colData(demo_expression_db)$target_gene_percentile
pert_types <- colData(demo_expression_db)$pert_type
drop <- pert_types == "KD" & !is.na(target_pct) & target_pct > 25
filtered_db <- demo_expression_db[, !drop]
c(all = ncol(demo_expression_db), filtered = ncol(filtered_db))## all filtered
## 21 20
filtered_db is a reference database and can be passed
straight to calcEnrichment().
Note that a CRISPR knockout (KO) won’t necessarily
reduce the target gene expression (only the target protein), so non-low
percentile is not by itself evidence that the perturbation failed.
Individual top hits are easier to interpret as a group when you ask
what biological processes the mimicking and reversing regulators have in
common. regulatorGSEA() does this by ranking the reference
regulators by their similarity score and performing gene set enrichment
(via fgsea) over Gene Ontology terms.
A regulator gene usually appears as several signatures (different
cell lines, knockdown versus overexpression).
regulatorGSEA() collapses these to one value per gene by
flipping the sign of knockdown/knockout scores so everything is on a
common inferred-activity scale (as in
plotSimilarity(inferActivity = TRUE)), then aggregating
each gene’s signatures (aggregate = "extreme" by default,
keeping the single strongest signature, or "mean" to
average them).
Gene sets are built from org.Hs.eg.db and readable GO
term names are added from GO.db when it is installed.
go <- regulatorGSEA(results_signedjaccard, ontology = "BP", minSize = 1)
head(go[, c("term", "NES", "padj", "size", "leadingEdge")])## term NES padj
## 1 negative regulation of transcription by RNA polymerase II 1.696715 0.7038893
## 2 MAPK cascade 1.121541 0.7038893
## 3 microtubule cytoskeleton organization -1.129590 0.7038893
## 4 ossification 1.272467 0.7038893
## 5 ovarian follicle development 1.121541 0.7038893
## 6 blood vessel development -1.216481 0.7038893
## size leadingEdge
## 1 4 ZBTB16, SMAD4, NR3C1
## 2 1 SMAD4
## 3 1 CCDC88A
## 4 3 ZBTB16, SMAD4, TAPT1
## 5 1 SMAD4
## 6 1 TBX5
plotRegulatorGSEA() draws a dot plot from that table,
placing each term by its NES:
On this 21-signature demo database the enrichment is underpowered and
none of the terms are individually significant, so
pvalueCutoff = 1 is used purely to render an illustrative
plot and minSize is dropped to 1. On a full reference
(thousands of regulators) keep the defaults (minSize = 15,
pvalueCutoff = 0.1), where the enrichment becomes
meaningful and the terms summarise the biology shared by the top
regulators.
| Method | Input Type | Description |
|---|---|---|
| cosine | Continuous | Classic cosine similarity between the full continuous log2FC vectors of the query and reference. |
| cosineExtreme | Rank/set-based | Cosine similarity restricted to the top N most extreme genes of the reference and matching query features. |
| GSEA | Continuous/ranked | Gene Set Enrichment Analysis. It evaluates whether the query gene set or ranked list is enriched at the top or bottom of the reference signature distribution. |
| SignedJaccard | Set-based | Intersection-over-union (Jaccard index) for gene sets, taking into account the direction (sign) of the log2FC regulation. |
The methods above rank the same reference signatures on different
notions of similarity, so they broadly agree but need not identify
identical top hits. Having already scored the airway/dexamethasone query
with cosine and SignedJaccard above, we
complete the comparison by also running GSEA (which, like
SignedJaccard, takes an explicit gene set) and
cosineExtreme (which, like cosine, takes the
ranked matrix):
results_gsea <- calcEnrichment(
demo_expression_db,
upRegulated = up_genes,
downRegulated = down_genes,
background = background_genes,
method = "GSEA"
)
results_cosineextreme <- calcEnrichment(
demo_expression_db,
expressionMatrix = final_res,
method = "cosineExtreme"
)Aligning the four result sets by signature and correlating their
z-scores shows how much they overlap. Methods within the same family,
the continuous cosine/cosineExtreme pair and
the set-based SignedJaccard/GSEA pair, tend to
track each other closely, while agreement across the two
families is typically weaker, because they measure genuinely different
things:
sig_ids <- rownames(regulatorResults)
score_compare <- data.frame(
cosine = regulatorTable(regulatorResults)$zscore,
cosineExtreme = regulatorTable(results_cosineextreme)[sig_ids, "zscore"],
SignedJaccard = regulatorTable(results_signedjaccard)[sig_ids, "zscore"],
GSEA = regulatorTable(results_gsea)[sig_ids, "zscore"]
)
round(cor(score_compare), 2)## cosine cosineExtreme SignedJaccard GSEA
## cosine 1.00 0.99 0.98 0.95
## cosineExtreme 0.99 1.00 0.96 0.93
## SignedJaccard 0.98 0.96 1.00 0.97
## GSEA 0.95 0.93 0.97 1.00
On this small demo database the correlations are good, but on a
full-size reference (thousands of signatures) cosine and
SignedJaccard can produce disconcordant rankings.
cosine asks whether the whole fold-change profile of the
query is aligned with the reference, so every gene contributes in
proportion to its magnitude.
SignedJaccard/GSEA first reduce each signature
to a discrete up/down gene set and then ask how much those sets overlap
in the same direction, so they are driven by which genes cross the
threshold rather than by how large their changes are. A regulator can
therefore rank highly under one family and modestly under the other
depending on whether its signal lives in a broad continuous shift or a
concentrated set of strongly moved genes.
In practice, cosine/cosineExtreme are a
reasonable default when a full ranked signature is available, while
SignedJaccard/GSEA are useful when only a
discrete up/down gene set can be defined, or when a set-based
interpretation (shared genes, rather than correlated magnitudes) is
preferred.
Every perturbMatch result carries at least two columns
beyond the method’s own score: zscore , the number of
standard deviations a signature’s score sits from the mean across the
reference queried (used for ranking with topRegulators()
and for plotSimilarity()) and, for
SignedJaccard, overlap, the number of query
genes that overlapped the reference gene set at all (in either
direction). A large |zscore| backed by a small
overlap is weaker evidence than the same
|zscore| backed by a large one, since it is based on fewer
shared genes:
reliable_hits <- regulatorTable(results_signedjaccard)
reliable_hits <- reliable_hits[reliable_hits$overlap >= 50, ]
reliable_hits <- reliable_hits[order(-abs(reliable_hits$zscore)), ]
head(reliable_hits[, result_cols])## pert_symbol pert_type score overlap zscore
## GSE227512_LINC01638_KD_1 LINC01638 KD 0.06209347 357 1.860321
## GSE197354_AFF4_KD_1 AFF4 KD -0.05921616 366 -1.608796
## GSE125167_ZBTB16_KD_1 ZBTB16 KD -0.05416928 380 -1.464469
## GSE194080_TBX5_KD_1 TBX5 KD 0.04537609 336 1.382251
## GSE179474_SMAD4_KD_1 SMAD4 KD -0.04368749 305 -1.164719
## GSE215822_CCDC88A_KO_2 CCDC88A KO 0.03364153 302 1.046675
SignedJaccard and GSEA need reference gene
sets, not just ranked values, so each reference signature’s continuous
scores are first discretised into up-/down-regulated sets. By default
this is done with topN: the topN highest- and
lowest-ranked reference genes become that signature’s up-/down-regulated
sets.
sj_small <- calcEnrichment(
demo_expression_db, upRegulated = up_genes, downRegulated = down_genes,
background = background_genes, method = "SignedJaccard", topN = 100
)
sj_large <- calcEnrichment(
demo_expression_db, upRegulated = up_genes, downRegulated = down_genes,
background = background_genes, method = "SignedJaccard", topN = 1000
)NR3C1 is the expected regulator of the dexamethasone target, so we find its rank under each setting to see the effect these settings have:
nr3c1_rank_at <- function(res) {
ranked <- topRegulators(res, n = nrow(res))
match("NR3C1", ranked$pert_symbol)
}
c(
topN_100 = nr3c1_rank_at(sj_small),
topN_1000 = nr3c1_rank_at(sj_large)
)## topN_100 topN_1000
## 4 12
The NR3C1 knockdown ranks 4 out of 21 with
topN = 100 but slips to 12 with topN = 1000:
widening the reference gene sets dilutes the overlap that placed it near
the top. Comparing a few topN settings this way is a
reasonable check that a finding is not strongly dependent on the cutoff
chosen.
Scoring the full reference database (thousands of signatures) is
dominated by reading and decompressing parts of the reference database,
and this work parallelises well. calcEnrichment() accepts a
BiocParallel back-end via its BPPARAM
argument. Supplying a multi-core parameter shares the reference
signatures across workers. On a typical multi-core machine this runs
roughly 3 to 4 times faster than the default.
library(BiocParallel)
regulatorResults <- calcEnrichment(
ref_limma,
expressionMatrix = final_res,
method = "cosine"
)Work is split into column wise chunks whose size is controlled by
chunkSize. By default (chunkSize = NULL)
calcEnrichment() picks a chunk size automatically from the
number of reference signatures and the number of workers in
BPPARAM. Because each worker holds its own block of the
reference in memory, more workers means higher peak RAM usage.
The reference database is assembled from public datasets whose metadata (which samples are cases versus controls, and which gene was perturbed) is majority LLM-annotated. This automated curation makes the collection broad and has been benchmarked against hand curated data, but it is not error-free: mislabelled case/control groups or incorrect perturbation targets are likely to occur.
Before interpreting or following up a top hit, manually
verify it. For each candidate regulator, check the original
data source (the accession, control_samples,
and case_samples columns in rowData()) to
confirm that the case/control assignment is correct and that the
annotated perturbed gene is the one actually manipulated.
Cell context matters. Each reference signature
reflects the perturbation of a gene in a particular cell line or cell
type (the cell_line column of rowData()), and
a regulator’s downstream program can differ substantially between
contexts. A genuine driver may therefore score only modestly if its
available reference signatures come from cell types unrelated to your
experiment, while signatures generated in a matching context tend to
rank more strongly. Consider how closely its cell_line
resembles your own system, and potentially give more weight to
regulators supported by several signatures across relevant contexts than
to those resting on a single signature from an unrelated cell type.
Because cell_line is free text taken from the source study,
the same cell line can appear under several spellings. Where an ontology
match was available, the cell_line_efo_id and
cell_line_efo_label columns give a standardised
Experimental Factor Ontology name for it, which is the more reliable
field to group or filter signatures on.
Any of the tables above is an ordinary data.frame, so it
can be written out with write.table() for use in Excel or a
downstream script. To save the ranked regulators as a tab-separated
(TSV) file:
If you use perturbMatch, please cite:
Soul J, Young DA (2026). Automated generation of a gene perturbation transcriptomic atlas using large language models. bioRxiv. doi:10.64898/2026.08.08.743502
This vignette was compiled using:
## R version 4.6.1 (2026-06-24)
## Platform: x86_64-pc-linux-gnu
## Running under: Ubuntu 26.04.1 LTS
##
## Matrix products: default
## BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
## LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so; LAPACK version 3.12.0
##
## locale:
## [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
## [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
## [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
## [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
## [9] LC_ADDRESS=C LC_TELEPHONE=C
## [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
##
## time zone: Etc/UTC
## tzcode source: system (glibc)
##
## attached base packages:
## [1] stats4 stats graphics grDevices utils datasets methods
## [8] base
##
## other attached packages:
## [1] airway_1.33.2 BiocParallel_1.47.0
## [3] scater_1.41.2 ggplot2_4.0.3
## [5] singscore_1.33.0 DESeq2_1.53.6
## [7] scuttle_1.23.2 muscData_1.27.1
## [9] SingleCellExperiment_1.35.2 SummarizedExperiment_1.43.0
## [11] Biobase_2.73.2 GenomicRanges_1.65.4
## [13] Seqinfo_1.3.2 IRanges_2.47.5
## [15] S4Vectors_0.51.10 MatrixGenerics_1.25.0
## [17] matrixStats_1.5.0 ExperimentHub_3.3.2
## [19] AnnotationHub_4.3.2 BiocFileCache_3.3.0
## [21] dbplyr_2.6.0 BiocGenerics_0.59.12
## [23] generics_0.1.4 perturbMatch_0.99.1
## [25] knitr_1.52 BiocStyle_2.41.0
##
## loaded via a namespace (and not attached):
## [1] RColorBrewer_1.1-3 sys_3.4.3
## [3] jsonlite_2.0.0 magrittr_2.0.5
## [5] ggbeeswarm_0.7.3 farver_2.1.2
## [7] rmarkdown_2.32 vctrs_0.7.3
## [9] DelayedMatrixStats_1.35.0 memoise_2.0.1
## [11] htmltools_0.5.9 S4Arrays_1.13.2
## [13] BiocBaseUtils_1.15.1 curl_8.0.0
## [15] BiocNeighbors_2.7.3 Rhdf5lib_2.1.0
## [17] SparseArray_1.13.4 rhdf5_2.57.20
## [19] sass_0.4.10 bslib_0.12.0
## [21] plyr_1.8.9 httr2_1.3.0
## [23] cachem_1.1.0 buildtools_1.0.0
## [25] lifecycle_1.0.5 pkgconfig_2.0.3
## [27] rsvd_1.0.5 Matrix_1.7-6
## [29] R6_2.6.1 fastmap_1.2.0
## [31] digest_0.6.39 AnnotationDbi_1.75.2
## [33] RSpectra_0.16-2 irlba_2.4.1
## [35] RSQLite_3.53.3 org.Hs.eg.db_3.23.1
## [37] beachmat_2.29.3 filelock_1.0.3
## [39] labeling_0.4.3 httr_1.4.9
## [41] abind_1.4-8 compiler_4.6.1
## [43] bit64_4.8.6 withr_3.0.3
## [45] S7_0.2.2 viridis_0.6.5
## [47] DBI_1.3.0 HDF5Array_1.41.3
## [49] rappdirs_0.3.4 DelayedArray_0.39.8
## [51] tools_4.6.1 vipor_0.4.7
## [53] otel_0.2.0 beeswarm_0.4.0
## [55] glue_1.8.1 h5mread_1.5.3
## [57] rhdf5filters_1.25.4 grid_4.6.1
## [59] reshape2_1.4.5 fgsea_1.39.2
## [61] gtable_0.3.6 tidyr_1.3.2
## [63] data.table_1.18.6.1 BiocSingular_1.29.2
## [65] ScaledMatrix_1.21.0 XVector_0.53.0
## [67] RcppAnnoy_0.0.23 ggrepel_0.9.8
## [69] BiocVersion_3.24.0 pillar_1.11.1
## [71] stringr_1.6.0 limma_3.99.1
## [73] dplyr_1.2.1 lattice_0.23-1
## [75] bit_4.6.0 annotate_1.91.0
## [77] tidyselect_1.2.1 GO.db_3.23.1
## [79] locfit_1.5-9.12 maketools_1.3.2
## [81] Biostrings_2.81.9 gridExtra_2.3.1
## [83] edgeR_4.99.6 xfun_0.61
## [85] statmod_1.5.2 stringi_1.8.9
## [87] yaml_2.3.12 evaluate_1.0.5
## [89] codetools_0.2-20 tibble_3.3.1
## [91] BiocManager_1.30.27 graph_1.91.0
## [93] cli_3.6.6 uwot_0.2.5
## [95] xtable_1.8-8 jquerylib_0.1.4
## [97] Rcpp_1.1.2 png_0.1-9
## [99] XML_3.99-0.25 parallel_4.6.1
## [101] blob_1.3.0 sparseMatrixStats_1.25.0
## [103] viridisLite_0.4.3 GSEABase_1.75.0
## [105] scales_1.4.0 purrr_1.2.2
## [107] crayon_1.5.3 rlang_1.3.0
## [109] cowplot_1.2.0 fastmatch_1.1-8
## [111] KEGGREST_1.53.6
This package was developed at the University of Liverpool.