1. NLP-Text 전처리 : 현대 벡터화(FastText) (4) - AI 핵심기술 강의 복습

안상훈·2024년 9월 18일

AI핵심기술

목록 보기
7/21
post-thumbnail

개요

본 블로그 포스팅은 수도권 ICT 이노베이션 스퀘어에서 진행하는 AI 핵심 기술 집중 클래스의 자연어처리(NLP) 강좌 내용을 필자가 다시 복기한 내용에 관한 것입니다.


1. FastText 등장배경

이전 포스트 1. NLP-Text 전처리 : 현대 벡터화(Word2Vec) (3)에서 벡터화 방법론으로 임베딩 레이어에 대해 공부를 했고

임베딩 레이어를 잘 학습시키기 위한 전용 모델 :
Word2V2c에 대한 개념의 이해와

Word2V2c으로 학습이 완료된 임베딩(Pre-trained Embedding)을 사용하여 자연어 처리(NLP) 작업을 수행하는
Downstream Task 실습을 진행했다.

이때 Word2V2c의 두가지 학습 알고리즘
Word2V2c : CBoW
Word2V2c : Skip-gram의 개념에 대해 공부를 했으며, 임베딩 레이어를 학습한 결과물을 아래의 코드로 저장했다.

from gensim.models import KeyedVectors

# 훈련이 완료된 단어 벡터를 저장
# 여기서 단어 벡터는 {단어 : 단어의 임베딩된 벡터}를 의미함
cbow_model.wv.save_word2vec_format('news_w2v_CB') # 모델 저장
SG_model.wv.save_word2vec_format('news_w2v_SG') # 모델 저장

이 두 모델의 wv(word vector)는 거의 동일하고 이전 포스트에서 Word2V2c : CBoW 보다
Word2V2c : Skip-gram의 성능이 더 우수함을 확인했으니 저장한 Word2V2c : Skip-gram를 로드하여 좀 더 분석을 진행해 보도록 하자.

from gensim.models import Word2Vec
from gensim.models import KeyedVectors

SG_model = KeyedVectors.load_word2vec_format("news_w2v_SG") # 모델 로드
# Word2Vec 모델의 단어장 가져오기
vocab = SG_model.index_to_key
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# 단어장을 pandas 라이브러리로 저장하기
S = pd.Series(vocab)
S.to_csv('exam23.csv')

위 저장한 csv파일을 엑셀로 열어서 분석을 해보면 아래와 같이 참 애매한 단어목록이 있음을 알 수 있다.

그러니까 이런 애매한 케이스는
강건하지 않다.
라는 단어로 표현이 가능하며,

word2vec는 오타, 파생어, 합성어에 대한 단어를 새로운 단어벡터로 처리하기에
새로운 단어벡터로 등록되지 못한 애매한 경우가 많아질 수록 그만큼 OOV, Out-Of-Vocabulary 미 등록 단어에 대해
유의미한 단어벡터를 제공할 수 없다.

이 문제를 해결, 즉 생성한 단어벡터(wv, word vector)강건하게 단어벡터를 생성하는 방법론으로
N-gram모델을 도입한것이 FastText라 보면 된다.


1.1 N-gram

FastText에 사용된 N-gram언어모델의 발전사에서 Statistical Language Model(통계적 기법을 활용한 언어모델)에 사용되었던 방법론으로

문장을 생성할 때 n번째 단어를 추정(생성)하고자 한다면 추정의 근간이 되는 정보를 이전에 주어진 n-1개의 단어에 기반하여 추론(계산)하는 모델(방법론)을 의미한다.

대충 위 도식처럼 N-gram 방법론을 도입한 통계적 언어모델은 설정한 N 값에 따라 다음 단어를 추정(생성)하는데 어떻게 보면 word2vec중심 단어, 주변단어 크기를 결정하는 windows랑 유사한 개념이라 보면 된다.

N-gram 방법론을 단어벡터(wv, word vector)를 생성하는데 도입한다.

이를 도식화하면 위 사진과 같으며, FastText으로 임베딩 레이어를 학습시킬때는 두개의 인자값
min_n, max_n을 설정한다. 위 사진에서는
min_n = 3
max_n = 6
으로 설정한 상황이며, N-gram을 적용할 때
다양한 N에 대하여 텍스트를 쪼갠 subword군을 생성한다.

subword군에 원본 단어(origin word)을 포함시켜서 모든 단어에 대한 단어벡터를 생성한다.

따라서 기존 word2vec로 'Monster'이란 단어를 학습시키면 1개의 데이터만 입력되지만

FastText방법을 사용하면
'Monster'이란 단어가 총 23개로 입력되면서 23개의 단어벡터를 학습해야 한다.

여기서 헷갈리면 안되는 사항은
FastTextword2vec 알고리즘에서 입력하는 데이터(단어)를 subword로 더 쪼개서 입력해 강건성을 높이는 방법론임을 잊으면 안된다.

따라서
word2vec : CBow [FastText]
word2vec : Skip-gram [FastText]
이렇게 기존의 word2vec에 새로운 입력데이터 전처리 방법론을 도입한게 FastText인 것이다.


1.3 FastText 제대로 이해하기

필자가 FastText를 공부하면서 이게 마치 마법의 요술상자처럼 느껴지는 벡터화 방법론이라 생각했는데

이것저것 코드 실습을 하면서 내용을 좀 정리할 필요성이 있어 따로 챕터를 분리했다.

1) FastText로 학습시킨 임베딩 레이어는 기존 word2vec로 학습시킨 임베딩 레이어와 크기가 동일하다
(min_n, max_n인자값에 전혀 영향을 받지 않는다.)

FastText에서 하나의 단어가 여러개의 subword로 쪼개지고 각 subword단어벡터를 갖는다기에
단어 + 해당 단어의 subword 개수만큼
vocab_size가 증가할 것이라 생각했다.

이는 잘못된 생각이며, 단어를 분절하는 subword 과정은 FastText의 학습 과정에서만 발생하지
최종 출력물인 임베딩 레이어에는 subword 단어벡터는 전부 제외하고 Originword 단어벡터임베딩 레이어에 포함된다.

따라서 기존 word2vecOriginword 단어벡터로만 임베딩 레이어를 구성하는것과 같은 의미이기에

둘 다 임베딩 레이어의 첫번째 축인 vocab_size값은 동일하다.

2) FastText는 기존 word2vec의 학습에 사용되는 단어간 파생관계를 고려해서 단어벡터의 표현력(유사한 의미정도)를 높인 것에 불과하다.

아래 실전예시로 한번 위 내용을 확인해보자

앞서 word2vec로 학습시킨 임베딩 레이어에는 애매하게 비슷한 단어들이지만 유사도는 떨어지게 표현된 여러가지 케이스를 나열했다.

오타인데 단어장에 같이 있는경우
합성어와 합성어의 구성어가 같이 있는 경우
대표어근과 이로 비롯된 파생어가 단어장에 있는경우

총 3가지 경우이며,
각 경우에 대해 기존 word2vec는 각각의 경우에 대해서 단어 유사도를 재대로 표현하지 못하는 문제가 있는 것이다.

즉, 사람이 보기에 거의 같은 단어인데 word2vec로 학습시키니 유사도가 낮게 표현되는 항목들을
개선하여 단어벡터의 표현력을 높인 알고리즘이 FastText인 것이다.

