春江暮客

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

用 Python 和 SHA-256 清单检查实验数据是否改变

2026-09-27 技术
用 Python 和 SHA-256 清单检查实验数据是否改变

输入文件变了,固定随机种子也无法复现原来的实验。标签被修正、划分表被重新生成,或者 FASTA 文件被替换后,文件名可能没变,实验输入却已经不同。

这篇教程用一个小型 Python 命令记录目录中的文件路径和 SHA-256 摘要,并在下次运行前核对。它可以接在 FASTA 检查和按组划分数据集之后:先检查数据,再固定划分,最后记录实际使用的文件。

1. 确定清单记录哪些文件

把一次实验需要的输入放进独立目录。模型输出、缓存、日志和清单本身都放在目录之外。

experiment/
  inputs/
    proteins.fasta
    labels.csv
    split.csv
  inputs-v1.json
  dataset_manifest.py

清单把每个文件的相对路径映射到摘要。整体移动输入目录不会改变这份映射。重命名文件则会被记录为旧路径缺失、新路径新增。

这里检查的是文件字节。重新折行 FASTA 序列或转换换行符,都会改变文件摘要,即使解析后的序列相同。原始文件摘要用于追踪输入版本;如果还需要按序列去重,应另存规范化后的序列标识,并说明规范化规则。

脚本包含隐藏文件,拒绝符号链接和特殊文件,忽略空目录。它不记录权限和修改时间。请对已经停止写入的本地目录使用它;扫描过程不是原子的文件系统快照。

2. 下载并运行示例

需要 Python 3.11 或更新版本,无须安装第三方依赖。示例已在 macOS 的 Python 3.14.7 上测试。哈希计算使用 Python 3.11 引入的 hashlib.file_digest,以二进制模式读取文件。

下载 dataset_manifest.py,或复制后面的完整代码。把脚本保存到一个新的工作目录,再执行以下命令。Shell 示例适用于 Bash 或 Zsh。

python3 --version
python3 dataset_manifest.py --help
mkdir inputs
python3 - <<'PY'
from pathlib import Path
Path("inputs/proteins.fasta").write_bytes(b">p1\nACDE\n")
Path("inputs/labels.csv").write_bytes(b"sample_id,label\np1,1\n")
Path("inputs/split.csv").write_bytes(b"sample_id,split\np1,train\n")
PY
python3 dataset_manifest.py create inputs inputs-v1.json
python3 dataset_manifest.py verify inputs inputs-v1.json

最后两条命令应输出:

CREATED: 3 files
OK: 3 files match

这三个文件是人为构造的测试输入,不能用于实际训练。脚本只创建新清单:如果 inputs-v1.json 已经存在,会报错而不会覆盖。排查意外改动时,需要保留原始清单。

3. 完整脚本

