텍스트 벡터화

chromis·2022년 3월 7일
0

NLP

목록 보기
1/2

Word2Vec의 대중화 이전에, 텍스트의 분포를 활용하여 텍스트를 벡터화하는 아이디어를 들여다보자.

  1. 단어 빈도를 이용한 벡터화
    (1) Bag of Words
    (2) Bag of Words 구현해보기
    (3) DTM과 코사인 유사도
    (4) DTM의 구현과 한계점
    (5) TF-IDF
    (6) TF-IDF 구현하기
  2. LSA와 LDA
    (1) LSA
    (2) LSA 실습
    (3) LDA
    (4) LDA 실습
  3. 텍스트 분포를 이용한 비지도 학습 토크나이저
    (1) 형태소 분석기와 단어 미등록 문제
    (2) soynlp

텍스트를 벡터화하는 방법으로는 (1) 통계와 머신러닝 활용, (2) 인공 신경망을 활용하는 두가지 방법이 있다.
이번엔 전자의 방법으로 가보자

BoW, DTM, TF-IDF 차이

Bag of Words

BoW란, 단어들의 순서는 전혀 고려하지 않고, 단어들의 출현 빈도(frequency)에만 집중하는 텍스트 데이터의 수치화 표현 방법이다. 텍스트를 전부 단어 단위로 토큰화 하고, 단어 사용 횟수를 카운트 한다.

doc1 = 'John likes to watch movies. Mary likes movies too.'
BoW1 = {"John":1, "likes":2, "to":1, "watch":1, "movies":2, "Mary":1, "too":1}

doc2 = 'Mary also likes to watch football games.'
BoW2 = {"Mary":1, "also":1, "likes":1, "to":1, "watch":1, "football":1, "games":1}

# 순서는 다르지만 둘 다 같다
BoW = {"too":1, "Mary":1, "movies":2, "John":1, "watch":1, "likes":2, "to":1}
BoW1 = {"John":1, "likes":2, "to":1, "watch":1, "movies":2, "Mary":1, "too":1}

doc3 = 'John likes to watch movies. Mary likes movies too. Mary also likes to watch football games.'
BoW3 = {"John":1, "likes":3, "to":2, "watch":2, "movies":2, "Mary":2, "too":1, "also":1, "football":1, "games":1}

어순이 달라지더라도 같은 문장으로 취급한다는 한계가 있다

Keras Tokenizer로 Bag of Words 구현

# Keras Tokenizer로 Bag of Words 구현

from tensorflow.keras.preprocessing.text import Tokenizer

sentence = ["John likes to watch movies. Mary likes movies too! Mary also likes to watch football games."]

tokenizer = Tokenizer()
tokenizer.fit_on_texts(sentence) # 텍스트를 리스트 형태로 단어장 생성 (중복 X)
bow = dict(tokenizer.word_counts) # 각 단어와 각 단어의 빈도를 bow에 저장

print("Bag of Words :", bow) # bow 출력
print('단어장(Vocabulary)의 크기 :', len(tokenizer.word_counts)) # 중복을 제거한 단어들의 개수
Bag of Words : {'john': 1, 'likes': 3, 'to': 2, 'watch': 2, 'movies': 2, 'mary': 2, 'too': 1, 'also': 1, 'football': 1, 'games': 1}
단어장(Vocabulary)의 크기 : 10

scikit-learn CountVectorizer로 구현

from sklearn.feature_extraction.text import CountVectorizer

sentence = ["John likes to watch movies. Mary likes movies too! Mary also likes to watch football games."]

vector = CountVectorizer()
bow = vector.fit_transform(sentence).toarray()

print('Bag of Words : ', bow) # 코퍼스로부터 각 단어의 빈도수를 기록한다.
print('각 단어의 인덱스 :', vector.vocabulary_) # 각 단어의 인덱스가 어떻게 부여되었는지를 보여준다.
print('단어장(Vocabulary)의 크기 :', len(vector.vocabulary_))
Bag of Words :  [[1 1 1 1 3 2 2 2 1 2]]
각 단어의 인덱스 : {'john': 3, 'likes': 4, 'to': 7, 'watch': 9, 'movies': 6, 'mary': 5, 'too': 8, 'also': 0, 'football': 1, 'games': 2}
단어장(Vocabulary)의 크기 : 10

scikit-learn의 CountVectorizer 빈도수만 나올 뿐이다.
Keras의 토크나이저를 사용하는 것이 보통이다

DTM(Document-Term Matrix)

DTM이란, 여러 문서의 Bag of Words를 하나의 행렬로 구현한 것이다.
즉, 각 문서에 등장한 단어의 빈도수를 하나의 행렬로 통합한 것이다.

Doc 1: Intelligent applications creates intelligent business processes
Doc 2: Bots are intelligent applications
Doc 3: I do business intelligence


위 문장들로 만들어진 DTM

