简介:面向程序员和IT从业者的《程序员数学》配套源码包,意在用Python打通线性代数与微积分的学习路径,覆盖向量、矩阵、行列式、特征值、线性空间、极限、导数与积分等核心主题,并关联图像处理、机器学习、数据分析和优化算法等实际应用场景。作者在原书代码基础上做了大量错误修正和bug修复,确保每个程序都能直接运行,避免读者被代码问题干扰。资源包共104个文件,以74个Python脚本和17个Jupyter Notebook为主,另附示意图、OFF三维模型、Markdown笔记等辅助材料,压缩后仅有13.55MB,轻巧又完整。每个章节的walkthrough笔记本可以引导读者边读边练,借助numpy、scipy、sympy和matplotlib直观观察向量运算、矩阵变换、导数切线与积分面积的可视化结果,也能对照书本完成线性方程组、矩阵分解、微分方程等练习。目前已有1646人学习,适合希望同步提升数学功底与Python编程能力,并通过动手实践理解抽象概念的初中级开发者。 很多人学编程三五年后,都会撞上一堵墙:用框架写业务没问题,调API实现功能也顺手,但一聊到矩阵运算、梯度下降、特征值分解这些数学概念,心里就开始发虚。我也不例外。去年我给自己下了个死任务,要把《程序员数学》这本书从头到尾过一遍,而且不是拿眼睛看,是把书里所有涉及线性代数和微积分的知识点,全部用Python亲手实现一遍、跑出结果、画出图像。做完这个项目后,我把源码按照书本章节一一归类整理,顺手还把书里示例代码中那些跑不通、算不对的bug全部修掉了。
这篇文章把整个项目从设计到实现的完整过程做个复盘,包括目录结构怎么组织、线性代数和微积分各自的核心代码怎么写、书里那些bug是怎么被定位和修正的、以及整套代码环境的搭建方法。如果你也想用程序员的方式把数学补回来,这份复盘应该能帮你少踩不少坑。
1. 为什么用Python补数学这条路走得通
1.1 程序员学数学的真正瓶颈出在哪
很多程序员手里都有几本数学书,但翻不了几章就放弃了。以我自己的经历来说,问题根本不在“数学难”,而在学习方式错位。教材里的推导过程是一步步演算的,这和程序员大脑里“输入-处理-输出”的思维习惯完全不同。比如线性代数里讲矩阵乘法,教材会给你公式 (C_{ij} = \sum_k A_{ik}B_{kj}),看起来很简单,但真要你解释清楚“这个乘法到底在算什么”,很多人卡住了。可如果换成Python,用几行代码把矩阵乘法写出来,再配合一个实际案例,你很快就理解了它是在做线性变换的复合。
另一个瓶颈是反馈回路太长。看数学书的时候,做错一道题往往要翻答案才知道对不对,而写代码面对的是即时反馈:数值不对、形状不匹配、图画出来不符合预期,立刻就能感知到“我理解得不对”。这种快速反馈对成年学习者特别重要,它能让人保持专注和动力。可能就是这个原因,我才觉得用Python学数学这件事值得认真做。
1.2 Python作为“数学实验室”的独特优势
用Python学数学,本质上是在搭一个私人数学实验室。你不需要每一步都手动推导,而是把数学思想变成代码,让计算机替你完成繁琐的计算和可视化。比如学微积分中的极限,手算时要用夹逼定理、洛必达法则去分析,而用Python可以直接把函数画出来,观察自变量逼近某个值时函数值的变化趋势,这种直观感受是教科书给不了的。
我在学习过程中主要用了NumPy、Matplotlib和SciPy这三个库。NumPy负责向量和矩阵运算,Matplotlib负责可视化,SciPy负责数值积分、优化等高级计算。对比一下传统学习方式和代码实验方式的差异,就很明显了。
| 学习环节 | 传统教材方式 | Python实验方式 |
|---|---|---|
| 理解概念 | 看公式、看推导 | 写函数、跑数、画图 |
| 验证理论 | 做习题、对答案 | 用代码生成随机用例验证性质 |
| 发现错误 | 找老师、查解析 | 调试、打印中间变量 |
| 应用落地 | 做纯数学题 | 做图像压缩、线性回归等真实任务 |
所以我不建议把这个项目理解成“照着书写一遍代码”,而是把它当成一个用代码和数学对话的过程。书里讲一个概念,我就去写一段最小可运行的实验,把概念变成代码和图像。这条路走下来,数学公式不再是纸上的符号,而是变成了一堆可以调参、可以观察、可以验证的对象。
2. 源码仓库设计与章节对应思路
2.1 目录组织:一个章节一个文件夹
拿到《程序员数学》这本书的时候,我第一个想法就是:书里的章节结构其实非常清晰,线性代数在前、微积分在后,完全可以做成一个与书本一一对应的源码仓库。目录设计上我坚持一个原则:看到文件名就能定位到书里的知识点,看到代码就能回溯到对应的公式。
我的最终目录结构长这样:
programmer_math/ ├── requirements.txt ├── ch01_linear_algebra/ │ ├── 01_vector_basic.py │ ├── 02_matrix_multiplication.py │ ├── 03_determinant_and_inverse.py │ ├── 04_eigen_decomposition.py │ ├── 05_svd_image_compress.py │ └── utils.py ├── ch02_calculus/ │ ├── 01_limit_and_continuity.py │ ├── 02_derivative_numerical.py │ ├── 03_integral_definite.py │ ├── 04_monte_carlo_integral.py │ └── 05_gradient_descent_regression.py ├── ch03_applications/ │ ├── 01_pca_visualization.py │ └── 02_linear_regression_learning.py └── tests/ ├── test_matrix.py └── test_derivative.py这个结构最大的好处是“可定位”。我在学习的时候遇到某个概念模糊,不是重新翻书,而是直接去对应文件里看代码,代码里的注释就是我自己的理解。这样等于是把书读薄了,再回头看概念的时候,脑海里浮现的是当时的实验结果和图像。
2.2 依赖环境与最省心的启动方案
项目用到的Python库很标准,我在requirements.txt里固定了版本,主要是为了避免“书里的代码是几年前的写法,新版库跑不了”这种破事。环境用的是Python 3.10,版本锁定如下:
numpy>=1.24,<2.0 matplotlib>=3.7,<4.0 scipy>=1.10,<2.0 sympy>=1.11,<2.0 pytest>=7.0,<8.0这里要提醒一句,如果你之前没有接触过Python环境管理,建议直接装Anaconda,里面自带科学计算全家桶,省得一个一个库去配。python安装教程网上已经很多了,但核心要点就两条:一是Python版本别太低,3.9以上比较稳妥;二是装完库之后立刻跑一个小脚本验证环境通不通,比如import numpy; print(numpy.__version__),确认没有报错再开始跑项目。
3. 线性代数核心代码拆解与实操记录
3.1 向量与矩阵:从手写乘法到NumPy语义
《程序员数学》线性代数部分,最基础的是向量和矩阵。书里有一道例题是要求读者手写一个矩阵乘法函数,我一开始也是老老实实用三重循环写,代码如下:
def matmul_manual(A, B): m, k = len(A), len(A[0]) k2, n = len(B), len(B[0]) assert k == k2, "矩阵维度不匹配" C = [[0] * n for _ in range(m)] for i in range(m): for j in range(n): for t in range(k): C[i][j] += A[i][t] * B[t][j] return C这个代码能跑,但写完之后我更关心的是“为什么矩阵乘法不是逐元素相乘”。于是我用一个平面上的旋转矩阵和一个缩放矩阵相乘,画出了两个矩阵作用在向量上的先后顺序,才彻底理解 (AB) 和 (BA) 的区别:矩阵乘法对应的是变换的复合,顺序不能随便换。这个概念在写代码之前我其实背过很多次,但直到自己画出那个变换过程才真正内化。
还有一个很关键的坑:NumPy里*和@的语义完全不同。*对两个形状相同的ndarray做逐元素乘法,@才做矩阵乘法。很多新手甚至书里早期示例都会混用,导致结果莫名其妙。我统一在项目里用@表示矩阵乘法,用np.dot也等价,但可读性上@更直观。
3.2 特征值分解:对称矩阵用eigh而不是eig
特征值分解是线性代数里比较抽象的部分,书上讲PCA时用的是协方差矩阵,这个矩阵一定是实对称矩阵。我一开始照书里示例写了np.linalg.eig,跑了之后发现特征向量经常在正负方向上跳变,图画出来每次都不一样,一度以为是自己代码写错了。
后来查资料才发现,np.linalg.eig是通用特征值求解器,适合一般方阵,但它求出的特征向量方向不固定,符号可能有随机性。而对实对称矩阵,更好的选择是np.linalg.eigh,它专门利用对称性做优化,数值上更稳定,速度也更快。修正后的代码是这样的:
# 协方差矩阵特征分解(推荐写法) eigenvalues, eigenvectors = np.linalg.eigh(cov_matrix) # 降序排列 idx = np.argsort(eigenvalues)[::-1] eigenvalues = eigenvalues[idx] eigenvectors = eigenvectors[:, idx]这个改动看起来只是换了个函数,实际上涉及数值线性代数的一个重要原则:尽量利用矩阵的结构信息来选择合适的算法。对称矩阵用eigh,一般方阵才用eig,这是实践中很容易忽略但影响很大的细节。
3.3 实战:用SVD压缩一张图片
线性代数部分我觉得最有成就感的实战,是用奇异值分解(SVD)做图像压缩。书上给出SVD的公式是 (A = U\Sigma V^T),但光看公式很难感受到它为什么有用。我用一张512x512的灰度图做了实验,代码很简单:
import numpy as np from PIL import Image import matplotlib.pyplot as plt img = np.array(Image.open("lena_gray.png").convert("L")) U, s, Vt = np.linalg.svd(img, full_matrices=False) k = 50 # 保留前50个奇异值 compress = U[:, :k] @ np.diag(s[:k]) @ Vt[:k, :] plt.figure(figsize=(10, 5)) plt.subplot(1, 2, 1) plt.imshow(img, cmap="gray") plt.title("原图") plt.subplot(1, 2, 2) plt.imshow(compress, cmap="gray") plt.title(f"k={k} 压缩后") plt.show()跑完之后,直观感受非常强烈:只保留前50个奇异值,图像细节依然清晰可辨,但存储量从512x512降到了50+512+50左右。更重要的是,通过plt.plot(s)打印奇异值曲线,可以看到后面的奇异值衰减得非常快,这直接解释了为什么低秩近似能保持主体信息。这种“数学原理直接作用于真实数据”的体验,是单纯翻书绝对换不来的。
4. 微积分核心代码拆解与实操记录
4.1 数值微分:步长才是最大的坑
微积分部分我提前踩了不少坑,第一个坑就是数值微分。教材里导数定义是极限形式 (f'(x) = \lim_{h \to 0} \frac{f(x+h)-f(x)}{h}),但直接用这个公式做数值计算时,h不能取得太小,否则浮点数减法会引入灾难性的误差。书里某段示例把h设成了1e-8,结果对部分函数算出的导数抖动得很厉害。
我的应对措施是改用中心差分公式:(f'(x) \approx \frac{f(x+h)-f(x-h)}{2h}),并且把步长设置为h = np.sqrt(np.finfo(float).eps),这个值的数量级大约是1.5e-8,是数值微分中一个经验上比较平衡的取值。修正后的函数如下:
def numerical_derivative(f, x): h = np.sqrt(np.finfo(float).eps) return (f(x + h) - f(x - h)) / (2 * h) # 验证:对 f(x)=x^3 求 x=2 处的导数,解析解是 12 print(numerical_derivative(lambda x: x**3, 2.0)) # 11.999999999...为了确认修正后的精度,我用SymPy求了符号导数作为基准答案,再和中心差分的数值结果做对比,误差在1e-10量级。这个过程也让我真正理解了数值计算中“步长不是越小越好”这个核心矛盾,这是微积分书里不会直接告诉你,但在工程中很容易踩中的暗礁。
4.2 定积分:解析、scipy和蒙特卡洛三条路
定积分章节书里讲了黎曼和的思想,示例代码是用小矩形逼近曲线下的面积。我照着实现了矩形法之后,又对比了用SciPy的quad函数求精确值,以及用蒙特卡洛方法做随机撒点积分。三种方法的结果对同一函数 (f(x)=x^2) 在 [0,1] 上积分,理论上都是1/3。
- 矩形法:分割10000个区间,误差在1e-5左右
- scipy.integrate.quad:误差接近机器精度,速度也快
- 蒙特卡洛方法:随机撒点100万次,误差在1e-3量级,稳定性差一些
动手跑完对比之后,我最大的收获是:工程上求积分不要自己造轮子,scipy.integrate.quad是首选;而蒙特卡洛方法虽然精度低,但在高维积分中几乎是唯一实用的选择,因为传统网格方法会随维度指数爆炸。书里把这几个方法放在一起讲,配合代码验证之后,各自的优缺点就很清晰了。
4.3 实战:梯度下降实现线性回归
微积分全项目里我玩得最久的是梯度下降。书里先从导数的概念切入,然后引出梯度下降算法,最后落到了一个线性回归案例。我照着思路从零实现了一个简易版本,代码不长,但信息量很大:
import numpy as np import matplotlib.pyplot as plt np.random.seed(42) X = np.linspace(0, 10, 200) y = 3 * X + 5 + np.random.randn(200) * 2 # 构造特征矩阵,第一列全1用来学偏置 X_b = np.c_[np.ones((200, 1)), X] theta = np.zeros((2, 1)) alpha = 0.001 losses = [] for iteration in range(1000): gradient = X_b.T @ (X_b @ theta - y.reshape(-1, 1)) / len(y) theta -= alpha * gradient loss = np.mean((X_b @ theta - y.reshape(-1, 1)) ** 2) losses.append(loss) print(f"学到的参数:截距 {theta[0][0]:.2f},斜率 {theta[1][0]:.2f}")这个实验最有价值的部分不是最终拟合出来的参数,而是画出损失曲线。我试了三个学习率:0.0001、0.001、0.01,分别得到收敛缓慢、正常收敛、震荡发散三种曲线。肉眼看着这些曲线,才能真正理解学习率对梯度下降意味着什么——它本质上是“沿着梯度方向迈的步子大小”,步子太小走得慢,步子太大反而跳过最优点。
5. 修复书中bug的全过程与方法论
5.1 三个典型的bug修复案例
这个项目里前前后后修了十几个bug,挑三个印象最深的说说。第一个是前面提过的数值微分步长问题,这不算语法错误,但结果错了,是最难排查的一类问题。排查它的关键是用解析解做基准:我对每个测试函数先用SymPy求出真实导数,再和数值结果比对,误差超出预期就说明代码有问题,这时才去检查步长和差分格式。
第二个是NumPy旧接口迁移问题。书里很多示例代码用的是np.matrix,但新版NumPy已经不建议使用,而且np.matrix的*运算符是矩阵乘法,换成普通ndarray后同样的代码语义完全不同。最坑的是一个多元线性回归的正规方程 ( \theta = (X^TX)^{-1}X^Ty ) 示例,在np.matrix下运行正常,但如果数据被读成ndarray,X.T * X就变成了逐元素乘,结果矩阵形状不匹配直接报错。我统一把np.matrix改成了np.ndarray,并把所有矩阵乘法换成@运算符。
第三个bug是特征向量符号不稳定。这个问题严格来说不是代码错误,而是算法特性:np.linalg.eig求出的特征向量方向具有随机性,导致PCA结果每次运行都不一样。书里没有说明这一点,很多读者跟练时会以为自己写错了。我的修复方法是在输出特征向量前做一个符号归一化,让每个特征向量的第一个非零元素恒为正。这样处理后,结果就稳定了,图像也能和书里对上。
5.2 验证数学代码正确性的一线方法
随着项目推进,我总结出一套“数学代码验证三板斧”,用来判断一段实现到底对不对。第一是解析对照:能用SymPy求解析解的,先求出标准答案,再用数值结果比,误差在合理范围内才算过。第二是性质验证:利用数学性质做自检,比如矩阵分解后要能还原,即np.allclose(U @ np.diag(s) @ Vt, A)应该返回True;再比如正交矩阵 (Q) 应该满足Q.T @ Q接近单位阵。第三是图形审计:把损失曲线、拟合结果、残差图画出来,肉眼判断趋势是否符合预期。
所有验证代码我都整理进了tests/目录,用pytest写了基础的回归测试。这样做的好处是,后续当我新建代码或调整参数时,跑一遍测试就能快速发现是否有改动导致旧功能失效。做自己代码的“bug观察员”,说的就是这个状态——不放过任何一次异常输出,把每次报错都当成理解数学原理的线索。
6. 常见运行问题与学习建议速查
6.1 环境与依赖相关的问题
整理项目过程中,有几个问题几乎可以确定你会遇到。把它们列成一张速查表,能帮你省下不少查资料的时间:
| 现象 | 原因 | 处理方法 |
|---|---|---|
ImportError: numpy.core.multiarray failed to import | NumPy与其它库版本不匹配 | 升级或重装NumPy,尽量同环境同版本 |
| Matplotlib图窗不显示 | 非交互环境或后端配置问题 | 在代码里加plt.show(),或改用%matplotlib inline |
np.linalg.eig结果每次不同 | 特征向量符号随机性是正常现象 | 做符号归一化,或用np.linalg.eigh处理对称矩阵 |
| 中文标签在图上显示为方框 | 系统缺中文字体 | 设置plt.rcParams['font.sans-serif'] = ['SimHei'] |
还有一个常常被忽略的坑是Python版本。有些旧代码在Python 2时代写的,print后面没加括号,跑起来直接语法错误;有些代码用了NumPy 1.x的旧接口,在NumPy 2.x里被移除了。遇到这种问题先看报错提示,再根据报错去定位版本差异。判断一个报错到底是环境问题还是代码问题,其实和工作里区分前后端bug是一个道理——先看错误在哪个环节爆出来,再逐层缩小范围,锁定到具体模块后再读报错细节。
6.2 给想用同样方式学数学的人的建议
如果你也想用Python把数学重新捡起来,我能给的最重要建议是:不要贪多。一次只盯一个小知识点,把对应的代码写出来、跑通、画图,确认自己真的理解了,再推进下一个。我见过很多人一开始兴致勃勃,列了一长串学习计划,结果第一周就把所有章节的代码全部抄了一遍,后面再也不想打开。抄代码和写代码是完全两回事,前者只是打字,后者才是思考。
另外,一定要给自己找应用场景。线性代数学完,就去找一个图片数据做SVD压缩;微积分学完,就找一份房价数据做线性回归。只有把数学用在实际数据上,那些公式才不会变成过眼云烟。这个项目对我最大的影响不是“会了NumPy”,而是养成了遇到看不明白的公式先写代码验证的习惯。
最后说一点个人体验。数学书里那些推导,该读还是要读,但光读是不够的。把公式变成代码的那一刻,你能感觉到那些符号从纸上站起来,变成了可以触摸、可以调试、可以观察的活的东西。如果你也在某个深夜面对一本数学书发呆,不如现在就打开Jupyter Notebook,把你看到的第一条公式变成三行Python代码。
本文还有配套的精品资源,点击获取