☰
手写CNN:从零实现卷积神经网络理解反向传播原理
2026/10/19 3:25:00 网站建设 项目流程

简介:本资源是一份面向深度学习初学者与原理探究者的纯Python卷积神经网络(CNN)实现方案,不依赖TensorFlow、PyTorch等框架,聚焦底层计算逻辑与反向传播机制的理解。适合高校学生、转行学习者及希望夯实CNN数学基础的开发者,用于图像识别原理教学、课程设计或自主实验。压缩包共6个文件,含4个核心Python脚本(myCnn.py实现卷积/池化/全连接层及训练流程;analysisMNIST.py提供完整MNIST手写数字识别训练与评估流程;myCnnTest.py用于模块功能验证;gParam.py统一管理网络结构、学习率等关键参数)及2个.gz格式MNIST测试集数据文件,整体大小仅1.58MB,轻量易解压运行。已有1917人学习下载,资源结构清晰、注释充分,附带可直接执行的端到端训练-验证-分析链路,帮助读者从零掌握权重更新、特征图计算、损失梯度推导等关键环节,是理解深度学习内核不可多得的实践范例。

1. 卷积神经网络:为什么不用框架,反而能看清反向传播的“血肉”?

你写过model = Sequential(),调过model.compile(optimizer='adam'),也见过训练时跳动的 loss 曲线——但当 loss 突然炸开、梯度消失成零、卷积核权重全变成 NaN,你翻遍 PyTorch 文档却找不到那个「真正被求导的张量」在哪一层哪一维被篡改过?这不是玄学,是黑匣子封装太深导致的感知失焦。这份纯 Python 实现的 CNN(无任何深度学习框架依赖),恰恰用 2300 行手写代码,把卷积、池化、激活、全连接、Softmax、交叉熵、链式求导全部摊开在你眼前:它不加速、不优化、不支持 GPU,但它让反向传播第一次有了坐标——你能用print(grad_w.shape)看清每个卷积核权重梯度的维度如何从(3,3,3,16)一步步回传到输入层;能单步调试dL_dout = dL_dsoftmax @ softmax.T这一行矩阵乘法为何必须转置;甚至能手动把某次前向的 feature map 存下来,和反向算出的dL_dinput做 element-wise 验证。这不是复古情怀,而是当你需要定制稀疏卷积、实现梯度裁剪的底层逻辑、或给嵌入式设备移植轻量 CNN 时,唯一能让你不靠猜、不靠试、不靠 Stack Overflow 就定位问题根源的“后悔药”。


2. 卷积层:从图像滑窗到张量切片,为什么 stride=1 时输出尺寸是(H−K+1)×(W−K+1)?

卷积不是魔法,是确定性滑动窗口加权求和。框架里一句nn.Conv2d(3,16,3)背后,藏着对输入张量X ∈ R^(C_in × H_in × W_in)的显式切片、重塑与矩阵乘法。这份实现中,卷积层核心逻辑完全脱离torch.nn.functional.conv2d或scipy.signal.convolve2d,只用原生 NumPy 的as_strided和reshape构建 im2col——这是理解内存布局与计算效率的关键入口。

2.1 im2col:把三维卷积降维成矩阵乘的底层动机

传统逐点卷积需三重嵌套循环(通道、高度、宽度),时间复杂度 O(C_in × C_out × K² × H_out × W_out)。而 im2col 将每个卷积窗口内的像素块拉平为一行,把整个输入转换为二维矩阵X_col ∈ R^((K²×C_in) × (H_out×W_out)),卷积核则展平为W_row ∈ R^(C_out × (K²×C_in)),最终输出Y = W_row @ X_col。这不仅提升 CPU 缓存命中率,更让反向传播中∂L/∂W的计算直接对应∂L/∂Y @ X_col.T——无需再写三层 for 循环求梯度。

