NLP

NLP 문장 수준 임베딩 - 01

참고로 이 모든 내용은 이기창 님의 한국어 임베딩이라는 책을 기반으로 작성하고 있다.

문장 수준 임베딩

  • 크게는 행렬 분해, 확률 모형, Neural Network 기반 모델 등 세 종류를 소개할 것이다.

  • 행렬 분해

    • LSA(잠재 의미 분석)
  • 확률 모형

    • LDA(잠재 디리클레 할당)
  • Neural Network

    • Doc2Vec
    • ELMo
    • GPT (transformer 구조 - self-attention)
    • BERT (transformer 구조 - self-attention)

잠재 의미 분석(LSA, Latent Semantic Analysis)

  • 단어 수준 임베딩에서의 LSA 방법론들은 word-documents 행렬이나 TF-IDF 행렬, word-context 행렬 또는 PMI 행렬에 SVD로 차원 축소를 시행하고, 여기에서 단어에 해당하는 벡터를 취해 임베딩을 만드는 방법이었다. 문장 수준 입베딩에서의 LSA 방법은 단어 수준 임베딩에서의 LSA 방법론을 통해 얻게된 정확히 말하자면 SVD를 통해 축소된 행렬에서 문서에 대응하는 벡터를 취해 문서 임베딩을 만드는 방식이다.

  • 실습 대상 데이터는 ratsgo.github.uo의 아티클 하나로 markdwon 문서의 제목과 본문을 그대로 텍스트로 저장한 형태이다. 1개 라인이 1개 문서에 해당한다. 불필요한 기호나 LaTex math 패기지의 문법으로 작성되어있는 부분들이 다수 존재한다. 우선 이 실습의 가정을 수식이나 기호는 분석에 있어서 큰 의미를 갖지 않는다라고 가정하고 시작하겠다.

  • 우선, 형태소분석기를 어떤것을 사용하던 가능하게 함수를 하나 만들어준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from khaiii import KhaiiiApi
from konlpy.tag import Okt, Komoran, Mecab, Hannanum, Kkma


def get_tokenizer(tokenizer_name):
if tokenizer_name == "komoran":
tokenizer = Komoran()
elif tokenizer_name == "okt":
tokenizer = Okt()
elif tokenizer_name == "mecab":
tokenizer = Mecab()
elif tokenizer_name == "hannanum":
tokenizer = Hannanum()
elif tokenizer_name == "kkma":
tokenizer = Kkma()
elif tokenizer_name == "khaiii":
tokenizer = KhaiiiApi()
else:
tokenizer = Mecab()
return tokenizer
  • 한 문단 별로 구분자를 어떤것으로 했는지 확인하기 하나씩 프린트해보았다.
1
2
3
4
5
6
7
8
9
10
corpus_fname = "./data/processed/processed_blog.txt"

with open(corpus_fname, 'r', encoding='utf-8') as f:
print(f.readline())
print("---------------------------------------------------------------------------------------------------------------------")
print(f.readline())
print("---------------------------------------------------------------------------------------------------------------------")
print(f.readline())
print("---------------------------------------------------------------------------------------------------------------------")
print(f.readline())

라인 하나씩 출력

  • 아래 코드를 실행하면 제일 처음 문서의 임베딩과 코사인 유사도가 가장 높은 문서 임베딩의 제목을 return해준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.decomposition import TruncatedSVD
from sklearn.preprocessing import normalize
from sklearn.manifold import TSNE
from sklearn.metrics.pairwise import cosine_similarity

from bokeh.io import export_png, output_notebook, show
from bokeh.plotting import figure
from bokeh.models import Plot, Range1d, MultiLine, Circle, HoverTool, TapTool, BoxSelectTool, LinearColorMapper, ColumnDataSource, LabelSet, SaveTool, ColorBar, BasicTicker
from bokeh.models.graphs import from_networkx, NodesAndLinkedEdges, EdgesAndLinkedNodes
from bokeh.palettes import Spectral8


def LSAeval(corpus_file, doc_idx, nth_top):
tokenizer = get_tokenizer("mecab")
titles, raw_corpus, noun_corpus = [], [], []

with open(corpus_fname, 'r', encoding='utf-8') as f:
for line in f:
try:
title, document = line.strip().split('\u241E')
titles.append(title)
raw_corpus.append(document)
nouns = tokenizer.nouns(document)
noun_corpus.append(' '.join(nouns))
except:
continue
# 문서(단락)에서 기호들과 조사를 제외하고 명사들만 추출한 데이터 중 Unigram(ngram_range(1,1)),
# DF가 1이상(min_df=1)인 데이터를 추려 TF-IDF 행렬을 만들 것이다.
vectorizer = TfidfVectorizer(min_df=1,
ngram_range=(1,1),
# tokenizing전에 모든 문자를 소문자로 바꿔준다.
lowercase=True,
# analyzer == 'word'인 경우만 사용가능.
tokenizer=lambda x: x.split())

# 행은 문서, 열은 단어에 각각 대응한다. (204 x 37153)
input_matrix = vectorizer.fit_transform(noun_corpus)

id2vocab = {vectorizer.vocabulary_[token]:token for token in vectorizer.vocabulary_.keys()}

# curr_doc : Corpus 첫번 째 문서의 TF-IDF 벡터
curr_doc, result = input_matrix[doc_idx], []

