TextCNN - 구현편


안녕하세요, 이번 포스팅에서는 저번 시간에 공부한 TextCNN를 이용하여 문장이 긍정적인지, 부정적인지 분류하는 모델을 pytorch 기반으로 구현해보도록 하겠습니다.

들어가기 앞서, 코드의 전반적인 구조는 graykode님의 코드를 적극적으로 참고했음을 알려드립니다.

추가로, graykode님의 코드에서는 간단한 구현과 batch 단위의 학습을 위해 sequence length를 3으로 통일하고 filter size를 2로 통일하셨지만, 저는 다양한 시퀀스 길이를 가지는 문장들에 대한 호환성을 가질수 있도록 batch size를 1로 설정했습니다.

각 파트별로 자세한 설명을 한 이후, 글의 마지막에 전체 코드를 올리도록 하겠습니다.

TextCNN Model

class TextCNN(nn.Module):

    def __init__(self):
        super(TextCNN, self).__init__()
        self.Total_filters = num_filters * len(filter_sizes)
        self.W = nn.Embedding(vocab_size, emb_dim)
        self.Weight = nn.Linear(self.Total_filters, num_classes)
        self.Bias = nn.Parameter(torch.ones([num_classes]))

        # nn.Conv2d = [input_channels, output_channels, (kernel_height, kernel_width)]
        self.Filter_list = nn.ModuleList([nn.Conv2d(1, num_filters, (filter_dim, emb_dim)) for filter_dim in filter_sizes])

    def forward(self,X):

       # input X는 look-up table을 할 수 있도록 각 토큰의 인덱스 형태로 제공받음 [batch, sequence_length]
       X = torch.LongTensor(X) #index이므로 long tensor로 변환해줌
       embedded = self.W(X) # [sequence_length, emb_dim]

       # batch 및 channel 차원 추가 (conv2d layer의 input 형식에 맞게)
       embedded = embedded.unsqueeze(0) #[channel=1, sequence_length, emb_dim]
       embedded = embedded.unsqueeze(0) #[batch=1, channel=1, sequence_length, emb_dim]

       # sequence_length
       sequence_length = embedded.shape[2]

       #convolution
       pooled = []
       for i in range(len(filter_sizes)):
           conv = self.Filter_list[i]

           # Convolution 후 ReLU  
           out = F.relu(conv(embedded)) # out = [batch=1, 1, sequence_length - filter_size[i] + 1, 1]

           # Maxpooling for each Feature Map
           mp = nn.MaxPool2d((sequence_length - filter_sizes[i] + 1, 1)) # out = [batch=1, output_channels=3, 1, 1]

           # Append maxpooled units to pooled
           pooled.append(mp(out).squeeze())

       # Fully connected layer
       fc = torch.cat(pooled, 0)
       out = self.Weight(fc) + self.Bias # out = [self.Total_filters, num_classes]

       return out

먼저 모델의 초기값 부분부터 설정해 보겠습니다. nn.Module을 상속받은 후 super().__init__을 통해 초기화 해줍니다.

self.Total_filter =  모델에 사용된 모든 filter의 개수
num_filters = 각 filter 종류 별로 몇개씩 썼는지
len(filter_sizes) = filter 종류가 몇 개인지

self.W = 단어 임베딩에서 look-up table을 하기 위한 matrix
self.Weight = Fully connected layer에서 사용할 Weight matrix
self.Bias = 각 output에 대한 prediction을 위한 bias 값.

self.Filter_list = 각 필터 종류에 따른 covolution layer를 모은 list

여기서 키 포인트는 각 필터 종류별로 convolution을 동일한 방식으로 수행하므로, 각 필터 종류에 따른 convolution layer을 ModuleList를 이용하여 묶어서 정리해주는 부분입니다.

다음으로, forward propagation 부분을 만들어 줍니다. input은 look-up table을 할 수 있도록 vocabulary의 index 배열 형태로 준비해 줍니다. 들어온 input(X)는 W(X)로 look-up table을 통해 임베딩 벡터로 변환됩니다.

