--- title: "Decoding antigen-stimulated T cell receptor repertoires with ClustIRR" author: - name: Simo Kitanovski affiliation: Bioinformatics and Computational Biophysics, University Duisburg-Essen email: simo.kitanovski@uni-due.de output: BiocStyle::html_document vignette: > %\VignetteIndexEntry{Decoding antigen-stimulated T cell receptor repertoires with ClustIRR} %\VignetteEncoding{UTF-8} %\VignetteEngine{knitr::rmarkdown} editor_options: markdown: wrap: 72 --- ```{r, include=FALSE} knitr::opts_chunk$set(comment = "", warning = FALSE, message = FALSE) ``` # Analysis of T cell receptor (TCR) repertoires The huge diversity of T and B cell receptors is critical for the function of the adaptive immune system: recognizing and eliminating all kinds of antigens, from viruses, to cancer cells. However, this diversity makes it also difficult to understand B and T cell receptor repertoires (or collectively: immune receptor repertoires, IRRs). You have already learned about some techniques that are useful for IRR analysis when we discussed microbiomes and B cell receptor repertoires (e.g. normalized RADs, Ig gene usage). These techniques can be applied to T cell repertoires, too. Today we will explore further techniques that can deliver a detailed picture of an IRR, namely clustering and graph analysis. These techniques exploit the similarity of TCR sequences: - Two TCRs with *similar* CDR sequences will recognize *similar* antigens or the same antigen and thus have *similar* functions. In our analysis we will ask several questions related to similarities within and between TCR repertoires: - Are there clusters (or "communities") of TCR clonotypes with similar CDR3 sequences within a TCR repertoire? - Are these communities enriched with TCRs from one TCR repertoire (e.g. TCR from patient infected by a virus) relative to another TCR repertoire (e.g. TCR repertoire from the same patient after recovery)? - What antigens are likely recognized by TCRs in differentially abundant communities? - Are expanding TCR communities associated with elevated expression of T cell activation markers? In our analyses we will use a mix of visualization and quantification. # Install and load the current version of ClustIRR To install ClustIRR please open RStudio and enter [this might take a couple of minutes]. ```{r, eval=FALSE} library(devtools) install_github("snaketron/ClustIRR", upgrade = "never") ``` ... after you have installed ClustIRR please load it by entering: ```{r, eval = FALSE} library(ClustIRR) ``` Make sure that ClustIRR version \>1.9.39 is loaded. Click on the tab `Packages` (bottom right corner in RStudio) search for ClustIRR. We also need to load other R-packages. Do this next: ```{r, eval = FALSE} library(ggplot2) library(ggforce) library(igraph) library(patchwork) library(ggrepel) library(rBLAST) ``` # Familiarize with TCR data We have two single cell datasets of TCR$\alpha\beta$ antigen-stimulated repertoires downloaded from the website of Parse Biosciences[^1]: [^1]: Performance of Evercodeā„¢ TCR v4 in Antigen-Stimulated Human T Cells, antigen-stimulated-human-t-cells/; Parse Biosciences; Accessed 2026 June 30 - *CMV_A0201*: TCR repertoire from HLA-A02:01+ human T cells stimulated with cytomegalovirus (CMV) antigens - *Flu*: TCR repertoire from human PBMCs stimulated with Influenza (Flu) antigens Load the data: ```{r, eval = FALSE} # load the data data(D_CMV_FLU) ``` The TCR repertoires are stored in `s` and the corresponding metadata in `meta`. Access them now: ```{r, eval = FALSE} # TCR repertoires s <- D_CMV_FLU$s # Meta data meta <- D_CMV_FLU$meta ``` Let's peek into the data frame containing both TCR repertoires ```{r, eval = FALSE} str(s) ``` ... and also into the metadata ```{r, eval = FALSE} str(meta) ```
**Problem:** How many clonotypes are in each repertoire? How many CDR3$\beta$ sequences are in the data frame? How many CDR3$\alpha$ sequences are in the data frame? How many **cells** are in each repertoire?
**Solution** ```{r, eval = FALSE} # number of clonotypes table(s$sample) # number of unique CDR3b sequences length(unique(s$CDR3b)) # number of unique CDR3b sequences length(unique(s$CDR3a)) # count number of cells per samples print(aggregate(clone_size~sample, data = s, FUN = sum)) ```

