动手学深度学习-09

敖炜 Lv5

softmax回归 + 损失函数 + 图片分类数据集

softmax回归

回归可以用于预测多少的问题。通常,机器学习实践者用分类这个词来描述两个有微妙差别的问题:
- 我们只对样本的“硬性”类别感兴趣,即属于哪个类别
- 我们希望得到“软性”类别,即得到属于每个类别的概率。这两者的界限往往很模糊。其中一个原因是:即使我们只关心硬类别,我们仍然使用软类别的模型

知乎上有一篇很好的文章:softmax

分类问题

  1. 一般的分类问题并不与类别之间的自然顺序有关。幸运的是,统计学家很早以前就发明了一种表示分类数据的简单方法:独热编码(ont-hot encoding)。独热编码是一个向量,它的分量和类别一样多。类别对应的分量设置为1,其他所有分量设置为0

网络架构

  1. 为了估计所有可能类别的条件概率,我们需要一个有多个输出的模型,每个类别对应一个输出。为了解决线性模型的分类问题,我们需要和输出一样多的仿射函数(affine function)。每个输出对应于它自己的仿射函数。
  2. 与线性回归一样,softmax回归也是一个单层神经网络,softmax回归的输出层也是全连接层

全连接层的参数开销

  1. 在深度学习中,全连接层无处不在。顾名思义,全连接层是“完全”连接的,可能有很多学习的参数。具体来说,对于任何具有d个输入和q个输出的全连接层,参数开销为,这个数字在实践中可能高得令人望而却步。幸运的是,将个输入转换为个输出的成本可以减少到,其中超参数可以由我们灵活指定,以在实际应用中平衡参数节约和模型有效性

softmax运算

  1. 现在我们将优化参数以最大化观测数据的概率。为了得到预测结果,我们将设置一个阈值,如选择具有最大概率的标签
  2. 我们无法将未规范化的预测直接作为我们感兴趣的输出,因为将线性层的输出直接视为概率时存在一些问题
    • 一方面,我们没有限制这些输出数字的总和为1
    • 另一方面,根据不同的输入,输出可能为负值,违反了概率的基本公理
  3. 要将输出视为概率,我们必须保证在任何数据上的输出都是非负的且总和为1。此外,我们需要一个训练的目标函数,来激励模型精准地估计概率,这个属性叫做校准(calibration)
  4. softmax函数能够将未规范化的预测变换为非负数并且总和为1,同时让模型保持可导的性质。我们首先对每个未规范化的预测求幂,这样可以确保输出非负。为了确保最终输出的概率值总和为1,我们再让每个求幂后的结果除以他们的总和。如下式
  5. softmax运算不会改变未规范化的预测之间的大小次序,只会确定分配给每个类别的概率。尽管softmax是一个非线性函数,但softmax回归的输出仍然由输入特征的仿射变换决定。因此,softmax回归是一个线性模型

小批量样本的矢量化

为了提高计算效率并且充分利用GPU,我们通常会对小批量样本的数据执行矢量计算。
假设我们读取了一个批量的样本
其中特征维度(输入数量)为,批量大小为
此外,假设我们在输出中有个类别。
那么小批量样本的特征为
权重为
偏置为
softmax回归的矢量计算表达式为:


:eqlabel:eq_minibatch_softmax_reg

相对于一次处理一个样本,
小批量样本的矢量化加快了的矩阵-向量乘法。
由于中的每一行代表一个数据样本,
那么softmax运算可以按行(rowwise)执行:
对于的每一行,我们先对所有项进行幂运算,然后通过求和对它们进行标准化。
在 :eqref:eq_minibatch_softmax_reg中,
的求和会使用广播机制,
小批量的未规范化预测和输出概率
都是形状为的矩阵。

损失函数

我们将使用最大似然估计

对数似然

