for textmining

Sequence-to-Sequence 모델로 뉴스 제목 추출하기

|

이번 포스팅에서는 Sequence-to-Sequence 모델로 ‘뉴스 제목 추출하기’를 해보려고 합니다. 결론부터 말씀드리자면 완벽한 실험 결과가 나온 건 아니고요, 제가 삽질했던 점들을 중심으로 S2S 모델의 특징과 한계 등에 대해 이야기해볼까 합니다. 자 그럼 시작하겠습니다!

Sequence-to-Sequence?

Sequence-to-Sequence 모델(S2S)은 Recurrent Neural Network의 가장 발전된 형태의 아키텍처입니다. LSTM, GRU 등 RNN cell을 길고 깊게 쌓아서 복잡하고 방대한 시퀀스 데이터를 처리하는 데 특화된 모델이죠. 실제로 S2S는 영어-한국어, 한국어-일본어 등 기계번역에 쓰이고 있다고 합니다. 이 모델은 2014년 이 논문에서 처음 소개가 됐는데요, 구글 텐서플로우에서도 이를 구현한 코드를 공개해 눈길을 끌고 있습니다. 이번 포스팅은 기본적으로 텐서플로우 예제들을 커스터마이징해서 만들었습니다.

S2S는 크게 인코더(encoder)디코더(decoder) 두 파트로 나뉩니다. 영어를 한국어로 변환하는 기계번역을 예로 들어보겠습니다. 인코더는 소스랭귀지(source language)인 영어 텍스트를 처리합니다. 디코더는 타겟랭귀지(target language)인 한국어 텍스트를 맡게 되죠. 디코더의 입력은 이 모델 정답에 해당하는 한국어 텍스트이며 출력 또한 한국어 텍스트입니다. <go><eos>는 각각 정답 시작과 끝을 알리는 일종의 기호라고 보시면 되겠습니다. 인코더와 디코더의 입출력과 관련해 한번 예를 들어보겠습니다.

인코더 입력 : Good morning!

디코더 입력 : <go> 좋은 아침입니다!

디코더 출력 : 좋은 아침입니다! <eos>

인코더는 소스랭귀지 정보를 압축합니다. 디코더는 인코더가 압축해 보내준 정보를 받아서 타겟랭귀지로 변환해 출력합니다. 다만 학습과정과 예측(테스트)과정이 조금 다릅니다. 학습과정에서 디코더는 인코더가 보내온 정보와 실제 정답(‘<go>좋은 아침입니다!’)를 입력으로 받아 ‘좋은 아침입니다<eos>’를 출력합니다.

예측 과정에서 디코더는 인코더가 보내온 정보와 ‘<go>’만 입력으로 받아 결과물을 차례대로 출력합니다. 띄어쓰기를 기준으로 인풋을 만든다면 예측 과정에서의 디코더가 내놓는 첫 결과물은 ‘좋은’이 되겠지요. 예측 과정에서의 디코더는 직전에 예측한 ‘좋은’이라는 결과를 다시 자신의 다음 단계 입력으로 넣어 ‘아침입니다’를 출력하게 됩니다.

디코더가 예측 과정에서 자신의 직전 출력(좋은)을 다음 입력으로 다시 넣는 이유는 예측 단계에선 학습 때와는 달리 정답이 없기 때문입니다. 그래야 정답이 주어지지 않은 상태(예컨대 구글 번역기에 우리가 번역하고 싶은 문장을 입력할 때)에서도 예측이 가능해 집니다.

문제 정의

자, 그럼 이번 포스팅의 목적인 ‘뉴스 제목 생성’을 위 아키텍처에 적용해 봅시다. 위 그림처럼 인코더에 뉴스 본문을 넣고 디코더에 제목을 넣는 것이죠. 왜 공들여 이런 일을 하냐고요? 제 포부는 원대했습니다. 정답이 있는 데이터만 S2S 학습이 가능합니다. 하지만 자연언어처리 분야에서 정답이 있는 데이터를 얻기는 매우 어렵습니다. 그런데 뉴스 기사는 다르죠. 뉴스는 다른 분야 말뭉치 대비 양질이면서도 하루에도 수백 수천건씩 쏟아집니다. 조간신문이나 방송에 보도된 뉴스는 제목을 다는 데도 수많은 전문인력이 투입되죠. 비교적 수월하게 구할 수 있는 한국어 말뭉치 중에는 이만한 컨텐츠가 없다고 생각했습니다.

