☰
程序员数学 Python 源码实战:向量、矩阵、线性变换与梯度下降
2026/10/26 15:00:14 网站建设 项目流程

简介:《程序员数学》配套源码包面向希望用Python打通线性代数与微积分的IT从业者、学生和自学者,针对书本示例代码易报错、抽象概念难落地的问题,提供与章节对应的可运行程序及已修正的错误与bug。压缩包为7z格式,共104个文件、约13.55MB,以74个Python脚本和17个Jupyter Notebook为主,辅以图示、模型、说明文档及配置文件,便于按章节检索和复现。内容覆盖向量与矩阵运算、行列式、特征值、线性变换、线性方程组,以及极限、导数、积分等主题,notebook中的章节演练可配合脚本边读边跑。已有1653人学习,适合作为课程实验、自学练习和排错参考,帮助读者把公式推导转化为可验证的代码结果。

1. 从「公式看得懂、代码跑不通」说起

线性代数课本翻到第三章,矩阵乘法手算没问题,可一旦让你用 Python 写出来,*和@到底哪个才是矩阵乘,索引先取行还是先取列,转置之后的 shape 又该是什么样,脑子立刻打结。这份《程序员数学》配套源码就是冲这个场景来的:它把书里每一章的向量、矩阵、线性变换、导数、梯度下降都落成了可以直接运行的 Python 文件,还顺手把书里印刷错误、示例参数写反、绘图范围不对的地方做了修正。适合两类人:一类是跟着书学过一遍但写不出代码的自学者,另一类是手里有数学基础、想把公式变成工具函数塞进项目的开发者。源码按章节编号组织,纯 Python 起步,不依赖重型框架,跑通门槛低。

2. 项目定位与技术栈:从章节编号到运行环境的完整落地

2.1 这份源码的形态:章节对应 + 勘误修复

先把这个资源拆开看。它不是一个完整的应用工程,而是「书 + 代码」的伴读型仓库:每个章节目录里通常有两个东西,一个是.py模块,把该章的核心概念封装成类或函数;另一个是同名的.ipynb或demo.py,用来在交互环境里一步步演示计算结果。这样做的好处是,你读一段文字就能定位到对应文件,不必在几百个文件里猜哪个是重点。

勘误修复是这份源码比较有价值的地方。纸质书和早期电子版里存在一些典型问题:矩阵示例的行列数写错、函数参数顺序和定义对不上、绘图时的坐标轴范围写小了导致图形被裁掉、某些数值近似的步长取太大结果失真。源码在这些位置做了修改,并且在关键修复处留了注释说明「原书写的是什么、这里改成了什么」。对新手来说,这一点很关键——照着书敲代码报错的时候,人会先怀疑自己,而实际是书印错了,这种血泪经验只有踩过才知道。

需要提醒的是,这份源码的定位是教学示例,不是生产级数值库。它没有追求计算性能,也没有覆盖大规模稀疏矩阵、GPU 加速这类场景。如果你的目标是理解概念、把数学直觉建立起来、再自己动手扩写,它非常合适;如果你想找一个能直接拿去跑深度学习的矩阵运算库,那应该去看专门的科学计算库。

2.2 目录结构:按章切分的模块化组织

仓库的组织方式基本遵循「一章一目录、一模块一主题」的思路,下面是常见的目录形态,不同版本可能略有差别,但主干一致:

programmer-math-python/ ├── ch02_vectors/ # 向量:加法、数乘、长度、方向 │ ├── vector.py # Vector 类定义 │ └── vector_demo.ipynb # 交互演示 ├── ch03_matrices/ # 矩阵:乘法、转置、单位阵 │ ├── matrix.py │ └── matrix_demo.ipynb ├── ch04_linear_transforms/ # 线性变换:旋转、缩放、剪切 │ ├── transform.py │ └── image_transform.py # 把变换作用到图片像素上 ├── ch05_calculus/ # 微积分:导数、极限、数值近似 │ ├── derivative.py │ └── plot_function.py ├── ch06_gradient/ # 梯度下降与最优化 │ ├── gradient_descent.py │ └── loss_surface.py ├── requirements.txt └── README.md