conv2d layer를 사용하기 위해 batch차원과 channel 차원을 추가해줍니다. 여기서 channel 차원은 기존 CNN이 이미지에 특화되어있기 때문에 이미지가 Color일 경우 3차원(RGB), 흑백일경우 1차원 등으로 설정할 수 있는 부분입니다. 우리가 사용하는 텍스트 데이터는 1차원으로 설정해주면 됩니다.

각 필터 종류별로 convolution, activation, maxpooling을 순차적으로 진행한 후 concat하여 fully connected layer로 보내줍니다.

Loss function에서 사용할 nn.CrossEntropyLoss()에 softmax가 포함되어 있으므로 softmax 이전까지의 output에서 끊어줍니다.

Main code

if __name__ == '__main__':

    emb_dim = 2
    num_filters = 3
    filter_sizes = [2,3,4]
    num_classes = 2

    # Corpus and Labeles
    sentences = ['i am happy guy', 'you are happy guy', 'it is really interesting', 'i love playing piano', 'she really love me',
                 'it is so terrible', 'he is very ugly', 'she hate the man', 'i hate my ugly voice', 'it is not good']

    labels = [1,1,1,1,1,0,0,0,0,0] # 1 : Positive, 0: Negative

    # make inputs
    word_list = " ".join(sentences).split()
    vocab = list(set(word_list))
    vocab_size = len(vocab)
    vocab_dict = {word:idx for idx, word in enumerate(vocab)}

    inputs = [[vocab_dict[i] for i in s.split()] for s in sentences]
    targets = torch.LongTensor([out for out in labels]) # label이므로 long tensor

    # get model
    model =  TextCNN()

    # optimizer and loss function
    optimizer = optim.Adam(model.parameters(), lr=0.001)
    criterion = nn.CrossEntropyLoss()

    # Training
    for epoch in range(5000):
        optimizer.zero_grad()
        loss = 0

        for batch in range(len(inputs)):

            output = model(inputs[batch])
            loss_tmp = criterion(output, targets[batch])
            loss += loss_tmp

        if (epoch + 1) % 1000 == 0:
            print('Epoch:', '%04d' % (epoch + 1), 'cost =', '{:.6f}'.format(loss))

        loss.backward()
        optimizer.step()

Epoch: 1000 cost = 0.019409
Epoch: 2000 cost = 0.003515
Epoch: 3000 cost = 0.001238
Epoch: 4000 cost = 0.000545
Epoch: 5000 cost = 0.000268

Main code에서는 hyperparameter들을 설정하고, Corpus와 label, vocabulary등을 만들어 줍니다. 임베딩 차원은 2, 필터 종류별 개수는 3개, 각 필터 종류 별 사이즈(높이)는 2,3,4로 설정했습니다. 긍정과 부정에 대한 예측이므로 class는 총 2개입니다.

Optimaizer는 Adam Optimizer를 사용했으며, Epoch은 5000번으로 설정했습니다.

Test

test_text = 'i really love playing the piano'
tests = [np.asarray([vocab_dict[n] for n in test_text.split()])]
test_batch = torch.LongTensor(tests)[0]
out = model(test_batch).data

predict = int(torch.argmax(out))
if predict == 0:
    print("'", test_text, "'"," is Bad Mean...")
else:
    print("'", test_text, "'"," is Good Mean!!")
' i really love playing the piano '  is Good Mean!!

test할 문장을 input 형태에 맞게 만들어준 후에 긍정인지 부정인지 예측을 해봅시다. test할 문장은 학습 시에 구축한 vocabulary 안에 있는 단어로만 구성되어 있으면 되기 때문에, 훈련 데이터에 존재하지 않는 문장에 대해서도 추측이 가능합니다.

vocabulary안에 있는 단어들로 구성한 새로운 문장에 대해서도 문장 분류를 훌륭하게 잘 하는 것을 확인할 수 있습니다.

마치며

TextCNN을 이용해 문장 감성분석 모델을 구현해 보았습니다.
별다른 pre-trained embedding vector를 사용하지 않고도 아주 간단한 CNN 구조만을 이용해서 분류 성능이 나온다는 것이 고무적입니다.

읽어주셔서 감사드리며, 다음 포스팅에서 또 뵙겠습니다.

References

graykode님의 코드
Convolutional Neural Networks for Sentence Classification

Comments