[Python 인공지능] TextRank 를 이용한 키워드 추출과 핵심 문장 추출 (구현과 실험)

문서 집합을 요약하는 방법으로 키워드와 핵심 문장을 선택하는 extractive methods 를 이용할 수 있습니다. 이를 위해 가장 널리 이용되는 방법 중 하나는 2004 년에 제안된 TextRank 입니다. TextRank 는 word graph 나 sentence graph 를 구축한 뒤, Graph ranking 알고리즘인 PageRank 를 이용하여 각각 키워드와 핵심 문장을 선택합니다. 그리고 이들을 이용하여 주어진 문서 집합을 요약합니다. 그 뒤, TextRank 와 유사한 방법들이 여러 제안되었지만, 큰 차이는 없습니다. 이번 포스트에서는 TextRank 의 원리를 정리하고, TextRank 가 키워드와 핵심 문장을 추출하는 기준에 대한 직관적인 탐색도 해봅니다.

Introduction

문서 집합을 요약하는 분야를 summarization 이라 하며, 이 분야의 접근법은 extractive approaches 와 abstractive approaches 로 나뉩니다. Extractive approaches 는 주어진 문서 집합 내에서 이를 대표할 수 있는 단어들이나 문장들을 선택하는 방법입니다. 이러한 방법은 주어진 데이터에서 단어와 문장을 선택하기 때문에 터무니없는 요약 결과를 만들어 낼 가능성은 적습니다. 하지만 가능한 표현이 제한된다는 단점이 있습니다. 최근의 자연어처리 분야에서 딥러닝 모델들의 발전이 있기 전에는 extractive approaches 방법을 떠올리면 사실 TextRank 외에 다른 방법들이 잘 떠오르지 않을 정도로 TextRank 가 널리 이용되었습니다.

그와 반대로 abstractive approaches 는 사람이 요약문을 만드는 것처럼, 문서 집합 혹은 한 문서의 내용을 기반으로 요약문을 생성하는 방법입니다. 최근에는 sequence to sequence + attention 기반 모델에 copy mechanism, pointer network 등의 기법들이 더해지며 많은 발전을 이루고 있는 분야입니다. 그리고 최근의 연구들은 이 두 접근법을 부분적으로 모두 이용하는 형태로 발전하고 있습니다.

그런데 abstractive approaches 의 가장 큰 단점은 학습 데이터를 기반으로 한 supervised learning 이라는 점 입니다. 특정 도메인의 문서 집합을 요약하는 모델을 만들기 위해서는 해당 도메인을 요약한 학습 데이터가 반드시 필요합니다. 이에 반해 TextRank 로 대표되는 전통적인 extractive approaches 는 대부분 통계 (PageRank) 기반으로 작동하기 때문에 별도의 학습 데이터가 필요하지 않습니다. 또한 모델 특성상 학습도 매우 빠릅니다. Gensim 에 포함되어 있는 summarizer 함수도 TextRank 와 비슷한 방식으로 작동합니다.

TextRank 는 핵심 단어를 선택하기 위해서 단어 간의 co-occurrence graph 를 만듭니다. 핵심 문장을 선택하기 위해서는 문장 간 유사도를 기반으로 sentence similarity graph 를 만듭니다. 그 뒤 각각 그래프에 PageRank 를 학습하여 각 마디 (단어 혹은 문장) 의 랭킹을 계산합니다. 이 랭킹이 높은 순서대로 키워드와 핵심 문장이 됩니다. TextRank 의 원리를 이해하기 위하여 우선 PageRank 를 간략히 리뷰합니다.

Brief review of PageRank

많은 수의 머신 러닝 알고리즘은 벡터 공간 위에서 설계되었습니다. 그리고 이들이 이용하는 데이터는 벡터로 표현됩니다. 그래프도 데이터를 표현하는 한 가지 방법입니다. 아래의 소셜 네트워크 그래프는 사람이 그래프의 마디 (node, vertex), 그리고 사람 간의 친밀도 혹은 영향력이 호 (edge) 로 표현됩니다. 흔히 그래프를 G=(V,E) 로 표현하는데, V 와 E 는 vertex 와 edge 를 의미합니다.

텍스트 데이터도 그래프로 표현할 수 있습니다. 문장 내에서의 co-occurrence 나 토픽 정보를 바탕으로 두 단어 간의 유사도를 정의하면 아래와 같은 단어 그래프를 만들 수 있습니다.

그래프 데이터에서 학습할 수 있는 몇 가지 질문 중 하나는 어떤 마디가 그래프 내에서 중요한 마디인가 입니다. 소셜 네트워크 분석에서는 영향력이 큰 사람을 찾는 문제일 수 있으며, 단어 그래프에서는 그래프를 대표하는 핵심 단어를 선택하는 문제일 수 있습니다. 이러한 문제를 graph ranking 이라 합니다.

