Assemble the validation-case set

Goal. Assemble and verify the 13-paper genotype→phenotype-discordance case set that the downstream analysis validates against: load each paper’s per-record extraction table, load the direction classification, and check that both reproduce exactly from the committed files — no re-extraction, no network, no withheld inputs.

Inputs (all committed, all reachable without the withheld publisher PDFs):

What this notebook is not. It does not re-run the extraction — that step reads the withheld publisher PDFs and is frozen (shown, not executed) in docs/NB3_case_assembly_provenance.md §2–3. This notebook is the machine-checkable half of that provenance record: it re-derives the headline numbers (694 records; 3 D1 / 5 D2 / 5 both) from the files actually committed to the repository, so the claim is auditable by anyone who checks the repo out, with no paper access required.

Provenance. Full acquisition, extraction methodology, the md→PDF correction, and the classification scheme are documented in docs/NB3_case_assembly_provenance.md. This notebook cites that document rather than repeating it, and focuses on the reproducibility check.

Key terms — so this notebook stands on its own (you shouldn’t need the other notebooks to read this one).

  • Genotype→phenotype-discordance case set (the “validation-case set”) — 13 published pigmentation papers, hand-curated for this project, in which the genetics and the pigmentation phenotype do not line up the way the textbook would predict. This notebook only verifies that the curated tables reproduce from the committed files; the set itself is the empirical base that the project’s later “rescue” analysis — connecting hard-to-explain pigmentation loci into the melanin-making gene network — is validated against.
  • Discordance direction — D1 / D2 / both — how each paper’s genotype–phenotype mismatch runs (defined in the provenance doc, restated here). D1: a person carries the usual causal variant but does not show the expected phenotype (reduced/incomplete penetrance). D2: the phenotype is present but the usual causal variant is absent (a different gene, an atypical genotype, or a compound/single-allele cause). both: the paper documents cases in each direction, often at one locus. The headline “3 D1 / 5 D2 / 5 both” is the count of papers in each category.
  • Withheld publisher PDFs / “frozen” extraction — the source papers are copyrighted and are not committed to the repo, so the step that reads them to build the per-paper record tables cannot be re-run here; it is shown-but-not-executed (“frozen”) in docs/NB3_case_assembly_provenance.md. This notebook deliberately touches only the committed extraction outputs, so anyone can audit the headline numbers without any paper access or network.
  • EXTRACT_<paper>_records.csv / n_records_extracted — one machine-readable table per paper listing the extracted genetic records (grain: one variant × source table); n_records_extracted is that paper’s claimed row count stored in the classification table, which Step 3 checks against the actual row count of the committed file.

Setup

Load libraries and resolve the repo root so the notebook runs the same way whether launched from notebooks/ or from the repo root (matches the convention in Notebook 2).

Show code
import pandas as pd
from pathlib import Path

ROOT = Path.cwd().parent if Path.cwd().name == "notebooks" else Path.cwd()
PROC = ROOT / "data" / "processed"
RECORDS = ROOT / "data" / "case_records"

CLASSIFICATION_CSV = PROC / "discordance_case_classification.csv"

assert CLASSIFICATION_CSV.exists(), f"missing {CLASSIFICATION_CSV}"
assert RECORDS.is_dir(), f"missing {RECORDS}"
print("ROOT resolved:", ROOT.name)  # repo folder name only — avoid leaking a local absolute path
print("classification file:", CLASSIFICATION_CSV.name)
print("case_records dir:", RECORDS.name)
ROOT resolved: pigmentation-gene-network
classification file: discordance_case_classification.csv
case_records dir: case_records

Step 1 — Load the classification table

discordance_case_classification.csv carries one row per paper: the DOI, phenotype system, discordance-direction call (D1 / D2 / both, defined in docs/NB3_case_assembly_provenance.md §4), and n_records_extracted — the row count of that paper’s committed records CSV at the time the classification was pinned.

