BUILDING AI AGENTS WITH LLMs, RAG, AND KNOWLEDGE GRAPHS · PART 1
01

딥러닝으로 텍스트 데이터 분석하기
— 언어를 숫자의 공간에 올려놓다

언어는 인간의 가장 경이로운 능력이지만, 기계에게는 해독 불가능한 기호의 나열일 뿐이다. 이 장은 자연어를 원-핫 인코딩에서 word2vec까지 점점 정교한 벡터로 바꾸고, RNN·LSTM·GRU·CNN으로 그 벡터의 시퀀스를 읽어내는 여정을 다룬다. 이것이 ChatGPT 같은 거대 언어 모델을 이해하기 위한 출발점이다.

저자 Salvatore Raieli · Gabriele Iuculano 출판 Packt, 2025 원서 pp.3–33

언어는 한 사람의 생애 동안 진화하며, 복잡한 의미를 실어 나르는 인간의 가장 놀라운 능력이다. 그러나 자연 상태의 언어는 기계가 이해할 수 없다. 단어의 미묘한 뉘앙스를 집어내는 알고리즘을 만드는 일은 지극히 어렵다. 그래서 이 장이 던지는 질문은 단순하다. 텍스트를 어떻게 기계가 소화할 수 있는 형태로 바꿀 것인가.

경로는 이렇다. 자연어 텍스트에서 출발해 점점 정교해지는 수치 벡터 — 원-핫 인코딩, 단어 가방(BoW), TF-IDF — 를 거쳐, 마침내 단어의 의미 자체를 실수 벡터로 표현하는 word2vec에 도달한다. 표현을 얻은 다음에는 시퀀스를 분석하는 딥러닝 모델 — RNN, LSTM, GRU, CNN — 의 구조와 강점, 그리고 한계를 짚는다. 마지막으로 이 모든 부품을 조립해 영화 리뷰 감성 분류기를 직접 훈련한다.

Technical Requirements

이 장의 코드는 표준 파이썬 라이브러리로 구성되며, 각 주피터 노트북에 필요한 라이브러리가 명시되어 있다. 저장소는 github.com/PacktPublishing/Modern-AI-Agents/tree/main/chr1이다. CPU에서도 실행할 수 있으나 GPU를 권장한다.

SECTION 01

AI를 위한 텍스트 표현

이미지나 표와 달리 텍스트가 유난히 다루기 어려운 이유가 있다. 단어의 의미(기의)와 그것을 나타내는 기호(기표) 사이에 고정된 관계가 없기 때문이다. 같은 단어라도 문맥과 저자의 의도에 따라 의미가 달라진다.

알고리즘에 텍스트를 먹이려면 먼저 수치 표현으로 바꿔야 한다. 그 첫걸음이 텍스트 정규화다. 말뭉치(corpus)를 기본 단위인 단어로 쪼개는데, 공백이라는 자연스러운 경계를 이용하고(text segmentation) 구두점도 하나의 단어로 취급한다. 구두점은 문장의 경계이자 화제 전환·질문·감탄 같은 중요한 정보를 담고 있기 때문이다.

다음은 "무엇을 같은 단어로 볼 것인가"의 문제다. "He"와 "he"는 같은 단어이므로 소문자로 통일한다. 더 정교하게는 표제어 추출(lemmatization) — "came"과 "comes"를 같은 동사의 어형으로 묶는 것 — 이나 어간 추출(stemming) — 접미사를 모두 벗겨내는 것 — 을 쓴다. 텍스트에는 단어 외에도 백분율, 숫자, 웹사이트 주소가 섞여 있으므로, 텍스트를 기본 단위로 변환하는 이 전체 작업을 토큰화(tokenization)라 부른다.

원-핫 인코딩 — 가장 단순한 출발점

전통 NLP는 텍스트를 이산 기호로 표현했고, 그 가장 단순한 형태가 원-핫 인코딩이다. 말뭉치 전체에서 서로 다른 단어의 집합, 즉 어휘집(vocabulary)을 만들고, 각 단어를 어휘집 크기만큼 긴 벡터 — 해당 단어의 자리만 1이고 나머지는 전부 0인 희소 벡터 — 로 나타낸다. 이 과정을 벡터화(vectorization)라고도 한다.

restaurant = 000001000
pizzeria = 001000000
FIG 1-A원-핫 벡터. 두 단어 모두 음식을 파는 장소지만, 벡터 어디에도 그 유사성이 담기지 않는다.

이 표현의 문제는 뚜렷하다.

구현 절차는 세 단계다. 토큰화 전에 텍스트를 표준화(소문자 변환)하고, 고유 단어로 어휘집을 만들어 벡터에서 단어를 역으로 찾을 수 있게 저장하고, 배열을 만들어 어휘집 내 단어 인덱스 위치에 1을, 나머지에 0을 채운다.

# "Should we go to a pizzeria or do you prefer a restaurant?"
sentence = "Should we go to a pizzeria or do you prefer a restaurant?"
one_hot_matrix, vocabulary = one_hot_encoding(sentence)
print("Vocabulary:", vocabulary)
print("One-Hot Encoding Matrix:\n", one_hot_matrix)
Important Note

