CoolFace
Datasetpublic

PerturbReason/PerturbReason_dataset_code

sourceHugging Facemitupdated 5mo agoView on Hugging Face
0likes12downloads
data_model.py630 linesDownload Raw Back to eval_v3
1"""2eval_v3/data_model.py3=====================4Shared data structures, IO utilities, and parsing for the PerturbQA v3 pipeline.5 6Key changes from eval_v2:7  - Model outputs now contain structured <thinking>, <answer>, <triplet> blocks8  - GT label is in the input file's "label" field9  - GT triplets are in input file's "output.triplets_cell_conditioned"10  - No external LLM-based triplet extraction needed11"""12 13from __future__ import annotations14 15import json16import re17from collections import defaultdict18from dataclasses import dataclass, field, asdict19from pathlib import Path20from typing import Any, Dict, List, Optional, Set, Tuple21 22 23# ────────────────────────────────────────────24# Causal-edge & causal-path25# ────────────────────────────────────────────26 27@dataclass(frozen=True)28class CausalEdge:29    """A single directed edge in the biological causal graph."""30    source: str31    target: str32    sign: str  # "activates", "inhibits", "regulates"33 34    @property35    def numeric(self) -> Optional[int]:36        return {"activates": +1, "inhibits": -1}.get(self.sign)37 38    def __repr__(self) -> str:39        sym = {"activates": "─(+)→", "inhibits": "─(-)→", "regulates": "─(?)→"}40        return f"{self.source} {sym.get(self.sign, '→')} {self.target}"41 42 43# ────────────────────────────────────────────44# Prompt metadata (parsed from prompt text)45# ────────────────────────────────────────────46 47@dataclass48class PromptData:49    """Structured content parsed from the prompt text."""50    cell_type: str51    pert_type: str          # "chemical" | "genetic"52    perturbation: str       # drug name or perturbation description53    pert_gene: str          # actual gene name for genetic perturbation54    effect_gene: str55    task: str               # always "3way"56    basal_expression: Dict[str, str] = field(default_factory=dict)57    edges: List[CausalEdge] = field(default_factory=list)58    all_genes: Set[str] = field(default_factory=set)59 60 61# ────────────────────────────────────────────62# Sample record (one prediction instance)63# ────────────────────────────────────────────64 65@dataclass66class SampleRecord:67    """68    One evaluation sample, combining GT data (from input file)69    and model prediction (from output file).70 71    Fields72    ------73    id : sample identifier (positional index in JSONL)74    prompt : full prompt text75    model_output : raw model generation (contains <thinking>, <answer>, <triplet>)76    gt_label : ground-truth answer ("up", "down", "unchanged")77    gt_triplets : GT causal path from triplets_cell_conditioned78    gt_reasoning : GT reasoning text (if available)79    prompt_data : structured parsed metadata from prompt80    basal_context : per-gene basal expression values81    pert_type : "chemical" or "genetic"82    """83    id: Any84    prompt: str85    model_output: str86    gt_label: str87    gt_triplets: List[Tuple[str, str, str]] = field(default_factory=list)88    gt_reasoning: str = ""89    prompt_data: Optional[PromptData] = None90    basal_context: Dict[str, Any] = field(default_factory=dict)91    pert_type: str = ""92 93 94# ────────────────────────────────────────────95# Per-sample result container96# ────────────────────────────────────────────97 98@dataclass99class SampleResult:100    """Holds the evaluation result for a single sample across all tiers."""101    id: Any102    # ── Metadata ──103    cell_type: str = ""104    pert_type: str = ""105    perturbation: str = ""106    effect_gene: str = ""107    task: str = ""108 109    # ── Tier-1: Answer accuracy ──110    gt_answer: str = ""111    model_answer: str = ""112    answer_correct: Optional[bool] = None113    answer_parse_fail: bool = False114 115    # ── Tier-2: Symbolic reasoning ──116    # M1: Edge F1117    edge_f1_strict: float = 0.0118    edge_f1_relaxed: float = 0.0119    edge_recall_strict: float = 0.0120    edge_precision_strict: float = 0.0121    # M2: Path Connectivity122    path_connectivity: str = ""123    # M3: Sign Consistency124    sign_match_rate: float = 0.0125    sign_flips: int = 0126    # M4: Graph Validity127    in_kg_rate: float = 0.0128    hallucination_rate: float = 0.0129    num_model_triplets: int = 0130    num_hallucinated_edges: int = 0131    # M5: Error Taxonomy132    error_label: str = ""133 134    # ── Tier-3: GO Functional Similarity ──135    go_sim_score: Optional[float] = None136 137    # ── Tier-4: LLM Rescue ──138    llm_rescue_label: str = ""139    llm_rescue_applied: bool = False140    llm_rescue_explanation: str = ""141 142    def to_dict(self) -> Dict[str, Any]:143        return asdict(self)144 145 146# ────────────────────────────────────────────147# File-level aggregate148# ────────────────────────────────────────────149 150@dataclass151class FileResult:152    """Aggregate of all SampleResult values for one prediction file."""153    file_name: str154    split_name: str = ""155    pert_type: str = ""156    metadata: Dict[str, str] = field(default_factory=dict)157    sample_results: List[SampleResult] = field(default_factory=list)158    aggregate: Dict[str, Any] = field(default_factory=dict)159 160 161# ────────────────────────────────────────────162# Structured output parsing163# ────────────────────────────────────────────164 165def extract_thinking(text: str) -> str:166    """Extract text from <thinking>...</thinking> block."""167    m = re.search(r"<thinking>(.*?)</thinking>", text, re.DOTALL)168    return m.group(1).strip() if m else ""169 170 171def extract_answer(text: str) -> Optional[str]:172    """Extract and normalize the answer from <answer>...</answer> block.173 174    When models self-correct, they may emit multiple <answer> blocks.175    We use the **last** valid <answer> block, which is the corrected one.176    """177    valid = {"up", "down", "unchanged"}178 179    # Primary: LAST <answer>...</answer>180    matches = re.findall(r"<answer>\s*(\w+)\s*</answer>", text, re.IGNORECASE)181    for ans_raw in reversed(matches):182        ans = ans_raw.strip().lower()183        if ans in valid:184            return ans185 186    # Fallback: \boxed{...}187    m = re.search(r"\\boxed\{(\w+)\}", text)188    if m:189        ans = m.group(1).strip().lower()190        if ans in valid:191            return ans192 193    # Fallback: last word194    text_clean = text.strip()195    if text_clean:196        last_word = text_clean.rstrip(".").split()[-1].lower()197        if last_word in valid:198            return last_word199 200    return None201 202 203def extract_triplets(text: str) -> List[Tuple[str, str, str]]:204    """205    Extract causal triplets from <triplet>...</triplet> block.206 207    When models self-correct, they may emit multiple <triplet> blocks.208    We use the **last** block, which is the corrected one.209 210    Handles both proper JSON arrays and common formatting variations211    (missing outer brackets, trailing commas, etc.).212    """213    blocks = re.findall(r"<triplet>(.*?)</triplet>", text, re.DOTALL)214    if not blocks:215        return []216 217    # Try each block from last to first until one parses successfully218    for raw in reversed(blocks):219        raw = raw.strip()220        if not raw:221            continue222 223        result = _try_parse_triplet_block(raw)224        if result:225            return result226 227    return []228 229 230def _try_parse_triplet_block(raw: str) -> List[Tuple[str, str, str]]:231    """Attempt to parse a single raw triplet block string."""232    # Try parsing as proper JSON array of arrays233    try:234        parsed = json.loads(raw)235        if isinstance(parsed, list):236            result = _validate_triplets(parsed)237            if result:238                return result239    except json.JSONDecodeError:240        pass241 242    # Common issue: list of lists without outer brackets243    # e.g. ["A", "inhibits", "B"],\n["C", "activates", "D"]244    if not raw.startswith("[["):245        wrapped = "[" + raw.rstrip(",").rstrip() + "]"246        try:247            parsed = json.loads(wrapped)248            if isinstance(parsed, list):249                result = _validate_triplets(parsed)250                if result:251                    return result252        except json.JSONDecodeError:253            pass254 255    # Regex fallback: extract individual triplets256    triplets = []257    for match in re.finditer(258        r'\[\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\]', raw259    ):260        triplets.append((match.group(1), _normalize_relation(match.group(2)), match.group(3)))261    return [t for t in triplets if t[1] is not None]262 263 264# ── Relation normalization map ──265# Maps non-standard relation strings to canonical {activates, inhibits, regulates}.266_RELATION_MAP = {267    # Standard (identity)268    "activates": "activates",269    "activate": "activates",270    "inhibits": "inhibits",271    "regulates": "regulates",272    "regulate": "regulates",273    # Positive / activating synonyms274    "upregulates": "activates",275    "increases": "activates",276    "promotes": "activates",277    "induces": "activates",278    "stabilizes": "activates",279    "supports": "activates",280    "positively regulates": "activates",281    "indirectly activates": "activates",282    "induces upregulation of": "activates",283    "phosphorylates": "activates",284    "is upregulated": "activates",285    # Negative / inhibiting synonyms286    "suppresses": "inhibits",287    "downregulates": "inhibits",288    "reduces": "inhibits",289    "represses": "inhibits",290    "inactivates": "inhibits",291    "negatively regulates": "inhibits",292    "indirectly suppresses": "inhibits",293    "indirectly reduces": "inhibits",294    "phosphorylates and destabilizes": "inhibits",295    "inhibited by": "inhibits",296    "inhibited_by": "inhibits",297    # Ambiguous → regulates298    "modulates": "regulates",299    "influences": "regulates",300    "affects": "regulates",301    "indirectly regulates": "regulates",302    "leads to": "regulates",303    "involved in": "regulates",304    "responds to": "regulates",305    "regulated by": "regulates",306    "regulated_by": "regulates",307    "is regulated by": "regulates",308    "upregulated by": "regulates",309    "downregulated by": "regulates",310    "repressed by": "regulates",311    "is stabilized by": "regulates",312    "is not regulated by": None,   # negation → drop313    "not regulated": None,         # negation → drop314    "does not regulate": None,     # negation → drop315    "no regulatory link": None,    # negation → drop316    "expression": "regulates",317}318 319 320def _normalize_relation(rel: str) -> Optional[str]:321    """Map a relation string to canonical form, or None if unrecognized."""322    return _RELATION_MAP.get(rel.lower().strip())323 324 325def _validate_triplets(parsed: list) -> List[Tuple[str, str, str]]:326    """Validate and convert parsed JSON to list of triplet tuples.327 328    Non-standard relations (e.g. 'suppresses', 'upregulates') are329    normalized to the canonical set {activates, inhibits, regulates}.330    Triplets with truly unknown relations are discarded.331    """332    result = []333    for item in parsed:334        if isinstance(item, (list, tuple)) and len(item) == 3:335            src, tgt = str(item[0]), str(item[2])336            norm_rel = _normalize_relation(str(item[1]))337            if norm_rel is not None:338                result.append((src, norm_rel, tgt))339    return result340 341 342# ────────────────────────────────────────────343# Prompt parsing344# ────────────────────────────────────────────345 346def parse_prompt(prompt: str) -> PromptData:347    """Extract structured info from a PerturbQA prompt string."""348    cell = re.search(r"\*\*Cell Type:\*\*\s*(.+)", prompt)349    pert = re.search(r"\*\*Perturbation \((\w+)\):\*\*\s*(.+)", prompt)350    gene = re.search(r"\*\*Effect Gene:\*\*\s*(.+)", prompt)351 352    # Basal expression353    basal: Dict[str, str] = {}354    basal_sec = re.search(355        r"basal gene expression.*?:\n(.*?)(?=\n##)", prompt, re.DOTALL | re.IGNORECASE356    )357    if basal_sec:358        for m in re.finditer(r"-\s+(\S+):\s*(\w+)", basal_sec.group(1)):359            basal[m.group(1)] = m.group(2)360 361    # Knowledge edges362    edges: List[CausalEdge] = []363    know_sec = re.search(r"## Retrieved Knowledges?\n(.*?)(?=\n##)", prompt, re.DOTALL)364    if know_sec:365        for line in know_sec.group(1).split("\n"):366            line = line.strip().lstrip("- ")367            if line:368                e = _parse_edge_text(line)369                if e:370                    edges.append(e)371 372    all_genes: Set[str] = set()373    for e in edges:374        all_genes.add(e.source)375        all_genes.add(e.target)376    all_genes.update(basal.keys())377 378    pert_name = pert.group(2).strip() if pert else ""379    pert_type_str = pert.group(1).strip() if pert else ""380    effect_name = gene.group(1).strip() if gene else ""381 382    # For genetic perturbations, extract gene name383    pert_gene = ""384    if pert_type_str.lower() == "genetic":385        gene_match = re.search(386            r"(?:knockdown|knockout|overexpression|activation)\s+(?:of\s+)?(\S+)",387            pert_name, re.IGNORECASE,388        )389        if gene_match:390            pert_gene = gene_match.group(1)391        else:392            parts = pert_name.split()393            if parts:394                pert_gene = parts[-1]395    else:396        pert_gene = pert_name397 398    if pert_name:399        all_genes.add(pert_name)400    if pert_gene and pert_gene != pert_name:401        all_genes.add(pert_gene)402    if effect_name:403        all_genes.add(effect_name)404 405    return PromptData(406        cell_type=cell.group(1).strip() if cell else "",407        pert_type=pert_type_str,408        perturbation=pert_name,409        pert_gene=pert_gene,410        effect_gene=effect_name,411        task="3way",412        basal_expression=basal,413        edges=edges,414        all_genes=all_genes,415    )416 417 418def _parse_edge_text(text: str) -> Optional[CausalEdge]:419    """Parse 'A inhibits B' → CausalEdge."""420    for pat, sign in [421        (r"(.+?)\s+inhibits?\s+(.+)", "inhibits"),422        (r"(.+?)\s+activates?\s+(.+)", "activates"),423        (r"(.+?)\s+regulates?\s+(.+)", "regulates"),424    ]:425        m = re.match(pat, text.strip(), re.IGNORECASE)426        if m:427            return CausalEdge(m.group(1).strip(), m.group(2).strip(), sign)428    return None429 430 431# ────────────────────────────────────────────432# External KG loading433# ────────────────────────────────────────────434 435def load_external_kg(436    kg_path: Path | str,437) -> Tuple[Set[Tuple[str, str, str]], Set[Tuple[str, str]]]:438    """439    Load an external KG from a PKL (NetworkX DiGraph) or JSON file.440 441    PKL format: networkx.DiGraph where each edge has attribute442        {'relation': 'activates' | 'inhibits' | ...}443 444    JSON format: {"edges": [[src, rel, tgt], ...]}445 446    Returns (edge_set, pair_set) where:447        edge_set = set of (src, rel, tgt) tuples448        pair_set = set of (src, tgt) tuples (for relaxed matching)449    """450    kg_path = Path(kg_path)451    edge_set: Set[Tuple[str, str, str]] = set()452    pair_set: Set[Tuple[str, str]] = set()453 454    if kg_path.suffix == ".pkl":455        import pickle456        with open(kg_path, "rb") as f:457            G = pickle.load(f)458        for src, tgt, attr in G.edges(data=True):459            rel = attr.get("relation", "regulates")460            edge_set.add((str(src), rel, str(tgt)))461            pair_set.add((str(src), str(tgt)))462    else:463        with open(kg_path, "r", encoding="utf-8") as f:464            data = json.load(f)465        for e in data.get("edges", []):466            if len(e) >= 3:467                src, rel, tgt = e[0], e[1], e[2]468                edge_set.add((src, rel, tgt))469                pair_set.add((src, tgt))470 471    return edge_set, pair_set472 473 474# ────────────────────────────────────────────475# IO helpers476# ────────────────────────────────────────────477 478def load_jsonl(path: Path | str) -> List[Dict[str, Any]]:479    """Load a JSONL file, returning a list of dicts."""480    records: List[Dict[str, Any]] = []481    with open(path, "r", encoding="utf-8") as f:482        for line in f:483            line = line.strip()484            if line:485                records.append(json.loads(line))486    return records487 488 489def load_paired_samples(490    gt_path: Path | str,491    pred_path: Path | str,492) -> List[SampleRecord]:493    """494    Load paired GT + prediction files into SampleRecords.495 496    GT file format (noisy_input):497      {id, label, pert_type, prompt, response, input, output, basal_context, data_type}498      - output.triplets_cell_conditioned = GT causal path499      - label = GT answer (up/down/unchanged)500 501    Prediction file format (noisy_context_output):502      {source_file, prompt, ground_truth_response, model_output}503      - model_output = <thinking>...<answer>...<triplet>...504    """505    gt_records = load_jsonl(gt_path)506    pred_records = load_jsonl(pred_path)507 508    # Match by position (both files have same ordering)509    if len(gt_records) != len(pred_records):510        print(f"  WARNING: GT has {len(gt_records)} samples, "511              f"predictions has {len(pred_records)} samples. "512              f"Using min({len(gt_records)}, {len(pred_records)}).")513 514    n = min(len(gt_records), len(pred_records))515    samples: List[SampleRecord] = []516 517    for i in range(n):518        gt = gt_records[i]519        pred = pred_records[i]520 521        prompt_text = gt.get("prompt", "") or pred.get("prompt", "")522        pd = parse_prompt(prompt_text)523 524        # GT data525        gt_label = gt.get("label", "")526        gt_output = gt.get("output", {}) or {}527        gt_triplets_raw = gt_output.get("triplets_cell_conditioned", [])528        gt_triplets = [tuple(t) for t in gt_triplets_raw if len(t) == 3]529        basal = gt.get("basal_context", {}) or {}530        pert_type = gt.get("pert_type", "")531 532        # Model output533        model_output = pred.get("model_output", "")534 535        rec = SampleRecord(536            id=gt.get("id", i),537            prompt=prompt_text,538            model_output=model_output,539            gt_label=gt_label,540            gt_triplets=gt_triplets,541            prompt_data=pd,542            basal_context=basal,543            pert_type=pert_type,544        )545        samples.append(rec)546 547    return samples548 549 550def _classify_pert_type(fname_lower: str) -> Optional[str]:551    """552    Classify a JSONL filename into a pert_type string.553 554    More-specific compound keywords are checked first to avoid the generic555    "chemical" / "genetic" matches swallowing them.556 557    Compound types (used in other_test):558      chemical2genetic, genetic2chemical, genetic_combo559 560    Generic types (all other splits):561      chemical, genetic562    """563    # Compound keywords — must be checked before the generic ones564    for keyword in ("chemical2genetic", "genetic2chemical", "genetic_combo"):565        if keyword in fname_lower:566            return keyword567    if "chemical" in fname_lower:568        return "chemical"569    if "genetic" in fname_lower:570        return "genetic"571    return None572 573 574def find_file_pairs(575    gt_dir: Path | str,576    pred_dir: Path | str,577) -> List[Tuple[Path, Path, str, str]]:578    """579    Find matching GT/prediction file pairs across the split directory structure.580 581    Both directories must share the same 5 split sub-folders.  Within each582    split folder JSONL files are classified by pert_type keyword:583 584    * Generic splits (id_test, cell_ood, pert_ood, double_ood):585        exactly one file with "chemical" and one with "genetic".586    * other_test (extended):587        up to 3 files — "chemical2genetic", "genetic2chemical", "genetic_combo"588        (and still supports the legacy 2-file layout for backwards compat).589 590    Files are paired by split + pert_type — no filename prefix conventions591    are assumed.592 593    Returns list of (gt_path, pred_path, split_name, pert_type).594    """595    gt_dir = Path(gt_dir)596    pred_dir = Path(pred_dir)597    pairs: List[Tuple[Path, Path, str, str]] = []598 599    for split_dir in sorted(gt_dir.iterdir()):600        if not split_dir.is_dir():601            continue602        split_name = split_dir.name603 604        pred_split_dir = pred_dir / split_name605        if not pred_split_dir.is_dir():606            print(f"  WARNING: No prediction directory for split '{split_name}'")607            continue608 609        # Index pred files by pert_type keyword610        pred_by_type: Dict[str, Path] = {}611        for f in pred_split_dir.glob("*.jsonl"):612            pt = _classify_pert_type(f.name.lower())613            if pt is not None:614                pred_by_type[pt] = f615 616        for gt_file in sorted(split_dir.glob("*.jsonl")):617            pert_type = _classify_pert_type(gt_file.name.lower())618            if pert_type is None:619                print(f"  WARNING: Cannot determine pert_type for {gt_file.name}, skipping")620                continue621 622            pred_file = pred_by_type.get(pert_type)623            if pred_file is None:624                print(f"  WARNING: No '{pert_type}' prediction file in {pred_split_dir}")625                continue626 627            pairs.append((gt_file, pred_file, split_name, pert_type))628 629    return pairs630