얼렁뚱땅 파이토치 딥러닝 프로젝트 모음집

[파이토치 딥러닝 프로젝트] Text Classification Part 5. 국민 청원 분류하기

MOSTAR 2022. 8. 24. 20:40

본 프로젝트의 목표는 간단히 말해서

청원 참여 인원이 1000명이 넘을 거 같은 것을 분류하는 것이다.

 

프로젝트의 전체 흐름은

1. 데이터 수집(웹크롤링)

2. 데이터 전처리(공백문자 및 특수문자 제거)

3. 토크나이징 및 변수 생성

4. 단어 임베딩

5. 실험 설계(데이터 분할)

6. Text CNN

으로 진행된다. 

 

하지만, 1 데이터 수집은 청원 홈페이지의 개편으로 다시 코드를 처음부터 짜야해서 ..ㅎ_ㅎ ;

따로 데이터 수집을 하지않고, 대신 공식 Github에 있는 데이터를 사용하도록 하겠다.

 

여기서 다운 받으면 됩니당

https://github.com/deep-learning-with-projects/deep-learning-with-projects

 

GitHub - deep-learning-with-projects/deep-learning-with-projects

Contribute to deep-learning-with-projects/deep-learning-with-projects development by creating an account on GitHub.

github.com

 

본 책에서는 Beautifulsoup 패키지를 이용해서 데이터를 수집하였다.

(갠적으로 나는 Selenium을 이용해서 크롤링 하는 걸 더 좋아한당 ㅎㅎ)

 

 

데이터 전처리

- 크롤링을 하지 않고 csv 파일을 사용한 것이기에 어떻게 생겼나 보았다.

- 아래 데이터를 보면 사람들이 직접 글을 쓰는 부분인 content와 title 항목에 '\n'과 같은 공백문자, 그리고 *과 같은 특수문자가 섞여 있는 것을 확인 할 수 있다.

import pandas as pd

df = pd.read_csv('crawling.csv')
display(df.head())

 

토크나이징 및 변수 생성

- remove_white_space : 공백을 제거해주는 함수

- remove_white_char :특수문자 및 영어를 제거해주는 함수

- re 패키지의 경우 정규 표현식을 이용하여 데이터를 특정 형태로 변환, 치환, 분할 등을 해주어 텍스트 데이터 전처리시 많이 사용하는 패키지이다.

 

- re.sub('[패턴]', 대체문자, what) 이라고 있을 때, 해당 코드의 경우, what에 있는 데이터에서 패턴에 맞는 텍스트를 대체문자로 변환해준다.

- ^의 의미는 '제외하다'이고 +의 의미는 '한 개 이상'이라는 것이다.

- 따라서 아래 코드를 해석해보면, ㄱ부터 l까지 그리고 가나다..-힣까지 그리고 0~9를 제외하고 한개이상의 문자가 있다면 해당 문자를 지우기 위해 ''으로 바꿔주어라 라는 의미다.

text = re.sub('[^ㄱ-ㅣ 가-힣 0-9]+', ' ',str(text))

- 영어도 제거를 해주는데 영어를 제거해주는 이유는 한국 Word Embedding Vector를 생성하기 위해서이다.

import re

def remove_white_space(text) :
    text = re.sub(r'[\t\r\n\f\v]', ' ', str(text))
    return text

def remove_special_char(text) :
    text = re.sub('[^ㄱ-ㅣ 가-힣 0-9]+', ' ',str(text))
    return text

df.title = df.title.apply(remove_white_space)
df.title = df.title.apply(remove_special_char)
df.content = df.content.apply(remove_white_space)
df.content = df.content.apply(remove_special_char)

display(df.head(5))

 

- Tokenize 란 문장을 의미 단위에 맞춰 나누는 것를 의미한다.

- konlpy 형태로 분석기 패키지로, 한국어 Tokenizing을 지원하는 Okt, Kkma, Komoran 등의 클래스가 존재한다. 본 프로젝트에서는 Okt를 이용하였다.

- 제목 데이터의 경우 형태소 단위(문자에서 의미를 지닌 최소 단위를 의미함)로 토크나이징하고, 본문의 경우 학습 효율 및 키워드 중심 분석을 수행하기 위해 '명사' 단위로 토크나이징을 진행하였다.

