for textmining

CNN으로 문장 분류하기

|

이번 포스팅에서는 Convolutional Neural Networks(CNN)로 문장을 분류하는 방법에 대해 살펴보겠습니다. 이번 포스팅의 아키텍처와 코드는 각각 Yoon Kim(2014)이곳을 참고했음을 먼저 밝힙니다. 그럼 시작하겠습니다.

자연어 처리에 특화된 네트워크 구조?

자연언어는 단어나 표현의 등장 순서가 중요한 sequential data입니다. 아래 예문을 볼까요?

{무거운 / *가벼운 / *큰} 침묵

침묵이 {*무겁다}.

Only I hit him in the eye yesterday. (No one else did)

I only hit him in the eye yesterday. (did not slap him)

‘무거운 침묵’은 고정된 형식으로 ‘정적이 흐르는 상태가 매우 심하다’는 뜻의 연어(collocation)으로 쓰입니다. 하지만 순서를 바꿔서 ‘침묵이 무겁다’는 표현은 문장 자체가 성립되지 않는 비문이라고 할 수 있죠. 영어의 경우 위 예시처럼 단어 등장 순서가 의미 차이를 만드는 경우가 많습니다. 이렇듯 단어나 표현의 순서는 그 의미 차이를 드러내거나 문장 성립 여부를 결정하는 등 중요한 정보를 함축하고 있습니다.

Recurrent Neural Networks(RNN)이나 이번 포스팅의 주제인 CNN은 등장 순서가 중요한 sequential data를 처리하는 데 강점을 지닌 아키텍처입니다. RNN의 경우 아래와 같이 입력값을 순차적으로 처리합니다. 입력값을 ‘단어’로 바꿔놓고 생각해보면 단어의 등장 순서를 보존하는 형태로 학습이 이뤄지게 됨을 알 수 있습니다.

그렇다면 CNN은 어떨까요? 본래 CNN은 이미지 처리를 하기 위해 만들어진 아키텍처입니다. 아래 움짤(출처)처럼 필터(filter)가 움직이면서 이미지의 지역적인 정보를 추출, 보존하는 형태로 학습이 이뤄지게 됩니다.

cnn

CNN을 텍스트 처리에 응용한 연구가 바로 Yoon Kim(2014)입니다. 이미지 처리를 위한 CNN의 필터(9칸짜리 노란색 박스)가 이미지의 지역적인 정보를 추출하는 역할을 한다면, 텍스트 CNN의 필터는 텍스트의 지역적인 정보, 즉 단어 등장순서/문맥 정보를 보존한다는 것이죠. 이를 도식화하면 제가 만든 아래 움짤과 같습니다.

위 움짤을 자세히 보시면 한 문장당 단어 수는 총 n개(아래 설명해 드릴 코드에서 변수명 : sequence_length)입니다. 이 단어들 각각은 p차원(변수명 : embedding_size)의 벡터이구요, 붉은색 박스는 필터를 의미합니다. 위 움짤의 경우 필터의 크기는 2로써 한번에 단어 2개씩을 보게 됩니다. 이 필터는 문장에 등장한 단어 순서대로 슬라이딩해가면서 문장의 지역적인 정보를 보존하게 됩니다. 필터의 크기가 1이라면 Unigram, 2라면 Bigram, 3이면 Trigram.. 이런 식으로 필터의 크기를 조절함으로써 다양한 N-gram 모델을 만들어낼 수 있습니다.

요컨대 RNN은 단어 입력값을 순서대로 처리함으로써, CNN은 문장의 지역 정보를 보존함으로써 단어/표현의 등장순서를 학습에 반영하는 아키텍처라 할 수 있겠습니다. RNN과 CNN이 자연언어처리 분야에서도 각광받고 있는 이유이기도 합니다. 이 글에서 설명하지 않았지만 Recursive Neural Networks도 단어/표현의 등장순서를 학습에 반영하는 구조인데요, Recursive Neural Networks의 자세한 내용을 보시려면 이곳을 참고하시면 좋을 것 같습니다. 아울러 CNN의 역전파(backpropagation) 등 학습방식에 관심이 있으시면 이곳을, RNN의 어플리케이션 사례를 보시려면 이곳을 방문하시길 권해드립니다.