# convolution.py 中 im2col 核心实现(简化版) def im2col(X, kernel_h, kernel_w, stride=1, pad=0): N, C, H, W = X.shape # 补零:上下左右各 pad 行/列 X_padded = np.pad(X, ((0,0), (0,0), (pad,pad), (pad,pad)), mode='constant') # 计算输出空间尺寸 out_h = (H + 2*pad - kernel_h) // stride + 1 out_w = (W + 2*pad - kernel_w) // stride + 1 # 使用 as_strided 构造视图(不拷贝内存!) shape = (N, C, kernel_h, kernel_w, out_h, out_w) strides = (X_padded.strides[0], X_padded.strides[1], X_padded.strides[2] * stride, X_padded.strides[3] * stride, X_padded.strides[2], X_padded.strides[3]) X_col = np.lib.stride_tricks.as_strided( X_padded, shape=shape, strides=strides ) # 重排为 (N*out_h*out_w, C*kernel_h*kernel_w) X_col = X_col.transpose(0, 4, 5, 1, 2, 3).reshape(-1, C * kernel_h * kernel_w) return X_col

提示:as_strided是 NumPy 最危险也最强大的函数之一——它绕过内存安全检查,直接按指定步长构造新视图。若strides计算错误,会读取非法内存地址导致段错误。本实现中strides[2]和strides[3]乘以stride,正是为了跳过被跳过的行/列,确保每次切片都落在正确起始位置。

2.2 卷积核权重更新:为什么dW必须对 batch 维度求和?

前向传播中,Y = W @ X_col输出形状为(C_out, N*out_h*out_w),而损失L是标量。反向传播时:

  • dY = ∂L/∂Y ∈ R^(C_out × (N×out_h×out_w))
  • dW = dY @ X_col.T ∈ R^(C_out × (C_in×K²))—— 这是对单个样本的梯度
  • 但实际训练是 mini-batch,X_col已将 N 个样本拼接,故dW天然包含 N 个样本的梯度累加
# 在 Conv2D.backward() 中关键片段 def backward(self, dY): N, C_in, H_in, W_in = self.X.shape _, C_out, H_out, W_out = dY.shape # 将 dY 展平为 (C_out, N*H_out*W_out) 以匹配 im2col 输出 dY_flat = dY.transpose(1, 0, 2, 3).reshape(C_out, -1) # (C_out, N*H_out*W_out) # dW = dY_flat @ X_col.T → (C_out, C_in*K²) self.dW = dY_flat @ self.X_col.T # dX_col = W.T @ dY_flat → (C_in*K², N*H_out*W_out) dX_col = self.W.T @ dY_flat # col2im:将 dX_col 重构成 dX_padded 形状 dX_padded = col2im(dX_col, self.X.shape, self.kernel_h, self.kernel_w, self.stride, self.pad) # 去除 padding 区域 if self.pad > 0: dX = dX_padded[:, :, self.pad:-self.pad, self.pad:-self.pad] else: dX = dX_padded return dX

注意self.dW并未除以N——这是有意为之。该实现采用「batch 梯度下降」而非「单样本梯度下降」,dW天然是 N 个样本梯度之和,后续优化器(如 SGD)会统一除以N或使用lr/N控制步长。这种设计让梯度计算与数学定义严格对齐:∂L/∂W = Σ_i ∂L_i/∂W,避免了框架中隐式平均带来的缩放困惑。

2.3 避坑:卷积层常见问题排查

  • 现象:训练初期 loss 不下降,dW全为极小值(如1e-8量级)
    原因:权重初始化不当。本实现中W = np.random.randn(C_out, C_in, K, K) * 0.01,若改为* 1.0,前向输出方差爆炸,ReLU 后大量神经元死亡,反向梯度几乎为零。
    解决:改用 He 初始化:W = np.random.randn(C_out, C_in, K, K) * np.sqrt(2.0/(C_in*K*K))

  • 现象:dX形状与X不匹配,报ValueError: could not broadcast
    原因:col2im中pad参数与前向im2col不一致,或stride在as_strided步长计算中漏乘。例如strides[2]应为X_padded.strides[2] * stride,若写成X_padded.strides[2],则切片行距错误,X_col列数错乱,导致dX_col无法正确映射回空间维度。
    解决:在im2col返回前加断言assert X_col.shape[1] == C_in * kernel_h * kernel_w

  • 现象:启用 padding 后,dX边缘出现非零值,但理论应为零梯度
    原因:col2im未对 padding 区域做梯度归零。as_strided构造的X_col视图中,padding 区域被多个卷积窗口重复覆盖,反向时dX_padded对同一 padding 位置累加多次梯度。
    解决:col2im函数内部需维护一个计数矩阵count,记录每个dX_padded位置被累加次数,最后除以count(避免除零,count=0处设为 1)。


