knitr::opts_chunk$set( collapse = TRUE, # comment = "#>", warning = FALSE, message = FALSE ) library(BiocStyle)
In this vignette, you can learn how to perform a MultiNicheNet analysis on data with complex multifactorial experimental designs. More specifically, we will cover in depth how to study differential dynamics of cell-cell communication between conditions. We will assume the following type of design that is quite common: we have 2 or more conditions/groups of interest, and for each condition we also have 2 or more time points (eg before and after a certain treatment). Research questions are: how does cell-cell communication change over time in each condition? And, importantly, how are these time-related changes different between the conditions? Certainly the latter is a non-trivial question. We will here demonstrate how MultiNicheNet can exploit the flexibility of generalized linear models in the pseudobulk-edgeR framework to handle this non-trivial question.
This vignette is quite advanced, so if you are new to MultiNicheNet, we recommend reading and running this vignette: basis_analysis_steps_MISC.knit.md to get acquainted with the methodology and simple applications.
As example expression data of interacting cells for this vignette, we will here use scRNAseq data from breast cancer biopsies of patients receiving anti-PD1 immune-checkpoint blockade therapy. Bassez et al. collected from each patient one tumor biopsy before anti-PD1 therapy (“pre-treatment”) and one during subsequent surgery (“on-treatment”) A single-cell map of intratumoral changes during anti-PD1 treatment of patients with breast cancer. Based on additional scTCR-seq results, they identified one group of patients with clonotype expansion as response to the therapy (“E”) and one group with only limited or no clonotype expansion (“NE”). To make this vignette more easily adaptable to the user, we will rename the groups and time points as follows: E --> group1, NE --> group2, Pre-treatment: timepoint1, On-treatment: timepoint2.
Now, how can we define which CCC patterns are changing during anti-PD1 therapy and which of these changes are specific to E compared to NE patients (and vice versa)? First, we will run a MultiNicheNet, focusing on anti-PD1 therapy related differences in group1. Secondly, we will do the same for group2. Then we will compare the prioritized interactions between both groups in a basic way. Finally, we will run a MultiNicheNet analysis with a complex contrast to focus specifically on group-specific differences in anti-PD1 therapy associated CCC changes.
In this vignette, we will first prepare the common elements of all MultiNicheNet core analyses, then run, interpret and compare the different analyses.
library(SingleCellExperiment) library(dplyr) library(ggplot2) library(nichenetr) library(multinichenetr)
The Nichenet v2 networks and matrices for both mouse and human can be downloaded from Zenodo .
We will read these object in for human because our expression data is of human patients.
Gene names are here made syntactically valid via make.names()
to avoid the loss of genes (eg H2-M3) in downstream visualizations.
organism = "human"
options(timeout = 250) if(organism == "human"){ lr_network_all = readRDS(url( "https://zenodo.org/record/10229222/files/lr_network_human_allInfo_30112033.rds" )) %>% mutate( ligand = convert_alias_to_symbols(ligand, organism = organism), receptor = convert_alias_to_symbols(receptor, organism = organism)) lr_network_all = lr_network_all %>% mutate(ligand = make.names(ligand), receptor = make.names(receptor)) lr_network = lr_network_all %>% distinct(ligand, receptor) ligand_target_matrix = readRDS(url( "https://zenodo.org/record/7074291/files/ligand_target_matrix_nsga2r_final.rds" )) colnames(ligand_target_matrix) = colnames(ligand_target_matrix) %>% convert_alias_to_symbols(organism = organism) %>% make.names() rownames(ligand_target_matrix) = rownames(ligand_target_matrix) %>% convert_alias_to_symbols(organism = organism) %>% make.names() lr_network = lr_network %>% filter(ligand %in% colnames(ligand_target_matrix)) ligand_target_matrix = ligand_target_matrix[, lr_network$ligand %>% unique()] } else if(organism == "mouse"){ lr_network_all = readRDS(url( "https://zenodo.org/record/10229222/files/lr_network_mouse_allInfo_30112033.rds" )) %>% mutate( ligand = convert_alias_to_symbols(ligand, organism = organism), receptor = convert_alias_to_symbols(receptor, organism = organism)) lr_network_all = lr_network_all %>% mutate(ligand = make.names(ligand), receptor = make.names(receptor)) lr_network = lr_network_all %>% distinct(ligand, receptor) ligand_target_matrix = readRDS(url( "https://zenodo.org/record/7074291/files/ligand_target_matrix_nsga2r_final_mouse.rds" )) colnames(ligand_target_matrix) = colnames(ligand_target_matrix) %>% convert_alias_to_symbols(organism = organism) %>% make.names() rownames(ligand_target_matrix) = rownames(ligand_target_matrix) %>% convert_alias_to_symbols(organism = organism) %>% make.names() lr_network = lr_network %>% filter(ligand %in% colnames(ligand_target_matrix)) ligand_target_matrix = ligand_target_matrix[, lr_network$ligand %>% unique()] }
In this vignette, we will load in a subset of the breast cancer scRNAseq data . For the sake of demonstration, this subset only contains 3 cell types.
Because the NicheNet 2.0. networks are in the most recent version of the official gene symbols, we will make sure that the gene symbols used in the expression data are also updated (= converted from their "aliases" to official gene symbols). Afterwards, we will make them again syntactically valid.
sce = readRDS(url( "https://zenodo.org/record/8010790/files/sce_subset_breastcancer.rds" )) sce = alias_to_symbol_SCE(sce, "human") %>% makenames_SCE()
Now we will go further in defining the settings for the MultiNicheNet analysis
In this step, we will formalize our research question into MultiNicheNet input arguments.
In this case study, we want to study differences in therapy-induced cell-cell communication changes (On-vs-Pre therapy) between two patient groups (E vs NE: patients with clonotype expansion versus patients without clonotype expansion). Both therapy-timepoint and patient group are indicated in the following meta data column: expansion_timepoint
, which has 4 different values: PreE, PreNE, OnE, OnNE.
Cell type annotations are indicated in the subType
column, and the sample is indicated by the sample_id
column.
sample_id = "sample_id" group_id = "expansion_timepoint" celltype_id = "subType"
Important: It is required that each sample-id is uniquely assigned to only one condition/group of interest. Therefore, our sample_id
here does not only indicate the patient, but also the timepoint of sampling.
If you would have batch effects or covariates you can correct for, you can define this here as well. However, this is not applicable to this dataset. Therefore we will use the following NA settings:
batches = NA covariates = NA
Important: for batches, there should be at least one sample for every group-batch combination. If one of your groups/conditions lacks a certain level of your batch, you won't be able to correct for the batch effect because the model is then not able to distinguish batch from group/condition effects.
Important: The column names of group, sample, cell type, and batches should be syntactically valid (make.names
)
Important: All group, sample, cell type, and batch names should be syntactically valid as well (make.names
) (eg through SummarizedExperiment::colData(sce)$ShortID = SummarizedExperiment::colData(sce)$ShortID %>% make.names()
)
If you want to focus the analysis on specific cell types (e.g. because you know which cell types reside in the same microenvironments based on spatial data), you can define this here. If you have sufficient computational resources and no specific idea of cell-type colocalzations, we recommend to consider all cell types as potential senders and receivers.
Here we will consider all cell types in the data:
senders_oi = SummarizedExperiment::colData(sce)[,celltype_id] %>% unique() receivers_oi = SummarizedExperiment::colData(sce)[,celltype_id] %>% unique() sce = sce[, SummarizedExperiment::colData(sce)[,celltype_id] %in% c(senders_oi, receivers_oi) ]
In case you would have samples in your data that do not belong to one of the groups/conditions of interest, we recommend removing them and only keeping conditions of interest.
Before we do this, we will here rename our group/timepoint combinations. This is not necessary for you to do this, but then you will need to replace "G1.T1" with the equivalent of group1-timepoint1 in your data. Etc.
SummarizedExperiment::colData(sce)[,group_id][SummarizedExperiment::colData(sce)[,group_id] == "PreE"] = "G1.T1" SummarizedExperiment::colData(sce)[,group_id][SummarizedExperiment::colData(sce)[,group_id] == "PreNE"] = "G2.T1" SummarizedExperiment::colData(sce)[,group_id][SummarizedExperiment::colData(sce)[,group_id] == "OnE"] = "G1.T2" SummarizedExperiment::colData(sce)[,group_id][SummarizedExperiment::colData(sce)[,group_id] == "OnNE"] = "G2.T2"
conditions_keep = c("G1.T1", "G2.T1", "G1.T2", "G2.T2") sce = sce[, SummarizedExperiment::colData(sce)[,group_id] %in% conditions_keep ]
In MultiNicheNet, following steps are executed:
For each of these steps, some parameters need to be defined. This is what we will do now.
Parameters for step 1: Cell-type filtering:
Since MultiNicheNet will infer group differences at the sample level for each cell type (currently via Muscat - pseudobulking + EdgeR), we need to have sufficient cells per sample of a cell type, and this for all groups. In the following analysis we will set this minimum number of cells per cell type per sample at 10. Samples that have less than min_cells
cells will be excluded from the analysis for that specific cell type.
min_cells = 10
Parameters for step 2: Gene filtering
For each cell type, we will consider genes expressed if they are expressed in at least a min_sample_prop
fraction of samples in the condition with the lowest number of samples. By default, we set min_sample_prop = 0.50
.
min_sample_prop = 0.50
But how do we define which genes are expressed in a sample? For this we will consider genes as expressed if they have non-zero expression values in a fraction_cutoff
fraction of cells of that cell type in that sample. By default, we set fraction_cutoff = 0.05
, which means that genes should show non-zero expression values in at least 5% of cells in a sample.
fraction_cutoff = 0.05
Parameters for step 5: Ligand activity prediction
One of the prioritization criteria is the predicted activity of ligands in receiver cell types. Similarly to base NicheNet (https://github.com/saeyslab/nichenetr), we use the DE output to create a "geneset of interest": here we assume that DE genes within a cell type may be DE because of differential cell-cell communication processes. To determine the genesets of interest based on DE output, we need to define which logFC and/or p-value thresholds we will use.
By default, we will apply the p-value cutoff on the normal p-values, and not on the p-values corrected for multiple testing. This choice was made because most multi-sample single-cell transcriptomics datasets have just a few samples per group and we might have a lack of statistical power due to pseudobulking. But, if the smallest group >= 20 samples, we typically recommend using p_val_adj = TRUE. When the biological difference between the conditions is very large, we typically recommend increasing the logFC_threshold and/or using p_val_adj = TRUE.
logFC_threshold = 0.50 p_val_threshold = 0.05
p_val_adj = FALSE
After the ligand activity prediction, we will also infer the predicted target genes of these ligands in each contrast. For this ligand-target inference procedure, we also need to select which top n of the predicted target genes will be considered (here: top 250 targets per ligand). This parameter will not affect the ligand activity predictions. It will only affect ligand-target visualizations and construction of the intercellular regulatory network during the downstream analysis. We recommend users to test other settings in case they would be interested in exploring fewer, but more confident target genes, or vice versa.
top_n_target = 250
The NicheNet ligand activity analysis can be run in parallel for each receiver cell type, by changing the number of cores as defined here. Using more cores will speed up the analysis at the cost of needing more memory. This is only recommended if you have many receiver cell types of interest. You can define here the maximum number of cores you want to be used.
n.cores = 8
Parameters for step 6: Prioritization
We will use the following criteria to prioritize ligand-receptor interactions:
We will combine these prioritization criteria in a single aggregated prioritization score. In the default setting, we will weigh each of these criteria equally (scenario = "regular"
). This setting is strongly recommended. However, we also provide some additional setting to accomodate different biological scenarios. The setting scenario = "lower_DE"
halves the weight for DE criteria and doubles the weight for ligand activity. This is recommended in case your hypothesis is that the differential CCC patterns in your data are less likely to be driven by DE (eg in cases of differential migration into a niche). The setting scenario = "no_frac_LR_expr"
ignores the criterion "Sufficiently high expression levels of ligand and receptor in many samples of the same group". This may be interesting for users that have data with a limited number of samples and don’t want to penalize interactions if they are not sufficiently expressed in some samples.
Here we will choose for the regular setting.
scenario = "regular"
Finally, we still need to make one choice. For NicheNet ligand activity we can choose to prioritize ligands that only induce upregulation of target genes (ligand_activity_down = FALSE
) or can lead potentially lead to both up- and downregulation (ligand_activity_down = TRUE
). The benefit of ligand_activity_down = FALSE
is ease of interpretability: prioritized ligand-receptor pairs will be upregulated in the condition of interest, just like their target genes. ligand_activity_down = TRUE
can be harder to interpret because target genes of some interactions may be upregulated in the other conditions compared to the condition of interest. This is harder to interpret, but may help to pick up interactions that can also repress gene expression.
Here we will choose for setting ligand_activity_down = FALSE
and focus specifically on upregulating ligands.
ligand_activity_down = FALSE
Set the required contrast of interest.
Here, we want to compare on-treatment samples with pre-treatment samples for group E. Therefore we can set this contrast as:
contrasts_oi = c("'G1.T2-G1.T1','G1.T1-G1.T2'")
Very Important Note the format to indicate the contrasts! This formatting should be adhered to very strictly, and white spaces are not allowed! Check ?get_DE_info
for explanation about how to define this well. The most important points are that:
each contrast is surrounded by single quotation marks
contrasts are separated by a comma without any white space
*all contrasts together are surrounded by double quotation marks.
For downstream visualizations and linking contrasts to their main condition, we also need to run the following: This is necessary because we will also calculate cell-type+condition specificity of ligands and receptors.
contrast_tbl = tibble( contrast = c("G1.T2-G1.T1", "G1.T1-G1.T2"), group = c("G1.T2","G1.T1") )
Cell-type filtering: determine which cell types are sufficiently present
abundance_info = get_abundance_info( sce = sce, sample_id = sample_id, group_id = group_id, celltype_id = celltype_id, min_cells = min_cells, senders_oi = senders_oi, receivers_oi = receivers_oi, batches = batches )
abundance_info$abund_plot_sample
Gene filtering: determine which genes are sufficiently expressed in each present cell type
frq_list = get_frac_exprs( sce = sce, sample_id = sample_id, celltype_id = celltype_id, group_id = group_id, batches = batches, min_cells = min_cells, fraction_cutoff = fraction_cutoff, min_sample_prop = min_sample_prop)
Now only keep genes that are expressed by at least one cell type:
genes_oi = frq_list$expressed_df %>% filter(expressed == TRUE) %>% pull(gene) %>% unique() sce = sce[genes_oi, ]
Pseudobulk expression calculation: determine and normalize per-sample pseudobulk expression levels for each expressed gene in each present cell type
abundance_expression_info = process_abundance_expression_info( sce = sce, sample_id = sample_id, group_id = group_id, celltype_id = celltype_id, min_cells = min_cells, senders_oi = senders_oi, receivers_oi = receivers_oi, lr_network = lr_network, batches = batches, frq_list = frq_list, abundance_info = abundance_info)
Differential expression (DE) analysis: determine which genes are differentially expressed
DE_info_group1 = get_DE_info( sce = sce, sample_id = sample_id, group_id = group_id, celltype_id = celltype_id, batches = batches, covariates = covariates, contrasts_oi = contrasts_oi, min_cells = min_cells, expressed_df = frq_list$expressed_df)
Check DE output information in table with logFC and p-values for each gene-celltype-contrast:
DE_info_group1$celltype_de$de_output_tidy %>% head()
Evaluate the distributions of p-values:
DE_info_group1$hist_pvals
These distributions look fine (uniform distribution, except peak at p-value <= 0.05), so we will continue using these regular p-values. In case these p-value distributions look irregular, you can estimate empirical p-values as we will demonstrate in another vignette.
empirical_pval = FALSE
if(empirical_pval == TRUE){ DE_info_emp = get_empirical_pvals(DE_info_group1$celltype_de$de_output_tidy) celltype_de_group1 = DE_info_emp$de_output_tidy_emp %>% select(-p_val, -p_adj) %>% rename(p_val = p_emp, p_adj = p_adj_emp) } else { celltype_de_group1 = DE_info_group1$celltype_de$de_output_tidy }
Combine DE information for ligand-senders and receptors-receivers
sender_receiver_de = combine_sender_receiver_de( sender_de = celltype_de_group1, receiver_de = celltype_de_group1, senders_oi = senders_oi, receivers_oi = receivers_oi, lr_network = lr_network )
sender_receiver_de %>% head(20)
Ligand activity prediction: use the DE analysis output to predict the activity of ligands in receiver cell types and infer their potential target genes
We will first inspect the geneset_oi-vs-background ratios for the default tresholds:
geneset_assessment = contrast_tbl$contrast %>% lapply( process_geneset_data, celltype_de_group1, logFC_threshold, p_val_adj, p_val_threshold ) %>% bind_rows() geneset_assessment
We can see here that for all cell type / contrast combinations, all geneset/background ratio's are within the recommended range (in_range_up
and in_range_down
columns). When these geneset/background ratio's would not be within the recommended ranges, we should interpret ligand activity results for these cell types with more caution, or use different thresholds (for these or all cell types).
ligand_activities_targets_DEgenes = suppressMessages(suppressWarnings( get_ligand_activities_targets_DEgenes( receiver_de = celltype_de_group1, receivers_oi = intersect(receivers_oi, celltype_de_group1$cluster_id %>% unique()), ligand_target_matrix = ligand_target_matrix, logFC_threshold = logFC_threshold, p_val_threshold = p_val_threshold, p_val_adj = p_val_adj, top_n_target = top_n_target, verbose = TRUE, n.cores = n.cores ) ))
Prioritization: rank cell-cell communication patterns through multi-criteria prioritization
sender_receiver_tbl = sender_receiver_de %>% distinct(sender, receiver) metadata_combined = SummarizedExperiment::colData(sce) %>% tibble::as_tibble() if(!is.na(batches)){ grouping_tbl = metadata_combined[,c(sample_id, group_id, batches)] %>% tibble::as_tibble() %>% distinct() colnames(grouping_tbl) = c("sample","group",batches) } else { grouping_tbl = metadata_combined[,c(sample_id, group_id)] %>% tibble::as_tibble() %>% distinct() colnames(grouping_tbl) = c("sample","group") } prioritization_tables = suppressMessages(generate_prioritization_tables( sender_receiver_info = abundance_expression_info$sender_receiver_info, sender_receiver_de = sender_receiver_de, ligand_activities_targets_DEgenes = ligand_activities_targets_DEgenes, contrast_tbl = contrast_tbl, sender_receiver_tbl = sender_receiver_tbl, grouping_tbl = grouping_tbl, scenario = "regular", # all prioritization criteria will be weighted equally fraction_cutoff = fraction_cutoff, abundance_data_receiver = abundance_expression_info$abundance_data_receiver, abundance_data_sender = abundance_expression_info$abundance_data_sender, ligand_activity_down = ligand_activity_down ))
Compile the MultiNicheNet output object
multinichenet_output_group1_t2vst1 = list( celltype_info = abundance_expression_info$celltype_info, celltype_de = celltype_de_group1, sender_receiver_info = abundance_expression_info$sender_receiver_info, sender_receiver_de = sender_receiver_de, ligand_activities_targets_DEgenes = ligand_activities_targets_DEgenes, prioritization_tables = prioritization_tables, grouping_tbl = grouping_tbl, lr_target_prior_cor = tibble() ) multinichenet_output_group1_t2vst1 = make_lite_output(multinichenet_output_group1_t2vst1)
Summarizing ChordDiagram circos plots
In a first instance, we will look at the broad overview of prioritized interactions via condition-specific Chordiagram circos plots.
We will look here at the top 50 predictions across all contrasts, senders, and receivers of interest.
prioritized_tbl_oi_all = get_top_n_lr_pairs( multinichenet_output_group1_t2vst1$prioritization_tables, top_n = 50, rank_per_group = FALSE )
prioritized_tbl_oi = multinichenet_output_group1_t2vst1$prioritization_tables$group_prioritization_tbl %>% filter(id %in% prioritized_tbl_oi_all$id) %>% distinct(id, sender, receiver, ligand, receptor, group) %>% left_join(prioritized_tbl_oi_all) prioritized_tbl_oi$prioritization_score[is.na(prioritized_tbl_oi$prioritization_score)] = 0 senders_receivers = union(prioritized_tbl_oi$sender %>% unique(), prioritized_tbl_oi$receiver %>% unique()) %>% sort() colors_sender = RColorBrewer::brewer.pal(n = length(senders_receivers), name = 'Spectral') %>% magrittr::set_names(senders_receivers) colors_receiver = RColorBrewer::brewer.pal(n = length(senders_receivers), name = 'Spectral') %>% magrittr::set_names(senders_receivers) circos_list = make_circos_group_comparison(prioritized_tbl_oi, colors_sender, colors_receiver)
We observe more interactions that increase during therapy ("G1.T2" condition) compared to interactions that decrease ("G1.T1" condition)
Interpretable bubble plots
Whereas these ChordDiagrams show the most specific interactions per group, they don't give insights into the data behind these predictions. Therefore we will now look at visualizations that indicate the different prioritization criteria used in MultiNicheNet.
In the next type of plots, we will 1) visualize the per-sample scaled product of normalized ligand and receptor pseudobulk expression, 2) visualize the scaled ligand activities, and 3) cell-type specificity.
We will now check the top interactions specific for the OnE group (G1.T2) - so these are the interactions that increase during anti-PD1 therapy.
group_oi = "G1.T2"
prioritized_tbl_oi_50 = get_top_n_lr_pairs( multinichenet_output_group1_t2vst1$prioritization_tables, top_n = 50, rank_per_group = FALSE) %>% filter(group == group_oi)
prioritized_tbl_oi_50_omnipath = prioritized_tbl_oi_50 %>% inner_join(lr_network_all)
Now we add this to the bubble plot visualization:
plot_oi = make_sample_lr_prod_activity_plots_Omnipath( multinichenet_output_group1_t2vst1$prioritization_tables, prioritized_tbl_oi_50_omnipath) plot_oi
Interestingly, we can already see that some of these interactions are only increasing in the E group, whereas others change in the NE group as well!
In practice, we always recommend generating these plots for all conditions/contrasts. To avoid that this vignette becomes too long, we will not do this here.
Now let's do the same analysis for the NE group instead
Set the required contrast of interest.
Here, we want to compare on-treatment samples with pre-treatment samples for group NE. Therefore we can set this contrast as:
contrasts_oi = c("'G2.T2-G2.T1','G2.T1-G2.T2'")
contrast_tbl = tibble( contrast = c("G2.T2-G2.T1", "G2.T1-G2.T2"), group = c("G2.T2","G2.T1") )
In this case, the first 3 steps (cell-type filtering, gene filtering, and pseudobulk expression calculation) were run for the entire dataset and are exactly the same now as for the first MultiNicheNet analysis. Therefore, we will not redo these steps, and just start with step 4, the first unique step for this second analysis.
Differential expression (DE) analysis: determine which genes are differentially expressed
DE_info_group2 = get_DE_info( sce = sce, sample_id = sample_id, group_id = group_id, celltype_id = celltype_id, batches = batches, covariates = covariates, contrasts_oi = contrasts_oi, min_cells = min_cells, expressed_df = frq_list$expressed_df)
Check DE output information in table with logFC and p-values for each gene-celltype-contrast:
DE_info_group2$celltype_de$de_output_tidy %>% head()
Evaluate the distributions of p-values:
DE_info_group2$hist_pvals
These distributions look fine (uniform distribution, except peak at p-value <= 0.05), so we will continue using these regular p-values. In case these p-value distributions look irregular, you can estimate empirical p-values as we will demonstrate in another vignette.
empirical_pval = FALSE
if(empirical_pval == TRUE){ DE_info_emp = get_empirical_pvals(DE_info_group2$celltype_de$de_output_tidy) celltype_de_group2 = DE_info_emp$de_output_tidy_emp %>% select(-p_val, -p_adj) %>% rename(p_val = p_emp, p_adj = p_adj_emp) } else { celltype_de_group2 = DE_info_group2$celltype_de$de_output_tidy }
Combine DE information for ligand-senders and receptors-receivers
sender_receiver_de = combine_sender_receiver_de( sender_de = celltype_de_group2, receiver_de = celltype_de_group2, senders_oi = senders_oi, receivers_oi = receivers_oi, lr_network = lr_network )
sender_receiver_de %>% head(20)
Ligand activity prediction: use the DE analysis output to predict the activity of ligands in receiver cell types and infer their potential target genes
We will first inspect the geneset_oi-vs-background ratios for the default tresholds:
geneset_assessment = contrast_tbl$contrast %>% lapply( process_geneset_data, celltype_de_group2, logFC_threshold, p_val_adj, p_val_threshold ) %>% bind_rows() geneset_assessment
We can see here that for all cell type / contrast combinations, all geneset/background ratio's are within the recommended range (in_range_up
and in_range_down
columns). When these geneset/background ratio's would not be within the recommended ranges, we should interpret ligand activity results for these cell types with more caution, or use different thresholds (for these or all cell types).
ligand_activities_targets_DEgenes = suppressMessages(suppressWarnings( get_ligand_activities_targets_DEgenes( receiver_de = celltype_de_group2, receivers_oi = intersect(receivers_oi, celltype_de_group2$cluster_id %>% unique()), ligand_target_matrix = ligand_target_matrix, logFC_threshold = logFC_threshold, p_val_threshold = p_val_threshold, p_val_adj = p_val_adj, top_n_target = top_n_target, verbose = TRUE, n.cores = n.cores ) ))
Prioritization: rank cell-cell communication patterns through multi-criteria prioritization
sender_receiver_tbl = sender_receiver_de %>% distinct(sender, receiver) metadata_combined = SummarizedExperiment::colData(sce) %>% tibble::as_tibble() if(!is.na(batches)){ grouping_tbl = metadata_combined[,c(sample_id, group_id, batches)] %>% tibble::as_tibble() %>% distinct() colnames(grouping_tbl) = c("sample","group",batches) } else { grouping_tbl = metadata_combined[,c(sample_id, group_id)] %>% tibble::as_tibble() %>% distinct() colnames(grouping_tbl) = c("sample","group") } prioritization_tables = suppressMessages(generate_prioritization_tables( sender_receiver_info = abundance_expression_info$sender_receiver_info, sender_receiver_de = sender_receiver_de, ligand_activities_targets_DEgenes = ligand_activities_targets_DEgenes, contrast_tbl = contrast_tbl, sender_receiver_tbl = sender_receiver_tbl, grouping_tbl = grouping_tbl, scenario = "regular", # all prioritization criteria will be weighted equally fraction_cutoff = fraction_cutoff, abundance_data_receiver = abundance_expression_info$abundance_data_receiver, abundance_data_sender = abundance_expression_info$abundance_data_sender, ligand_activity_down = ligand_activity_down ))
Compile the MultiNicheNet output object
multinichenet_output_group2_t2vst1 = list( celltype_info = abundance_expression_info$celltype_info, celltype_de = celltype_de_group2, sender_receiver_info = abundance_expression_info$sender_receiver_info, sender_receiver_de = sender_receiver_de, ligand_activities_targets_DEgenes = ligand_activities_targets_DEgenes, prioritization_tables = prioritization_tables, grouping_tbl = grouping_tbl, lr_target_prior_cor = tibble() ) multinichenet_output_group2_t2vst1 = make_lite_output(multinichenet_output_group2_t2vst1)
Summarizing ChordDiagram circos plots
We will look here at the top 50 predictions across all contrasts, senders, and receivers of interest.
prioritized_tbl_oi_all = get_top_n_lr_pairs( multinichenet_output_group2_t2vst1$prioritization_tables, top_n = 50, rank_per_group = FALSE )
prioritized_tbl_oi = multinichenet_output_group2_t2vst1$prioritization_tables$group_prioritization_tbl %>% filter(id %in% prioritized_tbl_oi_all$id) %>% distinct(id, sender, receiver, ligand, receptor, group) %>% left_join(prioritized_tbl_oi_all) prioritized_tbl_oi$prioritization_score[is.na(prioritized_tbl_oi$prioritization_score)] = 0 senders_receivers = union(prioritized_tbl_oi$sender %>% unique(), prioritized_tbl_oi$receiver %>% unique()) %>% sort() colors_sender = RColorBrewer::brewer.pal(n = length(senders_receivers), name = 'Spectral') %>% magrittr::set_names(senders_receivers) colors_receiver = RColorBrewer::brewer.pal(n = length(senders_receivers), name = 'Spectral') %>% magrittr::set_names(senders_receivers) circos_list = make_circos_group_comparison(prioritized_tbl_oi, colors_sender, colors_receiver)
Also in this group of patients we see rather an increase on therapy compared to pre-therapy.
Interpretable bubble plots
We will now check the top interactions specific for the OnE group - so these are the interactions that increase during anti-PD1 therapy.
group_oi = "G2.T2"
prioritized_tbl_oi_50 = get_top_n_lr_pairs( multinichenet_output_group2_t2vst1$prioritization_tables, top_n = 50, rank_per_group = FALSE) %>% filter(group == group_oi)
prioritized_tbl_oi_50_omnipath = prioritized_tbl_oi_50 %>% inner_join(lr_network_all)
Now we add this to the bubble plot visualization:
plot_oi = make_sample_lr_prod_activity_plots_Omnipath( multinichenet_output_group2_t2vst1$prioritization_tables, prioritized_tbl_oi_50_omnipath) plot_oi
So interesting observation: even though the NE group is characterized by a lack of T cell clonotype expansion after therapy, there are still clear therapy-induced differences on gene expression and cell-cell communication.
Interestingly, we can see here as well that some interactions are only increasing in the NE group, whereas others change in the E group as well!
This brings us to the next research question: do some interactions increase/decrease more in the E group vs the NE group or vice versa?
To address this question, we will first do a simple analysis. We will just compare the prioritization scores between the E and the NE group, to find CCC patterns that are most different between the E and NE group.
Set up the comparisons
comparison_table = multinichenet_output_group1_t2vst1$prioritization_tables$group_prioritization_tbl %>% select(group, id, prioritization_score) %>% rename(prioritization_score_group1 = prioritization_score) %>% mutate(group = case_when( group == "G1.T1" ~ "timepoint1", group == "G1.T2" ~ "timepoint2", TRUE ~ group) ) %>% inner_join( multinichenet_output_group2_t2vst1$prioritization_tables$group_prioritization_tbl %>% select(group, id, prioritization_score) %>% rename(prioritization_score_group2 = prioritization_score) %>% mutate(group = case_when( group == "G2.T1" ~ "timepoint1", group == "G2.T2" ~ "timepoint2", TRUE ~ group) ) ) %>% mutate(difference_group1_vs_group2 = prioritization_score_group1 - prioritization_score_group2)
Let's now inspect some of the top interactions that got much higher scores in the E group
comparison_table %>% arrange(-difference_group1_vs_group2) %>% filter(prioritization_score_group1 > 0.80)
Let's now inspect some of the top interactions that got much higher scores in the NE group
comparison_table %>% arrange(difference_group1_vs_group2) %>% filter(prioritization_score_group2 > 0.80)
Visualize now interactions that are in the top250 interactions for the contrast On-vs-Pre in the E group.
E-group specific
Of these, we will first zoom into the top25 interactions with biggest difference in the E-vs-NE group.
group_oi = "G1.T2"
prioritized_tbl_oi_250 = get_top_n_lr_pairs( multinichenet_output_group1_t2vst1$prioritization_tables, top_n = 250, rank_per_group = FALSE) %>% filter(group == group_oi) %>% inner_join(lr_network_all)
ids_group1_vs_group2 = comparison_table %>% filter(group == "timepoint2" & id %in% prioritized_tbl_oi_250$id) %>% top_n(25, difference_group1_vs_group2) %>% filter(difference_group1_vs_group2 > 0) %>% pull(id)
prioritized_tbl_oi_250_unique_E = prioritized_tbl_oi_250 %>% filter(id %in% ids_group1_vs_group2)
plot_oi = make_sample_lr_prod_activity_plots_Omnipath( multinichenet_output_group1_t2vst1$prioritization_tables, prioritized_tbl_oi_250_unique_E) plot_oi
shared between E-group and NE-group
Now we will look at the interactions with a very similar score between E and NE. So these interactions are interactions that increase after therapy, but shared between group E and NE.
ids_E_shared_NE = comparison_table %>% filter(group == "timepoint2" & id %in% prioritized_tbl_oi_250$id) %>% mutate(deviation_from_0 = abs(0-difference_group1_vs_group2)) %>% top_n(25, -deviation_from_0) %>% arrange(deviation_from_0) %>% pull(id)
prioritized_tbl_oi_250_shared_E_NE = prioritized_tbl_oi_250 %>% filter(id %in% ids_E_shared_NE)
plot_oi = make_sample_lr_prod_activity_plots_Omnipath( multinichenet_output_group1_t2vst1$prioritization_tables, prioritized_tbl_oi_250_shared_E_NE) plot_oi
Visualize now interactions that are in the top250 interactions for the contrast On-vs-Pre in the E group. We will focus on decreasing interactions here.
E-group specific
Of these, we will first zoom into the top10 interactions with biggest difference in the E-vs-NE group.
group_oi = "G1.T1"
prioritized_tbl_oi_250 = get_top_n_lr_pairs( multinichenet_output_group1_t2vst1$prioritization_tables, top_n = 250, rank_per_group = FALSE) %>% filter(group == group_oi) %>% inner_join(lr_network_all)
ids_group1_vs_group2 = comparison_table %>% filter(group == "timepoint1" & id %in% prioritized_tbl_oi_250$id) %>% top_n(10, difference_group1_vs_group2) %>% filter(difference_group1_vs_group2 > 0) %>% pull(id)
prioritized_tbl_oi_250_unique_E = prioritized_tbl_oi_250 %>% filter(id %in% ids_group1_vs_group2)
plot_oi = make_sample_lr_prod_activity_plots_Omnipath( multinichenet_output_group1_t2vst1$prioritization_tables, prioritized_tbl_oi_250_unique_E) plot_oi
shared between E-group and NE-group
Now we will look at the interactions with a very similar score between E and NE. So these interactions are interactions that decrease after therapy, but shared between group E and NE.
ids_E_shared_NE = comparison_table %>% filter(group == "timepoint1" & id %in% prioritized_tbl_oi_250$id) %>% mutate(deviation_from_0 = abs(0-difference_group1_vs_group2)) %>% top_n(10, -deviation_from_0) %>% arrange(deviation_from_0) %>% pull(id)
prioritized_tbl_oi_250_shared_E_NE = prioritized_tbl_oi_250 %>% filter(id %in% ids_E_shared_NE)
plot_oi = make_sample_lr_prod_activity_plots_Omnipath( multinichenet_output_group1_t2vst1$prioritization_tables, prioritized_tbl_oi_250_shared_E_NE) plot_oi
Visualize now interactions that are in the top250 interactions for the contrast On-vs-Pre in the E group.
To avoid making this vignette too long, we will not explicitly show this for the NE group since the code is the same as for the E group, you only need to change the group_oi
to "G2.T2"
.
Whereas these comparisons are quite intuitive, they are also relatively arbitrary (requiring cutoffs to compare interactions) and suboptimal. They are suboptimal because the prioritization scores of interactions are relative versus the other interactions within a group. If there is a difference in effect size of the therapy-induced changes in CCC, comparing the final prioritization scores may not be optimal.
Therefore, we will now discuss another way to infer group-specific therapy-associated CCC patterns: namely setting up complex multifactorial contrasts in the DE analysis of a MultiNicheNet analysis.
Set the required contrasts
For this analysis, we want to compare how cell-cell communication changes On-vs-Pre anti-PD1 therapy are different between responder/expander patients vs non-responder/expander patients. In other words, we want to study how both patient groups react differently to the therapy.
To perform this comparison, we need to set the following contrasts:
contrasts_oi = c("'(G1.T2-G1.T1)-(G2.T2-G2.T1)','(G2.T2-G2.T1)-(G1.T2-G1.T1)','(G1.T1-G1.T2)-(G2.T1-G2.T2)','(G2.T1-G2.T2)-(G1.T1-G1.T2)'") contrast_tbl = tibble(contrast = c("(G1.T2-G1.T1)-(G2.T2-G2.T1)", "(G2.T2-G2.T1)-(G1.T2-G1.T1)", "(G1.T1-G1.T2)-(G2.T1-G2.T2)", "(G2.T1-G2.T2)-(G1.T1-G1.T2)"), group = c("G1.T2","G2.T2","G1.T1","G2.T1"))
To understand this, let's take a look at the first contrasts of interest: (G1.T2-G1.T1)-(G2.T2-G2.T1)
. As you can see, the first part of the expression: (G1.T2-G1.T1)
will cover differences on-vs-pre therapy in group1, the second part (G2.T2-G2.T1)
in the NE group. By adding the minus sign, we can compare these differences between the E and NE group.
In this case, the first 3 steps (cell-type filtering, gene filtering, and pseudobulk expression calculation) were run for the entire dataset and are exactly the same now as for the first MultiNicheNet analysis. Therefore, we will not redo these steps, and just start with step 4, the first unique step for this second analysis.
Differential expression (DE) analysis: determine which genes are differentially expressed
DE_info = get_DE_info( sce = sce, sample_id = sample_id, group_id = group_id, celltype_id = celltype_id, batches = batches, covariates = covariates, contrasts_oi = contrasts_oi, min_cells = min_cells, expressed_df = frq_list$expressed_df)
Check DE output information in table with logFC and p-values for each gene-celltype-contrast:
DE_info$celltype_de$de_output_tidy %>% head()
Evaluate the distributions of p-values:
DE_info$hist_pvals
These distributions do not look fine. We expect a uniform distribution, or a uniform distribution with a peak at p-value <= 0.05. Irregularities in the p-value distribution might point to issues in the DE model definition. For example in case we did not add all important covariates, or if there is substructure present in the groups, etc. In such cases, we can use the empiricall null procedure from Efron. This is a procedure that will define empirical p-values based on the observed distribution of the test statistic (here: logFC) and not based on the theoretical distribution. This approach has also been used in the Saturn package: https://github.com/statOmics/satuRn. We only recommend this if the p-value distributions point to possible issues, which is the case here.
To continue with the empirical p-values:
empirical_pval = TRUE
if(empirical_pval == TRUE){ DE_info_emp = get_empirical_pvals(DE_info$celltype_de$de_output_tidy) celltype_de = DE_info_emp$de_output_tidy_emp %>% select(-p_val, -p_adj) %>% rename(p_val = p_emp, p_adj = p_adj_emp) } else { celltype_de = DE_info$celltype_de$de_output_tidy }
Check empirical p-value distributions:
DE_info_emp$hist_pvals_emp
These look already better now.
The following plots show how well the correction worked. The green fitted curve should fit well with the histogram. If not, this might point to some issues in the DE model definition.
DE_info_emp$z_distr_plots_emp_pval
In general, these plots look OK. Therefore we will continue with these p-values.
Ligand activity prediction: use the DE analysis output to predict the activity of ligands in receiver cell types and infer their potential target genes
Here we get to the most complex part of this vignette.
Let's look at the first contrasts of interest: (G1.T2-G1.T1)-(G2.T2-G2.T1)
. A positive logFC value here means that the timepoint2-timepoint1 difference in group1 is bigger than in group2. This can be because the increase after therapy (resulting in higher G1.T2 values) is higher in group1 than in the group2. This is the type of trend we are looking for. However, a positive logFC will also be returned in case nothing happens in group1, and there is a therapy-associated decrease in group2. Or when the therapy-associated decrease in group2 is bigger than in group1. This latter examples are trends whe are not looking for.
If we would just filter genes based on logFC to get a geneset of interest, we will confound this geneset with different patterns with different biological meanings.
To recap: we are interested in: ligand-receptor interactions that increase after therapy more strongly in group1 vs group2 target genes that follow the trend of their upstream ligand-receptor interactions and thus also increase after therapy more strongly in group1 vs group2.
How to get to these genesets:
1) Change the celltype_de
table of DE results:
for the contrast (G1.T2-G1.T1)-(G2.T2-G2.T1): give all genes with a pos logFC value a value of 0 if their logFC in the timepoint2-timepoint1 comparison (kept in celltype_de_group1
) is smaller than (logFC_threshold
). Give all genes with a negative logFC a value of 0. As a result, the geneset for ligand activity analysis will only contain genes that are increasing after therapy, and this increase in stronger in group1 than group2.
for the contrast (G2.T2-G2.T1)-(G1.T2-G1.T1): give all genes with a pos logFC value a value of 0 if their logFC in the timepoint2-timepoint1 comparison (kept in celltype_de_group2
) is smaller than (logFC_threshold
). Give all genes with a negative logFC a value of 0. As a result, the geneset for ligand activity analysis will only contain genes that are increasing after therapy, and this increase in stronger in group2 than group1.
for the contrast (G1.T1-G1.T2)-(G2.T1-G2.T2): give all genes with a pos logFC value a value of 0 if their logFC in the timepoint1-timepoint2 comparison (kept in celltype_de_group1
) is smaller than (logFC_threshold
). Give all genes with a negative logFC a value of 0. As a result, the geneset for ligand activity analysis will only contain genes that are decreasing after therapy, and this decrease in stronger in group1 than group1.
for the contrast (G2.T1-G2.T2)-(G1.T1-G1.T2): give all genes with a pos logFC value a value of 0 if their logFC in the timepoint1-timepoint2 comparison (kept in celltype_de_group2
) is smaller than (logFC_threshold
). Give all genes with a negative logFC a value of 0. As a result, the geneset for ligand activity analysis will only contain genes that are decreasing after therapy, and this decrease in stronger in group2 than group1.
Because we want to keep the same patterns for the ligands/receptors: we will do the same but in more permissive setting: just make sure the On/timepoint2-vs-Pre/timepoint1 difference is in the expected direction without needing to be significant.
Gene-celltype table with genes that will keep their pos logFC value for contrast (G1.T2-G1.T1)-(G2.T2-G2.T1) + modified celltype_de table
gene_celltype_tbl_increase_group1_permissive = celltype_de_group1 %>% filter(contrast == "G1.T2-G1.T1") %>% mutate(logFC_multiplier = ifelse(logFC > 0, 1, 0)) %>% distinct(gene, cluster_id, logFC_multiplier) gene_celltype_tbl_increase_group1_stringent = celltype_de_group1 %>% filter(contrast == "G1.T2-G1.T1") %>% mutate(logFC_multiplier = ifelse(logFC >= logFC_threshold & p_val <= p_val_threshold, 1, 0)) %>% distinct(gene, cluster_id, logFC_multiplier)
celltype_de_increase_group1_permissive = celltype_de %>% filter(contrast == "(G1.T2-G1.T1)-(G2.T2-G2.T1)") %>% inner_join(gene_celltype_tbl_increase_group1_permissive) celltype_de_increase_group1_permissive = bind_rows( celltype_de_increase_group1_permissive %>% filter(logFC > 0) %>% mutate(logFC = logFC*logFC_multiplier), celltype_de_increase_group1_permissive %>% filter(logFC <= 0) %>% mutate(logFC = logFC*0) ) %>% arrange(-logFC) celltype_de_increase_group1_stringent = celltype_de %>% filter(contrast == "(G1.T2-G1.T1)-(G2.T2-G2.T1)") %>% inner_join(gene_celltype_tbl_increase_group1_stringent) celltype_de_increase_group1_stringent = bind_rows( celltype_de_increase_group1_stringent %>% filter(logFC > 0) %>% mutate(logFC = logFC*logFC_multiplier), celltype_de_increase_group1_stringent %>% filter(logFC <= 0) %>% mutate(logFC = logFC*0) ) %>% arrange(-logFC) celltype_de_increase_group1_stringent %>% head()
Gene-celltype table with genes that will keep their pos logFC value for contrast (G2.T2-G2.T1)-(G1.T2-G1.T1):
gene_celltype_tbl_increase_group2_permissive = celltype_de_group2 %>% filter(contrast == "G2.T2-G2.T1") %>% mutate(logFC_multiplier = ifelse(logFC > 0, 1, 0)) %>% distinct(gene, cluster_id, logFC_multiplier) gene_celltype_tbl_increase_group2_stringent = celltype_de_group2 %>% filter(contrast == "G2.T2-G2.T1") %>% mutate(logFC_multiplier = ifelse(logFC >= logFC_threshold & p_val <= p_val_threshold, 1, 0)) %>% distinct(gene, cluster_id, logFC_multiplier)
celltype_de_increase_group2_permissive = celltype_de %>% filter(contrast == "(G2.T2-G2.T1)-(G1.T2-G1.T1)") %>% inner_join(gene_celltype_tbl_increase_group2_permissive) celltype_de_increase_group2_permissive = bind_rows( celltype_de_increase_group2_permissive %>% filter(logFC > 0) %>% mutate(logFC = logFC*logFC_multiplier), celltype_de_increase_group2_permissive %>% filter(logFC <= 0) %>% mutate(logFC = logFC*0) ) %>% arrange(-logFC) celltype_de_increase_group2_stringent = celltype_de %>% filter(contrast == "(G2.T2-G2.T1)-(G1.T2-G1.T1)") %>% inner_join(gene_celltype_tbl_increase_group2_stringent) celltype_de_increase_group2_stringent = bind_rows( celltype_de_increase_group2_stringent %>% filter(logFC > 0) %>% mutate(logFC = logFC*logFC_multiplier), celltype_de_increase_group2_stringent %>% filter(logFC <= 0) %>% mutate(logFC = logFC*0) ) %>% arrange(-logFC) celltype_de_increase_group2_stringent %>% head()
Gene-celltype table with genes that will keep their pos logFC value for contrast (G1.T1-G1.T2)-(G2.T1-G2.T2):
gene_celltype_tbl_decrease_group1_permissive = celltype_de_group1 %>% filter(contrast == "G1.T1-G1.T2") %>% mutate(logFC_multiplier = ifelse(logFC > 0, 1, 0)) %>% distinct(gene, cluster_id, logFC_multiplier) gene_celltype_tbl_decrease_group1_stringent = celltype_de_group1 %>% filter(contrast == "G1.T1-G1.T2") %>% mutate(logFC_multiplier = ifelse(logFC >= logFC_threshold & p_val <= p_val_threshold, 1, 0)) %>% distinct(gene, cluster_id, logFC_multiplier)
celltype_de_decrease_group1_permissive = celltype_de %>% filter(contrast == "(G1.T1-G1.T2)-(G2.T1-G2.T2)") %>% inner_join(gene_celltype_tbl_decrease_group1_permissive) celltype_de_decrease_group1_permissive = bind_rows( celltype_de_decrease_group1_permissive %>% filter(logFC > 0) %>% mutate(logFC = logFC*logFC_multiplier), celltype_de_decrease_group1_permissive %>% filter(logFC <= 0) %>% mutate(logFC = logFC*0) ) %>% arrange(-logFC) celltype_de_decrease_group1_stringent = celltype_de %>% filter(contrast == "(G1.T1-G1.T2)-(G2.T1-G2.T2)") %>% inner_join(gene_celltype_tbl_decrease_group1_stringent) celltype_de_decrease_group1_stringent = bind_rows( celltype_de_decrease_group1_stringent %>% filter(logFC > 0) %>% mutate(logFC = logFC*logFC_multiplier), celltype_de_decrease_group1_stringent %>% filter(logFC <= 0) %>% mutate(logFC = logFC*0) ) %>% arrange(-logFC) celltype_de_decrease_group1_stringent %>% head()
Gene-celltype table with genes that will keep their pos logFC value for contrast (G2.T1-G2.T2)-(G1.T1-G1.T2):
gene_celltype_tbl_decrease_group2_permissive = celltype_de_group2 %>% filter(contrast == "G2.T1-G2.T2") %>% mutate(logFC_multiplier = ifelse(logFC > 0, 1, 0)) %>% distinct(gene, cluster_id, logFC_multiplier) gene_celltype_tbl_decrease_group2_stringent = celltype_de_group2 %>% filter(contrast == "G2.T1-G2.T2") %>% mutate(logFC_multiplier = ifelse(logFC >= logFC_threshold & p_val <= p_val_threshold, 1, 0)) %>% distinct(gene, cluster_id, logFC_multiplier)
celltype_de_decrease_group2_permissive = celltype_de %>% filter(contrast == "(G2.T1-G2.T2)-(G1.T1-G1.T2)") %>% inner_join(gene_celltype_tbl_decrease_group2_permissive) celltype_de_decrease_group2_permissive = bind_rows( celltype_de_decrease_group2_permissive %>% filter(logFC > 0) %>% mutate(logFC = logFC*logFC_multiplier), celltype_de_decrease_group2_permissive %>% filter(logFC <= 0) %>% mutate(logFC = logFC*0) ) %>% arrange(-logFC) celltype_de_decrease_group2_stringent = celltype_de %>% filter(contrast == "(G2.T1-G2.T2)-(G1.T1-G1.T2)") %>% inner_join(gene_celltype_tbl_decrease_group2_stringent) celltype_de_decrease_group2_stringent = bind_rows( celltype_de_decrease_group2_stringent %>% filter(logFC > 0) %>% mutate(logFC = logFC*logFC_multiplier), celltype_de_decrease_group2_stringent %>% filter(logFC <= 0) %>% mutate(logFC = logFC*0) ) %>% arrange(-logFC) celltype_de_decrease_group2_stringent %>% head()
So let's now make the new cellype_de
tables:
The permissive table that will be used for the prioritization of ligand-receptor pairs based on DE values:
celltype_de_LR = list( celltype_de_increase_group1_permissive, celltype_de_increase_group2_permissive, celltype_de_decrease_group1_permissive, celltype_de_decrease_group2_permissive) %>% bind_rows()
The stringent table that will be used for the creation of genesets for the ligand activity analysis:
celltype_de_genesets = list( celltype_de_increase_group1_stringent, celltype_de_increase_group2_stringent, celltype_de_decrease_group1_stringent, celltype_de_decrease_group2_stringent) %>% bind_rows()
We will first inspect the geneset_oi-vs-background ratios for the default tresholds:
geneset_assessment = contrast_tbl$contrast %>% lapply( process_geneset_data, celltype_de_genesets, logFC_threshold, p_val_adj, p_val_threshold ) %>% bind_rows() geneset_assessment
We can see here that for most geneset/background ratio's we are within or close to the recommended range for the upregulated genes (in_range_up
and in_range_down
columns). This is not the case for the downregulated genes, but we are not interested in those for now.
ligand_activities_targets_DEgenes = suppressMessages(suppressWarnings( get_ligand_activities_targets_DEgenes( receiver_de = celltype_de_genesets, receivers_oi = intersect(receivers_oi, celltype_de_genesets$cluster_id %>% unique()), ligand_target_matrix = ligand_target_matrix, logFC_threshold = logFC_threshold, p_val_threshold = p_val_threshold, p_val_adj = p_val_adj, top_n_target = top_n_target, verbose = TRUE, n.cores = n.cores ) ))
Combine DE information for ligand-senders and receptors-receivers
Use the adapted celltype_de_LR
table to keep the expression change patterns we want:
sender_receiver_de = combine_sender_receiver_de( sender_de = celltype_de_LR, receiver_de = celltype_de_LR, senders_oi = senders_oi, receivers_oi = receivers_oi, lr_network = lr_network )
sender_receiver_de %>% head(20)
Prioritization: rank cell-cell communication patterns through multi-criteria prioritization
sender_receiver_tbl = sender_receiver_de %>% distinct(sender, receiver) metadata_combined = SummarizedExperiment::colData(sce) %>% tibble::as_tibble() if(!is.na(batches)){ grouping_tbl = metadata_combined[,c(sample_id, group_id, batches)] %>% tibble::as_tibble() %>% distinct() colnames(grouping_tbl) = c("sample","group",batches) } else { grouping_tbl = metadata_combined[,c(sample_id, group_id)] %>% tibble::as_tibble() %>% distinct() colnames(grouping_tbl) = c("sample","group") } prioritization_tables = suppressMessages(generate_prioritization_tables( sender_receiver_info = abundance_expression_info$sender_receiver_info, sender_receiver_de = sender_receiver_de, ligand_activities_targets_DEgenes = ligand_activities_targets_DEgenes, contrast_tbl = contrast_tbl, sender_receiver_tbl = sender_receiver_tbl, grouping_tbl = grouping_tbl, scenario = "regular", # all prioritization criteria will be weighted equally fraction_cutoff = fraction_cutoff, abundance_data_receiver = abundance_expression_info$abundance_data_receiver, abundance_data_sender = abundance_expression_info$abundance_data_sender, ligand_activity_down = FALSE ))
Compile the MultiNicheNet output object
multinichenet_output = list( celltype_info = abundance_expression_info$celltype_info, celltype_de = celltype_de_genesets, sender_receiver_info = abundance_expression_info$sender_receiver_info, sender_receiver_de = sender_receiver_de, ligand_activities_targets_DEgenes = ligand_activities_targets_DEgenes, prioritization_tables = prioritization_tables, grouping_tbl = grouping_tbl, lr_target_prior_cor = tibble() ) multinichenet_output = make_lite_output(multinichenet_output)
Summarizing ChordDiagram circos plots
We will look here at the top 50 predictions across all contrasts, senders, and receivers of interest.
prioritized_tbl_oi_all = get_top_n_lr_pairs( multinichenet_output$prioritization_tables, top_n = 50, rank_per_group = FALSE )
prioritized_tbl_oi = multinichenet_output$prioritization_tables$group_prioritization_tbl %>% filter(id %in% prioritized_tbl_oi_all$id) %>% distinct(id, sender, receiver, ligand, receptor, group) %>% left_join(prioritized_tbl_oi_all) prioritized_tbl_oi$prioritization_score[is.na(prioritized_tbl_oi$prioritization_score)] = 0 senders_receivers = union(prioritized_tbl_oi$sender %>% unique(), prioritized_tbl_oi$receiver %>% unique()) %>% sort() colors_sender = RColorBrewer::brewer.pal(n = length(senders_receivers), name = 'Spectral') %>% magrittr::set_names(senders_receivers) colors_receiver = RColorBrewer::brewer.pal(n = length(senders_receivers), name = 'Spectral') %>% magrittr::set_names(senders_receivers) circos_list = make_circos_group_comparison(prioritized_tbl_oi, colors_sender, colors_receiver)
In general, we see most specific interactions are the ones that increase in the E group (= patients with T cell clonotype expansion after aPD1 therapy)
Interpretable bubble plots
We will now check the top interactions specific for the OnE group - so these are the interactions that increase during anti-PD1 therapy and more strongly in the E than NE group.
group_oi = "G1.T2"
prioritized_tbl_oi_50 = get_top_n_lr_pairs( multinichenet_output$prioritization_tables, top_n = 50, rank_per_group = FALSE) %>% filter(group == group_oi)
prioritized_tbl_oi_50_omnipath = prioritized_tbl_oi_50 %>% inner_join(lr_network_all)
Now we add this to the bubble plot visualization:
plot_oi = make_sample_lr_prod_activity_plots_Omnipath( multinichenet_output$prioritization_tables, prioritized_tbl_oi_50_omnipath) plot_oi
Now let's check the top interactions specific for the PreE group - so these are the interactions that decrease during anti-PD1 therapy, and more strongly in group1 than group2.
group_oi = "G1.T1"
prioritized_tbl_oi_50 = get_top_n_lr_pairs( multinichenet_output$prioritization_tables, top_n = 50, rank_per_group = FALSE) %>% filter(group == group_oi)
prioritized_tbl_oi_50_omnipath = prioritized_tbl_oi_50 %>% inner_join(lr_network_all)
plot_oi = make_sample_lr_prod_activity_plots_Omnipath( multinichenet_output$prioritization_tables, prioritized_tbl_oi_50_omnipath) plot_oi
These are just a few interactions because they should be part of top50. Let's now look at some more interactions: top25 within PreE.
prioritized_tbl_oi_50 = get_top_n_lr_pairs( multinichenet_output$prioritization_tables, top_n = 25, groups = group_oi)
prioritized_tbl_oi_50_omnipath = prioritized_tbl_oi_50 %>% inner_join(lr_network_all)
plot_oi = make_sample_lr_prod_activity_plots_Omnipath( multinichenet_output$prioritization_tables, prioritized_tbl_oi_50_omnipath) plot_oi
We recommend generating these plots as well for the other conditions, but we will not do this here to reduce the length of the vignette.
One of the unique benefits of MultiNicheNet is the prediction of ligand activities and target genes downstream of ligand-receptor pairs. This may offer additional unique insights into the data at hand. We will showcase this here by inferring an intercellular regulatory network. Interestingly, some target genes can be ligands or receptors themselves. This illustrates that cells can send signals to other cells, who as a response to these signals produce signals themselves to feedback to the original sender cells, or who will effect other cell types. With MultiNicheNet, we can generate this 'systems' view of these intercellular feedback and cascade processes than can be occuring between the different cell populations involved. In this intercellular regulatory network, we will draw links between ligands of sender cell types their ligand/receptor-annotated target genes in receiver cell types. So links are ligand-target links (= gene regulatory links) and not ligand-receptor protein-protein interactions!
Because we typically predict many target genes for each ligand, we may get a crowded ligand-target network in the end. To prune this network, we can filter target genes based on correlation in expression across samples with their upstream ligand-receptor pairs.
So, before inferring the intercellular regulatory network, we will calculate these LR-target correlations:
lr_target_prior_cor = lr_target_prior_cor_inference( receivers_oi = multinichenet_output$prioritization_tables$group_prioritization_tbl$receiver %>% unique(), abundance_expression_info = abundance_expression_info, celltype_de = celltype_de_LR, grouping_tbl = multinichenet_output$grouping_tbl, prioritization_tables = multinichenet_output$prioritization_tables, ligand_target_matrix = ligand_target_matrix, logFC_threshold = logFC_threshold, p_val_threshold = p_val_threshold, p_val_adj = p_val_adj )
In the plots before, we demonstrated that some DE genes have both expression correlation and prior knowledge support to be downstream of ligand-receptor pairs. We will infer this intercellular regulatory network here for the top250 interactions overall.
prioritized_tbl_oi = get_top_n_lr_pairs( multinichenet_output$prioritization_tables, 250, rank_per_group = FALSE) lr_target_prior_cor_filtered = multinichenet_output$prioritization_tables$group_prioritization_tbl$group %>% unique() %>% lapply(function(group_oi){ lr_target_prior_cor_filtered = lr_target_prior_cor %>% inner_join( multinichenet_output$ligand_activities_targets_DEgenes$ligand_activities %>% distinct(ligand, target, direction_regulation, contrast) ) %>% inner_join(contrast_tbl) %>% filter(group == group_oi) lr_target_prior_cor_filtered_up = lr_target_prior_cor_filtered %>% filter(direction_regulation == "up") %>% filter( (rank_of_target < top_n_target) & (pearson > 0.2 | spearman > 0.2)) lr_target_prior_cor_filtered_down = lr_target_prior_cor_filtered %>% filter(direction_regulation == "down") %>% filter( (rank_of_target < top_n_target) & (pearson < -0.2 | spearman < -0.2)) lr_target_prior_cor_filtered = bind_rows( lr_target_prior_cor_filtered_up, lr_target_prior_cor_filtered_down ) }) %>% bind_rows() lr_target_df = lr_target_prior_cor_filtered %>% distinct(group, sender, receiver, ligand, receptor, id, target, direction_regulation)
network = infer_intercellular_regulatory_network(lr_target_df, prioritized_tbl_oi) network$links %>% head() network$nodes %>% head()
colors_sender["Fibroblast"] = "pink" # the original yellow background with white font is not very readable network_graph = visualize_network(network, colors_sender) network_graph$plot
Interestingly, we are only left with interactions that increase after therapy in the E group. This means that we don't find many intercellular regulatory connections for the other groups. In most cases, you will get this type of network for all conditions in your data.
Note: we can also use this network to further prioritize differential CCC interactions. Here we will assume that the most important LR interactions are the ones that are involved in this intercellular regulatory network. We can get these interactions as follows:
network$prioritized_lr_interactions
prioritized_tbl_oi_network = prioritized_tbl_oi %>% inner_join( network$prioritized_lr_interactions) prioritized_tbl_oi_network
Visualize now the expression and activity of these interactions for the OnE group
group_oi = "G1.T2"
prioritized_tbl_oi_M = prioritized_tbl_oi_network %>% filter(group == group_oi) plot_oi = make_sample_lr_prod_activity_plots_Omnipath( multinichenet_output$prioritization_tables, prioritized_tbl_oi_M %>% inner_join(lr_network_all) ) plot_oi
All the above visualizations were general and not specific to the multifactorial design and complex contrast of this data and analysis. In the final part of this vignette, we will demonstrate some visualizations that better showcase differences in therapy-induced cell-cell communication changes. Because it is very hard to do this for the final MultiNicheNet prioritization score including both expression and activity, we will only visualize ligand-receptor expression in the following plots. But realise that the interactions we will show are prioritized by MultiNicheNet not only based on differential expression, but thus also cell-type specificity and ligand activity.
As example, we will focus on the top 10 interactions with stronger On-Pre differences in group1 versus group2.
prioritized_tbl_oi = get_top_n_lr_pairs(multinichenet_output$prioritization_tables, 10, rank_per_group = TRUE, groups_oi = "G1.T2")
A first visualization will not require that the timepoint 1 and timepoint 2 samples were gathered from the same subjects. The other visualization will assume this, and can only be generated in those scenarios.
Boxplots of LR pseudobulk expression product
In the following blocks of code, we will first create and reformat a data frame so that we know for each sample the time point (timepoint.1/Pre or timepoint.2/On) and patient group (group1/E or group2/NE), and also what the pseudobulk expression product is for a ligand-receptor pair.
# create sample-level data frame for these interactions sample_data = multinichenet_output$prioritization_tables$sample_prioritization_tbl %>% dplyr::filter(id %in% prioritized_tbl_oi$id) %>% dplyr::mutate(sender_receiver = paste(sender, receiver, sep = " --> "), lr_interaction = paste(ligand, receptor, sep = " - ")) %>% dplyr::arrange(receiver) %>% dplyr::group_by(receiver) %>% dplyr::arrange(sender, .by_group = TRUE) sample_data = sample_data %>% dplyr::mutate(sender_receiver = factor(sender_receiver, levels = sample_data$sender_receiver %>% unique()))
# define the time point and group and link it all together grouping_tbl2 = multinichenet_output$grouping_tbl %>% dplyr::inner_join(multinichenet_output$prioritization_tables$sample_prioritization_tbl %>% dplyr::distinct(sample)) grouping_tbl2 = grouping_tbl2 %>% inner_join(tibble(group = c("G1.T1","G2.T1","G1.T2","G2.T2"), contrast = c("group1","group2","group1","group2"))) grouping_tbl2$timepoint = "timepoint2" grouping_tbl2$timepoint[grouping_tbl2$group %in% c("G1.T1","G2.T1")] = "timepoint1" sample_data = sample_data %>% ungroup() %>% inner_join(grouping_tbl2) sample_data = sample_data %>% select(sample, group, contrast, timepoint, id, ligand_receptor_pb_prod) %>% distinct()
p_lr_prod_change_boxplot = sample_data %>% ggplot(aes(x = contrast, y = ligand_receptor_pb_prod, fill = timepoint, group = group)) + geom_boxplot() + facet_wrap(id~.) + theme_bw() + xlab("") + ylab("") p_lr_prod_change_boxplot
These boxplots reflect what the DE model underlying MultiNicheNet infers: namely average group differences.
However, these boxplots don't show potential inter-sample heterogeneity. That's why we will also create bubble and line plots in the following blocks of code. In those plots we will calculate the timepoint2 vs timepoint1 differences for each subject separately. Therefore, you can only generate those if you have this type of experimental design (same subject, multiple time points).
Difference plots
Prepare the appropriate data frame structure to create this plots. This will be very similar to the previous blocks of code. Exception: we will now also include information about the subject the samples were collected from + whether sender and receiver cell types were sufficiently present in each subject both before and on treatment.
First: link sample_id to subject_id:
sample_subject_tbl = colData(sce) %>% as_tibble() %>% distinct(patient_id, sample_id) %>% rename(subject = patient_id, sample = sample_id)
Create sample-level data frame for these interactions
sample_data = multinichenet_output$prioritization_tables$sample_prioritization_tbl %>% dplyr::filter(id %in% prioritized_tbl_oi$id) %>% dplyr::mutate(sender_receiver = paste(sender, receiver, sep = " --> "), lr_interaction = paste(ligand, receptor, sep = " - ")) %>% dplyr::arrange(receiver) %>% dplyr::group_by(receiver) %>% dplyr::arrange(sender, .by_group = TRUE) sample_data = sample_data %>% dplyr::mutate(sender_receiver = factor(sender_receiver, levels = sample_data$sender_receiver %>% unique()))
Define the time point and group and link it all together
grouping_tbl2 = multinichenet_output$grouping_tbl %>% dplyr::inner_join(multinichenet_output$prioritization_tables$sample_prioritization_tbl %>% dplyr::distinct(sample, sender, receiver, keep_sender, keep_receiver)) grouping_tbl2 = grouping_tbl2 %>% inner_join(tibble(group = c("G1.T1","G2.T1","G1.T2","G2.T2"), contrast = c("group1","group2","group1","group2"))) grouping_tbl2$timepoint = "timepoint2" grouping_tbl2$timepoint[grouping_tbl2$group %in% c("G1.T1","G2.T1")] = "timepoint1" sample_data = sample_data %>% ungroup() %>% inner_join(grouping_tbl2) %>% inner_join(sample_subject_tbl) sample_data = sample_data %>% select(subject, sample, group, contrast, timepoint, sender_receiver, keep_sender, keep_receiver, keep_sender_receiver, ligand, receptor, lr_interaction, id, ligand_receptor_pb_prod, scaled_LR_pb_prod) %>% distinct()
Then we will remove samples where sender and/or receiver was missing and calculate the On-vs-Pre difference in pseudobulk expression (absolute and relative difference, named respectively diff
and lfc
).
sample_data = sample_data %>% filter(keep_sender & keep_receiver) %>% mutate(group = factor(group, levels = c("G2.T1","G1.T1", "G2.T2","G1.T2")), timepoint = factor(timepoint, levels = c("timepoint1","timepoint2"))) sample_data = sample_data %>% inner_join( sample_data %>% filter(keep_receiver == 1 & keep_sender == 1) %>% ungroup() %>% select(id, subject, timepoint, ligand_receptor_pb_prod) %>% distinct() %>% tidyr::spread(timepoint, ligand_receptor_pb_prod) %>% mutate(diff = timepoint2-timepoint1, fc = timepoint2/timepoint1) %>% mutate(lfc = log(fc)) %>% arrange(-lfc) )
For the visualization, we will also order the subjects based the total difference across all shown interactions:
order_subjects = sample_data %>% group_by(subject) %>% summarise(sum_diff = sum(diff, na.rm = TRUE)) %>% arrange(-sum_diff) %>% pull(subject) order_samples = sample_data %>% group_by(subject) %>% summarise(sum_diff = sum(diff, na.rm = TRUE)) %>% inner_join(sample_data) %>% arrange(-sum_diff) %>% pull(sample) %>% unique()
Difference Bubble plots
Bubble Blot for E group
We will now visualize the On-vs-Pre absolute difference in pseudobulk ligand-receptor expression product in a bubble plot.
max_diff = abs(sample_data$diff) %>% max(na.rm = TRUE) custom_scale_color = scale_color_gradientn(colours = RColorBrewer::brewer.pal(n = 7, name = "RdBu") %>% rev(), values = c(0, 0.30, 0.425, 0.5, 0.575, 0.70, 1), limits = c(-1 * max_diff, max_diff)) p_lr_prod_change = sample_data %>% mutate(subject = factor(subject, levels = order_subjects)) %>% ggplot(aes(subject, lr_interaction, color = diff)) + geom_point(size = 5) + facet_grid(sender_receiver~contrast, scales = "free", space = "free", switch = "y") + theme_light() + theme(axis.ticks = element_blank(), axis.title = element_blank(), axis.text.y = element_text(face = "bold.italic", size = 9), axis.text.x = element_text(size = 9, angle = 90, hjust = 0), panel.grid.major = element_blank(), panel.grid.minor = element_blank(), panel.spacing.x = unit(0.4, "lines"), panel.spacing.y = unit(0.25, "lines"), strip.text.x.top = element_text(size = 10, color = "black", face = "bold", angle = 0), strip.text.y.left = element_text(size = 9, color = "black", face = "bold", angle = 0), strip.background = element_rect(color = "darkgrey", fill = "whitesmoke", size = 1.5, linetype = "solid")) + custom_scale_color + xlab("") + ylab("") p_lr_prod_change
All interactions increased in exrpession in almost all group1 subjects, and certainly not in all group2 subjects.
Line plots
Another type of plot to visualize this type of data is the line plot:
line_plot = sample_data %>% filter(ligand_receptor_pb_prod != 0) %>% ggplot(aes(timepoint, ligand_receptor_pb_prod, group = subject, color = contrast)) + geom_point() + geom_line() + facet_grid(id~contrast, scales = "free", switch = "y") + theme_bw() + scale_color_brewer(palette = "Set2") + xlab("") + ylab("") line_plot
Bubble plot of pseudobulk values: contrasting t1 and t2
Now instead of a difference bubble plot, we will plot the timepoint1 and timepoint2 values under each other.
keep_sender_receiver_values = c(0.25, 0.9, 1.75, 4) names(keep_sender_receiver_values) = levels(sample_data$keep_sender_receiver) p1 = sample_data %>% mutate(timepoint = factor(timepoint, levels = c("timepoint2","timepoint1"))) %>% ggplot(aes(subject, timepoint, color = scaled_LR_pb_prod, size = keep_sender_receiver)) + geom_point() + facet_grid(sender_receiver + lr_interaction ~ contrast, scales = "free", space = "free", switch = "y") + scale_x_discrete(position = "top") + theme_light() + theme( axis.ticks = element_blank(), axis.title = element_blank(), axis.text.y = element_text(size = 8, face = "bold"), axis.text.x = element_text(size = 8, angle = 90,hjust = 0), panel.grid.major = element_blank(), panel.grid.minor = element_blank(), panel.spacing.x = unit(0.40, "lines"), panel.spacing.y = unit(0.25, "lines"), strip.text.x.top = element_text(size = 9, color = "black", face = "bold", angle = 0), strip.text.y.left = element_text(size = 9, color = "black", face = "bold.italic", angle = 0), strip.background = element_rect(color="darkgrey", fill="whitesmoke", size=1.5, linetype="solid") ) + labs(color = "Scaled L-R\npseudobulk exprs product", size= "Sufficient presence\nof sender & receiver") + scale_y_discrete(position = "right") + scale_size_manual(values = keep_sender_receiver_values) max_lfc = abs(sample_data$scaled_LR_pb_prod) %>% max() custom_scale_fill = scale_color_gradientn(colours = RColorBrewer::brewer.pal(n = 7, name = "RdBu") %>% rev(),values = c(0, 0.350, 0.4850, 0.5, 0.5150, 0.65, 1), limits = c(-1*max_lfc, max_lfc)) p1 = p1 + custom_scale_fill p1
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.