难度:★★★(综合课;不需要生物背景)
教材:生信入门/(课程 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 六个错误"的清单)
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)
--impl=work(默认):测学生写的--impl=ref:测参考解(开发课程时自检)--impl=template:测模板(应该全部失败)再看 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 确认任务真的留了空。
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) # 校验通过才改名到正式位置
...
三个关键动作:
datasets.py 里的 Dataset):URL + sha256 + 解压目标。sha256 是内容的"指纹",保证你拿到的和作者当时用的是同一份
.part,校验通过才改名:和课程发布器的"原子替换"同一思想——半截文件永远不会被当成正式数据
这是"可复现科研(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——用参考解生成一份缓存当输入即可。
这是"降低学习摩擦"的设计。
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"——失败信息永远告诉下一步怎么做。
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 有两种"失败"——
assert 不成立):说明你写了,但结果不对NotImplementedError):说明你还没写模板里未实现的函数统一写 raise NotImplementedError("任务N 函数名"),
hook 捕获它并单独列成"TODO 清单"。学习者看到的不再是一片红,而是待办列表——
从"我全错了"变成"我还没开始这几项",心理体验完全不同。
这是"产品思维"落到技术细节里:错误提示不只是给机器看的,更是给人看的。
每个实验都有能被外部核对的已知答案,写进测试:
| 实验 | 核心任务 | 已知答案(来源可查) |
|---|---|---|
| 1 序列基础 | 解析 FASTA、GC 含量、ORF 查找、翻译 | TP53 mRNA 最长 ORF = 393 个氨基酸,起始 MEEPQSDPSV |
| 2 差异表达 | 覆盖度→计数、过滤、PCA、PyDESeq2 | dex 处理组 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(均值比)用来展示"旧版错在哪",对比教学比只讲正确做法有效得多。
归档/README.md)v1 被完整归档,并附了一张"v1 六个错误"清单:
GC 分母把 N/X 算进去、RNA 反向互补出现 T、非法字符未过滤、
模板语法错误、编造表达数据且无标准化/检验、文档写死绝对路径。
没有偷偷删掉,而是列出来。 这有三个作用:
AGENTS.md)项目根有一份 AGENTS.md,开头写明"给 AI 助手看的工作区约定":
.venv\Scripts\python.exe,依赖锁定在 requirements.txtNotImplementedError 怎么用改模板/参考解后必须验证 check --ref 全过且 check --template 全失败
这和开源项目的 CONTRIBUTING.md、团队的编码规范是同一种东西:
把"默契"变成"明文",让人(和 AI)参与时不跑偏。
| 教学项目(本课) | 生产项目 | |
|---|---|---|
| 首要目标 | 让人学会 | 稳定提供价值 |
| 错误处理 | 区分"没写/写错",给下一步提示 | 记录日志、告警、降级 |
| 数据 | 小而真实 + 可核对答案 | 全量 + 权限控制 |
| 代码 | 三态(模板/参考解/交付) | 单一实现 + 测试 |
目标不同,设计就不同——能识别"这是教学场景",本身就是判断力。
requirements.txt;科研界用 conda/容器(Docker)course.cmd fetch/check;科研界用 Snakemake/Nextflow在线判题系统(LeetCode/牛客)也是"提交 → 自动跑测试 → 给结果"。
你的 course check 是它的本地版,而且多了"TODO 清单"这类教学设计。
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 仍然全过。
自动判分、已知答案核对
--impl 开关 + importlib 动态加载;一套测试给三种实现判分.part 后校验再改名";错误信息必须告诉用户下一步NotImplementedError,区分"没写"和"写错"——产品思维的体现AGENTS.md / CONTRIBUTING 的作用:把默契变成明文,让协作者(含 AI)不跑偏| 术语 | 人话解释 |
|---|---|
| 可复现性 | 别人能拿到相同数据、环境、步骤得到相同结果 |
| sha256 校验 | 用文件指纹确认内容没变 |
| 原子替换 | 一次操作要么完成要么不发生 |
| pytest 钩子(hook) | 框架预留的扩展点,在特定时机执行你的代码 |
| fixture | pytest 的测试环境准备机制 |
| skip | 条件不足时跳过测试(而不是失败) |
| 三态代码 | 模板 / 参考解 / 交付三种版本 |
| ORA / GSEA | 两种基因富集分析方法 |
| PyDESeq2 | 用负二项模型做差异表达的库 |
| 归档 | 把历史版本原样保存,便于追溯 |
这一轮增补覆盖了作者在 2026-09-13 之后的全部新进展:
| 新课 | 主题 | 教材 |
|---|---|---|
| 13 | 前端工程化:Vite + TypeScript | worry 新前端 |
| 14 | 流式会话进阶:锁 / 快照回滚 / 打断 | worry 后端 |
| 15 | 结构化输出实战:AI 总结 + 信源 + 关联 | 查资料 |
| 16 | 安全设计:白名单 / 令牌 / 签名链接 | phone-remote |
| 17 | 数据流水线:采集 / 去重 / 打分 / 预算 | paper-follower |
| 18 | 纯函数与脱离平台测试 | wechat-map-tools |
| 19 | 零依赖构建与交付验收 | portfolio-site + course-publisher |
| 20 | 数据分析实验工程化 | 生信入门 v2 |