← 代码课堂

第 20 课:数据分析实验的工程化

难度:★★★(综合课;不需要生物背景)
教材:生信入门/(课程 v2:4 个真实数据实验 + 自动判分)
预计时间:讲解 70 分钟 + 练习 50 分钟
学完你能:把"一份能做出来的分析"变成"一套能复现、能教、能自动判分的实验"


课前须知:会做 ≠ 能复现、能教

自己跑通一次分析很简单;难的是:

这个项目把"生信入门课程"当成软件工程来做:数据有校验、代码有三态、
测试自动判分、错误进档案。本课拆它的四个支柱。


第一步:跑起来

项目根有一个 course.cmd:

cd 生信入门
course.cmd status                 REM 看每个实验:数据有没有、交付有没有
course.cmd fetch 1                REM 下载实验1数据(带 sha256 校验)
course.cmd start 1                REM 把模板复制到"交付/"让你开始填
course.cmd check 1                REM 跑测试判分(默认测你写的)
course.cmd check 1 --template     REM 测模板:应该"全部卡在 TODO"
course.cmd check 1 --ref          REM 测参考解:应该全过

观察重点:check 1 --template 的输出很特别——它把失败分成
"还没写的 TODO",而不是一坨红色报错。这是刻意设计的(后面讲)。


第二步:模块地图

生信入门/
├── course.cmd              入口,调用 tools/course.py
├── conftest.py             自动判分的公共设施(pytest 插件)
├── pytest.ini              pytest 配置
├── AGENTS.md               给 AI 助手的"工作区约定"(环境/结构/规则)
├── tools/
│   ├── course.py           命令:status / fetch / start / check / run
│   ├── datasets.py         数据清单:URL + sha256 + 解压
│   └── de_cache.py         实验3 的输入缓存(跑实验2 参考解)
├── 实验/实验N_名称/
│   ├── 实验说明.md          问题与数据/目标/前置/分步任务/验收/常见错误/思考题
│   ├── 前置知识.md          "生物概念 ↔ 编程实现"对照表
│   ├── 数据/               只放很小的内置数据
│   ├── 模板/<模块>.py       未实现处 raise NotImplementedError("任务N 函数名")
│   ├── 参考解/<模块>.py     标准答案
│   ├── 测试/test_labN.py    测试名以"任务N_"开头
│   └── 交付/               学生的产出
└── 归档/                   v1 原样存档(含"v1 六个错误"的清单)

第三步:逐块精讲

块 1:三态目录 —— 模板 / 参考解 / 交付(conftest.py 第 22-36 行)

同一份代码有三个版本,用一个开关切换:

def pytest_addoption(parser):
    parser.addoption("--impl", default="work", choices=list(course.IMPL_DIRS),
                     help="测哪份代码:work=交付/,ref=参考解/,template=模板/")

def load_lab(n: int, impl: str):
    path = course.module_path(n, impl)
    if not path.exists():
        pytest.skip(f"没找到 {path.relative_to(ROOT)},先运行 course start {n}")
    return course.load_module(n, impl)

再看 course.py 怎么动态加载指定实现(第 43-50 行):

def load_module(n: int, impl: str):
    path = module_path(n, impl)
    name = f"lab{n}_{impl}_{LABS[n][1]}"
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec)
    sys.modules[name] = mod
    spec.loader.exec_module(mod)
    return mod

三个同名模块 seqtools.py(模板/参考解/交付),
靠 importlib 按需加载不同路径,还起了不同的模块名避免冲突。
测试用 load_lab(N, impl) 拿到"当前该测的那份"。

这个设计的好处:一套测试,能分别给"学生答案""标准答案""空模板"判分——
开发课程时跑 --ref 确认答案对,跑 --template 确认任务真的留了空。

块 2:可复现的数据(datasets.py 第 121-148 行)

def fetch(key: str, log=print) -> Path:
    d = DATASETS[key]
    dest = path_of(key)
    if dest.is_file() and (not d.sha256 or sha256_of(dest) == d.sha256):
        log(f"  已有  {d.path}")
    else:
        dest.parent.mkdir(parents=True, exist_ok=True)
        part = dest.with_suffix(dest.suffix + ".part")          # 先下到 .part
        req = urllib.request.Request(d.url, headers={"User-Agent": "bioinfo-course/2.0"})
        with urllib.request.urlopen(req, timeout=120) as resp, open(part, "wb") as out:
            shutil.copyfileobj(resp, out)
        got = sha256_of(part)
        if d.sha256 and got != d.sha256:                        # 校验
            part.unlink()                                       # 失败就删掉,不留坏文件
            raise RuntimeError(
                f"{d.path} 校验失败:期望 sha256={d.sha256},实际 {got}。\n"
                "可能是网络中断或上游文件变了,重新运行一次;仍失败请告诉助手。"
            )
        part.replace(dest)                                      # 校验通过才改名到正式位置
        ...

