Skip to content

第 14 章 · 模块、导入与项目结构 ​

这一章要解决什么:把写好的函数从"一个越来越长的文件"变成"可以被重复使用的模块", 让 import 不再诡异地报 ModuleNotFoundError。 要打掉的关键坑:from x import * 是禁止的 —— 它会悄悄覆盖你的函数,让代码"行为变了却不报错"。

本章你需要完成的代码:demos/ch14-module.py

14.1 为什么要模块化:复制粘贴的代价 ​

第 12 章你写了一个读 CSV 的脚本,第 13 章又写了一个,第 23 章还要写。如果每次都复制粘贴:

python
def calc_soh(cap_now, cap_rated=2.5):      # 脚本 A 里定义了一遍
    return cap_now / cap_rated

# 脚本 B 里把同样的函数又复制了一遍;某天额定容量要按批次变 → A 改了,B 忘了改

模块化解决的就是这件事:一份代码放一个地方,其他文件 import 它 —— 这也是标准库能覆盖你 80% 日常需求的原因。

14.2 import 的四种写法 ​

写法调用方式评价
import mathmath.sqrt(2.25)✅ 最推荐:来源一目了然
from math import sqrtsqrt(2.25)✅ 用于高频使用的少数名字
from math import sqrt as square_rootsquare_root(2.25)✅ 名字冲突或太长时用
import numpy as npnp.array(...)✅ 社区约定的别名:np / pd / plt
python
import math                           # ① import 模块,用 math.sqrt 调用(最推荐)
from math import sqrt                 # ② 只导入某个名字,直接用 sqrt
from math import sqrt as square_root  # ③ 起别名
import numpy as np                    # ④ 社区约定的别名:np / pd / plt

真实输出:

text
=== ① 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……它们会和你自己定义的函数抢名字:

python
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 为底)")

真实输出:

text
覆盖前 log(100) = 2.0
覆盖后 log(100) = 4.605170185988092 ← 变成了自然对数(e 为底)

注意:没有报错。 你的 log 被悄悄换掉了,只是"结果不一样了"。 如果这是算 SOH 的一环,你会在报告里看到一个不对劲的数字,然后花半天排查。 替代写法只有一行之差:from math import sqrt, log10。 包作者还可以用 __all__ = ["calc_soh", "is_eol"] 显式声明"公开 API 清单"。

14.4 模块就是文件,包就是目录 ​

text
模块(module) = 一个 .py 文件        文件名去掉 .py 就是模块名
包(package)  = 一个目录 + __init__.py

本书示例代码的结构是:code/common/__init__.py(有了它 common 才算包)、 code/common/plots.py(模块)、code/demos/ch14-module.py(示例脚本)。 导入路径 = 包.模块.函数,所以示例里这样写:

python
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 了

真实输出:

text
=== ② 导入自己写的模块 ===
  从 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'(模块名)

于是这个判断成了"这里是不是主入口"的开关:

python
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 常用标准库速览(不用装就有) ​

真实输出:

text
=== ⑤ 常用标准库(无需安装) ===
  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均值 / 中位数 / 标准差
collectionsCounter 计数、defaultdict 分组

random.Random(42) 值得单独说:固定种子意味着结果可复现。 AI 项目里"昨天跑出 0.92、今天跑出 0.87",如果没固定种子,你永远说不清是代码改了 还是随机性 —— 固定种子 = 让实验可对比。collections 的 Counter(计数)和 defaultdict(list)(分组不用先判断键在不在)能省掉一堆样板代码, 第 28 章的 groupby 本质就是把这两件事做得更快更通用。

14.7 pip 与虚拟环境(第 01 章的回顾) ​

text
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% 是装到的环境和你运行代码的环境不是同一个。排查三步:

bash
python -c "import sys; print(sys.executable)"      # 我在用哪个 python?
pip -V                                             # pip 属于哪个环境?

两个输出指向同一个目录才算对。

14.8 项目目录怎么摆 ​

这是从"能跑的脚本"到"能维护的项目"最关键的一步:

text
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 导入时不会打印自测内容。

python
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
参考答案
python
# ---------- 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 个问题
python
# 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 完全没被用到 —— "死导入"会让读代码的人以为这里有环境相关逻辑。

修正后:

python
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:

python
# 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 从文件自身反推路径。

python
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 项目失败的常见原因不是算法,而是"代码太乱、改不动了"。 能长期维护的项目长这样:

text
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 章)。

③ 配置与代码分离。 别把超参数写死在代码里:

python
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 章), 于是每个实验脚本只剩几行:

python
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 是"不重复自己",目录结构是"找得到东西"。


下一章:第 15 章 · 类与对象:给一支电芯建模