softmax函数给出了一个向量,我们可以将其视为“对给定任意个输入的每个类的条件概率”。例如,=
假设整个数据集具有个样本,
其中索引的样本由特征向量和独热标签向量组成。
我们可以将估计值与实际值进行比较:

根据最大似然估计,我们最大化,相当于最小化负对数似然:

其中,对于任何标签和模型预测,损失函数为:


在本节稍后的内容会讲到,上式中的损失函数
通常被称为交叉熵损失(cross-entropy loss)。
由于是一个长度为的独热编码向量,
所以除了一个项以外的所有项都消失了。
由于所有都是预测的概率,所以它们的对数永远不会大于
因此,如果正确地预测实际标签,即如果实际标签
则损失函数不能进一步最小化。
注意,这往往是不可能的。
例如,数据集中可能存在标签噪声(比如某些样本可能被误标),
或输入特征没有足够的信息来完美地对每一个样本分类。

softmax及其导数

由于softmax和相关的损失函数很常见,
因此我们需要更好地理解它的计算方式。

考虑相对于任何未规范化的预测的导数,我们得到:

换句话说,导数是我们softmax模型分配的概率与实际发生的情况(由独热标签向量表示)之间的差异。
从这个意义上讲,这与我们在回归中看到的非常相似,
其中梯度是观测值和估计值之间的差异。这不是巧合,在任何指数族分布模型中对数似然的梯度正是由此得出的。这使梯度计算在实践中变得容易很多。

交叉熵损失

交叉熵损失是分类问题最常用的损失之一

信息论基础

信息论(information theory)涉及编码、解码、发送以及尽可能简洁地处理信息或数据

信息论的核心思想是量化数据中的信息内容。在信息论中,该数值被称为分布P的熵(entropy)。可以通过以下方程得到:

信息论的基本定理之一指出,为了对从分布p中随机抽取的数据进行编码,我们至少需要“纳特(nat)”对其进行编码。
“纳特”相当于比特(bit),但是对数底为而不是2。因此,一个纳特是比特。

信息量

压缩与预测有什么关系呢?想象一下,我们有一个要压缩的数据流。如果我们很容易预测下一个数据,那么这个数据就很容易压缩。举一个极端的例子,假如数据流中的每个数据完全相同,这会是一个非常无聊的数据流。由于它们总是相同的,我们总是知道下一个数据是什么。所以为了传递数据流的内容,我们不必传输任何信息。也就是说,“下一个数据是xx”这个事件毫无信息量。
但是,如果我们不能完全预测每一个事件,那么我们有时可能会感到“惊异”。克劳德·香农决定用信息量来量化这种惊异程度。
在观察一个事件时,并赋予它(主观)概率
当我们赋予一个事件较低的概率时,我们的惊异会更大,该事件的信息量也就更大。
在之前定义的熵,
是当分配的概率真正匹配数据生成过程时的信息量的期望

重新审视交叉熵

如果把熵想象为“知道真实概率的人所经历的惊异程度”,那么什么是交叉熵?
交叉熵,记为
我们可以把交叉熵想象为“主观概率为的观察者在看到根据概率生成的数据时的预期惊异”。
时,交叉熵达到最低。
在这种情况下,从的交叉熵是

简而言之,我们可以从两方面来考虑交叉熵分类目标:
(i)最大化观测数据的似然;(ii)最小化传达标签所需的惊异。

模型预测和评估

在训练softmax回归模型后,给出任何样本的特征,我们可以预测每个输出类别的概率。通常我们使用预测概率最高的类别作为输出类别。如果预测与实际类别(标签)一致,则预测是正确的。在接下来的实验中,我们将使用精度(accuracy)来评估模型的性能。精度等于正确预测数与预测总数之间的比率

小结

  • softmax运算获取一个向量并将其映射为概率
  • softmax回归是一个多类分类模型
  • 使用softmax操作子得到每个类的预测置信度
  • 使用交叉熵来衡量预测和标签的区别
  • softmax回归适用于分类问题,他使用了softmax运算中输出类别的概率分布
  • 交叉熵是一个衡量两个概率分布之间差异的很好的度量,它测定给定模型编码数据所需的比特数

