본문 바로가기

& other stories/Study

[NLP] TextRank 이용해 핵심 키워드 추출하기

1. Summarization: extractive approaches와 abstractive approaches

- 문서 집합을 요약하는 분야를 summarization이라 하며 이 분야의 접근법은 문장 생성 방식에 따라 extractive approaches와 abstractive approaches로 나뉜다. extractive approaches는 문서 집합 내에서 이를 대표하는 단어나 문장을 추출하는 것이고, abstractive approaches는 사람이 요약문을 만드는 것처럼, 문서 집합, 내용을 기반으로 요약문을 생성하는 방법이다. 

 

- abstractive approaches의 경우 특정 도메인의 문서 집합을 요약하는 모델을 만들기 위해서는 해당 도메인을 요약한 학습 데이터가 반드시 필요하다는 단점이 있다. 그에 반해 extractive approaches는 대부분 통계기반으로 동작하여 사전 학습 데이터가 필요하지 않다.

 

- 우리는 동화책 데이터에 있는 핵심 키워드를 추출하여 새로운 내용을 생성하기 위한 input으로 사용할 것이다. 따라서 대표적인 extractive approaches인 TextRank를 사용해보도록 하자.

 


2. TextRank란? - 이론

TextRank는 PageRank의 응용이므로 먼저 PageRank를 알아야 한다. 또한 PageRank를 이해하기 위해서는 마르코프 연쇄를 알아야 한다.

- 마르코프 체인

마르코프 체인의 정의란 마르코프 성질을 가진 이산 확률과정을 의미한다. 여기서 마르코프 성질은 ‘특정 상태의 확률은 오직 과거의 상태에 의존한다’라는 것이다. 

예를 들어, 아래 그림은 상태 A에서 상태 E로 전이할 확률을 0.4, 반대의 확률을 0.7로 상태 E와 A를 반복할 경우의 확률을 각각 0.3과 0.6으로 보고 상태 전이도를 나타낸 것이다.  

 

출처: 위키피디아

 

이를 행렬로 나타내면 다음과 같이 나타낼 수 있다. 1행 1열은 A, 2행 2열은 E에 관여한다. 즉 A에서 A로 갈 확률은 0.6, E에서 A로 전이할 확률은 0.7인 것이다. 

 

 

이러한 행렬을 추이 행렬이라고 한다. 이때 한 열의 모든 확률을 더하면 1이 되므로 추이행렬의 모든 열은 확률벡터이다.

 

만약 초기값이 A와 E, 그로부터 한번 전이가 일어난 상황이 0.6A+0.7E, 0.4A+0.3E라고 하자. 그럼 다음과 같은 전이가 한번 더 일어난다면 결과는 다음과 같다.

 

 

여전히 A와 E에 대해 더하면 1이 되는 확률 벡터이다. N번을 반복하면, 결과값은 확률벡터로 이루어진 추이 행렬의 N제곱 * 초기값 (A, E)에 의해 정해진다.

 

이때, 추이행렬을 무한히 거듭제곱하면 반드시 하나의 극한 행렬에 수렴하게 된다. 이 극한 행렬과 초기값(A,E)를 곱한 값은 결국 추이 행렬을 통해 변화하는 값들이 결국 어떤 안정적인 상태에 다다르는지를 알 수 있게하는 값이 된다.

 

- PageRank

그래서 결국 위의 마르코프 체인이 PageRank와 어떤 관련이 있다는 건지 알아보자. 먼저 PageRank는 구글 서치 결과로 어떤 웹사이트를 제일 상위에 보여줄지를 결정하는 알고리즘이다. 페이지의 중요도를 평가하는 기준이 다른 웹사이트에서 얼마나 많이 참조하는지, 링크를 타고 들어오는지가 되는 것이다. 

 

 

 

만약 다음과 같이 페이지에 링크가 걸려있다고 한다면 페이지 A, B, C 사이의 링크는 다음과 같은 행렬로 표현된다.

 