Show code
cls = pd.read_csv(CLASSIFICATION_CSV)
print(f"{len(cls)} papers in classification table")
cls[["paper", "doi", "phenotype_system", "discordance_direction", "n_records_extracted"]]
13 papers in classification table
paper doi phenotype_system discordance_direction n_records_extracted
0 Ang2023_eLife_Kalinago 10.7554/eLife.77514 skin (albinism/hypopig) both 53
1 Yang2016_MBE_OCA2_EastAsian 10.1093/molbev/msw003 skin D2 10
2 Kenny2012_Science_TYRP1 10.1126/science.1217849 hair (blond) D2 36
3 Norton2014_AJPA_MelanesianBlond_TYRP1 10.1002/ajpa.22466 hair (blond) both 45
4 Norton2016_AJHB_Bougainville_TYRP1 10.1002/ajhb.22795 hair (blond) D2 31
5 Crawford2017_Science_AfricanPigmentation 10.1126/science.aan8433 skin both 27
6 Morell1997_JMedGenet_Waardenburg 10.1136/jmg.34.6.447 syndromic (Waardenburg) D1 31
7 Morgan2018_NatCommun_HairColour_MC1R 10.1038/s41467-018-07691-z hair (red) both 63
8 Abbatangelo2026_SciRep_eyecolour_discordance 10.1038/s41598-026-44580-8 eye colour both 46
9 Meyer2020_PLoSONE_GGbrowneyes 10.1371/journal.pone.0239131 eye colour D2 211
10 Kastelic2013_CroatMedJ_IrisPlex 10.3325/cmj.2013.54.381 eye colour D1 105
11 Pospiech2016_IntJLegalMed_IrisPlex_population 10.1007/s00414-016-1388-2 eye colour D1 18
12 Salvo2023_Genes_AAAGblueeyes 10.3390/genes14030698 eye colour D2 18

Step 2 — Load every per-paper records CSV and count rows

For each paper, find its committed EXTRACT_<paper>*_records.csv under data/case_records/ (some filenames carry a _records suffix, some don’t — the classification table’s extract_artifact column names the paper’s .md extract; the matching .csv sits alongside it) and read the actual row count. This is the independent check: n_records_extracted in the classification table is a claim; the row count of the committed file is the fact.

Show code
def committed_rows(paper: str) -> tuple[int, str]:
    # Row count of paper's committed records CSV(s) under data/case_records/, and the filename(s) used.
    hits = sorted(RECORDS.glob(f"EXTRACT_{paper}*.csv"))
    if not hits:
        raise FileNotFoundError(f"no committed records CSV found for {paper!r} under {RECORDS}")
    total = sum(len(pd.read_csv(h)) for h in hits)
    names = ", ".join(h.name for h in hits)
    return total, names

cls[["committed_rows", "committed_file"]] = cls["paper"].apply(
    lambda p: pd.Series(committed_rows(p))
)
cls[["paper", "n_records_extracted", "committed_rows", "committed_file"]]
paper n_records_extracted committed_rows committed_file
0 Ang2023_eLife_Kalinago 53 53 EXTRACT_Ang2023_eLife_Kalinago.csv
1 Yang2016_MBE_OCA2_EastAsian 10 10 EXTRACT_Yang2016_MBE_OCA2_EastAsian_records.csv
2 Kenny2012_Science_TYRP1 36 36 EXTRACT_Kenny2012_Science_TYRP1_records.csv
3 Norton2014_AJPA_MelanesianBlond_TYRP1 45 45 EXTRACT_Norton2014_AJPA_MelanesianBlond_TYRP1_records.csv
4 Norton2016_AJHB_Bougainville_TYRP1 31 31 EXTRACT_Norton2016_AJHB_Bougainville_TYRP1_records.csv
5 Crawford2017_Science_AfricanPigmentation 27 27 EXTRACT_Crawford2017_Science_AfricanPigmentation_records.csv
6 Morell1997_JMedGenet_Waardenburg 31 31 EXTRACT_Morell1997_JMedGenet_Waardenburg_records.csv
7 Morgan2018_NatCommun_HairColour_MC1R 63 63 EXTRACT_Morgan2018_NatCommun_HairColour_MC1R_records.csv
8 Abbatangelo2026_SciRep_eyecolour_discordance 46 46 EXTRACT_Abbatangelo2026_SciRep_eyecolour_discordance_records.csv
9 Meyer2020_PLoSONE_GGbrowneyes 211 211 EXTRACT_Meyer2020_PLoSONE_GGbrowneyes_records.csv
10 Kastelic2013_CroatMedJ_IrisPlex 105 105 EXTRACT_Kastelic2013_CroatMedJ_IrisPlex_records.csv
11 Pospiech2016_IntJLegalMed_IrisPlex_population 18 18 EXTRACT_Pospiech2016_IntJLegalMed_IrisPlex_population_records.csv
12 Salvo2023_Genes_AAAGblueeyes 18 18 EXTRACT_Salvo2023_Genes_AAAGblueeyes_records.csv