따라서 위 사진처럼 FastText로 단어장에 포함된 단어들 중 애매한 케이스에 대해서 전부 일괄적으로 유사성이 상당히 높아지는것을 확인할 수 있다.

3) FastTextOOV에 강건하게 대응하는것은 모델'평가'모드일때 효과가 있으며, 모델이 '훈련' 및 '검증' 단계에서는 OOV문제에 대응하는데 한계가 있다.

우선 기존 Word2vecOriginword가 아닌 단어들은 OOV처리되어 단어벡터를 출력할 수 없으나,
FastTextsubword 정보를 활용하여 의사 단어벡터를 만들어 낼 수 장점이 있다.

이는 FastText는 단어장에 없는 단어(OOV)가 입력될 시 해당 단어를 subword단위로 분해하여 FastText의 임베딩 레이어중 분해한 subword 단어벡터가 존재하면 이를 다 수집한 뒤 평균을 내는 방식으로
의사 단어벡터를 만들어 낸다.

즉, 입력되는 단어(OOV)가 범주형 데이터로 입력되어야만 의사 단어벡터를 만들어 낼 수 있는 것이다.

범주형 데이터로 입력되는 경우는 모델 평가(추론)일 뿐이며, 모델'훈련' 및 '검증' 과정에서는 입력되는 데이터가
모두 정수형으로 변환되고, 정수형으로 변환될 때 희소단어는 <UNK>라는 스페셜토큰화 되버린다.

따라서 희소단어가 <UNK>로 처리되면 해당 단어로부터 어떠한 subword 정보를 추출할 수 없으니 OOV문제에 대응할 수 없다.


위 3가지 항목을 숙지하고 FastText에 대해 실습을 진행하도록 하자.



2. FastText 영어실습

이전 포스트 1. NLP-Text 전처리 : 현대 벡터화(Word2Vec) (3) - AI 핵심기술 강의 복습 에서

데이터셋으로 기사 분류를 진행했던 작업을

1) Word2Vec : CBoW
2) Word2Vec : Skip-gram
3) FastText

방식으로 훈련시킨 Embedding layerDownstream Task하여 기사분류하는 작업을 수행한다.

코드는 거의 동일하기에 넘어갈 항목은 빠르게 넘어가도록 하겠다.

데이터 전처리 ~ 텍스트 전처리

from sklearn.datasets import fetch_20newsgroups
data = fetch_20newsgroups(subset='all', shuffle=True)

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

raw_data = pd.DataFrame(
    {'data' : data['data'],
     'label': data['target']}
)
# 컬럼별 결측치 cnt값을 모두 더한 값 (정수형 데이터)
missing_data = raw_data.isna().sum().values.sum()

# 결측치 정보가 0이면 결측치가 없으니 아래 함수가 실행안됨
if missing_data != 0:
    raw_data.dropna(how='any', inplace=True)

# 중복치 제거
raw_data.drop_duplicates(subset='data',
                         keep='first',
                         inplace=True)
import re

# 감지할 문자열을 정규표현식 패턴으로 표현
p1 = re.compile(r'\(\d{3}\)\s?\d{3}-\d{4}') # (000) 000-0000 찾음
p2 = re.compile(r'\d{3}-\d{3}-\d{4}') # 000-000-000 찾음
# 이메일 주소 감지 정규표현식 패턴
p3 = re.compile(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z-Z]{2,}')
p4 = re.compile(r'[^a-zA-Z0-9\s]') # 영어, 숫자, 공백외 특수문자

def regex_sub(origin_doc):
    clean_doc = p1.sub(repl="", string=origin_doc)
    clean_doc = p2.sub(repl="", string=clean_doc)
    clean_doc = p3.sub(repl="", string=clean_doc)
    clean_doc = p4.sub(repl=" ", string=clean_doc)

    return clean_doc

raw_data['data'] = raw_data['data'].apply(regex_sub)

def doc_normalize(origin_doc):
    # 대문자+소문자 --> 모두 소문자로 정규화
    return origin_doc.lower()

raw_data['data'] = raw_data['data'].apply(doc_normalize)
raw_x_data = raw_data['data'].values.tolist()
raw_y_label = raw_data['label'].values.tolist()

import nltk
# punkt 코퍼스 기반 
from nltk.tokenize import WordPunctTokenizer 
from tqdm import tqdm

word_tokenizer = WordPunctTokenizer()

def tokenize(x_data, tokenizer):
    tokenized_doc = list()

    for sent in tqdm(x_data):
        temp = tokenizer.tokenize(sent)
        tokenized_doc.append(temp)

    return tokenized_doc

# 토큰화 수행
tokenized_x_data = tokenize(raw_x_data, word_tokenizer)
from nltk.corpus import stopwords

# nltk라이브러리의 불용어 리스트 로드
stopwords_list = stopwords.words('english')

# 리스트 컴프리헨션 기반 불용어 제거
def remove_stopword(tokenized_data, stopword):
    return [[word for word in sent if word not in stopword]
            for sent in tokenized_data]

# 불용어 제거 수행
r_t_x_data = remove_stopword(tokenized_x_data, stopwords_list)
from sklearn.model_selection import train_test_split

# 훈련데이터셋(60%) 그 외 데이터셋(40)로 나누는 작업 수행
# random_state -> 데이터셋을 내누는데 '재현성' 유지를 위해 넣음 -> 안넣어도 됨
# stratify -> Y_label의 클래스 비율을 유지하면서 데이터 나눌때 옵션
x_train, x_etc, y_train, y_etc = train_test_split(
    r_t_x_data, raw_y_label, test_size=0.4, random_state=42, stratify=raw_y_label
)

# 그 외 데이터셋을 반반으로 Val, Test로 나눔
x_val, x_test, y_val, y_test = train_test_split(
    x_etc, y_etc, test_size=0.5, random_state=42, 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)

total_vocab_cnt = len(word_counts) #전체 단어 종류
rare_vocab_cnt = 0 #등장빈도수가 적은 단어는 몇 종?
total_freq, rare_freq = 0, 0

# 희소단어를 결정하는 하이퍼 파라미터
threshold = 3

for key, value in word_counts.items():
    # 전체 단어의 등장빈도를 모두 가산하여 더함
    total_freq = total_freq + value
    
    if (value < threshold):
        rare_vocab_cnt += 1
        rare_freq += value

# 단어집합 내 희소 단어 비율
rare_ratio = rare_vocab_cnt / total_vocab_cnt
# 희소단어 등장 비율
freq_ratio = rare_freq / total_freq

#등장 빈도가 높은 단어 순으로 정렬하기
vocab = sorted(word_counts, key=word_counts.get, reverse=True)

#등장 빈도가 높은 단어만 인덱싱 하기
vocab_size = total_vocab_cnt - rare_vocab_cnt
vocab = vocab[:vocab_size]
# 특수단어를 포함시켜 {단어:인덱스} 딕셔너리 생성하기
# 포함시킬 특수단어는 `<PAD>`, `<UNK>`으로 
# <PAD> : 0, <UNK> : 1 순으로 특수단어는 맨 앞에 위치하기
word_to_idx = {'<PAD>' : 0, '<UNK>' : 1}

