临床 Python 进阶路线图
⌕ /
路线图 › 第四阶段 · AI 与工程化

第 17 章 · 工程化与后续进阶

本章目标:把你的 Python 代码从"能跑的脚本"变成"可交付的工程"。 这一章是"业余"与"专业"的分界线。


17.1 Git:临床程序员必须掌握的版本控制

SAS 时代的版本管理通常是:t_demo_v2.sas、t_demo_v2_final.sas、 t_demo_v2_final_20240513.sas……

Python 世界的答案只有一个:Git。

15 分钟上手(需要记住的命令就这几个)

Shell
# 一次性配置
git config --global user.name  "Your Name"
git config --global user.email "you@example.com"

# 日常循环(99% 的时间只用这四条)
git status                      # 看哪些文件改了
git diff                        # 看具体改了什么
git add <文件>                  # 把改动加入暂存区
git commit -m "说明这次改了什么"  # 提交(形成历史快照)

# 同步到远端
git push

# 查看历史
git log --oneline -20
git log -p <文件>               # 看某文件的完整变更历史

# 撤销(重要)
git restore <文件>              # 丢弃未提交的修改
git restore --staged <文件>     # 取消暂存
git revert <commit>             # 生成一个"反向提交"(安全,推荐)

分支:为每个 TLF 或每个需求开分支

Shell
git checkout -b feat/table02-teae     # 新建并切到分支
# ... 写代码、提交 ...
git push -u origin feat/table02-teae  # 推到远端
# 在 GitHub 上开 Pull Request → 同事 review → 合并

💡 为什么临床编程特别需要 PR(Pull Request): 它天然是一个 QC 工作流——改动被展示、被评论、被批准, 每一步都留痕。这比"邮件发程序给 QC 同事"可追溯得多。 PHUSE 甚至有专门的工作组研究 The Use of Git in Statistical Programming。

.gitignore:什么绝不能提交

GITIGNORE
# 虚拟环境
.venv/
venv/
__pycache__/
*.pyc

# 数据(体积大 / 可能敏感)
data/raw/
*.xpt
*.sas7bdat

# 输出(可重新生成)
outputs/

# 密钥(★绝对★不要提交)
.env
*.key
secrets.json

# 编辑器
.vscode/
.idea/
.ipynb_checkpoints/

🔥 最重要的一条:永远不要提交 API Key、密码、患者数据。 一旦 push 到远端,即使后来删除,历史记录里依然存在。 补救需要用 git filter-repo 重写历史——非常麻烦。 防范成本远低于补救成本。


17.2 项目结构:建立一个可复用的模板

文本
my-clinical-analysis/
├── README.md                  # 项目说明:做什么、怎么跑
├── requirements.txt           # 依赖清单(可复现的前提)
├── .gitignore
├── .env.example               # 环境变量模板(不含真实值)
├── data/
│   ├── README.md              # 数据来源与说明
│   ├── raw/                   # 原始数据(gitignore)
│   └── samples/               # 小样本(可提交)
├── src/ 或 clinic/            # 你自己的工具包
│   ├── __init__.py
│   ├── io.py                  # 读写
│   ├── derive.py              # 派生逻辑
│   ├── report.py              # 报表构造
│   └── qc.py                  # QC 检查
├── programs/                  # 生产脚本(每个 TLF 一个)
│   ├── t01_demographics.py
│   └── t02_teae.py
├── tests/                     # 单元测试
│   ├── test_derive.py
│   └── test_report.py
├── metadata/                  # 规格文档(变量级)
│   └── adsl_spec.xlsx
├── outputs/                   # 产出(gitignore)
└── docs/                      # 文档、SAP 摘录、ADRG

为什么这个结构重要

目录 对应 SAS 世界的什么
clinic/ 你的宏库(%include 的那些)
programs/ 每个 TLF 的 .sas 程序
tests/ SAS 世界里通常缺失的一块,见 17.3
metadata/ 变量级规格(SAS 里是 Excel 或 define.xml)
outputs/ 输出目录

