Codong's Development Diary RSS 태그 관리 글쓰기 방명록
2021-05-05 18:24:28

개요


딥 러닝을 이용한 자연어 처리 입문
자연어처리를 위해서는 문자를 숫자로 수치화할 필요가 있다. 첫 발자국인 카운트 기반의 단어표현에 대해 알아보려고 한다.



1. 다양한 단어의 표현 방법


시작하기에 앞서 카운트 기반의 단어 표현 방법 외에도 다양한 단어의 표현 방법에는 어떤 것이 있는지 간략하게 보고 가겠다.

1.1 단어의 표현 방법

크게 국소 표현(Local Representation) 방법(또는 이산 표현)과 분산 표현(Distributed Representation) 방법(또는 연속 표현)으로 나뉜다.

  1. 국소 표현(Local Representation) 또는 이산 표현(Discrete Representation)
    • 특징 : 해당 단어 그 자체만 보고, 특정값을 매핑하여 단어를 표현하는 방법
      ex) 강아지, 고양이, 친칠라 라는 단어가 있을 때 각 단어에 1번, 2번, 3번 등과 같은 숫자를 맵핑(mapping)하여 부여한다.
    • 종류 : One-Hot Vector, N-gram, count base(Bag of Words) 등
  2. 분산 표현(Distributed Representation) 또는 연속 표현(Continuous Representation)
    • 특징 : 그 단어를 표현하고자 주변을 참고하여 단어를 표현하는 방법
      ex) 강아지라는 단어 근처에 주로 귀여운, 사랑스러운이라는 단어가 자주 등장하므로, 강아지라는 단어는 귀엽운, 사랑스러운 느낌이다라고 단어를 정의한다.
    • 종류 : prediction base(Word2Vec, FastText), count base(LSA, Glove) 등

즉, 국소 표현 방법에서 단어의 의미, 느낌을 표현할 수 없지만, 분산 표현 방법은 단어의 느낌을 표현할 수 있다는 차이가 있다.



2. Bag of Words(Bow)


2.1 Bag of Words 란

Bag of Words란 단어들의 순서는 전혀 고려하지 않고, 단어들의 출현 빈도(frequency)에만 집중하는 텍스트 데이터의 수치화 표현 방법이다. 만드는 과정은 다음과 같다.

  1. 우선, 각 단어에 고유한 정수 인덱스를 부여한다.
  2. 각 인덱스의 위치에 단어 토큰의 등장 회수를 기독한 벡터를 만든다.

1번은 뭐 그렇다 치는데 2번은 살짝 무슨 소린지 느낌이 잘 안온다. 다음 예시를 통해 이해해보자.

from konlpy.tag import Okt
okt=Okt()  

token="내꺼인듯 내꺼아닌 내꺼같은 너란 사람은 참"
token=okt.morphs(token) # 형태소분석
word2index={}  # 단어에 인덱스 부여
bow=[]    # 빈도수저장


# 토큰하나씩 들고온다
for voca in token:
    # word2index 안에 해당 토큰이 없다면
    if voca not in word2index:  
        # 인덱스(word2index길이만큼)를 지정해주고
        word2index[voca]=len(word2index)
        # 단어가 등장했으니, 빈도수를 담는 리스트에 1을 넣어준다.
        bow.insert(len(word2index)-1,1)
    # word2index 안에 있는 토큰이라면
    else:
        # 해당 토큰의 인덱스를 가져와서
        index=word2index[voca]
        # 한 번 더나왔으니 그 토큰의 인덱스로 빈도수 리스트에 접근하여 값을 1을 더해준다
        bow[index]=bow[index]+1

결과

print(word2index)
# {'내꺼인듯': 0, '내꺼': 1, '아닌': 2, '같은': 3, '너': 4, '란': 5, '사람': 6, '은': 7, '참': 8}
print(bow)
# [1, 2, 1, 1, 1, 1, 1, 1, 1]

내꺼라는 단어가 두번 나와서 2라고 적혔고, 나머지는 1번 나와서 위와 같은 결과가 나왔다.

