在哥伦比亚大学的“计算机视觉第一原理”课程体系中,神经网络不是作为现成工具库直接出现的,而是被拆成一组可以逐行推演的数学问题。很多人学习计算机视觉时,第一步就接触卷积神经网络、目标检测框架或现成模型库,代码能跑通,却很难解释网络为什么有效、某个结果为什么异常。回到第一原理,本质上就是把视觉任务重新理解为“从像素到语义的映射学习”,把网络训练理解为“用梯度下降拟合一个大型参数化函数”。这样做的好处是:当模型在真实数据上表现不稳定时,可以按函数结构逐层排查,而不是盲调超参数。
这门课程名字里的“第一原理”有明确指向:从图像形成、几何、光照、传感器和人类视觉的基本机制出发,先理解经典方法,再过渡到神经网络。2021 年的课程内容中,神经网络部分承前启后。前几部分讲的是滤波、特征提取、立体视觉、光流等手工设计特征的方法,到神经网络环节,重点变成“让特征从数据中自动学出来”。这篇文章围绕这门课的学习主线,从单个神经元模型开始,梳理多层网络、反向传播、卷积结构,最后落到视觉任务的训练流程和排错方法。
1. 为什么计算机视觉要先理解神经网络的第一原理
1.1 从特征工程到特征学习的思路转变
传统计算机视觉流程通常分为四步:图像预处理、特征提取、特征编码、分类器训练。预处理解决噪声、尺度、光照不一致;特征提取依赖人设计,例如 SIFT 提取尺度不变关键点,HOG 提取局部梯度方向统计;分类器使用 SVM 或随机森林把特征映射到类别。
这个流程的主要瓶颈是特征设计成本高。边缘检测需要设计卷积核,纹理描述需要挑选统计量,不同数据集往往需要重新设计特征。神经网络把“特征提取”和“分类器”统一成一个可微分的函数,卷积核不再人为确定,而是通过训练数据自动更新。换句话说,传统方法先让人决定“看什么”,再让算法“怎么判断”;神经网络把两层问题合并,直接学习“看什么并判断”。
这不是说传统方法没有价值。在数据量小、算力有限、可解释性要求高的场景里,手工特征仍然稳定。课程安排先讲经典方法,再进入神经网络,目的就是让你理解深度模型到底在哪个环节改进了经典流程。
1.2 神经网络是用多层函数逼近图像语义映射
从数学上看,神经网络是一个参数化函数。输入是图像像素向量或图像张量,输出是类别概率、物体坐标或分割掩码。网络内部由多个变换层组成,每一层先做线性变换,再经过一个非线性激活函数。多层堆叠之后,整体函数可以表示非常复杂的输入输出关系。
图像输入有一个很突出的特点:维度高且有空间结构。一张 224x224 的彩色图像展平成向量后有 150528 维,如果直接用全连接层,第一层参数规模就可能达到千万甚至亿级。更重要的是,图像里的对象具有平移不变性:一只猫移动几个像素,应该还是猫。全连接层把每个像素位置当成独立维度,很难利用这种空间结构。卷积神经网络通过局部连接和权重共享解决这两个问题,这让 CNN 成为视觉任务中最基础的网络结构。
1.3 课程中神经网络部分的常见位置与学习方法
在这类课程里,神经网络章节通常安排在几何视觉方法之后、复杂视觉任务之前。学习时建议按三层结构推进:
- 第一层:理解神经元、多层感知机、激活函数、损失函数。
- 第二层:理解反向传播和梯度下降,能手动推导一个简单网络。
- 第三层:理解卷积、池化、批量归一化的动机,然后在图像数据集上跑通训练。
不建议一上来就调用预训练模型做迁移学习。预训练模型能快速出效果,但遇到训练脚本报错、loss 异常、精度上不去时,如果缺失前面三层基础,排查会非常被动。
2. 从感知机到多层神经网络:前向传播的数学结构
2.1 一个神经元就是“加权求和加非线性”
神经网络的最小单元可以理解为一个“带偏置的线性加权和再经过非线性激活”。给定输入向量 (x),权重向量 (w),偏置 (b),神经元先计算加权和:
[ z = \sum_{i=1}^{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, activation=relu): 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, target=0) 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(axis=0, keepdims=True) da1 = dz2 @ W2.T dz1 = da1 * (z1 > 0) # ReLU 的导数 dW1 = X.T @ dz1 db1 = dz1.sum(axis=0, keepdims=True) 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, stride=1, pad=0): 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, mode='constant') output = np.zeros((out_h, out_w)) for i in range(out_h): for j in range(out_w): region = padded[i*stride:i*stride+K, j*stride:j*stride+K] 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, stride=1, pad=0) print(out)这个代码演示了垂直边缘检测。卷积核三列分别是 1、0、-1,当图像从左到右有明显亮度变化时,输出值会偏大。传统视觉中这个核是人工设计的,神经网络中卷积核元素由训练数据决定。
4.2 一个最小 CNN 示例与各层输出尺寸
以 28x28 灰度图像为例,构造一个简单 CNN:
import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 16, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), 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_classes=10) x = torch.randn(1, 1, 28, 28) y = model(x) print(y.shape) # torch.Size([1, 10])特征图尺寸变化:
- 输入:1x28x28
- 第一次卷积:16x28x28,padding=1 保持尺寸不变
- 第一次池化:16x14x14
- 第二次卷积:32x14x14
- 第二次池化:32x7x7
- 展平后进入全连接层:3277=1568
这里要特别关注最后一个池化层输出与全连接层输入维度的对应关系。如果修改了输入图像尺寸,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(axis=0).reshape(1, 1, 1, 3) std = X_train.reshape(X_train.shape[0], -1).std(axis=0).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_size=64, shuffle=True) model = SimpleCNN(num_classes=10) optimizer = torch.optim.Adam(model.parameters(), lr=1e-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(f"epoch {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 输入、padding=1、stride=1 时的输出尺寸。
- 能否解释训练集和验证集归一化参数必须分开计算的原因。
- 能否用训练曲线判断模型是否过拟合。
- 能否在 10 分钟内完成一个最小 CNN 的训练和验证。
这七条基本覆盖了神经网络的数学结构、工程实现和验证方法。把这些内容消化好,再进入目标检测、图像分割、视频理解或图神经网络时,就能把注意力放在任务特性上,而不必反复被网络基础问题打断。
从感知机到多层网络,从全连接到卷积,从反向传播到训练排错,这一整条链路正是计算机视觉第一原理课程希望建立的能力:先理解模型为什么这样设计,再动手实现,最后用实验结论修正判断。视觉领域的新模型还在不断出现,但“输入结构、网络结构、任务目标”三者如何匹配,这个方法不会变。