訂閱
糾錯(cuò)
加入自媒體

PyTorch 2簡(jiǎn)介:卷積神經(jīng)網(wǎng)絡(luò)

介紹

在本系列的上一部分中,我們使用了CIFAR-10數(shù)據(jù)集,并介紹了PyTorch的基礎(chǔ)知識(shí):

張量及其相關(guān)操作

數(shù)據(jù)集和數(shù)據(jù)加載器

構(gòu)建基本的神經(jīng)網(wǎng)絡(luò)

基本模型的訓(xùn)練和評(píng)估

我們?yōu)镃IFAR-10數(shù)據(jù)集中的圖像分類開(kāi)發(fā)的模型只能在驗(yàn)證集上達(dá)到53%的準(zhǔn)確率,并且在一些類別(如鳥(niǎo)類和貓類)的圖像分類上表現(xiàn)非常困難(約33-35%的準(zhǔn)確率)。這是預(yù)期的,因?yàn)槲覀兺ǔ?huì)使用卷積神經(jīng)網(wǎng)絡(luò)進(jìn)行圖像分類。在本教程系列的這一部分,我們將專注于卷積神經(jīng)網(wǎng)絡(luò)(CNN)并改善在CIFAR-10上的圖像分類性能。

CNN基礎(chǔ)知識(shí)

在我們深入代碼之前,讓我們討論卷積神經(jīng)網(wǎng)絡(luò)的基礎(chǔ)知識(shí),以便更好地理解我們的代碼在做什么。如果你已經(jīng)對(duì)CNN的工作原理感到熟悉,可以跳過(guò)本節(jié)。

與前一部分中開(kāi)發(fā)的前饋網(wǎng)絡(luò)相比,卷積神經(jīng)網(wǎng)絡(luò)具有不同的架構(gòu),并由不同類型的層組成。在下圖中,我們可以看到典型CNN的一般架構(gòu),包括它可能包含的不同類型的層。

卷積網(wǎng)絡(luò)中通常包含的三種類型的層是:

卷積層(紅色虛線框)

池化層(藍(lán)色虛線框)

全連接層(紅色和紫色實(shí)線框)

卷積層

CNN的定義組件和第一層是卷積層,它由以下部分組成:

輸入數(shù)據(jù)(在本例中為圖像)

濾波器

特征圖

將卷積層與全連接層區(qū)分開(kāi)來(lái)的關(guān)鍵是卷積運(yùn)算。我們不會(huì)詳細(xì)討論卷積的定義,但如果你真的感興趣并想深入了解其數(shù)學(xué)定義以及一些具體的示例,我強(qiáng)烈推薦閱讀這篇文章,它在解釋數(shù)學(xué)定義方面做得非常好

https://betterexplained.com/articles/intuitive-convolution/#Part_3_Mathematical_Properties_of_Convolution

卷積相對(duì)于密集連接層(全連接層)在圖像數(shù)據(jù)中的優(yōu)勢(shì)何在?簡(jiǎn)而言之,密集連接層會(huì)學(xué)習(xí)輸入中的全局模式,而卷積層具有學(xué)習(xí)局部和空間模式的優(yōu)勢(shì)。這可能聽(tīng)起來(lái)有些模糊或抽象,所以讓我們看一個(gè)例子來(lái)說(shuō)明這是什么意思。

在圖片的左側(cè),我們可以看到一個(gè)基本的2D黑白圖像的4是如何在卷積層中表示的。

紅色方框是濾波器/特征檢測(cè)器/卷積核,在圖像上進(jìn)行卷積操作。在右側(cè)是相同圖像在一個(gè)密集連接層中的表示。你可以看到相同的9個(gè)圖像像素被紅色的卷積核框起來(lái)。請(qǐng)注意,在左側(cè),像素在空間上是分組的,與相鄰的像素相鄰。然而,在右側(cè),這相同的9個(gè)像素不再是相鄰的。

通過(guò)這個(gè)例子,我們可以看到當(dāng)圖像被壓平并表示為完全連接/線性層時(shí),空間/位置信息是如何丟失的。這就是為什么卷積神經(jīng)網(wǎng)絡(luò)在處理圖像數(shù)據(jù)時(shí)更強(qiáng)大的原因。輸入數(shù)據(jù)的空間結(jié)構(gòu)得到保留,圖像中的模式(邊緣、紋理、形狀等)可以被學(xué)習(xí)。

這基本上是為什么在圖像上使用卷積神經(jīng)網(wǎng)絡(luò)的原因,但現(xiàn)在讓我們討論一下如何實(shí)現(xiàn)。讓我們來(lái)看看我們的輸入數(shù)據(jù)的結(jié)構(gòu),我們一直在談?wù)摰哪切┙凶?ldquo;濾波器”的東西,以及當(dāng)我們將它們放在一起時(shí)卷積是什么樣子。

輸入數(shù)據(jù)

CIFAR-10數(shù)據(jù)集包含60,000個(gè)32x32的彩色圖像,每個(gè)圖像都表示為一個(gè)3D張量。每個(gè)圖像將是一個(gè)(32,32,3)的張量,其中的維度是32(高度)x 32(寬度)x 3(R-G-B顏色通道)。下圖展示了從數(shù)據(jù)集中分離出來(lái)的飛機(jī)全彩色圖像的3個(gè)不同的顏色通道(RGB)。

通常將圖像視為二維的,所以很容易忘記它們實(shí)際上是以三維表示的,因?yàn)樗鼈冇?個(gè)顏色通道!

濾波器

在卷積層中,濾波器(也稱為卷積核或特征檢測(cè)器)是一組權(quán)重?cái)?shù)組,它以滑動(dòng)窗口的方式在圖像上進(jìn)行掃描,計(jì)算每一步的點(diǎn)積,并將該點(diǎn)積輸出到一個(gè)稱為特征圖的新數(shù)組中。這種滑動(dòng)窗口的掃描稱為卷積。讓我們看一下這個(gè)過(guò)程的示例,以幫助理解正在發(fā)生的事情。

一個(gè)3x3的濾波器(藍(lán)色)對(duì)輸入(紅色)進(jìn)行卷積,生成一個(gè)特征圖(紫色):

在每個(gè)卷積步驟中計(jì)算點(diǎn)積的示意圖:

需要注意的是,濾波器的權(quán)重在每個(gè)步驟中保持不變。就像在全連接層中的權(quán)重一樣,這些值在訓(xùn)練過(guò)程中進(jìn)行學(xué)習(xí),并通過(guò)反向傳播在每個(gè)訓(xùn)練迭代后進(jìn)行調(diào)整。