다른 문장을 고르면 행렬이 달라지고, 문장이 길어지면 서로 다른 단어의 수에 비례해 행렬이 커진다. 반복되는 단어는 동일한 벡터를 갖는다는 점도 확인하라.

단어 가방(Bag-of-Words) — 빈도를 보존하다

실제 말뭉치는 수천, 수백만 개의 문서로 이루어지고 각 문서에는 단어들이 서로 다른 빈도로 등장한다. 내용이 비슷한 문서는 의미도 비슷하므로, 이 빈도 정보는 텍스트 분류에 중요하다. BoW는 바로 이 빈도 속성을 보존하는 특징 추출 알고리즘이다. 이름의 "가방"은 문장의 어순과 구조에 관한 정보를 전혀 보존하지 않는다는 뜻이다. 필요한 것은 어휘집과 단어를 세는 방법뿐이며, 문서 하나가 벡터 하나 — 어휘집 단어들의 등장 빈도를 담은 문서 벡터 — 가 된다.

DOCUMENT (Hamlet) to be, or not to be, that is the question 어순과 구조는 버리고 빈도만 가방에 담는다 wordcount to2 be2 or / not / that1 is / the / question1 → 문서 하나 = 빈도 벡터 하나
FIG 1-BBoW 알고리즘의 표현. 원서 Figure 1.1은 햄릿의 몇 구절로 같은 개념을 시각화한다.

물론 이 표현에도 문제는 남는다. 어휘집이 커지면 벡터도 커지고(벡터 길이 = 어휘집 길이), 문서들이 서로 많이 다를수록 벡터는 흩어진다. 고차원·희소 벡터는 메모리와 연산 비용만이 아니라 알고리즘 자체에도 해롭다. 벡터가 길수록 필요한 가중치가 늘어 과적합 위험이 커지고, 특징이 많아질수록 예제 간 거리가 무의미해진다. 이것이 차원의 저주다. 대규모 말뭉치에서는 구두점 무시, 오탈자 교정, 어간 추출, 그리고 정보를 더하지 못하는 고빈도 단어(관사, 전치사 등)의 제거 같은 처방이 제안되어 왔다.

구현은 세 단계다. 각 문서를 토큰화해 단어 목록을 얻고, 고유 단어의 어휘집을 만들어 각 단어를 인덱스에 대응시키고, 행이 문서·열이 어휘집 단어인 행렬을 만든다. 문서가 예제이고 단어가 특징이다.

import numpy as np
def bag_of_words(sentences):
    """문서 목록의 bag-of-words 표현을 만든다."""
    tokenized_sentences = [
        sentence.lower().split() for sentence in sentences
    ]
    flat_words = [
        word for sublist in tokenized_sentences for word in sublist
    ]
    vocabulary = sorted(set(flat_words))
    word_to_index = {word: i for i, word in enumerate(vocabulary)}

    bow_matrix = np.zeros((len(sentences), len(vocabulary)), dtype=int)
    for i, sentence in enumerate(tokenized_sentences):
        for word in sentence:
            if word in word_to_index:
                bow_matrix[i, word_to_index[word]] += 1
    return vocabulary, bow_matrix

corpus = ["This movie is awesome awesome",
          "I do not say is good, but neither awesome",
          "Awesome? Only a fool can say that"]
vocabulary, bow_matrix = bag_of_words(corpus)
Important Note

이 예제에서 "awesome"이라는 단어는 긍정·중립·부정 리뷰 모두에 등장한다. 문맥 없이 빈도만으로는 리뷰의 감성을 알 수 없다는 사실을 눈여겨보라.

TF-IDF — 흔한 단어를 누르고 희귀한 단어를 띄우다

앞에서 문서-단어 행렬을 얻었지만 원시 빈도는 심하게 치우쳐 있어 문서를 구별하는 데 늘 유효하지는 않다. 문서-단어 행렬은 원래 정보 검색에서 문서를 찾기 위해 태어났는데, "good"이나 "bad" 같은 단어는 어디에나 일반적인 의미로 쓰이니 변별력이 없다. 반대로 빈도가 낮은 단어일수록 정보량이 크다. 우리의 관심은 절대 빈도가 아니라 상대 빈도다.

TF — 단어 빈도 IDF — 역문서 빈도 이 문서 안에서 얼마나 자주 나오는가 — 다만 100번 나온 단어가 100배 중요하진 않으므로 log₁₀을 취한다 전체 문서 중 몇 개에만 등장하는가 — 소수 문서에만 나타나는 단어일수록 그 문서의 의미를 잘 대변한다 ×
FIG 1-CTF-IDF의 두 축. 원서 Figure 1.2의 직관을 재구성했다.

구체적으로, 100번 등장한 단어가 의미상 100배 중요하지는 않으므로 원시 빈도 대신 밑이 10인 로그를 쓴다(빈도가 0이면 0을 배정한다). 그리고 일부 문서에만 등장하는 단어에 더 큰 무게를 주기 위해 IDF — 말뭉치 전체 문서 수를, 해당 단어가 등장하는 문서 수로 나눈 비율 — 로 정규화한다. 요컨대 TF에 IDF의 로그를 곱한 것이 TF-IDF다.

