10 분 소요

딥러닝 이론 공부 후에 프로젝트를 진행하며 실습의 부족함을 느껴, 파이토치 실습 위주의 학습을 진행하려고 한다.

교재는 “텐초의 파이토치 딥러닝 특강”을 참고하였다. 더 자세히 학습하고 싶은 사람은, 이론 부분을 따로 참고해서 읽길 권한다.

주석을 상세히 적으며 설명하였지만, 초반부분(1단계)에 이미 설명한 부분은 설명을 생략하였다.

2단계: 입문용 신경망 3총사 CNN, ResNet, RNN

1. 데이터 전처리하기 (데이터 증강, 정규화)

  • 핵심 용어
    • 합성곱: 작은 필터를 이용해 이미지로부터 특징을 뽑아내는 알고리즘이다.

    • CNN: 합성곱층을 반복적으로 쌓아서 만든 인공 신경망이다.

    • 특징 맵: 합성곱층의 결과이다.

    • 데이터 증강: 이미지를 회전시키거나 잘라내는 등, 데이터 하나로 여러 가지 형태의 다른 데이터를 만들어 개수를 늘리는 기법이다.

    • 데이터 전처리: 학습에 이용되기 이전에 처리하는 모든 기법을 의미한다. 데이터 증강도 데이터 전처리의 일종이다.

    • 이미지 정규화: 이미지 픽셀 간 편향을 제거하는 데 사용한다. 각 채널의 분포가 동일해지므로 학습이 원활하게 이루어진다.

    • 패딩: 이미지 외곽을 0으로 채우는 기법이다. 합성곱 전후 이미지 크기를 같게 만든다.

    • 크롭핑: 이미지의 일부분을 잘라내는 것을 의미한다.

    • 최대 풀링은 이미지 크기를 줄이는 데 사용하는 기법으로 커널에서 가장 큰 값을 이용한다.

    • 전이 학습: 사전 학습된 모델의 파라미터를 수정해 자신의 데이터셋에 최적화시키는 방법이다. 학습에 걸리는 시간을 단축할 수 있다.

1.1 데이터 증강 (크롭핑, 좌우대칭 등)

# 데이터 전처리에 크롭핑과 좌우대칭 추가
import matplotlib.pyplot as plt
import torchvision.transforms as T

from torchvision.datasets.cifar import CIFAR10
from torchvision.transforms import Compose
from torchvision.transforms import RandomHorizontalFlip, RandomCrop

transforms = Compose([ # 데이터 전처리 함수
    T.ToPILImage(), # 텐서에서 PIL로 변환
    RandomCrop((32, 32), padding=4), # 랜덤으로 이미지 일부 제거 후 패딩
    RandomHorizontalFlip(p=0.5) # y축 기준으로 대칭
])

# CIFAR-10 데이터셋 불러오기
training_data = CIFAR10(
    root='./',
    train=True,
    download=True,
    transform=transforms # transform에는 데이터를 변환하는 함수가 들어감
)

test_data = CIFAR10(
    root='./',
    train=False,
    download=True,
    transform=transforms
)

# 9개 이미지 시각화하기
for i in range(9):
  plt.subplot(3, 3, i+1)
  plt.imshow(transforms(training_data.data[i]))
plt.show()

image

  • CIFAR10(): CIFAR-10 데이터셋 불러오기

    • root: 이미지를 내려받을 경로

    • download: 이미지를 내려받을지 여부 결정

  • Compose([*tf]): 전처리 함수 tf를 입력 받아 차례대로 실행한다.

  • RandomCrop(size): 이미지의 일부를 제거한 뒤 size 크기로 복원한다.

    • 이미지의 검정색 부분은 0으로 패딩된 부분
  • RandomHorizontalFlip(p): p 확률로 이미지를 좌우대칭시킨다.


1.2 이미지 정규화

# 데이터 전처리에 정규화 추가

import matplotlib.pyplot as plt
import torchvision.transforms as T

from torchvision.datasets.cifar import CIFAR10
from torchvision.transforms import Compose
from torchvision.transforms import RandomHorizontalFlip, RandomCrop, Normalize

transforms = Compose([
    T.ToPILImage(),
    RandomCrop((32, 32), padding=4),
    RandomHorizontalFlip(p=0.5), # y축 기준으로 대칭
    T.ToTensor(),

    Normalize(mean=(0.4914, 0.4822, 0.4465), std=(0.247, 0.243, 0.261)), # 세 값은 R, G, B 채널의 값을 의미
    T.ToPILImage()
])