for idx, word in enumerate(vocab):
    word_to_idx[word] = idx + 2
# 단어를 정수 인덱싱 규칙으로 정수 인덱싱 수행하기
def text_to_sequences(tokenized_data, word_to_idx):
    encoded_data = [] #리턴해야할 정수 인코딩 결과값

    for sent in tokenized_data:
        idx_sequence = [] #단어장 리스트에서 idx를 찾아서 여기에 입력
        
        for word in sent:
            try: #word_to_idx에서 단어를 찾은 뒤 해당 단어의 인덱스(숫자)를 입력
                idx_sequence.append(word_to_idx[word])
            except KeyError: # word_to_idx 딕셔너리에 없는 키(단어)등장시 UNK로 인덱싱
                idx_sequence.append(word_to_idx['<UNK>'])
        
        #문장 내 단어를 모두 정수로 변환한 후에 이를 리턴값(리스트)에 입력
        encoded_data.append(idx_sequence)
    
    return encoded_data

# 데이터셋의 정수 인코딩 수행
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)
max_len = 440 #문장의 최대 길이를 지정하기 위한 Th값

# x_data 항목을 문장패딩하기 위한 코드
def pad_seq_x(x_data, max_len):
    features = np.zeros((len(x_data), max_len), dtype=int)

    for idx, sent in enumerate(x_data):
        if len(sent) != 0: #예외처리구문
            features[idx, :len(sent)] = np.array(sent)[:max_len]
    
    return features

# 문장패딩 처리한 x_data (인코딩 완료)
padded_x_train = pad_seq_x(e_x_train, max_len)
padded_x_val = pad_seq_x(e_x_val, max_len)
padded_x_test = pad_seq_x(e_x_test, max_len)

여기까지 빠르게 정수(문장패딩)인코딩까지 수행하자.


2.1 임베딩 레이어 학습

학습 데이터셋 설정

# Word2Vec 및 FastText 학습에 사용할 데이터: 
# 원본 데이터셋의 토큰화 후 불용어 제거를 수행한 데이터터
word2vec_doc = r_t_x_data

임베딩 레이어를 학습시키는데 사용하는 데이터는 토큰화 수행 \rightarrow 불용어제거(정규화 포함)까지 수행한 순도 높은 범주형 데이터를 기반으로
word2vec, FastText를 수행한다.


word2vec 학습

from gensim.models import Word2Vec

# word2Vec : CBow 모델 학습
CB_model = Word2Vec(
    sentences=word2vec_doc,
    vector_size = 100, # 임베딩 차원은 100으로 설정
    window = 5, # 논문의 최대 관심가질 주변단어 사이즈인 5~20
    min_count = threshold, # (3) 단어장에서 배제할 희소단어 빈도 기준
    workers= -1, # 학습에 참여할 프로세스 개수 (최대로 설정)
    sg = 0 # CBoW 방식으로 학습 수행
)

# word2Vec : skip-gram 모델 학습
SG_model = Word2Vec(
    sentences=word2vec_doc,
    vector_size = 100, # 임베딩 차원은 100으로 설정
    window = 5, # 논문의 최대 관심가질 주변단어 사이즈인 5~20
    min_count = threshold, # (3) 단어장에서 배제할 희소단어 빈도 기준
    workers= -1, # 학습에 참여할 프로세스 개수 (최대로 설정)
    sg = 1 # Skip-gram 방식으로 학습 수행
)

FastText 학습

from gensim.models import FastText

FT_model = FastText(
    sentences=word2vec_doc,
    vector_size = 100, # 임베딩 차원은 100으로 설정
    window = 5, # 논문의 최대 관심가질 주변단어 사이즈인 5~20
    min_count = threshold, # (3) 단어장에서 배제할 희소단어 빈도 기준
    workers= -1, # 학습에 참여할 프로세스 개수 (최대로 설정)
    sg = 1, # Skip-gram 방식으로 학습 수행
    # FastText의 N-gram 범위 설정(3~6)
    min_n=3, max_n=6
)

참고로 FastText로 학습을 진행할 때는
학습 방법론을 CBoW, Skip-gram 둘 중 하나를 선택이 가능하며,

이전 포스트에서 Skip-gram 학습 방법론이 CBoW보다 더 우수함을 확인했으니
FastText : Skip-gram으로 모델을 학습시킨다.
설정 인자값은 word2vec와 동일하게 설정하며

추가로 설정할 인자값이 min_n, max_n두개가 있는데 이는 앞서 설명했듯이 N-gram의 범위라 보면 된다.


단어 벡터 유사도 성능 비교

# FastText로 학습한 임베딩 레이어의 단어벡터간 유사도 
sim_list = FT_model.wv.most_similar("subject")
for ele in sim_list:
    word = ele[0]
    vel = ele[1]

    print(f"유사단어:{word:>13}, 유사정도:{vel:.3f}")

위 코드를 바탕으로 FastTextword2vec의 단어 벡터간 유사도를 비교하면 아래와 같다.

단어벡터의 유사도를 확인해 본다면 FastText로 학습시킨 임베딩 레이어가 좀더 파생어를 잘 묶어서 단어 간 유사한 정도를 잘 표현하고 있음을 확인할 수 있다.

from gensim.models import KeyedVectors

# 훈련이 완료된 FastText 단어 벡터 저장
# 여기서 단어 벡터는 {단어 : 단어의 임베딩된 벡터}를 의미함
FT_model.wv.save_word2vec_format('news_FT_SG') # 모델 저장
!python -m gensim.scripts.word2vec2tensor --input news_FT_SG --output news_FT_SG

모델을 저장한 뒤 임베딩 레이어에 대해서 3D 디스플레이한 결과값을 보면
이게 확 와닿지는 않지만 FastText가 조금 더 밀집한 느낌으로 표현되는 것을 확인할 수 있다.


임베딩 레이어 조정

이전 포스트에서 수행했던 임베딩 레이어의
단어벡터 순번을

word_to_idx의 단어장 순번으로 재조정하는 작업을 수행한다.

# 단어장 크기 : 48151
vocab_size = len(word_to_idx) 
# 임베딩 차원 크기 : 100
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
# CBoW 방식으로 학습된 임베딩 레이어의 조정
my_CB_embedding = build_my_embed(word_to_idx, CB_model.wv)
# Skip-gram 방식으로 학습된 임베딩 레이어의 조정
my_SG_embedding = build_my_embed(word_to_idx, SG_model.wv)

# FastText 방식으로 학습된 임베딩 레이어 조정
my_FT_embedding = build_my_embed(word_to_idx, FT_model.wv)

텐서 데이터셋 ~ 데이터 로더 생성

import torch
import torch.nn.functional as F

# 정수(원핫)인코딩된 x_data를 텐서 자료형으로 변환
t_x_train = torch.tensor(padded_x_train, dtype=torch.int64)
t_x_val = torch.tensor(padded_x_val, dtype=torch.int64)
t_x_test = torch.tensor(padded_x_test, dtype=torch.int64)

# Y_label 데이터를 텐서 자료형으로 변환 (list -> tensor)
t_y_train = torch.tensor(y_train, dtype=torch.int64)
t_y_val = torch.tensor(y_val, dtype=torch.int64)
t_y_test = torch.tensor(y_test, dtype=torch.int64)
from torch.utils.data import TensorDataset, DataLoader

