4. 通配符与参数化:打通流水线的任督二脉

说实话,我刚学 Snakemake 的时候,觉得规则写起来挺顺的。但是一碰到要处理几十个样本,要每个样本跑不同的参数,瞬间就有点懵了。难道我要为每个样本手写一条规则吗?

当然不是。这就是通配符和参数化大显身手的地方。这一章,我就带你彻底搞懂它们。

4.1 通配符:让你的规则活起来

我们先想一个问题。你有一个质控脚本 fastqc,用它能处理任何 FASTQ 文件。你希望 Snakemake 能自动识别这个模式,对每个输入文件都跑一遍。

通配符就是干这个的。它像是一个占位符,可以在规则运行时,根据你生成的目标文件动态替换成实际的值。

看一个最简单的例子。假设我的输入文件都在 data/ 目录下,文件名是 sample1.fastq、sample2.fastq……

rule fastqc:
    input:
        "data/{sample}.fastq"
    output:
        "results/fastqc/{sample}_fastqc.html"
    shell:
        "fastqc {input} -o results/fastqc/"

你注意看 {sample} 这个花括号里的东西。Snakemake 一看就知道,这是一个通配符。它会根据你要生成的 output 文件,反向推导出 {sample} 的值。

比如,当你执行 snakemake results/fastqc/sample1_fastqc.html 时,Snakemake 会做如下推导:

  1. 匹配输出:目标文件 results/fastqc/sample1_fastqc.html 是否符合 results/fastqc/{sample}_fastqc.html?符合。因为 {sample} 被匹配为 sample1。
  2. 确定通配符:现在明确了 {sample} = sample1。
  3. 生成输入:将通配符值代入输入模板,得到输入文件 data/sample1.fastq。
  4. 执行命令:用 sample1 去填充 shell 命令中的 {input} 和 {output}。

这就是通配符的魔力。你只需要写一条规则,它就能处理无数个样本。我的项目里经常同时跑上百个样本,没有通配符的话,我早就疯了。

通配符匹配原则:Snakemake 会尽可能“贪婪”地匹配通配符,直到找到能成功推导出所有 input 的值。如果存在多个通配符,它会尝试所有可能的组合。

比如,如果你的输出是 results/{run}/{sample}.bam,那么 {run} 和 {sample} 都会被推导出来。

我曾经遇到过一个问题:我的输入文件名是 sample_1.fastq,但为了区分批次,我希望用 {batch}_{sample}.fastq 这样的模式。结果发现文件名里的下划线让通配符匹配变得混乱。我的经验是:文件名分隔符尽量用整洁,避免花括号里的通配符名包含不必要的特殊字符。 比如用 - 或者 . 来分隔,会清晰很多。

4.2 params指令:给规则传递“私房钱”

通配符解决了“用哪个文件”的问题,但还有另一个问题:参数不固定怎么办?

比如,我用 cutadapt 做接头去除。有的样本是单端测序,有的样本是双端测序。双端测序需要传入两个文件,并且要指定成对的接头序列。这时,shell 命令不再是一成不变的,而是需要根据通配符的值来动态调整。

params 指令就是用来干这个的。它允许你定义一个规则内的参数变量,这些变量可以是静态字符串,也可以是基于通配符动态计算的值。

先看一个最简单的静态参数例子:

rule trimmomatic:
    input:
        "data/{sample}.fastq"
    output:
        "results/trimmed/{sample}_trimmed.fastq"
    params:
        adapters="TruSeq3-SE.fa",
        minlen=36
    shell:
        "TrimmomaticSE {input} {output} ILLUMINACLIP:{params.adapters}:2:30:10 LEADING:3 TRAILING:3 SLIDINGWINDOW:4:15 MINLEN:{params.minlen}"

嗯,这样写虽然也行,但没什么技术含量,对吧?真正牛的是 params 结合 Python 表达式,动态生成参数。

rule cutadapt:
    input:
        f1="data/{sample}_R1.fastq",
        f2="data/{sample}_R2.fastq"
    output:
        "results/cutadapt/{sample}_R1_trimmed.fastq",
        "results/cutadapt/{sample}_R2_trimmed.fastq"
    params:
        ad1="AGATCGGAAGAGC",
        ad2="GTGCGGCGGCGC",
        adapter_pairs=lambda wildcards: f"-a {wildcards.ad1} -A {wildcards.ad2}"
    shell:
        "cutadapt {params.adapter_pairs} -o {output[0]} -p {output[1]} {input.f1} {input.f2}"

看到了吗?params 里可以写 lambda wildcards: ...。这个 wildcards 对象包含了当前规则下所有通配符的值。你可以根据这些值,用 Python 代码进行任意计算,然后返回一个字符串或数字,最后在 shell 命令中用 {params.xxx} 引用。

我经常用 params 来传递一些与样本相关的元数据,比如样本的分组信息。这样做的好处是,你可以把参数逻辑完全从 shell 命令里剥离出来,让规则更清晰、更可维护。

小心参数中的引号嵌套:Shell 命令对引号非常敏感。如果你在 params 里生成包含引号的字符串(比如接头序列里含有引号),一定要确保拼接后的 shell 命令是语法正确的。我的建议是,在 params

4.3 输入函数:当通配符不够用时

有时候,你的输入文件不是简单地根据通配符替换就能得到的。比如,你想根据通配符的值,去读取一个样本配置文件,或者查询一个数据库,来决定具体用哪些文件作为输入。

