RSS Fine-mapping with GWAS Summary Statistics#
End-to-end SuSiE-RSS fine-mapping over GWAS summary statistics, one fit per study per LD block.
Overview#
Fine-mapping asks which variants in a region are causal, not merely correlated with a causal one. Doing that properly needs individual-level genotypes, which for GWAS are usually unavailable. SuSiE-RSS fits the same sum-of-single-effects model to what can be obtained – per-variant z-scores plus an LD matrix from a reference panel – and returns credible sets and posterior inclusion probabilities rather than a single lead SNP per locus (Zou et al., 2022).
Because the LD reference stands in for the study own genotypes, results are only as good as the match between the two. Allele flips and z-score outliers relative to the panel are the usual source of spurious credible sets, which is why the QC options exist: SLALOM or DENTIST screening flags suspicious variants, and RAISS can impute variants that are missing from the study but present in the panel. The LD reference must be genotype-backed (PLINK2/PLINK1/GDS/VCF); a precomputed .cor.xz correlation matrix is not supported here, because the QC steps need the genotypes themselves.
The module takes a list of LD reference blocks and a list of GWAS summary statistics files and runs four SoS steps, which chain in the order below:
Workflow |
What it does |
|---|---|
|
Pair each study with the LD blocks it covers |
|
Harmonise, QC and optionally impute summary statistics per block |
|
Fit SuSiE-RSS per block |
|
Diagnostic plots for the fitted regions |
generate_manifest resolves --gwas-meta or --gwas-tsv-list against --region-list or --regions into one manifest TSV with a row per study x region. generate_gwas_sumstats then builds one QC-d GwasSumStats RDS per study x LD block, harmonising variants to the LD-panel ID convention. gwas_fine_mapping dispatches to pecotmr::fineMappingPipeline(), and gwas_rss_plot renders a PIP plot unless --no-plot is given. The results feed TWAS and PRS weight construction downstream.
This chain replaces the legacy two-step get_analysis_regions + univariate_rss workflow, which called the now-removed rss_analysis_pipeline().
When to run it. After GWAS summary statistics and a genotype-backed LD reference panel are in
place, and before TWAS or PRS weight construction, which read the GwasFineMappingResult objects
written here. This is the summary-statistics-only route: with individual-level genotypes and
phenotypes, use mnm_regression instead.
Input#
GWAS inputs – supply either form:
--gwas-meta input/rss_analysis/protocol_example.rss_mwe.gwas_meta.tsvTSV with columnsstudy_id,path(relative to the meta file, or absolute) and an optionalcolumn_mappingnaming a per-study YAML. One row per study.study_id path column_mapping AD_Bellenguez_2022 protocol_example.gwas_sumstats.chr22.tsv.gz protocol_example.gwas_column_mapping.fixed.yml
In this file
pathresolves toinput/rss_analysis/protocol_example.gwas_sumstats.chr22.tsv.gzandcolumn_mappingtoinput/rss_analysis/protocol_example.gwas_column_mapping.fixed.yml.--gwas-tsv-list S1=path1 ...: explicitSTUDY=PATHpairs; no per-study column mapping is possible this way.
Each summary statistics file is tab-delimited, with chrom, pos, A1, A2 as the first four columns. A column-mapping YAML lets other layouts be read: it must resolve chrom, pos, A1, A2, either z or both beta and se, and n_sample or the pair n_case / n_control (for a case-control study, give one or the other and fill the unused field with 0). var_y is optional.
Region inputs – supply either form:
--region-list: BED-like TSV of LD blocks.--regions chr:start-end ...: explicit regions.
LD reference and genome:
--ld-meta(required): LD-reference meta TSV with columns#chr,start,end,path, wherepathis a genotype prefix. One LD block per row, or a single row withstart=end=0for a whole chromosome. The reference must be genotype-backed (PLINK2/PLINK1/GDS/VCF). The protocol shipsinput/ld_reference/protocol_example.ld_meta_file.tsv, whose prefixes point intoinput/ld_reference/chr*/:#chr start end path chr1 16103 2888443 chr1/protocol_example.LD.chr1 chr1 2888443 4320284 chr1/protocol_example.LD.chr1
--genome(defaultGRCh38): genome build label carried into the output objects.
QC knobs, forwarded to gwas_sumstats_construct.R and on to summaryStatsQc():
--qc-method(defaultnone):none,slalomordentist.--impute/--no-impute(default off): enable RAISS imputation of variants present in the panel but missing from the study.--qc-args: JSON passthrough for any othersummaryStatsQc()argument, e.g.{"mafCutoff":0.01,"nCutoff":0,"alleleFlipKriging":true}.--maf(default0.0025;0disables): MAF cutoff, i.e.mafCutoff.--allele-flip-kriging/--no-allele-flip-kriging(default off) and--effective-n/--no-effective-n(default on): individualsummaryStatsQc()switches.--skip-regions chr:start-end ...: drop variants overlapping these windows, i.e.skipRegion.--skip-analysis-pip-cutoff(default0.025;0disables): skip a region whose single-effect max PIP falls below this, i.e.pipCutoffToSkip.
Fine-mapping knobs, forwarded to fine_mapping.R:
--methods(defaultsusie),--coverage(0.95),--secondary-coverage(0.7,0.5).--min-abs-corr(0.8),--median-abs-corr(off; sets OR-logic purity),--pip-cutoff(0.025).--L(20) and--L-greedy(5): SuSiE fit defaults.--method-args: JSON overriding any of the above per method, e.g.{"susie":{"L":10}}.--ser-fallback/--no-ser-fallback(default on),--r-finite,--r-mismatch(eb_mix),--rss-control: numerical safeguards for the RSS likelihood.
Other:
--no-plot: skipgwas_rss_plot.--manifest-name(defaultgwas_rss): output prefix for the manifest file.--cwd(defaultoutput),--modular-script-dir(defaultcode/script),--container,--job-size,--walltime,--mem,--numThreads.
Output#
gwas_rss.manifest.tsv– one row per study x region, recording which summary statistics file and column mapping were paired with which LD block. Every later step reads it; thegwas_rssprefix comes from--manifest-name. The example command produces a single row, since it runs one study over one LD block, andgwas_tsv/column_mappinghold absolute paths, abbreviated here:study_id gwas_tsv column_mapping chr start end region_id gwas_tsv_basename AD_Bellenguez_2022 .../input/rss_analysis/protocol_example.gwas_sumstats.chr22.tsv.gz .../input/rss_analysis/protocol_example.gwas_column_mapping.fixed.yml chr22 49355984 50799822 chr22_49355984_50799822 protocol_example.gwas_sumstats.chr22
sumstats/<study>.<region>.gwas_sumstats.rds– aGwasSumStatsobject per study x block: the QC-d, optionally imputed summary statistics, with variant IDs harmonised to the LD panel. The step reports how many variants survived into the object:Wrote QC'd GwasSumStats for study 'AD_Bellenguez_2022' over chr22:49355984-50799822 (7609 variants in)
fine_mapping/<study>.<region>.gwas_finemap.rds– aGwasFineMappingResultobject per study x block, holding the SuSiE-RSS fit underMethodNames(susieby default): credible sets, PIPs, single-effect posterior summaries, and the QC provenance of the variants that went in:Wrote fineMapping result for GwasSumStats (1 row(s))
plots/<study>.<region>.pip_plot.png– diagnostic PIP plot, unless--no-plotwas given.a
.stdoutand a.stderrlog beside each of the targets above.
Everything is written under --cwd. <study> is the study_id from the GWAS meta file and
<region> is the LD block written as chrN_start_end, which for the example command above are
AD_Bellenguez_2022 and chr22_49355984_50799822 – one file of each kind.
Minimal Working Example#
Default fine-mapping, no summary-statistics QC#
This fine-maps one study x one LD block and writes the manifest, the QC-d GwasSumStats, the GwasFineMappingResult, and a PIP plot under output/rss_analysis/.
Timing: ~2 min (on toy dataset)
sos run pipeline/rss_analysis.ipynb \
generate_manifest+generate_gwas_sumstats+gwas_fine_mapping+gwas_rss_plot \
--cwd output/rss_analysis --modular-script-dir code/script \
--gwas-meta input/rss_analysis/protocol_example.rss_mwe.gwas_meta.tsv \
--regions chr22:49355984-50799822 \
--ld-meta input/ld_reference/protocol_example.ld_meta_file.tsv
SLALOM QC, RAISS imputation and per-method tuning#
Timing: ~2 min (on toy dataset)
sos run pipeline/rss_analysis.ipynb \
generate_manifest+generate_gwas_sumstats+gwas_fine_mapping+gwas_rss_plot \
--cwd output/rss_analysis --modular-script-dir code/script \
--gwas-meta input/rss_analysis/protocol_example.rss_mwe.gwas_meta.tsv \
--regions chr22:49355984-50799822 \
--ld-meta input/ld_reference/protocol_example.ld_meta_file.tsv \
--qc-method slalom --impute --qc-args '{"mafCutoff":0.01}' \
--min-abs-corr 0.5 --method-args '{"susie":{"L":10}}'
Command Interface#
sos run pipeline/rss_analysis.ipynb -h
usage: sos run pipeline/rss_analysis.ipynb
[workflow_name | -t targets] [options] [workflow_options]
workflow_name: Single or combined workflows defined in this script
targets: One or more targets to generate
options: Single-hyphen sos parameters (see "sos run -h" for details)
workflow_options: Double-hyphen workflow-specific parameters
Workflows:
generate_manifest
generate_gwas_sumstats
gwas_fine_mapping
gwas_rss_plot
Global Workflow Options:
--cwd output (as path)
--modular-script-dir code/script (as path)
--gwas-meta . (as path)
--- per-study GWAS inputs (use either or both)
---------------------
--gwas-tsv-list (as list)
--region-list . (as path)
--- region inputs (use either or both)
-----------------------------
--regions (as list)
--ld-meta VAL (as path, required)
--- LD reference + genome
------------------------------------------
--genome GRCh38
--qc-method none
--- QC knobs (forwarded to gwas_sumstats_construct.R)
--------------
--[no-]impute (default to False)
--qc-args ''
--maf 0.0025 (as float)
--skip-regions (as list)
--skip-analysis-pip-cutoff 0.025 (as float)
--[no-]allele-flip-kriging (default to False)
--[no-]effective-n (default to True)
--methods susie
--- fine-mapping knobs (forwarded to fine_mapping.R)
---------------
--coverage 0.95 (as float)
--secondary-coverage '0.7,0.5'
--min-abs-corr 0.8 (as float)
--median-abs-corr ''
--pip-cutoff 0.025 (as float)
--L 20 (as int)
--L-greedy 5 (as int)
--method-args ''
--[no-]ser-fallback (default to True)
GWAS SuSiE-RSS fine-mapping: SER fallback is ON by
default (reproduces the single-panel notebook): fit
finite-sample R + EB LD-mismatch SuSiE-RSS and fall back
to the single-effect (SER) result for regions susieR
flags as unreliable. ser_fallback and r_mismatch are
orthogonal: ser_fallback = False only disables the SER
fallback (EB LD-mismatch stays on); pass r_mismatch =
'none' too for the pre-change plain multi-effect fit.
Requires susieR >= 0.16.6 (which moved R_mismatch_method
/ check_prior into susie_rss_control(); pass those and
other control settings via rss_control).
--r-finite ''
--r-mismatch 'eb_mix'
--rss-control ''
--[no-]no-plot (default to False)
--- plot opt-out
---------------------------------------------------
--manifest-name 'gwas_rss'
--- output prefix for the manifest file
----------------------------
--container ''
--- infrastructure
-------------------------------------------------
--job-size 1 (as int)
--walltime 2h
--mem 16G
--numThreads 1 (as int)
Sections
generate_manifest:
generate_gwas_sumstats:
gwas_fine_mapping:
gwas_rss_plot:
Workflow implementation#
[global]
parameter: cwd = path('output')
parameter: modular_script_dir = path('code/script')
# --- per-study GWAS inputs (use either or both) ---------------------
parameter: gwas_meta = path('.')
parameter: gwas_tsv_list = [] # list of STUDY=PATH items
# --- region inputs (use either or both) -----------------------------
parameter: region_list = path('.')
parameter: regions = [] # list of chr:start-end strings
# --- LD reference + genome ------------------------------------------
parameter: ld_meta = path
parameter: genome = 'GRCh38'
# --- QC knobs (forwarded to gwas_sumstats_construct.R) --------------
parameter: qc_method = 'none' # none | slalom | dentist
parameter: impute = False
parameter: qc_args = '' # JSON object spliced into summaryStatsQc()
parameter: maf = 0.0025 # MAF cutoff (summaryStatsQc mafCutoff); 0 disables
parameter: skip_regions = [] # chr:start-end window(s) whose variants are dropped (skipRegion)
parameter: skip_analysis_pip_cutoff = 0.025 # skip a region below this max PIP (pipCutoffToSkip); 0 disables
parameter: allele_flip_kriging = False # kriging allele-flip QC: sign-flip switched z (logLR>2 & |z|>2); off by default
parameter: effective_n = True # case/control: use effective sample size 4/(1/n_case+1/n_control) as N (summaryStatsQc effectiveN); False -> raw N
# --- fine-mapping knobs (forwarded to fine_mapping.R) ---------------
parameter: methods = 'susie'
parameter: coverage = 0.95
parameter: secondary_coverage = '0.7,0.5'
parameter: min_abs_corr = 0.8
parameter: median_abs_corr = '' # empty -> off (OR-logic purity; needs pecotmr step-1)
parameter: pip_cutoff = 0.025
parameter: L = 20
parameter: L_greedy = 5
parameter: method_args = '' # nested per-method JSON object
# GWAS SuSiE-RSS fine-mapping: SER fallback is ON by default (reproduces the single-panel notebook):
# fit finite-sample R + EB LD-mismatch SuSiE-RSS and fall back to the single-effect (SER) result for
# regions susieR flags as unreliable. ser_fallback and r_mismatch are orthogonal: ser_fallback = False
# only disables the SER fallback (EB LD-mismatch stays on); pass r_mismatch = 'none' too for the
# pre-change plain multi-effect fit. Requires susieR >= 0.16.6 (which moved R_mismatch_method /
# check_prior into susie_rss_control(); pass those and other control settings via rss_control).
parameter: ser_fallback = True # fall back to single-effect (SER) when susieR flags R unreliable
parameter: r_finite = '' # finite-sample R size; empty -> auto (LD-panel sample size)
parameter: r_mismatch = 'eb_mix' # LD-mismatch correction: 'none' | 'eb' | 'eb_mix' (residual-mixture EB, default)
parameter: rss_control = '' # JSON object of susie_rss_control() settings, e.g. '{"check_prior":true,"mismatch_estimator":"map"}'; empty -> defaults
# --- plot opt-out ---------------------------------------------------
parameter: no_plot = False
# --- output prefix for the manifest file ----------------------------
parameter: manifest_name = 'gwas_rss'
# --- infrastructure -------------------------------------------------
parameter: container = ''
parameter: job_size = 1
parameter: walltime = '2h'
parameter: mem = '16G'
parameter: numThreads = 1
[generate_manifest]
# Resolve --gwas-meta / --gwas-tsv-list x --region-list / --regions into a
# single manifest TSV via gwas_rss_manifest.R. Downstream steps fan out
# over its rows; no Python parsing in this notebook.
input: None
output: f"{cwd}/{manifest_name}.manifest.tsv"
task: trunk_workers = 1, trunk_size = 1, walltime = '15m', mem = '2G', cores = 1, tags = f"{step_name}_{_output:bn}"
bash: expand = '${ }', stderr = f"{_output}.stderr", stdout = f"{_output}.stdout", container = container
Rscript ${modular_script_dir}/pecotmr_integration/gwas_rss_manifest.R \
${('--gwas-meta ' + str(gwas_meta)) if gwas_meta.is_file() else ''} \
${('--gwas-tsv-list ' + ' '.join(str(x) for x in gwas_tsv_list)) if gwas_tsv_list else ''} \
${('--region-list ' + str(region_list)) if region_list.is_file() else ''} \
${('--regions ' + ' '.join(str(x) for x in regions)) if regions else ''} \
--output ${_output}
[generate_gwas_sumstats]
# Fan out over the manifest's rows: one (study, region) GwasSumStats per
# row. Manifest columns: study_id, gwas_tsv, column_mapping, chr, start,
# n_case, n_control, n_sample (optional), end, region_id, gwas_tsv_basename.
# n_sample is the study-level total N: the level-4 fallback used when a study
# has no per-variant n_case/n_control and no per-variant N column.
import csv
jobs = list(csv.DictReader(open(f"{cwd}/{manifest_name}.manifest.tsv"), delimiter='\t'))
input: for_each = 'jobs'
output: f"{cwd}/sumstats/{_jobs['study_id']}.{_jobs['region_id']}.gwas_sumstats.rds"
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime, mem = mem, cores = numThreads, tags = f"{step_name}_{_output:bn}"
bash: expand = '${ }', stderr = f"{_output}.stderr", stdout = f"{_output}.stdout", container = container
Rscript ${modular_script_dir}/pecotmr_integration/gwas_sumstats_construct.R \
--study ${_jobs['study_id']} \
--gwas-tsv ${_jobs['gwas_tsv']} \
--ld-block ${_jobs['chr']}:${_jobs['start']}-${_jobs['end']} \
--ld-meta ${ld_meta} \
--genome ${genome} \
${('--column-mapping ' + _jobs['column_mapping']) if _jobs['column_mapping'] else ''} \
--qc-method ${qc_method} \
--maf ${maf} \
--pip-cutoff-to-skip ${skip_analysis_pip_cutoff} \
${('--skip-region ' + ','.join(str(x) for x in skip_regions)) if skip_regions else ''} \
${'--impute' if impute else ''} \
${('--qc-args ' + repr(qc_args)) if qc_args else ''} \
${'--allele-flip-kriging' if allele_flip_kriging else ''} \
--effective-n ${'TRUE' if effective_n else 'FALSE'} \
${('--n-case ' + _jobs['n_case']) if _jobs.get('n_case','') not in ('', 'NA') else ''} \
${('--n-control ' + _jobs['n_control']) if _jobs.get('n_control','') not in ('', 'NA') else ''} \
${('--n-sample ' + _jobs['n_sample']) if _jobs.get('n_sample','') not in ('', 'NA') else ''} \
--output ${_output}
[gwas_fine_mapping]
# Per-RDS fan-out: one fine-mapping task per (study, region) GwasSumStats.
output: f"{cwd}/fine_mapping/{_input:bnn}.gwas_finemap.rds", group_by = 1
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime, mem = mem, cores = numThreads, tags = f"{step_name}_{_output:bn}"
bash: expand = '${ }', stderr = f"{_output}.stderr", stdout = f"{_output}.stdout", container = container
Rscript ${modular_script_dir}/pecotmr_integration/fine_mapping.R \
--gwas-sumstats ${_input} \
--methods ${methods} \
--coverage ${coverage} \
--secondary-coverage ${secondary_coverage} \
--min-abs-corr ${min_abs_corr} \
--pip-cutoff ${pip_cutoff} \
--L ${L} \
--L-greedy ${L_greedy} \
${('--median-abs-corr ' + str(median_abs_corr)) if str(median_abs_corr) != '' else ''} \
${('--method-args ' + repr(method_args)) if method_args else ''} \
--ser-fallback ${'TRUE' if ser_fallback else 'FALSE'} \
--r-mismatch ${r_mismatch} \
${('--r-finite ' + str(r_finite)) if str(r_finite) != '' else ''} \
${('--rss-control ' + repr(rss_control)) if rss_control else ''} \
--output ${_output}
[gwas_rss_plot]
stop_if(no_plot, '--no-plot set; skipping PIP plot step.')
output: f"{cwd}/plots/{_input:bnn}.pip_plot.png", group_by = 1
task: trunk_workers = 1, trunk_size = job_size, walltime = '30m', mem = '4G', cores = 1, tags = f"{step_name}_{_output:bn}"
bash: expand = '${ }', stderr = f"{_output}.stderr", stdout = f"{_output}.stdout", container = container
Rscript ${modular_script_dir}/pecotmr_integration/gwas_rss_plot.R \
--input ${_input} \
--output ${_output}