[CNN] 전이 학습 - 특성 추출 기법

[딥러닝 파이토치 교과서]를 기반으로 공부한 내용입니다.

 

전이 학습

: 이미지넷처럼 아주 큰 데이터셋을 써서 훈련된 모델(사전 훈련된 모델(네트워크))의 가중치를 가져와 우리가 해결하려는 과제에 맞게 보정해서 사용하는 것을 의미함

=> 비교적 적은 수의 데이터를 가지고도 우리가 원하는 과제를 해결할 수 있음

 

특성 추출 기법

특성 추출은 ImageNet 데이터셋으로 사전 훈련된 모델을 가져온 후 마지막에 완전연결층 부분만 새로 만듦.

즉, 학습할 때는 마지막 완전연결층(이미지의 카테고리를 결정하는 부분)만 학습하고 나머지 계층들은 학습되지 않도록 함. 

-> 합성곱층과 데이터 분류기(완전연결층)으로 구성

  • 합성곱층 : 합성곱층과 풀링층으로 구성
  • 데이터 분류기(완전연결층) : 추출된 특성을 입력받아 최종적으로 이미지에 대한 클래스를 분류하는 부분

사전 훈련된 네트워크의 합성곱층(가중치 고정)에 새로운 데이터를 통과시키고,

그 출력을 데이터 분류기에서 학습시킴.

 

사용가능한 이미지 분류 모델

-> Xception, Inception V3, ResNet50, VGG16, VGG19, MobileNet                                                                                        

  • 라이브러리 호출
import os
import time
import copy
import glob
import cv2
import shutil

import torch
import torchvision
import torchvision.transforms as transforms
import torchvision.models as models
import torch.nn as nn
import torch.optim as optim

import matplotlib.pyplot as plt

 

  • 이미지 데이터 전처리 방법 정의
data_path = '../chap05/data/catanddog/train'
    
transform = transforms.Compose(
                [
                    transforms.Resize([256, 256]),
                    transforms.RandomResizedCrop(224),
                    transforms.RandomHorizontalFlip(),
                    transforms.ToTensor(),
                ]) #1
train_dataset = torchvision.datasets.ImageFolder(
    data_path,
    transform=transform
) #2
train_loader = torch.utils.data.DataLoader(
    train_dataset,
    batch_size=32,
    num_workers=8,
    shuffle=True
) #3

print(len(train_dataset))

#385

 

#1

torchvision.transform은 이미지 데이터를 변환하여 모델(네트워크)이 입력으로 사용할 수 있게 변환해줌 

- Resize : 이미지의 크기를 조정, 256 x 256 크기로 이미지 데이터를 조정함

- RandomResizedCrop : 이미지를 랜덤한 크기 및 비율로 자름

 

** resize와 randomresizedcrop 모두 이미지를 자르는 데 사용하지만, 

resize는 합성곱층을 통과하기 위해 이미지 크기를 조정하는 전처리 과정이라면,

randomresizedcrop은 데이터 확장 용도로 사용됨. 이미지를 랜덤한 비율로 자른 후 데이터 크기를 조정함.

 

- RandomHorizontalFlip : 이미지를 랜덤하게 수평으로 뒤집음

- ToTensor : 이미지 데이터를 텐서로 변환함.

 

#2

datasets.ImageFolder는 데이터로더가 데이터를 불러올 대상(혹은 경로)과 방법(transform)(혹은 전처리)을 정의함.

 

#3

DataLoader는 데이터를 불러오는 부분으로 앞으로 정의한 ImageFolder을 데이터로더에 할당하는데, 

이 때, 한번에 불러올 데이터양을 결정하는 batch_size를 지정하고, 데이터를 무작위로 섞을 것인지도 설정함.(shuffle=True)

 

  • 학습에 사용될 이미지 출력
