v2431/FormulaShowcase
0
1# report.py2# -*- coding: utf-8 -*-3 4from __future__ import annotations5 6from dataclasses import dataclass, asdict7from pathlib import Path8import json9import pickle10import re11import itertools12import urllib.request13from collections import OrderedDict14from typing import List, Dict, Any, Iterable, Tuple, Optional, Set15 16import numpy as np17import pandas as pd18import networkx as nx19import matplotlib.pyplot as plt20import matplotlib as mpl21import matplotlib.patches as mpatches22from matplotlib.path import Path as MPath23from matplotlib.patches import PathPatch24 25from pycirclize import Circos # type: ignore26from distinctipy import distinctipy # type: ignore27from goatools.obo_parser import GODag # type: ignore28from goatools.godag_plot import plot_gos # type: ignore29import plotly.graph_objects as go # type: ignore30 31from data_process import UniBioMapKG32from modules import FormulaSolution33from utils import EnrichmentAnalyzer34 35import warnings36warnings.filterwarnings(37 "ignore",38 message=r".*Glyph.*missing from font\(s\).*",39 category=UserWarning,40)41 42# 全局绘图风格:Arial + 字号 12 + SVG,支持中文43# 优先使用系统中支持中文的字体44plt.rcParams['font.family'] = [45 'Microsoft YaHei', # 微软雅黑46 'SimHei', # 黑体47 'SimSun', # 宋体48 'Arial', # 默认英文字体49 'DejaVu Sans',50 'sans-serif'51]52plt.rcParams['font.size'] = 1253plt.rcParams['axes.labelsize'] = 1254plt.rcParams['axes.titlesize'] = 1255plt.rcParams['xtick.labelsize'] = 1256plt.rcParams['ytick.labelsize'] = 1257plt.rcParams['legend.fontsize'] = 1258plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题59# 设置 SVG 导出时字体类型为 path,确保文本在 PDF 报告中正确显示60# 这样SVG中的文字会被转换为路径,避免了字体匹配问题61plt.rcParams['svg.fonttype'] = 'path'62# 设置PDF导出时字体类型,提高兼容性63plt.rcParams['pdf.fonttype'] = 4264# 确保SVG导出时不嵌入字体,而是将文本转换为路径65plt.rcParams['savefig.format'] = 'svg'66# 提高SVG输出的分辨率67plt.rcParams['figure.dpi'] = 15068 69 70@dataclass71class CommonResults:72 """Pipeline 核心数据容器(Step0-Step4 输出汇总)"""73 74 # KG75 kg: UniBioMapKG76 77 # 场景信息78 module_name: str79 module_nodes: Set[str]80 modules_dict: Dict[str, Set[str]] # module_name -> protein nodes81 82 # Step4: 组方方案83 presc_solutions: List[FormulaSolution]84 85 # Step2/3: 候选实体与网络86 ns_targets: Dict[str, Set[str]] # candidate_id -> protein targets87 ns_hit_modules: Dict[str, List[str]] # candidate_id -> [significant modules]88 sep_result: Dict[str, Any] # Step3 separation 结果89 90 # 可选的输入定义91 input_definition: Optional[str] = None92 93 def __post_init__(self):94 """类型转换:确保 Set 类型"""95 if not isinstance(self.module_nodes, set):96 self.module_nodes = set(self.module_nodes)97 98 if self.modules_dict:99 self.modules_dict = {k: set(v) for k, v in self.modules_dict.items()}100 101 if self.ns_targets:102 self.ns_targets = {k: set(v) for k, v in self.ns_targets.items()}103 104 105@dataclass106class FormulaSummary:107 """单个组方的关键数值指标汇总,用于写入 summary.json。"""108 109 index: int110 selected: List[str]111 size: int112 113 # Step4 基本指标114 mean_z: float115 mean_separation: float116 coverage: float117 118 # coverage 扩展119 weighted_coverage: float120 key_target_hit_ratio: float121 122 # hub bias123 hub_hit_ratio: float124 nonhub_coverage: float125 126 # group separation(pairwise 统计 + set-level 指标)127 separation_mean: float128 separation_median: float129 separation_min: float130 separation_max: float131 set_separation: float132 pos_pair_ratio: float133 134 135class FormulaReport:136 """对一批组方(FormulaSolution)生成评估报告。137 138 v2.1 新增/增强:139 - (3) GO DAG & 语义压缩(GOATOOLS):从 GO 富集结果抽取 GO:ID,构建 enriched-term 子 DAG。140 - (5) Circos/Chord:herb–target 以及 target–(GO/KEGG) 的弦图;以及 herb–side_effect 风险结构。141 - (7) 网络打击效应:从一次性 LCC 改为 robustness curve + random / degree-matched baselines,并加入效率指标。142 143 输出结构(不破坏原有):144 results/{module_name}/formula_{i}/145 - summary.json (原有)146 - targets.txt (原有)147 - enrichment/* (原有:由 EnrichmentAnalyzer 生成)148 - go_dag/* (新增)149 - circos/* (新增)150 - network_attack/* (新增)151 - llm_result.json (新增:聚合可读摘要)152 """153 def __init__(154 self,155 common_results: "CommonResults", # type: ignore156 *,157 out_root: str = "results",158 top_enrich: int = 15,159 verbose: bool = False,160 # --- v2.1: GO DAG ---161 go_obo_path: str = "database/go-basic.obo",162 go_dag_top_terms: int = 12,163 # --- v2.1: Circos/Chord ---164 chord_top_targets: int = 30,165 chord_top_terms: int = 12,166 chord_term_groups: Optional[List[str]] = None,167 # v2.2+: side-effects track 已弃用(保留参数以兼容旧调用,但不再绘制)168 circos_show_side_effects: bool = False,169 sankey_prefer_pdf: bool = True,170 # v2.2+: circos 中 module-track 改为 proximity z-score barplot,并支持阈值虚线171 circos_z_thres: float = -1.6,172 # --- v2.1: Network attack ---173 attack_steps: int = 20,174 attack_random_n: int = 50,175 attack_shuffle_n: int = 20,176 attack_eff_n_pairs: int = 3000,177 random_seed: int = 2025,178 ) -> None:179 """基于 CommonResults 初始化 FormulaReport。180 181 Parameters182 ----------183 common_results : CommonResults184 Pipeline 核心数据容器(Step0-Step4 输出汇总)185 out_root : str186 输出根目录187 top_enrich : int188 富集分析保留 top terms 数量189 verbose : bool190 是否打印详细日志191 ... (其他参数保持不变)192 """193 # 解包 CommonResults194 self.kg = common_results.kg195 self.module_name = common_results.module_name196 self.module_nodes = common_results.module_nodes197 self.presc_solutions = common_results.presc_solutions198 self.ns_targets = common_results.ns_targets199 self.ns_hit_modules = common_results.ns_hit_modules # 新增200 self.input_definition = common_results.input_definition # 新增201 202 self.sep_df: pd.DataFrame = common_results.sep_result.get("raw_separation")203 if not isinstance(self.sep_df, pd.DataFrame):204 raise ValueError("sep_result 中未找到有效的 'raw_separation' DataFrame")205 206 # 配置参数207 self.out_root = Path(out_root)208 self.top_enrich = int(top_enrich)209 self.verbose = bool(verbose)210 211 # 使用 ppi_gcc212 self.ppi_graph: nx.Graph = self.kg.ppi_gcc213 214 # v2.1 参数215 self.go_obo_path = Path(go_obo_path)216 self.go_dag_top_terms = int(go_dag_top_terms)217 self.chord_top_targets = int(chord_top_targets)218 self.chord_top_terms = int(chord_top_terms)219 self.chord_term_groups = chord_term_groups or ["kegg", "go"]220 # 兼容旧参数,但已不再绘制 side-effects track221 self.circos_show_side_effects = False222 self.sankey_prefer_pdf = bool(sankey_prefer_pdf)223 self.circos_z_thres = float(circos_z_thres)224 self.attack_steps = int(attack_steps)225 self.attack_random_n = int(attack_random_n)226 self.attack_shuffle_n = int(attack_shuffle_n)227 self.attack_eff_n_pairs = int(attack_eff_n_pairs)228 self.random_seed = int(random_seed)229 230 # v2.2: modules_dict(多模块信息)与配色231 self._prepare_modules_dict(common_results.modules_dict)232 233 # 预计算:模块诱导子图上的 degree234 self._prepare_module_centrality()235 236 # 富集分析器237 self.enrich_libraries = [238 "kegg", "go_bp", "go_cc", "go_mf",239 "hpo", "tf", "kinase", "tissue", "hallmark",240 ]241 self.enrichment_analyzer = EnrichmentAnalyzer(242 enrich_libraries=self.enrich_libraries,243 top_enrich=top_enrich,244 verbose=verbose,245 )246 247 # # v2.2+: 用于 circos barplot 的 proximity z-score 矩阵(如存在就读取)248 # self.prox_z_df: Optional[pd.DataFrame] = self._load_proximity_z_scores()249 250 # 预处理 herb 显示数据251 self._prepare_herb_display_data()252 253 # v2.2+: 尝试加载 Step2 输出的 z-score 矩阵(用于 circos module-track barplot)254 self.prox_z_df: Optional[pd.DataFrame] = self._try_load_proximity_z_scores()255 256 257 # ------------------------------------------------------------------258 # 公共入口259 # ------------------------------------------------------------------260 def run(self) -> List[FormulaSummary]:261 """对所有组方生成报告,返回数值 summary 列表。"""262 base_dir = self.out_root / self.module_name / "report"263 base_dir.mkdir(parents=True, exist_ok=True)264 265 summaries: List[FormulaSummary] = []266 267 for idx, sol in enumerate(self.presc_solutions, start=1):268 presc_dir = base_dir / f"formula_{idx}"269 presc_dir.mkdir(parents=True, exist_ok=True)270 271 summary, llm_result = self._analyze_single_formula(272 index=idx, sol=sol, presc_dir=presc_dir273 )274 summaries.append(summary)275 276 with open(presc_dir / "llm_result.json", "w", encoding="utf-8") as f:277 json.dump(llm_result, f, ensure_ascii=False, indent=2)278 279 if self.verbose:280 print(281 f"[REPORT] {self.module_name} - formula_{idx}: "282 f"size={summary.size}, coverage={summary.coverage:.3f}, "283 f"weighted_cov={summary.weighted_coverage:.3f}, "284 f"hub_hit_ratio={summary.hub_hit_ratio:.3f}, "285 f"set_sep={summary.set_separation:.3f}"286 )287 288 # 输出总览表289 summary_df = pd.DataFrame([asdict(s) for s in summaries])290 summary_df.to_csv(base_dir / "formulas_summary.tsv", sep="\t", index=False)291 292 return summaries293 294 # ------------------------------------------------------------------295 # 内部预处理:centrality / hub / 关键靶标296 # ------------------------------------------------------------------297 def _prepare_module_centrality(self) -> None:298 """在模块诱导子图上计算 degree,并定义 hub / key targets。"""299 module_nodes_in_graph = self.module_nodes & set(self.ppi_graph.nodes())300 self.module_subgraph = self.ppi_graph.subgraph(module_nodes_in_graph).copy()301 302 deg_dict = dict(self.module_subgraph.degree())303 self.module_degree: Dict[str, float] = deg_dict304 305 if len(deg_dict) == 0:306 self.hub_nodes = set()307 self.degree_sum_all = 1.0308 self.key_targets = set()309 return310 311 degrees = np.array(list(deg_dict.values()), dtype=float)312 313 # hub 定义为度数 >= 95 百分位314 thresh = np.percentile(degrees, 95)315 self.hub_nodes = {n for n, d in deg_dict.items() if d >= thresh}316 317 # key targets:中心性 top 10% 的节点318 key_thresh = np.percentile(degrees, 90)319 self.key_targets = {n for n, d in deg_dict.items() if d >= key_thresh}320 321 self.degree_sum_all = float(degrees.sum()) if degrees.sum() > 0 else 1.0322 323 # ------------------------------------------------------------------324 # 单个组方分析325 # ------------------------------------------------------------------326 def _analyze_single_formula(327 self,328 index: int,329 sol: FormulaSolution,330 presc_dir: Path,331 ) -> Tuple[FormulaSummary, Dict[str, Any]]:332 selected = list(sol.selected)333 size = len(selected)334 335 # 1) 靶标集合336 herb_targets = self._collect_targets_by_herb(selected)337 targets = set().union(*herb_targets.values()) if herb_targets else set()338 module_targets = targets & self.module_nodes339 340 # 保存靶标列表341 (presc_dir / "targets.txt").write_text("\n".join(sorted(targets)), encoding="utf-8")342 343 # 2) coverage / weighted coverage / key target 命中率344 coverage, weighted_cov, key_hit_ratio = self._compute_coverage_metrics(module_targets)345 346 # 3) hub bias347 hub_hit_ratio, nonhub_cov = self._compute_hub_bias(module_targets)348 349 # 4) separation(原有)350 (351 sep_mean,352 sep_median,353 sep_min,354 sep_max,355 set_sep,356 pos_ratio,357 ) = self._compute_group_separation_metrics(selected)358 359 # 4.1) separation heatmap(新增可视化)360 self._plot_separation_heatmap(selected, presc_dir / "separation_heatmap.svg")361 362 # 5) 富集分析(原有:由 EnrichmentAnalyzer 生成)363 enrich_dir = presc_dir / "enrichment"364 self.enrichment_analyzer.run_enrichment(365 proteins=targets,366 output_dir=enrich_dir,367 prefix="",368 )369 370 # 6) (3) GO DAG & 语义压缩(新增)371 go_dag_dir = presc_dir / "go_dag"372 go_dag_result = self._build_go_dag_from_enrichment(373 enrich_dir=enrich_dir,374 out_dir=go_dag_dir,375 top_terms=self.go_dag_top_terms,376 )377 378 # 7) (5) Circos/Chord(新增):herb-target、target-term、herb-side_effect379 circos_dir = presc_dir / "circos"380 circos_result = self._build_circos_chords(381 selected=selected,382 herb_targets=herb_targets,383 enrich_dir=enrich_dir,384 out_dir=circos_dir,385 )386 387 # 8) (7) 网络打击效应(重写):robustness curve + baselines388 attack_dir = presc_dir / "network_attack"389 attack_result = self._network_attack_robustness(390 hit_nodes=module_targets,391 out_dir=attack_dir,392 )393 394 # 9) 写 summary.json(保持原有字段不变)395 summary = FormulaSummary(396 index=index,397 selected=selected,398 size=size,399 mean_z=float(sol.mean_z),400 mean_separation=float(sol.mean_separation),401 coverage=float(coverage),402 weighted_coverage=float(weighted_cov),403 key_target_hit_ratio=float(key_hit_ratio),404 hub_hit_ratio=float(hub_hit_ratio),405 nonhub_coverage=float(nonhub_cov),406 separation_mean=float(sep_mean),407 separation_median=float(sep_median),408 separation_min=float(sep_min),409 separation_max=float(sep_max),410 set_separation=float(set_sep),411 pos_pair_ratio=float(pos_ratio),412 )413 414 with open(presc_dir / "summary.json", "w", encoding="utf-8") as f:415 json.dump(asdict(summary), f, ensure_ascii=False, indent=2)416 417 # 10) LLM 友好结果(尽量少数值,多结构)418 # separation:把 separation>0 的 pair 直接列出来,更符合“互补”语义419 complementary_pairs = self._get_complementary_pairs(selected, thres=0.0)420 421 # enrichment:将 enrich_libraries 的显著条目汇总进 json(只保留 Term + Adjusted P-value)422 enrichment_summary = self._collect_enrichment_summary(enrich_dir, topk=self.top_enrich)423 424 llm_result = {425 "module_name": self.module_name,426 "formula_index": index,427 "selected": selected,428 "targets": {429 "n_total": int(len(targets)),430 "n_in_module": int(len(module_targets)),431 "multi_herb_targets_top": self._top_multi_support_targets(herb_targets, topk=30),432 },433 "separation": {434 "complementary_pairs": complementary_pairs,435 },436 "enrichment": enrichment_summary,437 "go_dag": go_dag_result,438 "circos": circos_result,439 "network_attack": attack_result.get("summary_llm", {}),440 }441 442 # 添加input_definition(如果存在)443 if self.input_definition:444 llm_result["input_definition"] = self.input_definition445 446 return summary, llm_result447 448 # ------------------------------------------------------------------449 # 靶标集合450 # ------------------------------------------------------------------451 def _collect_targets_by_herb(self, selected: List[str]) -> Dict[str, Set[str]]:452 """返回:herb -> set(UniProt)。"""453 return {h: set(self.ns_targets.get(h, [])) for h in selected}454 455 # ------------------------------------------------------------------456 # coverage & weighted coverage & key targets457 # ------------------------------------------------------------------458 def _compute_coverage_metrics(self, module_targets: set) -> Tuple[float, float, float]:459 """返回:coverage, weighted_coverage, key_target_hit_ratio"""460 module_size = len(self.module_nodes)461 if module_size == 0:462 return 0.0, 0.0, 0.0463 464 coverage = len(module_targets) / float(module_size)465 466 if len(self.module_degree) == 0:467 weighted_cov = coverage468 else:469 num = float(sum(self.module_degree.get(g, 0.0) for g in module_targets))470 weighted_cov = num / float(self.degree_sum_all) if self.degree_sum_all > 0 else 0.0471 472 if len(self.key_targets) == 0:473 key_hit_ratio = 0.0474 else:475 key_hits = len(module_targets & self.key_targets)476 key_hit_ratio = key_hits / float(len(self.key_targets))477 478 return coverage, weighted_cov, key_hit_ratio479 480 # ------------------------------------------------------------------481 # hub bias482 # ------------------------------------------------------------------483 def _compute_hub_bias(self, module_targets: set) -> Tuple[float, float]:484 """返回:hub_hit_ratio, nonhub_coverage"""485 if len(self.module_degree) == 0:486 return 0.0, 0.0487 488 if len(module_targets) == 0:489 hub_hit_ratio = 0.0490 else:491 hub_hits = len(module_targets & self.hub_nodes)492 hub_hit_ratio = hub_hits / float(len(module_targets))493 494 nonhub_nodes = self.module_nodes - self.hub_nodes495 if len(nonhub_nodes) == 0:496 nonhub_cov = 0.0497 else:498 nonhub_hits = len(module_targets & nonhub_nodes)499 nonhub_cov = nonhub_hits / float(len(nonhub_nodes))500 501 return hub_hit_ratio, nonhub_cov502 503 # ------------------------------------------------------------------504 # 组方整体分离度:S_set + pairwise 统计505 # ------------------------------------------------------------------506 def _compute_group_separation_metrics(507 self, selected: List[str]508 ) -> Tuple[float, float, float, float, float, float]:509 """使用 pairwise separation 矩阵,计算组方内部的统计量。"""510 idx = [h for h in selected if h in self.sep_df.index and h in self.sep_df.columns]511 if len(idx) < 2:512 nan = float("nan")513 return nan, nan, nan, nan, nan, nan514 515 sub = self.sep_df.loc[idx, idx].astype(float)516 mat = sub.values.copy()517 n = mat.shape[0]518 519 mask = ~np.eye(n, dtype=bool)520 vals = mat[mask]521 vals = vals[~np.isnan(vals)]522 if vals.size == 0:523 nan = float("nan")524 return nan, nan, nan, nan, nan, nan525 526 sep_mean = float(np.mean(vals))527 sep_median = float(np.median(vals))528 sep_min = float(np.min(vals))529 sep_max = float(np.max(vals))530 531 nn_mins: List[float] = []532 for i in range(n):533 row = mat[i, :].astype(float)534 row[i] = np.nan535 row = row[~np.isnan(row)]536 if row.size == 0:537 continue538 nn_mins.append(float(np.min(row)))539 set_sep = float(np.mean(nn_mins)) if nn_mins else float("nan")540 541 pos = vals[vals > 0]542 pos_pair_ratio = float(len(pos) / len(vals)) if len(vals) > 0 else float("nan")543 544 return sep_mean, sep_median, sep_min, sep_max, set_sep, pos_pair_ratio545 546 # ------------------------------------------------------------------547 # (3) GO DAG & 语义压缩(GOATOOLS)548 # ------------------------------------------------------------------549 550 def _build_go_dag_from_enrichment(551 self,552 enrich_dir: Path,553 out_dir: Path,554 top_terms: int,555 ) -> Dict[str, Any]:556 """从 GO 富集结果构建 GO DAG,并产出“更适合读”的 GO 总结与官方风格图。557 558 这版做两件事:559 1) GO lineage/DAG:优先用 GOATOOLS 的绘图(plot_gos),失败再回退到 networkx 简易布局。560 2) GO 语义压缩:保留 leaf enriched term + GO-slim 映射汇总(更像论文里“主题级”GO总结)。561 """562 out_dir.mkdir(parents=True, exist_ok=True)563 564 # 读取 GO 富集结果565 go_files = {566 "go_bp": enrich_dir / "go_bp_enrichment.tsv",567 "go_cc": enrich_dir / "go_cc_enrichment.tsv",568 "go_mf": enrich_dir / "go_mf_enrichment.tsv",569 }570 571 # 1) 加载 go-basic.obo572 go_obo = self.go_obo_path573 if not go_obo.exists():574 self._download_go_obo(go_obo)575 576 godag = GODag(str(go_obo))577 578 # 2) 加载 goslim(可选)579 goslim_obo = self.go_obo_path.parent / "goslim_generic.obo"580 goslim_dag = None581 try:582 if not goslim_obo.exists():583 self._download_goslim_obo(goslim_obo)584 goslim_dag = GODag(str(goslim_obo))585 goslim_terms = set(goslim_dag.keys())586 except Exception as e:587 print(f"[GO-SLIM] Load failed -> skip slim mapping: {e}")588 goslim_terms = set()589 590 def _sort_df(df: pd.DataFrame) -> pd.DataFrame:591 # EnrichmentAnalyzer 固定输出 "Adjusted P-value" 列592 if "Adjusted P-value" in df.columns:593 return df.sort_values("Adjusted P-value", ascending=True)594 return df595 596 results: Dict[str, Any] = {}597 for tag, fp in go_files.items():598 if not fp.exists():599 continue600 df = pd.read_csv(fp, sep="\t")601 if df.empty or "Term" not in df.columns:602 continue603 604 df = _sort_df(df)605 606 # enriched GO IDs(按显著性排序,截断 top_terms)607 enriched: List[str] = []608 for term in df["Term"].tolist():609 go_id = self._extract_go_id(str(term))610 if go_id and go_id in godag:611 enriched.append(go_id)612 enriched = list(dict.fromkeys(enriched))[: int(top_terms)]613 614 if not enriched:615 continue616 617 # 语义压缩:保留“更具体”的 enriched term(剔除 enriched term 之间的祖先项)618 leaf_terms = self._semantic_compress_terms(enriched, godag)619 620 # GO-slim 映射:把每个 enriched term 投到上位 slim term(用于汇总主题)621 slim_counter: Dict[str, int] = {}622 if goslim_terms:623 for go_id in enriched:624 ancestors = set(godag[go_id].get_all_parents()) | {go_id}625 hits = ancestors & goslim_terms626 for sid in hits:627 slim_counter[sid] = slim_counter.get(sid, 0) + 1628 629 # 构建 DAG(只画 enriched + 其祖先上下文;避免全 DAG 爆炸)630 nodes_set: Set[str] = set()631 edges: List[Tuple[str, str]] = []632 for go_id in enriched:633 nodes_set.add(go_id)634 nodes_set.update(godag[go_id].get_all_parents())635 636 for go_id in list(nodes_set):637 term_obj = godag.get(go_id)638 if term_obj is None:639 continue640 for parent in term_obj.parents:641 pid = parent.id642 if pid in nodes_set:643 edges.append((pid, go_id))644 645 nodes_df = pd.DataFrame(646 [647 {648 "go_id": nid,649 "name": godag[nid].name if nid in godag else "",650 "namespace": godag[nid].namespace if nid in godag else "",651 "level": int(godag[nid].level) if nid in godag and godag[nid].level is not None else -1,652 "is_enriched": int(nid in enriched),653 "is_leaf_enriched": int(nid in leaf_terms),654 "is_goslim": int(nid in goslim_terms) if goslim_terms else 0,655 }656 for nid in sorted(nodes_set)657 ]658 )659 edges_df = pd.DataFrame(edges, columns=["parent", "child"])660 661 nodes_df.to_csv(out_dir / f"{tag}_dag_nodes.tsv", sep="\t", index=False)662 edges_df.to_csv(out_dir / f"{tag}_dag_edges.tsv", sep="\t", index=False)663 664 # 画图:优先 goatools.godag_plot.plot_gos,失败回退 networkx665 fig_path = out_dir / f"{tag}_go_dag.png"666 fig_fallback = out_dir / f"{tag}_go_dag.svg"667 668 try:669 plot_gos(str(fig_path), leaf_terms[: min(len(leaf_terms), int(top_terms))], godag, title=f"{tag.upper()} GO lineage")670 plotted = fig_path.exists()671 except Exception:672 plotted = False673 674 if not plotted:675 self._plot_go_dag_networkx(nodes_df, edges_df, fig_fallback)676 677 # plot_gos(str(fig_path), leaf_terms[: min(len(leaf_terms), int(top_terms))], godag, title=f"{tag.upper()} GO lineage")678 679 680 # GO-slim 汇总表(生物学家更易读)681 slim_tbl_path = None682 top_slim = []683 if slim_counter:684 items = sorted(slim_counter.items(), key=lambda x: x[1], reverse=True)685 top_slim = items[: min(20, len(items))]686 slim_rows = []687 for sid, cnt in top_slim:688 slim_rows.append(689 {690 "goslim_id": sid,691 "goslim_name": goslim_dag[sid].name if goslim_dag and sid in goslim_dag else (godag[sid].name if sid in godag else ""),692 "count_enriched_terms_mapped": int(cnt),693 }694 )695 slim_df = pd.DataFrame(slim_rows)696 slim_tbl_path = out_dir / f"{tag}_goslim_summary.tsv"697 slim_df.to_csv(slim_tbl_path, sep="\t", index=False)698 699 # LLM 友好:用 GO 英文名称替代 GO:ID(避免纯 ID 不可读)700 def _to_name(go_id: str) -> str:701 return str(godag[go_id].name) if go_id in godag else str(go_id)702 703 results[tag] = {704 "available": True,705 "n_enriched": int(len(enriched)),706 "top_enriched_terms": [_to_name(x) for x in enriched[: min(len(enriched), int(top_terms))]],707 "compressed_leaf_terms": [_to_name(x) for x in leaf_terms],708 "goslim_top": [709 {710 "goslim_term": (goslim_dag[sid].name if goslim_dag and sid in goslim_dag else _to_name(sid)),711 "count": int(cnt),712 }713 for sid, cnt in top_slim714 ],715 }716 717 return {"available": True, "ontologies": results}718 719 720 def _download_go_obo(self, out_path: Path) -> None:721 """下载 go-basic.obo(只需一次)。"""722 out_path.parent.mkdir(parents=True, exist_ok=True)723 url = "https://purl.obolibrary.org/obo/go/go-basic.obo"724 725 print(f"[GO-DAG] Downloading go-basic.obo -> {out_path}")726 try:727 urllib.request.urlretrieve(url, str(out_path))728 except Exception as e:729 raise RuntimeError(730 f"Failed to download go-basic.obo: {e}. "731 f"Please manually download from {url} and place it at {out_path}."732 )733 734 def _download_goslim_obo(self, out_path: Path) -> None:735 """下载 goslim_generic.obo(用于 GO 语义压缩/分组)。736 737 GOATOOLS 文档建议从 current.geneontology.org 获取 goslim_generic.obo。738 """739 out_path.parent.mkdir(parents=True, exist_ok=True)740 url = "http://current.geneontology.org/ontology/subsets/goslim_generic.obo"741 742 print(f"[GO-SLIM] Downloading goslim_generic.obo -> {out_path}")743 try:744 urllib.request.urlretrieve(url, str(out_path))745 except Exception as e:746 raise RuntimeError(747 f"Failed to download goslim_generic.obo: {e}. "748 f"Please manually download from {url} and place it at {out_path}."749 )750 751 @staticmethod752 def _extract_go_id(term: str) -> Optional[str]:753 """从 enrichr 的 Term 字段中提取 GO:xxxxxxx。"""754 m = re.search(r"(GO:\d{7})", str(term))755 return m.group(1) if m else None756 757 @staticmethod758 def _semantic_compress_terms(enriched: List[str], godag) -> List[str]:759 """保留更具体(非祖先)的 enriched terms 作为压缩后的代表。"""760 enriched_set = set(enriched)761 ancestor_set = set()762 for go_id in enriched:763 ancestor_set.update(godag[go_id].get_all_parents())764 # 如果某 enriched term 是另一个 enriched term 的祖先,则剔除765 leaf = [go_id for go_id in enriched if go_id not in (ancestor_set & enriched_set)]766 return leaf if leaf else enriched767 768 @staticmethod769 def _plot_go_dag_networkx(nodes_df: pd.DataFrame, edges_df: pd.DataFrame, out_path: Path) -> None:770 """用 networkx 画一个简易 DAG(层级布局),避免 graphviz 依赖。"""771 if nodes_df.empty or edges_df.empty:772 return773 774 G = nx.DiGraph()775 for _, r in nodes_df.iterrows():776 G.add_node(r["go_id"], level=int(r["level"]), enriched=int(r["is_enriched"]), leaf=int(r["is_leaf_enriched"]))777 for _, r in edges_df.iterrows():778 G.add_edge(r["parent"], r["child"])779 780 # 按 level 分层;level=-1 的放到最底层781 levels = {n: (G.nodes[n].get("level", -1)) for n in G.nodes()}782 uniq_levels = sorted(set(levels.values()))783 level_to_nodes: Dict[int, List[str]] = {lv: [] for lv in uniq_levels}784 for n, lv in levels.items():785 level_to_nodes[lv].append(n)786 for lv in level_to_nodes:787 level_to_nodes[lv] = sorted(level_to_nodes[lv])788 789 # 布局:同一层水平排列,层级越深 y 越低790 pos: Dict[str, Tuple[float, float]] = {}791 for yi, lv in enumerate(uniq_levels):792 nodes = level_to_nodes[lv]793 if not nodes:794 continue795 xs = np.linspace(0, 1, num=len(nodes)) if len(nodes) > 1 else np.array([0.5])796 y = -yi797 for x, n in zip(xs, nodes):798 pos[n] = (float(x), float(y))799 800 # 绘制801 plt.figure(figsize=(12, 8))802 # 边803 nx.draw_networkx_edges(G, pos, alpha=0.35, arrows=False)804 805 # 节点:enriched / leaf_enriched 强调806 base_nodes = [n for n in G.nodes() if G.nodes[n].get("enriched", 0) == 0]807 enr_nodes = [n for n in G.nodes() if G.nodes[n].get("enriched", 0) == 1]808 leaf_nodes = [n for n in G.nodes() if G.nodes[n].get("leaf", 0) == 1]809 810 nx.draw_networkx_nodes(G, pos, nodelist=base_nodes, node_size=40, alpha=0.35)811 nx.draw_networkx_nodes(G, pos, nodelist=enr_nodes, node_size=120, alpha=0.9)812 nx.draw_networkx_nodes(G, pos, nodelist=leaf_nodes, node_size=200, alpha=0.95)813 814 # 标签:只给 enriched term 打标签,避免图爆炸815 labels = {n: n for n in enr_nodes}816 nx.draw_networkx_labels(G, pos, labels=labels, font_size=8)817 818 plt.title("GO DAG (enriched terms in context)")819 plt.axis("off")820 plt.tight_layout()821 out_path.parent.mkdir(parents=True, exist_ok=True)822 plt.savefig(out_path, format="svg", bbox_inches="tight", dpi=150)823 # 同时保存为PNG格式,用于PDF报告(避免SVG字体解析问题)824 png_path = out_path.with_suffix(".png")825 plt.savefig(png_path, format="png", bbox_inches="tight", dpi=300)826 plt.close()827 828 # ------------------------------------------------------------------829 # (5) Circos/Chord830 # ------------------------------------------------------------------831 832 def _build_circos_chords(833 self,834 selected: List[str],835 herb_targets: Dict[str, Set[str]],836 enrich_dir: Path,837 out_dir: Path,838 ) -> Dict[str, Any]:839 """生成 Circos/Chord 图(v2.3):840 841 本次改动:842 - 不再绘制 chord_herb_side_effect.svg 与 chord_herb_target.svg(信息量较低)。843 - chord_target_term 拆分为多幅图:按 term 类型分别绘制(默认 ["kegg", "go"])。844 - circos / sankey 中 component 名称仅用于绘图时做简化(用 '|' 分隔取第一段)。845 - sankey 优先导出 PDF(Plotly + Kaleido 支持 PDF/PNG/SVG 等静态导出)。846 """847 out_dir.mkdir(parents=True, exist_ok=True)848 849 # --------------------------------------------------------------850 # 1) chord:Target–Term(按 term 类型分别绘制)851 # --------------------------------------------------------------852 top_targets = self._select_top_targets(herb_targets, topk=self.chord_top_targets)853 854 chord_term_files: Dict[str, str] = {}855 top_terms_by_group: Dict[str, List[str]] = {}856 857 for grp in (self.chord_term_groups or []):858 terms = self._select_top_terms_from_enrichment_group(enrich_dir, group=grp, topk=self.chord_top_terms)859 if not terms:860 continue861 top_terms_by_group[grp] = terms862 edges_tt = self._build_target_term_edges(863 enrich_dir,864 top_targets=top_targets,865 terms=terms,866 term_group=grp,867 )868 if not edges_tt:869 continue870 fig_tt = out_dir / f"chord_target_term_{grp}.svg"871 self._plot_chord_auto(edges_tt, fig_tt, title=f"Target–{grp.upper()} chord (most significant terms)")872 if fig_tt.exists():873 chord_term_files[grp] = fig_tt.name874 875 # --------------------------------------------------------------876 # 2) Circos:modules(side-effects track 已移除)877 # --------------------------------------------------------------878 fig_circos = out_dir / "circos_modules.svg"879 circos_ok = False880 881 if self.modules_dict and len(self.modules_dict) >= 2:882 self._plot_circos_modules_zbar(883 selected=selected,884 herb_targets=herb_targets,885 out_path=fig_circos,886 title="Source-target circos",887 z_thres=self.circos_z_thres,888 )889 circos_ok = fig_circos.exists()890 else:891 print("[CIRCOS] modules_dict not provided or <2 modules -> skip circos_modules.svg")892 893 # --------------------------------------------------------------894 # 3) Sankey:Herb → Protein → Module → Term(优先 PDF)895 # --------------------------------------------------------------896 sankey_ext = ".pdf" if self.sankey_prefer_pdf else ".html"897 sankey_go_req = out_dir / f"sankey_herb_protein_module_go{sankey_ext}"898 sankey_pw_req = out_dir / f"sankey_herb_protein_module_pathway{sankey_ext}"899 900 sankey_go_out = None901 sankey_pw_out = None902 903 if self.modules_dict and len(self.modules_dict) >= 1:904 sankey_go_out = self._plot_sankey_formula(905 selected=selected,906 herb_targets=herb_targets,907 enrich_dir=enrich_dir,908 out_path=sankey_go_req,909 mode="go",910 )911 sankey_pw_out = self._plot_sankey_formula(912 selected=selected,913 herb_targets=herb_targets,914 enrich_dir=enrich_dir,915 out_path=sankey_pw_req,916 mode="pathway",917 )918 else:919 print("[SANKEY] modules_dict not provided -> skip sankey outputs")920 921 # 注:具体图片文件路径不写入 llm_result.json(避免喂给 LLM 无关信息)。922 return {923 "available": True,924 "top_targets": [self._to_gene(t) for t in top_targets],925 "top_terms": top_terms_by_group,926 "has_circos": bool(circos_ok),927 "has_sankey": bool((sankey_go_out is not None and sankey_go_out.exists()) or (sankey_pw_out is not None and sankey_pw_out.exists())),928 "has_target_term_chord": bool(chord_term_files),929 }930 931 def _plot_chord_auto(self, edges: List[Tuple[str, str]], out_path: Path, title: str) -> None:932 """使用 pyCirclize 画 chord diagram。933 934 - term 扇区直接使用分配的颜色,连线颜色使用 term 颜色935 """936 if not edges:937 return938 939 left = [a for a, _ in edges]940 right = [b for _, b in edges]941 left_names = sorted(set(left))942 right_names = sorted(set(right))943 944 # 清理 term 名称:删除括号及内容945 def clean_term_name(term: str) -> str:946 """remove parentheses and content: 'some function (GO:12345)' -> 'some function'"""947 cleaned = re.sub(r'\s*\([^)]*\)\s*', '', str(term)).strip()948 return cleaned if cleaned else str(term)949 950 # 为 term 生成显示 label(最多20字符)和全名映射951 term_full_names: Dict[str, str] = {}952 term_display_names: Dict[str, str] = {}953 for term in right_names:954 cleaned = clean_term_name(term)955 term_full_names[term] = cleaned956 if len(cleaned) > 20:957 term_display_names[term] = cleaned[:20] + '...'958 else:959 term_display_names[term] = cleaned960 961 # 构造矩阵(使用原始term名称作为列,保证唯一性)962 mat = pd.DataFrame(0, index=left_names, columns=right_names, dtype=int)963 for a, b in edges:964 if a in mat.index and b in mat.columns:965 mat.loc[a, b] += 1966 967 # 为每个原始term生成颜色(使用 distinctipy)968 term_colors_list = self._get_distinct_colors(len(right_names), pastel_factor=0.3, colorblind_type="Deuteranomaly", rng=114514)969 term_colors: Dict[str, str] = {name: term_colors_list[i] for i, name in enumerate(right_names)}970 971 # 手动构建 circos(不使用 chord_diagram 自动模式,以便自定义颜色)972 # sectors 使用原始term名称作为key,保证数据正确性973 sectors = OrderedDict()974 975 # 添加 left (proteins) 扇区 - 使用 .values 确保获取标量976 for name in left_names:977 row_sum = mat.loc[name, :].values.sum() # 使用 .values.sum() 获取标量978 sectors[name] = int(row_sum)979 980 # 添加 right (terms) 扇区 - 使用原始term名称,使用 .values 确保获取标量981 for name in right_names:982 col_sum = mat.loc[:, name].values.sum() # 使用 .values.sum() 获取标量983 sectors[name] = int(col_sum)984 985 circos = Circos(sectors, space=2)986 987 # 绘制扇区和标签988 for sector in circos.sectors:989 name = sector.name990 991 # term 扇区:使用分配的颜色作为背景,标签使用简化名称992 if name in term_colors:993 track = sector.add_track((92, 100))994 track.axis(fc=term_colors[name], ec="white", lw=0.5, alpha=0.8)995 # 使用简化的显示名称作为标签996 display_label = term_display_names.get(name, name)997 sector.text(display_label, r=105, size=10, orientation="vertical", adjust_rotation=True)998 # protein 扇区:使用默认浅色999 else:1000 track = sector.add_track((92, 100))1001 track.axis(fc="#e0e0e0", ec="white", lw=0.5)1002 sector.text(name, r=105, size=9, orientation="vertical", adjust_rotation=True)1003 1004 # 绘制连线:使用 term 颜色1005 for (source, target), count in mat.stack().items():1006 if count > 0:1007 target_color = term_colors.get(target, "#888888")1008 # 使用 .values.sum() 获取标量值1009 source_sum = int(mat.loc[source, :].values.sum())1010 target_sum = int(mat.loc[:, target].values.sum())1011 circos.link(1012 (source, 0, source_sum),1013 (target, 0, target_sum),1014 color=target_color,1015 alpha=0.4,1016 lw=0.0,1017 )1018 1019 fig = circos.plotfig()1020 fig.suptitle(title, y=0.98)1021 1022 # 添加 legend 展示 term 全名1023 legend_handles = []1024 for term_orig in right_names:1025 full_name = term_full_names[term_orig]1026 color = term_colors[term_orig] # 使用原始term名称获取颜色1027 # legend 显示全名,最多50字符1028 label_text = full_name[:50] + '...' if len(full_name) > 50 else full_name1029 legend_handles.append(mpatches.Patch(color=color, label=label_text))1030 1031 if legend_handles:1032 fig.legend(1033 handles=legend_handles,1034 title="Terms (full names)",1035 loc="center left",1036 bbox_to_anchor=(1.0, 0.5),1037 fontsize=7,1038 title_fontsize=8,1039 frameon=True,1040 )1041 1042 fig.savefig(out_path, format="svg", dpi=300, bbox_inches="tight")1043 # 同时保存为PNG格式,用于PDF报告(避免SVG字体解析问题)1044 png_path = out_path.with_suffix(".png")1045 fig.savefig(png_path, format="png", dpi=300, bbox_inches="tight")1046 plt.close(fig)1047 # ------------------------------------------------------------------1048 # v2.2: modules_dict(多模块信息)与配色1049 # ------------------------------------------------------------------1050 1051 def _prepare_modules_dict(self, modules_dict: Optional[Dict[str, Iterable[str]]]) -> None:1052 """保存 modules_dict(module_name -> protein nodes),并预生成 module 配色。1053 1054 注意:1055 - modules_dict 的节点应与 KG 的 PPI 节点同 ID 空间(通常 UniProt)。1056 - 报告内部展示时可能用 Gene Symbol 作为 label,但 membership 判断仍用原始节点 ID。1057 """1058 self.modules_dict: Dict[str, Set[str]] = {}1059 if modules_dict:1060 self.modules_dict = {k: set(v) for k, v in modules_dict.items()}1061 1062 # module 顺序:保持输入顺序(dict 在 py3.7+ 有序),否则按 key 排1063 self.module_names: List[str] = list(self.modules_dict.keys()) if self.modules_dict else []1064 self.module_count: int = len(self.module_names)1065 1066 # module 配色:使用 distinctipy 生成高区分度颜色(更适合 module 数量较多的场景)1067 # - module: 高饱和度(pastel_factor 较低)1068 mod_colors = self._get_distinct_colors(self.module_count,1069 pastel_factor=0.2,1070 colorblind_type="Deuteranomaly",1071 rng=666)1072 self.module_colors: Dict[str, str] = {m: mod_colors[i] for i, m in enumerate(self.module_names)}1073 1074 def _prepare_herb_display_data(self) -> None:1075 """预处理 herb 显示数据和颜色映射,用于 circos/sankey 绘图。1076 1077 在所有组方生成前预先准备好这些数据,避免在每次绘图时重复计算:1078 - herb 显示标签映射(简化且唯一)1079 - herb 颜色映射(固定色板)1080 - herb 排序(按覆盖蛋白数量降序)1081 """1082 # 收集所有组方中的 selected1083 all_selected: Set[str] = set()1084 for sol in self.presc_solutions:1085 all_selected.update(sol.selected)1086 1087 # 为每个 herb 生成简化显示标签(保证唯一性)1088 self.herb_label_map: Dict[str, str] = self._make_unique_display_labels(list(all_selected))1089 self.disp2herb: Dict[str, str] = {v: k for k, v in self.herb_label_map.items()}1090 1091 # 生成固定的 herb 颜色映射(离散色板,易区分)1092 self.herb_palette = [1093 "#66c2a5", "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854",1094 "#ffd92f", "#e5c494", "#b3b3b3", "#1b9e77", "#d95f02",1095 ]1096 1097 def _protein_to_modules(self, protein_id: str) -> List[str]:1098 """返回 protein 属于哪些 module(按 module_names 顺序)。"""1099 if not self.modules_dict:1100 return []1101 out = []1102 for m in self.module_names:1103 if protein_id in self.modules_dict.get(m, set()):1104 out.append(m)1105 return out1106 1107 def _herb_to_modules(self, herb: str, herb_targets: Dict[str, Set[str]]) -> Set[str]:1108 """返回 herb 显著命中的 module(基于 Step2 proximity z-score < -1.6)。1109 1110 Parameters1111 ----------1112 herb : str1113 候选实体 ID1114 herb_targets : Dict[str, Set[str]]1115 备用:若 ns_hit_modules 中无该 herb,回退到靶标覆盖判断1116 1117 Returns1118 -------1119 Set[str]1120 该 herb 显著命中的 module 集合1121 """1122 # 优先使用 Step2 的显著性结果1123 if herb in self.ns_hit_modules:1124 return set(self.ns_hit_modules[herb])1125 1126 # 回退:基于靶标覆盖(任一靶标落入 module 即认为覆盖,但不保证显著性)1127 if not self.modules_dict:1128 return set()1129 1130 ts = herb_targets.get(herb, set()) or set()1131 hit = set()1132 for m, nodes in self.modules_dict.items():1133 if ts & nodes:1134 hit.add(m)1135 1136 return hit1137 1138 # ------------------------------------------------------------------1139 # v2.2: CircOS(modules + side-effect 方格风格)1140 # ------------------------------------------------------------------1141 1142 def _plot_circos_modules_zbar(1143 self,1144 selected: List[str],1145 herb_targets: Dict[str, Set[str]],1146 out_path: Path,1147 title: str,1148 z_thres: float,1149 max_proteins: int = 60,1150 ) -> None:1151 """Circos:Component–Protein links + module-track(z-score barplot)。1152 1153 改动点:1154 - 移除 side-effects track1155 - component 的 module-track 把 proximity 的 z-score 画成条形图1156 - 在条形图上加一条 z_thres 的虚线作为显著阈值1157 - 条形颜色保持与 module 的配色一致1158 """1159 out_path.parent.mkdir(parents=True, exist_ok=True)1160 if not selected or not herb_targets or not self.modules_dict:1161 return1162 1163 # --- 1) 选择 proteins(避免节点爆炸) ---1164 top_targets = self._select_top_targets(herb_targets, topk=int(max_proteins))1165 top_targets_set = set(top_targets)1166 1167 herb2t: Dict[str, List[str]] = {}1168 for h in selected:1169 ts = sorted(set(herb_targets.get(h, set()) & top_targets_set))1170 if ts:1171 herb2t[h] = ts1172 if not herb2t:1173 return1174 1175 herb2t_disp: Dict[str, List[str]] = {self.herb_label_map.get(h, h): ts for h, ts in herb2t.items()}1176 1177 def _uniq_labels(uniprots: List[str]) -> Tuple[List[str], Dict[str, str]]:1178 label2id: Dict[str, str] = {}1179 labels: List[str] = []1180 seen: Set[str] = set()1181 for uid in uniprots:1182 g = self._to_gene(uid)1183 lab = g1184 if lab in seen:1185 lab = f"{g}|{uid}"1186 seen.add(lab)1187 labels.append(lab)1188 label2id[lab] = uid1189 return labels, label2id1190 1191 protein_labels, label2uid = _uniq_labels(list(dict.fromkeys(top_targets)))1192 1193 # --- 2) 扇区 size 设计:herb 大弧度,protein 小弧度 ---1194 M = max(int(self.module_count), 1)1195 sectors = OrderedDict()1196 1197 herb_sorted = sorted(list(herb2t_disp.keys()), key=lambda x: len(herb2t_disp.get(x, [])), reverse=True)1198 for h in herb_sorted:1199 n_link = len(herb2t_disp.get(h, []))1200 sectors[h] = int(max(12, n_link, M * 2))