CNN을 활용한 비속어 필터링

CNN을 활용해서 텍스트를 분류하는 예제는 이전에서 살펴봤습니다.
해당 예제는 영화평점 텍스트를 학습하고 그 평가가 긍정인지 부정인지를 판단하는 문제를 CNN을 통해서 예측하는 내용이었습니다.
내용이 궁금하신 분은 아래의 예제를 살펴보시기 바랍니다.

이번에도 CNN을 활용한 예제로 비속어를 필터링하는 내용입니다. 본 예제에서는 학습을 통해서 생성된 모델을 python에서 제공하는 웹 어플리케이션 제작 프레임워크인 django를 통해서 간단한 웹서버를 구현해보겠습니다.

먼저 아래의 그림은 이미 많은 연구 문서나 블로그에서 보셨을듯한 이미지로 CNN을 활용한 텍스트 분류의 대표적인 이미지입니다. 해당 이미지를 간단히 설면하면 아래의 문장 “wait for the video and don’t rent it” 문장을 어절단위로 분리하여 Embedding(n, 5) 레이어를 통과시키면 아래의 문장은 “문장어절 × 5” 형태의 값을 가지게 됩니다. 이것이 첫번째 이미지입니다. 이것을 CNN에서 처리하는 벡터 shape으로 만들기 위해서는 앞에 Channel 값을 입력하게 됩니다.

이렇게 되면 예시 문장은 하나의 이미지 데이터의 모양(Shape)을 가지게 됩니다. 그러나 이런 문장이 하나만 존재하지는 않고 여러개 존재합니다. 그렇게 되면 맨 앞에는 n_batch 정보를 입력 할 수 있습니다.

예를 들어서 100개의 문장이라면 [100 × 1 × 9 × 5] 형태의 shape이 되는 것이죠. 이는 CNN을 통해서 이미지를 분류한다고 생각할 때에 9×5의 1채널 이미지 100장에 해당됩니다.

이제 이것을 Conv2d 레이어를 통과시키고 나온 output 데이터를 Fully Connected 한 후에 Linear 레이어를 통과시키고 이 값을 Softmax로 시켜 최종 출력값을 얻습니다.

이 과정은 이미지를 분류하는 과정과 굉장히 유사합니다. 다만 앞부분에서 “어떻게 텍스트를 처리해서 매트릭스를 만드는가?” 하는 과정만 차이가 있습니다. 위의 논문에서는 어절 단위로 분리해서 처리했고 또 다른 논문에서는 한글 자소단위로 분리하여 필터를 적용하는 연구도 있습니다.

본 예제에서는 문장을 단어 단위로 분리하되 각 단어를 n-gram 하여 2글자씩 분리해서 워드 벡터를 생성하였습니다. 학습에 사용할 비속어 리스트는 아래와 같습니다.

txt 컬럼은 비속어 정보를 label 정보는 1의 경우는 비속어, 0은 일반 단어로 표시합니다. 각 텍스트는 2글자씩 분리하여 워드 벡터를 만든다고 했는데 해당 과정을 수행하면 “야해요야동” 문장의 경우”야해, 해요, 요야, 야동, 야해요야동” 이런 방법으로 구성됩니다. 비속어가 1글자 인경우도 상당히 많기 때문에 1글자의 경우는 한글자만 사용하는 것으로 했습니다. 2글자씩 분리한 다음 마지막에는 원문도 포함해서 훈련용 데이터셋을 생성합니다.

# n-gram
def textgram(text):
    tmp = []
    if len(text) > 1:
        for i in range(len(text)-1):
            tmp.append(text[i]+text[i+1])
        tmp.append(text)
    return tmp

textgram('야해요야동')
# output ['야해', '해요', '요야', '야동', '야해요야동']

아래 데이터는 테스트에 사용했던 도메인의 일반 텍스트입니다. 당연한 말이지만 도메인이 넓은 경우 보다는 한정된 범위로 축소하는 것이 더 좋은 예측 결과를 보입니다. 본 예제의 경우는 챗봇에 사용하는 일상 대화들로부터 데이터를 수집하였습니다. 아래의 데이터셋 역시 2글자로 분리합니다. label이 0인 것은 정상 단어라는 의미입니다.

생성된 단어의 리스트들을 통해서 vocab을 만듭니다. 이때 중복 제거는 필수입니다.
만들어진 vocab에 padding, unk 값을 추가합니다. 그 이유는 각 단어가 기준 크기 보다 작은 경우 빈 값을 패딩값으로 채우기 위함입니다. unk의 경우는 vocab에 존재하지 않는 단어가 나올 경우 해당 위치를 채워주는 코드입니다.

vocab = list(set([w for word in words for w in word]))
vocab = np.insert(vocab,0,'!') # padding
vocab = np.insert(vocab,0,'#') # unk

x_data = [[word2index[w] for w in word] for word in words]

이제 각 워드를 인덱스로 바꾸는 과정을 완료하면 아래와 같은 데이터셋을 얻을 수 있습니다. 아래의 데이터셋은 보시는 것처럼 그 크기가 각각 달라서 일정한 값으로 Shape을 맞출 필요가 있습니다.

print(x_data[0:10])

[[2179, 2693, 4402, 2776, 3215],
 [964, 1927, 2027, 1767, 6721, 3171],
 [964, 1927, 1525, 5679, 3310],
 [964, 1927, 4133, 257, 2462, 1061, 554, 1941, 1753, 1666],
 [964, 1927, 5247, 1177],
 [964, 1927, 3795, 6693, 191, 5585, 3299, 5066],
 [964, 1927, 601, 4397, 2938],
 [1298, 5558, 2423, 5374, 877, 4260],
 [3911, 229, 5374, 877, 4103],
 [3241, 5603, 3173]]

고정 크기를 정해주고 해당 길이보다 작은 데이터들은 아래와 같이 사전에 정의한 padding 값으로 채워줍니다. 그렇게 되면 아래와 같은 형태의 데이터 데이터 값을 얻을 수 있습니다. 여기서 vocab에 존재하지 않는 새로운 단어가 입력되면 해당 단어는 0으로 채우게 됩니다.

[array([2179, 2693, 4402, 2776, 3215,    1,    1,    1,    1,    1]),
 array([ 964, 1927, 2027, 1767, 6721, 3171,    1,    1,    1,    1]),
 array([ 964, 1927, 1525, 5679, 3310,    1,    1,    1,    1,    1]),
 array([ 964, 1927, 4133,  257, 2462, 1061,  554, 1941, 1753, 1666]),
 array([ 964, 1927, 5247, 1177,    1,    1,    1,    1,    1,    1]),
 array([ 964, 1927, 3795, 6693,  191, 5585, 3299, 5066,    1,    1]),
 array([ 964, 1927,  601, 4397, 2938,    1,    1,    1,    1,    1]),
 array([1298, 5558, 2423, 5374,  877, 4260,    1,    1,    1,    1]),
 array([3911,  229, 5374,  877, 4103,    1,    1,    1,    1,    1]),
 array([3241, 5603, 3173,    1,    1,    1,    1,    1,    1,    1])]

입력 데이터에 대한 준비가 마무리되면 파이토치의 nn.Module 모듈을 상속 받아서 훈련용 모듈을 생성합니다. 이전에 활용한 CNN을 활용한 텍스트 분류 글에서 사용했던 CNN 모듈을 그대로 사용하기 때문에 해당 부분은 생략합니다. 다만 생성된 모듈을 출력해보면 아래와 같은 정보를 얻을 수 있습니다.

CNN(
  (embedding): Embedding(7037, 100)
  (convs): ModuleList(
    (0): Conv2d(1, 100, kernel_size=(2, 100), stride=(1, 1))
    (1): Conv2d(1, 100, kernel_size=(3, 100), stride=(1, 1))
    (2): Conv2d(1, 100, kernel_size=(4, 100), stride=(1, 1))
    (3): Conv2d(1, 100, kernel_size=(5, 100), stride=(1, 1))
  )
  (fc): Linear(in_features=400, out_features=2, bias=True)
)

해당 모듈은 Embedding 레이어, 4개의 Conv2d 레이어, Linear 레이어로 구성되어 있습니다. Linear 레이어의 최종 값은 2이고 이는 (0,1) 둘 중에 하나의 값을 출력하게됩니다.

훈련용 데이터는 DataLoader를 통해서 데이터셋을 만들고 batch_size = 1000, epoch = 1000으로 학습을 수행합니다. 테스트 환경은 구글 코랩 프로(Colab Pro) 버전을 사용합니다. 일반 Colab 버전을 사용해서 테스트 하셔도 무방합니다. 속도의 차이가 있지만 그리 큰 차이는 아닌듯 합니다.

최종 학습이 수행하고 나온 model과 word2index 파일을 저장합니다.

이제 저장된 모델을 django를 통해서 간단한 웹서버를 만들어봅니다. 참고로 해당 부분에 대한 설명은 이번 글에서는 하지 않고 Django를 통해서 웹서버를 개발하는 예제는 이후에 다른 글에서 다뤄보겠습니다.

위와 같은 형태로 간단한 입력과 출력 결과를 표시합니다. 결과에 보면 “영화관”은 비속어가 아닌데 비속어로 처리된 부분이 있습니다. 이 부분은 영화관이라는 단어가 비속어 데이터에 추가 되어 있기 때문에 표시된 부분입니다. 학습용 데이터의 중요성이 다시 한번 확인되네요.

CNN을 통해서 필터링 하면 철자에 오타가 있거나 단어의 조합인 경우 앞뒤 순서가 바뀌는 경우에도 비교적 잘 탐지 하는 것을 확인했습니다.

CNN을 활용한 텍스트 분류

CNN(Convolutional Neural Networks)은 이미지 분류에 높은 성능을 발휘하는 알고리즘이나 이 외에도 여러 분야에서도 활용되고 있습니다. 그중에 하나가 텍스트를 분류하는 문제입니다.

본 예제는 아래의 논문을 참조하고 있습니다.

Convolutional Neural Networks for Sentence Classification