# CIFAR-10 데이터셋 불러오기
training_data = CIFAR10(
    root='./',
    train=True,
    download=True,
    transform=transforms # transform에는 데이터를 변환하는 함수가 들어감
)

test_data = CIFAR10(
    root='./',
    train=False,
    download=True,
    transform=transforms
)

# 9개 이미지 시각화하기
for i in range(9):
  plt.subplot(3, 3, i+1)
  plt.imshow(transforms(training_data.data[i]))
plt.show()

image

  • Normalize(mean, std): 평균 mean, 표준편차 std를 갖는 정규분포가 되도록 정규화를 실행한다.

    • 데이터셋에 따라 값들이 달라지기 때문에 직접 구해야하지만, 유명 데이터셋의 값을 사용하는 경우도 있다.


1.3 데이터셋의 평균과 표준편차 구하기

import torch

training_data = CIFAR10(
    root='./',
    train=True,
    download=True,
    transform=ToTensor()
)

# item[0]은 이미지, item[1]은 정답 레이블
imgs = [item[0] for item in training_data]

# imgs를 하나로 합침
imgs = torch.stack(imgs, dim=0).numpy()

# rgb 각 평균
mean_r = imgs[:,0,:,:].mean()
mean_g = imgs[:,1,:,:].mean()
mean_b = imgs[:,2,:,:].mean()
print(mean_r, mean_g, mean_b)

# rgb 각 표준편차
std_r = imgs[:,0,:,:].std()
std_g = imgs[:,1,:,:].std()
std_b = imgs[:,2,:,:].std()
print(std_r, std_g, std_b)
0.49139968 0.48215827 0.44653124
0.24703233 0.24348505 0.26158768
  • stack(tensor, dim): tensor를 dim 방향으로 합쳐준다.

    • ex) (224, 224) 텐서를 dim=0 방향으로 세 개 합치면 (3, 224, 224) 텐서가 된다.

    • 가로, 세로가 다른 두 이미지는 하나의 텐서에 넣을 수 없다.


2. CNN으로 이미지 분류하기 (VGG)

  • nn.Sequential: 입력층에서부터 출력층까지 순차적으로 흘러하는 경우에 한해서 사용 가능하다. 데이터 흐름을 마음대로 제어할 수 없다.

  • nn.Module: 은닉층에서 순전파 도중의 결과를 저장하거나, 데이터 흐름 제어하는 등의 복잡한 신경망 커스터마이징 가능함


2.1 기본 블록 정의하기

image

  • 반복 사용하는 블록을 정의하고, 기본 블록이라고 하자.

  • 아래 기본 블록을 거칠 때마다 이미지 크기는 절반으로 줄어든다.

# VGG 기본 블록 정의
import torch
import torch.nn as nn

# 기본 블록 정의
class BasicBlock(nn.Module):
  # 기본 블록을 구성하는 층 정의
  def __init__(self, in_channels, out_channels, hidden_dim):
    # nn.Module 클래스의 요소 상속
    super(BasicBlock, self).__init__()

    # 합성곱층 정의
    self.conv1 = nn.Conv2d(in_channels, hidden_dim, kernel_size=3, padding=1)
    self.conv2 = nn.Conv2d(hidden_dim, out_channels, kernel_size=3, padding=1)
    self.relu = nn.ReLU()

    self.pool = nn.MaxPool2d(kernel_size=2, stride=2)

  # 기본 플록의 순전파 정의
  def forward(self, x):
    x = self.conv1(x)
    x = self.relu(x)
    x = self.conv2(x)
    x = self.relu(x)
    x = self.pool(x)

    return x
  • MaxPool2d(kernel, stride): 최대 풀링을 실행한다.

    • kernel: 커널 크기

    • stride: 커널이 이동하는 거리 지정

  • Conv2d(in, out, kernel, stride): 합성곱을 계산한다.

    • in: 입력 채널 개수

    • out: 출력 채널 개수

    • stride: 커널이 이동하는 거리


2.2 전체 CNN 모델 정의하기

image