def compute_tf(sentences):
    """문장 목록의 단어 빈도(TF) 행렬을 계산한다."""
    vocabulary = sorted(set(word for sentence in sentences
                        for word in sentence.lower().split()))
    word_index = {word: i for i, word in enumerate(vocabulary)}
    tf = np.zeros((len(sentences), len(vocabulary)), dtype=np.float32)
    for i, sentence in enumerate(sentences):
        words = sentence.lower().split()
        for word in words:
            if word in word_index:
                tf[i, word_index[word]] += 1 / len(words)
    return tf, vocabulary

def compute_idf(sentences, vocabulary):
    """역문서 빈도(IDF)를 계산한다."""
    num_documents = len(sentences)
    idf = np.zeros(len(vocabulary), dtype=np.float32)
    word_index = {word: i for i, word in enumerate(vocabulary)}
    for word in vocabulary:
        df = sum(1 for s in sentences if word in s.lower().split())
        idf[word_index[word]] = np.log(num_documents / (1 + df)) + 1  # 평활화
    return idf

def tf_idf(sentences):
    tf, vocabulary = compute_tf(sentences)
    idf = compute_idf(sentences, vocabulary)
    return vocabulary, tf * idf
Important Note

이전 절과 같은 말뭉치를 사용했다. 정규화 후 단어의 빈도 값이 어떻게 달라졌는지 확인하라.

이 절에서 배운 것을 한 문장으로 줄이면 이렇다. 가장 흔한 단어의 영향력은 줄이고, 특정 문서 집합에만 나타나는 단어에 무게를 실어주도록 텍스트를 정규화하는 법. 다음은 임베딩이다.

SECTION 02

임베딩 — 의미를 담는 밀집 벡터

지금까지의 벡터는 컴퓨터가 소화할 수는 있지만 희소성과 고차원이라는 병을 앓는다. 처방의 근거는 분포 가설이다. 비슷한 의미의 단어는 자주 가까이 나타나고, 같은 문맥에 자주 등장하는 단어는 같은 의미를 갖는다.

거꾸로 같은 단어도 문맥에 따라 의미가 갈린다. "은행(bank)에 돈을 맡기러 갔다"와 "강둑(bank)에서 소풍을 했다"의 bank는 같은 기호지만 다른 의미다. 우리가 원하는 것은 텍스트에서 출발해 단어마다 벡터 하나를 배정하는 사상(mapping)이다. 조건은 명확하다. 크기가 작고, 실수(밀집)로 채워지고, 문맥 정보를 보존하며, 어휘집이 커져도 크기가 늘지 않는 벡터. 희소 벡터에서는 의미가 "1이 어디에 있는가"라는 국소적 위치에 담겼지만, 밀집 벡터에서는 의미가 벡터 전체에 분산 표현된다. 이런 밀집 벡터가 곧 단어 임베딩이며, 유사성 개념을 훨씬 잘 담기 때문에 다양한 연산에 쓸 수 있다. 이 개념은 2013년 미콜로프(Mikolov)가 word2vec이라는 프레임워크로 도입했다.

word2vec — 문맥을 맞히면 의미가 남는다

word2vec의 직관은 단순하다. 문맥으로부터 단어 w를 예측하라. 신경망과 큰 말뭉치만 있으면 된다. 혁명적인 발상은, 목표 단어 w 근처에 어떤 단어 c가 오는지 맞히도록 신경망을 훈련하면 그 신경망의 가중치 자체가 임베딩 벡터가 된다는 것이다. 레이블은 텍스트 안에 이미 암묵적으로 들어 있으니 우리가 제공할 필요가 없다 — 자기지도(self-supervised) 학습이다.

word2vec은 두 가지 장치로 이 아이디어를 극도로 빠르고 효과적으로 만든다. 과제를 이진 분류("단어 c가 단어 w의 문맥에 필요한가? 예/아니오")로 바꾸고, 로지스틱 회귀 분류기를 쓰는 것이다.

we went to bank for deposit 문맥 창 c — 긍정 예제 목표 단어 w pizza · guitar · cloud ← 무작위 추출한 부정 예제(negative sampling)
FIG 1-Dword2vec은 문맥 창을 밀며 창 안의 단어를 긍정 예제로, 무작위 표본 단어를 부정 예제로 삼는다(원서 Figure 1.4의 개념).

텍스트 t 위로 문맥 창 c를 밀고 가면, 창 중앙의 단어 w 주변 단어들은 긍정 클래스의 예제다. 여기에 다른 무작위 단어들을 부정 예제로 뽑아, 긍정과 부정을 분류하는 모델을 훈련한다. 그 모델의 가중치가 우리의 임베딩이다.

단어 w와 c가 주어졌을 때, "c가 w의 문맥에 있을 확률"이 두 임베딩의 유사도와 같기를 원한다. w와 c의 벡터가 비슷하면 c는 w의 문맥에 자주 나타나야 한다 — word2vec은 문맥 유사성이라는 개념 위에 서 있다. 임베딩 유사도는 두 벡터의 내적으로 정의하고, 시그모이드 함수로 확률로 바꾼다.