**Problem:** Do clone sizes vary significantly within the repertoires? Create a `sina plot` to explore the distributions.
**Solution** ```{r, eval = FALSE, fig.width=4, fig.height=2.5} ggplot(data = s)+ geom_sina(aes(x = sample, y = clone_size), shape = 21)+ theme_bw()+ scale_y_log10()+ annotation_logticks(base = 10, sides = "l") ``` There are many more clonotypes in the TCR repertoire `Flu`. Hence we see many more dots at x=Flu in the sina plot. Overall, we see similar distributions: most clone sizes fall between 1 and 10 cells. However, each repertoire also includes a few large clonotypes with more than 1,000 cells (visible at the top). Both sina plots use a log10-scaled vertical axis.
# Familiarize with gene expression data In addition to single cell TCRs, we also have the gene expression profiles of individual cells stored in `gex`. ```{r, eval = FALSE} # Single cell gene expression data gex <- D_CMV_FLU$gex ``` ```{r, eval = FALSE} str(gex) ``` **Problem:** Are all \~20,000 human genes included in `gex`? If not, do you see common functions among these genes? **Hint:** If you do not recognize these gene symbols run gene ontology (GO) enrichment analysis (e.g., by providing these gene symbols as inputs to gProfiler ) **Important remark:** Rows in `s` are TCR clonotypes (groups of clonally expanded T cells with common ancestors), while rows in `gex` are T cells. How can we integrate these two datasets? **Hint:** Remember cell barcodes from the lesson on single cell RNA-seq? Use the `cell_id` column to access the cell IDs obtained based on these barcodes. # Using ClustIRR to compare the two repertoires, *CMV* vs. *Flu* We have two repertoires, *CMV* and *Flu*. We have seen that several TCR clonotypes are expanded in each repertoire. Such clonotype expansions have to be expected as a consequence of the experimental stimulation by CMV and Influenza M1 antigens. With ClustIRR we can detect the effect of expansions (or contraction) on the structure of TCR repertoires, or in general IRR repertoires. Let's use ClustIRR to identify how the structure of the TCR repertoire changes from *CMV* to *Flu*. ClustIRR operates on CDR3 sequences and clone sizes: ```{r, eval = FALSE, eval = FALSE} o <- clustirr(s = s, meta = meta, control = list(blast_gmi = 0.9)) # min. BLAST sequence identity ``` ```{r, eval = FALSE} str(o) ``` The output in `o` is quite rich. See the help page for explanations. Check whether you can interpret it! # **Step 1** Building a joint graph One of the main outputs generated by `clustirr` is a joint graph ($J$), stored as an `igraph` object named `graph`. ```{r, eval = FALSE} is_igraph(o$graph) ``` Each node in the graph represents a TCR clonotype from either *CMV* or *Flu*. Node widths indicate clone sizes, and edge weights represent CDR3$\alpha$ and CDR3$\beta$ sequence similarities.
**Problem:** Visualize $J$ as an *interactive* graph using the function `plot_graph`! Explore the structure and try coloring the nodes based on antigen specificity. Do you see any nodes corresponding to CMV-specific TCR clonotypes? Are these clonotypes typically small or large?
**Solution** ```{r, eval = FALSE, fig.width=6, fig.height=6} plot_graph(g = o, as_visnet = T, node_opacity = 0.75, show_singletons = F) ``` **Important remark:** Even in this small case study--where the TCR repertoire contains only about 1,700 TCR clonotypes--the joint graph is already quite complex. This level of complexity makes qualitative analyses impractical. To address this, ClustIRR emphasizes *quantitative analyses* (see Step 2).
## Using `igraph` functions to inspect $J$ We can use the R-package `igraph` to explore various properties of $J$. For example, we can examine the edge weights stored in the edge attribute `nweight`. These weights represent alignment scores between pairs of CDR3$\beta$ sequences, calculated using BLOSUM62 substitution scores with a gap opening penalty of -12 and a gap extension penalty of -2. To ensure comparability between pairs of CDR3 sequences of different lengths, ClustIRR normalizes the alignment scores by dividing them by the alignment lengths. Consequently, identical or similar CDR3 sequence pairs receive large positive `nweight` scores, while dissimilar pairs have `nweight` values close to zero or even negative.
**Problem:** How many nodes are there in the graph? The `igraph` function `as_data_frame(x = o$graph, what = "vertices")` allows you to access the node attributes. The output is a data.frame with nodes shown as rows. How many rows are there?
**Solution** ```{r, eval = FALSE} n <- as_data_frame(x = o$graph, what = "vertices") nrow(n) ```