图片分类数据集

MNIST数据集是图像分类中广泛使用的数据集之一,但作为基准数据集过于简单。我们将使用类似但更复杂的Fashion-MNIST数据集

1
2
3
4
5
6
7
8
%matplotlib inline
import torch
import torchvision
from torch.utils import data
from torchvision import transforms
from d2l import torch as d2l

d2l.use_svg_display()

读取数据集

  1. 通过框架中的内置函数将Fasion-MNIST数据集下载并读取到内存中

    1
    2
    3
    4
    5
    6
    7
    8
    # 通过ToTensor实例将图像数据从PIL类型变换为32位浮点数格式,并除以255使得所有像素的数值均在0~1之间
    trans = transforms.ToTensor() # 将图片转换为PyTorch的tensor
    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
    )
  2. Fasion-MNIST由10个类别的图像组成,每个类别由训练数据集中的6000张图像和测试数据集中的1000张图像组成。因此,训练集和测试集分别包含60000和10000张图像。测试数据集不会用于训练,只用于评估模型性能

  3. 每个输入图像的高度和宽度均为28像素。数据集由灰度图像组成,其通道数为1。为了简洁起见,将高度像素、宽度像素图像的形状记为或(,

  4. 两个可视化数据集的函数

    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
    # 用于在数字标签索引及其文本名称之间进行转换
    def get_fashion_mnist_label(labels): #@save
    """返回Fashion-MNIST数据集的文本标签"""
    text_labels = ['t-shirt', 'trouser', 'pullover', 'dress', 'coat',
    'sandal', 'shirt', 'sneaker', 'bag', 'ankle boot']
    return [text_labels[int(i)] for i in labels]

    # 可视化样本的函数
    def show_images(imgs, num_rows, num_cols, titles=None, scale=1.5): #@save
    """绘制图像列表"""
    figsize = (num_rows * scale, num_cols * scale)
    _, axes = d2l.plt.subplots(num_rows, num_cols, figsize=figsize)
    axes = axes.flatten()
    for i, (ax, img) in enumerate(zip(axes, imgs)):
    if torch.is_tensor(img):
    # 图片张量
    ax.imshow(img.numpy())
    else:
    # PIL图片
    ax.imshow(img)
    ax.axes.get_xaxis().set_visible(False)
    ax.axes.get_yaxis().set_visible(False)
    if titles:
    ax.set_title(titles[i])
    return axes
  5. 以下是训练数据集中前几个样本的图像以及其相应的标签

1
2
X, y = next(iter(data.DataLoader(mnist_train, batch_size=18)))
show_images(X.reshape(18, 28, 28), 2, 9, title=get_fashion_mnist_labels(y))

读取小批量

为了使我们在读取训练集和测试集时更容易,我们使用内置的数据迭代器,而不是从零开始创建。在每次迭代中,数据加载器每次都会读取一小批量数据,大小为batch_size。通过内置数据迭代器,我们可以随机打乱了所有样本,从而无偏见地读取小批量

1
2
3
4
5
6
7
batch_size = 256

def get_dataloader_workers(): #@save
"""使用4个进程来读取数据"""
return 4

train_iter = data.DataLoader(mnist_train, batch_size, shuffle=True, num_workers=get_dataloader_workers())

整合所有组件

定义load_data_fashion_mnist函数,用于获取和读取Fashion-MNIST数据集。这和函数返回训练集和验证集的数据迭代器。此外,这个函数还接受一个可选参数resize,用来将图像大小调整为另一种形状

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def load_data_fashion_mnist(batch_size, resize=None): #@save
"""下载Fashion-MNIST数据集,然后将其加载到内存中"""
trans = [transforms.ToTensor()]
if resize:
trans.insert(0, transforms.Resize(resize))
trans = transforms.Compose(trans)
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()),
data.DataLoader(mnist_test, batch_size, shuffle=False,
num_workers=get_dataloader_workers()))

