春江暮客

春江暮客的个人学习分享网站

生成蛋白质向量之前:用 Python 检查 FASTA 文件

2026-09-23 技术
生成蛋白质向量之前:用 Python 检查 FASTA 文件

运行蛋白质编码器之前,先检查 FASTA 文件里到底有什么。两个不同编号可能对应同一条序列,同一个编号也可能重复出现,却带着不同序列。这两种情况都容易导致向量与测量值匹配错误,或让重复输入同时进入训练集和测试集。

本文用 Python 标准库编写一个小工具,只生成 CSV 检查报告,不修改输入文件。可以把它放在氨基酸组成基线ESM-2 向量提取流程之前运行。

先明确输入规则

FASTA 记录以 > 开头的描述行起始,后面是序列行。NCBI 格式说明介绍了这一结构及 BLAST 接受的字符。模型流程可以采用更严格的规则。

本例仅接受 20 种标准氨基酸字母,比较时把小写转为大写,并拼接换行分隔的序列。解析器会跳过空白行,接受 UTF-8 字节顺序标记。这些是本文明确选择的处理方式,不代表所有 FASTA 软件都如此。

检查项 处理方式
记录编号 > 后按空白分隔的第一个字段,要求在文件内唯一。
空序列 标记,等待检查。
20 种字母以外的字符 标记,不删除或替换残基。
序列行中的空格或制表符 标记,包括行首和行尾空白。
超出指定长度 标记,不截断。
完全相同的标准字母序列 后续记录指向首次出现的记录序号。

XBZUO、缺口和终止符都不符合这套刻意收窄的规则,但不代表含这些字符的序列在生物学上一定无效。应根据数据来源和所选模型的分词器决定如何处理。仅含 ACGT 的字符串也能通过字母检查;程序无法据此判定输入是蛋白质还是核酸。

长度上限必须由用户提供,因为它取决于具体流程。分词器的 token 预算可能包含特殊标记,不同编码器也可能采用不同分词方式,不能直接把模型标称的 token 上限当作残基上限。

运行一个方便核对的例子

下载 audit_fasta.pydemo.fasta,放在同一目录。不需要安装第三方依赖,脚本已在 Python 3.14.7 上测试。

示例包含用于检查程序行为的人工字符串:

>sample_a synthetic example
ACDEFGHIKL
MNPQRSTVWY
>sample_b same sequence in lowercase
acdefghiklmnpqrstvwy
>sample_c requires a residue policy
ACDXEFG
>sample_a reused identifier
MKT
>sample_empty

运行:

python3 audit_fasta.py demo.fasta --max-residues 20 --out audit.csv

这里用 20 作为长度上限,只是为了演示,并非推荐的模型设置。输出:

records=5 flagged=4 exact_repeats=1
report=audit.csv

两条 sample_a 都会因编号冲突被标记;sample_c 包含 Xsample_empty 没有残基。sample_b 转成大写后与记录 1 相同,重复关系写在单独一列。

退出状态 0 表示没有问题记录或完全重复序列,1 表示报告已完成但需要检查,2 表示解析、参数或文件错误。因此,本例虽然成功生成报告,仍会返回状态 1。程序不会覆盖已有报告,再次运行时请使用新的 --out 路径。

完整检查脚本

"""Audit protein FASTA under an explicit 20-residue policy; never rewrite input."""
import argparse
import csv
import hashlib
import sys
from collections import Counter
from pathlib import Path

AA = set("ACDEFGHIKLMNPQRSTVWY")


def records(path):
    header, chunks = None, []
    with path.open(encoding="utf-8-sig") as handle:
        for line_no, raw in enumerate(handle, 1):
            line = raw.rstrip("\r\n")
            if not line.strip():
                continue
            if line.startswith(">"):
                if header is not None:
                    yield header, "".join(chunks)
                header, chunks = line[1:].strip(), []
                if not header:
                    raise ValueError(f"line {line_no}: empty FASTA header")
            else:
                if header is None:
                    raise ValueError(f"line {line_no}: sequence before first header")
                chunks.append(line)
    if header is not None:
        yield header, "".join(chunks)


