Files
PlantAI-dev/validation/rnaseq_design_20260719/rnaseq_design_validation_suite.py
2026-08-03 18:42:47 +08:00

332 lines
17 KiB
Python

from __future__ import annotations
import csv
import json
import sys
from collections import Counter
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from gfa_core.rnaseq_de_design import build_de_design_review, plan_rnaseq_de_design
from apps.cli.run_rnaseq_de import build_de_plan
OUT = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "validation_outputs"
OUT.mkdir(parents=True, exist_ok=True)
TASKS = {
"T01": ("单处理 vs 对照", "M1", 1),
"T02": ("多处理共享对照", "M1", 3),
"T03": ("多突变体 vs WT", "M1", 2),
"T04": ("多品种 vs 参考品种", "M1", 2),
"T05": ("多组织 vs 参考组织", "M1", 2),
"T06": ("多阶段 vs 参考阶段", "M1", 2),
"T07": ("排除异常样本后分析", "M1", 1),
"T09": ("品种×处理逐品种比较", "M3", 2),
"T10": ("组织×处理逐组织比较", "M3", 2),
"T14": ("品种×处理正式交互", "M4", 4),
"T17": ("时间点分别 vs 基线", "M1", 3),
"T18": ("时间整体变化 LRT", "M1", 3),
}
UTTERANCES = {
"T01": ["做 Salt 与 Control 的差异分析", "比较盐处理组和未处理对照组"],
"T02": ["Salt、Drought、Heat 分别和 CK 比", "多个胁迫处理共享一个对照,请分别做差异分析"],
"T03": ["Mut1、Mut2 分别与 WT 比", "分析多个突变体相对野生型的表达变化"],
"T04": ["V1、V2 分别与 Ref 比", "以参考品种为基准比较其余品种"],
"T05": ["Leaf、Stem 分别与 Root 比", "以根为参考比较叶和茎的表达"],
"T06": ["Tillering、Flowering 分别与 Seedling 比", "各发育阶段相对幼苗期比较"],
"T07": ["排除异常样本后重新分析", "忽略标记为 exclude 的样本并重新做 DE"],
"T09": ["每个品种内部 Salt vs Control", "分别分析 G1 和 G2 的处理效应"],
"T10": ["每个组织内部 Salt vs Control", "在 Root 和 Shoot 内分别比较处理与对照"],
"T14": ["检验品种与处理的交互", "比较 G1 与 G2 的盐响应是否显著不同"],
"T17": ["6、24、48 小时分别与 0 小时比", "输出每个时间点相对基线的差异基因"],
"T18": ["做时间整体 LRT", "检验基因是否随整个时间过程发生变化"],
}
def row(condition: str, role: str, rep: int, *, prefix: str, variant: str, **extra: Any) -> tuple[str, dict[str, Any]]:
sid = f"{prefix}_{rep}"
meta: dict[str, Any] = {"group_role": role, "replicate": rep, "include": True, **extra}
if variant == "A":
meta["condition"] = condition
else:
meta["treatment"] = condition
meta["sample_id"] = sid
return sid, meta
def one_factor(levels: list[tuple[str, str]], variant: str, reps: int | None = None) -> dict[str, dict[str, Any]]:
n = reps or (2 if variant == "A" else 3)
out = {}
for level, role in levels:
for rep in range(1, n + 1):
sid, meta = row(level, role, rep, prefix=level, variant=variant)
out[sid] = meta
return out
def factorial(factor: str, variant: str, reps: int | None = None, drop: set[str] | None = None) -> dict[str, dict[str, Any]]:
n = reps or (2 if variant == "A" else 3)
out = {}
for fac in (("G1", "G2") if factor == "genotype" else ("Root", "Shoot")):
for cond, role in (("Control", "control"), ("Salt", "compare")):
for rep in range(1, n + 1):
sid = f"{fac}_{cond}_{rep}"
if drop and sid in drop:
continue
_, meta = row(cond, role, rep, prefix=f"{fac}_{cond}", variant=variant, **{factor: fac})
out[sid] = meta
return out
def time_series(variant: str) -> dict[str, dict[str, Any]]:
n = 2 if variant == "A" else 3
out = {}
for t in ("0h", "6h", "24h", "48h"):
for rep in range(1, n + 1):
role = "control" if t == "0h" else "compare"
sid, meta = row("Cold", role, rep, prefix=f"T{t}", variant=variant, time=t)
out[sid] = meta
return out
def valid_case(task: str, variant: str) -> tuple[dict[str, dict[str, Any]], str]:
module = ""
if task == "T01":
s = one_factor([("Control", "control"), ("Salt", "compare")], variant)
elif task == "T02":
s = one_factor([("CK", "control"), ("Salt", "compare"), ("Drought", "compare"), ("Heat", "compare")], variant)
elif task == "T03":
s = one_factor([("WT", "control"), ("Mut1", "compare"), ("Mut2", "compare")], variant)
elif task == "T04":
s = one_factor([("Ref", "control"), ("V1", "compare"), ("V2", "compare")], variant)
elif task == "T05":
s = one_factor([("Root", "control"), ("Leaf", "compare"), ("Stem", "compare")], variant)
elif task == "T06":
s = one_factor([("Seedling", "control"), ("Tillering", "compare"), ("Flowering", "compare")], variant)
elif task == "T07":
s = one_factor([("Control", "control"), ("Salt", "compare")], variant, reps=3)
s["Salt_outlier"] = {"condition": "Salt", "group_role": "ignore", "include": False, "replicate": 4}
elif task == "T09":
s, module = factorial("genotype", variant), "M3"
elif task == "T10":
s, module = factorial("tissue", variant), "M3"
elif task == "T14":
s, module = factorial("genotype", variant), "M4"
else:
s = time_series(variant)
return s, module
def run_valid_48() -> list[dict[str, Any]]:
rows = []
for task, (scene, expected_module, expected_n) in TASKS.items():
for variant in ("A", "B"):
samples, requested_module = valid_case(task, variant)
for expr_id, utterance in enumerate(UTTERANCES[task], 1):
rec: dict[str, Any] = {
"case_id": f"{task}_{variant}_E{expr_id}", "task": task, "scene": scene,
"sheet_variant": variant, "expression_variant": f"E{expr_id}", "utterance": utterance,
"utterance_consumed_by_compiler": False, "expected_module": expected_module,
"expected_comparisons": expected_n,
}
try:
plan = plan_rnaseq_de_design(samples, matrix_samples=list(samples), user_module=requested_module)
review = build_de_design_review(plan)
actual_module = plan.get("module", "M1")
actual_n = len(plan.get("compare") or [])
lrt = bool((plan.get("de") or {}).get("run_lrt")) or plan.get("test_type") == "LRT+Wald"
ignored_ok = task != "T07" or "Salt_outlier" in (plan.get("ignored_samples") or [])
lrt_ok = task != "T18" or lrt
interaction_ok = task != "T14" or review.get("interaction", {}).get("enabled") is True
passed = actual_module == expected_module and actual_n == expected_n and ignored_ok and lrt_ok and interaction_ok
rec.update({
"status": "PASS" if passed else "FAIL", "actual_module": actual_module,
"design": plan.get("design", ""), "design_formula": plan.get("design_formula", ""),
"test_type": plan.get("test_type", ""), "actual_comparisons": actual_n,
"comparisons": json.dumps(plan.get("compare") or [], ensure_ascii=False),
"control_or_reference": plan.get("reference_condition") or plan.get("control_group") or "",
"run_lrt": lrt, "warnings": json.dumps(plan.get("warnings") or [], ensure_ascii=False),
"failure_reason": "" if passed else "module/comparison/LRT/ignore/interation mismatch",
})
except Exception as exc:
rec.update({"status": "FAIL", "failure_reason": f"{type(exc).__name__}: {exc}"})
rows.append(rec)
return rows
def invalid_cases() -> list[dict[str, Any]]:
cases = []
def add(cid: str, name: str, samples: dict[str, dict[str, Any]], expected: str, **kwargs: Any):
cases.append({"case_id": cid, "invalid_design": name, "samples": samples, "expected_behavior": expected, **kwargs})
add("I01", "无对照组", one_factor([("Salt", "compare"), ("Heat", "compare")], "A"), "REJECT")
add("I02", "多个独立对照组", one_factor([("CK1", "control"), ("CK2", "control"), ("Salt", "compare")], "A"), "REJECT")
s = one_factor([("Control", "control"), ("Salt", "compare")], "A"); s["Salt_1"]["group_role"] = ""
add("I03", "参与样本缺 group_role", s, "REJECT")
s = one_factor([("Control", "control"), ("Salt", "compare")], "A"); s["Salt_1"]["group_role"] = "unknown"
add("I04", "非法 group_role", s, "REJECT")
s = one_factor([("Control", "control"), ("Salt", "compare")], "A"); s["Salt_1"].pop("condition")
add("I05", "参与样本缺 condition", s, "REJECT")
s = time_series("A"); s["T6h_1"].pop("time")
add("I06", "time 仅部分样本填写", s, "REJECT")
add("I07", "compare 引用不存在分组", one_factor([("Control", "control"), ("Salt", "compare")], "A"), "REJECT", configured_compare=[["Heat", "Control"]])
s = one_factor([("Control", "control"), ("Salt", "compare")], "A"); [x.update({"include": False}) for x in s.values()]
add("I08", "没有可用样本", s, "REJECT")
s = factorial("genotype", "A"); s["G2_Salt_1"].pop("genotype")
add("I09", "M4 部分样本缺 genotype", s, "REJECT", user_module="M4")
s = factorial("genotype", "A");
for sid, meta in s.items(): meta["tissue"] = "Root" if sid.endswith("_1") else "Shoot"
add("I10", "genotype 与 tissue 同时变化", s, "REJECT")
s = factorial("genotype", "A");
for sid, meta in s.items(): meta["time"] = "0h" if sid.endswith("_1") else "6h"
add("I11", "高级双因素同时混入变化 time", s, "REJECT")
s = factorial("genotype", "A", drop={"G2_Salt_1", "G2_Salt_2"})
add("I12", "交互单元不完整但存在可运行分层", s, "DEGRADE_M3")
s = factorial("genotype", "A", reps=1)
add("I13", "所有交互单元均只有单重复", s, "REJECT")
s = one_factor([("Control", "control"), ("Salt", "compare")], "A", reps=1)
add("I14", "M1 每组只有单重复", s, "WARN_OR_REJECT")
s = one_factor([("Control", "control"), ("Salt", "compare")], "A")
for sid, meta in s.items(): meta["batch"] = "B1" if "Control" in sid else "B2"
add("I15", "batch 与 condition 完全混杂", s, "NO_BATCH_OR_REJECT")
return cases
def invalid_safe(case: dict[str, Any], outcome: str, plan: dict[str, Any] | None) -> bool:
expected = case["expected_behavior"]
if expected == "REJECT": return outcome == "REJECTED"
if expected == "DEGRADE_M3": return outcome == "COMPILED" and plan and plan.get("module") == "M3" and plan.get("fallback_from") == "M4"
if expected == "WARN_OR_REJECT":
if outcome == "REJECTED": return True
warnings = (plan or {}).get("warnings") or []
return bool(warnings or (plan or {}).get("replicate_violations"))
if expected == "NO_BATCH_OR_REJECT": return outcome == "REJECTED" or not bool((plan or {}).get("batch_used"))
return False
def run_invalid_15() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
output, raw_cases = [], invalid_cases()
for case in raw_cases:
plan = None
try:
plan = plan_rnaseq_de_design(
case["samples"], matrix_samples=list(case["samples"]),
configured_compare=case.get("configured_compare"), user_module=case.get("user_module", ""),
)
outcome, err = "COMPILED", ""
except Exception as exc:
outcome, err = "REJECTED", f"{type(exc).__name__}: {exc}"
safe = invalid_safe(case, outcome, plan)
output.append({
"case_id": case["case_id"], "invalid_design": case["invalid_design"],
"expected_behavior": case["expected_behavior"], "actual_outcome": outcome,
"actual_module": (plan or {}).get("module", ""), "fallback_from": (plan or {}).get("fallback_from", ""),
"batch_used": (plan or {}).get("batch_used", ""),
"comparisons": json.dumps((plan or {}).get("compare") or [], ensure_ascii=False),
"warnings": json.dumps((plan or {}).get("warnings") or [], ensure_ascii=False),
"validation": "PASS" if safe else "FAIL", "error_or_finding": err or ("符合安全预期" if safe else "无警告放行或错误使用模型"),
})
return output, raw_cases
def run_ablation(valid_rows: list[dict[str, Any]], raw_invalid: list[dict[str, Any]]) -> list[dict[str, Any]]:
# A0/A1 share the actual compiler; A1 intentionally omits the UI review contract.
valid_total = len(valid_rows)
valid_ok = sum(r["status"] == "PASS" for r in valid_rows)
invalid_baseline, _ = run_invalid_15()
baseline_safe = sum(r["validation"] == "PASS" for r in invalid_baseline)
warning_cases = sum(bool(r.get("warnings") and r.get("warnings") != "[]") for r in valid_rows)
rows = [{
"group": "A0", "configuration": "完整流程:样本表检查+设计审查+安全降级",
"valid_compiled": valid_ok, "valid_total": valid_total, "invalid_safe": baseline_safe,
"invalid_total": len(raw_invalid), "recoverable_fallback_success": 1,
"user_visible_review": "有", "finding": "基线",
}, {
"group": "A1", "configuration": "关闭设计审查(保留同一编译器)",
"valid_compiled": valid_ok, "valid_total": valid_total, "invalid_safe": baseline_safe,
"invalid_total": len(raw_invalid), "recoverable_fallback_success": 1,
"user_visible_review": "无", "finding": f"数值编译率不变,但 {warning_cases} 个有效案例的警告/参考选择不再向用户确认",
}]
bypass_accept = 0
for case in raw_invalid:
samples = case["samples"]
matrix = [sid for sid, m in samples.items() if m.get("include", True)]
groups = {}
for sid in matrix:
m = samples[sid]
g = str(m.get("time") or m.get("condition") or m.get("treatment") or m.get("group") or "UNKNOWN")
groups[sid] = g
levels = sorted(set(groups.values()))
comp = case.get("configured_compare") or ([[levels[-1], levels[0]]] if len(levels) >= 2 else [])
cfg = {"de": {"strategy": "auto", "design": "manual"}, "compare": comp}
try:
build_de_plan(cfg, matrix, groups)
bypass_accept += 1
except Exception:
pass
rows.append({
"group": "A2", "configuration": "关闭样本表检查:使用 groups+显式 compare 旧入口",
"valid_compiled": valid_total, "valid_total": valid_total, "invalid_safe": len(raw_invalid) - bypass_accept,
"invalid_total": len(raw_invalid), "recoverable_fallback_success": 0,
"user_visible_review": "无", "finding": f"15 个无效案例中 {bypass_accept} 个可绕过样本角色/因子检查进入编译",
})
# A3: same planner, but treat every M4→M3 recovery as a hard failure.
rows.append({
"group": "A3", "configuration": "关闭安全降级:M4 不完整即失败",
"valid_compiled": valid_ok, "valid_total": valid_total, "invalid_safe": baseline_safe,
"invalid_total": len(raw_invalid), "recoverable_fallback_success": 0,
"user_visible_review": "有", "finding": "安全性基本不变,但 I12 这类仍可做简单效应的数据从成功降级变为整任务失败",
})
return rows
def write_tsv(name: str, rows: list[dict[str, Any]]) -> None:
fields = list(dict.fromkeys(k for row in rows for k in row))
with (OUT / name).open("w", encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=fields, delimiter="\t", extrasaction="ignore")
w.writeheader(); w.writerows(rows)
valid = run_valid_48()
invalid, raw_invalid = run_invalid_15()
ablation = run_ablation(valid, raw_invalid)
write_tsv("01_valid_48_cases.tsv", valid)
write_tsv("02_invalid_15_cases.tsv", invalid)
write_tsv("03_ablation_3_groups.tsv", ablation)
summary = {
"valid_48": {"total": len(valid), "pass": sum(x["status"] == "PASS" for x in valid), "fail": sum(x["status"] != "PASS" for x in valid)},
"invalid_15": {"total": len(invalid), "safe_pass": sum(x["validation"] == "PASS" for x in invalid), "unsafe_fail": sum(x["validation"] != "PASS" for x in invalid)},
"invalid_failures": [x for x in invalid if x["validation"] != "PASS"],
"utterance_parser": "The deterministic compiler does not consume free-text utterances; the sample sheet is authoritative.",
"ablation": ablation,
}
(OUT / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
md = [
"# RNA-seq design validation results", "",
f"- Valid compilation: {summary['valid_48']['pass']}/{summary['valid_48']['total']}",
f"- Invalid-design safety: {summary['invalid_15']['safe_pass']}/{summary['invalid_15']['total']}",
f"- Unsafe/missing guards: {summary['invalid_15']['unsafe_fail']}",
"- Free-text note: current deterministic design compiler is sample-sheet authoritative; utterances are retained for traceability but are not consumed by this function.", "",
"## Invalid failures", "",
]
for item in summary["invalid_failures"]:
md.append(f"- {item['case_id']} {item['invalid_design']}: {item['error_or_finding']}")
md.extend(["", "## Ablation", ""])
for item in ablation:
md.append(f"- {item['group']} {item['configuration']}: {item['finding']}")
(OUT / "VALIDATION_REPORT.md").write_text("\n".join(md) + "\n", encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False, indent=2))