BS = 256 # Batch_size는 통일

# 임베딩 레이어가 있는 언어모델에 입력할 데이터셋 + 데이터로더
oh_trainset = TensorDataset(t_x_train, t_y_train)
oh_trainloader = DataLoader(oh_trainset, shuffle=True, batch_size=BS)

oh_valset = TensorDataset(t_x_val, t_y_val)
oh_valloader = DataLoader(oh_valset, shuffle=False, batch_size=BS)

oh_testset = TensorDataset(t_x_test, t_y_test)
oh_testloader = DataLoader(oh_testset, shuffle=False, batch_size=BS)

모델 설계

이번에는 copy_옵션으로 임베딩 레이어의 파라미터를 교체하는 방식이 아닌

클래스 설계부터 nn.Parameter메서드로 임베딩 레이어Pre-trained Word Embedding을 진행하고자 한다.

# 주요 하이퍼 파라미터 정리
VOCAB_SIZE = len(word_to_idx)
CONTEXT_LENGTH = max_len
EMB_DIM = FT_model.wv.vector_size
NUM_CLASS = len(data['target_names'])

print(f"단어장 크기: {VOCAB_SIZE}")
print(f"문장 최대길이: {CONTEXT_LENGTH}")
print(f"임베딩차원: {EMB_DIM}")
print(f"클래스 종류: {NUM_CLASS}")

우선 주요 파라미터는 미리 정리를 해두고

class emb_simpleNet(nn.Module):
    def __init__(self, vocab_size, emb_matirx, embed_dim, hidden_dim, num_label):
        super(emb_simpleNet, self).__init__()

        self.embed = nn.Embedding(vocab_size, embed_dim)

        # 사전 훈련된 임베딩 매트릭스를 붙여넣음
        self.embed.weight = nn.Parameter(
            torch.tensor(emb_matirx, dtype=torch.float32))
        # 붙여넣은 Pretrained 임베드 레이어만 Freeze하고 싶을때는 False
        self.embed.weight.requires_grad = True

        self.fcn1 = nn.Linear(embed_dim, hidden_dim)
        self.relu = nn.ReLU()
        self.fcn2 = nn.Linear(hidden_dim, num_label)

    def forward(self, x):
        x = self.embed(x)
        # 단어의 유사성 의미정보는 뭉게버린다.
        x = torch.mean(x, dim=1)

        x = self.fcn1(x)
        x = self.relu(x)
        x = self.fcn2(x)
        
        return x

클래스를 설계할 때 사전훈련된 임베딩 메트릭스를 인자값으로 받게 설계하고
이를 임베딩 레이어의 파라미터에 적용할 때는 nn.Parameter 메서드를 활용한다.

# 모델 객체화 수행
CB_model = emb_simpleNet(VOCAB_SIZE, my_CB_embedding, EMB_DIM, HIDE_DIM, NUM_CLASS)

SG_model = emb_simpleNet(VOCAB_SIZE, my_SG_embedding, EMB_DIM, HIDE_DIM, NUM_CLASS)

FT_model = emb_simpleNet(VOCAB_SIZE, my_FT_embedding, EMB_DIM, HIDE_DIM, NUM_CLASS)
# GPU사용 가능 유/무 확인
device = torch.device("cuda" if torch.cuda.is_available() else 'cpu')

CB_model.to(device)
SG_model.to(device)
FT_model.to(device)

다음으로 모델 인스턴스화 + GPU이전을 수행한다.


학습 준비

import torch.optim as optim
# 로스함수 및 옵티마이저 설계
criterion = nn.CrossEntropyLoss()

LR = 0.001 # 러닝레이트는 통일

CB_optimizer = optim.Adam(CB_model.parameters(), lr=LR)
SG_optimizer = optim.Adam(SG_model.parameters(), lr=LR)
FT_optimizer = optim.Adam(FT_model.parameters(), lr=LR)
# 사전에 모듈화 한 학습/검증용 라이브러리 import
from C_ModelTrainer import ModelTrainer

num_epoch = 15 #총 훈련/검증 epoch값

ES = 3 # 디스플레이용 에포크 스텝
# BC_mode = True(이진), False(다중)
# aux = 보조분류기 유/무
# wandb = 완디비에 연결 안하면 None
trainer = ModelTrainer(epoch_step=ES, device=device, BC_mode=False, aux=False)
#학습/검증 중간 정보 저장
keys = [['CBoW', 'Skip-gram', 'FastText'], ['loss', 'acc']]
history = {key: {'loss': [], 'acc': []} for key in keys[0]}

models = {'CBoW' : CB_model, 
          'Skip-gram' : SG_model, 
          'FastText' : FT_model}

train_loaders = {'CBoW' : oh_trainloader, 
                 'Skip-gram' : oh_trainloader, 
                 'FastText' : oh_trainloader}

val_loaders = {'CBoW' : oh_valloader, 
               'Skip-gram' : oh_valloader, 
               'FastText' : oh_valloader}

optimizers = {'CBoW' : CB_optimizer, 
              'Skip-gram' : SG_optimizer, 
              'FastText' : FT_optimizer}

학습 및 사후분석

for step in range(len(keys[0])):
    epoch = 0 #에포크 초기화
    
    for epoch in range(num_epoch):
        #훈련 손실&성능지표 반환
        train_loss, train_acc = trainer.model_train(
            models[keys[0][step]], train_loaders[keys[0][step]],
            criterion, optimizers[keys[0][step]], epoch
            )
        #검증 손실&성능지표 반환
        val_loss, val_acc = trainer.model_evaluate(
            models[keys[0][step]], val_loaders[keys[0][step]],
            criterion, epoch
            )
        
        #손실&성능지표를 history에 저장
        history[keys[0][step]][keys[1][0]].append((train_loss, val_loss))
        history[keys[0][step]][keys[1][1]].append((train_acc, val_acc))

        # Epoch_Step(ES)일때 print하기
        if (epoch+1) % ES == 0 or epoch == 0:
            print(f"현재 훈련중인 모델: {keys[0][step]}")
            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----모델{keys[0][step]} 훈련 종료----\n")

훈련이 잘 되었으면 위 메세지들이 모델별로 출력될 것이다.

import matplotlib.pyplot as plt

# 모델 목록과 메트릭 목록
models = keys[0]
metrics = keys[1]

# 데이터 추출을 위한 딕셔너리 초기화
extracted_data = {}

for model in models:
    extracted_data[model] = {}
    for metric in metrics:
        # 각 모델의 메트릭 데이터 추출
        metric_data = history[model][metric]
        # 훈련 및 검증 값 분리
        train_values = [tup[0] for tup in metric_data]
        val_values = [tup[1] for tup in metric_data]
        extracted_data[model][f'train_{metric}'] = train_values
        extracted_data[model][f'val_{metric}'] = val_values
# 손실 그래프 생성
fig, axes = plt.subplots(3, 2, figsize=(12, 16))
axes = axes.flatten()  # 2차원 배열을 1차원으로 변환하여 인덱싱 쉽게 함