17.3 单元测试:把 QC 变成代码

这是 Python 相对 SAS 最大的增量价值之一。

在 SAS 世界里,"验证"通常靠: 1. 独立编程(双编程)+ PROC COMPARE 2. 人工看日志 3. 人工抽查输出

这些方法每次都要重做。而在 Python 里,你可以把验证写成测试代码, 一次编写,永久运行。

Python
"""tests/test_report.py —— 报表工具的单元测试"""
import numpy as np
import pandas as pd
import pytest

from clinic.report import pct_format, summarize_continuous
from clinic.derive import sas_round_series, derive_agegr1


class TestSasRound:
    """验证舍入规则与 SAS 一致(这是最容易出错的地方)"""

    @pytest.mark.parametrize("value,expected", [
        (2.5, 3.0), (1.5, 2.0), (0.5, 1.0), (-2.5, -3.0), (-1.5, -2.0),
        (2.4, 2.0), (2.6, 3.0),
    ])
    def test_round_half_away_from_zero(self, value, expected):
        """SAS 的 ROUND 是"四舍五入(远离零)",而 Python 内置 round 是银行家舍入"""
        assert sas_round_series(pd.Series([value]), 0)[0] == expected

    def test_nearest_unit(self):
        """ROUND(x, 10) 应舍入到最近的 10"""
        assert sas_round_series(pd.Series([1234.0, 1235.0, 1236.0]), 0)[0] == 1234


class TestPctFormat:
    def test_zero_shows_zero(self):
        assert pct_format(0, 86) == "0"

    def test_basic(self):
        assert pct_format(26, 86) == "26 (30.2%)"

    def test_zero_denominator(self):
        assert pct_format(5, 0) == "-"

    def test_rounding(self):
        # 1/3 = 33.333...% → 33.3%
        assert pct_format(1, 3) == "1 (33.3%)"


class TestSummarizeContinuous:
    def test_uses_sample_sd(self):
        """SD 必须用 ddof=1,与 SAS 的 STD 一致"""
        s = pd.Series([63.0, 64.0, 71.0, 74.0, 55.0])
        res = summarize_continuous(s)
        assert res["n"] == 5
        assert res["sd"] == pytest.approx(round(s.std(ddof=1), 1))
        assert res["sd"] != pytest.approx(round(s.std(ddof=0), 1))


class TestAgeGr1:
    """★ 边界测试:这是分组派生最容易出错的地方"""

    @pytest.mark.parametrize("age,expected", [
        (64, "<65"),
        (65, "65-80"),      # 65 属于 65-80(左闭)
        (80, "65-80"),      # 80 属于 65-80(右闭)★ 最易错
        (81, ">80"),
        (51, "<65"),
        (89, ">80"),
    ])
    def test_boundaries(self, age, expected):
        df = pd.DataFrame({"AGE": [age]})
        assert derive_agegr1(df)["AGEGR1"].iloc[0] == expected

运行:

Shell
pip install pytest
pytest tests/ -v

输出:

文本
tests/test_report.py::TestSasRound::test_round_half_away_from_zero[2.5-3.0] PASSED
tests/test_report.py::TestPctFormat::test_zero_shows_zero PASSED
tests/test_report.py::TestAgeGr1::test_boundaries[80-65-80] PASSED
...
======================== 21 passed in 0.34s ========================

🧠 为什么这对临床编程是革命性的: 1. 边界条件被永久固定。AGE=80 应该归到哪一组, 这个规则现在写在测试里,任何人改动代码导致边界变化,测试会立刻失败。 2. QC 有据可依。测试文件本身就是一份可执行的验证文档。 3. 重构变安全。优化代码后跑一遍测试,就知道有没有改坏东西。 4. 可挂 CI。每次提交自动跑测试(GitHub Actions), 相当于有了一个 24 小时值班的 QC。

与 SAS 的双编程对照