즉, 각 token(중복없이)에 인덱스를 부여하고, 해당 token의 빈도수를 기록한 것을 bag에 담아놓는다. 이렇게 bow화된 bag을 이용하여 이후에 어떤 문장이 주어지면, 그 문장을 token화 한 뒤, bag에서 해당 token의 빈도수를 가져와 위 예시의 bow와 같이 단어를 정수화 된 리스트로 만들 수 있다.

2.2 CountVectorizer 클래스로 BoW 만들기

사이킷 런(scikit-learn)에서 단어의 빈도를 Count 하여 Vector로 만드는 CountVectorizer 클래스를 지원한다.

from sklearn.feature_extraction.text import CountVectorizer
corpus = ['내꺼인듯 내꺼아닌 내꺼같은 너란 사람은 참','아니 왜 한 글 자 는 없 어 지 는 거 지']
vector = CountVectorizer()

print(vector.fit_transform(corpus).toarray()) # 코퍼스로부터 각 단어의 빈도 수를 기록한다.
print(vector.vocabulary_) # 각 단어의 인덱스가 어떻게 부여되었는지를 보여준다. 

print 결과

[[1 1 1 1 1 0]
 [0 0 0 0 0 1]]
{'내꺼인듯': 2, '내꺼아닌': 1, '내꺼같은': 0, '너란': 3, '사람은': 4, '아니': 5}

2.1 에서 했던 과정을 아주 손쉽게 진행할 수 있다. 하지만 결과에서 보면 내부 로직에 의해 한글자 이하는 포함시키지 않고, 띄어쓰기로 구분하여 하기 때문에 토큰화하기 때문에, 띄어쓰기를 구분자로 한 토큰화된 결과를 인풋으로 넣거나(ex. 내꺼인듯 내꺼 아닌 내꺼 같은 너란 ...), 한국어에 특화된 다른 라이브러리를 이용하면 좋을 것 같다.

2.3 정리

Bow를 사용한다는 것은 그 문서에서 각 단어가 얼마나 자주 등장했는지를 보겠다는 것이다. 그리고 각 단어에 대한 빈도수를 수치화 하겠다는 것은 결국 텍스트 내에서 어떤 단어들이 중요한지를 보고싶다는 의미로 함축된다. 그렇기에 불용어와 같은 별로 의미를 갖지 않는 단어들을 제거해줘야 더 의미있는 단어들만 사용하여 정확도를 올릴 수 있다. 하지만 count 기반이다 보니 중요한 것은 빈도수가 낮거나, bag안에 없는 단어들은 그만큼 의미를 두지 않는다는 점이다. 이후 DTM의 한계에서 더 자세히 알아 보도록하자.



3. 문서 단어 행렬(Document-Term Matrix, DTM)


각 문서에 대한 BoW 표현 방법을 그대로 갖고와서, 서로 다른 문서들의 BoW들을 결합한 표현 방법이다. 이하 DTM이라 명명, 행과 열을 반대로하면 TDM이라 부르기도 함. 이 방법을 통해 서로 다른 문서들을 비교할 수 있게 된다.

3.1 문서 단어 행렬 (DTM)의 표기법

문서 단어 행렬(DTM)이란 다수의 문서에서 등장하는 각 단어들의 빈도를 행렬로 표현한 것을 말한다. 쉽게 생각하면 각 문서에 대한 BoW를 하나의 행렬로 만든 것으로 생각할 수 있다.

문서1 : 먹고 싶은 사과
문서2 : 먹고 싶은 바나나
문서3 : 길고 노란 바나나 바나나
문서4 : 저는 과일이 좋아요

이를 문서 단어 행렬로 표현하면 다음과 같다.

- 과일이 길고 노란 먹고 바나나 사과 싶은 저는 좋아요
문서1 0 0 0 1 0 1 1 0 0
문서2 0 0 0 1 1 0 1 0 0
문서3 0 1 1 0 2 0 0 0 0
문서4 1 0 0 0 0 0 0 1 1

이처럼 각 문서에서 등장한 단어의 빈도를 행렬의 값으로 표기하여 서로 비교할 수 있도록 수치화 할 수 있다.

3.2 문서 단어 행렬(DTM)의 한계

