# SVAlignR and Virus-Associated Cancer

#### Kevin R. Coombes, Keiko Akagi, Davied Symer, and Maura Gillison

#### 2025-09-04

# Introduction

Some cancers are associated with the presence of viruses that disrupt chromosomal DNA. Among the best known examples are cervical cancers and head-and-neck cancers that are associated with human papilloma virus (HPV). Using whole genome short-read sequencing, it is possible to identify breakpoints, including those where viral DNA is merged with human DNA. Each breakpoint defines a local instance of “structural variation” (SV) where segments of chromosomes are joined together in non-normal ways. It remains an open question whether those fragments are fully integrated into a human chromosome or just exist as circular extrachromosomal DNA. Using newer long-read sequencing technology, one can obtain information about the order of multiple breakpoints in the original DNA molecules in the tumor sample. In this vignette, we describe SVAlignR, an R package intended to use these long-read sequences to reconstruct the original molecules.

# Getting Started

We begin by loading the `SVAlignR` package, and a sample data set.

    library(SVAlignR)
    data(longreads)
    head(longreads[, 1:3])

    ##                                    qname  qlen                    connection
    ## LR1 870fe397-c2e5-4fbc-8324-506484f4c89a 64349  50-74-0-0-50-74-0-50-74-0-50
    ## LR2 cc6314e0-3582-4f15-8d59-44c09bfef1e3 56447  74-0-63-74-0-50-74-0-50-74-0
    ## LR3 52f855b6-de11-49d8-b298-6460d5274a2f 56306 50-74-0-50-74-0-50-74-0-50-74
    ## LR4 55becc2c-c24c-49df-b73a-6459aabd2a05 55510 74-50-74-0-50-74-0-50-74-0-50
    ## LR5 591c04a7-e1f5-40ca-b707-82ce2cfd0ff2 54409 74-50-74-0-50-74-0-50-74-0-50
    ## LR6 b89fe96f-d773-494a-a702-7d7879219581 51084     0-50-74-0-50-74-0-50-74-0

Each of the 197 rows in this spreadsheet represents a different Oxford Nanopore long read. The `qname` column is a unique identifier; the `qlen` column contains the length of the read in bytes; and the `connections` column lists the breakpoints that the read contains, each of which has been assigned a numeric identifier. We are interested in this column. We have two main goals:

1. Assemble the long-reads (which should be thought of as fragmentary) into complete molecules.
2. Start constructing a phylogenetic tree showing the evolution of these HPV-human combining molecules.

As with many sequence alignment problems, we start by removing any duplicate “reads”; here that means duplication of the break-point sequence summaries of the actual reads.

    LR0 <- longreads$connection
    names(LR0) <- rownames(longreads)
    LR <- LR0[!duplicated(LR0)]
    weights <- sapply(LR, function(lr) sum(LR0 == lr))
    length(LR) # expect 158

    ## [1] 158

# Clustering Sequences

We want to cluster the break-point sequences.

## Recoding

In order to make use of existing algorithms, we need to convert the numeric identifiers into single characters as part of an “alphabet”. The `Cipher` class takes in a character vector containing the “words” (that is, our break-point sequences) that need to be rewritten. By default, it assumes (as in our example) that “letters” are separated by hyphens, identifies the unique symbols, and creates a tool to convert back-and-forth from one “alphabet” to another.

    alphabet <- Cipher(LR)
    alphabet

    ## An object of class "Cipher"
    ## Slot "forward":
    ##   0  12  13  14  15  17  19  23  24  25  26  27  28  31  32  33  34  35  36  37  38  40
    ## "A" "B" "C" "D" "E" "F" "G" "H" "I" "K" "L" "M" "N" "O" "P" "Q" "R" "S" "T" "U" "V" "W"
    ##  45  46  48  50  51  52  54  56  57  58   6  60  61  63  65  66  70  71  74  75   x
    ## "Y" "Z" "a" "b" "c" "d" "e" "f" "g" "h" "i" "j" "k" "l" "m" "n" "o" "p" "q" "r" "s"
    ##
    ## Slot "reverse":
    ##    A    B    C    D    E    F    G    H    I    K    L    M    N    O    P    Q    R
    ##  "0" "12" "13" "14" "15" "17" "19" "23" "24" "25" "26" "27" "28" "31" "32" "33" "34"
    ##    S    T    U    V    W    Y    Z    a    b    c    d    e    f    g    h    i    j
    ## "35" "36" "37" "38" "40" "45" "46" "48" "50" "51" "52" "54" "56" "57" "58"  "6" "60"
    ##    k    l    m    n    o    p    q    r    s    -    ?
    ## "61" "63" "65" "66" "70" "71" "74" "75"  "x"  ":"  "?"
    ##
    ## Slot "bytes":
    ## [1] 1