SAS 传统 QC Python + 测试
边界规则 放在 SAP 文档里(人读) 放在测试里(机器跑)
每次修改 重新双编程 pytest 自动验证
覆盖范围 关键输出 可以做到函数级全覆盖
成本 高(人力) 前期投入,长期摊薄

💡 务实建议:不必追求 100% 覆盖率。 优先给这几类代码写测试: ① 边界敏感的派生(年龄/体重分组、期别划分) ② 数值格式化(舍入、百分比) ③ 人群标记(SAFFL/ITTFL 等) 这三类占了临床 bug 的大半。


17.4 依赖管理:让结果可复现

Shell
# 导出当前环境的精确依赖
pip freeze > requirements.txt

# 更好的做法:区分直接依赖与传递依赖
pip install pip-tools
# requirements.in 里写你直接用的包
# requirements.txt 由 pip-compile 生成(含精确版本)
pip-compile requirements.in

requirements.txt 应该写什么:

文本
# requirements.in —— 直接依赖
pandas>=2.2
numpy>=1.26
pyreadstat>=1.2
matplotlib>=3.8
openpyxl>=3.1
requests>=2.31
pytest>=8.0
文本
# requirements.txt —— 锁定版本(由 pip-compile 生成,用于复现)
pandas==2.2.3
numpy==2.1.3
pyreadstat==1.2.8
...

🔥 临床场景的硬要求:所有的分析结果必须可复现。 这意味着必须记录:Python 版本、包版本、操作系统、随机种子。 ```python import sys, platform print(sys.version) print(platform.platform()) import numpy, pandas; print(numpy.version, pandas.version)

随机种子:任何涉及随机的步骤都要固定

rng = np.random.default_rng(20240101) ```


17.5 CI:自动跑测试(GitHub Actions)

在仓库里加一个 .github/workflows/tests.yml:

YAML
name: tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12"]

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - name: 安装依赖
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
      - name: 运行测试
        run: pytest tests/ -v
      - name: 运行案例脚本(冒烟测试)
        run: |
          python cases/case01_数据体检.py
          python cases/case02_人口学表.py

💡 这样一来,每次你 push 代码,GitHub 会自动跑一遍全部测试和案例。 如果某个案例跑挂了,你会收到通知。 这在 SAS 世界里是不可想象的自动化程度。


17.6 合规与实践考量(真实环境落地)

这一节是"技术上能做"与"实际上能上"之间的差距。