samples, labels = iter(train_loader).next() #1
classes = {0:'cat', 1:'dog'} #개와 고양이에 대한 클래스로 구성
fig = plt.figure(figsize=(16,24))
for i in range(24): #24개의 이미지 데이터 출력
    a = fig.add_subplot(4,6,i+1)
    a.set_title(classes[labels[i].item()]) #레이블 정보(클래스)를 함께 출력
    a.axis('off')
    a.imshow(np.transpose(samples[i].numpy(), (1,2,0))) #2
plt.subplots_adjust(bottom=0.2, top=0.6, hspace=0)

 

#1

반복자(iteratior, for 구문과 같은 효과)를 사용하려면 iter()과 next()가 필요함.

iter()는 전달된 데이터의 반복자를 꺼내 반환하며, next()는 그 반복자가 다음에 출력해야할 요소를 반환함.

=> iter()로 반복자를 구하고, 그 반복자를 next()에 전달하여 차례대로 꺼낼 수 있음.

-> train_lodader에서 samples와 labels의 값을 순차적으로 꺼내서 저장(데이터 하나씩 꺼내오기)

 

#2

행렬의 내적연산을 위해 행렬의 차원 변경

torch.Size([32,3,224,224])인 데이터를,

np.transpose(samples[i].numpy(), (1,2,0))을 사용해서 (224, 224, 3)과 같은 형태로 변환한 후 사용해야함. 

 

+원래 (2,3,4)(3x4인 행렬 두개)의 크기를 np.transpose(data,(2,1,0)) (여기 숫자는 원래 데이터 크기의 각 숫자를 인덱스로 취급했을 때) 로 바꾸면,

(2,3,4) 크기 ->  (4,3,2)로 변환됨.

 

  • 사전 훈련된 모델 내려받기
resnet18 = models.resnet18(pretrained=True)
#pretrained=True는 사전 학습된 가중치를 사용하겠다는 의미

 

** ResNet18?

ResNet18은 50개의 계층으로 구성된 합성곱 신경망임. 입력 제약이 매우 크고, 충분한 메모리가 필요하다는 단점.

 

  • 사전 훈련된 모델의 파라미터 학습 유무 지정
def set_parameter_requires_grad(model, feature_extracting=True):
    if feature_extracting:
        for param in model.parameters():
            param.requires_grad = False #1
            
set_parameter_requires_grad(resnet18)

 

#1

역전파 중 파라미터들에 대한 변화를 계산할 필요가 없음을 나타냄. 

모델의 일부(합성곱층, 풀링층)를 고정하고, 나머지를 학습하고자 할 때 이렇게 설정. 

+ 내려받은 ResNet18의 마지막 부분에 완전연결층 추가(개와 고양이 클래스 분류하는 용도로 사용)

 

 # 전에 파라미터 업데이트 할 때도,

.requires_grad_(True) : 연결된 Tensor 로부터의 계산된 자동미분 값을, 다시 현 텐서부터 시작하도록 만듬

 

  • ResNet18에 완전연결층 추가
resnet18.fc = nn.Linear(512, 2) 
#2는 클래스가 두 개라는 의미

 

  • 모델의 파라미터 값 확인
for name, param in resnet18.named_parameters(): #model.named_parameters()는 모델에 접근하여 파라미터 값들을 가져올 때 사용
    if param.requires_grad:
        print(name, param.data)
fc.weight tensor([[ 0.0290, -0.0368, -0.0243,  ...,  0.0268,  0.0337,  0.0388],
        [ 0.0173, -0.0082,  0.0215,  ..., -0.0272, -0.0227, -0.0236]])
fc.bias tensor([-0.0427, -0.0383])

 

 

  • 모델 객체 생성 및 손실 함수 정의
model = models.resnet18(pretrained = True)  #모델 객체 생성

for param in model.parameters():  #모델의 합성곱층 가중치 고정
    param.requires_grad = False

model.fc = torch.nn.Linear(512, 2)
for param in model.fc.parameters(): #완전연결층은 학습
    param.requires_grad = True

