AI/K-Digital Training

028. Hello NLP(Natural Language Processing)!

찌리남 2021. 10. 7. 01:14
728x90

이번 주 들어 본격적으로 자연어 처리에 대해 공부하기 시작했습니다.
자연어 처리는 AI 3 대장 중 하나라고 합니다.
AI 3 대장은 현재 AI에서 가장 많이 쓰이고 있는 분야 세 가지를 꼽은 건데,

  1. 자연어 처리
  2. 컴퓨터 비젼
  3. 스피치 프로세싱

위의 세 개가 AI 3 대장이라고 합니다.
자연어 처리는 딥러닝 등장 이전에도 존재해왔는데 등장 이후 성능이 퀀텀 점프를 하면서 주목을 받았다고 합니다.

일단 자연어라는 단어부터 생소합니다. 평소에 거의 쓰지 않는 단어라서 그렇습니다.
우리가 흔히 생각하고 우리가 현재 쓰고 있는 한국어, 영어 같은 언어가 자연어라고 보면 됩니다.
누군가 의도적으로 만들지 않고 자연적으로 생겨난 부드러운 언어라고 생각하면 됩니다.
그 반대로 파이썬, C++, Java, HTML(?) 같은 것들이 인공어라고 불립니다.
이는 컴퓨터를 이해시키기 위한 딱딱한(?) 언어라고 보면 됩니다.

자연어 처리를 한 마디로 설명하면 "사람의 말을 컴퓨터에게 이해시키기 위한 기술"입니다.
그래서 자연어 처리의 최종 목표는 영화 "인터스텔라"에 나오는 타스처럼
컴퓨터가 사람의 말을 이해하도록 만들어서, 컴퓨터가 우리를 위해 일하게 하는 것입니다.

위에서 이야기했든 자연어는 부드럽고, 인공어는 딱딱합니다.
다시 말하면 프로그래밍 언어는 기계적이고 형태도 고정되어 있는 반면에
우리가 쓰는 언어는 같은 의미도 다르게 표현할 수 있고, 의미가 애매하고, 형태도 유연합니다.
그리고 세월의 흐름에 따라 새로운 말과 의미가 생기고 사라지기까지 합니다.

이 두 종류 언어의 차이 때문에 자연어 처리는 평범하지 않은 일입니다.
그래서 이 어려운 문제만 해결한다면 사람들에게 직접적으로 도움이 되는 일을 컴퓨터에게 시킬 수 있습니다.
예를 들면 검색 엔진, 기계 번역, 문장 자동 요약이 그 예로 이미 우리 삶 깊숙이 들어와 큰 역할을 해내고 있습니다.

자연어 처리는 의미의 최소 단위인 '단어'를 컴퓨터에게 이해시키는 것에서 시작한다고 합니다.
컴퓨터에게 단어의 의미를 이해시키기 위해 단어의 의미를 잘 파악하는 표현 방법을 잘 구상해야 합니다.

초기에는 시소러스(Thesaurus)를 이용했습니다.
시소러스는 사람의 손으로 직접 단어의 유의어들을 한 그룹으로 묶은 사전을 말합니다.
특히 자연어 처리에 사용되는 시소러스는 '상위와 하위', '전체와 부분'까지 정의합니다.

위의 이미지처럼 단어의 관계를 정의해 단어 사이의 관계를 컴퓨터에게 가르칠 수 있습니다.

자연어 처리 분야에서 가장 유명한 시소러스는 WordNet입니다.
WordNet은 프린스턴 대학교에서 1985년부터 구축한 전통 있는 시소러스로
지금까지도 많은 연구가 이뤄지고 있습니다.