# curr_doc에서 TF-IDF 값이 0이 아닌 요소들은 내림차순 정렬
# curr_doc은 105개의 원소(단어)만이 저장되어 있는 Compressed Sparse Row format이다.
# 그러므로 indices(CSR format index array of the matrix)로 해당 index에 위치하는 단어와 그에대한 tf-idf값을 쌍으로 tuple형태로 넣어준다.
for idx, el in zip(curr_doc.indices, curr_doc.data):
result.append((id2vocab[idx], el))

sorted(result, key=lambda x : x[1], reverse=True)[:5]

# 이번에는 이 TF-IDF 행렬에 100차원 SVD를 수행할 것이다. 204 x 37153의 희소 행렬을
# 204 x 100 크기의 Dense Matrix로 linear Transforamtion하는 것이다.
svd = TruncatedSVD(n_components=100)
vecs = svd.fit_transform(input_matrix)
svd_l2norm_vectors = normalize(vecs, axis=1, norm='l2')
cosine_similarity = np.dot(svd_l2norm_vectors, svd_l2norm_vectors[doc_idx])
query_sentence = titles[doc_idx]
return titles, svd_l2norm_vectors, [query_sentence, sorted(zip(titles, cosine_similarity), key=lambda x: x[1], reverse=True)[1:nth_top + 1]]

임베딩 벡터를 만든 Corpus는 명사만 추출

  • 상위 5개의 벡터의 내적이 높은 순으로 내림차순 정력했을때의 결과물 출력
1
2
titles, svd_l2norm_vectors, top_five = LSAeval(corpus_file="./data/processed/processed_blog.txt", doc_idx=0, nth_top=5)
top_five

첫번째 문서와의 높은 유사도를 갖는 상위 5개의 문서

임베딩 시각화

  • t-SNE 기법을 사용해서 벡터공간을 2차원으로 줄여준 뒤 시각화 할 것이다. 또한 벡터들간의 전체적인 유사도는 시각적으로 그리기보다는 상관행렬 방식으로 나타내 줄 것이다.
  • 시각화에 필요한 함수들 정의

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72

    def visualize(titles, vectors, mode="between", num_sents=30, palette="Viridis256", use_notebook=False):
    doc_idxes = random.sample(range(len(titles)), num_sents)
    sentences = [titles[idx] for idx in doc_idxes]
    vecs = [vectors[idx] for idx in doc_idxes]
    if mode == "between":
    visualize_between_sentences(sentences, vecs, palette, use_notebook=use_notebook)
    else:
    visualize_sentences(vecs, sentences, palette, use_notebook=use_notebook)



    def visualize_between_sentences(sentences, vec_list, palette="Viridis256",
    filename="between-sentences.png",
    use_notebook=False):
    df_list, score_list = [], []
    for sent1_idx, sentence1 in enumerate(sentences):
    for sent2_idx, sentence2 in enumerate(sentences):
    vec1, vec2 = vec_list[sent1_idx], vec_list[sent2_idx]
    if np.any(vec1) and np.any(vec2):
    score = cosine_similarity(X=[vec1], Y=[vec2])
    # [0][0]인 이유는 값만 뽑아 내기 위해서이다.
    df_list.append({'x': sentence1, 'y': sentence2, 'similarity': score[0][0]})
    score_list.append(score[0][0])
    df = pd.DataFrame(df_list)
    color_mapper = LinearColorMapper(palette=palette, low=np.max(score_list), high=np.min(score_list))
    TOOLS = "hover,save,pan,box_zoom,reset,wheel_zoom"
    p = figure(x_range=sentences, y_range=list(reversed(sentences)),
    x_axis_location="above", plot_width=900, plot_height=900,
    toolbar_location='below', tools=TOOLS,
    tooltips=[('sentences', '@x @y'), ('similarity', '@similarity')])
    p.grid.grid_line_color = None
    p.axis.axis_line_color = None
    p.axis.major_tick_line_color = None
    p.axis.major_label_standoff = 0
    p.xaxis.major_label_orientation = 3.14 / 3
    p.rect(x="x", y="y", width=1, height=1,
    source=df,
    fill_color={'field': 'similarity', 'transform': color_mapper},
    line_color=None)
    color_bar = ColorBar(ticker=BasicTicker(desired_num_ticks=5),
    color_mapper=color_mapper, major_label_text_font_size="7pt",
    label_standoff=6, border_line_color=None, location=(0, 0))
    p.add_layout(color_bar, 'right')
    if use_notebook:
    output_notebook()
    show(p)
    else:
    export_png(p, filename)
    print("save @ " + filename)


    def visualize_sentences(vecs, sentences, palette="Viridis256", filename="/notebooks/embedding/sentences.png",
    use_notebook=False):
    tsne = TSNE(n_components=2)
    tsne_results = tsne.fit_transform(vecs)
    df = pd.DataFrame(columns=['x', 'y', 'sentence'])
    df['x'], df['y'], df['sentence'] = tsne_results[:, 0], tsne_results[:, 1], sentences
    source = ColumnDataSource(ColumnDataSource.from_df(df))
    labels = LabelSet(x="x", y="y", text="sentence", y_offset=8,
    text_font_size="12pt", text_color="#555555",
    source=source, text_align='center')
    color_mapper = LinearColorMapper(palette=palette, low=min(tsne_results[:, 1]), high=max(tsne_results[:, 1]))
    plot = figure(plot_width=900, plot_height=900)
    plot.scatter("x", "y", size=12, source=source, color={'field': 'y', 'transform': color_mapper}, line_color=None, fill_alpha=0.8)
    plot.add_layout(labels)
    if use_notebook:
    output_notebook()
    show(plot)
    else:
    export_png(plot, filename)
    print("save @ " + filename)
  • 혹시 이러한 error가 난다면, 다음과 같이 PhantomJS를 설치한다. 간단히 말하자면 PhantomJS도 Selenium같이 웹브라우져 개발용으로 만들어진 프로그램이다. bokeh는 javascript기반으로 짜여져있어서 필요한 것 같다.