Now we are able to convert the original sequences of numbers into words in our specialized alphabet.

    head(LR)

    ##                             LR1                             LR2
    ##  "50-74-0-0-50-74-0-50-74-0-50"  "74-0-63-74-0-50-74-0-50-74-0"
    ##                             LR3                             LR4
    ## "50-74-0-50-74-0-50-74-0-50-74" "74-50-74-0-50-74-0-50-74-0-50"
    ##                             LR6                             LR7
    ##     "0-50-74-0-50-74-0-50-74-0"              "74-0-0-0-50-74-0"

    pseudo <- encode(alphabet, LR)
    head(pseudo)

    ##           LR1           LR2           LR3           LR4           LR6           LR7
    ## "bqAAbqAbqAb" "qAlqAbqAbqA" "bqAbqAbqAbq" "qbqAbqAbqAb"  "AbqAbqAbqA"     "qAAAbqA"

## Clusters

The recoding step is already included in the `SequenceCluster` constructor and will happen automatically. So, to cluster the long-read break point sequences, we just need to call the constructor on the (named and de-duplicated) sequences. The core algorithm is the classic Needelman-Wunsch global alignment algorithm, as implemented in the `NameNeedle` R package. As used in the `SVAlignR` package, it computes an alignment score between any two sequences based on a match score of 2, a mismatch penalty of -6, and a gap penalty of -2. We fill a square matrix with scores for every pair (I, J) of sequences. The resulting scores can be either positive or negative. For each row X, we rescale into the interval [0,1] by computing \[Y_{IJ} = 1 - \frac{X_{IJ} - min(X_{I.})}{max(X_{I.})-min(X_{I.})}.\] Since the scores need not be symmetric, we force symmetry by averaging the scaled score matrix and its transpose.

We then perform hierarchical clustering using the symmetrized, rescaled score matrix to define distances. We cut the dendrogam to cluster the sequences into 20 groups. The number 20 is arbitrary. In our data set, it should yield about 8 long-read break-point sequences per cluster. For later steps, we also want to make sure that each cluster contains at leeast 2 sequences.

    seqclust <- SequenceCluster(LR0, NC = 15)

    ## Warning in SequenceCluster(LR0, NC = 15): Removing 39 duplicated sequences.

    table(seqclust@clusters)

    ##
    ##  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
    ##  9 24 14  6  7 10  7 13 14  5  9  7 18  7  8

Now we see why 20 clusters is appropriate. Each cluster is a relatively small size, but they all contain more than one sequence so that aligning those sequences will be meaningful. Figure 1 displays the resulting dendrogram, with each of the 20 clusters shown in a different color.

    plot(seqclust)

Figure 1: Hierarchical clustering of break point sequences.

Figure 2 contains a simplified view of the same dendrogram, cutting off the clutter below the major splits into clusters.

    plot(seqclust, type = "clipped")

Figure 2: Uncluttered clusters.

Figure 3 displays an alternate view of the dendrogram, emphasizing the relationships between clusters.

    plot(seqclust, type = "unrooted")

Figure 3: Unrooted phylogeny tree.

Both Figure 2 and, more strongly, Figure 3 indicate that there are approximately four major clusters.

We can also show the dendrogram attached to a heatmap containing the distance matrix.

    heat(seqclust)

Figure 4: Heatmap of the distance matrix.

# Aligning Clustered Sequences