이동행렬을 추이행렬로 바꾸면 다음과 같고, 이제 이 추이행렬을 무한히 거듭제곱하면 A, B, C 페이지의 안정적인 방문자 수를 알아낼 수 있다. 이 안정적인 방문자 수가 많은 페이지가 중요도가 높게 측정될 것이다.

 

 

모든 추이행렬은 극한 행렬을 가지므로 추이 행렬과 초기값 벡터가 주어지면 우리는 안정 상태에 다다른 페이지의 중요도를 알 수 있다는 것

 

페이지 랭크의 실제 적용 수식

- TextRank

이 PageRank에서 파생된 알고리즘이 TextRank이다. PageRank는 위와 같이 그래프를 대상으로 하는 알고리즘이므로 텍스트에서 정점이 될만한 단위와 이 단위들끼리 연결 관계를 지정해 줄 수 있다면, PageRank를 적용할 수 있는 것이다. 우리는 각각의 단어를 정점으로 잡고, 한 문장에서 같이 등장하는 동시 출현 빈도를 가지고 간선을 구축하여 주요 키워드를 추출할 것이다. 이 경우 TextRank의 값은 각 단어의 중요도를 표현하게 되고, 만약 단위를 더 크게 잡는다면 문장 요약이 가능해진다. 

 


3. TextRank 구현 - 키워드 추출

(lovit님께서 Python으로 작성한 TextRank 패키지 구현을 참조하여 공부하였습니다.)

 

TextRank가 실제로 어떻게 구현되는지 알아보자. TextRank는 핵심키워드와 문장을 추출하는 기능 두가지가 있다. 그 중 우리는 키워드를 추출하기 위해 TextRank를 사용할 예정이다.

 

1. 키워드를 추출하기 위해서는 먼저 단어그래프를 만들어야 한다. 이를 위해 단어는 문서집합에서 최소 빈도수 min_count 이상 출현한 단어들로 한다. 해당 코드에서는 min_count=2로 설정하였다. 

 

from collections import Counter

def scan_vocabulary(sents, tokenize, min_conunt=2):
	counter = Counter(w for sent in sents for w in tokenize(sent))
    counter = {w: c for w, c in counter.items() if c >= min_count}
    idx_to_vocab = [w for w, _ in sorted(counter.items(), key:lambda x:-x[1])]
    vocab_to_idx = {vocab:idx for idx, vocab in enumerate(idx_to_vocab)}
    return idx_to_vocab, vocab_to_idx

 

 

코드를 보면, string으로 들어온 문장을 토크나이저로 쪼개 list로 만들고, Counter 클래스를 이용해 단어의 빈도수를 세고 있다. 이 빈도수가 min_count 이상인 단어들에 대해서 단어가 key, 빈도수가 value인 딕셔너리를 생성하고, value를 기준으로 정렬한 뒤 인덱스를 부여하고 있음을 확인할 수 있다.

 

2. 두 단어의 co-occurrence를 계산한다. Co-occurrence 는 문장 내에서 두 단어의 간격이 window인 횟수이다. (textrank 논문에서는 2~8 사이를 추천한다고 한다.)

 

from collections import defaultdict
from scipy.sparse import csr_matrix

def dit_to_mat(d, n_rows, n_cols):
	rows, cols, data = [], [], []
    for (i, j), v in d.items():
    	rows.append(i)
        cols.append(j)
        data.append(v)
	return csr_matrix((data, (rows, cols)), shape=(n_rows, n_cols))

def cooccurrence(tokens, vocab_to_idx, window=2, min_cooccurrence=2):
    counter = defaultdict(int)
    for s, tokens_i in enumerate(tokens):
        vocabs = [vocab_to_idx[w] for w in tokens_i if w in vocab_to_idx]
        n = len(vocabs)
        for i, v in enumerate(vocabs):
            if window <= 0:
                b, e = 0, n
            else:
                b = max(0, i - window)
                e = min(i + window, n)
            for j in range(b, e):
                if i == j:
                    continue
                counter[(v, vocabs[j])] += 1
                counter[(vocabs[j], v)] += 1
    counter = {k:v for k,v in counter.items() if v >= min_cooccurrence}
    n_vocabs = len(vocab_to_idx)
    return dict_to_mat(counter, n_vocabs, n_vocabs)

 