小结

  • 数据迭代器是获得更高性能的关键组件。依靠实现良好的数据迭代器,利用高性能计算来避免减慢训练过程

softmax回归的从零开始实现

就像我们从零开始实现线性回归一样,我们认为softmax回归也是重要的基础,因此应该知道实现softmax回归的细节

1
2
3
4
5
6
import torch
from IPython import display
from d2l import torch as d2l

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

初始化模型参数

和之前线性回归的例子一样,这里的每个样本都将用固定长度的向量表示。原始数据集中的每个样本都是的图像。本节将展平每个图像,把它们看作长度为784的向量。之后我们将讨论能够利用图像空间结构的特征,但现在我们暂时只把每个像素位置看作一个特征
因为我们的数据集有10个类别,所以网络输出维度为10.因此权重将构成一个的矩阵,偏置将构成一个的行向量。与线性回归一样,我们将使用正态分布初始化我们的权重W,偏置初始化为0

1
2
3
4
5
num_imputs = 784
num_outputs = 10

W = torch.normal(0, 0.01, size=(num_inputs, num_outputs), requires_gard=True)
b = torch.zeros(num_outputs, require_grad=True)

定义softmax操作

  1. 简要回顾一下sum运算符如何沿着张量中的特定维度工作。给定一个矩阵X,我们可以对所有元素求和(默认情况下)。也可以只求同一个轴上的元素,即同一列(相当于沿轴0,将轴0求和压缩)或同一行(沿轴1)。当调用sum运算符时,我们可以指定保持在原始张量的轴数,而不折叠求和的维度

  2. 实现softmax由三个步骤组成:

    1. 对每个项求幂(使用exp
    2. 对每一行求和(小批量中每个样本是一行),得到每个样本的规范化常数
    3. 将每一行除以其规范化常数,确保结果的和为1
  3. 分母或规范化常数,有时也称为配分函数(其对数称为对数-配分函数)。该名称来自统计物理学中一个模拟粒子群分布的方程

    1
    2
    3
    4
    def softmax(X):
    X_exp = torch.exp(X)
    partition = X_exp.sum(1, keepdim=True)
    return X_exp / partition # 这里应用了广播机制

定义模型

  1. 定义softmax操作后,我们可以实现softmax回归模型。下面的代码定义了输入如何通过网络映射到输出。注意,将数据传递到模型之前,我们使用reshape函数将每张原始图像展平为向量

    1
    2
    def net(X):
    return softmax(torch.matmul(X.reshape((-1, W.shape[0])), W) + b)

定义损失函数

  1. 交叉熵采用真实标签的预测概率的负对数似然。这里我们不使用Python的for循环迭代预测(这往往是低效的),而是通过一个运算符选择所有元素

  2. 实现交叉熵损失函数

    1
    2
    def cross_entropy(y_hay, y):
    return - torch.log(y_hat[range(len(y_hat)), y])

分类精度

  1. 给定预测概率分布y_hat,当我们必须输出硬预测时,我们通常选择预测概率最高的类。当预测与标签分类y是一致时,即是正确的。分类精度即正确预测数量与总预测数量之比。虽然直接优化精度可能很困难(因为精度的计算不可导),但精度通常是我们最关心的性能衡量标准,我们在训练分类器时几乎总会关注它

  2. 为了计算精度,我们执行以下操作。首先,如果y_hat是矩阵,那么假定第二个维度存储每个类的预测分数。我们使用argmax获得每行中最大元素的索引来获得预测类别。然后我们将预测类别与真实y元素进行比较

    1
    2
    3
    4
    5
    6
    7
    def accuracy(y_hat, y): #@save
    """计算预测正确的数量"""
    if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:
    y_hat = y_hat.argmax(axis=1)
    cmp = y_hat.type(y.dtype) == y
    return float(cmp.type(y.dtype).sum())
    accuracy(y_hat, y) / len(y)
  3. 对于任意数据迭代器data_iter可访问的数据集,我们可以评估在任意模型net的精度

    1
    2
    3
    4
    5
    6
    7
    8
    9
    def evaluate_accuracy(net, data_iter):  #@save
    """计算在指定数据集上模型的精度"""
    if isinstance(net, torch.nn.Module):
    net.eval() # 将模型设置为评估模式
    metric = Accumulator(2) # 正确预测数、预测总数
    with torch.no_grad():
    for X, y in data_iter:
    metric.add(accuracy(net(X), y), y.numel())
    return metric[0] / metric[1]
  4. 这里定义了一个实用程序类Accumulator,用于对多个变量进行累加。以上代码中,我们在Accumulator实例中创建了2个变量,分别用于存储正确预测的数量和预测的总数量。当我们遍历数据集时,两者都将随着时间的推移而累加

    1
    2
    3
    4
    5
    6
    7
    class Accumulator: #@save
    """在n个变量上累加"""
    def __init__(self, n):
    self.data = [0.0] * n

    def add(self, *args):
    self.data = [a + float(b) for a, b in zip(self.data, args)]

训练

  1. 在这里,我们重构训练过程的实现以使其可重复使用。首先,我们定义一个函数来训练一个迭代周期。updater是更新模型参数的常用函数,它接受批量大小作为参数。它可以是d2l.sgd函数,也可以是框架内的内置优化函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    def train_epoch_ch3(net, train_iter, loss, updater):  #@save
    """训练模型一个迭代周期(定义见第三章)"""
    # 将模型设置为训练模式
    if isinstance(net, torch.nn.Moudle):
    net.train()
    # 训练损失总和、训练准确度总和、样本数
    metric = Accumulator(3)
    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.mean().backward()
    updater.step()
    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]
  2. 定义一个在动画中绘制数据的实用程序类

    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
    30
    31
    32
    33
    34
    class Animator: #@save
    """在动画中绘制数据"""
    def __init__(self, xlabel=None, ylabel=None, legend=None, xlim=None, ylim=None, xscale='linear', yscale='linear', fmts=('-', 'm--', 'g-.', 'r:'), nrows=1, nclos=1, figsize=(3.5, 2.5)):
    # 增量地绘制多条线
    if legeng is None:
    legeng = []
    d2l.use_svg_display()
    self.fig, self.axes = d2l.plt.subplots(nrows, ncols, figsize=figsize)
    if nrows * ncols == 1:
    self.axes = [self.axes, ]
    # 使用lambda函数捕获参数
    self.config_axes = lambda: d2l.set_axes(self.axes[0], xlabel, ylabel, xlim, ylim, xscale, yscale, legend)
    self.X, self.Y, self.fmts = None, None, fmts

    def add(self, x, y):
    # 向图表中添加多个数据点
    if not hasattr(y, "__len__"):
    y = [y]
    n = len(y)
    if not hasattr(x, "__len__"):
    x = [x] * n
    if not self.X:
    self.X = [[] for _ in range(n)]
    if not self.Y:
    self.Y = [[] for _ in range(n)]
    for i, (a, b) in enumerate(zip(x,y)):
    if a is not None and b in not None:
    self.X[i].append(a)
    self.Y[i].append(b)
    self.axes[0].cla()
    for x, y, fmt in zip(self.X, self.Y, self.fmt):
    self.config_axes()
    display.display(self.fig)
    display.clear_output(wait=True)
  3. 接下来实现一个训练函数,它会在train_iter访问到的训练数据集上训练一个模型net。该训练函数将会运行多个迭代周期(由num_epochs指定)。在每个迭代周期结束时,利用test_iter访问到的测试数据集对模型进行评估,利用Animator类来可视化训练进度

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    def train_ch3(net, train_iter, test_iter, loss, num_epochs, updater): #@save
    """训练模型"""
    animator = Animator(xlabel="epoch", xlim=[1, num_epochs], ylim=[0.3, 0.9], legend=['train loss', 'train acc', 'test acc'])
    for epoch in range(num_epochs):
    train_metrics = train_epoch_ch3(net, train_iter, loss, updater)
    test_acc = evaluate_accuracy(net, test_iter)
    animator.add(epoch + 1, train_metrics + (test_acc,))
    train_loss, train_acc = train_metrics
    assert train_loss < 0.5, train_loss
    assert train_acc <=1 and train_acc > 0.7, train_acc
    assert test_acc <=1 and test_acc > 0.7 test_acc
  4. 我们使用小批量随机梯度下降来优化模型的损失函数

    1
    2
    3
    4
    lr = 0.1

    def updater(batch_size):
    return d2l.sgd([W, b], lr, batch_size)
  5. 训练模型10个迭代周期

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