**Problem:** Now access the edges by replacing `what = "vertices"` with `what = "edges"`. How many edges are there? Find which pairs of nodes are connected by the *largest*/*lowest* `nweight` scores.
**Solution** ```{r, eval = FALSE} # get a data.frame 'e' with of edges and their attributes e <- as_data_frame(x = o$graph, what = "edges") # max edge weight e_max <- e[which.max(e$nweight),] # get CDR3 sequences n$CDR3b[which(n$name %in% c(e_max$from, e_max$to))] ``` ```{r, eval = FALSE} # min edge weight e_min <- e[which.min(e$nweight),] # get CDR3 sequences n$CDR3b[which(n$name %in% c(e_min$from, e_min$to))] ```
# **Step 2** Detecting *communities* in $J$ *ClustIRR* employs graph-based community detection (GCD) algorithms--such as Leiden, Louvain, and InfoMap--to identify communities of nodes characterized by a high density of strong edges within the community and a low density of strong edges connecting to nodes outside the community. Do community detection with the function `detect_communities` now! Use similarities based only on the more diverse CDR3$\beta$ chain for clustering. ```{r, eval = FALSE} gcd <- detect_communities(graph = o$graph, algorithm = "leiden", chains = "CDR3b") ``` The output in `gcd` is quite rich. See the help page for explanations. Check whether you can interpret it! ```{r, eval = FALSE} names(gcd) ``` The main element is the `community_occupancy_matrix`, which contains the number of T cells in each community (rows) and repertoire (columns). Here, we have two repertoires (two columns) and approximately 800 communities. This matrix serves as the main input to the function `dco` (Step 3), which detects differences in community occupancy between repertoires. ```{r, eval = FALSE} dim(gcd$community_occupancy_matrix) ``` ```{r, eval = FALSE} head(gcd$community_occupancy_matrix) ```
**Problem:** Visualize the community abundance matrix with the function `get_honeycombs` now!
**Solution** ```{r, eval = FALSE, fig.width=4.5, fig.height=3} honeycomb <- get_honeycombs(com = gcd$community_occupancy_matrix) honeycomb[[1]] ``` In the **honeycomb plot**, we see that most of the dots (communities) do not lie close to the diagonal, indicating that they drastically different number of T cells in *CMV* (y-axis) and *Flu* (x-axis). These communities contain more cells from either repertoire relative to the other. The color of the hexagons encodes the density of dots in the 2D scatterplot: dark hexagons represent regions with many communities, while light hexagons indicate sparsely populated areas. The plot uses log10-scaled axes.

**Next step:** Are there any, and if so which, differentially abundant communities (DACs) between *CMV* and *Flu*? # **Step 3** Finding differentially abundant communities (DACs) To detect DACs, ClustIRR employs a Bayesian model (remember other models discussed in this lecture series: Sleuth, DADA2, IgGeneUsage). Using the community occupancy counts stored in the `community_occupancy_matrix` as its main input, the Dirichlet-Multinomial model applied by ClustIRR describes the relative abundance (occupancy) of community $i$ in repertoire $j$ with the parameter $\beta_i^j$.
**Problem:** Run this analysis now! ```{r, eval = FALSE} dco_obj <- dco(community_occupancy_matrix = gcd$community_occupancy_matrix, mcmc_control = list(mcmc_warmup = 500, mcmc_iter = 1500, mcmc_chains = 4, mcmc_cores = 4)) ``` ## Posterior predictive checks Before interpreting the model results, we need to ensure that the model is valid. A standard approach for this is to check whether the model can *retrodict* the observed data (i.e., the community occupancy matrix used to fit the model parameters). General idea of posterior predictive checks: 1. Fit the model based on observed data $y$ 2. Simulate new data $\hat{y}$ from the model 3. Compare $y$ and $\hat{y}$. If $y$ and $\hat{y}$ are clearly different, the model is likely not valid ClustIRR provides both $y$ and $\hat{y}$ for each repertoire in `posterior_summary` object within `dco_obj`. Visualize it with ggplot now! ```{r, eval = FALSE, fig.width=4, fig.height=3} ggplot(data = dco_obj$posterior_summary$y_hat)+ geom_abline(intercept = 0, slope = 1, linetype = "dashed", col = "gray")+ geom_errorbar(aes(x = y_obs, y = mean, ymin = L95, ymax = H95), col = "darkgray", width=0)+ geom_point(aes(x = y_obs, y = mean), size = 0.8)+ xlab(label = "observed y")+ ylab(label = "predicted y (and 95% HDI)")+ theme_bw() ```
**Problem:** Based on the plot, does the model do a good job of "re-creating" the data it was trained on? ## Taking a peek at the relative community abundances ($\beta$) Larger communities are associated with larger, positive $\beta$ coefficients. Let's visualize the mean $\beta$ values for each community across the different TCR repertoires using the `get_beta_violin_ag` function. This brings us to a key biological question: Are the communities with high $\beta$ values specific to antigens from CMV or Flu? ```{r, eval = FALSE, fig.width=5, fig.height=3, warning=FALSE} bv_cmv <- get_beta_violin_ag(node_summary = gcd$node_summary, beta = dco_obj$posterior_summary$beta, ag_species = TRUE, ag = "CMV", db = "vdjdb") bv_cmv$plot ``` In the plot, each point represents a community, shown with respect to repertoire (x-axis) and its mean $\beta$ coefficient (y-axis). - **Orange** dots mark communities that contain at least one TCR clonotype specific for CMV. - **Blue dots** represent communities without any known CMV-specific clonotype. - **Dot size** reflects the number of CMV-specific T cells in the community -- larger dots indicate more CMV-specific cells. Notice the orange dot with particularly high $\beta$s in repertoire *CMV?* This community contains over 10,000 cells specific for CMV antigens. Let's do the same analysis with respect to Flu antigens ```{r, eval = FALSE, fig.width=5, fig.height=3, warning=FALSE} bv_flu <- get_beta_violin_ag(node_summary = gcd$node_summary, beta = dco_obj$posterior_summary$beta, ag_species = TRUE, ag = "InfluenzaA", db = "vdjdb") bv_flu$plot ``` Notice that now we see the reverse: large orange dots with particularly high $\beta$s in repertoire *Flu*
**Problem:** what are the community IDs of these large orange dots? ```{r, eval = FALSE} bv_cmv$d[which(bv_cmv$d$sample == "CMV_A0201" & bv_cmv$d$mean>6.5 & bv_cmv$d$hit==T),] # community 9 ``` ```{r, eval = FALSE} # Large orange dots with high beta in repertoire Flu bv_flu$d[which(bv_flu$d$sample == "Flu" & bv_flu$d$mean>3 & bv_flu$d$hit==T),] # communities 34, 91, 94 ``` # Inspecting DACs of interest Community 9 has 33 clonotypes with paired CDR3$\alpha$ and CDR3$\beta$ sequences. ```{r, eval = FALSE} ns <- gcd$node_summary nrow(ns[ns$community == 9,]) ``` We performed clustering based on the CDR3$\beta$ sequences, hence, we can assume to find similar CDR3$\beta$s among the clonotypes. To verify this, we can construct a sequence logo from the CDR3 sequences ## Show the CDR3$\alpha$\beta\$ sequence logos of community 9 ```{r, eval = FALSE, fig.width=6, fig.height=1.3} get_cdr3_motifs(node_summary = gcd$node_summary, community_id = 9) ``` Based on the logo, community 9 is made of clonotypes with conserved CDR3$\beta$s (left logo) and diverse CDR3$\alpha$s (right logo). ## Visualize the graph of community 9 ```{r, eval = FALSE, fig.width=5, fig.height=5, warning=FALSE} sg <- subgraph(graph = gcd$graph, vids = V(gcd$graph)$community %in% 9) V(sg)$Ag <- ifelse(test = regexec(pattern = "CMV", text = V(sg)$Ag_species)!=-1, yes = "+", no = "-") V(sg)$col <- ifelse(test = V(sg)$Ag=="+", yes = "orange", no = "#A1BFD9") V(sg)$label <- paste0("Community:", V(sg)$community, " (", V(sg)$clone_size, ")" , "\n CDR3a:",V(sg)$CDR3a, "\n CDR3b:",V(sg)$CDR3b) V(sg)$label[which(ave(V(sg)$clone_size, V(sg)$community, FUN = function(x) x == max(x))==0)] <- paste0("(", V(sg)$clone_size, ")")[which(ave(V(sg)$clone_size, V(sg)$community, FUN = function(x) x == max(x))==0)] V(sg)$shape <- ifelse(test = V(sg)$sample=="Flu", yes = "circle", no = "square") par(mai=c(0,0,0,0)) plot(sg, vertex.label = V(sg)$label, layout = igraph::layout_components, vertex.label.cex = 0.8, vertex.size = log(V(sg)$clone_size)+3, vertex.frame.color = NA, vertex.color = V(sg)$col, vertex.shape = V(sg)$shape, edge.width = 0.2) ``` **How to interpret this plot?** Orange and blue nodes represent TCR clonotypes that are known to be CMV-specific or not, respectively. Most of the clonotypes in this community are known to recognize CMV antigens. Node widths represent the number of T cells within each clonotype (i.e., clonal expansion). Node labels show the community ID, CDR3$\alpha$ and CDR3$\beta$ sequences of the largest clonotype and clone sizes (in parentheses for all clonotypes). The nodes are densely connected owing to their similar CDR3$\beta$ sequences. ```{r, eval = FALSE} d <- igraph::as_data_frame(x = sg, what = "vertices") d[order(d$clone_size, decreasing = T), ][1:2, c("CDR3a", "CDR3b", "clone_size", "sample", "Ag_species")] ``` The two largest nodes in this graph have the same CDR3$\beta$ sequence (CAWSISDLAKNIQYF), but different CDR3$\alpha$s. These clonotypes contain 9,754 and 6,052 T cells, respectively.
**Problem:** do the same analysis for Flu communities (e.g., 34, 91, 94)
**Solution** ```{r, eval = FALSE, fig.width=5.5, fig.height=3} # show the CDR3 logos wrap_plots(lapply(X = c(34, 91, 94), FUN = get_cdr3_motifs, node_summary = gcd$node_summary), ncol = 1) ``` ```{r, eval = FALSE, fig.width=10, fig.height=10, warning=FALSE} # Show the networks sg <- subgraph(graph = gcd$graph, vids = V(gcd$graph)$community %in% c(34, 91, 94)) V(sg)$Ag <- ifelse(test = regexec(pattern = "CMV", text = V(sg)$Ag_species)!=-1, yes = "+", no = "-") V(sg)$col <- ifelse(test = V(sg)$Ag=="+", yes = "orange", no = "#A1BFD9") V(sg)$label <- paste0("Community:", V(sg)$community, " (", V(sg)$clone_size, ")" , "\n CDR3a:",V(sg)$CDR3a, "\n CDR3b:",V(sg)$CDR3b) V(sg)$label[which(ave(V(sg)$clone_size, V(sg)$community, FUN = function(x) x == max(x))==0)] <- paste0("(", V(sg)$clone_size, ")")[which(ave(V(sg)$clone_size, V(sg)$community, FUN = function(x) x == max(x))==0)] V(sg)$shape <- ifelse(test = V(sg)$sample=="Flu", yes = "circle", no = "square") par(mai=c(0,0,0,0)) plot(sg, vertex.label = V(sg)$label, layout = igraph::layout_components, vertex.label.cex = 0.8, vertex.size = log(V(sg)$clone_size)+3, vertex.frame.color = NA, vertex.color = V(sg)$col, vertex.shape = V(sg)$shape, edge.width = 0.2) ``` **Important remark:** Notice that community 34 contains many clonotypes, where the largest clonotype contains nearly 8,000 cells. Yet, the largest clonotypes are not known to recognize Influenza antigens, i.e. we see low prevalence of antigen-specific CDR3s in the community. In contrast, community 91 and 94 are large (are associated with large $\beta$ coefficients) and are also enriched with large clonotypes that are known to recognize Influenza antigens.
# Integration of TCR and GEX data We now come to another important biological question: community 9 is expanded in TCR repertoire CMV and contains many clonotypes that are known to recognize CMV antigens. Do we also see higher levels of T cell activation among the corresponding T cells compared to the T cells outside this community? Answer this question now! For this we will follow the following procedure: 1. Extract T-cell activation profiles from gex for cells inside community 9 2. Compute for each cell the sum of expressions across 12 genes and use this as a signature of T-cell activation (higher expression means more T cell activation) 3. Do the same analysis for cells outside community 9 4. Compare the distributions of T-cell activation scores between cells of community 9 and the remaining cells ```{r, eval = FALSE} # this is the data.frame with all clonotypes ns <- gcd$node_summary # extract the clonotypes in community 9 from repertoire CMV_A0201 ns_com_9 <- ns[ns$sample == "CMV_A0201" & ns$community == 9,] # extract the cell_ids (barcodes) cell_ids <- unlist(strsplit(x = ns_com_9$cell_id, split = "\\,")) ``` ```{r, eval = FALSE} # extract the gex profiles of cells in community 9 gex_com_9 <- gex[gex$sample == "CMV_A0201" & gex$cell_id %in% cell_ids,] # ... and in cells not in community 9 gex_com_not_9 <- gex[gex$sample == "CMV_A0201" & !gex$cell_id %in% cell_ids,] ``` ```{r, eval = FALSE, fig.width=5.5, fig.height=3.5} # compute the sum of gene expressions within each cell t_act_9 <- apply(X = gex_com_9[,1:12], MARGIN = 1, FUN = sum) t_act_not_9 <- apply(X = gex_com_not_9[,1:12], MARGIN = 1, FUN = sum) # compare the distributions # red = 9 # black = not 9 # do we see a clear difference? yes: red distribution shifted towards larger # values -> up-regulation of genes involved in T-cell activation plot(density(t_act_9), col = "red", main = "T cell activation in community 9 (red) vs. rest (black)", xlab = "T cell activation score") points(density(t_act_not_9), type = "l", col = "black") ```
**Problem:** do the same analysis for Flu communities 34, 91, and 94. Can we draw any conclusion about the T cell activation scores from the specificity structure of these communities?
**Solution** ```{r, eval = FALSE} # this is the data.frame with all clonotypes ns <- gcd$node_summary # extract the clonotypes in community 34 from repertoire Flu ns_com_34 <- ns[ns$sample == "Flu" & ns$community == 34,] ns_com_91 <- ns[ns$sample == "Flu" & ns$community == 91,] ns_com_94 <- ns[ns$sample == "Flu" & ns$community == 94,] # extract the cell_ids (barcodes) cell_ids_34 <- unlist(strsplit(x = ns_com_34$cell_id, split = "\\,")) cell_ids_91 <- unlist(strsplit(x = ns_com_91$cell_id, split = "\\,")) cell_ids_94 <- unlist(strsplit(x = ns_com_94$cell_id, split = "\\,")) ``` ```{r, eval = FALSE} # extract the gex profiles of cells in each community gex_com_34 <- gex[gex$sample == "Flu" & gex$cell_id %in% cell_ids_34,] gex_com_91 <- gex[gex$sample == "Flu" & gex$cell_id %in% cell_ids_91,] gex_com_94 <- gex[gex$sample == "Flu" & gex$cell_id %in% cell_ids_94,] # ... and in cells not in communities 34, 91, and 94 gex_com_not <- gex[gex$sample == "Flu" & !gex$cell_id %in% c(cell_ids_34, cell_ids_91, cell_ids_94),] ``` ```{r, eval = FALSE, fig.width=5.4, fig.height=4} # compute the sum of gene expressions within each cell t_act_34 <- apply(X = gex_com_34[,1:12], MARGIN = 1, FUN = sum) t_act_91 <- apply(X = gex_com_91[,1:12], MARGIN = 1, FUN = sum) t_act_94 <- apply(X = gex_com_94[,1:12], MARGIN = 1, FUN = sum) t_act_not <- apply(X = gex_com_not[,1:12], MARGIN = 1, FUN = sum) # compare the distributions plot(density(t_act_34), col = "pink", main = "T cell activation in community 34, 91, 94, vs. rest", xlab = "T cell activation score") points(density(t_act_91), type = "l", col = "forestgreen") points(density(t_act_94), type = "l", col = "green") points(density(t_act_not), type = "l", col = "black") ```