這些示意圖并不能完全展示所有情況。當(dāng)訓(xùn)練一個(gè)卷積神經(jīng)網(wǎng)絡(luò)時(shí),模型不僅在卷積層中使用一個(gè)濾波器是很常見(jiàn)的。通常在一個(gè)卷積層中會(huì)有32或64個(gè)濾波器,實(shí)際上,在本教程中,我們將在一個(gè)層中使用多達(dá)96個(gè)濾波器來(lái)構(gòu)建我們的模型。

最后,雖然濾波器的權(quán)重是需要訓(xùn)練的主要參數(shù),但卷積神經(jīng)網(wǎng)絡(luò)也有一些可以調(diào)整的超參數(shù):

層中的濾波器數(shù)量

濾波器的維度

步幅(每一步濾波器移動(dòng)的像素?cái)?shù))

填充(濾波器如何處理圖像邊界)

我們不會(huì)詳細(xì)討論這些超參數(shù),因?yàn)楸疚牟恢荚谌娼榻B卷積神經(jīng)網(wǎng)絡(luò),但這些是需要注意的重要因素。

池化層

池化層與卷積層類似,都是通過(guò)濾波器對(duì)輸入數(shù)據(jù)(通常是從卷積層輸出的特征圖)進(jìn)行卷積運(yùn)算。

然而,池化層的功能不是特征檢測(cè),而是降低維度或降采樣。最常用的兩種池化方法是最大池化和平均池化。在最大池化中,濾波器在輸入上滑動(dòng),并在每一步選擇具有最大值的像素作為輸出。在平均池化中,濾波器輸出濾波器所經(jīng)過(guò)像素的平均值。

全連接層

最后,在卷積和池化層之后,卷積神經(jīng)網(wǎng)絡(luò)通常會(huì)有全連接層,這些層將在圖像分類任務(wù)中執(zhí)行分類,就像本教程中的任務(wù)一樣。

現(xiàn)在,我們已經(jīng)了解了卷積神經(jīng)網(wǎng)絡(luò)的結(jié)構(gòu)和操作方式,讓我們開(kāi)始進(jìn)行有趣的部分,在PyTorch中訓(xùn)練我們自己的CNN模型!

設(shè)置

與本教程的第一部分一樣,我建議使用Google Colab進(jìn)行跟隨,因?yàn)槟愕腜ython環(huán)境已經(jīng)安裝了PyTorch和其他庫(kù),并且有一個(gè)GPU可以用于訓(xùn)練模型。

因此,如果你使用的是Colab,請(qǐng)確保使用GPU,方法是轉(zhuǎn)到“運(yùn)行時(shí)”(Runtime)并點(diǎn)擊“更改運(yùn)行時(shí)類型”。

在對(duì)話框中選擇GPU并保存。

現(xiàn)在你可以在Colab中使用GPU了,并且我們可以使用PyTorch驗(yàn)證你的設(shè)備。

因此,首先,讓我們處理導(dǎo)入部分:

import torch

from torch import nn

from torch.utils.data import DataLoader

from torchvision.utils import make_grid

from torchvision.datasets import CIFAR10

from torchvision import transforms

from torchvision import utils

from torchvision.utils import make_grid

import matplotlib.pyplot as plt

import numpy as np

import seaborn as sns

import pandas as pd

如果你想檢查你可以訪問(wèn)的GPU是什么,請(qǐng)鍵入并執(zhí)行torch.cuda.get_device_name(0),你應(yīng)該會(huì)看到設(shè)備輸出。Colab有幾種不同的GPU選項(xiàng)可供選擇,因此你的輸出將根據(jù)你所能訪問(wèn)的內(nèi)容而有所不同,但只要你在運(yùn)行此代碼時(shí)沒(méi)有看到“RuntimeError: No CUDA GPUs are available”錯(cuò)誤,那么你正在使用GPU!

我們可以將GPU設(shè)備設(shè)置為device,以便在開(kāi)發(fā)模型時(shí)將其分配給GPU,如果沒(méi)有CUDA GPU設(shè)備可用,我們也可以使用CPU。

device = "cuda" if torch.cuda.is_available() else "cpu"

print(device)

# cuda

接下來(lái),讓我們?cè)O(shè)置一個(gè)隨機(jī)種子,以便我們的結(jié)果是可重現(xiàn)的,并下載我們的訓(xùn)練數(shù)據(jù)并設(shè)置一個(gè)轉(zhuǎn)換,將圖像轉(zhuǎn)換為張量并對(duì)數(shù)據(jù)進(jìn)行歸一化。

torch.manual_seed(42)

transform = transforms.Compose(

    [transforms.ToTensor(),

     transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))]

)

training_data = CIFAR10(root="cifar",

                        train = True,

                        download = True,

                        transform=transform)

test_data = CIFAR10(root = "cifar",

                    train = False,

                    download = True,

                    transform = transform)

一旦下載完成,讓我們查看數(shù)據(jù)集中的類別:

classes = training_data.classes

classes

#['airplane',

# 'automobile',

# 'bird',

# 'cat',

# 'deer',

# 'dog',

# 'frog',

# 'horse',

# 'ship',

# 'truck']

最后,讓我們?cè)O(shè)置訓(xùn)練和測(cè)試數(shù)據(jù)加載器:

batch_size = 24

train_dataloader = DataLoader(training_data, batch_size=batch_size, shuffle=True, num_workers=0)

test_dataloader = DataLoader(test_data, batch_size=batch_size, shuffle=True, num_workers=0)

for X, y in train_dataloader:

  print(f"Shape of X [N, C, H, W]: {X.shape}")

  print(f"Shape of y: {y.shape} {y.dtype}")

  break

#Shape of X [N, C, H, W]: torch.Size([24, 3, 32, 32])

#Shape of y: torch.Size([24]) torch.int64

現(xiàn)在我們準(zhǔn)備構(gòu)建我們的模型!

構(gòu)建CNN

在PyTorch中,nn.Conv2d是用于圖像輸入數(shù)據(jù)的卷積層。Conv2d的第一個(gè)參數(shù)是輸入中的通道數(shù),在我們的第一層卷積層中,我們將使用3,因?yàn)椴噬珗D像將有3個(gè)顏色通道。