phantomjs error

1
conda install -c conda-forge phantomjs
1
visualize(titles, svd_l2norm_vectors, mode="between", num_sents=30, palette="Viridis256", use_notebook=True)

벡터들간의 유성성 상관행렬

1
visualize(titles, svd_l2norm_vectors, mode="tsne", num_sents=30, palette="Viridis256", use_notebook=True)

t-SNE를 활용한 임베딩 벡터 시각화

Doc2Vec

모델 개요

  • Word2Vec에 이어 구글 연구 팀이 개발한 문서 임베딩 기법이다. 이전 단어 sequence k개가 주어졌을 때 그 다음 단어를 맞추는 언어 모델을 만들었다. 이 모델은 문장 전체를 처음부터 끝까지 한 단어씩 슬라이딩해 가면서 다음 단어가 무엇일지 예측한다.

  • 로그 확률 평균의 값이 커진다는 의미는 이전 k개 단어들을 입력하면 모델이 다음 단어를 잘 예측하므로 로그 확률 평균을 최대화는 과정에서 학습된다.

  • NPLM에서 설명했던 방식처럼 문장 전체를 한 단어씩 슬라이딩해가면서 다음 target word를 맞추는 과정에서 context word에 해당하는 $(w_{t-k}, \cdots, w_{t-1})$에 해당하는 W 행렬의 벡터들이 업데이트 한다. 따라서 주변 이웃 단어 집합 즉 context가 유사한 단어벡터는 벡터 공간에 가깝게 임베딩 된다.학습이 종료되면 W를 각 단어의 임베딩으로 사용한다.

Doc2vec 언어 모델
  • $T$ : 학습 데이터 문장 하나의 단어 개수
  • $w_{t}$ : 문장의 t번째 단어
  • $y_{i}$ : corpus 전체 어휘 집합 중 i번째 단어에 해당하는 점수
    • 1) 이전 k개 단어들을 W라는 단어 행렬에서 참조한 뒤 평균을 취하거나 이어 붙인다. 여기에 U라는 행렬을 내적하고 bias 벡터인 b를 더해준 뒤 softmax를 취한다. U의 크기는 어휘집합 크기 $\times$ 임베딩 차원 수 이다.
  • $h$ : 벡터 sequence가 주어졌을 때 평균을 취하거나 concatenate하여 고정된 길이의 벡터 하나를 반환하는 역할을 하는 함수이다.
Doc2Vec 언어 모델 Score 계산
  • 위의 초기 구조에서 문서 id를 추가해 이전 k개 단어들과 문서 id를 넣어서 다음 단어를 예측하게 했다. y를 계산할 때 D라는 문서 행렬(Paragraph matrix)에서 해당 문서 ID에 해당하는 벡터를 참조해 h 함수에 다른 단어 벡터들과 함께 입력하는 것 외에 나머지 과정은 동일하다. 이런 구조를 PV-DM(the Distributed Memory Model of Paragraph Vectors)이라고 부른다. 학습이 종료되면 문서 수 $\times$ 임베딩 차원 수 크기를 가지는 문서 행렬 D를 각 문서의 임베딩으로 사용한다. 이렇게 만든 문서 임베딩이 해당 문서의 주제 정보를 함축한다고 설명한다. PV-DM은 단어 등장 순서를 고려하는 방식으로 학습하기 때문에 순서 정보를 무시하는 Bag of Words 기법 대비 강점이 있다고 할 수 있을 것이다.

Doc2vec 모델

  • 또한, Word2Vec의 Skip-gram을 본뜬 PV-DBOW(the Distributed Bag of Words version of Paragraph Vectors)도 제안했다. Skip-gram은 target word를 가지고 context word들을 예측하는 과정에서 학습되었다. PV-DBOW도 문서 id를 가지고 context word들을 맞춘다. 따라서 문서 id에 해당하는 문서 임베딩엔 문서에 등장하는 모든 단어의 의미 정보가 반영된다.

Doc2Vec CBOW 모델

Doc2Vec 실습

실습 데이터

  • 영화 댓글과 해당 영화의 ID가 라인 하나를 구성하고 있다. 영화하나를 문서로 보고 Doc2Vec 모델을 학습할 예정이다. 따라서 영화 ID가 동일한 문장들을 하나의 문서로 처리해 줄 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
with open("./data/processed/processed_review_movieid.txt") as f:
print(f.readline())
print("--------------------------------------------------------------------------------------------")
print(f.readline())
print("--------------------------------------------------------------------------------------------")
print(f.readline())
print("--------------------------------------------------------------------------------------------")
print(f.readline())
print("--------------------------------------------------------------------------------------------")
count = 4
for sentence in f:
count+=1
print("총 sentence의 개수 : {}개".format(count))

결과

1
2
3
4
5
6
7
8
9
10
11
12
13
종합 평점은 4점 드립니다.␞92575

--------------------------------------------------------------------------------------------
원작이 칭송받는 이유는 웹툰 계 자체의 질적 저하가 심각하기 때문. 원작이나 영화나 별로인건 마찬가지.␞92575