We report on a series of experiments with convolutional neural networks (CNN) trained on top of pre-trained word vectors for sentence-level classification tasks. We show that a simple CNN with little hyperparameter tuning and static vectors achieves excellent results on multiple benchmarks. Learning task-specific vectors through fine-tuning offers further gains in performance. We additionally propose a simple modification to the architecture to allow for the use of both task-specific and static vectors. The CNN models discussed herein improve upon the state of the art on 4 out of 7 tasks, which include sentiment analysis and question classification.

https://arxiv.org/abs/1408.5882

합성곱신경망이라고도 불리는 CNN 알고리즘은 여러 좋은 강의가 있으니 참고하시기 바랍니다. 또 관련해서 좋은 예제들도 많이 있으니 아래 예제를 수행하시기 전에 살펴보시면 도움이 되시리라 생각합니다.
아래의 예제는 가장 유명한 예제 중에 하나인 MNIST 분류 예제입니다.

먼저 config를 정의합니다. config에는 학습에 필요한 여러가지 변수들을 미리 정의하는 부분입니다. model을 저장할 때에 함께 저장하면 학습 모델을 이해하는데 도움이 됩니다.

학습을 완료하고 저장된 모델 파일을 업로드해서 사용할 때에 해당 모델이 어떻게 학습됐는지에 대한 정보가 없을 경우나 모델을 재학습 한다거나 할 때에 config 정보가 유용하게 사용됩니다. 본 예제는 해당 알고리즘을 이해하는 정도로 활용할 예정이기 때문에 학습은 100번 정도로 제한합니다.

나머지 정의된 변수들은 예제에서 사용할 때에 설명하도록 하겠습니다.

from argparse import Namespace
config = Namespace(
    number_of_epochs=100, lr=0.001, batch_size=50, sentence_lg=30, train_ratio=0.2, embedding_dim=100, n_filters=100, n_filter_size=[2,3,4], output_dim=2
)

본 예제는 영화의 평점 데이터를 활용합니다. 해당 데이터는 네이버 영화 평점과 이에 대한 긍정,부정의 반응이 저장된 데이터입니다. 컬럼은 [id, document, label]의 구조로 되어 있습니다. 영화 평이 부정적인 경우는 label=0, 그렇지 않은 경우는 label=1으로 되어 있어 비교적 간단하게 활용할 수 있는 데이터입니다.

아래의 코드를 실행하면 데이터를 읽어 올 수 있습니다. 해당 데이터에 검색해보면 쉽게 찾을 수 있습니다. 파일인 train 데이터와 test 데이터로 되어 있습니다. 본 예제에서는 train 데이터만 사용합니다. 많은 데이터를 통해서 결과를 확인하고자 하시는 분은 train, test 모두 사용해보시길 추천합니다.

def read_data(filename):
    with open(filename, 'r',encoding='utf-8') as f:
        data = [line.split('\t') for line in f.read().splitlines()]
        data = data[1:]
    return data  
train_data = read_data("../Movie_rating_data/ratings_train.txt")

읽어온 데이터를 몇개 살펴보면 아래와 같습니다. 아래 샘플에는 Label 데이터를 표시하지 않았습니다. 하지만 읽어 보면 대충 이 리뷰를 작성한 사람이 영화를 추천하고 싶은지 그렇지 않은지를 이해할 수 있습니다. 사람의 경우에는 이러한 글을 읽고 판단 할 수 있지만 컴퓨터의 경우에는 이런 텍스트(자연어)를 바로 읽어서 긍정이나 부정을 파악하는 것은 어렵습니다. 그렇기 때문에 각 단어들을 숫자 형태의 벡터로 변환하는 작업을 수행합니다.

['많은 사람들이 이 다큐를 보고 우리나라 슬픈 현대사의 한 단면에 대해 깊이 생각하고 사죄하고 바로 잡기 위해 노력했으면 합니다. 말로만 듣던 보도연맹, 그 민간인 학살이 이정도 일 줄이야. 이건 명백한 살인입니다. 살인자들은 다 어디있나요?',
 '이틀만에 다 봤어요 재밌어요 근데 차 안에 물건 넣어 조작하려고 하면 차 안이 열려있다던지 집 안이 활짝 열려서 아무나 들어간다던가 문자를 조작하려고하면 비번이 안 걸려있고 ㅋㅋㅋ 그런 건 억지스러웠는데 그래도 내용 자체는 좋았어요',
 '이 영화를 이제서야 보다니.. 감히 내 인생 최고의 영화중 하나로 꼽을 수 있을만한 작품. 어떻게 살아야할지 나를 위한 고민을 한번 더 하게 되는 시간. 그리고 모건 프리먼은 나이가 들어도 여전히 섹시하다.',
 '아~ 진짜 조금만 더 손 좀 보면 왠만한 상업 영화 못지 않게 퀄리티 쩔게 만들어 질 수 있었는데 아쉽네요 그래도 충분히 재미있었습니다 개인적으로 조금만 더 잔인하게 더 자극적으로 노출씬도 화끈하게 했더라면 어땠을까 하는 국산영화라 많이 아낀 듯 보임',
 '평점이 너무 높다. 전혀 재미있지 않았다. 쓸데없이 말만 많음. 이런 류의 영화는 조연들의 뒷받침이 중요한데 조연들의 내용자체가 전혀 없음. 또한 여배우도 별로 매력 없었다. 이틀전에 저스트고위드잇의 애니스톤을 보고 이 영화를 봐서 그런가. 실망했음',
 '왜 극을 끌어가는 중심있는 캐릭터가 있어야 하는지 알게 된영화 살인마와 대적하는 그리고 사건을 해결하는 인물이 없고 그리고 왜 마지막에 다 탈출 해놓고 나서 잡히고 죽임을 당하는지 이해할수가 없다. 대체 조달환 정유미는 왜 나옴?',
 '초딩 때 친척형이 비디오로 빌려와서 봤던 기억이 난다...너무 재미 없었다 근데 나중에 우연히 다시보니 재밌더라 그 땐 왜 그렇게 재미가 없었을까?? 98년이면 내가 초등학교 2학년 때니까...사촌형이 당시 나름 최신 비디오를 빌려온거 같다',
 '창업을 꿈꾸는가! 좋은 아이템이 있어 사업을 하려하는가!! 그렇다면 기를 쓰고 이 영활 보기바란다!! 그 멀고 험한 여정에 스승이 될것이요 지침서가 될것이다... 혹은 단념에 도움이 될지도... 참 오랜만에 박장대소하며 본 독립영활세~~~ ★',
 "영화'산업'이라고 하잖는가? 이딴식으로 홍보 해놓고 속여서 팔았다는 게 소비자 입장에서는 짜증난다. 그나마 다행은 아주 싸구려를 상급품으로 속여판 게 아니라는 점. 그래서 1점. 차라리 연상호 감독 작품 처럼 홍보가 됐다면, 그 비슷하게 만이라도 하지",
 '도입부를 제외하고는 따분.헬기에서 민간인을 마구 쏴 죽이는 미군, 베트공 여성 스나이퍼 등,현실감 없는 극단적인 설정.라이언 일병에서의 업햄 그리고 이 영화 주인공인 조커, 두 넘 모두 내가 싫어하는 캐릭터, 착한척 하면서 주위에 피해를 주는 넘들.']

각 리뷰를 읽은 후에 문장을 어절 단위로 분리합니다. 분리한 어절을 형태소까지 분리해서 활용하면 좋겠지만 본 예제에서는 간단히 어절 단위로만 분리합니다. 형태소로 분리하는 예제는 본 블로그에 다른 예제에서도 내용이 있으니 참고하시기 바랍니다. 어절 단위로 분리한 텍스트에서 중복을 제거해보면 32,435개 어절을 얻을 수 있습니다.

이렇게 얻은 32,435개의 어절을 어떻게 벡터로 나타내는가에 대해서는 pytorch의 Embedding을 사용하여 표한합니다. 해당 내용도 본 블로그의 다른 예제에서 많이 다뤘기 때문에 여기서는 생략하고 넘어가도록 하겠습니다.

words = []
for s in sentences:
    words.append(s.split(' '))
    
words = [j for i in words for j in i]
words = set(list(words))

print('vocab size:{}'.format(len(words)))
vocab_size = len(words) #vocab size:32435

리뷰의 길이를 보면 길은 것은 70 어절이 넘고 짧은 것은 1 어절도 있기 때문에 어절의 편차가 크다는 것을 확인 할 수 있습니다. 그렇기 때문에 본 예제에서는 30 어절 이상 되는 리뷰들만 사용하겠습니다. 이를 위해서 config 파일에 sentence_lg=30와 같은 값을 설정했습니다.

x_data = [[word2index[i] for i in sentence.split(' ')] for sentence in sentences]
sentence_length = np.array([len(x) for x in x_data])
max_length = np.array([len(x) for x in x_data]).max()

위의 그래프는 샘플 어절의 분포를 나타냅니다. 본 예제에서는 약 30~40 사이의 어절 정도만 사용하도록 하겠습니다. 만약 어절의 편차가 너무 크면 상당 부분을 의미 없는 데이터로 채워야 합니다. 아래의 예제는 빈 어절을 패딩값(0)으로 채우는 부분입니다.

for ndx,d in enumerate(x_data):
    x_data[ndx] = np.pad(d, (0, max_length), 'constant', constant_values=0)[:max_length]

아래와 같이 각 어절을 숫자 형태의 값으로 변환하면 리뷰의 내용은 숫자로 구성된 리스트 형태가 됩니다. 이때 0은 패딩 값으로 max_length 보다 작을 경우 남은 값을 0으로 채우게 됩니다. 0 데이터가 많을 수록 예측의 정확도가 떨어지게 됩니다.

