Skip to contents

Overview

A QtlDataset is an individual-level container for a single study: one genotype source plus a per-context set of molecular phenotypes. MultiStudyQtlDataset bundles several QtlDatasets (optionally alongside a summary-statistics-only QtlSumStats) so a molecular trait measured across multiple studies can be analyzed with downstream pipelines together.

It is a MultiAssayExperiment, so the whole multi-assay surface works on it directly. Each QTL context is one experiment, and the genotypes are one more:

  • genotype — rowRanges is one range per variant (carrying A1 / A2); the dosage assay is variants x samples, read lazily from the genotype files; colData holds genotype-derived covariates such as ancestry PCs.
  • one per context — rowRanges is trait positions, the assay is traits x samples, and colData holds that context’s phenotype covariates.

Alongside them the object keeps a colData naming every sample and a sampleMap recording which of them each experiment observes — so contexts need not share a sample set — plus its own slots:

  • study — study identifier (length-1 character).
  • genotypes — the genotype panel (a lazy PLINK1/PLINK2/VCF/GDS/BGEN source, read with readGenotypes()) that the dosage assay reads through.
  • QC thresholds — mafCutoff, macCutoff, xvarCutoff, imissCutoff, keepIndel, keepVariants, scaleResiduals. Stored as lazy filters, applied at genotype-extraction time.

To restrict a dataset to a set of samples, subset it: x[, samples, ] narrows colData and sampleMap together, and keeps the class and its slots. The keepSamples constructor argument is shorthand for the same thing.

There are two ways to build a QtlDataset:

  • From a manifest — loadQtlDatasetFromManifest() reads a table (data.frame or file) that points at the phenotype / covariate / genotype files and assembles the object for you. This is the recommended route for real data.
  • Manually — build the per-context SummarizedExperiments and the genotype panel yourself and call the QtlDataset() constructor. Useful when your data is already in memory.

Toy data for this vignette