三个关键动作:

  1. 数据登记(datasets.py 里的 Dataset):URL + sha256 + 解压目标。

sha256 是内容的"指纹",保证你拿到的和作者当时用的是同一份

  1. 先下到 .part,校验通过才改名:和课程发布器的"原子替换"同一思想——

半截文件永远不会被当成正式数据

  1. 错误信息可操作:说清"可能是什么原因、你可以怎么做"

这是"可复现科研(reproducible research)"的最小实践:
数据版本化 + 环境锁定(requirements.txt)+ 一键获取。

顺带看 cmd_fetch 里的一段巧思(course.py 第 61-66 行):

if 3 in labs:
    # 实验 3 的输入是实验 2 的差异表达结果。用参考解算一份放进缓存,
    # 这样实验 3 不会因为实验 2 没做完而卡住(你也可以换成自己的结果)。
    import de_cache
    de_cache.ensure(log=print)

实验之间有依赖(实验3 要用实验2 的输出),
但课程不强迫你先做完实验2——用参考解生成一份缓存当输入即可。
这是"降低学习摩擦"的设计。

块 3:自动判分(course.py 第 96-107 行)

def cmd_check(args) -> int:
    n = int(args.lab)
    impl = "ref" if args.ref else "template" if args.template else "work"
    if impl == "work" and not module_path(n, "work").exists():
        print(f"交付/{LABS[n][1]}.py 还不存在,先运行 course start {n}。")
        return 1
    cmd = [sys.executable, "-m", "pytest", str(lab_dir(n) / "测试"), f"--impl={impl}", "-q"]
    if args.k:
        cmd += ["-k", args.k]
    if args.x:
        cmd.append("-x")
    return subprocess.call(cmd, cwd=ROOT)

它只是组装并调用 pytest——判分逻辑全写在测试里。
这就是"工具脚本"的正确姿势:薄封装,真正的规则在测试文件里。

测试长什么样?测试名直接写任务描述,学生一眼看懂考点:

def test_任务1_GC含量_分母只算确定碱基(...): ...

配合 require_data(n)(conftest.py 第 39-42 行):没数据就 pytest.skip,
提示"先运行 course fetch n"——失败信息永远告诉下一步怎么做。

块 4:TODO 清单 —— 区分"没写"和"写错"(conftest.py 第 45-61 行)

这是全项目对学习者最友好的设计:

def pytest_runtest_makereport(item, call):
    # 模板里没填的函数会抛 NotImplementedError。记下来,结尾单独列一张"还没写"清单,
    # 一眼看出"是还没写"而不是"写错了"。(返回 None,报告仍由 pytest 默认逻辑生成)
    if call.excinfo is not None and call.excinfo.errisinstance(NotImplementedError):
        item.user_properties.append(("todo", str(call.excinfo.value)))

def pytest_terminal_summary(terminalreporter):
    todos = []
    for rep in terminalreporter.stats.get("failed", []) + terminalreporter.stats.get("error", []):
        for key, value in getattr(rep, "user_properties", []):
            if key == "todo":
                todos.append((rep.nodeid.split("::")[-1], value))
    if todos:
        terminalreporter.section("还没写的 TODO(不是写错,是还没填)")
        for test, msg in todos:
            terminalreporter.write_line(f"  {test:<40} {msg}")

原理:pytest 有两种"失败"——

模板里未实现的函数统一写 raise NotImplementedError("任务N 函数名"),
hook 捕获它并单独列成"TODO 清单"。学习者看到的不再是一片红,而是待办列表——
从"我全错了"变成"我还没开始这几项",心理体验完全不同。

这是"产品思维"落到技术细节里:错误提示不只是给机器看的,更是给人看的。

块 5:用"文献已知答案"验收(各实验测试)

每个实验都有能被外部核对的已知答案,写进测试:

实验核心任务已知答案(来源可查)
1 序列基础解析 FASTA、GC 含量、ORF 查找、翻译TP53 mRNA 最长 ORF = 393 个氨基酸,起始 MEEPQSDPSV
2 差异表达覆盖度→计数、过滤、PCA、PyDESeq2dex 处理组 553 上调 / 485 下调,FKBP5 显著上调
3 富集分析超几何 ORA + GSEA + BH 校正上调第一 TNFA_SIGNALING_VIA_NFKB,下调第一 P53_PATHWAY
4 单细胞入门QC 过滤、标准化、Leiden 聚类、标记注释PBMC 3k 过滤后约 2638 细胞 / 13714 基因,8 类细胞

为什么这很重要:如果测试只是"跑通不报错",那学生写错公式也能过。
用领域已知答案(能被文献/数据库复核)做断言,才真正验证了"分析对不对"。

