Skip to contents

Overview

fineMappingPipeline() returns a QtlFineMappingResult or GwasFineMappingResult. This vignette covers what you do with one afterwards: summarising credible sets, comparing results across studies, intersecting QTL with GWAS, and reaching the underlying SuSiE quantities when the summaries are not enough.

Every function here reads an existing result — none of them refit anything.

library(pecotmr)
data(qtlFineMappingExample, gwasFineMappingExample)
data(qtlFineMappingLbfExample, gwasFineMappingLbfExample)
data(qtlFineMappingPairedExample, qtlDatasetExample)
qtlFineMappingExample
## QtlFineMappingResult: 1 entries
##   1 studies, 1 contexts, 1 traits, 1 methods
##   LD reference: NULL (individual-level fit)

The two tables

A fine-mapping result carries two flat summaries, and which one you want depends on whether your unit of interest is a variant or a credible set.

topLoci() is one range per retained variant, with its PIP, effect size, and credible-set membership at each coverage level carried in mcols(). Take a table with as.data.frame():

loci <- topLoci(qtlFineMappingExample)
length(loci)
## [1] 15
head(as.data.frame(loci)[, c(
    "variant_id",
    "pip",
    "cs_95",
    "cs_95_purity",
    "posterior_effect",
    "posterior_se"
)], 4)
##           variant_id        pip   cs_95 cs_95_purity posterior_effect
## 1 chr22:32596246:T:C 0.14449697 susie_0    0.0000000     -0.012846434
## 2 chr22:32724254:T:C 0.04426635 susie_1    0.6255032      0.006013377
## 3 chr22:32724508:C:A 0.04426635 susie_1    0.6255032      0.006013377
## 4 chr22:32728910:G:A 0.03949625 susie_1    0.6255032      0.005349899
##   posterior_se
## 1   0.03244071
## 2   0.02838201
## 3   0.02838201
## 4   0.02680179

posterior_effect is the posterior mean effect — the same quantity a TWAS weight is, and literally what susieWeights() returns — and posterior_se its standard error. They are deliberately not called beta/se: marginalEffects() publishes the marginal single-variant estimate under those names, so sharing them would let the two views be joined into a silent mix of posterior and marginal effects.

Both are on the standardized genotype scale, because the QtlDataset accessors centre and scale every genotype column to unit variance before fitting — residualizedGenotypes() does it, governed by ResidualizationParam(scaleResiduals = FALSE) per call or the dataset’s own scaleResiduals slot. The published weight_standardized records whichever way it resolved. That is what makes a posterior effect comparable with a TWAS weight from any other method — they are all on the scale of the X the fitter was given, and on the same scale as the LD correlations and z-scores a TWAS Z is computed from. The published weight_standardized column records it.

cs_95 names the credible set the variant belongs to, or is NA when it belongs to none. The cs_70 / cs_50 columns give the same membership at looser coverage, so a variant can be in a 95% set but not a 50% one.

credibleSetSummary() is one row per credible set instead:

as.data.frame(credibleSetSummary(qtlFineMappingExample))[, c(
    "cs",
    "coverage",
    "n_variants",
    "purity_min",
    "cs_log10bf",
    "lead_variant",
    "lead_pip"
)]
##        cs coverage n_variants purity_min cs_log10bf       lead_variant
## 1 susie_1     0.95         42  0.6255032   16.22962 chr22:32745710:T:C
##    lead_pip
## 1 0.0769631

purity_min is the smallest absolute correlation between any pair of variants in the set — the standard measure of whether a credible set is a single signal or an artefact of a few uncorrelated variants being lumped together. cs_log10bf says how much the data prefer that effect being present.

A credible set with low purity_min deserves suspicion before interpretation, which is why the pipeline exposes minPurity as a filter rather than dropping such sets silently.

Merging overlapping credible sets

A multi-effect SuSiE fit can return credible sets that overlap, especially when two effects are in strong LD. mergeSusieCs() collapses those into one row per variant: credibleSetNames labels every set the variant belongs to (comma-joined when it belongs to more than one), and maxPip / medianPip summarise its PIP across them. The example fit has a single set, so each label names just that one:

merged <- mergeSusieCs(qtlFineMappingExample, coverage = 0.95)
dim(merged)
## [1] 14  4
head(as.data.frame(merged), 4)
##           variant_id credibleSetNames     maxPip  medianPip
## 1 chr22:32724254:T:C           cs_1_1 0.04426635 0.04426635
## 2 chr22:32724508:C:A           cs_1_1 0.04426635 0.04426635
## 3 chr22:32728910:G:A           cs_1_1 0.03949625 0.03949625
## 4 chr22:32729588:T:C           cs_1_1 0.03451015 0.03451015

