Skip to contents

This vignette will demonstrate how to work with large datasets in tidypopgen.

This includes whole-genome data. Note that tidypopgen only handles variant sites, so if you are using a whole-genome dataset make sure it has been previously subsetted to variant sites only. Think very carefully before merging this kind of data during your pipeline!

The latest version of tidypopgen has implemented a new option to minimise the memory footprint of very large datasets. As a large chunk of memory is occupied by loci names, the gen_tibble() function now offers the user the option to switch loci names from character strings to integers, reducing the memory used by the gen_tibble object.

Getting the data

Downloading the SGDP data

We will demonstrate the new capacity of tidypopgen using a subset of the Simons Genome Diversity Project (SGDP), containing 10 individuals each from Africa, Oceania, America, West Eurasia and East Asia. The data are too large to download as part of this article, but the create_sgdp_subset_* scripts in the data-raw folder of the tidypopgen repository let you create the dataset yourself if you wish to follow along.

The 50 individuals are listed in the files afr_subset.txt, oceania_subset.txt, america_subset.txt, west_eur_subset.txt and east_asia_subset.txt, in data-raw/data_files/sgdp. These were created by create_sgdp_subset_s01_metadata.R, which takes the first 10 fully public individuals from each region in the SGDP metadata.

SGDP provides a VCF for each individual, but distributes them as a single 57 GB archive containing all 279 public individuals. The shell script create_sgdp_subset_s02.sh downloads only the parts of the archive that hold our 50 individuals (about 10.8 GB), checks them against the checksums provided by SGDP, and merges them into a single VCF, all_subset.vcf.gz, with bcftools. You will need bcftools and curl installed and available on your PATH. From the root of a copy of the tidypopgen repository, run:

bash data-raw/create_sgdp_subset_s02.sh vignettes/articles/data/sgdp

This writes all_subset.vcf.gz and its index to vignettes/articles/data/sgdp/, which is where we read it from below (the paths in this article are relative to vignettes/articles/). The individual VCFs are kept in vignettes/articles/data/sgdp/sgdp_vcfs/, so that an interrupted download can be resumed by running the script again; once the merged VCF has been created, they can be deleted.

Note that each SGDP VCF only contains the sites at which that individual carries a non-reference allele. When merging, a site that is absent from an individual’s VCF is therefore set to homozygous for the reference allele (with the --missing-to-ref option of bcftools merge). Without this option, every homozygous reference genotype would be recorded as missing. The cost is that a site that was not sequenced in an individual is also set to homozygous reference, which is a reasonable approximation for these high-coverage genomes.

Reading a large VCF into a gen_tibble

Now that we have our data we can begin by loading the necessary libraries:

library(tidypopgen)
#> Loading required package: dplyr
#> 
#> Attaching package: 'dplyr'
#> The following objects are masked from 'package:stats':
#> 
#>     filter, lag
#> The following objects are masked from 'package:base':
#> 
#>     intersect, setdiff, setequal, union
#> Loading required package: tibble
library(ggplot2)

In many descriptive population genetic analyses, where the end goal is to visualise populations (e.g., PCA) or calculate a summary statistic (e.g. FST), the names of your loci are not needed for the analysis. In these cases, we can save a lot of memory (over half of the memory footprint of the gen_tibble) by switching the loci names from strings to integers.

This option is only enabled when loading from a .vcf file. To do this, we need to set parameter names_as_int to TRUE when creating the gen_tibble:

sgdp <-
  gen_tibble("data/sgdp/all_subset.vcf.gz",
    quiet = TRUE,
    backingfile = "data/sgdp/all_subset",
    names_as_int = TRUE
  )

When we now call our loci tibble with show_loci(), we can see that the loci names are now integers:

show_loci(sgdp)
#> # A tibble: 20,728,532 × 7
#>    big_index  name chromosome position genetic_dist allele_ref allele_alt
#>        <int> <int> <fct>         <int>        <dbl> <chr>      <chr>     
#>  1         1     1 1             10109            0 A          T         
#>  2         2     2 1             10177            0 A          C         
#>  3         3     3 1             10180            0 T          C         
#>  4         4     4 1             10231            0 C          A         
#>  5         5     5 1             10234            0 C          G         
#>  6         6     6 1             10241            0 T          A         
#>  7         7     7 1             10248            0 A          T         
#>  8         8     8 1             10250            0 A          C         
#>  9         9     9 1             10255            0 A          T         
#> 10        10    10 1             10257            0 A          C         
#> # ℹ 20,728,522 more rows