--------------------------------------------------------------------------------------------
나름의 감동도 있고 안타까운 마음에 가슴도 먹먹 배우들의 연기가 good 김수현 최고~␞92575

--------------------------------------------------------------------------------------------
이런걸 돈주고 본 내자신이 후회스럽다 최악의 쓰레기 영화 김수현 밖에없는 저질 삼류영화␞92575

--------------------------------------------------------------------------------------------
총 sentence의 개수 : 712532개

  • Doc2Vec 모델 학습을 위해 Python gensim 라이브러리의 Doc2Vec 클래스를 사용하는데 Doc2VecInput은 이 클래스가 요구하는 입력 형태를 맞춰주는 역할을 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from khaiii import KhaiiiApi
from konlpy.tag import Okt, Komoran, Mecab, Hannanum, Kkma
from gensim.models.doc2vec import TaggedDocument

def get_tokenizer(tokenizer_name):
if tokenizer_name == "komoran":
tokenizer = Komoran()
elif tokenizer_name == "okt":
tokenizer = Okt()
elif tokenizer_name == "mecab":
tokenizer = Mecab()
elif tokenizer_name == "hannanum":
tokenizer = Hannanum()
elif tokenizer_name == "kkma":
tokenizer = Kkma()
elif tokenizer_name == "khaiii":
tokenizer = KhaiiiApi()
else:
tokenizer = Mecab()
return tokenizer

class Doc2VecInput:

def __init__(self, fname, tokenizer_name='mecab'):
self.fname = fname
self.tokenizer = get_tokenizer(tokenizer_name)

def __iter__(self):
with open(self.fname, encoding='utf-8') as f:
for line in f:
try:
sentence, movie_id = line.strip().split("\u241E")
tokens = self.tokenizer.morphs(sentence)
tagged_doc = TaggedDocument(words=tokens, tags=['movie_%s' % movie_id])
yield tagged_doc
except:
continue
  • dm : 1 (default) -> PV-DM, 0- > PV-DBOW
1
2
3
4
5
6
7
8
from gensim.models import Doc2Vec

corpus_fname = './data/processed/processed_review_movieid.txt'
output_fname = './doc2vec.model'

corpus = Doc2VecInput(corpus_fname)
model = Doc2Vec(corpus, dm=1, vector_size=100)
model.save(output_fname)
  • 학습이 잘 되었는지를 평가하기 위해서 아래와 같이 평가 클래스를 이용할 것이며, 평가를 하면 tag된 영화 id가 나올텐데 직관적으로 그 영화가 어떤 영화인지 모를 것이다. 그러므로 직관적으로 결과를 이해하기 위해 학습 데이터에는 없는 영화 제목을 네이버 영화 사이트에 접속해 id에 맞는 영화 제목을 스크래핑해 올 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import requests
from lxml import html


class Doc2VecEvaluator:

def __init__(self, model_fname="data/doc2vec.vecs", use_notebook=False):
self.model = Doc2Vec.load(model_fname)
self.doc2idx = {el:idx for idx, el in enumerate(self.model.docvecs.doctags.keys())}
self.use_notebook = use_notebook

def most_similar(self, movie_id, topn=10):
similar_movies = self.model.docvecs.most_similar('movie_' + str(movie_id), topn=topn)
for movie_id, score in similar_movies:
print(self.get_movie_title(movie_id), score)

def get_titles_in_corpus(self, n_sample=5):
movie_ids = random.sample(self.model.docvecs.doctags.keys(), n_sample)
return {movie_id: self.get_movie_title(movie_id) for movie_id in movie_ids}

def get_movie_title(self, movie_id):
url = 'http://movie.naver.com/movie/point/af/list.nhn?st=mcode&target=after&sword=%s' % movie_id.split("_")[1]
resp = requests.get(url)
root = html.fromstring(resp.text)
try:
title = root.xpath('//div[@class="choice_movie_info"]//h5//a/text()')[0]
except:
title = ""
return title
1
2
3
model_fname='./doc2vec.model'
model = Doc2VecEvaluator(model_fname)
print("영화의 종류 : {} 개".format(len(model.doc2idx.keys())))

결과

1
영화의 종류 : 14730 개

  • 학습 데이터에 포함된 아무 영화 10개의 제목을 보여준다.
1
model.get_titles_in_corpus(n_sample=14730)

결과

1
2
3
4
5
6
7
8
9
10
{'movie_89743': '여자가 두 번 화장할 때',
'movie_16490': '투 문 정션 2',
'movie_100953': '더 퍼지',
'movie_84375': '퍼펙트 센스',
'movie_24203': '섀터드 이미지',
'movie_12054': '나폴레옹',
'movie_10721': '더티 해리',
'movie_11440': '킹 뉴욕',
'movie_20896': '미망인',
'movie_123068': '캠걸'}

  • 해당 id와 유사한 영화 상위 5개를 보여준다.
1
model.most_similar(37758, topn=5)

결과

1
2
3
4
5
돈비 어프레이드-어둠 속의 속삭임 0.746237576007843
더 퍼지:거리의 반란 0.7402248382568359
고양이: 죽음을 보는 두 개의 눈 0.6967850923538208
ATM 0.690518856048584
힛쳐 0.6751468777656555

잠재 디리클레 할당(LDA, Latent Dirichlet Allocation)

  • 주어진 문서에 대하여 각 문서에 어떤 topic들이 존재하는지에 대한 확률 모형이다. corpus의 이면에 잠재된 topic을 추출한다는 의미에서 topic modeling이라고 부르기도 한다. 문서를 topic 확률 분포로 나타내 각각을 벡터화한다는 점에서 LDA를 임베딩 기법의 일종으로 이해할 수 있다.