CNN을 활용한 문장 분류 아키텍처

Yoon Kim(2014)의 아키텍처는 아래와 같습니다.

이 논문은 영화 리뷰 사이트에 게시된 댓글과 평점 정보를 이용해 각 리뷰가 긍정인지 부정인지 분류하는 모델을 만들고자 했습니다. n개의 단어로 이뤄진 리뷰 문장을 각 단어별로 k차원의 행벡터로 임베딩합니다. 단어를 벡터로 임베딩하기 위해서는 Word2Vec이나 GloVe처럼 distributed representation을 쓸 수도 있고요, 단어벡터의 초기값을 랜덤으로 준 뒤 이를 다른 파라메터들처럼 학습 과정에서 조금씩 업데이트해서 사용하는 방법도 있습니다. 한편 위 그림을 보면 CNN 필터의 크기는 2와 3인데요, 각각 Bigram, Trigram 모델을 뜻한다고 볼 수 있겠습니다. 이후 필터 개수만큼의 feature map을 만들고, Max-pooling 과정을 거쳐 클래스 개수(긍정 혹은 부정 : 2개)만큼의 스코어를 출력하는 네트워크 구조입니다.

영화리뷰를 숫자로 바꾸기

텐서플로우 코드로 구현된 이 아키텍처를 한국어 영화 리뷰에 적용해 보기로 했습니다. 우선 영화 리뷰 품질이 국내에서 가장 좋은 것으로 평가받는 왓챠에서 댓글과 평점 데이터 657만2288건을 모았습니다. 스크래핑한 데이터는 아래와 같습니다.

알프레도는 필름을 통해 인생과 사랑에 대해서 얘기하고자 했던것같다. 그 무수한 날들을 거치면서 항상 전진해야했던 토토에 비해 영화는 항상 향수를 자극시키며 그 자리에 계속 있었다. 많은 오고가는 흐릿한 생각들 끝에 내가 생각하는 영화는 정말 사랑과도 같다. 이해할수도 설명할수도 없는 그 느낌. 이 영화를 통해 영화를 더욱 사랑하게되었다, 5.0
고마워요 알프레도, 4.0
나머지 별 반개는 감독판을 보는 날에, 4.5
영화가 주는 감동이란..., 4.5
뭘 얘기하고 싶은건데?, 2.0

우선 CNN 문장분류 아키텍처의 입력값과 출력값을 만들어야 합니다. 우선 입력 데이터를 만들어 볼까요? 이 글에서는 Word2Vec 같은 distributed representation을 쓰지 않고, 단어벡터를 랜덤하게 초기화한 뒤 이를 학습과정에서 업데이트하면서 쓰는 방법을 채택했습니다. 이런 방식을 사용하기 위해서는 위 텍스트 문장을 숫자들(단어의 ID)의 나열로 변환해야 합니다.

그런데 단어들이 너무 많으면 메모리 문제로 학습 자체가 불가능해지므로 전체 단어 숫자를 좀 줄일 필요가 있습니다. 이 때문에 문장을 띄어쓰기 기준으로 어절로 나누고 어절 처음 두 글자만 잘랐습니다. 바꿔 말하면 아래 단어들을 모두 한 단어로 보는 방식입니다. 이 방식을 적용하면 말뭉치에 등장하는 전체 단어수(변수명 : vocab_size)를 아무런 처리를 하지 않았던 기존 대비 4분의 1로 줄일 수 있습니다.

잘어울리는 잘어울렸다 잘어울리네요 잘어울리는데 잘어울림 잘어울려요 잘어울리고 잘어울렸는데 잘어울렸음 잘어울린다

이후 단어별로 ID를 부여해 사전을 만들었습니다. 이 사전을 가지고 각 리뷰를 ID(숫자)들의 나열로 바꾸는 것입니다.

{‘가’: 0, ‘가가’: 1, ‘가감’: 2, (중략) ‘잘어’: 10004, (하략) }

이제 출력(정답) 데이터를 살펴볼까요? 왓챠 평점은 5점 만점인데 2.5점보다 높으면 긍정([1,0])을, 2.5점보다 낮으면 부정([0,1]) 범주를 부여했습니다. 지금까지 설명한 방식을 적용해 입력값과 출력값을 만든 결과의 예시는 아래와 같습니다.

