본 블로그 포스팅은 수도권 ICT 이노베이션 스퀘어에서 진행하는 AI 핵심 기술 집중 클래스의 자연어처리(NLP) 강좌 내용을 필자가 다시 복기한 내용에 관한 것입니다.
이전 포스트 2. NLP-RNN (1) : 텍스트 분류기에서 진행한
RNN모델로 스팸 문자 분류기를 설계한 뒤 이를 성능평가한 결과물을 확인하면 아래와 같다.

이게 결과를 보면 알 수 있듯이 FastText방법론으로 사전학습된 임베딩 레이어 파라미터를 적용시켜도 분류 정확도가 80% 미만으로 성능이 꽤나 처참한 것을 알 수 있다.
이는 데이터 및 텍스트 전처리 과정까지 분석해보면 이유를 어느정도 가늠해 볼 수 있다.

먼저 데이터셋의 구성을 보면
문서는 평균 87개의 토큰으로 구성되어있고, 문서 중 토큰 보유 개수가 많은 문서와 적은 문서 중 context_length(토큰 보유 개수)를 330으로 설정 했을 때 5% 문서만이 330개 보다 더 많은 토큰을 보유하고 그 이하 문서는 토큰 개수가 적은 것으로 확인할 수 있다.
그러면 대부분의 문서는 87개의 의미있는 데이터 토큰 + 243의 <PAD> : 0 제로 패딩 토큰이 채워져 있다고 볼 수 있다.
이를 RNN의 동작구조와 대응하여 어떤식으로 학습결과가 어떻게 형성되는지를 확인하자면 아래의 그림처럼 표현할 수 있다.

위를 수학적으로 풀이해보면 아래의 수식과 같아지며

입력데이터 가 제로 패딩 토큰이 입력되는 88번째 토큰부터는 이전토큰의 학습정보만 남게 되며
이 정보는 활성화함수(tanh)로 인해 계속해서 로 정규화 되고 수식에 의해 이전 토큰의 학습정보가 0으로
수렴하는 기울기 소실문제가 발생하게 된다.
대략 zero pad 토큰이 10회 이상 반복되면 이전토큰의 학습정보는 거의 다 유실되는데
이것이 RNN의 치명적인 문제라 볼 수 있는 장기 의존성 문제 (Long-Term Dependency Problem)이다.
LSTM은 RNN의 장기의존성 문제를 해결하기 위해 제안된 모델로

기존에 RNN이 출력하는 hidden_state정보 외에도 새로운 상태정보값인 cell_state정보를 출력하며,장기적인 정보 흐름을 유지시켜주는 매우 중요한 상태정보이다.
cell_state를 통하여 의미가 있는 정보라 판단되면 오랜 시퀀스를 거치면서 모델이 순환Recurrent학습 하더라도 정보가 희미해지는 문제를 방지해 장기 의존성을 유지한다.

cell_state의 정보흐름을 본다면 지극히 간단한 행렬연산을 수행하고 활성화 함수를 거치지 않기에 기울기 소실 문제가 발생하지 않는다.
따라서 정보의 장기적 유지 및 업데이트가 가능하며, cell_state는 내부에 포함되어 있는 Forget Gate, Input Gate 2개의 게이트 결과물을 통해
기존정보는 어느정도 잊고 신규정보는 어느정도 기억할지?
를 결정하고(Update)
이를 반영하여 Output Gate에서 hidden_state 및
Output Feature를 출력한다.