이후 dict_to_mat 함수를 이용해 sparse matrix로 변환한 후 반환한다.

 

3. 이제 단어 그래프를 생성하도록 한다.

 

def word_graph(sents, tokenize=None, min_count=2, window=2, min_cooccurrence=2):
    idx_to_vocab, vocab_to_idx = scan_vocabulary(sents, tokenize, min_count)
    tokens = [tokenize(sent) for sent in sents]
    g = cooccurrence(tokens, vocab_to_idx, window, min_cooccurrence, verbose)
    return g, idx_to_vocab

 

여기서 입력되는 tokenize 함수에서 불필요한 결과를 낼 수 있는 단어(출현빈도만 높고 의미는 없는 관사, 조사 등)를 걸러내줘야 명사, 동사, 형용사 등의 의미있는 단어들로 단어 그래프를 만들 수 있다.

 

4. pagerank를 학습하는 함수를 만든다. 

 

import numpy as np
from sklearn.preprocessing import normalize

def pagerank(x, df=0.85, max_iter=30):
    assert 0 < df < 1

    # initialize
    A = normalize(x, axis=0, norm='l1')
    R = np.ones(A.shape[0]).reshape(-1,1)
    bias = (1 - df) * np.ones(A.shape[0]).reshape(-1,1)

    # iteration
    for _ in range(max_iter):
        R = df * (A * R) + bias

    return R

 

이 부분이 위에서 공부했던 pagerank를 학습하는 부분이다. 컬럼의 합이 1이 되도록 L1 정규화 하고 있다.

 

5. 최종적인 핵심 키워드 함수는 다음과 같다.

 

def textrank_keyword(sents, tokenize, min_count, window, min_cooccurrence, df=0.85, max_iter=30, topk=30):
    g, idx_to_vocab = word_graph(sents, tokenize, min_count, window, min_cooccurrence)
    R = pagerank(g, df, max_iter).reshape(-1)
    idxs = R.argsort()[-topk:]
    keywords = [(idx_to_vocab[idx], R[idx]) for idx in reversed(idxs)]
    return keywords

 


4. TextRank를 적용해 핵심키워드 추출하기 - Tutorials

 

1. TextRank 패키지를 설치한다.

 

pip install git+https://github.com/lovit/textrank.git

 

2. 먼저 토크나이저로 konlpy의 Komoran을 이용하여 tale.txt 파일에 대한 형태소 분석을 진행한 tale_komoran.txt 파일을 생성한다.

 

from konlpy.tag import Komoran

with open("tale.txt", "r", encoding="utf-8") as f:
    sents = f.read()

komoran = Komoran()
def komoran_tokenizer(sent):
    words = komoran.pos(sent, join=True)
    words = [w for w in words if ('/NN' in w or '/XR' in w or '/VA' in w or '/VV' in w)]
    return words

with open('./tale_komoran.txt', 'w') as f:
    f.writelines(komoran_tokenizer(sents))

 

※ 패키지에 있는 konlpy가 에러가 나서 새로 다운받아 진행을 했는데, 그 과정에서 내가 겪은 에러와 해결방법을 정리하였다.

1. AttributeError: module 'tweepy' has no attribute 'StreamListener'
StreamListener 클래스가 Tweepy 버전 4부터 Stream 이란 클래스로 통합이 되었다고 한다. konlpy를 업데이트 할 수 없으니 tweepy 버전을 낮춘다. (pip install tweepy=3.10.0 이용)
2. TypeError: can't apply this __setattr__ to _jpype._JClass object. 
파이썬 3.8.5부터 jpype1 0.7.5 버전에서 konlpy와는 상관없이 해당 에러가 발생한다고 한다. 따라서 jpype1의 버전을 올려봤다. (pip install -U "jpype1<1.1" 이용. 참조)

 