[array([18196, 16747,  1952, 27879,  4206, 29579,  3641, 14582,  8661,
        16754,   964, 10240,  6070, 25011,  3902, 16410, 30182, 22634,
         5531, 24456,  6360,  6482, 26016,  9239, 25466, 31032,  6505,
        30782, 30861, 30494,  6876, 12237, 27035, 14997,     0,     0,
            0,     0,     0,     0,     0]),
 array([30000, 27035, 15316,  4633, 26703,  7875,  5042, 16695, 25520,
        14681, 20133,  7875,    71,  8983,   363,    71,  5149,  2391,
        27910, 28746, 23902, 32136, 12475, 24439, 15973, 20236,  4726,
         6190, 17515, 20610, 29270, 13967, 28490,     0,     0,     0,
            0,     0,     0,     0,     0]),
 array([ 1952, 12632, 10665, 27623, 25106,  1978,   184,  1537, 29451,
         4705, 22537, 21866, 14473, 26012,  6744, 15690, 27119, 15822,
        12491, 31747, 11202, 14268, 31494,  3202, 10936, 21619, 29214,
        15185,  5496, 12854, 27679,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0])]

학습을 위한 데이터를 train, test 형태로 분리하게 됩니다. 분리하면서 학습용 데이터와 테스트용 데이터의 비율을 8:2로 설정합니다. test 데이터는 학습에 사용하지 않는 데이터로 모델의 정확도 평가에만 사용됩니다.

from sklearn.model_selection import train_test_split

y_data = np.array(label).astype(np.long)
x_train, x_test, y_train, y_test = train_test_split(np.array(x_data), y_data, test_size = config.train_ratio, random_state=0) # 8:2
print(x_train.shape, y_train.shape, x_test.shape, y_test.shape)

학습용 데이터는 데이터로더에 입력하여 일정 크기(config.batch_size)로 묶어 줍니다. 예를 들어 100건의 데이터를 20개로 묶는다면 5개의 묶음으로 나타낼 수 있습니다. 지금 수행하는 예제는 비교적 적은 양의 데이터이기 때문에 이런 과정이 불필요할 수도 있지만 많은 데이터를 통해서 학습하시는 분을 위해서 해당 로직을 구현했습니다. 그리고 학습 데이터를 shuffle 해줍니다. 이 과정도 훈련의 정확도를 높이기 위해서 필요한 부분이니 True로 설정하시기 바랍니다.

from torch.utils.data import Dataset, DataLoader
class TxtDataSet(Dataset):

    def __init__(self, data, labels):
        super().__init__()
        self.data = data
        self.labels = labels

    def __len__(self):
        return len(self.data)

    def __getitem__(self, idx):
        return self.data[idx], self.labels[idx]
train_loader = DataLoader(dataset=TxtDataSet(x_train, y_train), batch_size=config.batch_size, shuffle=True)

파이토치를 활용해서 수행하기 때문에 필요한 모듈을 임포트합니다.

import numpy as np

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F

CNN 모델을 아래와 같이 생성합니다. 이미 설명한 내용도 있기 때문에 자세한 내용은 넘어가겠습니다. 가장 중요한 부분은 텍스트 데이터를 [number_of_batch, channel, n, m] 형태의 데이터로 만드는 과정이 중요합니다. 이렇게 데이터가 만들어지면 해당 데이터를 통해서 학습을 수행합니다.

https://halfundecided.medium.com/%EB%94%A5%EB%9F%AC%EB%8B%9D-%EB%A8%B8%EC%8B%A0%EB%9F%AC%EB%8B%9D-cnn-convolutional-neural-networks-%EC%89%BD%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-836869f88375

CNN 알고리즘을 잘 설명하고 있는 블로그의 링크를 올립니다. 자세한 내용은 이곳 블로그도 참고해 보시기 바랍니다.

class CNN(nn.Module):

    def __init__(self, vocab_size, embedding_dim, n_filters, filter_size, output_dim):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.convs = nn.ModuleList([
            nn.Conv2d(in_channels=1, out_channels=n_filters, kernel_size=(fs, embedding_dim)) for fs in filter_size
            ])
        self.fc = nn.Linear(len(filter_size)*n_filters, output_dim)

    def forward(self, text):
        embedded = self.embedding(text)
        embedded = embedded.unsqueeze(1)
        conved = [F.relu(conv(embedded)).squeeze(3) for conv in self.convs]
        pooled = [F.max_pool1d(conv, conv.shape[2]).squeeze(2) for conv in conved]
        
        return self.fc(torch.cat(pooled, dim=1)) # make fully-connected
    
embedding_dim = config.embedding_dim
n_filters = config.n_filters
n_filter_size = config.n_filter_size
output_dim = config.output_dim # 0 or 1

model = CNN(vocab_size, embedding_dim, n_filters, n_filter_size, output_dim)
print(model)
CNN(
  (embedding): Embedding(32435, 100)
  (convs): ModuleList(
    (0): Conv2d(1, 100, kernel_size=(2, 100), stride=(1, 1))
    (1): Conv2d(1, 100, kernel_size=(3, 100), stride=(1, 1))
    (2): Conv2d(1, 100, kernel_size=(4, 100), stride=(1, 1))
  )
  (fc): Linear(in_features=300, out_features=2, bias=True)
)

아래와 같이 학습을 수행합니다. 간단히 100번 정도만 반복했습니다.

optimizer = optim.Adam(model.parameters())
criterion = nn.CrossEntropyLoss()

model.train()
for epoch in range(config.number_of_epochs):
    train_loss, valid_loss = 0, 0
    
    # train_batch start
    for x_i, y_i in train_loader:
        optimizer.zero_grad()
        
        output = model(x_i)
        loss = criterion(output, y_i)
        
        loss.backward()
        optimizer.step()
        
        train_loss += float(loss)
    if epoch % 5 == 0:
        print('Epoch : {}, Loss : {:.5f}'.format(epoch, train_loss/len(train_loader)))
Epoch : 0, Loss : 0.69145
Epoch : 5, Loss : 0.04431
Epoch : 10, Loss : 0.00848
Epoch : 15, Loss : 0.00352
Epoch : 20, Loss : 0.00192
Epoch : 25, Loss : 0.00120
Epoch : 30, Loss : 0.00082
Epoch : 35, Loss : 0.00059
Epoch : 40, Loss : 0.00044
Epoch : 45, Loss : 0.00034
Epoch : 50, Loss : 0.00027
Epoch : 55, Loss : 0.00022
Epoch : 60, Loss : 0.00018
Epoch : 65, Loss : 0.00015
Epoch : 70, Loss : 0.00012
Epoch : 75, Loss : 0.00010
Epoch : 80, Loss : 0.00009
Epoch : 85, Loss : 0.00008
Epoch : 90, Loss : 0.00007
Epoch : 95, Loss : 0.00006

학습을 완료하고 테스트 데이터를 통해서 모델을 평가해본 결과 68.39%의 정확도를 얻었습니다.
높은 정확도는 아니지만 많은 부분 간소화한 학습이었음을 감안하면 나름대로 유의미한 결과를 얻었다고 생각됩니다.

with torch.no_grad():
    output = model(torch.tensor(x_test, dtype=torch.long))
    predict = torch.argmax(output, dim=-1)
    predict = (predict==torch.tensor(y_test, dtype=torch.long))
    print('Accuracy!',predict.sum().item()/len(x_test)*100)
    #Accuracy! 68.39622641509435

CNN Fashion-MNIST 테스트 (PyTorch)

Fashion-MNIST에 대한 설명은 아래 링크로 대신하겠습니다.

Fashion-MNIST is a dataset of Zalando‘s article images—consisting of a training set of 60,000 examples and a test set of 10,000 examples. Each example is a 28×28 grayscale image, associated with a label from 10 classes. We intend Fashion-MNIST to serve as a direct drop-in replacement for the original MNIST dataset for benchmarking machine learning algorithms. It shares the same image size and structure of training and testing splits.

본 예제 코드는 데이터셋을 학습해서 입력되는 이미지가 어떤 분류에 속하는지를 예측해보는 것입니다. Fashion-MNIST 데이터셋을 벡터 공간에 표시하면 위와 같은 이미지로 분류할 수 있습니다.

이제 학습을 위해 해당 데이터셋을 다운로드합니다.

# Define a transform to normalize the data
transform = transforms.Compose([transforms.ToTensor(),
                                transforms.Normalize((0.5,), (0.5,))])

# Download and load the training data
train_loader = torch.utils.data.DataLoader(datasets.FashionMNIST('../F_MNIST_data/', download=True, train=True, transform=transform), batch_size=128, shuffle=True)

# Download and load the test data
test_loader = torch.utils.data.DataLoader(datasets.FashionMNIST('../F_MNIST_data/', download=True, train=False, transform=transform), batch_size=128, shuffle=True)

다운로드한 데이터가 어떤 이미지가 있는지 살펴보기 위해서 랜덤하게 몇개의 샘플을 추출해서 표시해보겠습니다. 해당 이미지들은 10개 [‘t-shirt’, ‘trouser’, ‘pullover’, ‘press’, ‘coat’, ‘sandal’, ‘shirt’, ‘sneaker’, ‘bag’, ‘ankleboot’]로 분류할 수 있는 패션 아이템들입니다.

x_train, y_train = next(iter(train_loader))
x_valid, y_valid = next(iter(test_loader))

fig, ax = plt.subplots(5,5)
fig.set_size_inches((20,14))
for i in range(5):
    for j in range(5):
        idx = numpy.random.randint(128)
        ax[i][j].imshow(x_train[idx,0,:])
        ax[i][j].set_xlabel(label[y_train[idx].item()])
        ax[i][j].set_xticklabels([])
        ax[i][j].set_yticklabels([])