row는 문서 벡터(document vector), column은 단어 벡터(word vector)
문서 수가 많아지면 단어장이 커져서 희소벡터가 되어버린다.

# 코사인 유사도
# 문서1 : I like dog
# 문서2 : I like cat
# 문서3 : I like cat I like cat
# 위 문장의 DTM에서 코사인 유사도 계산
# https://wikidocs.net/24603

import numpy as np
from numpy import dot
from numpy.linalg import norm

doc1 = np.array([0,1,1,1]) # 문서1 벡터
doc2 = np.array([1,0,1,1]) # 문서2 벡터
doc3 = np.array([2,0,2,2]) # 문서3 벡터

def cos_sim(A, B):
    return dot(A, B)/(norm(A)*norm(B))

print(cos_sim(doc1, doc2)) #문서1과 문서2의 코사인 유사도
print(cos_sim(doc1, doc3)) #문서1과 문서3의 코사인 유사도
print(cos_sim(doc2, doc3)) #문서2과 문서3의 코사인 유사도
0.6666666666666667
0.6666666666666667
1.0000000000000002

문서1문서2의 코사인 유사도는 0.67, 문서1문서3의 코사인 유사도도 0.67
문서2문서3의 유사도는 1 (모든 단어의 빈도수가 동일하게 증가했기 때문)
코사인 유사도는 벡터의 크기가 아니라 벡터의 방향(패턴)에 초첨을 둔다

# CountVectorizer로 DTM을 만들자

from sklearn.feature_extraction.text import CountVectorizer

corpus = [
    'John likes to watch movies',
    'Mary likes movies too',
    'Mary also likes to watch football games',    
]
vector = CountVectorizer()

print(vector.fit_transform(corpus).toarray()) # 코퍼스로부터 각 단어의 빈도수를 기록.
print(vector.vocabulary_) # 각 단어의 인덱스가 어떻게 부여되었는지를 보여준다.
[[0 0 0 1 1 0 1 1 0 1]
 [0 0 0 0 1 1 1 0 1 0]
 [1 1 1 0 1 1 0 1 0 1]]
{'john': 3, 'likes': 4, 'to': 7, 'watch': 9, 'movies': 6, 'mary': 5, 'too': 8, 'also': 0, 'football': 1, 'games': 2}

DTM의 한계

  1. DTM의 문서 수와 단어 수가 늘어날 수록 벡터가 쓸데없이 커진다. (희소벡터, 차원의 저주)
  2. 단어의 빈도에만 집중하는 방법이기에 한계가 있다. the가 많이 있다고 해서 유사한 문장 X
  • 그렇다면, 중요한 단어와 중요하지 않은 단어에 가중치를 따로 선별하는 방법은?

TF-IDF

TF-IDF(Term Frequency-Inverse Document Frequency)는 모든 문서에서 자주 등장하는 단어는 중요도를 낮게 보고, 특정 문서에서만 자주 등장하는 단어는 중요도를 높게 본다. 마치 불용어를 제외하고 보듯이. -> IDF 항에서 이 역할을 수행
하지만 이것이 DTM보다 성능이 항상 좋지는 않다.
DTM을 만든 뒤 TF-IDF 가중치를 DTM에 적용
사실 DTM 자체가 이미 TF (Term Frequency)

tf 뒤에 곱해지는 log항이 IDF.

전체 문서의 수가 5개라고 해봅시다. 그리고 단어 'like'가 문서2에서 200번, 문서 3에서 300번 등장했다고 해봅시다. 다른 문서에서 단어 'like'는 등장하지 않았습니다. 이때, 단어 'like'의 IDF는 몇일까요?
($tf1=200,tf2=300, N=500, df=200 $)

IDF=log(500/200)=ln(5/2)=0.91629073187IDF = log(500/200) = ln(5/2) = 0.91629073187
그러면 여기서 문서2와 문서3의 단어 'like'의 TF-IDF의 값은?
문서2TFIDF=200ln(5/2)=183.258146375문서2 TF-IDF = 200 * ln(5/2) = 183.258146375
문서3TFIDF=300ln(5/2)=274.887219562문서3 TF-IDF = 300 * ln(5/2) = 274.887219562

위에꺼 계산 이거 맞아??

https://www.bloter.net/newsView/blt201609280001
희소벡터를 해결하기 위해 특이값분해를 통해 축소시킨다.

from math import log
import pandas as pd
docs = [
  'John likes to watch movies and Mary likes movies too',
  'James likes to watch TV',
  'Mary also likes to watch football games',  
]
# DTM의 열을 만들기 위해 문서 3개의 단어가 모두 들어간 통합 단어장을 만든다.

vocab = list({w for doc in docs for w in doc.split()}) # set comprehension으로 중복 단어 제거    
vocab.sort()
print('단어장의 크기 :', len(vocab))
print(vocab)