在第一個(gè)卷積層之后,該參數(shù)將取決于前一層輸出的通道數(shù)。第二個(gè)參數(shù)是在該層中卷積操作輸出的通道數(shù)。這些通道是卷積層介紹中討論的特征圖。最后,第三個(gè)參數(shù)將是卷積核或?yàn)V波器的大小。這可以是一個(gè)整數(shù)值,如3表示3x3的卷積核,或者是一個(gè)元組,如(3,3)。因此,我們的卷積層將采用nn.Conv2d(in_channels, out_channels, kernel_size)的形式。還可以添加其他可選參數(shù),包括(但不限于)步幅(stride)、填充(padding)和膨脹(dilation)。在我們的卷積層conv4中,我們將使用stride=2。

在一系列卷積層之后,我們將使用一個(gè)扁平化層將特征圖扁平化,以便能夠輸入到線性層中。為此,我們將使用nn.Flatten()。我們可以使用nn.BatchNorm1d()應(yīng)用批量歸一化,并需要將特征數(shù)作為參數(shù)傳遞。

最后,我們使用nn.Linear()構(gòu)建線性的全連接層,第一個(gè)參數(shù)是特征數(shù),第二個(gè)參數(shù)是指定輸出特征數(shù)。

因此,要開(kāi)始定義我們模型的基本架構(gòu),我們將定義一個(gè)ConvNet類,該類繼承自PyTorch的nn.Module類。然后,我們可以將每個(gè)層定義為類的屬性,并根據(jù)需要構(gòu)建它們。

一旦我們指定了層的架構(gòu),我們可以通過(guò)創(chuàng)建一個(gè)forward()方法來(lái)定義模型的流程。我們可以使用激活函數(shù)包裝每個(gè)層,在我們的情況下,我們將使用relu。我們可以通過(guò)傳遞前一層和p(元素被丟棄的概率,缺省值為0.5)在層之間應(yīng)用dropout。

最后,我們創(chuàng)建模型對(duì)象并將其附加到設(shè)備上,以便可以在GPU上訓(xùn)練。

class ConvNet(nn.Module):

  def __init__(self):

    super().__init__()

    self.d1 = 0.1

    self.conv1 = nn.Conv2d(3, 48, 3)

    self.conv2 = nn.Conv2d(48, 48, 3)

    self.conv3 = nn.Conv2d(48, 96, 3)

    self.conv4 = nn.Conv2d(96, 96, 3, stride=2)

    self.flat = nn.Flatten()

    self.batch_norm = nn.BatchNorm1d(96 * 12 * 12)

    self.fc1 = nn.Linear(96 * 12 * 12, 256)

    self.fc2 = nn.Linear(256, 10)

  def forward(self, x):

    x = nn.functional.relu(self.conv1(x))

    x = nn.functional.relu(self.conv2(x))

    x = nn.functional.dropout(x, self.d1)

    x = nn.functional.relu(self.conv3(x))

    x = nn.functional.relu(self.conv4(x))

    x = nn.functional.dropout(x, 0.5)

    x = self.flat(x)

    x = nn.functional.relu(self.batch_norm(x))

    x = nn.functional.relu(self.fc1(x))

    x = self.fc2(x)

    return x

model = ConvNet().to(device)

訓(xùn)練和測(cè)試函數(shù)

如果你完成了本教程的第一部分,我們的訓(xùn)練和測(cè)試函數(shù)將與之前創(chuàng)建的函數(shù)相同,只是在訓(xùn)練方法中返回?fù)p失,而在測(cè)試方法中返回?fù)p失和正確數(shù)量,以便在調(diào)整超參數(shù)時(shí)使用。

# Train Method

def train(dataloader, model, loss_fn, optimizer, verbose=True):

    size = len(dataloader.dataset)

    model.train()

    for batch, (X, y) in enumerate(dataloader):

        X, y = X.to(device), y.to(device)

        # Compute prediction error

        pred = model(X)

        loss = loss_fn(pred, y)

        # Backpropagation

        optimizer.zero_grad()

        loss.backward()

        optimizer.step()

        if verbose == True:

          if batch % 50 == 0:

              loss, current = loss.item(), batch * len(X)

              print(f"loss: {loss:>7f}  [{current:>5d}/{size:>5d}]")

    return loss

# Test Method

def test(dataloader, model, loss_fn, verbose=True):

    size = len(dataloader.dataset)

    num_batches = len(dataloader)

    model.eval()

    test_loss, correct = 0, 0

    with torch.no_grad():

        for X, y in dataloader:

            X, y = X.to(device), y.to(device)

            pred = model(X)

            test_loss += loss_fn(pred, y).item()

            correct += (pred.argmax(1) == y).type(torch.float).sum().item()

    test_loss /= num_batches

    correct /= size

    if verbose == True:

        print(f"Test Error:  Accuracy: {(100*correct):>0.1f}%, Avg loss: {test_loss:>8f} ")

    return test_loss, correct # For reporting tuning results/ early stopping

最后,在基本模型訓(xùn)練之前,我們定義損失函數(shù)和優(yōu)化器。

loss_fn = nn.CrossEntropyLoss()

optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

讓我們訓(xùn)練模型。

epochs = 10

for t in range(epochs):

    print(f"Epoch {t+1}-------------------------------")

    train(train_dataloader, model, loss_fn, optimizer)

    test(test_dataloader, model, loss_fn)

print("Done!")

僅經(jīng)過(guò)10個(gè)epochs,61.7%的性能比我們訓(xùn)練的全連接模型要好得多!很明顯,CNN更適合用于圖像分類,但我們可以通過(guò)延長(zhǎng)訓(xùn)練時(shí)間和調(diào)整超參數(shù)來(lái)進(jìn)一步提高性能。

在進(jìn)行這些之前,讓我們快速看看模型內(nèi)部是什么樣子。請(qǐng)記住,濾波器的像素是我們模型中可訓(xùn)練的參數(shù)。這不是訓(xùn)練圖像分類模型的必要步驟,也不會(huì)得到太多有用的信息,但是了解模型內(nèi)部的情況還是挺有意思的。

可視化濾波器

我們可以編寫(xiě)一個(gè)函數(shù)來(lái)繪制模型中指定層的濾波器。我們只需要指定要查看的層,并將其傳遞給我們的函數(shù)。