게다가 뉴스 제목은 대체로 본문 내용을 전반적으로 포괄하는 경향이 있습니다. 뉴스 본문이라는 문서를 요약하는 학습모델을 만들 때 제목을 정답으로 놓고 모델링을 하면 참 좋겠다는 생각을 했습니다. S2S는 시퀀스 형태의 인풋을 받아 또 다시 시퀀스 형태의 아웃풋을 내놓으므로 요약 결과가 완결된 문장 형태를 지닐 수 있지 않을까 하는 기대를 하기도 했습니다. 뒤에 설명드리겠지만 시작은 아주 창대했답니다.

데이터 전처리

우선 웹에서 2016년 1월초 사흘치 기사 1000건을 모았습니다순Siri 기사를 보고 싶지 않아서는 절대 아닙니다. 원래 기사는 이렇게 생겼습니다.

경기도 준예산 사태…‘보육대란’ 이미 시작됐다

[한겨레] 해법 안보이는 ‘누리예산’

경기도의회 연말 여야 몸싸움

남경필 지사·강득구 도의회 의장

준예산 사태 논의에도 접점 못찾아

당장 이달부터 누리과정 지원중단

교육부는 계속 교육청만 압박

지난 12월31일 경기도의회에서 여야 의원들의 몸싸움이라는 최악의 사태까지 연출한 누리과정(만 3~5살 무상보육) 사태가 새해에도 해법을 찾지 못한 채 정부와 교육청, 시·도의회, 여야 간 타협 없는 대치 국면으로 이어질 전망이다. (하략)

우선 위 기사를 제목과 본문으로 나눴습니다. 본문에는 중간제목으로 보이는 내용들(이탤릭체)도 일부 보입니다만 일일이 손으로 거르기보다는 이대로 넣어도 학습이 가능할 것이라는 근거없는 희망으로 모두 본문에 포함했습니다. 다음은 토크나이징, 노말라이즈 등 전처리를 할 차례인데요. KoNLPy 같은 오픈소스 형태소 분석기를 사용하려고 했습니다. 그러나 형태소 분석 과정에서 잘못된 태깅으로 말뭉치 정보가 왜곡되거나 손실될 염려를 배제하기 위해 다른 방법을 쓰기로 했습니다. 단어를 띄어쓰기 기준으로 나누고 3글자까지만 잘라서 노말라이즈를 하는 겁니다. 이렇게 하면 아래 예시의 토큰들을 한 단어로 취급합니다.

감정가

감정가

감정가격에

감정가격은

감정가격이

텍스트 노말라이즈를 하는 근본 이유는 단어수를 줄여 분석의 효율성을 높이기 위해서입니다. 위 다섯개 단어를 각각 다른 단어로 보고 분석하면 물론 정확성은 높아지겠지만 계산복잡성 또한 증가합니다. 어느 순간엔 정확성 상승 대비 분석 비용이 지나치게 높아지게 될 겁니다. 실제로 단어 단위 S2S 모델은 학습 말뭉치의 단어 수가 분류해야할 클래스의 개수가 되기 때문에 단어수가 지나치게 크면 학습의 질은 물론 속도도 급격하게 감소하는 문제가 있습니다. 이 때문에 단어 수를 적절하게 줄일 필요가 있습니다. 저의 경우 위와 같이 노말라이즈를 했음에도 불구하고 단어 개수가 무려 6만5016개나 됐습니다(ㅠㅠ).

자 그럼 구체적으로 예시를 볼까요? 사흘치 기사에서 영어와 숫자, 문장부호, 줄바꿈문자 등을 제거한 기사는 아래와 같은 형태가 됩니다. (title, contents는 변수명이므로 무시하고 보세요)

title: 경기도 준예산 사태 보육대란 이미 시작됐다

contents: 해법 안보이 누리예산 경기도의회 연말 여야 몸싸움 남경필 지사 강득구 도의회 의장준예산 사태 논의에도 접점 못찾아 당장 이달부터 누리과정 지원중단 교육부는 계속 교육청만 압박 지난 월 일 경기도의회에서 여야 의원들의 몸싸움이라는 최악의 사태까지 연출한 누리과정 만 살 무상보육 사태가 새해에도 해법을 찾지 못한 채 정부와 교육청 시 도의회 여야 간 타협 없는 대치 국면으로 이어질 전망이다 (하략)