3개의 게이트와 게이트로 인해 발생하는 중간 연산을 도식화 하면 위 사진과 같아지며, 각 게이트 및 업데이트 연산에 대한 수식은 생략하도록 하겠다.
요지는 '의미있는 정보는 순환학습이 지속되더라도 보존하려 함'
이것이다.
물론 코드를 작성하는 과정에서 가장 중요한 것은 이것이다
cell_state의 Feautre 차원정보는 어떻게 되는가?
hidden_state 차원 : (num_layers, Batch_size, hid_dim)
과 동일한 차원을 갖는다
즉 cell_state 차원 : (num_layers, Batch_size, hid_dim) 이다.
GRU은 LSTM를 경량화 시킨 버전으로 재귀 필터의 개념을 도입해
이전 정보와 현재 정보의 반영 비율을 결정하는 방식으로 모델이 동작한다.
즉, LSTM은 장기기억을 보존하기 위해 cell_state가 사용된다면
GRU는 어차피 재귀필터의 가중치만 조정하면 이전정보가 계속 보존되니 장기기억 보존용 cell_state가 없는 장점이 있는 것이다.

GRU는 Update, Reset Gate, Output Gate 이렇게 3개만 존재하기에 하나의 게이트가 없고
cell_state가 존재하지 않아 파라미터 및 연산이 단순화된 장점이 존재하며,
수식으로 살펴본다면 전체적으로 재귀 필터의 일반식
과 유사한 형태를 띄고 있음을 확인 할 수 있다.
따라서 LSTM대비 학습 속도나 모델의 무게도 꽤 최적화된 모델이라 볼 수 있으나, 그만큼 Trade-off 개념으로 성능도 하락하는 측면이 있어서
산업계에서는 두 모델을 혼용하여 사용하는 편이다.
통상 GRU는 LSTM대비 30~40% 정도 파라미터 수 감소가 되지만
정확도가 5~7% 정도 하락하기에 이같은 장/단점을 숙지해 적합한 모델을 선택해야 한다.
LSTM, GRU에 대한 공부가 어느정도 되었으니 이제 실습을 진행하고자 한다
이전포스트 2. NLP-RNN (1) : 텍스트 분류기에서 실습한
실습 데이터셋 - 한국어 스팸 분류기용 데이터셋
https://github.com/tbvjvsladla/ASH_NLP_lacture/blob/main/spam_SNS.csv
을 기반으로 RNN 과의 텍스트 분류 비교성능을 확인해보자
데이터&텍스트 전처리에는 이전 포스트 1. NLP-Text 전처리 마침 : 모듈화에서 설명하고 있는
데이터&텍스트 전처리 함수 모음 NLP_pp.py를 사용한다.
데이터 및 텍스트 전처리
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# 데이터 및 텍스트 전처리 함수를 모듈화 시킨 파일
from NLP_pp import *
import urllib.request
url = 'https://raw.githubusercontent.com/tbvjvsladla/ASH_NLP_lacture/main/spam_SNS.csv'
path = './data/spam_SNS.csv'
# 깃허브에 있는 파일 다운로드
urllib.request.urlretrieve(url=url, filename=path)
# 다운로드 받은 파일 불러오기
raw_data = pd.read_csv(path)
# 데이터셋읜 결측치 & 중복치 제거 함수 실행
raw_data = df_cleaning(raw_data, 'content')
import re
# 한글, 영어(소문자, 대문자), 숫자
p1 = re.compile(r'[^가-힣a-zA-Z0-9\s]')
# 한글 자모 데이터
p2 = re.compile(r'[ㄱ-ㅎㅏ-ㅣ]+')
# 개행문자 + 하나 이상의 공백문자
p3 = re.compile(r'\n|\s+')
def regex_sub(origin_sent):
clean_text = p1.sub(repl=" ", string=origin_sent)
clean_text = p2.sub("", clean_text)
clean_text = p3.sub(" ", clean_text)
return clean_text
# 설계한 정규표현식기반 특수문자 삭제 함수 적용
# apply함수는 inplace=True(덮어쓰기) 기능이 없음
raw_data['content'] = raw_data['content'].apply(regex_sub)
# '일상대화'는 0으로, '스팸문자'는 1로 변환
raw_data['class'] = raw_data['class'].map({'일상대화': 0, '스팸문자': 1})