optimizer = torch.optim.Adam(model.fc.parameters())
cost = torch.nn.CrossEntropyLoss()  #손실 함수 정의
print(model)

 

  • 모델 학습을 위한 함수 생성
def train_model(model, dataloaders, criterion, optimizer, device, num_epochs=13, is_train=True):
    since = time.time()   #컴퓨터의 현재 시각을 구하는 함수 
    acc_history = []
    loss_history = []
    best_acc = 0.0
    
    for epoch in range(num_epochs): #에포크(13)만큼 반복
        print('Epoch {}/{}'.format(epoch, num_epochs - 1))
        print('-' * 10)

        running_loss = 0.0
        running_corrects = 0

        for inputs, labels in dataloaders: #데이터로더에 전달된 데이터만큼 반복
            inputs = inputs.to(device)
            labels = labels.to(device)
            
            model.to(device)
            optimizer.zero_grad()  #기울기를 0으로 설정
            outputs = model(inputs)  #순전파 학습
            loss = criterion(outputs, labels)
            _, preds = torch.max(outputs, 1)
            loss.backward()  #역전파 학습
            optimizer.step()

            running_loss += loss.item() * inputs.size(0)  # 출력 결과와 레이블의 오차를 계산한 결과를 누적하여 저장
            running_corrects += torch.sum(preds == labels.data) #출력 결과와 레이블이 동일한지 확인한 결과를 누적하여 저장

        epoch_loss = running_loss / len(dataloaders.dataset) #평균 오차 계산
        epoch_acc = running_corrects.double() / len(dataloaders.dataset) # 평균 정확도 계산

        print('Loss: {:.4f} Acc: {:.4f}'.format(epoch_loss, epoch_acc))

        if epoch_acc > best_acc:
            best_acc = epoch_acc

        acc_history.append(epoch_acc.item())
        loss_history.append(epoch_loss)        
        torch.save(model.state_dict(), os.path.join('../chap05/data/catanddog/', '{0:0=2d}.pth'.format(epoch))) #모델 재사용을 위해
        print()

    time_elapsed = time.time() - since  #실행 시간(학습 시간)을 계산
    print('Training complete in {:.0f}m {:.0f}s'.format(time_elapsed // 60, time_elapsed % 60))
    print('Best Acc: {:4f}'.format(best_acc))    
    return acc_history, loss_history #모델의 정확도와 오차를 반환

 

  • 파라미터 학습 결과를 옵티마이저에 전달
params_to_update = []
for name,param in resnet18.named_parameters():
    if param.requires_grad == True:
        params_to_update.append(param) # 파라미터 학습 결과 저장
        print("\t",name)
            
optimizer = optim.Adam(params_to_update) # 학습 결과를 옵티마이저에 전달
fc.weight
fc.bias

#weight와 bias 값들이 업데이트되고 옵티마이저에 전달됨

 

 

  • 모델 학습
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
criterion = nn.CrossEntropyLoss()
train_acc_hist, train_loss_hist = train_model(resnet18, train_loader, criterion, optimizer, device)

 

 

  • 테스트 데이터 호출 및 전처리
test_path = '../chap05/data/catanddog/test'

transform = transforms.Compose(
                [
                    transforms.Resize(224),
                    transforms.CenterCrop(224),
                    transforms.ToTensor(),
                ])
test_dataset = torchvision.datasets.ImageFolder(
    root=test_path,
    transform=transform
)
test_loader = torch.utils.data.DataLoader(
    test_dataset,
    batch_size=32,
    num_workers=1,
    shuffle=True
)

print(len(test_dataset))


# 98

 

 

  • 테스트 데이터 평가 함수 생성
def eval_model(model, dataloaders, device):
    since = time.time()    
    acc_history = []
    best_acc = 0.0

    saved_models = glob.glob('../chap05/data/catanddog/' + '*.pth')  #1
    saved_models.sort() #불러온 .pth 파일들을 정렬
    print('saved_model', saved_models)

    for model_path in saved_models:
        print('Loading model', model_path)

        model.load_state_dict(torch.load(model_path))
        model.eval()
        model.to(device)
        running_corrects = 0

        for inputs, labels in dataloaders: #테스트 반복
            inputs = inputs.to(device)
            labels = labels.to(device)

            with torch.no_grad():  #autograd를 사용하지 않겠다
                outputs = model(inputs)  # 데이터를 모델에 적용한 결과를 outputs에 저장

            _, preds = torch.max(outputs.data, 1)        # 2  왜 값이 두개?
            preds[preds >= 0.5] = 1  #torch.max로 출력된 값이 0.5보다 크면 올바르게 예측
            preds[preds < 0.5] = 0  #torch.max로 출력된 값이 0.5보다 작으면 틀리게 예측
            running_corrects += preds.eq(labels.cpu()).int().sum()  #3
            
        epoch_acc = running_corrects.double() / len(dataloaders.dataset)  #테스트 데이터의 정확도 계산
        print('Acc: {:.4f}'.format(epoch_acc))
        
        if epoch_acc > best_acc:
            best_acc = epoch_acc

        acc_history.append(epoch_acc.item())
        print()

    time_elapsed = time.time() - since
    print('Validation complete in {:.0f}m {:.0f}s'.format(time_elapsed // 60, time_elapsed % 60))
    print('Best Acc: {:4f}'.format(best_acc))
    
    return acc_history  #계산된 정확도 반환

 

#1

glob은 현재 디렉토리에서 원하는 파일들만 추출하여 가져올 때 사용함.

즉 '../chap05/data/catanddog/' 경로에서 pth 확장자를 갖는 파일을 가져오라는 의미. 

(pth는 훈련 데이터로 모델을 훈련시킬 때 생성된 파일)

 

 

#2

torch.max는 주어진 텐서 배열의 최댓값이 들어 있는 index를 반환하는 함수임. 

outputs의 크기가 (배치 크기)x(클래스의 개수)이므로  열이 하나의 이미지의 대응되는 벡터를 나타냄. 따라서 행(0), 열(1) 중 열을 기준으로 최댓값을 뽑아 예측값을 하나 만드는것..
ex) 배치 크기가 2이고 클래스가 3개인 outputs가 있다고 했을 때,
outputs = [[0.1, 0.4, 0.5], [0.2, 0.6, 0,2]] 
여기서의 최댓값의 위치는 2번째(0.5)와 1번째(0.6)임.
즉, 첫번째 이미지는 2라고 예측하는 것이고 두번째 이미지는 1이라고 예측을 하게 됨.
이를 torch.max를 이용하여 나타냄.
torch.max는 최댓값과 최댓값의 위치를 산출해주는데 여기서 우리는 최댓값은 필요가 없으므로 받지 않아도 됨(어차피 이 예측값과 비교할 target 값도 class 값(0,1,2,...) 으로 저장되어 있을테니)
-> 따라서 _ (언더바)로 처리하여 해당 출력값은 저장하지 않겠다는 의미!!.
즉, _, predicted는 최댓값의 위치만 predicted에 저장하겠다는 의미.
따라서 _, predicted = torch.max(outputs.data, 1)의 의미는 각 열(1)마다 최댓값의 위치를 예측값으로 사용하겠다는 의미임. (dim=1이면 각 행의 최대값들과 각 행에서의 위치를 나타내는데, 이게 각 열마다 최댓값의 위치를 예측값으로 사용하겠단 의미)
마지막으로 .data는 예측값을 계산할 때는 역전파 계산이 필요없기 때문에 데이터만 사용한다는 의미에서 사용된 것인데 지금 보니까 with torch.no_grad()를 사용했기 때문에 .data를 없애도 될 거 같음.

 

#3

preds.eq(labels)는 preds 배열과 labels가 일치하는지 검사하는 용도로 사용함. 

.sum()은 모델의 예측 결과와 정답(레이블)이 일치하는 것들의 개수 합을 숫자로 출력함.

 

  • 테스트 데이터를 평가 함수에 적용
val_acc_hist = eval_model(resnet18, test_loader, device)

 

  • 예측 이미지 출력을 위한 전처리 함수
def im_convert(tensor):  
    image=tensor.clone().detach().numpy()  # 1
    image=image.transpose(1,2,0)  
    image=image*(np.array((0.5,0.5,0.5))+np.array((0.5,0.5,0.5)))  
    image=image.clip(0,1)  # 2
    return image

 

#1

tensor.clone()은 기존 텐서의 내용을 복사한 텐서를 생성하겠다는 의미이며,

detach()는 기존 텐서에서 기울기가 전파되지 않는 텐서임. 

=> tensor.clone().detach()는 기존 텐서를 복사한 새로운 텐서를 생성하지만 기울기에 영향을 주진 않겠다.

 

구분 메모리 계산 그래프 상주 유무
tensor.clone() 새롭게 할당 상주
tensor.detach() 공유해서 사용 상주하지 않음
tensor.clone().detach() 새롭게 할당 상주하지 않음

 

    #한번 연산 다 끝나면, 가중치 업뎃 되면, 한번 끊어줘야함. -> detach
    #아니면 계속 그 미분값으로 back propagation까지 돌테니깐.

    # detach_() : 텐서를 기존 방향성 비순환 그래프(DAG; Directed Acyclic Graph) 로부터 끊음
   

#2

clip()은 입력 값이 주어진 범위를 벗어날 때 입력 값을 특정 범위로 제한시키기 위해 사용함.

ex) image.clip(0,1)은 image 데이터를 0과 1 사이의 값으로 제한하겠다는 의미임.

 

  • 개와 고양이 예측 결과 출력
classes = {0:'cat', 1:'dog'} # 개와 고양이 두 개에 대한 테이블

dataiter=iter(test_loader)  # 테스트 데이터셋 가져옴
images,labels=dataiter.next() # 테스트 데이터셋에서 이미지와 레이블을 분리하여 가져옴 
output=model(images)  
_,preds=torch.max(output,1) 

fig=plt.figure(figsize=(25,4))  
for idx in np.arange(20):  
    ax=fig.add_subplot(2,10,idx+1,xticks=[],yticks=[])  # 1
    plt.imshow(im_convert(images[idx]))  #이미지 출력을 위해 앞에서 정의한 전처리 함수 사용
    a.set_title(classes[labels[i].item()])
    ax.set_title("{}({})".format(str(classes[preds[idx].item()]),str(classes[labels[idx].item()])),color=("green" if preds[idx]==labels[idx] else "red"))  #2  
plt.show()  
plt.subplots_adjust(bottom=0.2, top=0.6, hspace=0) # 3

 

#1

add_subplot은 한 화면에 여러 개 이미지 담기 위해 사용

add_subplot(2,10,idx+1,xticks=[],yticks=[])

2 -> 행의 수 의미, 이미지를 두 줄로 출력

10 -> 열의 수 의미, 한 줄에 열 개의 이미지 출력

idx+1 -> 인덱스 의미, 행과 열을 기준으로 순차적으로 이미지 출력

xticks=[], yticks=[] ->  틱(그래프 에서 x와 y축 각각 밑에 숫자 있는 부분) 삭제

 

#2

classes[preds[idx].item()]은 preds[idx].item() (예측값) 값이 classes로 정의된 0 과 1 중 어떤 값을 갖는지 판별하겠다는 의미. 값0 0이면 고양이 1이면 개로 출력됨. 

+ format 뒤에 괄호 값은 label (정답) !

 

#3

figure 안에서 suplot의 위치를 조정할 때 사용함.