for idx, model in enumerate(models):
    ax = axes[idx*2]
    ax.plot(extracted_data[model]['train_loss'], label='Train Loss')
    ax.plot(extracted_data[model]['val_loss'], label='Validation Loss')
    ax.set_title(f'{model} Loss')
    ax.set_xlabel('Epoch')
    ax.set_ylabel('Loss')
    ax.legend()

for idx, model in enumerate(models):
    ax = axes[idx*2 + 1]
    ax.plot(extracted_data[model]['train_acc'], label='Train Accuracy')
    ax.plot(extracted_data[model]['val_acc'], label='Validation Accuracy')
    ax.set_title(f'{model} Accuracy')
    ax.set_xlabel('Epoch')
    ax.set_ylabel('Accuracy')
    ax.legend()

plt.tight_layout()
plt.show()

# 모든 모델의 손실과 정확도를 비교하는 그래프 생성
fig, axes = plt.subplots(2, 1, figsize=(10, 18))

# 모든 모델의 손실 그래프
ax = axes[0]
for model in models:
    ax.plot(extracted_data[model]['train_loss'], label=f'{model} Train Loss')
    ax.plot(extracted_data[model]['val_loss'], label=f'{model} Val Loss', linestyle='--')
ax.set_title('Training and Validation Loss for All Models')
ax.set_xlabel('Epoch')
ax.set_ylabel('Loss')
ax.legend()

# 모든 모델의 정확도 그래프
ax = axes[1]
for model in models:
    ax.plot(extracted_data[model]['train_acc'], label=f'{model} Train Acc')
    ax.plot(extracted_data[model]['val_acc'], label=f'{model} Val Acc', linestyle='--')
ax.set_title('Training and Validation Accuracy for All Models')
ax.set_xlabel('Epoch')
ax.set_ylabel('Accuracy')
ax.legend()

plt.tight_layout()
plt.show()

각 모델별로 성능을 비교하자면

FastText > Skip-gram >> CBoW
순으로 기사 분류(Text Calssification)을 잘 수행하고 있음을 확인할 수 있다.



3. FastText 한글실습

FastText는 앞서 수행한 실습 및 적용 방식을 본다면 언어가 영어인 경우에 대해서는 크게 어렵지 않게 적용하는 것이 가능하다.

문제는 한글인데
한글은 단어를 문자로 쪼개서 N-gram방식으로 합치는 subword를 생성하는게 참 어렵기 때문이다.

예시로 '크다'에 대하여 FastText음절 단위로 분리한다면 위 사진중 붉은색 박스로 표시한
크기, 크게와 같이 전체 파생어 중 음절: 크 하나만 같은 파생어만 인식이 가능하다.

하지만 나머지 파생어를 전부 인식하려면 음절이 아닌 초성, 중성, 종성의 자음,모음 단위까지 분해해야
올바르게 'ㅋ'로 연관된 파생어를 전부 파악할 수 있다.

한글에 대한 FastText 예제를 만든다면 위 도식처럼 표현할 수 있다.

이때 한글의 자모 분해과 같이 쌍자음 + 복모음 + 겹받침 같은 복합 자음/모음으로 구성된 단어일 시

1) 복합 자모는 추가로 나누지 않고 초성, 중성, 종성 3가지로만 나눔

2) 복합 자모도 세밀하게 분해

이렇게 2가지 과정 중 하나를 선택할 수 있는데
https://github.com/kaniblu/hangul-utils

https://github.com/JDongian/python-jamo/tree/master

위 두개의 깃허브에 업로드된 자모 분해 라이브러리나 다른 사용 예제를 살펴도 대체로
1) 초성, 중성, 종성 3개로만 단어를 형태소 분해한다.


한글의 자모를 분리하는 라이브러리는 위에 첨부한
hangul-utils, jamo 두개의 라이브러리가 존재하지만 다들 적어도 4년 이상 유지보수가 제대로 수행되지 않은 라이브러리 이기도 하고 hangul-utils는 의존성이 있는 라이브러리가 mecab-ko여서 설치도 잘 안된다.

이때 자모 분리 / 분리된 자모의 원복 함수는
unicode.py에 다른 라이브러리 의존없이 독립적으로 구현되어 있어서
해당파일만으로 함수 실행이 가능하다.


3.1 자모분리/원복 함수 설계

한글 유니코드를 활용하여
word to jamo : 단어를 초/중/종성의 자모로 분리
jamo to word : 초/중/종성으로 분리된 자모를 단어로 원복

이때 받침(종성)이 없는 단어는 임의의 종성 자모
'_'를 기입한다.

ko_unicode = [0xAC00, 0xD7A3] #한글 유니코드 시작 '가', 끝 '힣'

cho_list = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']
jung_list = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ']
jong_list = ['_'] + ['ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']

cjj_list = [cho_list, jung_list, jong_list]
def word_to_jamo(token):
    # 유니코드상 한글이 아닌 토큰데이터가 있으면 자모분리 안함
    for word in token:
        if ord(word) < ko_unicode[0] or ord(word) > ko_unicode[1]:
            return token
    
    jamo_str = ''
    for koword in token:
        # 한글단어(koword)를 초성/중성/종성으로 분리
        # 한글 유니코드 : (초성*(21*28) + 중성*28) + 종성 + 0xAC00
        ko_code = ord(koword) - ko_unicode[0]
        
        cho_idx = ko_code // (21 * 28)
        jung_idx = (ko_code % (21 * 28)) // 28
        jong_idx = ko_code % 28
        
        cho = cjj_list[0][cho_idx]
        jung = cjj_list[1][jung_idx]
        jong = cjj_list[2][jong_idx]
        
        jamo_str += cho + jung + jong

    return jamo_str
def jamo_to_word(jamo_seq):
    word_str = ""
    # 자모로 분리된 데이터는 무조건 3의 배수가 됨
    # 즉, 길이가 3의 배수가 아닌건 원복 처리 안함
    if len(jamo_seq) % 3 != 0:
        return jamo_seq
    else:
        # 자모 시퀀스를 3개 단위로 끊어서 리스트화
        jamo_list = [jamo_seq[i:i+3] for i in range(0, len(jamo_seq), 3)]

        # 자모 시퀀스는 [초성, 중성, 종성]의 jamo_word로 리스트화됨
        for jamo_word in jamo_list:
            # 단어를 구성하는 jamo가 
            # 초/중/종성 리스트에 없는 이상한 단어면 걸러냄
            for idx, jamo in enumerate(jamo_word):
                if jamo not in cjj_list[idx]:
                    return jamo_seq

            # 자모의 초/중/종성의 index값을 구함
            cho_idx = cjj_list[0].index(jamo_word[0])
            jung_idx = cjj_list[1].index(jamo_word[1])
            jong_idx = cjj_list[2].index(jamo_word[2])
            
            # 한글 유니코드 : (초성*(21*28) + 중성*28) + 종성 + 0xAC00
            ko_code = cho_idx*(21*28) + jung_idx*28 + jong_idx + ko_unicode[0]
            # 유니코드 기반으로 한글 단어 복원
            ko_word = chr(ko_code)
            
            word_str += ko_word
    
    return word_str

위 코드를 실행한 결과는 아래와 같다.

위 함수는 타 라이브러리인 한글 단어 \rightarrow 자모 분리 라이브러리가 오래되서 임의로 필자가 함수를 설계한 것이며