P(+ | w, c) = σ(c · w) = 11 + exp(−c · w) c가 w의 문맥일 확률 = 두 임베딩이 유사할 확률

문맥 L 안의 모든 단어에 대해 이를 수행하되, 계산을 단순화하기 위해 문맥 창의 단어들이 서로 독립이라 가정하고 확률을 곱한다. 동시에 문맥에 없는 단어에 대해서는 내적이 최소가 되기를 원한다. 즉 한쪽에서는 문맥 단어의 확률을 최대화하고, 다른 쪽에서는 훈련 중 무작위로 추출한 비문맥 단어의 확률을 최소화한다.

P(+ | w, c) = ∏i=1..L 11 + exp(−ci · w)    P(− | w, c) = ∏i=1..L 11 + exp(ci · w) 문맥 단어는 확률을 키우고, 부정 표본은 확률을 줄인다 — 로그를 취해 곱을 합으로 바꾼다
LCE = −log P(+ | w, cpos) + ∑i=1..L log P(− | w, cneg) 교차 엔트로피 손실로 각 벡터의 가중치를 학습한다

실제로 모델은 두 개의 벡터 행렬(w용, c용)을 학습하지만 둘이 매우 비슷하므로 하나만 취한다. 이 가중치 행렬 w가 우리의 임베딩이고, 이제부터 쓸 것이 바로 이것이다.

임베딩의 품질을 좌우하는 선택지들이 있다.

from gensim.models import Word2Vec
model = Word2Vec(sentences=list_of_tokens,
                 sg=1,
                 vector_size=100,
                 window=5,
                 workers=4)
Important Note

전체 코드는 GitHub 저장소에 있다. 여기서는 100차원 임베딩과 5단어 창을 사용했다.

임베딩을 얻었으면 시각화할 수 있다. 벡터를 군집화하면 의미가 비슷한 단어끼리 모여야 한다(원서 Figure 1.6). 그런데 벡터는 100~1,024차원의 다차원 데이터라 눈으로 볼 수 없으므로 2~3차원으로 줄이는 차원 축소가 필요하다. 고전적으로는 PCAt-SNE가 쓰였고, 최근 몇 년 사이 UMAP이 첫 번째 선택지로 떠올랐다. UMAP은 예제 간 의미 관계를 더 잘 보존하고 국소 구조와 전역 구조를 모두 잘 나타내므로 군집이 더 선명하며, 분류 전 전처리 단계로도 쓸 수 있다(원서 Figure 1.7).

SECTION 03

유사도라는 자, 코사인 유사도

벡터 표현을 얻었으면 이제 벡터 사이의 유사도를 재는 자가 필요하다. 어떤 단어와 가장 비슷한 단어를 임베딩 공간에서 찾을 때도, 질의 문장과 가장 관련 있는 문서를 검색할 때도, 결국 하는 일은 벡터 비교다.

대부분의 유사도 측정은 내적(dot product)에 기반한다. 두 벡터가 같은 차원에서 값을 가지면 내적이 커지고, 서로 엇갈리게 0을 가지면 내적이 0 — 직교, 즉 비유사 — 이 되기 때문이다. 단어 공기(共起) 행렬이나 문서 TF 행렬에서 나온 벡터의 유사도 측정에 내적이 쓰인 이유다.

a · b = ∑i=1..N ai × bi = a₁b₁ + a₂b₂ + ⋯ + aNbN    |a| = √( ∑i=1..N ai² ) 내적과 크기(magnitude)의 정의

그러나 내적에는 문제가 있다.

그래서 정규화된 내적, 즉 두 벡터 사이 각도의 코사인과 동치인 코사인 유사도가 대안으로 자리 잡았다.

cos Θ = a · b|a| |b| = ∑ ai bi√∑ai² · √∑bi² 코사인 유사도 — 크기를 지우고 방향만 남긴다
cos Θ ≈ 1 · 유사 cos Θ = 0 · 직교 cos Θ = −1 · 반대
FIG 1-E두 벡터 사이의 각도로 유사도를 잰다(원서 Figure 1.8의 개념).

코사인 유사도의 성질은 실용적이다.

SECTION 04

임베딩의 성질 — 유추, 시간, 다의성

임베딩은 놀랄 만큼 유연하다. 다양한 구문적·의미적 속성을 인코딩하며, 이를 시각화하거나 연산에 활용할 수 있다.

유사도라는 자를 손에 쥐었으니, 어떤 단어 w와 가장 비슷한 단어들을 검색할 수 있다. 단, 여기서 유사성은 "같은 문맥 창에 나타남"으로 정의된다는 점을 기억해야 한다. 그래서 모델은 유의어와 반의어를 구별하지 못한다 — good과 bad는 같은 자리에 곧잘 등장하기 때문이다. 한편 모델은 최상급이나 동사 활용형 같은 문법 관계도 표현해낸다.

유추 — 벡터 산수로 푸는 관계

