머신러닝·신경망

DL / 33번째 글

Seq2Seq: 인코더-디코더로 시퀀스를 시퀀스로

입력과 출력의 길이가 다른 문제를 두 RNN을 이어 붙여 푸는 방법. 컨텍스트 벡터, Teacher Forcing과 노출 편향, 자기회귀 디코딩, Beam Search의 길이 정규화까지 계산을 따라가며 살펴본다.

PALDYN Team29 MIN READ

지난 글에서 게이트로 셀 안을 고치고 읽는 방향을 늘려 RNN의 기억을 붙잡는 세 방법을 살펴봤다. 그 글까지의 RNN은 입력을 한 토큰씩 읽으며 그때그때 출력을 내놓는 구조였다. 그런데 기계 번역("저는 배가 고픕니다" → "I am hungry"), 문서 요약, 대화 응답 생성은 그 구조로는 손도 못 댄다. 입력과 출력의 길이가 다르고, 게다가 출력이 몇 토큰이 될지는 만들어 보기 전에는 알 수 없기 때문이다. 2014년 Sutskever et al.이 제안한 Seq2Seq(Sequence-to-Sequence) 아키텍처는 RNN 둘을 직렬로 이어 붙여 이 문제를 풀었다. 지금의 번역기와 챗봇이 쓰는 트랜스포머도 이 글에서 세우는 얼개를 그대로 물려받았으므로, 여기서 나오는 용어는 뒤에서 계속 다시 만나게 된다.

길이가 다른 입출력

정렬 가정

지금까지 본 RNN의 출력 형태 중 입력과 출력이 모두 여러 개인 것을 정렬형 many-to-many라 부른다. 타임스텝마다 입력 하나를 받아 출력 하나를 내놓으므로, 입력 토큰과 출력 토큰이 1 대 1로 짝지어진다는 가정이 깔려 있다. 품사 태깅이 그 가정에 딱 맞는다 — "나는/오늘/사과를/먹었다"의 네 토큰에 태그 네 개를 붙이면 되고, 세 번째 태그는 세 번째 단어의 것이다.

번역은 그 가정이 처음부터 깨져 있다. 한국어 "저는 배가 고픕니다"를 토크나이저가 다섯 조각으로 끊었다고 하자. 영어 정답 "I am hungry"는 세 조각이다. 다섯 스텝을 돌리는 RNN에서 세 토큰만 꺼내려면 어느 두 스텝을 버릴지 정해야 하는데, 그 대응이 애초에 없다. "고픕니다" 하나가 "am hungry" 둘을 맡고 "저는"이 "I" 하나를 맡는 식으로 뒤엉켜 있다.

더 곤란한 것은 출력 길이를 미리 모른다는 점이다. 입력 길이는 문장이 주어진 순간 세면 되지만, 번역이 세 토큰일지 일곱 토큰일지는 번역을 해 봐야 안다. 그러니 필요한 것은 "입력 길이만큼 도는 루프"가 아니라 "스스로 멈출 줄 아는 루프"다.

인코더와 디코더

Seq2Seq는 읽는 일과 쓰는 일을 RNN 둘로 나눈다. 인코더는 입력 시퀀스를 끝까지 읽기만 하고 출력을 내놓지 않는 RNN이다. 디코더는 인코더가 멈춘 자리에서 출발해 토큰을 하나씩 내놓는 RNN이다. 둘은 파라미터를 공유하지 않는 별개의 네트워크이고, 각자 자기 임베딩 행렬과 자기 어휘 사전을 갖는다 — 번역이라면 인코더는 한국어 어휘를, 디코더는 영어 어휘를 본다.

둘을 나눈 것만으로 길이 문제가 풀린다. 인코더가 도는 횟수는 입력 토큰 수가 정하고 디코더가 도는 횟수는 디코더 자신이 정하므로, 두 숫자가 서로 묶이지 않는다. 인코더가 다섯 번 돌고 디코더가 세 번 도는 것이 아무 문제가 되지 않는 구조다.

Seq2Seq: 인코더-디코더 구조

import torch
import torch.nn as nn

class Encoder(nn.Module):
    def __init__(self, vocab_size, emb_dim, hid):
        super().__init__()
        self.emb  = nn.Embedding(vocab_size, emb_dim)
        self.lstm = nn.LSTM(emb_dim, hid, batch_first=True)

    def forward(self, src):
        e = self.emb(src)
        _, (h, c) = self.lstm(e)  # 마지막 h, c만 사용
        return h, c