N = len(docs)
N
단어장의 크기 : 13
['James', 'John', 'Mary', 'TV', 'also', 'and', 'football', 'games', 'likes', 'movies', 'to', 'too', 'watch']





3
# TF-IDF 함수를 만드는 데, log 항에는 분모 1을 더해준다 (0 방지)

def tf(t, d):
    return d.count(t)

def idf(t):
    df = 0
    for doc in docs:
        df += t in doc
    return log(N/(df + 1)) + 1 # log항에 또 1을 더해준다. 분자와 분모값이 같아져서 0이 되는 것을 방지

def tf_idf(t,d):
    return tf(t,d) * idf(t)
# TF 함수를 사용하여 DTM 생성

result = []
for i in range(N):
    result.append([]) # 빈 리스트 삽입
    d= docs[i]
    for j in range(len(vocab)):
        t = vocab[j]
        
        result[-1].append(tf(t,d)) # 빈 리스트에 tf 삽입


tf_  = pd.DataFrame(result, columns= vocab)
tf_
James John Mary TV also and football games likes movies to too watch
0 0 1 1 0 0 1 0 0 2 2 2 1 1
1 1 0 0 1 0 0 0 0 1 0 1 0 1
2 0 0 1 0 1 0 1 1 1 0 1 0 1
result = []
for j in range(len(vocab)):
    t = vocab[j]
    result.append(idf(t))

idf_ = pd.DataFrame(result, index = vocab, columns=["IDF"])
idf_
IDF
James 1.405465
John 1.405465
Mary 1.000000
TV 1.405465
also 1.405465
and 1.405465
football 1.405465
games 1.405465
likes 0.712318
movies 1.405465
to 0.712318
too 1.405465
watch 0.712318
result = []
for i in range(N):
    result.append([])
    d = docs[i]
    for j in range(len(vocab)):
        t = vocab[j]
        
        result[-1].append(tf_idf(t,d))

tfidf_ = pd.DataFrame(result, columns = vocab)
tfidf_
James John Mary TV also and football games likes movies to too watch
0 0.000000 1.405465 1.0 0.000000 0.000000 1.405465 0.000000 0.000000 1.424636 2.81093 1.424636 1.405465 0.712318
1 1.405465 0.000000 0.0 1.405465 0.000000 0.000000 0.000000 0.000000 0.712318 0.00000 0.712318 0.000000 0.712318
2 0.000000 0.000000 1.0 0.000000 1.405465 0.000000 1.405465 1.405465 0.712318 0.00000 0.712318 0.000000 0.712318

사이킷런에서 DTM을 만들 때 CountVectorizer를 사용했듯이,
TF-IDF를 자동으로 계산하여 출력하는 TfidfVectorizer 사용 가능하다.
여기에선 log항의 분자에도 1을 더해주며, TF-IDF 결과에 L2 Norm까지 추가로 수행한다

from sklearn.feature_extraction.text import TfidfVectorizer

corpus = [
  'John likes to watch movies and Mary likes movies too',
  'James likes to watch TV',
  'Mary also likes to watch football games',  
]

tfidfv = TfidfVectorizer().fit(corpus)
vocab = list(tfidfv.vocabulary_.keys()) # 단어장을 리스트로 저장
vocab.sort() # 단어장을 알파벳 순으로 정렬

# TF-IDF 행렬에 단어장을 데이터프레임의 열로 지정하여 데이터프레임 생성
tfidf_ = pd.DataFrame(tfidfv.transform(corpus).toarray(), columns = vocab)
tfidf_
also and football games james john likes mary movies to too tv watch
0 0.000000 0.321556 0.000000 0.000000 0.000000 0.321556 0.379832 0.244551 0.643111 0.189916 0.321556 0.000000 0.189916
1 0.000000 0.000000 0.000000 0.000000 0.572929 0.000000 0.338381 0.000000 0.000000 0.338381 0.000000 0.572929 0.338381
2 0.464997 0.000000 0.464997 0.464997 0.000000 0.000000 0.274634 0.353642 0.000000 0.274634 0.000000 0.000000 0.274634

LSA

LSA(Latent Semantic Analysis) 는 전체 코퍼스에서 문서 속 단어들 사이의 관계를 찾아내는 자연어 처리 정보 검색 기술
LSA를 사용하면 단어와 단어 사이, 문서와 문서 사이, 단어와 문서 사이의 의미적 유사성 점수를 찾아낼 수 있다.

고유값(eigenvalue), 고유벡터(eigenvector), 고유값 분해(eigen decomposition)
특잇값 분해
Singular value decomposition의 목적


