[파이토치 딥러닝 프로젝트] Text Classification Part 5. 국민 청원 분류하기
본 프로젝트의 목표는 간단히 말해서
청원 참여 인원이 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()