학습을 위한 모델을 선언합니다. 이전 MNIST 데이터셋을 테스트했을 때와 같은 모델을 재활용했습니다.

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        
        self.convs = nn.Sequential(
            nn.Conv2d(1, 10, kernel_size=3), # input_channel, output_channel, kernel_size
            nn.ReLU(),
            nn.BatchNorm2d(10),
            nn.Conv2d(10, 20, kernel_size=3, stride=2),
            nn.ReLU(),
            nn.BatchNorm2d(20),
            nn.Conv2d(20, 40, kernel_size=3, stride=2)
        )
        
        self.layers = nn.Sequential(
            nn.Linear(40*5*5, 500),
            nn.Dropout(p=0.2),
            nn.ReLU(),
            nn.BatchNorm1d(500),
            nn.Linear(500,250),
            nn.Linear(250,100),
            nn.Dropout(p=0.2),
            nn.ReLU(),
            nn.BatchNorm1d(100),
            nn.Linear(100,50),
            nn.Linear(50, 10),
            nn.Softmax(dim=-1)
        )

    def forward(self, x):
        x = self.convs(x)
        x = x.view(-1, 40*5*5)
        return self.layers(x)
    
cnn = Net().to(DEVICE)

이전 MNIST 코드는 하나의 mini batch 데이터만 학습했다면 이번에는 전체 데이터를 대상으로 학습을 진행합니다. 많은 학습을 거친다면 모델의 정확도가 높아지겠지만 성능을 높이는 테스트가 아니기 때문에 최소한의 학습 epcohs만 수행합니다.

optimizer = optim.Adam(cnn.parameters())
criterion = nn.CrossEntropyLoss()

hist_loss = []
hist_accr = []

epochs = 30

for epoch in range(epochs):

    for idx, (data, label) in enumerate(train_loader):
        data, label = data.to(DEVICE), label.to(DEVICE)
        output = cnn(data)
        loss = criterion(output, label)
        
        predict = torch.argmax(output, dim=-1) == label
        accuracy = predict.float().mean().item()

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        
        hist_loss.append(loss.item())
        hist_accr.append(accuracy)

        if idx % 100 == 0:
            print('Epoch {}, idx {}, Loss : {:.5f}, Accuracy : {:.5f}'.format(epoch, idx, loss.item(), accuracy))

학습이 완료되고 학습의 진행이 어떻게 되었는지 알기 위해서 사전에 정의한 hist_loss와 hist_accr을 사용해서 시각화 해보겠습니다.

fig, ax = plt.subplots(2,1)
fig.set_size_inches((12,8))

ax[0].set_title('Loss')
ax[0].plot(hist_loss, color='red')
ax[0].set_ylabel('Loss')
ax[1].set_title('Accuracy')
ax[1].plot(hist_accr, color='blue')
ax[1].set_ylabel('Accuracy')
ax[1].set_xlabel('Epochs')

학습이 완료된 후에 테스트 데이터를 사용해서 모델의 정확도를 확인해보았고 결과 값으로 Accuracy : 0.93750를 얻었습니다.

cnn.eval()

with torch.no_grad():
    for idx, (data, label) in enumerate(test_loader):
        data, label = data.to(DEVICE), label.to(DEVICE)
        output = cnn(data)
        loss = criterion(output, label)
    
        predict = torch.argmax(output, dim=-1) == label
        accuracy =  predict.float().mean().item()
    
        print('Accuracy : {:.5f}'.format(accuracy))

Google Colab GPU Text-classification

Colaboratory(혹은 Colab)를 사용하면 브라우저에서 Python을 작성하고 실행할 수 있습니다. 장점이라면 별도의 구성이 필요 없고 무료로 GPU를 사용할 수 있다는 장점이 있습니다. 또 만든 코드를 간단하게 공유할 수도 있습니다.

감성분석(Text Classification)에 사용한 데이터는 네이버에서 공개한 영화 평점 정보입니다. 해당 데이터는 아래 링크에서 받을 수 있습니다.
https://github.com/e9t/nsmc

import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
import io

본 예제에서 사용할 konlpy를 Colab에 설치합니다.

!pip install konlpy
from konlpy.tag import Okt
okt = Okt()

Colab에서 사용할 파일을 사용자 계정의 구글 드라이브에 업로드합니다. 그리고 업로드한 파일 정보를 Colab에서 읽을 수 있도록 필요한 python 라이브러리를 등록해야합니다. 아래 코드를 실행하면 구글 계정에 인증할 수 있는 정보가 나오고 키값을 입력하면 드라이브에 접근할 수 있습니다.

from google.colab import drive
drive.mount('/content/gdrive')

구글 드라이브에 접근이 완료되면 파일이 있는 디렉토리 위치로 이동합니다.

from google.colab import drive
drive.mount('/content/gdrive')
%cd gdrive/My\ Drive/Colab\ Notebooks

해당 위치로 이동한 후에 %ls 명령을 실행시켜보면 해당 폴더에 있는 파일 리스트를 표시해줍니다. 파일 중에서 학습에 사용할 파일을 open하면 됩니다.

%ls
def read_data(filename):
    with io.open(filename, 'r',encoding='utf-8') as f:
        data = [line for line in f.read().splitlines()]
        data = data[1:]
    return data 

sentences = []
# 테스트를 위해 길이가 30 이하인 문장을 읽음
for sentence in read_data('./ratings_test.txt'):
  if len(sentence) <= 30:
    sentences.append(sentence)

해당 파일을 읽어보면 아래와 같은 형태로 데이터가 구성되어 있습니다. 이전 예제에서 설명했던 것처럼 1은 긍정적인 답변을 0은 부정적인 답변을 의미합니다.

['6270596|굳 ㅋ|1',
 '7898805|음악이 주가 된, 최고의 음악영화|1',
 '6315043|진정한 쓰레기|0',
 '7462111|괜찮네요오랜만포켓몬스터잼밌어요|1',
 '10268521|소위 ㅈ문가라는 평점은 뭐냐?|1' ...
class Vocab():
    def __init__(self):
        self.vocab2index = {'<pad>':0,'<unk>':1} # padding 0, unkown 1
        self.index2vocab = {0:'<pad>',1:'<unk>'} # 0 padding, 1 unkown
        self.vocab_count = {}
        self.n_vocab = len(self.vocab2index)
        
    def add_vocab(self, sentence):
        for word in sentence:
            if word not in self.vocab2index:
                self.vocab2index[word] = self.n_vocab
                self.index2vocab[self.n_vocab] = word
                self.vocab_count[word] = 1
                self.n_vocab += 1
            else:
                self.vocab_count[word] += 1

vo = Vocab()

def charStrip(s):
    s = s.replace('"','').replace('「','').replace('」','').replace('“','').replace('?','').replace('”','')
    s = s.replace('(',' ').replace(')',' ').replace('‘','').replace('’','').replace('□','').replace('◆','').replace('◇','')
    s = s.replace('[',' ').replace(']',' ').replace('○','').replace('△','').replace('◎','').replace('▣','').replace('◇','')
    s = s.replace('.',' ').replace('*',' ').replace('.',' ').replace('~',' ')
    
    return s

x = [] # text sentence
y = [] # label
for sentence in sentences:
    arr = sentence.split('|')
    if(len(arr) == 3):
      sentence = okt.morphs(charStrip(arr[1]))
      
      vo.add_vocab(sentence)
      x.append(sentence) 
      y.append(float(arr[2])) 
MAX_SEQUENCE_LENGTH = 0

for sentence in x:
    if MAX_SEQUENCE_LENGTH < len(sentence): MAX_SEQUENCE_LENGTH = len(sentence)

MAX_SEQUENCE_LENGTH

데이터 중에서 가장 긴 문장을 확인해봅니다. 이 문장의 크기가 Sequence Length가 됩니다. 이 문장의 길이보다 작은 문장의 경우 빈칸은 <unk> 값으로 채워줍니다.

def tensorize(vocab, sentence):
    idx = [vocab.vocab2index[word] for word in sentence]
    #return torch.Tensor(idx).long().item()
    return idx

tmp_tensor = []
for sentence in x:
    tmp = tensorize(vo, sentence)
    tmp_zero = np.zeros(MAX_SEQUENCE_LENGTH)
    
    for i,val in enumerate(tmp):
        tmp_zero[i] = val
        
    tmp_tensor.append(tmp_zero)
    
x_data = torch.Tensor(tmp_tensor).long()
y_data = torch.Tensor([float(t) for t in y])
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(device)
output : device(type='cuda')

구글 Colab에서는 GPU를 사용할 수 있기 때문에 필요한 설정을 해줍니다. CPU로 연산할 때보다 훨씬 빠른 계산속도를 보여줍니다.

학습용 데이터는 8/2로 학습용 Train Data / Valid Data 데이터로 나눠줍니다.

DATA_LENGTH = x_data.size(0)

train_cnt = int(DATA_LENGTH*.8)
valid_cnt = DATA_LENGTH - train_cnt
print('train_cnt, valid_cnt = ',train_cnt,valid_cnt)

idx = torch.randperm(DATA_LENGTH)
x_train = torch.index_select(x_data, dim=0, index=idx).to(device).split([train_cnt, valid_cnt], dim=0)
y_train = torch.index_select(y_data, dim=0, index=idx).to(device).split([train_cnt, valid_cnt], dim=0)

각 데이터셋은 pytorch의 Dataset, DataLoader를 사용해서 배치사이즈로 나눠줍니다. 학습할 데이터가 많은 경우에 많은 데이터를 한번에 읽으면 메모리 부족현상이 발생하는데 Dataset을 배치사이즈로 분리해서 로딩하면 메모리를 적게 사용하게 되어 큰 데이터도 학습할 수 있습니다.

from torch.utils.data import Dataset, DataLoader

class SimpanDataset(Dataset):
    
    def __init__(self, data, label):
        super().__init__()
        self.data = data
        self.labels = label
        
    def __len__(self):
        return len(self.data)
    
    def __getitem__(self, idx):
        return self.data[idx], self.labels[idx]
    
# train_loader
train_loader = DataLoader(dataset=SimpanDataset(x_train[0], y_train[0]), batch_size=250, shuffle=True)
valid_loader = DataLoader(dataset=SimpanDataset(x_train[1], y_train[1]), batch_size=250, shuffle=False)

학습용 모델을 아래와 같이 설정합니다. 학습 모델은 Embedding -> BiLSTM -> Softmax 레이어를 통과하면서 최종 output을 만들어냅니다.
단, output은 모든 Sequence의 데이터를 사용하지 않고 마지막 시퀀스의 값만 사용하며 LSTM의 모델에서 bidirectional을 True로 설정했기 때문에 output*2의 값이 리턴됩니다. 학습에 최종 결과물은 0과 1이기 때문에 hidden_size는 2입니다.

class SimpanClassificationModel(nn.Module):
    
    def __init__(self, input_size, hidden_size):
        super().__init__()
        
        self.embedding = nn.Embedding(input_size, 300)
        
        self.rnn = nn.LSTM(input_size=300, hidden_size=100, num_layers=4, batch_first=True, bidirectional=True)
        
        self.layers = nn.Sequential(
            nn.ReLU(),
            nn.Linear(100*2,100),
            nn.Linear(100,30),
            
            nn.Linear(30, hidden_size),
        )
        
        self.softmax = nn.Softmax(dim=-1)
        
    def forward(self, x):
        y = self.embedding(x)
        y,_ = self.rnn(y)
        y = self.layers(y)
        
        return self.softmax(y[:,-1,:])
    
input_size = vo.n_vocab
hidden_size = torch.unique(y_train[1]).size(dim=-1)

model = SimpanClassificationModel(input_size, hidden_size)
model = model.cuda()
# loss & optimizer setting
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters())

