Scores an arbitrary gene list (e.g. a 104-gene signature) across every cell type of
the integrated human plaque atlas, on all 259,116 cells, on a laptop with ~8 GB RAM.
cell_type_level1 keeps Fibromyocyte separate from Smooth Muscle Cell,
which matters for SMC-derived plaque phenotypes.
Why this pipeline
SeuratDisk / hdf5r are not installable here, and converting a 3 GB h5ad would need
20 GB RAM. Instead the CSR matrix is streamed in cell blocks with rhdf5
(no full matrix in memory) and only per-gene / per-cell accumulators are kept.
Two passes:
pass 1 – per-gene mean/SD over 12 blocks x 5,000 cells (--subsample cells
total) needed for z-scores and for the AddModuleScore control bins;
pass 2 – stream all cells, accumulate the scores below.
Runtime on this machine: ~2 min for all 259k cells (peak RSS ≈ 600 MB).
Scores (per cell)
column
definition
score_mean
mean log-normalised expression of the gene set
score_am
AddModuleScore-style: mean(feature expression) − mean(expression of control genes drawn from the same expression bin, 24 bins) — numerically comparable to Seurat::AddModuleScore
score_z
mean per-gene z-score ((x - mean_g) / sd_g), library-size/level independent
n_genes_det
how many of the gene set are detected (>0) in the cell
lib_size
total log-normalised counts of the cell (QC covariate)
score_am is the primary ranking score; score_z is the most robust to genes with
very different detection rates.
Usage
1. Get the gene list out of WGCNA
R objects live only inside the session that made them, so export the vector once
(write.csv(data.frame(gene = candidate_genes_final), "candidate_genes_final.csv", row.names = FALSE))
or save the workspace. scripts/02_genes_from_wgcna.R converts either form to the
one-symbol-per-line list the scorer wants, and pre-checks every symbol against the
atlas var before the 2-min scoring run:
It prints matched in atlas: N / M, writes data/genes.txt with the matched
symbols only, and drops the misses into data/genes_not_in_atlas.txt (aliases,
lncRNAs or probe IDs that the atlas calls differently).
2. Score the list
# one gene per line; '#' starts a comment
bash run.sh data/genes.txt # cell_type_level1
bash run.sh data/genes.txt cell_type_level2 # finer author labels
Useful flags: --chunk 5000 (cells per block), --subsample 60000 (cells for pass 1),
--nbin 24 (AddModuleScore bins), --no-plots, --max-cells N (smoke test on the
first N cells), --seed 1.
Outputs (per run, out/<tag>/)
file
content
cell_scores.tsv.gz
per-cell table: barcode, cell type, all scores, lib_size, n_genes_det
gene_mean_by_celltype.tsv
mean log-normalised expression, gene x cell type
gene_pct_by_celltype.tsv
percent of cells expressing, gene x cell type
score_summary_by_celltype.tsv
per cell type: n_cells, mean/median/SD of each score, pct of cells with score > 0, mean genes detected, rank, share of the top-1% scoring cells, enrichment
score_summary_console.txt
pretty-printed summary + Kruskal-Wallis test
genes_used.txt, genes_not_in_atlas.txt
gene-list QC (symbols matched against var/feature_name)
figures/violin_score_by_celltype.{png,pdf}
score distribution per cell type
figures/dotplot_genes_by_celltype.png
per-gene mean expression (colour) and % expressing (size)
figures/heatmap_genes_by_celltype.png
per-gene mean expression, z-scored across cell types
figures/umap_score.png, figures/umap_celltype.png
score on the atlas UMAP (colour clipped to 1st–99th pct)
sessionInfo.txt
reproducibility
Sanity check with canonical markers
data/genes_demo_markers.txt (29 lineage markers) was used to validate the pipeline.
It recovers exactly the expected biology (full atlas, cell_type_level1, score_am):
rank
cell type
n_cells
mean score_am
% cells score>0
enrichment in top 1%
1
Fibroblast
10,077
0.277
95.5
18.6x
2
Fibromyocyte
8,255
0.232
95.6
6.8x
3
Smooth Muscle Cell
16,124
0.165
91.7
0.60x
4
Macrophage
37,851
0.059
69.7
0.13x
5
EC
11,950
0.002
50.2
0.06x
6
Monocyte
13,370
-0.071
20.7
0
7
Neutrophil
3,867
-0.079
15.3
0
8
NK cell
12,483
-0.091
7.0
0
9
T cell
122,190
-0.128
4.7
0
10
Dendritic cell
7,259
-0.174
5.1
0
11
B cell
10,241
-0.183
1.0
0
12
Mast cell
2,965
-0.211
1.8
0
13
Plasma cell
2,484
-0.214
4.1
0
Per-gene dotplot confirms the drivers (SMC: ACTA2/MYH11/CNN1/TAGLN,
fibroblast: COL1A1/COL3A1/COL5A1/DCN/LUM/POSTN,
macrophage: CD68/C1QA/CD163/APOE, EC: PECAM1/VWF/CDH5/KDR/ACKR1),
so the extraction and the CSR indexing are correct.
Result: the 104-gene WGCNA module
Input: data/candidate_genes_final.csv (104 symbols, NF-kB / cytokine / ER-stress /
apoptosis / lipid-handling programme). 103 of 104 are present in the atlas var;
SOD2 is genuinely absent (the atlas carries SOD1, SOD2-OT1, SOD3), so it is
listed in data/genes_not_in_atlas.txt and the run uses 103 genes
(out/genes__cell_type_level1).
cell_type_level1 (13 author labels, all 259,116 cells)
rank
cell type
n_cells
mean score_am
median
% cells >0
top-1% enrichment
mean score_z
1
Neutrophil
3,867
0.153
0.152
97.4
20.7x
0.110
2
Monocyte
13,370
0.085
0.088
82.5
6.2x
0.172
3
Macrophage
37,851
0.049
0.042
73.3
2.4x
0.134
4
Plasma cell
2,484
0.037
0.034
73.9
0.28x
0.054
5
Mast cell
2,965
0.022
0.015
61.5
0.20x
0.022
6
Dendritic cell
7,259
0.005
-0.002
48.7
0.23x
0.070
7
EC
11,950
-0.002
-0.006
44.3
0.03x
0.035
8
T cell
122,190
-0.010
-0.012
40.7
0.01x
-0.024
9
B cell
10,241
-0.018
-0.022
33.6
0.11x
-0.001
10
NK cell
12,483
-0.020
-0.022
32.3
0
-0.053
11
Fibroblast
10,077
-0.025
-0.028
26.8
0.01x
-0.032
12
Smooth Muscle Cell
16,124
-0.051
-0.056
10.6
0
-0.057
13
Fibromyocyte
8,255
-0.057
-0.059
5.7
0
-0.061
Kruskal-Wallis across cell types: chi2 = 65,230, df = 12, p < 2.2e-16
(score_summary_console.txt).
score_am and score_z agree on the top 3; SMC and fibromyocyte are the lowest
of all 13 types, i.e. the module is not a smooth-muscle / fibroblast programme.
Within the monocyte-macrophage compartment the lipid-loaded / inflammatoryPLIN2+/TREM1+ subset is the highest scorer and has the largest score_z of any
label, while TREM2+/Foamy and HMOX1+ macrophages are close to the macrophage
average — the module tracks the inflammatory rather than the foamy/efferocytotic
phenotype.
SMC: mostly housekeeping/chaperone signal (CALM1 1.76, HSP90AB1 1.65,
HSP90AA1 1.43), i.e. no module-specific activation.
Controls (both in out/)
gene set
Neutrophil
Monocyte
Macrophage
module (103)
0.153
0.085
0.049
module minus CXCL8/NFKBIA/FOS/IL1B/NFE2L2 (_module_minus5)
0.019
0.018
0.014
expression-matched random 103 genes (_null_random103)
-0.007
-0.015
-0.020
All 13 labels side by side: out/validation_summary.tsv.
The random, expression-matched set scores ~0 (+/- 0.02) in every cell type, so
the neutrophil/monocyte signal is not an artifact of low RNA content: neutrophils
detect only 10.4 of the 103 module genes per cell vs 23.5 in monocytes.
cor(score_am, n_genes_detected) = 0.044 and cor(score_am, log10 lib_size) = -0.018
across all cells, and -0.02 / 0.01 / 0.05 within neutrophil / monocyte / macrophage
— the control bins removed the depth effect.
The five genes above carry most of the neutrophil signal (0.153 -> 0.019 when
removed); they are the canonical neutrophil activation genes of the plaque
(CXCL8/IL1B/NFKBIA/FOS/NFE2L2), so this is biology, not a single-gene
outlier — but it does mean the neutrophil ranking is driven by the immediate-early
/ NF-kB arm of the module.
Neutrophils are spread over 20+ donors (largest: 8_Slysz_femoral n=1122, mean 0.167;
4_Slysz n=791, 0.196; 15_Bashore n=448, 0.122; 6_Slysz_femoral n=271, 0.147) and all
sizeable donors are positive, so the hit is not one dataset.
Bottom line
The module is an inflammatory myeloid (NF-kB / cytokine / ER-stress) programme.
It is highest in neutrophils (1.5% of the atlas, 97% of cells positive, 20.7x
over-representation in the global top 1%), then in monocytes and macrophages,
with the PLIN2+/TREM1+ inflammatory macrophage as the top-scoring myeloid subset.
It is lowest in SMC / fibromyocyte / fibroblast, so it does not support an
SMC- or fibroblast-derived origin for this WGCNA module.
MR candidate genes, Figure 3, and pathway enrichment
1. mr_candidate_genes.csv — 18 genes for MR / target follow-up
scripts/04_mr_candidates.R combines three sources:
per-gene myeloid enrichment from out/genes__cell_type_level2 (myeloid mean vs
non-myeloid mean, log2FC, % of myeloid cells positive);
Druggable Genome / clinical-actionability annotation from the DGIdb GraphQL API
(data/dgidb_annotation.tsv; 101/103 genes annotated, 78/103 are in the
DRUGGABLE GENOME category);
a simple, stated selection rule: the five anchor genes the module is read on
(CXCL8, IL1B, NFKBIA, FOS, NFE2L2) + the five strongest
myeloid-enriched drivers + Druggable Genome genes with myeloid
log2FC >= 1, >= 10% positive cells and mean >= 0.05, until 18 genes.
#
gene
tier
neut
mono
mac (best)
% myeloid+
DGIdb druggable
role
1
CXCL8
anchor
4.71
2.05
2.25
65
yes
CXCR1/2 chemokine
2
IL1B
anchor
1.91
1.90
1.35
52
yes
IL-1β (IL-1R1)
3
NFKBIA
anchor
2.75
2.31
2.42
87
yes
IκBα, NF-κB feedback
4
FOS
anchor
2.22
2.01
2.21
84
yes
AP-1 immediate-early TF
5
NFE2L2
anchor
1.41
0.66
0.63
61
no
NRF2 (DMF-pathway target)
6
CXCL3
driver
0.13
1.19
2.07
46
yes
CXCR2 chemokine
7
CXCL2
driver
0.63
1.48
1.76
49
yes
CXCR2 chemokine
8
OLR1
driver
0.32
0.59
0.63
37
yes
LOX-1 oxLDL receptor
9
CYBB
driver
0.11
0.87
0.86
54
yes
NOX2 oxidative burst
10
MMP9
driver
0.23
0.07
0.89
14
yes
gelatinase B
11
CXCL1
fill
0.68
0.12
0.29
13
yes
CXCR2 chemokine
12
CD36
fill
0.01
0.40
0.92
32
yes
oxLDL scavenger receptor
13
LY96
fill
0.37
0.40
0.78
53
yes
MD-2, TLR4 co-receptor
14
CCL2
fill
0.02
0.24
1.49
29
yes
CCR2 chemokine
15
IL18
fill
0.02
0.20
0.49
38
yes
IL-18
16
ICAM1
fill
0.92
0.35
0.25
32
yes
ICAM-1 adhesion
17
ERO1A
fill
0.21
0.17
0.59
31
yes
ER oxidoreductase
18
PPARG
fill
0.00
0.05
0.27
14
yes
PPARγ (TZD target)
Expression columns are mean log1p(CP10k) in neutrophils / monocytes / the best
macrophage subset (from cell_type_level2). representative_agents in the CSV
lists approved or clinical-stage agents per gene (canakinumab for IL1B,
dimethyl fumarate for NFE2L2, TZDs for PPARG, …).
Notes:
SOD2 is not in the atlas, so it cannot appear in any expression-filtered list
(data/genes_not_in_atlas.txt).
NFE2L2 is not annotated DRUGGABLE GENOME by DGIdb (no direct small molecule;
dimethyl fumarate/bardoxolone act on the NRF2 pathway), so its druggable_genome
flag is FALSE although its representative_agents is populated.
Strongly druggable but not myeloid-enriched in this atlas (log2FC < 1, so not
selected): JAK2 (0.39), CHUK (0.48), IKBKG, TBK1 (0.34), IRAK4,
MAPK13 (1.51 but only 14% of cells), CASP3, BCL2, HSP90AA1.
The full ranking of all 103 genes (score, log2FC, % positive, DGIdb categories)
is in out/mr_candidates_all_genes.tsv, so the cut-offs can be re-tuned with
--n-max, --min-pct, --min-mean, --min-lfc.
scripts/03_core_gene_figures.R streams X once (same chunked CSR reader as
01_score_genes.R) and keeps only the 18 candidate columns, so the whole figure
costs ~40 MB of RAM and one pass over the 3 GB file.
Outputs in out/core_genes__cell_type_level2/figures/:
file
content
fig3A_violin_core_genes.png/.pdf
violins per gene x cell type (7 myeloid + 5 highest non-myeloid labels, <=3000 cells per group)
fig3B_umap_core_genes.png
one UMAP panel per gene, expression scaled to each gene’s 99th percentile, grey background = all cells
fig3C_umap_celltype_reference.png
which cluster is which: myeloid subsets coloured and labelled at their median position
fig3_core_genes_combined.png
A over B (patchwork), ready for assembly
Data behind the panels: core_gene_expression.tsv.gz (all 259,116 cells x 18
genes), core_gene_mean_by_celltype.tsv, core_gene_pct_by_celltype.tsv,
core_gene_overall_summary.tsv (mean, % positive, top cell type, myeloid log2FC).
3. GO / KEGG enrichment — scripts/05_enrichment.py
gseapy enrichr with the atlas’ own gene universe as background
(data/atlas_background_genes.txt, 25,377 symbols), so the Fisher exact test is
comparable to clusterProfiler::enricher rather than Enrichr’s generic background.
Libraries: GO BP/MF/CC (2021), KEGG 2021, Reactome 2022, WikiPathways 2024,
MSigDB Hallmark 2020. The seven GMT files are committed under data/gmt/ and read
locally, so the test runs offline and gives identical P/FDR values on every machine.
Key pathways (best term per family, KEGG 2021 unless stated):
pathway
overlap
FDR
Lipid and atherosclerosis
103/194
5.6e-231
TNF signalling
37/111
3.4e-61
Toll-like receptor signalling
34/90
2.7e-58
NOD-like receptor signalling
37/153
2.0e-55
IL-17 signalling
31/88
4.8e-52
Apoptosis
33/136
2.4e-49
NF-κB signalling
23/101
1.1e-33
Chemokine signalling
22/184
9.9e-26
Neutrophil extracellular trap formation
15/168
6.9e-16
Cytokine-cytokine receptor interaction
14/260
6.3e-12
TNF-α signalling via NF-κB (Hallmark)
17/198
1.9e-16
Cytokine-mediated signalling (GO BP)
40/588
2.8e-35
Outputs: out/enrichment/enrichment_all.tsv (every term, every library),
enrichment_key_pathways.tsv, fig_enrichment_barplot.png/.pdf (A: top 5 terms
per library, B: NF-κB / TNF / cytokine-receptor axis).
Interpretation caveat. The 104-gene module was itself assembled from NF-κB,
cytokine, ER-stress, apoptosis and lipid programmes, so these enrichments confirm
what the module is — they are not independent evidence for the phenotype. The
independent evidence is the cell-type scoring in the result section above. Two
further points worth knowing: all 103 genes fall inside KEGG “Lipid and
atherosclerosis” (a 194-gene term in this background), i.e. the module is
essentially a subset of that pathway; and terms named after infectious diseases
(Hepatitis B, measles, Kaposi) are driven by the same shared cytokine genes, so
they are hidden from the figure (still present in enrichment_all.tsv).
Cis-MR: do the candidate genes causally affect CAD?
scripts/06_fetch_mr_inputs.sh (data) + scripts/07_run_mr.R (TwoSampleMR) run a
two-sample cis-MR of the 18 candidate genes on coronary artery disease. Both
summary-statistic sources are streamed and cut down to the rows we need, so the
whole analysis runs locally from public files and nothing genome-wide has to be
kept on disk.
role
dataset
what is used
exposure
eQTLGen phase I cis-eQTLs, FDR<0.05 (2019-12-11-cis-eQTLsFDR0.05-ProbeLevel-...txt.gz)
11,800 of 12,454 instrument SNPs (60,801 cases / 123,504 controls)
Two details that matter and are easy to get wrong:
eQTLGen publishes Z-scores only. They are converted with the eQTLGen 1000G
allele frequencies, beta = Z / sqrt(2p(1-p)(N+Z^2)), se = 1 / sqrt(2p(1-p)(N+Z^2)).
eQTLGen uses Ensembl-v71 (2013) gene symbols, so CXCL8 is filed as IL8 and
ERO1A as ERO1L. scripts/06_fetch_mr_inputs.sh maps the old names and writes
the current HGNC symbol back into the table. Without this, two perfectly good
genes look like they have no cis-eQTL.
# 1. fetch + filter (streams ~1.3 GB; the GTEx tar and the 820 MB variant
# lookup table are only needed if you want the GTEx fallback)
bash scripts/06_fetch_mr_inputs.sh --gtex
# 2. MR, diagnostics, plots, report
Rscript scripts/07_run_mr.R
# or both as part of the main run:
MR=1 bash run.sh mr_candidate_genes.csv
Instruments are cis SNPs with p < 5e-8 (GTEx: each gene’s own permutation
threshold), distance-pruned at 250 kb — LD clumping is switched on only when a
local PLINK binary and 1000G --bfile are supplied (--plink, --bfile), so the
default path is offline. All instruments have F > 16, i.e. no weak-instrument
problem.
Results, per 1-SD higher genetically predicted expression
gene
method
SNPs
OR (95% CI)
P
NFKBIA
IVW
5
0.93 (0.89-0.97)
4.7e-4
CXCL8 (IL8)
IVW
5
1.07 (1.02-1.12)
9.2e-3
CXCL3
Wald
1
1.10 (0.98-1.24)
0.11
IL1B
Wald
1
1.24 (0.92-1.66)
0.16
OLR1
Wald
1
1.25 (0.90-1.72)
0.18
PPARG
IVW
3
1.06 (0.97-1.15)
0.19
CCL2
Wald
1
1.15 (0.83-1.60)
0.41
NFE2L2
Wald
1
0.87 (0.61-1.23)
0.42
CXCL2
Wald
1
1.04 (0.94-1.14)
0.44
ICAM1
IVW
2
0.91 (0.69-1.21)
0.53
IL18
IVW
3
1.02 (0.95-1.09)
0.62
MMP9
Wald
1
1.03 (0.93-1.14)
0.63
FOS
Wald
1
1.05 (0.83-1.33)
0.70
ERO1A
IVW
5
1.01 (0.97-1.04)
0.73
CD36
IVW
4
1.00 (0.96-1.06)
0.84
LY96
IVW
3
1.00 (0.94-1.06)
0.89
CXCL1
IVW
5
1.00 (0.89-1.12)
0.99
CYBB
—
0
not testable
—
CYBB is X-linked and GCST003116 contains autosomes only, so it has no outcome
data. Diagnostics are in out/mr/mr_report.md: MR-Egger intercepts are all
non-significant (no directional pleiotropy), Steiger directionality is correct for
every gene, and heterogeneity is limited to CXCL1 (Q p = 0.046) and ICAM1
(p = 0.014), both with 5 and 2 SNPs.
Reading of the two hits: higher NFKBIA (IκBα) expression is associated with lower
CAD risk and higher CXCL8 (IL-8) expression with higher risk. Both are
consistent with the atlas result (the module is NF-κB/cytokine driven) and survive
the multiple-testing look (17 genes, Bonferroni 0.05/17 = 2.9e-3) only for NFKBIA —
CXCL8 at p = 9.2e-3 does not. The weighted-median estimate for CXCL8 agrees
(OR 1.06, p = 0.03), so the signal is not driven by a single SNP.
Variant-level follow-up: the Colocalisation
section asks whether the eQTL and CAD signals at these two loci share a causal variant.
Neither locus carries a genome-wide significant CAD association (min P = 2.7e-4 and
7.2e-4), so coloc is underpowered there (PP.H4 = 0.03 and 0.20) — MR can still be
significant because it aggregates small per-SNP effects.
Outputs: out/mr/mr_report.md (tables + embedded figures), mr_results_main.tsv,
mr_results_all_methods.tsv, mr_instruments_summary.tsv, mr_harmonised_all.tsv,
mr_leaveoneout.tsv, mr_singlesnp.tsv, mr_heterogeneity.tsv,
mr_pleiotropy.tsv, mr_directionality.tsv (the last five only for genes with
= 2 instruments), and plots/ with scatter,
leave-one-out and funnel plots per gene plus mr_forest_all_genes.png/.pdf.
Colocalisation: do the eQTL and CAD signals share a variant?
cis-MR says whether the transcript matters; it cannot say which variant carries the
effect. scripts/08_coloc.R (coloc 5.2.3) therefore asks, for the two MR hits, whether
the cis-eQTL and the CAD association in the same window are consistent with one shared
causal variant (PP.H4) or with two distinct causal variants (PP.H3).
role
dataset
what is used
exposure
eQTLGen phase I, full cis-eQTL file (2019-12-11-cis-eQTLsFDR-ProbeLevel-...txt.gz, 3.9 GB)
every SNP within ±500 kb of the TSS (13,263 rows for the four genes, 5,394 for the two targets)
allele frequencies
eQTLGen 1000G SNP AF
Z-scores → beta/SE for the regional SNPs
outcome
CARDIoGRAMplusC4D 1000G (already local in data/mr/_raw/)
every SNP in the same windows
controls
LPL (8p21.3) and SORT1/CELSR2 (1p13.3)
known CAD loci, run through exactly the same code
Why the MR input tables cannot be reused
coloc.abf() is only valid on complete regional summary statistics.
data/mr/eqtlgen_cis.tsv keeps FDR<0.05 pairs only and data/mr/cad_subset.tsv keeps
the 12,454 instrument SNPs only; running coloc on those truncated tables inflates the
eQTL likelihood and makes PP.H4 meaningless. scripts/06_fetch_mr_inputs.sh --coloc
therefore streams the full eQTLGen file (cached under data/coloc/_raw/, 3.9 GB,
git-ignored, safe to delete) and extracts every SNP in the window from both sources.
# 1. regional, complete summary statistics (~5 min from the cached file)
bash scripts/06_fetch_mr_inputs.sh --coloc \
--coloc-genes "CXCL8 NFKBIA LPL:8:19791998 SORT1:1:109896382"
# 2. coloc.abf + prior sensitivity + LocusZoom-style plots + report
Rscript scripts/08_coloc.R --genes CXCL8 NFKBIA LPL SORT1
# or as part of the main run:
COLOC=1 MR=1 bash run.sh mr_candidate_genes.csv
Both traits are harmonised on rsID: every eQTLGen Z-score is converted to
beta/SE with beta = Z / sqrt(2·MAF·(1-MAF)·(N + Z^2)) (Vosa et al. 2021), both
betas are put on the CAD effect allele (complementing strand-flipped pairs),
palindromic SNPs with MAF > 0.42 are dropped, and each gene is tested with
coloc.abf(p12 = 1e-5) plus coloc::sensitivity() over a grid of priors. The
eQTL side is a quantitative trait (N = per-gene eQTLGen sample size, sdY = 1),
the CAD side is case-control (s = 60,801/184,305 = 0.33).
Results
gene
role
chr
SNPs
PP.H1
PP.H3
PP.H4
reading
CXCL8 (IL8)
target
4
2,102
0.858
0.110
0.033
eQTL only
NFKBIA
target
14
2,608
0.696
0.105
0.199
inconclusive
LPL
control
8
4,061
0.007
0.134
0.859
shared variant
SORT1
control
1
2,671
3.6e-17
1.000
8.8e-16
two variants
Reading of the results
Neither target locus carries a genome-wide significant CAD signal in
CARDIoGRAMplusC4D: the strongest SNP in the CXCL8 window is P = 2.7e-4 and in the
NFKBIA window P = 7.2e-4. With no outcome association to attribute, PP.H4 cannot
become large — coloc is underpowered here, and PP.H1 (“eQTL only”) dominates for
CXCL8. This is a power statement, not a refutation of the MR: MR aggregates many
small per-SNP effects and can be significant while no single variant reaches 5e-8
(the NFKBIA IVW P = 4.7e-4 comes from exactly such small, concordant effects).
The code is not the explanation. The same script, on the same data sources,
returns PP.H4 = 0.86 at LPL (a CAD locus whose blood eQTL and CAD signal share
variants) and PP.H3 ≈ 1 at SORT1 (where the blood eQTL and the CAD signal are
two different variants — the CAD signal at 1p13 is hepatic, not blood). The pipeline
therefore discriminates shared from distinct signals on real data.
The regional plots show why: at CXCL8 the eQTL peak (rs1951703, 74.572 Mb) and the
nominal CAD peak (rs74361175, 74.742 Mb) are 170 kb apart; at NFKBIA the two peaks
are 17 kb apart but neither reaches significance and the posteriors remain split
(PP.H4 0.20, PP.H3 0.11, PP.H1 0.70).
These are whole-blood posteriors. The plasma pQTL replication and the
(power-limited) GTEx artery check are in
Protein and tissue replication;
they explain why a low blood PP.H4 is a tissue-specific lower bound rather than
a refutation of the cis-MR.
Outputs: out/coloc/coloc_report.md (tables, per-gene numbers, embedded figures),
coloc_results.tsv (PP.H0-H4, lead SNPs, effect sizes, interpretation),
coloc_sensitivity.tsv (PP.H4 over the p12 grid), coloc_region_<gene>.tsv
(harmonised regional data) and plots/coloc_locus_<gene>.png|.pdf plus
plots/coloc_pp_summary.png|.pdf.
Protein and tissue replication: is the blood eQTL the right proxy?
Both steps above rest on one exposure, a whole-blood cis-eQTL. Two questions
follow: does the protein behave the same way, and does vascular tissue
reproduce the RNA signal strongly enough to re-run either analysis?
1. Plasma pQTL (public GWAS Catalog)
UKB-PPP (Synapse account) and deCODE (download request form) cannot be fetched
unattended, but the GWAS Catalog hosts harmonised public pQTL summary statistics
for both proteins, with complete regional statistics and no registration:
gene
GWAS Catalog
trait
n
used for
CXCL8 (IL-8)
GCST90428420, PMID 39794498
Interleukin-8 levels
70,016
MR + coloc
NFKBIA (IκBα)
GCST90423455, PMID 39528825
NF-kappa-B inhibitor alpha levels
2,721
MR + coloc
These files are GRCh38 + rsIDs and already carry beta, SE and the effect-allele
frequency, so scripts/09_fetch_pqtl.sh only has to cut the ±500 kb windows:
there is no Z → beta step and no external allele-frequency file. The CAD GWAS is
hg19, but every join is on rsID, never on position.
Cis-MR, per 1 SD of plasma protein. The CXCL8 pQTL is strong (rs79656070,
β = −0.134, P = 1.7e-18, F = 77) and points the same way as the blood eQTL:
exposure
gene
instruments
OR (95% CI)
P
eQTLGen blood cis-eQTL
CXCL8
5
1.07 (1.02–1.12)
0.009
plasma IL-8 pQTL
CXCL8
1
1.29 (0.99–1.67)
0.055
eQTLGen blood cis-eQTL
NFKBIA
5
0.93 (0.89–0.97)
4.7e-4
plasma IκBα pQTL
NFKBIA
0
not estimable
—
NFKBIA cannot be tested at the protein level with public data: its only GWAS
Catalog pQTL has n = 2,721 and its best cis-SNP reaches P = 9.8e-3, nowhere near
the 5e-8 needed for an instrument. That is a data-availability limit, not a
negative result.
Coloc, pQTL vs CAD — same code, same windows, same priors as above:
gene
SNPs
PP.H1
PP.H3
PP.H4
reading
CXCL8
2,001
0.839
0.105
0.057
protein only
NFKBIA
1,913
0.087
0.010
0.006
H0 = 0.80 (neither trait)
The CXCL8 pQTL is enormous (P = 1.7e-18) and still gives PP.H4 = 0.06, because
the CAD side of the same window contains nothing significant to colocalise with
(min CAD P = 2.7e-4). Moving from mRNA to protein shifts PP.H4 from 0.033 to
0.057: the ceiling is set by the outcome, not by the exposure. Protein-level data
therefore reinforce the direction of the MR finding and leave the coloc null
where it was.
2. GTEx v8 artery: can vascular tissue carry the analysis?
scripts/10_gtex_artery_check.R reads the GTEx v8 single-tissue eGenes and
significant variant-gene pairs already in data/mr/_gtex/ (Artery_Aorta,
Artery_Coronary, Artery_Tibial, Whole_Blood), recovers each tissue’s sample size
from minor_allele_count / (2·MAF), and looks every lead SNP up in the CAD GWAS:
gene
tissue
n
eGene
q
lead SNP
β (alt)
CAD P at lead
F
instrument
CXCL8
Aorta
387
no
0.13
rs16849080
−0.193
0.10
13.5
none
CXCL8
Coronary
213
no
0.44
rs6532120
+0.230
0.49
8.8
none
CXCL8
Tibial
584
yes
0.018
rs16850659
−0.818
absent
18.1
lead SNP not in CAD
CXCL8
Whole blood
670
yes
3.0e-6
rs4694633
+0.202
0.073
38.9
usable
NFKBIA
Aorta
387
no
0.14
rs28758606
+0.188
0.21
13.7
none
NFKBIA
Coronary
213
no
0.40
rs77319708
+0.858
0.45
10.4
none
NFKBIA
Tibial
584
no
0.21
rs8017740
+0.440
0.72
10.6
none
NFKBIA
Whole blood
670
no
0.19
rs150976671
−0.362
0.064
11.7
none
No artery instrument exists for either gene (0 of 6 gene–tissue pairs).
In the disease-relevant coronary artery (n = 213) neither gene is an eGene:
q = 0.44 (CXCL8), q = 0.40 (NFKBIA), with lead SNPs ~1 Mb from the gene — the
signature of no cis signal at all rather than a weak one.
Aorta (n = 387) is the same: q = 0.13 and q = 0.14.
CXCL8 is an eGene in tibial artery (q = 0.018), but only through two
low-frequency variants — rs16850659 (MAF 1.5%, β = −0.82, the lead) and a second
SNP 244 kb away with the opposite sign (β = +0.62) — and the lead SNP is
absent from the CAD GWAS. No Wald ratio is estimable, and the pair could not
form a coherent instrument in any case.
NFKBIA is not an eGene in any artery, nor in GTEx whole blood at n = 670.
So for these two genes GTEx artery can support neither MR nor coloc; the full
table is out/tissue/gtex_tissue_eqtl.tsv. Coloc is blocked a second time by the
data structure: the v8 archive distributes only the significant variant-gene
pairs, and coloc.abf() needs complete regional statistics. Where the tissue does
have power the same code works: at the GTEx whole-blood CXCL8 lead SNP the Wald
ratio is OR = 1.14 (P = 0.07 at n = 670), the same direction as eQTLGen (1.07) and
the plasma pQTL (1.29).
3. Why a blood eQTL can under-report a vascular target
Tissue specificity is a genuine limitation of the coloc step above and belongs in
the interpretation of the PP.H4 values:
组织特异性会削弱全血 eQTL 的共定位信号。 A whole-blood cis-eQTL is only a
proxy for expression in the cells that build the plaque. If the CAD-relevant
regulatory variant acts on CXCL8 or NFKBIA in plaque macrophages, endothelial
cells or smooth-muscle cells rather than in circulating leukocytes, the blood
eQTL and the CAD signal can tag the same locus through different causal
variants (high PP.H3), or the blood eQTL can be too weak to support any
confident posterior (PP.H1 dominant, PP.H4 low) even when the gene is genuinely
causal in the vessel wall. A low PP.H4 measured in blood is therefore a
tissue-specific estimate and a lower bound on the evidence available in the
artery, not a refutation of the cis-MR. The converse also holds: a high PP.H4 in
blood implicates a shared regulatory variant, but not necessarily the cell type
in which that variant matters.
The GTEx check above shows that public artery data cannot currently settle the
question — at n = 213–584 these two genes are not eGenes in coronary artery or
aorta, so the missing artery-based replication reflects power and data
availability, not absence of a vessel-wall effect. What would settle it: a
well-powered artery eQTL (e.g. STARNET or GTEx v10 arteries), a plaque
single-cell/pseudobulk eQTL, or the pQTL route above, which measures the
circulating protein that a drug would actually engage.
Figures, manuscript and end-to-end reproduction
The figures in the manuscript are assembled from the tables in out/, not from
screenshots, so every panel is a vector PDF that can be regenerated:
11_bulk_gse310013.R downloads the GSE310013 raw counts once into data/bulk/
(git-ignored) and runs a paired DESeq2 model (~ donor + day). With the default
filter (>= 10 counts in >= 4 libraries, padj < 0.05, no LFC cut-off) this gives
11,650 DEGs, of which 117 fall in KEGG hsa05417, capturing 101 of the 103
scorable signature genes. An earlier, unarchived run of the same comparison reported
10,848 DEGs / 111 overlapping genes; the difference is the expressed-gene filter and
the LFC cut-off, and out/bulk/venn_counts.tsv is the authoritative table for the
current run. The 215-gene KEGG set is cached in data/atherosclerosis_kegg.txt so the
figure does not depend on network access to Enrichr.
12_wgcna_modules.R rebuilds the module structure on the 5,048 most variable DEGs
(plus all expressed signature genes): signed hybrid network, soft power 16, modules
renumbered by |r| with culture day so that ME1 is the module most anti-correlated
with Day 7 (r = -0.99, P = 9.3e-10). 101 signature genes fall in ME1/ME2 and 76 have
kME > 0.8.
20_ambient_sensitivity.R is the reviewer-facing control for the cell-type ranking. It
estimates the ambient profile from the 10% lowest-complexity cells, unmixes every cell at
contamination fractions phi = 0-0.30 (c’ = max(0, c - phiTa), renormalised to the atlas
scale T2 recovered per cell from X = log1p(c/T2*1e4)) and rescores all 259,116 cells.
At phi = 0 the recomputed score_mean reproduces out/genes__cell_type_level2/cell_scores.tsv.gz
exactly, which is the built-in check that the implementation is correct. Outputs
(ambient_profile.tsv, scores_by_celltype_by_phi.tsv, summary.tsv, the phi-sweep
figure and ambient_sensitivity.md) go to out/ambient_sensitivity/.
Composite figures are 180 mm wide (single column 90 mm, double column 180 mm), so they
drop into a manuscript template without rescaling; each panel is also saved on its own
for journals that want panels uploaded separately. figure3/fig3A subsamples the UMAP to
~70,000 cells (keeping every PLIN2+/TREM1+ macrophage) so the PDF stays a few MB;
fig3B bins the embedding instead of drawing 259k points, and the violin uses all cells.
The manuscript is manuscript/manuscript.md (Abstract, Introduction, Results,
Discussion, Limitations, Methods, availability statements, references, legends). Numbers
quoted in the text are the ones written by the scripts above; if a script is re-run with
different thresholds, the text has to be updated with the new venn_counts.tsv /
module_trait.tsv / mr_results_main.tsv values.
Caveats
X is used as stored (log-normalised). No re-normalisation, no scaling, no
imputation — this mirrors what Seurat::AddModuleScore/PercentageFeatureSet
would see after NormalizeData.
Gene symbols are matched against var/feature_name; anything unmatched is written
to data/genes_not_in_atlas.txt (alias problems: e.g. use current HGNC symbols).
score_am control bins come from a 60k-cell subsample, so values are stable to
~1e-3 but not bit-identical to a Seurat run on the full object.
Cell-type labels are the atlas’ own (cell_type_level1/2), i.e. author annotations,
not re-clustered here. cell_type (level 0) merges SMC + fibromyocyte.
The atlas is disease-mixed (atherosclerosis + controls); obs/disease, obs/dataset,
obs/donor_id are available for stratified follow-up runs.
Files
plaque-atlas-genescore/
├── run.sh # end-to-end wrapper
├── mr_candidate_genes.csv # 18-gene MR / target short list (script 04)
├── data/
│ ├── plaque_atlas.h5ad # 3.06 GB CELLxGENE download
│ ├── candidate_genes_final.csv # the 104-gene WGCNA module as exported
│ ├── genes.txt # 103 symbols actually scored (SOD2 missing)
│ ├── genes_not_in_atlas.txt # symbols the atlas does not carry
│ ├── genes_demo_markers.txt # 29-marker sanity set
│ ├── atlas_background_genes.txt # 25,377 var symbols = enrichment universe
│ ├── dgidb_annotation.tsv # DGIdb categories / drugs for the module genes
│ ├── _null_random103.txt # expression-matched random control set
│ ├── _module_minus5.txt # module minus the 5 neutrophil-driver genes
│ ├── mr/ # cis-MR inputs (row-filtered from public sumstats)
│ ├── eqtlgen_cis.tsv # eQTLGen cis-eQTLs for the 18 genes
│ ├── eqtlgen_snp_af.tsv # 1000G allele frequencies (Z -> beta)
│ ├── gtex_cis.tsv # GTEx v8 fallback (CXCL2, CXCL3, CYBB)
│ ├── cad_subset.tsv # CAD GWAS rows for the instrument SNPs
│ ├── pqtl_cis.tsv # plasma pQTL instruments (CXCL8)
│ ├── cad_subset_pqtl.tsv # CAD rows for the pQTL instruments
│ ├── SOURCES.tsv # URL / date / row counts
│ └── _raw/ # optional local CAD file (git-ignored, 0.8 GB)
│ └── coloc/ # coloc inputs: complete regional statistics
│ ├── eqtlgen_region.tsv # every cis-eQTL SNP within +/-500 kb of each gene
│ ├── eqtlgen_region_af.tsv # 1000G allele frequencies for those SNPs
│ ├── cad_region.tsv # every CAD SNP in the same windows
│ ├── SOURCES.coloc.tsv # URL / date / row counts
│ ├── pqtl_region.tsv # every pQTL SNP within +/-500 kb (GRCh38)
│ ├── SOURCES.pqtl.tsv # GWAS Catalog accessions / PMID / N
│ └── _raw/ # cached 3.9 GB eQTLGen + pQTL files (git-ignored)
├── scripts/00_inspect_h5ad.R # structure / label tables
├── scripts/01_score_genes.R # streaming scorer + figures
├── scripts/02_genes_from_wgcna.R # WGCNA/DE table -> data/genes.txt + symbol check
├── scripts/03_core_gene_figures.R # Figure 3: violins + per-gene UMAPs
├── scripts/04_mr_candidates.R # MR short list + DGIdb druggability annotation
├── scripts/05_enrichment.py # gseapy GO/KEGG/Reactome enrichment + bar chart
├── scripts/06_fetch_mr_inputs.sh # eQTLGen + GTEx + CAD -> data/mr/ (streaming, row-filtered)
├── scripts/07_run_mr.R # cis-MR per gene: TwoSampleMR, diagnostics, plots, report
├── scripts/08_coloc.R # coloc.abf per gene: posteriors, sensitivity, LocusZoom plots
├── scripts/09_fetch_pqtl.sh # plasma pQTL (GWAS Catalog) -> MR/coloc inputs
├── scripts/10_gtex_artery_check.R # GTEx artery vs blood: is there an instrument at all?
├── scripts/11_bulk_gse310013.R # Figure 1: GSE310013 DEGs + KEGG intersection
├── scripts/12_wgcna_modules.R # Figure 2A: WGCNA modules + module-trait heatmap
├── scripts/13_assemble_figures.R # all manuscript panels -> out/final_figures/
├── manuscript/manuscript.md # Abstract / Intro / Results / Discussion / Methods
├── out/
│ ├── genes__cell_type_level1/ # main result (13 labels)
│ ├── genes__cell_type_level2/ # main result (23 labels)
│ ├── _null_random103__cell_type_level1/ # control
│ ├── _module_minus5__cell_type_level1/ # control
│ ├── core_genes__cell_type_level2/ # Figure 3 + per-cell expression
│ ├── enrichment/ # enrichment tables + bar chart
│ ├── mr/ # cis-MR: report, result tables, per-gene plots
│ ├── coloc/ # colocalisation: report, posteriors, regional plots
│ ├── mr_pqtl/ # plasma pQTL cis-MR (same layout as out/mr/)
│ ├── coloc_pqtl/ # plasma pQTL coloc (same layout as out/coloc/)
│ ├── tissue/ # GTEx artery vs whole blood eQTL check
│ ├── bulk/ # GSE310013 DEGs, venn counts, summary
│ ├── wgcna/ # module assignments, module-trait table, kME
│ ├── final_figures/ # Figure 1-4 + supplementary (PDF + PNG) + captions
│ ├── mr_candidates_all_genes.tsv # ranking of all 103 genes
│ ├── validation_summary.tsv # module vs the two controls
│ ├── demo__cell_type_level1/
│ └── demo__cell_type_level2/
└── logs/
Gene-set scoring in the human atherosclerotic plaque atlas (Traeuble et al. 2025)
Code, figures and manuscript for this project: https://gitlink.org.cn/yanhaidong/vein-graft-atherosclerosis-atlas
Scores an arbitrary gene list (e.g. a 104-gene signature) across every cell type of the integrated human plaque atlas, on all 259,116 cells, on a laptop with ~8 GB RAM.
Dataset
plaque atlas(CELLxGENE Discover)db70986c-7d91-49fe-a399-a4730be394acX=log1p(CP10k)-normalised counts, CSR (X/indptr,X/indices,X/data)cell_type_level1(13 types),cell_type_level2(23 types, author-level),cell_typecell_type_level1keeps Fibromyocyte separate from Smooth Muscle Cell, which matters for SMC-derived plaque phenotypes.Why this pipeline
SeuratDisk/hdf5rare not installable here, and converting a 3 GB h5ad would needTwo passes:
--subsamplecells total) needed for z-scores and for the AddModuleScore control bins;Runtime on this machine: ~2 min for all 259k cells (peak RSS ≈ 600 MB).
Scores (per cell)
score_meanscore_amSeurat::AddModuleScorescore_z(x - mean_g) / sd_g), library-size/level independentn_genes_detlib_sizescore_amis the primary ranking score;score_zis the most robust to genes with very different detection rates.Usage
1. Get the gene list out of WGCNA
R objects live only inside the session that made them, so export the vector once (
write.csv(data.frame(gene = candidate_genes_final), "candidate_genes_final.csv", row.names = FALSE)) or save the workspace.scripts/02_genes_from_wgcna.Rconverts either form to the one-symbol-per-line list the scorer wants, and pre-checks every symbol against the atlasvarbefore the 2-min scoring run:It prints
matched in atlas: N / M, writesdata/genes.txtwith the matched symbols only, and drops the misses intodata/genes_not_in_atlas.txt(aliases, lncRNAs or probe IDs that the atlas calls differently).2. Score the list
or directly:
Useful flags:
--chunk 5000(cells per block),--subsample 60000(cells for pass 1),--nbin 24(AddModuleScore bins),--no-plots,--max-cells N(smoke test on the first N cells),--seed 1.Outputs (per run,
out/<tag>/)cell_scores.tsv.gzlib_size,n_genes_detgene_mean_by_celltype.tsvgene_pct_by_celltype.tsvscore_summary_by_celltype.tsvscore_summary_console.txtgenes_used.txt,genes_not_in_atlas.txtvar/feature_name)figures/violin_score_by_celltype.{png,pdf}figures/dotplot_genes_by_celltype.pngfigures/heatmap_genes_by_celltype.pngfigures/umap_score.png,figures/umap_celltype.pngsessionInfo.txtSanity check with canonical markers
data/genes_demo_markers.txt(29 lineage markers) was used to validate the pipeline. It recovers exactly the expected biology (full atlas,cell_type_level1,score_am):Per-gene dotplot confirms the drivers (SMC:
ACTA2/MYH11/CNN1/TAGLN, fibroblast:COL1A1/COL3A1/COL5A1/DCN/LUM/POSTN, macrophage:CD68/C1QA/CD163/APOE, EC:PECAM1/VWF/CDH5/KDR/ACKR1), so the extraction and the CSR indexing are correct.Result: the 104-gene WGCNA module
Input:
data/candidate_genes_final.csv(104 symbols, NF-kB / cytokine / ER-stress / apoptosis / lipid-handling programme). 103 of 104 are present in the atlasvar;SOD2is genuinely absent (the atlas carriesSOD1,SOD2-OT1,SOD3), so it is listed indata/genes_not_in_atlas.txtand the run uses 103 genes (out/genes__cell_type_level1).cell_type_level1(13 author labels, all 259,116 cells)Kruskal-Wallis across cell types: chi2 = 65,230, df = 12, p < 2.2e-16 (
score_summary_console.txt).score_amandscore_zagree on the top 3; SMC and fibromyocyte are the lowest of all 13 types, i.e. the module is not a smooth-muscle / fibroblast programme.cell_type_level2(23 labels,out/genes__cell_type_level2)Within the monocyte-macrophage compartment the lipid-loaded / inflammatory
PLIN2+/TREM1+subset is the highest scorer and has the largestscore_zof any label, whileTREM2+/FoamyandHMOX1+macrophages are close to the macrophage average — the module tracks the inflammatory rather than the foamy/efferocytotic phenotype.Per-gene drivers (full atlas means,
gene_mean_by_celltype.tsv)CXCL84.71 (93% of cells),NFKBIA2.75 (86%),FOS2.22 (71%),IL1B1.91 (59%),NFE2L21.41 (56%), thenICAM1,HSP90AA1,MAP2K3.NFKBIA2.30,CXCL82.05,FOS2.01,IL1B1.90,CXCL21.48.FOS1.89,HSP90AA11.60,NFKBIA1.48,CALM11.40,HSP90AB11.27.CALM11.76,HSP90AB11.65,HSP90AA11.43), i.e. no module-specific activation.Controls (both in
out/)CXCL8/NFKBIA/FOS/IL1B/NFE2L2(_module_minus5)_null_random103)All 13 labels side by side:
out/validation_summary.tsv.cor(score_am, n_genes_detected) = 0.044andcor(score_am, log10 lib_size) = -0.018across all cells, and -0.02 / 0.01 / 0.05 within neutrophil / monocyte / macrophage — the control bins removed the depth effect.CXCL8/IL1B/NFKBIA/FOS/NFE2L2), so this is biology, not a single-gene outlier — but it does mean the neutrophil ranking is driven by the immediate-early / NF-kB arm of the module.Bottom line
The module is an inflammatory myeloid (NF-kB / cytokine / ER-stress) programme. It is highest in neutrophils (1.5% of the atlas, 97% of cells positive, 20.7x over-representation in the global top 1%), then in monocytes and macrophages, with the
PLIN2+/TREM1+inflammatory macrophage as the top-scoring myeloid subset. It is lowest in SMC / fibromyocyte / fibroblast, so it does not support an SMC- or fibroblast-derived origin for this WGCNA module.MR candidate genes, Figure 3, and pathway enrichment
1.
mr_candidate_genes.csv— 18 genes for MR / target follow-upscripts/04_mr_candidates.Rcombines three sources:out/genes__cell_type_level2(myeloid mean vs non-myeloid mean, log2FC, % of myeloid cells positive);data/dgidb_annotation.tsv; 101/103 genes annotated, 78/103 are in theDRUGGABLE GENOMEcategory);CXCL8,IL1B,NFKBIA,FOS,NFE2L2) + the five strongest myeloid-enriched drivers + Druggable Genome genes with myeloid log2FC >= 1, >= 10% positive cells and mean >= 0.05, until 18 genes.Expression columns are mean log1p(CP10k) in neutrophils / monocytes / the best macrophage subset (from
cell_type_level2).representative_agentsin the CSV lists approved or clinical-stage agents per gene (canakinumab for IL1B, dimethyl fumarate for NFE2L2, TZDs for PPARG, …).Notes:
SOD2is not in the atlas, so it cannot appear in any expression-filtered list (data/genes_not_in_atlas.txt).NFE2L2is not annotatedDRUGGABLE GENOMEby DGIdb (no direct small molecule; dimethyl fumarate/bardoxolone act on the NRF2 pathway), so itsdruggable_genomeflag isFALSEalthough itsrepresentative_agentsis populated.JAK2(0.39),CHUK(0.48),IKBKG,TBK1(0.34),IRAK4,MAPK13(1.51 but only 14% of cells),CASP3,BCL2,HSP90AA1.out/mr_candidates_all_genes.tsv, so the cut-offs can be re-tuned with--n-max,--min-pct,--min-mean,--min-lfc.These 18 genes are what the cis-MR below uses; the actual MR estimates are in Cis-MR: do the candidate genes causally affect CAD?.
2. Figure 3 — core genes at
cell_type_level2scripts/03_core_gene_figures.RstreamsXonce (same chunked CSR reader as01_score_genes.R) and keeps only the 18 candidate columns, so the whole figure costs ~40 MB of RAM and one pass over the 3 GB file.Outputs in
out/core_genes__cell_type_level2/figures/:fig3A_violin_core_genes.png/.pdffig3B_umap_core_genes.pngfig3C_umap_celltype_reference.pngfig3_core_genes_combined.pngData behind the panels:
core_gene_expression.tsv.gz(all 259,116 cells x 18 genes),core_gene_mean_by_celltype.tsv,core_gene_pct_by_celltype.tsv,core_gene_overall_summary.tsv(mean, % positive, top cell type, myeloid log2FC).3. GO / KEGG enrichment —
scripts/05_enrichment.pygseapy
enrichrwith the atlas’ own gene universe as background (data/atlas_background_genes.txt, 25,377 symbols), so the Fisher exact test is comparable toclusterProfiler::enricherrather than Enrichr’s generic background. Libraries: GO BP/MF/CC (2021), KEGG 2021, Reactome 2022, WikiPathways 2024, MSigDB Hallmark 2020. The seven GMT files are committed underdata/gmt/and read locally, so the test runs offline and gives identical P/FDR values on every machine.Key pathways (best term per family, KEGG 2021 unless stated):
Outputs:
out/enrichment/enrichment_all.tsv(every term, every library),enrichment_key_pathways.tsv,fig_enrichment_barplot.png/.pdf(A: top 5 terms per library, B: NF-κB / TNF / cytokine-receptor axis).Interpretation caveat. The 104-gene module was itself assembled from NF-κB, cytokine, ER-stress, apoptosis and lipid programmes, so these enrichments confirm what the module is — they are not independent evidence for the phenotype. The independent evidence is the cell-type scoring in the result section above. Two further points worth knowing: all 103 genes fall inside KEGG “Lipid and atherosclerosis” (a 194-gene term in this background), i.e. the module is essentially a subset of that pathway; and terms named after infectious diseases (Hepatitis B, measles, Kaposi) are driven by the same shared cytokine genes, so they are hidden from the figure (still present in
enrichment_all.tsv).Cis-MR: do the candidate genes causally affect CAD?
scripts/06_fetch_mr_inputs.sh(data) +scripts/07_run_mr.R(TwoSampleMR) run a two-sample cis-MR of the 18 candidate genes on coronary artery disease. Both summary-statistic sources are streamed and cut down to the rows we need, so the whole analysis runs locally from public files and nothing genome-wide has to be kept on disk.2019-12-11-cis-eQTLsFDR0.05-ProbeLevel-...txt.gz)GTEx_Analysis_v8_eQTL.tar, tissues Whole_Blood, Artery_Coronary, Artery_Tibial)cad.add.160614.website.txt)Two details that matter and are easy to get wrong:
beta = Z / sqrt(2p(1-p)(N+Z^2)),se = 1 / sqrt(2p(1-p)(N+Z^2)).IL8and ERO1A asERO1L.scripts/06_fetch_mr_inputs.shmaps the old names and writes the current HGNC symbol back into the table. Without this, two perfectly good genes look like they have no cis-eQTL.Instruments are cis SNPs with p < 5e-8 (GTEx: each gene’s own permutation threshold), distance-pruned at 250 kb — LD clumping is switched on only when a local PLINK binary and 1000G
--bfileare supplied (--plink,--bfile), so the default path is offline. All instruments have F > 16, i.e. no weak-instrument problem.Results, per 1-SD higher genetically predicted expression
CYBBis X-linked and GCST003116 contains autosomes only, so it has no outcome data. Diagnostics are inout/mr/mr_report.md: MR-Egger intercepts are all non-significant (no directional pleiotropy), Steiger directionality is correct for every gene, and heterogeneity is limited to CXCL1 (Q p = 0.046) and ICAM1 (p = 0.014), both with 5 and 2 SNPs.Reading of the two hits: higher NFKBIA (IκBα) expression is associated with lower CAD risk and higher CXCL8 (IL-8) expression with higher risk. Both are consistent with the atlas result (the module is NF-κB/cytokine driven) and survive the multiple-testing look (17 genes, Bonferroni 0.05/17 = 2.9e-3) only for NFKBIA — CXCL8 at p = 9.2e-3 does not. The weighted-median estimate for CXCL8 agrees (OR 1.06, p = 0.03), so the signal is not driven by a single SNP.
Variant-level follow-up: the Colocalisation section asks whether the eQTL and CAD signals at these two loci share a causal variant. Neither locus carries a genome-wide significant CAD association (min P = 2.7e-4 and 7.2e-4), so coloc is underpowered there (PP.H4 = 0.03 and 0.20) — MR can still be significant because it aggregates small per-SNP effects.
Outputs:
out/mr/mr_report.md(tables + embedded figures),mr_results_main.tsv,mr_results_all_methods.tsv,mr_instruments_summary.tsv,mr_harmonised_all.tsv,mr_leaveoneout.tsv,mr_singlesnp.tsv,mr_heterogeneity.tsv,mr_pleiotropy.tsv,mr_directionality.tsv(the last five only for genes withColocalisation: do the eQTL and CAD signals share a variant?
cis-MR says whether the transcript matters; it cannot say which variant carries the effect.
scripts/08_coloc.R(coloc 5.2.3) therefore asks, for the two MR hits, whether the cis-eQTL and the CAD association in the same window are consistent with one shared causal variant (PP.H4) or with two distinct causal variants (PP.H3).2019-12-11-cis-eQTLsFDR-ProbeLevel-...txt.gz, 3.9 GB)data/mr/_raw/)Why the MR input tables cannot be reused
coloc.abf()is only valid on complete regional summary statistics.data/mr/eqtlgen_cis.tsvkeeps FDR<0.05 pairs only anddata/mr/cad_subset.tsvkeeps the 12,454 instrument SNPs only; running coloc on those truncated tables inflates the eQTL likelihood and makes PP.H4 meaningless.scripts/06_fetch_mr_inputs.sh --coloctherefore streams the full eQTLGen file (cached underdata/coloc/_raw/, 3.9 GB, git-ignored, safe to delete) and extracts every SNP in the window from both sources.Both traits are harmonised on rsID: every eQTLGen Z-score is converted to beta/SE with
beta = Z / sqrt(2·MAF·(1-MAF)·(N + Z^2))(Vosa et al. 2021), both betas are put on the CAD effect allele (complementing strand-flipped pairs), palindromic SNPs with MAF > 0.42 are dropped, and each gene is tested withcoloc.abf(p12 = 1e-5)pluscoloc::sensitivity()over a grid of priors. The eQTL side is a quantitative trait (N = per-gene eQTLGen sample size,sdY = 1), the CAD side is case-control (s = 60,801/184,305 = 0.33).Results
Reading of the results
Outputs:
out/coloc/coloc_report.md(tables, per-gene numbers, embedded figures),coloc_results.tsv(PP.H0-H4, lead SNPs, effect sizes, interpretation),coloc_sensitivity.tsv(PP.H4 over the p12 grid),coloc_region_<gene>.tsv(harmonised regional data) andplots/coloc_locus_<gene>.png|.pdfplusplots/coloc_pp_summary.png|.pdf.Protein and tissue replication: is the blood eQTL the right proxy?
Both steps above rest on one exposure, a whole-blood cis-eQTL. Two questions follow: does the protein behave the same way, and does vascular tissue reproduce the RNA signal strongly enough to re-run either analysis?
1. Plasma pQTL (public GWAS Catalog)
UKB-PPP (Synapse account) and deCODE (download request form) cannot be fetched unattended, but the GWAS Catalog hosts harmonised public pQTL summary statistics for both proteins, with complete regional statistics and no registration:
These files are GRCh38 + rsIDs and already carry beta, SE and the effect-allele frequency, so
scripts/09_fetch_pqtl.shonly has to cut the ±500 kb windows: there is no Z → beta step and no external allele-frequency file. The CAD GWAS is hg19, but every join is on rsID, never on position.Cis-MR, per 1 SD of plasma protein. The CXCL8 pQTL is strong (rs79656070, β = −0.134, P = 1.7e-18, F = 77) and points the same way as the blood eQTL:
NFKBIA cannot be tested at the protein level with public data: its only GWAS Catalog pQTL has n = 2,721 and its best cis-SNP reaches P = 9.8e-3, nowhere near the 5e-8 needed for an instrument. That is a data-availability limit, not a negative result.
Coloc, pQTL vs CAD — same code, same windows, same priors as above:
The CXCL8 pQTL is enormous (P = 1.7e-18) and still gives PP.H4 = 0.06, because the CAD side of the same window contains nothing significant to colocalise with (min CAD P = 2.7e-4). Moving from mRNA to protein shifts PP.H4 from 0.033 to 0.057: the ceiling is set by the outcome, not by the exposure. Protein-level data therefore reinforce the direction of the MR finding and leave the coloc null where it was.
2. GTEx v8 artery: can vascular tissue carry the analysis?
scripts/10_gtex_artery_check.Rreads the GTEx v8 single-tissue eGenes and significant variant-gene pairs already indata/mr/_gtex/(Artery_Aorta, Artery_Coronary, Artery_Tibial, Whole_Blood), recovers each tissue’s sample size fromminor_allele_count / (2·MAF), and looks every lead SNP up in the CAD GWAS:No artery instrument exists for either gene (0 of 6 gene–tissue pairs).
So for these two genes GTEx artery can support neither MR nor coloc; the full table is
out/tissue/gtex_tissue_eqtl.tsv. Coloc is blocked a second time by the data structure: the v8 archive distributes only the significant variant-gene pairs, andcoloc.abf()needs complete regional statistics. Where the tissue does have power the same code works: at the GTEx whole-blood CXCL8 lead SNP the Wald ratio is OR = 1.14 (P = 0.07 at n = 670), the same direction as eQTLGen (1.07) and the plasma pQTL (1.29).3. Why a blood eQTL can under-report a vascular target
Tissue specificity is a genuine limitation of the coloc step above and belongs in the interpretation of the PP.H4 values:
The GTEx check above shows that public artery data cannot currently settle the question — at n = 213–584 these two genes are not eGenes in coronary artery or aorta, so the missing artery-based replication reflects power and data availability, not absence of a vessel-wall effect. What would settle it: a well-powered artery eQTL (e.g. STARNET or GTEx v10 arteries), a plaque single-cell/pseudobulk eQTL, or the pQTL route above, which measures the circulating protein that a drug would actually engage.
Figures, manuscript and end-to-end reproduction
The figures in the manuscript are assembled from the tables in
out/, not from screenshots, so every panel is a vector PDF that can be regenerated:11_bulk_gse310013.Rdownloads the GSE310013 raw counts once intodata/bulk/(git-ignored) and runs a paired DESeq2 model (~ donor + day). With the default filter (>= 10 counts in >= 4 libraries, padj < 0.05, no LFC cut-off) this gives 11,650 DEGs, of which 117 fall in KEGG hsa05417, capturing 101 of the 103 scorable signature genes. An earlier, unarchived run of the same comparison reported 10,848 DEGs / 111 overlapping genes; the difference is the expressed-gene filter and the LFC cut-off, andout/bulk/venn_counts.tsvis the authoritative table for the current run. The 215-gene KEGG set is cached indata/atherosclerosis_kegg.txtso the figure does not depend on network access to Enrichr.12_wgcna_modules.Rrebuilds the module structure on the 5,048 most variable DEGs (plus all expressed signature genes): signed hybrid network, soft power 16, modules renumbered by |r| with culture day so that ME1 is the module most anti-correlated with Day 7 (r = -0.99, P = 9.3e-10). 101 signature genes fall in ME1/ME2 and 76 have kME > 0.8.20_ambient_sensitivity.Ris the reviewer-facing control for the cell-type ranking. It estimates the ambient profile from the 10% lowest-complexity cells, unmixes every cell at contamination fractions phi = 0-0.30 (c’ = max(0, c - phiTa), renormalised to the atlas scale T2 recovered per cell from X = log1p(c/T2*1e4)) and rescores all 259,116 cells. At phi = 0 the recomputedscore_meanreproducesout/genes__cell_type_level2/cell_scores.tsv.gzexactly, which is the built-in check that the implementation is correct. Outputs (ambient_profile.tsv,scores_by_celltype_by_phi.tsv,summary.tsv, the phi-sweep figure andambient_sensitivity.md) go toout/ambient_sensitivity/.13_assemble_figures.Rwrites:Composite figures are 180 mm wide (single column 90 mm, double column 180 mm), so they drop into a manuscript template without rescaling; each panel is also saved on its own for journals that want panels uploaded separately.
figure3/fig3Asubsamples the UMAP to ~70,000 cells (keeping every PLIN2+/TREM1+ macrophage) so the PDF stays a few MB;fig3Bbins the embedding instead of drawing 259k points, and the violin uses all cells.The manuscript is
manuscript/manuscript.md(Abstract, Introduction, Results, Discussion, Limitations, Methods, availability statements, references, legends). Numbers quoted in the text are the ones written by the scripts above; if a script is re-run with different thresholds, the text has to be updated with the newvenn_counts.tsv/module_trait.tsv/mr_results_main.tsvvalues.Caveats
Xis used as stored (log-normalised). No re-normalisation, no scaling, no imputation — this mirrors whatSeurat::AddModuleScore/PercentageFeatureSetwould see afterNormalizeData.var/feature_name; anything unmatched is written todata/genes_not_in_atlas.txt(alias problems: e.g. use current HGNC symbols).score_amcontrol bins come from a 60k-cell subsample, so values are stable to ~1e-3 but not bit-identical to a Seurat run on the full object.cell_type_level1/2), i.e. author annotations, not re-clustered here.cell_type(level 0) merges SMC + fibromyocyte.obs/disease,obs/dataset,obs/donor_idare available for stratified follow-up runs.Files