# 전체 CNN 모델 정의하기
class CNN(nn.Module):
  def __init__(self, num_classes): # num_classes: 클래스 개수
    super(CNN, self).__init__()

    # 합성곱 기본 블록 정의
    self.block1 = BasicBlock(in_channels=3, out_channels=32, hidden_dim=16)
    self.block2 = BasicBlock(in_channels=32, out_channels=128, hidden_dim=64)
    self.block3 = BasicBlock(in_channels=128, out_channels=256, hidden_dim=128)

    # 분류기 정의
    self.fc1 = nn.Linear(in_features=4096, out_features=2048)
    self.fc2 = nn.Linear(in_features=2048, out_features=256)
    self.fc3 = nn.Linear(in_features=256, out_features=num_classes)

    # 분류기의 활성화 함수
    self.relu = nn.ReLU()

  def forward(self, x):
    x = self.block1(x)
    x = self.block2(x)
    x = self.block3(x) # 출력 크기: (256,4,4)
    x = torch.flatten(x, start_dim=1) # 2차원 특징 맵을 1차원으로 평탄화

    x = self.fc1(x)
    x = self.relu(x)
    x = self.fc2(x)
    x = self.relu(x)
    x = self.fc3(x)

    return x
  • flatten(A, start_dim): 텐서 A를 1차원으로 풀어준다.

    • start_dim: 몇 번째 차원부터 풀어줄지를 결정


2.3 데이터 로드 및 모델 정의

from torch.utils.data.dataloader import DataLoader

from torch.optim.adam import Adam

# 데이터 전처리와 증강 정의
transforms = Compose([
    RandomCrop((32, 32), padding=4), # 이미지 랜덤하게 자르기
    RandomHorizontalFlip(p=0.5), # y축을 대칭으로 이미지 대칭
    ToTensor(), # 파이토치 텐서로 변환

    Normalize(mean=(0.4914, 0.4822, 0.4465), std=(0.247, 0.243, 0.261)) # 이미지 정규화
])


# 학습용 데이터와 평가용 데이터 불러오기
training_data = CIFAR10(root='./', train=True, download=True, transform=transforms)
test_data = CIFAR10(root='./', train=False, download=True, transform=transforms)

# 데이터로더 정의
train_loader = DataLoader(training_data, batch_size=32, shuffle=True)
test_loader = DataLoader(test_data, batch_size=32, shuffle=False)

# 학습을 진행할 프로세서 설정
devcie = 'cuda' if torch.cuda.is_available() else 'cpu'

# CNN 모델 정의
model = CNN(num_classes=10)

# 모델을 device로 보내기
model.to(device)

image


2.4 모델 학습하기

# 학습률 정의. 보통 0.001보다 작은 값 이용
lr = 1e-3

# 최적화 기법 정의. Adam: 가장 흔하게 사용하는 최적화 기법
optim = Adam(model.parameters(), lr=lr)

# 학습 루프 정의
for epoch in range(100):
  for data, label in train_loader: # 데이터 호출
    optim.zero_grad() # 기울기 초기화

    preds = model(data.to(device)) # 모델 예측

    loss = nn.CrossEntropyLoss()(preds, label.to(device)) # 손실 계산
    loss.backward() # 역전파
    optim.step() # 최적화

  if epoch==0 or epoch%10==9:
    print(f'epoch{epoch} loss:{loss.item()}')

# 모델 저장
torch.save(model.state_dic(), "CIFAR.pth")
epoch0 loss:1.3557438850402832
epoch9 loss:0.11133251339197159
epoch19 loss:0.6687377691268921
epoch29 loss:0.3688758611679077
...
  • CrossEntropyLoss(A,B): A와 B의 크로스 엔트로피를 계산한다.


2.5 모델 성능 평가하기

model.load_state_dict(torch.load("CIFAR.pth", map_location=device))

num_corr = 0

with torch.no_grad():
  for data, label in test_loader:
    output = model(data.to(device))
    preds = output.data.max(1)[1]
    corr = preds.eq(label.to(device).data).sum().item()
    num_corr += corr

  print(f'Accuracy:{num_corr/len(test_data)}')


3. 전이 학습 모델 VGG로 분류하기

  • ImageNet으로 훈련된 VGG16 사용

  • ImageNet은 상당히 많은 수의 이미지를 포함하므로 ImageNet으로 사전 학습된 모델은 대부분의 이미지에서 특징을 뽑아낼 수 있다.