def visualizeTensor(tensor, ch=0, all_kernels=False, nrow=8, padding=1): 

    n,c,w,h = tensor.shape

    if all_kernels: 

        tensor = tensor.view(n*c, -1, w, h)

    elif c != 3: 

        tensor = tensor[:,ch,:,:].unsqueeze(dim=1)

    rows = np.min((tensor.shape[0] // nrow + 1, 64))    

    grid = utils.make_grid(tensor, 

                           nrow=nrow, 

                           normalize=True, 

                           padding=padding)

    grid = grid.cpu() # back to cpu for numpy and plotting

    plt.figure( figsize=(nrow,rows) )

    plt.imshow(grid.numpy().transpose((1, 2, 0)))

讓我們來(lái)看看第一個(gè)卷積層(conv1)中的濾波器是什么樣子,因?yàn)檫@些濾波器直接應(yīng)用于圖像。

filter = model.conv1.weight.data.clone()

visualizeTensor(filter)

plt.axis('off')

plt.ioff()

plt.show

下面是輸出,包含了我們的conv1卷積層中48個(gè)濾波器的可視化。我們可以看到每個(gè)濾波器都是一個(gè)不同值或顏色的3x3張量。

如果我們的濾波器是5x5的,我們會(huì)在繪圖中看到以下差異。請(qǐng)記住,使用nn.Conv2d我們可以使用第三個(gè)參數(shù)更改濾波器的大小,因此如果我們想要一個(gè)5x5的濾波器,conv1將如下所示:

self.conv1 = nn.Conv2d(3, 48, 5) # New Kernel Size

如果我們用新的5x5濾波器重新訓(xùn)練模型,輸出將如下所示:

如我之前提到的,這里并沒(méi)有太多有用的信息,但還是很有趣可以看到這些。

超參數(shù)優(yōu)化

在本教程中,我們將調(diào)整的超參數(shù)是卷積層中的濾波器數(shù)量以及線性層中的神經(jīng)元數(shù)量。當(dāng)前這些值在我們的模型中是硬編碼的,所以為了使它們可調(diào)整,我們需要使我們的模型可配置。

我們可以在模型的__init__方法中使用參數(shù)(c1、c2和l1),并使用這些值創(chuàng)建模型的層,在調(diào)整過(guò)程中將動(dòng)態(tài)傳遞這些值。

class ConfigNet(nn.Module):

  def __init__(self, l1=256, c1=48, c2=96, d1=0.1):

    super().__init__()

    self.d1 = d1

    self.conv1 = nn.Conv2d(3, c1, 3)

    self.conv2 = nn.Conv2d(c1, c1, 3)

    self.conv3 = nn.Conv2d(c1, c2, 3)

    self.conv4 = nn.Conv2d(c2, c2, 3, stride=2)

    self.flat = nn.Flatten()

    self.batch_norm = nn.BatchNorm1d(c2 * 144)

    self.fc1 = nn.Linear(c2 * 144, l1)

    self.fc2 = nn.Linear(l1, 10)

  def forward(self, x):

    x = nn.functional.relu(self.conv1(x))

    x = nn.functional.relu(self.conv2(x))

    x = nn.functional.dropout(x, self.d1)

    x = nn.functional.relu(self.conv3(x))

    x = nn.functional.relu(self.conv4(x))

    x = nn.functional.dropout(x, 0.5)

    x = self.flat(x)

    x = nn.functional.relu(self.batch_norm(x))

    x = nn.functional.relu(self.fc1(x))

    x = self.fc2(x)

    return x

model = ConfigNet().to(device)

當(dāng)然,我們不僅限于調(diào)整這些超參數(shù)。事實(shí)上,學(xué)習(xí)率和批量大小通常也包括在要調(diào)整的超參數(shù)列表中,但由于我們將使用網(wǎng)格搜索,為了保持訓(xùn)練時(shí)間合理,我們必須大大減少可調(diào)整的變量數(shù)量。

接下來(lái),讓我們?yōu)樗阉骺臻g定義一個(gè)字典,并保存給我們最佳結(jié)果的參數(shù)。由于我們使用網(wǎng)格搜索進(jìn)行優(yōu)化,將使用每個(gè)超參數(shù)組合的所有組合。

你可以輕松地向每個(gè)超參數(shù)的列表中添加更多值,但每個(gè)額外的值都會(huì)大大增加運(yùn)行時(shí)間,因此建議從以下值開(kāi)始以節(jié)省時(shí)間。

search_space = {

    'c1': [48, 96],

    'c2': [96, 192],

    'l1': [256, 512],

}

best_results = {

    'c1': None,

    'c2': None,

    'l1': None,

    'loss': None,

    'acc': 0

}

提前停止

優(yōu)化過(guò)程中一個(gè)重要的組成部分是使用提前停止。由于我們將進(jìn)行多次訓(xùn)練運(yùn)行,每次訓(xùn)練運(yùn)行時(shí)間都很長(zhǎng),如果訓(xùn)練性能沒(méi)有改善,我們將希望提前結(jié)束訓(xùn)練。繼續(xù)訓(xùn)練一個(gè)沒(méi)有改善的模型是沒(méi)有意義的。

實(shí)質(zhì)上,我們將在每個(gè)時(shí)期之后跟蹤模型產(chǎn)生的最低損失。然后,我們定義一個(gè)容差,指定模型必須在多少個(gè)時(shí)期內(nèi)達(dá)到更好的損失。如果在指定的容差內(nèi)沒(méi)有實(shí)現(xiàn)更低的損失,將終止該運(yùn)行的訓(xùn)練,并繼續(xù)下一個(gè)超參數(shù)組合。

如果你像我一樣,喜歡檢查訓(xùn)練過(guò)程,可以設(shè)置self.verbose = True來(lái)記錄控制臺(tái)上的更新,并查看提前停止計(jì)數(shù)器增加的情況。你可以在此處硬編碼到EarlyStopping類中,也可以在優(yōu)化過(guò)程中實(shí)例化EarlyStopping對(duì)象時(shí)更改verbose值。

class EarlyStopping():

    def __init__(self, tolerance=5, verbose=False, path="cifar-tune.pth"):

      self.tolerance = tolerance

      self.counter = 0

      self.early_stop = False

      self.lowest_loss = None

      self.verbose = verbose

      self.path = path

    def step(self, val_loss):

      if (self.lowest_loss == None):

        self.lowest_loss = val_loss

        torch.save(model.state_dict(), self.path)

      elif (val_loss < self.lowest_loss):

        self.lowest_loss = val_loss

        self.counter = 0

        torch.save(model.state_dict(), self.path)

      else:

        if self.verbose:

          print("Early stop counter: {}".format(self.counter+1))

        self.counter +=1

        if self.counter >= self.tolerance:

          self.early_stop = True

          if self.verbose:

            print('Early stopping executed.')