class Decoder(nn.Module):
    def __init__(self, vocab_size, emb_dim, hid):
        super().__init__()
        self.emb  = nn.Embedding(vocab_size, emb_dim)
        self.lstm = nn.LSTM(emb_dim, hid, batch_first=True)
        self.fc   = nn.Linear(hid, vocab_size)

    def forward(self, tgt, state):
        e = self.emb(tgt)
        out, state = self.lstm(e, state)
        logits = self.fc(out)
        return logits, state

SOS와 EOS

두 RNN을 잘라 붙이고 나면 곧바로 구멍 둘이 보인다. 디코더의 첫 스텝에 넣을 입력이 없고, 디코더가 언제 멈춰야 하는지도 정해지지 않았다. 둘 다 어휘 사전에 특수 토큰을 한 자리씩 더 만들어 메운다. SOS(start of sequence)는 "지금부터 만들기 시작하라"는 뜻의 첫 입력이고, EOS(end of sequence)는 "여기서 끝났다"는 뜻의 출력이다.

이 둘은 규칙이 아니라 학습되는 값이다. 학습 데이터의 정답 문장을 [SOS] I am hungry [EOS] 꼴로 만들어 두면, 디코더는 "hungry 다음에는 EOS가 올 확률이 높다"를 다른 토큰의 확률과 똑같은 방식으로 배운다. 그래서 추론 때 EOS가 가장 높은 확률로 뽑히는 순간이 곧 모델이 스스로 정한 문장의 끝이다. 출력 길이를 사람이 정하는 대신 모델이 정하게 만드는 자리가 여기다.

컨텍스트 벡터

마지막 은닉 상태

인코더가 입력을 다 읽고 나면 손에 남는 것은 마지막 타임스텝의 은닉 상태 하나뿐이다. 이 벡터를 컨텍스트 벡터라 부른다. LSTM을 썼다면 은닉 상태 hh와 셀 상태 cc 둘이 한 쌍으로 넘어간다. 위 코드에서 _, (h, c) = self.lstm(e)의 밑줄 자리에 있는 것이 타임스텝별 출력 전부인데, Seq2Seq의 기본형은 그것을 통째로 버린다.

입력이 20토큰이면 인코더는 은닉 상태를 20개 만들고 그중 19개를 버리는 셈이다. 설계 의도로 보면 버리는 것이 아니라 접어 넣는 것이다 — 마지막 상태는 이전 상태에서 계산된 값이고 그 이전 상태는 또 그 앞에서 나왔으니, 원리상 마지막 하나에 문장 전체가 녹아 있어야 한다. 이 글 마지막 절에서 다루는 병목은 "원리상 녹아 있다"와 "실제로 꺼내 쓸 수 있다"가 다르다는 데서 온다.

디코더에 넘기는 두 방식

컨텍스트 벡터를 디코더에 건네는 방법은 크게 둘이다. 첫째는 디코더의 초기 은닉 상태로 넣는 것이다. 위 코드의 decoder(tgt, (h, c))가 그 방식이다. 구현이 가장 간단하지만, 디코더가 스텝을 밟을수록 자기 계산으로 상태를 덮어쓰므로 원문의 흔적이 점점 옅어진다. 출력 문장이 뒤로 갈수록 엉뚱해지는 증상이 여기서 온다.

둘째는 매 스텝의 입력 임베딩에 컨텍스트 벡터를 이어 붙이는 것이다. 디코더의 입력 차원이 emb_dim에서 emb_dim + hid로 늘어나는 대신, 마지막 스텝에서도 원문에서 온 값이 상태를 거치지 않고 직접 닿는다. 많은 구현이 둘을 함께 쓴다 — 초기 상태로도 넣고 매 스텝 이어 붙이기도 한다. 둘 다 컨텍스트 벡터 하나를 쓰는 것은 같으므로, 이어 붙이기가 다음 절의 병목을 없애 주지는 않는다.

입력 뒤집기

Sutskever et al.의 논문에는 지금 보면 이상한 트릭이 하나 나온다. 원문의 토큰 순서를 통째로 뒤집어 인코더에 넣었더니 번역 품질이 눈에 띄게 올랐다는 것이다. 모델도 데이터도 그대로이고 입력 순서만 뒤집었는데 성능이 오른 것이니, 이 관찰은 구조에 대해 무언가를 말하고 있다.