- title_token, content_token 이라는 이름의 열을 가진 파생 변수가 생긴 것을 확인 가능하다

from konlpy.tag import Okt

okt = Okt()

df['title_token'] =df['title'].apply(okt.morphs)
df['content_token'] =df['content'].apply(okt.nouns)

display(df.head(5))

 

- 최종 input 값으로 사용할 것은 title 토큰과 content 토큰을 합친 것이기 때문에, 이를 합친 'token_final'이라는 칼럼을 만들어준다.

- Series.astype(str)은 해당 Series를 str 타입으로 바꾸어라 라는 의미다.

- Count 칼럼에 있는 것을 보면 사람 눈에는 숫자로 보이겠지만, 컴퓨터 한테는 2,127이라는 일종의 문자이다. 따라서 이를 int형으로 인식시켜주기 위해 컴마를 제거하고, 다시 int형으로 형변환을 해준 것이다.

- 그러고 최종 Label로 쓸 것은 1000 이상인지 아닌지 이기 때문에, 1000이상이면 'Yes' 아니면 'No'라는 파생변수를 만들어 준다.

- df_drop에 x와 y로 쓸 것만 놔둔다.

df['token_final'] = df.title_token + df.content_token
df['count'] = df['count'].astype(str)
df['count'] = df['count'].str.replace(',','')
df['count'] = df['count'].astype(int)

df['label'] = df['count'].apply(lambda x : 'Yes' if x>= 1000 else 'No')

df_drop = df[['token_final', 'label']]

 

단어 임베딩

-  ['꽃', '병'] 과 같이 실제 텍스트를 이용해서 모델을 학습 시킬 수 없다  따라서, 컴퓨터가 이해할 수 있도록 숫자형태로 바꾸어줘야 할 필요성이 필요하고 이를 위해 필요한 것이 단어 임베딩이다.


1. One-Hot encoding
- 가장 기초적인 embedding 방법으로 각각의 토큰들에 다 하나의 인덱스를 부여하는 것을 의미한다.
- 문제점1 : 단어의 의미가 담긴 embedding 방법이 아니다. 따라서, 단어간의 유사성을 반영하지 못함(사고, 사건이 비슷한 단어지만 컴퓨터는 그것을 알 수 없다)
- 문제점2 : 문장이 몇개 안된다면 모르지만, 실제 뉴스 등의 데이터를 적용하기 위해서는 단어들이 매우 많기 때문에 인덱스가 무진장 많아 질 수 있다. 또한, 한 단어마다 하나의 index에 말고는 0 (ex)[0,0,0,0,0,..,1]으로 구성되어 있기 때문에 비효율적이다


2. Word2Vec
- 단어의 의미와 유사도를 반영할 수 있도록 단어를 하나의 Vector로 표현하고자 하는 방법이다(왕-남자+여자=여왕). 즉 원핫인코딩의 단점을 보완 하고자 하며, 특정 차원으로 단어를 표현하기 때문에, 원핫보다 메모리 측면에서 더 괜찮다.
- Word2Vec의 가정은 한 토큰의 의미 정보는 주변 토큰이 정보로 표현된다는 것이다. 다시말해, 특정 토큰 근처에 있는 토큰들은 비슷한 위치의 벡터로 표현된다
- Word2Vec의 학습은 단어의 가중치를 학습하는 것을 의미한다. 따라서, 학습 결과는 가중치 행열이다.
- 가중치 행열 학습 방법 : CBOW, Skip-GRAM (두가지 다 Window 형태로 근처 일부분만 본다)
- CBOW는 윈도우 크기만큼 주변 토큰을 벡터로 변환하여, 중심에 있는 토큰을 맞추는 방법이다
- Skip-GAN은 중심 토큰을 백터로 변환하여 윈도우만큼 주변 토큰을 맞춘는 것을 의미한다

- CBOW 방식으로 학습 시킬 때 아래와 같이 학습된다 그래서 Word2Vec을 통해 최적의 가중치 W를 구하는 것을 목표로 한다.

 