모델 개요

  • LDA는 topic별 단어의 분포, 문서별 topic의 분포를 모두 추정해 낸다.

LDA의 개략적 도식

  • LDA는 topic에 특정 단어가 나타날 확률을 내어 준다. 위의 그림에서 각 색깔별로 topic에 따른 단어가 등장할 확률을 보여주고있다. 문서를 보면 노란색 topic에 해당하는 단어가 많기 때문에 위 문서의 메인 주제는 노란색 topic인 유전자 관련 topic일 가능성이 클 것이다. 이렇듯 문서의 topic 비중 또한 LDA의 산출 결과가 된다.

  • LDA가 가정하는 문서 생성 과정은 우리가 글을 쓸때와 같다. 실제 글을 작성할 때는 글감 내지 주제를 먼저 결정한 후 어떤 단어를 써야 할지 결정한다. 이와 마찬가지로 LDA는 우선 corpus로 부터 얻은 topic 분포로부터 topic을 뽑는다. 이후 해당 topic에 해당하는 단어들을 뽑는다. 그런데 corpus에 등장하는 단어들 각각에 꼬리표가 달려 있는 것은 아니기 때문에 현재 문서에 등장한 단어들은 어떤 topic에서 뽑힌 단어들인지 우리가 명시적으로 알기는 어려울 것이다. 하지만, LDA는 이런 corpus 이면에 존재하는 정보를 추론해낼 수 있다.

아키텍처

  • LDA가 가정하는 문서 생성 과정은 아래의 그림과 같다.
    • D : corpus 전체 문서 개수
    • K : 전체 topic 수(hyper parameter)
    • N : d번째 문서의 단어 수
    • 네모칸 : 해당 횟수만큼 반복하라는 의미
    • $\phi_{k}$ : k번째 topic에 해당하는 벡터 $\phi_{k} \in R^{|V|}$, $\phi_{k}$는 word-topic 행렬의 k번째 열을 의미한다. $\phi_{k}$의 각 요소 값은 해당 단어가 k번째 토픽에서 차지하는 비중을 의미하며 확률값이므로 이 벡터의 요소값의 합은 1이 된다. 이런 topic의 단어비중을 의미하는 $\phi_{k}$는 디리클레 분포를 따른 다는 가정을 취하므로 $\beta$의 영향을 받는다.
    • $\theta_{d}$ : d번째 문서가 가진 topic 비중을 나타내는 벡터이다. 그러므로 전체 topic의 개수만큼의 길이 갖으며, 각 벡터는 확률을 의미하므로 합은 1이된다. 문서의 topic 비중 $\theta_{d}$는 디리클레 분포를 따른다는 가정을 취하므로 $\alpha$의 영향을 받는다.
    • $ z_{d,n}$ : d번째 문서에서 n번째 단어가 어떤 topic인지를 나타내는 변수이다. 그러므로 이 변수는 d번째 문서의 topic 확률 분포인 $\theta_{d}$에 영향을 받는다.
    • 동그라미 : 변수를 의미
    • 화살표가 시작되는 변수는 조건, 화살표가 향하는 변수는 결과에 해당하는 변수
  • 관찰 가능한 변수는 d번째 문서에 등장한 n번째 단어 $w_{d,n}$이 유일하다. 우리는 이 정보만을 가지고 하이퍼파라메터(사용자 지정) α,β를 제외한 모든 잠재 변수를 추정해야 한다.

graphical model로 표현한 LDA

  • 예시를 들자면, 아래 Document-topic 행렬을 살펴보자. 3번째 문서에 속한 단어들은 가장 높은 확률값 0.625를 갖는 topic-2일 가능성이 높다. $w_{d,n}$은 d번째 문서 내에 n번째로 등장하는 단어를 의미하며, 동시에 우리가 유일하게 corpus에서 관찰할 수 있는 데이터이다. 이는 $\phi_{k}$와 $\z_{d,n}$에 동시에 영향을 받는다. 예를 들면, $z_{3,1}$가 topic-2이라고 가정했을 경우, $w_{3,1}$은 word-topic 행렬에서 보게되면 제일 높은 확률값 0.393을 갖는 ‘코로나 바이러스’일 가능성이 높다.

이처럼 LDA는 topic의 word 분포($\phi$)와 문서의 topic 분포($\theta$)의 결합으로 문서 내 단어들이 생성된다고 가정한다.

Document-topic 행렬
문서 topic-1 topic-2 topic-3
문서1 0.400 0.000 0.600
문서2 0.000 0.600 0.400
문서3 0.375 0.625 0.000
word-topic 행렬
단어 topic-1 topic-2 topic-3
코로나 바이러스 0.000 0.393 0.000
우한폐렴 0.000 0.313 0.000
AWS 0.119 0.000 0.000
데이터 엔지니어링 0.181 0.000 0.000
Hadoop 0.276 0.000 0.000
Spark 0.142 0.000 0.000
낭만 닥터 김사부2 0.000 0.012 0.468
tensorflow 0.282 0.000 0.000
마스크 0.000 0.282 0.000
사랑의 불시착 0.000 0.000 0.532
1.0 1.0 1.0
  • 실제 관찰 가능한 corpus를 가지고 알고 싶은 topic의 word 분포, 문서의 topic 분포를 추정하는 과정을 통해 LDA는 학습한다. 즉, topic의 word 분포와 문서의 topic 분포의 결합 확률이 커지는 방향으로 학습을 한다는 의미이다.