看这个结构要有两个意识。第一,vector.py和matrix.py是整条链路的底座,后面所有章节的代码几乎都会import它们,所以读源码的顺序必须是先看这两个文件,再看变换和梯度部分,倒着读会一头雾水。第二,*_demo.ipynb不是可有可无的附属品,它承担了「把中间结果打印出来」的职责,很多概念只有看到Vector(3, 4)的长度是5.0、看到旋转 90 度后坐标变成(-4, 3),你才会真的相信公式是对的。

requirements.txt里一般只列三四个包:负责数组和绘图的matplotlib、负责交互环境的jupyter,有些版本会带numpy用于对照验证自己手写的实现,个别版本带pillow用于图片变换演示。依赖少是好事,装环境时不容易翻车。

2.3 运行环境:三步把代码跑起来

环境搭建没有太多花活,但顺序别搞错。我一般会先在仓库根目录建虚拟环境,再装依赖,最后启动交互环境,避免把一堆包塞进系统 Python 里。

# 1. 建虚拟环境(Python 3.8 以上都行) python -m venv .venv # 2. 激活,Windows 用 .venv\Scripts\activate source .venv/bin/activate # 3. 装依赖并注册内核,方便 Jupyter 里直接选这个环境 pip install -r requirements.txt python -m ipykernel install --user --name programmer-math --display-name "Python (math)"

参数含义说一下:-m venv是标准库自带的环境创建模块,不依赖第三方;-m ipykernel install那句容易被省略,但省略之后 Jupyter 里可能会用系统解释器,出现「命令里装好了、notebook 里 import 不到」的诡异现象,这类玄学问题排查起来非常浪费时间。装完执行jupyter notebook或jupyter lab,从章节目录里打开对应的 notebook,逐个单元格运行就行。

如果你不想用 notebook,把demo.ipynb里的代码复制成demo.py直接python demo.py也能跑,只是看不到分步输出。绘图相关的示例在纯脚本模式下需要显式调用plt.show(),否则窗口一闪而过。

3. 核心模块拆解:向量、矩阵与线性变换的代码骨架

3.1 Vector 类:把一个元组包装成可运算的对象

整份源码里最先值得逐行看的是向量类。它做的事不复杂,但设计取舍决定了后面代码好不好写。

from math import sqrt class Vector: def __init__(self, *components): # 用元组而不是列表:向量不可变,后续作为字典键或做缓存时不会出问题 self.components = tuple(components) def __add__(self, other): # 维度必须一致,否则加法没有定义 if len(self.components) != len(other.components): raise ValueError("向量维度不一致") return Vector(*[a + b for a, b in zip(self.components, other.components)]) def __mul__(self, scalar): # 数乘:每个分量乘同一个标量 return Vector(*[scalar * c for c in self.components]) def length(self): # 模长:各分量平方和开根号 return sqrt(sum(c * c for c in self.components)) def __repr__(self): return f"Vector{self.components}"

这段代码有几个点值得展开。__init__用*components收集可变参数,所以Vector(1, 2, 3)和Vector(*[1, 2, 3])都能构造,调用端比较自然。内部存成元组而不是列表,是因为向量在数学上是不可变对象,存列表的话,外部拿到v.components后执行一次append,向量维度就变了,后面所有计算全乱,这种 bug 极其难查。__add__里显式做维度校验而不是交给zip静默截断,也是一个正确的选择——zip在长度不等时会按短的截断,两维向量加三维向量会算出一个两维结果,不报错但结果错误,属于最阴的一类问题。

length()用的是平方和开根号,对应欧几里得范数。想加曼哈顿距离或者无穷范数,可以在类里补manhattan_length()和max_norm()两个方法,改动面很小。《程序员数学》里向量这一章还讲了点积和夹角,源码通常会在同一个文件里补上:

def dot(self, other): # 点积:对应分量相乘再求和,结果是标量 return sum(a * b for a, b in zip(self.components, other.components)) def angle_with(self, other): # 两个非零向量的夹角,用反余弦,注意浮点误差可能让括号里略超 [-1, 1] cos_theta = self.dot(other) / (self.length() * other.length()) cos_theta = max(-1.0, min(1.0, cos_theta)) from math import acos return acos(cos_theta)

angle_with里那句夹取[-1, 1]不是多余代码。浮点运算下,两个理论上平行的向量算出来的余弦可能是1.0000000000000002,acos直接抛出ValueError: math domain error,新手遇到这个报错会完全找不到北。这类边界处理在数学库里属于标配,手写实现时很容易漏。参数说明:other必须也是Vector实例,cos_theta的夹取范围来自余弦函数的定义域,改成math.isclose判断也行,但夹取更省事。

3.2 Matrix 类:矩阵乘法和@运算符的落地

矩阵部分是整份源码的重点,也是「书上看懂、代码写错」的高发区。先看骨架:

class Matrix: def __init__(self, rows): # rows 是二维列表,例如 [[1, 2], [3, 4]] self.rows = [list(r) for r in rows] self.shape = (len(self.rows), len(self.rows[0])) def __matmul__(self, other): # 矩阵乘向量:结果的第 i 个分量 = 第 i 行与向量的点积 if isinstance(other, Vector): rows, cols = self.shape if cols != len(other.components): raise ValueError(f"维度不匹配:{self.shape} x {len(other.components)}") return Vector(*[ sum(self.rows[i][j] * other.components[j] for j in range(cols)) for i in range(rows) ]) # 矩阵乘矩阵:外层遍历左矩阵的行,内层遍历右矩阵的列 if isinstance(other, Matrix): a_rows, a_cols = self.shape b_rows, b_cols = other.shape if a_cols != b_rows: raise ValueError(f"维度不匹配:{self.shape} x {other.shape}") result = [ [sum(self.rows[i][k] * other.rows[k][j] for k in range(a_cols)) for j in range(b_cols)] for i in range(a_rows) ] return Matrix(result) raise TypeError("只支持 Matrix 或 Vector")

这里用__matmul__对应 Python 3.5 之后引入的@运算符,写A @ v就能表达矩阵乘向量,比调方法名可读性高不少。注意*保留给数乘(__mul__),两者语义严格分开——很多人第一次写的时候用*做矩阵乘,跑出来是元素级相乘,结果不对但不报错,属于典型翻车点。

矩阵乘矩阵那段是三层嵌套,理解方式是这样:外层i遍历左矩阵的行,中层j遍历右矩阵的列,内层k沿着公共维度累加。时间复杂度是O(m·n·p),教学示例的规模下完全够用,真要做大矩阵就得换分块或者调库。参数说明:a_cols是左矩阵的列数,同时也是右矩阵的行数,这个数字是两边能否相乘的唯一判据;raise ValueError里把两个 shape 都打出来,调试时一眼能看出是谁的问题,比只写「维度错误」有用得多。

__init__里对每一行做了list(r)拷贝,这是一个防御性写法。如果直接self.rows = rows,调用方在外部改了原始列表,矩阵内容会跟着变,排查起来非常隐蔽。另外shape取的是len(self.rows[0]),隐含要求所有行长度一致,空矩阵或锯齿状输入会在这里炸掉,加一句校验会更稳:

if not rows or any(len(r) != len(rows[0]) for r in rows): raise ValueError("矩阵不能为空,且每行长度必须一致")

3.3 线性变换:旋转矩阵怎么作用到坐标和图片上

向量和矩阵定义好之后,线性变换这一章就变成了组装工作。最典型的例子是二维旋转:把平面上的点绕原点转 θ 角,对应的矩阵是[[cosθ, -sinθ], [sinθ, cosθ]]。

