FastAI 之书(面向程序员的 FastAI)(二)(3)

本文涉及的产品
服务治理 MSE Sentinel/OpenSergo,Agent数量 不受限
简介: FastAI 之书(面向程序员的 FastAI)(二)(3)

 FastAI 之书(面向程序员的 FastAI)(二)(2)https://developer.aliyun.com/article/1482982

随机梯度下降

你还记得 Arthur Samuel 在第一章中描述机器学习的方式吗?

假设我们安排一些自动手段来测试任何当前权重分配的有效性,以实际性能为基础,并提供一种机制来改变权重分配以最大化性能。我们不需要详细了解这种程序的细节,就可以看到它可以完全自动化,并且可以看到一个这样编程的机器会从中学习。

正如我们讨论过的,这是让我们拥有一个可以变得越来越好的模型的关键,可以学习。但我们的像素相似性方法实际上并没有做到这一点。我们没有任何权重分配,也没有任何根据测试权重分配的有效性来改进的方法。换句话说,我们无法通过修改一组参数来改进我们的像素相似性方法。为了充分利用深度学习的力量,我们首先必须按照 Samuel 描述的方式来表示我们的任务。

与其尝试找到图像与“理想图像”之间的相似性,我们可以查看每个单独的像素,并为每个像素提出一组权重,使得最高的权重与最有可能为特定类别的黑色像素相关联。例如,向右下方的像素不太可能被激活为 7,因此它们对于 7 的权重应该很低,但它们很可能被激活为 8,因此它们对于 8 的权重应该很高。这可以表示为一个函数和每个可能类别的一组权重值,例如,成为数字 8 的概率:

def pr_eight(x,w) = (x*w).sum()

在这里,我们假设X是图像,表示为一个向量—换句话说,所有行都堆叠在一起形成一个长长的单行。我们假设权重是一个向量W。如果我们有了这个函数,我们只需要一种方法来更新权重,使它们变得更好一点。通过这种方法,我们可以重复这个步骤多次,使权重变得越来越好,直到我们能够使它们尽可能好。

我们希望找到导致我们的函数对于那些是 8 的图像结果高,对于那些不是的图像结果低的向量W的特定值。搜索最佳向量W是搜索最佳函数以识别 8 的一种方式。(因为我们还没有使用深度神经网络,我们受到我们的函数能力的限制,我们将在本章后面解决这个约束。)

更具体地说,以下是将这个函数转化为机器学习分类器所需的步骤:

  1. 初始化权重。
  2. 对于每个图像,使用这些权重来预测它是 3 还是 7。
  3. 基于这些预测,计算模型有多好(它的损失)。
  4. 计算梯度,它衡量了每个权重的变化如何改变损失。
  5. 根据这个计算,改变(即,改变)所有权重。
  6. 回到步骤 2 并重复这个过程。
  7. 迭代直到你决定停止训练过程(例如,因为模型已经足够好或者你不想再等待了)。

这七个步骤,如图 4-1 所示,是所有深度学习模型训练的关键。深度学习完全依赖于这些步骤,这是非常令人惊讶和反直觉的。令人惊奇的是,这个过程可以解决如此复杂的问题。但是,正如你将看到的,它确实可以!

图 4-1. 梯度下降过程

每个步骤都有许多方法,我们将在本书的其余部分学习它们。这些细节对于深度学习从业者来说非常重要,但事实证明,对于每个步骤的一般方法都遵循一些基本原则。以下是一些建议:

初始化

我们将参数初始化为随机值。这可能听起来令人惊讶。我们当然可以做其他选择,比如将它们初始化为该类别激活该像素的百分比—但由于我们已经知道我们有一种方法来改进这些权重,结果证明只是从随机权重开始就可以完全正常运行。

损失

这就是 Samuel 所说的根据实际表现测试任何当前权重分配的有效性。我们需要一个函数,如果模型的表现好,它将返回一个小的数字(标准方法是将小的损失视为好的,大的损失视为坏的,尽管这只是一种约定)。

步骤

一个简单的方法来判断一个权重是否应该增加一点或减少一点就是尝试一下:增加一点权重,看看损失是增加还是减少。一旦找到正确的方向,你可以再多改变一点或少改变一点,直到找到一个效果好的量。然而,这很慢!正如我们将看到的,微积分的魔力使我们能够直接找出每个权重应该朝哪个方向改变,大概改变多少,而不必尝试所有这些小的改变。这样做的方法是通过计算梯度。这只是一种性能优化;我们也可以通过使用更慢的手动过程得到完全相同的结果。