议题 关键问题 实务做法
软件验证 Python 是开源的,怎么"验证"它? 参考 FDA 2021 Statistical Software Clarifying Statement;做 IQ/OQ/PQ;用开源验证框架(PHUSE 的 valtools)
包的可信度 谁能保证 pandas 不算错? 优先选有大量用户、有测试套件、有学术引用的包;关键算法与 SAS 对照验证
环境锁定 保证 3 年后还能跑出同样结果 容器化(Docker)+ 锁定版本 + 归档环境快照
变更控制 改一行代码要走什么流程 Git PR + Review + 回归测试;关键变更走变更控制流程
文档 监管要看什么 ADRG(Analysis Data Reviewer's Guide)、代码清单、测试报告
数据安全 患者数据不能出环境 本地/内网部署;不把数据放进公开 LLM
可追溯 结果→代码→数据 的链条 Git commit hash 记录在输出日志里

行业参考资料(值得通读)

资源 内容
PHUSE OSTCDA
https://phuse-org.github.io/OSTCDA/
《Open Source Technology in Clinical Data Analysis》——行业专家对开源落地所有关键问题的众包答案(是否想用、能不能用、值不值得用),必读
PHUSE CAMIS
https://psiaims.github.io/CAMIS/
SAS/R/Python 统计方法实现差异的比对库,跨语言验证的权威参考
CDISC CORE
https://cdisc-org.github.io/cdisc-rules-engine
官方开源的规则引擎,本身就是用 pandas/dask 实现的
FDA Statistical Software Clarifying Statement (2021) 明确开源工具可用于提交
phuse-org/git-in-statistical-programming Git 在统计编程中的行业工作组

💡 ADRG 里必须写的一段话(示例): "本分析使用 Python 3.12.3 + pandas 2.2.3 完成。 所有统计量的计算逻辑已通过与 SAS 9.4 独立实现的结果对照验证 (见 validation/proc_compare_report.html)。 舍入规则遵循 SAS ROUND 的'四舍五入'语义,实现于 clinic/derive.py::sas_round。 环境与代码版本记录于 environment.lock。"


17.7 后续学习路径

你已经走完了从 SAS 到 Python 的主干。接下来可以按兴趣分支:

方向 A:把 Python 用到极致(临床方向)

文本
✅ 已完成:pandas 数据处理、TFL 生成、QC 自动化
→ 深入:DuckDB / Polars(大数据量)
→ 深入:great_tables(专业报表排版)
→ 深入:Plotly / Altair(交互式可视化,给 DMC 用)
→ 深入:statsmodels / lifelines / pymer4(统计建模)
→ 深入:CDISC CORE 源码(学习用 pandas 写规则引擎)
→ 深入:package 化(把你的一套工具做成内部 pip 包)

方向 B:数据科学 / 机器学习

文本
→ scikit-learn:从 PROC REG/LOGISTIC 迁移到更广的模型
→ 特征工程、交叉验证、模型评估
→ 生存分析机器学习(RSF、DeepSurv)
→ 真实世界数据(RWD):OMOP CDM、MIMIC-IV
→ 因果推断(倾向性评分、Doubly Robust)

方向 C:AI Agent 与自动化(你选的方向)

文本
✅ 已完成:LLM API 调用、工具调用、单 Agent
→ RAG:让 Agent 读 CDISC 规范 / SAP / define.xml
→ 多 Agent 编排:LangGraph / OpenAI Agents SDK
→ MCP:把临床工具封装成标准协议服务
→ 本地模型:Ollama + Qwen / DeepSeek,数据不出内网
→ Agent 评测:怎么证明 Agent 的输出是可靠的

必读的行业论文(按优先级)

见 resources/03-论文与技术资料.md, 重点推荐:

  1. PharmaSUG 2026 ET-223 — Python 做临床统计编程的完整工作流 ⭐⭐⭐
  2. PharmaSUG 2019/2020 AP-212/AP-255 — Python-izing the SAS Programmer 系列 ⭐⭐⭐
  3. PharmaSUG 2026 AI-305 / AI-332 — AI 辅助代码迁移与 ADaM 标准化 ⭐⭐⭐
  4. PHUSE OSTCDA — 开源落地的行业共识 ⭐⭐⭐
  5. PHUSE CAMIS — 跨语言统计实现差异 ⭐⭐

17.8 动手练习

  1. 初始化 Git:把这个项目 clone 到你自己的 GitHub, 修改一个文件,走一遍 status → diff → add → commit → push。

  2. 写测试:给第 13 章的 pct_format() 和 summarize_continuous() 写测试,至少覆盖:正常情况、分母为 0、分子为 0、边界舍入。

  3. 边界测试:给"AGEGR1 派生"写边界测试(64/65/80/81 四个关键值)。

  4. 加 CI:给仓库加 GitHub Actions 配置,让每次 push 自动跑测试。

  5. 写 ADRG 片段:为自己写的一段分析代码,写一份简短的 "分析数据评审指南"摘录,说明:环境、依赖版本、关键算法、 与 SAS 对照的验证方式、舍入规则。

  6. (终极练习) 挑一个你工作里真实的小需求 (哪怕是"把 50 个日志文件里的 ERROR 汇总成 Excel"), 用 Python 完整实现,走 Git 提交,写测试。 做完这一步,你就真正完成迁移了。


上一章 ← 第 16 章 · Agent 开发入门 回到 第 00 章 · 学习路线图