临床 Python 进阶路线图
⌕ /
路线图 › 实战案例 · 数据与报表

案例 06 · 批处理自动化

case06_批处理自动化.py · 336 行 · 13 KB
案例 06 · 文件与批处理自动化 =============================
Python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
案例 06 · 文件与批处理自动化
=============================

**目标**:把"一次处理一个数据集"变成"一次处理一批数据集",
并且**一个文件出错不影响其他文件**。

**覆盖教程章节**:第 05 章(文件与批处理)、第 11 章(读取 XPT)、第 17 章(工程化)

为什么值得单独写一个案例
------------------------
SAS 程序员做批处理通常靠 ``%macro`` + ``%do`` 循环 + ``dictionary.tables``。
一旦某个数据集出错,SAS 会**中断整个程序** —— 你改完再跑,又从头开始。
Python 里可以做到"逐个处理、逐个记录、出错继续",
这对"一批 50 个域文件的例行检查"是质的差别。

本案例做三件事
--------------
1. **清点(inventory)**:扫描目录,逐个读进来体检,输出一张汇总表。
2. **批量转换(convert)**:把 ``.xpt`` 批量转成 ``.csv``
   (存在则跳过,``--force`` 才覆盖)—— 幂等、可重跑。
3. **汇总导出**:把结果写成「一个域一个 sheet」的 Excel + 一份运行日志。

运行
----
    python cases/case06_批处理自动化.py                      # 清点样本目录(默认)
    python cases/case06_批处理自动化.py --action convert      # 批量 XPT → CSV
    python cases/case06_批处理自动化.py --action all --force  # 清点 + 转换(覆盖)
    python cases/case06_批处理自动化.py --dir data/raw --pattern "*.xpt"