매우 간단하고 구현하기도 쉽지만, 본질적으로 가지는 몇 가지 한계들이 있다.

3.2.1 희소표현(Sparse representation)

원-핫 백터는 단어 집합의 크기가 벡터의 차원이 되고 대부분의 값이 0이 된다는 특징이 있다. 이 특징은 공간적 낭비와 계산 리소스를 증가시킬 수 있다는 점에서 원-핫 벡터의 단점이었다. DTM도 마찬가지다. 각 행을 문서 벡터라고 한다면, 각 문서 벡터의 차원은 원-핫 벡터와 마찬가지로 전체 단어 집합의 크기를 가진다. 만약 corpus가 방대하다면 문서 벡터의 차원은 수백만의 차원을 가질 수도 있다. 또한 대부분의 값이 0을 가질 수도 있다.

원-핫 벡터나 DTM과 같은 대부분의 값이 0인 표현을 희소 벡터(Sparse vector) 또는 희소 행렬(Sparse matrix)라고 부른다. 희소 벡터는 많은 양의 저장 공간과 계산을 위한 리소스를 필요하기 때문에, 전처리를 통해 단어 집합의 크기를 줄이는 일은 BoW 표현을 사용하는 모델에서 중요할 수 있다. 이에 대한 방법으론, 구두점, 빈도수 낮은 단어 / 불용어 제거하고 어간이나 표제어 추출을 통해 단어를 정규화하여 단어 집합의 크기를 줄일 수 있다.

3.2.2 단순 빈도 수 기반 접근

영어에 대해 DTM을 만들었을 때, 불용어인 'the'는 어떤 문서이든 자주 등장할 수 밖에 없다. 그래서 유사한 문서인지 비교할 때, 단순 the가 빈도수가 높다고 유사한 문서라고 판단해선 안 된다. 불용어와 중요 단어에 대해 가중치를 줄 수 있는 방법은 없을까? 그래서 등장한 것이 TF-IDF이다.



4. TF-IDF(Term Frequency-Inverse Document Frequency)


TF-IDF 가중치를 이용하여 기존의 DTM을 사용하는 것보다 더 많은 정보를 고려하여 문서들을 비교할 수 있다. (주의할 점은 TF-IDF가 DTM보다 항상 성능이 뛰어나진 않다)

4.1 TF-IDF(단어 빈도 - 역 문서 빈도)

단어의 빈도와 역 문서 빈도(문서의 빈도에 특정 식을 취함)를 사용하여 DTM 내의 각 단어들마다 중요한 정도를 가중치로 주는 방법이다. 우선 DTM을 만든 후, TF-IDF 가중치를 부여한다. TF-IDF는 TF와 IDF를 곱한 값을 의미한다.

주로 사용되는 경우

  1. 문서의 유사도를 구하는 작업
  2. 검색 시스템에서 검색 결과의 중요도를 정하는 작업
  3. 문서 내에서 특정 단어의 중요도를 구하는 작업

문서를 d, 단어를 t 문서의 총 개수를 n이라고 표현할 때, TF, DF, IDF 각각은 다음과 같이 정의할 수 있다.

1. tf(d,t) : 특정 문서 d에서 특정 단어 t의 등장 횟수.

DTM의 한 행이 하나의 문서에서 나온 단어의 빈도수 벡터이므로 TF는 DTM에서 이미 구했다고 볼 수 있다.

2. df(t) : 특정 단어 t가 등장한 문서의 수

여기서 특정 단어가 각 문서, 또는 문서들에서 몇 번 등장했는지는 관심가지지 않고, 등장한 문서 수만 관심을 가진다. 예를 들어 바나나가 각 문서에서 등장했는지 안했는지만 카운트하지, 하나의 문서에 몇 번 등장했는지까지 고려하지 않는다.

  • ex) 문서 1: 바나나 2번 출현, 문서 2: 바나나 1번 출현, 문서 3: 바나나 0번 출현.
    이 경우 바나나는 총 3번 등장했지만, 바나나가 존재한 문서는 2개 뿐이기에 -> df(바나나) = 2