圖像增強(qiáng)

在設(shè)置超參數(shù)優(yōu)化方法之前,我們還有最后一件事要做,以提取出一些額外的性能并避免在訓(xùn)練數(shù)據(jù)上過(guò)度擬合。圖像增強(qiáng)是一種將隨機(jī)變換應(yīng)用于圖像的技術(shù),從本質(zhì)上講,它會(huì)創(chuàng)建“新的”人工數(shù)據(jù)。這些變換可以是以下幾種:

旋轉(zhuǎn)圖像幾度

水平/垂直翻轉(zhuǎn)圖像

裁剪

輕微的亮度/色調(diào)變化

隨機(jī)縮放

包含這些隨機(jī)變換將提高模型的泛化能力,因?yàn)樵鰪?qiáng)后的圖像將與原始圖像類似,但不同。內(nèi)容和模式將保持不變,但數(shù)組表示將有所不同。

PyTorch通過(guò)torchvision.transforms模塊使圖像增強(qiáng)變得很容易。如果我們想要應(yīng)用多個(gè)變換,可以使用Compose將它們鏈接在一起。

需要記住的一點(diǎn)是,圖像增強(qiáng)對(duì)每個(gè)變換需要一點(diǎn)計(jì)算量,并且這些計(jì)算量應(yīng)用于數(shù)據(jù)集中的每個(gè)圖像。將許多不同的隨機(jī)變換應(yīng)用于我們的數(shù)據(jù)集將增加訓(xùn)練時(shí)間。

因此,現(xiàn)在讓我們限制變換的數(shù)量,以便訓(xùn)練時(shí)間不會(huì)太長(zhǎng)。如果你想添加更多變換,請(qǐng)查看PyTorch關(guān)于轉(zhuǎn)換和增強(qiáng)圖像的文檔,然后將它們添加到Compose列表中。

選擇了增強(qiáng)變換之后,我們可以像應(yīng)用規(guī)范化和將圖像轉(zhuǎn)換為張量一樣將它們應(yīng)用于數(shù)據(jù)集。

# Augment Images for the train set

augmented = transforms.Compose([

    transforms.RandomRotation(20),

    transforms.ColorJitter(brightness=0.2, hue=0.1),

    transforms.RandomHorizontalFlip(p=0.5),

    transforms.ToTensor(),

    transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))

])

# Standard transformation for validation set

transform = transforms.Compose([

    transforms.ToTensor(),

    transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))

])

training_data = CIFAR10(root="cifar",

                        train = True,

                        download = True,

                        transform=augmented)

test_data = CIFAR10(root = "cifar",

                    train = False,

                    download = True,

                    transform = transform)

現(xiàn)在我們已經(jīng)在訓(xùn)練數(shù)據(jù)上設(shè)置了圖像增強(qiáng),我們準(zhǔn)備設(shè)置我們的超參數(shù)優(yōu)化方法。

定義優(yōu)化方法

我們可以創(chuàng)建一個(gè)類(HyperSearch),其中包含超參數(shù)值配置、詳細(xì)報(bào)告設(shè)置、報(bào)告列表(以便在優(yōu)化完成后查看每個(gè)配置的表現(xiàn))的屬性,以及一個(gè)變量來(lái)存儲(chǔ)具有最佳性能的配置。

class HyperSearch():

    def __init__(self, config, verbose=True):

      self.config = config

      self.verbose = verbose

      self.report_list = []

      self.best_results = { 'c1': None,

                            'c2': None,

                            'l1': None,

                            'loss': None,

                            'acc': 0

                          }

接下來(lái),我們可以創(chuàng)建一個(gè)方法(仍在HyperSearch類中),以執(zhí)行網(wǎng)格搜索,并對(duì)每個(gè)超參數(shù)組合進(jìn)行訓(xùn)練運(yùn)行。首先,我們將使用tolerance=3配置EarlyStopping,并設(shè)置它保存每個(gè)超參數(shù)組合的權(quán)重。如果我們將self.verbose設(shè)置為T(mén)rue,我們可以在控制臺(tái)中看到當(dāng)前正在訓(xùn)練的超參數(shù)組合。

之后,我們使用我們?cè)O(shè)計(jì)的CoinfigNet模型定義我們的模型,并傳遞l1、c1和c2的值,同時(shí)選擇損失函數(shù)和優(yōu)化器,并設(shè)置我們的訓(xùn)練和驗(yàn)證DataLoader。由于我們沒(méi)有時(shí)間也沒(méi)有意愿完全訓(xùn)練每個(gè)組合,所以我們將保持較低的時(shí)期數(shù)。目標(biāo)是了解哪種組合在對(duì)數(shù)據(jù)集進(jìn)行分類時(shí)效果最好,然后我們可以將該模型完全訓(xùn)練,以查看它在完整的訓(xùn)練周期中的性能。

    # Optimization Method

    def optimize(self):

        for l1 in self.config['l1']:

            for c1 in self.config['c1']:

                for c2 in self.config['c2']:

                    early_stopping = EarlyStopping(tolerance=3, verbose=False, path="{}-{}-{}.pth".format(c1, c2, l1))

                    if self.verbose == True:

                        print('Conv1: {} | Conv2: {} | Lin1: {}'.format(str(c1), str(c2), str(l1)))

                    

                    model = ConfigNet(l1=l1, c1=c1, c2=c2).to(device)

                    loss_fn = nn.CrossEntropyLoss()

                    optimizer = torch.optim.Adam(model.parameters(), lr=lrate)

                    train_dataloader = DataLoader(training_data, batch_size=batch_sz, shuffle=True, num_workers=0)

                    test_dataloader = DataLoader(test_data, batch_size=batch_sz, shuffle=True, num_workers=0)

