--- title: "Comparing STR profiles with STRprofilerR" author: "Jared Andrews" date: "`r Sys.Date()`" output: BiocStyle::html_document: toc_float: true vignette: > %\VignetteIndexEntry{Comparing STR profiles with STRprofilerR} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") ``` # Introduction Cell lines drift, get mislabelled, and cross-contaminate each other. Short tandem repeat (STR) profiling is the standard way to catch it: genotype a panel of highly polymorphic repeat markers, and compare the result against the material the model was supposed to come from. **STRprofilerR** compares STR profiles to each other or to a reference database, scores the similarity three ways, flags samples that look mixed, and can search the Cellosaurus knowledge base for a match. It is an R port of the [STRprofiler](https://github.com/j-andrews7/STRprofiler) Python package. **STRprofilerR is intended for research purposes only.** A low score is a prompt to investigate, not a verdict. # Installation You can install **STRprofilerR** from Bioconductor with: ```{r install, eval = FALSE} if (!requireNamespace("BiocManager", quietly = TRUE)) install.packages("BiocManager") BiocManager::install("STRprofilerR") # You can install the development version from Github pak::pak("j-andrews7/STRprofilerR") ``` # Reading profiles STR files typically come in one of two shapes (at least from our facility/platform), and `readSTRProfiles()` reads both. A **long** file has one row per sample and one column per marker: ```{r read-long} library(STRprofilerR) longFile <- system.file("extdata", "ExampleSTR_long.csv", package = "STRprofilerR") head(read.csv(longFile, check.names = FALSE)) ``` ```{r read-long-2} profiles <- readSTRProfiles(longFile, sampleCol = "Sample Name") profiles ``` A **wide** file has one row per sample/marker pair, with the alleles spread across numbered columns. Columns whose names contain `Allele` are collected; size and height columns are ignored. Point `markerCol` at the column containing marker names and the layout is detected automatically. Files of either shape, and of any supported format, can be read together. The marker set is the union across files, and samples missing a marker are recorded as untyped rather than as having no alleles. Untyped markers are skipped during scoring rather than counted as mismatches. ```{r read-both} xlsxFile <- system.file("extdata", "ExampleSTR.xlsx", package = "STRprofilerR") if (requireNamespace("readxl", quietly = TRUE)) { both <- readSTRProfiles(c(longFile, xlsxFile), sampleCol = "Sample Name") print(both) } ``` ## Alleles are stored pre-split Inside a `STRProfiles` object each marker is a `r Biocpkg("IRanges")` `CharacterList`, so the alleles for a sample are already a character vector rather than a string waiting to be split: ```{r alleles} alleles(profiles)[["marker1"]] ``` Allele calls are cleaned on the way in: duplicates collapse, numeric alleles sort ahead of string ones, and a trailing `.0` is dropped while a genuine decimal such as `9.3` is kept. ```{r clean} cleanAlleles("10.0,10,13,13.0,14,14 ") ``` ## Marker names Instruments and databases spell markers inconsistently. `markerAliases()` holds the harmonisation table, applied by default: ```{r markers} harmonizeMarkers(c("Penta D", "Penta_E", "vWA")) ``` Extend it with `extraAliases` when your instrument produces something the default table does not cover. Every marker is also classified at ingest, which is how amelogenin is found without you having to name the column: ```{r marker-data} markerData(profiles) ``` ## Metadata Database files often carry non-marker columns. Name them in `metadataCols` and they end up in `sampleData()`, ignored for scoring purposes: ```{r metadata} dbFile <- system.file("extdata", "main_database.csv", package = "STRprofilerR") reference <- readSTRProfiles(dbFile) dim(reference) head(sampleData(reference), 3) ``` If using an existing database with extra sample metadata, be sure to set this so those columns aren't used in scoring. This file is the database the Shiny application uses by default: profiles of patient-derived models from The Jackson Laboratory PDX program and the NCI Patient-Derived Models Repository. It's also provided as the `pdxSTRProfiles` dataset used [below](#workflow). # Scoring Let $M$ be the markers where **both** profiles were typed, $s$ the number of alleles they share across those markers, and $q$ and $r$ the number of alleles each carries across those same markers: $$\textrm{Tanabe} = 100 \times \frac{2s}{q + r} \qquad \textrm{Masters (query)} = 100 \times \frac{s}{q} \qquad \textrm{Masters (reference)} = 100 \times \frac{s}{r}$$ Tanabe is the Sørensen–Dice coefficient and is symmetric: swap the two profiles and the score is unchanged. The Masters scores are not, which can be useful. Masters (query) asks how much of the query the reference accounts for, which is useful when a query may be contaminated. A query containing everything the reference has *plus* extra alleles scores 100 on Masters (reference) while Tanabe drops. Amelogenin is a sex marker rather than a polymorphic STR, so it is left out unless you ask for it with `useAmel = TRUE`. ```{r score} queries <- readSTRProfiles(longFile, sampleCol = "Sample Name") refs <- readSTRProfiles( system.file("extdata", "ExampleSTR_database.csv", package = "STRprofilerR"), sampleCol = "Sample Name" ) scoreProfiles(queries, refs) ``` Scoring is vectorised as a handful of sparse matrix products rather than a loop over pairs, so comparing a batch against a database of thousands is very quick. `chunkSize` controls how many queries are held in memory at once, and `minScore` discards weak pairs as they are produced: ```{r score-filter} scoreProfiles(queries, refs, minScore = 90) ``` # Comparing end to end `compareProfiles()` runs scoring, flags mixing, and summarises. ```{r compare} cmp <- compareProfiles(queries, refs) cmp ``` The summary carries one row per query - the best two hits, and everything passing each threshold: ```{r summary} as.data.frame(summary(cmp))[, c("Sample", "mixed", "topHit", "nextBest")] ``` Leave `reference` out to compare a batch against itself, dropping self-comparisons: ```{r compare-self} compareProfiles(refs) ``` ## Mixing A diploid genome gives at most two alleles per autosomal marker. While models can get pretty messed up and may occasionally harbor a third allele, consistently seeing three or more in many markers suggests more than one genome in the tube. To help identify such cases, STRprofilerR's `flagMixedSamples()` counts markers with **more than two** alleles and flags a sample when that count **exceeds** `threeAlleleThreshold` (3, by default). ```{r mixing} batch <- readSTRProfiles( system.file("extdata", "Example_Batch_File.csv", package = "STRprofilerR") ) flagMixedSamples(batch) ``` It is worth checking flagged samples more closely. # Writing results `writeSTRResults()` produces the file set the Python command line tool does, prefixed `STRprofilerR` rather than `strprofiler`: ```{r write} outDir <- file.path(tempdir(), "STRprofilerR-vignette") written <- writeSTRResults(cmp, outDir) basename(written) ``` That is a summary table, one table per query listing every reference it was compared against with that reference's alleles, a self-contained HTML view of the summary, and a log recording the parameters, inputs, and versions used. # Querying Cellosaurus When you have no local reference for a line, [CLASTR](https://www.cellosaurus.org/str-search/) searches the human profiles in Cellosaurus. Check your marker names against its controlled vocabulary first, as CLASTR silently ignores markers it does not recognise: ```{r validate} validateClastrMarkers(markers(refs)) ``` The query itself needs network access, so it is not run here: ```{r clastr, eval = FALSE} profile <- c( Amelogenin = "X", CSF1PO = "11,12", D2S1338 = "19,23", D3S1358 = "15,17", D5S818 = "11,12", D7S820 = "10", D8S1179 = "10", D13S317 = "11,12", D16S539 = "11,12", D18S51 = "13", D19S433 = "14", D21S11 = "29,30", FGA = "20,22", PentaD = "11,13", PentaE = "14,16", TH01 = "6,9", TPOX = "8,9", vWA = "17,19" ) hits <- clastrQuery(profile, scoreFilter = 90) as.data.frame(hits)[, c("accession", "name", "score", "problem")] ``` A cell line with a known contamination history carries a note in `problem`. A result that CLASTR returns as two profiles appears as two rows, with `(Best)` and `(Worst)` appended to the accession. `clastrBatchQuery()` writes CLASTR's own multi-sheet workbook instead, when you want its presentation rather than the data. # The command line application The CLI is built on [Rapp](https://github.com/r-lib/Rapp). Install the launcher once: ```{r cli-install, eval = FALSE} install.packages("Rapp") Rapp::install_pkg_cli_apps("STRprofilerR") ``` ```bash strprofilerr compare --database refs.csv -o ./results STR1.xlsx STR2.csv strprofilerr clastr --score-filter 90 -o ./results batch.csv strprofilerr app --database refs.csv strprofilerr compare --help ``` Flags mirror the Python CLI, and both spellings are accepted, so `--sample-col` and `--sample_col` both work and existing invocations keep running. # The Shiny application For colleagues who would rather not write code, `STRprofilerApp()` serves the same comparisons in a web browser. It is a port of the `strprofiler` Python package's application and needs the `shiny`, `bslib`, and `DT` packages. ```{r app, eval = FALSE} STRprofilerApp() # Against your own database instead of the bundled one. STRprofilerApp(database = "our_database.csv") ``` The application has four tabs: - **Single Query**: type a profile in marker by marker and score it against the database or search Cellosaurus with it. Alleles differing from the query are highlighted in the report. - **Batch Query**: upload a file of profiles and compare it against the database, within itself, or against Cellosaurus. - **Database File Management**: swap in a custom database for the session. - **Usage Guide**: how to read the reports, and what to cite. From a shell, `strprofilerr app --database our_database.csv` does the same. To host the application for others, deploy an `app.R` that calls `STRprofilerR::STRprofilerApp()` with your database. One ships with the package, at `system.file("app", "app.R", package = "STRprofilerR")`. # STRprofilerR in a larger workflow {#workflow} STR authentication is rarely an end in itself. It is a quality control step that decides which models go on into experiments, and its results belong with the data those experiments produce. ## Bioconductor data structures `STRProfiles` is built from `r Biocpkg("S4Vectors")` and `r Biocpkg("IRanges")` classes rather than bespoke ones. `sampleData()`, `markerData()`, `scores()`, and `summary()` all return `DataFrame`s, and each marker's alleles are a `CharacterList`, so the usual Bioconductor idioms for subsetting, merging, and list-wise operations apply. Counting the alleles each sample carries at a marker, for instance: ```{r bioc-classes} lengths(alleles(batch)[["D5S818"]]) ``` ## The bundled PDX database `pdxSTRProfiles` holds 1,258 STR profiles from The Jackson Laboratory PDX program and the NCI Patient-Derived Models Repository, ready to use as a reference: ```{r pdx} data(pdxSTRProfiles) pdxSTRProfiles pdxCmp <- compareProfiles(batch, pdxSTRProfiles) as.data.frame(summary(pdxCmp))[, c("Sample", "mixed", "topHit", "nextBest")] ``` ## Generating profiles from raw data `STRProfiles()` provides the ability to build profiles a `data.frame` already in memory, such as the result of a LIMS query, an instrument export after some reshaping, or STR calls made from sequencing data by another tool. It needs one row per sample and one column per marker, and need not cover the full panel: ```{r from-df} calls <- data.frame( Sample = "PDX_042", Amelogenin = "X", CSF1PO = "11", D13S317 = "9,14", D16S539 = "12", D18S51 = "17", D21S11 = "29,31", D3S1358 = "15", D5S818 = "11", D7S820 = "9,11", D8S1179 = "13,15", FGA = "21,22", TH01 = "9.3", TPOX = "8", vWA = "14" ) newModel <- STRProfiles(calls) scoreProfiles(newModel, pdxSTRProfiles, minScore = 80) ``` ## Carrying authentication info with an experiment Generally, authentication is frequently performed prior to larger experiments, and these profiles may be worth carrying along with said assay data. Recording each sample's authentication result in the `colData` of its `r Biocpkg("SummarizedExperiment")` keeps the quality control with the data, so it survives subsetting and goes wherever the object goes. The same applies to the `colData` of a `SingleCellExperiment` or `MultiAssayExperiment`. In this example, the experiment's sample sheet records which model each sample should be, and `Sample_C` has been swapped: ```{r se, eval = requireNamespace("SummarizedExperiment", quietly = TRUE), message = FALSE} library(SummarizedExperiment) # A stand-in for an experiment on the three models in the batch. counts <- matrix( rpois(30, lambda = 50), nrow = 10, dimnames = list(paste0("gene", 1:10), rownames(batch)) ) se <- SummarizedExperiment( assays = list(counts = counts), colData = DataFrame( model = c("J000077451", "J000077960", "J000077451"), row.names = rownames(batch) ) ) # Each sample's best match in the reference. pairs <- scores(pdxCmp) pairs <- pairs[order(pairs$query, -pairs$tanabeScore), ] best <- pairs[!duplicated(pairs$query), ] best <- best[match(colnames(se), best$query), ] se$strMatch <- best$reference se$strTanabe <- best$tanabeScore se$strMixed <- summary(pdxCmp)[colnames(se), "mixed"] se$authenticated <- se$strMatch == se$model & se$strTanabe >= 80 & !se$strMixed colData(se) # Keep only authenticated samples for analysis. se[, se$authenticated] ``` ## Maintaining a reference database Once a new model is authenticated, its profile could become a reference for the next batch. `c()` combines `STRProfiles` objects, taking the union of their markers, and `writeSTRProfiles()` writes a file that `readSTRProfiles()`, the command line application's `--database`, and `STRprofilerApp()` can all read: ```{r grow-db} inHouse <- c(pdxSTRProfiles, newModel) inHouse writeSTRProfiles(inHouse, file.path(tempdir(), "in_house_database.csv")) ``` This makes updating a reference database straightforward. # Session info ```{r session} sessionInfo() ```