Use it when downstream code needs a variant to belong to at most one set — overlap-counting, for instance, would otherwise double-count.

Intersecting QTL and GWAS

overlapTopLoci() joins two fine-mapping results on shared variants, prefixing each side’s columns. It is the cheap first pass before colocalization: if the two credible sets share no variants at all, there is nothing for a coloc model to work with.

overlap <- overlapTopLoci(qtlFineMappingLbfExample, gwasFineMappingLbfExample)
nrow(overlap)
## NULL
head(as.data.frame(overlap)[, c(
    "variant_id",
    "qtl_pip",
    "gwas_pip",
    "qtl_cs_95",
    "gwas_cs_95"
)], 4)
##           variant_id    qtl_pip   gwas_pip qtl_cs_95 gwas_cs_95
## 1 chr22:10416669:A:T 0.03101916 0.03101916   susie_0    susie_0
## 2 chr22:10424460:A:T 0.02609678 0.02609678   susie_0    susie_0
## 3 chr22:10443846:A:T 0.02806086 0.02806086   susie_0    susie_0
## 4 chr22:10456378:A:T 0.02518179 0.02518179   susie_0    susie_0

The variants worth attention are those in a credible set on both sides:

both <- overlap[!is.na(overlap$qtl_cs_95) & !is.na(overlap$gwas_cs_95), ]
nrow(both)
## NULL
table(both$qtl_cs_95, both$gwas_cs_95)
##          
##           susie_0
##   susie_0     100

Here that filter removes nothing, because topLoci in this result retains only credible-set variants — every shared variant is in one set on each side. The filter earns its place on results where topLoci also carries high-PIP variants outside any set, which is the usual case once pipCutoff is in play.

A non-empty overlap is not evidence of a shared causal variant, however large: two independent signals in the same LD block overlap too. That question is what colocPipeline() answers.

Combining results

combineFineMappingResults() stacks several results into one collection, which is how per-region or per-chromosome runs are assembled after parallel execution:

combined <- combineFineMappingResults(
    qtlFineMappingExample,
    qtlFineMappingLbfExample
)
nrow(combined)
## [1] 3
data.frame(
    study = combined$study,
    context = combined$context,
    trait = combined$trait,
    method = combined$method
)
##        study   context           trait method
## 1    study_1 context_1          gene_1  susie
## 2 test_study  context1 ENSG00000283047  susie
## 3 test_study  context2 ENSG00000283047  susie

The inputs keep their own identity columns, so a combined object still distinguishes which study and method each row came from. Note that the identity columns are read with $: as.data.frame() on a collection flattens its variants, not its per-row metadata.

Between-credible-set correlation

computeCsCorrelation() reports how correlated a fit’s credible sets are with each other. It is computed on demand rather than stored, because it needs LD — and the LD lives with the data, not with the fit:

computeCsCorrelation(qtlFineMappingPairedExample, qtlDatasetExample)
##             L2          L3
## L2  1.00000000 -0.08404977
## L3 -0.08404977  1.00000000

The ldSource argument takes a QtlDataset, QtlSumStats or GwasSumStats — whichever object the fit was derived from. It must cover the fit’s region: a source whose variants do not reach the fit’s window cannot supply the correlations, and the function says so rather than returning something empty.

Near-zero off-diagonal entries, as here, mean the credible sets are capturing distinct signals. Strongly correlated sets suggest the fit split one signal in two.

Reaching the SuSiE quantities

When the flat tables are not enough — you want the per-effect posterior, or to re-derive PIPs under a different assumption — the underlying fit is available.

lbf() returns the log Bayes factors, one row per variant and one column per single effect:

lbf <- lbf(qtlFineMappingLbfExample)
unit <- lbf[lbf$trait == lbf$trait[1] & lbf$context == lbf$context[1], ]
lbfMatrix <- as.matrix(unit[, grep("^lbf_L", names(unit))])
dim(lbfMatrix)
## [1] 200   5

lbfToAlpha() converts those to single-effect inclusion probabilities. It normalises across each row, so the matrix has to be oriented with effects as rows — transpose the variants-by-effects layout above:

alpha <- lbfToAlpha(t(lbfMatrix))
dim(alpha)
## [1]   5 200
rowSums(alpha) # each single effect sums to 1 across variants
## lbf_L1 lbf_L2 lbf_L3 lbf_L4 lbf_L5 
##      1      1      1      1      1

Getting that orientation wrong is quiet rather than loud: normalising across effects for each variant also produces a matrix whose rows sum to 1, just not one that means anything.

The LBF matrix is also what colocPipeline() consumes, which is why fineMappingPipeline(..., keepLbf = TRUE) is required before colocalization.

susieFit() returns the raw per-unit SuSiE object when you need something the accessors do not expose:

fit <- susieFit(qtlFineMappingExample)
class(fit)
## [1] "susie"
names(fit)
##  [1] "pip"                    "sets"                   "sets_secondary"        
##  [4] "alpha"                  "lbf_variable"           "V"                     
##  [7] "niter"                  "max_L"                  "n_effects"             
## [10] "X_column_scale_factors" "mu"                     "mu2"                   
## [13] "sigma2"                 "null_index"             "converged"             
## [16] "pi"

alpha and lbf_variable are the per-effect matrices, sets the credible sets before any purity filtering, and V the estimated prior variances — everything the flat tables above are derived from.

How the tables are built

The summaries above are not stored by the fitter — they are derived from the raw SuSiE fit by a short chain that fineMappingPipeline() runs for you:

postprocessFinemappingFits()   # per-method fits -> a post object
  -> computeCsTables()         # credible sets from alpha, with purity
  -> buildTopLoci()            # the flat one-row-per-variant table
  -> formatFinemappingOutput() # assemble the result payload

Call them directly when you have SuSiE fits from elsewhere and want pecotmr’s tables over them, or when you need to change how credible sets are formed — computeCsTables() is where the coverage levels and purity threshold are applied, so re-running it with a different coverage gives a different top_loci without refitting anything.

Two smaller extractors sit at the row level and are what credibleSetSummary() is assembled from:

  • extractCsInfo(fmRow, csNames, topLociTable, ldSource) — per-credible-set detail for one result row, including the between-variant correlations that give purity_min, which is why it needs an ldSource.
  • extractTopPipInfo(fmRow, sumstats) — the lead variant and its statistics, joined against summary statistics.

susieResult() unwraps a per-condition fit list into the single SuSiE object the other helpers expect.

Writing results out

Two generics cover every published format. exportToVcf() serialises a fine-mapping result — or summary statistics, or TWAS weights — to GWAS-VCF, the interchange format most external tools read: one record per variant, one sample per study or fitted model, and the statistics as FORMAT fields.

exportToVcf(
    qtlFineMappingExample,
    outputPath = "finemapping.vcf.gz",
    context = "context_1"
)

It needs the VariantAnnotation package, which is a suggested dependency rather than a hard one.

exportToBed() writes the published long-format tables instead, one row per published observation, bgzipped and tabix-indexed when the path ends in .gz:

# the top-loci table: the posterior view joined to the marginal one
exportToBed(qtlFineMappingExample, "top_loci.bed.gz")
# every remaining column the view carries, after the published schema
exportToBed(qtlFineMappingExample, "top_loci_full.bed.gz", columns = "all")
# the fSuSiE table: one row per (context, credible set), carrying the fitted
# curve at both resolutions -- the wavelet grid and its reinterpolation onto
# the trait positions. `levels` keeps only one of the two.
exportToBed(fsusieFineMappingExample, "fsusie.bed.gz", schema = "fsusie")
exportToBed(fsusieFineMappingExample, "fsusie_grid.bed.gz",
    schema = "fsusie", levels = "wavelet")

Both accept a list of results and fold it into one sorted, indexed output, so a genome’s worth of per-region RDSes needs no separate concatenate step.

Reading results back

readFineMappingResult() is the inverse, and it is also the way into pecotmr for a result produced somewhere else: it reads either format and rebuilds the collection, keyed by whatever (study, context, trait, method) tuples the file holds.

res <- readFineMappingResult("top_loci.bed.gz")
# only the variants of one region, from a tabix-indexed file
res <- readFineMappingResult("top_loci.bed.gz", region = "chr22:32e6-33e6")

It requires almost nothing: coordinate-encoding variant ids and a PIP. Every other column is optional, because each consumer needs a different few — Mendelian randomization the marginal effect and its standard error, colocalization the log Bayes factors — and a table converted from another tool will carry some and not others. A consumer reached without what it needs names the column that is missing rather than quietly producing nothing.