3. idf(d,t) : df(t)에 반비례하는 수

$ idf(d,f) = \log{(\frac{n}{1+df(t)})}$

반비례라면 당연 df의 역수지 않을까 생각했지만, log와 분모에 1을 더하는 것은 왜일까 라는 생각이 든다. 이유는 듣고보면 이해가 된다. log를 사용하지 않으면 분자인 문서수 n이 커질 수록, idf의 값은 기하급수적으로 커지게 때문에 log를 취한다(여기서 로그의 밑은 2나 10이나 상관없지만, 10을 이용한다). 그리고 1을 더하는 것은 df가 0일 경우가 있을 수도 있으니 0으로 나누는 것을 방지 위함이다. 이것을 스무딩(smoothing) 처리라 표현한다.

  • ex) 총 문서 10개 중 $n = 10$
  1. 문서1에서 '바나나'가 100번 등장했다 가정하고 $tf(문서1,바나나) = 100 $
    모든 문서에 바나나가 있을경우 $df(바나나) = 10$
    idf 값은 -0.04이 된다. $idf = \log{\left(\frac{n}{df(바나나)+1}\right)} = \log{(\frac{10}{11})} = -0.04 $
    문서 1의 '바나나'의 tf-idf 값은 -4 이 된다. $tf(문서1,바나나) \times idf(바나나) = 100 \times -0.04 = -4 $
    => 이와 같이 idf가 음수가 되는 경우 tf-idf 값은 보통 0으로 처리한다.

  2. 같은 문서인 문서 1에 '맛있다' 라는 단어가 50번 등장하고 $tf(문서1,맛있다) = 50 $
    문서 1에만 존재한다면 $df(맛있다) = 1$
    idf 값은 0.7이 되므로 $idf(맛있다)=\log{(\frac{10}{2})} = 0.7$
    문서1에서 '맛있다'의 tf- idf 값은 100이 된다 $tf(문서1,맛있다) \times idf(맛있다) = 50 \times 0.7 = 35$

위 예시를 통해 알 수 있는 것은, idf의 경우 모든 문서에서 등장할 경우 값이 0이 되거나 음수가 될 수 있다. 이 경우는 모든 문서에서 등장하므로 가치가 없다고 판단하는 것이다.

4.2 TF-IDF 정리

  1. tf 관점 : 문서 내에 빈도수가 높은 단어가 중요하다고 판단.
  2. idf 관점 : 해당 단어가 해당 문서에서 외에 다른 문서에 많이 등장하지 않은 경우 중요하다고 판단.
    위 예시에서 '맛있다' 처럼 한 문서에만 존재시 idf값이 커져서 문서 1에서 '맛있다' 라는 단어가 핵심어가 될 수 있음.

결과적으로 idf 값은 해당 단어가 각 문서에 존재한 경우가 적을 수록(df 값이 작을 수록) 분모가 작으므로 값이 커진다. 이것을 tf에 곱해주므로 해당 문서 내의 단어에 가중치를 줄 수 있다.

➕ scikit-learn 사용시 tf-idf 값이 위와 같이 직접한 것과 다른 이유

scikit-learn 에서의 idf 구하는 식이 다음과 같다.
$\log{\left(\frac{1+n}{1+df}\right)}$
위에서 배운 식과 다르게 분자에도 1을 더해줌을 알 수 있다.

뿐만아니라 tf-idf를 구할 떄에도 tf와 idf를 바로 곱하는 것이 아니라, idf에 1을 더하여 계산한다.
$tfidf=tf \times (idf+1)$
여기서 왜 idf에 1을 더해주는지는 잘 모르겠다.

마지막으로 여기에 L2 norm이란 걸 해준다고 한다. L2 norm 간단히 말하면 X라는 벡터의 각 원소들을 제곱합에 루트를 씌워준다.
$\sqrt{\sum_{k=1}^N x_k^2} \quad where . \quad X=[x_1,x_2,x_3, ..., x_n]$
이 값을 각 원소에 나눠서 tf-idf 값을 결정한다.
$tfidf = x_1 / \sqrt{\sum_{k=1}^N x_k^2}$

reference