또 하나의 흥미로운 관계가 유추(analogy)다. 평행사변형 모델은 인지 공간에서 유추를 표현하는 체계로, 고전적 예가 king : queen :: man : ? (수식으로는 a : b :: a* : ?)이다. 벡터가 있으니 이것을 a − a* + b라는 연산으로 바꿀 수 있다.

word_1, syn, ant = "good", "great", "bad"
most_sim = model.wv.most_similar("good")          # 최근접 단어 검색

synonyms_dist = model.wv.distance(word_1, syn)     # 유의어 거리
antonyms_dist = model.wv.distance(word_1, ant)     # 반의어 거리

a, a_star, b = 'king', 'man', 'woman'
b_star = model.wv.most_similar(positive=[a, b], negative=[a_star])
print("{} is to {} as {} is to: {}".format(a, a_star, b, b_star[0][0]))
Important Note

앞에서 직접 훈련한 임베딩으로 실험한 결과다. 모델이 반의어를 잘 처리하지 못한다는 점에 주목하라.

이 방법은 완벽하지 않다. 정답이 첫 번째 결과가 아니라 상위 세 개 안에 있는 경우도 있다. 또 이 체계는 텍스트에 자주 등장하는 개체(도시 이름, 흔한 단어)에는 잘 작동하지만 희귀한 개체에는 훨씬 약하다.

시간을 담는 벡터, 의미를 겹쳐 담는 벡터

수십 년에 걸친 말뭉치가 있다면, 임베딩은 단어의 의미가 시간에 따라 어떻게 변했는지를 연구하는 도구가 된다. 원서 Figure 1.9는 gay, broadcast, awful 세 단어의 의미가 시대별 텍스트로 만든 임베딩의 2D 투영 위에서 이동해 온 궤적을 보여준다(arxiv.org/abs/1605.09096).

Key Insight — 선형 중첩

단어는 여전히 여러 뜻을 가질 수 있다. "good" 같은 흔한 단어는 문맥에 따라 의미가 갈린다. 그렇다면 임베딩 벡터 하나는 한 가지 뜻만 담는가, 뜻의 집합을 담는가? 다행히 임베딩 벡터는 단어의 여러 의미의 가중합(선형 중첩)이며, 각 의미의 가중치는 텍스트에서 그 의미가 쓰인 빈도에 비례한다. 여러 뜻이 한 벡터에 살지만, 유추 계산에서 더하고 빼는 동안 우리는 이 성분들을 조작한다. "apple"은 과일이자 회사 이름인데, apple : red :: banana : ?라는 연산을 하면 apple 벡터에서 red와 유사한 아주 특정한 의미 성분만 빼는 셈이다. 벡터 공간이 희소하므로 희소 코딩(sparse coding)을 이용해 여러 의미를 분리해낼 수도 있다(원서 Figure 1.10, aclanthology.org/Q18-1034).

이제 벡터는 텍스트 속 각 단어의 문맥적·의미적 의미를 제공한다. 이 풍부한 정보를 텍스트 분류 같은 과제에 쓰려면, 텍스트의 순차적 본성을 다룰 줄 아는 모델이 필요하다.

SECTION 05

RNN · LSTM · GRU · CNN — 시퀀스를 읽는 네 가지 기계

표현을 얻었으니 분석할 차례다. 전통적으로는 나이브 베이즈나 로지스틱 회귀에 표현을 먹였지만, 신경망의 성공이 이 고전 알고리즘들을 낡은 것으로 만들었다.

RNN — 기억을 가진 신경망

고전 신경망의 문제는 기억이 없다는 것이다. 시계열과 텍스트에서 이것은 치명적이다. 단어 시퀀스에서 시각 t의 단어는 t−1의 단어에 의존하고, 문장의 마지막 단어는 종종 앞의 여러 단어에 의존한다. 그래서 이전 입력의 기억을 유지하는 모델이 필요하다. RNN은 내부 상태(hidden state)로 이 기억을 유지한다. 이전 입력의 정보를 저장하고, 출력은 이전 입력의 영향을 받는다. 시퀀스의 모든 원소에 같은 연산을 반복하기에(그래서 recurrent) 이 연산의 기억이 이어진다.

피드포워드 신경망의 한 층이 시각 t의 원소 벡터에 수행하는 연산은 다음 하나다.

y⁽ᵗ⁾ = σ(W xₜ + b)고전 신경망 — 입력들은 서로 독립으로 취급된다

단순 RNN은 다음 네 연산을 수행한다.

a⁽ᵗ⁾ = b + U h⁽ᵗ⁻¹⁾ + W xₜ   h⁽ᵗ⁾ = tanh(aₜ)   o⁽ᵗ⁾ = c + V h⁽ᵗ⁾   y⁽ᵗ⁾ = σ(o⁽ᵗ⁾) 이전 은닉 상태 h를 가중치 U로 끌어와 과거가 현재 출력에 개입하게 한다