해석은 거리에 있다. 원문 첫 단어와 번역 첫 단어는 뜻으로 가장 가까운 짝인데, 순서대로 넣으면 그 둘 사이에 인코더 스텝 전부와 디코더 첫 스텝이 놓인다. 뒤집어 넣으면 원문 첫 단어가 인코더의 마지막에 오므로 그 거리가 몇 스텝으로 줄어든다. 단어 쌍들의 평균 거리는 그대로지만 앞쪽 몇 쌍의 거리가 확 짧아지고, 자기회귀 디코딩에서는 앞을 맞히는 것이 뒤를 맞히는 조건이 되므로 초반의 이득이 문장 끝까지 이어진다.

이 트릭이 통했다는 사실 자체가 증거다. 컨텍스트 벡터가 정말로 문장 전체를 고르게 담고 있다면 입력 순서를 뒤집어도 아무 차이가 없어야 한다. 차이가 났다는 것은 그 벡터가 최근에 읽은 것을 더 많이 담고 있다는 뜻이고, 이것이 병목의 첫 번째 징후다.

Teacher Forcing

한 칸 어긋난 자르기

디코더를 학습시킬 때는 정답 문장을 입력과 정답으로 한 칸 어긋나게 자른다. 정답이 [SOS] I am hungry [EOS]라면 입력은 [SOS] I am hungry이고 맞혀야 할 것은 I am hungry [EOS]다. 코드의 tgt[:, :-1]과 tgt[:, 1:]이 그 두 조각이다.

어긋나게 자르는 이유는 디코더가 하는 일의 정의에 있다. 디코더는 tt번째 자리에서 t+1t+1번째 토큰을 예측한다. SOS를 보고 "I"를, "I"를 보고 "am"을 맞히는 식이다. 그러니 같은 텐서를 한 칸 밀어 짝지으면 그대로 입력과 정답이 된다. 이렇게 정답 토큰을 디코더 입력으로 먹이는 방식을 Teacher Forcing이라 한다.

여기서 얻는 것은 속도다. 정답 전체가 이미 있으므로 디코더의 모든 타임스텝을 한 번에 돌릴 수 있고, 손실도 자리마다 따로 계산해 한꺼번에 평균 낼 수 있다. 자기 예측을 물려주며 돌린다면 앞 스텝이 끝나야 다음 스텝을 시작할 수 있어 한 문장에 스텝 수만큼의 순차 호출이 필요하다.

def train_step(encoder, decoder, src, tgt, optimizer, criterion):
    optimizer.zero_grad()
    h, c = encoder(src)
    # tgt[:,:-1]: 입력 (SOS~마지막-1), tgt[:,1:]: 정답 (1~EOS)
    logits, _ = decoder(tgt[:, :-1], (h, c))
    # (B, T-1, vocab) vs (B, T-1)
    loss = criterion(logits.reshape(-1, logits.size(-1)),
                     tgt[:, 1:].reshape(-1))
    loss.backward()
    torch.nn.utils.clip_grad_norm_(
        list(encoder.parameters()) + list(decoder.parameters()), 1.0
    )
    optimizer.step()
    return loss.item()

노출 편향

Teacher Forcing으로 학습한 디코더는 훈련 내내 정답만 본다. 다섯 번째 자리에서 무엇을 맞히든 여섯 번째 자리의 입력은 언제나 정답 다섯 번째 토큰이다. 그런데 추론 때는 정답이 없으므로 여섯 번째 자리의 입력이 자기가 방금 뽑은 다섯 번째 토큰이다. 훈련에서 본 입력 분포와 추론에서 마주치는 입력 분포가 다른 것이다. 이 어긋남을 노출 편향(exposure bias)이라 부른다.

한 번 틀어지면 되돌아오기 어렵다는 점이 이 문제를 성가시게 만든다. 세 번째 토큰을 잘못 뽑으면 네 번째 자리의 입력은 훈련 데이터에 한 번도 나온 적 없는 접두사가 되고, 거기서 나온 확률 분포는 모델이 제대로 학습한 적 없는 영역의 값이다. 오류가 줄어들며 회복되는 것이 아니라 스텝을 밟을수록 쌓이는 구조다.

Scheduled Sampling

