Skip to content

Commit a0daa40

Browse files
committed
Harden eQTL evidence semantics and refresh site
1 parent 0c2f4cf commit a0daa40

261 files changed

Lines changed: 234033 additions & 96500 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,24 @@ chrXatlas is a chromosome X evidence atlas built from publicly available Pan-UK
1010

1111
---
1212

13-
## Current build (2026-03-18)
13+
## Current build (2026-03-19)
1414

1515
| Metric | Value |
1616
|--------|-------|
1717
| Curated panels | 3 (Broad Atlas, Mind & Risk, Biochemistry Deep Dive) |
1818
| Curated traits | 79 across panels |
1919
| Chromosome X loci mapped | 1,109 |
20-
| eQTL support rate | 97% of curated traits |
20+
| eQTL lookup-hit coverage | 77 of 79 curated traits |
21+
| Strict eQTL-supported traits | 62 of 79 curated traits |
2122
| Biological domains | 26 unique |
2223
| Discovery pool | 150 traits, 643 loci |
2324

2425
### Curated panels
2526

26-
- **Broad Atlas** — 48 traits, 751 loci, 98% eQTL-supported. The featured all-purpose panel with broad trait coverage across 21 biological domains.
27-
- **Mind & Risk** — 10 traits, 26 loci, 90% eQTL-supported. Focused behavior and cognition panel: risk-taking, smoking, alcohol, mood, irritability, reaction time, neuroticism, insomnia, depression, fluid intelligence.
28-
- **Biochemistry Deep Dive** — 21 traits, 332 loci, 100% eQTL-supported. High-yield blood biochemistry panel covering lipoproteins, kidney markers, endocrine biomarkers, minerals, and liver enzymes.
29-
- **Discovery Pool** — 150 traits, 643 loci, 48% eQTL-supported. The discovery pool is built from the full Pan-UKB max independent set filtered to `num_pops_pass_qc >= 2`, so the broad build starts from non-redundant traits with at least minimal multi-population QC rather than the noisiest single-population results. It is kept as a curation reservoir, not a featured panel.
27+
- **Broad Atlas** — 48 traits, 751 loci, 47 lookup-hit and 40 strictly eQTL-supported. The featured all-purpose panel with broad trait coverage across 21 biological domains.
28+
- **Mind & Risk** — 10 traits, 26 loci, 9 lookup-hit and 4 strictly eQTL-supported. Focused behavior and cognition panel: risk-taking, smoking, alcohol, mood, irritability, reaction time, neuroticism, insomnia, depression, fluid intelligence.
29+
- **Biochemistry Deep Dive** — 21 traits, 332 loci, 21 lookup-hit and 18 strictly eQTL-supported. High-yield blood biochemistry panel covering lipoproteins, kidney markers, endocrine biomarkers, minerals, and liver enzymes.
30+
- **Discovery Pool** — 150 traits, 643 loci, 72 lookup-hit and 34 strictly eQTL-supported. The discovery pool is built from the full Pan-UKB max independent set filtered to `num_pops_pass_qc >= 2`, so the broad build starts from non-redundant traits with at least minimal multi-population QC rather than the noisiest single-population results. It is kept as a curation reservoir, not a featured panel.
3031

3132
---
3233

@@ -125,13 +126,13 @@ eQTL evidence is incorporated through targeted queries against the **eQTL Catalo
125126
3. **Stop if no rsID** — if no rsID can be recovered, mark the locus as not safely assessed (coordinate-based region queries are provisional and explicitly flagged)
126127

127128
For each locus, the pipeline records:
128-
- Whether the locus has eQTL support
129-
- Which studies provided evidence
130-
- Best eQTL p-value
131-
- Supporting gene IDs
129+
- Whether the lookup returned any eQTL associations (`eQTL lookup-hit`)
130+
- Whether any positional candidate gene passes the strict support rule (`eQTL-supported`)
131+
- Which studies and datasets contributed evidence
132+
- Best observed eQTL p-value
132133
- Lookup mode used (`rsid`, `variant_recoder+rsid`, or `region_provisional`)
133134