복잡해 보여도 하는 일은 단순하다. 첫 식은 이전 은닉 상태 h에 가중치 행렬 U를 곱해 더한 변형 피드포워드 층이다. U는 신경망이 과거 문맥을 어떻게 사용해 현재 입력과 묶을지를 제어한다. 둘째 식에서 이후 계산과 다음 입력에 쓰일 새 은닉 상태를 만들고, 셋째 식에서 편향 벡터와 행렬로 출력을 계산하며, 마지막 식에서 비선형 함수를 통과시킨다. RNN은 시간을 따라 펼쳐진(unrolled) 개체로 볼 수 있다(원서 Figure 1.11, 1.12).

h⁽¹⁾ x₁ o₁ h⁽²⁾ x₂ o₂ h⁽³⁾ x₃ o₃ U U
FIG 1-F시간을 따라 펼친 RNN. 같은 연산이 시퀀스 전체에 반복되고 은닉 상태가 기억을 실어 나른다.
array = np.random.random((10, 5, 3))
data_tensor = torch.tensor(array, dtype=torch.float32)
RNN = nn.RNN(input_size=3, hidden_size=10, num_layers=1, batch_first=True)
output, hidden = RNN(data_tensor)
output.shape

여기서 흥미로운 점 세 가지를 볼 수 있다.

LSTM — 잊는 법을 배운 기억

이론상 RNN은 긴 시퀀스를 처리하며 초기 입력을 기억해야 하지만, 현실의 은닉 상태 정보는 전역이 아니라 국소적이다. 시각 t에서 전체 시퀀스가 아니라 직전 몇 단계만 고려한다. 근본 문제는 은닉 상태 하나가 두 역할을 동시에 떠맡는다는 데 있다. 시각 t의 출력에 필요한 정보 제공과, 미래 결정을 위한 기억 저장. LSTM은 "중요하지 않은 정보는 잊고 중요한 문맥만 남기자"는 발상으로 설계된 RNN의 확장이다(원서 Figure 1.13).

LSTM에는 층 내부의 정보를 통제하는 게이트와 전용 문맥(context) 층이 있다. 은닉 상태가 둘이다. h는 시각 t의 정보(단기 기억), c는 장기 정보를 맡는다. 게이트는 열림(1)과 닫힘(0)을 오가는데, 시그모이드 활성화를 가진 피드포워드 층으로 값을 0과 1 사이로 눌러 만든다. 여기에 Hadamard 곱(원소별 곱)을 적용하면 값이 1에 가까우면 정보가 통과하고 0에 가까우면 차단되는 이진 게이트가 된다. 게이트 덕분에 매 시간 단계마다 얼마나 보존하고 얼마나 잊을지를 결정하는 동적 체계가 성립한다.

1단계 — 망각 게이트(forget gate)

더는 필요 없는 정보를 문맥에서 지운다. 망각 게이트의 출력을 문맥에 곱한다. 입력과 직전 은닉 상태 양쪽에서 정보를 뽑으며, 게이트마다 고유한 U 가중치를 갖는다.

f⁽ᵗ⁾ = σ(b_f + U_f h⁽ᵗ⁻¹⁾ + W_f xₜ)   k⁽ᵗ⁾ = c⁽ᵗ⁻¹⁾ ⊙ f⁽ᵗ⁾

2단계 — 입력 게이트(input gate)

입력에서 정보를 추출하고, 그중 무엇을 문맥에 더할지를 입력 게이트 i가 조절한다. 문맥은 "더한 것"과 "잊고 남긴 것"의 합이다.

g⁽ᵗ⁾ = tanh(b_g + U_g h⁽ᵗ⁻¹⁾ + W_g xₜ)   i⁽ᵗ⁾ = σ(b_i + U_i h⁽ᵗ⁻¹⁾ + W_i xₜ)
j⁽ᵗ⁾ = g⁽ᵗ⁾ ⊙ i⁽ᵗ⁾   c⁽ᵗ⁾ = j⁽ᵗ⁾ + k⁽ᵗ⁾

3단계 — 출력 게이트(output gate)

마지막 게이트로 출력을 계산하고, 이 결정으로 은닉 상태도 갱신한다.

o⁽ᵗ⁾ = σ(b_o + U_o h⁽ᵗ⁻¹⁾ + W_o xₜ)   h⁽ᵗ⁾ = o⁽ᵗ⁾ ⊙ tanh(c⁽ᵗ⁾)

게이트들은 서로 독립이어서 효율적인 구현에서는 병렬화할 수 있다.

data_tensor = torch.tensor(np.random.random((10, 5, 3)), dtype=torch.float32)
LSTM = nn.LSTM(input_size=3, hidden_size=10, num_layers=1, batch_first=True)
output, (hidden, cell) = LSTM(data_tensor)   # 은닉 상태와 셀 상태 모두 접근 가능

GRU — 더 가볍게, 거의 같게

GRU는 기울기 소실을 풀기 위한 RNN의 또 다른 변형이다. LSTM처럼 내부 게이트를 갖지만 훨씬 단순하고 가볍다. 매개변수가 적은데도 LSTM보다 빨리 수렴하며 비슷한 성능에 도달한다. LSTM을 효과적으로 만든 요소들 — 덧셈 연산, Hadamard 곱, 문맥의 존재, 층 내 정보 통제 — 을 그대로 활용한다(원서 Figure 1.14).