現(xiàn)在,我們定義訓(xùn)練循環(huán),大部分與之前相同,只是現(xiàn)在我們將保存train和test方法的損失,以便early_stopping可以跟蹤訓(xùn)練進(jìn)展(或缺乏進(jìn)展)。最后,在每個(gè)時(shí)期之后,將結(jié)果保存到報(bào)告中,并更新最佳損失的值。

                    epochs = 10

                    for t in range(epochs):

                        if self.verbose == True:

                            print(f"Epoch {t+1}-------------------------------")

                        train_loss = train(train_dataloader, model, loss_fn, optimizer, verbose=self.verbose)

                        test_loss, test_acc = test(test_dataloader, model, loss_fn, verbose=self.verbose)

                        # Early Stopping

                        early_stopping.step(test_loss)

                        if early_stopping.early_stop:

                          break

                    print("Done!")

                    self.append_to_report(test_acc, test_loss, c1, c2, l1)

                    if self.best_results['loss'] == None or test_loss < self.best_results['loss']:

                        if self.verbose == True:

                            print("UPDATE: Best loss changed from {} to {}".format(self.best_results['loss'], test_loss))

                        self.best_results.update({

                            'c1': c1,

                            'c2': c2,

                            'loss': test_loss,

                            'l1': l1,

                            'acc': test_acc

                        })

        self.report()

我們可以將整個(gè)超參數(shù)優(yōu)化周期的結(jié)果輸出到一個(gè)漂亮的表格中,在表格中,我們可以看到每次運(yùn)行的超參數(shù)配置,以及相應(yīng)的損失和準(zhǔn)確率。

    def report(self):

        print("""

|-----------------------------------------------------------------------------------------------------|

|                                                                                                     |

|                              Report for hyperparameter optimization                                 |

|                                                                                                     |

|-----------------------------------------------------------------------------------------------------|

|    RUN     |              PERFORMANCE             |                   CONFIGURATION                 |

|------------|--------------------------------------|-------------------------------------------------|""")

        for idx, item in enumerate(self.report_list):

            

            print("|   Run {:02d}   |  Accuracy: {:.2f}%   |   Loss: {:.2f}   |  Conv-1: {}  |  Conv-2: {:3}  |  Linear-1: {:>4}  |".format(idx,

                                                                                                                                       item[0]*100,

                                                                                                                                       item[1],

                                                                                                                                       item[2],

                                                                                                                                       item[3],

                                                                                                                                       item[4]))

            print("|------------|---------------------|----------------|--------------|---------------|------------------|")

            

        print("Best Results | Accuracy: {:.2f}%  |  Loss: {:.2f}  |  Conv-1: {}   |  Conv-2: {}  |  Linear-1: {:>4}  |".format(self.best_results['acc']*100,

                                                                                                                                              self.best_results['loss'],

                                                                                                                                              self.best_results['c1'],

                                                                                                                                              self.best_results['c2'],

                                                                                                                                              self.best_results['l1']))

    def append_to_report(self, acc, loss, c1, c2, l1):

        list_set = (acc, loss, c1, c2, l1)

        self.report_list.append(list_set)

因此,將所有這些代碼放在一起,我們的HyperSearch類應(yīng)該如下所示:

class HyperSearch():

    def __init__(self, config, verbose=True):

      self.config = config

      self.verbose = verbose

      self.report_list = []

      self.best_results = { 'c1': None,

                            'c2': None,

                            'l1': None,

                            'loss': None,

                            'acc': 0

                            # 'd1': None,

                            # 'lr': None,

                            # 'bsz': None,

                          }

    # Optimization Method

    def optimize(self):

        for l1 in self.config['l1']:

            for c1 in self.config['c1']:

                for c2 in self.config['c2']:

                    early_stopping = EarlyStopping(tolerance=3, verbose=False, path="{}-{}-{}.pth".format(c1, c2, l1))

                    if self.verbose == True:

                        print('Conv1: {} | Conv2: {} | Lin1: {}'.format(str(c1), str(c2), str(l1)))

                    model = ConfigNet(l1=l1, c1=c1, c2=c2).to(device)

                    loss_fn = nn.CrossEntropyLoss()

                    optimizer = torch.optim.Adam(model.parameters(), lr=lrate)

                    train_dataloader = DataLoader(training_data, batch_size=batch_sz, shuffle=True, num_workers=0)

                    test_dataloader = DataLoader(test_data, batch_size=batch_sz, shuffle=True, num_workers=0)

                    epochs = 10

                    for t in range(epochs):

                        if self.verbose == True:

                            print(f"Epoch {t+1}-------------------------------")

                        train_loss = train(train_dataloader, model, loss_fn, optimizer, verbose=self.verbose)

                        test_loss, test_acc = test(test_dataloader, model, loss_fn, verbose=self.verbose)

                        # Early Stopping

                        early_stopping.step(test_loss)

                        if early_stopping.early_stop:

                          break

                    print("Done!")

                    self.append_to_report(test_acc, test_loss, c1, c2, l1)

                    if self.best_results['loss'] == None or test_loss < self.best_results['loss']:

                        if self.verbose == True:

                            print("UPDATE: Best loss changed from {} to {}".format(self.best_results['loss'], test_loss))

                        self.best_results.update({

                            'c1': c1,

                            'c2': c2,

                            'loss': test_loss,

                            'l1': l1,

                            'acc': test_acc

                        })

        self.report()

    def report(self):

        print("""

|-----------------------------------------------------------------------------------------------------|

|                                                                                                     |

|                              Report for hyperparameter optimization                                 |

|                                                                                                     |

|-----------------------------------------------------------------------------------------------------|

|    RUN     |              PERFORMANCE             |                   CONFIGURATION                 |

|------------|--------------------------------------|-------------------------------------------------|""")

        for idx, item in enumerate(self.report_list):

            

            print("|   Run {:02d}   |  Accuracy: {:.2f}%   |   Loss: {:.2f}   |  Conv-1: {}  |  Conv-2: {:3}  |  Linear-1: {:>4}  |".format(idx,

                                                                                                                                       item[0]*100,

                                                                                                                                       item[1],

                                                                                                                                       item[2],

                                                                                                                                       item[3],

                                                                                                                                       item[4]))

            print("|------------|---------------------|----------------|--------------|---------------|------------------|")

        print("Best Results | Accuracy: {:.2f}%  |  Loss: {:.2f}  |  Conv-1: {}   |  Conv-2: {}  |  Linear-1: {:>4}  |".format(self.best_results['acc']*100,

                                                                                                                                              self.best_results['loss'],

                                                                                                                                              self.best_results['c1'],

                                                                                                                                              self.best_results['c2'],

                                                                                                                                              self.best_results['l1']))

    def append_to_report(self, acc, loss, c1, c2, l1):

        list_set = (acc, loss, c1, c2, l1)

        self.report_list.append(list_set)

調(diào)整