We will reuse the toy_ref PLINK1 panel shipped with the package as the genotype source, take its first few samples as our phenotyped individuals, and write two small per-context phenotype BED files (TensorQTL-style layout: #chr, start, end, gene_id, then one column per sample) plus a covariate table to a temporary directory.

genoPrefix <- sub(
    "\\.bed$",
    "",
    system.file("extdata", "toy_ref.bed", package = "pecotmr")
)
samples <- head(colnames(readGenotypes(plink1Prefix = genoPrefix)), 40)

workdir <- file.path(tempdir(), "qtl-vignette")
dir.create(workdir, showWarnings = FALSE)

# Two genes on chr22, positioned inside the toy_ref variant span so a cis
# window captures nearby variants.
writePhenoBed <- function(path, seed) {
    set.seed(seed)
    bed <- data.frame(
        `#chr` = c("chr22", "chr22"),
        start = c(14600000L, 14900000L),
        end = c(14600001L, 14900001L),
        gene_id = c("GENE1", "GENE2"),
        check.names = FALSE
    )
    for (s in samples) {
        bed[[s]] <- round(rnorm(2), 3)
    }
    readr::write_tsv(bed, path)
    path
}
muscleBed <- writePhenoBed(file.path(workdir, "Muscle.bed"), 1)
bloodBed <- writePhenoBed(file.path(workdir, "Blood.bed"), 2)

# Per-context covariate table: samples as rows, covariate columns after an ID
# column (here two genotype PCs).
covPath <- file.path(workdir, "covariates.tsv")
set.seed(3)
readr::write_tsv(
    data.frame(
        sample = samples,
        PC1 = round(rnorm(length(samples)), 3),
        PC2 = round(rnorm(length(samples)), 3),
        check.names = FALSE
    ),
    covPath
)

Constructing a QtlDataset from a manifest

The QtlDataset manifest has one row per context. Canonical column names are camelCase; snake_case aliases such as cond, cov_path, genotype_prefix are also accepted and normalized on read.

Column Required Meaning
context (alias cond) yes context / condition label
phenotypePath (alias path) yes bgzipped or plain BED phenotype file
covariatePath (alias cov_path) no per-context covariate table
study single-valued study identifier (may also be an argument)
genotypePath (alias genotype_prefix) single-valued genotype file or PLINK prefix (may also be an argument)
genotypeCovariatePath no, single-valued genotype-derived covariates applied to all contexts

study and genotypePath are single-valued for the whole dataset, so you can supply them either as a constant manifest column or as an argument; if you give both, they must agree.

manifest <- data.frame(
    context = c("Muscle", "Blood"),
    phenotypePath = c(muscleBed, bloodBed),
    covariatePath = c(covPath, covPath),
    study = "GTEx_toy",
    genotypePath = genoPrefix,
    stringsAsFactors = FALSE
)

# The path columns hold absolute paths; show basenames for readability
# (the real `manifest` keeps the full paths the loader needs).
transform(
    manifest,
    phenotypePath = basename(phenotypePath),
    covariatePath = basename(covariatePath),
    genotypePath = basename(genotypePath)
)
##   context phenotypePath  covariatePath    study genotypePath
## 1  Muscle    Muscle.bed covariates.tsv GTEx_toy      toy_ref
## 2   Blood     Blood.bed covariates.tsv GTEx_toy      toy_ref
qd <- loadQtlDatasetFromManifest(manifest)
qd
## QtlDataset for study 'GTEx_toy'
##   2 context(s): Muscle, Blood
##   2 unique traits across contexts
##   Genotypes: plink1 @ /tmp/RtmpmBLZhq/temp_libpathaae388f3498/pecotmr/extdata/toy_ref
##   Genotype covariates: 0 cols
##   Samples: 165
##   Scale residuals: TRUE

The manifest can equally be a path to a .csv (read as CSV) or any other extension (read as TSV):

manifestPath <- file.path(workdir, "qtl_manifest.tsv")
readr::write_tsv(manifest, manifestPath)
qd <- loadQtlDatasetFromManifest(manifestPath)

The QC thresholds are ordinary arguments (they are stored as lazy filters, not applied at load), for example:

qdFiltered <- loadQtlDatasetFromManifest(
    manifest,
    genotypeFilterParam = GenotypeFilterParam(
        mafCutoff = 0.01,
        imissCutoff = 0.05,
        keepIndel = FALSE
    )
)

Constructing a QtlDataset manually

The manifest loader is a thin wrapper over the QtlDataset() constructor. Doing it by hand shows exactly what the object contains: a named list of SummarizedExperiments and a genotype panel.

# One SummarizedExperiment per context. Rows are traits, columns are samples;
# rowRanges carries each trait's position, colData carries per-context
# covariates.
buildSE <- function(bedPath) {
    bed <- as.data.frame(
        readr::read_tsv(bedPath, show_col_types = FALSE),
        check.names = FALSE
    )
    sampleCols <- setdiff(names(bed), c("#chr", "start", "end", "gene_id"))
    expr <- as.matrix(bed[, sampleCols, drop = FALSE])
    rownames(expr) <- bed$gene_id
    rr <- GenomicRanges::GRanges(
        seqnames = bed$`#chr`,
        ranges = IRanges::IRanges(start = bed$start + 1L, end = bed$end)
    )
    names(rr) <- bed$gene_id
    cov <- as.data.frame(
        readr::read_tsv(covPath, show_col_types = FALSE),
        check.names = FALSE
    )
    cd <- S4Vectors::DataFrame(
        cov[match(sampleCols, cov$sample), c("PC1", "PC2"), drop = FALSE],
        row.names = sampleCols
    )
    SummarizedExperiment::SummarizedExperiment(
        assays = list(expression = expr),
        rowRanges = rr,
        colData = cd
    )
}

phenotypes <- list(Muscle = buildSE(muscleBed), Blood = buildSE(bloodBed))
genotypes <- readGenotypes(plink1Prefix = genoPrefix)

qdManual <- QtlDataset(
    studyName = "GTEx_toy",
    genotypes = genotypes,
    phenotypes = phenotypes
)
qdManual
## QtlDataset for study 'GTEx_toy'
##   2 context(s): Muscle, Blood
##   2 unique traits across contexts
##   Genotypes: plink1 @ /tmp/RtmpmBLZhq/temp_libpathaae388f3498/pecotmr/extdata/toy_ref
##   Genotype covariates: 0 cols
##   Samples: 165
##   Scale residuals: TRUE

This approach gives users more flexibility if they need to make more modifications to the molecular phenotype data after loading it from a file but before making a QtlDataset

MultiStudyQtlDataset

A MultiStudyQtlDataset needs at least two studies in total - at least two individual-level studies or at least one individual-level study and one summary-only study. When loading from a manifest, a qtlDatasetsManifest with study and genotypePath must be provided where rows are grouped by study and one QtlDataset is built per group. A sumStatsManifest describing summary-only studies can also be provided (see the Constructing QtlSumStats and GwasSumStats objects vignette).

multiManifest <- data.frame(
    study = c("StudyA", "StudyB"),
    context = c("Muscle", "Muscle"),
    phenotypePath = c(muscleBed, bloodBed),
    genotypePath = genoPrefix,
    stringsAsFactors = FALSE
)

msd <- loadMultiStudyQtlDatasetFromManifest(multiManifest)
msd
## MultiStudyQtlDataset: 2 individual-level + 0 sumstats studies
##   Individual-level studies: StudyA, StudyB

A MultiStudyQtlDataset can be manually constructed by building a named list of QtlDatasets and passing it to the constructor:

msdManual <- MultiStudyQtlDataset(
    qtlDatasets = list(StudyA = qd, StudyB = qdManual)
)
names(qtlDatasets(msdManual))
## [1] "StudyA" "StudyB"

To attach a summary-only study, pass a QtlSumStats object to the sumStats argument. For the manifest form, supply sumStatsManifest, genome, and ldReference.

Both fineMappingPipeline() and twasWeightsPipeline() fit models that assume independent samples. Related individuals break that assumption: they inflate the effective sample size, so cross-validated R-squared comes back optimistic and PIPs overconfident. Relatedness is therefore a QC step applied before the dataset is built — the pipelines do not model it.

filterRelatedness() takes a kinship table — one row per pair, the shape PLINK’s --genome or KING emits — and returns the individuals to drop:

relatedness <- data.frame(
    IID1 = c(samples[1], samples[3], samples[5]),
    IID2 = c(samples[2], samples[4], samples[6]),
    PI_HAT = c(0.50, 0.25, 0.125),
    stringsAsFactors = FALSE
)
excluded <- filterRelatedness(relatedness, relatednessThreshold = 0.0625)
excluded
## [1] "NA06989" "NA11843" "NA12739"
unrelated <- setdiff(samples, excluded)
length(unrelated)
## [1] 37

One member of each related pair comes back, chosen so that what remains is as large an unrelated set as possible. relatednessThreshold defaults to 0.0625, which is third-degree relatives; raise it to 0.125 to keep everyone but second-degree and closer.

The column names above are the defaults. A table that spells them differently is described with RelatednessColumnsParam():

filterRelatedness(
    relatedness,
    columns = RelatednessColumnsParam(iid1 = "ID1", iid2 = "ID2", value = "KINSHIP")
)

Building the description rather than passing a bare list means a misspelled field is an error at the call site instead of a default silently left in place.

The exclusion list is not applied for you — feed it to keepSamples, the constructor argument this vignette introduced above, or subset an existing dataset with qd[, unrelated, ]. Both narrow colData and sampleMap together.

analysisType changes what is being maximised. The default, "maximizeUnrelated", keeps the largest unrelated set. "maximizeCases" instead retains as many cases as it can — the right objective for a case/control design, where cases are the scarce resource — and requires phenoData naming each sample’s status.

filterRelatedness() needs the suggested packages igraph and plinkQC; the chunk above is skipped when they are not installed.

Next steps

Once built, the accessors extract data lazily, applying the stored QC filters at extraction time:

## [1] "GTEx_toy"
## [1] "Muscle" "Blood"
# Cis genotypes around a trait (+/- a window), QC applied on the fly:
dim(genotypes(qd, traitId = "GENE1", cisWindow = 1e6))
## [1] 165  58
# Phenotype matrix for one context:
dim(SummarizedExperiment::assay(molecularTraits(qd, contexts = "Muscle")))
## [1]  2 40

QtlDataset and MultiStudyQtlDataset are direct inputs to fineMappingPipeline(), twasWeightsPipeline() and colocboostPipeline()— see the Fine-mapping with pecotmr, Learning TWAS weights with pecotmr and Multi-trait colocalization with ColocBoost vignettes.

## R version 4.5.3 (2026-03-11)
## Platform: x86_64-conda-linux-gnu
## Running under: Ubuntu 24.04.5 LTS
## 
## Matrix products: default
## BLAS/LAPACK: /home/runner/work/pecotmr/pecotmr/.pixi/envs/default/lib/libopenblasp-r0.3.34.so;  LAPACK version 3.12.0
## 
## locale:
##  [1] LC_CTYPE=C.UTF-8       LC_NUMERIC=C           LC_TIME=C.UTF-8       
##  [4] LC_COLLATE=C.UTF-8     LC_MONETARY=C.UTF-8    LC_MESSAGES=C.UTF-8   
##  [7] LC_PAPER=C.UTF-8       LC_NAME=C              LC_ADDRESS=C          
## [10] LC_TELEPHONE=C         LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C   
## 
## time zone: Etc/UTC
## tzcode source: system (glibc)
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
## [1] pecotmr_0.9.3
## 
## loaded via a namespace (and not attached):
##   [1] DBI_1.3.0                   bitops_1.1-0               
##   [3] httr2_1.3.0                 gridExtra_2.3.1            
##   [5] rlang_1.3.0                 magrittr_2.0.5             
##   [7] otel_0.2.0                  matrixStats_1.5.0          
##   [9] susieR_0.16.6               compiler_4.5.3             
##  [11] RSQLite_3.53.3              systemfonts_1.3.2          
##  [13] vctrs_0.7.3                 quadprog_1.5-8             
##  [15] stringr_1.6.0               pkgconfig_2.0.3            
##  [17] crayon_1.5.3                fastmap_1.2.0              
##  [19] dbplyr_2.6.0                backports_1.5.1            
##  [21] XVector_0.50.0              Rsamtools_2.26.0           
##  [23] rmarkdown_2.32              tzdb_0.5.0                 
##  [25] ragg_1.5.2                  UpSetR_1.4.1               
##  [27] purrr_1.2.2                 bit_4.6.0                  
##  [29] xfun_0.61                   Rfast_2.1.5.2              
##  [31] MultiAssayExperiment_1.36.2 randomForest_4.7-1.2       
##  [33] cachem_1.1.0                jsonlite_2.0.0             
##  [35] blob_1.3.0                  DelayedArray_0.36.1        
##  [37] reshape_0.8.10              tictoc_1.2.1               
##  [39] BiocParallel_1.44.0         irlba_2.4.1                
##  [41] parallel_4.5.3              R6_2.6.1                   
##  [43] bslib_0.12.0                stringi_1.8.9              
##  [45] RColorBrewer_1.1-3          plinkQC_1.1.0              
##  [47] GenomicRanges_1.62.1        jquerylib_0.1.4            
##  [49] numDeriv_2016.8-1.1         Rcpp_1.1.2                 
##  [51] Seqinfo_1.0.0               SummarizedExperiment_1.40.0
##  [53] knitr_1.52                  readr_2.2.0                
##  [55] IRanges_2.44.0              BiocBaseUtils_1.12.0       
##  [57] Matrix_1.7-6                igraph_2.3.4               
##  [59] tidyselect_1.2.1            abind_1.4-8                
##  [61] yaml_2.3.12                 codetools_0.2-20           
##  [63] curl_8.0.0                  metafor_5.0-1              
##  [65] lattice_0.23-1              tibble_3.3.1               
##  [67] plyr_1.8.9                  withr_3.0.3                
##  [69] Biobase_2.70.0              S7_0.2.2                   
##  [71] evaluate_1.0.5              archive_1.1.13             
##  [73] desc_1.4.3                  RcppParallel_5.1.11-2      
##  [75] BiocFileCache_3.0.0         Biostrings_2.78.0          
##  [77] filelock_1.0.3              pillar_1.11.1              
##  [79] MatrixGenerics_1.22.0       metadat_1.6-0              
##  [81] checkmate_2.3.3             stats4_4.5.3               
##  [83] generics_0.1.4              vroom_1.7.1                
##  [85] mathjaxr_2.0-0              S4Vectors_0.48.1           
##  [87] hms_1.1.4                   ggplot2_4.0.3              
##  [89] scales_1.4.0                glue_1.8.1                 
##  [91] tools_4.5.3                 robustbase_0.99-7          
##  [93] fs_2.1.0                    cowplot_1.2.0              
##  [95] grid_4.5.3                  PlinkMatrix_1.0.0          
##  [97] optparse_1.8.2              tidyr_1.3.2                
##  [99] nlme_3.1-170                cli_3.6.6                  
## [101] zigg_0.0.2                  textshaping_1.0.5          
## [103] mixsqp_0.3-54               S4Arrays_1.10.1            
## [105] dplyr_1.2.1                 DEoptimR_1.2-1             
## [107] gtable_0.3.6                sass_0.4.10                
## [109] digest_0.6.39               BiocGenerics_0.56.0        
## [111] SparseArray_1.10.10         htmlwidgets_1.6.4          
## [113] farver_2.1.2                memoise_2.0.1              
## [115] htmltools_0.5.9             pkgdown_2.2.1              
## [117] lifecycle_1.0.5             bit64_4.8.6                
## [119] MASS_7.3-66