第 14 章 · 模块、导入与项目结构
这一章要解决什么:把写好的函数从"一个越来越长的文件"变成"可以被重复使用的模块", 让
import不再诡异地报ModuleNotFoundError。 要打掉的关键坑:from x import *是禁止的 —— 它会悄悄覆盖你的函数,让代码"行为变了却不报错"。
本章你需要完成的代码:demos/ch14-module.py
14.1 为什么要模块化:复制粘贴的代价
第 12 章你写了一个读 CSV 的脚本,第 13 章又写了一个,第 23 章还要写。如果每次都复制粘贴:
def calc_soh(cap_now, cap_rated=2.5): # 脚本 A 里定义了一遍
return cap_now / cap_rated
# 脚本 B 里把同样的函数又复制了一遍;某天额定容量要按批次变 → A 改了,B 忘了改模块化解决的就是这件事:一份代码放一个地方,其他文件 import 它 —— 这也是标准库能覆盖你 80% 日常需求的原因。
14.2 import 的四种写法
| 写法 | 调用方式 | 评价 |
|---|---|---|
import math | math.sqrt(2.25) | ✅ 最推荐:来源一目了然 |
from math import sqrt | sqrt(2.25) | ✅ 用于高频使用的少数名字 |
from math import sqrt as square_root | square_root(2.25) | ✅ 名字冲突或太长时用 |
import numpy as np | np.array(...) | ✅ 社区约定的别名:np / pd / plt |
import math # ① import 模块,用 math.sqrt 调用(最推荐)
from math import sqrt # ② 只导入某个名字,直接用 sqrt
from math import sqrt as square_root # ③ 起别名
import numpy as np # ④ 社区约定的别名:np / pd / plt真实输出:
=== ① import 的几种写法 ===
math.sqrt(2.25) = 1.5
sqrt(2.25) = 1.5
square_root(2.25) = 1.5
约定:`import math` 最清晰;`from x import *` 是本课程禁止的(会污染名字空间、看不出谁从哪来)。为什么推荐 import math? 三个月后回头看:soh = sqrt(4.84) / 2.5 里的 sqrt 是哪来的?翻遍整个文件也找不到定义;写成 math.sqrt(...) 就一眼知道来源。 as 的两个用途:名字太长(社区约定 np / pd / plt / sns), 以及不同模块里有同名函数时靠别名区分。照抄社区别名 —— 全世界的教程和 AI 生成的代码 都用它;自创一个 import numpy as shuzu,只会让读代码的人多绕一层。
14.3 为什么禁止 from x import *
from math import * 会把 math 里所有公开名字一次性倒进你的名字空间;数一下: len([n for n in dir(math) if not n.startswith("_")]) → 62 个,包括 log、pow、 gamma、degrees……它们会和你自己定义的函数抢名字:
import math
def log(x):
"""我自己的:以 10 为底的对数。"""
return math.log10(x)
print("覆盖前 log(100) =", log(100))
from math import * # noqa: F403
print("覆盖后 log(100) =", log(100), "← 变成自然对数(e 为底)")真实输出:
覆盖前 log(100) = 2.0
覆盖后 log(100) = 4.605170185988092 ← 变成了自然对数(e 为底)注意:没有报错。 你的 log 被悄悄换掉了,只是"结果不一样了"。 如果这是算 SOH 的一环,你会在报告里看到一个不对劲的数字,然后花半天排查。 替代写法只有一行之差:from math import sqrt, log10。 包作者还可以用 __all__ = ["calc_soh", "is_eol"] 显式声明"公开 API 清单"。
14.4 模块就是文件,包就是目录
模块(module) = 一个 .py 文件 文件名去掉 .py 就是模块名
包(package) = 一个目录 + __init__.py本书示例代码的结构是:code/common/__init__.py(有了它 common 才算包)、 code/common/plots.py(模块)、code/demos/ch14-module.py(示例脚本)。 导入路径 = 包.模块.函数,所以示例里这样写:
from pathlib import Path
import sys
CODE_DIR = Path(__file__).resolve().parent.parent # demos/ 的上一级 = code/
sys.path.insert(0, str(CODE_DIR)) # 把项目根加进「搜索路径」
from common.plots import PALETTE # 现在就能 import 了真实输出:
=== ② 导入自己写的模块 ===
从 common.plots 导入成功,配色有 8 种:['#4C72B0', '#DD8452', '#55A868'] …
搜索路径(sys.path)按顺序找模块,第一个是脚本所在目录:
[0] /…/ai-python-quick-start/code
[1] /…/ai-python-quick-start/code/demos(/…/ 处是你机器上的真实路径;顺序才是重点。) sys.path 是一个字符串列表,Python 按顺序在这些目录里找模块,找到就用、不再往后找。 它的构成:① 脚本所在目录(python demos/ch14-module.py → 加进去的是 demos/); ② PYTHONPATH 环境变量;③ 标准库目录;④ site-packages(pip 装的第三方库)。 "在我机器上能跑、在你机器上 ModuleNotFoundError" 多半是因为原代码依赖了 IDE 自动加项目根的便利,而命令行运行没有这层照顾。
sys.path.insert(0, ...) 的 0 是插到最前面,优先于系统目录;用 append 加到最后, 可能被同名的库抢先。更"干净"的做法:从项目根用 python -m demos.ch14_module 运行 (-m 会把项目根放进 sys.path[0]),或 pip install -e . 做可编辑安装。
⚠ 别写 sys.path.insert(0, ".."):".." 是相对当前工作目录的(第 12 章的坑), 换个目录运行就失效;Path(__file__).resolve().parent 才是"相对这个文件自己算",永远正确。
14.5 if __name__ == "__main__": 有什么用
| 使用方式 | __name__ 的值 |
|---|---|
直接运行 python demos/ch14-module.py | '__main__' |
被别人 import | 'ch14-module'(模块名) |
于是这个判断成了"这里是不是主入口"的开关:
def mean(values):
"""求均值(这个函数可以被别的文件 import 走)。"""
return sum(values) / len(values)
if __name__ == "__main__":
print(" ↑ 这段演示代码只在本文件被直接运行时才执行")真实输出:本文件被直接运行时,__name__ = '__main__'。 没有这个判断会怎样?假设 cellutils.py 末尾写了自测的 print, 那么任何 from cellutils import calc_soh 的地方都会顺手跑一遍自测 —— 在数据管道里,这意味着每次导入都多出几行输出、甚至多跑几百行计算。 这条习惯的正式名字:让一个文件既能当脚本运行,又能当模块导入(Jupyter 里 __name__ 就是 '__main__',所以这个判断里的代码会执行)。
14.6 常用标准库速览(不用装就有)
真实输出:
=== ⑤ 常用标准库(无需安装) ===
csv 读写 CSV
datetime 现在: 2026-10-09 08:03
pathlib 路径操作: code
json 配置与数据交换
statistics 均值 2.4000,中位数 2.5
random 可复现随机数: [1454, 914, 825]
collections 计数: Counter({'A': 2, 'B': 1})
os / sys 环境变量与解释器信息: Python 3.13| 模块 | 解决什么问题 |
|---|---|
csv / json | 读写 CSV / 配置与数据交换(第 12 章) |
pathlib | 路径拼接、判断存在(Path(__file__).parent / "data") |
datetime | 时间戳、日期间隔(第 29 章) |
random | 抽样、打乱;Random(42) 固定种子 |
statistics | 均值 / 中位数 / 标准差 |
collections | Counter 计数、defaultdict 分组 |
random.Random(42) 值得单独说:固定种子意味着结果可复现。 AI 项目里"昨天跑出 0.92、今天跑出 0.87",如果没固定种子,你永远说不清是代码改了 还是随机性 —— 固定种子 = 让实验可对比。collections 的 Counter(计数)和 defaultdict(list)(分组不用先判断键在不在)能省掉一堆样板代码, 第 28 章的 groupby 本质就是把这两件事做得更快更通用。
14.7 pip 与虚拟环境(第 01 章的回顾)
python -m venv .venv # 创建虚拟环境(每个项目一个,互不干扰)
source .venv/bin/activate # macOS/Linux;Windows 用 .venv\Scripts\Activate.ps1
pip install numpy pandas matplotlib
pip freeze > requirements.txt # 别人靠它复现你的环境
pip install -r requirements.txt为什么必须用虚拟环境:A 项目要 pandas 1.x、B 项目要 pandas 3.x,装在全局会互相打架 —— 而且这种打架往往不报错,只是行为不对。更省事的替代是 uv(第 01 章): uv run python x.py 自动建环境并同步依赖,pyproject.toml + uv.lock 让环境变成锁定的清单。
⚠ 高频问题:"我 pip install 了,为什么还是 ModuleNotFoundError?" 99% 是装到的环境和你运行代码的环境不是同一个。排查三步:
python -c "import sys; print(sys.executable)" # 我在用哪个 python?
pip -V # pip 属于哪个环境?两个输出指向同一个目录才算对。
14.8 项目目录怎么摆
这是从"能跑的脚本"到"能维护的项目"最关键的一步:
code/
├── README.md 依赖与数据说明(下载包里带来)
├── check_env.py 环境自检
├── requirements.txt 依赖清单
├── data/battery/ 数据(下载包里带来,只读不改)
├── demos/ 你敲的示例脚本(第 03 章起)
├── exercises/ 你敲的练习
├── common/plots.py 可复用模块(第 35 章起,你自己敲)
└── outputs/ 脚本产出的图和报告(自动创建)四条原则:① 数据、代码、产物分开放(data/ 输入不改 → demos/ 代码 → outputs/ 产物); 把 outputs/ 删掉重跑能重现一切,说明流程可复现,反之说明"某一步是手工做的"。 ② 一个文件只干一件事 —— 别写 2000 行的 utils.py。 ③ 名字要能预测 —— 找"清洗"就去 src/clean.py;能预测的目录结构比"聪明的"结构更有价值。 ④ 配置文件单独放 —— "调参时会改"的东西(额定容量、阈值 0.80、随机种子、路径) 都不该硬编码在代码里。
14.9 用 dir() 与 help() 探索陌生模块
技巧:dir(mod) 列出模块里所有名字("这个库有什么功能?"),但以下划线开头的都是内部实现,用 [n for n in dir(json) if not n.startswith("_")] 跳过;help(mod.fn) 打印 docstring("这个参数是干嘛的?"),交互式环境里的 <Tab> 补全最快。 help() 显示的就是 docstring —— 这也是第 16 章要求认真写 docstring 的原因: 你写的东西,别人和 IDE 就是这样看到的。
常见坑
from x import *:62 个名字灌进来、静默覆盖你的函数、IDE 抓不住。- 把"脚本所在目录"当成"当前工作目录":用
Path(__file__).resolve().parent.parent,不要写".."。 - 忘了
if __name__ == "__main__"::别人一import,你的测试代码就跟着跑。 pip install了却还是ModuleNotFoundError:用python -c "import sys; print(sys.executable)"确认装的环境就是运行的环境。- 文件名撞上标准库:自己写一个
random.py再import random,导入的其实是你自己那个,它里面又import random→AttributeError: partially initialized module。 - 循环导入:
a.py里import b、b.py里import a→ImportError: cannot import name 'x' from partially initialized module 'a';修法是把共用部分抽到第三个模块common.py。 - 以为
import会"每次重新执行":Python 会缓存已导入的模块,改了.py后正在跑的进程不会自动重载 —— 要么重启,要么importlib.reload。
本章练习
练习 1:把函数拆成模块
把下面这段代码拆成 cellutils.py(放函数)+ main.py(调用并打印)。 要求 cellutils.py 里有 if __name__ == "__main__": 自测,被 main.py 导入时不会打印自测内容。
def calc_soh(cap_now, cap_rated=2.5):
return cap_now / cap_rated
def is_eol(soh, thr=0.80):
return soh < thr
print(calc_soh(2.30), is_eol(0.78)) # ← 期望搬进 main.py,输出 0.92 与 True参考答案
# ---------- cellutils.py ----------
"""电芯计算工具(演示用模块)。"""
__all__ = ["calc_soh", "is_eol"] # 公开 API 清单
def calc_soh(cap_now, cap_rated=2.5):
"""由实测容量与额定容量算 SOH。"""
return cap_now / cap_rated
def is_eol(soh, thr=0.80):
"""SOH 是否已跌破退役阈值。"""
return soh < thr
if __name__ == "__main__": # ★ 只在「直接运行本文件」时执行
print(f" 自测:calc_soh(2.30) = {calc_soh(2.30):.4f}")main.py 只有三行:from cellutils import calc_soh, is_eol, 再打印 calc_soh(2.30)(:0.2f)和 is_eol(0.78)。
三个要点:① 同一目录的模块可以直接 import(sys.path 第一个就是"脚本所在目录"), 跨目录才需要 sys.path.insert(0, ...);② if __name__ == "__main__": 让一个文件身兼两职: 既可被导入,也可直接运行;③ __all__ 是给读代码的人看的公开清单。 验证:python cellutils.py 打印自测,python main.py 只打印两行结果。 完整练习在 exercises/ex04-14-module。
练习 2:找出这段代码的 4 个问题
# analysis.py
from math import *
from myutils import *
import os, sys
def sqrt(x):
"""我自己的 sqrt:先做物理校验。"""
if x < 0:
raise ValueError(f"不能对负数开方:{x}")
return math.sqrt(x)
print(sqrt(4))参考答案
① 两个 from ... import *。 from math import * 会倒进来 62 个名字(其中就有 sqrt), 接着你又定义了自己的 sqrt,然后 from myutils import *(可能也有 sqrt)—— 谁最终生效取决于行的顺序,这种"顺序敏感"就是 bug 温床。② 用了 math.sqrt,但 math 没被导入 —— 星号导入不会带来 math 这个名字, 报 NameError: name 'math' is not defined。 ③ import os, sys 一行两个模块(PEP 8 要求一行一个); ④ os 和 sys 完全没被用到 —— "死导入"会让读代码的人以为这里有环境相关逻辑。
修正后:
import math # ① 只导入模块本身,来源一目了然
from myutils import load_cells # ② 只导入真正用的名字
def validate_sqrt(x): # ③ 别叫 sqrt,避开和标准库重名
if x < 0:
raise ValueError(f"不能对负数开方:{x}")
return math.sqrt(x) # math 已经导入了一条经验:不要用标准库里已有的名字命名你自己的函数(sqrt、log、json、error)。 改叫 validate_sqrt,既避开覆盖,也让调用处的意图更清楚。
练习 3:为什么"能跑"却"换个目录就跑不了"?
下面的脚本在 code/ 里运行没问题,但 cd demos && python load.py 就报 FileNotFoundError:
# code/demos/load.py
import csv
with open("data/battery/cycle_raw.csv", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
print(f"读到 {len(rows)} 行")要求:解释原因并给出两种修法。
参考答案
原因:相对路径是相对当前工作目录(os.getcwd()),不是相对 load.py。 在 code/ 里运行,open 找的是 code/data/battery/cycle_raw.csv ✅; 在 code/demos/ 里运行,找的是 code/demos/data/battery/... ❌ —— 报错正是 FileNotFoundError: [Errno 2] No such file or directory: 'data/battery/cycle_raw.csv' (用第 13 章的三步法读:最后一行是错因,File 段给行号)。
修法一(推荐):用 pathlib 从文件自身反推路径。
from pathlib import Path
CODE_DIR = Path(__file__).resolve().parent.parent # load.py -> demos/ -> code/
CSV_PATH = CODE_DIR / "data" / "battery" / "cycle_raw.csv"
if not CSV_PATH.exists():
raise SystemExit(f"数据文件不存在:{CSV_PATH}\n请确认下载包已完整解压。")Path(__file__).resolve() 是这个文件自己的绝对路径,parent.parent 就是 code/ —— 不管在哪个目录运行都不会错(第 12 章讲过,本书所有示例都这样写)。
修法二:从项目根用模块方式运行 —— cd code && python -m demos.load。 -m 会把项目根放进 sys.path[0],于是 from common.plots import ... 也能直接用。 提示:报路径错时先 print(CSV_PATH, CSV_PATH.exists());不要用 os.chdir() 去修(它改的是整个进程的工作目录)。
面向 AI 开发:AI 项目的目录结构与可复用模块
AI 项目失败的常见原因不是算法,而是"代码太乱、改不动了"。 能长期维护的项目长这样:
my_project/
├── pyproject.toml 依赖与项目元信息(uv / pip 用它复现环境)
├── configs/ default.yaml、exp_2024lfp.yaml
├── data/raw/ 原始数据(只读!绝对不改) data/processed/ 干净数据
├── src/ data.py(读+校验+清洗)/ model.py / train.py(有 __main__ 入口)
├── notebooks/ 探索性分析(可以乱,但结论要搬进 src/)
└── outputs/ figures/ 与 metrics.json① src/ 和 notebooks/ 的分工,是 AI 项目最重要的一条纪律。 notebook 用来探索(乱、试错、单元格顺序颠倒都行,看完就能删); src/ 用来生产(有序、可测、有函数边界,长期维护)。 规矩:notebook 里试出来的东西,必须搬进 src/ 才算数 —— 留在 notebook 里的逻辑,下次跑训练时不会被执行,这是"结果复现不出来"的头号原因。 ② data/raw/ 只读:洗着洗着把原始数据改了,出问题就无法回退;原始数据是唯一的事实来源,只要它还在,任何一步都能重跑(第 32~34 章)。
③ 配置与代码分离。 别把超参数写死在代码里:
import json
from pathlib import Path
def load_config(path: str) -> dict:
"""读配置(json 用标准库;yaml 需要 pip install pyyaml)。"""
return json.loads(Path(path).read_text(encoding="utf-8"))一次实验 = 一份配置 + 一个 git commit,不改代码就能复现任何一次实验。
④ 可复用模块 = 你的"私人标准库"。 src/data.py 里放 load_cells(path) -> list[dict] 和 validate(rows) -> tuple[list[dict], list[dict]](返回"通过 / 被拦截",第 13 章), 于是每个实验脚本只剩几行:
from src.data import load_cells, validate
if __name__ == "__main__":
good, bad = validate(load_cells("data/raw/cycle.csv"))
print(f"通过 {len(good)} 条,拦截 {len(bad)} 条")这就是"能跑"和"能维护"的分界线:能跑 = 一个 300 行的脚本; 能维护 = 300 行拆成 5 个各司其职的模块。
关于"AI 生成的代码":让大模型写代码时,把目录结构一起告诉它 —— 数据在 data/raw/、公共函数放 src/data.py、入口是 src/train.py; 否则你会得到一堆 from utils import ...,而不知道 utils.py 该放在哪。
一句话总结:import 是"不重复自己",目录结构是"找得到东西"。