https://github.com/bluedisk/hangul-toolkit

여러 라이브러리를 참조해서 커스텀 함수를 설계한 것임을 밝힌다.

따라서 원본 라이브러리를 조합해서 사용하는걸 권장한다.


3.2 FastText 한글 실습 준비

이번에 FastText로 한글 데이터에 대한
Text Classification을 수행하는 대상 데이터셋은

https://github.com/bab2min/corpus/tree/master

위 깃허브에 업로드된 네이버 쇼핑 리뷰 데이터를 사용하고자 한다.

3.2.1 데이터 전처리

데이터셋 다운로드

import urllib.request

# 파일 다운로드 URL (raw 링크)
url = 'https://raw.githubusercontent.com/bab2min/corpus/master/sentiment/naver_shopping.txt'

# 파일을 저장할 경로 및 이름
save_path = 'naver_shopping.txt'

urllib.request.urlretrieve(url, filename=save_path)
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
raw_data = pd.read_table(save_path,
                         header=None, #첫줄을 헤더로 사용안함,
                         # 헤더가 없으니 컬럼 헤더 지정
                         names=['점수', '리뷰'])

데이터셋을 다운로드 한 뒤 파일 구성을 살펴보면 위와 같다.

데이터셋 전처리 - 결측치&중복치 제거

# 컬럼별 결측치 cnt값을 모두 더한 값 (정수형 데이터)
missing_data = raw_data.isna().sum().values.sum()

# 결측치 정보가 0이면 결측치가 없으니 아래 함수가 실행안됨
if missing_data != 0:
    raw_data.dropna(how='any', inplace=True)

# 중복치 제거
raw_data.drop_duplicates(subset='리뷰',
                         keep='first',
                         inplace=True)

결측치& 중복치 약 100여개 데이터 제거

데이터 전처리 - 정규표현식

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['리뷰'] = raw_data['리뷰'].apply(regex_sub)

이전 포스트 1. NLP-Text 전처리 : 현대 벡터화(Word Embedding) (2) - AI 핵심기술 강의 복습에서

한글 띄어쓰기를 적용했었는데

적용 결과가 썩 마음에 들지 않는다.
(띄어쓰기를 하면서 데이터가 깨지거나 이상한 문자열이 생성되서 나중에 텐서 데이터셋 만들때 열받는 상황이 발생한다...)

따라서 한글 띄어쓰기는 적용하지 아니한다.


3.2.2 텍스트 전처리 - 1차

# 데이터프레임의 항목을 분리 후 리스트 타입으로 변경
raw_x_data = raw_data['리뷰'].values.tolist()
raw_y_label = raw_data['점수'].values.tolist()

텍스트 전처리 - 토큰화

from mecab import MeCab #한글 단어 토크나이저
from tqdm import tqdm

#mecab 형태소 분석기 인스턴스화
word_tokenizer = MeCab()

# document 컬럼에 대한 워드 토크나이징 수행
def tokenize(x_data, word_tokenizer):
    tokenized_sentences = list()

    for sent in tqdm(x_data):
        tokenized_sent = word_tokenizer.morphs(sent)
        tokenized_sentences.append(tokenized_sent)

    return tokenized_sentences
# 토큰화 수행
tokenized_x_data = tokenize(raw_x_data, word_tokenizer)

불용어 제거

import gdown

# 구글 드라이브에 업로드된 stopword.txt 파일 ID
file_id = '1-KtRjx2HBVuqP99kN8tZTiND7oRM6DIO'
# 파일 다운로드 링크 생성
url = f'https://drive.google.com/uc?id={file_id}'
# 'stopword.txt'파일을 다운로드한 뒤 저장할 경로 지정(파일명도 함께)
stopword = './data/kr_stopword_list.txt'
# 파일 다운로드
gdown.download(url, stopword, quiet=True)

# 불용어 단어장 stopword.txt를 열람 후 리스트 변수화
with open(stopword, 'r', encoding='utf-8') as file:
    stopword_list = file.read().splitlines()
# 리스트 컴프리핸션을 적용하여 빠르게 불용어를 제거하는 함수
def remove_stopword(tokenized_data, stopword):
    return [[word for word in sent if word not in stopword] 
            for sent in tokenized_data]
# 토큰화 처리한 '기사 본문' 데이터셋의 불용어 제거
r_t_x_data = remove_stopword(tokenized_x_data, stopword_list)


토큰데이터 \rightarrow 자모 분리

FastText를 수행하기 위해서는 입력되는 토큰 데이터(불용어제거 수행)를 jamo data으로 더 쪼갠 다음
쪼갠 자모로 정수(원핫)인코딩을 해야한다.

사실상 문자열 길이만 늘어나는거지
내부 데이터는 그대로 보존되는 것을 기억하자

jamo_x_data = []
for sent in tqdm(tokenized_x_data):
    temp = []
    for word in sent:
        jamo = word_to_jamo(word)
        temp.append(jamo)
    jamo_x_data.append(temp)

리스트 컴프리핸션으로 멋 부리려다 머리아파서 포기..

자모 분리가 잘 되었는지 확인은
위 코드로 검증을 수행한다.


3.2.3 데이터셋 분리

from sklearn.model_selection import train_test_split

# 훈련데이터셋(60%) 그 외 데이터셋(40)로 나누는 작업 수행
# 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.4, random_state=42, stratify=raw_y_label
)

# 그 외 데이터셋을 반반으로 Val, Test로 나눔
x_val, x_test, y_val, y_test = train_test_split(
    x_etc, y_etc, test_size=0.5, random_state=42, stratify=y_etc
)


3.2.4 텍스트 전처리 - 2차

데이터셋을 훈련/검증/평가용으로 분리했고
클래스 비율은 유지해서 분리하도록 인자를 넣었는데
훈련 데이터셋의 클래스 비율은 조금 이상하다?

뭐 자잘한 오류니 넘어가고
단어장 만들기를 시작하자.

단어장 만들기

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)


희소단어 정리

#등장 빈도가 높은 단어 순으로 정렬하기
vocab = sorted(word_counts, key=word_counts.get, reverse=True)

#등장 빈도가 높은 단어만 인덱싱 하기
vocab_size = total_vocab_cnt - rare_vocab_cnt
vocab = vocab[:vocab_size]

스페셜 토큰 추가

# 특수단어를 포함시켜 {단어:인덱스} 딕셔너리 생성하기
# 포함시킬 특수단어는 `<PAD>`, `<UNK>`으로 
# <PAD> : 0, <UNK> : 1 순으로 특수단어는 맨 앞에 위치하기
word_to_idx = {'<PAD>' : 0, '<UNK>' : 1}

for idx, word in enumerate(vocab):
    word_to_idx[word] = idx + 2


정수인코딩

# 단어를 정수 인덱싱 규칙으로 정수 인덱싱 수행하기
def text_to_sequences(tokenized_data, word_to_idx):
    encoded_data = [] #리턴해야할 정수 인코딩 결과값

    for sent in tokenized_data:
        idx_sequence = [] #단어장 리스트에서 idx를 찾아서 여기에 입력
        
        for word in sent:
            try: #word_to_idx에서 단어를 찾은 뒤 해당 단어의 인덱스(숫자)를 입력
                idx_sequence.append(word_to_idx[word])
            except KeyError: # word_to_idx 딕셔너리에 없는 키(단어)등장시 UNK로 인덱싱
                idx_sequence.append(word_to_idx['<UNK>'])
        
        #문장 내 단어를 모두 정수로 변환한 후에 이를 리턴값(리스트)에 입력
        encoded_data.append(idx_sequence)
    
    return encoded_data