hist_loss = []
hist_accr = []

epochs = 501
# start training
model.train()

for epoch in range(epochs):
    epoch_loss = 0
    for x,y in train_loader:
      x, y = x.to(device), y.to(device)
      output = model(x)
      loss = criterion(output, y.long())
        
      optimizer.zero_grad()
      loss.backward()
      optimizer.step()
        
      epoch_loss += loss.item()
      accuracy = (torch.argmax(output, dim=-1) == y).float().mean().item()
    
      
    hist_loss.append(epoch_loss)
    hist_accr.append(accuracy)
        
    print('Cost: {:.6f}, Accuracy : {:.6f}'.format(loss.item(),accuracy))
    print('--{}--'.format(epoch))

학습의 진행상황을 기록하기 위해서 2개의 배열(hist_loss, hist_accr)을 사용합니다.

hist_loss는 loss값의 변화를 기록하는 배열이며 hist_accr은 해당 모델의 정확도 정보를 얻기 위해서 만든 배열입니다. 학습이 진행되며 해당 배열에 데이터가 기록되고 matplotlib.pyplot을 사용해서 그래프를 그려봅니다.

import matplotlib.pyplot as plt

fig, ax = plt.subplots(2,1)
fig.set_size_inches((12, 8)) 

ax[0].set_title('Loss')
ax[0].plot(hist_loss, color='red')
ax[0].set_ylabel('Loss')
ax[1].set_title('Accuracy')
ax[1].plot(hist_accr, color='blue')
ax[1].set_ylabel('Accuracy')
ax[1].set_xlabel('Epochs')

plt.show()
model.eval()
    
for xv, yv in valid_loader:
    output = model(x)
    
    accuracy = (torch.argmax(output, dim=-1) == y).float().mean().item()

    hist_loss.append(loss.item())
    hist_accr.append(accuracy)

    print('Accuracy : {:.6f}'.format(accuracy))

모델의 학습이 완료된 후 valid data를 통해서 학습 모델의 정확도를 알아봅니다.

Accuracy : 0.916667
Accuracy : 0.916667
Accuracy : 0.916667
Accuracy : 0.916667
Accuracy : 0.916667
Accuracy : 0.916667
Accuracy : 0.916667
Accuracy : 0.916667
Accuracy : 0.916667
Accuracy : 0.916667
Accuracy : 0.916667
Accuracy : 0.916667
Accuracy : 0.916667
Accuracy : 0.916667

Seq2Seq 문장번역

파이토치 Seq2Seq 예제

import random
import torch
import torch.nn as nn
import torch.optim as optim
torch.manual_seed(0)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
raw = ['I called Tom for help.	나는 톰에게 도움을 요청했다.',
'I do not like science.	나는 과학이 싫어.',
'I hate myself as well.	나도 내 자신을 싫어해.',
'I knew Tom would lose.	톰이 질 거라는 것을 난 알고 있었어.',
'I know Tom personally.	난 톰을 개인적으로 알고 있어.',
'I like Korean cuisine.	전 한국 요리가 좋아요.',
'I like Korean cuisine.	전 한국 요리를 좋아해요.',
'I like helping others.	나는 남을 돕는 것을 좋아한다.',
'I really like puppies.	저는 강아지가 정말 좋아요.',
'I run faster than Tom.	나는 톰보다 빠르게 달릴 수 있어.',
'I think Tom is lonely.	톰이 외로워하는 것 같아.',
'I think they like you.	그들이 널 좋아하는 것 같아.',
'I want to go to sleep.	나 자러 가고 싶어.',
'I want to go to sleep.	나 자고 싶어.',
'I want to visit Korea.	나는 한국에 들르고 싶다.']

사용한 데이터는 http://www.manythings.org/anki/ 에서 kor-eng.zip 파일을 다운로드 받아 일부 데이터만 사용했습니다. 해당 사이트에 들어가면 한국어 외에도 다양한 형태의 파일을 다운 받을 수 있습니다.

SOS_token = 0 # 문장의 시작 Start of Sentence
EOS_token = 1 #  문장의 끝 End of Sentence
class Vocab:
    def __init__(self):
        self.vocab2index = {"<SOS>":SOS_token, "<EOS>":EOS_token}
        self.index2vocab = {SOS_token:"<SOS>", EOS_token:"<SOS>"}
        self.vocab_count = {}
        self.n_vocab = len(self.vocab2index)
    
    def add_vocab(self, sentence):
        for word in sentence.split(' '):
            if word not in self.vocab2index:
                self.vocab2index[word] = self.n_vocab
                self.vocab_count[word] = 1
                self.index2vocab[self.n_vocab] = word
                self.n_vocab += 1
            else:
                self.vocab_count[word] += 1
# declare simple encoder
class Encoder(nn.Module):
    def __init__(self, input_size, hidden_size):
        super(Encoder, self).__init__()
        self.hidden_size = hidden_size
        self.embedding = nn.Embedding(input_size, hidden_size) # Embedding(17, 16)
        self.gru = nn.GRU(hidden_size, hidden_size)

    def forward(self, x, hidden):
        x = self.embedding(x).view(1, 1, -1)
        x, hidden = self.gru(x, hidden)
        return x, hidden

    
# declare simple decoder
class Decoder(nn.Module):
    def __init__(self, hidden_size, output_size):
        super(Decoder, self).__init__()
        self.hidden_size = hidden_size
        self.embedding = nn.Embedding(output_size, hidden_size)
        self.gru = nn.GRU(hidden_size, hidden_size, num_layers=1, batch_first=True)
        self.out = nn.Linear(hidden_size, output_size)
        self.softmax = nn.Softmax(dim=1)

    def forward(self, x, hidden):
        x = self.embedding(x).view(1, 1, -1)
        x, hidden = self.gru(x, hidden) # lstm을 사용할 경우 해당 위치 수정
        x = self.softmax(self.out(x[0]))
        return x, hidden
# read and preprocess the corpus data
def preprocess(corpus):
    print("reading corpus...")
    pairs = []
    for line in corpus:
        pairs.append([s for s in line.strip().lower().split("\t")])
    print("Read {} sentence pairs".format(len(pairs)))

    pairs = [pair for pair in pairs]
    print("Trimmed to {} sentence pairs".format(len(pairs)))

    source_vocab = Vocab()
    target_vocab = Vocab()

    print("Counting words...")
    for pair in pairs:
        source_vocab.add_vocab(pair[0])
        target_vocab.add_vocab(pair[1])
    print("source vocab size =", source_vocab.n_vocab)
    print("target vocab size =", target_vocab.n_vocab)

    return pairs, source_vocab, target_vocab

# 데이터셋, 입력단어정보, 출력단어정보
pairs, source_vocab, target_vocab = preprocess(raw)

훈련용 입출력 데이터셋을 위와 같이 만든후 이제 인코더, 디코더 모델을 만들어야 합니다. 먼저 만들기 전에 인코더-디코더의 입출력 정보에 대하여 직접 그림으로 그려보시기를 추천합니다. 가장 좋은 것은 노트에 펜으로 그려보시는 것이 좋겠지만 그렇지 않다면 머리속으로 어떤 입력이 들어오고 어떤 출력이 나가는지에 대한 정보를 설계하는 과정이 필요합니다.

이런 과정이 없으면 나중에 인코더와 디코더를 설계할 때에 혼동하기 쉽기 때문에 반드시 모델의 입출력 흐름을 구상해보시기 바랍니다.

본 예제의 인코더-디코더 정보는 다음과 같습니다.
인코더 : input_vector(41) -> Embedding(41,30) -> LSTM(30,30)
디코더 : Embedding(52,30) -> LSTM(30, 52) – hidden_vector(52)

enc_hidden_size = 30
dec_hidden_size = enc_hidden_size
enc = Encoder(source_vocab.n_vocab, enc_hidden_size).to(device)
dec = Decoder(dec_hidden_size, target_vocab.n_vocab).to(device)
def tensorize(vocab, sentence):
    idx = [vocab.vocab2index[word] for word in sentence.lower().split(' ')]
    idx.append(vocab.vocab2index['<EOS>'])
    return torch.Tensor(idx).long().to(device).view(-1,1)
tensorize(source_vocab, 'I called Tom for help.')
output : tensor([[2], [3], [4], [5], [6], [1]])
training_source = [tensorize(source_vocab, pair[0]) for pair in pairs]
training_target = [tensorize(target_vocab, pair[1]) for pair in pairs]

Train

loss_total = 0
number_epoch = 5001

encoder_optimizer = optim.SGD(enc.parameters(), lr=0.01)
decoder_optimizer = optim.SGD(dec.parameters(), lr=0.01)

criterion = nn.NLLLoss()