from math import cos, sin, pi def rotation_matrix(theta): # 逆时针旋转 theta 弧度 return Matrix([ [cos(theta), -sin(theta)], [sin(theta), cos(theta)], ]) def scale_matrix(sx, sy): # 沿 x、y 轴分别缩放 return Matrix([[sx, 0], [0, sy]]) # 把点 (1, 0) 逆时针转 90 度,期望得到 (0, 1) v = Vector(1, 0) rotated = rotation_matrix(pi / 2) @ v print(rotated) # Vector(6.12e-17, 1.0)

结果里 x 分量是6.12e-17而不是0,这是浮点误差,不是代码错。演示时如果直接拿它和Vector(0, 1)做相等比较会失败,正确做法是用一个容差判断,或者显示时做四舍五入。这一点在避坑章节还会再提一次。

图片变换的示例更有体感:读一张图片,把每个像素的(x, y)坐标当成向量,乘上变换矩阵,再写回新图片。源码里这类 demo 一般会做最近邻采样——变换后的坐标往往不是整数,需要取最近的整数像素,否则会出现空洞或者黑边。

def transform_image(img, mat): w, h = img.size new_img = Image.new(img.mode, (w, h)) for x in range(w): for y in range(h): # 以图片中心为原点建坐标系,避免整体平移出画布 v = Vector(x - w / 2, y - h / 2) tx, ty = (mat @ v).components nx, ny = int(round(tx + w / 2)), int(round(ty + h / 2)) if 0 <= nx < w and 0 <= ny < h: new_img.putpixel((nx, ny), img.getpixel((x, y))) return new_img

这段代码的关键在于坐标系平移:如果不减去中心点,旋转会把整张图甩到画布外面,看起来像「代码没生效」,实际上是所有点都跑到了负坐标区域。参数上,mat必须是 2×2 矩阵,img需要是支持getpixel/putpixel的对象,常见的图像库都满足。用双重循环逐像素处理,几百像素的小图没问题,大图会明显变慢,这也是教学代码和生产代码的分界线。

4. 微积分部分:从变化率到梯度下降的完整链路

4.1 数值导数:用两次函数求值逼近斜率

微积分章节的第一块内容是导数。符号求导在 Python 里需要专门的库,这份源码走的是数值路线,用差商近似,好处是不依赖任何第三方符号计算工具,坏处是精度受步长影响。

def numerical_derivative(f, x, h=1e-6): # 中心差分:在 x 两侧各取一点,比单侧差分精度高一阶 return (f(x + h) - f(x - h)) / (2 * h)

sympy方案能给出精确表达式,numerical_derivative只能给数值,但后者对任意可调用对象都成立,函数是黑盒也没关系。参数h是步长,取1e-6是个折中:太大截断误差明显,太小则两个相近的数相减会损失有效位,出现「减出 0」的现象。稳妥的做法是让h随x的量级缩放:

def numerical_derivative(f, x, h=None): # 步长按 x 的量级自适应,避免 x 很大或很小时精度崩塌 if h is None: h = 1e-6 * max(1.0, abs(x)) return (f(x + h) - f(x - h)) / (2 * h)

拿几个已知导数的函数验一下,f(x) = x**2在x = 3处应该是6,f(x) = sin(x)在x = 0处应该是1。误差在1e-9量级属于正常,如果算出来差得离谱,先怀疑步长,再怀疑函数本身写错了。这部分在 notebook 里通常会画一张图:原函数一条曲线,导函数另一条曲线,直观看到「导数就是切线斜率」。

4.2 梯度下降:多变量情形下的迭代框架

单变量导数推广到多变量就是梯度。梯度下降的代码不长,但每一步都容易写错方向:

def gradient_descent(f, start, learning_rate=0.1, steps=100, h=1e-6): """ f : 目标函数,接受一个 Vector 返回标量 start : 初始点,Vector lr : 学习率,太大发散,太小收敛慢 steps : 迭代次数上限 """ point = start history = [point] for _ in range(steps): # 数值梯度:对每个分量分别求偏导 grad = Vector(*[ (f(Vector(*[p + (h if i == j else 0) for j, p in enumerate(point.components)])) - f(Vector(*[p - (h if i == j else 0) for j, p in enumerate(point.components)]))) / (2 * h) for i in range(len(point.components)) ]) # 沿负梯度方向走一步 point = point + grad * (-learning_rate) history.append(point) return point, history