The rsID’s of each SNP have been removed, but all other metadata (chromosome, position, genetic distance, and alleles) are retained. We can check the memory footprint of the gen_tibble using:

format(object.size(sgdp), units = "auto", digits = 2)
#> [1] "790.76 Mb"

Setting up cores

Because tidypopgen utilizes disk storage through file-backed matrices (FBM), we can run the following pipeline on a modern laptop without running out of RAM. Here, we use a machine with a GenuineIntel, 13th Gen Intel(R) Core(TM) i9-13900, 24 CPU and 126 GiB of RAM.

Before we begin, we will limit the number of cores used by the parallelised BLAS library with bigparallelr::set_blas_ncores(), and by the package data.table with data.table::setDTthreads(). Note You may need to adjust n_cores according to your own hardware.

n_cores <- 20
data.table::setDTthreads(n_cores)
bigparallelr::set_blas_ncores(n_cores)

Quality control for large datasets

Individual and loci reports

All the normal functionality of tidypopgen is preserved, as our qc_report functions exemplify:

sgdp_indiv_rep <- qc_report_indiv(sgdp)

indiv_report: 537ms

sgdp <- sgdp %>% group_by(Region)
sgdp_loci_rep <- qc_report_loci(sgdp)
sgdp <- sgdp %>% ungroup()

loci_report: 406ms

However, it is important to be mindful that analyses on very large datasets will produce large outputs:

format(object.size(sgdp_loci_rep), units = "auto", digits = 2)
#> [1] "553.51 Mb"

In this case, a loci report on 20728532 loci for 50 individuals occupies approximately 553.51 Mb of memory.

Let’s plot the overview of the loci report:

autoplot(sgdp_loci_rep,
  maf_threshold = 0.05,
  miss_threshold = 0.05
)
plot of chunk autoplot_loci
plot of chunk autoplot_loci

We can see from the Upset plot above that MAF filtering will remove a large number of loci. WGS data require heavy filtering for MAF to avoid including uninformative loci in analyses (e.g. sequencing errors or private mutations).

On the other hand, no loci fail the missingness threshold. Because sites absent from an individual’s VCF were set to homozygous reference when we merged the VCFs, there are no missing genotypes in this dataset. We can check the number of loci with any missing genotypes:

sum(sgdp_loci_rep$missingness > 0)
#> [1] 0

When objects are no longer needed, or once results outputs have been saved, we can remove them using rm() to keep memory usage under control:

rm(sgdp_loci_rep)

Filtering

We can then proceed with basic data filtering in the same way as working with any other gen_tibble object.

We filter for minor allele frequency, choosing to select loci with MAF greater than 5%:

sgdp <- sgdp %>%
  select_loci_if(loci_maf(genotypes) > 0.05)
cat("Loci remaining:", count_loci(sgdp))
#> Loci remaining: 7456643

maf_filter: 463ms

With other datasets, we would also remove loci with too many missing genotypes (e.g. with select_loci_if(loci_missingness(genotypes) < 0.1)), and then impute the remaining missing genotypes with gt_impute_simple(), as LD clumping and PCA do not accept missing values. Our dataset has no missing genotypes, so we can skip both steps.

Updating the backing file

After removing loci, we update the file backing matrix with gt_update_backingfile(), so that it only contains the loci that we kept:

sgdp <- gt_update_backingfile(sgdp, quiet = TRUE,
                              backingfile = tempfile())

update_backingfile: 6.1s

This will help us with the next step (LD clumping).

LD clumping

Then we can LD clump the dataset to remove loci in linkage disequilibrium.

With many loci, this step can be a little more time consuming, so it is important to remember to specify the n_cores parameter to speed things up:

sgdp <- sgdp %>%
  select_loci_if(loci_ld_clump(genotypes,
    n_cores = n_cores,
    thr_r2 = 0.2
  ))
cat("Loci remaining:", count_loci(sgdp))
#> Loci remaining: 507481

ld_clump: 7.9s

Analysis

PCA

Now that we have a clean dataset, we can proceed with our analysis by plotting a PCA:

sgdp_pca <- sgdp %>% gt_pca_partialSVD()

pca: 612ms

autoplot(sgdp_pca, type = "scores") +
  aes(colour = sgdp$Region) +
  labs(color = "Region")
plot of chunk PCA
plot of chunk PCA