import torch
import torch.nn as nn

from torchvision.models.vgg import vgg16

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

model = vgg16(pretrained=True) # vgg16 모델 객체 생성

# 분류층 정의
fc = nn.Sequential(
    nn.Linear(512*7*7, 4096),
    nn.ReLU(),
    nn.Dropout(),
    nn.Linear(4096, 4096),
    nn.ReLU(),
    nn.Dropout(),
    nn.Linear(4096,10),
)

# vgg의 classifier를 덮어씀
model.classifier = fc

model.to(device)

image

  • Dropout(p): p 확률로 드롭아웃을 결정한다.

    • 기본값은 0.5로 50% 확률로 가중치가 사라진다.

    image


3.1 모델 전처리와 증강

import tqdm

from torchvision.datasets.cifar import CIFAR10
from torchvision.transforms import Compose, ToTensor, Resize, RandomHorizontalFlip, RandomCrop, Normalize
from torch.utils.data.dataloader import DataLoader

from torch.optim.adam import Adam

transforms = Compose([
    Resize(224),
    RandomCrop((224,224), padding=4),
    RandomHorizontalFlip(p=0.5),
    ToTensor(),
    Normalize(mean=(0.4914, 0.4822, 0.4465), std=(0.247, 0.243, 0.261))
])


3.2 데이터로더 정의

training_data = CIFAR10(root='./', train=True, download=True, transform=transforms)
test_data = CIFAR10(root='./', train=False, download=True, transform=transforms)

train_loader = DataLoader(training_data, batch_size=32, shuffle=True)
train_loader = DataLoader(test_data, batch_size=32, shuffle=False)


3.3 학습 루프 정의

lr = 1e-4
optim = Adam(model.parameters(), lr=lr)

for epoch in range(30):
  iterator = tqdm.tqdm(train_loader) # 학습 로그 출력
  for data, label in iterator:
    optim.zero_grad()

    preds = model(data.to(device)) # 모델의 예측값 출력

    loss = nn.CrossEntropyLoss()(preds, label.to(device))
    loss.backward()
    optim.step()

    # tqdm이 출력할 문자열
    iterator.set_description(f'epoch:{epoch+1} loss:{loss.item()}')

torch.save(model.state_dict(), 'CIFAR_pretrained.pth')
  • tqdm(iterator): 학습 로그를 출력하는 함수. iteratable 객체에 대한 진행 상황을 프로세스바를 이용해 보여준다.


3.4 모델 성능 평가하기

model.load_state_dict(torch.load("CIFAR_pretrained.pth", map_location=device))

num_corr = 0

with torch.no_grad():
  for data, label in test_loader:
    output = model(data.to(device))
    preds = output.data.max(1)[1]
    corr = preds.eq(label.to(device).data).sum().item()
    num_corr += corr

  print(f"Accuracy:{num_corr/len(test_data)}")


4. ResNet으로 이미지 분류하기

  • 핵심 용어 미리보기

    • ResNet: 스킵 구조를 이용한 CNN 신경망이다.

    • 기울기 소실: 은닉층이 깊어짐에 따라 입력층에 가까운 가중치들의 기울기가 0에 가까워지는 현상이다. 기울기가 0이 되면 가중치가 더 이상 업데이트되지 않아 학습이 이루어지지 않는다.

    • 배치 정규화: 배치 간의 차이를 정규화해주므로 더 안정되게 학습할 수 있다.

    • nn.Sequential: 커스터마이징이 불가능하지만, forward() 메서드를 직접 작성할 필요가 없다.

    • nn.Module: 커스터마이징이 가능하다. 복잡한 신경망을 구성할 때 사용한다.

    • 스킵 커넥션: 은닉층을 거치지 않은 입력값과 은닉층의 결과를 더하는 구조이다.

    • 평균 풀링: 커널의 평균값을 이용하는 풀링이다.


4.1 ResNet 이해하기

image

  • 층을 깊게 쌓으면 기울기 소실 문제가 발생한다. 이를 해결하기 위해서 스킵 커넥션을 사용한다. 스킵 커넥션은 자기 자신을 미분하면 1이 나오기 때문에 신경망의 출력 부분에 입력을 더하는 방식으로 기울기를 최소 1로 확보하는 기법이다. 이를 통해 기울기 소실 문제를 해결할 수 있다.

  • 장점

    • 층을 깊게 쌓을 수 있다.

    • VGG에 비해 학습이 안정적이다.

    • 기울기 소실 문제를 어느 정도 해결한다.

  • 단점

    • 가중치가 늘어나기 때문에 계산량이 많아진다.

    • VGG에 비해 오버피팅이 일어나기 쉽다.