하지만 시소러스는 사람이 수작업으로 레이블링 하는 방식이므로 크나큰 결점들이 있습니다.
첫째로 시시각각 변하는 의미의 변화에 적응하지 못합니다.
예를 들어 2021년의 아마존과 1991년의 아마존은 다른 의미를 가질 것입니다.
지금의 아마존은 인터넷 쇼핑몰을 떠올릴 것이고 30년 전에는 브라질의 정글을 떠올릴 것입니다.
둘째로 인력이 많이 듭니다. 세상에 존재하는 단어를 모두 수작업으로 입력해야 됩니다.
현재 존재하고 있는 영어 단어의 수가 1,000만 개가 넘는다고 하니 엄두가 안 납니다.
셋째로 단어의 애매함과 미묘한 차이를 표현할 수 없습니다.

이런 시소러스의 한계를 피하기 위해 '통계 기반 기법'과 '추론 기반 기법'이 고안되었습니다.
두 방법 모두 방대한 텍스트 데이터에서 단어의 의미를 자동으로 추출합니다.
자동 의미 추출 과정에서 시소러스의 한계를 탈피한 것입니다.
이번 글에서는 통계 기반 기법까지 살펴볼 것입니다.

통계 기반 기법에서는 말뭉치(Corpus)를 이용합니다. 말뭉치는 대량의 텍스트 데이터입니다.
말뭉치는 맹목적으로 수집된 데이터가 아니라 자연어에 대한 사람의 '지식'이 담긴 데이터입니다.
데이터 안에 문장을 쓰는 방법, 단어를 선택하는 방법, 단어의 의미가 포함되어 있다는 가정에서
자동으로 그리고 효율적으로 핵심을 추출하는 것이 통계 기반 기법의 목표입니다.

"밑바닥부터 시작하는 딥러닝 2"의 2장에 나오는 코드를 그대로 구현하면서 공부해보겠습니다.

우선 말뭉치로 이용할 예시 문장을 변수 처리합니다.

text = "You say goodbye and I say hello."


text를 단어 단위로 분할합니다.

text = text.lower() # 모든 글자를 소문자로 바꿉니다. text = text.replace('.', ' .') # .도 구분하기위해 '.'을 ' .'으로 바꿉니다. text

you say goodbye and i say hello .

words = text.split(' ') # 공백을 기준으로 분할합니다. words

['you', 'say', 'goodbye', 'and', 'i', 'say', 'hello', '.']

정규표현식 re(regular expression)을 임포트하고 re.split('(\W+)?'.text)를 호출하면 단어 단위로 분할할 수 있지만
여기선 짧은 말뭉치를 사용하기 때문에 이렇게 하겠습니다.

이렇게 words라는 list로 만들었지만 str 그대로 조작하기는 불편하기 때문에
단어와 ID를 짝 지어줘 dict로 만들어 주겠습니다.

word_to_id = {}
id_to_word = {}

for word in words:
    if word not in word_to_id:
        new_id = len(word_to_id)
        word_to_id[word] = new_id
        id_to_word[new_id] = word

print(word_to_id)
print(id_to_word)

{'you': 0, 'say': 1, 'goodbye': 2, 'and': 3, 'i': 4, 'hello': 5, '.': 6}
{0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6: '.'}

print(id_to_word[2]) print(word_to_id['goodbye'])

goodbye
2

word_to_id를 numpy array로 변환합니다.

import numpy as np # 넘파이 모듈 불러오기 
corpus = [word_to_id[w] for w in words]  
corpus = np.array(corpus)

array([0, 1, 2, 3, 4, 1, 5, 6])

이것으로 "You say goodbye and I say hello."를 넘파이로 만들어 말뭉치를 이용하기 위한 준비를 마쳤습니다.
위의 과정을 한 번에 처리하는 preprocess라는 함수를 만듭니다.

def preprocess(text):
    text = text.lower()
    text = text.replace('.', ' .')
    words = text.split(' ')
    
    word_to_id = {}
    id_to_word = {}

    for word in words:
        if word not in word_to_id:
            new_id = len(word_to_id)
            word_to_id[word] = new_id
            id_to_word[new_id] = word

    corpus = [word_to_id[w] for w in words]

    return corpus, word_to_id, id_to_word
preprocess(text)