데이터 전처리 중간결과 체크
# 데이터프레임의 항목을 분리 후 리스트 타입으로 변경
raw_x_data = raw_data['content'].values.tolist()
raw_y_label = raw_data['class'].values.tolist()
from mecab import MeCab #한글 단어 토크나이저
from tqdm import tqdm
#mecab 형태소 분석기 인스턴스화
word_tokenizer = MeCab()
# 토큰화 수행
tokenized_x_data = tokenize(raw_x_data, word_tokenizer)
# 깃허브에 있는 stopwordlist.txt파일 다운
stop_url = 'https://raw.githubusercontent.com/tbvjvsladla/ASH_NLP_lacture/main/kr_stopword_list.txt'
# 불용어 데이터셋 다운로드
stopword_list = download_stopword_list(stop_url)
# 토큰화 처리한 '기사 본문' 데이터셋의 불용어 제거
r_t_x_data = remove_stopword(tokenized_x_data, stopword_list)

불용어 제거 중간결과 체크
# 불용어 제거된 데이터를 자모 데이터로 분리
jamo_x_data = decompose_jamo(r_t_x_data)
from sklearn.model_selection import train_test_split
# 훈련/검증/평가를 75%, 20%, 5%로 분할을 수행
# random_state -> 데이터셋을 내누는데 '재현성' 유지를 위해 넣음 -> 안넣어도 됨
# stratify -> Y_label의 클래스 비율을 유지하면서 데이터 나눌때 옵션
x_train, x_etc, y_train, y_etc = train_test_split(
jamo_x_data, raw_y_label, test_size=0.25, stratify=raw_y_label
)
# 그 외 데이터셋을 반반으로 Val, Test로 나눔
x_val, x_test, y_val, y_test = train_test_split(
x_etc, y_etc, test_size=0.2, stratify=y_etc
)

from collections import Counter
word_list = []
# train항목을 워드 리스트에 입력
for sent in x_train:
for word in sent:
word_list.append(word)
# val항목을 워드 리스트에 입력
for sent in x_val:
for word in sent:
word_list.append(word)
# 단어와 해당 단어의 출몰 빈도를 함께 저장하는
# Counter 타입의 변수 생성
word_counts = Counter(word_list)

rare_th = 3 #희소단어의 등장 빈도를 결정하는 파라미터
# 희소단어 등장 빈도를 바탕으로 희소 단어를 배제하기 위해 준비 함수
tot_vocab_cnt, rare_vocab_cnt = set_rare_vocab(word_counts, rare_th,
report=False)
#등장 빈도가 높은 단어 순으로 정렬하기
vocab = sorted(word_counts, key=word_counts.get, reverse=True)
#등장 빈도가 높은 단어만 인덱싱 하기
vocab_size = tot_vocab_cnt - rare_vocab_cnt
vocab = vocab[:vocab_size]
# 스페셜 토큰 선언
spec_token = ['<PAD>', '<UNK>']
# 스페셜 토큰을 포함한 {단어:단어idx}의 딕셔너리 생성
word_to_idx, idx_to_word = set_word_to_idx(spec_token, vocab,
report=True)

# 데이터셋의 정수 인코딩 수행
e_x_train = text_to_sequences(x_train, word_to_idx)
e_x_val = text_to_sequences(x_val, word_to_idx)
e_x_test = text_to_sequences(x_test, word_to_idx)

context_length = 330
set_sent_pad(e_x_train, context_length, report=False)

# 데이터셋의 문장 패딩(정수인코딩의 완료)
padded_x_train = pad_seq_x(e_x_train, context_length)
padded_x_val = pad_seq_x(e_x_val, context_length)
padded_x_test = pad_seq_x(e_x_test, context_length)