LDA의 단어 생성 과정
  • 우리가 구해야할 사후확률 분포는 $ p(z, \phi, \theta|w) = p(z, \phi, \theta, w)/p(w)$를 최대로 만드는 $ z, \phi, \theta$를 찾아야 한다. 이 사후확률을 직접 계산하려면 분자도 계산하기 어렵겠지만 분모가 되는 $ p(w)$도 반드시 구해야 확률값으로 만들어 줄 수 있다. $ p(w)$는 잠재변수 $ z, \phi, \theta$의 모든 경우의 수를 고려한 각 단어(w)의 등장 확률을 의미하는데, $ z, \phi, \theta$를 직접 관찰하는 것은 불가능하다. 이러한 이유로 깁스 샘플링(gibbs sampling)같은 표본 추출 기법을 사용해 사후확률을 근사시키게 된다. 깁스 샘플링이란 나머지 변수는 고정시킨 채 하나의 랜덤변수만을 대상으로 표본을 뽑는 기법이다. LDA에서는 사후확률 분포 $ p(z, \phi, \theta|w)$를 구할 때 topic의 단어 분포($\phi$)와 문서의 topic 분포($\theta$)를 계산에서 생략하고 topic(z)만을 추론한다. z만 알 수 있으면 나미저 변수를 이를 통해 계산 할 수 있도록 설계 했기 때문이다.

  • 깁스 샘플링 참조

깁스 샘플링을 활용한 LDA
LDA 변수 표기법
표기 내용
$n_{d,k}$ k번째 topic에 할당된 d번째 문서의 빈도
$v_{k,w_{d,n}}$ 전체 corpus에서 k번째 topic에 할당된 단어 $w_{d,n}$의 빈도
$w_{d,n}$ d번째 문서에 n번째로 등장한 단어
$\alpha$ 문서의 topic 분포 생성을 위한 디리클레 분포 파라미터
$\beta$ topic의 word 분포 생성을 위한 디리클레 분포 파라미터
K 사용자가 지정하는 topic 개수
V corpus에 등장하는 전체 word의 수
A d번째 문서가 k번째 topic과 맺고 있는 연관성 정도
B d번째 문서의 n번째 단어($ w_{d,n}$)가 k번째 topic과 맺고 있는 연관성 정도

LDA와 깁스 샘플링

  • LDA가 각 단어에 잠재된 주제를 추론하는 방식을 살펴본다. 아래표와 같이 단어 5개로 구성된 문서1의 모든 단어에 주제(z)가 이미 할당돼 있다고 가정해보자. LDA는 이렇게 문서 전체의 모든 단어의 주제를 랜덤하게 할당을 하고 학습을 시작하기 때문에 이렇게 가정하는 게 크게 무리가 없다. 또한, topic 수는 사용자가 3개로 이미 지정해 놓은 상태라고 하자. 문서1의 첫 번째 단어($ w_{11} = 천주교)$ 의 주제($z_{11}$)는 3번 topic이다. 마찬가지로 문서1의 3 번째 단어($ w_{13} = 가격$)의 주제($z_{13}$)는 1번 topic이다. 이런 방식으로 Corpus 전체 문서 모든 단어에 topic이 이미 할당됐다고 가정한다. 이로부터 word-topic 행렬을 만들수 있다. 전체 문서 모든 단어에 달린 주제들을 일일이 세어서 만든다. 같은 단어라도 topic이 다른 배(동음다의어)같은 경우가 있으므로 각 단어별로 topic 분포가 생겨난다.
문서 1의 단어별 topic 분포($\theta$)
$z_{1i}$ 3 2 1 3 1
$w_{1,n}$ 천주교 무역 가격 불교 시장
word-topic 행렬
단어 topic-1 topic-2 topic-3
천주교 1 0 35
시장 50 0 1
가격 42 1 0
불교 0 0 20
무역 10 8 1
$\cdots$ $\cdots$ $\cdots$ $\cdots$
  • 깁스 샘플링으로 문서1 두 번째 단어의 잠재된 topic이 무엇인지 추론해보자면, 깁스 샘플링을 적용하기 위해 문서1의 두 번째 topic정보를 지울것이다. 그렇다면 아래와 같은 표로 변화될 것이다.
문서1의 단어별 topic 분포
$z_{1i}$ 3 ? 1 3 1
$w_{1,n}$ 천주교 무역 가격 불교 시장
word-topic 행렬
단어 topic-1 topic-2 topic-3
천주교 1 0 35
시장 50 0 1
가격 42 1 0
불교 0 0 20
무역 10 7 = (8 - 1) 1
$\cdots$ $\cdots$ $\cdots$ $\cdots$
  • p($ z_{1,2} $)는 A와 B의 곱으로 도출된다. A값은 파란색 영역을 의미하며, 문서 내 단어들의 topic 분포에($\theta$)에 영향을 받는다. 또한, B값은 topic의 단어 분포($\phi$)에 영향을 받는다. A와 B를 각각 직사각형의 높이와 너비로 둔다면, p($ z_{1,2} $)는 아래와 같이 직사각형의 넓이로 이해할 수 있다. 이와 같은 방식으로 모든 문서, 모든 단어에 관해 깁스 샘플링을 수행하면 모든 단어마다 topic을 할당해줄 수가 있게 된다. 즉, word-topic 행렬을 완성할 수 있다는 것이다. 보통 1,000회 ~ 10,000회 반복 수행하면 그 결과가 수렴한다고 하며, 이를 토대로 문서의 topic 분포, topic 단어 분포 또한 구할 수 있게 된다. $\theta$의 경우 각 문서에 어떤 단어사 쓰였는지 조사해 그 단어의 topic 분포를 더해주는 방식으로 계산한다. 사용자가 지정하는 하이퍼파라메터 α 존재 덕분에 A가 아예 0으로 되는 일을 막을 수 있게 된다. 일종의 smoothing 역할을 한다. 따라서 α가 클수록 토픽들의 분포가 비슷해지고, 작을 수록 특정 토픽이 크게 나타나게 된다. 이는 β가 B에서 차지하는 역할도 동일하다.

