多层感知机适合用来处理表格数据,但当数据维度提升,多层感知机会丢失数据的结构特征。例如,我们在进行图片处理时,需要满足两条性质:

  • 平移不变形。不管检测对象出现在图像中的哪个位置,神经网络的前面几层应该对相同的图像区域具有相似的反应。例如在手写体识别中,无论数字出现在图片的哪个角落,我们都应该能够准确识别它。
  • 神经网络的前面几层应该只探索输入图像中的局部区域,而不过度在意图像中相隔较远区域的关系。最终,可以聚合这些局部特征,以在整个图像级别进行预测。

很显然,多层感知机已经无法满足我们的需要。

卷积层

卷积层的主要作用是进行特征提取。它通过使用一组可学习的滤波器(或称卷积核、权重矩阵)对输入图像进行滑动窗口式的操作,卷积窗口从输入张量的左上角开始,从左到右、从上到下滑动。 当卷积窗口滑动到新一个位置时,包含在该窗口中的部分张量与卷积核张量进行按元素相乘,得到的张量再求和得到一个单一的标量值。然后将结果汇总形成一个特征图(或称为特征映射、激活图)。这个过程可以捕获图像中的局部空间关系和特征,比如边缘、角点、颜色纹理等。例如:

二维互相关运算

在上图中,0×0+1×1+3×2+4×3=19,依次类推计算出其余位置的值,组成输出矩阵。

可以观察到,只要卷积核大小大于1,输出的矩阵大小便会小于输入,为了避免矩阵不断变小,我们可以对矩阵边缘进行填充。除此之外,我们在移动卷积核时,每次不一定只能移动一格,我们将每次移动的长度称为步长。

  • 步长:控制滤波器在图像上移动的步幅。

  • 填充:在输入图像边缘添加额外的像素来控制输出特征图的大小和保持某些空间信息。

当我们使用一个二维卷积核对一张图片进行运算,我们可以理解为抽取出图片的某种特征,例如边缘、纹理等。很显然,我们处理一张图片需要分析它的多种特征。当我们需要实现多输入多输出时,便引入了通道的概念。

例如,当我们需要实现n个输入时,我们可以给每个输入分配一个x*y大小的卷积核,这n个卷积核并在一起,便形成一个n×x×y大小的三维卷积核。同理,当我们需要实现m个输出,便可创建一个m×n×x×y的卷积核。其中m称为输出通道数,n称为输入通道数。我们可以使用1×1大小的卷积核来改变通道数。

随着卷积层的堆叠加深,每一层能够捕获越来越复杂和抽象的特征,从底层的边缘、线条到高层的物体部件、整体形状乃至语义概念。

卷积层相较于全连接层的优势在于参数共享(每个滤波器在整个图像上重复使用)、稀疏连接(只考虑局部区域而不是全局连接),这大大减少了模型所需的参数数量,并且增强了模型对于平移不变性的学习能力。

池化层

池化层是CNN中的一个重要组成部分,它的作用可以被通俗理解为“降采样”或“特征抽样”。在图像处理的上下文中,池化层通过执行一种称为池化操作的过程,对上一层(通常是卷积层)输出的特征图进行下采样。

具体来说,池化层会在输入的特征图上滑动一个固定大小的窗口(比如2x2),并在每个窗口内执行某种聚合操作,最常见的是最大值池化和平均值池化:

  • 最大值池化:取窗口内所有数值的最大值作为该窗口的输出。这样做的好处是可以保留最重要的激活信息,忽略掉不那么显著的特征。

  • 平均值池化:取窗口内所有数值的平均值作为输出。这有助于保持背景信息或局部区域的整体灰度水平。

通过池化层的操作,特征图的尺寸会减小,同时保持住关键的、不变形的特征表达。这样做有几个主要的好处:

  • 减少计算量:降低后续层需要处理的数据量。

  • 控制过拟合:通过减少参数数量和引入某种程度的平移不变性来提高模型的泛化能力。

  • 增加鲁棒性:由于池化层对小范围内的变化不敏感,它可以帮助模型在一定程度上抵御微小的位置变化带来的影响。

ResNet

随着深度学习模型层数的增加,传统的深度神经网络往往会遇到梯度消失或梯度爆炸的问题,这使得训练极深的网络变得非常困难。

为了解决这个问题,残差网络引入了一种叫做“残差块”的设计。在残差块中,除了常规的前馈路径(即输入经过一系列卷积层和非线性激活函数),还引入了“跳跃连接”。跳跃连接直接将输入信号传递到较深的网络层,然后将这个原始输入与经过多层非线性变换后的输出相加。这样一来,网络可以更容易地学习残差映射,也就是从输入到输出之间的变化,而不是直接学习输入到输出的整体映射。

数学上,对于一个残差块,假设我们想要学习的映射是H(x),那么网络实际优化的是F(x) = H(x) - x,这样就简化为了学习一个残差F(x),而网络的输出则变为x + F(x)。通过这种设计,即使F(x)近似于零,信息也能顺利通过网络,避免了梯度消失问题。

由于残差网络的这一创新设计,模型可以有效地训练超过100层甚至更深的网络,而且不会出现明显的退化现象,反而随着网络深度的增加,准确率能够进一步提升,这让残差网络成为图像分类、物体检测、语义分割等多个计算机视觉任务中的标准模型之一。

残差神经网络

为了巩固阶段性学习成果,尝试应用MLP进行实践。本次使用MNIST数据集,通过一个使用MLP训练一个手写体数字识别模型。

获取数据

本次仍然采用MNIST数据集中的数据,数据获取方式与softmax一节中相似。不同之处在于,这里从训练集中划分了20%作为验证集,用于在最终测试前对模型进行调整。

1
2
3
4
5
6
7
8
#下载数据
transform = transforms.ToTensor()
train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform = transform)
test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform = transform)
#划分训练集和验证集
train_size = int(0.8*len(train_dataset))
val_size = len(train_dataset)-train_size
train_dataset,val_dataset=torch.utils.data.random_split(train_dataset,[train_size,val_size])

接着将训练集、验证集、测试集分别打乱并按64的批量存入迭代器中。

1
2
3
4
batch_size = 64
train_loader=DataLoader(train_dataset, batch_size, shuffle=True)
val_loader=DataLoader(val_dataset, batch_size, shuffle=True)
test_loader=DataLoader(test_dataset, batch_size, shuffle=True)