import torch
bs = 256 # Batch_size 하이퍼 파라미터
# 정수(원핫)인코딩 데이터를 데이터로더로 변환
trainloader = set_dataloader(padded_x_train, y_train, bs, '훈련')
valloader = set_dataloader(padded_x_val, y_val, bs, '검증')
testloader = set_dataloader(padded_x_test, y_test, bs, '평가')
FastText로 임베딩 레이어 학습시키기
# Word2Vec 및 FastText 학습에 사용할 데이터:
# 원본 데이터셋의 토큰화 후 불용어 제거를 수행한 데이터터
# 에다가 단어 -> 자모 분리를 수행한 데이터
word2vec_doc = jamo_x_data
from gensim.models import FastText
FT_model = FastText(
sentences=word2vec_doc,
vector_size = 100, # 임베딩 차원은 100으로 설정
window = 5, # 논문의 최대 관심가질 주변단어 사이즈인 5~20
min_count = rare_th, # (3) 단어장에서 배제할 희소단어 빈도 기준
workers= -1, # 학습에 참여할 프로세스 개수 (최대로 설정)
sg = 1, # Skip-gram 방식으로 학습 수행
# FastText의 N-gram 범위 설정(3~6)
min_n=3, max_n=6
)

# 단어장 크기 및 임베딩 차원 정보 추출
vocab_size = len(word_to_idx)
embedding_dim = FT_model.wv.vector_size
def build_my_embed(word2idx, vocab_vector):
vocab_size = len(word2idx)
emb_dim = vocab_vector.vector_size
embedding_matrix = np.zeros((vocab_size, emb_dim))
for word, idx in word2idx.items():
# word2idx의 단어를 학습된 임베딩레이어가
# 포함된 단어벡터에서 찾아냄
if word in vocab_vector:
embedding_vector = vocab_vector[word]
embedding_matrix[idx] = embedding_vector
# 스페셜 토큰별로 처리하기
elif word == '<PAD>':
# '<PAD>' 토큰의 임베딩 벡터는 0으로 유지
embedding_matrix[idx] = np.zeros(emb_dim)
else: # 단어벡터에 없는 단어 발생 -> '<UNK>' 처리
# '<UNK>'는 랜덤 초기화 해버린다
embedding_matrix[idx] = np.random.normal(size=(emb_dim,))
return embedding_matrix
# FastText 방식으로 학습된 임베딩 레이어 조정
my_FT_embedding = build_my_embed(word_to_idx, FT_model.wv)
RNN, LSTM, GRU 모델기반 스팸분류기를 설계하기 전
주요 하이퍼 파라미터를 정리하자