문서 1 두 번째 단어의 topic 추론

최적 토픽 수 찾기

  • LDA의 토픽수 K는 여러 실험을 통해 사용자가 지정하는 미지수인 hyper parameter이다. 최적 토픽수를 구하는 데 쓰는 Perplexity 지표있다. p(w)는 클수록 좋은 inference이므로 exp(−log(p(w)))는 작을수록 좋다. 따라서 토픽 수 K를 바꿔가면서 Perplexity를 구한 뒤 가장 작은 값을 내는 K를 최적의 토픽수로 삼으면 된다.

LDA 실습

데이터 소개|

  • 네이버 영화 corpus를 soynlp로 띄어쓰기 교정한 결과를 LDA의 학습 데이터로 사용할 것이다.
  • 아래의 코드는 LDA 모델 피처를 생성하는 역할을 한다. LDA의 입력값은 문서 내 단어의 등장 순서를 고려하지 않고 해당 단어가 몇 번 쓰였는지 그 빈도만을 따진다. 그런데 ‘노잼! 노잼! 노잼!’ 같이 특정 단어가 중복으로 사용된 문서가 있다면 해당 문서의 topic 분포가 한쪽으로 너무 쏠릴 염려가 있다. 이 때문에 token의 순서를 고려하지 않고 중복을 제거한 형태로 LDA 피처를 만들 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from gensim import corpora
from khaiii import KhaiiiApi
from konlpy.tag import Okt, Komoran, Mecab, Hannanum, Kkma


def get_tokenizer(tokenizer_name):
if tokenizer_name == "komoran":
tokenizer = Komoran()
elif tokenizer_name == "okt":
tokenizer = Okt()
elif tokenizer_name == "mecab":
tokenizer = Mecab()
elif tokenizer_name == "hannanum":
tokenizer = Hannanum()
elif tokenizer_name == "kkma":
tokenizer = Kkma()
elif tokenizer_name == "khaiii":
tokenizer = KhaiiiApi()
else:
tokenizer = Mecab()
return tokenizer
1
2
3
4
5
6
7
8
9
10
11
12
13
corpus_fname = './data/processed/corrected_ratings_corpus.txt'

documents, tokenized_corpus = [], []
tokenizer = get_tokenizer('mecab')

with open(corpus_fname, 'r', encoding='utf-8') as f:
for document in f:
tokens = list(set(tokenizer.morphs(document.strip())))
documents.append(document)
tokenized_corpus.append(tokens)

dictionary = corpora.Dictionary(tokenized_corpus)
corpus = [dictionary.doc2bow(text) for text in tokenized_corpus]

corpora.Dictionary 참조

  • dictionary형태로 vocabulary dictionary를 만들어주는 것이다.

    • add_documents로 문서를 추가할 수도 있다.
    • doc2bow는 bag-of-words (BoW) 형태로 문서를 변환시켜준다. [(token_id, token_count)]형태이다.
    • doc2bow의 옵션으로 return_missing=True를 주면 해당 sentence의 단어중 미등록 단어와 카운트를 같이 출력해준다.
  • 아래 코드를 실행하면 LDA를 학습하고 그 결과를 확인 할 수 있다. LdaMulticore에서 num_topicss는 토픽 수(K)에 해당되는 parameter이다. get_document_topics라는 함수는 학습이 끝난 LDA 모델로부터 각 문서별 topic 분포를 리턴한다. minimum_probability 인자를 0.5를 줬는데, 이는 0.5미만의 topic 분포는 무시한다는 뜻이다. 특정 토픽의 확률이 0.5보다 클 경우에만 데이터를 리턴한다. 확률의 합은 1이기 때문에 해당 토픽이 해당 문서에서 확률값이 가장 큰 토픽이 된다.

1
2
3
4
5
from gensim.models import ldamulticore

LDA = ldamulticore.LdaMulticore(corpus, id2word=dictionary, num_topics=30, workers=4)

all_topics = LDA.get_document_topics(corpus, minimum_probability=0.5, per_word_topics=False)
  • 아래 결과를 해석하자면, 0번 문서는 전체 topic 30개 중 19번에 해당하는 topic의 확률 값이 제일 높으며 그 값은 0.7227057이다. 3번 문서 같은 경우 전체 topic 중 0.5를 넘는 topic이 없음을 확인 할 수 있다.
1
2
for doc_idx, topic in enumerate(all_topics[:5]):
print(doc_idx, topic)
결과
1
2
3
4
5
0 [(19, 0.7227057)]
1 [(9, 0.5350808)]
2 []
3 [(19, 0.7778823)]
4 [(14, 0.80464107)]
모델 저장
1
2
3
4
5
6
7
8
9
output_fname = './lda'
all_topics = LDA.get_document_topics(corpus, minimum_probability=0.5, per_word_topics=False)
with open(output_fname + ".results", 'w') as f:
for doc_idx, topic in enumerate(all_topics):
if len(topic) == 1:
# tuple 형태로 되어있는 데이터로 가져와서 나눠줌
topic_id, prob = topic[0]
f.writelines(documents[doc_idx].strip() + "\u241E" + ' '.join(tokenized_corpus[doc_idx]) + "\u241E" + str(topic_id) + "\u241E" + str(prob) + "\n")
LDA.save(output_fname + ".model")