고마워요 알프레도 (하략), 4.0                  [[502, 3101, ......], [1,0]]
영화가 주는 감동이란 (하략), 4.5            [[2022, 3043, 301, ...], [1,0]]
뭘 얘기하고 싶은건데? (하략), 2.0              [[1005, 2206, 1707, ...], [0,1]]

Lookup 테이블 구축

지금까지 우리가 만든 입력값은 아래 그림에서 빨간색 박스에 대응됩니다. 다시 말해 문장 길이(변수명 : sequence_length)에 해당하는 개수만큼의 단어 ID들의 나열이죠.

이미 설명드렸다시피 이번 글에서는 Word2Vec을 쓰지 않고 단어벡터를 만들기로 했었습니다. 대신 커다란 Lookup 테이블(아래 그림에서 파란색 박스)을 만들겁니다. 이 테이블의 크기는 전체 단어수(변수명 : vocab_size) ** * 사용자가 지정한 단어벡터 차원수(변수명 : embedding_size)**입니다. 파란색 박스의 행벡터들은 개별 단어 ID에 대응하는 벡터입니다. 초기값은 랜덤하게 설정하고요, 이후 학습 과정에서 문장 분류를 가장 잘하는 방식으로 조금씩 업데이트됩니다.

지금까지 리뷰를 단어 ID들의 나열로 바꿨고, ID에 해당하는 단어벡터도 만들었으니 이제 단어 ID들 각각을 벡터로 바꿔주기만 하면 되겠네요. 방법은 쉽습니다. 파란색 Lookup 테이블에서 단어 ID에 해당하는 행벡터를 참조해서 가져오기만 하면 됩니다. 그 결과는 바로 아래 그림의 보라색 박스입니다. 이 박스의 크기는 (문장길이 * 사용자가 지정한 단어벡터 차원수)가 됩니다.

cnn input

이상 논의한 내용을 텐서플로우 코드로 표현하면 다음과 같습니다. 최종 결과물인 보라색 박스에 대응하는 변수명은 embedded_chars인데요, 여기에 conv2d 함수가 요구하는 차원수로 만들어주기 위해 차원을 하나 추가해 줍니다.

W = tf.Variable(tf.random_uniform([vocab_size, embedding_size], -1.0, 1.0), name="W")
embedded_chars = tf.nn.embedding_lookup(W, input_x) # 차원수: [sequence length, embedding size]
embedded_chars_expanded = tf.expand_dims(embedded_chars, -1) # 차원수 : [sequence length * embedding size * 1]

Conv Layer 만들기

embedded_chars_expanded배치(batch) 단위로 CNN에 들어가게 됩니다. 배치를 반영한 입력값의 차원수는 [batch_size, sequence_length, embedding_size, channel_size]가 됩니다. 그런데 제가 실험한 CNN 구조에선 채널수를 1로 설정하여서 결과적으로 [batch_size, sequence_length, embedding_size, 1]이 됩니다.

자, 그러면 필터의 차원수를 살펴보겠습니다. 아래 그림을 보면 아시겠지만 필터의 너비는 embedding_size입니다. 높이는 filter_size인데요, 만약 2라면 Bigram, 3이라면 Trigram 모델이 될 겁니다. 채널수는 1로 고정했습니다.

filter

이를 종합해 텐서플로우가 요구하는 문법으로 필터 차원수를 정의하면 이렇습니다.

filter_shape = [filter_size, embedding_size, 1, num_filters]

자, 그러면 텐서플로우 구현의 핵심인 conv layer를 만드는 부분을 볼까요? 여기서 입력값과 필터의 가중치는 각각 embedded_chars_expanded와 W입니다. 필터가 입력값을 볼 때 그 보폭(strides)은 [1, 1, 1, 1]로 하고 엣지 패딩없이 문장을 슬라이딩하라는 뜻입니다.

conv = tf.nn.conv2d(self.embedded_chars_expanded,
                    W,
                    strides=[1, 1, 1, 1],
                    padding="VALID",
                    name="conv")
h = tf.nn.relu(tf.nn.bias_add(conv, b), name="relu")

