规则、输入输出与通配符——Snakemake 的“灵魂三件套”

讲批量对接流程,第一步就是把“要对什么、怎么对、对完生成什么”给说清楚。Snakemake 里,这个“说清楚”的工具就是 规则(rule)。我个人习惯把规则看成 Snakemake 的最小执行单元——每个规则只做一件事,但做得明白。

规则定义:写好你的“配方”

一个规则,说白了就是一个“配方”。它告诉 Snakemake:你需要什么文件进来,你想用什么脚本或命令处理,以及你希望生成什么文件。

举个例子。最简单的规则长这样:

rule prepare_protein:
    input:
        "raw/uniprot.fasta"
    output:
        "prepared/targets.fasta"
    shell:
        "grep '>' {input} > {output}"

你看,这个规则做了什么?它只做了一件事:从原始 FASTA 里提取序列头信息。输入、输出、shell 命令,三个部分清清楚楚。

规则的核心元素其实就这几个:

  • input——你需要的依赖文件
  • output——你要生成的文件
  • shell 或 run——具体执行什么操作

别小看这个结构。我在项目中遇到过有人把十几个处理步骤写在一个规则里,结果出了问题根本不知道在哪一步。你想想看,拆成多个规则,每个规则只做一件事,调试起来舒服多了。

规则元素 作用 常用写法
input 声明依赖文件 字符串、列表、字典
output 声明生成文件 字符串(推荐带通配符)
shell 执行 Shell 命令 用 {} 引用 input/output
run 执行 Python 代码 直接写 Python 逻辑

提示:shell 里引用文件时用 {input} 或 {output},Snakemake 会自动替换成你定义好的路径。别硬编码路径,否则失去可复用性。

输入输出声明:让 Snakemake 知道谁依赖谁

Snakemake 的工作方式,是“由终及始”的。什么意思?你告诉它“我要生成这个最终文件”,它会自己倒推:生成这个文件需要哪些中间文件?生成中间文件又需要哪些原始数据?

这依赖的就是规则之间的输入输出连接。

看个例子:

rule dock_one:
    input:
        protein="prepared/targets.pdbqt",
        ligand="ligands/compound_1.pdbqt"
    output:
        "results/compound_1_out.pdbqt"
    shell:
        "vina --receptor {input.protein} "
        "--ligand {input.ligand} "
        "--out {output}"

这里的 input.protein 和 input.ligand 都是命名输入。命名输入的好处是什么?当你有多个输入文件时,用名字引用比用索引更清晰。嗯,这里要注意:如果输入只有一个文件,直接用 {input} 就够了,没必要硬加个名字。

输出文件名的命名方式也值得聊两句。我个人建议输出路径里带上配体 ID 或蛋白质 ID,这样你一眼就能看出这个结果是谁的。

警告:输入文件必须真实存在,Snakemake 会先检查它们。如果文件缺失,规则不会执行,而是直接报错。我曾经因为这个特性排查过一整天“为什么流程卡住了”——其实就是配体文件路径写错了。

通配符:让一条规则适配成千上万个小分子

好了,上面那个 dock_one 规则只能处理一个化合物。但我们要对接 1000 个化合物呢?写 1000 条规则?

不可能。这时候就轮到通配符登场了。

通配符,说白了就是在文件名里用花括号 {} 定义一个“变量”。Snakemake 会从你指定的最终目标文件里,自动提取这个变量的值,然后替换到规则中。

比如:

rule dock_all:
    output:
        "results/{ligand_id}_out.pdbqt"
    input:
        protein="prepared/targets.pdbqt",
        ligand="ligands/{ligand_id}.pdbqt"
    shell:
        "vina --receptor {input.protein} "
        "--ligand {input.ligand} "
        "--out {output}"

你猜怎么着?只要你告诉 Snakemake “我要生成 results/compound_42_out.pdbqt”,它会自动识别出 {ligand_id} 是 compound_42,然后去找 ligands/compound_42.pdbqt。

为什么会这样?因为 Snakemake 把输出文件名上的通配符,映射到了输入文件名的相同通配符上。一个通配符,贯穿整个规则。

通配符占位 实际值举例 匹配文件
{ligand_id} compound_42 ligands/compound_42.pdbqt
{protein_name} 1a2b proteins/1a2b.pdbqt
{system} docking_set_1 configs/docking_set_1.yaml

提示:通配符的名字最好有意义。用 {id} 不如用 {ligand_id},因为几个月后你回来看代码,能立刻明白这个 id 代表什么。

通配符约束:别让 Snakemake 猜错

通配符好用,但也可能“过度匹配”。你想想看,如果你有两个文件:ligands/compound_42.pdbqt 和 ligands/compound_42_extra.pdbqt,一个规则里的 {ligand_id} 到底会匹配成 compound_42 还是 compound_42_extra?

Snakemake 的默认行为是尽量匹配最短的可能。但如果你不希望它猜错,就需要 通配符约束(wildcard_constraints) 上场了。

约束可以是正则表达式,也可以是简单的字符串匹配规则。比如:

rule dock_with_constraint:
    output:
        "results/{ligand_id}_out.pdbqt"
    input:
        ligand="ligands/{ligand_id}.pdbqt"
    wildcard_constraints:
        ligand_id="compound_\d+"
    shell:
        "vina --ligand {input.ligand} --out {output}"

这个约束意味着:{ligand_id} 只能匹配“compound_”后面跟一串数字的形式。像 compound_42_extra 这种带有额外字符的,就匹配不上了。

约束不只在规则里写,也可以在 Snakefile 全局定义:

wildcard_constraints:
    ligand_id = "compound_\d+"
    protein_id = "[a-z0-9]{4}"

全局定义的约束,会应用到所有规则的同名通配符上。我个人喜欢在规则里单独写约束,因为这样更清晰,不会让全局文件变得臃肿。

注意:我曾经因为没有加通配符约束,导致流程意外匹配了错误的文件——一个叫 compound_42_backup.pdbqt 的文件被当成了配体。结果对接后的结果全不对,排查了整整一个下午。从那以后,只要文件名不唯一,我一定加约束。

知识结构小结

让我用一张图把这一章的核心逻辑串起来。不用死记硬背,看完这张图你大概就明白了:

Snakemake 规则与通配符核心逻辑 规则 · 通配符 · 约束 核心逻辑 规则定义 input / output / shell 输出:带通配符 results/{ligand_id}_out.pdbqt 输入:通配符映射 ligands/{ligand_id}.pdbqt 执行 通配符约束 wildcard_constraints 匹配过程示例 目标文件: results/lig_01_out.pdbqt → {ligand_id} = lig_01 约束过滤示例 约束: ligand_id = "lig_\d+" 不匹配: "lig_01_backup"

这张图的核心逻辑是:规则定义输出 → 输出带通配符 → 输入自动映射同一通配符 → 通配符约束限制匹配范围 → 最终执行。记住这个链条,后面写复杂流程时就不会乱。

核心要点:

  • 每个规则只做一件事,输入输出清晰
  • 通配符让一个规则能处理所有配体
  • 通配符约束防止文件名误匹配
  • Snakemake 由终及始自动推导依赖关系

如何验证你的理解?

动手试试吧。假设你要对接 5 个化合物,试试用通配符规则来执行。我建议你先从一个规则开始,确认能跑通,再扩展到多个配体。批量流程不是一步到位的——就像搭积木,一块一块搭,稳当。