# 데이터셋의 정수 인코딩 수행
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)


문장 패딩

max_len = 50 #문장의 최대 길이를 지정하기 위한 Th값

# x_data 항목을 문장패딩하기 위한 코드
def pad_seq_x(x_data, max_len):
    features = np.zeros((len(x_data), max_len), dtype=int)

    for idx, sent in enumerate(x_data):
        if len(sent) != 0: #예외처리구문
            features[idx, :len(sent)] = np.array(sent)[:max_len]
    
    return features
# 데이터셋의 문장 패딩(정수인코딩의 완료)
padded_x_train = pad_seq_x(e_x_train, max_len)
padded_x_val = pad_seq_x(e_x_val, max_len)
padded_x_test = pad_seq_x(e_x_test, max_len)

여기까지 수행하면 얼추 텍스트 전처리는 끝난것이다.


3.3 FastText - 임베딩레이어 학습

학습 데이터셋 설정

학습 데이터셋은 자모 분리된 데이터를 활용하여
word2vec, FastText 두개의 모델을 학습시킨다.

# Word2Vec 및 FastText 학습에 사용할 데이터: 
# 원본 데이터셋의 토큰화 후 불용어 제거를 수행한 데이터터
# 에다가 단어 -> 자모 분리를 수행한 데이터
word2vec_doc = jamo_x_data

word2vec 및 FastText 학습

from gensim.models import Word2Vec

# word2Vec : CBow 모델 학습
CB_model = Word2Vec(
    sentences=word2vec_doc,
    vector_size = 100, # 임베딩 차원은 100으로 설정
    window = 5, # 논문의 최대 관심가질 주변단어 사이즈인 5~20
    min_count = threshold, # (3) 단어장에서 배제할 희소단어 빈도 기준
    workers= -1, # 학습에 참여할 프로세스 개수 (최대로 설정)
    sg = 0 # CBoW 방식으로 학습 수행
)

# word2Vec : skip-gram 모델 학습
SG_model = Word2Vec(
    sentences=word2vec_doc,
    vector_size = 100, # 임베딩 차원은 100으로 설정
    window = 5, # 논문의 최대 관심가질 주변단어 사이즈인 5~20
    min_count = threshold, # (3) 단어장에서 배제할 희소단어 빈도 기준
    workers= -1, # 학습에 참여할 프로세스 개수 (최대로 설정)
    sg = 1 # Skip-gram 방식으로 학습 수행
)
from gensim.models import FastText

FT_model = FastText(
    sentences=word2vec_doc,
    vector_size = 100, # 임베딩 차원은 100으로 설정
    window = 5, # 논문의 최대 관심가질 주변단어 사이즈인 5~20
    min_count = threshold, # (3) 단어장에서 배제할 희소단어 빈도 기준
    workers= -1, # 학습에 참여할 프로세스 개수 (최대로 설정)
    sg = 1, # Skip-gram 방식으로 학습 수행
    # FastText의 N-gram 범위 설정(3~6)
    min_n=3, max_n=6
)

학습 결과 비교

임의의 단어를 FastTextWord2vec에 입력하여 유사한 단어벡터를 어떻게 출력하는지 확인해보자

# 유사한 단어벡터를 확인하기 위한 샘플
sim_text = '벗겨짐'
sim_jamo = word_to_jamo(sim_text)

초성/중성/종성으로 분리한 jamo 데이터로 학습시켜도 꽤 유의미한 단어간 유사성정보를 벡터화 하는 것을 확인할 수 있다.


임베딩 레이어 재조정

# 단어장 크기 : 17591 
vocab_size = len(word_to_idx) 
# 임베딩 차원 크기 : 100
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
# CBoW 방식으로 학습된 임베딩 레이어의 조정
my_CB_embedding = build_my_embed(word_to_idx, CB_model.wv)
# Skip-gram 방식으로 학습된 임베딩 레이어의 조정
my_SG_embedding = build_my_embed(word_to_idx, SG_model.wv)

# FastText 방식으로 학습된 임베딩 레이어 조정
my_FT_embedding = build_my_embed(word_to_idx, FT_model.wv)

임베딩 레이어까지 재조정을 완료했으니

한글 데이터셋에 대한 Text Classification을 수행해보자


3.4 FastText 학습

텐서 자료형으로 변환

import torch
import torch.nn.functional as F

# 정수(원핫)인코딩된 x_data를 텐서 자료형으로 변환
t_x_train = torch.tensor(padded_x_train, dtype=torch.int64)
t_x_val = torch.tensor(padded_x_val, dtype=torch.int64)
t_x_test = torch.tensor(padded_x_test, dtype=torch.int64)

# Y_label 데이터를 텐서 자료형으로 변환 (list -> tensor)
t_y_train = torch.tensor(y_train, dtype=torch.int64)
t_y_val = torch.tensor(y_val, dtype=torch.int64)
t_y_test = torch.tensor(y_test, dtype=torch.int64)

from torch.utils.data import TensorDataset, DataLoader

BS = 256 # Batch_size는 통일

# 임베딩 레이어가 있는 언어모델에 입력할 데이터셋 + 데이터로더
oh_trainset = TensorDataset(t_x_train, t_y_train)
oh_trainloader = DataLoader(oh_trainset, shuffle=True, batch_size=BS)

oh_valset = TensorDataset(t_x_val, t_y_val)
oh_valloader = DataLoader(oh_valset, shuffle=False, batch_size=BS)

oh_testset = TensorDataset(t_x_test, t_y_test)
oh_testloader = DataLoader(oh_testset, shuffle=False, batch_size=BS)

주요 파라미터 정리

여기서 클래스 개수는 6으로 설정해야 한다
네이버 쇼핑 리뷰 데이터를 보면
1, 2, 4, 5 4개의 데이터만 존재하지만
클래스 개수는 0부터 시작해서 연속된 숫자로 지정해야 하기에
빠진 데이터 0, 3을 포함시켜서 6으로 설정한다
(이거 몰라서 헤멧음...)


모델 설계

import torch.nn as nn

class emb_simpleNet(nn.Module):
    def __init__(self, vocab_size, emb_matirx, embed_dim, hidden_dim, num_label):
        super(emb_simpleNet, self).__init__()

        self.embed = nn.Embedding(vocab_size, embed_dim)

        # 사전 훈련된 임베딩 매트릭스를 붙여넣음
        self.embed.weight = nn.Parameter(
            torch.tensor(emb_matirx, dtype=torch.float32))
        # 붙여넣은 Pretrained 임베드 레이어만 Freeze하고 싶을때는 False
        self.embed.weight.requires_grad = True

        self.fcn1 = nn.Linear(embed_dim, hidden_dim)
        self.relu = nn.ReLU()
        self.fcn2 = nn.Linear(hidden_dim, num_label)

    def forward(self, x):
        x = self.embed(x)
        # 단어의 유사성 의미정보는 뭉게버린다.
        x = torch.mean(x, dim=1)

        x = self.fcn1(x)
        x = self.relu(x)
        x = self.fcn2(x)
        
        return x