这段的参数是重点。learning_rate决定步子大小:函数比较陡的时候,学习率大会直接跨过最低点然后越跑越远,表现为损失值越来越大;学习率太小则迭代steps次之后还在半山腰。steps是保险丝,防止死循环。history记录每一步的位置,用来画轨迹图或者做收敛判断。

梯度计算那里用列表推导构造了「只有第 i 个分量偏移 h」的向量,这是数值偏导的标准写法。写的时候容易把i和j弄反,导致算出来的「梯度」实际上是同一个值重复了 n 次,程序不报错但下降方向完全错误,最终收敛到一个奇怪的位置。验证方法很简单:拿f(x, y) = x² + y²测,理论最小值在原点,梯度在(1, 1)处应该是(2, 2),跑一次对一下就知道实现对不对。

4.3 可视化:把损失曲面和收敛轨迹画出来

数值计算结果如果不画出来,你对「收敛」的感知是很弱的。源码里的绘图部分会用matplotlib画两张图,一张是损失函数的等高线,一张是下降轨迹叠加在上面。

import matplotlib.pyplot as plt from math import sqrt def plot_descent(f, history, xlim=(-5, 5), ylim=(-5, 5)): # 生成网格点,算每个点的函数值 xs = [xlim[0] + i * (xlim[1] - xlim[0]) / 100 for i in range(101)] ys = [ylim[0] + j * (ylim[1] - ylim[0]) / 100 for j in range(101)] zs = [[f(Vector(x, y)) for x in xs] for y in ys] plt.contour(xs, ys, zs, levels=20, cmap="viridis") # 轨迹点转成两个列表,方便一次画完 px = [p.components[0] for p in history] py = [p.components[1] for p in history] plt.plot(px, py, "o-", color="red", markersize=3) plt.xlabel("x") plt.ylabel("y") plt.show()

contour画等高线,levels控制线条密度,太少看不出形状,太多会糊成一团,20左右是个实用值。轨迹用红色点线叠加,一眼能看出是「直奔谷底」还是「来回震荡」。如果画出来是一条来回折返的锯齿线,那基本就是学习率偏大;如果线段几乎不动,那就是学习率偏小或者steps不够。

中文环境下还有个小坑:plt.xlabel("x")用英文没问题,一旦换成中文标签,默认字体不包含中文字形,会显示成一排方块。解决方式是显式设置字体,这件事在下一章的避坑里细说。

5. 避坑与排查:那些「照着书上敲却报错」的地方

5.1 现象:变量看着是 0,断言却失败

现象:旋转 90 度之后,打印出来是Vector(6.12e-17, 1.0),写assert result == Vector(0, 1)直接报断言失败。

原因:浮点数用二进制表示,pi/2的余弦不可能精确等于 0,误差在1e-17量级,是正常现象。==做的是精确比较,所以必然失败。

解决:所有涉及浮点的比较都换成容差判断。可以在Vector里加一个is_close(other, tol=1e-9)方法,逐分量比较差值绝对值;或者在测试里用math.isclose。这属于数学代码的基础设施,早加早省事。

5.2 现象:notebook 里import vector报找不到模块

现象:在ch02_vectors/目录下打开 notebook,写from vector import Vector,提示ModuleNotFoundError。

原因:Jupyter 的工作目录不一定是你以为的那个目录,尤其是在父目录启动jupyter lab之后,内核的工作目录是启动位置,不是 notebook 文件所在位置。

解决:两个办法,一是在 notebook 顶部加一段把当前目录插入sys.path的代码,二是干脆从对应章节目录启动 Jupyter。我一般选后者,少写代码少出错。加代码的话:

import sys, os # 把 notebook 所在目录加入模块搜索路径 sys.path.insert(0, os.path.abspath("."))

5.3 现象:矩阵乘法结果全是对的,但形状不对

现象:用A * B做两个矩阵相乘,结果看起来「每个元素都对」,但整体数值和手算不一样。