PageRank 는 가장 대표적인 graph ranking 알고리즘입니다. Google 의 Larry Page 가 초기 Google 의 검색 엔진의 랭킹 알고리즘으로 만든 알고리즘으로도 유명합니다. 웹페이지 그래프에서 중요한 페이지를 찾아서 검색 결과의 re-ranking 의 과정에서 중요한 페이지의 랭킹을 올리는데 이용되었습니다. 중요한 웹페이지를 찾기 위하여 PageRank 는 매우 직관적인 아이디어를 이용하였습니다. 많은 유입 링크 (backlinks)를 지니는 페이지가 중요한 페이지라 가정하였습니다. 일종의 ‘투표’입니다. 각 웹페이지는 다른 웹페이지에게 자신의 점수 중 일부를 부여합니다. 다른 웹페이지로부터의 링크 (backlinks) 가 많은 페이지는 자신에게 모인 점수가 클 것입니다. 자신으로 유입되는 backlinks 가 적은 페이지는 다른 웹페이지로부터 받은 점수가 적을 것입니다. 또한 모든 페이지가 같은 양의 점수를 가지는 것이 아닙니다. 중요한 페이지는 많은 점수를 가지고 있습니다. Backlinks 가 적은 링크라 하더라도 중요한 페이지에서 투표를 받은 페이지는 중요한 페이지가 됩니다.

즉 중요한 페이지로부터 많은 유입을 받는 페이지가 중요한 페이지라고 각 웹페이지의 중요도를 정의합니다. 이는 재귀적 (recursive) 인 정의입니다. 그리고 이러한 방식의 정의는 이후 TextRank 나 SimRank 와 같은 graph ranking & similarity 알고리즘에서의 마디 간 중요도나 유사도의 정의에 이용되었습니다.

여기에 한 가지 더, 한 페이지의 유입은 backlinks 외에도 임의적인 유입이 있을 수 있습니다. 그것은 검색일 수도, 혹은 알고 있는 웹주소에 의한 유입일 수도 있습니다. PageRank 는 웹페이지 유입의 c 만큼은 링크에 의한, 1c 만큼은 임의적인 유입이라 가정하여 아래와 같은 식을 기술합니다. 이 임의 유입은 PageRank 계산의 안정성을 가져오는 역할도 합니다.

PR(u)=c×vBuPR(v)Nv+(1c)×1N

Bu 는 마디 u 로의 backlink 출발점들이며, Nv 는 각 마디 v 의 링크 개수 입니다. 한 마디 v 는 자신의 랭킹을 Nv 개로 나눠 링크로 연결된 페이지 u 에 전달합니다. 중요한 (랭킹이 높은 ) 마디로부터 backlinks 가 많은 마디는 랭킹이 높게 됩니다.

PageRank 의 더 자세한 의미 및 구현에 대해서는 다음의 블로그에 자세히 정리해 두었습니다. (링크)

TextRank

PageRank 가 1999 년도에 논문이 나온 뒤 5 년 뒤, 2004 년에 TextRank 가 제안되었습니다. 그 사이에 PageRank 의 마디의 중요도 정의 방식을 이용하는 정말 많은 x-Rank 이름의 알고리즘들이 제안되었습니다.

TextRank based keyword extraction

TextRank 는 키워드 추출 기능과 핵심 문장 추출 기능, 두 가지를 제공합니다. 키워드를 추출하기 위해서 먼저 단어 그래프를 만들어야 합니다. 마디인 단어는 주어진 문서 집합에서 최소 빈도수 min_count 이상 등장한 단어들 입니다. sents 는 list of str 형식의 문장들이며, tokenize 는 str 형식의 문장을 list of str 형식의 단어열로 나누는 토크나이저 입니다.

from collections import Counter

def scan_vocabulary(sents, tokenize, min_count=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

TextRank 에서 두 단어 간의 유사도를 정의하기 위해서는 두 단어의 co-occurrence 를 계산해야 합니다. Co-occurrence 는 문장 내에서 두 단어의 간격이 window 인 횟수입니다. 논문에서는 2 ~ 8 사이의 값을 이용하기를 추천하였습니다. 여기에 하나 더하여, 문장 내에 함께 등장한 모든 경우를 co-occurrence 로 정의하기 위하여 window 에 -1 을 입력할 수 있도록 합니다. 또한 그래프가 지나치게 dense 해지는 것을 방지하고 싶다면 min_coocurrence 를 이용하여 그래프를 sparse 하게 만들 수도 있습니다.

from collections import defaultdict

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 함수는 dict of dict 형식의 그래프를 scipy 의 sparse matrix 로 변환하는 함수입니다.

from scipy.sparse import csr_matrix

def dict_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))

TextRank 에서는 명사, 동사, 형용사와 같은 단어만 단어 그래프를 만드는데 이용합니다. 모든 종류의 단어를 이용하면 ‘a’, ‘the’ 와 같은 단어들이 다른 단어들과 압도적인 co-occurrence 를 지니기 때문입니다. 즉, stopwords 를 지정할 필요가 있다면 지정하여 키워드 후보만 그래프에 남겨둬야 한다는 의미입니다. 그러므로 입력되는 tokenize 함수는 불필요한 단어를 모두 걸러내고, 필요한 단어 혹은 품사만 return 하는 함수이어야 합니다.

이 과정을 정리하면 아래와 같은 word_graph 함수를 만들 수 있습니다.

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

그 뒤 만들어진 그래프에 PageRank 를 학습하는 함수를 만듭니다. 입력되는 x 는 co-occurrence 그래프일 수 있으니, column sum 이 1 이 되도록 L1 normalization 을 합니다. 이를 A 라 합니다. A * R 은 column j 에서 row i 로의 랭킹 Rj 의 전달되는 값을 의미합니다. 이 값에 df 를 곱하고, 모든 마디에 1 - df 를 더합니다. 이를 max_iter 만큼 반복합니다.

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

