#!/usr/bin/env python3 """Score RNA-seq candidate gene families from PlantAI result artifacts. This script implements the analysis-suitability scoring scheme: RNA-seq response evidence x family-analysis suitability, with split outputs for analysis-ready families, expression-only responses, singleton candidates, broad core responses, and manual-review families. """ from __future__ import annotations import argparse import csv import json import math import os import re from collections import Counter, defaultdict from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Any PROJECT_ROOT = Path(__file__).absolute().parents[2] if str(PROJECT_ROOT) not in os.sys.path: os.sys.path.insert(0, str(PROJECT_ROOT)) try: from apps.cli.run_rnaseq_candidate_family import ( canonical_gene_id, classify_hit, parse_float, parse_interproscan_tsv, ) except Exception: from run_rnaseq_candidate_family import ( # type: ignore canonical_gene_id, classify_hit, parse_float, parse_interproscan_tsv, ) EVIDENCE_WEIGHTS = { "differential_response_score": 0.35, "family_response_score": 0.25, "direction_consistency_score": 0.15, "wgcna_support_score": 0.15, "enrichment_support_score": 0.10, } @dataclass class SampleInfo: sample: str condition: str = "" time: str = "" group_role: str = "" @dataclass class DeRecord: gene_id: str comparison: str log2fc: float | None pvalue: float | None padj: float | None regulation: str = "" @dataclass class WgcnaSupport: score: float | None = None module: str = "" trait: str = "" module_cor: float | None = None module_p: float | None = None module_genes: int = 0 module_ratio: float | None = None cor_score: float | None = None p_score: float | None = None membership_score: float | None = None @dataclass class FunctionalSupport: go_score: float | None = None kegg_score: float | None = None fgsea_score: float | None = None enrichment_score: float | None = None go_hit_genes: int = 0 kegg_hit_genes: int = 0 fgsea_hit_genes: int = 0 best_go_padj: float | None = None best_kegg_padj: float | None = None best_fgsea_padj: float | None = None best_abs_nes: float | None = None specific_pathway_score: float | None = None family_hit_ratio_score: float | None = None enrichment_significance_score: float | None = None broad_pathway_score: float | None = None broad_pathway_hits: int = 0 total_support_terms: int = 0 specific_pathway_count: int = 0 only_generic_pathway_support: bool = False support_labels: list[str] = field(default_factory=list) def now_iso() -> str: return datetime.now().isoformat(timespec="seconds") def clamp_score(value: float | None) -> float | None: if value is None or math.isnan(value): return None return max(0.0, min(100.0, value)) def safe_neglog_score(value: float | None, denominator: float) -> float: if value is None: return 0.0 value = max(value, 1e-300) return min(-math.log10(value) / denominator, 1.0) * 100.0 def median(values: list[float]) -> float | None: if not values: return None ordered = sorted(values) mid = len(ordered) // 2 if len(ordered) % 2: return ordered[mid] return (ordered[mid - 1] + ordered[mid]) / 2.0 def fmt_num(value: Any, digits: int = 4) -> str: if value is None or value == "": return "" try: val = float(value) except Exception: return str(value) if math.isnan(val): return "" if abs(val) >= 1000 or (abs(val) > 0 and abs(val) < 0.001): return f"{val:.3e}" return f"{val:.{digits}f}".rstrip("0").rstrip(".") def split_genes(text: str) -> set[str]: genes: set[str] = set() for item in re.split(r"[/;,|\s]+", text or ""): gene = canonical_gene_id(item.strip()) if gene: genes.add(gene) return genes def read_tsv(path: Path) -> list[dict[str, str]]: if not path.exists(): return [] with path.open("r", encoding="utf-8", errors="ignore", newline="") as handle: reader = csv.DictReader(handle, delimiter="\t") return list(reader) def write_tsv(path: Path, rows: list[dict[str, Any]], fieldnames: list[str]) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8", newline="") as handle: writer = csv.DictWriter(handle, delimiter="\t", fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() for row in rows: clean = {} for key in fieldnames: value = row.get(key, "") if isinstance(value, float): clean[key] = fmt_num(value, 6) elif isinstance(value, list): clean[key] = ";".join(str(v) for v in value) else: clean[key] = value writer.writerow(clean) def simple_yaml_samples(path: Path) -> dict[str, SampleInfo]: samples: dict[str, SampleInfo] = {} if not path.exists(): return samples current: str | None = None in_samples = False with path.open("r", encoding="utf-8", errors="ignore") as handle: for raw in handle: if not raw.strip() or raw.lstrip().startswith("#"): continue if raw.startswith("samples:"): in_samples = True continue if not in_samples: continue match = re.match(r"^\s{2}([^:\s][^:]*):\s*$", raw) if match: current = match.group(1).strip().strip("'\"") samples[current] = SampleInfo(sample=current) continue if current: kv = re.match(r"^\s{4}([A-Za-z0-9_]+):\s*(.*?)\s*$", raw) if kv: key = kv.group(1) value = kv.group(2).strip().strip("'\"") if key in {"condition", "time", "group_role"}: setattr(samples[current], key, value) return samples def read_samples(path: Path) -> dict[str, SampleInfo]: if not path.exists(): return {} try: import yaml # type: ignore with path.open("r", encoding="utf-8", errors="ignore") as handle: data = yaml.safe_load(handle) or {} result: dict[str, SampleInfo] = {} for sample, info in (data.get("samples") or {}).items(): info = info or {} result[str(sample)] = SampleInfo( sample=str(sample), condition=str(info.get("condition", "")), time=str(info.get("time", "")), group_role=str(info.get("group_role", "")), ) return result except Exception: return simple_yaml_samples(path) def infer_input_dir(workspace: Path) -> Path: project_root = workspace.parents[1] if len(workspace.parents) > 1 else workspace.parent candidate = project_root / "input" / workspace.name if candidate.exists(): return candidate alt = workspace.parent.parent / "input" / workspace.name return alt if alt.exists() else workspace def comparison_from_de_path(path: Path, prefix: str) -> str: name = path.name name = name.replace(f"{prefix}.rnaseq_de.", "") return name.replace(".DE_results.txt", "").replace(".DE_genes.txt", "") def read_de_records(final_dir: Path, prefix: str, padj_cutoff: float, log2fc_cutoff: float, include_lrt: bool) -> dict[str, dict[str, DeRecord]]: records: dict[str, dict[str, DeRecord]] = {} for path in sorted(final_dir.glob(f"{prefix}.rnaseq_de.*.DE_results.txt")): comparison = comparison_from_de_path(path, prefix) if not include_lrt and comparison.endswith("_lrt"): continue comp_records: dict[str, DeRecord] = {} for row in read_tsv(path): gene = canonical_gene_id(row.get("gene_id", "")) if not gene: continue log2fc = parse_float(row.get("log2FoldChange")) padj = parse_float(row.get("padj")) pvalue = parse_float(row.get("pvalue")) regulation = (row.get("regulation") or row.get("Regulation") or "").lower() if not regulation and padj is not None and log2fc is not None: if padj < padj_cutoff and abs(log2fc) >= log2fc_cutoff: regulation = "up" if log2fc > 0 else "down" else: regulation = "not" comp_records[gene] = DeRecord(gene, comparison, log2fc, pvalue, padj, regulation) records[comparison] = comp_records return records def read_tpm_matrix(path: Path) -> tuple[list[str], dict[str, dict[str, float]]]: if not path.exists(): return [], {} matrix: dict[str, dict[str, float]] = {} with path.open("r", encoding="utf-8", errors="ignore", newline="") as handle: reader = csv.DictReader(handle, delimiter="\t") if not reader.fieldnames: return [], {} sample_names = [x for x in reader.fieldnames if x != "gene_id"] for row in reader: gene = canonical_gene_id(row.get("gene_id", "")) if not gene: continue matrix[gene] = {sample: parse_float(row.get(sample)) or 0.0 for sample in sample_names} return sample_names, matrix def samples_for_comparison(comparison: str, samples: dict[str, SampleInfo], matrix_samples: list[str]) -> list[str]: if not samples: return matrix_samples if comparison.endswith("_lrt"): return [s for s in matrix_samples if s in samples] or matrix_samples left = "" right = "" if "_vs_" in comparison: left, right = comparison.split("_vs_", 1) selected: list[str] = [] for sample in matrix_samples: info = samples.get(sample) if not info: continue values = {info.time, info.condition, info.group_role} if left in values or right in values: selected.append(sample) return selected or [s for s in matrix_samples if s in samples] or matrix_samples def is_expressed(gene: str, selected_samples: list[str], matrix: dict[str, dict[str, float]], threshold: float) -> bool: values = matrix.get(gene) if not values: return False return any(values.get(sample, 0.0) > threshold for sample in selected_samples) def family_map_from_interpro(interproscan_tsv: Path) -> dict[str, set[str]]: family_genes: dict[str, set[str]] = defaultdict(set) hits = parse_interproscan_tsv(interproscan_tsv, set()) for hit in hits: family = classify_hit(hit) if family: family_genes[family].add(hit.gene_id) return dict(family_genes) def family_map_from_gene_annotations(path: Path) -> dict[str, set[str]]: family_genes: dict[str, set[str]] = defaultdict(set) for row in read_tsv(path): gene = canonical_gene_id(row.get("gene_id", "")) if not gene: continue for family in (row.get("candidate_families") or "").split(";"): family = family.strip() if family: family_genes[family].add(gene) return dict(family_genes) def read_wgcna_tables(final_dir: Path, prefix: str) -> tuple[dict[str, str], dict[str, dict[str, float]], dict[str, dict[str, float]]]: gene_modules: dict[str, str] = {} module_path = final_dir / f"{prefix}.rnaseq_wgcna.gene_modules.tsv" for row in read_tsv(module_path): gene = canonical_gene_id(row.get("gene_id", "")) module = row.get("module", "") if gene and module: gene_modules[gene] = module def read_matrix(path: Path) -> dict[str, dict[str, float]]: values: dict[str, dict[str, float]] = {} if not path.exists(): return values with path.open("r", encoding="utf-8", errors="ignore", newline="") as handle: reader = csv.reader(handle, delimiter="\t") try: header = next(reader) except StopIteration: return values traits = header[1:] for row in reader: if not row: continue module = row[0].replace("ME", "", 1) values[module] = {} for trait, raw in zip(traits, row[1:]): parsed = parse_float(raw) if parsed is not None: values[module][trait] = parsed return values cor = read_matrix(final_dir / f"{prefix}.rnaseq_wgcna.module_trait_correlation.tsv") pval = read_matrix(final_dir / f"{prefix}.rnaseq_wgcna.module_trait_pvalue.tsv") return gene_modules, cor, pval def infer_trait(comparison: str, samples: dict[str, SampleInfo], available_traits: set[str], requested: str = "") -> str: if requested and requested in available_traits: return requested if "Salt" in available_traits: return "Salt" if "_vs_" in comparison: left, _right = comparison.split("_vs_", 1) conditions = {info.condition for info in samples.values() if info.time == left} for condition in conditions: if condition in available_traits: return condition if left in available_traits: return left return sorted(available_traits)[0] if available_traits else "" def calc_wgcna_support( family_genes: set[str], expressed_genes: set[str], comparison: str, samples: dict[str, SampleInfo], gene_modules: dict[str, str], module_cor: dict[str, dict[str, float]], module_p: dict[str, dict[str, float]], requested_trait: str, ) -> WgcnaSupport: if not gene_modules or not module_cor: return WgcnaSupport(score=None) genes_for_module = expressed_genes or family_genes module_counts = Counter( gene_modules[gene] for gene in genes_for_module if gene in gene_modules and gene_modules[gene].lower() != "grey" ) if not module_counts: return WgcnaSupport(score=0.0, module_ratio=0.0) traits = {trait for data in module_cor.values() for trait in data} trait = infer_trait(comparison, samples, traits, requested_trait) best_module = "" best_count = -1 best_abs_cor = -1.0 for module, count in module_counts.items(): cor = module_cor.get(module, {}).get(trait) abs_cor = abs(cor) if cor is not None else 0.0 if count > best_count or (count == best_count and abs_cor > best_abs_cor): best_module = module best_count = count best_abs_cor = abs_cor cor = module_cor.get(best_module, {}).get(trait) pval = module_p.get(best_module, {}).get(trait) denominator = max(len(genes_for_module), 1) ratio = best_count / denominator cor_score = min((abs(cor) if cor is not None else 0.0) / 0.8, 1.0) * 100.0 p_score = safe_neglog_score(pval, 5.0) membership_score = min(ratio / 0.5, 1.0) * 100.0 score = 0.45 * cor_score + 0.25 * p_score + 0.30 * membership_score return WgcnaSupport( score=score, module=best_module, trait=trait, module_cor=cor, module_p=pval, module_genes=best_count, module_ratio=ratio, cor_score=cor_score, p_score=p_score, membership_score=membership_score, ) def comparison_from_enrich_path(path: Path, prefix: str, kind: str) -> str: name = path.name if kind == "go": return name.replace(f"{prefix}.rnaseq_go_enrich.", "").replace(".GO_enrich.txt", "") return name.replace(f"{prefix}.rnaseq_kegg_enrich.", "").replace(".KEGG_enrich.txt", "").replace(".raw", "") def read_go_support(final_dir: Path, prefix: str, padj_cutoff: float) -> dict[str, list[dict[str, Any]]]: by_comp: dict[str, list[dict[str, Any]]] = defaultdict(list) for path in sorted(final_dir.glob(f"{prefix}.rnaseq_go_enrich.*.GO_enrich.txt")): comparison = comparison_from_enrich_path(path, prefix, "go") for row in read_tsv(path): padj = parse_float(row.get("p.adjust") or row.get("padj") or row.get("qvalue")) if padj is None or padj >= padj_cutoff: continue genes = split_genes(row.get("geneID", "")) if genes: by_comp[comparison].append({ "id": row.get("ID", ""), "description": row.get("Description", ""), "padj": padj, "genes": genes, }) return dict(by_comp) def read_kegg_support(final_dir: Path, prefix: str, padj_cutoff: float) -> dict[str, list[dict[str, Any]]]: by_comp: dict[str, list[dict[str, Any]]] = defaultdict(list) enrich_by_comp: dict[str, dict[str, tuple[float, str]]] = defaultdict(dict) for path in sorted(final_dir.glob(f"{prefix}.rnaseq_kegg_enrich.*.KEGG_enrich.txt")): if path.name.endswith(".raw.txt"): continue comparison = comparison_from_enrich_path(path, prefix, "kegg") for row in read_tsv(path): pathway = row.get("ID") or row.get("PathwayID") or row.get("pathway") or "" padj = parse_float(row.get("p.adjust") or row.get("padj") or row.get("qvalue")) if pathway and padj is not None and padj < padj_cutoff: enrich_by_comp[comparison][pathway] = (padj, row.get("Description") or row.get("PathwayName") or pathway) for path in sorted(final_dir.glob(f"{prefix}.rnaseq_kegg_enrich.*.KEGG_pathway_gene_hits.txt")): comparison = path.name.replace(f"{prefix}.rnaseq_kegg_enrich.", "").replace(".KEGG_pathway_gene_hits.txt", "") significant = enrich_by_comp.get(comparison, {}) if not significant: continue pathway_genes: dict[str, set[str]] = defaultdict(set) for row in read_tsv(path): pathway = row.get("PathwayID") or row.get("ID") or "" if pathway in significant: pathway_genes[pathway].update(split_genes(row.get("Genes", "") or row.get("geneID", ""))) for pathway, genes in pathway_genes.items(): padj, desc = significant[pathway] if genes: by_comp[comparison].append({"id": pathway, "description": desc, "padj": padj, "genes": genes}) return dict(by_comp) def read_fgsea_support(final_dir: Path, prefix: str, padj_cutoff: float) -> dict[str, list[dict[str, Any]]]: by_comp: dict[str, list[dict[str, Any]]] = defaultdict(list) for path in sorted(final_dir.glob(f"{prefix}.rnaseq_fgsea.*.fgsea_kegg.tsv")): comparison = path.name.replace(f"{prefix}.rnaseq_fgsea.", "").replace(".fgsea_kegg.tsv", "") for row in read_tsv(path): padj = parse_float(row.get("padj")) nes = parse_float(row.get("NES")) if padj is None or padj >= padj_cutoff or nes is None: continue genes = split_genes(row.get("leadingEdge", "")) if genes: by_comp[comparison].append({ "id": row.get("pathway", ""), "description": row.get("Description") or row.get("pathway", ""), "padj": padj, "nes": nes, "genes": genes, }) return dict(by_comp) def ratio_sig_score(hit_genes: set[str], deg_genes: set[str], best_padj: float | None) -> float: hit_ratio = len(hit_genes) / max(len(deg_genes), 1) ratio_score = min(hit_ratio / 0.5, 1.0) * 100.0 sig_score = safe_neglog_score(best_padj, 5.0) return 0.60 * ratio_score + 0.40 * sig_score BROAD_PATHWAY_TERMS = [ "ko01100", "ko01110", "metabolic pathways", "biosynthesis of secondary metabolites", "membrane", "atp binding", "binding", "response to stimulus", "response to stress", "cellular process", "catalytic activity", ] SPECIFIC_PATHWAY_TERMS = [ "photosynthesis", "mapk signaling", "porphyrin", "protein processing in endoplasmic reticulum", "protein processing in er", "plant hormone signal transduction", "starch", "sucrose", "phenylpropanoid", "glutathione", "carotenoid", "flavonoid", "alpha-linolenic acid", ] def support_specificity_score(label: str) -> tuple[float, bool]: text = str(label).lower() if any(term in text for term in BROAD_PATHWAY_TERMS): return 30.0, True if any(term in text for term in SPECIFIC_PATHWAY_TERMS): return 100.0, False if any(term in text for term in ["transporter", "protein kinase", "signal", "hydrolase", "transferase"]): return 75.0, False return 70.0, False def calc_functional_support( family_deg_genes: set[str], comparison: str, go_terms: dict[str, list[dict[str, Any]]], kegg_terms: dict[str, list[dict[str, Any]]], fgsea_terms: dict[str, list[dict[str, Any]]], mode: str, ) -> FunctionalSupport: has_any_source = bool(go_terms or kegg_terms or fgsea_terms) if not has_any_source: return FunctionalSupport(enrichment_score=None) support = FunctionalSupport() labels: list[str] = [] support_term_scores: list[float] = [] broad_hits = 0 go_hit: set[str] = set() go_padjs: list[float] = [] for term in go_terms.get(comparison, []): overlap = family_deg_genes & term["genes"] if overlap: go_hit.update(overlap) go_padjs.append(term["padj"]) label = f"GO:{term.get('id')}:{term.get('description')}" labels.append(label) term_score, is_broad = support_specificity_score(label) support_term_scores.append(term_score) broad_hits += int(is_broad) if go_terms: support.go_hit_genes = len(go_hit) support.best_go_padj = min(go_padjs) if go_padjs else None support.go_score = ratio_sig_score(go_hit, family_deg_genes, support.best_go_padj) if go_hit else 0.0 kegg_hit: set[str] = set() kegg_padjs: list[float] = [] for term in kegg_terms.get(comparison, []): overlap = family_deg_genes & term["genes"] if overlap: kegg_hit.update(overlap) kegg_padjs.append(term["padj"]) label = f"KEGG:{term.get('id')}:{term.get('description')}" labels.append(label) term_score, is_broad = support_specificity_score(label) support_term_scores.append(term_score) broad_hits += int(is_broad) if kegg_terms: support.kegg_hit_genes = len(kegg_hit) support.best_kegg_padj = min(kegg_padjs) if kegg_padjs else None support.kegg_score = ratio_sig_score(kegg_hit, family_deg_genes, support.best_kegg_padj) if kegg_hit else 0.0 fgsea_hit: set[str] = set() fgsea_padjs: list[float] = [] fgsea_nes: list[float] = [] for term in fgsea_terms.get(comparison, []): overlap = family_deg_genes & term["genes"] if overlap: fgsea_hit.update(overlap) fgsea_padjs.append(term["padj"]) fgsea_nes.append(abs(term["nes"])) label = f"fgsea:{term.get('id')}:{term.get('description')}" labels.append(label) term_score, is_broad = support_specificity_score(label) support_term_scores.append(term_score) broad_hits += int(is_broad) if fgsea_terms: support.fgsea_hit_genes = len(fgsea_hit) support.best_fgsea_padj = min(fgsea_padjs) if fgsea_padjs else None support.best_abs_nes = max(fgsea_nes) if fgsea_nes else None if fgsea_hit and support.best_abs_nes is not None: nes_score = min(support.best_abs_nes / 3.0, 1.0) * 100.0 sig_score = safe_neglog_score(support.best_fgsea_padj, 5.0) support.fgsea_score = 0.70 * nes_score + 0.30 * sig_score else: support.fgsea_score = 0.0 values = [v for v in [support.go_score, support.kegg_score, support.fgsea_score] if v is not None] all_hit = set().union(go_hit, kegg_hit, fgsea_hit) best_padj_values = [v for v in [support.best_go_padj, support.best_kegg_padj, support.best_fgsea_padj] if v is not None] support.family_hit_ratio_score = min((len(all_hit) / max(len(family_deg_genes), 1)) / 0.5, 1.0) * 100.0 if family_deg_genes else 0.0 support.enrichment_significance_score = safe_neglog_score(min(best_padj_values) if best_padj_values else None, 5.0) support.total_support_terms = len(support_term_scores) support.broad_pathway_hits = broad_hits support.broad_pathway_score = (broad_hits / max(len(support_term_scores), 1)) * 100.0 if support_term_scores else 0.0 support.specific_pathway_count = sum(1 for score in support_term_scores if score >= 70.0) support.only_generic_pathway_support = bool(support_term_scores and broad_hits == len(support_term_scores)) if support_term_scores: support.specific_pathway_score = max(support_term_scores) elif values: support.specific_pathway_score = 0.0 if values: support.enrichment_score = ( 0.50 * float(support.specific_pathway_score or 0.0) + 0.30 * float(support.family_hit_ratio_score or 0.0) + 0.20 * float(support.enrichment_significance_score or 0.0) ) support.support_labels = labels[:5] return support def evidence_score(parts: dict[str, float | None]) -> tuple[float, float]: numerator = 0.0 denominator = 0.0 for key, weight in EVIDENCE_WEIGHTS.items(): value = parts.get(key) if value is None: continue numerator += value * weight denominator += weight if denominator <= 0: return 0.0, 0.0 return numerator / denominator, denominator def top_direction(up: int, down: int) -> str: total = up + down if total <= 0: return "None" if up > down and up / total >= 0.60: return "Up" if down > up and down / total >= 0.60: return "Down" return "Mixed" def database_source(family: str) -> str: if family.startswith("Pfam/InterPro:"): return "Pfam/InterPro" if family.startswith("InterPro:"): return "InterPro" return "curated_rule" def annotation_specificity(family: str) -> tuple[float, str, bool]: text = family.lower() if any(token in text for token in ["domain of unknown function", "unknown protein", " duf", "(duf"]): return 0.30, "duf_unknown_or_putative", True if any(token in text for token in ["putative", "ig-like", "act-like", "domain profile", "had-like"]): return 0.40, "duf_unknown_or_putative", True if any(token in text for token in ["superfamily", "large family", "broad domain group"]): return 0.40, "superfamily_domain", True if any(token in text for token in ["nad(p)h-binding", "binding domain", "generic binding"]): return 0.50, "broad_domain", True if any(token in text for token in ["hydrolase", "transferase"]): return 0.80, "enzyme_family_with_clear_function", False if any(token in text for token in ["transporter family", "protein kinase/rlk", "lrr receptor/signaling"]): return 0.75, "transport_or_signal_family", False if family.startswith("Pfam/InterPro:") or family.startswith("InterPro:"): return 0.85, "specific_domain_or_protein", False return 1.00, "curated_specific_family", False def superfamily_annotation_score(family: str) -> float: text = family.lower() if any(token in text for token in ["superfamily", "large family", "broad domain group"]): return 100.0 if any(token in text for token in ["protein kinase/rlk", "lrr receptor/signaling", "ubiquitin system", "ppr/pentatricopeptide", "aaa atpase"]): return 85.0 if any(token in text for token in ["group 1", "transferase", "transporter family", "hydrolase", "domain profile"]): return 60.0 return 0.0 GENERIC_ANNOTATION_TERMS = [ "response to stress", "response to stimulus", "response to abiotic stimulus", "response to biotic stimulus", "defense response", "stress response", "cellular response to stimulus", ] def generic_annotation_ratio(labels: list[str]) -> float: if not labels: return 0.0 generic = 0 for label in labels: low = str(label).lower() if any(term in low for term in GENERIC_ANNOTATION_TERMS): generic += 1 return generic / max(len(labels), 1) def percentile_lookup(values: dict[str, int]) -> dict[str, float]: ordered = sorted(values.values()) n = len(ordered) if n == 0: return {} result: dict[str, float] = {} for key, value in values.items(): le_count = 0 for item in ordered: if item <= value: le_count += 1 else: break result[key] = le_count / n return result def reliability_from_counts(deg_genes: int, expressed_genes: int) -> tuple[float, float, float]: deg_reliability = min(math.log2(deg_genes + 1) / math.log2(6), 1.0) if deg_genes > 0 else 0.0 expressed_reliability = min(math.log2(expressed_genes + 1) / math.log2(11), 1.0) if expressed_genes > 0 else 0.0 small_family_reliability = deg_reliability * expressed_reliability return deg_reliability, expressed_reliability, small_family_reliability def family_size_suitability(total_family_genes: int) -> float: if total_family_genes < 5: return 0.0 if total_family_genes <= 7: return 70.0 if total_family_genes <= 30: return 100.0 if total_family_genes <= 60: return 85.0 if total_family_genes <= 100: return 60.0 return 30.0 def phylogeny_feasibility(total_family_genes: int, annotation_clarity_score: float, redundant: bool = False) -> float: if redundant: return 20.0 if annotation_clarity_score < 40.0: return 30.0 if 8 <= total_family_genes <= 60: return 100.0 if 5 <= total_family_genes < 8: return 70.0 if total_family_genes > 60: return 50.0 return 20.0 def downstream_interpretability( deg_member_support: float, annotation_clarity_score: float, wgcna_score: Any, enrichment_score: Any, ) -> float: return ( 0.35 * deg_member_support + 0.25 * annotation_clarity_score + 0.20 * float(wgcna_score or 0.0) + 0.20 * float(enrichment_score or 0.0) ) def calculate_score_cap(row: dict[str, Any]) -> tuple[float, str]: caps: list[tuple[float, str]] = [] if int(row.get("total_family_genes") or 0) < 5: caps.append((50.0, "total_family_genes_lt_5")) if int(row.get("deg_family_genes") or 0) < 3: caps.append((55.0, "deg_family_genes_lt_3")) if float(row.get("annotation_clarity_score") or 0.0) < 40.0: caps.append((60.0, "annotation_clarity_lt_40")) if row.get("redundant_family_flag"): caps.append((50.0, "redundant_family")) if row.get("superfamily_flag") and float(row.get("family_response_ratio") or 0.0) < 0.20: caps.append((65.0, "superfamily_low_response_ratio")) if row.get("only_generic_pathway_support"): caps.append((70.0, "only_generic_pathway_support")) if row.get("manual_review_flag"): caps.append((75.0, "manual_review")) if not caps: return 100.0, "" cap = min(value for value, _ in caps) reasons = ";".join(reason for value, reason in caps if value == cap) return cap, reasons class DisjointSet: def __init__(self, items: list[str]) -> None: self.parent = {item: item for item in items} def find(self, item: str) -> str: parent = self.parent[item] if parent != item: self.parent[item] = self.find(parent) return self.parent[item] def union(self, a: str, b: str) -> None: ra = self.find(a) rb = self.find(b) if ra != rb: self.parent[rb] = ra def annotate_redundancy(rows: list[dict[str, Any]], family_genes: dict[str, set[str]]) -> None: families = [str(row["family_id"]) for row in rows] row_by_family = {str(row["family_id"]): row for row in rows} ds = DisjointSet(families) possible: dict[str, set[str]] = defaultdict(set) gene_to_families: dict[str, list[str]] = defaultdict(list) for family in families: for gene in family_genes.get(family, set()): gene_to_families[gene].append(family) pair_seen: set[tuple[str, str]] = set() for fams in gene_to_families.values(): if len(fams) < 2: continue fams = sorted(set(fams)) for i, a in enumerate(fams): genes_a = family_genes.get(a, set()) for b in fams[i + 1:]: pair = (a, b) if pair in pair_seen: continue pair_seen.add(pair) genes_b = family_genes.get(b, set()) union_size = len(genes_a | genes_b) if union_size == 0: continue jaccard = len(genes_a & genes_b) / union_size if jaccard >= 0.80: ds.union(a, b) elif jaccard >= 0.50: possible[a].add(b) possible[b].add(a) groups: dict[str, list[str]] = defaultdict(list) for family in families: groups[ds.find(family)].append(family) group_index = 1 for members in sorted(groups.values(), key=lambda xs: sorted(xs)[0]): representative = max( members, key=lambda fam: ( float(row_by_family[fam].get("pre_redundancy_score") or 0.0), int(row_by_family[fam].get("total_family_genes") or 0), -len(str(fam)), ), ) group_id = f"NRG{group_index:04d}" group_index += 1 for member in members: row = row_by_family[member] row["non_redundancy_group"] = group_id row["representative_family_id"] = representative row["redundant_family_flag"] = member != representative row["non_redundancy_factor"] = 1.0 if member == representative else 0.50 row["possible_redundant_families"] = ";".join(sorted(possible.get(member, set()))[:5]) def make_report( path: Path, args: argparse.Namespace, ranking_rows: list[dict[str, Any]], per_rows: list[dict[str, Any]], metadata: dict[str, Any], ) -> None: family_rows = metadata.get("family_level_rows", [])[: args.top_n_report] singleton_rows = metadata.get("singleton_rows", [])[: min(args.top_n_report, 15)] manual_rows = metadata.get("manual_review_rows", [])[: min(args.top_n_report, 15)] def table(rows: list[dict[str, Any]]) -> list[str]: lines = [ "| Rank | Family | Final | Raw | Best comparison | DEG | Scale | Specificity | Redundancy | Broad factor | Flags |", "|---:|---|---:|---:|---|---:|---:|---:|---:|---:|---|", ] for row in rows: flags = [] for key, label in [ ("singleton_flag", "singleton"), ("small_family_flag", "small"), ("superfamily_flag", "superfamily"), ("generic_annotation_flag", "generic"), ("redundant_family_flag", "redundant"), ("manual_review_flag", "review"), ]: if row.get(key) in {True, "True", "true", 1, "1"}: flags.append(label) lines.append( "| {rank} | {family} | {final} | {raw} | {best_comp} | {deg} | {scale} | {specific} | {nr} | {broad} | {flags} |".format( rank=row.get("rank", ""), family=str(row.get("family_name", "")).replace("|", "\\|"), final=fmt_num(row.get("final_family_score")), raw=fmt_num(row.get("raw_family_score")), best_comp=row.get("best_comparison", ""), deg=row.get("deg_family_genes", ""), scale=fmt_num(row.get("family_scale_factor")), specific=fmt_num(row.get("annotation_specificity_factor")), nr=fmt_num(row.get("non_redundancy_factor")), broad=fmt_num(row.get("broad_penalty_factor")), flags=",".join(flags), ) ) return lines lines = [ f"# RNA-seq 候选基因家族修订版评分报告 - {args.prefix}", "", "## 任务信息", "", f"- Job ID: {args.job_id or metadata.get('job_id', '')}", f"- Workspace: `{metadata.get('workspace', '')}`", f"- Final directory: `{metadata.get('final_dir', '')}`", f"- Prefix: `{args.prefix}`", f"- 计分时间: {now_iso()}", "", "## 评分说明", "", "本脚本按用户提供的《候选基因家族综合评分说明(修订版)》重算候选家族评分。", "", "单比较组 evidence_score 使用可用权重重标化:", "", "`0.40 * 差异响应强度 + 0.25 * 家族响应比例 + 0.20 * 响应方向一致性 + 0.10 * WGCNA模块支持 + 0.05 * 功能富集支持`", "", "跨比较组汇总采用:", "", "`raw_family_score = 0.70 * best_score + 0.30 * top2_mean_score`", "", "若只有一个响应比较组,`top2_mean_score = (best_score + 0) / 2`,避免单时间点响应获得过高家族级分数。", "", "最终分数采用修订版乘法修正:", "", "`final_family_score = raw_family_score * family_scale_factor * annotation_specificity_factor * non_redundancy_factor * broad_penalty_factor`", "", "## 数据完整度", "", f"- 全部候选条目数: {len(ranking_rows)}", f"- family-level response ranking 条目数: {len(metadata.get('family_level_rows', []))}", f"- singleton candidate ranking 条目数: {len(metadata.get('singleton_rows', []))}", f"- manual review list 条目数: {len(metadata.get('manual_review_rows', []))}", f"- 单比较组评分记录数: {len(per_rows)}", f"- DE comparisons: {', '.join(metadata.get('comparisons', []))}", f"- TPM matrix: `{metadata.get('tpm_matrix', '')}`", f"- InterProScan TSV: `{metadata.get('interproscan_tsv', '')}`", f"- WGCNA available: {metadata.get('has_wgcna')}", f"- GO available: {metadata.get('has_go')}", f"- KEGG available: {metadata.get('has_kegg')}", f"- fgsea available: {metadata.get('has_fgsea')}", "", "## Top Family-Level Response Ranking", "", ] lines.extend(table(family_rows)) lines.extend(["", "## Top Singleton Candidate Ranking", ""]) lines.extend(table(singleton_rows)) lines.extend(["", "## Manual Review Preview", ""]) lines.extend(table(manual_rows)) lines.extend([ "", "## 主要修订效果", "", "- 单个 DEG 支撑的条目进入 singleton candidate ranking,并通过 small_family_reliability 和分数上限降低误判。", "- 位于家族规模前 10% 的条目标记 superfamily_flag,并按 family size percentile 与注释宽泛程度计算 superfamily_penalty_factor。", "- DUF、unknown、putative、domain profile、Ig-like、ACT-like 等泛注释条目通过 annotation_specificity_factor 降低优先级。", "- gene set Jaccard >= 0.80 的高度重叠条目被分到同一 non_redundancy_group,非代表条目乘以 0.85。", "- 广谱响应不删除条目,只通过 broad_penalty_factor 最多降低 30%。", "", "## 输出文件", "", f"- 全部修订版评分表: `{metadata.get('ranking_path', '')}`", f"- family-level response ranking: `{metadata.get('family_level_path', '')}`", f"- singleton candidate ranking: `{metadata.get('singleton_path', '')}`", f"- manual review list: `{metadata.get('manual_review_path', '')}`", f"- 单比较组评分明细: `{metadata.get('per_comparison_path', '')}`", f"- 报告: `{path}`", "", "## 注意事项", "", "- `expressed_family_genes` 基于当前比较相关样本中 TPM > 阈值的家族成员数计算。", "- `time_lrt` 默认不纳入评分,因为其不是普通 pairwise 比较;需要时可使用 `--include-lrt`。", "- WGCNA 缺失时记为 NA,并通过可用权重重标化处理;不是按 0 分扣除。", "- 功能富集支持默认取 GO、KEGG、fgsea 三者最高分。", "- 修订版报告应优先解释 family-level response ranking;singleton 表保留强响应单基因候选,但不直接解释为稳定家族响应。", ]) path.write_text("\n".join(lines) + "\n", encoding="utf-8") def make_analysis_ready_report( path: Path, args: argparse.Namespace, ranking_rows: list[dict[str, Any]], per_rows: list[dict[str, Any]], metadata: dict[str, Any], ) -> None: analysis_rows = metadata.get("analysis_ready_rows", [])[: args.top_n_report] expression_rows = metadata.get("expression_response_rows", [])[: min(args.top_n_report, 15)] singleton_rows = metadata.get("singleton_rows", [])[: min(args.top_n_report, 15)] manual_rows = metadata.get("manual_review_rows", [])[: min(args.top_n_report, 15)] core_rows = metadata.get("core_broad_rows", [])[: min(args.top_n_report, 15)] def table(rows: list[dict[str, Any]]) -> list[str]: lines = [ "| Rank | Family | Type | Analysis-ready | Raw response | Suitability | Best comparison | DEG | Ratio | Flags |", "|---:|---|---|---:|---:|---:|---|---:|---:|---|", ] for row in rows: flags = [] for key, label in [ ("singleton_flag", "singleton"), ("small_family_flag", "small"), ("superfamily_flag", "superfamily"), ("generic_annotation_flag", "generic"), ("redundant_family_flag", "redundant"), ("manual_review_flag", "review"), ("core_broad_response_flag", "core_broad"), ("time_specific_flag", "time_specific"), ]: if row.get(key) in {True, "True", "true", 1, "1"}: flags.append(label) lines.append( "| {rank} | {family} | {rtype} | {final} | {raw} | {suit} | {best_comp} | {deg} | {ratio} | {flags} |".format( rank=row.get("rank", ""), family=str(row.get("family_name", "")).replace("|", "\\|"), rtype=row.get("ranking_type", ""), final=fmt_num(row.get("analysis_ready_score")), raw=fmt_num(row.get("raw_response_family_score")), suit=fmt_num(row.get("family_analysis_suitability_score")), best_comp=row.get("best_comparison", ""), deg=row.get("deg_family_genes", ""), ratio=fmt_num(row.get("family_response_ratio")), flags=",".join(flags), ) ) return lines lines = [ f"# RNA-seq 候选基因家族评分报告(分析适合度版)- {args.prefix}", "", "## 任务信息", "", f"- Job ID: {args.job_id or metadata.get('job_id', '')}", f"- Workspace: `{metadata.get('workspace', '')}`", f"- Final directory: `{metadata.get('final_dir', '')}`", f"- Prefix: `{args.prefix}`", f"- 评分时间: {now_iso()}", "", "## 评分说明", "", "本脚本按《候选基因家族综合评分说明(分析适合度版)》重算候选家族评分。", "", "单比较组 evidence_score 使用可用权重重标化:", "", "`0.35 * 差异响应强度 + 0.25 * 家族响应比例 + 0.15 * 方向一致性 + 0.15 * WGCNA支持 + 0.10 * 功能富集支持`", "", "跨比较组汇总采用:", "", "`raw_response_family_score = 0.70 * best_score + 0.30 * top2_mean_score`", "", "若只有一个响应比较组,`top2_mean_score = (best_score + 0) / 2`,避免单一比较组强响应被过度抬高。", "", "最终主分数采用分析适合度修正:", "", "`analysis_ready_score = raw_response_family_score * family_analysis_suitability_score / 100 * broad_penalty_factor * functional_specificity_factor * non_redundancy_factor`", "", "并按 score_cap 上限处理小家族、DEG 不足、注释不清、冗余、超家族低响应比例和仅泛通路支持等情况。", "", "## 数据完整度", "", f"- 全部候选条目数: {len(ranking_rows)}", f"- analysis_ready_family 主榜条目数: {len(metadata.get('analysis_ready_rows', []))}", f"- expression_response_family 副榜条目数: {len(metadata.get('expression_response_rows', []))}", f"- singleton_candidate 条目数: {len(metadata.get('singleton_rows', []))}", f"- manual_review_family 条目数: {len(metadata.get('manual_review_rows', []))}", f"- core_broad_response_family 条目数: {len(metadata.get('core_broad_rows', []))}", f"- time_specific_family 条目数: {len(metadata.get('time_specific_rows', []))}", f"- 单比较组评分记录数: {len(per_rows)}", f"- DE comparisons: {', '.join(metadata.get('comparisons', []))}", f"- TPM matrix: `{metadata.get('tpm_matrix', '')}`", f"- InterProScan TSV: `{metadata.get('interproscan_tsv', '')}`", f"- WGCNA available: {metadata.get('has_wgcna')}", f"- GO available: {metadata.get('has_go')}", f"- KEGG available: {metadata.get('has_kegg')}", f"- fgsea available: {metadata.get('has_fgsea')}", "", "## Top Analysis-Ready Families", "", ] lines.extend(table(analysis_rows)) lines.extend(["", "## Top Expression-Response Families", ""]) lines.extend(table(expression_rows)) lines.extend(["", "## Top Singleton Candidates", ""]) lines.extend(table(singleton_rows)) lines.extend(["", "## Core Broad-Response Families", ""]) lines.extend(table(core_rows)) lines.extend(["", "## Manual Review Preview", ""]) lines.extend(table(manual_rows)) lines.extend([ "", "## 主要修订效果", "", "- analysis_ready_family 主榜要求 total_family_genes >= 5、expressed_family_genes >= 5、deg_family_genes >= 3、family_response_ratio >= 0.20。", "- 家族响应比例按 50% 封顶,避免中大型家族过早满分。", "- 方向一致性使用 log2(deg+1)/log2(6) 可靠性修正,降低 DEG 数过少时的虚高。", "- 功能支持拆分为具体通路、家族命中比例、显著性和 broad_pathway_score,并用 functional_specificity_factor 降低泛通路支撑。", "- 广谱响应惩罚最大扣分降为 15%,但 breadth_score 不再 50% 封顶。", "- 高度重叠的结构域条目按 gene set Jaccard >= 0.80 合并到同一 non_redundancy_group,非代表条目乘以 0.50。", "- 小家族、DEG 不足、注释不清和仅泛通路支持的条目会进入副表或复核表,并应用 score_cap。", "", "## 输出文件", "", f"- 全部评分表: `{metadata.get('ranking_path', '')}`", f"- analysis_ready_family 主榜: `{metadata.get('analysis_ready_path', '')}`", f"- expression_response_family 副榜: `{metadata.get('expression_response_path', '')}`", f"- singleton_candidate: `{metadata.get('singleton_path', '')}`", f"- manual_review_family: `{metadata.get('manual_review_path', '')}`", f"- core_broad_response_family: `{metadata.get('core_broad_path', '')}`", f"- time_specific_family: `{metadata.get('time_specific_path', '')}`", f"- 单比较组评分明细: `{metadata.get('per_comparison_path', '')}`", f"- 报告: `{path}`", "", "## 注意事项", "", "- `expressed_family_genes` 基于当前比较相关样本中 TPM > 阈值的家族成员数计算。", "- `time_lrt` 默认不纳入评分,因为其不是普通 pairwise 比较;需要时可使用 `--include-lrt`。", "- WGCNA 缺失时记为 NA,并通过可用权重重标化处理,不按 0 分扣除。", "- singleton 和 expression_response_family 可用于候选基因或表达响应解释,但不默认作为完整基因家族分析主对象。", ]) path.write_text("\n".join(lines) + "\n", encoding="utf-8") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Score RNA-seq candidate gene families from existing PlantAI outputs.") parser.add_argument("-w", "--workspace", required=True, type=Path, help="workspace/ directory") parser.add_argument("--input-dir", type=Path, default=None, help="input/ directory containing samples.yaml") parser.add_argument("--final-dir", type=Path, default=None, help="final directory; default workspace/final") parser.add_argument("-p", "--prefix", required=True, help="Output prefix, e.g. Os") parser.add_argument("-j", "--job-id", default="", help="Job ID") parser.add_argument("-t", "--cpu", type=int, default=1, help="Accepted for pipeline compatibility; not used") parser.add_argument("--sample-config", type=Path, default=None, help="samples.yaml path") parser.add_argument("--tpm-matrix", type=Path, default=None, help="TPM matrix path") parser.add_argument("--interproscan-tsv", type=Path, default=None, help="Whole-proteome InterProScan TSV") parser.add_argument("--expression-threshold", type=float, default=1.0, help="TPM threshold for expressed family genes") parser.add_argument("--padj", type=float, default=0.05, help="Adjusted P-value threshold for DEG") parser.add_argument("--log2fc", type=float, default=1.0, help="Absolute log2FC threshold for DEG") parser.add_argument("--include-lrt", action="store_true", help="Include *_lrt comparisons in scoring") parser.add_argument("--wgcna-trait", default="", help="Trait column for WGCNA correlation; default infers Salt if available") parser.add_argument("--functional-mode", choices=["max", "top2_mean"], default="max", help="How to combine GO/KEGG/fgsea support") parser.add_argument("--top-n-report", type=int, default=30, help="Top N families shown in report") return parser.parse_args() def main() -> int: args = parse_args() workspace = args.workspace.absolute() final_dir = (args.final_dir or (workspace / "final")).absolute() input_dir = (args.input_dir or infer_input_dir(workspace)).absolute() sample_config = (args.sample_config or (input_dir / "samples.yaml")).absolute() if args.tpm_matrix: tpm_matrix = args.tpm_matrix elif (final_dir / f"{args.prefix}.salmon.tpm_matrix.txt").exists(): tpm_matrix = final_dir / f"{args.prefix}.salmon.tpm_matrix.txt" else: tpm_matrix = final_dir / f"{args.prefix}.tpm_matrix.txt" tpm_matrix = Path(tpm_matrix).absolute() interproscan_tsv = ( args.interproscan_tsv or workspace / "annotation" / "analysis" / f"{args.prefix}.interproscan.tsv" ) interproscan_tsv = Path(interproscan_tsv).absolute() final_dir.mkdir(parents=True, exist_ok=True) samples = read_samples(sample_config) matrix_samples, tpm = read_tpm_matrix(tpm_matrix) de_by_comp = read_de_records(final_dir, args.prefix, args.padj, args.log2fc, args.include_lrt) if not de_by_comp: raise FileNotFoundError(f"No DE result files found in {final_dir} for prefix {args.prefix}") if interproscan_tsv.exists(): family_genes = family_map_from_interpro(interproscan_tsv) else: family_genes = family_map_from_gene_annotations(final_dir / f"{args.prefix}.rnaseq_candidate_family.gene_annotations.tsv") if not family_genes: raise FileNotFoundError("No family-gene mapping found from InterProScan TSV or candidate family annotations") gene_modules, module_cor, module_p = read_wgcna_tables(final_dir, args.prefix) go_terms = read_go_support(final_dir, args.prefix, args.padj) kegg_terms = read_kegg_support(final_dir, args.prefix, args.padj) fgsea_terms = read_fgsea_support(final_dir, args.prefix, args.padj) per_rows: list[dict[str, Any]] = [] ranking_source: dict[str, list[dict[str, Any]]] = defaultdict(list) for family, genes in sorted(family_genes.items()): for comparison, comp_records in de_by_comp.items(): selected_samples = samples_for_comparison(comparison, samples, matrix_samples) expressed_genes = {gene for gene in genes if is_expressed(gene, selected_samples, tpm, args.expression_threshold)} denominator_genes = expressed_genes if expressed_genes else genes sig_records = [ rec for gene, rec in comp_records.items() if gene in genes and rec.padj is not None and rec.log2fc is not None and rec.padj < args.padj and abs(rec.log2fc) >= args.log2fc ] deg_genes = {rec.gene_id for rec in sig_records} up = sum(1 for rec in sig_records if (rec.log2fc or 0.0) > 0) down = sum(1 for rec in sig_records if (rec.log2fc or 0.0) < 0) mean_abs_log2fc = (sum(abs(rec.log2fc or 0.0) for rec in sig_records) / len(sig_records)) if sig_records else None median_padj = median([rec.padj for rec in sig_records if rec.padj is not None]) log2fc_score = min((mean_abs_log2fc or 0.0) / 3.0, 1.0) * 100.0 if sig_records else 0.0 padj_score = safe_neglog_score(median_padj, 10.0) if sig_records else 0.0 differential_response_score = 0.60 * log2fc_score + 0.40 * padj_score if sig_records else 0.0 family_response_ratio = len(deg_genes) / max(len(denominator_genes), 1) family_response_score = min(family_response_ratio / 0.50, 1.0) * 100.0 direction_consistency_raw = (max(up, down) / max(len(sig_records), 1) * 100.0) if sig_records else 0.0 direction_reliability = min(math.log2(len(sig_records) + 1) / math.log2(6), 1.0) if sig_records else 0.0 direction_consistency_score = direction_consistency_raw * direction_reliability wgcna = calc_wgcna_support( genes, denominator_genes, comparison, samples, gene_modules, module_cor, module_p, args.wgcna_trait, ) func = calc_functional_support(deg_genes, comparison, go_terms, kegg_terms, fgsea_terms, args.functional_mode) score, completeness = evidence_score({ "differential_response_score": differential_response_score, "family_response_score": family_response_score, "direction_consistency_raw": direction_consistency_raw, "direction_reliability": direction_reliability, "direction_consistency_score": direction_consistency_score, "wgcna_support_score": wgcna.score, "enrichment_support_score": func.enrichment_score, }) row = { "family_id": family, "family_name": family, "comparison": comparison, "total_family_genes": len(genes), "expressed_family_genes": len(denominator_genes), "deg_family_genes": len(deg_genes), "mean_abs_log2fc": mean_abs_log2fc, "median_padj": median_padj, "up_genes": up, "down_genes": down, "major_direction": top_direction(up, down), "family_response_ratio": family_response_ratio, "log2fc_score": log2fc_score, "padj_score": padj_score, "differential_response_score": differential_response_score, "family_response_score": family_response_score, "direction_consistency_raw": direction_consistency_raw, "direction_reliability": direction_reliability, "direction_consistency_score": direction_consistency_score, "wgcna_support_score": wgcna.score, "wgcna_main_module": wgcna.module, "wgcna_trait": wgcna.trait, "wgcna_module_cor": wgcna.module_cor, "wgcna_module_p": wgcna.module_p, "wgcna_module_genes": wgcna.module_genes, "wgcna_module_ratio": wgcna.module_ratio, "go_support_score": func.go_score, "kegg_support_score": func.kegg_score, "fgsea_support_score": func.fgsea_score, "enrichment_support_score": func.enrichment_score, "specific_pathway_score": func.specific_pathway_score, "family_hit_ratio_score": func.family_hit_ratio_score, "enrichment_significance_score": func.enrichment_significance_score, "broad_pathway_score": func.broad_pathway_score, "broad_pathway_hits": func.broad_pathway_hits, "total_support_terms": func.total_support_terms, "specific_pathway_count": func.specific_pathway_count, "only_generic_pathway_support": func.only_generic_pathway_support, "go_hit_genes": func.go_hit_genes, "kegg_hit_genes": func.kegg_hit_genes, "fgsea_hit_genes": func.fgsea_hit_genes, "best_go_padj": func.best_go_padj, "best_kegg_padj": func.best_kegg_padj, "best_fgsea_padj": func.best_fgsea_padj, "best_abs_nes": func.best_abs_nes, "evidence_score": score, "evidence_completeness": completeness, "has_DE_result": True, "has_WGCNA": bool(gene_modules), "has_GO": bool(go_terms), "has_KEGG": bool(kegg_terms), "has_fgsea": bool(fgsea_terms), "support_labels": func.support_labels, } per_rows.append(row) if len(deg_genes) > 0 or score > 0: ranking_source[family].append(row) size_percentiles = percentile_lookup({family: len(genes) for family, genes in family_genes.items()}) ranking_rows: list[dict[str, Any]] = [] for family, rows in ranking_source.items(): rows = sorted(rows, key=lambda row: float(row.get("evidence_score") or 0.0), reverse=True) if not rows: continue best = rows[0] best_score = float(best.get("evidence_score") or 0.0) response_rows = [ row for row in rows if float(row.get("evidence_score") or 0.0) >= 50.0 or (int(row.get("deg_family_genes") or 0) >= 2 and float(row.get("family_response_ratio") or 0.0) >= 0.10) ] if len(response_rows) >= 2: top2 = response_rows[:2] top2_mean = sum(float(row.get("evidence_score") or 0.0) for row in top2) / 2.0 top2_comparisons = ";".join(str(row.get("comparison", "")) for row in top2) else: top2 = [best] top2_mean = best_score / 2.0 top2_comparisons = f"{best.get('comparison', '')};NA" raw_score = 0.70 * best_score + 0.30 * top2_mean direction_counts = Counter(row.get("major_direction", "") for row in rows if row.get("deg_family_genes", 0)) major_direction = direction_counts.most_common(1)[0][0] if direction_counts else best.get("major_direction", "") response_direction_rows = [ row for row in response_rows if row.get("major_direction") in {"Up", "Down"} and row.get("major_direction") == major_direction ] cross_direction_consistency = len(response_direction_rows) / max(len(response_rows), 1) * 100.0 if response_rows else 0.0 final_direction_score = 0.60 * float(best.get("direction_consistency_score") or 0.0) + 0.40 * cross_direction_consistency total_family_genes = int(best.get("total_family_genes") or len(family_genes.get(family, set()))) best_expressed_genes = int(best.get("expressed_family_genes") or 0) max_deg_genes = max(int(row.get("deg_family_genes") or 0) for row in rows) best_deg_genes = int(best.get("deg_family_genes") or 0) best_family_response_ratio = float(best.get("family_response_ratio") or 0.0) deg_reliability, expressed_reliability, small_family_reliability = reliability_from_counts(best_deg_genes, best_expressed_genes) family_size_percentile = size_percentiles.get(family, 0.0) superfamily_size_score = min(max((family_size_percentile - 0.90) / 0.10, 0.0), 1.0) * 100.0 superfamily_ann_score = superfamily_annotation_score(family) low_response_ratio_score = (1.0 - min(best_family_response_ratio / 0.50, 1.0)) * 100.0 superfamily_penalty = 0.40 * superfamily_size_score + 0.35 * superfamily_ann_score + 0.25 * low_response_ratio_score superfamily_penalty_factor = 1.0 - 0.25 * superfamily_penalty / 100.0 annotation_specificity_factor, specificity_class, generic_flag = annotation_specificity(family) annotation_clarity_score = annotation_specificity_factor * 100.0 all_support_labels: list[str] = [] for row in response_rows or rows[:2]: all_support_labels.extend(row.get("support_labels") or []) generic_ratio = generic_annotation_ratio(all_support_labels) generic_annotation_score = min(generic_ratio / 0.50, 1.0) * 100.0 response_breadth = len(response_rows) / max(len(de_by_comp), 1) breadth_score = response_breadth * 100.0 broad_pathway_score = max(float(row.get("broad_pathway_score") or 0.0) for row in response_rows or rows[:1]) only_generic_pathway_support = any(bool(row.get("only_generic_pathway_support")) for row in response_rows or rows[:1]) specific_pathway_count = sum(int(row.get("specific_pathway_count") or 0) for row in response_rows or rows[:1]) broad_response_penalty = 0.60 * breadth_score + 0.25 * generic_annotation_score + 0.15 * broad_pathway_score broad_penalty_factor = 1.0 - 0.15 * broad_response_penalty / 100.0 functional_specificity_factor = 1.0 - 0.20 * broad_pathway_score / 100.0 family_size_score = family_size_suitability(total_family_genes) deg_member_support = min(max_deg_genes / 8.0, 1.0) * 100.0 expressed_member_support = min(best_expressed_genes / 10.0, 1.0) * 100.0 non_redundancy_score = 100.0 phylogeny_score = phylogeny_feasibility(total_family_genes, annotation_clarity_score) downstream_score = downstream_interpretability( deg_member_support, annotation_clarity_score, best.get("wgcna_support_score"), best.get("enrichment_support_score"), ) family_analysis_suitability_score = ( 0.25 * family_size_score + 0.20 * deg_member_support + 0.15 * expressed_member_support + 0.15 * annotation_clarity_score + 0.10 * non_redundancy_score + 0.10 * phylogeny_score + 0.05 * downstream_score ) mean_other_scores = 0.0 if len(rows) > 1: mean_other_scores = sum(float(row.get("evidence_score") or 0.0) for row in rows[1:]) / (len(rows) - 1) temporal_specificity_score = max(0.0, min((best_score - mean_other_scores) / 30.0, 1.0)) * 100.0 pre_redundancy_score = raw_score * family_analysis_suitability_score / 100.0 * broad_penalty_factor * functional_specificity_factor singleton_flag = total_family_genes < 5 or best_deg_genes <= 1 small_family_flag = total_family_genes < 5 or best_deg_genes < 3 or best_expressed_genes < 5 superfamily_flag = family_size_percentile >= 0.90 family_analysis_candidate = ( total_family_genes >= 5 and best_expressed_genes >= 5 and max_deg_genes >= 3 and best_family_response_ratio >= 0.20 ) ranking_rows.append({ "family_id": family, "family_name": family, "database_source": database_source(family), "total_family_genes": total_family_genes, "expressed_family_genes": best_expressed_genes, "deg_family_genes": max_deg_genes, "best_deg_family_genes": best_deg_genes, "family_response_ratio": best_family_response_ratio, "best_comparison": best.get("comparison", ""), "best_score": best_score, "top2_comparisons": top2_comparisons, "top2_mean_score": top2_mean, "raw_response_family_score": raw_score, "raw_family_score": raw_score, "major_direction": major_direction, "cross_comparison_direction_consistency": cross_direction_consistency, "final_direction_score": final_direction_score, "response_comparison_count": len(response_rows), "total_comparison_count": len(de_by_comp), "response_breadth": response_breadth, "breadth_score": breadth_score, "generic_annotation_ratio": generic_ratio, "generic_annotation_score": generic_annotation_score, "broad_pathway_score": broad_pathway_score, "specific_pathway_count": specific_pathway_count, "only_generic_pathway_support": only_generic_pathway_support, "broad_response_penalty": broad_response_penalty, "broad_penalty_factor": broad_penalty_factor, "functional_specificity_factor": functional_specificity_factor, "deg_reliability": deg_reliability, "expressed_reliability": expressed_reliability, "small_family_reliability": small_family_reliability, "family_size_percentile": family_size_percentile, "superfamily_size_score": superfamily_size_score, "low_response_ratio_score": low_response_ratio_score, "superfamily_annotation_score": superfamily_ann_score, "superfamily_penalty": superfamily_penalty, "superfamily_penalty_factor": superfamily_penalty_factor, "family_size_suitability": family_size_score, "DEG_member_support": deg_member_support, "expressed_member_support": expressed_member_support, "annotation_clarity_score": annotation_clarity_score, "annotation_specificity_factor": annotation_specificity_factor, "annotation_specificity_class": specificity_class, "non_redundancy_score": non_redundancy_score, "phylogeny_feasibility_score": phylogeny_score, "downstream_interpretability_score": downstream_score, "family_analysis_suitability_score": family_analysis_suitability_score, "temporal_specificity_score": temporal_specificity_score, "pre_redundancy_score": pre_redundancy_score, "best_evidence_completeness": best.get("evidence_completeness", ""), "best_differential_response_score": best.get("differential_response_score", ""), "best_family_response_score": best.get("family_response_score", ""), "best_direction_consistency_raw": best.get("direction_consistency_raw", ""), "best_direction_reliability": best.get("direction_reliability", ""), "best_direction_consistency_score": best.get("direction_consistency_score", ""), "best_wgcna_support_score": best.get("wgcna_support_score", ""), "best_enrichment_support_score": best.get("enrichment_support_score", ""), "best_wgcna_main_module": best.get("wgcna_main_module", ""), "best_support_labels": best.get("support_labels", []), "family_analysis_candidate": family_analysis_candidate, "singleton_flag": singleton_flag, "small_family_flag": small_family_flag, "superfamily_flag": superfamily_flag, "generic_annotation_flag": generic_flag, }) annotate_redundancy(ranking_rows, family_genes) for row in ranking_rows: row["non_redundancy_score"] = 100.0 if not row.get("redundant_family_flag") else 50.0 row["phylogeny_feasibility_score"] = phylogeny_feasibility( int(row.get("total_family_genes") or 0), float(row.get("annotation_clarity_score") or 0.0), bool(row.get("redundant_family_flag")), ) row["family_analysis_suitability_score"] = ( 0.25 * float(row.get("family_size_suitability") or 0.0) + 0.20 * float(row.get("DEG_member_support") or 0.0) + 0.15 * float(row.get("expressed_member_support") or 0.0) + 0.15 * float(row.get("annotation_clarity_score") or 0.0) + 0.10 * float(row.get("non_redundancy_score") or 0.0) + 0.10 * float(row.get("phylogeny_feasibility_score") or 0.0) + 0.05 * float(row.get("downstream_interpretability_score") or 0.0) ) row["pre_redundancy_score"] = ( float(row.get("raw_response_family_score") or 0.0) * float(row.get("family_analysis_suitability_score") or 0.0) / 100.0 * float(row.get("broad_penalty_factor") or 1.0) * float(row.get("functional_specificity_factor") or 1.0) ) row["manual_review_flag"] = bool( row.get("generic_annotation_flag") or row.get("redundant_family_flag") or (row.get("superfamily_flag") and float(row.get("family_response_ratio") or 0.0) < 0.20) or float(row.get("annotation_clarity_score") or 0.0) < 40.0 or row.get("only_generic_pathway_support") or row.get("possible_redundant_families") ) cap_value, cap_reason = calculate_score_cap(row) final_score = float(row.get("pre_redundancy_score") or 0.0) * float(row.get("non_redundancy_factor") or 1.0) if final_score > cap_value: final_score = cap_value row["score_cap"] = cap_value row["score_cap_reason"] = cap_reason row["analysis_ready_score"] = final_score row["final_family_score"] = final_score if row.get("singleton_flag"): row["ranking_type"] = "singleton_candidate" elif row.get("manual_review_flag"): row["ranking_type"] = "manual_review_family" elif row.get("family_analysis_candidate"): row["ranking_type"] = "analysis_ready_family" else: row["ranking_type"] = "expression_response_family" if ( row.get("ranking_type") == "analysis_ready_family" and float(row.get("response_breadth") or 0.0) >= 0.50 and float(row.get("cross_comparison_direction_consistency") or 0.0) >= 60.0 and float(row.get("annotation_clarity_score") or 0.0) >= 60.0 ): row["core_broad_response_flag"] = True else: row["core_broad_response_flag"] = False row["time_specific_flag"] = ( row.get("ranking_type") == "analysis_ready_family" and float(row.get("temporal_specificity_score") or 0.0) >= 60.0 ) ranking_rows.sort(key=lambda row: float(row.get("final_family_score") or 0.0), reverse=True) for idx, row in enumerate(ranking_rows, start=1): row["rank"] = idx row["overall_rank"] = idx def rerank_subset(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: reranked: list[dict[str, Any]] = [] for idx, row in enumerate(rows, start=1): item = dict(row) item["overall_rank"] = row.get("overall_rank", row.get("rank", "")) item["rank"] = idx reranked.append(item) return reranked analysis_ready_rows = rerank_subset([row for row in ranking_rows if row.get("ranking_type") == "analysis_ready_family"]) expression_response_rows = rerank_subset([row for row in ranking_rows if row.get("ranking_type") == "expression_response_family"]) singleton_rows = rerank_subset([row for row in ranking_rows if row.get("ranking_type") == "singleton_candidate"]) manual_review_rows = rerank_subset([row for row in ranking_rows if row.get("ranking_type") == "manual_review_family"]) core_broad_rows = rerank_subset([row for row in ranking_rows if row.get("core_broad_response_flag")]) time_specific_rows = rerank_subset([row for row in ranking_rows if row.get("time_specific_flag")]) out_prefix = f"{args.prefix}.rnaseq_family_score" ranking_path = final_dir / f"{out_prefix}.ranking.tsv" analysis_ready_path = final_dir / f"{out_prefix}.analysis_ready_family.tsv" expression_response_path = final_dir / f"{out_prefix}.expression_response_family.tsv" family_level_path = final_dir / f"{out_prefix}.family_level.tsv" singleton_path = final_dir / f"{out_prefix}.singleton_candidate.tsv" manual_review_path = final_dir / f"{out_prefix}.manual_review_family.tsv" core_broad_path = final_dir / f"{out_prefix}.core_broad_response_family.tsv" time_specific_path = final_dir / f"{out_prefix}.time_specific_family.tsv" per_path = final_dir / f"{out_prefix}.per_comparison.tsv" report_path = final_dir / f"{out_prefix}_report.md" manifest_path = final_dir / f"{out_prefix}.manifest.json" ranking_fields = [ "rank", "overall_rank", "ranking_type", "family_id", "family_name", "database_source", "non_redundancy_group", "representative_family_id", "total_family_genes", "expressed_family_genes", "deg_family_genes", "best_deg_family_genes", "family_response_ratio", "family_analysis_candidate", "best_comparison", "best_score", "top2_comparisons", "top2_mean_score", "raw_response_family_score", "raw_family_score", "analysis_ready_score", "final_family_score", "major_direction", "cross_comparison_direction_consistency", "final_direction_score", "response_comparison_count", "total_comparison_count", "response_breadth", "breadth_score", "temporal_specificity_score", "generic_annotation_ratio", "generic_annotation_score", "broad_pathway_score", "specific_pathway_count", "only_generic_pathway_support", "broad_response_penalty", "broad_penalty_factor", "functional_specificity_factor", "deg_reliability", "expressed_reliability", "small_family_reliability", "family_size_percentile", "superfamily_size_score", "low_response_ratio_score", "superfamily_annotation_score", "superfamily_penalty", "superfamily_penalty_factor", "family_size_suitability", "DEG_member_support", "expressed_member_support", "annotation_clarity_score", "annotation_specificity_factor", "annotation_specificity_class", "non_redundancy_score", "non_redundancy_factor", "phylogeny_feasibility_score", "downstream_interpretability_score", "family_analysis_suitability_score", "pre_redundancy_score", "score_cap", "score_cap_reason", "best_evidence_completeness", "best_differential_response_score", "best_family_response_score", "best_direction_consistency_raw", "best_direction_reliability", "best_direction_consistency_score", "best_wgcna_support_score", "best_enrichment_support_score", "best_wgcna_main_module", "singleton_flag", "small_family_flag", "superfamily_flag", "generic_annotation_flag", "redundant_family_flag", "manual_review_flag", "core_broad_response_flag", "time_specific_flag", "possible_redundant_families", "best_support_labels", ] per_fields = [ "family_id", "family_name", "comparison", "total_family_genes", "expressed_family_genes", "deg_family_genes", "mean_abs_log2fc", "median_padj", "up_genes", "down_genes", "major_direction", "family_response_ratio", "log2fc_score", "padj_score", "differential_response_score", "family_response_score", "direction_consistency_raw", "direction_reliability", "direction_consistency_score", "wgcna_support_score", "wgcna_main_module", "wgcna_trait", "wgcna_module_cor", "wgcna_module_p", "wgcna_module_genes", "wgcna_module_ratio", "go_support_score", "kegg_support_score", "fgsea_support_score", "enrichment_support_score", "specific_pathway_score", "family_hit_ratio_score", "enrichment_significance_score", "broad_pathway_score", "broad_pathway_hits", "total_support_terms", "specific_pathway_count", "only_generic_pathway_support", "go_hit_genes", "kegg_hit_genes", "fgsea_hit_genes", "best_go_padj", "best_kegg_padj", "best_fgsea_padj", "best_abs_nes", "evidence_score", "evidence_completeness", "has_DE_result", "has_WGCNA", "has_GO", "has_KEGG", "has_fgsea", "support_labels", ] write_tsv(ranking_path, ranking_rows, ranking_fields) write_tsv(analysis_ready_path, analysis_ready_rows, ranking_fields) write_tsv(expression_response_path, expression_response_rows, ranking_fields) write_tsv(family_level_path, analysis_ready_rows, ranking_fields) write_tsv(singleton_path, singleton_rows, ranking_fields) write_tsv(manual_review_path, manual_review_rows, ranking_fields) write_tsv(core_broad_path, core_broad_rows, ranking_fields) write_tsv(time_specific_path, time_specific_rows, ranking_fields) write_tsv(per_path, sorted(per_rows, key=lambda row: (row["family_name"], row["comparison"])), per_fields) metadata = { "workspace": str(workspace), "final_dir": str(final_dir), "input_dir": str(input_dir), "sample_config": str(sample_config), "tpm_matrix": str(tpm_matrix), "interproscan_tsv": str(interproscan_tsv), "comparisons": sorted(de_by_comp.keys()), "has_wgcna": bool(gene_modules), "has_go": bool(go_terms), "has_kegg": bool(kegg_terms), "has_fgsea": bool(fgsea_terms), "ranking_path": str(ranking_path), "analysis_ready_path": str(analysis_ready_path), "expression_response_path": str(expression_response_path), "family_level_path": str(family_level_path), "singleton_path": str(singleton_path), "manual_review_path": str(manual_review_path), "core_broad_path": str(core_broad_path), "time_specific_path": str(time_specific_path), "per_comparison_path": str(per_path), "job_id": args.job_id, "analysis_ready_rows": analysis_ready_rows, "expression_response_rows": expression_response_rows, "family_level_rows": analysis_ready_rows, "singleton_rows": singleton_rows, "manual_review_rows": manual_review_rows, "core_broad_rows": core_broad_rows, "time_specific_rows": time_specific_rows, } make_analysis_ready_report(report_path, args, ranking_rows, per_rows, metadata) manifest = { "manifest_version": "1.0", "stage": "rnaseq_family_score", "status": "success", "prefix": args.prefix, "job_id": args.job_id, "created_at": now_iso(), "parameters": { "padj": args.padj, "log2fc": args.log2fc, "expression_threshold": args.expression_threshold, "include_lrt": args.include_lrt, "wgcna_trait": args.wgcna_trait, "functional_mode": args.functional_mode, "penalties_applied": True, "scoring_formula": EVIDENCE_WEIGHTS, "family_summary_formula": "0.70 * best_score + 0.30 * top2_mean_score", "single_response_top2_rule": "top2_mean = best_score / 2 when responsive_comparison_count < 2", "family_analysis_candidate_rule": "total_family_genes >= 5 and expressed_family_genes >= 5 and deg_family_genes >= 3 and family_response_ratio >= 0.20", "final_formula": "analysis_ready_score = raw_response_family_score * family_analysis_suitability_score / 100 * broad_penalty_factor * functional_specificity_factor * non_redundancy_factor, capped by score_cap", }, "inputs": { "workspace": str(workspace), "final_dir": str(final_dir), "input_dir": str(input_dir), "sample_config": str(sample_config), "tpm_matrix": str(tpm_matrix), "interproscan_tsv": str(interproscan_tsv), "comparisons": sorted(de_by_comp.keys()), "has_wgcna": bool(gene_modules), "has_go": bool(go_terms), "has_kegg": bool(kegg_terms), "has_fgsea": bool(fgsea_terms), }, "metrics": { "family_count": len(ranking_rows), "analysis_ready_count": len(analysis_ready_rows), "expression_response_count": len(expression_response_rows), "singleton_count": len(singleton_rows), "manual_review_count": len(manual_review_rows), "core_broad_response_count": len(core_broad_rows), "time_specific_count": len(time_specific_rows), "per_comparison_rows": len(per_rows), "comparison_count": len(de_by_comp), }, "artifacts": { "ranking": {"artifact": "ranking", "path": f"final/{ranking_path.name}", "type": "tsv"}, "analysis_ready_family": {"artifact": "analysis_ready_family", "path": f"final/{analysis_ready_path.name}", "type": "tsv"}, "expression_response_family": {"artifact": "expression_response_family", "path": f"final/{expression_response_path.name}", "type": "tsv"}, "family_level": {"artifact": "family_level_alias", "path": f"final/{family_level_path.name}", "type": "tsv"}, "singleton": {"artifact": "singleton", "path": f"final/{singleton_path.name}", "type": "tsv"}, "manual_review": {"artifact": "manual_review", "path": f"final/{manual_review_path.name}", "type": "tsv"}, "core_broad_response_family": {"artifact": "core_broad_response_family", "path": f"final/{core_broad_path.name}", "type": "tsv"}, "time_specific_family": {"artifact": "time_specific_family", "path": f"final/{time_specific_path.name}", "type": "tsv"}, "per_comparison": {"artifact": "per_comparison", "path": f"final/{per_path.name}", "type": "tsv"}, "report": {"artifact": "report", "path": f"final/{report_path.name}", "type": "markdown"}, "manifest": {"artifact": "manifest", "path": f"final/{manifest_path.name}", "type": "json"}, }, } manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(f"[OK] ranking: {ranking_path}") print(f"[OK] analysis-ready: {analysis_ready_path}") print(f"[OK] expression-response: {expression_response_path}") print(f"[OK] family-level alias: {family_level_path}") print(f"[OK] singleton: {singleton_path}") print(f"[OK] manual-review: {manual_review_path}") print(f"[OK] core-broad: {core_broad_path}") print(f"[OK] time-specific: {time_specific_path}") print(f"[OK] per-comparison: {per_path}") print(f"[OK] report: {report_path}") print(f"[OK] manifest: {manifest_path}") return 0 if __name__ == "__main__": raise SystemExit(main())