現(xiàn)在我們可以調(diào)整超參數(shù)了!通過(guò)使用%%time,在整個(gè)調(diào)整過(guò)程執(zhí)行完成后,我們可以看到整個(gè)過(guò)程花費(fèi)的時(shí)間。讓我們保持學(xué)習(xí)率lrate=0.001和批量大小batch_sz=512,用我們之前定義的search_space實(shí)例化HyperSearch類,將verbose設(shè)置為T(mén)rue或False(根據(jù)你的喜好),然后調(diào)用optimize()方法開(kāi)始調(diào)優(yōu)。

注意:在我的機(jī)器上(NVIDIA RTX 3070),完成這個(gè)過(guò)程大約需要50分鐘,所以如果你使用的是Colab上提供的GPU,可能需要大致相同的時(shí)間。

%%time

lrate=0.001

batch_sz=512

hyper_search = HyperSearch(search_space, verbose=True)

hyper_search.optimize()

完成整個(gè)優(yōu)化周期后,你應(yīng)該得到一個(gè)如下所示的表格:

結(jié)果

從表格中可以看出,最佳結(jié)果來(lái)自于Run 00,它具有c1=48、c2=96和l1=256。損失為0.84,準(zhǔn)確率為71.24%,這是一個(gè)不錯(cuò)的改進(jìn),尤其是考慮到只有10個(gè)時(shí)期!

因此,現(xiàn)在我們已經(jīng)找到了在10個(gè)時(shí)期內(nèi)性能最佳的超參數(shù),讓我們對(duì)這個(gè)模型進(jìn)行微調(diào)!我們可以在更多的時(shí)期內(nèi)訓(xùn)練它,并稍微降低學(xué)習(xí)率,以嘗試獲得更好的性能。

所以首先,讓我們定義我們想要使用的模型,并設(shè)置批量大小和學(xué)習(xí)率:

class ConfigNet(nn.Module):

  def __init__(self, l1=256, c1=48, c2=96, d1=0.1):

    super().__init__()

    self.d1 = d1

    self.conv1 = nn.Conv2d(3, c1, 3)

    self.conv2 = nn.Conv2d(c1, c1, 3)

    self.conv3 = nn.Conv2d(c1, c2, 3)

    self.conv4 = nn.Conv2d(c2, c2, 3, stride=2)

    self.flat = nn.Flatten()

    self.batch_norm = nn.BatchNorm1d(c2 * 144)

    self.fc1 = nn.Linear(c2 * 144, l1)

    self.fc2 = nn.Linear(l1, 10)

  def forward(self, x):

    x = nn.functional.relu(self.conv1(x))

    x = nn.functional.relu(self.conv2(x))

    x = nn.functional.dropout(x, self.d1)

    x = nn.functional.relu(self.conv3(x))

    x = nn.functional.relu(self.conv4(x))

    x = nn.functional.dropout(x, 0.5)

    x = self.flat(x)

    x = nn.functional.relu(self.batch_norm(x))

    x = nn.functional.relu(self.fc1(x))

    x = self.fc2(x)

    return x

model = ConfigNet().to(device)

model = ConfigNet(l1=256, c1=48, c2=96, d1=0.1).to(device)

batch_sz = 512

lrate = 0.0008

最后,我們可以將時(shí)期數(shù)設(shè)置為50,并更改保存權(quán)重的路徑。讓訓(xùn)練周期運(yùn)行起來(lái),如果進(jìn)展停滯,early stopping將終止訓(xùn)練。

%%time

early_stopping = EarlyStopping(tolerance=6, verbose=True, path="cifar-optimized-test.pth")

loss_fn = nn.CrossEntropyLoss()

optimizer = torch.optim.Adam(model.parameters(), lr=lrate)

train_dataloader = DataLoader(training_data, batch_size=batch_sz, shuffle=True, num_workers=0)

test_dataloader = DataLoader(test_data, batch_size=batch_sz, shuffle=True, num_workers=0)

epochs = 50

for t in range(epochs):

    print(f"Epoch {t+1}-------------------------------")

    train_loss = train(train_dataloader, model, loss_fn, optimizer)

    test_loss, test_acc = test(test_dataloader, model, loss_fn)

    # Early Stopping

    early_stopping.step(test_loss)

    if early_stopping.early_stop:

      break

print("Done!")

Early stopping應(yīng)該在達(dá)到50個(gè)時(shí)期之前終止訓(xùn)練,并且應(yīng)該達(dá)到約77%的準(zhǔn)確率。

現(xiàn)在,我們已經(jīng)調(diào)整了超參數(shù),找到了最佳配置,并對(duì)該模型進(jìn)行了微調(diào),現(xiàn)在是對(duì)模型的性能進(jìn)行更深入評(píng)估的時(shí)候了。

模型評(píng)估

在這種情況下,我們的測(cè)試數(shù)據(jù)集實(shí)際上是我們的驗(yàn)證數(shù)據(jù)。我們將重復(fù)使用我們的驗(yàn)證數(shù)據(jù)來(lái)評(píng)估模型,但通常在超參數(shù)調(diào)整之后,你將希望使用真實(shí)的測(cè)試數(shù)據(jù)進(jìn)行模型評(píng)估。

讓我們加載優(yōu)化后的模型,準(zhǔn)備沒(méi)有應(yīng)用任何圖像增強(qiáng)的test_dataloader,并運(yùn)行test()來(lái)進(jìn)行評(píng)估。

model = ConfigNet(l1=256, c1=48, c2=96, d1=0.1).to(device)

model.load_state_dict(torch.load("cifar-optimized-test.pth"))

loss_fn = nn.CrossEntropyLoss()

batch_sz = 512

test_dataloader = DataLoader(test_data, batch_size=batch_sz, shuffle=False, num_workers=0)

classes = test_data.classes

test_loss, test_acc = test(test_dataloader, model, loss_fn)

這應(yīng)該會(huì)輸出準(zhǔn)確率和損失:

總體性能不錯(cuò),但每個(gè)類別的性能對(duì)我們更有用。以下代碼將輸出數(shù)據(jù)集中每個(gè)類別的模型準(zhǔn)確率:

correct_pred = {classname: 0 for classname in classes}

total_pred = {classname: 0  for classname in classes}

with torch.no_grad():

    for data in test_dataloader:

        images, labels = data

        outputs = model(images.to(device))

        _, predictions = torch.max(outputs, 1)

    for label,prediction in zip(labels, predictions):

        if label == prediction:

            correct_pred[classes[label]] += 1

        total_pred[classes[label]] += 1