가장 널리 알려진 완화책은 훈련 중에 정답과 자기 예측을 섞어 먹이는 것이다. 확률 ϵ\epsilon로 정답 토큰을, 1−ϵ1-\epsilon로 자기 예측을 다음 입력으로 넣고, 학습 초반에는 ϵ\epsilon을 1에 가깝게 두었다가 에폭이 지나며 낮춰 간다. 이 방식을 Scheduled Sampling이라 한다. 학습 초기에는 예측이 엉망이라 자기 예측을 먹이면 아무것도 못 배우고, 후반에는 예측이 쓸 만해져 추론 조건에 가까운 연습이 되기 때문이다.

공짜는 아니다. 자기 예측을 입력으로 쓰면 그 자리에서 기울기 경로가 끊기거나(뽑는 연산이 미분 불가능이다) 근사로 이어야 해서 학습이 불안정해질 수 있고, 섞는 비율을 낮추는 일정 자체가 또 하나의 하이퍼파라미터가 된다. 실무에서는 Scheduled Sampling을 쓰는 대신 뒤에 나오는 Beam Search로 디코딩 쪽을 손보는 경우가 더 많다 — 노출 편향은 한 토큰을 잘못 골라 생기는 문제이고, 여러 후보를 들고 가면 잘못 고를 확률 자체가 줄기 때문이다.

자기회귀 디코딩

탐욕 디코딩

추론 때 디코더는 SOS에서 출발해 한 스텝 돌리고, 나온 분포에서 토큰 하나를 뽑아 다음 스텝의 입력으로 넣는다. 자기가 만든 것을 다시 입력으로 먹는다는 뜻에서 이를 자기회귀(autoregressive) 디코딩이라 한다. 매 스텝 확률이 가장 높은 토큰 하나만 고르는 가장 단순한 방식이 탐욕 디코딩이다.

Teacher Forcing vs 자기회귀 디코딩

@torch.no_grad()
def generate(encoder, decoder, src, sos_id, eos_id, max_len=50):
    h, c = encoder(src)
    token = torch.tensor([[sos_id]])
    output = []
    for _ in range(max_len):
        logits, (h, c) = decoder(token, (h, c))
        token = logits.argmax(-1)  # 탐욕 디코딩
        if token.item() == eos_id:
            break
        output.append(token.item())
    return output

여기서 눈여겨볼 것은 (h, c)를 루프 바깥에서 받아 다시 넣는 줄이다. 학습 코드에서는 디코더에 문장 전체를 한 번에 넘겼지만, 추론에서는 토큰 하나를 넣고 상태를 받아 다시 넣기를 반복한다. 같은 모듈이 두 가지 방식으로 불리고 있는 것이고, 그 차이가 앞 절의 노출 편향이 실제로 드러나는 자리다.

종료 조건

루프는 EOS가 나오거나 max_len에 닿으면 끝난다. 둘 중 무엇이 먼저 걸렸는지는 결과의 뜻이 완전히 다른데, 위 코드는 그것을 돌려주지 않는다. EOS로 끝났다면 모델이 문장을 완성했다는 뜻이고, max_len에서 끊겼다면 모델이 멈출 줄 몰라 사람이 잘랐다는 뜻이다. 뒤쪽은 대개 문장이 중간에서 잘린 채 사용자에게 나간다.

그래서 디코딩 결과를 로그로 남길 때 "EOS로 끝난 비율"을 함께 재 두는 편이 좋다. 이 비율이 낮으면 max_len을 키울 문제가 아니라 학습이 EOS를 제대로 못 배운 것을 의심해야 한다. 정답 문장에 EOS를 안 붙였거나, 패딩 토큰이 손실에 섞여 들어가 EOS의 확률을 눌러 놓은 경우가 흔하다.

반복 생성

탐욕 디코딩을 돌려 보면 "그리고 그는 그리고 그는"처럼 같은 구절을 끝없이 되풀이하는 출력을 자주 만난다. 이유는 argmax의 성질에 있다. 어떤 상태에서 특정 토큰이 뽑히면 그 토큰이 다음 스텝의 입력이 되어 비슷한 상태를 만들고, 비슷한 상태에서는 또 같은 토큰이 가장 높은 확률을 받는다. 한 번 들어가면 스스로 빠져나올 길이 없는 고리다.

완화하는 방법은 크게 셋이다. 이미 나온 토큰의 로짓을 깎는 반복 페널티, 같은 n-그램이 두 번 나오지 않게 막는 차단, 그리고 argmax 대신 확률에 따라 뽑는 샘플링이다. 앞의 둘은 증상을 직접 누르는 방식이고 샘플링은 고리 자체가 생기지 않게 하는 방식인데, 번역처럼 정답이 좁은 과제에서는 샘플링이 품질을 떨어뜨리므로 다음 절의 Beam Search를 쓴다.