GRU에서 망각 게이트는 업데이트 게이트라 불리지만 목적은 같다. 중요한 정보는 유지하고(1에 가까운 값) 중요하지 않은 정보는 갱신 중에 덮어쓴다(0에 가까운 값). 입력 게이트는 리셋 게이트라 불리며, LSTM과 달리 독립적이지 않고 업데이트 게이트와 연결되어 있다.

z⁽ᵗ⁾ = σ(b_z + U_z h⁽ᵗ⁻¹⁾ + W_z xₜ)   r⁽ᵗ⁾ = σ(b_r + U_r h⁽ᵗ⁻¹⁾ + W_r xₜ) 업데이트 게이트 z와 리셋 게이트 r을 동시에 계산한다
h̃⁽ᵗ⁾ = tanh(W_h xₜ + r⁽ᵗ⁾ ⊙ U_z h⁽ᵗ⁻¹⁾) 현재 메모리 게이트 — 어떤 새 정보를 넣고 과거의 어떤 정보를 살릴지 결정한다
h⁽ᵗ⁾ = z⁽ᵗ⁾ ⊙ h⁽ᵗ⁻¹⁾ + (1 − z⁽ᵗ⁾) ⊙ h̃⁽ᵗ⁾ 업데이트 게이트로 은닉 상태를 최종 갱신한다
GRU = nn.GRU(input_size=3, hidden_size=10, num_layers=1, batch_first=True)
output, hidden = GRU(data_tensor)

텍스트를 위한 CNN — 필터로 패턴을 훑다

CNN은 원래 이미지(또는 2D 행렬)에서 필터(커널)를 밀며 패턴을 찾도록 설계됐다. 합성곱은 픽셀 단위로 수행되며, 필터 값과 픽셀을 곱해 더한다. 훈련 중에 필터 항목마다 가중치가 학습되고, 필터마다 이미지를 훑은 결과인 feature map을 얻는다. 국소 정보를 추출하고 복잡한 패턴을 인식하는 능력 덕분에 컴퓨터 비전에서 성공했고, 같은 이유로 시퀀스에도 제안됐다.

시퀀스에서는 1차원 합성곱을 쓴다. 2차원 필터 대신 1차원 필터 — word2vec의 문맥 창처럼 볼 수 있다 — 를 시퀀스 위로 민다(원서 Figure 1.15). 과정은 매우 빠르고, 필터 크기는 3~7 단어 혹은 그 이상으로 임의로 잡을 수 있다. 모델은 커널 안 단어들 사이의 패턴을 학습한다. 임베딩에서 얻은 벡터 위에 적용할 수도 있고, 커널을 여러 개 써서 시퀀스마다 다른 패턴을 학습시킬 수도 있다. 이미지 CNN과 마찬가지로 최대 풀링(max pooling) 같은 연산을 더해 가장 중요한 특징을 추출할 수 있다.

Conv1d = nn.Conv1d(in_channels=5, out_channels=16,
                   kernel_size=3, stride=1, padding=1)
output = Conv1d(data_tensor)   # 앞의 모델들과 출력 변형이 어떻게 다른지 보라
모델핵심 장치기억 범위주의점
RNN은닉 상태의 순환약 7 시간 단계 후 망각 시작병렬화 불가, 기울기 소실·폭발
LSTM망각·입력·출력 게이트 + 문맥 셀 c최대 100 시간 단계매개변수가 많다
GRU업데이트·리셋 게이트 (게이트 결합)긴 시퀀스 처리 가능가볍고 빠르나 과적합 취약, 복잡 패턴엔 LSTM 우위
1D CNN시퀀스 위를 미는 1차원 필터커널 크기(3~7+ 단어) 내 국소 패턴매우 빠름, 다중 커널·풀링과 결합
SECTION 06

실전 — 임베딩과 딥러닝으로 감성 분석하기

이제 부품을 조립할 시간이다. 영화 리뷰를 긍정 또는 부정으로 분류하는 모델을 훈련한다. 지금까지 만난 모든 요소 — 전처리, 벡터화, 임베딩, 순환 신경망 — 가 여기서 하나의 시스템이 된다.

큰 그림은 두 단계다.

  1. 데이터셋을 전처리하고, 수치 벡터로 변환하고, 벡터 길이를 통일한다.
  2. 임베딩을 포함한 신경망을 정의하고 훈련한다.

데이터셋은 긍정·부정 리뷰 5만 건이다. 리뷰 길이는 제각각이고 평균 230 단어다(원서 Figure 1.16). 최빈 단어는 예상대로 "movie"와 "film"이다(Figure 1.17 워드 클라우드). 텍스트는 지저분하므로 훈련 전에 청소가 필요하다.

첫 단계는 레이블의 이진 인코딩("positive"=0, "negative"=1)이다. 다음으로 특징과 레이블을 분리하고(지도 학습에서 X는 특징, y는 레이블), 훈련·검증·테스트라는 세 개의 균형 잡힌 데이터셋을 만든다.