134-
A locus is **eQTL-supported** when its rsID-based lookup against prioritized eQTL Catalogue datasets returns at least one significant association between the lead variant and gene expression. A trait is **eQTL-supported** when at least one of its chrX loci has eQTL-supported candidate-gene evidence.
135+
A locus is **eQTL lookup-hit** when its rsID-based follow-up against prioritized eQTL Catalogue datasets returns one or more associations. A locus is **eQTL-supported** only when at least one pre-defined candidate gene at that locus has aggregated eQTL evidence with `best_eqtl_pvalue <= 1e-5` in the current build. A trait is **eQTL-supported** when at least one of its chrX loci has eQTL-supported candidate-gene evidence.
135136

136137
Note: the current pipeline does not perform chrX LD modeling, colocalization analysis, or trait-specific tissue relevance matching. Study selection uses a prioritized dataset list (see `config/eqtl_priority_studies.csv`), not a tissue-relevance model. LD-aware colocalization and tissue-specific weighting are future work.
137138

@@ -165,7 +166,7 @@ Each trait receives a **coverage grade** reflecting the quality and completeness
165166
- **Grade C** — high-quality cohort of at least 1,000
166167
- **Grade U** — does not meet the above thresholds; coverage is uncertain
167168

168-
The coverage grade reflects **GWAS data quality**, not eQTL follow-up completeness. A trait can have Grade A coverage (strong upstream GWAS) but no eQTL support (no functional follow-up hits).
169+
The coverage grade reflects **GWAS data quality**, not eQTL follow-up completeness. A trait can have Grade A coverage (strong upstream GWAS) but no strict eQTL support.
169170

170171
### Gene annotation
171172