빔 넓이

탐욕 디코딩의 약점은 매 스텝 최선이 문장 전체의 최선이 아니라는 것이다. 첫 토큰에서 확률 0.6짜리를 고르고 막다른 길로 들어가는 것보다, 0.3짜리로 시작해 뒤에서 높은 확률로 이어지는 길이 나을 수 있다. Beam Search는 한 스텝에 하나만 남기는 대신 상위 kk개의 가설을 동시에 들고 간다. 이 kk를 빔 넓이(beam size)라 한다.

절차는 스텝마다 같다. 빔 넓이가 4이고 어휘 크기가 VV라면, 가설 4개 각각을 한 스텝 돌려 4×V4 \times V개의 이어 붙인 후보를 만들고, 각 후보의 누적 로그 확률을 계산해 상위 4개만 남긴다. 나머지는 버린다. 첫 스텝만 예외인데, 가설이 SOS 하나뿐이라 VV개 중 4개를 고른다.

Beam Search: 스텝마다 후보를 추리는 방식

두 스텝을 손으로 따라가 보자. 첫 스텝에서 A·B·C·D가 각각 0.6·0.3·0.08·0.02로 뽑혔다. 두 번째 스텝에서 A 뒤에 X가 0.1로, B 뒤에 Y가 0.9로 이어진다면 AX의 확률은 0.06이고 BY는 0.27이다. 탐욕 디코딩은 첫 스텝에서 A를 확정했으므로 BY를 아예 볼 수 없지만, 빔 넓이 4는 B를 들고 있었으므로 두 번째 스텝에서 순위가 뒤집히는 것을 잡아낸다.

beam_size=4 일 때 각 스텝:
Step 1: SOS → [A(0.6), B(0.3), C(0.08), D(0.02)] 상위 4개 유지
Step 2: A → [AA, AB, AC, AD] / B → [BA, BB, ...] → 전체 16개 중 상위 4개
...EOS 도달까지 반복, log-probability 합 기준 최적 시퀀스 선택

길이 정규화

확률을 곱해 나가면 값이 금세 작아져 부동소수점이 0으로 내려앉으므로, 실제 구현은 로그 확률을 더한다. 그런데 로그 확률은 언제나 음수이고 토큰을 하나 더할 때마다 값이 작아지기만 한다. 점수가 높은 것을 고르면 짧은 문장이 언제나 유리하다. 세 토큰짜리 어정쩡한 번역이 열 토큰짜리 제대로 된 번역을 이기는 일이 실제로 일어난다.

가장 단순한 해법은 누적 로그 확률을 토큰 수로 나누어 토큰당 평균으로 비교하는 것이다. 이것도 길이가 아주 짧은 쪽을 완전히 막지는 못해서, GNMT 논문은 나누는 값을 (5+∣Y∣6)α\left(\frac{5+|Y|}{6}\right)^{\alpha}로 두고 α\alpha를 0.6~0.7로 쓴다. α\alpha가 0이면 정규화가 없는 것이고 1이면 평균과 같아지므로, 그 사이를 조절하는 손잡이인 셈이다. 번역 결과가 자꾸 짧게 나온다면 먼저 의심할 값이 이 α\alpha다.

완료 가설

EOS에 닿은 가설은 더 이어 갈 데가 없으므로 그대로 두면 빔 한 자리를 차지한 채 굳는다. 그래서 완료된 가설은 따로 모아 두고 남은 자리를 새 후보로 채워 탐색을 계속한다. 종료 조건은 완료 가설이 kk개 모였거나, 남은 가설의 최선 점수가 이미 모아 둔 가설의 최선을 넘을 수 없을 때다.

빔 넓이를 키우면 언제나 좋아질 것 같지만 그렇지 않다. 번역 품질은 대개 빔 4~10 근처에서 정점을 찍고 그 뒤로는 오히려 떨어진다. 빔을 넓힐수록 모델이 높은 점수를 준 짧고 밋밋한 문장을 더 잘 찾아내기 때문이고, 계산량은 빔 넓이에 비례해 그대로 늘어난다. 실제 구현은 transformers 라이브러리의 model.generate(num_beams=4)처럼 인자 하나로 쓰되, 이 값이 품질과 지연 시간을 동시에 건드린다는 것을 알고 만져야 한다.