定义模型

由于数据集较简单,事实上很简单的模型就能取得较高的精确度。一开始仅仅使用三个全连接层并直接使用ReLU激活函数就能在验证集上达到97%的精确度。后续引入了批量归一化层,并尝试着加大深度、调整宽度,但最终也只能达到98%的精确度。一者因为精确度已经较高,二者因为MLP的局限,再做过多调整意义已经不大。以下是经过调整的模型代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(28*28,1024)
self.bn1 = nn.BatchNorm1d(1024)#批量归一化层
self.fc2 = nn.Linear(1024,512)
self.bn2 = nn.BatchNorm1d(512)
self.fc3 = nn.Linear(512,256)
self.bn3 = nn.BatchNorm1d(256)
self.fc4 = nn.Linear(256,10)
self.relu=nn.ReLU()
def forward(self,x):
x = x.view(x.shape[0],-1)#将x展平
x = self.relu(self.bn1(self.fc1(x)))
x = self.relu(self.bn2(self.fc2(x)))
x = self.relu(self.bn3(self.fc3(x)))
return self.fc4(x)

model = MLP()
#定义损失函数和优化器
criterion = nn.CrossEntropyLoss()#会自动softmax并计算交叉熵
optimzer = optim.Adam(model.parameters(),lr=0.001)#Adam是比sgd更加先进的优化器,对学习率较不敏感且收敛更快

训练模型

共进行10轮训练,每轮训练完成后使用验证集对模型进行检验,看是否过拟合,从而对超参数进行调整。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
num_epoches = 10#训练轮数
epoch_losses = []#用于记录各轮训练损失,供可视化使用
val_losses = []#记录各轮验证损失
for epoch in range(num_epoches):
model.train()#声明训练模式
epoch_loss=0.0#该轮训练损失
for features, labels in train_loader:
optimzer.zero_grad()
outputs = model(features)
loss = criterion(outputs,labels)
loss.backward()
optimzer.step()
epoch_loss += loss.item()
avg_epoch_loss = epoch_loss/len(train_loader)#平均损失
epoch_losses.append(avg_epoch_loss)
#验证模型
model.eval()#切换评估模型
val_loss=0.0
with torch.no_grad():#不更新梯度
for features,labels in val_loader:
outputs = model(features)
val_loss += criterion(outputs,labels).item()
avg_val_loss=val_loss/len(val_loader)
val_losses.append(avg_val_loss)

将各轮损失进行可视化:

损失变化

可以看到第四轮开始就出现过拟合现象了,可以根据损失的变化调整训练轮数。由于模型拟合已较好,经过调整实际变化不大。

测试

确定模型后,就可以使用测试集对模型效果进行测试。调整后的模型在测试集上精确度达到了98.08%。

1
2
3
4
5
6
7
8
9
10
11
model.eval()
with torch.no_grad():
correct = 0
total = 0
for features, labels in test_loader:
outputs = model(features)
_,predicted = torch.max(outputs.data,1)#1表示从每行中选最大的,即预测值
total += labels.size(0)#labels.size(0)返回张量labels第一个维度的大小。通常,这用于获取张量第一个轴上的样本数或元素数。
correct += (predicted==labels).sum().item()#预测正确的数量
accuracy=correct/total#精确度
print(f"{accuracy*100:.2f}%")

我们还可以输出几个样本来观察预测是否正确。

部分预测结果

数据集

常见数据集

MNIST:手写数字

ImageNet:百万级别的图片,主要来自google等图片搜索引擎(先用关键字搜索图片,再人工标注去除噪音)

AudioSet:来自YouTube的声音切片

Kinetics:来自YouTube的视频切片

KITTI:无人驾驶数据集

Amazon Review:Amazon产品评论

SQuAD:Wikipedia收集的知识点做成问答对

LibriSpeech:有声读物数据集(语音-文字)

数据集搜集渠道

paperswithcodes:整理了大量论文常见的数据集,并能看到在数据集上的榜单。学术数据集更加干净,但数量较少且往往规模较小,很难找到实际需要的。

kaggle:竞赛数据集及用户提交的数据集。竞赛数据集更加贴近实际应用,但数量较少且主要集中在热点领域。

tensorflow:带有数百个数据集

huggingface:大量文本数据集

AWS:超大规模原始数据。原始数据集十分灵活,但需要大量的工作去做预处理。

数据融合

把不同来源的数据融合成一个数据集(table join)。

按keys融合数据时,可能出现两张表keys不完全一致的的情况。

  • inner join:选择两张表都有的keys

  • left join:保留第一张表的所有keys,第二张表没有的keys对应项设为空值

数据生成

当数据不够时,可以使用各种方法生成样本,例如:

  • 使用GANS生成图片
  • 数据增强:对原始图片做处理(例如拉伸、旋转等)形成新样本
  • 对文本进行back translation(中-英-中)

将数据转化为特定长度的向量,以便SVM处理。机器学习中,抽取特征的方式由人工定义。

表格数据

  • int/float:①直接使用②转化为n个数,分别代表分布在n个区间的数字个数(类似直方图)
  • 类别:独热编码,稀有的类别可以统一分为unknown
  • 时间:可以抽取出一系列特征,例如:[year, month, day, day_of_year, week_of_year, day_of_week]
  • 特征组合:一个m分类和一个n分类可以组合成一个m*n分类

文本数据

  • Bag of words (BoW) model:将每个词元转化为独特编码,并将所有独特编码相加
  • Word Embeddings(如Word2vec)
  • 预训练语言模型(如BERT)

图片/视频数据

  • 传统方法:手动抽取特征,如SIFT
  • 使用预训练的深度神经网络

总结

目前文本、图片、视频等数据一般可以使用预训练的深度学习模型来抽取特征,但表格数据仍需使用较为传统的方法。

多层感知机概念

多层感知机为最简单的神经网络,由多层神经元构成,每一层的输出成为下一层的输入。

加入隐藏层

单层线性模型所能拟合的情况十分有限,比如无法解决xor问题。为了拟合更复杂的情况,可以将多个全连接层堆叠到一起,每一层的输出成为下一层的输入,直到生成最终的输出,最后一层为输出层,中间层为隐藏层,这种架构即为多层感知机