"""Byte-level manifest for a quiet local dataset directory. Python 3.11+."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import sys


def scan(root):
    def fail(error):
        raise error

    files = {}
    for folder, dirs, names in os.walk(root, onerror=fail, followlinks=False):
        for name in dirs + names:
            path = Path(folder) / name
            mode = path.lstat().st_mode
            if stat.S_ISLNK(mode):
                raise ValueError(f"Symlink is not supported: {path}")
            if stat.S_ISDIR(mode):
                continue
            if not stat.S_ISREG(mode):
                raise ValueError(f"Not a regular file: {path}")
            with path.open("rb") as handle:
                digest = hashlib.file_digest(handle, "sha256").hexdigest()
            files[path.relative_to(root).as_posix()] = digest
    return files


def read_manifest(path):
    data = json.loads(path.read_text(encoding="utf-8"))
    if (not isinstance(data, dict) or data.get("version") != 1
            or data.get("algorithm") != "sha256"
            or not isinstance(data.get("files"), dict)):
        raise ValueError("Unsupported manifest format")
    for name, digest in data["files"].items():
        if (not name or not isinstance(digest, str)
                or re.fullmatch(r"[0-9a-f]{64}", digest) is None):
            raise ValueError("Invalid manifest entry")
    return data["files"]


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("action", choices=["create", "verify"])
    parser.add_argument("root", type=Path)
    parser.add_argument("manifest", type=Path)
    args = parser.parse_args()
    try:
        if args.root.is_symlink():
            raise ValueError("Dataset root must not be a symlink")
        root = args.root.resolve(strict=True)
        if not root.is_dir():
            raise ValueError("Dataset root must be a directory")
        manifest = args.manifest.resolve()
        if manifest.is_relative_to(root):
            raise ValueError("Keep the manifest outside the dataset directory")
        expected = read_manifest(manifest) if args.action == "verify" else None
        current = scan(root)
        if args.action == "create":
            data = {"version": 1, "algorithm": "sha256", "files": current}
            with manifest.open("x", encoding="utf-8", newline="\n") as handle:
                handle.write(json.dumps(data, sort_keys=True, indent=2) + "\n")
            print(f"CREATED: {len(current)} files")
            return 0
        missing = sorted(expected.keys() - current.keys())
        added = sorted(current.keys() - expected.keys())
        changed = sorted(name for name in expected.keys() & current.keys()
                         if expected[name] != current[name])
        for label, names in [("MISSING", missing), ("ADDED", added),
                             ("CHANGED", changed)]:
            for name in names:
                print(f"{label}: {json.dumps(name, ensure_ascii=False)}")
        if missing or added or changed:
            return 1
        print(f"OK: {len(current)} files match")
        return 0
    except (OSError, ValueError) as error:
        print(f"ERROR: {error}", file=sys.stderr)
        return 2


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

目录扫描使用 os.walk,并设置会抛出异常的错误处理函数。无法读取的目录不能被悄悄漏掉。每个条目都先检查类型,再计算摘要,符号链接目录也会被检查。

输出按 JSON 键排序,并使用以 / 分隔的相对路径,方便在版本控制中比较。验证时,路径来自重新扫描的目录;程序比较清单中的键,不会按照 JSON 提供的路径去打开文件。

4. 验证它能发现改动

改动示例中的一个残基,同时保持文件字节数不变:

python3 - <<'PY'
from pathlib import Path
Path("inputs/proteins.fasta").write_bytes(b">p1\nACDF\n")
PY
python3 dataset_manifest.py verify inputs inputs-v1.json
printf 'exit code: %s\n' "$?"

预期输出:

CHANGED: "proteins.fasta"
exit code: 1

只比较文件大小会漏掉这次改动。新增文件会输出 ADDED,删除已记录的文件会输出 MISSING。一次验证可以同时报告三类差异。文件名采用 JSON 引号形式,名称中的换行符等特殊字符也能被区分。

退出码 含义 后续操作
0 清单创建成功,或所有路径及摘要均匹配 可以继续流程。空目录也可能通过,需要检查输出的文件数。
1 存在新增、缺失或内容变化 先核对差异,再运行实验。
2 参数不正确、输入不可读、文件类型不支持或清单格式错误 修复报错后重新验证。

在流水线中,让验证成功成为下一条命令的前提。核对改动并确定要使用的输入版本后,可以用下面的包装脚本在验证失败时退出:

#!/usr/bin/env bash
set -euo pipefail
python3 dataset_manifest.py verify inputs inputs-v1.json
# Place the real training or analysis command after the verification line.

这只能检查验证时的目录状态。分析程序读取数据期间,也要保持目录不变,或使用不可变快照。

5. 把清单和实验结果一起保存

清单应随结果保存。一起记录代码版本、环境锁文件、模型标识或检查点摘要、训练配置和随机种子。比较蛋白质模型时,输入目录应包含实际使用的划分表和标签文件,不能只记录 FASTA。

如果确实需要修正数据,写明原因,再生成 inputs-v2.json。旧结果继续保留对应的 inputs-v1.json。验证刚失败就重新生成清单,会丢掉原本要检查的版本差异。

校验和不会检查标签是否正确,也不能发现同源序列泄漏或保证模型输出一致。本地 JSON 文件同样不能证明数据来源:能同时替换数据和清单的人,可以生成一对匹配的文件。应把参考清单放在可信、有版本记录的位置。这个脚本用于受控研究流程中的意外改动检查,不用于对抗恶意的并发文件系统操作。

6. 常见问题

报错或现象 处理办法
hashlib 没有 file_digest 使用 Python 3.11 或更新版本,通过 python3 --version 确认解释器。
Keep the manifest outside the dataset directory 使用同级路径 inputs-v1.json,不要保存为 inputs/manifest.json。
创建时出现 File exists 保留现有清单,改用 verify;如果是有意更新,选择新的版本文件名。
Symlink is not supported 准备包含实际文件的输入目录,或另行设计并记录链接处理规则。
缓存或 .DS_Store 被标记为 ADDED 检查文件后,把与实验无关的文件移出输入目录。脚本有意不设置忽略规则。
序列相同却显示 CHANGED 检查 FASTA 折行、头部、换行编码和文件顺序。如果关心生物学意义上的等价性,另行比较解析后的序列。

下载脚本已检查正常验证、同长度内容改动、新增和缺失文件、目录迁移、确定性输出、已有清单保护、错误输入、符号链接、特殊文件和空目录。这些检查验证的是示例的文件处理行为,不代表任何生物学数据集已经通过验证。

保存准确的输入记录

固定输入目录,生成清单,并把它与实验结果一起保存。再次使用输入前先验证,把不匹配的情况查清楚。清单配合划分表和软件环境,能为后续比较保留明确的文件版本记录。

封面为概念插画,不代表实验测得的蛋白质结构。Python 文档核对日期:2026 年 9 月 27 日。

友情链接

其它