RNN기반 스팸분류기 모델
import torch.nn as nn
class SimpleRNN(nn.Module):
def __init__(self, vocab_size, embed_dim, num_classes,
hid_dim, emb_matirx=None):
super(SimpleRNN, self).__init__()
self.embed = nn.Embedding(vocab_size, embed_dim)
if emb_matirx is not None:
# 사전 훈련된 임베딩 매트릭스를 붙여넣음
self.embed.weight = nn.Parameter(
torch.tensor(emb_matirx, dtype=torch.float32))
# 붙여넣은 Pretrained 임베드 레이어만 Freeze하고 싶을때는 False
self.embed.weight.requires_grad = True
# RNN은 반복횟수가 context_length으로 자동으로 지정됨
self.rnn = nn.RNN(input_size=embed_dim, #RNN에 입력되는 차원
hidden_size=hid_dim, #RNN의 내부 cell의 차원
num_layers=1, #내부 은닉 셀이 몇층인지?
batch_first=True, #입력 텐서의 첫번째가 Batch임
nonlinearity='tanh') #RNN활성화 함수 어떤것?
# hidden_dim은 임베딩 차원 * context_length로 설정한다.
self.classifier = nn.Sequential(
nn.Linear(hid_dim, num_classes),
)
def forward(self, x):
emb = self.embed(x)
rnn_out, hidden = self.rnn(emb)
out = hidden.squeeze(0)
out = self.classifier(out)
return out
LSTM 기반 스팸분류기 모델
import torch.nn as nn
class SimpleLSTM(nn.Module):
def __init__(self, vocab_size, embed_dim, num_classes,
hid_dim, emb_matirx=None):
super(SimpleLSTM, self).__init__()
self.embed = nn.Embedding(vocab_size, embed_dim)
if emb_matirx is not None:
# 사전 훈련된 임베딩 매트릭스를 붙여넣음
self.embed.weight = nn.Parameter(
torch.tensor(emb_matirx, dtype=torch.float32))
# 붙여넣은 Pretrained 임베드 레이어만 Freeze하고 싶을때는 False
self.embed.weight.requires_grad = True
# LSTM은 반복횟수가 context_length으로 자동으로 지정됨
self.lstm = nn.LSTM(input_size=embed_dim, # LSTM에 입력차원
hidden_size=hid_dim, # LSTM 내부 cell 차원
num_layers=1, # 내부 Cell layer 개수=1
batch_first=True) # 입력 텐서는 batch가 첫번째로 옴
# hidden_dim은 임베딩 차원 * context_length로 설정한다.
self.classifier = nn.Sequential(
nn.Linear(hid_dim, num_classes),
)
def forward(self, x):
emb = self.embed(x)
# LSTM의 출력은 out, hidden, cell 3가지가 나오는데
# hidden, cell은 튜플로 묶어서 출력된다.
# 이때 hidden은 입력데이터의 압축된 표현
# cell은 장기적인 정보를 보존하기위한 메모리임을 숙지하자
# lstm_out = (batch_size, seq_length, hidden_size)
# hidden 및 cell = (num_layers, batch_size, hidden_size)
lstm_out, (hidden, cell) = self.lstm(emb)
# hidden의 첫번째 차원 num_layers=1 을 축소시킴
out = hidden.squeeze(0)
out = self.classifier(out)
return out
GRU 기반 스팸분류기 모델
import torch.nn as nn
class SimpleGRU(nn.Module):
def __init__(self, vocab_size, embed_dim, num_classes,
hid_dim, emb_matirx=None):
super(SimpleGRU, self).__init__()
self.embed = nn.Embedding(vocab_size, embed_dim)
if emb_matirx is not None:
# 사전 훈련된 임베딩 매트릭스를 붙여넣음
self.embed.weight = nn.Parameter(
torch.tensor(emb_matirx, dtype=torch.float32))
# 붙여넣은 Pretrained 임베드 레이어만 Freeze하고 싶을때는 False
self.embed.weight.requires_grad = True
# RNN은 반복횟수가 context_length으로 자동으로 지정됨
self.gru = nn.GRU(input_size=embed_dim, # GRU에 입력차원
hidden_size=hid_dim, # GRU 내부 cell 차원
num_layers=1, # 내부 Cell layer 개수=1
batch_first=True) # 입력 텐서는 batch가 첫번째로 옴
# hidden_dim은 임베딩 차원 * context_length로 설정한다.
self.classifier = nn.Sequential(
nn.Linear(hid_dim, num_classes),
)
def forward(self, x):
emb = self.embed(x)
# GRU의 출력구조는 RNN과 동일하다
# gur_out = (batch_size, seq_length, hidden_size)
# hidden = (num_layers, batch_size, hidden_size)
gru_out, hidden = self.gru(emb)
# hidden의 첫번째 차원 num_layers=1 을 축소시킴
out = hidden.squeeze(0)
out = self.classifier(out)
return out
LSTM모델만 출력 형식이
lstm_out, (hidden, cell) = self.lstm(emb)
위와 같이 hidden_state, cell_state가 같이 튜플형식으로 묶여서 출력되는 것만 다르고
나머지는 RNN, GRU 모두 동일하다
학습을 위한 사전설정
# 학습 실험 조건을 구분하기 위한 키
model_key = ['RNN', 'LSTM', 'GRU']
cod_key = ['랜덤초기화', '사전훈련']
metrics_key = ['Loss', '정확도']
key_list = [f"{mk}_{ck}" for mk in model_key for ck in cod_key]
RNN_model_raninit = SimpleRNN(VOCAB_SIZE, EMB_DIM,
NUM_CLASS, HIDE_DIM)
RNN_model_pre_emb = SimpleRNN(VOCAB_SIZE, EMB_DIM,
NUM_CLASS, HIDE_DIM, my_FT_embedding)
LSTM_model_raninit = SimpleLSTM(VOCAB_SIZE, EMB_DIM,
NUM_CLASS, HIDE_DIM)
LSTM_model_pre_emb = SimpleLSTM(VOCAB_SIZE, EMB_DIM,
NUM_CLASS, HIDE_DIM, my_FT_embedding)
GRU_model_raninit = SimpleGRU(VOCAB_SIZE, EMB_DIM,
NUM_CLASS, HIDE_DIM)
GRU_model_pre_emb = SimpleGRU(VOCAB_SIZE, EMB_DIM,
NUM_CLASS, HIDE_DIM, my_FT_embedding)
# GPU사용 가능 유/무 확인
device = torch.device("cuda" if torch.cuda.is_available() else 'cpu')
models = {} # 딕셔너리
models[key_list[0]] = RNN_model_raninit.to(device)
models[key_list[1]] = RNN_model_pre_emb.to(device)
models[key_list[2]] = LSTM_model_raninit.to(device)
models[key_list[3]] = LSTM_model_pre_emb.to(device)
models[key_list[4]] = GRU_model_raninit.to(device)
models[key_list[5]] = GRU_model_pre_emb.to(device)
import torch.optim as optim
# 로스함수 및 옵티마이저 설계
criterion = nn.CrossEntropyLoss()
LR = 0.001 # 러닝레이트는 통일
optimizers = {}
optimizers[key_list[0]] = optim.Adam(RNN_model_raninit.parameters(), lr=LR)
optimizers[key_list[1]] = optim.Adam(RNN_model_pre_emb.parameters(), lr=LR)
optimizers[key_list[2]] = optim.Adam(LSTM_model_raninit.parameters(), lr=LR)
optimizers[key_list[3]] = optim.Adam(LSTM_model_pre_emb.parameters(), lr=LR)
optimizers[key_list[4]] = optim.Adam(GRU_model_raninit.parameters(), lr=LR)
optimizers[key_list[5]] = optim.Adam(GRU_model_pre_emb.parameters(), lr=LR)
# 사전에 모듈화 한 학습/검증용 라이브러리 import
from C_ModelTrainer import ModelTrainer
num_epoch = 8 #총 훈련/검증 epoch값
ES = 2 # 디스플레이용 에포크 스텝
# BC_mode = True(이진), False(다중)
# aux = 보조분류기 유/무
# wandb = 완디비에 연결 안하면 None
# iter = 훈련시 iteration의 acc및 loss 정보 추출
trainer = ModelTrainer(epoch_step=ES, device=device,
BC_mode=False, aux=False, iter=False)
# 학습/검증 정보 저장
history = {key: {metric: []
for metric in metrics_key}
for key in key_list}
학습 실행
#실험조건 : 모델 + 임베딩레이어 pretrain 유/무
for key in key_list:
# 모델 훈련/검증 코드
for epoch in range(num_epoch):
# 훈련모드의 손실&성과 지표
train_loss, train_acc = trainer.model_train(
models[key], trainloader,
criterion, optimizers[key], epoch)
# 검증모드의 손실&성과 지표
val_loss, val_acc = trainer.model_evaluate(
models[key], valloader,
criterion, epoch)
# 손실 및 성과 지표를 history에 저장
history[key]['Loss'].append((train_loss, val_loss))
history[key]['정확도'].append((train_acc, val_acc))
# Epoch_step(ES)일 때마다 print수행
if (epoch+1) % ES == 0 or epoch == 0:
if epoch == 0:
print(f"현재 훈련중인 조건: [{[key]}]")
print(f"epoch {epoch+1:03d}," + "\t" +
f"훈련 [Loss: {train_loss:.3f}, " +
f"Acc: {train_acc*100:.2f}%]")
print(f"epoch {epoch+1:03d}," + "\t" +
f"검증 [Loss: {val_loss:.3f}, " +
f"Acc: {val_acc*100:.2f}%]")
print(f"\n----조건[{[key]}] 훈련 종료----\n")