# 모델 객체화 수행
CB_model = emb_simpleNet(VOCAB_SIZE, my_CB_embedding, EMB_DIM, HIDE_DIM, NUM_CLASS)

SG_model = emb_simpleNet(VOCAB_SIZE, my_SG_embedding, EMB_DIM, HIDE_DIM, NUM_CLASS)

FT_model = emb_simpleNet(VOCAB_SIZE, my_FT_embedding, EMB_DIM, HIDE_DIM, NUM_CLASS)

코드는 영어버전용 FastText랑 거의 같으나 한번 복습차원에서 다 복붙하자.


학습 준비

# GPU사용 가능 유/무 확인
device = torch.device("cuda" if torch.cuda.is_available() else 'cpu')

CB_model.to(device)
SG_model.to(device)
FT_model.to(device)
import torch.optim as optim
# 로스함수 및 옵티마이저 설계
criterion = nn.CrossEntropyLoss()

LR = 0.001 # 러닝레이트는 통일

CB_optimizer = optim.Adam(CB_model.parameters(), lr=LR)
SG_optimizer = optim.Adam(SG_model.parameters(), lr=LR)
FT_optimizer = optim.Adam(FT_model.parameters(), lr=LR)
# 사전에 모듈화 한 학습/검증용 라이브러리 import
from C_ModelTrainer import ModelTrainer

num_epoch = 15 #총 훈련/검증 epoch값

ES = 3 # 디스플레이용 에포크 스텝
# BC_mode = True(이진), False(다중)
# aux = 보조분류기 유/무
# wandb = 완디비에 연결 안하면 None
trainer = ModelTrainer(epoch_step=ES, device=device, BC_mode=False, aux=False)
#학습/검증 중간 정보 저장
keys = [['CBoW', 'Skip-gram', 'FastText'], ['loss', 'acc']]
history = {key: {'loss': [], 'acc': []} for key in keys[0]}

models = {'CBoW' : CB_model, 
          'Skip-gram' : SG_model, 
          'FastText' : FT_model}

train_loaders = {'CBoW' : oh_trainloader, 
                 'Skip-gram' : oh_trainloader, 
                 'FastText' : oh_trainloader}

val_loaders = {'CBoW' : oh_valloader, 
               'Skip-gram' : oh_valloader, 
               'FastText' : oh_valloader}

optimizers = {'CBoW' : CB_optimizer, 
              'Skip-gram' : SG_optimizer, 
              'FastText' : FT_optimizer}

학습 및 성능비교

for step in range(len(keys[0])):
    epoch = 0 #에포크 초기화
    
    for epoch in range(num_epoch):
        #훈련 손실&성능지표 반환
        train_loss, train_acc = trainer.model_train(
            models[keys[0][step]], train_loaders[keys[0][step]],
            criterion, optimizers[keys[0][step]], epoch
            )
        #검증 손실&성능지표 반환
        val_loss, val_acc = trainer.model_evaluate(
            models[keys[0][step]], val_loaders[keys[0][step]],
            criterion, epoch
            )
        
        #손실&성능지표를 history에 저장
        history[keys[0][step]][keys[1][0]].append((train_loss, val_loss))
        history[keys[0][step]][keys[1][1]].append((train_acc, val_acc))

        # Epoch_Step(ES)일때 print하기
        if (epoch+1) % ES == 0 or epoch == 0:
            print(f"현재 훈련중인 모델: {keys[0][step]}")
            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----모델{keys[0][step]} 훈련 종료----\n")


import matplotlib.pyplot as plt

# 모델 목록과 메트릭 목록
models = keys[0]
metrics = keys[1]

# 데이터 추출을 위한 딕셔너리 초기화
extracted_data = {}

for model in models:
    extracted_data[model] = {}
    for metric in metrics:
        # 각 모델의 메트릭 데이터 추출
        metric_data = history[model][metric]
        # 훈련 및 검증 값 분리
        train_values = [tup[0] for tup in metric_data]
        val_values = [tup[1] for tup in metric_data]
        extracted_data[model][f'train_{metric}'] = train_values
        extracted_data[model][f'val_{metric}'] = val_values
# 손실 그래프 생성
fig, axes = plt.subplots(3, 2, figsize=(12, 16))
axes = axes.flatten()  # 2차원 배열을 1차원으로 변환하여 인덱싱 쉽게 함

for idx, model in enumerate(models):
    ax = axes[idx*2]
    ax.plot(extracted_data[model]['train_loss'], label='Train Loss')
    ax.plot(extracted_data[model]['val_loss'], label='Validation Loss')
    ax.set_title(f'{model} Loss')
    ax.set_xlabel('Epoch')
    ax.set_ylabel('Loss')
    ax.legend()

for idx, model in enumerate(models):
    ax = axes[idx*2 + 1]
    ax.plot(extracted_data[model]['train_acc'], label='Train Accuracy')
    ax.plot(extracted_data[model]['val_acc'], label='Validation Accuracy')
    ax.set_title(f'{model} Accuracy')
    ax.set_xlabel('Epoch')
    ax.set_ylabel('Accuracy')
    ax.legend()

plt.tight_layout()
plt.show()
# 모든 모델의 손실과 정확도를 비교하는 그래프 생성
fig, axes = plt.subplots(2, 1, figsize=(10, 18))

# 모든 모델의 손실 그래프
ax = axes[0]
for model in models:
    ax.plot(extracted_data[model]['train_loss'], label=f'{model} Train Loss')
    ax.plot(extracted_data[model]['val_loss'], label=f'{model} Val Loss', linestyle='--')
ax.set_title('Training and Validation Loss for All Models')
ax.set_xlabel('Epoch')
ax.set_ylabel('Loss')
ax.legend()

# 모든 모델의 정확도 그래프
ax = axes[1]
for model in models:
    ax.plot(extracted_data[model]['train_acc'], label=f'{model} Train Acc')
    ax.plot(extracted_data[model]['val_acc'], label=f'{model} Val Acc', linestyle='--')
ax.set_title('Training and Validation Accuracy for All Models')
ax.set_xlabel('Epoch')
ax.set_ylabel('Accuracy')
ax.legend()

plt.tight_layout()
plt.show()


훈련/및 검증데이터에는 없는 리뷰점수 0, 3도 예측을 수행하다보니

전반적으로 성능이 많이 하락하긴 했지만

이번 포스트의 목적은 절대평가가 아닌 상대평가이니

성능은 역시 영어 FastText기반 Text Classification과 동일하게

FastText > Skip-gram > CBoW 순으로 성능이 좋게 나옴을 확인할 수 있다.


드디어 텍스트 전처리 파트가 끝났다.

텍스트 전처리 - 벡터화 방법론으로 GloVeELMO 등이 있지만

GloVeDTM을 기반으로 임베딩을 어쩌구 저쩌구 하는거니까 넘어가고...

ELMO는 RNN을 배우고 나서 공부를 해야한다.

이제는 진짜 언어모델을 공부하자...

자연어 전처리에 7개 포스트 쓴거면 많이썻다...

profile
자율차 공부중

0개의 댓글