Each of the clusters uses a small enough set of symbols that we can safely rewrite them as though they are sequences of amino acids. We then use the `align` function from the `SVAlignR` package to perform multiple sequence alignments, using the “ClustalW” algorithm on each cluster. Because the components are not amino acids, however, we do not need to use one of the PAM or BLOSUM matrices. Instead, we define a substitution matrix that gives the same match and mismatch scores to each pair of symbols.

    if (!requireNamespace("msa", quietly = TRUE)) {
      stop("Cluster alignment only works if the `msa` package is available.")
    }
    MYSUB <- makeSubsMatrix(match = 2, mismatch = -6)
    consensus <- alignAllClusters(seqclust, mysub = MYSUB, gapO = 2, gapE = 0.1)

    ## use user defined matrix
    ## use user defined matrix
    ## use user defined matrix
    ## use user defined matrix
    ## use user defined matrix
    ## use user defined matrix
    ## use user defined matrix
    ## use user defined matrix
    ## use user defined matrix
    ## use user defined matrix
    ## use user defined matrix
    ## use user defined matrix
    ## use user defined matrix
    ## use user defined matrix
    ## use user defined matrix

    class(consensus)

    ## [1] "list"

    length(consensus)

    ## [1] 15

Figure 5 contains an example of a multiple sequence alignment.

    opar <- par(mai = c(1.02, 0.82, 0.82, 0.82))
    image(consensus[[15]])

Figure 5: Example alignment of cluster 15.

    par(opar)
    rm(opar)

## Alignments By Cluster

Figure 6 shows all 20 alignments.

    opar <- par(mfrow = c(4,3))
    for (K in 1:length(consensus)) {
      image(consensus[[K]], col = SVAlignR:::myColorSet[K])
    }

Figure 6: Multiple sequence alignments of clusters.

    par(opar)

Figure 6: Multiple sequence alignments of clusters.

## Consensus Sequences

    library(dendextend)

    ##
    ## ---------------------
    ## Welcome to dendextend version 1.19.0
    ## Type citation('dendextend') for how to cite the package.
    ##
    ## Type browseVignettes(package = 'dendextend') for the package vignette.
    ## The github page is: https://github.com/talgalili/dendextend/
    ##
    ## Suggestions and bug-reports can be submitted at: https://github.com/talgalili/dendextend/issues
    ## You may ask questions at stackoverflow, use the r and dendextend tags:
    ##   https://stackoverflow.com/questions/tagged/dendextend
    ##
    ##  To suppress this message use:  suppressPackageStartupMessages(library(dendextend))
    ## ---------------------

    ##
    ## Attaching package: 'dendextend'

    ## The following object is masked from 'package:stats':
    ##
    ##     cutree

    myColorSet <- SVAlignR:::myColorSet
    NC <- seqclust@NC
    mycut <- mean(rev(seqclust@hc$height)[NC + -1:0])
    hcd <- as.dendrogram(seqclust@hc)
    dend2 <- cut(hcd, h = mycut)
    DU <- dend2$upper
    blocks <- sapply(consensus, function(X) {
      X@consensus
    })
    labels(DU) <- paste("   [", 1:NC, "]  ",  blocks, "   ", sep = "")

    opar <- par(bg = "gray90")
    plot(ape::as.phylo(DU), type = "unrooted", rotate.tree = -20,
         edge.color = "black", edge.width = 2,
         lab4ut = "axial",
         tip.color = myColorSet[1:NC], cex = 1.2)

Figure 7: Unrooted phylogeny tree with consensus sequences.

    par(opar)

# De Bruijn Graphs