이 과정을 정리하면 아래와 같은 textrank_keyword 함수를 만들 수 있습니다.

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

TextRank based key-sentence extraction

TextRank 를 이용하여 핵심 문장을 추출하기 위해서는 문장 그래프를 만들어야 합니다. 각 문장이 마디가 되며, edge weight 는 문장 간 유사도 입니다. 일반적으로 문서 간 혹은 문장 간 유사도를 측정하기 위하여 Cosine similarity 가 이용되는데, TextRank 는 아래와 같은 문장 간 유사도 척도를 제안했습니다. 두 문장에 공통으로 등장한 단어의 개수를 각 문장의 단어 개수의 log 값의 합으로 나눈 것 입니다.

sim(s1,s2)=|{wk|wkS1&wkS2}|log|S1|+log|S2|

그런데 위의 척도는 한 가지 특징이 있습니다. 이 값의 최대값은 1 이 아니며, 문장의 길이가 길수록 높은 유사도를 지닙니다. 예를 들어 두 문장 S1,S2 가 모두 16 개 단어로 구성되어 있고, 이 중 15 개가 겹친다면 두 문장의 유사도는 15 / (4 + 4) = 1.85 입니다. 문장 길이에 log 를 부여하기 때문에 길이가 길어질수록 분모의 값의 증가분은 줄어듭니다. 대신, 길이가 길기 때문에 다른 문장과 중복된 단어가 등장할 가능성은 높아집니다. 즉 TextRank 는 길이가 긴 문장을 선호합니다.

또한 한 문장이 여러 문장과 높은 유사도를 지니기 위해서는 주어진 문서 집합에서 자주 등장한 단어들을 여러 개 포함해야 합니다. 앞서 tokenize 함수에서 관사와 같은 문법 기능의 단어들을 제거하고, 명사나 형용사와 같이 의미를 지니는 단어만 남겨 두었기 때문에 여러 문장들고 높은 유사도를 지니는 문장은, 주어진 문서 집합에서 자주 등장한 명사 / 동사 / 형용사들로 이뤄진 문장입니다. 문서 집합에서 반복적으로 사용되는 의미있는 단어들을 여러 개 지닌 문장은 핵심 문장일 가능성이 높습니다.

문장 간 유사도로 Cosine similarity 를 이용하여도 이러한 현상은 동일하지만, Cosine similarity 는 길이가 짧은 문장에 민감하게 반응할 수 있습니다. 앞선 예시에서처럼 16 개의 단어로 구성된 두 문장 사이에 공통된 단어가 15 개가 등장할 가능성은 매우 작습니다. 아마도 3, 4 개의 단어가 공통으로 등장했을 것입니다. 만약 다른 문장이 2 개의 단어로 구성되어 있다면, 이중 하나의 단어만 함께 등장하여도 절반이 넘는 단어가 공통으로 등장한 것이 됩니다. TextRank 는 이러한 문제를 해결하기 위하여 위와 같은 문장 간 유사도 척도를 재정의 했습니다.

이후에 다른 문장 간 유사도를 이용하는 방법들이 제안되었습니다만, 그 결과는 크게 다르지 않습니다. LexRank (Erkan at al., 2004) 는 TF-IDF + Cosine similarity 를 이용하였으며, Gensim (Barrios et al., 2016) 의 summarize 함수는 검색 엔진에서 이용하는 BM25 라는 문서 간 유사도 함수를 이용하였습니다.

위의 이야기를 아래의 함수로 구현합니다. 실험을 위하여 문장 간 유사도를 Cosine similarity 와 TextRank 의 유사도 모두 구현합니다. 물론 문장 간 유사도를 아래와 같이 str 연산으로 구현하면 매우 느립니다만, 눈에 보기 편한 코드로 구현해뒀습니다.

또한 min_sim 이라는 argument 를 추가하였습니다. 문장 간 그래프의 sparsity 가 클수록 PageRank 의 계산이 빠릅니다. 이를 위하여 문장 간 유사도가 0.3 보다 작은 경우에는 edge 를 연결하지 않습니다.

경축! 아무것도 안하여 에스천사게임즈가 새로운 모습으로 재오픈 하였습니다.
어린이용이며, 설치가 필요없는 브라우저 게임입니다.
https://s1004games.com

from collections import Counter
from scipy.sparse import csr_matrix
import math

def sent_graph(sents, tokenize, similarity, min_count=2, min_sim=0.3):
    _, vocab_to_idx = scan_vocabulary(sents, tokenize, min_count)

    tokens = [[w for w in tokenize(sent) if w in vocab_to_idx] for sent in sents]
    rows, cols, data = [], [], []
    n_sents = len(tokens)
    for i, tokens_i in enumerate(tokens):
        for j, tokens_j in enumerate(tokens):
            if i >= j:
                continue
            sim = similarity(tokens_i, tokens_j)
            if sim < min_sim:
                continue
            rows.append(i)
            cols.append(j)
            data.append(sim)
    return csr_matrix((data, (rows, cols)), shape=(n_sents, n_sents))

def textrank_sent_sim(s1, s2):
    n1 = len(s1)
    n2 = len(s2)
    if (n1 <= 1) or (n2 <= 1):
        return 0
    common = len(set(s1).intersection(set(s2)))
    base = math.log(n1) + math.log(n2)
    return common / base

