资讯动态

程序员数学 Python 源码实战:向量、矩阵、线性变换与梯度下降

发布时间:2026/10/26 15:00:16 来源:尧图企业网站定制
简介《程序员数学》配套源码包面向希望用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 fVector{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.0000000000000002acos直接抛出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, h1e-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, hNone): # 步长按 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处应该是6f(x) sin(x)在x 0处应该是1。误差在1e-9量级属于正常如果算出来差得离谱先怀疑步长再怀疑函数本身写错了。这部分在 notebook 里通常会画一张图原函数一条曲线导函数另一条曲线直观看到「导数就是切线斜率」。4.2 梯度下降多变量情形下的迭代框架单变量导数推广到多变量就是梯度。梯度下降的代码不长但每一步都容易写错方向def gradient_descent(f, start, learning_rate0.1, steps100, h1e-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, levels20, cmapviridis) # 轨迹点转成两个列表方便一次画完 px [p.components[0] for p in history] py [p.components[1] for p in history] plt.plot(px, py, o-, colorred, markersize3) 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, tol1e-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, TupleIDE 的补全和静态检查立刻好用很多几个月后回来看也不会忘参数含义。第二件是加单元测试把勘误修复的成果锁住。这份源码的价值之一就是修正了书里的错误但如果你改代码时不小心改回去了没有测试就发现不了。import pytest from math import isclose, pi def test_rotation_90_degrees(): # 旋转 90 度后x 分量应接近 0y 分量应接近 1 v rotation_matrix(pi / 2) Vector(1, 0) assert isclose(v.components[0], 0, abs_tol1e-9) assert isclose(v.components[1], 1, abs_tol1e-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_rate0.1, steps200) assert point.length() 1e-3abs_tol是绝对容差浮点比较不能省。steps给 200 是留余量收敛速度和学习率相关测试里宁可多跑几次也不要卡在临界值上否则换个解释器版本就可能挂掉。第三件是把 demo 里的硬编码参数抽成配置。比如绘图范围、学习率、迭代次数全部提到函数签名或者一个简单的配置字典里这样同一份代码既能演示也能拿去试自己的数据。改造点源码里的做法建议做法类型约束无类型提示补typing与__post_init__校验浮点比较直接用统一走isclose容差函数异常信息只写「维度错误」带上两边的 shape 和运算名参数来源demo 里硬编码抽成函数参数或配置正确性保障靠人眼看输出加 pytest 用例覆盖勘误点我踩过最疼的一次是改矩阵类时把shape缓存成了属性后来加了转置方法忘了同步更新结果所有后续计算都拿着旧 shape 跑报错信息还指向完全无关的位置。从那以后我每次动这类核心数据结构都强制先跑一遍测试再继续写别的宁可多花十分钟也不愿意事后去当一个黑匣子侦探。这份源码本身不大正好适合拿来练这个习惯——先跑通再补测试然后才是加功能。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