Step 3 — Verify: does n_records_extracted match the committed file, paper by paper?

This is the reconciliation check documented in docs/NB3_case_assembly_provenance.md §6. It must pass with zero mismatches — if it doesn’t, the classification table is out of sync with the committed record files and needs to be re-pinned before anything downstream trusts it.

Show code
mismatch = cls.loc[cls["n_records_extracted"] != cls["committed_rows"]]
assert mismatch.empty, (
    f"row-count mismatch between classification and committed files:\n"
    f"{mismatch[['paper', 'n_records_extracted', 'committed_rows']]}"
)
print("PASS: n_records_extracted matches the committed records CSV for all", len(cls), "papers")
PASS: n_records_extracted matches the committed records CSV for all 13 papers

Step 4 — Reproduce the headline numbers: 694 records, 3 D1 / 5 D2 / 5 both

Sum the per-paper counts for the canonical record total, and tally discordance_direction for the direction split. Both are asserted against the values reported in docs/NB3_case_assembly_provenance.md §1 and §6 (an earlier “511” headline is retired there — it did not reproduce from the committed files).

Show code
total_records = int(cls["committed_rows"].sum())
direction_counts = cls["discordance_direction"].value_counts().to_dict()

print("canonical record total:", total_records)
print("direction tally:", direction_counts)

assert total_records == 694, f"expected 694 total records, got {total_records}"
assert direction_counts == {"both": 5, "D2": 5, "D1": 3}, f"expected 3 D1 / 5 D2 / 5 both, got {direction_counts}"
print("PASS: 694-record total and 3 D1 / 5 D2 / 5 both split both reproduce from committed files")
canonical record total: 694
direction tally: {'both': 5, 'D2': 5, 'D1': 3}
PASS: 694-record total and 3 D1 / 5 D2 / 5 both split both reproduce from committed files

Step 5 — Per-paper summary table

