Dataset Curation
How datasets: records get into dismech entries, and why the workflow is shaped
the way it is.
The problem this solves
A Dataset record's accession is the one identifier class in dismech that had
no validator behind it. linkml-reference-validator checks PMIDs, DOIs, and
NCTs against real sources; nothing resolved geo:GSE67472. A fabricated
geo:GSE9999999 passed just qc without complaint.
Dataset accessions are unusually easy to hallucinate — they are short, numeric, and highly patterned, so a plausible-looking wrong answer costs a model nothing. Any workflow that adds them in bulk needs a machine check, not a curator's eye.
Two failure modes, two different guards
The distinction matters, because the second one defeats the first.
1. The accession does not exist. Caught by just verify-datasets, which
resolves each accession against the repository's own API.
2. The accession exists, but it is the wrong disease. Not caught by verification — the accession resolves perfectly. Two routes lead here:
- Gene-mediated: searching the causal gene surfaces datasets about whatever
disease that gene is famous for.
FTLfor neuroferritinopathy returns Alzheimer and medulloblastoma series;FGFR3for achondroplasia returns glioblastoma methylation data. - Sibling disease: relaxing a precise entry name collapses it onto a
different disease.
Acquired_Angioedemaattracts hereditary angioedema data;Acquired_Partial_Lipodystrophyattracts familial partial lipodystrophy data.
This is Named Entity Confusion
arriving through dataset search rather than through a deep-research report. The
tooling reduces it (candidates are tagged DIRECT / GENE_ONLY / CONFLICT)
but cannot eliminate it, so relevance triage stays a human/model judgement.
Which source to use
Sources are not interchangeable, and two of them mostly duplicate others. Figures below are measured, not estimated.
| Source | Recipe | Coverage | Use it for |
|---|---|---|---|
| EGA | discover_ega.py |
10,453 studies | Controlled-access human cohorts GEO cannot index |
| GEO | discover_datasets.py |
— | Common/complex disease. Poor for rare disease (see below) |
| ArrayExpress | discover_arrayexpress.py |
21,319 native of 80,697 | 73.6% are GEO re-imports — native submissions only |
| OmicsDI | discover_omicsdi.py |
aggregator | 89% duplicates other sources — used only as a router to Metabolomics Workbench / MassIVE / dbGaP |
Two rules follow from the measurements:
Never curate ArrayExpress E-GEOD-* records. E-GEOD-19431 is GSE19431.
Curating both puts one experiment in an entry twice under two accessions that
both resolve, which no verifier can detect.
Never curate OmicsDI hits from GEO/ArrayExpress/PRIDE/MetaboLights/EGA. Same duplication problem, one aggregation layer further out.
Why GEO stops working for rare disease
Measured over 140 Mendelian entries: 24% yielded anything and 40% of proposals were rejected as wrong-disease. A GEO series carries no coded disease, so for a rare disorder the only available signal is its causal gene — and a gene hit is usually a study about something else. Prefer EGA and dbGaP there.
The workflow
# 1. What still needs datasets?
just datasets-coverage
# 2. Candidates for one disorder, real by construction
just discover-datasets Asthma
# 3. Batch: search + verify + write a proposal file
uv run python scripts/build_dataset_records.py propose \
--slugs-file batch.txt --out proposals/batch.json
# 4. Triage -- the step that cannot be skipped
uv run python scripts/triage_dataset_proposals.py show proposals/batch.json
uv run python scripts/triage_dataset_proposals.py reject proposals/batch.json \
--accession geo:GSE219154 --reason "sibling disease"
# 5. Write approved records into the KB
uv run python scripts/build_dataset_records.py apply proposals/batch.json
# 6. Confirm only `datasets:` moved
git diff kb/disorders/Asthma.yaml
# 7. Validate. For geo: accessions this also fetches
# references_cache/GEO_<ID>.md -- stage those with the entry.
just verify-datasets kb/disorders/Asthma.yaml
just validate kb/disorders/Asthma.yaml
git add kb/disorders/Asthma.yaml references_cache/GEO_*.md
# 8. Record the change (CLAUDE.md requires a history record per KB edit)
uv run python scripts/new_history.py --kind disorder --slug Asthma \
--event EDIT --outcome changed --sections datasets \
--summary "Add public dataset records from GEO" --details "..."
apply splices records in as text and then re-parses to confirm nothing but
datasets: changed, so step 6 should show a pure addition.
(just new-history used to be unable to take multi-word argument values, because
the recipe pasted {{ARGS}} in as text and the shell re-split it — which is why
the snippet above calls scripts/new_history.py directly. The recipe now forwards
real positional arguments, so just new-history accepts quoted prose too; either
form works.)
For repositories GEO search cannot reach (PRIDE, MetaboLights, EGA, dbGaP, cellxgene), use the deep-research path:
just research-datasets openscientist Marfan_Syndrome
Treat its output as candidate accessions only — every one must pass
just verify-datasets --accession <acc> before curation. The template is
written so that an empty answer is a safe answer, because the failure that
matters is an invented accession, not a short list.
What a generated record contains
Every field comes from the repository's own metadata, so there is nothing for a model to invent:
| Field | Source |
|---|---|
accession |
GEO, re-verified against NCBI E-utilities |
title |
GEO's title, verbatim |
description |
GEO's summary, trimmed at a sentence boundary |
organism |
GEO taxon, mapped to NCBITaxon |
data_type |
GEO gdsType, mapped to DatasetTypeEnum |
sample_count |
GEO n_samples |
publication |
GEO's own linked PMID |
notes |
provenance: how it was found and when it was verified |
Why there is no evidence: block
A dismech evidence item requires an exact quote from the cited abstract.
Generating those in bulk is exactly where fabrication enters — it is the
SOP's
central warning. Bulk-generated dataset records therefore carry publication:
and provenance notes instead, and evidence enrichment is left as a deliberate
follow-up for a curator or a targeted, verified agent pass.
What has changed is that the quote now exists: the GEO summary is cached at
references_cache/GEO_<ID>.md, so a curator can quote it and cite GEO:<ID>
(worked example: Acne_Vulgaris). That makes evidence enrichment possible
per-record; it does not make it safe in bulk, and the rule above is unchanged.
Where verification results are stored
Dataset.accession carries implements: linkml:authoritative_reference — it is
a reference slot, and always was. conf/reference_validator_config.yaml merely
lists the dataset prefixes under skip_prefixes.
For geo:, verification and caching are now one operation.
just verify-datasets asks the reference fetcher for the record; the fetcher
writes references_cache/GEO_<ID>.md carrying GEO's title and summary, and
writes it only if the repository returned something. So:
- a cache file present is the proof the accession resolves;
- commit it with the
datasets:block, exactly like aPMID_*.md; - every later run, and CI, verifies offline.
All 919 geo: accessions in kb/ are backfilled, so a run over an untouched
file makes no network calls.
GEO records are validated, not skipped
geo and GEO have been removed from skip_prefixes, so
linkml-reference-validator now checks a GEO dataset record like any other
reference. Two rules follow, and both are enforced:
datasets[].title is the repository's title, copied exactly. It is a title
slot adjacent to a reference field, so the validator compares it with the
fetched record. Your own summary of what the dataset contains goes in
description. Copy the title even when it is wrong — geo:GSE301492 carries
GEO's misspelled "Reed-Stenberg" — for the same reason an evidence snippet never
"corrects" the source it quotes.
A GEO:-cited snippet must be an exact quote from the cached summary. The
summary is the abstract-length text in references_cache/GEO_<ID>.md; GEO's
"overall design" field is not cached, so a quote taken from the GEO web page
may not be quotable here. Pick a sentence from the cache file.
Enabling this was a curation pass, not a config change. It required correcting
30 dataset titles that paraphrased or replaced GEO's own (of 951 records;
e.g. Bbs8-deficient mouse retinal pigment epithelium transcriptomics against
GEO's Transcriptome profile of Bbs8/TTC8 Knockout mouse RPE Tissue), and 2
evidence snippets — one a reordered paraphrase of a sentence that was in the
cache all along, one quoting the uncached "overall design" field. Expect the
same shape of work when migrating the next prefix.
Other prefixes (EGA, MassIVE, dbGaP, PRIDE, MetaboLights, …) still resolve
against their repository API on every run and cache nothing. Migrating one means
writing a reference fetcher for it and adding it to REFERENCE_CACHED_PREFIXES
in scripts/verify_dataset_accessions.py.
cache/dataset_accessions.json is frozen — never touch it
Verification results used to go into one shared JSON object. Every run rewrote
that file in full, including a run over a single disorder file, so every
curation PR touching a datasets: block churned the same 1.8 MB file — and with
919 geo: keys sorted into one contiguous region, two PRs adding neighbouring
accessions collided.
Nothing reads or writes it now, and
test_no_automation_touches_the_frozen_dataset_cache keeps it that way. It stays
in git only until the open PRs carrying edits to it have drained. Do not stage
it, and do not regenerate it.
Why not a datasets/ folder instead, one file per dataset shared across
entries? Because de-duplication is not the problem: of 1,747 dataset records,
1,696 accessions are distinct, and the 49 that repeat (2.9%, maximum fan-out 3)
are all pairs of sibling entries. See
design decision 6c.
Verification statuses
| Status | Meaning |
|---|---|
OK |
Resolved to a real record |
PREFIX_MISMATCH |
Record exists, filed under the wrong prefix (e.g. a BioProject ID as sra:) |
NOT_FOUND |
Did not resolve — treat as fabricated until shown otherwise |
MALFORMED |
Does not match any known accession pattern |
UNSUPPORTED |
No per-record public API (cellxgene, GTEx, ENCODE, TCGA) or a literature ID used as an accession |
UNSUPPORTED is not a pass. 82 records in the KB use a PMID or DOI as their
accession, which is a real data-quality problem: a paper is not a dataset.
They are reported rather than failed because fixing them needs a human.
Supported repositories
NCBI GEO / SRA / BioProject / dbGaP, EBI BioStudies (ArrayExpress) / PRIDE / MetaboLights / MGnify, EGA, MassIVE, NASA OSDR, and Metabolomics Workbench.
Adding another means writing a resolver in scripts/verify_dataset_accessions.py
and registering its accession shape. Migrating an existing one to the reference
cache (the geo: treatment) means instead giving it a linkml-reference-validator
source and adding it to REFERENCE_CACHED_PREFIXES. Worth doing next by volume:
ega (382 accessions), massive (120), metabolomics_workbench (81), dbgap
(71). lrv already ships a BIOPROJECT source, and its generic json_api source
may cover others with configuration rather than code.
For discovery, the ArrayExpress and EGA study indexes resolve offline against
committed retrieval metadata. Their bulk archives are gitignored and rebuilt with
--refresh; only the derived index or a retrieval stamp is committed.
Known KB issues this tooling surfaced
Recorded here because they are curation problems rather than tooling ones:
Dorsalgiais bound toMONDO:0000001("disease", the ontology root). The only such entry. Its disease term matches essentially anything, which is why it attracted prostate-cancer and Parkinson datasets from GEO. The matchers now blocklist bare generic phrases, but the binding should be fixed.- Four entries are bound to a parent concept, so searching the label
retrieves the general disease:
BRCA_Mutant_Prostate_Cancer→ "prostate cancer",NRAS_Mutant_Melanoma→ "cutaneous melanoma",Arsenic_Related_Cancers→ "squamous cell carcinoma",Hospital-Acquired_Acute_Kidney_Injury→ "kidney injury". The matchers drop an over-broad label; the bindings may still warrant review. - Two near-synonymous entry pairs behave as one entity to any name- or
gene-driven process:
Addisons_Disease/Chronic_Primary_Adrenal_InsufficiencyandNeuromyelitis_Optica/Neuromyelitis_Optica_Spectrum_Disorder. - OAK is unimportable under this project's Python (
pyhornedowlraisesAttributeError: 'typing.Union' object attribute '__doc__' is read-only), which is whyscripts/run_term_validator.shexits 1 with no output on every file. Ontology lookups in the newer discovery scripts use the OLS4 REST API instead.