1. 为什么我从鱼书开始重新梳理Python算术计算
最近在看《深度学习入门:基于Python的理论与实现》(就是那本封面是一条鱼的经典书,圈内俗称“鱼书”),发现一个很有意思的现象:书里最前面的几个章节,几乎没有用到任何高级语法,就是加减乘除、数组切片、循环和函数。但真正动手敲的时候,不少初学者反而会卡在那些看似基础的算术计算上——比如3 / 2和3 // 2结果为什么不一样?0.1 + 0.2为什么不等于0.3?2 ** 3 ** 2到底该从左算还是从右算?
这些问题的答案,鱼书里不会细讲,因为作者默认读者已经掌握了Python的基础。但你如果直接跳过去,后面的反向传播、梯度下降、矩阵运算,每一步都会因为底子不牢而出各种莫名其妙的Bug。这就像盖楼不打地基,装修得再漂亮也会塌。
这篇内容就是把我自己踩过的坑、查过的资料、验证过的结论梳理成一份偏实战的总结。核心只聚焦一件事:Python里的算术计算。从运算符、类型体系、优先级这些基础规则讲起,再到NumPy里的广播机制、向量化运算,最后落到鱼书里常见的代码场景。无论你是刚装好Python想搞清楚print(7 % 3)到底输出什么的新手,还是写了不少代码但从来没深究过is和==区别的“熟练工”,这篇文章应该都能给你一些新的视角。
先放一段最简单的代码,这段代码几乎出现在所有Python教程的第一页:
print(1 + 1) print(10 - 4) print(7 * 6) print(8 / 2) print(7 // 2) print(7 % 3) print(2 ** 10)输出如下:
2 6 42 4.0 3 1 1024注意第三行和第四行的区别:8 / 2的结果是4.0而不是4。这个细节后面会专门展开讲,它不像看起来那么简单,直接关系到你在鱼书里写损失函数时会不会遇到类型不匹配的问题。
2. Python算术计算的底层类型体系
2.1 数字类型的分类与算术行为
Python里参与算术计算的数据类型主要有四种:int(整数)、float(浮点数)、complex(复数)、bool(布尔值)。它们之间的算术规则是整个计算体系的地基。
先看一个实操中非常容易踩坑的点:bool也是数字类型。True就是1,False就是0。这就意味着True + True的结果是2,False * 10的结果是0。我第一次在鱼书的代码里看到有人用sum(is_active for item in list)来计数时,才真正意识到这个特性的实用价值——布尔值可以直接参与算术计算,省去一长串if判断。
int和float之间的运算遵循“向高精度看齐”的原则:
print(type(3 + 2)) # <class 'int'> print(type(3 + 2.0)) # <class 'float'> print(type(3 * 2.5)) # <class 'float'> print(type(10 / 2)) # <class 'float'>这里面最需要注意的是除法:只要用了单斜杠/,无论两个操作数是不是整数,结果一定是float。这是Python 3做出的一个关键设计决策,和Python 2完全不同。在Python 2里,10 / 3的结果是3(整数除法),这个行为坑了无数从Python 2迁移到Python 3的人。鱼书里的代码只支持Python 3,所以/永远返回浮点数就成了一切逻辑的前提。
复数类型用得相对少,但在信号处理、傅里叶变换场景下绕不开。鱼书主体不涉及复数运算,但如果你后面去看DFT的实现,你会发现complex的算术规则和实数一致,只是多了虚部而已:
z1 = 3 + 4j z2 = 1 - 2j print(z1 + z2) # (4+2j) print(z1 * z2) # (11-2j)注意Python里虚数单位是j(不是数学书里的i),而且写4j时4和j之间不能有空格。这种小细节查错时容易被忽略。
2.2 除法家族的三兄弟
Python的除法运算符可能是初学者最容易混淆的一组。/、//、%这三个符号各司其职,但它们的关系密切到可以互相换算。我习惯把它们称作“三兄弟”。
/是真正的除法,永远返回浮点数。//是地板除(floor division),结果是向下取整的整数或浮点数。%是取余(取模),满足一个核心恒等式:
a == (a // b) * b + (a % b)这个恒等式是你验证代码正确性最可靠的武器。举个例子:
print(7 // 2) # 3 print(7 % 2) # 1 print((7 // 2) * 2 + (7 % 2)) # 7一切看起来都很自然。但负数出现之后,局面立刻变得微妙起来:
print(-7 // 2) # -4 print(-7 % 2) # 1有没有发现不对的地方?-7 / 2的数学结果是-3.5,但-7 // 2的结果是-4,不是-3。很多人的第一反应是“取整嘛,向零取整不就应该是-3吗”,但Python的地板除是“向下取整”,也就是向负无穷方向取整,-3.5向下取是-4。这是初学者在算术计算中遇到的第一道坎。
结合前面的恒等式来理解:
-7 == (-4) * 2 + 1等式成立。这意味着-7 % 2的结果是1,而不是数学上常见的-1。Python的取模运算保证结果的正负号和除数一致,这个设计背后的逻辑和循环数组索引、循环队列等场景直接相关。后面在鱼书实现数据加载器时,如果自己做循环批采样,这个特性就能派上用场。
2.3 幂运算的右结合陷阱
**运算符表示幂运算,它在所有算术运算符中优先级最高,但是它的结合性是从右往左的。看下面这行代码:
result = 2 ** 3 ** 2如果从左往右算,结果是(2 ** 3) ** 2 = 64。但Python实际输出多少呢?
print(2 ** 3 ** 2) # 512因为幂运算右结合,实际计算的是2 ** (3 ** 2) = 2 ** 9 = 512。
这个细节在鱼书里其实比较少见,但在写指数衰减学习率、实现softmax函数时,如果你不小心写出了2 ** 3 ** 2这样的表达式且没加括号,结果会比预期大很多。我的习惯是:凡是用到幂运算的地方,一律显式加括号,不依赖结合性规则。人的脑子从左往右读代码,不会自动切换成右结合模式。
再看一个和负数搭配的坑:
print(-2 ** 2) # -4 print((-2) ** 2) # 4-2 ** 2的实际解析是-(2 ** 2),因为**的优先级高于一元负号-。这个规则和很多人的直觉相反。如果你要表达“负二的平方”,必须写(-2) ** 2。这种细节看似无所谓,一旦出现在梯度计算的公式里,一正一负就是天壤之别。
3. 运算符优先级与表达式求值规则
3.1 完整优先级阶梯
Python的运算符优先级不是靠背诵记忆的,而是靠“加括号”这个动作来消解的。但作为从业者,至少要知道主干顺序,否则连bug在哪一层都判断不出来。
从高到低的常见优先级排列(只列我们日常用得上的):
**:幂运算,右结合*、/、//、%:乘、除、地板除、取模,左结合+、-:加、减,左结合<<、>>:移位运算(位运算方向)&:按位与^:按位异或|:按位或==、!=、>、>=、<、<=:比较运算not:逻辑非and:逻辑与or:逻辑或
有一个很经典的面试题同样适合自测:
print(1 + 2 ** 3 * 2)如果从左到右一个接一个算,会得出(1 + 2) ** (3 * 2) = 729这种荒谬的结果。实际过程是:先算幂2 ** 3 = 8,再算乘法8 * 2 = 16,最后算加法1 + 16 = 17。正确答案是17。
另一个经常被忽视的点是比较运算符的链式求值。Python允许a < b < c这种写法,等价于a < b and b < c。这在算术计算里用来判断数值是否落在某个区间内非常方便:
x = 15 print(10 < x < 20) # True但链式比较有个隐含的副作用:中间的变量会被求值两次。如果中间是带副作用的函数调用,就会执行两次。写代码时小心这个问题。
3.2 整数除法的银行家舍入与浮点陷阱
浮点数的算术计算是Python里“看起来不应该有问题,但一跑就出问题”的重灾区。最典型的就是:
print(0.1 + 0.2) # 0.30000000000000004为什么不是0.3?因为计算机里浮点数是用二进制表示的,而十进制的0.1写成二进制是一个无限循环小数,计算机只能存一个近似值。两个近似值相加,误差就被放大了。这个不是Python的Bug,是所有用IEEE 754标准存储浮点数的语言共有的特性(C、Java、JavaScript、Go都一样)。
鱼书里涉及大量浮点运算(权重更新、梯度计算、损失函数),所以浮点精度问题几乎是每天都会碰到的。应付办法有三个,按优先级排序:
第一,比较浮点数时不要用==,用绝对误差或相对误差:
import math a = 0.1 + 0.2 b = 0.3 print(math.isclose(a, b, rel_tol=1e-9)) # True第二,涉及金额、计数、索引这类“必须精确”的场景,优先用Decimal或者整数运算:
from decimal import Decimal print(Decimal('0.1') + Decimal('0.2')) # 0.3注意Decimal构造器的参数是字符串,如果直接传浮点数Decimal(0.1),精度问题就提前注入了。
第三,知道什么时候可以忽略误差。深度学习里权重更新本来就有随机性,浮点误差相对于梯度的噪声小到可以忽略。你不需要让loss精确等于某个值,只需要让它单调下降就行。
还有一个容易被忽略的问题:Python做浮点数除法求余时,输出可能也很“调皮”:
print(5.5 % 1.2) # 0.6999999999999993数学期望是0.7,但浮点给的是0.6999...。用这种结果去判断“是不是整除”,十有八九会出错。正确做法是先转成Decimal或者用math.isclose去比较。
4. NumPy里的算术计算:从标量到向量化
4.1 为什么要引入数组计算
看标题里的热搜词就知道,numpy库是被搜烂了的关键词。鱼书之所以全书都离不开NumPy,是因为深度学习的计算对象是“批量数据”——成百上千个样本、成千上万个特征。如果用纯Python的for循环一个个算,一个简单的矩阵乘法就能把训练时间从秒级拖到分钟级,这是不可接受的。
NumPy的核心武器是“向量化运算”:对数组的操作自动应用到每个元素上,底层跑的是C语言实现的高效循环。举个对比:
import numpy as np import time size = 1000000 a = np.arange(size) b = np.arange(size) # NumPy向量化 start = time.time() result_np = a * b print(f"NumPy耗时: {time.time() - start:.4f}秒") # 纯Python循环 start = time.time() result_py = [a[i] * b[i] for i in range(size)] print(f"纯Python耗时: {time.time() - start:.4f}秒")在我自己的机器上(Intel i5-12400,内存32G),NumPy大概0.002秒,纯Python循环大概0.08秒,差距大约40倍。这个差距在数据规模扩大、计算层数加深之后只会更加夸张。
np.arange(n)和Python内置的range(n)行为类似,都生成从0到n-1的序列,但返回的是NumPy数组(ndarray)。这点在鱼书第一章就会出现,熟悉它是一切后续的基础。
4.2 广播规则:形状不一致时到底怎么算
广播(broadcasting)是NumPy算术计算的灵魂机制。它的意思是:两个形状不一致的数组做算术运算时,NumPy会自动扩展较小的数组来匹配较大的数组,而不需要手动复制数据。
核心规则只有三条:
- 如果两个数组维度相同,对应轴的长度相同,直接逐元素计算。
- 如果两个数组维度不同,将维度较少的数组在前面补1,直到维度数相同。
- 运算时,每一维上要么长度相同,要么其中一个长度为1,才允许广播。长度为1的维度会被扩展到另一个数组的长度。
举个例子:
import numpy as np a = np.array([[1, 2, 3], [4, 5, 6]]) # 形状 (2, 3) b = np.array([10, 20, 30]) # 形状 (3,) -> 被广播为 (2, 3) result = a + b print(result)输出:
[[11 22 33] [14 25 36]]这里b的形状是(3,),在广播时被视作(1, 3),然后沿行方向复制成(2, 3),逐元素相加。
广播最大的坑是“静默错误”:
a = np.array([1, 2, 3]) # 形状 (3,) b = np.array([[1, 2], [3, 4]]) # 形状 (2, 2) result = a + b运行这段代码会直接抛ValueError: operands could not be broadcast together with shapes (3,) (2,2)。为什么不匹配?因为(3,)被补成(1, 3),再尝试扩展到(2, 2)时,最后一个维度3和2不一致且都不是1,所以不行。
但下面这种就有点迷惑性:
a = np.array([[1], [2], [3]]) # 形状 (3, 1) b = np.array([10, 20, 30]) # 形状 (3,) result = a + ba的形状(3, 1),b的形状(3,)被看作(1, 3)。逐维对齐之后,a的3和b的1匹配,a的1和b的3匹配,所以广播成功,结果是:
[[11 21 31] [12 22 32] [13 23 33]]在鱼书的softmax函数、mini-batch梯度计算里,大量使用这种“列向量+行向量”的广播。你要是没完全理解广播的规则,代码写出来很容易出现“没报错但是结果全错”的隐蔽问题。排查方法很简单:打印两个数组的shape,逐维对比一遍。
4.3 矩阵乘法的三种写法
鱼书里权重更新最核心的操作就是矩阵乘法。NumPy里矩阵乘法有三种等价写法:
import numpy as np A = np.array([[1, 2], [3, 4]]) # 形状 (2, 2) B = np.array([[5, 6], [7, 8]]) # 形状 (2, 2) c1 = np.dot(A, B) c2 = A @ B c3 = A.dot(B) print(c1) print(c2) print(c3)三个结果完全一样:
[[19 22] [43 50]]@是Python 3.5引入的矩阵乘法运算符,最简洁,也是我推荐的主力写法。使用@时,逻辑一目了然:你清楚自己在做矩阵乘法,而不是逐元素乘法。*在NumPy里做的是逐元素乘积(Hadamard积),不是矩阵乘法。一个经典的错误就是混淆*和@:
A = np.array([[1, 2], [3, 4]]) B = np.array([[5, 6], [7, 8]]) print(A * B) # 逐元素乘 -> [[ 5 12] [21 32]] print(A @ B) # 矩阵乘 -> [[19 22] [43 50]]两者形状相同,输出却完全不同。在鱼书的神经网络代码里,正向传播用的是@,激活函数里的逐元素非线性变换用的是*。一旦混用,前向传播虽然不报错,反向传播算出来的梯度就会剧烈震荡。
另外注意矩阵乘法有一个“维度匹配”要求:A @ B要求A的最后一维和B的倒数第二维相等。初学者最常见的报错就是:
A = np.ones((3, 4)) B = np.ones((3, 4)) C = A @ B # ValueError报错信息会明确告诉你:shapes (3,4) and (3,4) not aligned: 4 (dim 1) != 3 (dim 0)。这个报错本身写得已经很友好了,认真读一遍就能知道问题出在哪一维。
5. 鱼书场景实战:算术计算在神经网络中的落地
5.1 实现一个带中间层的感知机
鱼书第三章的经典例子是MNIST手写数字识别。整个网络的推理过程,本质上就是一连串的矩阵乘法和向量加法,中间穿插非线性的激活函数。我用一个简化版的感知机来演示算术计算在这之中的具体角色。
import numpy as np def sigmoid(x): # 分数形式,分子分母都涉及算术计算 return 1 / (1 + np.exp(-x)) def init_network(): network = {} # 2维输入 -> 3维隐藏层 -> 2维输出 network["W1"] = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]]) # 形状 (2, 3) network["b1"] = np.array([0.1, 0.2, 0.3]) # 形状 (3,) network["W2"] = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]]) # 形状 (3, 2) network["b2"] = np.array([0.1, 0.2]) # 形状 (2,) return network def forward(network, x): W1, W2 = network["W1"], network["W2"] b1, b2 = network["b1"], network["b2"] a1 = x @ W1 + b1 # 矩阵乘法 + 广播加偏置 z1 = sigmoid(a1) # 逐元素非线性 a2 = z1 @ W2 + b2 z2 = sigmoid(a2) return z2 network = init_network() x = np.array([1.0, 0.5]) # 输入形状 (2,) y = forward(network, x) print(y) # [0.59254132 0.57427806]这个例子里藏了三处算术计算的关键点。
第一处,x @ W1:x形状(2,),W1形状(2, 3),一维数组和二维数组做矩阵乘法时,结果是形状(3,)的一维数组。这里一维数组被当作行向量处理。
第二处,+ b1:a1的形状是(3,),b1的形状也是(3,),逐元素相加,没有广播参与。但如果你把x改成形状(4, 2)(一次处理4个样本),a1就变成(4, 3),b1是(3,),此时广播就被激活了:b1沿行方向复制4份,加到每个样本的输出上。这就是mini-batch处理的本质。
第三处,1 / (1 + np.exp(-x)):np.exp(-x)先逐元素计算指数,然后1 +向量整体加标量,最后1 /向量整体除标量。这又是广播机制在起作用——标量和数组运算时,标量被广播到数组的每个元素上。
整个前向传播,算术计算的正确性直接决定了后面反向传播梯度的正确性。你不可能在错误的前向传播结果上训练出好的模型。
5.2 梯度下降中的算术计算
鱼书第四章用最简单的线性回归演示了梯度下降。核心公式是:
w = w - learning_rate * gradient这个公式虽然简单,但涉及到的问题在真实项目中一样都不少。看一段典型的梯度下降参数更新代码:
import numpy as np # 模拟数据:y = 2x + 1 加上一点噪声 np.random.seed(42) X = np.random.rand(100, 1) y = 2 * X + 1 + np.random.randn(100, 1) * 0.1 w = np.random.randn(1, 1) * 0.01 # 初始权重,避免过大 b = np.zeros((1, 1)) # 初始偏置 learning_rate = 0.1 epochs = 1000 for epoch in range(epochs): # 前向:预测值 y_pred = X @ w + b # 损失:均方误差 loss = np.mean((y_pred - y) ** 2) # 梯度(推导后) grad_w = X.T @ (y_pred - y) * (2 / len(X)) grad_b = np.mean(y_pred - y) * 2 # 参数更新:注意这里是 -=,不是 = w -= learning_rate * grad_w b -= learning_rate * grad_b if epoch % 100 == 0: print(f"Epoch {epoch}, Loss: {loss:.6f}")这段代码里的算术细节很有意思。(y_pred - y) ** 2是先逐元素计算差值,再逐元素计算平方,最后np.mean对所有元素求平均。整个过程如果用纯Python写,需要一个双重循环嵌套,而NumPy只用三行就搞定了。
梯度公式里的X.T @ (y_pred - y),维度是(1, 100) @ (100, 1) = (1, 1),这个(1, 1)形状在更新w时会对上w的形状。不做形状检查的人初学时常犯一个错:用grad_w的“值”去更新,但忽略了形状必须严格一致。如果w的形状是(1, 1),而grad_w因为某个操作失误变成(100, 1),w -= learning_rate * grad_w就会直接抛维度不匹配的异常。这种错误在鱼书的练习里太常见了。
5.3 一个容易忽略的印刷级细节:np.exp在数值稳定上的角色
softmax是鱼书第三章登场的激活函数,定义是:
def softmax(a): exp_a = np.exp(a) sum_exp_a = np.sum(exp_a) return exp_a / sum_exp_a从算术计算的角度看,这个实现没有问题。但如果你传入一个较大的数,比如[1000, 2000, 3000],np.exp(3000)会直接溢出,Python会给你一个RuntimeWarning: overflow encountered in exp,结果变成inf,然后inf / inf就产生了nan。
修正方法是对输入做平移,减去最大值后再算softmax。这是数值计算里“稳定化”的经典手段:
def softmax_stable(a): c = np.max(a) exp_a = np.exp(a - c) # 防止溢出 sum_exp_a = np.sum(exp_a) return exp_a / sum_exp_a为什么减去最大值不影响结果?因为softmax分子的指数和分母的求和项都同时除以了exp(c),整体比值不变。这个技巧在深度学习的语言模型、多分类任务里都是标配。如果不做稳定化,训练到一半loss突然变成nan,多数情况都和这类指数溢出有关,而不是模型本身出问题。
这也是一个典型的“算术计算里藏着工程问题”的例子。你以为自己在写数学公式,实际上你在跟IEEE 754浮点数标准打交道。
6. 常见算术计算错误排查实录
6.1 排查利器:类型与形状自检
写Python算术计算卡壳的时候,不要靠猜,要靠“打印”。我有一套固定的自检流程:
- 打印参与运算的每个变量的
type,确认是不是int、float、ndarray。 - 打印每个数组的
shape,逐维对比,尤其关注(3,)和(3, 1)的区别。 - 打印中间结果的
dtype。浮点数组和整数数组混合运算时,dtype会自动向上转型,但有时你会遇到float32和float64混用带来的细微精度波动。
具体到一个小细节:a.shape返回的是元组,a.shape[0]是第一个维度大小。很多教程里用a.shape[0]取行数,但对一维数组(3,),a.shape[0]返回3,a.shape[1]就报IndexError。这个报错我见过太多次,一堆初学者在二维数组和一维数组之间来回折腾时,错误信息都指向“IndexError: tuple index out of range”。记住:一维数组只有一个维度。
6.2 一张速查表:算术常见问题与解法
以下是我在实际项目和鱼书练习中积累的“问题-现象-解法”速查表,按出镜率排序:
| 问题现象 | 根因 | 解决方法 |
|---|---|---|
0.1 + 0.2 != 0.3 | 二进制浮点表示误差 | math.isclose()或Decimal |
10 / 3结果是3.3333333333333335不是3 | 除法返回浮点数 | 用自己的语法规则确认 |
-7 // 2结果是-4 | 地板除向下取整 | 记住方向是负无穷方向 |
2 ** 3 ** 2结果是512 | 幂运算右结合 | 显式加括号(2 ** 3) ** 2 |
0.1 * 3 == 0.3为False | 浮点累积误差 | 使用误差容忍比较 |
数组*和@混用 | 混淆逐元素乘和矩阵乘 | 检查操作符 |
shape mismatch报错 | 矩阵维度不匹配 | 打印形状逐维对比 |
inf或nan | 指数溢出或除零 | 数值稳定化技巧(减最大值) |
(3,)和(3, 1)傻傻分不清 | 一维数组和列向量 | reshape(3, 1)显示声明维度 |
这种速查表的价值在于,出现问题时能快速定位原因,而不是在Stack Overflow上翻半天帖子。自己亲手踩过、填过坑之后,对这些条目的记忆会深刻得多。
6.3 我调试算术计算时的几个习惯
第一个习惯:把所有中间步骤拆开写。初学者喜欢把所有运算揉在一个长表达式里,看起来像大师风范,实际上出问题时根本没法查。w -= learning_rate * (X.T @ (y_pred - y) * (2 / len(X)))这种写法,一旦报错,你无法确定是X.T @ (y_pred - y)出了问题,还是2 / len(X)出了问题。拆成变量来写就清爽多了。代码在调试阶段的首要目标是可读,其次才是简洁。
第二个习惯:用assert做运行时检查。在数据流的关键节点上,断言形状是否符合预期:
assert x.shape == (4, 2), f"输入形状错误,期望(4,2),实际{x.shape}"这个习惯能在维度错配时立刻暴露问题,而不是让错误在几十行之后才以“ValueError: operands could not be broadcast”的形式爆发出来。特别在实现神经网络时,每一层前后的形状都是可以预判的,assert的成本极低,价值极高。
第三个习惯:能用整数就不使用浮点数。比如循环次数、数组索引、步长这种天然是整数的场景,只用int,避免混入浮点数后带来隐式类型转换的副作用。比如range(0, 100, 0.5)直接报TypeError,因为range要求整数参数。如果要做等距采样,优先用np.linspace(0, 100, 200)。
6.4 复杂数字运算的长段操作细节
前面讲的理论和排查手段,在某些更“硬核”的算术计算场景里会显现出更大的价值。比如图像处理里的灰度变换、音频数据的归一化、时间序列差分——这些场景下你不再逐个变量地处理,而是面对整个数组。看看一个简单的灰度图像线性变换:
import numpy as np # 模拟一张64x64的单通道灰度图 image = np.random.randint(0, 256, size=(64, 64)).astype(np.float32) # 线性对比度拉伸:把区间[20, 200]映射到[0, 255] low, high = 20.0, 200.0 stretched = (image - low) / (high - low) * 255.0 stretched = np.clip(stretched, 0, 255).astype(np.uint8) print(stretched.shape) # (64, 64) print(stretched.dtype) # uint8这个过程中image - low是数组和标量的广播减法,/ (high - low)是整体除法,* 255.0是整体乘法。每一步的结果都可能引入浮点误差,最后的astype(np.uint8)又会做截断。如果你忽略dtype的处理,直接拿uint8的数组去做(image - low) / (high - low),结果会先经历整数减法溢出(小数值减大数值变成大的正数),再被强制转换,最终得到的图像会花成一片,完全看不出原本的明暗层次。这种初学者最容易踩的坑,本质上就是“数组运算的dtype规则没有被理解透”。
再举个偏实用向的:信号去噪里的滑动平均。直接把算术计算推向大规模数据:
import numpy as np # 模拟100万点的噪声信号 x = np.random.randn(1_000_000) # 滑动平均,窗口大小50,用卷积实现 kernel = np.ones(50) / 50.0 smoothed = np.convolve(x, kernel, mode="same") print(smoothed.shape) # (1000000,)这里np.ones(50) / 50.0产生了窗口内所有元素均值为1的核,和原始信号做卷积后,效果相当于逐点取附近50个点的平均值。整个过程没有显式写for循环,全靠向量化和底层数组操作。一旦你熟练了这类写法,处理百万级数据也就是几十毫秒的事。
6.5 为什么不建议用eval做算术计算
聊一个隐藏在“来自网络热词”里的常见操作。不少人想做计算器、做公式求值时,第一反应是eval("2 + 3 * 4")。eval确实能算,但它会执行任意字符串表达式,存在注入风险。如果你的输入来自用户,eval("__import__('os').system('rm -rf /')")这种字符串就能直接让程序干出不可控的事。
安全替代方案是ast.literal_eval,但它只解析字面量,不支持任意表达式运算。真正需要“字符串公式求值”时,推荐用sympy的安全解析,或者自己写一个表达式解析器。核心原则是:不要把eval用在不可信输入上。这条教训不需要等踩坑,提前知道就能省下大量精力。
7. 实操心得与后续扩展
鱼书开篇的算术计算,用一句话总结就是:每个符号背后都有一整套语言设计规则。/和//的差别是Python 3的破而后立,%对负数的处理是循环索引设计的基本功,**的右结合性是表达式的隐藏陷阱,NumPy的广播则是从标量思维迈向批量运算的分水岭。
我个人在实际写代码时最深刻的两个体会:
第一个,遇到诡异的计算结果,先别怀疑数学公式,先排查类型和形状。有一次我在跑二分类交叉熵损失,训练loss在前几百步飞速下降,突然变成nan。排查了模型结构、学习率、正则项,都没问题。最后打印日志,发现输入数据里有几个inf——是特征工程阶段用1 / (x - mean)计算倒数时,某列恰好全部等于均值,导致除零溢出。这就是“算术计算”层面的问题藏在了业务逻辑下面。打印日志、逐步断言、从数据源头查起,这套方法论比任何高深的调试工具都管用。
第二个,一定要手动算一遍小例子。我每次实现新的矩阵运算,都会先用纸笔手算一个2x3的样例,再用Python跑一遍对比。这么做看起来费时,但几乎总能抓住写代码时想不到的维度排列问题。比如写X.T @ (y_pred - y)之前,先在草稿纸上标出X的形状和X.T的形状,确认两个矩阵相乘后的维度是期望的(特征数, 样本数) @ (样本数, 1) = (特征数, 1),再动手敲代码。磨刀不误砍柴工,在算术计算这个领域尤其明显。
最后分享一个真正的小技巧:在Jupyter Notebook或脚本开头加一行np.set_printoptions(precision=6, suppress=True),这样打印NumPy数组时不会出现1.23456789e-07这种科学计数法,精度显示的默认设置会清爽很多。排查问题的时候,肉眼直接扫数组结果,比眯着眼睛数科学计数法里的指数快得多。一旦你的数据规模扩大,这个习惯能省下的时间会超乎想象。