2. 科学计算脚本的模块化组织
当一个批处理脚本从几十行增长到几百行时,最先出问题的往往不是算法本身,而是结构:参数散落在各处、文件路径硬编码在循环里、计算函数既读文件又写日志。模块化组织要解决的核心问题,是把一个"能跑"的脚本改造成"能改、能测、能复用"的工程结构。本节围绕三条主线展开:计算逻辑、参数配置、文件读写的职责分离,以及用函数与类承载批处理流程的具体做法。
一、三层职责的边界划分
科学计算脚本通常包含三类性质完全不同的代码。第一类是计算逻辑,例如数值积分、矩阵求解、信号滤波、统计拟合,它只依赖输入数据与参数,输出结果,不应关心数据从哪来、存到哪去。第二类是参数配置,包括网格步长、迭代容差、批处理文件列表、输出目录等,它应当集中声明、可被外部覆盖。第三类是文件读写,负责路径拼接、格式解析、编码处理、异常捕获。
把三者混在一起的典型症状是:想换一个输入目录,要改五处字符串;想单独验证一个算法,必须先造一个真实文件;想批量重跑,只能复制整个脚本改参数。分离之后,计算函数变成纯函数式的"数据进、结果出",配置变成一份可替换的字典或配置文件,读写变成薄薄的一层适配代码。这样每一层都能独立测试,也能被其他脚本复用。
二、用函数封装计算逻辑
计算函数的第一条纪律是只依赖显式参数。下面这个示例把一维热传导显式差分推进封装成函数,参数全部通过形参传入,返回值是新的温度数组,函数内部不出现任何文件路径或全局变量:
import numpy as np
def heat_step(u, alpha, dx, dt):
"""显式差分推进一个时间步。
u : 当前温度场 (n,)
alpha : 热扩散系数
dx : 空间步长
dt : 时间步长
返回 : 推进后的温度场 (n,)
"""
r = alpha * dt / dx**2
if r > 0.5:
raise ValueError(f"稳定性条件不满足: r={r:.3f} > 0.5")
new_u = u.copy()
new_u[1:-1] = u[1:-1] + r * (u[2:] - 2*u[1:-1] + u[:-2])
return new_u
这里把稳定性判据 r = α·dt/dx² ≤ 0.5 写成显式检查,而不是让结果悄悄发散。这样函数既承担计算,也承担"参数是否合法"的守卫职责,调用方无需重复判断。测试时只需构造一个已知初值,例如 u = np.array([0., 1., 0.])、r = 0.25,验证中间点变为 0.5,即可确认逻辑正确,完全不需要真实数据文件。
三、参数配置的集中与覆盖
配置层推荐采用"默认字典 + 外部覆盖"的模式。默认值写在模块内,运行时从 YAML 或命令行读取覆盖项,合并后形成唯一一份生效配置。这样既保证脚本开箱可跑,又允许批处理时逐任务替换参数:
DEFAULTS = {
"alpha": 0.01, "dx": 0.1, "dt": 0.002,
"steps": 500, "input_dir": "data/raw",
"output_dir": "results",
}
def build_config(overrides=None):
cfg = dict(DEFAULTS)
if overrides:
unknown = set(overrides) - set(DEFAULTS)
if unknown:
raise KeyError(f"未知配置项: {unknown}")
cfg.update(overrides)
return cfg
对未知键直接报错,是为了防止拼写错误被静默忽略——批处理中最难排查的故障之一,就是参数名写错后脚本仍按默认值跑完,结果看似正常却全部无效。
四、用类组织批处理流程
当流程涉及"遍历多个输入、逐个计算、逐个落盘、汇总统计"时,用类承载编排比一串函数更清晰。类持有配置与读写依赖,方法负责单任务与整批两个粒度:
from pathlib import Path
import numpy as np
class BatchRunner:
def __init__(self, config, loader, saver):
self.cfg = config
self.loader = loader
self.saver = saver
def run_one(self, path):
u0 = self.loader(path)
u = u0
for _ in range(self.cfg["steps"]):
u = heat_step(u, self.cfg["alpha"],
self.cfg["dx"], self.cfg["dt"])
out = Path(self.cfg["output_dir"]) / (Path(path).stem + ".npz")
self.saver(out, u)
return {"file": Path(path).name,
"max_temp": float(u.max()),
"mean_temp": float(u.mean())}
def run_all(self, files):
return [self.run_one(f) for f in files]
把 loader 与 saver 作为构造参数注入,而不是在类内部直接调用 np.load,带来两个实际好处:单元测试时可传入内存中的假读写函数,验证编排逻辑而不产生磁盘 IO;将来数据源从 CSV 换成数据库,只需替换注入对象,BatchRunner 一行不改。这就是依赖注入在科学计算脚本中的朴素用法。
五、目录结构与落地检查
一个可直接套用的最小工程结构如下,模块名即职责名,读者一眼能定位代码:
project/
├── config.yaml # 参数配置
├── core/
│ └── physics.py # heat_step 等纯计算函数
├── io_utils/
│ └── readers.py # load_csv / save_npz
├── batch/
│ └── runner.py # BatchRunner 编排类
└── run_batch.py # 入口:读配置 → 建 Runner → run_all
改造完成后,用三个问题自检是否真正分离:换输入目录是否只改配置?单独验证算法是否无需任何文件?新增一种输出格式是否只动读写层?三者皆"是",说明边界成立。反之,若计算函数里仍出现 open(),或配置值以字面量形式散落在循环体内,就应回退重构。模块化的收益不在写的那一刻,而在第三次修改需求时才显现。