LSA는 DTM이나 TF-IDF 행렬 등에 Truncated SVD를 수행
Truncated SVD를 수행하면 행렬 Σ의 대각 원솟값 중에서 상윗값 t개만 남게 되며, U행렬과 V행렬의 k열까지만 남는다. 이로 인해 세 행렬에서 값(정보)의 손실이 일어나 기존의 행렬 A를 정확히 복구할 수는 없다.
여기서 k는 하이퍼파라미터. t를 크게 잡으면 기존의 행렬 A로부터 다양한 의미를 가져갈 수 있지만, 노이즈를 제거하려면 k를 작게 잡아야 한다.
여기서 얻은 Uk,VkT,SU_k, V_k^T, S는 각각 '문서들과 관련된 의미들을 표현한 행렬', '단어들과 관련된 의미를 표현한 행렬' , '각 의미의 중요도를 표현한 행렬' 이라고 해석할 수 있다.
Uk는 mXk의 크기를 가지는데 m은 문서 벡터로, 줄어들지 않는다.
VkT는 kXn으로 저차원으로 축소되었다. k열은 전체 코퍼스로부터 얻어낸 k개의 주요 주제(topic)라고 할 수 있다.

import pandas as pd
import numpy as np
import urllib.request
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer
import nltk
from nltk.corpus import stopwords
from nltk.stem import WordNetLemmatizer
# nltk 데이터셋 다운
nltk.download('punkt')
nltk.download('wordnet')
nltk.download('stopwords')
[nltk_data] Downloading package punkt to /aiffel/nltk_data...
[nltk_data]   Unzipping tokenizers/punkt.zip.
[nltk_data] Downloading package wordnet to /aiffel/nltk_data...
[nltk_data]   Unzipping corpora/wordnet.zip.
[nltk_data] Downloading package stopwords to /aiffel/nltk_data...
[nltk_data]   Unzipping corpora/stopwords.zip.





True
# 데이터 다운로드

import os

csv_filename = os.getenv('HOME')+'/aiffel/topic_modelling/data/abcnews-date-text.csv'

urllib.request.urlretrieve("https://raw.githubusercontent.com/franciscadias/data/master/abcnews-date-text.csv", 
                           filename=csv_filename)
('/aiffel/aiffel/topic_modelling/data/abcnews-date-text.csv',
 <http.client.HTTPMessage at 0x7f31e2fe6690>)
data = pd.read_csv(csv_filename, error_bad_lines=False)
data.shape

# 108만개의 샘플 존재
(1082168, 2)
data.head()
publish_date headline_text
0 20030219 aba decides against community broadcasting lic...
1 20030219 act fire witnesses must be aware of defamation
2 20030219 a g calls for infrastructure protection summit
3 20030219 air nz staff in aust strike for pay rise
4 20030219 air nz strike to affect australian travellers
# 헤드라인만 가져오자
text = data[['headline_text']].copy()
text.head()
headline_text
0 aba decides against community broadcasting lic...
1 act fire witnesses must be aware of defamation
2 a g calls for infrastructure protection summit
3 air nz staff in aust strike for pay rise
4 air nz strike to affect australian travellers
text.nunique() # 중복을 제외하고 유일한 시퀀스를 가지는 샘플의 개수를 출력
headline_text    1054983
dtype: int64
text.drop_duplicates(inplace=True) # 중복 샘플 제거
text.reset_index(drop=True, inplace=True)
text.shape
(1054983, 1)

데이터 정제 및 정규화

# NLTK 토크나이저를 이용해서 토큰화
text['headline_text'] = text.apply(lambda row: nltk.word_tokenize(row['headline_text']), axis=1)

# 불용어 제거
stop_words = stopwords.words('english') # 영어 불용어 선언
text['headline_text'] = text['headline_text'].apply(lambda x: [word for word in x if word not in (stop_words)])

text.head()
headline_text
0 [aba, decides, community, broadcasting, licence]
1 [act, fire, witnesses, must, aware, defamation]
2 [g, calls, infrastructure, protection, summit]
3 [air, nz, staff, aust, strike, pay, rise]
4 [air, nz, strike, affect, australian, travellers]
# 단어 정규화. 3인칭 단수 표현 -> 1인칭 변환, 과거형 동사 -> 현재형 동사 등을 수행한다.
# WordNetLemmatizer().lemmatize로 동일한 단어이지만 다른 표현을 가지는 단어들을 하나로 통합(단어 정규화)
text['headline_text'] = text['headline_text'].apply(lambda x: [WordNetLemmatizer().lemmatize(word, pos='v') for word in x])

# 길이가 1 ~ 2인 단어는 제거.
text = text['headline_text'].apply(lambda x: [word for word in x if len(word) > 2])
print(text[:5])
0     [aba, decide, community, broadcast, licence]
1    [act, fire, witness, must, aware, defamation]
2       [call, infrastructure, protection, summit]
3            [air, staff, aust, strike, pay, rise]
4    [air, strike, affect, australian, travellers]
Name: headline_text, dtype: object

역토큰화 및 DTM 생성