预测

  1. 现在训练已经完成,我们的模型已经准备好对图像进行分类预测。给定一系列图像,我们将比较它们的实际标签(第一行)和模型预测(第二行)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    def predict_ch3(net, test_iter, n=6): #@save
    """预测标签"""
    for X, y in test_iter:
    break
    trues = d2l.get_fashion_mnist_labels(y)
    preds = d2l.get_fashion_mnist_labels(net(X).argmax(axes=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]
    )
    predict_ch3(net, test_iter)

softmax回归的简洁实现

  1. 通过深度学习框架的高级API能够使实现softmax回归变得更容易

    1
    2
    3
    4
    5
    6
    import torch
    from torch import nn
    from d2l import torch as d2l

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

初始化模型参数

  1. softmax回归的输出层是一个全连接层。因此,为了实现我们的模型,我们只需在Sequential中添加一个带有10个输出的全连接层

    1
    2
    3
    4
    5
    6
    7
    8
    # PyTorch不会隐式地调整输出的形状。因此,我们在线性层前定义了展平层(flatten),来调整网络输出的形状
    net = nn.Sequential(nn.Flatten(), nn.Linear(784, 10))

    def init_weights(m):
    if type(m) == nn.Linear:
    nn.init.normal_(m.weight, std=0.01)

    net.apply(init_weights);