고정 벡터의 병목

벡터 하나의 용량

지금까지 세운 구조에서 인코더가 디코더에 건네는 것은 벡터 하나뿐이다. 은닉 차원이 256이면 입력이 5토큰이든 100토큰이든 256개의 실수가 전부다. 100토큰짜리 문단의 등장인물, 시간 순서, 부정 표현, 숫자를 모두 그 안에 넣고 디코더가 하나씩 꺼내 쓸 수 있어야 한다는 요구다.

차원을 키우면 되지 않느냐는 물음이 자연스럽게 나오는데, 그 길은 생각만큼 멀리 가지 못한다. 은닉 차원을 키우면 파라미터 수가 제곱으로 늘고 짧은 문장에서는 과적합이 심해진다. 무엇보다 길이에 상관없이 크기가 고정이라는 성질은 그대로다. 차원을 512로 늘려도 200토큰 입력 앞에서는 같은 문제가 되돌아온다.

압축의 관점에서 보면 이 구조는 입력 문장을 고정 길이로 인코딩한 뒤 그것만으로 복원하라고 요구하는 것과 같다. 압축률이 문장 길이에 비례해 올라가는데 복원 품질은 그대로이기를 바라는 셈이다. 짧은 문장에서 잘 되던 것이 긴 문장에서 무너지는 것은 모델이 못 배워서가 아니라 통로가 좁아서다 — 학습으로 메울 수 있는 종류의 부족이 아니라는 뜻이고, 그래서 해법도 학습이 아니라 구조를 바꾸는 쪽에서 나왔다.

문장 길이와 번역 품질

이 병목은 실험에서 그래프 모양으로 나타났다. 입력 문장 길이별로 번역 품질을 재면, 짧은 문장에서는 괜찮다가 대략 20~30토큰을 넘어가는 지점부터 점수가 눈에 띄게 꺾인다. 모델 크기나 학습량을 늘려도 꺾이는 모양 자체는 남고 꺾이는 지점만 조금 밀린다.

앞에서 본 입력 뒤집기가 통했던 이유도 같은 자리에서 설명된다. 그 트릭은 컨텍스트 벡터가 담을 수 있는 양을 늘린 것이 아니라, 담기는 것의 순서를 바꿔 앞쪽 단어를 더 잘 남게 만든 것이다. 병목을 없앤 것이 아니라 병목 안에서 무엇을 살릴지 골랐을 뿐이므로, 문장이 길어지면 같은 벽에 다시 부딪힌다.

Attention의 출발점

해결의 실마리는 버린 것 쪽에 있다. 인코더는 타임스텝마다 은닉 상태를 만들었고 우리는 마지막 하나만 쓰고 나머지를 버렸다. 버리지 않고 전부 들고 있다가, 디코더가 토큰을 하나 만들 때마다 그중 지금 필요한 것을 골라 보게 하면 된다. 그러면 디코더가 참조하는 정보의 양이 입력 길이에 따라 함께 늘어나므로 고정 크기라는 제약이 사라진다.

그 "골라 보기"를 미분 가능한 가중 합으로 구현한 것이 Attention이다. 다음 글에서는 이 가중치를 어떻게 계산하고, 그것이 어떻게 트랜스포머로 이어지는지를 다룬다.


읽어주셔서 감사합니다. 😊

LATEST

머신러닝·신경망의 최신 글

머신러닝·신경망2026.05.08

문맥적 임베딩: ELMo부터 BERT까지

정적 임베딩의 다의어 문제를 해결하는 문맥적 임베딩의 원리, ELMo의 양방향 LSTM 레이어 표현, BERT의 트랜스포머 기반 서브워드 임베딩 추출법을 수식과 코드로 완전히 해설한다.

12 MIN
머신러닝·신경망2026.05.08

FastText: 부분 단어로 OOV를 정복하다

FastText가 문자 n-gram 기반의 부분 단어 모델로 OOV 문제를 해결하는 방법, 한국어 형태론에서의 강점, 실전 학습과 추론 코드를 완전히 해설한다.

11 MIN
머신러닝·신경망2026.05.08

GloVe: 전역 공기 통계로 단어 벡터를 만들다

GloVe가 공기 행렬의 전역 통계와 국소 문맥 창의 장점을 결합하는 방법, 목적 함수의 수학적 의미, 사전 학습 벡터 활용법을 깊이 있게 다룬다.

11 MIN