def audit(path, max_residues):
    rows, first_sequence = [], {}
    for number, (header, raw) in enumerate(records(path), 1):
        ident = header.split()[0]
        sequence = raw.upper()
        problems = []
        if not sequence:
            problems.append("empty_sequence")
        if any(c.isspace() for c in raw):
            problems.append("whitespace_in_sequence")
        invalid = sorted(set(raw) - AA - set("acdefghiklmnpqrstvwy"))
        if invalid:
            problems.append("unsupported=" + repr("".join(invalid)))
        if len(sequence) > max_residues:
            problems.append("over_limit")
        # Hash and duplicate comparison only for canonical, nonempty sequences.
        canonical = bool(sequence) and not invalid
        digest = hashlib.sha256(sequence.encode("ascii")).hexdigest() if canonical else ""
        duplicate = ""
        if canonical:
            duplicate = first_sequence.get(sequence, "")
            first_sequence.setdefault(sequence, number)
        rows.append(dict(record=number, id=ident, length=len(sequence),
                         lowercase=raw != sequence, sha256=digest,
                         duplicate_of_record=duplicate, issues=";".join(problems)))
    if not rows:
        raise ValueError("no FASTA records")
    counts = Counter(row["id"] for row in rows)
    for row in rows:
        if counts[row["id"]] > 1:
            row["issues"] = ";".join(filter(None, [row["issues"], "duplicate_id"]))
    return rows


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("fasta", type=Path)
    parser.add_argument("--max-residues", type=int, required=True)
    parser.add_argument("--out", type=Path, default=Path("audit.csv"))
    args = parser.parse_args()
    if args.max_residues < 1:
        parser.error("--max-residues must be positive")
    try:
        rows = audit(args.fasta, args.max_residues)
        with args.out.open("x", newline="", encoding="utf-8") as handle:
            writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
            writer.writeheader()
            writer.writerows(rows)
    except (OSError, ValueError) as error:
        parser.exit(2, f"error: {error}\n")
    flagged = sum(bool(row["issues"]) for row in rows)
    repeats = sum(row["duplicate_of_record"] != "" for row in rows)
    print(f"records={len(rows)} flagged={flagged} exact_repeats={repeats}")
    print(f"report={args.out}")
    return 1 if flagged or repeats else 0


if __name__ == "__main__":
    sys.exit(main())

程序使用 csv.DictWriter 写报告,打开文件时指定 newline=""。序列指纹由 hashlib.sha256 计算,对应拼接并转成大写后的标准字母序列,不包含原始文件字节、标题或测量值。

重复检查直接比较规范化后的序列字符串。含不支持字符的序列和空序列不生成指纹,也不参与重复比较。若标准字母序列只是超长,仍会获得指纹,并参与重复检查。

工具逐行读取输入,但会把报告行和不同的标准字母序列保留在内存中,适合内存能够容纳的数据集。处理大型语料时,可将编号和序列索引移入磁盘数据库,同时保持一致的检查规则。

根据报告整理可复现的数据集

合并标签或向量前,先解决编号冲突。程序保留标题的完整第一个字段,包括其中的竖线,不会自动识别数据库 accession。若标签表使用另一套编号,需要明确编写并检查映射关系。

重复序列需要结合来源判断。它们可能来自重复下载、重复测量,也可能是同一种蛋白质在不同条件下的实验。应保留测量来源。若要评估对未见序列的泛化能力,完全相同的序列应放在同一侧,并进一步处理相关序列。完全匹配无法发现同源关系、共享抗体谱系或抗原重叠;抗体模型评价指南讨论了这些更广泛的分组问题。

处理完报告后,保存原始 FASTA、规范化规则、最终编号映射和数据划分。修改序列后,重新生成对应指纹和向量。合并时核对编号与序列指纹,不要只依赖数组中的行顺序。

通过检查并不代表标签正确,也不能证明数据完全没有泄漏。标准化、特征选择等需要从数据学习参数的步骤,仍应只在训练集上拟合,参见 scikit-learn 的数据泄漏说明

常见问题

提示或现象 检查方法
sequence before first header 序列数据前必须有 FASTA 标题,标题的 > 应位于行首。
empty FASTA header > 后填写有意义的记录编号。
duplicate_id 检查两条记录,不要直接保留最后一条。
unsupported= 查看原始字符,并记录适用于当前数据集的处理规则。
over_limit 核对编码器可用的残基预算,明确长序列处理方法。
退出状态 1 打开已生成的 CSV,检查问题记录和重复序列。

报告中的问题解决后,再运行组成基线并保存划分,为后续向量实验留下可追溯的输入数据。

资料核对日期:2026 年 9 月 23 日。封面为 AI 生成的概念插图。

友情链接

其它