Two arguments exist for files that were assembled by hand. studyName, context, trait and method fill in identity columns the file does not record (a value that disagrees with the file is an error, not an override), and weightStandardized says which genotype scale a published WGT is on — required when the file does not say, because the two scales differ by a per-variant, allele-frequency-dependent factor and neither is recognisable on its own.

readTwasWeights() does the same for a weights file. Note that a fine-mapping table reads as weights too: the posterior effect is the prediction weight, which is why both schemas publish it as WGT.

Next steps

  • Fine-mapping — producing the results this vignette consumes.
  • Colocalization — the model that answers whether an overlap reflects a shared causal variant.
  • TWAS weights — reusing a fine-mapping fit as a weight method.

Session info

## 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                 rlang_1.3.0                
##   [5] magrittr_2.0.5              otel_0.2.0                 
##   [7] matrixStats_1.5.0           susieR_0.16.6              
##   [9] compiler_4.5.3              RSQLite_3.53.3             
##  [11] systemfonts_1.3.2           vctrs_0.7.3                
##  [13] quadprog_1.5-8              stringr_1.6.0              
##  [15] pkgconfig_2.0.3             crayon_1.5.3               
##  [17] fastmap_1.2.0               backports_1.5.1            
##  [19] dbplyr_2.6.0                XVector_0.50.0             
##  [21] Rsamtools_2.26.0            rmarkdown_2.32             
##  [23] tzdb_0.5.0                  ragg_1.5.2                 
##  [25] purrr_1.2.2                 bit_4.6.0                  
##  [27] xfun_0.61                   Rfast_2.1.5.2              
##  [29] MultiAssayExperiment_1.36.2 cachem_1.1.0               
##  [31] jsonlite_2.0.0              blob_1.3.0                 
##  [33] DelayedArray_0.36.1         reshape_0.8.10             
##  [35] tictoc_1.2.1                BiocParallel_1.44.0        
##  [37] irlba_2.4.1                 parallel_4.5.3             
##  [39] R6_2.6.1                    bslib_0.12.0               
##  [41] stringi_1.8.9               RColorBrewer_1.1-3         
##  [43] GenomicRanges_1.62.1        jquerylib_0.1.4            
##  [45] numDeriv_2016.8-1.1         Rcpp_1.1.2                 
##  [47] Seqinfo_1.0.0               SummarizedExperiment_1.40.0
##  [49] knitr_1.52                  readr_2.2.0                
##  [51] IRanges_2.44.0              BiocBaseUtils_1.12.0       
##  [53] Matrix_1.7-6                tidyselect_1.2.1           
##  [55] abind_1.4-8                 yaml_2.3.12                
##  [57] codetools_0.2-20            curl_8.0.0                 
##  [59] metafor_5.0-1               lattice_0.23-1             
##  [61] tibble_3.3.1                plyr_1.8.9                 
##  [63] Biobase_2.70.0              withr_3.0.3                
##  [65] S7_0.2.2                    evaluate_1.0.5             
##  [67] archive_1.1.13              desc_1.4.3                 
##  [69] RcppParallel_5.1.11-2       BiocFileCache_3.0.0        
##  [71] Biostrings_2.78.0           filelock_1.0.3             
##  [73] pillar_1.11.1               MatrixGenerics_1.22.0      
##  [75] metadat_1.6-0               checkmate_2.3.3            
##  [77] stats4_4.5.3                generics_0.1.4             
##  [79] vroom_1.7.1                 mathjaxr_2.0-0             
##  [81] S4Vectors_0.48.1            hms_1.1.4                  
##  [83] ggplot2_4.0.3               scales_1.4.0               
##  [85] glue_1.8.1                  tools_4.5.3                
##  [87] fs_2.1.0                    grid_4.5.3                 
##  [89] PlinkMatrix_1.0.0           tidyr_1.3.2                
##  [91] nlme_3.1-170                cli_3.6.6                  
##  [93] zigg_0.0.2                  textshaping_1.0.5          
##  [95] mixsqp_0.3-54               S4Arrays_1.10.1            
##  [97] dplyr_1.2.1                 gtable_0.3.6               
##  [99] sass_0.4.10                 digest_0.6.39              
## [101] BiocGenerics_0.56.0         SparseArray_1.10.10        
## [103] htmlwidgets_1.6.4           farver_2.1.2               
## [105] memoise_2.0.1               htmltools_0.5.9            
## [107] pkgdown_2.2.1               lifecycle_1.0.5            
## [109] bit64_4.8.6                 MASS_7.3-66