([0, 1, 2, 3, 4, 1, 5, 6],
{'.': 6, 'and': 3, 'goodbye': 2, 'hello': 5, 'i': 4, 'say': 1, 'you': 0},
{0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6: '.'})

위의 방법으로 단어를 벡터로 표현할 수 있게 되었습니다.
이 세상의 거의 모든 색은 RGB(빨강, 초록, 파랑)의 벡터 값에 의해 표현될 수 있습니다.
이것과 비슷한 논리로 단어도 벡터 표현으로 거의 모든 의미를 표현할 수 있을 거라는 아이디어가
자연어 처리 분야에서는 "분산 표현"이라고 합니다.

벡터 값으로 단어의 의미를 찾는 연구의 시작은 모두 "단어의 의미는 주변 단어에 의해 형성된다"는
아이디어에 기반을 두고 있습니다. 이를 분포 가설(Distributional Hypothesis)라고 합니다.
쉽게 말하면 컴퓨터가 단어 자체의 의미보다는 맥락으로 의미를 파악하는 것입니다.
여기서 맥락이란 주변의 단어를 말합니다.
"You say goodbye and I say hello." 에서 and의 맥락은 goodbye와 I입니다.
그리고 윈도우 크기(window_size)가 1이면 단어 하나만 맥락에 포함되고,
윈도우 크기가 2이면 단어 두 개가 맥락에 포함됩니다.

분포 가설에 기반하여 단어를 벡터로 나타내는 방법은 위에서 이야기한 윈도우 크기를 이용해야 합니다.
어떤 단어를 중심으로 그 주변에 어떤 단어가 몇 번이나 등장하는지를 세어 집계하면 됩니다.

이란 위에서 만든 corpus와 id_to word를 불러옵니다.

text = "You say goodbye and I say hello." 
corpus, word_to_id, id_to_word = preprocess(text) 
print(corpus) 
print(id_to_word)

[0, 1, 2, 3, 4, 1, 5, 6]
{0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6: '.'}

you의 맥락은 say가 되고, say의 맥락은 you, goodbye, i, hello 4개가 됩니다.
이를 모든 단어에 직접 세면

  you say goodbye and i hello .
you 0 1 0 0 0 0 0
say 1 0 1 0 1 1 0
goodbye 0 1 0 1 0 0 0
and 0 0 1 0 1 0 0
i 0 1 0 1 0 0 0
hello 0 1 0 0 0 0 1
. 0 0 0 0 0 1 0

이런 표를 만들 수 있습니다. (Google Spreadsheet에서 가져오느라 사이즈가 뒤죽박죽입니다..)
이 표의 각 행은 해당 단어를 표현한 벡터가 됩니다. 그리고 이런 표를 동시발생 행렬이라고 부릅니다.
이 표를 파이썬에서 구현하면,

C = np.array([
              [0, 1, 0, 0, 0, 0, 0],
              [1, 0, 1, 0, 1, 1, 0],
              [0, 1, 0, 1, 0, 0, 0],
              [0, 0, 1, 0, 1, 0, 0],
              [0, 1, 0, 1, 0, 0, 0],
              [0, 1, 0, 0, 0, 0, 1],
              [0, 0, 0, 0, 0, 1, 0]
], dtype=np.int32)

위 행렬에서 만든 벡터 값을 다음과 같은 방법으로 각 단어의 벡터를 얻을 수 있습니다.

print(C[1]) 
print(C[word_to_id['hello']])

[1 0 1 0 1 1 0]
[0 1 0 0 0 0 1]

위의 과정을 자동화하는 함수를 만들어 줍니다.

def create_co_matrix(corpus, vocab_size, window_size=1):
    corpus_size = len(corpus) 
    co_matrix = np.zeros((vocab_size, vocab_size), dtype=np.int32) 

    for idx, word_id in enumerate(corpus): 
        for i in range(1, window_size + 1):
            left_idx = idx - 1  
            right_idx = idx + 1

            if left_idx >= 0: 
                left_word_id = corpus[left_idx] 
                co_matrix[word_id, left_word_id] += 1 

            if right_idx < corpus_size: 
               right_word_id = corpus[right_idx] 
               co_matrix[word_id, right_word_id] += 1

    return co_matrix