@@ -192,8 +193,8 @@ The interactive frontend lives in `site/` and reads the JSON data bundle from `s
192193
### Pages
193194

194195
- **Homepage** (`index.html`) — Hero, metrics, curated panel cards, domain composition, top traits rail, panel comparison, discovery pool, methods explainer, search
195-
- **Panel detail** (`panel.html?id=...`) — Full trait table with column sorting, domain filter chips, text search, eQTL-only toggle
196-
- **Trait detail** (`trait.html?panel=...&trait=...`) — Evidence score, eQTL support explanation card, trait metadata grid, expandable locus cards with candidate gene tables, notes
196+
- **Panel detail** (`panel.html?id=...`) — Full trait table with column sorting, domain filter chips, text search, eQTL-supported-only toggle
197+
- **Trait detail** (`trait.html?panel=...&trait=...`) — Evidence score, eQTL lookup-hit versus support explanation card, trait metadata grid, expandable locus cards with candidate gene tables, notes
197198

198199
### Features
199200

docs/FRONTEND_DATA_CONTRACT.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Frontend Data Contract
2+
3+
This document defines the JSON bundle contract produced by:
4+
5+
`scripts/14_export_frontend_bundle.py`
6+
7+
The bundle is additive. Existing release TSV files remain canonical for scientific auditing.
8+
9+
## Output root
10+
11+
Default output root:
12+
13+
`../data/release/frontend/`
14+
15+
Layout:
16+
17+
- `manifest.json`
18+
- `panels/<panel_id>/summary.json`
19+
- `panels/<panel_id>/traits.json`
20+
- `panels/<panel_id>/traits/<trait_id>.json`
21+
22+
## Schema versioning
23+
24+
- `schema_version` is currently `2`.
25+
- `schema_version` is included in:
26+
- `manifest.json`
27+
- each panel `summary.json`
28+
- each trait-detail JSON
29+
- If shape changes are not backward compatible, increment `schema_version`.
30+
31+
## Panels in v1
32+
33+
V1 exports exactly these panel IDs:
34+
35+
- `panel_expanded` (`featured: true`)
36+
- `panel_mind_risk_core` (`featured: true`)
37+
- `panel_blood_biochemistry_core` (`featured: true`)
38+
- `panel_independent_set` (`featured: false`)
39+
40+
`panel_independent_set` is intentionally marked non-featured so frontend can demote it by default.
41+
42+
## Field semantics
43+
44+
## `manifest.json`
45+
46+
- `generated_at`: UTC ISO timestamp
47+
- `panels`: one entry per exported panel
48+
- each panel entry includes:
49+
- `panel_id`, `slug`, `title`, `featured`, `description`
50+
- `trait_count`, `lookup_hit_trait_count`, `supported_trait_count`, `zero_locus_trait_count`, `locus_count`
51+
- `files` with relative paths to:
52+
- `summary`
53+
- `traits`
54+
- `trait_details_dir`
55+
56+
## `summary.json`
57+
58+
- `panel`: panel metadata copied from manifest entry
59+
- `metrics`: aggregate counts
60+
- `domain_summary`: grouped by `domain`
61+
- `top_traits`: top compact trait rows
62+
- `unsupported_traits`: traits without strict eQTL support
63+
64+
## `traits.json`
65+
66+
Compact list for grid/table UIs. Each row includes:
67+
68+
- `trait_id`
69+
- `query_id`
70+
- `slug`
71+
- `description`
72+
- `domain`
73+
- `x_evidence_score`
74+
- `coverage_grade`
75+
- `n_loci`
76+
- `eqtl_lookup_hit`
77+
- `eqtl_lookup_hit_locus_count`
78+
- `eqtl_lookup_hit_count`
79+
- `eqtl_supported`
80+
- `eqtl_supported_locus_count`
81+
- `eqtl_total_hit_count`
82+
- `top_candidate_genes` (array)
83+
- `confidence_notes`
84+
- `eqtl_lookup_note`
85+
86+
## `traits/<trait_id>.json`
87+
88+
- `trait`: compact trait row plus phenotype metadata (`trait_type`, `phenocode`, `pheno_sex`, `coding`, `modifier`, `category`)
89+
- `loci`: sorted by strongest signal first
90+
- each locus includes:
91+
- `locus_id`, `x_region`, `locus_start`, `locus_end`
92+
- `lead_pos`, `lead_rsid`, `lead_varid`, `lead_neglog10_pvalue`
93+
- `top_gene_id`, `top_gene_name`, `best_mapping_relation`
94+
- `eqtl_lookup_hit`, `eqtl_lookup_hit_gene_count`
95+
- `eqtl_supported`, `eqtl_supported_gene_count`
96+
- `eqtl_lookup_status`, `eqtl_lookup_mode`, `eqtl_lookup_n_hits`
97+
- `candidate_genes` array
98+
- each candidate gene includes:
99+
- `candidate_rank`, `gene_id`, `gene_name`, `gene_biotype`
100+
- `mapping_relation`, `distance_to_lead_bp`, `eqtl_gene_role`
101+
- `eqtl_lookup_hit`, `eqtl_lookup_hit_count`
102+
- `eqtl_supported`, `eqtl_study_count`, `eqtl_dataset_count`, `best_eqtl_pvalue`
103+
104+
## Evidence semantics
105+
106+
- `eqtl_lookup_hit` means the prioritized rsID-based follow-up returned one or more associations.
107+
- `eqtl_supported` is stricter and means at least one candidate gene passed the build's current support rule after aggregation across queried datasets.
108+
- Trait-level `eqtl_supported` does not imply the positional top gene is supported.
109+
- `eqtl_gene_role = candidate` means the gene came from the positional mapping hierarchy.
110+
- `eqtl_gene_role = followup` means the gene was added only because it appeared in eQTL lookup results.
111+
112+
## Normalization rules
113+
114+
- Booleans are JSON booleans, not string literals.
115+
- Missing numeric/string values are `null`.
116+
- `top_candidate_genes` is always an array (split from comma-delimited TSV field).
117+
- Sorting:
118+
- traits: `x_evidence_score desc`, `n_loci desc`, `eqtl_total_hit_count desc`, then stable string tie-breakers
119+
- loci in trait detail: `lead_neglog10_pvalue desc`, `n_sig_variants desc`, then genomic start
120+
- candidate genes per locus: `candidate_rank asc`, then lexical tie-breakers

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
55
[project]
66
name = "chrXatlas"
77
version = "0.1.0"
8-
description = "Chromosome X evidence atlas built from public Pan-UK Biobank summary statistics with locus mapping and targeted eQTL follow-up"
8+
description = "Chromosome X discovery atlas built from public Pan-UK Biobank summary statistics with locus mapping and targeted eQTL follow-up"
99
readme = "README.md"
1010
requires-python = ">=3.10"
1111
dependencies = [

scripts/06_fetch_eqtl_region_hits.py

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,21 @@ def lookup_eqtl_by_rsid(
177177
api_base_url: str,
178178
timeout_seconds: float,
179179
study_specs: list[dict[str, str]],
180+
pvalue_threshold: float | None,
180181
) -> tuple[pd.DataFrame, str, str, str]:
181182
failures: list[str] = []
182-
saw_explicit_no_hits = False
183+
all_hits: list[pd.DataFrame] = []
184+
completed_datasets = 0
185+
no_hit_datasets = 0
183186

184187
for spec in study_specs:
185188
dataset_id = str(spec.get("dataset_id", "") or "").strip()
186189
if not dataset_id:
187190
continue
191+
dataset_failed = True
188192
params = {"rsid": rsid, "size": 1000}
193+
if pvalue_threshold is not None:
194+
params["p_upper"] = pvalue_threshold
189195
for api_version in ("v3", "v2"):
190196
outcome, hits, detail = try_api_request(
191197
session,
@@ -196,18 +202,44 @@ def lookup_eqtl_by_rsid(
196202
)
197203
mode = f"rsid_api_dataset:{api_version}:{dataset_id}"
198204
if outcome == "hits":
199-
return hits.drop_duplicates(), "complete", mode, detail
205+
dataset_failed = False
206+
completed_datasets += 1
207+
tagged_hits = hits.drop_duplicates().copy()
208+
tagged_hits["eqtl_dataset_id"] = dataset_id
209+
tagged_hits["eqtl_api_version"] = api_version
210+
all_hits.append(tagged_hits)
211+
break
200212
if outcome == "no_hits":
201-
saw_explicit_no_hits = True
202-
failures.append(f"{mode}: {detail or 'no hits'}")
213+
dataset_failed = False
214+
completed_datasets += 1
215+
no_hit_datasets += 1
203216
break
217+
failures.append(f"{mode}: {detail}")
204218
if detail.startswith("HTTP 404:"):
205-
failures.append(f"{mode}: {detail}")
206219
continue
207-
failures.append(f"{mode}: {detail}")
220+
break
221+
if dataset_failed:
222+
continue
223+
224+
if all_hits:
225+
merged = pd.concat(all_hits, ignore_index=True).drop_duplicates()
226+
status = "complete" if not failures else "partial_complete"
227+
detail = (
228+
f"Aggregated {len(merged):,} associations across {int(merged['eqtl_dataset_id'].nunique())} dataset(s)"
229+
)
230+
if no_hit_datasets:
231+
detail += f"; {no_hit_datasets} dataset(s) returned no hits"
232+
if failures:
233+
detail += f"; {len(failures)} dataset/version attempt(s) failed"
234+
return merged, status, "rsid_api_aggregate", compact_detail(detail)
235+
236+
if completed_datasets:
237+
status = "no_hits" if not failures else "partial_complete"
238+
detail = f"No associations returned across {completed_datasets} completed dataset lookup(s)."
239+
if failures:
240+
detail += f" {len(failures)} dataset/version attempt(s) failed."
241+
return pd.DataFrame(), status, "rsid_api_aggregate", compact_detail(detail)
208242

209-
if saw_explicit_no_hits:
210-
return pd.DataFrame(), "no_hits", "rsid_api", "No associations returned for the requested rsID."
211243
raise RuntimeError(compact_detail("; ".join(failures) or "No successful rsID lookup attempt."))
212244

213245

@@ -218,15 +250,21 @@ def lookup_eqtl_by_variant(
218250
api_base_url: str,
219251
timeout_seconds: float,
220252
study_specs: list[dict[str, str]],
253+
pvalue_threshold: float | None,
221254
) -> tuple[pd.DataFrame, str, str, str]:
222255
failures: list[str] = []
223-
saw_explicit_no_hits = False
256+
all_hits: list[pd.DataFrame] = []
257+
completed_datasets = 0
258+
no_hit_datasets = 0
224259

225260
for spec in study_specs:
226261
dataset_id = str(spec.get("dataset_id", "") or "").strip()
227262
if not dataset_id:
228263
continue
264+
dataset_failed = True
229265
params = {"variant": variant, "size": 1000}
266+
if pvalue_threshold is not None:
267+
params["p_upper"] = pvalue_threshold
230268
for api_version in ("v3", "v2"):
231269
outcome, hits, detail = try_api_request(
232270
session,
@@ -237,18 +275,44 @@ def lookup_eqtl_by_variant(
237275
)
238276
mode = f"variant_api_dataset:{api_version}:{dataset_id}"
239277
if outcome == "hits":
240-
return hits.drop_duplicates(), "complete", mode, detail
278+
dataset_failed = False
279+
completed_datasets += 1
280+
tagged_hits = hits.drop_duplicates().copy()
281+
tagged_hits["eqtl_dataset_id"] = dataset_id
282+
tagged_hits["eqtl_api_version"] = api_version
283+
all_hits.append(tagged_hits)
284+
break
241285
if outcome == "no_hits":
242-
saw_explicit_no_hits = True
243-
failures.append(f"{mode}: {detail or 'no hits'}")
286+
dataset_failed = False
287+
completed_datasets += 1
288+
no_hit_datasets += 1
244289
break
290+
failures.append(f"{mode}: {detail}")
245291
if detail.startswith("HTTP 404:"):
246-
failures.append(f"{mode}: {detail}")
247292
continue
248-
failures.append(f"{mode}: {detail}")
293+
break
294+
if dataset_failed:
295+
continue
296+
297+
if all_hits:
298+
merged = pd.concat(all_hits, ignore_index=True).drop_duplicates()
299+
status = "complete" if not failures else "partial_complete"
300+
detail = (
301+
f"Aggregated {len(merged):,} associations across {int(merged['eqtl_dataset_id'].nunique())} dataset(s)"
302+
)
303+
if no_hit_datasets:
304+
detail += f"; {no_hit_datasets} dataset(s) returned no hits"
305+
if failures:
306+
detail += f"; {len(failures)} dataset/version attempt(s) failed"
307+
return merged, status, "variant_api_aggregate", compact_detail(detail)
308+
309+
if completed_datasets:
310+
status = "no_hits" if not failures else "partial_complete"
311+
detail = f"No associations returned across {completed_datasets} completed dataset lookup(s)."
312+
if failures:
313+
detail += f" {len(failures)} dataset/version attempt(s) failed."
314+
return pd.DataFrame(), status, "variant_api_aggregate", compact_detail(detail)
249315

250-
if saw_explicit_no_hits:
251-
return pd.DataFrame(), "no_hits", "variant_api", "No associations returned for the requested variant."
252316
raise RuntimeError(compact_detail("; ".join(failures) or "No successful variant lookup attempt."))
253317

254318

@@ -408,6 +472,12 @@ def main() -> None:
408472
)
409473
parser.add_argument("--window-bp", type=int, default=1_000_000)
410474
parser.add_argument("--max-studies-per-locus", type=int, default=5)
475+
parser.add_argument(
476+
"--eqtl-pvalue-threshold",
477+
type=float,
478+
default=1e-5,
479+
help="Conservative raw p-value cutoff applied to API queries when per-dataset FDR calls are unavailable.",
480+
)
411481
parser.add_argument("--sleep-seconds", type=float, default=2.0)
412482
parser.add_argument("--out", default=str(INTERIM_DIR / "eqtl_hits.tsv.gz"))
413483
parser.add_argument("--summary-out", default=str(INTERIM_DIR / "eqtl_lookup_summary.tsv"))
@@ -496,6 +566,7 @@ def main() -> None:
496566
api_base_url=args.api_base_url.rstrip("/"),
497567
timeout_seconds=args.request_timeout_seconds,
498568
study_specs=study_specs,
569+
pvalue_threshold=args.eqtl_pvalue_threshold,
499570
)
500571
if recovered_via_variant_recoder:
501572
query_mode = f"variant_recoder+{query_mode}"
@@ -529,6 +600,7 @@ def main() -> None:
529600
api_base_url=args.api_base_url.rstrip("/"),
530601
timeout_seconds=args.request_timeout_seconds,
531602
study_specs=study_specs,
603+
pvalue_threshold=args.eqtl_pvalue_threshold,
532604
)
533605
status = build_locus_query_status(
534606
locus_id=locus_id,

0 commit comments

Comments
 (0)