for classname, correct_count in correct_pred.items():

    accuracy = 100 * float(correct_count) / total_pred[classname]

    print(f'Accuracy for class {classname:5s}: {accuracy:.1f}%')

執(zhí)行此代碼塊將給出以下輸出:

我們的模型在飛機(jī)、汽車、青蛙、船和卡車類別上表現(xiàn)非常好。有趣的是,它在狗和貓這兩個(gè)類別上遇到了最大的困難,這也是前面這個(gè)系列中完全連接模型面臨的最棘手的類別。

混淆矩陣

我們可以通過(guò)混淆矩陣進(jìn)一步了解模型的性能。讓我們?cè)O(shè)置一個(gè)混淆矩陣,并進(jìn)行可視化。

num_classes = 10

confusion_matrix = torch.zeros(num_classes, num_classes)

with torch.no_grad():

    for i, (inputs, classes) in enumerate(test_dataloader):

        inputs = inputs.to(device)

        classes = classes.to(device)

        outputs = model(inputs)

        _, preds = torch.max(outputs, 1)

        for t, p in zip(classes.view(-1), preds.view(-1)):

                confusion_matrix[t.long(), p.long()] += 1

通過(guò)定義混淆矩陣,我們可以使用Seaborn庫(kù)來(lái)幫助我們可視化它。

plt.figure(figsize=(15,10))

cf_dataframe = pd.DataFrame(np.array(confusion_matrix, dtype='int'), index=test_data.classes, columns=test_data.classes)

heatmap = sns.heatmap(cf_dataframe, annot=True, fmt='g')

這個(gè)表格的兩個(gè)維度是“實(shí)際”和“預(yù)測(cè)”值。我們希望大部分?jǐn)?shù)據(jù)都在中心對(duì)角線上對(duì)齊,即實(shí)際和預(yù)測(cè)屬于同一類別。從錯(cuò)誤的預(yù)測(cè)中,我們可以看到模型經(jīng);煜埡凸罚@兩個(gè)類別的準(zhǔn)確率最低。

總數(shù)看起來(lái)不錯(cuò),但每個(gè)類別的精確度和召回率將為我們提供更有意義的數(shù)據(jù)。讓我們首先看一下每個(gè)類別的召回率。

每個(gè)類別的召回率cf = np.array(confusion_matrix)

norm_cf = cf / cf.astype(float).sum(axis=1)

plt.figure(figsize=(15,10))

cf_dataframe = pd.DataFrame(np.array(norm_cf, dtype='float64'), index=test_data.classes, columns=test_data.classes).astype(float)

heatmap = sns.heatmap(cf_dataframe, annot=True)

每個(gè)類別的精確度cf = np.array(confusion_matrix)

norm_cf = cf / cf.astype(float).sum(axis=0)

plt.figure(figsize=(15,10))

cf_dataframe = pd.DataFrame(np.array(norm_cf, dtype='float64'), index=test_data.classes, columns=test_data.classes).astype(float)

heatmap = sns.heatmap(cf_dataframe, annot=True)

樣本模型預(yù)測(cè)

最后,讓我們給模型提供幾張圖像,并檢查它的預(yù)測(cè)結(jié)果。讓我們創(chuàng)建一個(gè)函數(shù)來(lái)準(zhǔn)備我們的圖像數(shù)據(jù)以供查看:

def imshow(img):

    img = img / 2 + .05 # revert normalization for viewing

    npimg = img.numpy()

    plt.imshow(np.transpose(npimg, (1,2,0)))

    plt.show()

現(xiàn)在,我們可以準(zhǔn)備我們的測(cè)試數(shù)據(jù),并創(chuàng)建另一個(gè)函數(shù)來(lái)獲取n個(gè)樣本預(yù)測(cè)。

test_data = CIFAR10(root = "cifar",

                    train = False,

                    transform = transforms.ToTensor())

classes = test_data.classes

def sample_predictions(n = 4):

    test_dataloader = DataLoader(test_data, batch_size=n, shuffle=True, num_workers=0)

    dataiter = iter(test_dataloader)

    images, labels = dataiter.next()

    outputs = model(images.to(device))

    _, predicted = torch.max(outputs, 1)

    imshow(make_grid(images))

    print('[Ground Truth | Predicted]:', ' '.join(f'[{classes[labels[j]]:5s} | {classes[predicted[j]]:5s}]' for j in range(n)))

調(diào)用該函數(shù),傳遞你想要采樣的圖像數(shù)量。輸出將給出每個(gè)圖像的實(shí)際類別和預(yù)測(cè)類別,從左到右。

利用經(jīng)過(guò)超參數(shù)調(diào)優(yōu)和圖像增強(qiáng)的卷積網(wǎng)絡(luò),我們成功提高了在CIFAR-10數(shù)據(jù)集上的性能!感謝你的閱讀,希望你對(duì)PyTorch和用于圖像分類的卷積神經(jīng)網(wǎng)絡(luò)有所了解。這里提供了包含所有代碼的完整筆記本在GitHub上可用。

https://github.com/florestony54/intro-to-pytorch-2/blob/main/pytorch2_2.ipynb

       原文標(biāo)題 : PyTorch 2簡(jiǎn)介:卷積神經(jīng)網(wǎng)絡(luò)

聲明: 本文由入駐維科號(hào)的作者撰寫(xiě),觀點(diǎn)僅代表作者本人,不代表OFweek立場(chǎng)。如有侵權(quán)或其他問(wèn)題,請(qǐng)聯(lián)系舉報(bào)。

發(fā)表評(píng)論

0條評(píng)論,0人參與

請(qǐng)輸入評(píng)論內(nèi)容...

請(qǐng)輸入評(píng)論/評(píng)論長(zhǎng)度6~500個(gè)字

您提交的評(píng)論過(guò)于頻繁,請(qǐng)輸入驗(yàn)證碼繼續(xù)

  • 看不清,點(diǎn)擊換一張  刷新

暫無(wú)評(píng)論

暫無(wú)評(píng)論

人工智能 獵頭職位 更多
掃碼關(guān)注公眾號(hào)
OFweek人工智能網(wǎng)
獲取更多精彩內(nèi)容
文章糾錯(cuò)
x
*文字標(biāo)題:
*糾錯(cuò)內(nèi)容:
聯(lián)系郵箱:
*驗(yàn) 證 碼:

粵公網(wǎng)安備 44030502002758號(hào)