df['sentiment_encoded'] = np.where(df['sentiment']=='positive', 0, 1)
X, y = df['review'].values, df['sentiment_encoded'].values
x_train, x_test, y_train, y_test = train_test_split(X, y, stratify=y, test_size=.2)
x_train, x_val, y_train, y_val = train_test_split(x_train, y_train,
                                                  stratify=y_train, test_size=.1)

훈련에 앞서 필요한 단계는 네 가지다.

  1. 전처리 — 과도한 공백, 특수 문자, 구두점을 제거한다.
  2. 토큰화 — 리뷰를 토큰으로 바꾸면서 불용어와 한 글자 단어를 제거한다. 훈련 계산 시간을 줄이기 위해 리뷰마다 가장 인기 있는 1,000개 단어만 추출한다.
  3. 벡터화 — 어휘집에 따라 단어를 인덱스로 바꿔 모델이 수치로 작동하게 한다.
  4. 패딩 — 리뷰 길이가 제각각이므로 고정 길이로 통일한다. 훈련에 필수다.
주의

이 전처리 단계들은 데이터셋에 따라 달라지며, 토큰화·전처리의 선택이 리뷰의 속성 — 이 경우 요약 통계 — 을 바꾼다는 점을 기억해야 한다(원서 Figure 1.18은 토큰화 후 리뷰 길이 분포의 변화를 보여준다). 코드는 GitHub 저장소에 있다.

모델은 임베딩과 GRU로 구성된 신경망이다. 훈련을 안정시키기 위해 정규화(드롭아웃)를 더한다. 선형 층은 추출된 특징들을 단일 표현으로 사상하고, 이 표현으로 리뷰가 긍정일 확률과 부정일 확률을 계산한다.

# 하이퍼파라미터
no_layers = 3
vocab_size = len(vocab) + 1  # 패딩용 +1
embedding_dim = 300
output_dim = 1
hidden_dim = 256

model = SentimentRNN(no_layers, vocab_size, hidden_dim,
                     embedding_dim, drop_prob=0.5)

범주가 둘(긍정/부정)뿐이므로 이진 교차 엔트로피 손실을 쓰고, 옵티마이저는 Adam을 쓴다(다른 것을 시험해봐도 좋다). 리뷰가 수천 건이므로 배치 훈련을 수행한다.

criterion = nn.BCELoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
for epoch in range(epochs):
    model.train()
    h = model.init_hidden(50)
    for inputs, labels in train_loader:
        inputs, labels = inputs.to(device), labels.to(device)
        h = h.data
        model.zero_grad()
        output, h = model(inputs, h)
        loss = criterion(output.squeeze(), labels.float())
        loss.backward()
        optimizer.step()

훈련·검증 세트의 정확도와 손실 곡선(원서 Figure 1.19)을 보면 학습이 안정적으로 진행되며, 테스트 세트의 혼동 행렬(Figure 1.20)이 보여주듯 모델은 좋은 정확도에 도달한다. 가장 인상적인 것은 Figure 1.21이다. 훈련 전과 후의 리뷰 임베딩 투영을 비교하면, 모델이 긍정 리뷰와 부정 리뷰를 공간적으로 분리하는 법을 학습했음이 눈에 보인다.

The Takeaway

이로써 평문 리뷰를 받아 긍정·부정으로 분류하는 모델이 완성됐다. 이 장에서 본 요소들을 결합해 만든 것이다. 같은 접근을 다른 어떤 데이터셋에도 적용할 수 있다 — 그것이 딥러닝의 힘이다.

SECTION 07

정리 — 다음 장으로 가는 다리

이 장의 여정을 되짚어보면 이렇다. 텍스트를 점점 더 복잡한 벡터 표현으로 변환했고, 그 수치 표현 덕분에 기계 학습 모델을 쓸 수 있게 됐다. 텍스트의 문맥 정보를 보존하는 법(단어 임베딩)과 그것을 이후 분석 — 유의어 검색이나 단어 군집화 — 에 활용하는 법을 봤다. 그리고 신경망(RNN, LSTM, GRU)으로 텍스트를 분석하고 감성 분석 같은 과제를 수행하는 법을 익혔다.

그러나 풀리지 않은 숙제가 남아 있다. 순차 처리의 병렬화 불가능성, 장거리 의존성의 한계. 다음 장에서는 이 남은 난제들을 어떻게 풀었는지, 그리고 그것이 어떻게 여기서 본 모델들의 자연스러운 진화 — 트랜스포머 — 로 이어졌는지를 본다.

개념한 줄 요약
원-핫 인코딩단어 하나 = 자리 하나. 단순하지만 희소하고, 의미 유사성이 없다.
BoW어순을 버리고 빈도를 보존한다. 차원의 저주가 따라온다.
TF-IDF흔한 단어는 누르고, 특정 문서에만 나오는 단어를 띄운다.
word2vec문맥 예측 과제의 가중치가 곧 의미를 담은 밀집 벡터가 된다.
코사인 유사도크기를 지우고 방향으로 유사도를 잰다. −1 ~ 1, 척도 불변.
RNN → LSTM → GRU기억을 갖되, 잊는 법을 배우고, 더 가볍게 다듬는다.
1D CNN문맥 창 크기의 필터로 국소 패턴을 빠르게 훑는다.