for epoch in range(number_epoch):
    epoch_loss = 0
    
    for i in range(len(training_source)):
        encoder_optimizer.zero_grad()
        decoder_optimizer.zero_grad()
        
        source_tensor = training_source[i]
        target_tensor = training_target[i]

        encoder_hidden = torch.zeros([1, 1, enc.hidden_size]).to(device)

        source_length = source_tensor.size(0)
        target_length = target_tensor.size(0)
        
        loss = 0

        for enc_input in range(source_length):
            _, encoder_hidden = enc(source_tensor[enc_input], encoder_hidden)

        decoder_input = torch.Tensor([[SOS_token]]).long().to(device)
        decoder_hidden = encoder_hidden # connect encoder output to decoder input

        for di in range(target_length):
            decoder_output, decoder_hidden = dec(decoder_input, decoder_hidden)
            #print(decoder_output, target_tensor[di], criterion(decoder_output, target_tensor[di]))
            loss += criterion(decoder_output, target_tensor[di])
            decoder_input = target_tensor[di]  # teacher forcing
        
        loss.backward()

        encoder_optimizer.step()
        decoder_optimizer.step()
        
        #print(loss.item(),target_length)
        epoch_loss += loss.item()/target_length
        #loss_total += loss_epoch
    if epoch % 100 == 0:
        print('--- epoch {}, total loss {} '.format(epoch,float(epoch_loss/15)))

Evaluate

for pair in pairs:
    print(">", pair[0])
    print("=", pair[1])
    source_tensor = tensorize(source_vocab, pair[0])
    source_length = source_tensor.size()[0]
    encoder_hidden = torch.zeros([1, 1, enc.hidden_size]).to(device)

    for ei in range(source_length):
        _, encoder_hidden = enc(source_tensor[ei], encoder_hidden)
        #print(encoder_hidden.size()) # 1,1,16

    decoder_input = torch.Tensor([[SOS_token]], device=device).long()
    decoder_hidden = encoder_hidden
    decoded_words = []

    for di in range(20):
        decoder_output, decoder_hidden = dec(decoder_input, decoder_hidden)
        #print('decoder_iput',decoder_input, 'decoder_output',decoder_output)
        _, top_index = decoder_output.data.topk(1)
        if top_index.item() == EOS_token:
            decoded_words.append("<EOS>")
            break
        else:
            decoded_words.append(target_vocab.index2vocab[top_index.item()])

        decoder_input = top_index.squeeze().detach()

    predict_words = decoded_words
    predict_sentence = " ".join(predict_words)
    print("<", predict_sentence)
    print("")
> i called tom for help.
= 나는 톰에게 도움을 요청했다.
< 나는 톰에게 도움을 요청했다. <EOS>

> i do not like science.
= 나는 과학이 싫어.
< 나는 과학이 싫어. <EOS>

> i hate myself as well.
= 나도 내 자신을 싫어해.
< 나도 내 자신을 싫어해. <EOS>

> i knew tom would lose.
= 톰이 질 거라는 것을 난 알고 있었어.
< 톰이 질 거라는 것을 난 알고 있었어. <EOS>

> i know tom personally.
= 난 톰을 개인적으로 알고 있어.
< 난 톰을 개인적으로 알고 있어. <EOS>

> i like korean cuisine.
= 전 한국 요리가 좋아요.
< 전 한국 요리를 좋아해요. <EOS>

> i like korean cuisine.
= 전 한국 요리를 좋아해요.
< 전 한국 요리를 좋아해요. <EOS>

> i like helping others.
= 나는 남을 돕는 것을 좋아한다.
< 나는 남을 돕는 것을 좋아한다. <EOS>

> i really like puppies.
= 저는 강아지가 정말 좋아요.
< 저는 강아지가 정말 좋아요. <EOS>

> i run faster than tom.
= 나는 톰보다 빠르게 달릴 수 있어.
< 나는 톰보다 빠르게 달릴 수 있어. <EOS>

> i think tom is lonely.
= 톰이 외로워하는 것 같아.
< 톰이 외로워하는 것 같아. <EOS>

> i think they like you.
= 그들이 널 좋아하는 것 같아.
< 그들이 널 좋아하는 것 같아. <EOS>

> i want to go to sleep.
= 나 자러 가고 싶어.
< 나 자고 싶어. <EOS>

> i want to go to sleep.
= 나 자고 싶어.
< 나 자고 싶어. <EOS>

> i want to visit korea.
= 나는 한국에 들르고 싶다.
< 나는 한국에 들르고 싶다. <EOS>

PyTorch LSTM 예제

이 예제는 파이토치를 활용해서 LSTM을 구현하는 예제입니다.
LSTM은 RNN(Recurrent Neural Network)의 하나로 좋은 성능을 발휘하는 모델입니다. 파이토치는 LSTM를 직관적으로 구현할 수 있도록 하는 좋은 인공지능 프레임워크입니다.

본 예제는 현재 문장을 주고 다음 문장을 예측하는 알고리즘입니다. 이러한 예제들을 활용하면 다양한 시계열 데이터를 다룰 수 있습니다. 시계열 데이터라 함은 데이터가 어떤 시간의 순서를 가진다는 것을 의미합니다.

예를 들어 한 문장의 다양한 단어들은 비록 같은 단어일지라도 앞에 오느냐 뒤에 오느냐에 따라서 그 의미가 달라지는 경우가 있습니다. 그렇기 때문에 현재 문장을 유추하기 위해서는 앞에 어떤 단어가 있는지를 알아내는 것이 중요합니다.
RNN은 이러한 예측을 가능하게 해줍니다.

RNN은 두개의 Linear 모델이 합쳐진 하나의 Activation Function입니다. 아래 그림이 이를 잘 설명하고 있습니다.

https://pytorch.org/tutorials/intermediate/char_rnn_classification_tutorial.html
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np

입력 문장은 “In the beginning God created the heavens and the earth”라는 문장입니다. x 데이터는 입력 문장이고 x 데이터를 통해 예측한 결과를 비교하기 위해서 정답 데이터셋 y를 만듭니다. y 데이터는 x 데이터의 첫번째 입력 ‘I’의 경우 ‘n’를 예측하고 ‘n’가 입력된 경우 ‘ ‘(공백) 를 시스템이 예측할 수 있도록 하기 위함입니다.

sentence = 'In the beginning God created the heavens and the earth'
x = sentence[:-1]
y = sentence[1:]

char_set = list(set(sentence))
input_size = len(char_set)
hidden_size = len(char_set)

index2char = {i:c for i, c in enumerate(char_set)}
char2index = {c:i for i, c in enumerate(char_set)}

index2char과 char2index는 각각 문자를 문자 자체로 입력하지 않고 one-hot의 형태로 입력하기 위해서 만들어준 python dict입니다.
char2index를 출력하면 아래와 같은 형태가 됩니다.

{‘s’: 0, ‘ ‘: 1, ‘t’: 2, ‘I’: 3, ‘o’: 4, ‘h’: 5, ‘e’: 6, ‘g’: 7, ‘d’: 8, ‘c’: 9, ‘b’: 10, ‘i’: 11, ‘n’: 12, ‘G’: 13, ‘v’: 14, ‘a’: 15, ‘r’: 16}

one_hot = []
for i, tkn in enumerate(x):
    one_hot.append(np.eye(len(char_set), dtype='int')[char2index[tkn]])

x_train = torch.Tensor(one_hot)
x_train = x_train.view(1,len(x),-1)

입력된 sentence는 그대로 입력값으로 사용하지 않고 one-hot 형태로 변경해서 최종 x_train 형태의 데이터를 만듭니다. 문장을 one-hot 형태로 만들기 위해서 numpy의 eye함수를 사용합니다.

print(x_train)

