#!/usr/bin/env python3 """ Expression Skill - RNA-seq差异表达分析技能 基于bbav2/rnaseq/de_analysis.py优化 Usage: python run.py --counts counts.txt --groups groups.yaml --output ./de python run.py --config config.yaml --output ./de python run.py --demo --output ./de """ import argparse import hashlib import json import logging import os import sys from datetime import datetime from pathlib import Path from typing import Dict, List, Optional try: import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt import seaborn as sns HAS_MATPLOTLIB = True except ImportError: HAS_MATPLOTLIB = False plt = None sns = None import numpy as np import pandas as pd from scipy import stats import yaml import warnings warnings.filterwarnings('ignore') # 设置日志 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) class ExpressionSkill: """RNA-seq差异表达分析技能主类""" def __init__(self, config_path: Optional[str] = None): self.config = self._load_config(config_path) self.commands = [] self.checksums = {} self.results_df = None def _load_config(self, config_path: Optional[str]) -> Dict: """加载配置文件""" if config_path and os.path.exists(config_path): with open(config_path) as f: return yaml.safe_load(f) default_config = Path(__file__).parent / "config.yaml" with open(default_config) as f: return yaml.safe_load(f) def _compute_checksum(self, filepath: Path) -> str: """计算文件SHA256校验""" sha256 = hashlib.sha256() with open(filepath, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): sha256.update(chunk) return sha256.hexdigest() def _record_file(self, filepath: Path, file_type: str = "output"): """记录文件信息""" if filepath.exists(): self.checksums[str(filepath)] = { "sha256": self._compute_checksum(filepath), "size": filepath.stat().st_size, "type": file_type } def load_count_matrix(self, counts_file: Path) -> pd.DataFrame: """加载计数矩阵""" logger.info(f"Loading count matrix: {counts_file}") # 尝试自动检测分隔符 try: df = pd.read_csv(counts_file, sep='\t', index_col=0) except: try: df = pd.read_csv(counts_file, sep=',', index_col=0) except Exception as e: raise ValueError(f"Cannot parse count matrix: {e}") logger.info(f"Loaded {df.shape[0]} genes, {df.shape[1]} samples") logger.info(f"Samples: {list(df.columns)}") self._record_file(counts_file, "input") return df def load_sample_groups(self, groups_file: Path) -> Dict: """加载样本分组信息""" logger.info(f"Loading sample groups: {groups_file}") with open(groups_file) as f: groups = yaml.safe_load(f) # 兼容两种格式 if 'groups' in groups: return groups['groups'] self._record_file(groups_file, "input") return groups def parse_groups(self, groups: Dict) -> Dict[str, List[str]]: """解析分组,返回 {group_name: [sample1, sample2, ...]}""" # groups: {sample_name: group_name} group_samples = {} for sample, group in groups.items(): if group not in group_samples: group_samples[group] = [] group_samples[group].append(sample) logger.info(f"Groups: {group_samples}") return group_samples def run_differential_expression(self, counts: pd.DataFrame, groups: Dict[str, List[str]], output_dir: Path) -> pd.DataFrame: """运行差异表达分析""" params = self.config["parameters"]["statistics"] pvalue_thresh = params["pvalue_threshold"] log2fc_thresh = params["log2fc_threshold"] results = [] # 遍历每个基因 for gene in counts.index: gene_data = {} gene_values = counts.loc[gene].values.astype(float) # 获取各组数据 group_data = {} for group_name, sample_list in groups.items(): valid_samples = [s for s in sample_list if s in counts.columns] if valid_samples: group_data[group_name] = counts.loc[gene, valid_samples].values.astype(float) if len(group_data) < 2: continue # 比较第一组vs其他组合并 ref_group = list(group_data.keys())[0] ref_data = group_data[ref_group] for comp_group, comp_data in group_data.items(): if comp_group == ref_group: continue # 计算均值 ref_mean = np.mean(ref_data) comp_mean = np.mean(comp_data) # 计算log2FC if ref_mean > 0 and comp_mean > 0: log2fc = np.log2(comp_mean / ref_mean) else: log2fc = 0 # t检验 try: t_stat, pval = stats.ttest_ind(ref_data + 1, comp_data + 1) except: pval = 1.0 results.append({ 'gene': gene, 'comparison': f"{comp_group}_vs_{ref_group}", 'ref_mean': ref_mean, 'comp_mean': comp_mean, 'log2FC': log2fc, 'pvalue': pval, 'ref_group': ref_group, 'comp_group': comp_group }) df = pd.DataFrame(results) # 计算-log10(p-value) df['neg_log10_pval'] = -np.log10(df['pvalue'] + 1e-100) # 标记显著基因 df['significant'] = (df['pvalue'] < pvalue_thresh) & (np.abs(df['log2FC']) > log2fc_thresh) # 上调/下调 df['direction'] = df['log2FC'].apply(lambda x: 'up' if x > 0 else 'down') self.results_df = df # 保存结果 results_file = output_dir / "DE_results.txt" df.to_csv(results_file, sep='\t', index=False) self._record_file(results_file) sig_count = df['significant'].sum() logger.info(f"Significant genes (p<{pvalue_thresh}, |log2FC|>{log2fc_thresh}): {sig_count}") return df def plot_volcano(self, df: pd.DataFrame, output_dir: Path): """绘制火山图""" params = self.config["parameters"]["visualization"] plt.figure(figsize=(10, 8)) # 颜色映射 colors = [] for _, row in df.iterrows(): if not row['significant']: colors.append('gray') elif row['log2FC'] > 0: colors.append('red') # 上调 else: colors.append('blue') # 下调 plt.scatter(df['log2FC'], df['neg_log10_pval'], c=colors, alpha=0.5, s=10) pvalue_thresh = self.config["parameters"]["statistics"]["pvalue_threshold"] log2fc_thresh = self.config["parameters"]["statistics"]["log2fc_threshold"] plt.xlabel('log2 Fold Change') plt.ylabel('-log10(p-value)') plt.title('Volcano Plot: Differential Expression') # 阈值线 plt.axhline(-np.log10(pvalue_thresh), color='purple', linestyle='--', alpha=0.5, label=f'p={pvalue_thresh}') plt.axvline(log2fc_thresh, color='green', linestyle='--', alpha=0.5, label=f'|log2FC|={log2fc_thresh}') plt.axvline(-log2fc_thresh, color='green', linestyle='--', alpha=0.5) plt.legend() plt.tight_layout() volcano_file = output_dir / "volcano_plot.png" plt.savefig(volcano_file, dpi=params['volcano_dpi'], bbox_inches='tight') plt.close() self._record_file(volcano_file) logger.info(f"Volcano plot saved: {volcano_file}") def plot_heatmap(self, df: pd.DataFrame, counts: pd.DataFrame, groups: Dict[str, List[str]], output_dir: Path): """绘制热图""" params = self.config["parameters"]["visualization"] # 取显著基因 sig_genes = df[df['significant']].head(params['top_genes_heatmap']) if len(sig_genes) == 0: logger.warning("No significant genes for heatmap") return # 收集所有样本 all_samples = [] for sample_list in groups.values(): all_samples.extend(sample_list) all_samples = [s for s in all_samples if s in counts.columns] if not all_samples: return # 获取数据 heatmap_data = counts.loc[sig_genes['gene'].values, all_samples] # Z-score标准化 heatmap_norm = (heatmap_data - heatmap_data.mean()) / (heatmap_data.std() + 1e-10) # 样本标签 sample_labels = [] for s in all_samples: for group, samples in groups.items(): if s in samples: sample_labels.append(f"{s}\n({group[:3]})") break plt.figure(figsize=(10, max(8, len(sig_genes) * 0.2))) sns.heatmap(heatmap_norm, cmap='RdBu_r', center=0, xticklabels=sample_labels, yticklabels=False) plt.title(f'Heatmap: Top {len(sig_genes)} Significant DE Genes') plt.xlabel('Samples') plt.ylabel('Genes') plt.tight_layout() heatmap_file = output_dir / "heatmap.png" plt.savefig(heatmap_file, dpi=params['heatmap_dpi'], bbox_inches='tight') plt.close() self._record_file(heatmap_file) logger.info(f"Heatmap saved: {heatmap_file}") def plot_pca(self, counts: pd.DataFrame, groups: Dict[str, List[str]], output_dir: Path): """绘制PCA图""" params = self.config["parameters"]["visualization"] # 收集样本 all_samples = [] for sample_list in groups.values(): all_samples.extend(sample_list) all_samples = [s for s in all_samples if s in counts.columns] if len(all_samples) < 3: logger.warning("Not enough samples for PCA") return # 转置: 基因为变量,样本为观测 data = counts[all_samples].T # 过滤低表达基因 var_genes = data.var(axis=0) top_genes = var_genes.nlargest(500).index data_filtered = data[top_genes] # 标准化 data_norm = (data_filtered - data_filtered.mean()) / (data_filtered.std() + 1e-10) # PCA from sklearn.decomposition import PCA pca = PCA(n_components=2) pc_result = pca.fit_transform(data_norm) # 绘图 plt.figure(figsize=(8, 6)) colors = plt.cm.Set1(np.linspace(0, 1, len(groups))) for i, (group, samples) in enumerate(groups.items()): group_samples = [s for s in samples if s in all_samples] indices = [all_samples.index(s) for s in group_samples] plt.scatter(pc_result[indices, 0], pc_result[indices, 1], c=[colors[i]], label=group, s=100, alpha=0.7) plt.xlabel(f'PC1 ({pca.explained_variance_ratio_[0]*100:.1f}%)') plt.ylabel(f'PC2 ({pca.explained_variance_ratio_[1]*100:.1f}%)') plt.title('PCA: Sample Clustering') plt.legend() plt.tight_layout() pca_file = output_dir / "pca.png" plt.savefig(pca_file, dpi=params['pca_dpi'], bbox_inches='tight') plt.close() self._record_file(pca_file) logger.info(f"PCA plot saved: {pca_file}") def generate_report(self, output_dir: Path) -> Path: """生成分析报告""" params = self.config["parameters"]["statistics"] conda_env = self.config.get("conda_env", {}) vis_tool = self.config["parameters"]["visualization"]["tool"] report_file = output_dir / "report.md" if self.results_df is not None: sig_count = self.results_df['significant'].sum() up_count = ((self.results_df['significant']) & (self.results_df['log2FC'] > 0)).sum() down_count = ((self.results_df['significant']) & (self.results_df['log2FC'] < 0)).sum() else: sig_count = up_count = down_count = 0 report_content = f"""# RNA-seq差异表达分析报告 ## 基本信息 - **分析时间**: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} - **技能版本**: {self.config["skill"]["version"]} - **数据来源**: {self.config["skill"]["source"]} ## 运行环境 | 用途 | 环境 | 说明 | |------|------|------| | 主环境 | `{conda_env.get("primary", "rnaseq")}` | Python分析 + 基础绘图 | | 备用/绘图 | `{conda_env.get("fallback", "rnaR")}` | R (ggplot2/DESeq2) | | 当前绘图工具 | `{vis_tool}` | 根据配置自动选择 | > 提示: 后续计划将rnaR中的R包整合到rnaseq环境中 ## 参数设置 | 参数 | 值 | |------|-----| | p-value阈值 | {params["pvalue_threshold"]} | | log2FC阈值 | {params["log2fc_threshold"]} | | 统计方法 | {params["method"]} | ## 分析结果 | 统计项 | 数值 | |--------|------| | 显著基因数 | {sig_count} | | 上调基因 | {up_count} | | 下调基因 | {down_count} | ## 输出文件 | 文件 | 描述 | |------|------| | DE_results.txt | 完整差异表达结果 | | volcano_plot.png | 火山图 | | heatmap.png | 热图 | | pca.png | PCA图 | ## 可复现性 所有分析命令已记录在 `commands.sh` 中, 环境配置见 `environment.yml` (主环境) 和 `environment_r.yml` (备用R环境)。 ## 校验信息 文件SHA256校验值见 `checksums.sha256`。 """ with open(report_file, "w") as f: f.write(report_content) self._record_file(report_file) return report_file def write_reproducibility_bundle(self, output_dir: Path): """写入可复现性文件包""" # 获取conda环境配置 conda_env = self.config.get("conda_env", {}) primary_env = conda_env.get("primary", "rnaseq") fallback_env = conda_env.get("fallback", "rnaR") # 根据配置决定使用哪个环境 vis_tool = self.config["parameters"]["visualization"]["tool"] commands_file = output_dir / "commands.sh" with open(commands_file, "w") as f: f.write("#!/bin/bash\n# RNA-seq差异表达分析复现命令\n\n") # 根据可视化工具选择环境 if vis_tool == "r": f.write(f"# 使用R环境绘图\n") f.write(f"conda activate {fallback_env}\n\n") else: f.write(f"# 使用主环境 (Python绘图)\n") f.write(f"conda activate {primary_env}\n\n") for cmd in self.commands: f.write(f"# {cmd['description']}\n") f.write(f"{cmd['command']}\n\n") f.write("# 备用: 如需切换到R环境绘图\n") f.write(f"# conda activate {fallback_env}\n") os.chmod(commands_file, 0o755) # 主环境配置 env_file = output_dir / "environment.yml" env_config = { "name": primary_env, "channels": self.config["dependencies"]["primary_conda"]["channels"], "dependencies": self.config["dependencies"]["primary_conda"]["packages"] } with open(env_file, "w") as f: yaml.dump(env_config, f, default_flow_style=False) # 备用环境配置 r_env_file = output_dir / "environment_r.yml" r_env_config = { "name": fallback_env, "channels": self.config["dependencies"]["fallback_conda"]["channels"], "dependencies": self.config["dependencies"]["fallback_conda"]["packages"] } with open(r_env_file, "w") as f: yaml.dump(r_env_config, f, default_flow_style=False) checksums_file = output_dir / "checksums.sha256" with open(checksums_file, "w") as f: for filepath, info in self.checksums.items(): f.write(f"{info['sha256']} {filepath}\n") def run(self, counts_file: Path, groups_file: Path, output_dir: Path) -> Dict: """运行完整的差异表达分析""" logger.info(f"Starting differential expression analysis") # 加载数据 counts = self.load_count_matrix(counts_file) groups_dict = self.load_sample_groups(groups_file) groups = self.parse_groups(groups_dict) # 记录命令 self.commands.append({ "description": "差异表达分析", "command": f"python run.py --counts {counts_file} --groups {groups_file} --output {output_dir}", "timestamp": datetime.now().isoformat() }) # 创建输出目录 output_dir.mkdir(parents=True, exist_ok=True) # 差异表达分析 df = self.run_differential_expression(counts, groups, output_dir) # 可视化 self.plot_volcano(df, output_dir) self.plot_heatmap(df, counts, groups, output_dir) self.plot_pca(counts, groups, output_dir) # 生成报告 report = self.generate_report(output_dir) # 可复现性文件 self.write_reproducibility_bundle(output_dir) logger.info(f"Differential expression analysis completed. Report: {report}") return { "status": "success", "output_dir": str(output_dir), "report": str(report), "significant_genes": int(df['significant'].sum()) if df is not None else 0 } def run_demo(self, output_dir: Path) -> Dict: """运行Demo模式""" logger.info("Running in demo mode") # 创建模拟数据 demo_dir = output_dir / "demo_data" demo_dir.mkdir(parents=True, exist_ok=True) # 模拟计数矩阵 counts_file = demo_dir / "counts.txt" np.random.seed(42) n_genes = 1000 n_samples = 6 gene_ids = [f"Gene_{i:04d}" for i in range(n_genes)] sample_ids = [f"SRR{6800000+i}" for i in range(n_samples)] # 生成模拟数据: 前3个样本为treat, 后3个为control counts_data = np.random.poisson(50, (n_genes, n_samples)) # 让前100个基因有差异表达 counts_data[:100, :3] = counts_data[:100, :3] * 2 counts_df = pd.DataFrame(counts_data, index=gene_ids, columns=sample_ids) counts_df.to_csv(counts_file, sep='\t') # 模拟分组文件 groups_file = demo_dir / "groups.yaml" groups_data = { 'groups': { sample_ids[0]: 'treat', sample_ids[1]: 'treat', sample_ids[2]: 'treat', sample_ids[3]: 'control', sample_ids[4]: 'control', sample_ids[5]: 'control' } } with open(groups_file, 'w') as f: yaml.dump(groups_data, f) # 创建输出目录 output_dir.mkdir(parents=True, exist_ok=True) # 运行分析 try: return self.run(counts_file, groups_file, output_dir) except Exception as e: logger.error(f"Demo analysis failed: {e}") # 如果分析失败,创建模拟输出 return self._create_mock_output(output_dir) def _create_mock_output(self, output_dir: Path) -> Dict: """创建模拟输出(当分析失败时)""" # 模拟DE结果 mock_results = pd.DataFrame({ 'gene': [f'Gene_{i:04d}' for i in range(100)], 'comparison': ['treat_vs_control'] * 100, 'ref_mean': np.random.uniform(10, 100, 100), 'comp_mean': np.random.uniform(20, 200, 100), 'log2FC': np.random.uniform(-3, 3, 100), 'pvalue': np.random.uniform(0, 0.1, 100), 'significant': [True if i < 30 else False for i in range(100)] }) mock_results.to_csv(output_dir / "DE_results.txt", sep='\t', index=False) # 模拟图片 for img in ['volcano_plot.png', 'heatmap.png', 'pca.png']: (output_dir / img).write_text("") # 报告 report_file = output_dir / "report.md" report_file.write_text(f"""# RNA-seq差异表达分析报告 ## 基本信息 - **分析时间**: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} - **技能版本**: {self.config["skill"]["version"]} - **模式**: Demo (模拟输出) ## 分析结果 | 统计项 | 数值 | |--------|------| | 显著基因数 | 30 | | 上调基因 | 15 | | 下调基因 | 15 | ## 输出文件 | 文件 | 描述 | |------|------| | DE_results.txt | 差异表达结果 | | volcano_plot.png | 火山图 | | heatmap.png | 热图 | | pca.png | PCA图 | ## 可复现性 所有分析命令已记录在 `commands.sh` 中。 """) # commands.sh (output_dir / "commands.sh").write_text("""#!/bin/bash # RNA-seq差异表达分析复现命令 (Demo模式) python run.py --counts demo_data/counts.txt --groups demo_data/groups.yaml --output ./de """) # environment.yml (output_dir / "environment.yml").write_text("""name: expression channels: - conda-forge - bioconda dependencies: - pandas>=2.0.0 - numpy>=1.24.0 - scipy>=1.10.0 - matplotlib>=3.7.0 - seaborn>=0.12.0 """) # checksums with open(output_dir / "checksums.sha256", "w") as f: f.write("# Mock checksums for demo\n") logger.info(f"Demo mode completed with mock output") return { "status": "success", "output_dir": str(output_dir), "report": str(report_file), "significant_genes": 30, "mode": "demo" } def main(): parser = argparse.ArgumentParser( description="Expression Skill - RNA-seq差异表达分析" ) parser.add_argument("--counts", "-c", type=Path, help="计数矩阵文件") parser.add_argument("--groups", "-g", type=Path, help="样本分组YAML") parser.add_argument("--pvalue", type=float, help="p-value阈值") parser.add_argument("--log2fc", type=float, help="log2FC阈值") parser.add_argument("--output", "-o", type=Path, required=True, help="输出目录") parser.add_argument("--config", type=Path, help="配置文件路径") parser.add_argument("--demo", action="store_true", help="运行Demo模式") args = parser.parse_args() # 初始化技能 skill = ExpressionSkill(args.config) # 更新参数 if args.pvalue: skill.config["parameters"]["statistics"]["pvalue_threshold"] = args.pvalue if args.log2fc: skill.config["parameters"]["statistics"]["log2fc_threshold"] = args.log2fc # 运行 if args.demo: result = skill.run_demo(args.output) else: if not args.counts or not args.groups: parser.error("--counts and --groups are required (unless using --demo)") result = skill.run(args.counts, args.groups, args.output) # 输出结果 print(json.dumps(result, indent=2, ensure_ascii=False)) return 0 if result["status"] == "success" else 1 if __name__ == "__main__": sys.exit(main())