- 아래 코드는 Word2Vec 학습 시키는 코드다.

- sg = 1 : Skip-Gram      0 : CBOW
- min_count : 일정 횟수 이상 등장하지 않는 토큰은 임베딩 벡터에서 제외
- gensim 버전 차이로 기존 size -> vector_size

 

- 학습을 시킨 후 '음주운전'과 유사한 단어를 뽑아 보면 아래와 같이 나온다.

from gensim.models import Word2Vec
embedding_model = Word2Vec(df_drop['token_final'], sg = 1, vector_size = 100, window = 2, min_count = 1)
print(embedding_model)

model_result = embedding_model.wv.most_similar('음주운전')
print(model_result)

 

- 학습 시킨 가중치를 저장하고 불러와서 다시 실험해도 위에와 동일한 결과가 나온다.

from gensim.models import KeyedVectors

embedding_model.wv.save_word2vec_format('petitions_tokens_w2v')

loaded_model = KeyedVectors.load_word2vec_format('petitions_tokens_w2v')
model_result = loaded_model.most_similar('음주운전')
print(model_result)

 

실험 설계

- Random으로 80 % 학습으로 나머지 20%은 Validation으로 사용하기 위해 나누어 저장한다.

from numpy.random import RandomState

rng = RandomState()
tr = df_drop.sample(frac=0.8, random_state=rng)
val = df_drop.loc[~df_drop.index.isin(tr.index)]

tr.to_csv('train.csv', index=False, encoding='utf-8-sig')
val.to_csv('validation.csv', index=False, encoding='utf-8-sig')

 

- Field 토크나이징 및 단어장 생성 등을 지원함 
- 앞선 단계에서 이미 Tokenizing을 한 번 했기 때문에 추가적인 것은 필요 없음
- 하지만, Field 클래스에 토큰을['토큰 1', '토큰 2'] 이렇게 해줘야 하는데
- 현재 '[토큰 1, 토큰 2]' 이렇게 전체를 하나의 토큰으로 볼 가능성이 존재하기 때문에 '[' 이랑 ']'을 제거해줌
- 그리고 Split하는 단위를 ', '로 보고 str을 ', '로 나누어서 사용함

 

-  Input : 단어의 흐름이 순서가 있는 것이기 때문에 Sequential = True(Default)

- output : yes/No 즉 순서가 있는게 아니라 단일로 딱 있기 때문에, Sequential = False

import torchtext
from torchtext.legacy.data import Field

def tokenizer(text) :
    text = re.sub('[\[\]\']','',str(text))
    text = text.split(', ')
    return text

TEXT = Field(tokenize=tokenizer)
LABEL = Field(sequential = False)

 

- 학습과 테스트에 쓰기 위해서 다음과 같이 데이터 정리를 한다.

from torchtext.legacy.data import TabularDataset

train, validation = TabularDataset.splits(
    path='',
    train = 'train.csv',
    validation = 'validation.csv',
    format = 'csv',
    fields =[('text',TEXT),('label',LABEL)],
    skip_header = True
)

print('Train : ', train[0].text , train[0].label)
print('Validation : ', validation[0].text, validation[0].label)

 

- 앞서서 학습 했던 임베딩 벡터를 vectors에 저장한다.

- vocab은 단어장인데 이 단어장은 '강아지 : 1' 이런 거다. 이거를 미리 학습했던 임베딩 벡터 만들 때랑 같은 순서로 만들어준다.

- Text.vocab.vectors는 미리 학습된 임베딩 벡터를 의미한다.

import torch
from torchtext.vocab import Vectors
from torchtext.legacy.data import BucketIterator

vectors = Vectors(name='petitions_tokens_w2v')

TEXT.build_vocab(train, vectors=vectors, min_freq = 1, max_size = None)
LABEL.build_vocab(train)

vocab = TEXT.vocab
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

train_iter, validation_iter = BucketIterator.splits(
    datasets = (train, validation),
    batch_size = 8,
    device = device,
    sort = False
)

print('임베딩 벡터의 개수와 차원 : {}'.format(TEXT.vocab.vectors.shape))

 

TextCNN

- 설계한 TextCNN은 아래와 같은 단계로 이루어 지도록 구성한다.

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