停止

一旦我们决定要为模型训练多少个周期(之前的列表中给出了一些建议),我们就会应用这个决定。对于我们的数字分类器,我们会继续训练,直到模型的准确率开始变差,或者我们用完时间为止。

在将这些步骤应用于我们的图像分类问题之前,让我们在一个更简单的情况下看看它们是什么样子。首先我们将定义一个非常简单的函数,二次函数—假设这是我们的损失函数,x是函数的权重参数:

def f(x): return x**2

这是该函数的图表:

plot_function(f, 'x', 'x**2')

我们之前描述的步骤序列从选择参数的随机值开始,并计算损失的值:

plot_function(f, 'x', 'x**2')
plt.scatter(-1.5, f(-1.5), color='red');

现在我们来看看如果我们稍微增加或减少参数会发生什么—调整。这只是特定点的斜率:

我们可以稍微改变我们的权重朝着斜坡的方向,计算我们的损失和调整,然后再重复几次。最终,我们将到达曲线上的最低点:


这个基本思想最早可以追溯到艾萨克·牛顿,他指出我们可以以这种方式优化任意函数。无论我们的函数变得多么复杂,梯度下降的这种基本方法不会有太大变化。我们在本书后面看到的唯一微小变化是一些方便的方法,可以让我们更快地找到更好的步骤。

计算梯度

唯一的魔法步骤是计算梯度的部分。正如我们提到的,我们使用微积分作为性能优化;它让我们更快地计算当我们调整参数时我们的损失会上升还是下降。换句话说,梯度将告诉我们我们需要改变每个权重多少才能使我们的模型更好。

您可能还记得高中微积分课上的导数告诉您函数参数的变化会如何改变其结果。如果不记得,不用担心;我们很多人高中毕业后就忘了微积分!但在继续之前,您需要对导数有一些直观的理解,所以如果您对此一头雾水,可以前往 Khan Academy 完成基本导数课程。您不必自己计算导数;您只需要知道导数是什么。

导数的关键点在于:对于任何函数,比如我们在前一节中看到的二次函数,我们可以计算它的导数。导数是另一个函数。它计算的是变化,而不是值。例如,在值为 3 时,二次函数的导数告诉我们函数在值为 3 时的变化速度。更具体地说,您可能还记得梯度被定义为上升/水平移动;也就是说,函数值的变化除以参数值的变化。当我们知道我们的函数将如何变化时,我们就知道我们需要做什么来使它变小。这是机器学习的关键:有一种方法来改变函数的参数使其变小。微积分为我们提供了一个计算的捷径,即导数,它让我们直接计算我们函数的梯度。

一个重要的事情要注意的是我们的函数有很多需要调整的权重,所以当我们计算导数时,我们不会得到一个数字,而是很多个—每个权重都有一个梯度。但在这里没有数学上的技巧;您可以计算相对于一个权重的导数,将其他所有权重视为常数,然后对每个其他权重重复这个过程。这就是计算所有梯度的方法,对于每个权重。

刚才我们提到您不必自己计算任何梯度。这怎么可能?令人惊讶的是,PyTorch 能够自动计算几乎任何函数的导数!而且,它计算得非常快。大多数情况下,它至少与您手动创建的任何导数函数一样快。让我们看一个例子。

首先,让我们选择一个张量数值,我们想要梯度:

xt = tensor(3.).requires_grad_()

注意特殊方法requires_grad_?这是我们告诉 PyTorch 我们想要计算梯度的神奇咒语。这实质上是给变量打上标记,这样 PyTorch 就会记住如何计算您要求的其他直接计算的梯度。

Alexis 说

如果您来自数学或物理学,这个 API 可能会让您困惑。在这些背景下,函数的“梯度”只是另一个函数(即,它的导数),因此您可能期望与梯度相关的 API 提供给您一个新函数。但在深度学习中,“梯度”通常意味着函数的导数在特定参数值处的。PyTorch API 也将重点放在参数上,而不是您实际计算梯度的函数。起初可能感觉有些反常,但这只是一个不同的视角。

