Files
PlantAI-dev/apps/cli/run_rnaseq_de.py
T
2026-08-03 18:42:47 +08:00

2053 lines
85 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 '<empty>'} "
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()