https://github.com/e9t/nsmc/
위 github 링크에서 네이버 영화 리뷰 데이터셋을 다운받을 수 있다. urllib.request를 이용해서 폴더 내로 영화 리뷰 모음을 txt파일로 저장한다. 파일 이름은 훈련 데이터, 테스트 데이터 각각 "ratings_train.txt"와 "ratings_test.txt"로 저장했다.
train_data = pd.read_table('ratings_train.txt')test_data = pd.read_table('ratings_test.txt')print('number of training data :', len(train_data))train_data[:5]
다운받은 데이터셋을 pandas 데이터프레임으로 만든다. 앞으로 진행할 데이터 전처리에서 pandas에 내장되어 있는 유용한 함수를 계속하여 사용할 예정이다.
현재 총 존재하는 data는 number of training data : 150000으로 확인할 수 있다.
밑 사진에서 데이터프레임의 상위 5개 데이터를 확인할 수 있다. train_data 데이터프레임의 'id', 'document', 'label'로 이루어져있다. label은 '부정'과 '긍정'의 0과 1만 존재한다.
2) Data Preprocessing
train_data['document'].nunique(), train_data['label'].nunique()train_data.drop_duplicates(subset=['document'], inplace=True)print('total number of sample :', len(train_data))
.nunique()를 이용해서, 'document' 열과 'label' 에서 중복된 데이터가 있는지 확인한다.
(146182, 2) 15만 개의 데이터 중에서 'document' 열 중복된 데이터를 제거한 샘플의 개수가 146182개가 됨을 뜻한다. 'label'의 열은 0 혹은 1의 값만을 가지므로, 2가 출력되는 것이다.
.drop_duplicates(subset=['document'], inplace=True)를 이용해서, 'document' 열에서 중복되는 값을 삭제한다. (inplace=True는 원본 데이터를 변경함을 의미한다)
그리고, 총 샘플의 개수를 확인하면, total number of sample : 146183으로 확인할 수 있다.
다음은 'document' 열에서의 Null 값의 유무를 알아보자. 밑 사진과 같이, 'document' 열에서 Null 값이 하나 존재함을 알 수 있다.
.dropna(how = 'any')를 이용하여 Null 값을 가진 샘플을 제거한다. (.dropna는 Null 값이 있는 을 삭제하는 함수다. 그리고, how = 'any'는 한 행에서 Null이 한 개라도 있으면 그 행을 삭제함을 의미한다. how = 'all'로 하면 모든 값이 Null이 되야 한다)
그리고, print(train_data.isnull().values.any())로 남아있는 Null 값을 확인하면 False를 출력함으로써, Null 값을 전부 제거 했음을 확인한다.
print('total number of sample :', len( train_data))print(train_data.groupby('label').size().reset_index(name ='count'))
중복값과 Null 값을 전부 제거한 데이터의 개수는 146182개다.
그리고, label 0과 1의 개수를 세면, '긍정'을 뜻하는 리뷰가 근소하게 더 많음을 알 수 있다.
import retext ='Hi !!! are you .... interested studying ??!!@@ ,, AI ?..'re.sub(r'[^a-z A-Z]', '', text)
네이버 영화 리뷰 데이터셋을 보면, 오직 한국어뿐만 아니라 수많은 문장부호와 기호들과 함께 이루어져있다. re 라이브러리를 import 하여 데이터프레임에서 한국어와 공백을 제외한 나머지를 제거해주도록 하자.
re.sub(r'[^a-z A-z]', ' ', text)에서 알파벳 소문자와 대문자, 공백을 제외한 나머지 text에서 날려준다. (^를 빼면, 알파벳과 공백을 모두 제거한 나머지를 보여준다. 그리고, r'[^a-z A-Z]'에서 r과 ' 사이를 띄어쓰기 하면 syntax error가 발생한다)
그렇다면, 한국어 같은 경우에는 범위를 어떻게 설정해야하는가. 이는 unicode를 참고하면 된다. 한글의 글자는 ㄱ~ㅎ, ㅏ~ㅣ로 이루어져있고, 한글의 완성형은 가~힣 까지 설정한다면, 모든 글자를 포함시킬 수 있게된다.
일단 str.replace를 이용하여 white space 공백으로만 이루어진 데이터를 empty value로 만들어준다. 그리고, empty value를 모두 Null로 바꾸어준 후에 앞서 했던 과정을 반복해준다.
밑 사진과 같이 새로운 Null 값이 789개나 생성된 것을 볼 수 있다.
그러면, 이제 전처리 후에 훈련용 데이터의 개수는 150000개 중에서 145393개가 남음을 알 수 있다. 테스트용 데이터에서도 위와 같은 전처리 과정을 똑같이 진행한다.
test_data.drop_duplicates(subset = ['document'], inplace=True)test_data['document']= test_data['document'].str.replace("[^ㄱ-ㅎ ㅏ-ㅣ 가-힣]", "")test_data['document']= test_data['document'].str.replace(" ", "")test_data['document'].replace('', np.nan, inplace=True)test_data = test_data.dropna(how ='any')print('Final total number of test_data: ', len(test_data))
전처리 후에 테스트용 샘플의 개수는 총 48210개이다.
3) Tokenize
토큰화를 거치기 전에 파이썬 한국어 처리 패키지인 KoNLPy를 설치해줘야 한다. 이는 바로 import 할 수 없고, 밑과 같이 pip를 이용하여 설치해줘야 한다. 하지만 그 이전에 JDK나 JPype가 우선시되어야 한다. 자세한 내용은 아래 링크를 참조하면 된다.
필자도 KoNLPy를 구동하는데에 있어서 큰 어려움을 겪었다. 이 경우에는 _jvmfinder.py에서 java_home = os.getenv("JAVA_HOME")라고 표시된 부분을 주석처리하고, 밑과 같이 편집한다.
java_home = "~ jdk 경로" #os.gentenv("JAVA_HOME")
또한, 콘솔창에서 pip install jpype1==0.7.0으로 jpype을 구버전으로 수정해주는 것도 도움이 될 것이다.
그리고, pip install nltk로 NLTK(Natural Learning ToolKit)도 이후에 필요하므로, 설치해줘야한다.
from konply.tag import Oktstopwords = ['의','가','이','은','들','는','좀','잘','걍','과','도','를','으로','자','에','와','한','하다']
이와 같이 복잡한 과정을 감수하면서 KoNLPy를 설치한 이유는 불용어를 제거하기 위해서다. 불용어 혹은 Stop word는 분석에 있어서 큰 의미가 없는 것을 말한다. 위와 같은 경우에는 여러 조사들이 이 불용어에 해당될 수 있다.
okt =Okt()okt.morphs('와 이 영화를 영화관에 내보냈네 차라리 뮤비를 만드는게 나을 것 같은데', stem=True)
KoNLPy에서 제공하는 형태소 분석기인 Okt를 이용해서 위 문장을 토큰화 해보자. 이 과정은 영어처럼 띄어쓰기 기준이 아닌 형태소를 기준으로 토큰화한다. (stem = True는 정규화를 의미한다. 위 예시 같은 경우에는 '이런'이 '이렇다'로 변환되었고, '만드는'이 '만들다'로 변환되었다)
tokenizer =Okt()X_train = []for sentence in train_data['document']: temp_X = okt.morphs(sentence, stem=True) temp_X = [word for word in temp_X ifnot word in stopwords] X_train.append(temp_X)print(X_train[:5])
위와 같이 Okt를 사용해서, 훈련용 데이터를 모두 토큰화 시켜준다. 이 과정은 꽤 시간이 소요된다. 그 이후에 상위 5개의 샘플을 출력해보자.
X_test = []for sentence in test_data['document']: temp_X = tokenizer.morphs(sentence, stem=True) temp_X = [word for word in temp_X ifnot word in stopwords] X_test.append(temp_X)
테스트용 데이터도 똑같이 토큰화 해준다.
4) Integer Encoding
vocab =FreqDist(np.hstack(X_train))print('Size of Vocabulary : {}'.format(len(vocab)))print('Frequency of 영화 : ', vocab['영화'])print('Frequency of 재밌 : ', vocab['재밌'])
NLTK에서 지원하는 FreqDist()를 이용하여 단어 집합(Vocabulary)를 생성한다. 단어를 key, 그리고 단어에 대한 빈도수가 값(value)로 저장된다. vocab에 단어를 입력한 그 빈도수를 반환해준다.
총 단어 집합의 개수는 46503개이다. 그 중 '영화'라는 말은 44383번, '재밌'이라는 말은 2번 쓰였다.
threshold =3total_cnt =len(vocab)# 총 단어 rare_cnt =0# 등장 빈도수가 낮은 단어 개수total_freq =0# 전체 단어 빈도수 총합rare_freq =0# 등장 빈도수가 낮은 단어의 빈도수 총합for key, value in vocab.items(): total_freq = total_freq + value#단어의 등장 빈도수가 threshold보다 작으면if(value < threshold): rare_cnt = rare_cnt +1 rare_freq = rare_freq + valueprint('Size of Vocab :', total_cnt)print('Rare Vocab which used under 3 times : ', rare_cnt)print('Ratio of Rare Vocab among whole Vocab : ', (rare_cnt/total_cnt)*100)print('Ratio of Rare Vocab Frequency among whole Vocab Frequency : ', (rare_freq/total_freq)*100)
vocab.items()에서 key와 value를 각각 불러와서 등장 빈도수가 3회 미만인 단어들이 데이터에서 어느정도 차지하는지 확인해보자.
등장 빈도가 2회 이하인 단어들은 단어 집합에서 약 54.8%를 차지하고 있다. 하지만, 실제로 등장 빈도로 차지하는 비중은 약 2% 밖에 되지 않는다. 그리하여, 이러한 단어들은 배제하고자 해도 자연어 처리에 있어서 무관할 것 같다.
전체 단어 집합의 개수에서 등장 빈도가 2회 이하인 단어들의 개수를 뺀 수를 새로운 단어 집합의 개수로 한다. 그리고, 등장 빈도 상위 단어들로 정렬하여 새로운 단어 집합을 만든다.
그러면, 현재 총 단어 집합의 개수는 21029개임을 확인할 수 있다.
이제, 본격적으로 정수 인코딩(Integer Encoding)을 진행하고자 한다.
컴퓨터는 인간처럼 단어 자체의 의미를 받아 들일 수 없다. 즉, 이러한 단어 집합을 다루기 위해서는 각 단어에 고유한 정수를 부여해서, 숫자의 형태로 변환해주어야한다.
word_to_index ={word[0]: index +2for index, word inenumerate(vocab)}word_to_index['pad']=1word_to_index['unk']=0
enumerate()는 list, tuple, dictionary, string 등을 입력으로 받아 인덱스를 순차적으로 함께 반환한다는 특징이 있다. 인덱스 0과 1은 다른 용도로 남겨두고 나머지 단어들은 2부터 순차적으로 인덱스를 부여한다.
encoded = []for line in X_train: temp = []for w in line:try: temp.append(word_to_index[w])exceptKeyError: temp.append(word_to_index['unk']) encoded.append(temp)print(encoded[:10])
그럼 이제 단어 집합에서 만들어진 단어와 인덱스를 토대로, 훈련용 데이터에 존재하는 각 단어를 고유한 정수로 변환해주는 작업을 거치려고 한다. 단어 집합에 존재하지 않는 경우, 'unk'로 대체되며, 'unk'의 인덱스를 부여하도록 만들었다. 그럼 밑에 있는 사진과 같은 결과를 확인할 수 있다.
5) Padding
패딩은 길이가 서로 다른 리뷰들을 모두 동일한 길이로 바꾸어주는 작업이다. 앞서 word_to_index['pad'] = 1은 패딩을 위해 추가한 토큰이었다. 패딩은 정해진 길이만큼으로 모든 샘플들의 길이를 맞춰주는 것이다. 하지만, 정해진 길이보다 길이가 짧은 샘플은 'pad' 토큰을 추가하면서 길이를 맞춘다.
max_len =max(len(n) for n in encoded)print('max length of Review : ', max_len)print('min length of Review : ', min(len(n) for n in encoded))print('average length of Review : ', (sum(map(len, encoded))/len(encoded)))
max length of Review : 68
min length of Review : 0
average length of Review : 10.95426877497541
for line in encoded:iflen(line)< max_len: line += [word_to_index['pad']] * (max_len -len(line))
가장 길이가 긴 리뷰의 길이인 68로 모든 리뷰의 길이를 통일시켜준다.
max length of Review : 68
min length of Review : 68
average length of Review : 68.0
classGRU(nn.Module):def__init__(self,n_layers,hidden_dim,n_vocab,embed_dim,n_classes,dropout_p=0.2):super(GRU, self).__init__() self.n_layers = n_layers self.hidden_dim = hidden_dim self.embed = nn.Embedding(n_vocab, embed_dim) self.dropout = nn.Dropout(dropout_p) self.gru = nn.GRU(embed_dim, self.hidden_dim, num_layers=self.n_layers, batch_first=True) self.out = nn.Linear(self.hidden_dim, n_classes)defforward(self,x): x = x.to(torch.int64) x = self.embed(x) h0 = self._init_state(batch_size=x.size(0))#h0 = torch.zeros(self.n_layers, batch_size, self.hidden_dim).cuda()#뒤에 cuda 안 붙이면 input과 hidden tensor가 다른 device에 들어감 x, _ = self.gru(x, h0)print(x.shape) ht = x[:,-1,:]#batch size, hidden state size 가져오기 self.dropout(ht) logit = self.out(ht)#logit = torch.sigmoid(logit)return logitdef_init_state(self,batch_size=1): weight =next(self.parameters()).datareturn weight.new(self.n_layers, batch_size, self.hidden_dim).zero_()#tensor를 zero로 만들려면 그냥 위에서 h0으로 생성
RNN셀보다는 더 뛰어난 성능을 보이는 GRU셀을 사용다. forward 함수를 오버라이딩하여, hidden state와 ouput 둘 다 다음 셀로 전달할 수 있도록 하였으며, _init_state를 통해, 은닉 상태의 초기값을 설정했다.
if torch.cuda.is_available(): device='cuda'else: device='cpu'torch.manual_seed(777)if device =='cuda': torch.cuda.manual_seed_all(777)model =GRU(1, 256, vocab_size, 128, 2).to(device)optimizer = torch.optim.Adam(model.parameters(), lr =0.001)
deftrain(model,optimizer,train_dataloader): model.train()for idx, batch inenumerate(train_dataloader): x, y = batch x, y = x.to(device), y.to(device) logit =model(x) loss = F.cross_entropy(logit, y.long()) loss.backward() optimizer.step()