现在我们用这个值计算我们的函数。注意 PyTorch 打印的不仅是计算的值,还有一个提示,它有一个梯度函数将在需要时用来计算我们的梯度:

yt = f(xt)
yt
tensor(9., grad_fn=<PowBackward0>)

最后,我们告诉 PyTorch 为我们计算梯度:

yt.backward()

这里的backward指的是反向传播,这是计算每一层导数的过程的名称。我们将在第十七章中看到这是如何精确完成的,当我们从头开始计算深度神经网络的梯度时。这被称为网络的反向传播,与前向传播相对,前者是计算激活的地方。如果backward只是被称为calculate_grad,生活可能会更容易,但深度学习的人确实喜欢在任何地方添加行话!

我们现在可以通过检查我们张量的grad属性来查看梯度:

xt.grad
tensor(6.)

如果您记得高中微积分规则,x**2的导数是2*x,我们有x=3,所以梯度应该是2*3=6,这就是 PyTorch 为我们计算的结果!

现在我们将重复前面的步骤,但使用一个向量参数来计算我们的函数:

xt = tensor([3.,4.,10.]).requires_grad_()
xt
tensor([ 3.,  4., 10.], requires_grad=True)

并且我们将sum添加到我们的函数中,以便它可以接受一个向量(即,一个秩为 1 的张量)并返回一个标量(即,一个秩为 0 的张量):

def f(x): return (x**2).sum()
yt = f(xt)
yt
tensor(125., grad_fn=<SumBackward0>)

我们的梯度是2*xt,正如我们所期望的!

yt.backward()
xt.grad
tensor([ 6.,  8., 20.])

梯度告诉我们函数的斜率;它们并不告诉我们要调整参数多远。但它们确实给了我们一些想法:如果斜率非常大,那可能意味着我们需要更多的调整,而如果斜率非常小,那可能意味着我们接近最优值。

使用学习率进行步进

根据梯度值来决定如何改变我们的参数是深度学习过程中的一个重要部分。几乎所有方法都从一个基本思想开始,即将梯度乘以一些小数字,称为学习率(LR)。学习率通常是 0.001 到 0.1 之间的数字,尽管它可以是任何值。通常人们通过尝试几个学习率来选择一个,并找出哪个在训练后产生最佳模型的结果(我们将在本书后面展示一个更好的方法,称为学习率查找器)。一旦选择了学习率,您可以使用这个简单函数调整参数:

w -= w.grad * lr

这被称为调整您的参数,使用优化步骤

如果您选择的学习率太低,可能意味着需要执行很多步骤。图 4-2 说明了这一点。

图 4-2。学习率过低的梯度下降

但选择一个学习率太高的学习率更糟糕——它可能导致损失变得更糟,正如我们在图 4-3 中看到的!

图 4-3. 学习率过高的梯度下降

如果学习率太高,它也可能会“弹跳”而不是发散;图 4-4 显示了这样做需要许多步骤才能成功训练。

图 4-4. 带有弹跳学习率的梯度下降

现在让我们在一个端到端的示例中应用所有这些。

一个端到端的 SGD 示例

我们已经看到如何使用梯度来最小化我们的损失。现在是时候看一个 SGD 示例,并看看如何找到最小值来训练模型以更好地拟合数据。

让我们从一个简单的合成示例模型开始。想象一下,您正在测量过山车通过顶峰时的速度。它会开始快速,然后随着上坡而变慢;在顶部最慢,然后在下坡时再次加速。您想建立一个关于速度随时间变化的模型。如果您每秒手动测量速度 20 秒,它可能看起来像这样:

time = torch.arange(0,20).float(); time
tensor([ 0.,  1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10., 11., 12., 13.,
 > 14., 15., 16., 17., 18., 19.])
speed = torch.randn(20)*3 + 0.75*(time-9.5)**2 + 1
plt.scatter(time,speed);

我们添加了一些随机噪声,因为手动测量不够精确。这意味着很难回答问题:过山车的速度是多少?使用 SGD,我们可以尝试找到一个与我们的观察相匹配的函数。我们无法考虑每种可能的函数,所以让我们猜测它将是二次的;即,一个形式为a*(time**2)+(b*time)+c的函数。

我们希望清楚地区分函数的输入(我们测量过山车速度的时间)和其参数(定义我们正在尝试的二次函数的值)。因此,让我们将参数收集在一个参数中,从而在函数的签名中分离输入t和参数params