이제 S2S에 넣은 인풋을 본격적으로 만들어볼까요? S2S엔 텍스트를 숫자로 바꾸어 넣어야 합니다. ‘경기도’를 0, ‘준예산’을 1, ‘사태’를 2… 이렇게요. 이렇게 단어를 숫자로 바꾸기 위해서는 단어와 숫자가 매칭된 사전(dictionary)이 있어야 합니다. 학습 말뭉치인 사흘치 기사에 한번이라도 나온 모든 단어들을 세어서 이를 숫자로 매핑하는 것이죠. 예측 과정에선 숫자를 단어로 바꿔야 하기 때문에 숫자와 단어가 매핑된 사전도 별도로 만들었습니다.

word_to_ix

{‘학생비자를’: 60946, ‘답답함이’: 13623, (중략) ‘감정가’: 979, ‘감정가의’: 979, ‘감정가격에’: 979, (하략) }

ix_to_word

{0: ‘가’, 1: ‘가가갤’, 2: ‘가감’, 3: ‘가거나’, (중략) 979: ‘감정가’ (하략) }

눈썰미가 있는 분들은 이미 눈치채셨겠지만 저같은 경우엔 학습 말뭉치를 바로 3글자로 줄인게 아니라 단어를 숫자로 바꿔주는 사전인 word_to_ix에 모든 단어를 넣되 앞에서부터 3글자가 겹치는 단어들은 같은 인덱스를 주어서 단어가 숫자로 변환될 때 자연스럽게 3글자로 노말라이즈될 수 있도록 했습니다. 이렇게 하면 원래 데이터가 어떻게 생겼는지 확인하기 쉬워서 이렇게 한건데 그냥 바로 3글자로 줄여도 됩니다. 어쨌든 ‘감정가’, ‘감정가의’, ‘감정가격에’라는 단어는 모두 979라는 숫자로 바뀌게 되고요, 나중에 숫자를 단어로 바꾸고 싶을 땐 979가 ‘감정가’로 변환되게 됩니다. 위 기사 본문의 경우 아래처럼 변환됩니다.

[15087, 42582, 3168, 1162, 3168, 10661, 44455, 47701, (하략) ]

위 데이터를 모델에 넣기 전에 하나 고려해야할 것이 있습니다. 바로 데이터 차원수인데요. RNN의 경우 시퀀스 길이에 유연한 네트워크 구조이긴 합니다만, S2S처럼 복잡하고 방대한 네트워크인 경우에는 시퀀스 길이를 어느 정도 맞춰주는 게 좋은 것 같습니다. 그래서 저는 디코더에 넣을 시퀀스 길이를 뉴스 제목 최대 길이(18개 단어)로 맞췄고요, 인코더에 넣을 시퀀스 길이를 기사 앞부분 100개 단어절대 메모리가 부족해서가 아닙…로 맞췄습니다. 만약 이들 길이보다 데이터 시퀀스 길이가 짧다면 <PAD>를 넣어 길이를 맞춰줬습니다. <PAD> 넣는 법은 쉽습니다. word_to_ix와 ix_to_word에 각각 <PAD>라는 요소를 추가해주고 길이가 부족한 데이터에 끼워 넣었습니다.

모델 구현

제가 사용한 S2S의 디테일한 설정들은 위 그림에 요약돼 있습니다. 아시다시피 녹색으로 표시된 은닉층에 어떤 cell을 쓸지 선택할 수 있지 않습니까? 저의 경우 cell은 두 가지를 실험했고요, 바로 LSTM/GRU입니다. 은닉층을 여러 개 쌓을 수도 있는데요, 저는 1개(single) 혹은 3개(multi)를 실험했습니다. 중요한 부분을 중점적으로 검토하게 해 분석의 정확도를 높이는 attention 기법을 적용/미적용한 것의 차이도 실험했습니다. 데이터 시퀀스의 순방향과 역방향 정보를 모두 고려하는 bidirectional 방법론 가운데 이번 실험에서는 메모리 문제로후자만 차용을 했습니다. 다시 말해 인코더 입력 시퀀스를 모델에 입력으로 넣을 때 역방향으로 넣었다는 이야기입니다(뉴스 본문 첫 단어가 인코더의 맨 마지막 입력이 됨). 아래는 텐서플로우 예제에서 제가 커스터마이징한 코드입니다.