原因:*走的是__mul__,如果类里顺手把__mul__实现成了元素级相乘,就会出现逐元素相乘的结果——形状一样,所以不报错,但数学含义完全不同。

解决:明确约定*只用于数乘,矩阵乘一律用@。如果想让*也支持矩阵乘,至少要在文档字符串里写清楚,并且加单元测试锁住行为。这条坑我在两个项目里都见过,属于典型的不报错但结果错的 bug。

5.4 现象:绘图代码报ValueError: math domain error

现象:算向量夹角时acos抛异常,输入看起来很正常。

原因:浮点误差让余弦值略微超过[-1, 1],反余弦函数的定义域不包含这个区间。

解决:调用前做夹取,cos_theta = max(-1.0, min(1.0, cos_theta))。同样的思路适用于sqrt收到极小负数、log收到 0 这类边界,凡是浮点参与的数学函数,入口处都该做一次范围保护。

5.5 现象:图片变换之后满屏黑点

现象:对图片做旋转,输出图里出现大量黑色斑点或者整体偏移。

原因:变换后的坐标不是整数,直接int()截断会让多个像素映射到同一个位置,另一些位置没有值;另外没有做边界判断,超出的坐标写入会异常或被裁掉。

解决:坐标用round而不是int取最近整数,写入前判断0 <= nx < w and 0 <= ny < h。要做质量更好的结果就得用双线性插值,但对理解线性变换来说,最近邻已经足够。

6. 二次开发:把示例代码改造成自己的数学工具箱

源码跑通只是开始,真正有价值的是把它变成你自己的东西。我的做法是新建一个独立包,把Vector、Matrix、求导、梯度下降这几个核心模块搬过去,然后做三件事。

第一件是补类型提示和文档字符串。教学代码一般不写typing,搬进自己的仓库后加上from typing import Callable, List, Tuple,IDE 的补全和静态检查立刻好用很多,几个月后回来看也不会忘参数含义。

第二件是加单元测试,把勘误修复的成果锁住。这份源码的价值之一就是修正了书里的错误,但如果你改代码时不小心改回去了,没有测试就发现不了。

import pytest from math import isclose, pi def test_rotation_90_degrees(): # 旋转 90 度后,x 分量应接近 0,y 分量应接近 1 v = rotation_matrix(pi / 2) @ Vector(1, 0) assert isclose(v.components[0], 0, abs_tol=1e-9) assert isclose(v.components[1], 1, abs_tol=1e-9) def test_gradient_descent_converges(): # 二次函数的最小值在原点 f = lambda v: v.components[0] ** 2 + v.components[1] ** 2 point, _ = gradient_descent(f, Vector(3, 4), learning_rate=0.1, steps=200) assert point.length() < 1e-3

abs_tol是绝对容差,浮点比较不能省。steps给 200 是留余量,收敛速度和学习率相关,测试里宁可多跑几次也不要卡在临界值上,否则换个解释器版本就可能挂掉。

第三件是把 demo 里的硬编码参数抽成配置。比如绘图范围、学习率、迭代次数,全部提到函数签名或者一个简单的配置字典里,这样同一份代码既能演示,也能拿去试自己的数据。

改造点源码里的做法建议做法
类型约束无类型提示补typing与__post_init__校验
浮点比较直接用==统一走isclose容差函数
异常信息只写「维度错误」带上两边的 shape 和运算名
参数来源demo 里硬编码抽成函数参数或配置
正确性保障靠人眼看输出加 pytest 用例覆盖勘误点

我踩过最疼的一次是改矩阵类时把shape缓存成了属性,后来加了转置方法忘了同步更新,结果所有后续计算都拿着旧 shape 跑,报错信息还指向完全无关的位置。从那以后我每次动这类核心数据结构,都强制先跑一遍测试再继续写别的,宁可多花十分钟也不愿意事后去当一个黑匣子侦探。这份源码本身不大,正好适合拿来练这个习惯——先跑通,再补测试,然后才是加功能。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询