candle-fire / scripts /derive_seeds.py
KevinIsInCoding
feat: automate ALS seed entities from trials and paper corpus
e53bbe5
Raw
History Blame Contribute Delete
6.74 kB
#!/usr/bin/env python3
"""
Derive dynamic seed entities from the ingested trial and paper corpus.
Sources:
1. trials.jsonl — DRUG/BIOLOGICAL interventions + LLM-extracted targets
2. entities.jsonl — entities appearing in >= SEED_PROMOTION_THRESHOLD papers
Writes data/seeds/derived_seeds.json (always overwritten, never hand-edited).
Run after extract_entities.py and before build_graph.py.
Usage:
uv run python scripts/derive_seeds.py
uv run python scripts/derive_seeds.py --threshold 3
"""
from __future__ import annotations
import argparse
import json
import sys
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from dotenv import load_dotenv
load_dotenv()
from rich.console import Console
from config import (
DERIVED_SEEDS_PATH,
ENTITIES_PATH,
SEED_PROMOTION_THRESHOLD,
TRIALS_PATH,
)
from extraction.normalizer import normalize_entity, _GENE_ALIASES, _COMPOUND_ALIASES
console = Console()
_INTERVENTION_BLOCKLIST = {
"placebo", "standard_care", "standard_of_care", "exercise",
"physical_therapy", "physiotherapy", "occupational_therapy",
"riluzole", # already in manual seeds
"edaravone",
"best_supportive_care", "nutritional_support", "sham",
"observation", "usual_care",
}
_EMPTY_SEEDS: dict[str, list[str]] = {
"genes": [], "proteins": [], "compounds": [], "mechanisms": [], "phenotypes": [],
}
_TYPE_TO_CATEGORY = {
"Gene": "genes",
"Protein": "proteins",
"Compound": "compounds",
"Mechanism": "mechanisms",
"Phenotype": "phenotypes",
"Pathway": "mechanisms",
}
def _derive_from_trials(trials_path: Path) -> dict[str, set[str]]:
"""Return category → set of display names derived from active trials."""
result: dict[str, set[str]] = defaultdict(set)
if not trials_path.exists():
console.print(f"[yellow]trials.jsonl not found at {trials_path} — skipping trial seeds[/yellow]")
return result
n_trials = 0
with open(trials_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
trial = json.loads(line)
n_trials += 1
# Drug/biological interventions from ClinicalTrials.gov structured data
for iv in trial.get("interventions", []):
if iv.get("type", "").upper() not in ("DRUG", "BIOLOGICAL"):
continue
name = iv.get("name", "").strip()
if not name:
continue
canonical = normalize_entity(name, "Compound")
slug = canonical.split(":", 1)[-1]
if slug not in _INTERVENTION_BLOCKLIST:
result["compounds"].add(name)
# LLM-extracted target entities
for target in trial.get("target_entities", []):
target = target.strip()
if not target:
continue
# Infer type via alias tables
u = target.upper()
if u in _GENE_ALIASES or target in _GENE_ALIASES:
result["genes"].add(target)
elif target in _COMPOUND_ALIASES:
result["compounds"].add(target)
else:
# Unknown — put in compounds (most unmatched trial targets are drugs)
slug = normalize_entity(target, "Compound").split(":", 1)[-1]
if slug not in _INTERVENTION_BLOCKLIST:
result["compounds"].add(target)
console.print(f" Scanned {n_trials} trials")
return result
def _derive_from_entities(entities_path: Path, threshold: int) -> dict[str, set[str]]:
"""Return category → set of display names for entities in >= threshold papers."""
if not entities_path.exists():
console.print(f"[yellow]entities.jsonl not found at {entities_path} — skipping paper seeds[/yellow]")
return defaultdict(set)
# canonical_id → {type, display_name, count}
counts: dict[str, dict] = {}
with open(entities_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
raw = json.loads(line)
for ent in raw.get("entities", []):
if ent.get("confidence", 0.0) < 0.6:
continue
cid = ent.get("canonical_id", "")
if not cid:
continue
if cid not in counts:
counts[cid] = {
"type": ent.get("type", "Unknown"),
"display_name": ent.get("name", cid.split(":", 1)[-1]),
"count": 0,
}
counts[cid]["count"] += 1
result: dict[str, set[str]] = defaultdict(set)
promoted = 0
for cid, data in counts.items():
if data["count"] >= threshold:
category = _TYPE_TO_CATEGORY.get(data["type"])
if category:
result[category].add(data["display_name"])
promoted += 1
console.print(f" {len(counts)} unique entities; {promoted} promoted above threshold={threshold}")
return result
def main() -> None:
parser = argparse.ArgumentParser(description="Derive seed entities from corpus")
parser.add_argument("--trials", type=Path, default=TRIALS_PATH)
parser.add_argument("--entities", type=Path, default=ENTITIES_PATH)
parser.add_argument("--threshold", type=int, default=SEED_PROMOTION_THRESHOLD)
parser.add_argument("--output", type=Path, default=DERIVED_SEEDS_PATH)
args = parser.parse_args()
console.print("[cyan]Deriving seeds from trials...[/cyan]")
trial_seeds = _derive_from_trials(args.trials)
console.print("[cyan]Deriving seeds from paper entities...[/cyan]")
paper_seeds = _derive_from_entities(args.entities, args.threshold)
merged: dict[str, list[str]] = {}
for category in _EMPTY_SEEDS:
combined = trial_seeds.get(category, set()) | paper_seeds.get(category, set())
merged[category] = sorted(combined)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(merged, indent=2, sort_keys=True))
total = sum(len(v) for v in merged.values())
console.print(f"\n[bold green]Done![/bold green] Written to {args.output}")
console.print(f" Total derived seeds: {total}")
for cat, names in merged.items():
if names:
console.print(f" {cat}: {len(names)} ({', '.join(names[:5])}{'...' if len(names) > 5 else ''})")
if __name__ == "__main__":
main()