천천히 설명을 드리면 tool.loading_data라는 함수는 아래와 같은 형식의 CSV(utf-8)를 읽어들여 title과 contents로 나누어 줍니다. tool.make_dict_all_cut은 학습 말뭉치를 읽어들여 위 예시처럼 word_to_ix와 ix_to_word를 생성해줍니다.

multi라는 파라메터는 은닉층을 1개만 쌓을건지 여러개 쌓을건지 지정하는 변수입니다. forward_only라는 변수는 학습 때는 false, 추론(테스트) 때는 true로 지정합니다. vocab_size는 말뭉치 사전 단어수, num_layers는 은닉층 개수, learning_rate는 학습률, batch_size는 1회당 학습하는 데이터 수(배치 크기)를 의미합니다.

encoder_size는 인코더에 넣을 입력 시퀀스의 최대 길이, decoder_size는 디코더에 넣을 시퀀스의 최대 길이입니다. tool.check_doclength 함수는 입력 컨텐츠의 최대 길이를 반환합니다. tool.make_inputs는 ‘데이터 전처리’에서 설명드렸듯 단어를 숫자로 바꾸는 과정을 한번에 해주는 함수입니다.

이제 클래스 ‘seq2seq’에 정의한 네트워크 구조를 살펴보겠습니다.

우선 변수 선언 부분입니다. encoder_size만큼의 placeholder를 생성해 encoder_inputs를 만듭니다. 마찬가지로 decoder_inputs, targets, target_weights를 만듭니다. target_weights는 target(제목)에 대응하는 요소가 <PAD>일 경우 0, <PAD>가 아닐 경우 1로 채워넣은 리스트입니다. target_weights는 정답에 끼어있는 <PAD>가 학습에 반영되지 않도록 도와주는 값이라고 이해하시면 될 것 같습니다.

네트워크를 선언한 부분을 보겠습니다. cell은 ‘tf.nn.rnn_cell.GRUCell’이라고 선언했습니다. tf.nn.rnn_cell.LSTMCell’이라고 선언하면 GRU를 간단하게 LSTM cell로 변환 가능합니다. 층을 여러개 쌓고 싶으면 ‘tf.nn.rnn_cell.MultiRNNCell’이라는 함수를 쓰면 됩니다.

이제 학습과 예측 과정을 살펴보겠습니다. 아래는 이해를 돕기 위한 그림입니다.

학습 과정을 살펴보겠습니다. if not forward_only 구문이 실행되는 부분입니다. ‘tf.nn.seq2seq.embedding_attention_seq2seq’ 함수는 outputs를 반환합니다. ‘feed_previous’에 False를 씁니다. attention을 쓰지 않으려면 ‘tf.nn.seq2seq.embedding_seq2seq’ 함수를 사용해 보세요. 이 output에 W를 내적하고 b를 더해 logit을 만들고요, 리를 바탕으로 cross-entropy loss를 구합니다. 여기서 주목할 점은 cross-entropy loss에 target_weight를 곱해준다는 점입니다. target_weight는 <PAD>인 경우 0이기 때문에 <PAD>에 해당하는 cross-entropy loss는 무시됩니다.

추론 과정을 살펴보겠습니다. else: 구문이 실행되는 부분입니다. ‘tf.nn.seq2seq.embedding_attention_seq2seq’ 함수의 ‘feed_previos’에 True를 집어넣습니다. 디코더가 직전 과정에서 내뱉은 결과를 다음 과정의 인풋으로 받아들여 추론하라는 지시입니다. 나머지는 일반적인 딥러닝 네트워크 구조와 크게 다르지 않습니다. 아래는 위 코드가 import해서 사용하는 tool.py입니다. tool.py에 있는 모든 코드들은 제가 직접 만들었습니다.

실험 결과

학습

3-layers, encoder_size(100), decoder_size(20), GRU(300차원), attention, batch_size=16

괄호 안은 정답입니다