위 사진같이 결과가 각 모델-조건별로 연속해서 출력되면 정상적으로 학습이 되고 있는 것이다.
학습 결과 분석
import matplotlib.pyplot as plt
# 한글 사용을 위한 폰트 포함
plt.rcParams['font.family'] = 'Malgun Gothic'
plt.rcParams['axes.unicode_minus'] = False
# 학습/검증 결과 데이터를 재배치
res_data = {}
for key in key_list:
res_data[key] = {}
for metric in metrics_key:
# 각 모델의 메트릭 데이터 추출
metric_data = history[key][metric]
# 훈련 및 검증 값 분리
train_values = [tup[0] for tup in metric_data]
val_values = [tup[1] for tup in metric_data]
res_data[key][f'훈련_{metric}'] = train_values
res_data[key][f'검증_{metric}'] = val_values
# 손실 및 정확도 그래프 그리기 그래프 생성
fig, axes = plt.subplots(3, 4, figsize=(12, 12))
axes = axes.flatten() # 2차원 배열을 1차원으로 변환하여 인덱싱 쉽게 함
# 손실 그래프 그리기
for idx, key in enumerate(key_list):
ax = axes[idx*2] #손실 그래프는 0, 2번째에 위치
ax.plot(res_data[key]['훈련_Loss'], label='훈련 로스')
ax.plot(res_data[key]['검증_Loss'], label='검증 로스')
ax.set_title(f'{key} 텍스트 분류기 Loss')
ax.set_xlabel('Epoch')
ax.set_ylabel('Loss')
ax.legend()
# 정확도 그래프 그리기
for idx, key in enumerate(key_list):
ax = axes[idx*2 + 1] #정확도 그래프는 1, 3번째에 위치
ax.plot(res_data[key]['훈련_정확도'], label='훈련 정확도')
ax.plot(res_data[key]['검증_정확도'], label='검증 정확도')
ax.set_title(f'{key} 텍스트 분류기 정확도')
ax.set_xlabel('Epoch')
ax.set_ylabel('정확도')
ax.legend()
plt.tight_layout()
plt.show()