def cosine_sent_sim(s1, s2):
    if (not s1) or (not s2):
        return 0

    s1 = Counter(s1)
    s2 = Counter(s2)
    norm1 = math.sqrt(sum(v ** 2 for v in s1.values()))
    norm2 = math.sqrt(sum(v ** 2 for v in s2.values()))
    prod = 0
    for k, v in s1.items():
        prod += v * s2.get(k, 0)
    return prod / (norm1 * norm2)

이를 정리하여 아래와 같은 핵심 문장 추출 함수를 만듭니다.

def textrank_keysentence(sents, tokenize, min_count, similarity, df=0.85, max_iter=30, topk=5)
    g = sent_graph(sents, tokenize, min_count, min_sim, similarity)
    R = pagerank(g, df, max_iter).reshape(-1)
    idxs = R.argsort()[-topk:]
    keysents = [(idx, R[idx], sents[idx]) for idx in reversed(idxs)]
    return keysents

Experiments

위의 내용들을 패키지 형태로 정리하여 github 에 올려두었습니다. 이를 이용하여 몇 가지 실험을 해봅니다.

데이터는 네이버 영화에서 수집한 라라랜드 영화의 영화평 15,595 문장입니다. 토크나이저로는 KoNLPy 의 코모란을 이용하였습니다. 명사, 동사, 형용사, 어간의 품사만 이용하여 단어 그래프를 만들었습니다.

from konlpy.tag import Komoran