"""

from __future__ import annotations

import argparse
import logging
import sys
import time
from pathlib import Path

import pandas as pd

BASE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(BASE))

from clinic import io as cio              # noqa: E402

pd.set_option("display.width", 200)

# 支持的文件类型(对应 SAS 里能 SET / 能 libname 的几种)
READABLE = {".csv", ".xpt", ".sas7bdat", ".xlsx", ".parquet"}

# 清点时跳过的"巨型表"(想全量扫描时用 --no-skip-large)
LARGE_HINT = {"advs", "adlbc", "adlb", "adpc", "adpp", "sv", "vs"}


def setup_logger(log_path: Path) -> logging.Logger:
    """同时往终端和文件写日志。

    对应 SAS 里的 ``proc printto log='...';`` ——
    但 pandas 的日志是**标准 logging**,可以和任何工具链(Airflow、
    调度器、Kubernetes)对接,不用解析 SAS 的 .log 文本。
    """
    log_path.parent.mkdir(parents=True, exist_ok=True)
    logger = logging.getLogger("batch")
    logger.setLevel(logging.INFO)
    logger.handlers.clear()

    fmt = logging.Formatter("%(asctime)s | %(levelname)-7s | %(message)s",
                            datefmt="%H:%M:%S")
    fh = logging.FileHandler(log_path, encoding="utf-8")
    fh.setFormatter(fmt)
    sh = logging.StreamHandler(sys.stdout)
    sh.setFormatter(fmt)
    logger.addHandler(fh)
    logger.addHandler(sh)
    return logger


def section(title: str) -> None:
    print("\n" + "=" * 88)
    print(f"  {title}")
    print("=" * 88)


# ==========================================================================
# 1. 目录扫描 —— 对应 SAS 的 filename pipe + data _null_ 读取目录
# ==========================================================================
def scan_dir(directory: Path, pattern: str = "*") -> list[Path]:
    """扫描目录,返回所有可读文件(按文件名排序,保证结果可复现)。

    ★ ``pathlib`` 与 SAS 的对比:

    ===================================  =============================
    SAS                                  Python
    ===================================  =============================
    ``filename d pipe "dir /b";``        ``Path(dir).glob("*.xpt")``
    ``%sysfunc(fileexist(...))``         ``p.exists()``
    ``dopen/dread`` 读目录                ``iterdir()`` / ``rglob()``
    ``scan(path,-1,'.')`` 取扩展名        ``p.suffix``
    ===================================  =============================
    """
    directory = Path(directory)
    if not directory.exists():
        raise FileNotFoundError(f"目录不存在:{directory}")
    files = sorted(p for p in directory.glob(pattern)
                   if p.is_file() and p.suffix.lower() in READABLE)
    return files


# ==========================================================================
# 2. 单文件体检 —— 对应"一个数据集一个 PROC CONTENTS + PROC FREQ"
# ==========================================================================
def profile_one(path: Path, nrows: int | None = None) -> dict:
    """读一个文件并返回体检结果(绝不抛异常,失败也返回记录)。

    ★ 这是批处理的关键设计:**把异常收在函数内部**,
      让调用方永远拿到一条可记录的、结构一致的结果。
      这样"某个文件坏了"就变成结果表里的一行,而不是整个程序崩掉。
    """
    t0 = time.perf_counter()
    rec = {"文件": path.name, "格式": path.suffix.lower().lstrip("."),
           "大小KB": round(path.stat().st_size / 1024), "状态": "", "行数": None,
           "列数": None, "缺失列数": None, "耗时秒": None, "备注": ""}
    try:
        df = cio.read_any(path)
        rec["行数"] = len(df)
        rec["列数"] = df.shape[1]
        na = df.isna().sum()
        rec["缺失列数"] = int((na > 0).sum())
        if rec["缺失列数"]:
            worst = na.sort_values(ascending=False).head(3)
            rec["备注"] = "缺失最多:" + "、".join(
                f"{c}({int(v)})" for c, v in worst.items())
        rec["状态"] = "OK"
    except Exception as exc:                      # noqa: BLE001
        rec["状态"] = "失败"
        rec["备注"] = f"{type(exc).__name__}: {str(exc)[:90]}"
    rec["耗时秒"] = round(time.perf_counter() - t0, 2)
    return rec


def inventory(directory: Path, pattern: str, logger: logging.Logger) -> pd.DataFrame:
    """批量体检整个目录。**一个文件失败不会中断整批。**"""
    files = scan_dir(directory, pattern)
    logger.info("扫描 %s → 找到 %d 个可读文件", directory, len(files))
    if not files:
        return pd.DataFrame()

    records = []
    for i, f in enumerate(files, 1):
        rec = profile_one(f)
        flag = "✓" if rec["状态"] == "OK" else "✗"
        logger.info("  [%2d/%2d] %s %-22s %s", i, len(files), flag, rec["文件"],
                    f"{rec['行数']:,} 行 × {rec['列数']} 列"
                    if rec["状态"] == "OK" else rec["备注"])
        records.append(rec)

    df = pd.DataFrame(records)
    n_fail = int((df["状态"] != "OK").sum())
    logger.info("清点完成:成功 %d 个,失败 %d 个", len(df) - n_fail, n_fail)
    return df


# ==========================================================================
# 3. 批量转换 —— 对应 SAS 的 "批量 libname 转换 / PROC EXPORT"
# ==========================================================================
def convert_batch(directory: Path, pattern: str, out_dir: Path,
                  force: bool, logger: logging.Logger) -> pd.DataFrame:
    """把 XPT / SAS7BDAT 批量转成 CSV。

    **幂等设计**:已存在的目标文件默认跳过 —— 这样脚本可以随时重跑,
    不会反复做无用功,也不会误删已有结果(除非显式 ``--force``)。
    这是"可重跑(idempotent)"在数据管线里的基本要求。
    """
    files = [f for f in scan_dir(directory, pattern)
             if f.suffix.lower() in (".xpt", ".sas7bdat")]
    logger.info("待转换 %d 个 SAS 格式文件 → %s", len(files), out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    rows = []
    for i, f in enumerate(files, 1):
        dst = out_dir / f"{f.stem}.csv"
        row = {"源文件": f.name, "目标": dst.name, "结果": "", "行数": None, "备注": ""}
        if dst.exists() and not force:
            row["结果"] = "跳过(已存在)"
            row["行数"] = sum(1 for _ in dst.open(encoding="utf-8-sig")) - 1
            logger.info("  [%2d/%2d] ⏭  %-20s 已存在,跳过", i, len(files), f.name)
            rows.append(row)
            continue
        try:
            df = cio.read_any(f)
            df.to_csv(dst, index=False, encoding="utf-8-sig")
            row["结果"] = "已转换"
            row["行数"] = len(df)
            logger.info("  [%2d/%2d] ✓  %-20s → %-20s %s 行",
                        i, len(files), f.name, dst.name, f"{len(df):,}")
        except Exception as exc:                  # noqa: BLE001
            row["结果"] = "失败"
            row["备注"] = f"{type(exc).__name__}: {str(exc)[:80]}"
            logger.error("  [%2d/%2d] ✗  %-20s %s", i, len(files), f.name, row["备注"])
        rows.append(row)
    return pd.DataFrame(rows)


# ==========================================================================
# 4. 汇总导出:一个域一个 sheet
# ==========================================================================
def export_workbook(summary: pd.DataFrame, out_path: Path,
                    logger: logging.Logger) -> None:
    """把清点结果写成多 sheet Excel(对应 SAS 的 ODS EXCEL + sheet_interval)。

    注意:Excel 单个 sheet 名最长 31 字符、且不能含 ``[]:*?/\\``,
    所以要对 sheet 名做清洗 —— 这类"看起来无关紧要"的限制
    在批处理里天天会撞上。
    """
    inv = summary[summary["_表"] == "清点"].drop(columns=["_表"])
    if inv.empty:
        return
    out_path.parent.mkdir(parents=True, exist_ok=True)
    with pd.ExcelWriter(out_path, engine="openpyxl") as writer:
        inv.to_excel(writer, sheet_name="汇总", index=False)
        for _, r in inv.iterrows():
            name = Path(str(r["文件"])).stem[:28]
            name = "".join("_" if c in '[]:*?/\\' else c for c in name) or "sheet"
            try:
                df = cio.read_any(Path(r["_路径"]))
            except Exception:                     # noqa: BLE001
                continue
            # 只写前 200 行,避免 Excel 被大表撑爆(真实报表另存 CSV)
            df.head(200).to_excel(writer, sheet_name=name, index=False)
        # 其余表(转换记录等)
        for tag in summary["_表"].unique():
            if tag == "清点":
                continue
            sub = summary[summary["_表"] == tag].drop(columns=["_表"])
            if not sub.empty:
                sub.to_excel(writer, sheet_name=str(tag)[:28], index=False)
    logger.info("已导出多 sheet 工作簿:%s", out_path)


def main() -> None:
    ap = argparse.ArgumentParser(description="临床数据文件批处理自动化")
    ap.add_argument("--dir", default=str(BASE / "data" / "samples"),
                    help="要处理的数据目录(默认 data/samples)")
    ap.add_argument("--pattern", default="*", help="文件通配符,如 '*.xpt'")
    ap.add_argument("--outdir", default=str(BASE / "outputs"), help="输出目录")
    ap.add_argument("--action", default="inventory",
                    choices=["inventory", "convert", "all"])
    ap.add_argument("-f", "--force", action="store_true",
                    help="转换时覆盖已存在的目标文件(默认跳过)")
    args = ap.parse_args()

    src_dir = Path(args.dir)
    out_dir = Path(args.outdir)
    out_dir.mkdir(parents=True, exist_ok=True)
    logger = setup_logger(out_dir / "case06_batch.log")

    print("=" * 88)
    print("  案例 06 · 文件与批处理自动化")
    print("=" * 88)
    print(f"  源目录:{src_dir}")
    print(f"  通配符:{args.pattern}     动作:{args.action}")
    print(f"  日志:  {out_dir / 'case06_batch.log'}")

    parts: list[pd.DataFrame] = []
    t0 = time.perf_counter()

    if args.action in ("inventory", "all"):
        section("1. 清点(inventory)—— 逐个体检,出错不影响其他")
        inv = inventory(src_dir, args.pattern, logger)
        if not inv.empty:
            inv["_路径"] = [str(p) for p in scan_dir(src_dir, args.pattern)]
            inv["_表"] = "清点"
            show = inv.drop(columns=["_路径", "_表"])
            print()
            print(show.to_string(index=False))
            parts.append(inv)
            print("\n  ★ 请注意「耗时秒」这一列:批量处理时,")
            print("    **你要先知道时间花在哪里**,再决定要不要优化。")

    if args.action in ("convert", "all"):
        section("2. 批量转换(convert)—— 幂等、可重跑")
        con = convert_batch(src_dir, args.pattern, out_dir / "converted",
                            args.force, logger)
        if not con.empty:
            con["_表"] = "转换"
            print()
            print(con.drop(columns=["_表"]).to_string(index=False))
            parts.append(con)
            print(f"\n  目标目录:{out_dir / 'converted'}")
            print("  ★ 默认【跳过已存在】—— 这就是「幂等」:")
            print("    脚本可以随时重跑,不会重复劳动,也不会误删已有结果。")

    section("3. 汇总导出")
    if parts:
        summary = pd.concat(parts, ignore_index=True)
        summary.to_csv(out_dir / "case06_summary.csv", index=False, encoding="utf-8-sig")
        logger.info("已导出汇总 CSV:%s", out_dir / "case06_summary.csv")
        export_workbook(summary, out_dir / "case06_summary.xlsx", logger)
        print(f"  汇总 CSV:  {out_dir / 'case06_summary.csv'}")
        print(f"  多 sheet:  {out_dir / 'case06_summary.xlsx'}")
    print(f"  运行日志:  {out_dir / 'case06_batch.log'}")
    print(f"\n  总耗时 {time.perf_counter() - t0:.2f} 秒")

    section("4. 从 SAS 到 Python:批处理这一块到底对应什么")
    print("""  +--------------------------------------------------+------------------------------------------+
  | SAS 里你会写                                      | Python 里对应什么                        |
  +--------------------------------------------------+------------------------------------------+
  | filename d pipe "dir /b *.xpt";                   | Path(dir).glob("*.xpt")                  |
  | %macro loop(list); %do i=1 %to %sysfunc(countw);   | for f in files:  —— 不需要宏,直接循环    |
  | data _null_; set sashelp.vtable; ...; run;        | pd.DataFrame(records) 收集结果           |
  | proc printto log='run.log'; run;                  | logging.FileHandler("run.log")           |
  | 一个 DATA 步出错 → 整个程序停下                    | try/except → 记一行"失败"继续跑           |
  | proc export data=x outfile='x.csv'; run;          | df.to_csv(path, index=False)             |
  | ods excel file=... options(sheet_interval=bygroup) | pd.ExcelWriter + 多个 sheet_name         |
  +--------------------------------------------------+------------------------------------------+

  ★ 三个真正拉开差距的地方:
    1) **异常隔离**:SAS 里一个数据集坏了会中断整批;
       Python 里用 try/except 把失败变成结果表的一行,
       整批照常跑完,最后统一看哪几个失败了。
    2) **幂等**:SAS 脚本通常"从头跑到尾",Python 脚本要设计成
       "跑几次结果都一样"(跳过已完成、结果可覆盖)。
       这是能被调度器反复执行的前提。
    3) **不依赖 SAS 环境**:这套脚本可以在没有装 SAS 的机器上、
       在 CI(GitHub Actions)、在服务器上跑。

  ★ 还要注意一个坑:Excel 的 sheet 名限制 31 字符、不能含 []:*?/\\。
    批处理时这类"格式限制"特别容易触发,务必做名字清洗。""")


if __name__ == "__main__":
    main()