def f(t, params):
    a,b,c = params
    return a*(t**2) + (b*t) + c

换句话说,我们已经将找到最佳拟合数据的最佳函数的问题限制为找到最佳二次函数。这极大地简化了问题,因为每个二次函数都由三个参数abc完全定义。因此,要找到最佳二次函数,我们只需要找到最佳的abc的值。

如果我们可以解决二次函数的三个参数的问题,我们就能够对其他具有更多参数的更复杂函数应用相同的方法——比如神经网络。让我们先找到f的参数,然后我们将回来对 MNIST 数据集使用神经网络做同样的事情。

首先,我们需要定义“最佳”是什么意思。我们通过选择一个损失函数来精确定义这一点,该函数将根据预测和目标返回一个值,其中函数的较低值对应于“更好”的预测。对于连续数据,通常使用均方误差

def mse(preds, targets): return ((preds-targets)**2).mean()

现在,让我们按照我们的七步流程进行工作。

第一步:初始化参数

首先,我们将参数初始化为随机值,并告诉 PyTorch 我们要使用requires_grad_跟踪它们的梯度:

params = torch.randn(3).requires_grad_()

第二步:计算预测

接下来,我们计算预测:

preds = f(time, params)

让我们创建一个小函数来查看我们的预测与目标的接近程度,并看一看:

def show_preds(preds, ax=None):
    if ax is None: ax=plt.subplots()[1]
    ax.scatter(time, speed)
    ax.scatter(time, to_np(preds), color='red')
    ax.set_ylim(-300,100)
show_preds(preds)

这看起来并不接近——我们的随机参数表明过山车最终会倒退,因为我们有负速度!

第三步:计算损失

我们计算损失如下:

loss = mse(preds, speed)
loss
tensor(25823.8086, grad_fn=<MeanBackward0>)

我们的目标现在是改进这一点。为了做到这一点,我们需要知道梯度。

第四步:计算梯度

下一步是计算梯度,或者近似参数需要如何改变:

loss.backward()
params.grad
tensor([-53195.8594,  -3419.7146,   -253.8908])
params.grad * 1e-5
tensor([-0.5320, -0.0342, -0.0025])

我们可以利用这些梯度来改进我们的参数。我们需要选择一个学习率(我们将在下一章中讨论如何在实践中做到这一点;现在,我们将使用 1e-5 或 0.00001):

params
tensor([-0.7658, -0.7506,  1.3525], requires_grad=True)

第 5 步:调整权重

现在我们需要根据刚刚计算的梯度更新参数:

lr = 1e-5
params.data -= lr * params.grad.data
params.grad = None

Alexis 说

理解这一点取决于记住最近的历史。为了计算梯度,我们在loss上调用backward。但是这个loss本身是通过mse计算的,而mse又以preds作为输入,preds是使用f计算的,fparams作为输入,params是我们最初调用required_grads_的对象,这是最初的调用,现在允许我们在loss上调用backward。这一系列函数调用代表了函数的数学组合,使得 PyTorch 能够在幕后使用微积分的链式法则来计算这些梯度。

让我们看看损失是否有所改善:

preds = f(time,params)
mse(preds, speed)
tensor(5435.5366, grad_fn=<MeanBackward0>)

再看一下图表:

show_preds(preds)

我们需要重复这个过程几次,所以我们将创建一个应用一步的函数:

def apply_step(params, prn=True):
    preds = f(time, params)
    loss = mse(preds, speed)
    loss.backward()
    params.data -= lr * params.grad.data
    params.grad = None
    if prn: print(loss.item())
    return preds

第 6 步:重复这个过程

现在我们进行迭代。通过循环和进行许多改进,我们希望达到一个好的结果:

for i in range(10): apply_step(params)
5435.53662109375
1577.4495849609375
847.3780517578125
709.22265625
683.0757446289062
678.12451171875
677.1839599609375
677.0025024414062
676.96435546875
676.9537353515625

损失正在下降,正如我们所希望的!但仅仅看这些损失数字掩盖了一个事实,即每次迭代代表尝试一个完全不同的二次函数,以找到最佳可能的二次函数。如果我们不打印出损失函数,而是在每一步绘制函数,我们可以看到形状是如何接近我们的数据的最佳可能的二次函数:

_,axs = plt.subplots(1,4,figsize=(12,3))
for ax in axs: show_preds(apply_step(params, False), ax)
plt.tight_layout()

第 7 步:停止

我们刚刚决定在任意选择的 10 个 epochs 后停止。在实践中,我们会观察训练和验证损失以及我们的指标,以决定何时停止,正如我们所讨论的那样。

总结梯度下降

现在您已经看到每个步骤中发生的事情,让我们再次看一下我们的梯度下降过程的图形表示(图 4-5)并进行一个快速回顾。

图 4-5. 梯度下降过程

在开始时,我们模型的权重可以是随机的(从头开始训练)或来自预训练模型(迁移学习)。在第一种情况下,我们从输入得到的输出与我们想要的完全无关,即使在第二种情况下,预训练模型也可能不太擅长我们所针对的特定任务。因此,模型需要学习更好的权重。

我们首先将模型给出的输出与我们的目标进行比较(我们有标记数据,所以我们知道模型应该给出什么结果),使用一个损失函数,它返回一个数字,我们希望通过改进我们的权重使其尽可能低。为了做到这一点,我们从训练集中取出一些数据项(如图像)并将它们馈送给我们的模型。我们使用我们的损失函数比较相应的目标,我们得到的分数告诉我们我们的预测有多么错误。然后我们稍微改变权重使其稍微更好。

为了找出如何改变权重使损失稍微变好,我们使用微积分来计算梯度。(实际上,我们让 PyTorch 为我们做这个!)让我们考虑一个类比。想象一下你在山上迷路了,你的车停在最低点。为了找到回去的路,你可能会朝着随机方向走,但那可能不会有太大帮助。由于你知道你的车在最低点,你最好是往下走。通过始终朝着最陡峭的下坡方向迈出一步,你最终应该到达目的地。我们使用梯度的大小(即坡度的陡峭程度)来告诉我们应该迈多大一步;具体来说,我们将梯度乘以我们选择的一个称为学习率的数字来决定步长。然后我们迭代直到达到最低点,那将是我们的停车场;然后我们可以停止

我们刚刚看到的所有内容都可以直接转换到 MNIST 数据集,除了损失函数。现在让我们看看如何定义一个好的训练目标。

MNIST 损失函数

我们已经有了我们的x—也就是我们的自变量,图像本身。我们将它们全部连接成一个单一的张量,并且还将它们从矩阵列表(一个秩为 3 的张量)转换为向量列表(一个秩为 2 的张量)。我们可以使用view来做到这一点,view是一个 PyTorch 方法,可以改变张量的形状而不改变其内容。-1view的一个特殊参数,意思是“使这个轴尽可能大以适应所有数据”:

train_x = torch.cat([stacked_threes, stacked_sevens]).view(-1, 28*28)

我们需要为每张图片标记。我们将使用1表示 3,0表示 7:

train_y = tensor([1]*len(threes) + [0]*len(sevens)).unsqueeze(1)
train_x.shape,train_y.shape
(torch.Size([12396, 784]), torch.Size([12396, 1]))

在 PyTorch 中,当索引时,Dataset需要返回一个(x,y)元组。Python 提供了一个zip函数,当与list结合使用时,可以简单地实现这个功能:

dset = list(zip(train_x,train_y))
x,y = dset[0]
x.shape,y
(torch.Size([784]), tensor([1]))
valid_x = torch.cat([valid_3_tens, valid_7_tens]).view(-1, 28*28)
valid_y = tensor([1]*len(valid_3_tens) + [0]*len(valid_7_tens)).unsqueeze(1)
valid_dset = list(zip(valid_x,valid_y))

现在我们需要为每个像素(最初是随机的)分配一个权重(这是我们七步过程中的初始化步骤):

def init_params(size, std=1.0): return (torch.randn(size)*std).requires_grad_()
weights = init_params((28*28,1))

函数weights*pixels不够灵活—当像素等于 0 时,它总是等于 0(即其截距为 0)。你可能还记得高中数学中线的公式是y=w*x+b;我们仍然需要b。我们也会将其初始化为一个随机数:

bias = init_params(1)

在神经网络中,方程y=w*x+b中的w被称为权重b被称为偏置。权重和偏置一起构成参数

术语:参数

模型的权重偏置。权重是方程w*x+b中的w,偏置是该方程中的b

现在我们可以为一张图片计算一个预测:

(train_x[0]*weights.T).sum() + bias
tensor([20.2336], grad_fn=<AddBackward0>)

虽然我们可以使用 Python 的for循环来计算每张图片的预测,但那将非常慢。因为 Python 循环不在 GPU 上运行,而且因为 Python 在一般情况下循环速度较慢,我们需要尽可能多地使用高级函数来表示模型中的计算。

在这种情况下,有一个非常方便的数学运算可以为矩阵的每一行计算w*x—它被称为矩阵乘法。图 4-6 展示了矩阵乘法的样子。

图 4-6. 矩阵乘法

这幅图展示了两个矩阵AB相乘。结果的每个项目,我们称之为AB,包含了A的对应行的每个项目与B的对应列的每个项目相乘后相加。例如,第 1 行第 2 列(带有红色边框的黄色点)计算为。如果您需要复习矩阵乘法,我们建议您查看 Khan Academy 的“矩阵乘法简介”,因为这是深度学习中最重要的数学运算。

在 Python 中,矩阵乘法用@运算符表示。让我们试一试:

def linear1(xb): return xb@weights + bias
preds = linear1(train_x)
preds
tensor([[20.2336],
        [17.0644],
        [15.2384],
        ...,
        [18.3804],
        [23.8567],
        [28.6816]], grad_fn=<AddBackward0>)

第一个元素与我们之前计算的相同,正如我们所期望的。这个方程batch @ weights + bias是任何神经网络的两个基本方程之一(另一个是激活函数,我们马上会看到)。

让我们检查我们的准确性。为了确定输出代表 3 还是 7,我们只需检查它是否大于 0,因此我们可以计算每个项目的准确性(使用广播,因此没有循环!)如下:

corrects = (preds>0.0).float() == train_y
corrects
tensor([[ True],
        [ True],
        [ True],
        ...,
        [False],
        [False],
        [False]])
corrects.float().mean().item()
0.4912068545818329

FastAI 之书(面向程序员的 FastAI)(二)(4)https://developer.aliyun.com/article/1482984

相关实践学习
基于MSE实现微服务的全链路灰度
通过本场景的实验操作,您将了解并实现在线业务的微服务全链路灰度能力。
相关文章
|
2天前
|
机器学习/深度学习 搜索推荐 算法
FastAI 之书(面向程序员的 FastAI)(四)(2)
FastAI 之书(面向程序员的 FastAI)(四)
63 0
|
2天前
|
机器学习/深度学习 算法 程序员
FastAI 之书(面向程序员的 FastAI)(三)(4)
FastAI 之书(面向程序员的 FastAI)(三)(4)
40 2
|
2天前
|
机器学习/深度学习 存储 程序员
FastAI 之书(面向程序员的 FastAI)(一)(4)
FastAI 之书(面向程序员的 FastAI)(一)(4)
31 0
|
2天前
|
机器学习/深度学习 PyTorch 算法框架/工具
FastAI 之书(面向程序员的 FastAI)(二)(2)
FastAI 之书(面向程序员的 FastAI)(二)(2)
60 0
|
2天前
|
机器学习/深度学习 PyTorch 程序员
FastAI 之书(面向程序员的 FastAI)(一)(1)
FastAI 之书(面向程序员的 FastAI)(一)(1)
107 0
|
2天前
|
搜索推荐 PyTorch 程序员
FastAI 之书(面向程序员的 FastAI)(四)(1)
FastAI 之书(面向程序员的 FastAI)(四)
29 0
|
2天前
|
机器学习/深度学习 算法 数据挖掘
FastAI 之书(面向程序员的 FastAI)(四)(4)
FastAI 之书(面向程序员的 FastAI)(四)
60 1
|
2天前
|
机器学习/深度学习 PyTorch 程序员
FastAI 之书(面向程序员的 FastAI)(二)(4)
FastAI 之书(面向程序员的 FastAI)(二)(4)
34 2
|
2天前
|
机器学习/深度学习 自然语言处理 搜索推荐
FastAI 之书(面向程序员的 FastAI)(一)(3)
FastAI 之书(面向程序员的 FastAI)(一)(3)
32 0
|
2天前
|
机器学习/深度学习 算法 数据可视化
FastAI 之书(面向程序员的 FastAI)(四)(3)
FastAI 之书(面向程序员的 FastAI)(四)
29 0

热门文章

最新文章