4.2 배치 정규화 이해하기

image

  • 배치 단위로 나눠서 학습할 때, 배치 간의 데이터 분포가 다르면 각 층마다 값의 범위가 달라지는 출력 분포의 불균형이 발생한다.

이를 해결하기 위해 배치 정규화를 진행한다.

  • 구체적인 설명

    image

    • 배치 정규화는 배치가 3이라면 샘플 3개를 입력 받고 각 노드(피처, 여기서는 6개의 노드가 사용됨)에 대한 평균과 분산을 구해 정규화하는 것으로 노드의 개수가 6이라면 노드 6개에 대한 평균과 분산을 각각 구한다. 즉 각 특성(노드)마다 평균과 분산을 구해서 정규화한다.

    • 레이어 정규화는 샘플 1개에 대한 평균과 분산을 구해 정규화하는 것이다.

    즉, 한 배치 안에 있는 모든 데이터의 평균과 분산을 배치마다 각각 구하는 것이다.

    image

    • 위 그림은 배치 정규화, 레이어 정규화, 인스턴스 정규화 등을 이해하기 쉽게 시각화한 그림이다. 파란 부분을 정규화한다는 의미이다.H,W는 노드(특성), N은 배치넘버, C는 채널을 뜻한다. 정규화 방향은 위의 사진의 화살표 방향과 같다.


4.3 기본 블록 정의하기

image

### ResNet 기본 블록 ###
import torch
import torch.nn as nn

# ResNet의 기본 합성곱층 정의
class BasicBlock(nn.Module): # nn.Module 클래스 상속
  def __init__(self, in_channels, out_channels, kernel_size=3):
    super(BasicBlock, self).__init__() # nn.Module 클래스의 __init__() 함수 실행

    # 합성곱층 정의 (2개의 3x3 합성곱)
    self.c1 = nn.Conv2d(in_channels, out_channels, kernel_size=kernel_size, padding=1)
    self.c2 = nn.Conv2d(out_channels, out_channels, kernel_size=kernel_size, padding=1)
    self.downsample = nn.Conv2d(in_channels, out_channels, kernel_size=1)

    # 배치 정규화층 정의 (2개의 배치 정규화)
    self.bn1 = nn.BatchNorm2d(num_features=out_channels)
    self.bn2 = nn.BatchNorm2d(num_features=out_channels)

    self.relu = nn.ReLU()

  # 기본 블록의 순전파 정의
  def forward(self, x):
    # 스킵 커넥션을 위해 초기 입력 저장
    x_ = x

    # ResNet 기본 블록에서 F(x) 부분
    x = self.c1(x)
    x = self.bn1(x)
    x = self.relu(x)
    x = self.c2(x)
    x = self.bn2(x)

    # 합성곱의 결과와 입력의 채널 수를 맞춤
    x_ = self.downsample(x_)

    # 합성곱층의 결과(x)와 저장해놨던 입력값(x_)을 더해줌 (스킵 커넥션)
    x += x_
    x = self.relu(x)

    return x
  • 배치 정규화층은 앞에 오는 합성곱층의 출력 채널만큼의 features를 갖는다. 이전 큐브 그림을 참고하면 이해하기 쉬울 것이다.

  • BatchNorm2d(feature): 배치 정규화를 실행한다. features개의 특징에 대해서 실행한다. 이미지의 채널 수에 맞추면 된다.


4.4 ResNet 모델 정의하기

image