Paper, DOI, phenotype system, discordance direction, and record count for all 13 cases — the table that documents which published finding backs each entry in the validation-case set. Each DOI resolves directly to the source paper on the publisher site (https://doi.org/<doi>).

Show code
summary = (
    cls[["paper", "doi", "phenotype_system", "discordance_direction", "committed_rows"]]
    .rename(columns={"committed_rows": "n_records"})
    .sort_values("paper")
    .reset_index(drop=True)
)
summary["doi_url"] = "https://doi.org/" + summary["doi"]
summary
paper doi phenotype_system discordance_direction n_records doi_url
0 Abbatangelo2026_SciRep_eyecolour_discordance 10.1038/s41598-026-44580-8 eye colour both 46 https://doi.org/10.1038/s41598-026-44580-8
1 Ang2023_eLife_Kalinago 10.7554/eLife.77514 skin (albinism/hypopig) both 53 https://doi.org/10.7554/eLife.77514
2 Crawford2017_Science_AfricanPigmentation 10.1126/science.aan8433 skin both 27 https://doi.org/10.1126/science.aan8433
3 Kastelic2013_CroatMedJ_IrisPlex 10.3325/cmj.2013.54.381 eye colour D1 105 https://doi.org/10.3325/cmj.2013.54.381
4 Kenny2012_Science_TYRP1 10.1126/science.1217849 hair (blond) D2 36 https://doi.org/10.1126/science.1217849
5 Meyer2020_PLoSONE_GGbrowneyes 10.1371/journal.pone.0239131 eye colour D2 211 https://doi.org/10.1371/journal.pone.0239131
6 Morell1997_JMedGenet_Waardenburg 10.1136/jmg.34.6.447 syndromic (Waardenburg) D1 31 https://doi.org/10.1136/jmg.34.6.447
7 Morgan2018_NatCommun_HairColour_MC1R 10.1038/s41467-018-07691-z hair (red) both 63 https://doi.org/10.1038/s41467-018-07691-z
8 Norton2014_AJPA_MelanesianBlond_TYRP1 10.1002/ajpa.22466 hair (blond) both 45 https://doi.org/10.1002/ajpa.22466
9 Norton2016_AJHB_Bougainville_TYRP1 10.1002/ajhb.22795 hair (blond) D2 31 https://doi.org/10.1002/ajhb.22795
10 Pospiech2016_IntJLegalMed_IrisPlex_population 10.1007/s00414-016-1388-2 eye colour D1 18 https://doi.org/10.1007/s00414-016-1388-2
11 Salvo2023_Genes_AAAGblueeyes 10.3390/genes14030698 eye colour D2 18 https://doi.org/10.3390/genes14030698
12 Yang2016_MBE_OCA2_EastAsian 10.1093/molbev/msw003 skin D2 10 https://doi.org/10.1093/molbev/msw003

The 13 cases, cited by DOI

Full evidence for each direction call (verbatim quotes, page/table locations) is in the corresponding EXTRACT_<paper>.md under data/case_records/, and the classification scheme (D1 / D2 / both) is defined in docs/NB3_case_assembly_provenance.md §4.

Outstanding provenance sign-offs (cheap, not yet closed)

Two items from docs/NB3_case_assembly_provenance.md §7 remain open. Both are inexpensive to close and are noted here rather than actioned, since they involve editing/retiring files outside this notebook’s own inputs:

  • Item B — 11 of 13 extracts are missing an md→PDF correction change-log entry. Only Ang 2023 and Abbatangelo 2026 currently record, inside their own EXTRACT_*.md, that an earlier md-derived extraction wave was superseded by a re-read of the authoritative publisher PDF (§3 of the provenance doc). The fix is a one-line change-log entry backfilled into each of the other 11 EXTRACT_*.md files, naming the authoritative source (typeset PDF, pages/tables read) and stating that any md-derived predecessor was superseded. No data changes — this is a documentation backfill.
  • Items D/E — retire superseded duplicate record CSVs from the artifact store. The two-wave extraction history (§3, §6 of the provenance doc) left md-era and PDF-era record CSVs (plus _stats / _gwas_leads splits for some papers) coexisting in the artifact store. The canonical file per paper is the one committed under data/case_records/ and read by this notebook (Step 2); the reconciliation in Step 3 confirms every canonical file’s row count matches the pinned classification. The remaining action is retention policy, not re-verification: retire or archive the non-canonical duplicates in the store so a downstream reader finds exactly one file per paper. This notebook does not delete anything — store-artifact retirement is a PI-authorized action outside notebook scope; this cell only records that the canonical set is already unambiguous (Steps 2–4 above) and names what should be retired once that authorization is given.

Summary

The 13-paper validation-case set assembled in discordance_case_classification.csv and data/case_records/EXTRACT_*_records.csv reproduces exactly from the committed files, with no paper access and no network required:

  • 694 records total across 13 papers (Step 4).
  • 3 D1 / 5 D2 / 5 both discordance-direction split (Step 4).
  • Every paper’s n_records_extracted in the classification table matches the row count of its committed records CSV, with zero mismatches (Step 3).

Full acquisition, extraction-methodology, and classification-scheme provenance — including the frozen (unexecuted) per-paper extraction driver that depends on the withheld publisher PDFs — is in docs/NB3_case_assembly_provenance.md.

Back to top