§Iter 500 : 신당 신당 신당 신당 신당 <E><E> <E> <E> <E> <E> <E> <E> <E><E> <E> <E> <E> 
(안철수 신당 호남 위 새누리당 수도권 영남 충청서선두)
§Iter 1000 : 윤미옥 김윤정 김윤정 두 두 두 두 향토방 <E><E> <E> <E> <E> <E> <E> <E> <E><E> 
(윤미옥 김윤정 예비역 소령 두 번째 군대 인생 향토방위 수준 높이겠다) 
§Iter 1500 : 삼성베트남 베트남 휴대폰 휴대폰 부품 부품 <E><E> <E> <E> <E> <E> <E> <E> <E><E> <E> 
(삼성 베트남 공장 덕분에 휴대폰 부품 수출 급증)
§Iter 2000 : 롯데 지분지분 취득 <E><E> <E> <E> <E> <E> <E> <E> <E><E> <E> <E> <E> <E> 
(롯데 롯데제과 지분 취득)
§Iter 2500 : 윤병세 윤병세 국민적 국민적 저항자화자찬 자화자찬 자화자찬 삶 삶 삶 높은 높은 높은 질 없는전 전 
(윤병세 장관은 국민적 저항 외면 자화자찬)

예측

1-layer 3-layers 3-layer + attention 정답
대북 도발 김정은 확성기 높이다 대북 방송 국민 단단한 안보 의식이 추가 도발 막는다
최경환 누리 누리 예산 누리 예산 누리과정 예산 일단 늘어나 교부금 활용하라
삼성 삼성 가전 너마저 실적 반도체 불투명 반도체 주춤 삼성전자 영업이익 감소

위 결과 표를 보면 아시겠지만 학습은 비교적 잘 되는 것처럼 보입니다. iter 1000번(배치를 1000번 넣었다는 뜻, 1000개 기사를 학습데이터로 넣었으므로 전체 데이터 기준으로는 16번 학습)만에 어느 정도 제목 윤곽이 나오는 것을 볼 수 있습니다. 학습시 디코더 입력에 실제 정답을 넣어서 이렇게 잘 되는 것 같기도 합니다.

하지만 학습에 쓰이지 않은 데이터를 넣어 예측한 결과를 보면 조금 아쉽습니다. 일단 학습 과정보다는 그 품질이 확실히 떨어집니다. 그 원인은 다양하겠지만 뉴스 자체의 특성 때문 아닐까 생각합니다. 같은 사건을 보도하더라도 완전히 같은 내용의 기사는 없으며, 그 본문이 거의 비슷하더라도 언론사마다 제목은 크게 다른 경우가 많습니다. 다시 말해 ‘Good morning!’의 한국어 표현은 ‘좋은 아침입니다!’, ‘안녕하세요’ 등 몇 가지 안되는 ‘닫힌 정답’이라면 뉴스 제목은 언론사마다 천차만별인 ‘열린 정답’이라는 것입니다. 학습데이터 양이 작아서인지 3개층, attention 모델 등 복잡한 네트워크가 1-layer와 비슷한 성능을 내는 점 또한 확인할 수 있었습니다.

아울러 이번에 실험하면서 S2S에 대해 가장 크게 느낀 점은 S2S는 예측시 비교적 일반적인 단어를 출력하는 경우가 많다는 점입니다. 위 결과에도 알 수 있듯 경제 기사엔 ‘삼성’, 안보 기사엔 ‘대북’, 경제정책 기사엔 ‘예산’ 같은 일반적인 단어를 예측하고 있습니다. 이는 고차원의 입력데이터를 추상화하여 표현하는 S2S만의 특징이 아닌가 생각이 듭니다. 제가 듣기로 챗봇(chatbot) 구현을 위해 대화 데이터를 S2S 입력으로 줄 경우, ‘아…’, ‘네…’, ‘그럼요’ 정도의 답을 내놓게 된다고 하는군요. ‘아…’, ‘네…’, ‘그럼요’ 같은 단어들은 어떤 대화에서도 성립되는, 오답이 아닌 답변이기 때문인 것 같은데요. 앞으로 연구해볼 만한 주제라는 생각이 듭니다.

향후 계획

제 컴퓨터(i5-3690, 32GB RAM, GTX 970) 기준으로 예측해야할 단어수가 6만개가 넘어가면 메모리 부족으로 학습 자체가 불가능했습니다. 단어수를 효과적으로 줄이거나 하드웨어 업그레이드 등을 통해 추가로 실험할 계획입니다. 정치, 경제, 사회 등 도메인별로 학습해볼 생각도 있습니다. 이번 포스팅 관련해서 제언이나 질문 있으시면 언제든지 메일이나 댓글로 알려주시기 바랍니다. 지금까지 읽어주셔서 감사합니다.

Comments