### ResNet 모델 정의하기
class ResNet(nn.Module):
  def __init__(self, num_classes=10):
    super(ResNet, self).__init__()

    # 기본 블록
    self.b1 = BasicBlock(in_channels=3, out_channels=64)
    self.b2 = BasicBlock(in_channels=64, out_channels=128)
    self.b3 = BasicBlock(in_channels=128, out_channels=256)

    # 풀링을 최댓값이 아닌 평균값으로
    self.pool = nn.AvgPool2d(kernel_size=2, stride=2)

    # 분류기
    self.fc1 = nn.Linear(in_features=4096, out_features=2048)
    self.fc2 = nn.Linear(in_features=2048, out_features=512)
    self.fc3 = nn.Linear(in_features=512, out_features=num_classes)

    self.relu = nn.ReLU()

  # ResNet 순전파 정의
  def forward(self, x):
    # 기본 블록과 풀링층 통과
    x = self.b1(x)
    x = self.pool(x)
    x = self.b2(x)
    x = self.pool(x)
    x = self.b3(x)
    x = self.pool(x)

    # 분류기의 입력으로 사용하기 위한 평탄화
    x = torch.flatten(x, start_dim=1)

    # 분류기로 예측값 출력
    x = self.fc1(x)
    x = self.relu(x)
    x = self.fc2(x)
    x = self.relu(x)
    x = self.fc3(x)
    
    return x
  • AvgPool2d(kernel_size, stride): 풀링을 커널의 평균값으로 실행한다. stride 만큼의 보폭을 갖는다.


4.5 모델 학습하기

### 데이터 전처리 정의
import tqdm

from torchvision.datasets.cifar import CIFAR10
from torchvision.transforms import Compose, ToTensor
from torchvision.transforms import RandomHorizontalFlip, RandomCrop
from torchvision.transforms import Normalize
from torch.utils.data.dataloader import DataLoader

from torch.optim.adam import Adam

transforms = Compose([
    RandomCrop((32, 32), padding=1), # 랜덤 크롭핑
    RandomHorizontalFlip(p=0.5), # 랜덤 y축 대칭
    ToTensor(),
    Normalize(mean=(0.4914, 0.4822, 0.4465), std=(0.247, 0.243, 0.261)) # 이미지 정규화
])
### 데이터 불러오기
# 데이터셋 정의
training_data = CIFAR10(root='./', train=True, download=True, transform=transforms)
test_data = CIFAR10(root='./', train=False, download=True, transform=transforms)

# 데이터로더 정의
train_loader = DataLoader(training_data, batch_size=32, shuffle=True)
test_loader = DataLoader(test_data, batch_size=32, shuffle=False)
  • 배치 단위를 32로 정하고 학습용 데이터와 평가용 데이터를 불러오는 데이터로더를 만들어준다.
### 모델 정의하기
device = 'cuda' if torch.cuda.is_available() else 'cpu'

model = ResNet(num_classes=10)
model.to(device)
model

image

 ### 학습 루프 정의
 lr = 1e-4
 optim = Adam(model.parameters(), lr=lr)

 for epoch in range(30):
  iterator = tqdm.tqdm(train_loader)
  for data, label in iterator:
    # 최적화를 위해 기울기를 초기화
    optim.zero_grad()

    # 모델의 예측값
    preds = model(data.to(device))

    # 손실 계산 및 역전파
    loss = nn.CrossEntropyLoss()(preds, label.to(device))
    loss.backward()
    optim.step()

    iterator.set_description(f'epoch:{epoch+1} loss:{loss.item()}')

torch.save(model.state_dict(), "ResNet.pth")
epoch:1 loss:0.6351765990257263: 100%|██████████| 1563/1563 [01:09<00:00, 22.33it/s]
epoch:2 loss:0.8363798260688782: 100%|██████████| 1563/1563 [01:17<00:00, 20.11it/s]
...
epoch:29 loss:0.017815232276916504: 100%|██████████| 1563/1563 [00:46<00:00, 33.61it/s]
epoch:30 loss:0.0027741980738937855: 100%|██████████| 1563/1563 [00:46<00:00, 33.29it/s]


4.6 모델 성능 평가하기

model.load_state_dict(torch.load("ResNet.pth", map_location=device))

num_corr = 0

with torch.no_grad():
  for data, label in test_loader:
    output = model(data.to(device))
    preds = output.data.max(1)[1]
    corr = preds.eq(label.to(device).data).sum().item()
    num_corr += corr

  print(f"Accuracy:{num_corr/len(test_data)}")
    
Accuracy:0.876
  • 이전에 100번 정도 반복해서 학습한 모델보다 30번 정도 학습한 ResNet의 성능이 더 좋았다.

  • 학습률, 반복 횟수 같이 미세한 조정보다는 성능이 좋은 모델을 선택하는 게 더 나은 방법이다.

댓글남기기