LDA 평가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from gensim.models import LdaModel
from collections import defaultdict

class LDAEvaluator:

def __init__(self, model_path="./lda", tokenizer_name="mecab"):
self.tokenizer = get_tokenizer(tokenizer_name)
self.all_topics = self.load_results(model_path + ".results")
self.model = LdaModel.load(model_path + ".model")

def load_results(self, results_fname):
topic_dict = defaultdict(list)
with open(results_fname, 'r', encoding='utf-8') as f:
for line in f:
sentence, _, topic_id, prob = line.strip().split("\u241E")
topic_dict[int(topic_id)].append((sentence, float(prob)))
for key in topic_dict.keys():
topic_dict[key] = sorted(topic_dict[key], key=lambda x: x[1], reverse=True)
return topic_dict

def show_topic_docs(self, topic_id, topn=10):
return self.all_topics[topic_id][:topn]

def show_topic_words(self, topic_id, topn=10):
return self.model.show_topic(topic_id, topn=topn)

def show_new_document_topic(self, documents):
tokenized_documents = [self.tokenizer.morphs(document) for document in documents]
curr_corpus = [self.model.id2word.doc2bow(tokenized_document) for tokenized_document in tokenized_documents]
topics = self.model.get_document_topics(curr_corpus, minimum_probability=0.5, per_word_topics=False)
for doc_idx, topic in enumerate(topics):
if len(topic) == 1:
topic_id, prob = topic[0]
print(documents[doc_idx], ", topic id:", str(topic_id), ", prob:", str(prob))
else:
print(documents[doc_idx], ", there is no dominant topic")

모델 초기화

1
model = LDAEvaluator('./lda')

topic 문서 확인

  • show_topic_docs 함수에 topic ID를 인자로 주어 실행하면 해당 topic 확률 값이 가장 높은 문서 상위 10개를 출력한다.
1
model.show_topic_docs(topic_id=0)
결과
1
2
3
4
5
6
7
8
9
10
11
12
[('내가 가장 좋아하는 영화! 색감과 영상 인물들의 감정이입 대사 한마디 한마디가 너무나 완벽한. 몇번을 봐도 또 보고싶은 영화.',
0.9707017),
('영화보다가 운적은 정우 주연 영화 바람말고는 없는데 이건 감정이입이 되서 그런지 몰라도 진짜 눈 충혈되도록 펑펑울었음',
0.9677608),
('세 명품배우, 몰입도 최고의 현출,간결하고 시같은 대사,상처받은 사람들의 아름다운 치유!', 0.95923144),
('"아.. 따듯하다.. ""천국의 아이들"" 못보신 분 꼭 보세요.. 같은 감독임.."', 0.957968),
('몸이 마음처럼 움직여 주지 않는 지체장애우들의 혼신의 노력과 열연이 돋보이는 영화였다', 0.95778286),
('영상미 아름답고 주인공의 사랑이 순수하고 풋풋하다. 한 번 더 보고싶은 영화!', 0.9539619),
('음악이 아름답고 가슴이 뭉클하니 감동적이었어. 마음이 따뜻해지는 영화예요.', 0.9539556),
('피아니스트와 같은 동급 영화라 생각합니다 보시면 후회없을거예요 실화영화이니요', 0.953952),
('그냥 고민말고 보세요.진짜 이건 명작이라는 말로는 부족합니다..꼭 보세요!', 0.9491169),
('영화를 보는내내 감정이 이입되고 첫사랑이 보고싶어지는 그런 영화입니다.', 0.9491167)]

topic 별 단어 확인

  • 해당 topic ID에서 가장 높은 확률 값을 지니는 단어들 중 상위 n개의 목록을 확인할 수 있다.
    • 어미나 조사가 많이 끼어 있음을 확인할 수 있다. LDA의 품질을 끌어 올리기 위해 피처를 만드는 과정에서 명사만 쓰기도 한다.
1
model.show_topic_words(topic_id=0)
결과
1
2
3
4
5
6
7
8
9
10
[('보', 0.03599964),
('는', 0.03479155),
('.', 0.030642439),
('고', 0.030473521),
('영화', 0.029688885),
('이', 0.018665483),
('로', 0.017465018),
('내내', 0.016784767),
('다', 0.016693212),
('한', 0.013309637)]

새로운 문서의 topic 확인

  • show_new_document_topic 함수는 새로운 문서의 topic을 확인하는 역할을 한다. 문서를 형태소 분석한 뒤 이를 LDA 모델에 넣어 topic을 추론해 가장 높은 확률 값을 지니는 topic id와 그 확률을 리턴해준다.
  • 해당 문서의 topic 분포 중 0.5를 넘는 지배적인 topic이 존재하지 않을 경우 ‘there is no dominant topic’메시지를 리턴한다.
1
model.show_new_document_topic(["너무 사랑스러운 영화", "인생을 말하는 영화"])
결과
1
2
너무 사랑스러운 영화 , topic id: 28 , prob: 0.8066608
인생을 말하는 영화 , topic id: 9 , prob: 0.7323683
1
2


1
2


1
2