重新审视Softmax的实现

  1. 这里要解决softmax函数在计算机上的上溢问题。解决这个问题的一个技巧是:在继续softmax计算之前,先从所有O中减去max(O)。

  2. 但是这样可能同样导致下溢的问题。通过将softmax和交叉熵结合在一起,可以避免反向传播过程中可能会困扰我们的数值稳定性问题如下面的等式所示,我们避免计算
    而可以直接使用,因为被抵消了。

  3. 我们也希望保留传统的softmax函数,以备我们需要评估通过模型输出概率。但是我们没有将softmax概率传递到损失函数中,而是在交叉熵损失函数中传递未规范化的预测,并同时计算softmax及其对数

优化算法

  1. 使用学习率为0.1的小批量随机梯度下降作为优化算法。这与在线性回归例子中的相同,这说明了优化器的普适性

    1
    trainer = torch.optim.SGD(net.parameters(), lr=0.1)

训练

  1. 调用之前定义的训练函数来训练模型

    1
    2
    num_epochs = 10
    d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)

QA

  1. softlabel训练策略
    • 不必使用one-hot标签(使用softmax函数的情况下要逼近one-hat标签的话,softmax的输入要无限大)
    • 使用softlabel,将目标类别记为0.9,其他类别平分0.1
  • 标题: 动手学深度学习-09
  • 作者: 敖炜
  • 创建于 : 2023-08-11 15:35:09
  • 更新于 : 2024-04-19 09:28:06
  • 链接: https://ao-wei.github.io/2023/08/11/动手学深度学习-09/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论