Files
PlantAI-dev/skills/expression/run.py
T

707 lines
24 KiB
Python
Raw Normal View History

2026-08-03 18:42:47 +08:00
#!/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())