시작하며
딥러닝 모델이 시계열 정보를 학습하면, 아직 알지 못하는 미래의 값을 예측할 수 있습니다. 모델이 예측한 값을 다시 모델의 입력으로 넣어주는 기법을 이용하면, 몇 가지 단어만 주어져도 문장을 적는 인공지능을 구현할 수 있습니다. 이번 글에서는 RNN의 발전 형태인 LSTM을 학습하여 문장을 쓰는 인공지능을 만들겠습니다.
학습 순서
미리보기
- LSTM은 RNN의 발전 형태로 장기 기억을 담당하는 셀 상태와 은닉 상태를 갖는 신경망입니다.
- BOW는 모든 단어를 겹치지 않도록 고유 번호로 나타낸 집합을 말합니다.
- 대부분의 숫자가 0인 숫자 표현을 희소 표현이라고 합니다.
- 대부분의 숫자가 0이 아닌 숫자 표현을 밀집 표현이라고 합니다.
- 파이토치의 임베딩층을 이용하면 희소 표현을 밀집 표현으로 바꿀 수 있습니다.
???? 순환 신경망(RNN)은 순차 데이터나 시계열 데이터를 이용하는 인공 신경망 유형입니다. 이 딥러닝 알고리즘은 언어 변환, 자연어 처리(NLP), 음성 인식과 같은 문제에 활용합니다.
텍스트 생성 이해하기
문장을 만들려면 바로 다음에 올 단어뿐만 아니라 그 이후의 단어들도 예측해야 합니다. 그러려면 모델을 반복 호출하면서 여러 차례 단어들을 출력해야 합니다. 이 과정에 LSTM을 사용합니다. LSTM은 게이트를 이용해 이전 은닉 상태를 현재의 입력에 반영하는 알고리즘입니다. 자세한 내용은 10.2절에서 곧바로 배우겠습니다. 우선은 LSTM을 사용해 문장을 예측하는 방법을 알아보겠습니다.
▼ LSTM을 반복 호출해 예측값을 여러 번 출력하기
그림에서 1과 2는 모델에 들어가는 입력값이며 0은 초기 은닉 상태입니다. 모델에 단어 1과 은닉상태 0을 입력으로 넣어서 얻은 은닉 상태를 다음 시점의 입력인 단어 2와 이전의 입력값인 단어 1을 함께 모델에 넣어줍니다. 그렇게 3번 시점의 입력값은 이전 시점의 입력인 단어 2와 이전 시점의 은닉 상태로부터 예측한 단어 3이 됩니다.
예를 들어 ‘나는’을 입력받은 모델은 ‘빨간’이라는 단어를 예측하고, 다시 ‘나는’과 ‘빨간’을 입력받아 ‘사과를’이라는 단어를 예측합니다. 이런 과정을 여러 번 반복함으로써 글을 쓰는 인공지능을 만들 수 있는 겁니다.
▼ ‘나는’으로부터 문장을 완성시키는 방법
LSTM 이해하기
RNN은 시계열 데이터를 처리할 수는 있지만 치명적인 단점을 가지고 있습니다. RNN은 단순히 가중치를 반복 사용해 데이터로부터 순서 정보를 추출합니다. 문제는 여기에 있습니다. RNN의 기울기를 역전파할 때, 기울기가 1보다 커지면 기울기가 걷잡을 수 없이 커지고, 반대로 1보다 작아지면 갈수록 0에 수렴하기 때문입니다. 따라서 시계열 길이가 길어질수록 RNN을 적용하기 어려웠습니다. 이런 RNN의 단점을 극복한 모델이 바로 LSTM입니다.
LSTM은 장기 기억을 위한 셀 상태(cell state)를 추가한 모델입니다. LSTM의 전체 흐름은 다음과 같습니다.
???? LSTM(long short term memory): 망각 게이트, 입력 게이트, 출력 게이트를 이용해 이전 시점 의 은닉 상태를 현시점에 반영하는 알고리즘입니다.
▼ LSTM 구조
LSTM의 구조를 나타낸 그림입니다. 구조가 상당히 복잡하니 하나씩 천천히 알아보겠습니다. 먼저 현재 입력 X와 이전 시점의 은닉 상태 h1을 합쳐 입력 텐서를 만들어줍니다. 입력 텐서는 A1층을 거쳐 셀 상태 C1과 곱해집니다. 이 단계에서 현재의 정보와 과거의 정보에 대한 관계를 파악합니다. 과거의 정보와 현재의 정보가 무관하다면 A1의 출력이 0에 가까워질 겁니다. 만약 그렇게 된다면 이전의 셀 상태 정보를 초기화할 수 있습니다. 따라서 ❶ 을 망각 게이트(forget gate)라고 부릅니다.
그다음에는 입력 텐서가 A2를 거쳐 현재의 정보를 셀 상태에 기록할 것인지를 결정하고, A3를 거쳐 기록할 양을 결정합니다.A2와A3의출력이곱해져서셀상태에더해집니다. ❷를 입력 게이트(input gate)라고 부릅니다.
마지막으로 입력 텐서로부터 A4를 이용해 특징을 추출합니다. 셀 상태는 A5를 거쳐서 현재의 출력을 결정하기 위해 얼만큼 셀 상태의 정보를 이용할 것인가를 결정합니다. A4와 A5의 출력이 곱해져서 현재의 은닉 상태를 결정하고 다음 시점으로 셀 상태와 은닉 상태를 넘겨줍니다. 현시점의 출력을 결정하기 때문에 ❸을 출력 게이트(output gate)라고 부릅니다. LSTM의 출력은 출력층의 은닉 상태 h2와 셀 상태 C2입니다. 새로운 개념이 많이 나왔으니 망각/입력/출력 게이트를 정리하고 넘어가겠습니다.
- 망각 게이트 : 셀 상태에 저장된 과거의 정보를 사용할 것인가에 대한 여부를 결정합니다.
- 입력 게이트 : 셀 상태에 현재 정보를 덮어쓸 것인가를 결정합니다.
- 출력 게이트 : 셀 상태와 현재 정보를 합쳐 현재의 은닉 상태를 결정합니다.
▼ LSTM 장단점
데이터 살펴보기
텍스트 생성에 뉴욕 타임스 코멘트 데이터를 사용하겠습니다(https://www.kaggle.com/aashita/nyt-comments). 데이터셋에는 여러 csv 파일이 들어 있을 겁니다.
데이터가 어떤 구성인지 컬럼을 확인해보겠습니다.
▼ 데이터 살펴보기
import pandas as pd import os import string df = pd.read_csv( "/content/drive/MyDrive/Colab Notebooks/data/CH10/ArticlesApril2017.csv") print(df.columns)
Index(['abstract', 'articleID', 'articleWordCount', 'byline', 'documentType', 'headline', 'keywords', 'multimedia', 'newDesk', 'printPage', 'pubDate', 'sectionName', 'snippet', 'source', 'typeOfMaterial', 'webURL'], dtype='object')
csv 파일 안에는 여러 항목이 있지만 우리는 headline만 이용하겠습니다. headline에는 사람이 직접 작성한 기사가 들어 있기 때문입니다. 나머지 컬럼은 학습에 도움이 되지 않기 때문에 사용하지 않습니다.
학습용 데이터 만들기
❶ 먼저 구두점과 특수 문자를 제거해야 합니다. ❷ 그 후에 딥러닝 모델이 단어들을 이해하도록 사전을 만들어 단어마다 고유 번호를 만들어줍니다. 사전을 만들고 나서는 자연어 문장 속 단어들을 전부 고유 번호로 바꿔 새로운 문장을 만들어줘야 합니다. 이때 고유 번호를 담고 있는 사전을 BOW라고 부릅니다. 자연어를 숫자로 변경해주었으면 2개의 단어와 다음에 올 단어를 반환해주도록 데이터셋 객체를 만들면 됩니다.
????BOW(bag of words): 모든 단어를 겹치지 않도록 고유 번호로 나타낸 집합을 말합니다.
▼ BOW를 만드는 과정
먼저 데이터셋 객체를 살펴봅시다.
▼ 학습용 데이터셋 정의
import numpy as np import glob from torch.utils.data.dataset import Dataset class TextGeneration(Dataset): def clean_text(self, txt): # 모든 단어를 소문자로 바꾸고 특수 문자를 제거 txt = "".join(v for v in txt if v not in string.punctuation).lower() return txt
clean_ text( ) 함수는 텍스트를 소문자로 바꾸고 특수 문자를 제거하는 함수입니다. 텍스트 생성에서는 대소문자를 구분할 필요가 없기 때문에 모든 단어를 소문자로 바꾸었습니다. 특수 문자도 마찬가지로 모델의 학습에 영향을 미치지 않기 때문에 제거해줍니다. string.punctuation 안에는 문장에서 쓰이는 특수 문자들이 들어 있습니다. 느낌표 물음표 마침표 등, 문장에서 등장하는 특수 문자들이 들어 있는 리스트입니다.
학습에 사용할 데이터셋 객체의 _ _ init_ _ ( ) 함수를 구현하겠습니다.
▼ 데이터를 불러와 BOW 생성하기
class TextGeneration(Dataset): def clean_text(self, txt): # 모든 단어를 소문자로 바꾸고 특수문자를 제거 txt = "".join(v for v in txt if v not in string.punctuation).lower() return txt def __init__(self): all_headlines = [] # ❶ 모든 헤드라인의 텍스트를 불러옴 for filename in glob.glob("/content/drive/MyDrive/Colab Notebooks/data/CH10/*.csv"): if 'Articles' in filename: article_df = pd.read_csv(filename) # 데이터셋의 headline의 값을 all_headlines에 추가 all_headlines.extend(list(article_df.headline.values)) break # ❷ headline 중 unknown 값은 제거 all_headlines = [h for h in all_headlines if h != "Unknown"] # ❸ 구두점 제거 및 전처리가 된 문장들을 리스트로 반환 self.corpus = [self.clean_text(x) for x in all_headlines] self.BOW = {} # ➍ 모든 문장의 단어를 추출해 고유번호 지정 for line in self.corpus: for word in line.split(): if word not in self.BOW.keys(): self.BOW[word] = len(self.BOW.keys()) # 모델의 입력으로 사용할 데이터 self.data = self.generate_sequence(self.corpus)
❶ Articles라는 이름이 붙는 파일을 전부 불러옵니다. 그다음 csv 파일 안의 헤드라인 텍스트만 불러와서 all_ headlines 리스트 안에 저장합니다. ❷ 값이 존재하지 않는 값, 즉 Unknown값을 전부 제거해줍니다. ❸ 구두점 등의 특수 문자는 학습에 방해가 되므로 제거해줍니다. ❹ 전처리가 완료되면 각 단어의 고유 번호를 지정해줍니다.
이번에는 텍스트 시계열을 생성하는 함수를 구현하겠습니다.
▼ BOW를 이용한 시계열 구성
def generate_sequence(self, txt): seq = [] for line in txt: line = line.split() line_bow = [self.BOW[word] for word in line] # 단어 2개를 입력으로, 그다음 단어를 정답으로 data = [([line_bow[i], line_bow[i+1]], line_bow[i+2]) for i in range(len(line_bow)-2)] seq.extend(data) return se
먼저 공백을 기준으로 문장으로부터 단어를 분리 한 뒤, 인접한 두 단어를 입력 데이터로, 그다음에 올 단어를 정답으로 사용합니다.
▼ 데이터셋을 만드는 과정
앞의 그림에 데이터셋의 배치와 정답을 만드는 과정을 나타냈습니다. 단어 ‘철수가’와 ‘밥을’을 모델의 입력으로 사용하면 다음에 등장하는 단어는 ‘영희와’입니다. 마찬가지로 ‘밥을’과 ‘영희와’가 입력으로 들어오면 ‘먹었다’가 정답이 됩니다.
다음으로 len( ) 함수를 만듭니다. self.data 안에 필요한 모든 데이터가 들어 있기 때문에 self.data의 길이를 반환하는 것으로 충분합니다.
▼ 데이터 개수를 반환하는 함수
def __len__(self): return len(self.data)
_ _ getitem_ _ ( ) 함수로 self.data로부터 정답과 입력 데이터를 읽어오겠습니다.
▼ 데이터를 불러오는 함수
def __getitem__(self, i): data = np.array(self.data[i][0]) # ❶ 입력 데이터 label = np.array(self.data[i][1]).astype(np.float32) # ❷ 출력 데이터 return data, label
self.data는 인접한 두 단어, 그 뒤에 올 단어 순서로 데이터를 보관하기 때문에 ❶ 입력 데이터로 self.data[ i][ 0]을, ❷ 정답으로 self.data[ i][ 1]을 입력해줍니다.
LSTM 모델 정의하기
LSTM 모델은 6장의 RNN 모델과 유사하게 구성하겠습니다. 하지만 BOW를 이용해 만든 단어들을 그대로 모델의 입력으로 사용하기엔 문제점이 많습니다. BOW를 그대로 입력으로 사용하고자 한다면, 전체 단어 개수와 같은 길이를 갖는 벡터를 만들고, 고유 번호에 해당하는 요소만 1이고 나머지는 0을 갖는 벡터를 만들어 입력으로 사용해야 합니다. 다중분류를 할 때 클래스를 정의할 때처럼 말이죠.
????희소 표현: 벡터의 대부분이 0인 표현 방법
▼ 희소 표현 벡터
하지만 자연어 처리에 사용하는 단어 개수는 다중분류의 클래스보다 훨씬 더 많습니다. 모델의 입력으로 들어가는 입력값의 대부분이 0이 되버리는 셈이죠. 이렇게 대부분의 값이 0이 되는 표현 방식을 희소 표현이라고 부릅니다. 대부분의 값이 0이 되면 학습이 원활하게 이루어지지 않습니다. 0에는 어떤 값을 곱해도 0이 되기 때문입니다. 이를 해결하려면 임베딩층이 필요합니다.
여기서 임베딩층은 희소 표현인 입력 벡터를 밀집 표현으로 바꿔주는 층을 말합니다. 밀집 표현이란 희소 표현과 반대로 0이 거의 포함돼 있지 않은 표현 방법을 말합니다. 단어 개수가 아무리 많아도 임베딩층을 이용해 적당한 차원의 밀집 표현으로 바꿔준다면 딥러닝 모델의 학습에 큰 도움이 됩니다.
????밀집 표현: 벡터의 요소 대부분이 0이 아닌 표현 방법
▼ 희소 표현을 밀집 표현으로 변환
앞의 그림이 희소 표현을 밀집 표현으로 바꾸는 방법을 간략하게 나타낸 그림입니다. 화살표가 임베딩층의 가중치를 나타냅니다. 즉, 여기서는 1이라고 되어 있는 입력벡터의 요소에 해당하는 임베딩층의 가중치가 0.1, 0.3, 0.2, 0.4로 계산이 되었다는 의미입니다. 다른 요소에는 다른 가중치가 설정되어 있습니다.
벡터의 밀집 표현은 파이토치의 임베딩층이 계산해줍니다. 임베딩층의 구조는 MLP층과 같습니다.
▼ LSTM 모델 기본 블록
기본 모델을 활용해 텍스트를 생성하는 LSTM 모델을 만들어봅시다.
import torch import torch.nn as nn class LSTM(nn.Module): def __init__(self, num_embeddings): super(LSTM, self).__init__() # ❶ 밀집표현을 위한 임베딩층 self.embed = nn.Embedding( num_embeddings=num_embeddings, embedding_dim=16) # LSTM을 5개층을 쌓음 self.lstm = nn.LSTM( input_size=16, hidden_size=64, num_layers=5, batch_first=True) # 분류를 위한 MLP층 self.fc1 = nn.Linear(128, num_embeddings) self.fc2 = nn.Linear(num_embeddings,num_embeddings) # 활성화 함수 self.relu = nn.ReLU() def forward(self, x): x = self.embed(x) # ❷ LSTM 모델의 예측값 x, _ = self.lstm(x) x = torch.reshape(x, (x.shape[0], -1)) x = self.fc1(x) x = self.relu(x) x = self.fc2(x) return x
❶ 임베딩층을 정의합니다. num_embeddings는 BOW의 단어 개수를 의미합니다. embedding_dim은 밀집 표현의 차원을 의미합니다. 즉, num_embeddings 차원의 벡터를 embedding_ dim 차원으로 변경하는 임베딩층을 정의하는 겁니다.
❷ 파이토치의 LSTM층은 RNN층과 비슷하게 전체 출력값과 함께 마지막 은닉 상태를 반환합니다. RNN은 출력값이 은닉 상태 하나뿐이었지만, LSTM은 은닉 상태와 함께 셀 상태도 반환합니다. 이번에는 전체 출력만을 이용하겠습니다.
학습하기
LSTM의 학습 루프는 6장의 예제와 같습니다. 다만, 이번에는 뒤에 올 단어를 분류하는 분류 문제이므로 손실 계산에 크로스 엔트로피 함수를 이용할 겁니다.
▼ 학습 루프
▼ 모델 학습하기
import tqdm from torch.utils.data.dataloader import DataLoader from torch.optim.adam import Adam # 학습을 진행할 프로세서 정의 device = "cuda" if torch.cuda.is_available() else "cpu" dataset = TextGeneration() # 데이터셋 정의 model = LSTM(num_embeddings=len(dataset.BOW)).to(device) # 모델 정의 loader = DataLoader(dataset, batch_size=64) optim = Adam(model.parameters(), lr=0.001) for epoch in range(200): iterator = tqdm.tqdm(loader) for data, label in iterator: # 기울기 초기화 optim.zero_grad() # 모델의 예측값 pred = model(torch.tensor(data, dtype=torch.long).to(device)) # 정답 레이블은 long 텐서로 반환해야 함 loss = nn.CrossEntropyLoss()( pred, torch.tensor(label, dtype=torch.long).to(device)) # 오차 역전파 loss.backward() optim.step() iterator.set_description(f"epoch{epoch} loss:{loss.item()}") torch.save(model.state_dict(), "lstm.pth")
먼저 학습에 필요한 데이터셋과 모델의 정의합니다. 다음으로 기울기를 초기화하고 모델의 예측값을 출력한 뒤, 오차를 역전파합니다.
모델 성능 평가하기
인공지능이 쓴 문장을 확인하려면 반복적으로 모델을 사용해야 합니다. 간단한 함수 하나를 만들어서 결과를 확인하겠습니다.
▼ 모델이 예측하는 문장을 출력하는 함수
def generate(model, BOW, string="finding an ", strlen=10): device = "cuda" if torch.cuda.is_available() else "cpu" print(f"input word: {string}") with torch.no_grad(): for p in range(strlen): # 입력 문장을 텐서로 변경 words = torch.tensor( [BOW[w] for w in string.split()], dtype=torch.long).to(device) # ❶ input_tensor = torch.unsqueeze(words[-2:], dim=0) output = model(input_tensor) # 모델을 이용해 예측 output_word = (torch.argmax(output).cpu().numpy()) string += list(BOW.keys())[output_word] # 문장에 예측된 단어를 추가 string += " " print(f"predicted sentence: {string}") model.load_state_dict(torch.load("lstm.pth", map_location=device)) pred = generate(model, dataset.BOW)
input word: finding an predicted sentence: finding an expansive view and dims it today today sister sharp in
모델이 만들어낸 문장이 어색해 보이지 않습니다. 학습이 잘 진행된 것 같군요.
❶ 모델의 입력으로 사용하기 위해 배치 차원을 추가합니다. 이때 문장의 마지막 두 단어를 사용합니다. 모델의 입력으로 두 단어를 넣어 그다음에 올 단어를 예측하게 되므로 매번 문장의 마지막 두 단어를 입력으로 사용해야 합니다.
▼ 새로 등장한 함수
마무리
이번 글에서는 텍스트를 생성하는 데 LSTM을 이용했습니다. 자연어를 딥러닝을 통해 학습하려면 단어를 숫자 표현으로 바꿔줘야 하는데, BOW와 임베딩층을 이용해 밀집 표현으로 바꿔 학습했습니다.
책 내용 중 궁금한 점, 공부하다가 막힌 문제 등 개발 관련 질문이 있으시다면
언제나 열려있는 <스프링 부트 3 백엔드 개발자 되기> 저자님의
카카오채널로 질문해주세요!