Our PCA separates the five regions. PC1 (5.23% of the variance) runs from the African individuals on the left, through West Eurasia, Oceania and East Asia, to the American individuals on the right, and PC2 (3.86%) separates the Oceanian and East Asian individuals at the top from the American individuals at the bottom. The individuals from America, West Eurasia and East Asia form fairly tight clusters, whereas the African individuals are spread out along PC1: the Mbuti, Ju/’hoan North, Yoruba, Mandenka and Dinka individuals are on the far left, followed by the Masai and Somali individuals from East Africa, while the two Mozabite individuals from Algeria lie close to the West Eurasian cluster. This is consistent with North and East African populations carrying some West Eurasian-related ancestry. The Oceanian individuals are also spread out: the Papuan, Bougainville and Australian individuals are at the top, the Maori individual is closer to the centre of the plot, and the Hawaiian individual and the two Dusun individuals (from Borneo) lie within the East Asian cluster. This reflects the Austronesian ancestry of these populations, which is largely related to East Asian populations.

DAPC

We can also perform a discriminant analysis of principal components (DAPC) using gt_dapc.

sgdp_dapc <- gt_dapc(sgdp_pca,
  pop = as.factor(sgdp$Region)
)

dapc: 29ms

autoplot(sgdp_dapc, type = "components")
plot of chunk DAPC_components_plot
plot of chunk DAPC_components_plot

Our DAPC autoplot shows that most individuals are assigned to their own region’s group with a probability close to one, showing that there is genetic differentiation between the continental regions. The exceptions are consistent with the PCA above: the two Mozabite individuals are assigned to the West Eurasian group (with probabilities of 0.93 and 0.99), the two Dusun individuals and the Hawaiian individual are assigned to the East Asian group (with probabilities of 0.96, 0.98 and 0.91), and the Maori individual has a probability of 0.10 of East Asian membership. These individuals may have admixed ancestry, or (as for the Dusun) be closer to the East Asian populations than to the other Oceanian populations in our sample.

Pairwise FST

Finally, to examine the differentiation between regions, we can calculate pairwise FST.

grouped_sgdp <- sgdp %>% group_by(Region)
pairwise_fst <- grouped_sgdp %>% pairwise_pop_fst(
  n_cores = n_cores,
  type = "pairwise"
)

autoplot(pairwise_fst,
  order = function(x) {
    stats::hclust(stats::as.dist(x), method = "complete")
  }
) +
  scale_fill_viridis_c() +
  theme(
    axis.text.x = element_text(angle = -60, hjust = 0, size = 6),
    axis.text.y = element_text(angle = 0, hjust = 1, size = 6)
  ) +
  labs(
    x = element_blank(),
    y = element_blank(),
    fill = expression("Pairwise F"[ST])
  )

plot of chunk pairwise_fstpairwise_fst: 188ms

The pairwise FST values are all small (between 0.02 and 0.08), as is typical of human populations. America is the most differentiated region, and Africa and America are the most differentiated pair (FST = 0.079); America is closest to East Asia (0.043). East Asia and Oceania are the least differentiated pair (0.017), reflecting the East Asian-related ancestry of the Dusun, Hawaiian and Maori individuals in our Oceanian sample. Africa is closest to West Eurasia (0.038), which partly reflects the West Eurasian-related ancestry of the Mozabite, Somali and Masai individuals in our African sample. Note that with only 10 individuals per region, these estimates are imprecise.

Performance summary

Here is a summary of the time taken (in seconds) to perform each step using a large dataset:

#>                 step  time
#> 1       indiv_report  0.54
#> 2        loci_report  0.41
#> 3         maf_filter  0.46
#> 4 update_backingfile  6.06
#> 5           ld_clump  7.86
#> 6                pca  0.61
#> 7               dapc  0.03
#> 8       pairwise_fst  0.19
#> 9              Total 16.16

Key takeaways

  • Large data don’t need a large machine. gen_tibble objects are file-backed, so the genotypes stay on disk and only what you are working on is held in memory.
  • Use names_as_int = TRUE when reading a VCF to reduce the memory taken by locus names. The trade-off is that the original locus IDs are not kept (chromosome and position are), so rbind() matches loci by chromosome and position.
  • Set your cores. Limit BLAS and data.table threads at the start, and pass n_cores to the functions that support it (e.g. LD clumping and FST).
  • Large analyses make large outputs. Reports such as qc_report_loci() can be big, so save what you need and rm() the rest.