We also provide the ability to compute de Bruijn graphs from any set of sequences. These graphs are frequently used for “de novo” sequencing, and thus can provide an alternative source of information about “consensus sequence” alignments. In order to work with graphs in general, you should load the `igraph` package.

    library("igraph")

    ##
    ## Attaching package: 'igraph'

    ## The following objects are masked from 'package:stats':
    ##
    ##     decompose, spectrum

    ## The following object is masked from 'package:base':
    ##
    ##     union

    DB <- deBruijn(seqclust@rawSequences, 4)
    G <- DB@G
    loci <- layout_with_fr(G)
    plot(G, layout = loci)

    count_components(G)

    ## [1] 13

    comp <- components(G)
    sort(comp$csize)

    ##  [1]  1  1  2  2  2  2  3  3  3  4  4 32 84

    GC <- largest_component(G)
    gloc <- layout_with_gem(GC)
    plot(GC, layout = gloc)

    sort(table(E(GC)$weight))

    ##
    ##  6 11 17 20 21 28  7  8 14  9  4  3  5  2  1
    ##  1  1  1  1  1  1  2  2  2  3  6  8  8 21 46

    GE <- delete_edges(GC, E(GC)[E(GC)$weight  < 6])
    GE <- largest_component(GE)
    #GE <- set_edge_attr(GE, "width", value = sqrt(edge_attr(G, "weight")))
    gloc <- layout_with_fr(GE)
    plot(GE, layout = gloc)

    dbset <- lapply(1:seqclust@NC, function(J) {
      rs <- seqclust@rawSequences[seqclust@clusters == J]
      db <- deBruijn(rs, 4)
    })
    L <- length(dbset)
    for (I in 1:L) {
      G <- dbset[[I]]@G
      lyt <- layout_with_fr(G)
      plot(G, layout =lyt, main = paste("Cluster", I))
    }

# Appendix

    sessionInfo()

    ## R version 4.5.0 (2025-04-11 ucrt)
    ## Platform: x86_64-w64-mingw32/x64
    ## Running under: Windows 11 x64 (build 26100)
    ##
    ## Matrix products: default
    ##   LAPACK version 3.12.1
    ##
    ## locale:
    ## [1] LC_COLLATE=English_United States.utf8  LC_CTYPE=English_United States.utf8
    ## [3] LC_MONETARY=English_United States.utf8 LC_NUMERIC=C
    ## [5] LC_TIME=English_United States.utf8
    ##
    ## time zone: America/New_York
    ## tzcode source: internal
    ##
    ## attached base packages:
    ## [1] stats     graphics  grDevices utils     datasets  methods   base
    ##
    ## other attached packages:
    ## [1] igraph_2.1.4      dendextend_1.19.0 SVAlignR_0.9.1
    ##
    ## loaded via a namespace (and not attached):
    ##  [1] viridis_0.6.5           sass_0.4.10             generics_0.1.3
    ##  [4] stringi_1.8.7           lattice_0.22-6          digest_0.6.37
    ##  [7] magrittr_2.0.3          evaluate_1.0.3          grid_4.5.0
    ## [10] fastmap_1.2.0           jsonlite_2.0.0          ape_5.8-1
    ## [13] GenomeInfoDb_1.43.4     gridExtra_2.3           httr_1.4.7
    ## [16] UCSC.utils_1.3.1        viridisLite_0.4.2       scales_1.3.0
    ## [19] Biostrings_2.75.4       jquerylib_0.1.4         cli_3.6.4
    ## [22] rlang_1.1.6             crayon_1.5.3            scatterplot3d_0.3-44
    ## [25] XVector_0.47.2          munsell_0.5.1           cachem_1.1.0
    ## [28] yaml_2.3.10             tools_4.5.0             parallel_4.5.0
    ## [31] dplyr_1.1.4             colorspace_2.1-1        ggplot2_3.5.2
    ## [34] GenomeInfoDbData_1.2.14 BiocGenerics_0.53.6     msa_1.39.5
    ## [37] vctrs_0.6.5             R6_2.6.1                stats4_4.5.0
    ## [40] lifecycle_1.0.4         stringr_1.5.1           oompaBase_3.2.10
    ## [43] S4Vectors_0.45.4        NameNeedle_1.2.8        IRanges_2.41.3
    ## [46] cluster_2.1.8.1         pkgconfig_2.0.3         bslib_0.9.0
    ## [49] pillar_1.10.2           gtable_0.3.6            glue_1.8.0
    ## [52] Rcpp_1.0.14             xfun_0.52               tibble_3.2.1
    ## [55] tidyselect_1.2.1        rstudioapi_0.17.1       knitr_1.50
    ## [58] htmltools_0.5.8.1       nlme_3.1-168            rmarkdown_2.29
    ## [61] Polychrome_1.5.4        compiler_4.5.0