그럼 [1, 1, 1, 1]의 의미는 무엇일까요? 텐서플로우 문서를 보면 strides값은 [batch_size, input_height, input_width, input_channels] 순서로 지정하라고 정의돼 있습니다. 보폭이 [1, 1, 1, 1]이라는 건 배치데이터 하나씩, 단어 하나씩 슬라이딩하면서 보라는 의미입니다.

그런데 여기서 input_width와 input_channels는 큰 의미가 없습니다. 왜냐하면 우리는 입력값의 너비와 필터의 너비를 embedding_size로 같게 설정했고, 채널수는 1로 모두 고정했기 때문입니다. 바꿔 말하면 보폭 [1, 1, 1, 1] 중 뒤의 두 개는 입력데이터와 필터가 같은 값을 지니기 때문에 슬라이딩할 수 있는 여유 공간이 없어 무의미하다는 뜻입니다.

이렇게 conv를 적용한 뒤의 텐서 차원수는 [batch_size, sequence_length - filter_size + 1, 1, num_filters]가 됩니다. 첫번째 요소 batch_size가 나오는 건 직관적으로 이해 가능하실 것 같고요, 나머지 차원수에 대해서는 텐서플로우 공식문서를 확인하시면 빠르게 이해하실 수 있으실 겁니다. 이후 ReLU를 써서 비선형성(non-linearity)을 확보합니다.

이제 Max-pooling하는 부분을 보겠습니다. 여기서 핵심은 ksize인데요, Max-pooling하는 영역의 크기가 되겠습니다. 예컨대 ksize가 [1, 2, 2, 1]이라면 배치데이터/채널별로 가로, 세로 두 칸씩 움직이면서 Max-pooling하라는 지시입니다. Max-pooling이 적용된 후의 텐서 차원수는 [batch_size, 1, 1, num_filters]가 됩니다.

pooled = tf.nn.max_pool(h,
                    ksize=[1, sequence_length - filter_size + 1, 1, 1],
                    strides=[1, 1, 1, 1],
                    padding='VALID',
                    name="pool")

이후엔 Max-pooling한 결과물을 합치고 Full-connected layer를 통과시켜 각 클래스에 해당하는 스코어를 낸 뒤 크로스엔트로피오차를 구하고 역전파(Backpropagation)를 수행해 필터의 가중치 등 파라메터들을 업데이트하는 일반적인 과정을 거칩니다. 여기서 특이한 점은 단어벡터의 모음인 Lookup 테이블도 학습과정에서 같이 업데이트한다는 사실입니다.

코드 공유

지금까지 이야기한 내용을 바탕으로 이곳을 참고해 커스터마이징한 코드는 아래와 같습니다.

위 코드에 쓰인 입력데이터 전처리용 코드는 다음과 같습니다.

제가 실험한 결과도 간략히 소개합니다. 제가 선택한 하이퍼파라메터 조합은 아래와 같습니다.

embedding_size = 128
filter_sizes = 3,4,5
num_filters = 128
dropout_keep_prob = 0.5
batch_size = 64
max_document_length = 100

우선 왓챠에서 모은 댓글과 평점 데이터 657만2288건 가운데 80%를 학습데이터, 20%를 검증데이터로 분리했습니다. 학습에 쓰지 않은 검증데이터에 대한 예측 결과(단순정확도)는 step수가 3만회를 넘어가면서 85% 내외로 수렴하는 모습을 보였습니다. 그 결과는 다음과 같습니다.

step 37600, loss 0.401777, acc 0.87
step 41500, loss 0.347608, acc 0.85
step 47000, loss 0.384077, acc 0.84
step 51000, loss 0.30739, acc 0.86
step 53800, loss 0.328676, acc 0.82

마치며

이상으로 CNN을 활용한 문장 분류 모델을 살펴보았습니다. CNN은 지역 정보를 보존한다는 점에서 순차적인 데이터 처리에 강점을 지닌 RNN과 더불어 자연언어처리 분야에서 주목을 받고 있습니다. 평점이라는 정답 데이터가 존재한다면 CNN을 가지고도 훌륭한 문장 분류기를 만들어낼 수 있다는 사실을 실험적으로 확인할 수 있었습니다. 제언이나 질문하실 것 있으시면 언제든지 댓글이나 이메일로 연락주시기 바랍니다. 여기까지 읽어주셔서 감사합니다.

Comments