在哥伦比亚大学的“计算机视觉第一原理”课程体系中神经网络不是作为现成工具库直接出现的而是被拆成一组可以逐行推演的数学问题。很多人学习计算机视觉时第一步就接触卷积神经网络、目标检测框架或现成模型库代码能跑通却很难解释网络为什么有效、某个结果为什么异常。回到第一原理本质上就是把视觉任务重新理解为“从像素到语义的映射学习”把网络训练理解为“用梯度下降拟合一个大型参数化函数”。这样做的好处是当模型在真实数据上表现不稳定时可以按函数结构逐层排查而不是盲调超参数。这门课程名字里的“第一原理”有明确指向从图像形成、几何、光照、传感器和人类视觉的基本机制出发先理解经典方法再过渡到神经网络。2021 年的课程内容中神经网络部分承前启后。前几部分讲的是滤波、特征提取、立体视觉、光流等手工设计特征的方法到神经网络环节重点变成“让特征从数据中自动学出来”。这篇文章围绕这门课的学习主线从单个神经元模型开始梳理多层网络、反向传播、卷积结构最后落到视觉任务的训练流程和排错方法。1. 为什么计算机视觉要先理解神经网络的第一原理1.1 从特征工程到特征学习的思路转变传统计算机视觉流程通常分为四步图像预处理、特征提取、特征编码、分类器训练。预处理解决噪声、尺度、光照不一致特征提取依赖人设计例如 SIFT 提取尺度不变关键点HOG 提取局部梯度方向统计分类器使用 SVM 或随机森林把特征映射到类别。这个流程的主要瓶颈是特征设计成本高。边缘检测需要设计卷积核纹理描述需要挑选统计量不同数据集往往需要重新设计特征。神经网络把“特征提取”和“分类器”统一成一个可微分的函数卷积核不再人为确定而是通过训练数据自动更新。换句话说传统方法先让人决定“看什么”再让算法“怎么判断”神经网络把两层问题合并直接学习“看什么并判断”。这不是说传统方法没有价值。在数据量小、算力有限、可解释性要求高的场景里手工特征仍然稳定。课程安排先讲经典方法再进入神经网络目的就是让你理解深度模型到底在哪个环节改进了经典流程。1.2 神经网络是用多层函数逼近图像语义映射从数学上看神经网络是一个参数化函数。输入是图像像素向量或图像张量输出是类别概率、物体坐标或分割掩码。网络内部由多个变换层组成每一层先做线性变换再经过一个非线性激活函数。多层堆叠之后整体函数可以表示非常复杂的输入输出关系。图像输入有一个很突出的特点维度高且有空间结构。一张 224x224 的彩色图像展平成向量后有 150528 维如果直接用全连接层第一层参数规模就可能达到千万甚至亿级。更重要的是图像里的对象具有平移不变性一只猫移动几个像素应该还是猫。全连接层把每个像素位置当成独立维度很难利用这种空间结构。卷积神经网络通过局部连接和权重共享解决这两个问题这让 CNN 成为视觉任务中最基础的网络结构。1.3 课程中神经网络部分的常见位置与学习方法在这类课程里神经网络章节通常安排在几何视觉方法之后、复杂视觉任务之前。学习时建议按三层结构推进第一层理解神经元、多层感知机、激活函数、损失函数。第二层理解反向传播和梯度下降能手动推导一个简单网络。第三层理解卷积、池化、批量归一化的动机然后在图像数据集上跑通训练。不建议一上来就调用预训练模型做迁移学习。预训练模型能快速出效果但遇到训练脚本报错、loss 异常、精度上不去时如果缺失前面三层基础排查会非常被动。2. 从感知机到多层神经网络前向传播的数学结构2.1 一个神经元就是“加权求和加非线性”神经网络的最小单元可以理解为一个“带偏置的线性加权和再经过非线性激活”。给定输入向量 (x)权重向量 (w)偏置 (b)神经元先计算加权和[ z \sum_{i1}^{n} w_i x_i b ]然后经过激活函数[ a \phi(z) ]这里 (b) 的作用是平移决策边界。热点问题“神经网络中 biases 是什么”问的就是这个偏置项。没有偏置时线性变换必须经过原点表达能力受限加上偏置后神经元可以表示任意位置的超平面划分。下面用一个最小的 Python 示例表示一个神经元的前向计算import numpy as np def relu(z): return np.maximum(0.0, z) def neuron(x, w, b, activationrelu): z np.dot(w, x) b a activation(z) return a, z x np.array([0.5, -1.0, 2.0]) w np.array([0.2, 0.8, -0.5]) b 0.1 a, z neuron(x, w, b, relu) print(加权和 z:, z) print(激活输出 a:, a)运行结果是加权和 z: -0.7 激活输出 a: 0.0ReLU 把负数置为 0输出为 0 时这个神经元在该输入上没有响应。这种“稀疏激活”让网络能自动决定哪些神经元参与当前样本的特征表达。2.2 多层堆叠为什么能拟合复杂函数单个神经元只能划分线性边界。视觉分类中“猫”和“狗”的像素分布通常不是线性可分的。解决方案是把多个神经元组织成层再层层堆叠。一个多层感知机MLP通常包含输入层、若干隐藏层、输出层。每一层的输出是上一层的非线性变换。用 (a^{(l)}) 表示第 (l) 层的输出[ a^{(l)} \phi(W^{(l)} a^{(l-1)} b^{(l)}) ]第一层学习到的是边缘、角点等局部模式第二层可以把边缘组合成纹理或局部部件再往后可以组合成更抽象的语义。这就是“从像素到语义”的层级化表达。通用近似定理说明在足够宽度的条件下多层网络可以逼近很多连续函数但实际训练中还要考虑参数量、数据量和优化难度。2.3 图像输入如何表示成张量以及全连接层的参数膨胀问题图像在程序里通常用多维数组表示。灰度图是二维矩阵形状为 ( (H, W) )彩色图是三通道形状为 ( (H, W, 3) )一批图像是四维形状为 ( (Batch, H, W, C) )。import numpy as np img np.random.rand(32, 32, 3) # 模拟一张 32x32 彩色图像 flat img.reshape(1, -1) # 展平成向量用于全连接输入 print(flat.shape) # 输出 (1, 3072)如果把 3072 维输入直接接到一个 1024 维的隐藏层这一层权重就是 (3072 \times 1024 \approx 314) 万个。如果把 224x224x3 的图像展平同样的隐藏层参数量会超过 1.5 亿。所以视觉模型不能简单堆全连接层这也是下一部分卷积结构出现的原因。学习这一步时要记住展平操作会丢失空间位置关系而卷积操作可以保留空间结构。3. 损失函数与反向传播神经网络学习的核心引擎3.1 损失函数衡量“错多少”并且必须可微训练神经网络要回答一个问题当前参数有多差。损失函数把模型输出的预测值和真实标签映射成一个标量数字数字越小代表越接近目标。常见损失函数包括均方误差MSE和交叉熵Cross Entropy。损失函数公式形式适用任务说明MSE(L \frac{1}{N}\sum (y_i - \hat{y}_i)^2)回归任务对大误差惩罚较大但分类任务中梯度减弱较慢二分类交叉熵(L -\sum [y_i \log p_i (1-y_i)\log(1-p_i)])二分类配合 Sigmoid 输出多分类交叉熵(L -\sum y_i \log p_i)多分类配合 Softmax 输出课程中强调一个关键点不能用准确率作为训练目标因为准确率是离散的无法求导。交叉熵来自最大似然估计它在 Softmax 输出下梯度形式简洁训练更稳定。import numpy as np def softmax(logits): # 减去最大值防止 exp 溢出 exp_logits np.exp(logits - np.max(logits)) return exp_logits / np.sum(exp_logits) def cross_entropy_loss(probs, target): return -np.log(probs[target] 1e-12) logits np.array([2.0, 1.0, 0.1]) probs softmax(logits) loss cross_entropy_loss(probs, target0) print(Softmax 输出:, probs) print(交叉熵损失:, loss)这里probs[target]是真实类别的预测概率。真实类别的概率越接近 1损失越接近 0。3.2 反向传播链式法则的工程化应用要更新参数需要计算损失对每个参数的梯度。反向传播的核心是链式法则从输出层开始逐层向前求偏导把后层的梯度复用到前层。以两层网络为例import numpy as np def relu(z): return np.maximum(0, z) # 模拟一个批次的一个样本 X np.random.randn(1, 4) y np.array([0]) W1 np.random.randn(4, 8) * 0.1 b1 np.zeros((1, 8)) W2 np.random.randn(8, 3) * 0.1 b2 np.zeros((1, 3)) # 前向 z1 X W1 b1 a1 relu(z1) z2 a1 W2 b2 probs softmax(z2) loss cross_entropy_loss(probs, y[0]) # 反向 dz2 probs dz2[0, y[0]] - 1 # Softmax CrossEntropy 的导数 dW2 a1.T dz2 db2 dz2.sum(axis0, keepdimsTrue) da1 dz2 W2.T dz1 da1 * (z1 0) # ReLU 的导数 dW1 X.T dz1 db1 dz1.sum(axis0, keepdimsTrue) print(loss:, loss) print(dW1 shape:, dW1.shape) print(dW2 shape:, dW2.shape)这个手写梯度让你直观看到反向传播不是“魔法”而是把输出误差逐层分配到每个参数上。实际框架中会使用自动微分不需要手写每层梯度但理解这个过程对排查梯度消失、梯度爆炸非常重要。3.3 优化器与学习率如何影响训练梯度下降是最基本的参数更新方式[ \theta \leftarrow \theta - \eta \frac{\partial L}{\partial \theta} ]其中 (\eta) 是学习率。实际工程中常用带动量的 SGD、Adam 等优化器。Adam 会为每个参数维护一阶矩和二阶矩估计对学习率选择更宽容但并不意味着学习率可以无限放大。学习率现象处理建议过大损失震荡、不下降、可能出现 NaN调小学习率或使用学习率预热过小损失下降缓慢长时间不收敛适当调大或使用余弦退火合适损失稳定下降验证指标同步提升记录当前值作为后续实验基准初始化和优化器同样重要。如果权重全部初始化为同一个常数同一层神经元会学到相同梯度网络退化成一个宽神经元。常用方案是 Xavier 初始化或 Kaiming 初始化框架默认值通常已经适配常见激活函数但手动实现网络时要特别小心。4. 卷积神经网络视觉任务中最常用的神经网络结构4.1 卷积操作解决局部性与权重共享问题图像分类要求模型关注局部纹理、边缘、角点而不需要每个像素和所有其他像素都建立连接。卷积操作让每个输出位置只与输入的一个局部窗口连接。同一个卷积核在整张图上滑动权重被重复使用这就是权重共享。卷积输出尺寸公式[ out \left\lfloor \frac{H 2P - K}{S} \right\rfloor 1 ]其中 (H) 是输入边长(P) 是 padding(K) 是卷积核大小(S) 是步长。保持尺寸不变通常设置 (P (K-1)/2)步长为 1。import numpy as np def conv2d_simple(image, kernel, stride1, pad0): H, W image.shape K kernel.shape[0] out_h (H 2 * pad - K) // stride 1 out_w (W 2 * pad - K) // stride 1 padded np.pad(image, pad, modeconstant) output np.zeros((out_h, out_w)) for i in range(out_h): for j in range(out_w): region padded[i*stride:i*strideK, j*stride:j*strideK] output[i, j] np.sum(region * kernel) return output img np.array([ [1, 2, 0, 1, 0], [2, 3, 1, 0, 1], [0, 1, 2, 1, 1], [1, 0, 1, 2, 0], [0, 1, 1, 0, 2], ]) kernel np.array([ [1, 0, -1], [1, 0, -1], [1, 0, -1], ]) out conv2d_simple(img, kernel, stride1, pad0) print(out)这个代码演示了垂直边缘检测。卷积核三列分别是 1、0、-1当图像从左到右有明显亮度变化时输出值会偏大。传统视觉中这个核是人工设计的神经网络中卷积核元素由训练数据决定。4.2 一个最小 CNN 示例与各层输出尺寸以 28x28 灰度图像为例构造一个简单 CNNimport torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 16, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(32 * 7 * 7, 128), nn.ReLU(), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x)) model SimpleCNN(num_classes10) x torch.randn(1, 1, 28, 28) y model(x) print(y.shape) # torch.Size([1, 10])特征图尺寸变化输入1x28x28第一次卷积16x28x28padding1 保持尺寸不变第一次池化16x14x14第二次卷积32x14x14第二次池化32x7x7展平后进入全连接层32771568这里要特别关注最后一个池化层输出与全连接层输入维度的对应关系。如果修改了输入图像尺寸32 * 7 * 7这个数字也必须同步修改否则会报维度不匹配错误。4.3 从基础网络走向检测、分割等复杂视觉任务CNN 是很多视觉模型的基础模块但不同任务需要不同的输出结构任务类型典型输出常见网络结构说明图像分类类别概率向量CNN 全连接层全图输出一个标签目标检测边界框坐标 类别概率R-CNN 系列、YOLO对图像中多个区域做回归和分类语义分割每个像素的类别概率FCN、U-Net输出与输入分辨率接近的特征图实例分割每个实例的掩码 类别Mask R-CNN分割和检测结合在课程语境中这一讲的核心不是把每种网络细节都展开而是建立“输入结构决定网络结构”的判断能力。分类用全图级特征分割用像素级特征检测往往要同时保留位置和语义信息。理解了 CNN 的卷积核学习机制后续理解这些复杂模型会顺畅很多。5. 完整落地流程从数据准备到模型验证5.1 数据划分与预处理直接决定实验结果可信度训练神经网络前必须把数据划分为训练集、验证集和测试集。训练集用于更新参数验证集用于调超参数和选择模型测试集只在最终评估时使用。如果验证集参与了反复调参它已经泄漏到模型选择过程里最终指标会被高估。图像预处理中最容易出错的是归一化统计量的计算。正确做法是只用训练集的均值、标准差做标准化import numpy as np # 假设 X_train 形状为 (num_samples, 32, 32, 3) mean X_train.reshape(X_train.shape[0], -1).mean(axis0).reshape(1, 1, 1, 3) std X_train.reshape(X_train.shape[0], -1).std(axis0).reshape(1, 1, 1, 3) X_train_norm (X_train - mean) / (std 1e-8) X_val_norm (X_val - mean) / (std 1e-8)这里的关键是不要在归一化时把所有数据合在一起计算均值和标准差。这个动作会引入数据泄漏让验证结果失真。5.2 最小 PyTorch 训练脚本用一个简单 CNN 在自定义张量数据上训练可以观察 loss 是否下降import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset # 模拟数据 X_train torch.randn(2000, 1, 28, 28) y_train torch.randint(0, 10, (2000,)) dataset TensorDataset(X_train, y_train) loader DataLoader(dataset, batch_size64, shuffleTrue) model SimpleCNN(num_classes10) optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() for epoch in range(10): running_loss 0.0 for xb, yb in loader: optimizer.zero_grad() logits model(xb) loss criterion(logits, yb) loss.backward() optimizer.step() running_loss loss.item() print(fepoch {epoch}, loss {running_loss / len(loader):.4f})一段训练代码里最容易忽略的是optimizer.zero_grad()。如果不调用它梯度会在多次迭代里累积参数更新方向就会混乱。另一个常见问题是模型在训练前没有设置model.train()如果网络里有 Dropout 或 BatchNorm训练和推理行为会不同。5.3 验证指标、过拟合判断与模型保存训练过程中不只观察训练 loss还要定期在验证集上计算准确率。一个比较理想的趋势是训练 loss 和验证 loss 同时下降如果训练 loss 持续下降而验证 loss 回升说明过拟合已经开始。模型保存时建议同时保存权重和优化器状态torch.save({ model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), epoch: epoch, loss: loss, }, checkpoint.pth)恢复训练时用torch.load读取并重新赋值给模型和优化器。这样即使训练中断也能回到中断点继续跑。6. 常见问题排查与进一步扩展方向6.1 网络不收敛的排查链路损失不下降是最常见的训练问题排查顺序应从输入到输出逐步核对问题现象可能原因检查方式处理建议loss 完全不下降标签类别数不匹配检查标签 min/max 和模型输出维度统一类别数从 0 开始编号loss 出现 NaN学习率过大或数据含 NaN打印梯度范数、输入统计量调小学习率检查数据清洗验证准确率低但 loss 正常数据划分或预处理不一致比较训练集与验证集分布用同一套 mean/std 做归一化推理结果与训练不一致模型处于训练模式检查model.eval()是否调用推理前调用model.eval()并关闭梯度梯度消失权重初始化过小、层数过深打印各层梯度统计改用合适的初始化或残差连接这些现象在课程实验中都很常见。遇到时先打印 shape再打印数值不要直接换模型结构。大部分情况下问题出在数据处理和输入输出配置上而不是网络结构。6.2 三个高频配置坑第一个坑用错误激活函数配合错误损失函数多分类任务最后一层通常先输出 logits再在损失函数内部做 Softmax。如果手动在最后一层添加 Softmax再用 CrossEntropyLoss相当于做两次 Softmax训练初期梯度会明显异常。推荐直接让模型输出 logits损失函数用nn.CrossEntropyLoss()。第二个坑卷积输出维度与全连接输入维度不一致修改输入分辨率或网络层数后展平后的维度和全连接层输入不一致。例如输入从 28x28 改成 32x32经过两次池化后特征图尺寸从 7x7 变成 8x8全连接层维度就要同步调整。建议在网络定义时加一行测试代码打印中间特征图尺寸避免手动推算错误。第三个坑训练和验证阶段的数据增强不一致图像分类中常在训练阶段做随机翻转、裁剪验证阶段通常只做中心裁剪或不做增强。这个差异是合理的但要记住验证集必须使用与训练集相同的归一化参数。如果不一致模型在验证集上的表现会失真。6.3 不同神经网络的适用场景与视觉扩展从 MLP 和 CNN 出发可以根据任务输入结构扩展到更多神经网络类型网络类型输入偏好典型任务在视觉中的场景MLP固定大小向量特征融合、小规模分类对图像全局特征分类CNN图像网格分类、检测、分割基础视觉任务1D CNN一维序列信号处理、时间序列传感器信号与光谱分析RNN / LSTM序列时序建模、视频视频帧序列、动作识别图神经网络图结构关系推理、点云场景图建模、对象关系脉冲神经网络脉冲序列低功耗实时处理生物启发视觉研究方向液态神经网络时间连续状态动态系统控制机器人视觉、边缘控制对这些扩展方向学习材料里经常看到“图神经网络论文引用数据集分析”或“液态神经网络论文”等热点名词。它们虽然名称不同但都可以用第一原理分析输入是什么结构网络如何建模这个结构损失函数如何定义。基础越扎实进入这些方向时越不会被复杂的网络示意图带偏。6.4 可复用的学习检查清单在继续下一个模型之前建议用这份清单检查自己的基础是否完整能否手算单个神经元的前向输出和梯度。能否写出三层网络的反向传播推导过程。能否说明交叉熵损失为什么配合 Softmax 使用。能否在不运行代码的情况下给出一个 3x3 卷积在 32x32 输入、padding1、stride1 时的输出尺寸。能否解释训练集和验证集归一化参数必须分开计算的原因。能否用训练曲线判断模型是否过拟合。能否在 10 分钟内完成一个最小 CNN 的训练和验证。这七条基本覆盖了神经网络的数学结构、工程实现和验证方法。把这些内容消化好再进入目标检测、图像分割、视频理解或图神经网络时就能把注意力放在任务特性上而不必反复被网络基础问题打断。从感知机到多层网络从全连接到卷积从反向传播到训练排错这一整条链路正是计算机视觉第一原理课程希望建立的能力先理解模型为什么这样设计再动手实现最后用实验结论修正判断。视觉领域的新模型还在不断出现但“输入结构、网络结构、任务目标”三者如何匹配这个方法不会变。