顺带一提,实验里还特意保留了"错误示范"——比如实验2 的
naive_fold_change(均值比)用来展示"旧版错在哪",对比教学比只讲正确做法有效得多。

块 6:把错误当资产(归档/README.md)

v1 被完整归档,并附了一张"v1 六个错误"清单:
GC 分母把 N/X 算进去、RNA 反向互补出现 T、非法字符未过滤、
模板语法错误、编造表达数据且无标准化/检验、文档写死绝对路径。

没有偷偷删掉,而是列出来。 这有三个作用:

  1. 学习者能对照"常见错误"
  2. 未来的自己不会重蹈覆辙
  3. 诚实地展示"版本演进"——这本身就是一种工程素养

块 7:给协作者写"约定"(AGENTS.md)

项目根有一份 AGENTS.md,开头写明"给 AI 助手看的工作区约定":

改模板/参考解后必须验证 check --ref 全过且 check --template 全失败

这和开源项目的 CONTRIBUTING.md、团队的编码规范是同一种东西:
把"默契"变成"明文",让人(和 AI)参与时不跑偏。


第四步:对照开源,别人怎么做教学与科研工程

对照:教学项目 vs 生产项目

教学项目(本课)生产项目
首要目标让人学会稳定提供价值
错误处理区分"没写/写错",给下一步提示记录日志、告警、降级
数据小而真实 + 可核对答案全量 + 权限控制
代码三态(模板/参考解/交付)单一实现 + 测试

目标不同,设计就不同——能识别"这是教学场景",本身就是判断力。

对照:可复现科研

对照:自动判分(OJ)

在线判题系统(LeetCode/牛客)也是"提交 → 自动跑测试 → 给结果"。
你的 course check 是它的本地版,而且多了"TODO 清单"这类教学设计。

对照:pytest 插件机制

conftest.py 里的 pytest_addoption / pytest_runtest_makereport /
pytest_terminal_summary 都是 pytest 的钩子(hook)。
框架留钩子、你用钩子扩展——这和第 06 课的事件总线是同一思想。


动手练习(做完才算过关)

练习 1(热身):三态都测一遍

course.cmd fetch 1
course.cmd start 1
course.cmd check 1 --template     REM 全是 TODO
course.cmd check 1 --ref          REM 全过

对比两种输出,理解"模板失败是设计,不是 bug"。

练习 2(必做):填一个功能
打开 实验/实验1_序列基础/交付/seqtools.py,找到 GC 含量那个 TODO,
打开 参考解/seqtools.py 对照着理解逻辑(别直接复制),自己写一遍,
然后 course.cmd check 1 直到通过。

练习 3(必做):看"没写 vs 写错"的区别
先把某个已通过函数的返回值改错,跑 course check 1(属于"写错");
再把它改回 raise NotImplementedError("任务X 测试"),再跑(属于"没写")。
观察输出里 TODO 清单的变化。

练习 4(必做):验证数据校验
手动改坏 data/ 里某个已下载文件的一个字节(或删掉),
再跑 course fetch 1,看它是否重新下载并校验。

练习 5(思考题):
为什么实验 3 的输入要用"实验 2 参考解的缓存",而不是强制学习者先做完实验 2?

看答案

从"卡住"和"学习节奏"两个角度想。

练习 6(选做):加一条测试
给实验 1 加一条边界测试:空序列的 GC 含量应该怎么处理?
先看参考解是怎么处理的(除零保护?返回 0?),再把这条断言加进
测试/test_lab1.py,并确保 check --ref 仍然全过。


本课小结

自动判分、已知答案核对

术语表

术语人话解释
可复现性别人能拿到相同数据、环境、步骤得到相同结果
sha256 校验用文件指纹确认内容没变
原子替换一次操作要么完成要么不发生
pytest 钩子(hook)框架预留的扩展点,在特定时机执行你的代码
fixturepytest 的测试环境准备机制
skip条件不足时跳过测试(而不是失败)
三态代码模板 / 参考解 / 交付三种版本
ORA / GSEA两种基因富集分析方法
PyDESeq2用负二项模型做差异表达的库
归档把历史版本原样保存,便于追溯

增补课程总结(第 13-20 课)

这一轮增补覆盖了作者在 2026-09-13 之后的全部新进展:

新课主题教材
13前端工程化:Vite + TypeScriptworry 新前端
14流式会话进阶:锁 / 快照回滚 / 打断worry 后端
15结构化输出实战:AI 总结 + 信源 + 关联查资料
16安全设计:白名单 / 令牌 / 签名链接phone-remote
17数据流水线:采集 / 去重 / 打分 / 预算paper-follower
18纯函数与脱离平台测试wechat-map-tools
19零依赖构建与交付验收portfolio-site + course-publisher
20数据分析实验工程化生信入门 v2