激活函数

两个线性层的堆叠形成的仍然是线性层,例如有以下多层感知机,其中X为输入,H为隐藏层变量,O为输出 消去H得到O和X的关系: 可以看到,O和X仍然存在线性关系,加入隐藏层也就失去了意义。因此,我们需要对每个隐藏层应用激活函数δ,使模型变为非线性: 常见的激活函数如下:

ReLU函数

ReLU函数实现简单且实际表现良好,因此得以广泛应用。 ReLU函数将相应的激活值设为0,仅保留正元素并丢弃所有负元素。当输入为负时,ReLU函数导数为0,输入为正时,导数为1。当输入为0时,ReLU不可导,我们可以直接默认导数为0,因为在实际中这并不会产生影响。

sigmoid函数

sigmoid函数将输入变换为区间(0,1)上的输出,因此也被成为挤压函数。 sigmoid是一个平滑、可微的函数,但是它具有以下缺点:

  • 由于涉及指数运算,需要消耗大量的资源;
  • sigmoid的导数值域为(0,0.25],因此在进行反向传播时经过连乘容易发生下溢,造成梯度消失。

在隐藏层中,sigmoid一般被更简单、更容易训练的ReLU所取代。

tanh函数

与sigmoid函数类似,tanh函数将输入压缩到区间(-1,1)上。

多层感知机的手动实现

下面使用Fashion-MNIST数据集尝试实现一个多层感知机,以便更好地理解其原理。Fashion-MNIST数据集中每张图像由28X28=784个灰度像素值组成,所有图像分为10个类别。因此忽略空间结构可以将每张图像视为具有784个输入特征和10个类别的简单分类数据集。本例将实现一个具有单隐藏层的多层感知机,其中包含256个隐藏单元。

1
2
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

初始化模型参数

在多层感知机中,每一层都需要一个权重矩阵和一个偏置向量。

1
2
3
4
5
6
7
8
9
10
num_inputs, num_outputs, num_hiddens = 784, 10, 256
#初始化隐藏层参数
W1 = nn.Parameter(torch.randn(
num_inputs, num_hiddens, requires_grad=True) * 0.01)
b1 = nn.Parameter(torch.zeros(num_hiddens, requires_grad=True))
#初始化输出层参数
W2 = nn.Parameter(torch.randn(
num_hiddens, num_outputs, requires_grad=True) * 0.01)
b2 = nn.Parameter(torch.zeros(num_outputs, requires_grad=True))
params = [W1, b1, W2, b2]

定义激活函数

采用最常使用的ReLU函数。

1
2
3
def relu(X):
a = torch.zeros_like(X)#生成一个shape与X相同的零矩阵
return torch.max(X,a)

定义模型

如前所述,我们将输入视为一个长度为784的向量,可以简单定义模型如下:

1
2
3
4
5
6
def net(X):
X = X.reshape(-1, num_inputs)#将X转化为num_inputs行矩阵,列数自动计算得到
H = relu(X @ W1 + b1)#隐藏层,@表示矩阵乘法
return(H @ W2 + b2)#输出层

loss = nn.CrossEntropyLoss()#交叉熵损失

训练

多层感知机的训练过程和softmax相同,这里直接调用d2l包中的train_ch3函数,不再手动实现。

1
2
3
num_epochs, lr = 10, 0.1
updater = torch.optim.SGD(params, lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, updater)

可以看到具有较好的拟合效果。

![epoch][epoch.png]

过拟合

机器学习的目的是发现泛化的模式,也就是模型捕获了总体规律,能适用于没有见过的个体。模型在训练数据上的拟合比在潜在分布的拟合更接近的现象称为过拟合,用于对抗过拟合的技术称为正则化

模型泛化

出自于训练数据的误差称为训练误差,将模型应用于新数据时的误差称为泛化误差,在实际中,使用测试集来估计泛化误差。(测试集指在确定了所有超参数之后使用的一次性测试数据,在实际应用中,为了在模型选择的过程中判断过拟合,引入验证集来对每轮实验进行测试。)

影响模型泛化的因素有:

  • 可调整参数的数量
  • 参数的取值范围
  • 训练样本的数量

这也是对抗过拟合的几个思路。

K折交叉验证

当训练数据稀缺时,可以采用K折交叉验证的方法:

  • 将原始数据划分为K个部分
  • 对于i = 1, 2, ..., K
  • 使用第i个部分作为验证集,其余部分用于训练
  • 报告K个部分在验证时的平均错误

权重衰减

通过收集更多训练数据可以缓解过拟合,但这并不总是容易做到,因此我们需要正则化技术来对抗过拟合。

权重衰减是使用最广泛的正则化技术之一,这项技术通过函数与零的距离来度量函数的复杂度。

一种简单的方法是通过权重向量的某个范数来度量复杂度。要保证权重向量较小,最常用的方法是将其范数作为惩罚项添加到最小化损失中: 这里加入w的L2范数作为惩罚,其中λ用于权衡w的范数带来的额外损失,当λ=0时恢复了原来的损失函数,λ/2是为了求导之后更加美观简单。

权重更新的递推式就变为: 可以理解为,在梯度下降前,先将权重在原方向上进行距离的衰减,这样就可以使权重的L2范数始终保持在一定范围内(拉格朗日乘数法)。

丢弃法

丢弃法又称暂退法,是一种通过在网络各层加入噪声提高函数平滑性,以对抗过拟合的方法。

通常以无偏的方式注入噪声,即: 在标准暂退法正则化中,每个中间激活值h以暂退概率p被随机变量h'替换: $$

h' =