说白了,当 input: 后面跟一个字符串或列表已经无法满足你的需求时,就该轮到“输入函数”上场了。

输入函数是一个 Python 函数,它接收一个参数——wildcards 对象——然后返回一个或多个文件路径。Snakemake 在执行规则前会调用这个函数,把返回值作为实际的输入。

举个例子。我有一个样本配置文件 samples.csv,里面记录了每个样本所属的批次和对应的原始数据路径:

sample,batch,fastq1,fastq2
sample1,batchA,/path/to/sample1_R1.fastq,/path/to/sample1_R2.fastq
sample2,batchA,/path/to/sample2_R1.fastq,/path/to/sample2_R2.fastq
sample3,batchB,/path/to/sample3_R1.fastq,/path/to/sample3_R2.fastq

现在,我想写一个规则来合并同一个 batch 里的所有样本。我需要动态地根据通配符 {batch} 去查询这个 CSV 文件,找出属于该 batch 的所有样本的 FASTQ 文件。

这时候,输入函数就派上用场了:

import pandas as pd

def get_batch_fastqs(wildcards):
    """根据batch通配符,从配置文件中读取所有样本的fastq文件列表。"""
    config_df = pd.read_csv("samples.csv")
    batch_df = config_df[config_df["batch"] == wildcards.batch]
    fastq_list = []
    for _, row in batch_df.iterrows():
        fastq_list.append(row["fastq1"])
        fastq_list.append(row["fastq2"])
    return fastq_list

rule merge_batch:
    input:
        get_batch_fastqs
    output:
        "results/merged/{batch}_merged.fastq"
    shell:
        "cat {input} > {output}"

仔细看。我没有在 input: 后面写一个字符串,而是写了一个函数名 get_batch_fastqs。Snakemake 会识别出它是一个函数,然后在运行规则时,自动把通配符对象 wildcards 传给这个函数。

函数内部,我可以用任何 Python 代码来处理逻辑。读取 CSV、查询数据库、发网络请求(虽然不推荐)……都可以。只要最后返回一个文件路径的列表或者字符串就行。

为什么要用输入函数?

你想想看,通配符替代只是一对一的映射。但实际项目中,文件之间的依赖关系往往是一对多、多对多,甚至需要外部配置来驱动。输入函数把这种复杂依赖逻辑封装起来,让规则本身保持干净。如果有一天配置文件格式变了,你只需要改输入函数,不用动规则和后面的 shell 命令。

我的一个习惯是:把输入函数单独放到一个 Python 文件中,比如 utils.py。然后在 Snakefile 里只写 from utils import get_batch_fastqs。这样 Snakefile 行数不会膨胀,函数本身也方便做单元测试。嗯,代码洁癖也好,工程实践也罢,我非常推荐这样做。

4.4 三者关系:一张图讲清楚

通配符、params、输入函数,这三者其实是相互配合的。通配符负责“定位”,params 负责“调味”,输入函数负责“找路”。

我画了张图,把它们的协作关系展示出来:

通配符、params、输入函数协作图 核心机制协作关系 通配符 {sample} {batch} ... 输入函数 def get_input(wildcards): params adapters, minlen, ... output / shell 生成结果文件 传递通配符 决定参数 确定输入文件 传入参数 工作流: 1. Snakemake 根据目标文件推导通配符值 2. 通配符同时传递给输入函数和 params 3. 输入函数返回文件列表,params 返回参数值 4. 最终输出文件并执行 shell 命令

这张图很直观地展示了:通配符是整个流程的“引信”。它一确定,输入函数和 params 就可以并行工作,一个去“找文件”,一个去“算参数”,最后汇聚到 shell 命令中,生成目标输出。

4.5 实用技巧:避坑与优化

通配符和参数化虽然强大,但用不好也会埋坑。我挑几个常见的,给大家说道说道。

  • 通配符冲突:如果多条规则都能匹配同一个输出文件,Snakemake 会报错。我的建议是:保持通配符的唯一性。比如,用 {sample} 就用到底,别在另一条规则里用 {library} 来表示同样的东西。
  • params 里不要写耗时操作:params 里的函数会在每次规则执行时被调用。千万别在里面读取大文件、训练模型什么的,否则流水线的构建阶段就会卡死。把耗时操作放到独立的规则里,用输出文件缓存起来。
  • 输入函数的缓存:Snakemake 会缓存输入函数的返回值。如果配置文件没变,它不会重复调用。但如果你在 params 里用 lambda 写实时计算,它不会缓存。所以,能写成输入函数的,就尽量写成输入函数。
  • 小心 shell 命令中的花括号:你在 shell 里用了 {input}、{output},但如果你还想在 shell 里使用 bash 的花括号扩展(比如 for i in {1..10}),那就会出问题。因为 Snakemake 会先解析自己的 {}。解决方案是:在 shell 字符串中使用 {{ }} 来转义。比如,for i in {{1..10}}。

我曾经在 shell 里写了个 samtools merge {input} {output},结果 input 文件列表超出了 shell 命令的长度限制,直接报错。这就是参数化没有考虑实际文件数量导致的生产事故。所以,当你用通配符和输入函数生成大量输入文件时,一定要在 shell 命令或者脚本里处理好参数长度问题。可以用 --threads 参数控制并行度,或者用 split 命令分批处理。

好了,通配符、params 和输入函数就讲到这里。你只要掌握了这三个利器,就能写出高度灵活、可复用的质控流水线了。再复杂的样本处理逻辑,都能用它们轻松应对。