#!/home/xia-lab/miniconda3/envs/test4py311/bin/python """ RNA-Seq Differential Expression阶段:进行差异表达分析 输入: - 基因计数矩阵(来自RNA_QUAN阶段) - 分组配置文件 - 基因家族成员ID文件(可选) 输出: - 差异表达基因列表(全基因组) - 基因家族成员差异表达结果(可选) - 可视化图表(MA plot, Volcano plot) - 分析报告和manifest """ import argparse import json import subprocess import sys import time import yaml from datetime import datetime from pathlib import Path PROJECT_ROOT = Path(__file__).absolute().parents[2] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from gfa_core.rnaseq_context import apply_rnaseq_context, build_rnaseq_context, resolve_rnaseq_project_context from gfa_core.rnaseq_de_design import plan_rnaseq_de_design from typing import Any, Dict, List, Optional, Tuple import hashlib import os import re from gfa_core.project_paths import get_conda_env_bin as _rnabin_de RNASEQ_BIN = str(_rnabin_de("rnaseq")) if RNASEQ_BIN not in os.environ.get("PATH", "").split(os.pathsep): os.environ["PATH"] = RNASEQ_BIN + os.pathsep + os.environ.get("PATH", "") GENE_ID_SUFFIX_PATTERNS = [ re.compile(r"(?i)([._-](?:mrna|rna|transcript|cds|protein|pep|peptide)\d+[A-Za-z]?)$"), re.compile(r"(?i)([._-][tp]\d+[A-Za-z]?)$"), re.compile(r"(?i)([._-][rp][A-Z])$"), re.compile(r"(\.\d+)$"), ] def normalize_gene_id(gene_id: str) -> str: value = (gene_id or "").strip().strip('"').lstrip(">") value = re.split(r"\s+", value, maxsplit=1)[0] previous = None while value and value != previous: previous = value for pattern in GENE_ID_SUFFIX_PATTERNS: value = pattern.sub("", value) return value def compute_file_hash(filepath: Path) -> str: """计算文件的SHA256哈希值""" sha256_hash = hashlib.sha256() try: with open(filepath, "rb") as f: for byte_block in iter(lambda: f.read(4096), b""): sha256_hash.update(byte_block) return sha256_hash.hexdigest() except Exception: return "" def abs_text(path: Optional[Path]) -> str: return str(Path(path).absolute()) if path else "" def get_tool_versions() -> Dict[str, str]: """获取所需工具版本信息""" versions = {} # R try: result = subprocess.run( [f"{RNASEQ_BIN}/R", "--version"], capture_output=True, text=True, check=False, timeout=5 ) output = result.stdout.strip() or result.stderr.strip() versions["R"] = output.split('\n')[0] if output else "unknown" except Exception: versions["R"] = "not_found" # DESeq2 (reported through R) versions["DESeq2"] = "via R" return versions def r_string(value: Any) -> str: """Return an R-safe string literal.""" return json.dumps(str(value)) def r_vector(values: List[Any]) -> str: return "c(" + ", ".join(r_string(value) for value in values) + ")" def parse_bool(value: Any, default: bool = False) -> bool: if value is None: return default if isinstance(value, bool): return value return str(value).strip().lower() in {"1", "true", "yes", "y", "on"} def parse_time_sort_value(value: Any) -> Tuple[int, Any]: text = str(value) match = re.search(r"-?\d+(?:\.\d+)?", text) if match: return (0, float(match.group(0))) return (1, text) def comparison_name(group_a: str, group_b: str) -> str: return f"{group_a}_vs_{group_b}" def normalize_samples_config(cfg: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: samples = cfg.get("samples") or {} if not isinstance(samples, dict): return {} normalized: Dict[str, Dict[str, Any]] = {} for sample, value in samples.items(): normalized[str(sample)] = value if isinstance(value, dict) else {} return normalized def extract_sample_metadata( sample_meta: dict, matrix_samples: list, ) -> dict: """Extract batch/genotype/subject_id/dose/tissue from sample metadata.""" result = { "batch": {}, "genotype": {}, "subject_id": {}, "dose": {}, "tissue": {}, } for s in matrix_samples: meta = sample_meta.get(s, {}) for field in result: val = meta.get(field) if val is not None and str(val).strip(): result[field][s] = str(val).strip() return result def normalize_group_label(group: Any, replicate: Any = "") -> str: value = str(group or "").strip() rep = str(replicate or "").strip() if not value: return value if re.match(r"^-?\d+(?:\.\d+)?$", value): return value if rep: match = re.match(rf"^(.+?)[._-](?:rep|r)?{re.escape(rep)}$", value, flags=re.IGNORECASE) if match and match.group(1).strip(): return match.group(1).strip() for pattern in (r"^(.+?)[._-](?:rep|r)?\d+$", r"^(.+?)[._-]\d+$"): match = re.match(pattern, value, flags=re.IGNORECASE) if match and match.group(1).strip(): return match.group(1).strip() return value def load_groups_from_config(cfg: Dict[str, Any]) -> Dict[str, str]: groups: Dict[str, str] = {} sample_meta = normalize_samples_config(cfg) raw_groups = cfg.get("groups") or {} if isinstance(raw_groups, dict): for sample, group in raw_groups.items(): meta = sample_meta.get(str(sample), {}) groups[str(sample)] = normalize_group_label(group, meta.get("replicate", "")) if not groups: for sample, meta in sample_meta.items(): group = meta.get("group") or meta.get("condition") or meta.get("treatment") if group is not None: groups[sample] = normalize_group_label(group, meta.get("replicate", "")) if not groups and "samples" in cfg: for sample in sample_meta.keys(): groups[sample] = sample return groups def load_sample_times(cfg: Dict[str, Any], matrix_samples: List[str]) -> Dict[str, Any]: sample_meta = normalize_samples_config(cfg) times: Dict[str, Any] = {} for sample in matrix_samples: meta = sample_meta.get(sample, {}) if "time" in meta: times[sample] = meta["time"] elif "day" in meta: times[sample] = meta["day"] return times def build_replicate_counts(matrix_samples: List[str], groups: Dict[str, str]) -> Dict[str, int]: counts: Dict[str, int] = {} for sample in matrix_samples: group = groups[sample] counts[group] = counts.get(group, 0) + 1 return counts def get_replicate_policy(cfg: Dict[str, Any]) -> Dict[str, Any]: policy: Dict[str, Any] = {} if isinstance(cfg.get("replicate_policy"), dict): policy.update(cfg["replicate_policy"]) de_cfg = cfg.get("de") if isinstance(cfg.get("de"), dict) else {} if isinstance(de_cfg.get("replicate_policy"), dict): policy.update(de_cfg["replicate_policy"]) return { "min_reps_per_group": int(policy.get("min_reps_per_group", policy.get("min_bio_reps_per_group", 1))), "allow_no_replicate": parse_bool(policy.get("allow_no_replicate"), default=True), "on_violation": str(policy.get("on_violation", "warn")).lower(), } def validate_replicates(replicate_counts: Dict[str, int], policy: Dict[str, Any]) -> List[str]: min_reps = int(policy.get("min_reps_per_group", 1)) violations = [ f"{group}={count}" for group, count in sorted(replicate_counts.items()) if count < min_reps ] if violations and not policy.get("allow_no_replicate", True): message = ( f"Replicate policy violation: min_reps_per_group={min_reps}, " + ", ".join(violations) ) if policy.get("on_violation") == "error": raise ValueError(message) print(f"Warning: {message}", file=sys.stderr) return violations def build_group_times( matrix_samples: List[str], groups: Dict[str, str], sample_times: Dict[str, Any], ) -> Dict[str, Any]: group_times: Dict[str, Any] = {} for sample in matrix_samples: if sample not in sample_times: continue group = groups[sample] time_value = sample_times[sample] if group in group_times and str(group_times[group]) != str(time_value): raise ValueError( f"Group {group} has inconsistent time values: {group_times[group]} and {time_value}" ) group_times[group] = time_value return group_times def infer_group_times_from_labels(groups: Dict[str, str]) -> Dict[str, Any]: """Infer time values from group labels such as T0, T12, T24 or day_3. This is intentionally conservative: every group must contain a numeric component, otherwise no inferred time-series design is returned. """ unique_groups = sorted(set(groups.values()), key=lambda value: parse_time_sort_value(value)) inferred: Dict[str, Any] = {} for group in unique_groups: sort_kind, sort_value = parse_time_sort_value(group) if sort_kind != 0: return {} inferred[group] = sort_value return inferred def dedupe_comparisons(comparisons: List[List[str]]) -> List[List[str]]: seen = set() deduped: List[List[str]] = [] for comp in comparisons: if len(comp) != 2: continue key = (str(comp[0]), str(comp[1])) if key in seen: continue seen.add(key) deduped.append([key[0], key[1]]) return deduped def filter_comparisons_to_groups(comparisons: List[List[str]], valid_groups: List[str]) -> List[List[str]]: valid = set(valid_groups) filtered: List[List[str]] = [] dropped: List[List[str]] = [] for comp in comparisons: if len(comp) != 2: continue if comp[0] in valid and comp[1] in valid: filtered.append(comp) else: dropped.append(comp) if dropped: print( "DESeq2 warning: dropped comparisons with groups absent from samples.yaml/count matrix: " + ", ".join(f"{comp[0]}_vs_{comp[1]}" for comp in dropped), file=sys.stderr, ) return dedupe_comparisons(filtered) def build_auto_time_comparisons( existing: List[Any], group_times: Dict[str, Any], de_cfg: Dict[str, Any], ) -> List[List[str]]: existing = existing if isinstance(existing, list) else [] comparisons = [[str(comp[0]), str(comp[1])] for comp in existing if isinstance(comp, list) and len(comp) == 2] if not group_times or not parse_bool(de_cfg.get("run_pairwise"), default=True): return dedupe_comparisons(comparisons) ordered_groups = [ group for group, _ in sorted(group_times.items(), key=lambda item: parse_time_sort_value(item[1])) ] comparisons = filter_comparisons_to_groups(comparisons, ordered_groups) if len(ordered_groups) < 2: return dedupe_comparisons(comparisons) modes = de_cfg.get("pairwise_modes", ["baseline"]) if isinstance(modes, str): modes = [modes] baseline_group = str(de_cfg.get("baseline_group") or ordered_groups[0]) if baseline_group not in ordered_groups: raise ValueError( f"de.baseline_group={baseline_group} is not found in time-series groups: {', '.join(ordered_groups)}" ) generated: List[List[str]] = [] if "baseline" in modes: for group in ordered_groups: if group != baseline_group: generated.append([group, baseline_group]) if "adjacent" in modes: for idx in range(1, len(ordered_groups)): generated.append([ordered_groups[idx], ordered_groups[idx - 1]]) return dedupe_comparisons(comparisons + generated) def get_experiment_cfg(cfg: Dict[str, Any]) -> Dict[str, Any]: experiment = cfg.get("experiment") if isinstance(cfg.get("experiment"), dict) else {} return experiment def normalize_design(value: Any) -> str: design = str(value or "").strip().lower().replace("-", "_") aliases = { "timecourse": "time_series", "time_course": "time_series", "timeseries": "time_series", "case_control": "two_group", "binary": "two_group", "pairwise": "two_group", "categorical": "multi_group", "condition": "multi_group", "conditions": "multi_group", } return aliases.get(design, design) def normalize_group_list(value: Any) -> List[str]: if value is None: return [] if isinstance(value, list): return [normalize_group_label(item) for item in value if str(item).strip()] if isinstance(value, tuple): return [normalize_group_label(item) for item in value if str(item).strip()] return [normalize_group_label(value)] if str(value).strip() else [] def load_configured_comparisons(cfg: Dict[str, Any], de_cfg: Dict[str, Any]) -> List[List[str]]: raw = cfg.get("compare") if raw in (None, []) and isinstance(de_cfg.get("compare"), list): raw = de_cfg.get("compare") raw = raw if isinstance(raw, list) else [] return [[normalize_group_label(comp[0]), normalize_group_label(comp[1])] for comp in raw if isinstance(comp, list) and len(comp) == 2] def configured_group_value( cfg: Dict[str, Any], de_cfg: Dict[str, Any], key: str, fallback_keys: Optional[List[str]] = None, ) -> Any: experiment = get_experiment_cfg(cfg) keys = [key] + (fallback_keys or []) for container in (de_cfg, experiment, cfg): for item_key in keys: if isinstance(container, dict) and container.get(item_key) not in (None, ""): return container.get(item_key) return None def build_control_comparisons( cfg: Dict[str, Any], de_cfg: Dict[str, Any], unique_groups: List[str], ) -> List[List[str]]: control_group = configured_group_value(cfg, de_cfg, "control_group", ["baseline_group", "reference_group"]) treatment_groups = configured_group_value(cfg, de_cfg, "treatment_groups", ["treat_groups", "case_groups"]) if not control_group: raise ValueError( "Non-time-series DE requires an explicit control_group/reference_group or manual compare. " "Example: experiment: {design: two_group, control_group: CK, treatment_groups: [Treat]} " "or compare: [[Treat, CK]]." ) control_group = normalize_group_label(control_group) if control_group not in unique_groups: raise ValueError( f"control_group={control_group} is not found in groups: {', '.join(unique_groups)}" ) treatments = normalize_group_list(treatment_groups) if not treatments: treatments = [group for group in unique_groups if group != control_group] missing = [group for group in treatments if group not in unique_groups] if missing: raise ValueError(f"treatment_groups not found in groups: {', '.join(missing)}") if not treatments: raise ValueError("No treatment groups remain after excluding control_group") return dedupe_comparisons([[group, control_group] for group in treatments if group != control_group]) def build_de_plan( cfg: Dict[str, Any], matrix_samples: List[str], groups: Dict[str, str], user_module: str = "", ) -> Dict[str, Any]: de_cfg = cfg.get("de") if isinstance(cfg.get("de"), dict) else {} experiment_cfg = get_experiment_cfg(cfg) configured_comparisons = load_configured_comparisons(cfg, de_cfg) sample_meta = normalize_samples_config(cfg) design_plan: Dict[str, Any] = {} if sample_meta: selected_module = str(user_module or de_cfg.get("module") or "").strip().upper() design_plan = plan_rnaseq_de_design( sample_meta, matrix_samples=matrix_samples, configured_compare=configured_comparisons, user_module=selected_module, design_options={**experiment_cfg, **de_cfg}, ) groups = dict(design_plan["groups"]) elif not configured_comparisons: raise ValueError( "samples.yaml 缺少 samples/group_role,无法自动生成 DE 比较;" "请在样本表中补齐 group_role=control/compare/ignore。" ) planning_samples = list(design_plan.get("active_samples") or matrix_samples) sample_times = load_sample_times(cfg, planning_samples) group_times = build_group_times(planning_samples, groups, sample_times) strategy = str(de_cfg.get("strategy", "auto")).lower() design = normalize_design( design_plan.get("design") or de_cfg.get("design") or experiment_cfg.get("design") ) inferred_design = "" if not group_times and strategy == "auto" and design in {"", "time_series"}: group_times = infer_group_times_from_labels(groups) if group_times and not design: inferred_design = "time_series_from_group_labels" if design == "time_series" and not group_times: group_times = infer_group_times_from_labels(groups) replicate_counts = build_replicate_counts(planning_samples, groups) policy = get_replicate_policy(cfg) replicate_violations = validate_replicates(replicate_counts, policy) unique_groups = sorted(set(groups[sample] for sample in planning_samples), key=lambda value: parse_time_sort_value(value)) if design_plan: comparisons = list(design_plan.get("compare") or []) comparison_mode = str(design_plan.get("comparison_mode") or "group_role_control") elif configured_comparisons: comparisons = filter_comparisons_to_groups(configured_comparisons, unique_groups) comparison_mode = "configured" elif strategy == "auto" and design in {"two_group", "multi_group"}: comparisons = build_control_comparisons(cfg, de_cfg, unique_groups) comparison_mode = "configured_control" elif strategy == "auto" and (design in {"", "time_series"} and group_times): comparisons = build_auto_time_comparisons(configured_comparisons, group_times, de_cfg) comparison_mode = "time_series_auto" else: comparisons = filter_comparisons_to_groups(configured_comparisons, unique_groups) comparison_mode = "configured" if configured_comparisons else "" if not comparisons and design == "manual": raise ValueError("manual/explicit DE design requires compare entries.") if strategy == "auto" and not comparisons and design_plan.get("module") != "M6": raise ValueError( "No pairwise comparisons were configured. Fill group_role in samples.yaml " "or provide explicit compare entries." ) has_time_series = len(group_times) >= 3 run_lrt = ( parse_bool(de_cfg.get("run_lrt"), default=False) if "run_lrt" in de_cfg else strategy == "auto" and has_time_series and design in {"", "time_series"} ) if design in {"two_group", "multi_group"}: run_lrt = False run_spline_lrt = parse_bool(de_cfg.get("run_spline_lrt"), default=False) spline_df = int(de_cfg.get("spline_df", 3)) time_values_numeric = [parse_time_sort_value(value)[0] == 0 for value in sample_times.values()] run_spline_lrt = run_spline_lrt and has_time_series and all(time_values_numeric) and len(group_times) > spline_df time_levels = [group for group, _ in sorted(group_times.items(), key=lambda item: parse_time_sort_value(item[1]))] effective_control_group = ( str(design_plan.get("control_group") or "") if design_plan else (time_levels[0] if time_levels else (comparisons[0][1] if comparisons else "")) ) module_name = str(design_plan.get("module") or "") design_formula_v2 = str(design_plan.get("design_formula") or "") test_type_v2 = str(design_plan.get("test_type") or "Wald") batch_used_v2 = bool(design_plan.get("batch_used", False)) # Extract extended metadata for modular R scripts sample_metadata = extract_sample_metadata(sample_meta, matrix_samples) batch_values = sample_metadata.get("batch", {}) genotype_values = sample_metadata.get("genotype", {}) subject_id_values = sample_metadata.get("subject_id", {}) dose_values = sample_metadata.get("dose", {}) tissue_values = sample_metadata.get("tissue", {}) return { "strategy": strategy, "design": design or inferred_design or ("time_series" if group_times else "manual"), "comparisons": comparisons, "comparison_mode": comparison_mode, "active_samples": planning_samples, "ignored_samples": list(design_plan.get("ignored_samples") or []), "groups": groups, "replicate_counts": replicate_counts, "replicate_policy": policy, "replicate_violations": replicate_violations, "module": module_name, "design_formula": design_formula_v2, "test_type": test_type_v2, "batch_used": batch_used_v2, "batch_requested": bool(design_plan.get("batch_requested", False)), "batch_levels": list(design_plan.get("batch_levels") or []), "batch_fallback": bool(design_plan.get("batch_fallback", False)), "batch_fallback_reason": str(design_plan.get("batch_fallback_reason") or ""), "batch_values": batch_values, "genotype_values": genotype_values, "subject_id_values": subject_id_values, "dose_values": dose_values, "tissue_values": tissue_values, "condition_values": dict(design_plan.get("condition_values") or {}), "factor_values": dict(design_plan.get("factor_values") or {}), "advanced_factor": str(design_plan.get("advanced_factor") or ""), "interaction_full": str(design_plan.get("interaction_full") or ""), "interaction_reduced": str(design_plan.get("interaction_reduced") or ""), "control_condition": str(design_plan.get("control_condition") or ""), "reference_condition": str(design_plan.get("reference_condition") or ""), "reference_factor": str(design_plan.get("reference_factor") or ""), "comparison_axis": str(design_plan.get("comparison_axis") or ""), "comparison_axis_source": str(design_plan.get("comparison_axis_source") or ""), "stratify_axis": str(design_plan.get("stratify_axis") or ""), "condition_reference_source": str(design_plan.get("condition_reference_source") or ""), "factor_reference_source": str(design_plan.get("factor_reference_source") or ""), "factor_levels": list(design_plan.get("factor_levels") or []), "condition_levels": list(design_plan.get("condition_levels") or []), "design_warnings": list(design_plan.get("warnings") or []), "fallback_from": str(design_plan.get("fallback_from") or ""), "fallback_reason": str(design_plan.get("fallback_reason") or ""), "skipped_comparisons": list(design_plan.get("skipped_comparisons") or []), "sample_metadata": sample_metadata, "sample_times": {sample: sample_times.get(sample) for sample in matrix_samples if sample in sample_times}, "group_times": group_times, "time_levels": time_levels, "effective_control_group": effective_control_group, "run_lrt": run_lrt and has_time_series, "run_spline_lrt": run_spline_lrt, "spline_df": spline_df, } def run_family_de_analysis( de_dir: Path, family_ids_file: Path, final_dir: Path, family_name: str, padj_thresh: float = 0.05, log2fc_thresh: float = 1.0, prefix: str = "rnaseq" ) -> Dict[str, int]: """提取基因家族成员的差异表达结果""" import shutil # 读取家族基因ID family_ids = set() with open(family_ids_file, 'r') as f: for line in f: gene_id = normalize_gene_id(line.strip().split('\t')[0]) if gene_id: family_ids.add(gene_id) print(f" Loaded {len(family_ids)} family member IDs") stats = {"total_family_genes": len(family_ids), "de_family_genes": 0} # 处理每个比较组 for de_file in de_dir.glob("*.DE_results.txt"): comp_name = de_file.stem.replace(".DE_results", "") # 读取全基因组DE结果 family_de_results = [] family_de_genes = [] with open(de_file, 'r') as f: header = f.readline() # 保存header for line in f: if not line.strip(): continue parts = line.strip().split('\t') if len(parts) < 7: continue gene_id = normalize_gene_id(parts[0]) if gene_id in family_ids: family_de_results.append(line) # 检查是否是差异表达基因 try: padj = float(parts[6]) if parts[6] != 'NA' else 1.0 log2fc = float(parts[2]) if parts[2] != 'NA' else 0.0 if padj < padj_thresh and abs(log2fc) > log2fc_thresh: family_de_genes.append(line) except (ValueError, IndexError): pass # 保存家族特异性结果,使用标准命名格式 safe_prefix = prefix if prefix else "rnaseq" if family_de_results: family_results_file = final_dir / f"{safe_prefix}.rnaseq_de.{family_name}_{comp_name}.family_DE_results.txt" with open(family_results_file, 'w') as f: f.write(header) f.writelines(family_de_results) print(f" {comp_name}: {len(family_de_results)} family genes in total results") # 生成家族特异性绘图 generate_family_plots(family_results_file, final_dir, family_name, comp_name, safe_prefix, padj_thresh) if family_de_genes: family_de_file = final_dir / f"{safe_prefix}.rnaseq_de.{family_name}_{comp_name}.family_DE_genes.txt" with open(family_de_file, 'w') as f: f.write(header) f.writelines(family_de_genes) stats["de_family_genes"] += len(family_de_genes) print(f" {comp_name}: {len(family_de_genes)} family DE genes") return stats def generate_family_plots( de_results_file: Path, final_dir: Path, family_name: str, comp_name: str, prefix: str, padj_thresh: float = 0.05 ): """为基因家族生成特异性绘图(MA图和火山图)""" import subprocess import tempfile # 创建R脚本 r_script = f""" library(ggplot2) library(dplyr) # 读取数据 de_data <- read.csv("{de_results_file}", sep="\\t", header=TRUE) # 确保列名正确 colnames(de_data) <- make.names(colnames(de_data)) # 计算mean expression (baseMean) de_data$meanExpr <- log2(de_data$baseMean + 1) # 确定显著性 de_data$significant <- ifelse(de_data$padj < {padj_thresh}, "Significant", "Not Significant") # MA Plot ma_plot <- ggplot(de_data, aes(x=meanExpr, y=log2FoldChange, color=significant)) + geom_point(alpha=0.6, size=2) + scale_color_manual(values=c("Not Significant"="gray50", "Significant"="red")) + geom_hline(yintercept=0, linetype="dashed", color="blue") + theme_bw() + theme(legend.position="bottom") + labs(title="MA Plot - {family_name} Family", subtitle="{comp_name}", x="Log2 Mean Expression", y="Log2 Fold Change", color="Significance") ggsave("{final_dir}/{prefix}.rnaseq_de.{family_name}_{comp_name}_family_MA_plot.pdf", ma_plot, width=8, height=6) ggsave("{final_dir}/{prefix}.rnaseq_de.{family_name}_{comp_name}_family_MA_plot.png", ma_plot, width=8, height=6, dpi=150) # Volcano Plot de_data$volcano_color <- case_when( de_data$padj < {padj_thresh} & de_data$log2FoldChange > 0 ~ "Up-regulated", de_data$padj < {padj_thresh} & de_data$log2FoldChange < 0 ~ "Down-regulated", TRUE ~ "Not Significant" ) volcano_plot <- ggplot(de_data, aes(x=log2FoldChange, y=-log10(pvalue), color=volcano_color)) + geom_point(alpha=0.6, size=2) + scale_color_manual(values=c("Up-regulated"="red", "Down-regulated"="blue", "Not Significant"="gray50")) + geom_vline(xintercept=c(-1, 1), linetype="dashed", color="gray") + geom_hline(yintercept=-log10(0.05), linetype="dashed", color="gray") + theme_bw() + theme(legend.position="bottom") + labs(title="Volcano Plot - {family_name} Family", subtitle="{comp_name}", x="Log2 Fold Change", y="-Log10 P-value", color="Regulation") ggsave("{final_dir}/{prefix}.rnaseq_de.{family_name}_{comp_name}_family_volcano_plot.pdf", volcano_plot, width=8, height=6) ggsave("{final_dir}/{prefix}.rnaseq_de.{family_name}_{comp_name}_family_volcano_plot.png", volcano_plot, width=8, height=6, dpi=150) cat("Family-specific plots generated successfully\\n") """ # 写入临时R脚本 with tempfile.NamedTemporaryFile(mode='w', suffix='.R', delete=False) as f: f.write(r_script) r_script_file = f.name try: # 运行R脚本 result = subprocess.run( [f"{RNASEQ_BIN}/Rscript", r_script_file], capture_output=True, text=True, timeout=60 ) if result.returncode == 0: print(f" Generated family-specific plots for {family_name}") else: print(f" Warning: Failed to generate family plots: {result.stderr}") except Exception as e: print(f" Warning: Error generating family plots: {e}") finally: # 清理临时文件 import os if os.path.exists(r_script_file): os.remove(r_script_file) def generate_modular_r_script( count_matrix, matrix_samples, de_plan, output_dir, padj_thresh, log2fc_thresh, ): """Generate modular R script based on v2 module type (M1-M6).""" pw_dds = "dds" pw_factor = "condition" module = de_plan.get("module", "") groups = de_plan.get("groups", {}) comparisons = de_plan.get("comparisons", []) condition_map = de_plan.get("condition_values", {}) sample_conditions = [condition_map.get(s, groups.get(s, "unknown")) for s in matrix_samples] sample_groups = [groups.get(s, "unknown") for s in matrix_samples] sample_cond_r = ", ".join('"' + str(s) + '"' for s in sample_conditions) sample_group_r = ", ".join('"' + str(s) + '"' for s in sample_groups) sample_names_r = ", ".join('"' + str(s) + '"' for s in matrix_samples) condition_values = sorted(set(str(s) for s in sample_conditions)) cond_levels_r = ", ".join('"' + c + '"' for c in condition_values) # Extract metadata batch_map = de_plan.get("batch_values", {}) genotype_map = de_plan.get("genotype_values", {}) subject_id_map = de_plan.get("subject_id_values", {}) dose_map = de_plan.get("dose_values", {}) tissue_map = de_plan.get("tissue_values", {}) sample_times = de_plan.get("sample_times", {}) time_levels = de_plan.get("time_levels", []) batch_used = bool(de_plan.get("batch_used") and batch_map) batch_vals = [str(batch_map.get(s, "unknown")) for s in matrix_samples] batch_vals_r = ", ".join('"' + v + '"' for v in batch_vals) batch_levels_r = ", ".join('"' + v + '"' for v in sorted(set(batch_vals))) batch_code = f"\nbatch_vals <- c({batch_vals_r})" if batch_used else "" batch_col = f",\n batch = factor(batch_vals, levels=c({batch_levels_r}))" if batch_used else "" batch_prefix = "batch + " if batch_used else "" r = f""" library(DESeq2) library(ggplot2) library(ggrepel) countData <- read.table("{count_matrix}", header=TRUE, row.names=1, sep="\\t", check.names=FALSE, quote="", comment.char="") featurecounts_meta_cols <- c("Chr", "Start", "End", "Strand", "Length") if (all(featurecounts_meta_cols %in% colnames(countData))) {{ countData <- countData[, setdiff(colnames(countData), featurecounts_meta_cols), drop=FALSE] colnames(countData) <- sub("\\\\.sorted$", "", tools::file_path_sans_ext(basename(colnames(countData)))) }} countData[] <- lapply(countData, function(x) as.numeric(as.character(x))) countData <- round(as.matrix(countData)) countData <- countData[, c({sample_names_r}), drop=FALSE] samples <- colnames(countData) condition_vals <- c({sample_cond_r}) group_vals <- c({sample_group_r}) {batch_code} """ # ── Module-specific colData setup ── if module == "M5": pw_dds = "dds_pw" pw_factor = "group" def _pure_cond(gv): parts = str(gv).rsplit("_", 1) if len(parts) == 2 and parts[1].replace(".","").replace("h","").replace("d","").replace("min","").replace("s","").isdigit(): return parts[0] return str(gv) pure_conds = [_pure_cond(groups.get(s, "")) for s in matrix_samples] pure_conds_r = ", ".join('"' + str(c) + '"' for c in pure_conds) cond_lvl = sorted(set(str(c) for c in pure_conds)) cond_lvl_r = ", ".join('"' + c + '"' for c in cond_lvl) pw_conds = [str(groups.get(s, "unknown")) for s in matrix_samples] pw_conds_r = ", ".join('"' + c + '"' for c in pw_conds) pw_lvl = sorted(set(str(c) for c in pw_conds)) pw_lvl_r = ", ".join('"' + c + '"' for c in pw_lvl) tvs = [str(sample_times.get(s, "")) for s in matrix_samples] tvs_r = ", ".join('"' + tv + '"' for tv in tvs) tls = sorted(set(tvs)) tls_r = ", ".join('"' + str(t) + '"' for t in tls) has_g = bool(genotype_map) if has_g: gvs = [str(genotype_map.get(s, "unknown")) for s in matrix_samples] gvs_r = ", ".join('"' + gv + '"' for gv in gvs) glvl_r = ", ".join('"' + g + '"' for g in sorted(set(gvs))) df = de_plan.get("design_formula") or f"~ {batch_prefix}genotype + condition * time" rf = de_plan.get("reduced_formula") or f"~ {batch_prefix}genotype + condition + time" gcol = ",\n genotype = factor(genotype_vals, c({glvl}))".format(glvl=glvl_r) gcode = "\ngenotype_vals <- c({gvs})".format(gvs=gvs_r) else: df = de_plan.get("design_formula") or f"~ {batch_prefix}condition * time" rf = de_plan.get("reduced_formula") or f"~ {batch_prefix}condition + time" gcol = "" gcode = "" r += f""" # M5: Time series LRT{gcode} condition_vals <- c({pure_conds_r}) time_vals <- c({tvs_r}) condition_vals_pw <- c({pw_conds_r}) coldata <- data.frame( condition = factor(condition_vals, levels=c({cond_lvl_r})), time = factor(time_vals, levels=c({tls_r})){gcol}{batch_col} ) rownames(coldata) <- samples dds <- DESeqDataSetFromMatrix(countData, coldata, design={df}) dds_lrt <- DESeq(dds, test="LRT", reduced={rf}) res_lrt <- na.omit(results(dds_lrt)) res_lrt_df <- as.data.frame(res_lrt) res_lrt_df$gene_id <- rownames(res_lrt_df) res_lrt_df <- res_lrt_df[, c("gene_id", setdiff(colnames(res_lrt_df), "gene_id"))] write.table(res_lrt_df, file="{output_dir}/time_lrt.DE_results.txt", sep="\t", quote=FALSE, row.names=FALSE) sig_lrt <- subset(res_lrt_df, padj < {padj_thresh}) write.table(sig_lrt, file="{output_dir}/time_lrt.DE_genes.txt", sep="\t", quote=FALSE, row.names=FALSE) saveRDS(dds_lrt, file="{output_dir}/dds_time_lrt.rds") coldata$group <- factor(condition_vals_pw, levels=c({pw_lvl_r})) dds_pw <- DESeqDataSetFromMatrix(countData, coldata, design=~ {batch_prefix}group) dds_pw <- DESeq(dds_pw) """ elif module == "M4": # M4 always emits both the directional M3 simple effects and an # unsigned interaction LRT. Missing factor metadata is a design error, # never a reason to silently run M1. pw_dds = "dds_group" pw_factor = "group" inter_factor = str(de_plan.get("advanced_factor") or "") factor_map = de_plan.get("factor_values", {}) control_condition = str(de_plan.get("effective_control_group") or "") control_condition = str(de_plan.get("control_condition") or control_condition) ordered_conditions = [control_condition] + sorted( value for value in set(sample_conditions) if value != control_condition ) ordered_conditions_r = ", ".join('"' + value + '"' for value in ordered_conditions) group_levels_r = ", ".join('"' + value + '"' for value in sorted(set(sample_groups))) reference_factor = str(de_plan.get("reference_factor") or "") if inter_factor == "genotype" and factor_map: gvs = [factor_map.get(s, "") for s in matrix_samples] gvs_r = ", ".join('"' + gv + '"' for gv in gvs) ordered_factors = [reference_factor] + sorted( value for value in set(gvs) if value != reference_factor ) glvl = ", ".join('"' + g + '"' for g in ordered_factors) r += f""" # M4: Interaction ~ genotype * condition genotype_vals <- c({gvs_r}) coldata <- data.frame( condition = factor(condition_vals, levels=c({ordered_conditions_r})), genotype = factor(genotype_vals, levels=c({glvl})), group = factor(group_vals, levels=c({group_levels_r})){batch_col} ) rownames(coldata) <- samples dds_group <- DESeqDataSetFromMatrix(countData, coldata, design=~ {batch_prefix}group) dds_group <- DESeq(dds_group) dds_wald <- DESeqDataSetFromMatrix(countData, coldata, design=~ {batch_prefix}genotype * condition) dds_wald <- DESeq(dds_wald, test="Wald") dds <- DESeqDataSetFromMatrix(countData, coldata, design=~ {batch_prefix}genotype * condition) dds <- DESeq(dds, test="LRT", reduced=~ {batch_prefix}genotype + condition) """ elif inter_factor == "tissue" and factor_map: tvs = [factor_map.get(s, "") for s in matrix_samples] tvs_r = ", ".join('"' + tv + '"' for tv in tvs) ordered_factors = [reference_factor] + sorted( value for value in set(tvs) if value != reference_factor ) tlvl = ", ".join('"' + t + '"' for t in ordered_factors) r += f""" # M4: Interaction ~ tissue * condition tissue_vals <- c({tvs_r}) coldata <- data.frame( condition = factor(condition_vals, levels=c({ordered_conditions_r})), tissue = factor(tissue_vals, levels=c({tlvl})), group = factor(group_vals, levels=c({group_levels_r})){batch_col} ) rownames(coldata) <- samples dds_group <- DESeqDataSetFromMatrix(countData, coldata, design=~ {batch_prefix}group) dds_group <- DESeq(dds_group) dds_wald <- DESeqDataSetFromMatrix(countData, coldata, design=~ {batch_prefix}tissue * condition) dds_wald <- DESeq(dds_wald, test="Wald") dds <- DESeqDataSetFromMatrix(countData, coldata, design=~ {batch_prefix}tissue * condition) dds <- DESeq(dds, test="LRT", reduced=~ {batch_prefix}tissue + condition) """ else: raise ValueError("M4 requires complete genotype or tissue metadata") r += f""" # M4 unsigned interaction result: GO/KEGG eligible, FGSEA ineligible. res_interaction <- na.omit(results(dds)) res_interaction_df <- as.data.frame(res_interaction) res_interaction_df$gene_id <- rownames(res_interaction_df) res_interaction_df <- res_interaction_df[, c("gene_id", setdiff(colnames(res_interaction_df), "gene_id"))] write.table(res_interaction_df, file="{output_dir}/{inter_factor}_by_condition.interaction_LRT.DE_results.txt", sep="\t", quote=FALSE, row.names=FALSE) sig_interaction <- subset(res_interaction_df, padj < {padj_thresh}) write.table(sig_interaction, file="{output_dir}/{inter_factor}_by_condition.interaction_LRT.DE_genes.txt", sep="\t", quote=FALSE, row.names=FALSE) # Directional interaction coefficients from the same factorial design. interaction_names <- resultsNames(dds_wald) interaction_names <- interaction_names[ grepl("{inter_factor}", interaction_names, fixed=TRUE) & grepl("condition", interaction_names, fixed=TRUE) ] if (length(interaction_names) == 0) {{ stop("M4 factorial model did not expose an interaction coefficient") }} for (coef_name in interaction_names) {{ safe_coef <- gsub("[^A-Za-z0-9_.-]+", "_", coef_name) res_wald_interaction <- na.omit(results(dds_wald, name=coef_name)) res_wald_df <- as.data.frame(res_wald_interaction) res_wald_df$gene_id <- rownames(res_wald_df) res_wald_df <- res_wald_df[, c("gene_id", setdiff(colnames(res_wald_df), "gene_id"))] res_wald_df$regulation <- ifelse( res_wald_df$padj < {padj_thresh} & res_wald_df$log2FoldChange > {log2fc_thresh}, "up", ifelse(res_wald_df$padj < {padj_thresh} & res_wald_df$log2FoldChange < -{log2fc_thresh}, "down", "not") ) output_base <- paste0("{inter_factor}_by_condition.interaction_Wald.", safe_coef) write.table(res_wald_df, file=file.path("{output_dir}", paste0(output_base, ".DE_results.txt")), sep="\t", quote=FALSE, row.names=FALSE) sig_wald <- subset(res_wald_df, padj < {padj_thresh} & abs(log2FoldChange) > {log2fc_thresh}) write.table(sig_wald, file=file.path("{output_dir}", paste0(output_base, ".DE_genes.txt")), sep="\t", quote=FALSE, row.names=FALSE) }} saveRDS(dds_wald, file="{output_dir}/dds_interaction_wald.rds") """ elif module == "M3": pw_factor = "group" group_levels_r = ", ".join('"' + value + '"' for value in sorted(set(sample_groups))) r += f""" # M3: factor-stratified group comparison coldata <- data.frame(group = factor(group_vals, levels=c({group_levels_r})){batch_col}) rownames(coldata) <- samples dds <- DESeqDataSetFromMatrix(countData, coldata, design=~ {batch_prefix}group) dds <- DESeq(dds) """ elif module == "M2": has_subject = bool(subject_id_map) if has_subject: sids = [subject_id_map.get(s, f"subject_{i}") for i, s in enumerate(matrix_samples)] sids_r = ", ".join('"' + sid + '"' for sid in sids) sids_lvl = ", ".join('"' + sid + '"' for sid in sorted(set(sids))) r += f""" # M2: Paired analysis ~ subject_id + condition subject_vals <- c({sids_r}) coldata <- data.frame( condition = factor(condition_vals), subject_id = factor(subject_vals, levels=c({sids_lvl})){batch_col} ) rownames(coldata) <- samples dds <- DESeqDataSetFromMatrix(countData, coldata, design=~ {batch_prefix}subject_id + condition) dds <- DESeq(dds) """ else: r += f""" # M2: Paired analysis (no subject_id metadata - falling back to ~condition) coldata <- data.frame(condition = factor(condition_vals){batch_col}) rownames(coldata) <- samples dds <- DESeqDataSetFromMatrix(countData, coldata, design=~ {batch_prefix}condition) dds <- DESeq(dds) print("[WARNING] M2 mode but no subject_id metadata found. Using ~condition instead.") """ elif module == "M6": has_dose = bool(dose_map) if has_dose: dvs = [float(dose_map.get(s, "0")) for s in matrix_samples] dvs_r = ", ".join(str(dv) for dv in dvs) r += f""" # M6: Dose-response ~ dose (continuous) dose_vals <- c({dvs_r}) coldata <- data.frame( condition = factor(condition_vals), dose = as.numeric(dose_vals){batch_col} ) rownames(coldata) <- samples dds <- DESeqDataSetFromMatrix(countData, coldata, design=~ {batch_prefix}dose) dds <- DESeq(dds) res <- results(dds, name="dose") res <- na.omit(res) res_df <- as.data.frame(res) res_df$gene_id <- rownames(res_df) res_df <- res_df[, c("gene_id", setdiff(colnames(res_df), "gene_id"))] res_df$regulation <- ifelse(res_df$padj < {padj_thresh} & res_df$log2FoldChange > 0, "up", ifelse(res_df$padj < {padj_thresh} & res_df$log2FoldChange < 0, "down", "not")) write.table(res_df, file="{output_dir}/dose_response.DE_results.txt", sep="\\t", quote=FALSE, row.names=FALSE) sig <- subset(res_df, padj < {padj_thresh}) write.table(sig, file="{output_dir}/dose_response.DE_genes.txt", sep="\\t", quote=FALSE, row.names=FALSE) """ else: r += f""" # M6: Dose-response (no dose metadata - falling back to ~condition) coldata <- data.frame(condition = factor(condition_vals){batch_col}) rownames(coldata) <- samples dds <- DESeqDataSetFromMatrix(countData, coldata, design=~ {batch_prefix}condition) dds <- DESeq(dds) print("[WARNING] M6 mode but no dose metadata found. Using ~condition instead.") """ else: # M1 or unknown: standard ~ condition r += f""" # M1: Standard comparison coldata <- data.frame(condition = factor(condition_vals){batch_col}) rownames(coldata) <- samples dds <- DESeqDataSetFromMatrix(countData, coldata, design=~ {batch_prefix}condition) dds <- DESeq(dds) """ # Time-series LRT is an execution feature, not an M5-only module. # Keep M1 pairwise Wald results and append the overall categorical # time test whenever the planner explicitly enables it. if de_plan.get("run_lrt") and module != "M5": time_values = [str(sample_times.get(sample, "")).strip() for sample in matrix_samples] if any(not value for value in time_values): missing_time = [ sample for sample, value in zip(matrix_samples, time_values) if not value ] raise ValueError( "run_lrt=True but time metadata is missing for samples: " + ", ".join(missing_time[:10]) ) planned_levels = [str(value) for value in de_plan.get("time_levels", [])] if set(time_values) != set(planned_levels): planned_levels = sorted(set(time_values), key=parse_time_sort_value) if len(planned_levels) < 3: raise ValueError("run_lrt=True requires at least 3 distinct time levels") time_values_r = ", ".join('"' + value + '"' for value in time_values) time_levels_r = ", ".join('"' + value + '"' for value in planned_levels) lrt_full = f"~ {batch_prefix}time_factor" lrt_reduced = "~ batch" if batch_used else "~ 1" r += f""" # M1 optional categorical time LRT time_factor <- factor(c({time_values_r}), levels=c({time_levels_r})) coldata_lrt <- data.frame(time_factor=time_factor{batch_col}) rownames(coldata_lrt) <- samples dds_lrt <- DESeqDataSetFromMatrix(countData, coldata_lrt, design={lrt_full}) dds_lrt <- DESeq(dds_lrt, test="LRT", reduced={lrt_reduced}) res_lrt <- na.omit(results(dds_lrt)) res_lrt_df <- as.data.frame(res_lrt) res_lrt_df$gene_id <- rownames(res_lrt_df) res_lrt_df <- res_lrt_df[, c("gene_id", setdiff(colnames(res_lrt_df), "gene_id"))] write.table(res_lrt_df, file="{output_dir}/time_lrt.DE_results.txt", sep="\\t", quote=FALSE, row.names=FALSE) sig_lrt <- subset(res_lrt_df, padj < {padj_thresh}) write.table(sig_lrt, file="{output_dir}/time_lrt.DE_genes.txt", sep="\\t", quote=FALSE, row.names=FALSE) saveRDS(dds_lrt, file="{output_dir}/dds_time_lrt.rds") """ # ── Pairwise comparisons (all modules except M6) ── if module != "M6": for comp in comparisons: if len(comp) == 2: g1, g2 = comp cn = f"{g1}_vs_{g2}" # Escape internal quotes g1q = str(g1).replace('"', '\"') g2q = str(g2).replace('"', '\"') r += f""" # {g1q} vs {g2q} res <- results({pw_dds}, contrast=c("{pw_factor}", "{g1q}", "{g2q}")) res <- na.omit(res) res_df <- as.data.frame(res) res_df$gene_id <- rownames(res_df) res_df <- res_df[, c("gene_id", setdiff(colnames(res_df), "gene_id"))] res_df$regulation <- ifelse(res_df$padj < {padj_thresh} & res_df$log2FoldChange > {log2fc_thresh}, "up", ifelse(res_df$padj < {padj_thresh} & res_df$log2FoldChange < -{log2fc_thresh}, "down", "not")) write.table(res_df, file="{output_dir}/{cn}.DE_results.txt", sep="\\t", quote=FALSE, row.names=FALSE) sig <- subset(res_df, padj < {padj_thresh} & abs(log2FoldChange) > {log2fc_thresh}) write.table(sig, file="{output_dir}/{cn}.DE_genes.txt", sep="\\t", quote=FALSE, row.names=FALSE) pdf("{output_dir}/{cn}_MA_plot.pdf") plotMA(res, main="{g1q} vs {g2q} MA Plot") dev.off() png("{output_dir}/{cn}_MA_plot.png", width=1000, height=800, res=150) plotMA(res, main="{g1q} vs {g2q} MA Plot") dev.off() res_df$significant <- res_df$regulation res_df$log10padj <- -log10(res_df$padj) res_df$log10padj[is.infinite(res_df$log10padj)] <- max( res_df$log10padj[is.finite(res_df$log10padj)], na.rm=TRUE) pdf("{output_dir}/{cn}_volcano_plot.pdf") ggplot(res_df, aes(x=log2FoldChange, y=log10padj, color=significant)) + geom_point(alpha=0.5) + scale_color_manual(values=c(not="grey", up="red", down="blue")) + ggtitle("{g1q} vs {g2q} Volcano Plot") + theme_minimal() dev.off() png("{output_dir}/{cn}_volcano_plot.png", width=1000, height=800, res=150) ggplot(res_df, aes(x=log2FoldChange, y=log10padj, color=significant)) + geom_point(alpha=0.5) + scale_color_manual(values=c(not="grey", up="red", down="blue")) + ggtitle("{g1q} vs {g2q} Volcano Plot") + theme_minimal() dev.off() """ r += f""" saveRDS(dds, file="{output_dir}/dds.rds") cat("DESeq2 modular analysis complete.\n") sessionInfo() """ return r def run_deseq2( count_matrix: Path, config: Path, output_dir: Path, padj_thresh: float = 0.05, log2fc_thresh: float = 1.0, user_module: str = "", ) -> Tuple[Dict[str, bool], Dict[str, Any]]: """运行DESeq2差异分析(通过R脚本)""" results = {} # 读取配置获取比较组 with open(config, 'r') as f: cfg = yaml.safe_load(f) or {} # 支持两种格式: groups (新格式) 或 samples (旧格式) groups = load_groups_from_config(cfg) # 按 count matrix 的列顺序对齐分组,避免 YAML 顺序与矩阵列顺序不一致。 with open(count_matrix, 'r') as f: matrix_header = f.readline().strip().split('\t') matrix_samples = matrix_header[1:] de_plan: Dict[str, Any] = { "strategy": "auto", "comparisons": [], "replicate_counts": {}, "replicate_policy": {}, "replicate_violations": [], "active_samples": [], "ignored_samples": [], "sample_times": {}, "group_times": {}, "time_levels": [], "run_lrt": False, "run_spline_lrt": False, "spline_df": 3, } try: de_plan = build_de_plan(cfg, matrix_samples, groups, user_module=user_module) except ValueError as exc: print(f"DESeq2 configuration error: {exc}", file=sys.stderr) for comp in cfg.get("compare", []): if len(comp) == 2: results[f"{comp[0]}_vs_{comp[1]}"] = False return results, de_plan groups = de_plan.get("groups") or groups active_samples = list(de_plan.get("active_samples") or matrix_samples) missing_groups = [s for s in active_samples if s not in groups] if missing_groups: print( "DESeq2 configuration is missing group labels for active samples: " + ", ".join(missing_groups), file=sys.stderr, ) for comp in cfg.get("compare", []): if len(comp) == 2: results[f"{comp[0]}_vs_{comp[1]}"] = False return results, de_plan matrix_samples = active_samples comparisons = de_plan["comparisons"] result_names = [comparison_name(comp[0], comp[1]) for comp in comparisons] if de_plan.get("module") == "M4": result_names.append(f"{de_plan.get('advanced_factor')}_by_condition.interaction_LRT") if de_plan.get("module") == "M6": result_names.append("dose_response") if de_plan.get("run_lrt"): result_names.append("time_lrt") if de_plan.get("run_spline_lrt"): result_names.append("time_spline_lrt") if not result_names: print("DESeq2 configuration has no pairwise comparisons or LRT analyses enabled.", file=sys.stderr) return {}, de_plan all_zero_counts = True total_genes = 0 with open(count_matrix, 'r') as f: next(f, None) for line in f: if not line.strip(): continue total_genes += 1 values = line.rstrip("\n").split('\t')[1:] for value in values: try: if float(value) != 0: all_zero_counts = False break except ValueError: all_zero_counts = False break if not all_zero_counts: break if all_zero_counts: no_signal_message = ( f"Count matrix has {total_genes} genes but all sample counts are 0. " "DESeq2 cannot estimate size factors or dispersion; empty DE result files were written." ) print(f"Warning: {no_signal_message}", file=sys.stderr) (output_dir / "NO_EXPRESSION_SIGNAL.txt").write_text(no_signal_message + "\n") empty_header = "gene_id\tbaseMean\tlog2FoldChange\tlfcSE\tstat\tpvalue\tpadj\tregulation\n" for comp_name in result_names: (output_dir / f"{comp_name}.DE_results.txt").write_text(empty_header) (output_dir / f"{comp_name}.DE_genes.txt").write_text(empty_header) results[comp_name] = True return results, de_plan # 生成R脚本 r_script = output_dir / "run_deseq2.R" # 构建样本条件映射(用于R代码) sample_conditions = [] for s in matrix_samples: cond = groups.get(s, "unknown") sample_conditions.append(cond) sample_time_values = [de_plan["sample_times"].get(s) for s in matrix_samples] time_factor_levels = de_plan.get("time_levels", []) group_times = de_plan.get("group_times", {}) time_factor_values = [groups[s] for s in matrix_samples] numeric_time_values = [ parse_time_sort_value(group_times.get(groups[s]))[1] if groups[s] in group_times else 0 for s in matrix_samples ] # v2: use modular R script when module is detected module = de_plan.get("module", "") use_modular = bool(module in {"M1","M2","M3","M4","M5","M6"}) if use_modular: print(f"[INFO] Using modular R script for module {module}: {de_plan.get('design_formula', '')}") r_code = generate_modular_r_script( count_matrix, matrix_samples, de_plan, output_dir, padj_thresh, log2fc_thresh ) else: r_code = f""" library(DESeq2) library(ggplot2) library(ggrepel) library(splines) # 读取计数矩阵 countData <- read.table({r_string(count_matrix)}, header=TRUE, row.names=1, sep="\\t", check.names=FALSE, quote="", comment.char="") featurecounts_meta_cols <- c("Chr", "Start", "End", "Strand", "Length") if (all(featurecounts_meta_cols %in% colnames(countData))) {{ countData <- countData[, setdiff(colnames(countData), featurecounts_meta_cols), drop=FALSE] colnames(countData) <- sub("\\\\.sorted$", "", tools::file_path_sans_ext(basename(colnames(countData)))) }} countData[] <- lapply(countData, function(x) as.numeric(as.character(x))) if (anyNA(countData)) {{ bad_cols <- colnames(countData)[vapply(countData, function(x) any(is.na(x)), logical(1))] stop(paste("Count matrix contains non-numeric columns:", paste(bad_cols, collapse=", "))) }} countData <- round(as.matrix(countData)) active_samples <- {r_vector(matrix_samples)} missing_active_samples <- setdiff(active_samples, colnames(countData)) if (length(missing_active_samples) > 0) {{ stop(paste("Count matrix is missing active samples:", paste(missing_active_samples, collapse=", "))) }} countData <- countData[, active_samples, drop=FALSE] # 读取分组信息 samples <- colnames(countData) conditions <- {r_vector(sample_conditions)} coldata <- data.frame(condition=factor(conditions)) row.names(coldata) <- samples # 创建DESeq2对象 dds <- DESeqDataSetFromMatrix(countData, coldata, design=~condition) # 运行DESeq2 dds <- DESeq(dds) # 获取比较结果 """ for comp in comparisons: if len(comp) == 2: g1, g2 = comp comp_name = comparison_name(g1, g2) r_code += f""" # {g1} vs {g2} res <- results(dds, contrast=c("condition", {r_string(g1)}, {r_string(g2)})) res <- na.omit(res) res_df <- as.data.frame(res) res_df$gene_id <- rownames(res_df) res_df <- res_df[, c("gene_id", setdiff(colnames(res_df), "gene_id"))] res_df$regulation <- ifelse(res_df$padj < {padj_thresh} & res_df$log2FoldChange > {log2fc_thresh}, "up", ifelse(res_df$padj < {padj_thresh} & res_df$log2FoldChange < -{log2fc_thresh}, "down", "not")) # 保存完整结果 write.table(res_df, file={r_string(output_dir / f"{comp_name}.DE_results.txt")}, sep="\\t", quote=FALSE, row.names=FALSE) # 筛选显著基因 sig_up <- subset(res_df, padj < {padj_thresh} & log2FoldChange > {log2fc_thresh}) sig_down <- subset(res_df, padj < {padj_thresh} & log2FoldChange < -{log2fc_thresh}) # 保存显著基因 sig_genes <- rbind(sig_up, sig_down) write.table(sig_genes, file={r_string(output_dir / f"{comp_name}.DE_genes.txt")}, sep="\\t", quote=FALSE, row.names=FALSE) # MA plot (PDF) pdf({r_string(output_dir / f"{comp_name}_MA_plot.pdf")}) plotMA(res, main="{g1} vs {g2} MA Plot") dev.off() # MA plot (PNG) png({r_string(output_dir / f"{comp_name}_MA_plot.png")}, width=1000, height=800, res=150) plotMA(res, main="{g1} vs {g2} MA Plot") dev.off() # Volcano plot res_df$significant <- res_df$regulation # Volcano plot (PDF) pdf({r_string(output_dir / f"{comp_name}_volcano_plot.pdf")}) ggplot(res_df, aes(x=log2FoldChange, y=-log10(padj), color=significant)) + geom_point(alpha=0.5) + scale_color_manual(values=c("not"="gray", "up"="red", "down"="blue")) + ggtitle("{g1} vs {g2} Volcano Plot") + theme_minimal() dev.off() # Volcano plot (PNG) png({r_string(output_dir / f"{comp_name}_volcano_plot.png")}, width=1000, height=800, res=150) ggplot(res_df, aes(x=log2FoldChange, y=-log10(padj), color=significant)) + geom_point(alpha=0.5) + scale_color_manual(values=c("not"="gray", "up"="red", "down"="blue")) + ggtitle("{g1} vs {g2} Volcano Plot") + theme_minimal() dev.off() """ if de_plan.get("run_lrt"): r_code += f""" # Time-series categorical LRT time_factor <- factor({r_vector(time_factor_values)}, levels={r_vector(time_factor_levels)}) coldata_lrt <- data.frame(time_factor=time_factor) row.names(coldata_lrt) <- samples dds_lrt <- DESeqDataSetFromMatrix(countData, coldata_lrt, design=~time_factor) dds_lrt <- DESeq(dds_lrt, test="LRT", reduced=~1) res_lrt <- results(dds_lrt) res_lrt <- na.omit(res_lrt) res_lrt_df <- as.data.frame(res_lrt) res_lrt_df$gene_id <- rownames(res_lrt_df) res_lrt_df <- res_lrt_df[, c("gene_id", setdiff(colnames(res_lrt_df), "gene_id"))] write.table(res_lrt_df, file={r_string(output_dir / "time_lrt.DE_results.txt")}, sep="\\t", quote=FALSE, row.names=FALSE) sig_lrt <- subset(res_lrt_df, padj < {padj_thresh}) write.table(sig_lrt, file={r_string(output_dir / "time_lrt.DE_genes.txt")}, sep="\\t", quote=FALSE, row.names=FALSE) saveRDS(dds_lrt, file={r_string(output_dir / "dds_time_lrt.rds")}) """ if de_plan.get("run_spline_lrt"): r_code += f""" # Time-series spline LRT time_numeric <- c({', '.join(str(value) for value in numeric_time_values)}) coldata_spline <- data.frame(time_numeric=time_numeric) row.names(coldata_spline) <- samples dds_spline <- DESeqDataSetFromMatrix(countData, coldata_spline, design=~ns(time_numeric, df={de_plan.get("spline_df", 3)})) dds_spline <- DESeq(dds_spline, test="LRT", reduced=~1) res_spline <- results(dds_spline) res_spline <- na.omit(res_spline) res_spline_df <- as.data.frame(res_spline) res_spline_df$gene_id <- rownames(res_spline_df) res_spline_df <- res_spline_df[, c("gene_id", setdiff(colnames(res_spline_df), "gene_id"))] write.table(res_spline_df, file={r_string(output_dir / "time_spline_lrt.DE_results.txt")}, sep="\\t", quote=FALSE, row.names=FALSE) sig_spline <- subset(res_spline_df, padj < {padj_thresh}) write.table(sig_spline, file={r_string(output_dir / "time_spline_lrt.DE_genes.txt")}, sep="\\t", quote=FALSE, row.names=FALSE) saveRDS(dds_spline, file={r_string(output_dir / "dds_time_spline_lrt.rds")}) """ r_code += f'\n# Save RDS\nsaveRDS(dds, file={r_string(output_dir / "dds.rds")})\n' with open(r_script, 'w') as f: f.write(r_code) def validate_planned_outputs() -> Dict[str, bool]: status: Dict[str, bool] = {} for comp_name in result_names: expected = [ output_dir / f"{comp_name}.DE_results.txt", output_dir / f"{comp_name}.DE_genes.txt", ] status[comp_name] = all(path.exists() and path.is_file() for path in expected) return status # Batch is optional. When an opted-in batch model fails, retry exactly # once with the historical no-batch model and keep an explicit warning. try: result = subprocess.run( [f"{RNASEQ_BIN}/Rscript", str(r_script)], check=True, capture_output=True, text=True, timeout=3600 # 1小时超时 ) results.update(validate_planned_outputs()) missing_outputs = [name for name, ok in results.items() if not ok] if missing_outputs: print( "DESeq2 completed but planned result files are missing: " + ", ".join(missing_outputs), file=sys.stderr, ) except subprocess.CalledProcessError as e: if de_plan.get("batch_used") and use_modular: print( "[BATCH WARNING] 批次校正模型执行失败,正在自动改用无批次模型重试。", file=sys.stderr, ) for pattern in ( "*.DE_results.txt", "*.DE_genes.txt", "*_MA_plot.*", "*_volcano_plot.*", "dds*.rds", ): for partial in output_dir.glob(pattern): partial.unlink(missing_ok=True) def remove_batch_term(formula): return str(formula or "").replace("batch + ", "") de_plan["batch_used"] = False de_plan["batch_fallback"] = True de_plan["batch_fallback_reason"] = "批次校正模型执行失败,已自动改用无批次模型完成分析。" de_plan["batch_error"] = (e.stderr or e.stdout or "batch model failed")[-2000:] de_plan["design_formula"] = remove_batch_term(de_plan.get("design_formula")) de_plan["interaction_full"] = remove_batch_term(de_plan.get("interaction_full")) de_plan["interaction_reduced"] = remove_batch_term(de_plan.get("interaction_reduced")) de_plan.setdefault("design_warnings", []).append({ "code": "batch_runtime_fallback", "message": de_plan["batch_fallback_reason"], }) (output_dir / "BATCH_FALLBACK.txt").write_text( de_plan["batch_fallback_reason"] + "\n", encoding="utf-8", ) r_script.write_text( generate_modular_r_script( count_matrix, matrix_samples, de_plan, output_dir, padj_thresh, log2fc_thresh, ), encoding="utf-8", ) try: subprocess.run( [f"{RNASEQ_BIN}/Rscript", str(r_script)], check=True, capture_output=True, text=True, timeout=3600, ) print("[BATCH WARNING] 已使用无批次模型完成差异分析。", file=sys.stderr) results.update(validate_planned_outputs()) missing_outputs = [name for name, ok in results.items() if not ok] if missing_outputs: print( "DESeq2 batch fallback completed but planned result files are missing: " + ", ".join(missing_outputs), file=sys.stderr, ) except subprocess.CalledProcessError as retry_error: print(f"DESeq2 failed after batch fallback: {retry_error.stderr}", file=sys.stderr) for comp_name in result_names: results[comp_name] = False else: print(f"DESeq2 failed: {e.stderr}", file=sys.stderr) for comp_name in result_names: results[comp_name] = False return results, de_plan def generate_report( output_dir: Path, comparison_stats: Dict[str, Dict[str, Any]], versions: Dict[str, str], start_time: datetime, end_time: datetime, prefix: str = "", de_plan: Optional[Dict[str, Any]] = None ) -> Path: """生成差异表达报告""" safe_prefix = prefix if prefix else "rnaseq" report_file = output_dir / f"{safe_prefix}.rnaseq_de_report.md" elapsed = (end_time - start_time).total_seconds() content = f"""# RNA-Seq Differential Expression Report ## Summary - **Analysis Date**: {end_time.strftime("%Y-%m-%d %H:%M:%S")} - **Total Comparisons**: {len(comparison_stats)} - **Total Runtime**: {elapsed:.1f} seconds ## Tool Versions """ for tool, version in versions.items(): content += f"- **{tool}**: {version}\n" if de_plan: content += "\n## DE Design\n\n" content += f"- **Strategy**: {de_plan.get('strategy', 'manual')}\n" content += f"- **Replicate counts**: {de_plan.get('replicate_counts', {})}\n" content += f"- **Pairwise comparisons**: {de_plan.get('comparisons', [])}\n" if de_plan.get("batch_requested"): batch_state = "used" if de_plan.get("batch_used") else "fallback to no-batch" content += f"- **Batch adjustment**: {batch_state}\n" if de_plan.get("batch_fallback_reason"): content += f"- **Batch note**: {de_plan.get('batch_fallback_reason')}\n" content += f"- **Categorical time LRT**: {de_plan.get('run_lrt', False)}\n" content += f"- **Spline time LRT**: {de_plan.get('run_spline_lrt', False)}\n" content += "\n## Comparison Results\n\n" content += "| Comparison | Test | Up-regulated | Down-regulated | Total DEGs |\n" content += "|------------|------|--------------|----------------|------------|\n" for comp, stats in comparison_stats.items(): content += f"| {comp} | {stats.get('test', 'Wald')} | {stats.get('up', 0)} | {stats.get('down', 0)} | {stats.get('total', 0)} |\n" notes = [stats.get("note") for stats in comparison_stats.values() if stats.get("note")] if notes: content += "\n## Notes\n\n" for note in sorted(set(notes)): content += f"- {note}\n" with open(report_file, 'w') as f: f.write(content) return report_file def generate_manifest( output_dir: Path, comparison_stats: Dict[str, Dict[str, Any]], versions: Dict[str, str], start_time: datetime, end_time: datetime, cmd: List[str], input_dir: Path, workspace_dir: Path, job_id: str, run_id: str, prefix: str, family_stats: Optional[Dict[str, Any]] = None, family_name: Optional[str] = None, de_plan: Optional[Dict[str, Any]] = None, sample_config: Optional[Path] = None, count_matrix: Optional[Path] = None, family_members_file: Optional[Path] = None, foreground_mode: str = "global", cpu: int = 4, padj_threshold: float = 0.05, log2fc_threshold: float = 1.0, ) -> Path: """生成manifest文件""" safe_prefix = prefix if prefix else "rnaseq" manifest_file = output_dir / f"{safe_prefix}.rnaseq_de.manifest.json" job_workspace = workspace_dir.parent if workspace_dir.name == "rnaseq_de" else workspace_dir total_de = sum(s.get('total', 0) for s in comparison_stats.values()) total_up = sum(s.get('up', 0) for s in comparison_stats.values()) total_down = sum(s.get('down', 0) for s in comparison_stats.values()) manifest = { "manifest_version": "1.0", "stage": "rnaseq_de", "status": "success", "job_id": job_id, "run_id": run_id, "timestamp": end_time.isoformat(), "start_time": start_time.isoformat(), "end_time": end_time.isoformat(), "elapsed_sec": (end_time - start_time).total_seconds(), "cmd": cmd, "workspace_root": abs_text(job_workspace.parent), "workspace": abs_text(job_workspace), "input_dir": abs_text(job_workspace.parent.parent / "input" / job_id), "metrics": { "total_comparisons": len(comparison_stats), "de_genes_up": total_up, "de_genes_down": total_down, "de_genes_total": total_de, "comparisons": comparison_stats, "family_analysis": family_stats if family_stats else None, "replicate_counts": de_plan.get("replicate_counts", {}) if de_plan else {}, "replicate_violations": de_plan.get("replicate_violations", []) if de_plan else [], "time_levels": de_plan.get("time_levels", []) if de_plan else [], "effective_control_group": de_plan.get("effective_control_group", "") if de_plan else "", "comparison_mode": de_plan.get("comparison_mode", "") if de_plan else "", "planned_comparisons": de_plan.get("comparisons", []) if de_plan else [], "de_module": de_plan.get("module", "") if de_plan else "", "advanced_factor": de_plan.get("advanced_factor", "") if de_plan else "", "fallback_from": de_plan.get("fallback_from", "") if de_plan else "", "fallback_reason": de_plan.get("fallback_reason", "") if de_plan else "", "skipped_comparisons": de_plan.get("skipped_comparisons", []) if de_plan else [], "comparison_axis": de_plan.get("comparison_axis", "") if de_plan else "", "comparison_axis_source": de_plan.get("comparison_axis_source", "") if de_plan else "", "stratify_axis": de_plan.get("stratify_axis", "") if de_plan else "", "reference_condition": de_plan.get("reference_condition", "") if de_plan else "", "reference_factor": de_plan.get("reference_factor", "") if de_plan else "", "condition_reference_source": de_plan.get("condition_reference_source", "") if de_plan else "", "factor_reference_source": de_plan.get("factor_reference_source", "") if de_plan else "", "design_warnings": de_plan.get("design_warnings", []) if de_plan else [], "batch_requested": bool(de_plan.get("batch_requested")) if de_plan else False, "batch_adjustment": bool(de_plan.get("batch_used")) if de_plan else False, "batch_levels": list(de_plan.get("batch_levels") or []) if de_plan else [], "batch_fallback": bool(de_plan.get("batch_fallback")) if de_plan else False, "batch_fallback_reason": de_plan.get("batch_fallback_reason", "") if de_plan else "", "batch_policy": ( "used" if de_plan and de_plan.get("batch_used") else "fallback_without_batch" if de_plan and de_plan.get("batch_requested") else "not_requested" ), }, "parameters": { "cpu": cpu, "prefix": prefix, "count_matrix": abs_text(count_matrix), "sample_config": abs_text(sample_config), "analysis_dir": abs_text(workspace_dir / "analysis"), "final_dir": abs_text(output_dir), "padj_threshold": padj_threshold, "log2fc_threshold": log2fc_threshold, "family_analysis": family_name is not None, "family_name": family_name, "family_members_file": abs_text(family_members_file) if family_members_file else "", "de_plan": de_plan if de_plan else {} }, "artifacts": {}, "tools": versions } sample_table = output_dir.parent / "rnaseq_sample_qc" / "sample_table.tsv" context = build_rnaseq_context( prefix=prefix, sample_config=sample_config, count_matrix=count_matrix, sample_table=sample_table if sample_table.exists() else None, foreground_mode=foreground_mode, family_members_file=family_members_file, family_label=family_name or "", stage="rnaseq_de", ) if de_plan and de_plan.get("replicate_counts"): normalized_groups = list(de_plan["replicate_counts"].keys()) normalized_signature = "_".join(re.sub(r"[^A-Za-z0-9_.-]+", "_", str(g)).strip("_") for g in normalized_groups[:8]) or "nogroups" context["rnaseq_condition_count"] = len(normalized_groups) context["rnaseq_condition_signature"] = normalized_signature if de_plan and de_plan.get("effective_control_group"): context["rnaseq_control_condition"] = de_plan["effective_control_group"] expression_manifest = output_dir / f"{safe_prefix}.expression.manifest.json" manifest["time_series"] = bool((de_plan or {}).get("run_lrt") or (de_plan or {}).get("run_spline_lrt")) manifest["triggered_expression"] = expression_manifest.exists() if expression_manifest.exists(): manifest["triggered_expression_manifest"] = f"final/{expression_manifest.name}" apply_rnaseq_context(manifest, context) # 添加artifacts(参考rnaseq_qc契约,包含完整字段) def add_artifact(name: str, filepath: Path, artifact_type: str, **extra): """添加 artifact 到 manifest""" if filepath.exists(): manifest["artifacts"][name] = { "artifact": name, "path": f"final/{filepath.name}", "full_path": str(filepath.absolute()), "type": artifact_type, "size": filepath.stat().st_size, "sha256": compute_file_hash(filepath), **extra } for comp in comparison_stats.keys(): # DE 结果文件 de_results = output_dir / f"{safe_prefix}.rnaseq_de.{comp}.DE_results.txt" add_artifact(f"de_results_{comp}", de_results, "tsv", format="tsv") # 显著基因文件 de_genes = output_dir / f"{safe_prefix}.rnaseq_de.{comp}.DE_genes.txt" add_artifact(f"de_genes_{comp}", de_genes, "tsv", format="tsv") # MA plot (PDF + PNG) ma_pdf = output_dir / f"{safe_prefix}.rnaseq_de.{comp}_MA_plot.pdf" ma_png = output_dir / f"{safe_prefix}.rnaseq_de.{comp}_MA_plot.png" add_artifact(f"ma_plot_pdf_{comp}", ma_pdf, "pdf", format="pdf") add_artifact(f"ma_plot_png_{comp}", ma_png, "image", format="png") # Volcano plot (PDF + PNG) volcano_pdf = output_dir / f"{safe_prefix}.rnaseq_de.{comp}_volcano_plot.pdf" volcano_png = output_dir / f"{safe_prefix}.rnaseq_de.{comp}_volcano_plot.png" add_artifact(f"volcano_plot_pdf_{comp}", volcano_pdf, "pdf", format="pdf") add_artifact(f"volcano_plot_png_{comp}", volcano_png, "image", format="png") # 报告 report_name = f"{prefix}.rnaseq_de_report.md" if prefix else "rnaseq_de_report.md" report_file = output_dir / report_name add_artifact("report", report_file, "markdown", format="md") no_signal_file = output_dir / f"{safe_prefix}.rnaseq_de.NO_EXPRESSION_SIGNAL.txt" add_artifact("no_expression_signal", no_signal_file, "text", format="txt") # 添加家族分析结果文件(如果存在) if family_name: for comp in comparison_stats.keys(): family_results = output_dir / f"{safe_prefix}.rnaseq_de.{family_name}_{comp}.family_DE_results.txt" family_de_genes = output_dir / f"{safe_prefix}.rnaseq_de.{family_name}_{comp}.family_DE_genes.txt" add_artifact(f"family_results_{comp}", family_results, "tsv", format="tsv") add_artifact(f"family_de_genes_{comp}", family_de_genes, "tsv", format="tsv") # manifest 自身 manifest["artifacts"]["manifest"] = { "artifact": "manifest", "path": f"final/{manifest_file.name}", "full_path": str(manifest_file.absolute()), "type": "json", "format": "json" } with open(manifest_file, 'w') as f: json.dump(manifest, f, indent=2) return manifest_file def parse_de_results(de_file: Path) -> Dict[str, Any]: """解析差异表达结果文件""" result_name = de_file.stem.replace(".DE_genes", "") is_lrt = result_name in {"time_lrt", "time_spline_lrt"} or ".interaction_LRT" in result_name stats: Dict[str, Any] = {"up": 0, "down": 0, "total": 0, "test": "LRT" if is_lrt else "Wald"} if not de_file.exists(): return stats with open(de_file, 'r') as f: lines = f.readlines() # 跳过header for line in lines[1:]: if not line.strip(): continue stats["total"] += 1 if is_lrt: continue parts = line.strip().split('\t') if len(parts) >= 3: try: log2fc = float(parts[2]) if log2fc > 0: stats["up"] += 1 else: stats["down"] += 1 except (ValueError, IndexError): pass return stats def cleanup_previous_outputs(de_dir: Path, final_dir: Path, safe_prefix: str) -> None: """Remove stale rnaseq_de outputs before a rerun so manifests reflect this run only.""" for pattern in ( "*.DE_results.txt", "*.DE_genes.txt", "*_MA_plot.pdf", "*_MA_plot.png", "*_volcano_plot.pdf", "*_volcano_plot.png", "dds*.rds", "NO_EXPRESSION_SIGNAL.txt", ): for path in de_dir.glob(pattern): if path.is_file(): path.unlink() for pattern in ( f"{safe_prefix}.rnaseq_de.*", f"{safe_prefix}.rnaseq_de_report.md", ): for path in final_dir.glob(pattern): if path.is_file(): path.unlink() def main(): parser = argparse.ArgumentParser(description="RNA-Seq Differential Expression") parser.add_argument("-i", "--input", required=True, help="Input directory with count matrix") parser.add_argument("-c", "--config", required=True, help="Group/sample configuration (YAML)") parser.add_argument("-o", "--output", required=True, help="Output directory") parser.add_argument("-p", "--prefix", default="rnaseq", help="Output prefix") parser.add_argument("-j", "--job-id", default="unknown", help="Job ID") parser.add_argument("-r", "--run-id", default="unknown", help="Run ID") parser.add_argument("-t", "--cpu", type=int, default=4, help="Number of CPU threads") parser.add_argument("--module", default="", choices=["", "M1", "M3", "M4"], help="Simplified module type (M1/M3/M4). Empty = auto-detect.") parser.add_argument("--padj", type=float, default=0.05, help="P-adj threshold") parser.add_argument("--log2fc", type=float, default=1.0, help="log2FC threshold") parser.add_argument("-f", "--family-ids", help="Gene family member IDs file (optional)") parser.add_argument("-n", "--family-name", default="family", help="Family name for output files") parser.add_argument("--final-dir", help="Main final directory (default: output_dir/final)") args = parser.parse_args() start_time = datetime.now() input_dir = Path(args.input) output_dir = Path(args.output) config_file = Path(args.config) context_for_prefix = resolve_rnaseq_project_context( input_dir=config_file.parent, final_dir=input_dir, requested_prefix=args.prefix, sample_config=config_file, ) resolved_prefix = context_for_prefix.get("output_prefix") if resolved_prefix and resolved_prefix != args.prefix: print( f"[INFO] Using RNA-seq output prefix {resolved_prefix} " f"instead of requested {args.prefix or ''} " f"(source={context_for_prefix.get('source', '')})" ) args.prefix = resolved_prefix # 创建输出目录 output_dir.mkdir(parents=True, exist_ok=True) # final_dir: 主final目录(所有阶段共享),默认是output_dir/final # 如果指定了--final-dir,使用那个;否则使用output_dir/final print(f"[DEBUG] args.final_dir = {args.final_dir}") if args.final_dir: final_dir = Path(args.final_dir) final_dir.mkdir(parents=True, exist_ok=True) print(f"[DEBUG] Using provided final_dir: {final_dir}") else: final_dir = output_dir / "final" final_dir.mkdir(exist_ok=True) print(f"[DEBUG] Using default final_dir: {final_dir}") de_dir = output_dir / "analysis" de_dir.mkdir(exist_ok=True) safe_prefix = args.prefix if args.prefix else "rnaseq" cleanup_previous_outputs(de_dir, final_dir, safe_prefix) # 查找计数矩阵 count_matrix = None for f in input_dir.glob("*.count_matrix.txt"): count_matrix = f break if not count_matrix: print("Error: Count matrix not found", file=sys.stderr) sys.exit(1) print(f"Processing differential expression analysis...") # 获取工具版本 versions = get_tool_versions() print(f"Tool versions: {versions}") # 运行DESeq2。这里必须把 R 层失败显式传回上层; # 否则会生成 success manifest,但没有任何 DE 结果文件。 deseq_status, de_plan = run_deseq2(count_matrix, config_file, de_dir, args.padj, args.log2fc, user_module=args.module) if not deseq_status: print("Error: No DESeq2 comparisons were configured or executed.", file=sys.stderr) sys.exit(1) failed_comparisons = [name for name, ok in deseq_status.items() if not ok] if failed_comparisons: print( "Error: DESeq2 failed for comparisons: " + ", ".join(failed_comparisons), file=sys.stderr, ) sys.exit(1) # 解析结果统计 comparison_stats = {} no_signal_marker = de_dir / "NO_EXPRESSION_SIGNAL.txt" no_signal_note = no_signal_marker.read_text().strip() if no_signal_marker.exists() else None for f in de_dir.glob("*.DE_genes.txt"): comp_name = f.stem.replace(".DE_genes", "") stats = parse_de_results(f) if no_signal_note: stats["note"] = no_signal_note comparison_stats[comp_name] = stats # 复制到final目录,使用标准命名格式: {prefix}.rnaseq_de.{comp_name}.xxx import shutil results_file = de_dir / f"{comp_name}.DE_results.txt" if results_file.exists(): shutil.copy(results_file, final_dir / f"{safe_prefix}.rnaseq_de.{comp_name}.DE_results.txt") shutil.copy(f, final_dir / f"{safe_prefix}.rnaseq_de.{comp_name}.DE_genes.txt") # 复制图表文件到final目录 ma_pdf = de_dir / f"{comp_name}_MA_plot.pdf" ma_png = de_dir / f"{comp_name}_MA_plot.png" volcano_pdf = de_dir / f"{comp_name}_volcano_plot.pdf" volcano_png = de_dir / f"{comp_name}_volcano_plot.png" if ma_pdf.exists(): shutil.copy(ma_pdf, final_dir / f"{safe_prefix}.rnaseq_de.{comp_name}_MA_plot.pdf") if ma_png.exists(): shutil.copy(ma_png, final_dir / f"{safe_prefix}.rnaseq_de.{comp_name}_MA_plot.png") if volcano_pdf.exists(): shutil.copy(volcano_pdf, final_dir / f"{safe_prefix}.rnaseq_de.{comp_name}_volcano_plot.pdf") if volcano_png.exists(): shutil.copy(volcano_png, final_dir / f"{safe_prefix}.rnaseq_de.{comp_name}_volcano_plot.png") if no_signal_marker.exists(): import shutil shutil.copy(no_signal_marker, final_dir / f"{safe_prefix}.rnaseq_de.NO_EXPRESSION_SIGNAL.txt") # 基因家族特异性分析(如果提供了家族ID文件) family_stats = {} if args.family_ids: family_ids_file = Path(args.family_ids) if family_ids_file.exists(): print(f"Processing family-specific DE analysis for {args.family_name}...") family_stats = run_family_de_analysis( de_dir, family_ids_file, final_dir, args.family_name, args.padj, args.log2fc, args.prefix ) else: print(f"Warning: Family IDs file not found: {family_ids_file}") # 生成报告 end_time = datetime.now() generate_report(final_dir, comparison_stats, versions, start_time, end_time, args.prefix, de_plan) # 生成manifest cmd = ["python", __file__, "-i", str(input_dir), "-c", str(config_file), "-o", str(output_dir), "-p", args.prefix, "-j", args.job_id, "-r", args.run_id, "--padj", str(args.padj), "--log2fc", str(args.log2fc)] if args.family_ids: cmd.extend(["-f", args.family_ids, "-n", args.family_name]) generate_manifest( final_dir, comparison_stats, versions, start_time, end_time, cmd, input_dir, output_dir, args.job_id, args.run_id, args.prefix, family_stats, args.family_name if args.family_ids else None, de_plan, sample_config=config_file, count_matrix=count_matrix, family_members_file=Path(args.family_ids) if args.family_ids else None, foreground_mode="family" if args.family_ids else "global", cpu=args.cpu, padj_threshold=args.padj, log2fc_threshold=args.log2fc, ) print(f"RNA-DE completed. Results in {output_dir}") if __name__ == "__main__": main()