array([[0, 1, 0, 0, 0, 0, 0],
[1, 0, 1, 0, 1, 1, 0],
[0, 1, 0, 1, 0, 0, 0],
[0, 0, 1, 0, 1, 0, 0],
[0, 1, 0, 1, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 1, 0]], dtype=int32)

위에서 만든 벡터 사이의 유사도는 코사인 유사도를 사용해 구합니다.
코사인 유사도의 분자는 벡터의 내적, 분모는 각 벡터의 노름(L2놂)이 들어갑니다.

출처:위키백과


파이썬으로 코사인 유사도를 표현하면 아래처럼 됩니다.
분모의 값이 0이 되면 오류가 발생하므로 분모에 아주 작은 값을 더해줍니다.

def cos_similarity(x, y, eps=1e-8):
	nx = x / np.sqrt(np.sum(x**2) + eps) 
   	ny = y / np.sqrt(np.sum(y**2) + eps) 
   	return np.dot(nx, ny)


그리고 다음은 위의 코드들을 모두 이용하여 단어의 유사도를 구하는 코드입니다.
"You say goodbye and i say hello"라는 문장에서 'you'와 'i'의 유사도를 구합니다.

text = "You say goodbye and I say hello." 
corpus, word_to_id, id_to_word = preprocess(text) 
vocab_size = len(word_to_id) 
C = create_co_matrix(corpus, vocab_size) 
c0 = C[word_to_id['you']] 
c1 = C[word_to_id['i']] 
print(cos_similarity(c0, c1))

0.7071067811865475

위의 유사도를 구하는 코드를 이용해 유사 단어의 랭킹을 표시하는 코드를 구현합니다.

def most_similar(query, word_to_id, id_to_word, word_matrix, top=5):
    if query not in word_to_id: 
        print('%s(을)를 찾을 수 없습니다.' % query)
        return
    
    print('\n[query]' + query)
    query_id = word_to_id[query] 
    query_vec = word_matrix[query_id] 

    vocab_size = len(id_to_word) 
    similarity = np.zeros(vocab_size) 
    for i in range(vocab_size): 
        similarity[i] = cos_similarity(word_matrix[i], query_vec) 

    count = 0
    for i in (-1 * similarity).argsort(): 
        if id_to_word[i] == query:
            continue
        print(count+1,'위: ',' %s: %s' % (id_to_word[i], similarity[i]))

        count += 1
        if count >= top:
            return
most_similar('i', word_to_id, id_to_word, C, top=5)

[query]i
1위: goodbye: 0.9999999999999998
2위: you: 0.7071067811865475
3위: hello: 0.4999999999999999
4위: say: 0.0
5위: and: 0.0

I와 유사도가 높은 단어들을 랭킹 순위대로 나열했습니다.
하지만 I와 유사도가 있는 단어가 3개인데, I와 goodbye가 이렇게도 유사도가 높은 것은 설명이 되지 않습니다.
물론 I와 you가 유사하다는 것은 같은 인칭대명사이므로 직관적으로 납득이 됩니다.
이런 한계점이 생기는 이유 중 하나는 말뭉치(Corpus)의 크기가 작다는 것입니다.
다음 시간에는 말뭉치의 크기를 키우거나 추론 기반 기법 등으로 NLP에 대해 공부하겠습니다.

오늘 다룬 내용은 자연어 처리의 원시 버전이라고 보면 됩니다.
GPT-3 나 BERT 등을 보면 지금 하고 있는 작업은 구석기시대 수준입니다..
그러나 어떤 철기 문명도 다 구석기시대는 겪었겠죠??

다음 글에서는 좀 더 진보된 버전의 NLP를 다뤄보겠습니다..

728x90
반응형