class TextCNN(nn.Module) :
    def __init__(self, vocab_built, emb_dim, dim_channel, kernel_wins, num_class) :
        super(TextCNN, self).__init__()

        self.embed = nn.Embedding(len(vocab_built), emb_dim)
        self.embed.weight.data.copy_(vocab_built.vectors)
        self.convs = nn.ModuleList([nn.Conv2d(1, dim_channel, (w, emb_dim)) for w in kernel_wins])
        self.relu = nn.ReLU()
        self.dropout = nn.Dropout(0.4)
        self.fc = nn.Linear(len(kernel_wins)*dim_channel, num_class)
        self.softmax = nn.Softmax()

    def forward(self, x) :
        emb_x = self.embed(x)
        print(emb_x.size())
        emb_x = emb_x.unsqueeze(1)

        con_x = [self.relu(conv(emb_x)) for conv in self.convs]

        pool_x = [F.max_pool1d(x.squeeze(-1), x.size()[2]) for x in con_x]

        fc_x = torch.cat(pool_x, dim=1)
        fc_x = fc_x.squeeze(-1)
        fc_x = self.dropout(fc_x)

        logit = self.fc(fc_x)
        logit = self.softmax(logit)

        return logit

 

- target에서 1을 빼주는 것은 해보니까 1 -> No, 2 -> Yes 로 되어있었다 따라서 0 -> No, 1->Yes가 되도록 바꿔주는ㄱ ㅓㅅ이다.

- text.size() -> (batch_size, batch내의 글 중 최장 길이) => TextCNN 모델에서 word2vec의 임베딩을 해서 최종 인풋으로 사용함

- (TextCNN의 emb_x의 사이즈는 그래서 [batch_size, 1, batch내의 글 중 최장 길이,100] 임) 1 -> channel

def train(model, device, train_itr, optimizer) :
    model.train()
    corrects, train_loss = 0.0, 0

    for batch in train_itr :
        text, target = batch.text, batch.label
        # 해당 배치에서 
        text = torch.transpose(text,0,1)
        target.data.sub_(1)
        text, target = text.to(device), target.to(device)

        optimizer.zero_grad()
        logit = model(text)

        loss = F.cross_entropy(logit, target)
        loss.backward()
        optimizer.step()

        train_loss += loss.item()
        result = torch.max(logit,1)[1]
        corrects += (result.view(target.size()).data == target.data).sum()
    
    train_loss /= len(train_itr.dataset)
    accuracy = 100.0 * corrects / len(train_itr.dataset)

    return train_loss, accuracy
    
def evaluation(model, device, itr) :
    model.eval()
    corrects, test_loss = 0.0, 0

    for batch in itr :
        text = batch.text
        target = batch.label
        text = torch.transpose(text, 0, 1)
        target.data.sub_(1)
        text, target = text.to(device), target.to(device)

        logit = model(text)
        loss = F.cross_entropy(logit, target)

        test_loss += loss.item()
        result = torch.max(logit,1)[1]
        corrects += (result.view(target.size()).data == target.data).sum()

    test_loss /= len(itr.dataset)
    accuracy = 100.0 * corrects / len(itr.dataset)

    return test_loss, accuracy

 

- 학습 및 테스트를 진행한다.

model = TextCNN(vocab, 100, 10, [3, 4, 5], 2).to(device)
print(model)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
optimizer = optim.Adam(model.parameters(), lr=0.001)
best_test_acc = -1

for epoch in range(1, 20):
 
    tr_loss, tr_acc = train(model, device, train_iter, optimizer) 
    print('Train Epoch {} : Loss : {}, Accuracy : {}'.format(epoch, tr_loss, tr_acc))
    val_loss, val_acc = evaluation(model, device, validation_iter)
    print('Valid Epoch {} : Loss : {}, Accuracy : {}'.format(epoch, val_loss, val_acc))
    
    if val_acc > best_test_acc :
        best_test_acc = val_acc
        print('model save epoch {}, accuracy {}'.format(epoch, best_test_acc))
        torch.save(model.state_dict(), 'TextCNN_Best_Validation')
    
    print()