3. 池化层:最大池化的不可导性,如何用 mask 实现“伪梯度”?

池化层没有可学习参数,却是反向传播中最易被误解的一环。最大池化(Max Pooling)在数学上不可导——当多个输入值相等且均为最大值时,梯度分配不唯一。但工程上我们采用“梯度路由”策略:仅将上游梯度传递给前向时选中的最大值位置,其余位置梯度为 0。这并非真实导数,而是使训练可行的实用约定。

3.1 mask 构造:为什么不能用argmax直接索引?

np.argmax返回的是扁平化后的线性索引,而我们需要在原始四维张量中定位(n,c,h,w)坐标。若直接idx = np.argmax(X_pool),再X_grad[n,c,idx//W,idx%W] = dY_val,会因X_pool是降采样后的视图,导致坐标映射错位。正确做法是在前向时就为每个池化窗口生成布尔掩码(mask),记录最大值位置。

# pooling.py 中 MaxPool2D.forward() def forward(self, X): N, C, H, W = X.shape out_h = (H - self.pool_h) // self.stride + 1 out_w = (W - self.pool_w) // self.stride + 1 # 展平为 (N*C, 1, H, W) 便于统一处理 X_reshaped = X.reshape(N*C, 1, H, W) # im2col-like 切片:得到 (N*C, pool_h*pool_w, out_h*out_w) X_col = im2col(X_reshaped, self.pool_h, self.pool_w, self.stride, 0) # 在每个窗口内找最大值及其索引(axis=1 即窗口维度) self.max_idx = np.argmax(X_col, axis=1) # shape: (N*C, out_h*out_w) out = np.max(X_col, axis=1) # shape: (N*C, out_h*out_w) # 重构为 (N, C, out_h, out_w) out = out.reshape(N, C, out_h, out_w) return out # backward() 中利用 mask 分配梯度 def backward(self, dY): N, C, H_out, W_out = dY.shape Nc = N * C dY_flat = dY.reshape(Nc, -1) # (N*C, H_out*W_out) # 初始化 dX_col 全零 dX_col = np.zeros_like(self.X_col) # (N*C, pool_h*pool_w, H_out*W_out) # 将 dY_flat 的每个值,填入 dX_col 中对应最大值位置 # self.max_idx[i, j] 是第 i 个样本第 j 个窗口的最大值索引(0~pool_h*pool_w-1) dX_col[np.arange(Nc)[:, None], self.max_idx, np.arange(H_out*W_out)] = dY_flat # col2im 还原 dX = col2im(dX_col, (N, C, H, W), self.pool_h, self.pool_w, self.stride, 0) return dX

关键点在于dX_col[np.arange(Nc)[:, None], self.max_idx, np.arange(H_out*W_out)]—— 这是 NumPy 高级索引的经典用法:np.arange(Nc)[:, None]构造(Nc, 1)列向量,self.max_idx是(Nc, H_out*W_out),广播后形成(Nc, H_out*W_out)个三元组(i, max_idx[i,j], j),精准定位每个窗口的最大值槽位。

3.2 平均池化 vs 最大池化:梯度分配的本质差异

平均池化(Avg Pooling)的梯度是均匀分配的:若窗口大小为K×K,则上游梯度dY_val被均分1/K²后填入窗口所有位置。而最大池化是“赢家通吃”。二者在backward中的代码差异极小,却暴露了不同归纳偏置:

特性最大池化平均池化
前向输出窗口内最大值窗口内均值
反向梯度仅传给最大值位置均分给所有位置
感受野关注点强响应特征(如边缘、纹理)整体统计信息(如颜色分布)
对噪声鲁棒性低(单个异常值主导输出)高(异常值被平均稀释)

本实现中,只需将forward()中np.max改为np.mean,backward()中dX_col填充逻辑改为dX_col[:, :, :] = dY_flat[:, None] / (self.pool_h * self.pool_w),即可切换模式。这种可替换性,正是手写实现赋予你的“设计透镜”。

3.3 避坑:池化层梯度常见问题

  • 现象:dX中出现大量inf或nan
    原因:前向forward()中未保存self.X_col,导致backward()调用col2im时传入随机内存(self.X_col为未初始化变量)。
    解决:在forward()结尾强制self.X_col = X_col,并在backward()开头加assert hasattr(self, 'X_col')

  • 现象:训练准确率远低于框架实现(如 PyTorch),尤其在深层网络
    原因:未实现ceil_mode=True。当H_in % stride != 0时,标准池化会丢弃末尾不足窗口的部分,而某些框架默认向上取整保留。本实现默认floor_mode,导致特征图尺寸略小,深层堆叠后感受野收缩。
    解决:在forward()中添加ceil_mode分支,计算out_h = int(np.ceil((H - self.pool_h) / self.stride)) + 1

  • 现象:self.max_idx形状为(N*C, H_out*W_out),但dX_col索引时报IndexError
    原因:self.max_idx中存在等于pool_h*pool_w的索引(越界),源于np.argmax在全零窗口中返回 0,但若窗口全为负数且pool_h*pool_w=1,可能触发边界条件。
    解决:在forward()中对X_col加极小正偏置1e-8,或用np.where显式过滤。


4. 激活函数与损失层:Sigmoid 的梯度饱和,以及 Softmax-Jacobian 的显式展开

框架中F.relu()或F.softmax()是原子操作,但它们的导数形态深刻影响训练动态。Sigmoid 在输入绝对值大于 5 时梯度趋近于 0,导致“梯度消失”;Softmax 的 Jacobian 矩阵具有J_ij = y_i(δ_ij − y_j)结构,决定了分类误差如何分解到每个 logit。手写实现迫使你直面这些数学实体。

4.1 Sigmoid:为什么x=10时dx=0是合理行为?

Sigmoid 函数σ(x) = 1/(1+e^{-x}),其导数σ'(x) = σ(x)(1−σ(x))。当x=10,σ(x)≈0.99995,σ'(x)≈5e-5;当x=20,σ'(x)≈2e-9。在 FP32 精度下,x>8.5时σ'(x)已低于1e-4,经多次链式求导后梯度彻底湮灭。这不是 bug,是函数固有性质。

# activation.py class Sigmoid: def forward(self, X): # 防止 exp(x) 溢出:对正数截断,负数用 1-sigmoid(-x) self.X = X self.Y = np.zeros_like(X) pos_mask = X > 0 neg_mask = ~pos_mask self.Y[pos_mask] = 1 / (1 + np.exp(-X[pos_mask])) self.Y[neg_mask] = np.exp(X[neg_mask]) / (1 + np.exp(X[neg_mask])) return self.Y def backward(self, dY): # σ'(x) = σ(x)(1-σ(x)) = Y*(1-Y) dX = dY * self.Y * (1 - self.Y) return dX

注意forward()中的数值稳定技巧:对X>0直接计算1/(1+exp(-x)),对X≤0改用exp(x)/(1+exp(x)),避免exp(-x)在x很大时溢出为inf。backward()则直接复用前向结果self.Y,避免重复计算sigmoid(X),这是手写实现的典型性能权衡。

4.2 Softmax + CrossEntropy:为什么合起来计算更稳定?

单独实现 Softmax 会导致exp(x)溢出,而 CrossEntropy 单独实现又需log(softmax(x)),数值不稳定。工业实践是将二者融合为LogSoftmax + NLLLoss。本实现提供两种模式:

  • 分离模式:Softmax.forward()输出概率y_i,CrossEntropy.forward(y, t)计算-Σ t_i log(y_i)
  • 融合模式:SoftmaxCrossEntropy.forward(X, t)直接计算-X_t + log(Σ exp(X_i)),其中X_t是真实类别的 logit

融合模式的核心是减去max(X):

# loss.py class SoftmaxCrossEntropy: def forward(self, X, t): # X: (N, C), t: (N,) 真实类别索引 N = X.shape[0] # 减去每行最大值防止 exp 溢出 X_shifted = X - np.max(X, axis=1, keepdims=True) exp_X = np.exp(X_shifted) self.Y = exp_X / np.sum(exp_X, axis=1, keepdims=True) # softmax # 交叉熵:-log(Y[t]) = -X[t] + log(sum(exp(X))) self.t = t self.loss = -X_shifted[np.arange(N), t] + np.log(np.sum(exp_X, axis=1)) return np.mean(self.loss) def backward(self, _): N, C = self.Y.shape dX = self.Y.copy() dX[np.arange(N), self.t] -= 1 return dX / N # 除以 N 因为 forward 返回的是 mean loss

backward()中dX = Y - one_hot(t)是 Softmax-CrossEntropy 的经典梯度公式。这里dX是(N,C),每一行表示该样本对每个 logit 的梯度。dX[i,c] = Y[i,c] - (1 if c==t[i] else 0),即:真实类别的梯度为Y[i,t]-1(负方向),其他类别为Y[i,c](正方向)。这比先算dSoftmax再乘dCE更高效、更稳定。

4.3 避坑:激活与损失层陷阱

  • 现象:Sigmoid 层输出全为1.0,后续层梯度为0
    原因:输入X全为大正数(如X>10),exp(-X)下溢为0,1/(1+0)=1,Y=1导致Y*(1-Y)=0。
    解决:在forward()中加入X = np.clip(X, -8.5, 8.5),或改用Tanh(输出范围[-1,1],梯度饱和点更远)

  • 现象:Softmax 输出Y中某列为nan
    原因:X中存在inf或nan,exp(inf)=inf,inf/inf=nan。
    解决:在forward()开头加assert np.isfinite(X).all(),或用np.nan_to_num(X, nan=0.0, posinf=8.5, neginf=-8.5)

  • 现象:backward()返回的dX形状为(N,)而非(N,C)
    原因:CrossEntropy.backward()中误将dX定义为np.zeros(N),忘记扩展为(N,C)。
    解决:dX = np.zeros_like(self.Y),然后dX[np.arange(N), self.t] = -1.0/self.Y[np.arange(N), self.t](分离模式)或直接用融合模式的Y - one_hot。


5. 全连接层与参数更新:为什么dW和db的维度必须严格匹配?

全连接层(Linear Layer)是 CNN 的“决策中枢”,它将空间特征图展平为向量,再通过矩阵乘法映射到类别空间。其参数更新看似简单,却是检验你是否真正理解张量维度流转的试金石。dW的形状必须是(output_dim, input_dim),db是(output_dim,),任何偏差都会导致梯度爆炸或静默失败。

5.1 Linear 层的维度契约:从X ∈ R^(N×D_in)到Y ∈ R^(N×D_out)

前向传播:Y = X @ W.T + b(W ∈ R^(D_out×D_in),b ∈ R^(D_out))
反向传播:

  • dY ∈ R^(N×D_out)
  • dW = (1/N) * X.T @ dY ∈ R^(D_in×D_out)→ 转置得R^(D_out×D_in)
  • db = (1/N) * np.sum(dY, axis=0) ∈ R^(D_out)
  • dX = dY @ W ∈ R^(N×D_in)

注意dW计算中X.T @ dY的顺序:X.T是(D_in×N),dY是(N×D_out),乘积为(D_in×D_out),再转置才是W的梯度。若误写为dY.T @ X,会得到(D_out×N) @ (N×D_in) = (D_out×D_in),看似形状对,但数学错误——它计算的是∂L/∂W = ∂L/∂Y @ ∂Y/∂W,而∂Y/∂W是(N×D_out×D_out×D_in)四阶张量,正确展开必须是X.T @ dY。

# linear.py class Linear: def __init__(self, in_features, out_features): # He 初始化 self.W = np.random.randn(out_features, in_features) * np.sqrt(2.0 / in_features) self.b = np.zeros(out_features) self.dW = np.zeros_like(self.W) self.db = np.zeros_like(self.b) self.X = None def forward(self, X): self.X = X # 保存输入用于反向 # X: (N, D_in), W: (D_out, D_in), b: (D_out,) # Y = X @ W.T + b → (N, D_out) self.Y = X @ self.W.T + self.b return self.Y def backward(self, dY): N = dY.shape[0] # dW = (1/N) * X.T @ dY → (D_in, D_out) → 转置为 (D_out, D_in) self.dW = (1/N) * (self.X.T @ dY).T # db = (1/N) * sum(dY, axis=0) → (D_out,) self.db = (1/N) * np.sum(dY, axis=0) # dX = dY @ W → (N, D_out) @ (D_out, D_in) = (N, D_in) dX = dY @ self.W return dX

self.dW = (1/N) * (self.X.T @ dY).T这一行是精髓:(self.X.T @ dY)得到(D_in, D_out),.T后为(D_out, D_in),与self.W形状一致。若省略.T,self.dW形状错,后续W -= lr * self.dW会触发广播错误或静默数据污染。

5.2 SGD 优化器:为什么学习率lr必须与dW的 scale 匹配?

dW的数值尺度由输入X和dY决定。若X均值为100,dY均值为0.1,则dW均值约为10,此时lr=0.01会导致W每步更新0.1,可能震荡;若X已归一化到[0,1],dY为0.5,dW均值0.5,lr=0.01则更新过慢。手写实现让你直面这个 scaling 问题。

本项目中,SGD 优化器极其简洁:

# optimizer.py class SGD: def __init__(self, params, lr=0.01): self.params = params # list of (W, dW) or (b, db) tuples self.lr = lr def step(self): for param, grad in self.params: param -= self.lr * grad

关键在params的构建方式:optimizer = SGD([(conv1.W, conv1.dW), (conv1.b, conv1.db), ...], lr=0.01)。grad是dW或db,已含1/N归一化(见Linear.backward()),故lr可直接使用。若你发现训练震荡,第一反应不应是调lr,而是检查dW是否漏除N——这是手写实现最常踩的坑。

5.3 避坑:全连接与优化器陷阱

  • 现象:W更新后迅速变为inf,loss 爆炸
    原因:Linear.backward()中dW未除以N,导致梯度放大N倍(mini-batch size),lr=0.01实际等效于lr=1.0。
    解决:确认dW计算中self.dW = (1/N) * (self.X.T @ dY).T,并在step()前打印np.max(np.abs(self.dW))验证量级(应在1e-3 ~ 1e-1)

  • 现象:b更新缓慢,bias 几乎不变
    原因:db计算中np.sum(dY, axis=0)未除以N,但dY是(N,D_out),sum后为(D_out,),若N=32,db被放大 32 倍,lr需相应缩小。
    解决:self.db = (1/N) * np.sum(dY, axis=0),保持与dW一致

  • 现象:dX形状为(D_in,)而非(N,D_in)
    原因:Linear.backward()中dX = dY @ self.W,但dY是(N,D_out),self.W是(D_out,D_in),乘积应为(N,D_in)。若dY被错误 reshape 为(D_out,),则dX为(D_in,)。
    解决:在backward()开头加assert dY.ndim == 2 and dY.shape[1] == self.W.shape[0]


6. 设计取舍:为什么放弃 GPU 加速,却换来对内存布局的绝对掌控?

我曾在一个嵌入式视觉项目中,需要将 CNN 移植到 ARM Cortex-A7 上,内存只有 256MB,且无 CUDA 支持。框架模型导出 ONNX 后,用 TVM 编译,却发现im2col生成的临时张量占满内存,col2im重建时频繁 malloc/free 导致卡顿。那时我才真正读懂这份手写 CNN 的价值:它不追求速度,而追求“可审计性”。每一个np.pad的mode、每一个as_strided的strides、每一个reshape的-1,都暴露在阳光下。你可以用sys.getsizeof(X_col)精确测量中间张量内存占用,用X_col.data.ptr查看内存地址连续性,甚至用mmap将X_col映射到共享内存供多进程读取——这些在框架黑盒中不可见的操作,在这里全是 open book。

这份实现刻意规避了所有“聪明”的优化:没有 cuDNN 的 Winograd 卷积,没有 TensorRT 的层融合,没有自动混合精度。它用最朴素的 NumPy,强迫你思考——当stride=2时,im2col的strides如何调整?当pad=1且kernel=3,X_padded的shape是(N,C,H+2,W+2),但col2im还原dX时,count矩阵为何必须是(N,C,H+2,W+2)而非(N,C,H,W)?这些问题的答案,不在文档里,而在你单步调试col2im的 17 行代码时,看到dX_padded[h:h+kh, w:w+kw] += dX_col_block那一刻的肌肉记忆。

从那以后,我每次写nn.Conv2d,都会先手算一遍H_out = (H_in + 2*pad - K) // S + 1;每次调F.max_pool2d,都会在脑中过一遍mask的布尔索引逻辑;每次看到loss.backward(),都会默念dL/dW = dL/dY @ dY/dW的链式路径。这份资源不是用来替代框架的,它是你的“反向传播解剖图谱”——当你在框架中迷失时,打开它,找到对应层的手写实现,对照着看那一行dW = ...,你就重新找回了对梯度流向的直觉。

希望帮到你。

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

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

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

立即咨询