DTM을 생성하는 CountVectorizer 또는 TF-IDF 행렬을 생성하는 TfidfVectorizer의 입력으로 사용하기 위해서는 토큰화 과정을 역으로 되돌리는 역토큰화(detokenization)

text
0               [aba, decide, community, broadcast, licence]
1              [act, fire, witness, must, aware, defamation]
2                 [call, infrastructure, protection, summit]
3                      [air, staff, aust, strike, pay, rise]
4              [air, strike, affect, australian, travellers]
                                 ...                        
1054978                   [compliment, womans, smile, guide]
1054979                 [white, house, defend, trump, tweet]
1054980           [winter, close, tasmania, snow, ice, fall]
1054981    [womens, world, cup, australia, win, despite, ...
1054982           [youtube, stunt, death, foreshadow, tweet]
Name: headline_text, Length: 1054983, dtype: object
# 역토큰화 (토큰화 작업을 역으로 수행)
detokenized_doc = []
for i in range(len(text)):
    t = ' '.join(text[i])
    detokenized_doc.append(t)

train_data = detokenized_doc
train_data[:5]
['aba decide community broadcast licence',
 'act fire witness must aware defamation',
 'call infrastructure protection summit',
 'air staff aust strike pay rise',
 'air strike affect australian travellers']
# 상위 5000개의 단어만 사용
c_vectorizer = CountVectorizer(stop_words='english', max_features = 5000)
document_term_matrix = c_vectorizer.fit_transform(train_data)
print('행렬의 크기 :',document_term_matrix.shape)
행렬의 크기 : (1054983, 5000)

scikit-learn TruncatedSVD 활용

Truncated SVD를 통해 LSA를 수행한다.
토픽의 수 10 = 하이퍼파라미터 k. $$ 행렬 V_k^T 가 k * (단어의 수)의 크기 $$를 가지도톡 DTM에 TruncatedSVD를 수행

from sklearn.decomposition import TruncatedSVD

n_topics = 10
lsa_model = TruncatedSVD(n_components = n_topics)
lsa_model.fit_transform(document_term_matrix)
array([[ 1.20376737e-02, -3.81908022e-03,  1.81480609e-02, ...,
         2.79256277e-03, -9.93909980e-04,  1.24848449e-02],
       [ 2.90955010e-02, -1.09056248e-02,  1.80152176e-02, ...,
        -5.38805396e-03,  1.28468980e-02, -7.26340264e-03],
       [ 5.05735116e-03, -2.00901941e-03,  9.65353416e-03, ...,
        -5.28652194e-03, -3.01980860e-03,  2.67820340e-03],
       ...,
       [ 2.96924830e-02,  4.83505650e-03,  2.49322624e-02, ...,
         3.50457171e-02, -6.91731142e-03,  3.45635228e-03],
       [ 6.16084902e-02,  7.94331605e-04,  1.35989941e-01, ...,
         1.07899084e+00, -6.19802463e-01, -3.47817540e-01],
       [ 7.06307670e-02,  3.09586671e-02,  3.65633899e-03, ...,
         8.62574723e-02,  6.14413597e-04,  1.42147004e-02]])
# 위 코드로 없는 TruncatedSVD를 통해 얻은 행렬의 크기 확인
print(lsa_model.components_.shape)
(10, 5000)
terms = c_vectorizer.get_feature_names() # 단어 집합. 5,000개의 단어가 저장됨.

# 각 주제에서 n개씩 단어 출력
# 토픽 모델링
def get_topics(components, feature_names, n=5):
    for idx, topic in enumerate(components):
        print("Topic %d:" % (idx+1), [(feature_names[i], topic[i].round(5)) for i in topic.argsort()[:-n - 1:-1]])
get_topics(lsa_model.components_, terms)
Topic 1: [('police', 0.74637), ('man', 0.45355), ('charge', 0.2109), ('new', 0.14089), ('court', 0.11144)]
Topic 2: [('man', 0.69418), ('charge', 0.3003), ('court', 0.16799), ('face', 0.11374), ('murder', 0.10701)]
Topic 3: [('new', 0.83671), ('plan', 0.23631), ('say', 0.18335), ('govt', 0.11207), ('council', 0.1097)]
Topic 4: [('say', 0.73696), ('plan', 0.36221), ('govt', 0.1653), ('council', 0.13156), ('fund', 0.07648)]
Topic 5: [('plan', 0.72884), ('council', 0.17292), ('govt', 0.15001), ('urge', 0.09461), ('water', 0.07344)]
Topic 6: [('govt', 0.52766), ('court', 0.28817), ('fund', 0.22782), ('urge', 0.18901), ('face', 0.17731)]
Topic 7: [('charge', 0.5379), ('court', 0.43098), ('face', 0.33173), ('plan', 0.14738), ('murder', 0.12647)]
Topic 8: [('win', 0.71685), ('court', 0.22828), ('crash', 0.2076), ('kill', 0.17477), ('australia', 0.11384)]
Topic 9: [('court', 0.5989), ('accuse', 0.12775), ('kill', 0.12475), ('face', 0.11953), ('crash', 0.1135)]
Topic 10: [('council', 0.77771), ('kill', 0.19497), ('crash', 0.1333), ('report', 0.08672), ('water', 0.05911)]

LDA (Latent Dirichlet Allocation, LDA)

위키독스
잠재 디리클레 할당. 다른 토픽 모델링. LDA는 문서들이 토픽들의 혼합으로 구성되어 있으며, 토픽들은 확률 분포에 기반하여 단어들을 생성한다고 가정. 그리고 데이터가 주어지면, LDA는 이 가정에 따라 단어들의 분포로부터 문서가 생성되는 과정을 역추적해 문서의 토픽을 찾아낸다.
즉, LDA는 각 토픽의 단어 분포와 각 문서의 토픽 분포를 추정한다.

  • LSA는 차원을 축소하여 축소 차원에서 근접 단어들을 토픽으로 묶는다.
  • LDA는 단어가 특정 토픽에 존재할 확률과 문서에 특정 토픽이 존재할 확률을 결합 확률로 추정하여 토픽을 추출
# 상위 5,000개의 단어만 사용
tfidf_vectorizer = TfidfVectorizer(stop_words='english', max_features=5000)
tf_idf_matrix = tfidf_vectorizer.fit_transform(train_data)

# TF-IDF 행렬의 크기를 확인해봅시다.
print('행렬의 크기 :', tf_idf_matrix.shape)
행렬의 크기 : (1054983, 5000)

scikit-learn LDA Model 활용

from sklearn.decomposition import LatentDirichletAllocation

# 토픽 개수 10개
lda_model = LatentDirichletAllocation(n_components=10, learning_method='online', random_state=777, max_iter=1)
lda_model.fit_transform(tf_idf_matrix)
array([[0.0335099 , 0.69841093, 0.0335099 , ..., 0.0335099 , 0.0335099 ,
        0.0335099 ],
       [0.03365631, 0.03365631, 0.03365631, ..., 0.03365631, 0.03365631,
        0.03365631],
       [0.0366096 , 0.0366096 , 0.0366096 , ..., 0.67051361, 0.0366096 ,
        0.0366096 ],
       ...,
       [0.02914502, 0.02914502, 0.14077174, ..., 0.02914502, 0.02914502,
        0.26688721],
       [0.02637829, 0.12325014, 0.02638944, ..., 0.21422895, 0.02637829,
        0.0996168 ],
       [0.03376121, 0.03376055, 0.03376055, ..., 0.03376055, 0.50437083,
        0.03376055]])
# LDA로 얻는 행렬 크기
print(lda_model.components_.shape)
(10, 5000)
# LDA의 결과 토픽과 각 단어의 비중을 출력
terms = tfidf_vectorizer.get_feature_names() # 단어 집합. 5,000개의 단어가 저장됨.

def get_topics(components, feature_names, n=5):
    for idx, topic in enumerate(components):
        print("Topic %d:" % (idx+1), [(feature_names[i], topic[i].round(5)) for i in topic.argsort()[:-n-1:-1]])

get_topics(lda_model.components_, terms)
Topic 1: [('new', 6839.37079), ('government', 6344.47105), ('election', 5419.59529), ('adelaide', 4864.1739), ('home', 4048.41226)]
Topic 2: [('say', 7929.44145), ('change', 4193.89405), ('year', 3924.88995), ('live', 3625.10473), ('market', 3541.15301)]
Topic 3: [('australian', 7667.75985), ('south', 4846.36918), ('perth', 4552.99622), ('2016', 3955.96018), ('open', 3771.68108)]
Topic 4: [('trump', 8187.32772), ('school', 3966.83143), ('jail', 3245.22756), ('women', 3029.28982), ('life', 2998.44275)]
Topic 5: [('police', 5589.78951), ('melbourne', 5299.84238), ('warn', 3577.31091), ('rural', 3521.5736), ('hospital', 3106.7779)]
Topic 6: [('world', 4536.54893), ('sydney', 4406.58731), ('country', 4167.71984), ('years', 3581.99631), ('man', 3520.89397)]
Topic 7: [('charge', 5946.75892), ('day', 5062.31785), ('house', 4481.76928), ('murder', 4065.57534), ('crash', 3793.50261)]
Topic 8: [('australia', 7253.84683), ('attack', 4787.62503), ('north', 3706.11788), ('state', 3658.17043), ('west', 2890.04311)]
Topic 9: [('kill', 4093.71098), ('die', 4025.52754), ('woman', 3983.65518), ('death', 3465.80304), ('drug', 3050.84164)]
Topic 10: [('queensland', 5552.50636), ('canberra', 4323.24), ('win', 4315.15603), ('coast', 3825.82182), ('tasmanian', 3552.17616)]

형태소 분석기와 OOV 문제

비지도 학습 토크나이저로 있는 형태소 분석기

# 영어 토큰화 슬쩍 해보자
# 띄어쓰기만 해도 잘 된다
en_text = "The dog ran back to the corner near the spare bedrooms"
print(en_text.split())
['The', 'dog', 'ran', 'back', 'to', 'the', 'corner', 'near', 'the', 'spare', 'bedrooms']
kor_text = "사과의 놀라운 효능이라는 글을 봤어. 그래서 오늘 사과를 먹으려고 했는데 사과가 썩어서 슈퍼에 가서 사과랑 오렌지 사 왔어"
print(kor_text.split())
['사과의', '놀라운', '효능이라는', '글을', '봤어.', '그래서', '오늘', '사과를', '먹으려고', '했는데', '사과가', '썩어서', '슈퍼에', '가서', '사과랑', '오렌지', '사', '왔어']

한국어와 같은 교착어에 존재하는 조사와 같은 문제 때문에, 이를 제거하지 않으면 전부 다른 단어로 인식한다.

from konlpy.tag import Okt

tokenizer = Okt()
print(tokenizer.morphs(kor_text))
['사과', '의', '놀라운', '효능', '이라는', '글', '을', '봤어', '.', '그래서', '오늘', '사과', '를', '먹으려고', '했는데', '사과', '가', '썩어서', '슈퍼', '에', '가서', '사과', '랑', '오렌지', '사', '왔어']

Okt로 했을 땐 그나마 잘 분리된다. 하지만 등록된 단어가 아닌 새로운 단어를 인식하기는 어렵다.

print(tokenizer.morphs('모두의연구소에서 자연어 처리를 공부하는 건 정말 즐거워'))
['모두', '의', '연구소', '에서', '자연어', '처리', '를', '공부', '하는', '건', '정말', '즐거워']

'모두의연구소'는 하나의 단어이나, 전부 분리되어버렸다!
이것을 해결하기 위해, 텍스트 데이터에서 특정 문자 시퀀스가 함께 자주 등장하는 빈도가 높고, 앞뒤로 조사 또는 완전히 다른 단어가 등장하는 것을 고려해서 해당 문자 시퀀스를 형태소라고 판단하는 형태소 분석기가 있는데, 이것이 soynlp!
예를 들어, '모두의연구소'라는 문자열이 자주 연결되어 등장한다면 형태소라고 판단하고, '모두의연구소'라는 단어 앞, 뒤에 '최고', 'AI', '실력'과 같은 독립된 다른 단어들이 계속해서 등장한다면 '모두의연구소'를 형태소로 파악하는 식

soynlp

soynlp는 품사 태깅, 형태소 분석 등을 지원하는 한국어 형태소 분석기
비지도 학습으로 형태소 분석을 하며, 데이터에 자주 등장하는 단어들을 형태소로 분석한다.
내부적으로 단어 점수표로 동작하며, 이 점수는 응집 확률(cohesion probability) 과 브랜칭 엔트로피(branching entropy)으로 활용한다.

# soynlp 예제 말뭉치 다운
import urllib.request

txt_filename = os.getenv('HOME')+'/aiffel/topic_modelling/data/2016-10-20.txt'

urllib.request.urlretrieve("https://raw.githubusercontent.com/lovit/soynlp/master/tutorials/2016-10-20.txt",\
                            filename=txt_filename)
('/aiffel/aiffel/topic_modelling/data/2016-10-20.txt',
 <http.client.HTTPMessage at 0x7f31d66c50d0>)
from soynlp import DoublespaceLineCorpus

# 말뭉치에 대해서 다수의 문서로 분리
corpus = DoublespaceLineCorpus(txt_filename)
len(corpus)
30091
# 상위 3개만 출력
i = 0
for document in corpus:
  if len(document) > 0:
    print(document)
    i = i+1
  if i == 3:
    break
19  1990  52 1 22
오패산터널 총격전 용의자 검거 서울 연합뉴스 경찰 관계자들이 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개를 압수했다 실제 폭발할지는 알 수 없는 요구르트병에 무언가를 채워두고 심지를 꽂은 사제 폭탄도 발견됐다  일부는 숲에서 발견됐고 일부는 성씨가 소지한 가방 안에 있었다
테헤란 연합뉴스 강훈상 특파원 이용 승객수 기준 세계 최대 공항인 아랍에미리트 두바이국제공항은 19일 현지시간 이 공항을 이륙하는 모든 항공기의 탑승객은 삼성전자의 갤럭시노트7을 휴대하면 안 된다고 밝혔다  두바이국제공항은 여러 항공 관련 기구의 권고에 따라 안전성에 우려가 있는 스마트폰 갤럭시노트7을 휴대하고 비행기를 타면 안 된다 며 탑승 전 검색 중 발견되면 압수할 계획 이라고 발표했다  공항 측은 갤럭시노트7의 배터리가 폭발 우려가 제기된 만큼 이 제품을 갖고 공항 안으로 들어오지 말라고 이용객에 당부했다  이런 조치는 두바이국제공항 뿐 아니라 신공항인 두바이월드센터에도 적용된다  배터리 폭발문제로 회수된 갤럭시노트7 연합뉴스자료사진

soynlp는 비지도학습 형태소 분석기이기에, 기존의 형태소 분석기와는 달리 학습 과정이 필요하다.
내부적으로는, 전체 코퍼스로부터 응집 확률과 브랜칭 엔트로피 단어 점수표를 만드는 과정!

# WordExtractor.extract()를 통해서 전체 코퍼스에 대해 단어 점수표를 계산
from soynlp.word import WordExtractor

word_extractor = WordExtractor()
word_extractor.train(corpus)
word_score_table = word_extractor.extract()
training was done. used memory 2.496 Gb
all cohesion probabilities was computed. # words = 223348
all branching entropies was computed # words = 361598
all accessor variety was computed # words = 361598

응집확률 (cohesion probability)

내부 문자열(substring)이 얼마나 응집하여 자주 등장하는지를 판단하는 척도다. 응집 확률을 문자열을 문자 단위로 분리하여 내부 문자열을 만드는 광정에서, 왼쪽부터 순서대로 문자를 추가하면서 각 문자열이 주어졌을 때 그다음 문자가 나올 확률을 계산하여 누적 곱을 한 값이다. 이 값이 높을수록 전체 코퍼스에서 이 문자열 시퀀스는 하나의 단어로 등장할 가능성이 높다.

print("반포한        : ",word_score_table["반포한"].cohesion_forward)
print("반포한강      : ",word_score_table["반포한강"].cohesion_forward)
print("반포한강공    : ",word_score_table["반포한강공"].cohesion_forward)
print("반포한강공원   : ",word_score_table["반포한강공원"].cohesion_forward, "< 가장 높음")
print("반포한강공원에 : ",word_score_table["반포한강공원에"].cohesion_forward)
반포한        :  0.08838002913645132
반포한강      :  0.19841268168224552
반포한강공    :  0.2972877884078849
반포한강공원   :  0.37891487632839754 < 가장 높음
반포한강공원에 :  0.33492963377557666

브랜칭 엔트로피 (branching entropy)

브랜칭 엔트로피(Branching Entropy) 는 확률 분포의 엔트로피값을 사용한다.
주어진 문자열에서 다음 문자가 등장할 수 있는 가능성을 판단하는 척도!

print("디스        : ", word_score_table["디스"].right_branching_entropy)
print("디스플      : ", word_score_table["디스플"].right_branching_entropy)
print("디스플레     : ", word_score_table["디스플레"].right_branching_entropy)
print("디스플레이   : ", word_score_table["디스플레이"].right_branching_entropy, "< 조사나 다른 단어 등장 확률 증가")
# 브랜칭 엔트로피 증가
디스        :  1.6371694761537934
디스플      :  -0.0
디스플레     :  -0.0
디스플레이   :  3.1400392861792916 < 조사나 다른 단어 등장 확률 증가

LTokenizer

띄어쓰기 단위로 잘 나뉜 문장은 L 토크나이저(LTokenizer)를 사용하면 좋다.
한국어는 띄어쓰기 단위로 나눈 어절 토큰이 주로 L 토큰 + R 토큰의 형식을 가질 때가 많다.
ex) '공원에' -> '공원' + '에'
L 토크나이저는 L 토큰 + R 토큰으로 나누되, 점수가 가장 높은 L 토큰을 찾아내는 분리 기준

from soynlp.tokenizer import LTokenizer

scores = {word:score.cohesion_forward for word, score in word_score_table.items()}
l_tokenizer = LTokenizer(scores=scores)
l_tokenizer.tokenize("국제사회와 우리의 노력들로 범죄를 척결하자", flatten=False)
[('국제사회', '와'), ('우리', '의'), ('노력', '들로'), ('범죄', '를'), ('척결', '하자')]

최대 점수 토크나이저

최대 점수 토크나이저(MaxScoreTokenizer)는 띄어쓰기가 되어 있지 않은 문장에서 점수가 높은 글자 시퀀스를 순차적으로 찾아내는 토크나이저

from soynlp.tokenizer import MaxScoreTokenizer

maxscore_tokenizer = MaxScoreTokenizer(scores=scores) # 응집 확률
maxscore_tokenizer.tokenize("국제사회와우리의노력들로범죄를척결하자")
['국제사회', '와', '우리', '의', '노력', '들로', '범죄', '를', '척결', '하자']

0개의 댓글