# 모든 모델의 손실과 정확도를 비교하는 그래프 생성
fig, axes = plt.subplots(2, 1, figsize=(10, 18))
# 모든 모델의 손실 그래프
ax = axes[0]
for key in key_list:
ax.plot(res_data[key]['훈련_Loss'], label=f'{key} 훈련 로스')
ax.plot(res_data[key]['검증_Loss'], label=f'{key} 검증 로스', linestyle='--')
ax.set_title('모든 조건별 모델의 훈련/검증 Loss')
ax.set_xlabel('Epoch')
ax.set_ylabel('Loss')
ax.legend()
# 모든 모델의 정확도 그래프
ax = axes[1]
for key in key_list:
ax.plot(res_data[key]['훈련_정확도'], label=f'{key} 훈련 정확도')
ax.plot(res_data[key]['검증_정확도'], label=f'{key} 검증 정확도', linestyle='--')
ax.set_title('모든 조건별 모델의 훈련/검증 정확도')
ax.set_xlabel('Epoch')
ax.set_ylabel('정확도')
ax.legend()
plt.tight_layout()
plt.show()

실험 결과를 본다면
이전 포스트처럼 RNN은 개념만 숙지하고 실 사용으로는 적합하지 않은 모델이라는 것을 재확인 할 수 있으며
LSTM이랑 GRU는 거의 성능이 유사하다 볼 수 있으나
현재 데이터셋의 크기가 그렇게 큰 편은 아니어서
두 모델간의 우열을 정확히 가르기에는 좀 부적절하다.
또 스팸 분류기나 감정분석기 같은 classification은
자연어 처리에서는 기본적인 자연어 이해, NLU(Natural Language Understanding) Task에 속하는거라
이것보다는 좀 더 난이도가 높은
Many-to-Many 방법론 : 개채명 인식(Named Entity Recognition)
실습을 통해 LSTM와 GRU의 성능을 비교분석하고자 한다.