tensor([[[0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
         [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0.],
         [0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
         [0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
         [0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.] ...

x_train 데이터를 출력하면 위와 같은 형태가 만들어집니다. 위의 데이터셋은 [1, 10, 8] 형태의 3차원 데이터셋입니다. NLP 데이터를 3차원 형태의 입력 데이터셋을 가집니다. 첫번째 차원은 문장의 갯수, 두번째는 단어의 갯수, 세번째는 단어의 입력 차원입니다.

참고로 CNN의 경우에는 4차원 형태의 데이터를 가집니다.
다음으로 아래와 같이 y_data를 만들어줍니다.

# y label
y_data = [char2index[c] for c in y]
y_data = torch.Tensor(y_data)

이제 모델을 만들 차례입니다.
파이토치는 nn.Module을 사용해서 Module을 만들 수 있습니다.

class RNN(nn.Module):
    
    # (batch_size, n, ) torch already know, you don't need to let torch know
    def __init__(self,input_size, hidden_size):
        super().__init__()
        self.input_size = input_size
        self.hidden_size = hidden_size
        
        self.rnn = nn.LSTM(
            input_size = input_size, 
            hidden_size = hidden_size, 
            num_layers = 4, 
            batch_first = True,
            bidirectional = True
        )
        
        self.layers = nn.Sequential(
            nn.ReLU(),
            nn.Linear(input_size*2, hidden_size),
        )
        
    def forward(self, x):
        y,_ = self.rnn(x)
        y = self.layers(y)
        return y
    
model = RNN(input_size, hidden_size)
model

RNN 클래스는 init, forward 함수로 구성됩니다. init함수는 LSTM 모델을 선언하는 부분과 softamx 함수를 선언하는 두부분이 있습니다. LSTM 함수는 두개의 인자값을 기본으로 받습니다. input_size, hidden_size입니다. input_size는 입력 벡터의 크기이며 hidden_size는 출력 벡터의 크기입니다. 본 예제에서는 입력 벡터와 출력 벡터가 크기가 같습니다. 배치 사이즈나 시퀀스 사이즈는 파이토치에서 자동으로 계산하기 때문에 입력할 필요가 없습니다.
num_layers는 RNN의 층을 의미합니다. 본 예제는 4개의 층으로 구성했기 때문에 num_layers를 4로 설정했습니다. 그리고 bidirectional을 True로 했기 때문에 마지막 output의 형태는 input_size*2의 형태가 됩니다.
Linear 레이어는 input_size의 차원을 줄이기 위해서 선언합니다.

또 모델을 만들면서 중요한 것은 batch_first를 True로 해줘야 한다는 것입니다. 그렇지 않으면 time-step(=sequence_length), batch_size, input_vector 의 형태가 됩니다.

선언한 모델의 정보를 출력해보면 다음과 같습니다.
RNN(
(rnn): LSTM(8, 8, num_layers=4, bidirectional=True)
(layers): Sequential(
(0): ReLU()
(1): Linear(in_features=16, out_features=8, bias=True)
)
)

# loss & optimizer setting
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters())

# start training
for i in range(5000):
    model.train()
    outputs = model(x_train)
    loss = criterion(outputs.view(-1, input_size), y_data.view(-1).long())
    
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

    if i%500 == 0:
        result = outputs.data.numpy().argmax(axis=2)
        result_str = ''.join([char_set[c] for c in np.squeeze(result)])
        print(i, "loss: ", loss.item(), "\nprediction: ", result, "\ntrue Y: ", y_data, "\nprediction str: ", result_str,"\n")

위의 코드와 같이 학습을 수행합니다.
x_train 데이터를 입력 받아 나온 결과를 y_data와 비교해서 loss를 계산하고 이 loss 값을 Back-propagation을 수행하고 Gradient를 초기화하는 과정을 반복합니다.

5000 회 학습할 경우 다음과 같이 loss가 내려가는 것을 확인 할 수 있습니다.

실제로 데이터를 돌려보면 약 1500번 정도 학습을 완료하면 입력 단어를 통해서 정확히 다음 단어를 예측하는 것을 확인할 수 있습니다.

1500 loss: 0.31987816095352173
prediction: [[12 1 2 5 6 1 10 6 7 11 12 12 11 12 7 1 13 4 8 1 9 16 6 15
2 6 8 1 2 5 6 1 5 6 15 14 6 12 0 1 15 12 8 1 2 5 6 1
6 15 16 2 5]]
true Y: tensor([12., 1., 2., 5., 6., 1., 10., 6., 7., 11., 12., 12., 11., 12.,
7., 1., 13., 4., 8., 1., 9., 16., 6., 15., 2., 6., 8., 1.,
2., 5., 6., 1., 5., 6., 15., 14., 6., 12., 0., 1., 15., 12.,
8., 1., 2., 5., 6., 1., 6., 15., 16., 2., 5.])
prediction str: n the beginning God created the heavens and the earth

PyTorch DataLoader Example

sklearn의 붓꽃 데이터를 활용하여 pytorch와 dataloader를 활용하여 분류 문제를 풀어 보겠습니다.

iris 데이터셋을 받아서 pandas로 데이터를 변환합니다. 변환 과정이 반드시 필요한 것은 아니지만 데이터셋을 변경하거나 학습용 컬럼 정보를 수정할 때에 도움이 됩니다.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.datasets import load_iris
iris = load_iris()

df = pd.DataFrame(iris.data)
df.columns = iris.feature_names
df['class'] = iris.target

다음으로 PyTorch로 데이터를 import하여 학습용 데이터를 생성합니다. 학습용 데이터는 train_data와 valid_data로 분리하되 8:2 비율로 분리합니다.

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F

## Prepare Dataset
data = torch.from_numpy(df.values).float()
#data.shape = torch.Size([150, 5])

# 데이터셋에서 feature 정보와 label 데이터를 분리하여 x,y 데이터를 생성
x = data[:,:4]
y = data[:,[-1]]

# train, valid 데이터셋 분리, 데이터는 8:2 or 7:3 생성
ratio = [.8, .2]

train_cnt = int(data.size(0) * ratio[0])
valid_cnt = data.size(0) - train_cnt
print(train_cnt, valid_cnt) #120, 30

# torch.randperm을 사용해서 랜덤한 int 순열을 생성, train/valid 데이터로 분리
indices = torch.randperm(data.size(0))
x = torch.index_select(x, dim=0, index=indice).split([train_cnt, valid_cnt], dim=0)
y = torch.index_select(y, dim=0, index=indice).split([train_cnt, valid_cnt], dim=0)

pytorch에서 제공하는 Dataset과 DataLoader를 import합니다.

Dataset 클래스를 상속하여 IrisDataset 클래스를 생성하고 data, label을 입력합니다.
IrisDataset을 DataLoader에 입력하여 데이터를 batch_size 만큼 데이터를 분리하여 train_loader에 넣어줍니다.

iris 데이터셋은 총 150개 데이터입니다. 이것을 train/valid 형태로 8:2로 분리했기 때문에 train 120, valid 30개의 데이터로 각각 생성됐습니다. 이렇게 생성된 데이터를 한번에 훈련하지 않고 일정 갯수로 데이터를 묶어 줍니다. 사실 소규모의 데이터 셋에서는 이러한 batch 작업이 불필요합니다. 그러나 많은 수의 데이터를 훈련하기 위해서는 이러한 작업이 필수입니다. 이번 예제에서는 30개 단위로 묶음을 만들어보겠습니다.

파이토치에서는 이러한 묶음 작업을 할 수 있는 DataLoader라는 편리한 패키지를 제공합니다. 이러한 과정을 통해서 120개의 데이터가 30개식 4묶음으로 train_loader에 저장되게 됩니다.

from torch.utils.data import Dataset, DataLoader

# Dataset 상속
class IrisDataset(Dataset):
    
    def __init__(self, data, labels):
        super().__init__()
        self.data = data
        self.labels = labels

    def __len__(self):
        return len(self.data)
    
    def __getitem__(self, idx):
        return self.data[idx], self.labels[idx]

# DataLoader
train_loader = DataLoader(dataset=IrisDataset(x[0],y[0]), batch_size=config['batch_size'], shuffle=True)
valid_loader = DataLoader(dataset=IrisDataset(x[1],y[1]), batch_size=config['batch_size'], shuffle=False)

참고로 data, train, validate, bacth_size, epoch을 이해하기 위해 예를 들어보면…
선생님이 학생들의 학력 수준을 알아보기 위해서 100문제를 만들었습니다. 선생님은 학생들에게 100문제 중에서 80 문제를 풀어보면서 수학적 원리를 설명합니다. 그러나 한번에 80문제를 풀기 어려우니 20문제씩 1~4교시 동안 풀어보게 합니다. 한번만 문제를 풀어보는 것보다는 같은 문제를 반복해서 풀어보는 것이 효과적이기 때문에 5~8교시 다시 문제를 풀어봅니다.

이제 학생들은 80문제를 20문제씩 나눠서 2번에 걸쳐 풀어본것이 됩니다. 만약 시간적 여유가 있다면 2번이 아니라 3번, 4번 풀어본다면 아마도 더 학습이 잘되겠죠.

이제 학생들이 수학원리를 잘 이해했는지 테스트해보기 위해서 남겨둔 20문제를 풀어보게 합니다. 그리고 20개의 문제를 얼마나 많은 학생이 맞췄는지를 계산해봅니다.

이러한 과정은 보통의 학습에서 매우 일반적인 방법입니다. 이제 생각해보면 100문제가 data, 80문제가 train_data, 20문제가 valid_data, 80문제를 20문제씩 나눠서 4묶음을 만드는 과정 batch, 같은 문제를 총 2회 풀어봄 epoch 이것이 지금까지의 과정에서 사용했던 용어를 정의한 것입니다.

즉, train_loader는 120개의 데이터가 30개씩 4묶음으로 되어 있는 것이 됩니다. valid_loader는 30개의 데이터가 30개씩 1묶음이 되겠네요.

자, 이제 모델을 간단히 구성합니다. 학습을 위한 모델이라기 보다는 간단히 테스트하기 위한 것임으로 간단한 모델을 만들어보겠습니다.

예측 데이터는 붓꽃의 꽃받침의 길이와 너비, 꽃잎의 길이와 너비에 따라 3종류 중 하나로 예측하는 것임으로 최종 아웃풋의 형태는 3입니다. 그리고 해당 데이터를 확률 값으로 나타내기 위하여 softmax_classification을 활용합니다.

# model 생성
model = nn.Sequential(
    nn.Linear(4,3)
)

optimizer = optim.Adam(model.parameters())

from copy import deepcopy
lowest_loss = np.inf
best_model = None
lowest_epoch = np.inf

copy 패키지로부터 deepcopy를 import합니다. 이것은 이번에 데이터를 만드는 과정과 직접적인 관련이 없기 때문에 간단히만 설명하면 객체의 모든 내용을 복사해서 새로운 하나의 객체를 만드는 것을 deep copy라고 합니다. 반대의 개념은 shallow copy 입니다.

이제 학습을 시작합니다. 이 모델은 2개의 for loop으로 되어 있습니다. 가장 먼저 나오는 for loop은 epoch에 대한 정의로 train data를 총 몇번 학습하는가에 대한 정의입니다. 다음에 나오는 또 하나의 for loop은 학습 데이터를 몇개로 나눠서 학습할 것인가 즉, batch에 대한 문제입니다.

1번 학습이 끝나면 학습의 loss를 계산해봅니다. loss는 정답과의 차이를 의미하는 것으로 작으면 작을 수록 학습이 잘됐다는 의미입니다. 한번 학습이 끝나면 valid data를 실행해봅니다. 그리고 valid에서 나온 loss와 train에서 나온 loss를 비교해보고 valid의 loss가 더 좋을 때에 해당 학습에 사용한 모델을 deepcopy해서 저장합니다.

그 이유는 무조건 학습을 오래 한다고 해서 좋은 결과가 나오는 것이 아니고 어느 순간에 학습이 정체되거나 과적합 되는 일이 있기 때문에 가장 좋은 모델을 저장하는 것입니다.

train_history, valid_history = [], []

for i in range(config['n_epochs']+1):
    model.train()
    
    train_loss, valid_loss = 0, 0
    y_hat = []
    
    # train_batch start
    for x_i, y_i in train_loader:
        y_hat_i = model(x_i)
        loss = F.cross_entropy(y_hat_i, y_i.long().squeeze())
        
        optimizer.zero_grad()
        loss.backward()

        optimizer.step()        
        train_loss += float(loss) # This is very important to prevent memory leak.

    train_loss = train_loss / len(train_loader)
    
    model.eval()
    with torch.no_grad():
        valid_loss = 0
        
        for x_i, y_i in valid_loader:
            y_hat_i = model(x_i)
            loss = F.cross_entropy(y_hat_i, y_i.long().squeeze())
            
            valid_loss += float(loss)
            
            y_hat += [y_hat_i]
            
    valid_loss = valid_loss / len(valid_loader)
    
    train_history.append(train_loss)
    valid_history.append(valid_loss)
    
    if i % config['print_interval'] == 0:
        print('Epoch %d: train loss=%.4e  valid_loss=%.4e  lowest_loss=%.4e' % (i, train_loss, valid_loss, lowest_loss))
        
    if valid_loss <= lowest_loss:
        lowest_loss = valid_loss
        lowest_epoch = i
        best_model = deepcopy(model.state_dict())
        
    model.load_state_dict(best_model)

이제 학습이 잘됐는지 아래와 같은 방법으로 train_loss와 valid_loss를 표시해봅니다.

import matplotlib.pyplot as plt

fig, loss_ax = plt.subplots()

loss_ax.plot(train_history, 'y', label='train loss')
loss_ax.plot(valid_history, 'r', label='val loss')

loss_ax.set_xlabel('epoch')
loss_ax.set_ylabel('loss')

loss_ax.legend(loc='upper left')

plt.show()

사실 이 예제는 torch의 Dataset과 DataLoader를 사용하는 방법에 대한 예제였는데 이것저것 설명하다 보니 글이 길어졌습니다.

여기서 중요한 것은 Dataset을 만들고 DataLoader를 통해서 학습에 사용하는 방법에 대한 내용이 중요하니 예제 코드를 활용해서 직접 테스트해보시기 바랍니다.

PyTorch’s Embedding()

단어 임베딩(Word Embedding)이란 말뭉치의 각 단어에 일대일로 대응하는 실수 벡터의 집합이나 혹은 이런 집합을 구하는 행위를 Word Embedding이라고 합니다. Word2Vec도 이런 워드 임베딩의 한 방법입니다.

그렇다면 왜 이런 워드 임베딩 방법이 필요할까요? 그 이유는 컴퓨터가 자연어를 이해하지 못하기 때문입니다. 그렇다면 컴퓨터가 이해할 수 있는 형태, 즉 숫자로 단어를 바꿔서 입력해줘야 합니다. 그렇다면 어떻게 해야 효과적으로 단어를 숫자의 형태로 바꿀 수 있을까요? 이러한 고민에서 나온 것이 워드 임베딩입니다.

먼저 워드를 숫자로 바꾸는 가장 간단한 방법은 원-핫-인코딩(One-Hot-Encoding)입니다. 예를 들어 “나는 학교에 갑니다” 이 문장을 3개의 단어로 구분하고 각 단어의 위치를 표시하는 것이죠. 이렇게 하면 일단 문자를 숫자로 바꾸는데는 성공했습니다.

1나는1,0,0
2학교에0,1,0
3갑니다0,0,1

그러나 이러한 방법에는 단점이 있습니다. 가장 큰 단점은 벡터의 사이즈가 너무 커진다는 것과 벡터의 내용이 하나의 1을 제외한 나머지 내용이 모두 0으로 채워진다는 것입니다. 예를 들어 “나는 학교에 갑니다” 3개의 단어이지만 책과 같은 대규모의 말뭉치에 등장하는 단어는 수만개가 된다는 것이죠. 그렇게 되면 벡터의 크기는 수만개가 넘는 사이즈에 대부분 0인 벡터가 만들어지기 때문에 이를 처리하는데 큰 문제가 생깁니다. 이것은 희소 벡터(Sparse Vector)라고 합니다. 또 하나의 문제는 각 단어의 값들은 모두 동일한 거리(Distance)를 가진다는 것입니다. 의미론적인 구분이 불가능하다는 것이죠.

그렇기 때문에 필요한 것은 단어의 크기와 상관 없는 차원의 벡터와 0과 1이 아닌 실수값을 가지는 새로운 벡터가 필요합니다. 이것을 밀집 벡터(Dense Vector)라고 합니다. 또 각 단어가 가지는 벡터에 방향성이나 유사도에 따라서 거리가 가깝거나 멀거나 하는 특징을 가지도록 표현할 필요가 있습니다.

워드 임베딩은 이러한 밀집된 형태의 벡터를 만드는 과정이라고 할 수 있습니다.

PyTorch는 입력 텍스트를 받아서 임베딩 벡터를 생성하는 nn.Eembedding()을 제공하고 있습니다. index 값이 부여되어 있는 단어를 입력 받습니다. 여기서 index는 고유한 값이 됩니다. 이 Index를 참조 테이블(look-up table)에서 사용할 것입니다. 즉, |?|×? 크기의 행렬에 단어 임베딩을 저장하는데, D 차원의 임베딩 벡터가 행렬의 ?i 번째 행에 저장되어있어 ?i 를 인덱스로 활용해 임베딩 벡터를 참조하는 것입니다. 여기서 |?|는 vocabulary의 수이고 D는 차원정보입니다.

https://wikidocs.net/64779

위의 그림은 단어 great이 정수 인코딩 된 후 테이블로부터 해당 인덱스에 위치한 임베딩 벡터를 꺼내오는 모습을 보여줍니다. 위의 그림에서는 임베딩 벡터의 차원이 4로 설정되어져 있습니다. 그리고 단어 great은 정수 인코딩 과정에서 1,918의 정수로 인코딩이 되었고 그에 따라 단어 집합의 크기만큼의 행을 가지는 테이블에서 인덱스 1,918번에 위치한 행을 단어 great의 임베딩 벡터로 사용합니다. 이 임베딩 벡터는 모델의 입력이 되고, 역전파 과정에서 단어 great의 임베딩 벡터값이 학습됩니다.

룩업 테이블의 개념을 이론적으로 우선 접하고, 처음 파이토치를 배울 때 어떤 분들은 임베딩 층의 입력이 원-핫 벡터가 아니어도 동작한다는 점에 헷갈려 합니다. 파이토치는 단어를 정수 인덱스로 바꾸고 원-핫 벡터로 한번 더 바꾸고나서 임베딩 층의 입력으로 사용하는 것이 아니라, 단어를 정수 인덱스로만 바꾼채로 임베딩 층의 입력으로 사용해도 룩업 테이블 된 결과인 임베딩 벡터를 리턴합니다.[1]

참고 : Word Embeddings in Pytorch

https://pytorch.org/tutorials/beginner/nlp/word_embeddings_tutorial.html

파이토치 공식홈에 있는 내용을 사용해서 간단한 테스트 코드를 만들어보겠습니다.

import torch
import torch.nn as nn

train_data = '태초에 하나님이 천지를 창조하시니라 창세기 1장 1절'.split(' ')
word_set = list(set(train_data))
word_to_ix = {tkn:i for i, tkn in enumerate(word_set)}
word_to_ix

입력 데이터를 글자 단위로 분리하여 dict에 저장합니다. 저장된 내용을 출력해보면 아래의 내용과 같습니다. 중복을 제거하기 위해 set() 자료형을 사용해서 단어의 순서는 무시되었습니다.

{'천지를': 0, '하나님이': 1, '창조하시니라': 2, '창세기': 3, '태초에': 4, '1절': 5, '1장': 6}

이제 이렇게 분리한 단어를 아래와 같은 방법으로 one-hot 형태로 나타내면 다음과 같은 형태로 표시됩니다.

one_hot = []
for i, tkn in enumerate(word_to_ix):
    one_hot.append(np.eye(len(vocab), dtype='int')[word_to_ix[tkn]])
[array([1, 0, 0, 0, 0, 0, 0]), #천지를
 array([0, 1, 0, 0, 0, 0, 0]), #하나님이
 array([0, 0, 1, 0, 0, 0, 0]), #창조하시니라
 array([0, 0, 0, 1, 0, 0, 0]), #창세기
 array([0, 0, 0, 0, 1, 0, 0]), #태초에
 array([0, 0, 0, 0, 0, 1, 0]), #1절
 array([0, 0, 0, 0, 0, 0, 1])] #1장

그러나 서두에 언급했듯이 이렇게 표현된 벡터 데이터를 직접 학습에 사용하기는 적절하지 않습니다. 그래서 차원을 입력 데이터의 차원을 낮춰주고 Sparse한 데이터를 Dense한 형태로 변경할 필요가 있습니다. 그때 사용하는 것이 Embedding이라고 할 수 있습니다.

torch.nn.Embedding()은 이러한 작업을 쉽게 할 수 있도록 도와줍니다.

embeds = nn.Embedding(len(vocab), 3)
lookup_tensor = torch.tensor([word_to_ix["태초에"]], dtype=torch.long)
w = embeds(lookup_tensor)
print(w)

위와 같은 방법을 사용하면 [1, 0, 0, 0, 0, 0, 0] 형태의 one-hot 벡터를 [-1.0998, -1.0605, -0.5849] 형태의 데이터로 변경할 수 있습니다.

#생성된 weight 벡터
Parameter containing:
tensor([[ 0.5765,  0.2391, -0.1834],
        [-0.1860, -0.0754,  0.4587],
        [-0.9538, -0.6950,  0.5682],
        [-2.1076, -0.4070,  0.2598],
        [-1.0998, -1.0605, -0.5849],
        [ 1.1632, -0.8139,  0.1154],
        [ 0.9705,  0.3963,  0.8804]], requires_grad=True)

아래의 링크는 Word2Vec을 실제로 어떻게 사용하는지에 대한 예제입니다. 궁금하신 분들은 참고하시기 바랍니다.

Reference

[1] https://wikidocs.net/64779