第3章 手把手写你的第一个 Snakefile

从这章开始,我们要亲手触碰 Snakemake 的核心 —— Snakefile。说实话,我第一次看到 Snakefile 时觉得它很像 Makefile,但又多了很多 Python 的影子。它不复杂,但有几个关键概念必须吃透。

Snakefile 长什么样?

Snakefile 本质上是一个 Python 脚本,里面放了一堆 rule(规则)。每条 rule 定义了:

  • 从哪些输入文件出发
  • 要生成什么输出文件
  • 用什么 shell 命令或 Python 代码来完成

我用一个最简单的例子让你感受一下:

rule say_hello:
    output:
        "hello.txt"
    shell:
        "echo 'Hello, Snakemake!' > hello.txt"

保存为 Snakefile,运行 snakemake -c1,你就能看到当前目录下多了一个 hello.txt。就是这么直白。

rule 的身份证:name, input, output, shell

每个 rule 都有四个最常见字段,我习惯把它们称为 rule 的「身份证」:

字段作用注意点
namerule 的名字,用来唯一标识不能重复,建议用下划线命名
input指定输入文件路径(可以是列表)无输入也合法,但流水线通常需要
output指定输出文件路径(同样可以是列表)Snakemake 会根据它判断是否重跑
shell要执行的 shell 命令字符串里面可以用 {input} {output} 占位

举个例子,我们在质控流水线中常遇到:用 fastp 对原始 FASTQ 进行质量过滤。

rule fastp_trim:
    input:
        "data/sample_R1.fastq.gz",
        "data/sample_R2.fastq.gz"
    output:
        "results/trimmed/sample_R1.fastq.gz",
        "results/trimmed/sample_R2.fastq.gz",
        "results/trimmed/sample_fastp.html"
    shell:
        "fastp -i {input[0]} -I {input[1]} -o {output[0]} -O {output[1]} -h {output[2]}"

注意这里 {input[0]} 对应第一个输入文件,{input[1]} 对应第二个。output 同理。Snakemake 会自动把 Python 列表转换成有序索引。

我个人的习惯:输入输出文件尽量用 str 或 list 书写,不要写太长的嵌套路径,否则后期改流程特别痛苦。我一般在项目根目录建 data/ 和 results/ 两个文件夹,rule 里只写相对路径。

shell 命令里的占位符魔法

Snakemake 会在执行 shell 前,把 {input}、{output} 替换成实际文件路径。如果输入输出是列表,它默认用空格拼接。但有些工具(比如 bwa、samtools)需要指定特定的顺序,这时可以用 {input[0]} 按位置取。

举个例子,我曾经被 fastqc 的参数坑过一次:

rule fastqc:
    input:
        "data/sample.fastq.gz"
    output:
        "results/fastqc/sample_fastqc.html",
        "results/fastqc/sample_fastqc.zip"
    shell:
        "fastqc -o results/fastqc/ {input}"

看,输出只写了 output 声明,shell 里并没有用到 {output}。因为 fastqc 默认会生成同名的 html 和 zip,Snakemake 只是用 output 来检查是否已经生成——如果文件已经存在且输入没变,它就不跑了。

注意:sh 里面用 {input} 或 {output} 时,如果路径包含空格,Snakemake 默认会帮你加引号。但如果你用了通配符(下一章讲),要格外小心文件名里带特殊字符的情况。

Snakefile 的结构骨架

一个实用的 Snakefile 通常长这样:

# 全局配置(可选)
configfile: "config.yaml"

# 第一条 rule 通常是 all,用来定义最终目标
rule all:
    input:
        "results/final_report.html"

# 其他 rule 按依赖顺序组织
rule fastp_trim:
    ...

rule multiqc:
    ...

你不需要像写脚本一样按顺序排列 rule,Snakemake 会自动分析依赖关系。它从你执行时指定的目标(默认第一个 rule)出发,反向找到所有需要的输入。

下面这张图展示了 Snakefile 里 rule 之间的依赖关系:

Snakefile 规则依赖示意 rule all rule fastp_trim rule fastqc rule multiqc 依赖关系 箭头 = 输出 → 输入 Snakemake自动解析

你看,rule all 需要 multiqc 的输出,而 multiqc 又需要 fastp_trim 和 fastqc 的输出。Snakemake 会按照图的拓扑顺序执行,如果某个文件已经存在且没变化,它直接跳过。

写 rule 时的实用小技巧

  1. 把最顶层的 rule 命名为 all,这样你直接运行 snakemake 就构建所有最终产物。
  2. 输出文件的名字尽量和样本名关联,比如 sample1_fastp.html,方便后面用通配符(下章主角)。
  3. 用 shell 时把命令写完整,不要依赖当前环境变量。我曾经在一个集群上因为 PATH 没加载全导致命令找不到,后来学乖了,直接在 shell 里写 /usr/bin/fastp 或者用 conda 环境。
避坑指南:我曾经在写 shell 时用了 {output} 但没加 -o 参数,结果输出文件根本没生成,Snakemake 报错说找不到目标文件。后来我才意识到:shell 命令必须确实创造出 output 里声明的那几个文件,Snakemake 才会认为规则执行成功。

你还应该知道的:rule 的更多字段

除了 input、output、shell,rule 还可以有 params、log、threads、resources 等字段。不过新手阶段,掌握前三者就足够跑通质控流水线了。比如:

rule fastp_with_params:
    input:
        "data/{sample}_R1.fastq.gz",
        "data/{sample}_R2.fastq.gz"
    output:
        "results/{sample}_R1.fastq.gz",
        "results/{sample}_R2.fastq.gz"
    params:
        adapter1 = "AGATCGGAAGAGCACACGTCTGAACTCCAGTCA",
        adapter2 = "AGATCGGAAGAGCGTCGTGTAGGGAAAGAGTGT"
    shell:
        "fastp -i {input[0]} -I {input[1]} "
        "-o {output[0]} -O {output[1]} "
        "--adapter_sequence {params.adapter1} "
        "--adapter_sequence_r2 {params.adapter2}"

这样把参数单独放在 params 里,代码更清晰。而且 params 里还可以放 Python 表达式,比如根据样本名动态计算参数。

最后说两句

Snakefile 的语法其实很轻,你记住 rule 的骨架,然后大胆往里面填命令就行了。别怕写错,Snakemake 的错误提示都挺友好的,它会告诉你哪个文件缺失、哪个 rule 失败了。

嗯,这一章的内容就到这。你写一个最简单的 Snakefile 跑一下试试,然后可以试着把 fastp 或 fastqc 的命令塞进去。我在项目中踩过的那些坑,已经变成上面的避坑指南了,希望你别再走一遍。