Linux与Python开发环境基础
本节主要内容涵盖Linux操作系统的实用入门、Python在Linux系统中的位置、包管理工具pip和virtualenv、可复现性的基本概念、Git版本控制与协作、现代Python打包方式、uv与poetry工具介绍、代码检查/格式化/类型标注,以及VS Code、Spyder、命令行等开发工具。
科学计算为何选择Linux
在科学研究和工程领域,Linux系统占据了主导地位。绝大多数研究工具、机器学习模型以及嵌入式系统90%都运行在Linux环境下。选择Linux的原因在于它提供了更高的透明度,用户可以直接访问文件、进程和各种库,而不像Windows或macOS那样隐藏了许多底层细节。Linux环境更容易实现可复现性。此外,Linux天然支持自动化,可以通过Bash脚本、Makefile和持续集成(CI)工具来批量处理任务。在实际的科学计算场景中,无论是高性能计算集群还是GPU服务器,几乎都采用Linux系统。
Linux系统的基本概念
用户体系
Linux 是一个多用户操作系统,这意味着多个用户可以同时使用同一台机器,系统需要区分每个用户的身份和权限,因此每个用户在系统中都被分配一个唯一的数字标识符(id)
系统中特殊的管理员账户叫做 root,其 id 固定为 0。root 用户拥有系统的全部权限,可以访问和修改任何文件、执行任何操作。
当普通用户需要执行管理员操作时,可以通过 sudo 命令临时获取 root 权限。sudo 的含义是 "superuser do",即以超级用户身份执行后面的命令。例如 sudo apt update 会以管理员权限运行软件更新命令,执行完毕后权限自动恢复为普通用户。
文件理念
Linux有一个核心设计理念:一切皆文件。当你从网络接收数据时,实际上是在读取一个特殊的文件,当你要在屏幕上显示内容时,实际上是在向一个代表显示设备的文件写入像素值,甚至程序本身也是文件,这类文件通常被称为二进制文件(binaires)。
权限机制
每个文件都与一个所有者用户关联,同时也属于一个用户组(默认情况下,这个组与所有者相同)。文件具有三种基本权限:读取(read)、写入(write)和执行(execute)。这三种权限针对三类对象进行设置:文件所有者(propriétaire)、所属组的成员(groupe)以及系统中的其他所有用户(tout le monde)。通过这种细粒度的权限控制,系统可以精确地管理谁能对哪些文件做什么操作。
sudo命令
由于root用户拥有系统的全部权限,直接使用root账户进行日常操作是危险的,一个误操作可能导致系统崩溃。因此Linux提供了sudo程序,普通用户可以通过配置获得使用sudo的权限。当执行sudo加上某条命令时,系统会临时以root身份执行该命令,执行完毕后权限自动恢复为普通用户。这样既保证了必要时可以进行系统级操作,又避免了长期处于高权限状态带来的风险。
文件系统结构
目录树组织
Linux的文件系统采用树状结构组织,最顶层是根目录,用单个斜杠 / 表示。从根目录向下,系统目录按照功能进行划分。/usr 目录下存放用户级的程序和资源,其中 /usr/bin 包含系统的可执行文件,/usr/lib 存放共享库文件。/bin 目录存放最基本的系统命令,这些命令在系统启动早期就需要使用。每个用户都有自己的个人目录,位于 /home/{user} 下,例如用户名为alice的用户,其个人目录就是 /home/alice。手动安装的第三方软件通常放在 /opt 目录下。系统的各种配置文件则集中存放在 /etc 目录中。
在日常使用中,建议主要在自己的 /home 目录下工作,这样既安全又整洁。
路径表示法
在文件系统中导航时,有两个特殊符号需要掌握:单个点 . 代表当前所在的目录,两个点 .. 代表当前目录的上一级目录(父目录)。使用 cd .. 命令可以返回上一级目录。
路径的表示分为两种方式。绝对路径从根目录开始,完整地写出文件的位置,例如 /home/utilisateur/projet-a/codes/fich1.py 和 /home/utilisateur/projet-b/data/fich2.py,这种方式无论当前在哪个目录下都能准确定位到文件。相对路径则是相对于当前目录的位置描述,假设当前位于 /home/utilisateur/projet-a/codes/ 目录下,要访问 fich2.py 文件,可以使用相对路径 ../../projet-b/data/fich2.py,其中 ../.. 表示向上跳两级目录到达 /home/utilisateur/,然后再进入 projet-b/data/ 找到目标文件。
终端与Shell
终端的本质
终端是一种文本界面,用于向计算机发送指令。与图形界面通过鼠标点击操作不同,终端完全依靠键盘输入命令来完成任务。使用终端的优势在于:操作速度比图形界面更快,可以通过编写脚本实现大规模自动化处理,能够直接操作文件和进程而无需中间层,在远程服务器、计算集群等没有图形界面的环境中是唯一的交互方式,而且每条执行过的命令都可以被记录和重放,这对于保证工作的可复现性至关重要。
工作机制
当打开终端时,实际上启动的是一个叫做shell的程序,最常见的shell是bash和zsh。shell的工作流程是:读取用户输入的一行命令,对其进行解释分析,然后执行相应的程序。在执行过程中,shell始终处于文件系统的某个位置(当前工作目录),被执行的程序也在同一位置运行,这会影响到相对路径的解析。每个程序执行完毕后都会返回一个返回码(code retour),返回值为0表示程序成功执行,非零值表示出现了某种错误。
命令结构解析
一条完整的命令由几个部分组成,以 grep -R "python" /usr/bin 为例进行说明。grep 是要执行的程序名,它的功能是在文件中搜索匹配的文本。-R 是一个选项(option),这里表示递归搜索,即不仅搜索指定目录,还会搜索其所有子目录。"python" 是第一个参数(argument),指定要搜索的文本内容。/usr/bin 是第二个参数,指定要在哪个目录中进行搜索。整条命令的含义是:在 /usr/bin 目录及其子目录中,递归搜索包含文本 python 的所有文件。
终端基础命令
导航命令
在终端中移动和定位是最基本的操作。pwd 命令用于显示当前所在的目录路径,当你在复杂的目录结构中迷失方向时,这个命令可以告诉你确切的位置。ls 命令列出当前目录下的所有文件和子目录,是查看目录内容的基本方式。cd dir 命令用于切换到指定的目录,其中 dir 是目标目录的路径。cd .. 则是返回上一级目录的快捷方式。
文件内容查看
查看文件内容有多种方式,适用于不同场景。cat fichier.txt 会一次性将整个文件内容输出到屏幕上,适合查看较短的文件。less fichier.txt 提供了一个可以上下滚动的查看界面,适合阅读较长的文件,按 q 键退出。head -n 20 fichier.log 只显示文件的前20行,这里 -n 20 指定了行数。与之相对,tail -f fichier.log 显示文件的末尾内容,-f 选项会持续监视文件的变化并实时显示新增内容,这在查看日志文件时特别有用。
搜索功能
查找文件和内容是日常工作中的常见需求。find . -name "*.py" 在当前目录及其子目录中查找所有以 .py 结尾的文件,点号 . 表示从当前目录开始搜索,-name 选项指定文件名模式。grep -R "mot" dossier/ 在指定目录中递归搜索包含特定文本的文件,-R 表示递归搜索所有子目录。
文件管理操作
创建、删除、复制和移动文件是基本的文件管理操作。mkdir nom 创建一个名为 nom 的新目录。rm fichier 删除指定文件,这里需要特别注意,Linux中的删除是永久性的,文件不会进入回收站,一旦删除就无法恢复。rm -r dossier 删除一个目录及其所有内容,-r 表示递归删除。cp src dest 将源文件复制到目标位置。mv src dest 将文件从源位置移动到目标位置,这个命令也常用于重命名文件。
重定向与管道
命令的输入输出可以被重定向,这是shell的强大功能之一。commande > fichier.txt 将命令的输出写入文件,如果文件已存在则会被覆盖。commande >> append.txt 将输出追加到文件末尾,不会覆盖原有内容。管道符 | 可以将一个命令的输出作为另一个命令的输入,例如 commande1 | commande2 表示将 commande1 的输出直接传递给 commande2 进行处理,这种机制允许将多个简单命令组合成复杂的数据处理流程。
常用命令汇总
获取帮助
Linux提供了内置的手册系统。man 命令后跟任意命令名可以查看该命令的详细使用说明,例如 man ls 会显示 ls 命令的完整手册。甚至可以使用 man man 来查看 man 命令本身的使用方法。
导航与查看类
这类命令用于在文件系统中移动和查看内容:ls 列出文件,ls -l 以详细列表形式显示(包含权限、大小、修改时间等信息),pwd 显示当前路径,cd 切换目录,tree 以树状结构显示目录层级。查看文件内容的命令包括 cat、less、head、tail。搜索方面,find 用于按名称或属性查找文件,grep 用于在文件内容中搜索文本。
编辑类
终端中有多种文本编辑器可用。nano 是一个简单易用的编辑器,适合初学者。vim 是功能强大的编辑器,学习较难但效率很高。配合前面介绍的重定向操作符 >、>> 和管道 |,可以实现灵活的文本处理。
进程管理
查看和控制正在运行的程序是系统管理的一部分。ps 显示当前运行的进程列表。top 提供实时更新的进程监控界面,显示CPU和内存使用情况。htop 是 top 的增强版本,界面更友好,支持鼠标操作。当需要强制结束某个进程时,使用 kill 命令加上进程ID即可终止该进程。
环境变量
基本概念
环境变量是系统存储的名值对,形式为 nom = valeur,用于向程序传递配置信息。环境变量的作用非常广泛:指定系统在哪些目录中查找可执行程序(PATH变量),存储API密钥和密码等敏感信息,避免将这些内容硬编码在源代码中,配置Python、pip、CUDA、OpenBLAS等软件的行为,定义编译选项或运行时参数,在不修改代码的情况下改变软件的行为。
环境变量的值始终是文本类型,即使看起来像数字也是以字符串形式存储。访问变量值时需要在变量名前加美元符号 $。有些环境变量由系统使用,有些则由用户自定义,任何人都可以创建新的环境变量。
常见环境变量
PATH 是最关键的环境变量之一,它包含一系列目录路径,系统会在这些目录中搜索可执行程序。HOME 存储当前用户的个人目录路径。SHELL 记录当前使用的shell程序,通常是 bash 或 zsh。PYTHONPATH 指定Python在导入模块时额外搜索的目录。VIRTUAL_ENV 指向当前激活的Python虚拟环境路径。CUDA_HOME 记录CUDA工具包的安装位置,这对于GPU计算很重要。
使用 echo $HOME 可以查看 HOME 变量的值。
环境变量操作
查看当前所有环境变量可以直接运行 env 命令。查看某个特定变量的值使用 echo 命令,例如 echo $PATH、echo $PWD、echo $HOME。
临时设置一个环境变量(仅在当前终端会话中有效)使用 export MYVAR="hello"。向 PATH 变量添加新路径使用 export PATH="$PATH:/chemin/vers/bin",这里 $PATH 保留了原有的路径列表,冒号后面是新增的路径。
如果希望环境变量的设置永久生效,需要将 export 语句写入 shell 的配置文件中。对于 bash 是 ~/.bashrc,对于 zsh 是 ~/.zshrc。例如 export PATH="$PATH:$HOME/.local/bin" 将用户本地的 bin 目录添加到搜索路径中。
对Python开发的意义
理解环境变量对于Python开发者解决很多问题至关重要。当遇到 command not found 错误时,通常是因为程序所在目录不在 PATH 中。通过修改 PATH 可以指向特定版本的Python解释器。将自己编写的模块目录添加到 PYTHONPATH 后,Python就能在任何位置导入这些模块。配置 CUDA、OpenCL、MKL 等加速库也需要设置相应的环境变量。
Python在Linux系统中的位置
多版本共存现象
在一个Linux系统中,可以同时存在多个Python安装,它们各自独立且服务于不同目的。理解这些不同的Python及其位置对于正确管理开发环境至关重要。
系统Python位于 /usr/bin/python3,可以通过 which python3 命令查看其确切路径。这个Python由系统的包管理器(如apt、dnf等)管理,主要用于运行系统脚本或个人的简单脚本。系统Python的包安装在 /usr/lib/python3.X/site-packages 目录下,可以通过 ls /usr/lib/python3/dist-packages | tail -4 查看最后几个已安装的包。
警告:绝对不要在系统包目录中安装自己的包,这样做有破坏整个系统的风险,因为很多系统组件依赖于特定版本的Python库。
用户级Python位于 ~/.local/bin/python3,当使用 pip install --user 安装包时,这些包会被放置在 ~/.local/lib/python3.X/site-packages 目录下。这种方式将用户安装的包与系统包分离开来。
虚拟环境中的Python位于 venv/bin/python,它是完全隔离的,专属于某个特定项目。这种方式应该尽可能优先使用,因为它提供了最好的隔离性和可控性。
为何需要多个Python
Linux系统本身需要一个内部的Python来运行系统工具和脚本,这个Python必须保持稳定。与此同时,用户的各个项目应该相互隔离,避免一个项目的依赖影响另一个项目。不同的库可能需要不同版本的依赖,例如项目A需要numpy 1.20而项目B需要numpy 1.24。为了保证科学研究的可复现性,必须能够精确控制每个项目使用的库版本。隔离环境还能避免依赖冲突,即两个库要求同一个第三方库的不兼容版本。
基于以上原因,有几条规则需要遵守。对于简单的一次性脚本,可以使用系统包。但是 pip install --user ... 这种方式应该避免使用,因为它会污染用户级的Python环境。更严重的是 sudo pip install ...,这条命令绝对禁止使用,因为它会直接修改系统Python的包,可能导致系统工具失效。正确的做法是使用虚拟环境,这是不可或缺的实践。
pip与虚拟环境
直接安装的问题
使用 sudo pip install 安装包必然导致灾难性后果。这种操作会覆盖系统已有的模块,忽略版本依赖关系,可能使系统工具无法正常运行。
虚拟环境解决方案
virtualenv 或 Python 内置的 venv 模块提供了解决方案。创建虚拟环境使用命令 python3 -m venv venv,这会在当前目录下创建一个名为 venv 的文件夹,包含独立的Python解释器和包目录。
激活虚拟环境使用 source venv/bin/activate,激活后终端提示符通常会显示环境名称。此时所有的安装命令都只影响这个隔离的环境。
在激活的环境中安装包直接使用 pip install numpy matplotlib,这些包只会安装到当前虚拟环境中,不会影响系统或其他项目。
导出当前环境的依赖列表使用 pip freeze > requirements.txt,这会生成一个文本文件,记录所有已安装库及其精确版本号。这个 requirements.txt 文件包含了重建该环境所需的全部信息,其他人可以通过 pip install -r requirements.txt 来复制相同的环境。
然而这种方式存在局限性:依赖关系可能出现损坏,pip不会自动解决版本冲突,也没有真正的锁定文件机制来确保完全可复现的安装。
pip的优缺点
pip作为Python的原生工具,随Python一起安装,使用简单快速,几乎所有Python开发者都熟悉它。但pip缺乏完善的版本管理功能,不能自动解决版本冲突,requirements.txt并非可靠的锁定文件,因此不太适合正式的项目开发。目前大多数严肃的项目已经转向使用 uv 或 poetry 这样的现代工具。
可复现性原则
科学项目的要求
一个科学计算项目应当满足几个关键特性。首先是可复现性(reproductible),即能够方便地重现实验结果,其他研究者按照相同步骤应该得到相同的输出。其次是可移植性(portable),项目理想情况下应能在不同环境中运行,无论是个人笔记本、实验室服务器还是计算集群。第三是版本化(versionné),可以回退到项目的任意历史版本,追踪每次修改。最后是文档化(documenté),依赖关系、使用说明和参数设置都应有清晰的记录。
实现工具
为达成上述目标,需要组合使用多种工具。隔离的Python环境确保依赖不会相互干扰。Git进行代码版本控制,记录每次修改的历史。依赖文件如 pyproject.toml 声明项目需要哪些库,配套的 lock 文件则锁定每个库的精确版本。预处理脚本应该是确定性的,例如数据下载脚本每次运行结果应该相同。对于涉及随机数的程序,需要显式设置随机种子(seeds),使随机数生成器产生可重复的序列。
Git版本控制
核心概念
Git本质上是一个文件系统快照的DAG(有向无环图)操作工具。DAG是Directed Acyclic Graph的缩写,表示一种没有循环的有向图结构。在Git中,图的每个节点(称为commit)代表文件系统在某一时刻的完整快照。节点之间的边表示版本的演进关系,从一个commit可以追溯到它的父commit,但不会形成循环。
Git的基本操作围绕几个概念展开。commit是一次提交,保存当前所有被跟踪文件的状态。stage(暂存区)是准备纳入下次提交的修改集合。diff显示两个版本之间的差异。branches(分支)允许开发线路分叉,同时进行不同的工作。merge将分叉的分支合并回来。remotes是远程仓库,如GitHub或GitLab上的副本。.gitignore 文件指定哪些文件不应被Git跟踪,这对Python项目尤其重要,因为有很多自动生成的文件不应该进入版本控制。
最佳实践
仓库中不应存放大型数据文件,数据应该通过其他方式管理和分发。不要提交无用的产物,如Python编译生成的 .pyc 文件或虚拟环境目录 venv/,这些都应该写入 .gitignore。每次commit应该对应一个明确的目标,而不是把多个不相关的修改混在一起。提交信息应该清晰描述所做的修改,例如写 fix solve_ode precision bug 而不是含糊的 update,这样在回顾历史时能快速理解每次修改的目的。
Git文件管理策略
.gitignore配置
一个典型的Python项目的 .gitignore 文件应该包含以下内容:
__pycache__/
*.pyc
*.ipynb_checkpoints/
venv/
.env
.DS_Store
__pycache__/ 是Python自动生成的字节码缓存目录。*.pyc 是编译后的Python字节码文件。*.ipynb_checkpoints/ 是Jupyter Notebook自动保存的检查点文件。venv/ 是虚拟环境目录。.env 通常存放环境变量和敏感配置。.DS_Store 是macOS系统自动生成的文件夹元数据。
版本控制的内容划分
应该始终纳入版本控制的文件包括:src/ 源代码目录、pyproject.toml 项目配置文件、README.md 项目说明文档、scripts 脚本文件、轻量级的notebooks用于演示或教学、以及examples示例代码。
绝对不应该纳入版本控制的内容包括:虚拟环境(environments)目录,因为它们可以从依赖文件重建,大型的训练好的模型文件(modèles entraînés lourds),这些应该通过专门的模型托管服务分发,数据集(datasets),数据应该通过其他方式管理,仓库中只保留获取数据的脚本。
项目组织架构
理想的分层结构
一个完整的科学计算项目理想情况下应该分成三个层次。第一层是核心库文件夹,使用git和uv管理,包含最小化的依赖,可能涉及C代码等底层实现,这是项目的基础算法和数据结构。
第二层是工具文件夹,同样使用git和uv管理,它依赖并使用第一层的库,提供命令行接口、图形界面、数据读写功能等面向用户的功能。
第三层是实验文件夹,每个实验是独立的目录,使用git和uv管理,调用第二层的工具来产生结果。这一层包含数据下载脚本、分析用的notebooks、运行脚本和生成的图表。
这种分层结构将稳定的核心代码与频繁变动的实验代码分离,便于维护和复用。
Python包的创建
基本概念
Python包是一个包含代码的文件夹,可以通过pip安装,安装后可以在任何地方导入使用。创建包的目的是将代码组织成可复用、可分发的单元。
推荐的目录结构
project/
src/
monmodule/
__init__.py
utils.py
tests/
pyproject.toml
项目根目录下有三个主要部分。src/ 目录包含实际的模块代码,其中 monmodule/ 是模块文件夹,__init__.py 标识这是一个Python包(可以为空或包含初始化代码),utils.py 等文件包含具体功能。tests/ 目录存放测试代码。pyproject.toml 是项目的配置文件。
这种结构的优势在于:导入语句清晰整洁,如 from monmodule.utils import f,本地开发时可以通过 pip install -e . 以可编辑模式安装,修改代码后无需重新安装即可生效。
pyproject.toml配置
pyproject.toml 是近年来标准化的现代项目配置文件,其基本结构如下:
[project]
name = "monmodule"
version = "0.1.0"
description = "Exemple de package"
requires-python = ">=3.10"
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project] 部分定义项目的基本信息:name 是包名,version 是版本号,description 是简短描述,requires-python 指定支持的Python版本。[build-system] 部分指定构建工具,这里使用setuptools作为构建后端。
这个格式在PEP 621中被正式标准化,是官方推荐的配置方式。配置好后可以直接发布到PyPI供他人安装。pyproject.toml还可以包含丰富的元数据:作者信息、源代码仓库地址、项目网站、文档站点、关键词标签等。
现代依赖管理工具
uv工具
uv是由Astral公司开发的新一代Python工具,它整合并替代了pip、venv、virtualenv、pip-tools等多个工具的功能,执行速度极快。uv的主要功能包括:创建、管理和使用虚拟环境,安装包时自动解析依赖关系和处理版本冲突,生成lock文件锁定精确版本,完整的项目管理功能。相比poetry,uv的使用门槛稍高一些。
基本使用流程:
uv init
uv add numpy scipy
uv run python script.py
uv init 初始化一个新项目。uv add numpy scipy 添加依赖包,uv会自动处理版本解析。uv run python script.py 在项目环境中运行脚本。
Poetry工具
Poetry提供了与uv基本相同的功能。它比uv出现得更早,因此获得的标准化支持较少,执行速度也相对较慢。Poetry遵循语义化版本控制(SemVer)规范,可以方便地将包发布到PyPI,入门使用比uv更简单直观。
基本使用流程:
poetry init
poetry add numpy
poetry shell
poetry init 交互式地创建新项目配置。poetry add numpy 添加依赖。poetry shell 进入项目的虚拟环境shell,之后可以直接运行Python命令。
代码质量保障
静态检查(Linting)
Linting是一种静态代码分析技术,在不运行代码的情况下检测潜在问题。它能够发现多种类型的错误:定义了但从未使用的变量(variables inutilisées)、导入了但没有用到的模块(imports morts)、可能导致问题的危险语法模式(syntaxe dangereuse)等。推荐使用的工具是ruff,它的执行速度极快,能够在瞬间完成对整个项目的检查。
代码格式化(Formatage)
格式化工具用于自动标准化代码风格,确保整个项目的代码外观一致。推荐使用black,它会自动调整缩进、空格、换行等,使代码符合统一的风格规范。使用格式化工具的好处是团队成员不再需要争论代码风格问题,所有代码都会被自动调整为相同的格式。
类型标注(Typage)
Python可以添加类型信息来增强代码的可读性和可维护性。类型标注的语法示例如下:
def (a: int = 0) -> str:
return str(a)
这个函数声明参数 a 的类型是 int,默认值为0,返回值类型是 str。由于Python是动态类型语言,即使传入一个 str 或 float 类型的参数,函数也不会报错,代码仍然会执行。但是通过mypy等类型检查工具,可以在运行前检测出这类逻辑错误。
实用命令
首先安装所需工具:
uv add ruff black mypy
然后可以对源代码目录运行各种检查:
ruff check src/
black src/
mypy src/
ruff check src/ 对src目录进行静态检查。black src/ 格式化src目录下的所有Python文件。mypy src/ 进行类型检查。
自动修复功能可以让工具直接修正发现的问题:
ruff check --fix
black .
ruff check --fix 会自动修复ruff能够处理的问题。black . 格式化当前目录下的所有文件,点号表示当前目录。
开发工具选择
VS Code
VS Code是目前最流行的代码编辑器之一,具有丰富的功能:内置的linter支持可以实时显示代码问题,保存文件时自动格式化,集成的Jupyter notebook支持,内置终端可以直接在编辑器中运行命令,官方提供的Python扩展功能完善。
Spyder
Spyder的界面风格类似MATLAB,对于习惯MATLAB的科学计算用户来说过渡更平滑。它提供了图形化的调试器(debugger graphique),可以直观地设置断点、查看变量值、单步执行代码。
极客向编辑器
VIM和Emacs是两款历史悠久的终端编辑器,学习难但掌握后效率极高。Cursor是集成了AI辅助功能的现代编辑器。Zed是一款新兴的高性能编辑器。
命令行界面(CLI)
掌握命令行操作对于以下场景不可或缺:自动化任务处理(automations)、编写和运行脚本(scripts)、保证工作的可复现性(reproductibilité)、在远程服务器上工作(travail sur serveurs),因为服务器通常没有图形界面。
本节总结
本节课涵盖了Python科学计算开发环境的核心内容:Linux系统的基本使用和文件系统结构,系统中不同Python安装的位置和用途,如何创建隔离的开发环境,pip、uv、poetry等包管理工具的使用,如何将代码组织成可分发的Python包,Git版本控制的基本概念和最佳实践,以及使用格式化工具、静态检查工具和类型标注来保证代码质量。
Python进阶与面向对象编程
本节属于数值计算课程的第二讲,主要介绍Python的高级特性。在开始之前,需要确保已经掌握以下预备知识:基础数据结构(列表、字典、集合)、控制结构(if、else、for等)、基础语法(赋值、算术运算、函数定义)以及模块的使用。这些内容是后续学习的基础。
类与面向对象编程
面向对象的核心思想
面向对象编程(POO/OOP)的核心概念是将数据与处理这些数据的函数绑定在一起形成一个整体,这个整体就叫做对象。在Python中,一切皆为对象,包括整数这样的基本类型。当你执行 python3 -c "print(type(3))" 时,会发现即使是数字3也有其类型,它是 int 类的一个实例。
对象具有两个基本组成部分:属性(attributs)和方法(méthodes)。属性是对象内部存储的数据,而方法是附加在对象上的函数,用于操作这些数据。这里需要理解一个关键点:在Python中,函数本身也是对象,因此函数可以作为参数传递给其他函数,这为后面的一等公民函数奠定了基础。
使用面向对象的理由
采用面向对象的编程方式有几个明确的好处:它能够让代码结构更加清晰,将相关的数据和操作这些数据的方法组织在一起,它允许创建自定义类型,比如向量、信号、滤波器等适合特定应用场景的数据类型,它还能促进代码的复用和扩展,当需要修改或增强功能时,不必重写整个程序。
类的基本结构
下面通过一个低通滤波器的例子来说明类的组成部分:
class FiltrePasseBas:
def __init__(self, fc):
self.fc = fc # attribut
def filter(self, signal):
...
在这个例子中,__init__ 是构造函数,当创建类的新实例时会自动调用它。self 代表当前正在操作的实例对象,通过 self 可以访问和修改实例的属性。self.fc = fc 这行代码将传入的参数 fc(截止频率)保存为实例的内部属性。filter 是一个方法,它是与对象关联的函数,可以访问对象的属性并对信号进行处理。FiltrePasseBas 是类名,同时也是通过这个类创建的所有对象的类型名称。
继承与多态
继承机制
继承允许基于一个已存在的类创建新的派生类,派生类会继承父类的属性和方法,同时可以添加新功能或重写已有功能。看下面的例子:
class Filtre:
def appliquer(self, x):
raise NotImplementedError
class FiltrePasseHaut(Filtre):
def appliquer(self, x):
...
这里 Filtre 是一个基类,它定义了 appliquer 方法但没有具体实现,而是抛出 NotImplementedError,这种做法表明子类必须重写这个方法。FiltrePasseHaut 通过在类名后的括号中写入 Filtre 来声明它继承自 Filtre 类。继承的好处在于,任何需要 Filtre 类型参数的函数,都可以接受 FiltrePasseHaut 或 FiltrePasseBas 或其他任何继承自 Filtre 的子类实例。
多态与鸭子类型
多态是指同一个方法在不同的类中可以有不同的行为实现。Python采用所谓的鸭子类型原则:如果一个东西走起来像鸭子,叫起来也像鸭子,那么它就是鸭子。在Python中,对象的接口(即它有什么方法可以调用)比它的实际类型更重要。只要一个对象具有所需的方法,就可以在相应的场景中使用它,而不必关心它具体属于哪个类。
一等公民函数
基本原理
在Python中,函数是一等公民,意味着函数和其他对象(如整数、字符串、列表)享有同等地位。具体表现为:可以将函数赋值给变量,可以将函数作为参数传递给另一个函数,可以从函数中返回另一个函数,可以将函数存储在列表、字典或类的属性中。
代码示例
def carre(x):
return x**2
def appliquer(f, x):
return f(x)
y = appliquer(carre, 3) # 将函数作为参数传递
在这个例子中,carre 函数计算平方值。appliquer 函数接受两个参数:一个函数 f 和一个值 x,然后将 f 应用于 x。调用 appliquer(carre, 3) 时,carre 函数本身(不是它的返回值)被传递给 appliquer,最终返回 3^2 = 9。
实际应用场景
一等公民函数在数值计算中有广泛应用:可以将代价函数传递给优化算法,如 algo_descente(f_cost, grad),可以将微分方程的右端函数传递给求解器,如 solve_ode(f, t0, x0),还可以构建数据处理流水线,将一系列处理函数串联起来。
闭包
定义与特性
闭包是一类特殊的函数,它具有两个特征:是函数,并且能够记住其定义时的上下文环境。Python中的函数可以访问在其外层作用域中定义的变量,这是闭包的基础。例如:
a = 10
def print_a():
print(a)
函数 print_a 虽然内部没有定义变量 a,但它可以访问外层作用域中的 a。
更准确地说,闭包是定义在另一个函数内部的函数,它捕获了外层函数的局部变量,并且即使外层函数已经执行完毕,这些被捕获的变量仍然可以被内部函数访问。
闭包示例
def generateur_gain(gain):
def appliquer(x):
return gain * x
return appliquer
filtre = generateur_gain(0.8)
y = filtre(10) # 返回 8.0
在这个例子中,generateur_gain 是一个外层函数,它接受参数 gain 并返回内部定义的函数 appliquer。当调用 generateur_gain(0.8) 时,创建了一个新的 appliquer 函数,这个函数记住了 gain = 0.8 这个值。之后即使 generateur_gain 函数已经返回,filtre(即返回的 appliquer 函数)仍然可以使用 gain 的值。调用 filtre(10) 时,计算 0.8 \times 10 = 8.0。
通过这种方式,generateur_gain 成为了一个函数工厂,每次调用它都会生成一个配置了特定增益值的新函数。这种模式在需要生成一系列相似但参数不同的函数时特别有用。
属性装饰器 @property
property的作用
在面向对象编程中,通常希望对类的属性访问进行控制,但又不想改变外部使用这个类的方式。@property 装饰器正是解决这个问题的工具。它允许在不改变接口的情况下控制对属性的访问,可以在存储值之前对其进行验证,可以动态计算一个"虚拟"属性(即这个属性并不真正存储,而是在访问时实时计算),同时还能提供一个简洁干净的API。
实际示例
class Signal:
def __init__(self, data, fs):
self._data = data
self.fs = fs
@property
def duree(self):
return len(self._data) / self.fs
@property
def data(self):
return self._data
@data.setter
def data(self, nouveau):
if len(nouveau) == 0:
raise ValueError("Le signal ne peut pas être vide.")
self._data = nouveau
在这个 Signal 类中,构造函数接收信号数据 data 和采样频率 fs。这里使用 self._data 而不是 self.data 来存储数据,下划线前缀是Python的约定,表示这是一个内部属性,不应该被外部直接访问。
duree 属性使用 @property 装饰,它是一个计算属性,返回信号的持续时间,计算方式为样本数除以采样频率,即 \text{duree} = \frac{\text{len(data)}}{f_s}。从外部看,访问 signal.duree 就像访问一个普通属性,但实际上每次访问时都会重新计算。
data 属性的getter同样使用 @property,返回内部存储的 _data。而 @data.setter 装饰器定义了设置 data 时的行为:在赋值之前检查新数据是否为空,如果为空则抛出异常,否则才更新内部数据。这样就实现了数据验证,同时对外保持了简洁的接口。
装饰器
基本概念
装饰器用于修改现有函数的行为而无需修改函数本身的代码。装饰器本质上是一个函数,它接受一个函数作为输入,并返回一个新函数。这种机制适合添加诸如验证或计时之类的附加行为。装饰器的典型应用包括:日志记录(logging)、执行时间测量(timing)、参数验证、以及记忆化(memoisation,即缓存函数结果避免重复计算)。
基础示例
def log(f):
def wrapper(*args, **kwargs):
print(f"Appel de {f.__name__}")
return f(*args, **kwargs)
return wrapper
@log
def carre(x):
return x**2
y = carre(4)
log 是一个装饰器函数,它接收函数 f 作为参数。在 log 内部定义了 wrapper 函数,这个函数首先打印被调用函数的名称,然后调用原始函数 f 并返回其结果。*args 和 **kwargs 用于接收任意数量的位置参数和关键字参数,确保 wrapper 可以包装任何签名的函数。最后 log 返回 wrapper 函数。
使用 @log 语法装饰 carre 函数,等价于执行 carre = log(carre)。当调用 carre(4) 时,实际执行的是 wrapper(4),它会先打印"Appel de carre",然后执行原始的 carre 函数返回16。
带参数的装饰器
装饰器本身也可以接受参数,这就需要额外增加一层函数嵌套。带参数的装饰器实际上是一个函数,它接受参数后返回一个装饰器,这个装饰器再接受函数并返回新函数。整个结构是:一个带参数的函数,它制造并返回一个装饰器,而这个装饰器接受函数并返回包装后的函数。
魔术方法
原理
魔术方法(也称为dunder方法,因为它们的名字前后都有双下划线)允许定义对象与运算符(如 +、-、== 等)交互时的行为,使自定义类能够融入Python的生态系统,并让代码更加可读和富有表达力。这些方法之所以叫dunder,是因为double underscore的缩写。
向量类示例
class Vecteur:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other):
return Vecteur(self.x + other.x, self.y + other.y)
def __mul__(self, a):
return Vecteur(a*self.x, a*self.y)
def __repr__(self):
return f"Vecteur({self.x}, {self.y})"
V1 = Vecteur(1, 1)
V2 = Vecteur(2, 2)
V3 = V1 + V2 # Vecteur(3, 3)
__init__ 是构造函数,初始化向量的 x 和 y 分量。__add__ 定义了 + 运算符的行为,当执行 V1 + V2 时,Python会调用 V1.__add__(V2),返回一个新向量,其分量为两个向量对应分量之和。__mul__ 定义了标量乘法,V * a 会调用此方法。__repr__ 定义了对象的字符串表示,当打印对象或在交互式环境中显示对象时会调用它。
使用魔术方法的理由
通过重载 +、-、@(矩阵乘法)、* 等运算符,可以为矩阵、信号、滤波器等对象定义自然的数学运算。这样可以实现向量、多项式、概率分布等数学对象,支持 with 语句来管理资源(如文件、GPU内存),使数据结构的使用更接近数学符号的表达方式。
迭代器
定义
迭代器是一种对象,它实现了 __iter__() 和 __next__() 两个方法,能够逐个提供元素而不是一次性将整个数据结构加载到内存中。这对数值计算特别有用,因为处理的数据量可能很大。Python中的 for 循环实际上就是在迭代器上进行迭代,例如 range 就是一个迭代器。许多基础数据结构都实现了迭代协议,比如字符串 str。
实现示例
class RangeClone:
def __init__(self, n):
self.n = n
self.i = 0
def __iter__(self):
return self
def __next__(self):
if self.i < self.n:
self.i += 1
return self.i
raise StopIteration
for k in RangeClone(3):
print(k) # 1, 2, 3
RangeClone 类模拟了 range 的行为。构造函数记录上限 n 和当前位置 i。__iter__ 返回迭代器对象本身(这里就是 self)。__next__ 在每次调用时返回下一个值:如果 i < n,则递增 i 并返回,否则抛出 StopIteration 异常,通知 for 循环迭代结束。
应用场景
迭代器在以下场景中特别有用:实现梯度下降的迭代过程、按块读取大型信号或图像文件、为机器学习生成mini-batch、构建数值序列(如迭代方法中的收敛序列)、在处理大数据时节省内存。
数据类
使用理由
dataclass 是Python 3.7引入的装饰器,用于简化数据结构的定义。它能自动生成 __init__、__repr__、__eq__ 等常用方法,使代码更加简洁可读,特别适合用来定义参数配置。
示例
from dataclasses import dataclass
@dataclass
class ParametresFiltre:
fc: float
ordre: int = 2
passe_bas: bool = True
p = ParametresFiltre(fc=200.0)
print(p)
使用 @dataclass 装饰后,只需声明属性及其类型,Python会自动生成构造函数。fc: float 声明了一个浮点类型的截止频率属性,没有默认值所以是必需参数。ordre: int = 2 声明了整型的阶数属性,默认值为2。passe_bas: bool = True 声明了布尔类型的低通标志,默认为True。创建实例时只需提供必需参数 fc,其他使用默认值。打印 p 时会自动显示所有属性值。
典型应用
数据类适合用于清晰地组织求解器的参数配置,以及存储机器学习模型的超参数,使参数管理更加规范和易于维护。
调试与错误处理
断言
断言是一种在代码执行过程中验证条件是否满足的机制。其基本语法如下:
assert x > 0, "x doit être strictement positif"
当 x > 0 为假时,程序会抛出 AssertionError 并显示后面的错误信息。断言主要用于原型开发阶段验证假设条件(如维度、类型等),帮助调试但不参与运行时控制。需要注意的是,断言可能被编译器优化掉(使用 -O 选项运行时),因此不应依赖断言进行生产环境的数据验证。
错误追踪
当程序出错时,Python会显示traceback(错误追踪信息)。阅读traceback时需要注意几点:首先定位错误出现的位置,traceback会显示行号、函数名和异常类型,其次要查看frames(调用栈帧),越靠下的frame越接近错误发生的位置,同时要注意错误可能来自更高层的调用。相比使用 print 语句调试,推荐使用 icecream 库(通过pypi安装),它能提供更丰富的调试信息。
显式异常
Python推崇EAFP原则(Easier to Ask Forgiveness Than Permission,宁可事后请求原谅,不要事先请求许可),即先尝试执行操作,出错后再处理异常,而不是事先检查所有条件。但在某些情况下,主动抛出异常是必要的:
if not isinstance(t, float):
raise TypeError("t doit être un float")
当参数 t 不是浮点数时,主动抛出 TypeError 异常并给出明确的错误信息。这种方式能让调用者快速定位问题所在。
内省机制
原理
内省是指程序在运行时检查自身结构的能力。在Python中,由于一切皆为对象,可以在运行时查询对象的类型、属性、方法、函数签名、所属模块等信息。
常用函数
type(obj) # 获取对象的类型
dir(obj) # 列出对象的所有属性和方法
id(obj) # 获取对象的唯一标识符
hasattr(obj, "x") # 检查对象是否有属性x
getattr(obj, "x") # 获取obj.x的值
setattr(obj, "x", valeur) # 设置obj.x = valeur
type() 返回对象所属的类。dir() 返回一个包含所有属性和方法名称的列表,对于探索未知对象的结构很有用。id() 返回对象在内存中的唯一标识,可用于判断两个变量是否指向同一对象。hasattr() 在访问属性前先检查其是否存在。getattr() 和 setattr() 允许通过字符串动态访问和设置属性。
探索函数
inspect 模块提供了更深入探索函数的工具:
import inspect
def f(a, b=1):
pass
inspect.signature(f) # 获取函数参数签名
inspect.getsource(f) # 获取函数源代码
inspect.getmodule(f) # 获取函数所在模块
inspect.signature() 返回函数的参数信息,包括参数名、默认值等。inspect.getsource() 返回函数的源代码字符串。inspect.getmodule() 返回定义该函数的模块对象。这些功能在动态分析代码或构建自动化工具时非常有用。
调试工具
内置调试器pdb
Python自带调试器pdb,可以在脚本中插入断点:
import pdb; pdb.set_trace()
执行到这行代码时,程序会暂停并进入交互式调试模式。常用命令包括:n(next,执行下一行)、s(step,进入函数内部)、c(continue,继续执行直到下一个断点)、p var(打印变量值)、l(list,显示当前代码上下文)。
更友好的调试工具
ipdb 是pdb的增强版,集成了IPython的特性,提供语法高亮和自动补全。pudb 提供终端界面的图形化调试体验。在IPython中,执行 %run main.py 后如果出错,可以使用 %debug 命令进入事后调试模式。
图形化调试
IDE如Spyder和VS Code提供图形化调试功能:可以通过点击行号设置断点,实时查看变量的值,逐行执行程序,以及可视化查看NumPy数组、图像、矩阵等数据结构。
调试实践建议
将bug隔离到一个简短的函数中便于定位问题。用最小的数据集复现错误,避免每次调试都要处理大量数据。使用 git checkout -b debug/... 创建专门的调试分支,可以放心地修改代码而不影响主分支。
性能分析
CPU性能分析
性能分析的目的是识别数值算法中的瓶颈,从而优化循环、卷积、积分、求解器等耗时操作。
Python内置的 cProfile 模块可以分析程序的CPU使用情况:
python -m cProfile -s tottime mon_script.py
-s tottime 选项按总耗时排序输出结果。分析完成后可以使用 pstats 模块处理结果,或使用 snakeviz(通过pip安装)进行可视化:
snakeviz output.prof
tuna 是另一个现代化的Web界面可视化工具:
tuna output.prof
需要注意的是,单纯依靠 print 语句和手动计时有其局限性,专业的性能分析工具能提供更全面准确的信息。
逐行性能分析
line_profiler 可以分析函数内每一行代码的执行时间,对于优化关键函数特别有用。安装方式为:
pip install line_profiler
使用时,用 @profile 装饰器标记需要分析的函数:
@profile
def compute_signal(x):
...
然后执行分析:
kernprof -l mon_script.py
python -m line_profiler mon_script.py.lprof
内存性能分析
memory_profiler 用于分析程序的内存使用情况,安装方式为:
pip install memory_profiler
同样使用 @profile 装饰器标记函数:
@profile
def allocate():
A = np.zeros((2000, 2000))
执行分析:
python -m memory_profiler mon_script.py
其他性能分析工具
timeit 用于精确测量小段代码的执行时间。scalene 是一个综合性能分析器,同时分析CPU和内存。tracemalloc 是Python内置的内存追踪模块。objgraph 用于分析对象引用关系,帮助发现内存泄漏。pyspy 是一个采样式性能分析器,可以在不修改代码的情况下分析正在运行的Python程序。
NumPy数组基础
Python的性能局限性
Python本质上是一种脚本语言,其设计初衷是为了快速开发和易用性,而非追求极致的运行速度。Python的内置列表类型过于通用,属于高层次的数据结构,这种通用性带来了灵活性,但也牺牲了性能。
Python之所以运行较慢,根本原因在于它是一门动态类型语言。当执行一个简单的加法操作 a + b 时,Python解释器必须在运行时完成一系列检查工作:首先验证变量 a 的类型,然后确认该类型是否支持加法运算符,最后才尝试执行加法操作。即使 a 和 b 都是整数,这些类型检查步骤仍然不可避免。此外,Python的循环结构本身就存在较大的开销,每次迭代都伴随着类型检查和解释执行。为了实现跨平台的可移植性,Python代码运行在虚拟机之上,这又增加了一层额外的性能损耗。
NumPy的设计理念
NumPy的诞生有着明确的历史背景:它最初就是为了在Python中替代MATLAB的功能而开发的。NumPy的核心贡献是引入了一个基础数据类型 numpy.array,这个类型专门针对数值计算进行了优化。
从技术架构来看,NumPy实际上是一个C语言编写的库,内部实现了复杂的数组数据结构,然后通过Python接口暴露给用户,使得使用者可以用简洁的Python语法来调用高效的底层实现。
向量化计算的核心机制
NumPy的基础对象是 numpy.ndarray,它具备以下关键特性:数组中的所有元素必须是同一类型,即同质数组;数据在内存中以连续或近似连续的方式存储;所有运算操作都用C或Fortran语言实现。当你对ndarray执行一个操作时,实际上触发的是一个经过高度优化的C语言循环,而不是Python层面的逐元素处理。
NumPy还充分利用了成熟的底层数值计算库,包括BLAS(基础线性代数子程序库)、LAPACK(线性代数包)以及它们的高性能实现如OpenBLAS和Intel MKL。这些库经过数十年的优化,能够充分发挥硬件性能。
性能提升的来源
使用NumPy带来的性能收益体现在多个层面。首先,它消除了显式的Python循环,将循环下沉到C语言层面执行,这就是所谓的向量化计算。典型情况下,向量化代码比纯Python实现快10到100倍,当处理大规模数据时加速比更为显著。其次,由于数据在内存中连续存储,CPU缓存的利用率大幅提高,减少了内存访问的延迟。同时,底层库能够自动利用CPU的SIMD指令集实现底层并行计算。最后,向量化的代码风格使得程序的书写形式更接近数学公式,提高了代码的可读性。
数值计算功能覆盖
NumPy与SciPy共同构成了Python科学计算的基础设施,覆盖了数值计算的各个核心领域。
线性代数
矩阵乘法可以通过 np.dot 函数或 @ 运算符实现。线性方程组求解使用 np.linalg.solve,特征值分解使用 np.linalg.eig,奇异值分解使用 np.linalg.svd。
统计与概率
随机数生成推荐使用 np.random.default_rng,它支持各种常用的概率分布。描述性统计函数包括均值 mean、标准差 std、分位数 quantile 以及相关系数计算。概率分布的密度函数、累积分布函数和分位数函数可以通过 scipy.stats 模块获取,例如正态分布 scipy.stats.norm 和泊松分布 poisson。基本的统计检验功能也已内置。
优化问题
函数最小化问题分为无约束优化(使用 minimize 和 least_squares)和线性/二次规划(使用 linprog 和 lsq_linear)。方程求根可以用 root_scalar 处理一元方程,用 root 处理多元方程组。非线性模型的参数拟合使用 curve_fit。
微分方程与数值积分
常微分方程初值问题使用 solve_ivp 求解。数值积分函数包括一重积分 quad、二重积分 dblquad 以及辛普森积分 simpson。
信号与图像处理
快速傅里叶变换通过 scipy.fft.fft 和 ifft 实现,支持一维和二维变换。数字滤波器设计支持FIR和IIR类型,可以计算频率响应。图像处理方面支持卷积、滤波以及基本的形态学操作。
ndarray的内存模型
ndarray 本质上是一个结构化的内存块,附带有描述性的元数据。数组对象本身只是对一段连续内存区域的引用,而元数据属性则定义了如何解释这段原始字节数据,包括数据类型、数组形状、步长等信息。这种设计将数据存储与数据解释分离,使得同一块内存可以被不同方式访问,这是NumPy实现视图、切片等高效操作的基础。
ndarray的核心属性
形状相关属性
每个ndarray对象都有三个描述其结构的基本属性。x.ndim 表示数组的维度数量,即数组有几个轴。x.shape 是一个元组,记录每个维度的大小。x.size 则是数组中元素的总数,等于 shape 中所有维度大小的乘积。
以 x = np.zeros((3, 4)) 为例,这创建了一个 3 \times 4 的全零矩阵。此时 x.ndim 等于2,因为它是二维数组;x.shape 等于 (3, 4),表示3行4列;x.size 等于12,即 3 \times 4 = 12 个元素。
类型与内存属性
x.dtype 指定数组元素的数据类型,可以是 float64、int32、complex128 等。x.itemsize 表示单个元素占用的字节数。x.nbytes 是数组占用的总字节数,等于 x.size * x.itemsize。
对于上述 3 \times 4 的数组,默认 dtype 是 float64,每个元素占8字节,因此 x.itemsize 为8,x.nbytes 为 12 \times 8 = 96 字节。
步长属性
x.strides 是一个元组,记录在每个维度上移动一个位置需要跳过的字节数。这个属性直接决定了如何在底层内存中定位元素。x.flags 包含数组的底层状态信息,包括是否是C连续存储、是否是Fortran连续存储、以及是否可写。
数据与视图机制
x.data 是一个指向底层数据缓冲区的指针,这是数组实际存储数据的内存地址。x.base 属性用于判断当前数组是否是另一个数组的视图:如果 x 是从其他数组派生出的视图,x.base 就指向那个父数组;如果 x 拥有自己独立的数据,x.base 为 None。
视图与副本是NumPy中的核心概念。视图与原数组共享同一块内存,对视图的修改会影响原数组。副本则分配全新的内存块,与原数组完全独立。例如执行 y = x[:, 0] 时,y 是 x 第一列的视图,此时 y.base is x 返回 True,表明 y 和 x 共享数据。
其他常用属性
x.T 返回数组的转置,当可能时它是一个视图而非副本。对于复数数组,x.real 和 x.imag 分别访问实部和虚部。x.flatten 方法将多维数组展平为一维数组。
dtype与strides的底层原理
ndarray的本质是一个结构化的视图,它建立在一块连续的C语言风格内存之上。理解 dtype 和 strides 是理解NumPy内存模型的关键。
dtype的作用
dtype 定义了三个层面的信息:底层C语言类型(如 double 对应 float64,int 对应 int32,复数类型 complex128 等)、单个元素的内存大小、以及内存对齐方式。整个数组的所有元素必须是同一 dtype,这种同质性是NumPy高效的前提。dtype 的选择直接影响数值计算的精度、运算性能以及内存占用。
strides的工作机制
C语言中的内存是线性连续的,而多维数组需要一种方式将多维索引映射到一维内存地址。strides 就是这个映射的关键:它定义了在每个维度上前进一步需要跳过多少字节。具体来说,strides 决定了如何从位置 x[i, j] 导航到 x[i+1, j](沿第一个轴移动)或 x[i, j+1](沿第二个轴移动)。
以 x = np.zeros((3, 4)) 为例,x.strides 等于 (32, 8)。每个 float64 元素占8字节,所以沿列方向移动一步(从 x[i,j] 到 x[i,j+1])需要跳过8字节。一行有4个元素,所以沿行方向移动一步(从 x[i,j] 到 x[i+1,j])需要跳过 4 \times 8 = 32 字节。
与C/Fortran的内存布局关系
NumPy默认采用行优先存储,即C-order,元素按行连续排列在内存中。也支持列优先存储,即Fortran-order,元素按列连续排列。可以通过 np.asfortranarray(x) 将数组转换为Fortran顺序,转换后 x.flags["F_CONTIGUOUS"] 为 True。
NumPy的运算操作在底层调用的是优化过的C循环。视图操作(如切片、转置)只修改 shape 和 strides 元数据,不复制实际数据,因此非常高效。
性能注意事项
转置操作通常是 O(1) 复杂度,因为它只改变 strides 而不移动数据。切片操作返回视图而非副本,这意味着不会产生数据复制的开销。然而,不连续的 strides(比如转置后的数组)可能导致缓存命中率下降,造成隐性的性能损失,这种损失在代码层面不可见。NumPy的这种内存模型是与C、C++、Fortran进行接口对接的基础,Cython和ctypes等工具都依赖于此。
NumPy标量类型
NumPy标量表示单个有类型的数值,其类型系统与ndarray保持一致。常用的标量类型包括整数类型 np.int32 和 np.int64,浮点类型 np.float32 和 np.float64,复数类型 np.complex64 和 np.complex128,以及布尔类型 np.bool_。可以通过类型构造函数创建标量,如 a = np.float64(3.14) 和 b = np.int32(5)。
与Python标量的区别
NumPy标量与Python内置的数值类型有本质区别。NumPy标量是固定类型的,类似C语言,而Python的数值类型是动态的。NumPy标量的内存大小在编译时已知且固定,而Python整数可以是任意精度的。NumPy标量是ndarray内部实际存储的数据类型,它们的行为与向量化计算完全一致。
通过 type() 函数可以观察这种区别:type(np.float64(3.14)) 返回 numpy.float64,而 type(3.14) 返回Python的 float 类型。
标量与数组的关联
当从数组中索引单个元素时,如 x[i],返回的是NumPy标量而非Python标量。所有运算操作都遵循数组的 dtype,例如 np.array([1, 2, 3], dtype=np.float32) 中的元素类型是 numpy.float32。
创建NumPy数组
创建ndarray的过程本质上是定义三个要素:数据内容、数组形状和元素类型。
从Python数据创建
最直接的方式是将Python列表传入 np.array() 函数。np.array([1, 2, 3]) 创建一维数组,np.array([[1, 2], [3, 4]], dtype=np.float64) 创建二维数组并指定数据类型为64位浮点数。
初始化数组
当需要特定形状的数组但数据稍后填充时,可以使用初始化函数。np.zeros((3, 4)) 创建全零数组,np.ones((2, 2)) 创建全一数组,np.empty((1000, 1000)) 分配内存但不初始化(内容是随机的残留值,速度最快),np.eye(4) 创建 4 \times 4 单位矩阵,np.ones_like(A) 创建与数组 A 形状和类型相同的全一数组。
数值序列
np.arange(0, 10, 0.1) 类似Python的 range,生成从0到10(不含)、步长为0.1的序列。np.linspace(0, 1, 100) 生成从0到1(包含两端)的100个等间距点,当需要精确控制点的数量时更常用。
随机数组
np.random.rand(3, 3) 生成 3 \times 3 的均匀分布随机数组,元素在 [0, 1) 区间。np.random.randn(1000) 生成1000个标准正态分布随机数。np.random.normal(0, 1, size=(2, 3)) 显式指定均值为0、标准差为1的正态分布,生成 2 \times 3 的数组。
向量化计算
向量化的核心思想是用数组操作替代Python循环,将计算下沉到C语言层面执行。
循环代码与向量化代码的对比
传统的Python循环写法如下:
y = np.zeros_like(x)
for i in range(len(x)):
y[i] = np.sin(x[i]) * np.exp(-x[i])
向量化的等价写法只需一行:
y = np.sin(x) * np.exp(-x)
这两段代码在数学上完全等价,但性能差异巨大。向量化版本的循环是用C语言编写的,当内存访问是连续的时候速度最快,因为可以充分利用CPU缓存和SIMD指令集进行并行计算。典型情况下向量化代码比纯Python循环快10到100倍,同时代码更短更易读,也减少了索引越界等错误的可能性。
Broadcasting机制
Broadcasting是NumPy的隐式维度扩展机制,它允许不同形状的数组进行运算,而不需要实际复制数据。例如:
x = np.arange(1000)
y = x + 3
x = np.reshape(x, (-1, 10))
y = np.reshape(y, (10, -1))
z = x**2 + y**2
在 x + 3 中,标量3被自动扩展为与 x 相同形状的数组。在 x**2 + y**2 中,形状为 (-1, 10) 和 (10, -1) 的数组通过broadcasting进行匹配计算。这种扩展是虚拟的,不产生内存复制。
索引与切片
基本索引
NumPy的索引从0开始,负数索引表示从末尾倒数。对于数组 x = np.arange(10),x[0] 获取第一个元素,x[-1] 获取最后一个元素。
切片语法
切片的基本语法是 start:stop:step,其中 start 是起始索引(包含),stop 是结束索引(不包含),step 是步长。x[2:7] 获取索引2到6的元素,x[:5] 获取前5个元素(索引0到4),x[::2] 每隔一个元素取一个。切片返回的是视图而非副本,对切片的修改会影响原数组。
多维数组索引
对于二维数组 A,使用逗号分隔不同维度的索引。A[1, 2] 获取第1行第2列的元素,A[:, 0] 获取第一列(所有行的第0列),A[1, :] 获取第1行(第1行的所有列)。
高级索引
布尔掩码
布尔数组可以作为索引,选出所有对应位置为 True 的元素。x[x > 0] 选出数组中所有正数元素。布尔掩码返回的是满足条件的元素组成的一维数组。
整数数组索引
可以用整数数组指定要选取的元素位置:
idx = [0, 2, 4]
y = x[idx]
这种索引方式返回副本而非视图。结果数组 y 的形状与索引数组 idx 的形状相同。对于二维数组,需要为每个轴提供一个索引数组,且所有索引数组必须具有相同的形状(如果是二维数组则需要2个索引数组)。
相关的工具函数包括 np.where(条件选择)、np.nonzero(非零元素索引)、np.ogrid(开放网格)、np.diag(对角线元素)等。
视图与副本的区别
简单切片返回视图,速度快,修改视图会影响原数组。高级索引(布尔掩码和整数数组索引)返回副本,会产生内存分配开销,修改副本不影响原数组。
Broadcasting详解
Broadcasting允许不同形状的数组进行运算,而不实际复制数据。
兼容性规则
两个维度是兼容的,当且仅当它们相等,或者其中一个为1。当维度为1时,该维度会被虚拟地扩展以匹配另一个数组的对应维度。这种扩展不涉及实际的内存复制。
多维情形
考虑 A = np.ones((3, 4)) 和 b = np.arange(4)。A 的形状是 (3, 4),b 的形状是 (4,)。执行 C = A + b 时,b 被扩展为形状 (3, 4),相当于将 b 复制3份堆叠成3行,然后与 A 逐元素相加。实际上 b 的数据只存储一次,扩展是通过修改 strides 实现的。
常见错误
如果 A = np.ones((3, 4)) 而 b = np.arange(3),执行 A + b 会产生 ValueError。因为 A 的形状 (3, 4) 与 b 的形状 (3,) 不兼容:从右对齐后,4和3既不相等也没有一个是1。
实用技巧
维度对齐是从右向左进行的。当需要broadcasting时,可以通过 reshape 添加维度1来调整形状。单独的1可以作为通配维度,匹配任意大小。einops 库提供了更直观的维度操作语法。
输入输出操作
I/O操作用于读写各类数据,包括文件、图像、信号和数值计算结果。推荐使用 pathlib 模块处理文件路径。
文本文件
import numpy as np
x = np.loadtxt("signal.txt")
np.savetxt("out.txt", x)
文本格式可读性好但效率较低,适合小规模数据交换。
NumPy二进制格式
np.save("signal.npy", x)
x = np.load("signal.npy")
.npy 格式是NumPy的原生二进制格式,保存和加载速度快,保留完整的dtype和shape信息。
多数组归档
np.savez("data.npz", x=x, y=y)
data = np.load("data.npz")
x = data["x"]
.npz 格式可以将多个数组打包到一个文件中,通过键名访问各个数组。
图像文件
import imageio.v3 as iio
img = iio.imread("image.png")
iio.imwrite("out.png", img)
imageio 库支持常见图像格式的读写。对于科学数据,hdf5 和 netcdf 格式也很常用,许多库都支持NumPy数组。
通用函数ufuncs
ufuncs(Universal Functions)是NumPy中对数组进行逐元素操作的函数。
核心特性
ufuncs是向量化的,底层用C或SIMD指令实现,速度很快。它们自动支持broadcasting,并且能正确处理不同dtype之间的类型转换。
常用示例
数学函数包括 np.exp(x)、np.log(x + 1)、np.sqrt(x)、np.sin(x) 等。这些函数作用于数组的每个元素,返回相同形状的结果数组。
运算符即ufuncs
Python的算术运算符在NumPy数组上被重载为对应的ufuncs。x + y 等价于 np.add(x, y),x * y 等价于 np.multiply(x, y),x ** 2 是逐元素平方,x > 0 返回布尔数组。显式调用函数形式(如 np.add)在需要指定输出数组或其他高级参数时更有用。
ufuncs的高级参数
out参数
out 参数允许指定结果写入的目标数组,实现原地操作:
np.add(x, y, out=z)
这种写法避免了 z = x + y 所产生的内存分配开销。当 z = x + y 执行时,NumPy会先分配一块新内存存储结果,然后将引用赋给 z。如果 z 已经存在且大小合适,使用 out=z 可以直接将结果写入现有内存,在处理大数组或循环计算时能显著减少内存分配次数。
where参数
where 参数用于条件性地执行操作:
np.divide(a, b, where=b!=0)
只有在 where 条件为 True 的位置才执行除法运算,这样可以安全地处理除零问题。不满足条件的位置保持原值不变。
归约操作
归约操作将数组沿某个轴压缩为更低维度的结果。x.sum() 计算所有元素之和,x.mean(axis=0) 沿第0轴(行方向)计算均值,得到每列的均值,x.max() 返回最大值。axis 参数指定沿哪个轴进行归约,不指定则对整个数组操作。
结构化数组
结构化数组允许在单个数组中存储异构数据,即不同类型的数据可以组合在一起。
基本原理
每个数组元素实际上是一个C语言风格的结构体,包含多个命名字段。数据在内存中仍然是连续存储的,可以通过字段名访问各个分量,并且与ufuncs兼容。
dtype定义
结构化数组的dtype通过字段列表定义:
dtype = [
("t", "f8"),
("x", "f4"),
("y", "f4"),
]
这里定义了三个字段:t 是64位浮点数(f8 表示8字节float),x 和 y 是32位浮点数(f4 表示4字节float)。
创建与使用
data = np.array(
[(0.0, 1.2, 3.4), (0.1, 1.3, 3.5)],
dtype=dtype
)
通过字段名访问数据:data["t"] 返回所有元素的 t 字段组成的数组。可以对字段进行向量化计算:
data["x"] = np.sin(2*np.pi*data["t"])
data["y"] = np.cos(2*np.pi*data["t"])
这种计算是向量化的,无需循环。
内存布局
内存中数据按照 [t | x | y][t | x | y]... 的方式交替存储,即每个记录的所有字段连续存放。这种布局称为AoS(Array of Structures)。逐字段访问时,由于字段在内存中不连续,缓存命中率不如将每个字段单独存储为独立数组(SoA,Structure of Arrays)的方式高效。
掩码数组
掩码数组用于处理包含无效值或缺失值的数据。
基本结构
一个掩码数组由两部分组成:数据数组和布尔掩码数组。掩码为 True 的位置表示该元素无效或缺失。
import numpy as np
x = np.array([1.0, 2.0, -999.0, 4.0])
mx = np.ma.masked_equal(x, -999.0)
这里 -999.0 被标记为缺失值。
访问与检查
mx.data 返回底层的原始数据数组,mx.mask 返回布尔掩码数组。被掩码的值不参与任何计算,掩码会在运算过程中自动传播(如果任一操作数被掩码,结果也被掩码)。ufuncs对掩码数组有一致的行为支持。
mx.mean()、mx.sum()、mx.max() 等方法会自动忽略被掩码的元素。
掩码创建方式
np.ma.masked_invalid(x) # 掩盖NaN和inf
np.ma.masked_where(x < 0, x) # 掩盖满足条件的元素
# 或手动指定掩码
mask = x < 0
mx = np.ma.array(x, mask=mask)
数组接口协议
数组接口协议允许编写独立于具体库(NumPy、CuPy、PyTorch、JAX等)的通用数值代码。
协议原理
如果一个对象暴露 __array_interface__ 属性(旧版)或 __array__ 方法(现代版),它就被视为NumPy兼容的数组对象。对象自身负责证明其兼容性。
class MonArray:
def __init__(self, data):
self.data = data
@property
def __array_interface__(self):
return self.data.__array_interface__
import numpy as np
a = MonArray(np.arange(5))
np.sum(a) # 正常工作
应用场景
这个协议用于与C/C++代码进行接口对接、实现共享内存访问、与外部缓冲区进行零拷贝数据交换、以及NumPy与SciPy、Cython之间的通信。
数组API标准
数组API标准的目标是为NumPy、CuPy、PyTorch、JAX、MXNet等库定义统一的数组操作API。
代码可移植性
遵循此API编写的代码可以在任何支持该标准的库上运行,无需在不同数组类型之间进行显式转换(如从CuPy转到NumPy)。所有库使用相同的函数名、相同的函数签名、相同的语义规则。
例如 freq, Pxx = signal.welch(x) 这样的调用,如果 signal.welch 函数遵循数组API,那么无论 x 是NumPy数组还是CuPy数组,返回的 freq 和 Pxx 都会是相同类型并位于相同设备上。
线性代数
矩阵乘法
A @ x # 矩阵-向量乘积
A @ B # 矩阵-矩阵乘积
@ 运算符是Python 3.5引入的矩阵乘法运算符(PEP 465),而 * 运算符执行的是逐元素乘法。早期NumPy有一个 matrix 类型,其中 * 表示矩阵乘法,但这个类型已被废弃,不应再使用。
np.linalg模块
np.linalg 包含各种线性代数函数:norm 计算范数,inner 和 outer 分别计算内积和外积,einsum 执行爱因斯坦求和约定,matvec 执行矩阵-向量乘法,kron 计算Kronecker积。
线性方程组求解
求解方程组 Ax = b:
x = np.linalg.solve(A, b)
这个函数适用于方阵且可逆的情况。应避免使用 np.inv 计算逆矩阵然后相乘,除非确实需要逆矩阵本身,因为直接求解更稳定且高效。
特殊情况
对于超定系统(方程数多于未知数),使用最小二乘解:
x, *_ = np.linalg.lstsq(A, b, rcond=None)
返回值 x 是使 \|Ax - b\|^2 最小的解。
对于对称正定矩阵,可以使用Cholesky分解获得更高效的求解:
x = scipy.linalg.cho_solve(
scipy.linalg.cho_factor(A), b
)
Cholesky分解利用矩阵的对称正定性质,计算量约为一般LU分解的一半。
矩阵分解与因式分解
进行矩阵分解时需要注意:矩阵 A 必须能够完整放入内存中。
LU分解
LU分解将矩阵分解为下三角矩阵和上三角矩阵的乘积:
P, L, U = scipy.linalg.lu(A, permute_l)
推荐使用 permute_l=True 参数,此时 L 已经包含了行置换信息。L 是下三角矩阵(对角线以下非零),U 是上三角矩阵(对角线以上非零)。LU分解本质上是高斯消元法的矩阵表示形式,常用于求解线性方程组和计算行列式。
QR分解
QR分解将矩阵分解为正交矩阵和上三角矩阵的乘积:
Q, R = np.linalg.qr(A)
Q 是正交矩阵(Q^TQ = I,列向量相互正交且模为1),R 是上三角矩阵。QR分解在最小二乘问题和特征值计算中有广泛应用。
Cholesky分解
Cholesky分解适用于对称正定矩阵,将其分解为下三角矩阵及其转置的乘积:
L = np.linalg.cholesky(A)
矩阵必须是对称正定(SPD)的。Cholesky分解速度快且数值稳定,计算量约为LU分解的一半,是求解对称正定线性系统的首选方法。
特征值分解
eig 和 eigvals 用于一般矩阵的特征值和特征向量计算。eigh 和 eigvalsh 专门用于厄米特矩阵(实对称矩阵),利用对称性可以获得更快的计算速度和更好的数值稳定性。
奇异值分解
SVD将任意矩阵分解为三个矩阵的乘积:
U, S, Vt = np.linalg.svd(A, full_matrices=False)
U 的列是左奇异向量,V^T 的行是右奇异向量,S 是奇异值组成的一维数组(\Sigma 的对角元素)。full_matrices=False 返回经济型SVD,节省内存。SVD是主成分分析(PCA)和数据降噪滤波的基础。
numexpr加速表达式计算
numexpr库能够比纯NumPy更快地计算大数组上的表达式,其加速原理包括:避免创建中间临时数组、使用多线程并行计算、优化运算顺序、以及在内存传输过程中进行即时压缩。
使用方式
标准NumPy写法:
y = a * np.exp(-b * x) + c * x**2
numexpr写法:
import numexpr as ne
y = ne.evaluate("a * exp(-b * x) + c * x**2")
表达式以字符串形式传入,numexpr会解析并优化执行。当NumPy执行 a * np.exp(-b * x) + c * x**2 时,它会创建多个临时数组来存储 -b * x、np.exp(...)、c * x**2 等中间结果。numexpr通过一次遍历数据完成所有计算,大幅减少内存分配和数据传输。
实用要点
numexpr支持常用数学函数如 sin、cos、exp、log、where 等,但API比NumPy受限。它支持Python标量和NumPy数组作为变量。可以通过 ne.set_num_threads(4) 配置使用的线程数。
xarray带标签的多维数组
xarray是建立在NumPy之上的库,为数组添加了命名维度和坐标系统。
核心特性
维度可以有名称,如 time、freq、x、y、channel 等。坐标是与维度关联的物理值,如时间戳、频率值、经纬度等。xarray提供两种主要对象:DataArray 是单个变量加坐标,Dataset 是多个共享坐标的 DataArray 的集合。
示例:频谱图
import numpy as np
import xarray as xr
t = np.linspace(0, 1, 1000)
f = np.linspace(0, 5000, 256)
S = np.random.randn(len(t), len(f)) # 伪频谱图
spec = xr.DataArray(
S,
dims=("time", "freq"),
coords={"time": t, "freq": f},
name="spectre"
)
S_1k_2k = spec.sel(freq=slice(1000, 2000))
moyenne_temps = spec.mean(dim="time")
dims 指定各维度的名称,coords 提供各维度的坐标值。可以用 sel 方法按坐标值选择数据(而非索引),用 mean(dim="time") 按维度名称进行归约。这种基于标签的操作比纯索引更直观,也更不容易出错。
PyTorch张量
torch.Tensor 是NumPy ndarray的扩展,增加了自动微分和GPU计算支持。
创建张量
import torch
x = torch.zeros(3, 4) # 零张量
x.shape # (3, 4)
x.dtype # torch.float32(默认)
x.device # cpu 或 cuda:0
PyTorch的API设计与NumPy非常接近,学习成本低。张量可以存在于CPU或GPU上,通过 device 属性查看和控制。
自动微分
PyTorch的核心特性是自动微分。当设置 requires_grad=True 时,PyTorch会记录对张量的所有操作,构建一个计算图(DAG,有向无环图)。
w = torch.randn(3, 4, requires_grad=True)
out = (w**2).sum()
out.backward() # 计算 d(out)/d(w)
w.grad # 梯度存储在这里
调用 backward() 后,PyTorch沿计算图反向传播,自动计算所有需要梯度的张量的偏导数。这是深度学习训练的基础。
NumPy的C语言API
使用场景
C API允许直接访问ndarray的底层内存缓冲区,对 dtype、shape、strides 进行精细控制,是构建高性能专用库的技术基础。
核心类型和函数
核心类型是 PyArrayObject*,它在C层面表示一个ndarray。需要包含的头文件:
#include <Python.h>
#include <numpy/arrayobject.h>
在C扩展模块的初始化函数中必须调用 import_array(),否则所有NumPy C API调用都会失败。
访问元数据
int ndim = PyArray_NDIM(arr);
npy_intp *shape = PyArray_SHAPE(arr);
npy_intp *strides = PyArray_STRIDES(arr);
int typenum = PyArray_TYPE(arr); // NPY_DOUBLE, NPY_FLOAT32, ...
这些函数分别获取数组的维度数、形状数组指针、步长数组指针和数据类型编号。
访问数据
double *data = (double*) PyArray_DATA(arr);
PyArray_DATA 返回指向数据缓冲区起始位置的指针,需要根据实际dtype进行类型转换。获得指针后可以直接用C代码高效地操作数据。
ctypes与缓冲区协议
ctypes结合缓冲区协议提供了一种不使用NumPy C API就能从C语言访问NumPy数组的方法。其核心思想是利用NumPy的高层接口,直接将指向ndarray底层缓冲区的指针传递给C函数。
C函数示例
首先编写一个简单的C函数,对数组进行原地缩放操作:
// 文件: myops.c
#include <stddef.h>
void scale_double(double *x, size_t n, double alpha) {
for (size_t i = 0; i < n; ++i)
x[i] *= alpha;
}
这个函数接收一个 double 类型的指针 x、数组长度 n 和缩放因子 alpha,将数组中的每个元素乘以 alpha。
编译为共享库
使用gcc将C代码编译为共享库:
gcc -O3 -fPIC -shared myops.c -o libmyops.so
-O3 启用最高级别优化,-fPIC 生成位置无关代码(共享库必需),-shared 指定生成共享库而非可执行文件。
Python端调用
import numpy as np
import ctypes as ct
# 加载C库
lib = ct.CDLL("./libmyops.so")
# 声明函数签名: void scale_double(double*, size_t, double)
lib.scale_double.argtypes = [
ct.POINTER(ct.c_double),
ct.c_size_t,
ct.c_double,
]
lib.scale_double.restype = None
# 创建NumPy数组,必须是float64且连续存储
x = np.arange(10, dtype=np.float64)
# 获取指向数据缓冲区的C指针
ptr = x.ctypes.data_as(ct.POINTER(ct.c_double))
# 调用C函数,原地修改x
lib.scale_double(ptr, x.size, 2.0)
ct.CDLL 加载共享库。argtypes 声明函数参数类型,restype 声明返回值类型(None 表示 void)。x.ctypes.data_as() 将ndarray的数据指针转换为ctypes兼容的指针类型。调用后 x 的内容被直接修改,无需返回值。
使用注意事项
传递给C函数的数组必须满足两个条件:dtype必须与C函数期望的类型兼容(这里是 np.float64 对应C的 double),数据必须在内存中连续存储。如果数组不连续(例如经过转置或非连续切片),需要先调用 x = np.ascontiguousarray(x) 创建连续副本。缓冲区协议不仅被NumPy实现,Python的 bytearray 等内置类型也支持,因此同样的C函数可以用于处理其他支持缓冲区协议的对象。