다음과 같은 결과를 얻을 수 있었다.

 

 

이때, 위에서 언급했듯이 모든 품사를 포함하면 상대적으로 많이 출현하는 관사, 조사가 상위에 랭크될 수 있으므로 명사, 동사, 형용사, 어근의 품사만 이용하여 단어 그래프를 만들도록 하였다.

 

3. textrank의 keywordSummarizer 클래스를 이용해 문장 리스트와 추출 키워드 개수를 인자로 넣고 summarize 함수를 호출한다.

 

from textrank import KeywordSummarizer

with open("./tale_komoran.txt", "r") as f:
    sents = [sent.strip() for sent in f]

keyword_summarizer = KeywordSummarizer(tokenize=komoran_tokenizer, min_count=2, min_cooccurrence=1)
keywords = keyword_summarizer.summarize(sents, topk=20)

for word, rank in keywords:
     print('{} ({:.3})'.format(word, rank))

 

다음과 같은 결과값을 얻을 수 있다.

하/VV (38.1)
말/NNG (31.7)
있/VV (29.3)
씨/NNB (20.8)
되/VV (20.6)
가/VV (18.7)
없/VA (15.8)
것/NNB (14.3)
수/NNB (14.2)
보/VV (14.0)
거/NNB (13.1)
집/NNG (12.9)
들/VV (12.6)
먹/VV (10.2)
자신/NNG (10.0)
사람/NNG (9.63)
위/NNG (9.36)
속/NNG (9.0)
그렇/VA (8.71)
봉사/NNP (8.56)

 

4. 단어가 너무 많이 분리되어 한 글자 단어가 상위권에 많이 랭크되는 결과가 나왔다. 우리의 프로젝트에서는 주로 명사형 단어를 키워드로 추출해 제시할 것이므로 토크나이저가 명사와 어근만 거를 수 있도록 다시 설정해보자.

 

komoran = Komoran()
def komoran_tokenizer(sent):
    words = komoran.pos(sent, join=True)
    words = [w for w in words if ('/NN' in w or '/XR' in w )]
    return words

 

다음과 같은 결과값을 얻었다.

말/NNG (34.6)
씨/NNB (23.3)
것/NNB (14.8)
수/NNB (14.5)
거/NNB (13.9)
집/NNG (13.1)
자신/NNG (10.9)
사람/NNG (10.2)
위/NNG (10.1)
속/NNG (9.8)
봉사/NNP (9.31)
때/NNG (9.09)
날/NNG (8.4)
생각/NNG (8.18)
나무/NNG (8.14)
번/NNB (8.08)
늑대/NNP (7.6)
다음/NNG (7.16)
눈/NNG (7.1)
게/NNG (6.78)

 

→TextRank가 문서 집합 내에서 자주 등장한 단어를 추출하는 경향이 있어 빈도수가 높은 한글자 단어가 여전히 많이 추출되고있다. 프로젝트에 input으로 사용될 키워드가 한글자일 경우 본문 생성에 어려움이 있을 수 있으므로, 토크나이저를 더 customize하거나 추출할 때 글자수 제한을 해야할 것으로 보인다. 

(또한 TextRank는 핵심 단어를 추출하는 기술인데, 이 경우 통일되지 않은 내용의 동화들이 input으로 사용되었다. 다양한 동화에서 등장하는 범용 키워드들(이 역시 동화 유형에 따라 grouping한 후 추출할 예정)과 더불어 특정 동화의 핵심 키워드들 역시 추출해 user 입력 키워드와 함께 가중치를 다르게 적용하여 input으로 사용될 예정이다.)

 

5. 추가적으로 맥락 유지를 위해 선택된 동화책 내의 키워드를 추출하고 가중치를 부여해주는 작업이 필요하다. 이를 위한 하나의 동화책에 대한 명사, 어근 추출 결과는 다음과 같이 연결도가 높은 핵심 단어들이 추출된 것을 확인할 수 있었다.

공주와 개구리 핵심 단어 추출