komoran = Komoran()
def komoran_tokenize(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

KeywordSummarizer 의 summarize 함수는 위에서 만든 textrank_keyword 함수입니다. 학습에 필요한 arguments 를 설정하는 부분을 KeywordSummarizer 의 init 함수에 넣어뒀으며, verbose mode 도 추가하였습니다. window 를 -1 로 설정하였으므로, 문장에서 함께 등장한 모든 단어 간에는 co-occurrnce 가 있습니다.

from textrank import KeywordSummarizer

keyword_extractor = KeywordSummarizer(
    tokenize = komoran_tokenize,
    window = -1,
    verbose = False
)

keywords = keyword_extractor.summarize(sents, topk=30)

아래는 30 개의 키워드 입니다. 단어, ‘영화’가 일반 명사 (NNG) 와 고유 명사 (NNP) 로 나뉘어 진 것은 토크나이저 문제이니 넘어갑니다. 그 외에는 뮤지컬, 영상미, 음악, 마지막과 같은 라라랜드를 기술하는 단어들이 키워드로 선택되었음을 알 수 있습니다. 단어 옆 괄호는 TextRank 로부터 계산된 랭킹값 입니다. 단어 간 상대적인 중요도로 해석할 수 있습니다.

영화/NNG (173) 영화/NNP (43.8) 수/NNB (30.1) 생각/NNG (23.2) 사람/NNG (19.0)
보/VV (1.29e+02) 음악/NNG (43.6) 사랑/NNG (28.3) 스토리/NNP (21.4) 여운/NNP (17.5)
좋/VA (65.5) 꿈/NNG (41.4) 아름답/VA (26.5) 번/NNB (20.3) 뮤지컬/NNP (16.9)
하/VV (52.0) 있/VV (40.8) 현실/NNG (24.8) 거/NNB (19.7) 나오/VV (16.5)
것/NNB (47.4) 없/VA (35.9) 되/VV (23.9) 최고/NNG (19.2) 듯/NNB (16.1)
같/VA (45.4) 마지막/NNG (31.9) 노래/NNG (23.4) 때/NNG (19.1) 영상미/NNG (16.0)

만약 아래와 같이 모든 품사의 단어를 이용하여 단어 그래프를 만들 경우에는 아래와 같이 무의미하지만, 문장에서 자주 등장하는 단어들인 조사나 어미가 키워드로 선택됩니다.

def komoran_tokenize(sent):
    return komoran.pos(sent, join=True)

keyword_extractor = KeywordSummarizer(tokenize = komoran_tokenize, window = -1)
keywords = keyword_extractor.summarize(sents, topk=30)
ㄴ/ETM (1.24e+02) 하/XSV (85.2) 을/JKO (64.2) 게/EC (46.7) 은/ETM (33.7)
고/EC (1.03e+02) 에/JKB (79.0) 하/XSA (58.8) 는/JX (42.3) 들/XSN (32.6)
영화/NNG (96.8) 았/EP (76.1) 의/JKG (58.4) 어/EC (37.9) 은/JX (32.0)
는/ETM (94.6) 보/VV (73.5) 도/JX (52.7) 좋/VA (37.6) 하/VV (29.8)
이/VCP (92.3) 었/EP (72.8) ㄹ/ETM (50.2) 를/JKO (34.3) 것/NNB (26.7)
이/JKS (92.0) 다/EC (68.3) 가/JKS (47.2) 아/EC (33.8) 과/JC (26.5)

window 의 크기를 바꾼다 하여도 큰 변화는 없습니다. 약간의 순위 변동은 있지만, 큰 맥락이 변하지는 않습니다.

def komoran_tokenize(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

keyword_extractor = KeywordSummarizer(tokenize = komoran_tokenize, window = 2)
keywords = keyword_extractor.summarize(sents, topk=30)
영화/NNG (190) 것/NNB (44.6) 아름답/VA (32.1) 스토리/NNP (23.6) 사람/NNG (18.6)
보/VV (1.5e+02) 꿈/NNG (42.5) 사랑/NNG (30.4) 생각/NNG (23.5) 때/NNG (18.0)
좋/VA (80.8) 같/VA (40.7) 수/NNB (29.5) 되/VV (23.1) 거/NNB (18.0)
하/VV (51.2) 있/VV (40.6) 현실/NNG (27.9) 번/NNB (22.7) 지루/XR (17.6)
음악/NNG (50.8) 없/VA (35.5) 노래/NNG (26.1) 여운/NNP (22.1) 영상미/NNG (16.8)
영화/NNP (50.3) 마지막/NNG (33.7) 최고/NNG (23.8) 감동/NNG (19.1) 재밌/VA (16.3)

사실 TextRank 는 문서 집합 내에서 자주 등장한 단어를 키워드로 추출하는 경향이 있기 때문입니다. 일단 빈도수가 높아야 많은 단어들과 연결이 될 수 있습니다. 그리고 그 edge weight 가 높기 위해서는 co-occurrence 가 커야 하며, 이는 그 단어의 빈도수가 어느 정도 크다는 의미이기 때문입니다.

문장 간 유사도를 기반으로 문장 그래프를 만들어 핵심 문장을 추출하는 과정도 KeysentenceSummarizer 라는 클래스로 정리하였습니다. 입력되는 arguments 는 위의 textrank_keysentence 함수와 같습니다.

from textrank import KeysentenceSummarizer

summarizer = KeysentenceSummarizer(tokenize = komoran_tokenize, min_sim = 0.5)
keysents = summarizer.summarize(sents, topk=10)

문장 간 유사도를 TextRank 에서 제안한 방법을 이용하였을 경우의 핵심 문장들 입니다. 대체적으로 문장의 길이가 깁니다. 그리고 꿈, 사랑, 여운, 이야기, 보다/VV 와 같은 단어들이 포함된 문장임을 알 수 있습니다.

시사회 보고 왔어요 꿈과 사랑에 관한 이야기인데 뭔가 진한 여운이 남는 영화예요
시사회 갔다왔어요 제가 라이언고슬링팬이라서 하는말이아니고 너무 재밌어요 꿈과 현실이 잘 보여지는영화 사랑스런 영화 전 개봉하면 또 볼생각입니당
황홀하고 따뜻한 꿈이었어요 imax로 또 보려합니다 좋은 영화 시사해주셔서 감사해요
시사회에서 보고왔는데 여운쩔었다 엠마스톤과 라이언 고슬링의 케미가 도입부의 강렬한음악좋았고 예고편에 나왓던 오디션 노래 감동적이어서 눈물나왔다ㅠ 이영화는 위플래쉬처럼 꼭 영화관에봐야함 색감 노래 배우 환상적인 영화
방금 시사회로 봤는데 인생영화 하나 또 탄생했네 롱테이크 촬영이 예술 영상이 넘나 아름답고 라이언고슬링의 멋진 피아노 연주 엠마스톤과의 춤과 노래 눈과 귀가 호강한다 재미를 기대하면 약간 실망할수도 있지만 충분히 훌륭한 영화
방금 시사회보고 왔어요 정말 힘든 하루였는데 눈이랑 귀가 절로 호강한 영화였어요ㅜ개봉하면 혼자 또 보러갈까해요 마지막에 라이언고슬링의 피아노연주는 아직도 여운이 남네요 뭔가 현실적이여서 더 와닿는 음악영화 좋아하시는분들은 꼭 보시길
ost가 너무좋네요 특히 라이언고슬링이 불르는 노래가 ㄷㄷ 정말 여운이 남아요
사랑과 꿈 그 흐름의 아름다움을 음악과 영상으로 최대한 담아놓았다 배우들 연기는 두말할것없고
시사회 갔다왔는데 실망했어요 너무 기대하면 안될 것 같습니다 꿈 같은 영화 마법 같은 영화는 맞는데 꿈과 마법이 깨지는 순간 이 영화는 어디로 가고 있는가 하는 생각이 들었어요 할 말은 많지만 욕먹을까봐 줄임
오늘 부산국제영화제에서 봤는데영화가 아름답네요 정말 좋은 ost때문에 귀도 호강하네요 개인적으로 엔딩이 너무 좋았던거 같아요

문장 간 유사도로 Cosine similarity 를 이용하여 TextRank 를 계산한 경우입니다. 짧은 문장들이 핵심 문장으로 선택되었습니다. 특히 아래의 좋아용 은악이너뮤신선하고 의 문장에서는 최소 빈도수 때문에 좋다/VA 정도가 문장 벡터를 만드는데 이용되었을 것입니다.

좋다 좋다 정말 너무 좋다 그 말 밖엔 인생영화 등극 ㅠㅠ
음악도 좋고 다 좋고 좋고좋고 다 좋고 씁쓸한 결말 뭔가 아쉽다
제 인생영화 등극이네요 끝나기 전쯤에는 그냥 훌륭한 뮤지컬영화다 라고 생각했는데 마지막에 감독의 메시지가 집약된 화려한 엔딩에서 와 인생영화다 라는생각밖에 안들었네요 개봉하고 2번은 더 보러갈겁니다
이거 2번보고 3번 보세요 진짜 최고입니다
너무 아름다운 영화였어요 ㅎ
나의 인생영화
벌써 두번째 보는 영화인데요 아무리 봐도 잊혀지지 않네요
좋아용 은악이너뮤신선하고
인생영화 두번째봐요
재밌고 좋았어요굿이에요

대체적으로 긴 문장을 선택하는 TextRank 의 문장 간 유사도를 이용한 결과의 품질이 더 좋다는 느낌이 듭니다.

이번에는 아래의 20 줄로 이뤄진 뉴스 기사로부터 요약문을 선택해 봅니다.

sents = [
  '오패산터널 총격전 용의자 검거 서울 연합뉴스 경찰 관계자들이 19일 오후 서울 강북구 오패산 터널 인근에서 사제 총기를 발사해 경찰을 살해한 용의자 성모씨를 검거하고 있다 성씨는 검거 당시 서바이벌 게임에서 쓰는 방탄조끼에 헬멧까지 착용한 상태였다', 
  '서울 연합뉴스 김은경 기자 사제 총기로 경찰을 살해한 범인 성모 46 씨는 주도면밀했다', 
  '경찰에 따르면 성씨는 19일 오후 강북경찰서 인근 부동산 업소 밖에서 부동산업자 이모 67 씨가 나오기를 기다렸다 이씨와는 평소에도 말다툼을 자주 한 것으로 알려졌다', 
  '이씨가 나와 걷기 시작하자 성씨는 따라가면서 미리 준비해온 사제 총기를 이씨에게 발사했다 총알이 빗나가면서 이씨는 도망갔다 그 빗나간 총알은 지나가던 행인 71 씨의 배를 스쳤다', 
  '성씨는 강북서 인근 치킨집까지 이씨 뒤를 쫓으며 실랑이하다 쓰러뜨린 후 총기와 함께 가져온 망치로 이씨 머리를 때렸다', 
  '이 과정에서 오후 6시 20분께 강북구 번동 길 위에서 사람들이 싸우고 있다 총소리가 났다 는 등의 신고가 여러건 들어왔다', 
  '5분 후에 성씨의 전자발찌가 훼손됐다는 신고가 보호관찰소 시스템을 통해 들어왔다 성범죄자로 전자발찌를 차고 있던 성씨는 부엌칼로 직접 자신의 발찌를 끊었다', 
  '용의자 소지 사제총기 2정 서울 연합뉴스 임헌정 기자 서울 시내에서 폭행 용의자가 현장 조사를 벌이던 경찰관에게 사제총기를 발사해 경찰관이 숨졌다 19일 오후 6시28분 강북구 번동에서 둔기로 맞았다 는 폭행 피해 신고가 접수돼 현장에서 조사하던 강북경찰서 번동파출소 소속 김모 54 경위가 폭행 용의자 성모 45 씨가 쏜 사제총기에 맞고 쓰러진 뒤 병원에 옮겨졌으나 숨졌다 사진은 용의자가 소지한 사제총기', 
  '신고를 받고 번동파출소에서 김창호 54 경위 등 경찰들이 오후 6시 29분께 현장으로 출동했다 성씨는 그사이 부동산 앞에 놓아뒀던 가방을 챙겨 오패산 쪽으로 도망간 후였다', 
  '김 경위는 오패산 터널 입구 오른쪽의 급경사에서 성씨에게 접근하다가 오후 6시 33분께 풀숲에 숨은 성씨가 허공에 난사한 10여발의 총알 중 일부를 왼쪽 어깨 뒷부분에 맞고 쓰러졌다', 
  '김 경위는 구급차가 도착했을 때 이미 의식이 없었고 심폐소생술을 하며 병원으로 옮겨졌으나 총알이 폐를 훼손해 오후 7시 40분께 사망했다', 
  '김 경위는 외근용 조끼를 입고 있었으나 총알을 막기에는 역부족이었다', 
  '머리에 부상을 입은 이씨도 함께 병원으로 이송됐으나 생명에는 지장이 없는 것으로 알려졌다', 
  '성씨는 오패산 터널 밑쪽 숲에서 오후 6시 45분께 잡혔다', 
  '총격현장 수색하는 경찰들 서울 연합뉴스 이효석 기자 19일 오후 서울 강북구 오패산 터널 인근에서 경찰들이 폭행 용의자가 사제총기를 발사해 경찰관이 사망한 사건을 조사 하고 있다', 
  '총 때문에 쫓던 경관들과 민간인들이 몸을 숨겼는데 인근 신발가게 직원 이모씨가 다가가 성씨를 덮쳤고 이어 현장에 있던 다른 상인들과 경찰이 가세해 체포했다', 
  '성씨는 경찰에 붙잡힌 직후 나 자살하려고 한 거다 맞아 죽어도 괜찮다 고 말한 것으로 전해졌다', 
  '성씨 자신도 경찰이 발사한 공포탄 1발 실탄 3발 중 실탄 1발을 배에 맞았으나 방탄조끼를 입은 상태여서 부상하지는 않았다', 
  '경찰은 인근을 수색해 성씨가 만든 사제총 16정과 칼 7개를 압수했다 실제 폭발할지는 알 수 없는 요구르트병에 무언가를 채워두고 심지를 꽂은 사제 폭탄도 발견됐다', 
  '일부는 숲에서 발견됐고 일부는 성씨가 소지한 가방 안에 있었다'
]

토크나이저는 코모란을 이용하였습니다.

summarizer = KeysentenceSummarizer(tokenize = komoran_tokenizer, min_sim = 0.3)
keysents = summarizer.summarize(sents, topk=3)

아래의 세 문장이 핵심 문장으로 선택되었습니다. 그럴듯합니다.

오패산터널 총격전 용의자 검거 서울 연합뉴스 경찰 관계자들이 19일 오후 서울 강북구 오패산 터널 인근에서 사제 총기를 발사해 경찰을 살해한 용의자 성모씨를 검거하고 있다 성씨는 검거 당시 서바이벌 게임에서 쓰는 방탄조끼에 헬멧까지 착용한 상태였다
경찰에 따르면 성씨는 19일 오후 강북경찰서 인근 부동산 업소 밖에서 부동산업자 이모 67 씨가 나오기를 기다렸다 이씨와는 평소에도 말다툼을 자주 한 것으로 알려졌다
서울 연합뉴스 김은경 기자 사제 총기로 경찰을 살해한 범인 성모 46 씨는 주도면밀했다

그런데 위의 결과를 얻기 위해서 반드시 제대로 된 토크나이저를 이용할 필요도 없습니다. 이전의 포스트에서 부분어절을 이용하여도 문서 간 유사도가 잘 표현된다는 내용을 다룬 적이 있습니다. 이번에도 subwords 를 단어로 이용해 봅니다. 어자피 많이 등장한 단어라면 해당 단어를 구성하는 부분어절 (subword) 역시 자주 등장하였을 것이며, 이를 이용한 문장 간 유사도를 측정하여도 비슷하기 때문입니다.

아래는 띄어쓰기 기준으로 나뉘어진 어절에서 3음절의 subwords 를 잘라내는 토크나이저 입니다.

def subword_tokenizer(sent, n=3):
    def subword(token, n):
        if len(token) <= n:
            return [token]
        return [token[i:i+n] for i in range(len(token) - n)]
    return [sub for token in sent.split() for sub in subword(token, n)]

subword_tokenizer('이것은 부분단어의 예시입니다 짧은 어절은 그대로 나옵니다')
# ['이것은', '부분단', '분단어', '단어의', '예시입', '시입니', '입니다', '짧은', '어절은', '그대로', '나옵니', '옵니다']

위에서 정의한 토크나이저를 이용하여 세 개의 핵심 문장을 선택합니다.

summarizer = KeysentenceSummarizer(tokenize = subword_tokenizer, min_sim = 0.3)
keysents = summarizer.summarize(sents, topk=3)

이번에는 세 번째 문장이 달라졌지만, 두 개의 문장은 그대로입니다. 그런데 이 결과도 그리 나쁘다는 생각이 들지 않습니다.

오패산터널 총격전 용의자 검거 서울 연합뉴스 경찰 관계자들이 19일 오후 서울 강북구 오패산 터널 인근에서 사제 총기를 발사해 경찰을 살해한 용의자 성모씨를 검거하고 있다 성씨는 검거 당시 서바이벌 게임에서 쓰는 방탄조끼에 헬멧까지 착용한 상태였다
경찰에 따르면 성씨는 19일 오후 강북경찰서 인근 부동산 업소 밖에서 부동산업자 이모 67 씨가 나오기를 기다렸다 이씨와는 평소에도 말다툼을 자주 한 것으로 알려졌다
이 과정에서 오후 6시 20분께 강북구 번동 길 위에서 사람들이 싸우고 있다 총소리가 났다 는 등의 신고가 여러건 들어왔다

Extraction with customized bias

앞서 pagerank 함수를 설명할 때에는 편의를 위하여 bias 를 1 / n 으로 통일하였습니다. 하지만 각 마디의 preference 를 bias 로 조절할 수 있습니다. 이를 Personalized PageRank 라 합니다. KeysentenceSummarizer 의 R 에는 각 문장의 PageRank 가 저장되어 있습니다.

summarizer.R
array([1.76438621, 0.74969733, 1.33782296, 0.61722741, 0.7377122 ,
       1.07534516, 0.62928904, 1.71145208, 1.07601036, 1.13590053,
       0.94446938, 0.67686714, 0.7008805 , 1.02103025, 1.61461996,
       0.76911158, 0.78024047, 0.65793743, 1.02927478, 0.97072522])

이번에는 문장의 위치에 따라 중요도를 다르게 설정해 봅니다. 뉴스 기사는 대부분 첫 문장이 중요합니다. 실제로 위의 예시에서도 첫 문장이 가장 중요한 핵심 문장으로 선택되었습니다. 만약 마지막 문장이 중요하다고 가정한다면 이러한 정보를 bias 에 추가할 수 있습니다. numpy.ndarray 형태로 bias 를 만듭니다. 마지막 문장이 다른 문장보다 10 배 중요하다고 가정하였습니다. 이를 summarize 함수의 bias 에 입력하면 가장 먼저 맨 마지막 문장이 중요한 문장으로 선택됩니다. 다른 문장들 중에서도 맨 마지막 문장과 비슷할수록 상대적인 중요도가 더 커집니다.

import numpy as np

bias = np.ones(len(sents))
bias[-1] = 10
keysents = summarizer.summarize(sents, topk=3, bias=bias)
일부는 숲에서 발견됐고 일부는 성씨가 소지한 가방 안에 있었다
경찰은 인근을 수색해 성씨가 만든 사제총 16정과 칼 7개를 압수했다 실제 폭발할지는 알 수 없는 요구르트병에 무언가를 채워두고 심지를 꽂은 사제 폭탄도 발견됐다
오패산터널 총격전 용의자 검거 서울 연합뉴스 경찰 관계자들이 19일 오후 서울 강북구 오패산 터널 인근에서 사제 총기를 발사해 경찰을 살해한 용의자 성모씨를 검거하고 있다 성씨는 검거 당시 서바이벌 게임에서 쓰는 방탄조끼에 헬멧까지 착용한 상태였다

R 을 다시 확인해보면 PageRank 값이 달라졌음을 확인할 수 있습니다. 일단 마지막 문장의 Rank 가 가장 높게 학습되었습니다. 상대적인 위치 외에도 특정 단어가 포함된 문장에 preference (bias) 를 더 높게 설정할 수도 있습니다.

summarizer.R
array([1.22183954, 0.51902092, 0.92584783, 0.42671701, 0.50982682,
       0.74430683, 0.43498201, 1.18547126, 0.74505343, 0.78632222,
       0.65347844, 0.46802437, 0.48465947, 0.70684359, 1.11845189,
       0.53125081, 0.53956034, 0.45476333, 3.14941282, 4.39416707])

Doubt about summarization quality

문서 요약 분야의 어려운 점 중 하나는 적절한 품질 평가 척도가 없다는 점입니다. 물론 이 분야에서 널리 이용되는 ROUGE 라는 척도가 있습니다. 이는 바로 뒤이은 포스트에서 다룰 예정이므로 ROUGE 의 정의는 그 때 알아봅니다. ROUGE 는 전반적으로 좋은 품질의 경향은 말해줄 수 있지만, 아주 엄밀한 품질 척도는 아닙니다.

그 보다 더 중요한 문제는, 사람도 좋은 문서 요약문의 기준을 정의하기 어렵다는 것입니다. 사람도 정의가 되지 않는데, 머신 러닝의 척도로 이를 정의하는 것은 무리가 있습니다.

또한 우리가 문서 요약을 하는 예시들이 무엇인지 고민해 볼 필요가 있습니다. 우리가 extractive summarization 방법을 이용하여 핵심 문장을 추출하는 문서 집합은 주로 몇 십 문장으로 이뤄진 뉴스나 라라랜드 영화평과 같이 비슷한 내용의 문장들로 이뤄진 문서 집합이지, 장편 소설 ‘토지’와 같은 책의 줄거리를 요약하기 위하여 extractive summarization 방법을 이용하지는 않습니다. 그리고 뉴스 문서에서는 사실 버릴 문장이 거의 없습니다. 애초에 뉴스라는 문서 자체가 효율적으로 정보를 전달하기 위해 작성되는 문서이기 때문입니다. 그렇기 때문에 위의 뉴스 예시에서 어떤 문장을 선택했어도 핵심 문장으로 큰 무리가 없습니다. 단지, 라라랜드 영화평에서는 지나치게 짧은 문장은 정보가 적다는 느낌이 듭니다. 그것만 아니면 됩니다.

그런데 TextRank 에서 제안한 문장 간 유사도 척도는 긴 문장에 높은 유사도를 부여합니다. 그리고 앞서 언급한 것처럼 TextRank 는 주어진 문서 집합에서 자주 등장하는 단어들을 다수 포함하는 문장을 핵심 문장으로 선택합니다. 이는 문서 집합의 많은 개념을 포함하는 문장이기 때문에 핵심 문장으로 쓸만합니다. 그래서 그럴듯한 결과가 학습된 것처럼 보입니다.

즉 뉴스처럼 주어진 문서 집합에 불필요한 문장이 거의 없는 경우에는 적절한 길이의 문장을 핵심 문장으로 선택하면 충분하고, 영화 평과 같이 핵심 문장으로써 품질이 떨어지는 문장이 포함되어 있을 때에는 TextRank 가 알아서 문서 집합 내에서 자주 등장하는 단어를 많이 포함하는 문장을 핵심 문장으로 선택하고 있었습니다. 그정도면 핵심 문장의 추출을 통한 문서 집합 요약으로 충분합니다.

References

  • Page, L., Brin, S., Motwani, R., & Winograd, T. (1999). The PageRank citation ranking: Bringing order to the web. Stanford InfoLab
  • Mihalcea, R., & Tarau, P. (2004). Textrank: Bringing order into text. In Proceedings of the 2004 conference on empirical methods in natural language processing
  • Erkan, G., & Radev, D. R. (2004). Lexrank: Graph-based lexical centrality as salience in text summarization. Journal of Artificial Intelligence Research, 22, 457-479
  • Barrios, F., López, F., Argerich, L., & Wachenchauzer, R. (2016). Variations of the similarity function of textrank for automated summarization. arXiv preprint arXiv:1602.03606.

 

[출처] https://lovit.github.io/nlp/2019/04/30/textrank/

 

 

본 웹사이트는 광고를 포함하고 있습니다.
광고 클릭에서 발생하는 수익금은 모두 웹사이트 서버의 유지 및 관리, 그리고 기술 콘텐츠 향상을 위해 쓰여집니다.
대표 김성준 주소 : 경기 용인 분당수지 U타워 등록번호 : 142-07-27414
통신판매업 신고 : 제2012-용인수지-0185호 출판업 신고 : 수지구청 제 123호 개인정보보호최고책임자 : 김성준 sjkim70@stechstar.com
대표전화 : 010-4589-2193 [fax] 02-6280-1294 COPYRIGHT(C) stechstar.com ALL RIGHTS RESERVED