$$ 期望值保持不变,即E[h']=h。

通俗地理解,即在前向传播的过程中以概率p丢弃一些神经元,并放大未被丢弃的输出来保持无偏。

丢弃法仅在训练期间使用。

数值稳定性

求梯度使用链式求导法则,需要大量的偏导数进行连乘。从而产生梯度爆炸梯度消失的问题。一个直观的例子:

梯度爆炸

假设现有一个MLP(为简单没有bias): ReLU、sigmoid等激活函数均为一元函数,故梯度为对角矩阵。

使用ReLU作为激活函数时,当x>0导数为1,当x<0导数为0。递推可得到其对W_i求导结果为W_i的部分元素和。若选中的是很大的元素,则会导致梯度很大,从而导致模型参数更新过大,破坏了模型的稳定收敛。

梯度消失

深度学习通常使用16位浮点数,多个小于1的偏导数连乘很容易发生数值下溢。例如sigmoid函数的导数上限为0.25,随着网络层数的增长梯度很容易就会消失。

梯度消失面临的问题是:梯度为0或几乎为0,导致参数更新过小,模型无法学习。

稳定性训练

权重初始化

使用适当范围内的随机值初始化权重

训练的开始容易受到数值不稳定的影响

  • 远离最优点的表面可能很复杂
  • 接近最优点的表面可能更平坦

批量归一化

损失发生在最后一层

  • 后面的层可以快速学习

数据插入在第一层

  • 底层的变化会向上传递
  • 上层需要经过重新学习
  • 导致收敛缓慢

因此要想办法避免在学习第一层时改变最后一层。

批量归一化步骤:

  • 固定小批量里面的均值和方差(引入噪声避免方差为0)

  • 然后再做额外的调整:

批量归一化首先进行标准化,使变量均值趋向于0、方差趋向于1。为保持无偏性,在变化中引入参数γ和β,其中γ用来控制方差,β用来控制均值,这两个参数通过学习得到。

通过挑战,分布变得更加规范,从而防止因分布过散而导致梯度爆炸和梯度消失。(批量归一化发生于激活函数前)

softmax回归

softmax回归用于分类问题,根据样本特征估计各类的概率,并选取概率最大的分类作为预测结果。

网络架构

softmax模型可表示为: 其中o为n维列向量,其元素分别与n种类别对应,其值代表对应类别的未规范化预测,值越大则概率越大。

W为n×m矩阵,表示每一种特征在各个类别中的权重。

x为m维列向量,表示特征。

b为n维列向量,表示偏置。

由于每个输出取决于所有输入,故softmax回归的输出层为全连接层

softmax运算

在上述模型中,o的值不一定满足概率的非负性和规范性,为了使输出能表示概率,我们需要对模型进行校准。

对输出做以下运算: 经过运算,得到的 既没有改变对应 的大小顺序,同时也满足概率的性质。因此有: 不会改变我们的预测结果。

损失函数

交叉熵常用来衡量两个概率的区别,我们用交叉熵损失作为softmax的损失函数: 在该式中,表示类别为j的真实概率,当且仅当真实类别为j时,为1,否则为0。因此,又有,其中y为真实类别。

softmax的实现

下面我们采用Fashion-MNIST数据集训练一个softmax模型,并使用模型对图像进行分类预测。

图像分类数据集

数据集获取

定义一个load_data_fashion_mnist函数来下载训练数据集和测试数据集,并将它们转化为tensor格式,装入小批量样本迭代器中。

1
2
3
4
5
6
7
8
9
def load_data_fashion_mnist(batch_size, resize=None):
trans = transforms.ToTensor()#输入的图片将转化为tensor格式
if resize:#调整图片尺寸
trans.insert(0, transforms.Resize(resize))
trans = transforms.Compose(trans)#将ToTensor和Resize(如有)封装在一起
mnist_train = torchvision.datasets.FashionMNIST(root="../data", train=True, transform=trans, download=True)#下载训练数据集
mnist_test = torchvision.datasets.FashionMNIST(root="../data", train=False, transform=trans, download=True)#下载测试数据集
return (data.DataLoader(mnist_train, batch_size, shuffle=True, num_workers=get_dataloader_workers())#用DataLoader迭代获取小批量样本

标签转换

在模型中,使用数字标签表示分类更便于运算和存储,而在可视化时,文本标签则更为直观。因此定义一个get_fashion_mnist_labels函数,用于将数字标签转换为文本标签。

1
2
3
def get_fashion_mnist_labels(labels):
text_labels = ['t-shirt', 'trouser', 'pullover', 'dress', 'coat', 'sandal', 'shirt', 'sneaker', 'bag', 'ankle boot']
return [text_labels[int(i)] for i in labels]

图像可视化

定义show_images函数来可视化图像,可更加直观观测预测情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def show_images(imgs, num_rows, num_cols, titles=None, scale=1.5):
figsize = (num_cols*scale, num_rows*scale)#图表大小
#创建包含rowsXcols个子图的图表,图表大小为figsize,axes是一个包含所有子图轴的数组
_, axes = d2l.plt.subplots(num_rows, num_cols, figsize=figsize)
axes = axes.flatten()#将二维数组转化为一维数组
#以子图轴为基准通过循环绘制数据(共循环num_cols*num_rows次),img的size为[28,28]
for i, (ax, img) in enumerate(zip(axes, imgs)):
if torch.is_tensor(img):
#如果图像数据为张量,转化为NumPy数组并显示
ax.imshow(img.numpy())
else:
#图像数据不是张量则直接显示
ax.imgshow(img)
#隐藏x和y轴标签
ax.axes.get_xaxis().set_visible(False)
ax.axes.get_yaxis().set_visible(False)
#如果提供了标题则设置子图标题
if titles:
ax.set_title(titles[i])
return axes

下面先通过迭代器获取18个样本来展示该函数的运行效果:

1
2
3
4
X, y = next(iter(data.DataLoader(mnist_train, batch_size=18)))
#图像排列为2行9列
show_images(X.reshape(18, 28, 28), 2, 9, titles=get_fashion_mnist_labels(y))
d2l.plt.show()

可得到如下图像列表:

图像展示

模型实现

初始化模型参数

将每个样本用固定长度的向量表示,数据集中的图片为28*28像素的图像,将之展平成为长度为784的向量(将每个像素位置看作一个特征)。

1
2
3
4
num_inputs = 784#图片尺寸为28*28,将之展平为一维长度为784
num_outputs = 10#共10个类别,故输出维度为10
w = torch.normal(0, 0.01, size=(num_inputs, num_outputs), requires_grad=True)
b = torch.zeros(num_outputs, requires_grad=True)

定义softmax模型

首先需要定义softmax操作,即式(1.2)的代码实现。

1
2
3
4
def softmax(o):#定义softmax操作
o_exp =torch.exp(o)
partition = o_exp.sum(1, keepdim=True)#o_exp按行求和变为列向量
return o_exp / partition#这里利用了广播机制

在上述代码中,对于任何随机输入,我们将所有元素转变为非负数,并且每行总和为1。

有了softmax函数,我们就可以定义模型了:

1
2
def net(X):#定义模型
return softmax(torch.matmul(X.reshape(-1, w.shape[0]), w)+b)#即softmax(Wx+b)

定义损失函数

如前所述,我们用交叉熵损失函数来评估softmax模型损失:

1
2
def cross_entropy(y_hat ,y):#定义交叉熵损失函数
return -torch.log(y_hat[range(len(y_hat)), y])

其中y是样本的真实类别,则是每个类别的概率,用y作为的索引即可找到真实类别的预测概率,再依次取log和相反数即可得到交叉熵损失。

分类精度

分类精度即预测的正确率。如果是一个多列矩阵的话,则从每列中选取元素最大的索引(即概率最大的类别)作为预测值,并将预测值与真实类别进行比较,相等即为预测正确。以下函数的返回值为预测正确的数量。

1
2
3
4
5
def accuracy(y_hat, y):#计算预测正确的数量
if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:#y为矩阵且列数>1
y_hat = y_hat.argmax(axis=1)
cmp = y_hat.type(y.dtype) == y#数据类型一致方可比较
return float(cmp.type(y.dtype).sum())

对于任意数据迭代器data_iter可访问的数据集,我们可评估在模型net上的精度:

1
2
3
4
5
6
7
def evaluate_accuracy(net, data_iter):
if isinstance(net, torch.nn.Module):#判断net是否为torch.nn.Module类的实例
net.eval()#切换为评估模式
metric = Accumulator(2)#metric[0]累积正确数,metric[1]累积总数
for X, y in data_iter:
metric.add(accuracy(net(X), y), y.numel())#累加
return metric[0]/metric[1]

这里用到了一个累加器Accumulator,同于对多个变量进行累加,具体定义如下:

1
2
3
4
5
6
7
8
9
class Accumulator:#累加器
def __init__(self, n):#初始化,创建一个包含n个元素,初始值为0.0的列表
self.data = [0.0] * n
def add(self, *args):#使列表中每个元素分别加上args中的对应值
self.data = [a+float(b) for a, b in zip(self.data, args)]
def reset(self):#重置
self.data = [0.0]*len(self.data)
def __getitem__(self, item):#索引
return self.data[item]

训练与预测

训练

首先,我们定义一个函数来进行一轮的训练,具体流程与线性回归相似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def train_epoch_ch3(net, train_iter, loss, updater):#训练模型一个迭代周期
if isinstance(net, torch.nn.Module):
net.train()#设置为训练模式
metric = Accumulator(3)#metric[0]记录损失,metric[1]记录正确数,metric[2]记录总数
for X, y in train_iter:
y_hat = net(X)
l = loss(y_hat, y)
if isinstance(updater, torch.optim.Optimizer):#使用pytorch内置的优化器和损失函数
updater.zero_grad()
l.backward()
updater.step()#做出一步优化,更新参数
metric.add(float(1)*len(y), accuracy(y_hat, y), y.size().numel())
else:#使用自定义的优化器和损失函数
l.sum().backward()
updater(X.shape[0])
metric.add(float(l.sum()), accuracy(y_hat,y), y.numel())
return metric[0]/metric[2], metric[1]/metric[2]#返回训练损失和训练精度

在以上代码中,updater是更新模型参数的常用函数,既可以是框架的内置优化函数,也可以是定制的优化器。我们这里采用小批量随机梯度下降进行优化,学习率设为0.1:

1
2
3
lr = 0.1#学习率
def updater(batch_size):#采用小批量随机梯度下降
return d2l.sgd([w,b], lr, batch_size)

接下来我们实现一个训练函数来实现多轮的训练。在每一轮训练中,我们都输出模型在测试集上的分类精度以及在训练集上的分类精度和损失来对模型进行评估。

1
2
3
4
5
6
7
def train_ch3(net, train_iter, test_iter, loss, num_epochs, updater):
for epoch in range(num_epochs):#迭代 num_epochs 次
train_metrics = train_epoch_ch3(net, train_iter, loss, updater)
test_acc = evaluate_accuracy(net, test_iter)#测试集精确度
print(f'epoch{epoch}_test:{test_acc}')
train_loss, train_acc = train_metrics # 训练损失和精确度
print(f'epoch{epoch}_train:loss={train_loss},acc={ train_acc}')

进行10轮训练:

1
2
num_epochs = 10
train_ch3(net, train_iter, test_iter, cross_entropy, num_epochs, updater)

得到输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
epoch0_test:0.7918
epoch0_train:loss=0.7882061706542969,acc=0.7462333333333333
epoch1_test:0.7963
epoch1_train:loss=0.5694080658594767,acc=0.8128833333333333
epoch2_test:0.8085
epoch2_train:loss=0.5254165397644043,acc=0.8261666666666667
epoch3_test:0.8157
epoch3_train:loss=0.5017157739003499,acc=0.8335
epoch4_test:0.8153
epoch4_train:loss=0.48570796445210773,acc=0.8378333333333333
epoch5_test:0.8239
epoch5_train:loss=0.47390937894185386,acc=0.8411833333333333
epoch6_test:0.8324
epoch6_train:loss=0.46491336631774904,acc=0.8436166666666667
epoch7_test:0.8273
epoch7_train:loss=0.4578305866241455,acc=0.8444666666666667
epoch8_test:0.8043
epoch8_train:loss=0.4522740385055542,acc=0.846
epoch9_test:0.8326
epoch9_train:loss=0.4478646834055583,acc=0.8478166666666667

可以看出随着轮次增加,分类精度总体呈上升趋势,损失呈下降趋势。

预测

训练完成后,我们使用模型对图像进行分类预测。给定几张图片,输出其实际标签(标题第一行)和模型预测(标题第二行)。

1
2
3
4
5
6
7
8
9
10
def predict_ch3(net, test_iter, n=6):
for X, y in test_iter:
break#获取一组样本
trues = get_fashion_mnist_labels(y)#正确标签
preds = get_fashion_mnist_labels(net(X).argmax(axis=1))#预测标签
titles = [true +'\n' + pred for true, pred in zip(trues, preds)]
d2l.show_images(
X[0:n].reshape((n, 28, 28)), 1, n, titles=titles[0:n], scale=4)
d2l.plt.show()
predict_ch3(net, test_iter)
预测结果

对于6张图像预测均正确。

总结

softmax回归的训练与线性回归十分相似,总体流程为:读取数据、定义模型和损失函数、使用优化算法训练模型。

借助softmax回归,我们可以训练多分类的模型,而线性回归则用于数量的预测。

今天开始学习李沐老师的《动手学深度学习》。李沐老师的课程是免费分享在b站上的(主页:跟李沐学AI),主要教学模式是概念讲解+实验,每节课讲完知识点都会带大家从零实现,对加深理解很有帮助。这系列笔记里的代码大部分都来自李沐等多位老师的《动手学深度学习(pytorch版)》,在看完老师的讲解后再自己手动实现一遍。

一、向量导数

(一)y为标量,x为向量

求梯度grad y。深度学习中,因变量一般为标量。

(二)y为向量,x为标量

分别对x求导,组成一个列向量。

(三)x, y均为向量

分别求梯度,组成矩阵。当x、y维度更高时同理类推。

二、自动求导

(一)计算图

计算图是有向无环图,类似于利用DAG表述表达式

(二)前向传播与反向传播

  • 前向传播是指输入数据通过网络逐层传递并计算预测输出的过程。
  • 反向传播则是用来更新网络参数(权重和偏置)以最小化损失函数的一种优化算法,通常用于梯度下降法中。

三、线性回归基础优化

线性回归方程为:

(一)训练数据

收集数据点并确定回归方程,可采用均方损失函数:

(二)梯度下降

  • 首先挑选初始值w0

  • 该递推式含义为沿损失函数的梯度方向更新参数值,其中η为学习率,由人为选择。通过该递推式迭代出w1、w2、w3。

(三)小批量随机梯度下降

在整个训练集上计算梯度代价过大,可以多次随机选取b个样本来近似损失。

四、线性回归的实现

以下采用小批量随机梯度下降训练一个线性回归模型。本例仅供学习使用,为了方便,数据集直接由人工生成。

(一)生成数据集

1
2
3
4
5
6
7
8
9
def synthetic_data(w, b, num): #生成y=wX+b+噪声(误差)
x = torch.normal(0, 1, (num, len(w))) #num个样本,len(w)个自变量,标准正态
y = torch.matmul(x, w)+b
y += torch.normal(0, 0.01, y.shape) #加入随机误差
return x, y.reshape((-1, 1)) #y变为列向量

true_w = torch.tensor([2,-3.4])
true_b = 4.2 #y=2*x1-3.4*x2+4.2
features, labels = synthetic_data(true_w, true_b, 1000) #得到训练样本

以上代码生成了线性回归模型 的一组样本(样本量n=1000)。通过训练,我们要使估计出的w和b尽可能接近其真实值。

(二)生成多组小批量样本

1
2
3
4
5
6
7
8
9
def data_iter(batch_size, features, labels):#随机抽取大小为batch_size的样本
num = len(features)
indices = list(range(num))#生成下标
random.shuffle(indices)#随机打乱下标
for i in range(0, num, batch_size):#for(int i=0;i<num;i+=batch_size),本例中共循环1000/10=10次
batch_indices = torch.tensor(
indices[i:min(i+batch_size, num)])
yield features[batch_indices], labels[batch_indices]#迭代生成100组样本
batch_size = 10

以上代码按批量大小为10将数据集进行随机划分

(三)定义初始化模型参数

1
2
w = torch.normal(0, 0.01, size=(2, 1), requires_grad=True) 
b = torch.zeros(1, requires_grad=True)

(四)定义模型和损失函数

1
2
3
4
def linreg(x, w, b):#线性回归模型
return torch.matmul(x, w) + b
def squared_loss(y_hat, y):#均方损失函数
return (y_hat-y.reshape(y_hat.shape))**2/2

(五)定义优化算法

1
2
3
4
5
def sgd(params, lr, batch_size):#小批量随机梯度下降
with torch.no_grad():#梯度于训练模块中计算
for param in params:#有w,b两个参数
param -= lr * param.grad / batch_size#参数迭代
param.grad.zero_()

(六)训练模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
lr = 0.03#学习率,可以自己调整,不能太大也不能太小
num_epochs = 3#对训练集进行3次扫描,多次重复可以降低误差
net = linreg
loss = squared_loss
for epoch in range(num_epochs):#对训练集进行3次扫描
for x, y in data_iter(batch_size, features, labels):
l = loss(net(x, w, b), y)#net为回归方程,估计出y_hat,得到损失函数
l.sum().backward()#l为向量,求和并反向传递
sgd([w, b], lr, batch_size)#使用参数的梯度迭代出新的参数
with torch.no_grad():
train_l = loss(net(features, w, b), labels)#用训练出的w,b计算损失
print(f'epoch {epoch+1}, loss {float(train_l.mean()):f}')
print(f'w的估计误差:{true_w-w.reshape(true_w.shape)}')
print(f'b的估计误差:{true_b-b}')

out:

1
2
3
4
5
epoch 1, loss 0.066226
epoch 2, loss 0.000361
epoch 3, loss 0.000052
w的估计误差:tensor([ 0.0014, -0.0013], grad_fn=<SubBackward0>)
b的估计误差:tensor([0.0016], grad_fn=<RsubBackward1>)

可以看出,每次扫描后参数估计值将更加接近真实值,最终训练得到的参数估计值和实际十分接近。

(七)总结

虽然线性回归模型在实际中很少应用,但也展示了较为完整的深度学习训练过程,作为入门有助于理解深度学习的方法论。出于学习需要,以上流程尽可能手动实现。在实际中,通过API调用可以大大减少代码量。

API:应用程序编程接口 - java api:JDK提供的各种功能的类,可以直接调用,如:Scanner; - API文档:可以查找API;

零、输入输出

输出:

1
System.out.println("Hello World"); //输入sout可以自动弹出
输入:
1
Scanner();
## 一、变量
1

## 二、运算符 +、-、*、/、%、++、--、<<、>>作用同C++; 关系运算符同C++; 三元运算符同C++; #### “+”运算符 字符串相加: “+”会将两个字符串拼接,其他类型和字符串相加也会直接拼接
1
System.out.println("123"+123); //输出123123
连续进行“+”操作时,从左到右执行:
1
System.out.println(1+2+"123"); //输出3123
字符相加:ASCII码相加 #### 逻辑运算符 与:& 或:| 异或:^ 非:! 短路运算符&&和||: - &和|会判断对前后语句的正误都进行判断; - 使用&&,当前面语句为FALSE,则不会对第二个语句进行判断,直接返回FALSE; #### 左移/右移运算符 <<、>>将将二进制数进行左移/右移
1
2
3
int a = 200
int b = a << 2; //b=800
int c = a >> 2; //c=50
## 三、隐式转换和强制转换 取值范围:byte<short<int<long<float<double #### 隐式转换 隐式转换:取值范围小的变成取值范围大的(自动类型提升);
1
2
int a = 10; 
double b = a; //b完成了隐式转换
byte、short、char在进行运算时都会自动转为int。 #### 强制转换 强制转换:可以将取值范围大的变成取值范围小的; 格式:目标数据类型 变量名 = (目标数据类型)被强转的数据;
1
2
double a = 300;
int b = (int)a; //强制转换
## 三、流程控制语句 #### 分支结构 if语句:
1
2
3
4
5
6
//if (关系表达式) {语句体;} else {语句体}
if (a > 2) {
a--;
} else{
a++;
}
switch语句:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
switch (a){
case 6:
System.out.println("星期六");
break;
case 7:
System.out.println("星期日");
break;
default: //以上情况都不是
System.out.println("上班");
break;
}
//case穿透:
int num = 6;
switch (num){
case 6:
System.out.println("星期六"); //没有break语句,输出星期六后继续往下执行
case 7:
System.out.println("星期日");//输出星期日
break;//跳出switch语句
default:
System.out.println("上班");
break;
}
//用->可以不用break:
switch (num){
case 6 -> System.out.println("星期六"); //输出星期六并跳出
case 7 -> System.out.println("星期日");
default -> System.out.println("上班");
}
#### 循环结构 基本同C++ ## 四、数组
1
2
3
4
5
6
int[] array = {11, 22, 33};//创建int数组并初始化,数组为引用数据类型(存储地址值),数组长度为3且不再变化;
System.out.println(array);//会输出数组的内存地址[I@xxxxxxxx,指向堆内存
System.out.println(array.length);//输出数组的长度3
//遍历数组:
for (int i = 0; i < array.length; i++) {} //输入array.fori可快速弹出
String[] arr = new String[50]; //创建长度50的字符串数组,默认初始值为null;
## 五、方法
1
2
3
public static int add(int a, int b){
return a+b;
}//返回两个参数相加的结果
Java方法也可以重载。 ## 六、面向对象 测试类:带main方法的类 Javabean类:用来描述一类事物,不写main方法
1
2
3
4
5
6
public class Person{
string m_name;
int m_age;//定义成员变量
public void print_name() { System.out.println(this.m_name);}//定义成员函数
}
Person p1 = new Person();//定义对象:类名 对象名 = new 类名();
一个java文件可以定义多个类,且只能一个是public修饰,public修饰的类名必须成为代码文件名,因此一个文件一般只定义一个class类。 封装:变量修饰为private,再通过public的方法调用; 成员变量与局部变量: - 就近原则:若没有声明,优先认为age是局部变量; - 可以使用this.age调用成员变量; 构造方法:与C++基本一致 - 利用ptg可以快速生成javabean类 ## 七、字符串 常用内容:String、StringBuilder、StringBuffer、Pattern、Matcher 字符串内容在创建后就不能改变,但可以改变字符串变量指向的字符串; #### String构造方法
1
2
3
4
5
6
7
8
9
10
11
12
//1.直接赋值
String s1 = "abc";
//2.使用new
String s2 = new String();
//3.传递一个字符串,根据传递的字符串内容再创建一个新的字符串对象
String s3 = new String(orginal:"abc");
//4.传递一个字符数组,根据字符数组内容再创建一个新的字符串对象
char[] chs = {'a','b','c'};
String s4 = new String(chs);
//5.传递一个字节数组,根据字节数组内容再创建一个新的字符串对象
byte[] bytes = {97,98,99};
String s5 = new String(bytes);
#### 常用方法 字符串比较:
1
2
3
4
String s1 = new String("abc");
String s2 = new String("abc");
boolean result1 = (s1==s2); //false,==比较的是地址
boolean result2 = s1.equals(s2); //true
StringBuilder:可以看作一个容器,创建之后里面的内容是可变的
1
2
3
4
5
StringBuilder sb = new StringBuilder("abc");//创建对象
sb.append(1);//添加元素,sb值为abc1
sb.reverse();//反转,sb值为1cba;
int len = sb.length();/获取长度,len值为3
String str = sb.toString();//变成字符串
StringJoiner
1
2
3
StringJoiner sj = new StringJoiner(",","[","]");//指定,为间隔符号,[]为开始结束符号
sj.add("aaa").add("bbb").add("ccc");//[aaa,bbb,ccc],add参数只能是字符串
String str = sj.toString;
## 八、集合ArrayList ArrayList类似vector,只能存放引用变量
1
2
3
4
5
6
7
8
ArrayList<String> list = new ArrayList<>();//创建字符串集合
list.add("abc");//添加
list.add("bbb");
list.remove("abc");//删除
String str = list.remove(0);//删除第一个数据,并将其赋值给str
String str1 = list.set(0, "ccc");//修改第一个数据为ccc并将修改前的数据返回
String.get(0);//返回第1个数据
int len = list.size();//返回长度

应用层是计算机网路体系结构的最顶层,是设计和建立计算机网络的最终目的,也是计算机网络中发展最快的部分。 客户/服务器方式(C/S方式) - 客户和服务器指通信中所涉及的两个应用进程; - 描述进程之间服务和被服务的关系; - 基于C/S方式的应用服务通常是服务集中型的,如WWW、电子邮件、FTP等; 对等方式(P2P方式) - 没有固定的服务请求者和服务提供者,对等方之间直接通信; - 基于P2P的应用是服务分散型的,如P2P文件共享、即时通信、P2P流媒体、分布式存储等; - P2P最突出的特性之一是可扩展性,系统性能不会因规模的增大而降低; ## 一、动态主机配置协议DHCO DHCP提供了即插即用连网的机制,允许一台计算机加入新网络时可自动获取IP地址等网络配置信息而不用手工参与。 工作过程 - DHCP客户广播发送DHCP发现报文(封装DHCP客户端MAC地址、事物ID等内容),源IP地址为0.0.0.0; - DHCP服务器根据DHCP发现报文内封装的DHCP客户端MAC地址查找数据库,若有针对该MAC地址的配置信息(IP地址、子网掩码、地址租期、默认网关、DNS服务器等)则用这些信息构建并广播发送DHCP提供报文,没有则用默认配置信息; - DHCP客户根据报文中的事务ID判断是否接收,若接收则广播发送DHCP请求报文(封装DHCP客户端MAC地址、事物ID、接受的租约中的IP地址、DHCP服务器端IP地址); - DHCP服务器广播发送DHCP确认报文; - 租用期过了一半后,客户向服务器发送DHCP请求报文,服务器发送反馈; DHCP 使用DHCP中继代理(给路由器配置DHCP服务器的IP地址)可以不用在每个网络上都设置一个DHCP服务器。 ## 二、域名系统DNS 域名系统DNS是因特网使用的命名系统,用来把便于人们记忆的具有特定含义的主机名转化为便于机器处理的IP地址。 因特网采用层次树状结构的域名结构:······.三级域名.二级域名.顶级域名。 - 顶级域名TLD分为国家顶级域名nTLD、通用顶级域名gTLD、反向域arpa; 域名和IP地址的映射关系必须保存在域名服务器中,供所有其他应用查询。DNS使用分布在各地的域名服务器来实现域名到IP地址的转换。 - 域名服务器可分为根域名服务器、顶级域名服务器、权限域名服务器、本地域名服务器; 域名解析过程查找方式:递归查询迭代查询域名解析过程 域名服务器中广泛使用了高速缓存,同来存放最近查询过的域名以及从何处获得域名映射信息的记录,以提高DNS查询效率。 - 域名服务器应为每项内容设置计时器并删除超过合理时间的项; - 用户主机中也很需要高速缓存; ## 三、文件传输协议FTP 文件传送协议FTP是因特网上使用得最广泛的文件传送协议。 - 提高交互式的访问,允许客户指明文件的类型、格式、存取权限; - 屏蔽了各计算机系统的细节,适合在各异构网络中任意计算机之间传送文件; 基本工作原理: - FTP客户和服务器之间要建立以下两个并行的TCP连接: - ①控制连接,整个会话期间一直打开,用于传送FTP相关控制命令; - ②数据连接,用于文件传输,每次文件传输时才建立,传输结束就关闭; - 默认情况下,FTP使用TCP21端口进行控制连接,TCP20端口进行数据连接。但是TCP20端口建立数据连接与传输模式有关,主动方式使用TCP20端口,被动方式由服务器和客户端自行协商决定; FTP ## 四、电子邮件 电子邮件采用C/S方式。三个组成构件:用户代理、邮件服务器,以及电子邮件所需要的协议。 - 用户代理是用户与电子邮件系统的接口,又称电子邮件客户端软件; - 邮件服务器是电子邮件系统的基础设施,因特网上所有的ISP都要邮件服务器,其功能是发送和接收邮件,同时还要负责维护用户的邮箱; - 协议包括邮件发送协议(如SMTP)和读取协议(如POP3); #### 邮件发送协议 常用的邮件发送协议是简单邮件传送协议SMTP; - 基于TCP连接,端口号25; - 只能传送ASCII码文本; - 用于用户代理向邮件服务器发送邮件以及邮件服务器之间的邮件发送; 为解决SMTP传送非ASCII码文本的问题,提出了多用途因特网邮件扩展MIME; #### 邮件读取协议 常用的邮件读取协议有邮局协议POP3因特网邮件访问协议IMAP; - 邮局协议POP3:非常简单、功能有限,不允许用户在邮件服务器上管理自己的邮件; - 因特网邮件访问协议IMAP:用户在自己的计算机上就可以操控邮件服务器的中的邮箱,IMAP是一个联机协议; - POP3和IMAP4都采用基于TCP连接的C/S方式,端口号分别为110和143; #### 基于万维网的电子邮件 通过浏览器登录邮件服务器万维网网站就可以撰写、收发、阅读和管理电子邮件,这种工作模式与IMAP很类似。 用户浏览器与邮件服务器网站之间使用HTTP协议,邮件服务器之间使用SMTP协议。 ## 五、万维网WWW 万维网是一个大规模的、联机式的信息储藏所,是运行在因特网上的一个分布式应用。 - 万维网利用网页之间的超链接 - 万维网使用统一资源定位符URL来指明因特网上任何种类“资源”的位置; #### 万维网文档 超文本标记语言HTML,使用多种标签来描述网页的结构和内容。 层叠样式表CSS,从审美角度来描述网页的样式。 脚本语言Javascript控制网页的行为。 #### 超文本传输协议HTTP 定义了浏览器(即万维网客户进程)怎样向万维网服务器请求万维网文档,以及万维网服务器怎样把万维网文档传送给浏览器。 - HTTP/1.0采用非连续连接方式,每次浏览器要请求一个文件都要与服务器建立TCP连接(80端口),收到响应后立即关闭连接; - HTTP/1.1采用持续连接方式,万维网服务器在发送响应后仍然保持连接,以便传送后续HTTP请求和响应报文。为了进一步提高效率,还可采用流水线方式,浏览器在收到响应报文前就可连续发送多个请求报文; HTTP有两类报文:请求报文响应报文 - 报文中的每个字段都是一些ASCII码串,并且每个字段的长度都是不确定的; #### Cookie和缓存机制 Cookie提高一种机制使得万维网服务器能够记住用户,而无需用户主动提供用户标识信息。Cookie是一种对无状态的HTTP进行状态化的技术。 Cookie

在万维网中还可以使用缓存机制以提高万维网的效率,万维网缓存又称为Web缓存,可位于客户机上,也可位于中间系统上(又称为代理服务器) - Web缓存把最近的一些请求和响应暂存在本地磁盘中,当新请求到达时,若发现其与暂存的请求相同,就返回暂存的响应,而不需要按URL地址再次去因特网访问该资源; - 原始服务器会为每个响应对象设定修改时间字段有效日期字段,代理服务器调用Web缓存时会询问原始服务器是否过期;

0%