##################################################################
(2026-07-05) LLM from the Scratch in DGX Spark - (1) 로컬에서 돌리는 이유
(2026-07-05) LLM from the Scratch in DGX Spark - (2) GB10에 대해서 자세하게 알아보자
(2026-07-06) LLM from the Scratch in DGX Spark - (3) 기계번역의 시조새 - Seq2Seq
(2026-07-07) LLM from the Scratch in DGX Spark - (4) Seq2Seq 극복의 시작 - 바다나우 어텐션
(2026-07-09) LLM from the Scratch in DGX Spark - (5) 문장이 거울을 보다 — 셀프어텐션의 전야제
(2026-07-10) LLM from the Scratch in DGX Spark - (6) Attention Is All You Need 논문은 어떤 차이점을 예기했을까?
(2026-07-11) LLM from the Scratch in DGX Spark - (7) Tokenizer를 구현해 보자
(2026-07-12) LLM from the Scratch in DGX Spark - (8) 위치 Embedding에 대해서 알아보자
(2026-07-13) LLM from the Scratch in DGX Spark - (9) CausalSelfAttention 작성
##################################################################
임베딩 층의 실체는 허무할 만큼 단순합니다.
행이 vocab size 개, 열이 d_model개인 행렬 하나이고, 토큰 ID는 그 행렬의 행 번호입니다. nn.Embedding은 원-핫 벡터와 행렬의 곱을 룩업으로 구현한 것에 불과합니다.
마법은 이 행렬이 학습된다는 데 있습니다. 처음에는 무작위 잡음이던 행들이, 학습이 진행되면서 의미 있는 배치로 이동합니다.
무엇이 그 이동을 이끄는가?
언어학의 오래된 통찰입니다. 바로 분포 가설입니다. 단어의 의미는 그 단어가 어울리는 문맥이 결정한다("You shall know a word by the company it keeps", Firth 1957). "커피"와 "홍차"는 비슷한 문맥(마시다, 따뜻한, 한 잔)에 나타나므로, 문맥을 예측하도록 학습된 벡터는 두 단어를 가까운 곳에 놓게 됩니다.
2013년 Mikolov의 word2vec이 이것을 대규모로 실증하며 놀라운 부산물을 발견했습니다.
의미의 방향까지 생긴다는 것 — E(king) − E(man) + E(woman)의 최근접 이웃이 queen이라는, 그 유명한 벡터 산술입니다. "성별"이라는 개념이 공간의 한 방향으로 인코딩된 거죠.
트랜스포머의 토큰 임베딩은 이 계보의 직계 후손입니다.
다른 점은 학습 신호가 skip-gram이 아니라 언어모델링 전체에서 온다는 것, 그리고 임베딩은 출발 좌표일 뿐 이후 어텐션 층들이 문맥에 맞게 좌표를 계속 이동시킨다는 것입니다(강둑의 bank와 은행의 bank는 임베딩 층에서는 같은 점이지만, 층을 통과하며 갈라집니다).
오늘은 그 출발 좌표를 다룹니다.
먼저 가장 근본이 되는, Token Embedding의 기초가 되는 Skip-Gram과 Nagative Sampling 이라는 개념에 대해서 알아보겠습니다.
핵심 아이디어 (1) Skip-gram
"우리가 사용할 방식은 스킵그램(Skip-gram)이라는 모델입니다. 이 모델의 목표는 아주 단순합니다. '중심 단어'가 주어졌을 때, 그 '주변 단어'가 무엇일지 예측하게 만드는 겁니다."
- 예시 문장: [물리학, 서울특별시, 음악, 조선, 전쟁]
- 만약 '음악(중심 단어)'을 골랐다면, 그 주변에 있는 '서울특별시'나 '조선(주변 단어)'을 정답으로 예측하도록 인공지능을 훈련시키는 것입니다.
- 이 훈련을 수만 번 반복하면, 컴퓨터는 "아, '음악' 주변에는 이런 단어들이 자주 오는구나"를 학습하며 단어의 의미(숫자 벡터)를 스스로 조정하게 됩니다.
두 개의 임베딩 층이 필요
"이제 코드를 이해하기 위한 중요한 구조를 하나 알아야 합니다. 우리 코드에는 단어 사전을 벡터로 바꿔주는 책받침(행렬)이 두 개 등장합니다."
|
임베딩 종류
|
역할
|
코드 내 변수명
|
|
중심 단어 임베딩
|
현재 내가 주인공(중심)일 때의 의미 벡터
|
Win (Word In)
|
|
주변 단어 임베딩
|
내가 다른 단어의 배경(문맥)으로 쓰일 때의 의미 벡터
|
Wout (Word Out)
|
"하나의 단어라도 내가 '중심'일 때와 '주변'일 때의 역할을 분리해서 학습시킵니다. 학습이 다 끝난 후에는 주로 중심 단어 임베딩(Win)만 꺼내서 우리가 원하는 단어 사전으로 사용합니다."
핵심 아이디어 (2) 네거티브 샘플링
"그런데 여기서 큰 문제가 생깁니다. 단어 사전이 10,000개라고 해봅시다. 중심 단어 하나를 보고, 나머지 9,999개의 단어 중 어떤 게 진짜 주변 단어인지 일일이 확률을 계산하려면 컴퓨터가 너무 힘들어합니다(소프트맥스 연산의 한계)."
그래서 천재적인 꼼수를 씁니다. 바로 네거티브 샘플링입니다.
10,000개를 다 비교하지 말고, 진짜 정답(주변 단어) 1개와 아무렇게나 뽑은 오답(무작위 단어) 8개만 가져와서, 이 9개 중에서만 진짜와 가짜를 구별하게 만드는 겁니다. 이렇게 하면 계산 속도가 수천 배 빨라집니다.
이제 이 두가지 알고리즘을 이용해서, Token Embedding을 생성해 보도록 하겠습니다.
아! 더 진행하기 전에, pytorch의 훈련 기본 단계도 알아볼 필요가 있습니다. 이후 소스 설명에 반드시 필요한 것이라 살짝 같이 전달해 드리면...
파이토치(PyTorch) 훈련의 마법의 4단계 공식
딥러닝 코드를 읽다 보면 아무리 복잡한 모델이라도 반드시 마주치는 핵심 루틴이 있습니다.
바로 순전파, 초기화, 역전파, 업데이트로 이어지는 4박자 흐름입니다.
초거대 AI인 GPT를 훈련시킬 때도 결국 가장 깊은 곳에서는 이 4단계가 수십억 번 반복되고 있습니다. 학생이 모의고사를 치르고 오답 노트를 통해 실력을 교정하는 과정에 비유하면 이해하기 쉽습니다.
(1) 예측 및 오차 계산 (Forward & Loss)
모델에게 데이터를 주고 예측값을 뽑아낸 뒤, 실제 정답과 비교해 얼마나 틀렸는지 오차(Loss)를 계산합니다. 학생이 시험을 치르고 채점을 받아 자신의 현재 점수를 확인하는 단계입니다.
(2) 기울기 초기화 (opt.zero_grad())
가장 많이 실수하는 부분.파이토치는 기본적으로 이전 루프에서 계산했던 수정 지시사항(기울기)을 버리지 않고 계속 누적하는 특징이 있습니다. 따라서 새로운 문제에 대한 오답 노트를 쓰기 전에, 이전 단계의 메모를 지우개로 깨끗하게 지워주는 작업이 필수적입니다.
(3) 역전파 (loss.backward())
딥러닝의 꽃이라고 불리는 단계입니다. 방금 구한 오차(Loss)를 바탕으로, 모델 내부의 수많은 톱니바퀴(파라미터)들을 각각 어느 방향으로 얼마나 돌려야 오차가 줄어들지 수정 지시사항(기울기)을 계산하여 각 파라미터에 메모해 둡니다.
(4) 가중치 업데이트 (opt.step())
최적화 알고리즘(Optimizer, 예: Adam)이 나설 차례입니다. 3단계에서 파라미터마다 적어둔 메모를 확인하고, 미리 설정해 둔 보폭(Learning Rate)에 맞추어 실제로 파라미터의 숫자들을 고칩니다. 비로소 모델의 실력이 한 단계 성장하게 됩니다.
자 좋습니다. 이제 실제 소스 코드를 보면서 하나씩 진행해 보시죠.
실험 1: 의미의 지도를 밑바닥부터 기르기
7편의 corpus_ko.txt를 재활용합니다.
먼저 환경으로 들어가 보겠습니다.
cd workspace
docker run --gpus all -it --rm --shm-size=64g \
-v $(pwd):/workspace -w /workspace \
nvcr.io/nvidia/pytorch:26.06-py3 bash
이후 아래 내용을 word2vec_zero.py로 저장합니다.
# word2vec_zero.py — skip-gram + negative sampling을 밑바닥부터
import argparse, collections, torch, torch.nn as nn
import torch.nn.functional as F
p = argparse.ArgumentParser()
p.add_argument("--corpus", required=True)
p.add_argument("--dim", type=int, default=100)
p.add_argument("--steps", type=int, default=20000)
p.add_argument("--query", nargs="+",
default=["물리학", "서울특별시", "음악", "조선", "전쟁"])
args = p.parse_args()
torch.manual_seed(42)
dev = "cuda"
# 어절(공백) 단위 + 조사·어미 제거 휴리스틱 — 선택 이유는 본문에서 논합니다
JOSA = ["에서는", "으로는", "에게는", "하였다", "되었다", "입니다",
"에서", "에게", "으로", "이며", "이다", "였다", "한다", "하고",
"에는", "에도", "까지", "부터", "보다", "처럼",
"은", "는", "이", "가", "을", "를", "의", "에", "로", "과", "와", "도", "만"]
JOSA.sort(key=len, reverse=True) # 긴 것부터 시도
def stem(w):
w = w.strip("\"'()[].,·;:!?«»“”‘’")
for j in JOSA:
if w.endswith(j) and len(w) - len(j) >= 2: # 어간이 2자 이상 남을 때만
return w[:-len(j)]
return w
words = [stem(w) for w in open(args.corpus, encoding="utf-8").read().split()]
words = [w for w in words if w]
freq = collections.Counter(words)
vocab = [w for w, c in freq.most_common(10000) if c >= 5]
w2i = {w: i for i, w in enumerate(vocab)}
ids = torch.tensor([w2i[w] for w in words if w in w2i], device=dev)
V = len(vocab)
print(f"어휘 {V:,}개, 학습 코퍼스 {len(ids):,} 어절")
Win = nn.Embedding(V, args.dim).to(dev) # 중심 단어 임베딩 — 우리가 가져갈 지도
Wout = nn.Embedding(V, args.dim).to(dev) # 문맥 단어 임베딩
opt = torch.optim.Adam(list(Win.parameters()) + list(Wout.parameters()), lr=2e-3)
B, WIN, NEG = 1024, 5, 8
for step in range(1, args.steps + 1):
c = torch.randint(WIN, len(ids) - WIN, (B,), device=dev) # 중심 위치
sign = torch.randint(0, 2, (B,), device=dev) * 2 - 1
off = torch.randint(1, WIN + 1, (B,), device=dev) * sign # 창 안의 이웃
center, ctx = ids[c], ids[c + off]
neg = torch.randint(0, V, (B, NEG), device=dev) # 무작위 오답
vc = Win(center)
pos_score = (vc * Wout(ctx)).sum(-1) # 진짜 이웃: 점수 ↑
neg_score = torch.bmm(Wout(neg), vc.unsqueeze(-1)).squeeze(-1) # 오답: 점수 ↓
loss = -(F.logsigmoid(pos_score).mean() + F.logsigmoid(-neg_score).mean())
opt.zero_grad(); loss.backward(); opt.step()
if step % 4000 == 0:
print(f"step {step} loss {loss.item():.4f}")
E = F.normalize(Win.weight.detach(), dim=1)
for q in args.query:
if q not in w2i:
print(f"{q}: 어휘에 없음"); continue
top = (E @ E[w2i[q]]).topk(7).indices.tolist()
print(f"{q:8s} → " + ", ".join(vocab[i] for i in top if vocab[i] != q))
설명이 필요한 부분을 하나씩 떼어서 설명을 드리면,
(1) 교착어의 특성을 제거하기 위하여 조사부분 제거
JOSA.sort(key=len, reverse=True) # 긴 것부터 시도
def stem(w):
w = w.strip("\"'()[].,·;:!?«»“”‘’")
for j in JOSA:
if w.endswith(j) and len(w) - len(j) >= 2: # 어간이 2자 이상 남을 때만
return w[:-len(j)]
return w
(2) Adam Optimizer와 Torch.Parameter를 이용한 훈련 대상 선정
Win = nn.Embedding(V, args.dim).to(dev) # 중심 단어 임베딩 — 우리가 가져갈 지도
Wout = nn.Embedding(V, args.dim).to(dev) # 문맥 단어 임베딩
opt = torch.optim.Adam(list(Win.parameters()) + list(Wout.parameters()), lr=2e-3)
(3) 중심 단어 선택은 임의로 그리고 병렬로 처리
c = torch.randint(WIN, len(ids) - WIN, (B,), device=dev) # 중심 위치
일단, 중심단어를 초반부터 하나씩 모두 가지고 와서 계산하는 것이 아니라, 여기서는 임의로 가지고 와서 처리를 하계 구성되어 있습니다.
이유는 크게 3가지인데, (1) 특정 문단 세그먼트에서 나오는 단어과도 구성 방지. 즉 편향성 방지 (2) GPU 병렬 자원 최대화 (3) 처음과 끝 부분의 Window 처리 복잡성 해결입니다. 예를 들어 중심 단어에 5개 이내의 주변 단어를 찾아야 하는데, Corpus 제일 처음 끝과, 마지막은 오른쪽/왼쪽 5개를 포함할 수 없으니, WIN 크기 만큼 빼고 나머지 단어중에서 중심 단어를 뽑는 형태로 간략화를 진행하였습니다.
(4) 단어의 왼쪽을 Context (주변) 볼지, 오르쪽을 볼지 결정
sign = torch.randint(0, 2, (B,), device=dev) * 2 - 1
(5) 중심 단어에서 몆번째 떨어진 단어를 볼지 Offset 값 임의 지정
off = torch.randint(1, WIN + 1, (B,), device=dev) * sign # 창 안의 이웃
(6) 실제 중심단어, 와 주변단어 가져오기
neg = torch.randint(0, V, (B, NEG), device=dev) # 무작위 오답
위에서 구한 위치(인덱스)를 바탕으로, 실제 '중심 단어 번호(center)'와 '진짜 주변 단어 번호(ctx)'를 가져옵니다.
(7) 위에서 언급된 Negative Sampling (주변 단어 아닌 애들 데려오기)
neg = torch.randint(0, V, (B, NEG), device=dev) # 무작위 오답
(8) 중심 단어에 대한 Embedding Vector 가져오기
vc = Win(center)
Win는 이미 잘 계산되어 있다고 생각하고, 중심 단어를 제공하여, Vector값을 가지고 옵니다. 실제 계산은 나중에 옵티마이저가 조정합니다.
(9) 주변 단어와의 거리 계산
pos_score = (vc * Wout(ctx)).sum(-1) # 진짜 이웃: 점수 ↑
방금 꺼낸 중심 단어(vc)와 진짜 주변 단어(Wout(ctx))가 얼마나 비슷한지 점수를 매깁니다.
(10) 가짜 단어와의 유사도 계산
neg_score = torch.bmm(Wout(neg), vc.unsqueeze(-1)).squeeze(-1) # 오답: 점수 ↓
(11) Loss 값을 계산
loss = -(F.logsigmoid(pos_score).mean() + F.logsigmoid(-neg_score).mean())
컴퓨터가 얼마나 문제를 잘 맞혔는지 평가하는 최종 오차(Loss)를 구합니다. 딥러닝은 이 오차를 가장 작게(음수 방향으로) 만드는 것이 목표입니다.
(12) 바로 위에서 언급된 훈련 코드입니다.
opt.zero_grad(); loss.backward(); opt.step()
이제, PyTorch 내부에 그려진, 계산 그래프와, 수정 조치에 대한 부분이, Optimizer의 Step으로 실제 Parameter 값을 조정하게 됩니다.
대략적인 설명은 여기까지고 이제 실제 실행을 해보도록 하겠습니다.
python word2vec_zero.py --corpus corpus_ko.txt

|
질의어
|
최근접 이웃 (실측)
|
|
물리학
|
분야, 물리, 분과, 응집, 역학, 물질
|
|
서울특별시
|
대한민국, 노원구, 중구, ==, 마포구, 종로구
|
|
음악
|
판소리, 소프라노, 빠르기, 악기, 인문학, 크고
|
|
조선
|
후기, 조정, 고려, 국왕, 장악, 국가
|
|
전쟁
|
명나라, 산업, 있던, 일본, 전투, 대첩
|
아무도 분류 라벨을 주지 않았는데, "주변 단어 맞히기"라는 과제만으로 학문·왕조·예술의 클러스터가 단지 2M의 Corpus 상에서도 보이게 됩니다.
조금 아쉬운 부분은 tiktoken과 같은 byte pair를 이용해 버리면, 한국어 같은 경우, 이런 word2vec이 잘 보이지 않습니다. 이로 인하여, 고빈도 조사 30여 개를 규칙으로 떼도록 구성하였습니다. 제대로 하려면 형태소 분석기(kiwipiepy, MeCab 계열)를 쓰는 것이 정답이긴 하나, 우리의 목적인 바로 이 단순한 주변 단어 맞히기 라는 목적 만으로도 훌륭하게 동작하는 것을 볼 수 있습니다.
물론, 아직은 그 양이 작아서, 왕−남자+여자 같은 산술까지 기대하기엔 데이터가 부족한데, 그건 다음 실험에서 수조 토큰으로 학습된 진짜 모델의 임베딩으로 확인해 보도록 하겠습니다.
실험 2: 그 유명한 산술 — GPT-2의 실제 임베딩에서
이해는 밑바닥으로, 검증은 실물로 우리의 포스팅 전제죠. 이번에는 GPT-2의 토큰 임베딩 행렬(50,257 × 768)을 내려받아 벡터 산술이 실제로 성립하는지 재보도록 하겠습니다.
다음 소스를 저장합니다.
# gpt2_arith.py — GPT-2 임베딩에서 벡터 산술 검증
import torch, torch.nn.functional as F
from transformers import GPT2Model, GPT2TokenizerFast
tok = GPT2TokenizerFast.from_pretrained("gpt2")
E = GPT2Model.from_pretrained("gpt2").wte.weight.detach() # (50257, 768)
En = F.normalize(E, dim=1)
def vec(word):
ids = tok.encode(" " + word) # GPT-2 어휘는 선행 공백 포함 형태가 기본
assert len(ids) == 1, f"'{word}'는 단일 토큰이 아님: {ids}"
return E[ids[0]], ids[0]
def analogy(a, b, c, k=5): # a - b + c ≈ ?
va, ia = vec(a); vb, ib = vec(b); vc, ic = vec(c)
q = F.normalize((va - vb + vc)[None], dim=1)
sims = (En @ q.T).squeeze(1)
sims[[ia, ib, ic]] = -1 # 입력 단어는 후보에서 제외
top = sims.topk(k).indices
print(f"{a} - {b} + {c} ≈ " +
", ".join(f"{tok.decode([i])!r}({sims[i]:.3f})" for i in top))
analogy("king", "man", "woman")
analogy("Paris", "France", "Korea")
analogy("bigger", "big", "small")
def direction_cos(a, b, c, d): # (a-b)와 (c-d) 방향의 코사인
va, _ = vec(a); vb, _ = vec(b); vc, _ = vec(c); vd, _ = vec(d)
cos = F.cosine_similarity((va - vb)[None], (vc - vd)[None]).item()
print(f"cos( E({a})-E({b}), E({c})-E({d}) ) = {cos:.3f}")
direction_cos("aunt", "uncle", "woman", "man") # 그 유명한 그림의 검증
direction_cos("queen", "king", "woman", "man")
direction_cos("aunt", "uncle", "Paris", "France") # 무관한 방향과의 대조군
실행을 시켜보겠습니다. 아직 Container이미지에는 Transformer 패키지가 설치되어 있지 않아서, 별도 설치를 진행해야 합니다.
pip install transformers
python gpt2_arith.py
|
검증 항목
|
결과
|
|
king − man + woman의 최근접
|
' queen'(0.709), ' princess'(0.605), ' Queen'(0.596), ' kings'(0.593), 'Queen'(0.572)
|
|
Paris − France + Korea의 최근접
|
' Korean'(0.652), ' Seoul'(0.604), ' Koreans'(0.574), ' Pyongyang'(0.530), ' Shanghai'(0.479)
|
|
bigger - big + small
|
' smaller'(0.797), ' larger'(0.762), 'small'(0.608), ' tiny'(0.598), 'Small'(0.592)
|
|
cos(aunt−uncle, woman−man)
|
0.293
|
|
cos(queen−king, woman−man)
|
0.417
|
|
대조군 cos(aunt−uncle, Paris−France)
|
0.067
|
세 번째 행이 이번 편의 하이라이트입니다.
E(aunt) − E(uncle) ≈ E(woman) − E(man) — 트랜스포머 해설 영상들에 단골로 나오는 바로 그 그림을, 그림이 아니라 내 기계에서 계산한 코사인 값으로 확인하는 겁니다.
성별이라는 의미 성분이 정말 하나의 방향이라면 두 차이 벡터의 코사인이 대조군(무관한 방향 쌍)보다 뚜렷이 높아야 합니다. 완벽한 1.0을 기대하지는 마십시오 — GPT-2의 임베딩은 벡터 산술을 목표로 학습된 것이 아니라 언어모델링의 부산물이라, "경향은 뚜렷하되 잡음이 섞인" 값이 나오는 것이 정상이고, 그 불완전함 자체가 정직한 관찰입니다.
아쉽게도 한국어 단어(이모/삼촌)로는 이 실험을 못 하는데, 7편에서 본 그대로 GPT-2 어휘에서 한국어는 단일 토큰이 아니라 바이트 파편이기 때문입니다 — 한국어 페널티가 의미 연구까지 따라오는 셈입니다.
위치: 순환을 버린 대가, 정산의 시간
5편 실험 1에서 우리는 셀프어텐션이 순서를 완전히 잃어버린다는 것을 수치로 증명했고, 학습형 위치 임베딩 한 줄로 급한 불을 껐습니다.
이제 그 선택지를 제대로 펼쳐볼 차례입니다.
계보는 이렇습니다 — MemN2N(2015)이 순환 없는 메모리에 시간 순서를 주려고 학습형 시간 임베딩 T(i)를 더했고(4편), 2017년 논문은 학습형과 함께 사인파(sinusoidal) 인코딩을 제안하며 실험상 두 방식의 성능이 비슷하다고 보고했습니다.
사인파 인코딩은 위치 pos의 벡터를 서로 다른 파장의 sin/cos 값들로 채웁니다.
PE(pos, 2i) = sin(pos / 10000^(2i/d))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d))
짧은 파장 차원은 이웃 위치를 구분하고, 긴 파장 차원은 멀리 떨어진 위치를 구분합니다 — 이진수 자릿수의 연속 버전이라고 생각하면 정확합니다.
학습이 필요 없고, 임의 길이로 즉시 확장되며, 결정적으로 논문이 강조한 성질이 하나 있습니다: 고정 간격 k에 대해 PE(pos+k)가 PE(pos)의 선형 함수라는 것 — 즉 이 인코딩에는 절대 위치만이 아니라 상대 위치가 새겨져 있습니다.
진짜 이 내용이 맞는지 한번 실험을 통해서 확인해 보시죠.
실험 3: 사인파의 성질 증명과 외삽 대결
pe_probe.py에 두 실험을 담습니다. 하나는 성질 증명, 하나는 학습형과의 대결입니다.
# pe_probe.py — 사인파 위치 인코딩: 상대 위치 성질 증명 + 길이 외삽 대결
import argparse, torch, torch.nn as nn, torch.nn.functional as F
p = argparse.ArgumentParser()
p.add_argument("--exp", choices=["property", "extrapolate"], required=True)
p.add_argument("--pe", choices=["learned", "sin", "rel"], default="sin")
p.add_argument("--steps", type=int, default=3000)
args = p.parse_args()
torch.manual_seed(42)
dev = "cuda"
def sinusoidal(L, d):
pos = torch.arange(L)[:, None].float()
i = torch.arange(0, d, 2).float()
ang = pos / 10000 ** (i / d)
pe = torch.zeros(L, d)
pe[:, 0::2] = ang.sin()
pe[:, 1::2] = ang.cos()
return pe
if args.exp == "property":
pe = sinusoidal(64, 128)
G = pe @ pe.T # 모든 위치 쌍의 내적
print("내적이 '간격'만의 함수인지 — 절대 위치를 바꿔가며 같은 간격을 비교:")
for k in [1, 3, 10, 30]:
vals = torch.tensor([G[i, i + k] for i in range(0, 64 - k)])
print(f"간격 {k:2d}: 내적 평균 {vals.mean():8.3f} 표준편차 {vals.std():.2e}")
if args.exp == "extrapolate":
Vv, Ltr, Lte, d = 50, 20, 40, 128 # 20으로 학습, 40에서 평가
class M(nn.Module):
def __init__(self):
super().__init__()
self.emb = nn.Embedding(Vv, d)
if args.pe == "learned":
self.pos = nn.Parameter(torch.randn(Lte, d) * 0.02)
# 자리는 40까지 잡아두지만, 학습 중엔 앞 20행만 기울기를 받습니다
elif args.pe == "sin":
self.register_buffer("pos", sinusoidal(Lte, d))
else: # rel: 절대 좌표를 아예 쓰지 않습니다
self.rel_bias = nn.Parameter(torch.zeros(2 * Lte - 1))
def w(): return nn.Parameter(torch.randn(d, d) / d ** 0.5)
self.Wq, self.Wk, self.Wv = w(), w(), w()
self.out = nn.Linear(d, Vv)
def forward(self, x):
L = x.size(1)
h = self.emb(x)
if args.pe != "rel":
h = h + self.pos[:L]
Q, K, Vh = h @ self.Wq, h @ self.Wk, h @ self.Wv
S = Q @ K.transpose(-2, -1) / d ** 0.5
if args.pe == "rel": # 점수에 '간격별' 바이어스를 직접 더함
idx = torch.arange(L, device=x.device)
S = S + self.rel_bias[idx[None, :] - idx[:, None] + Lte - 1]
return self.out(torch.softmax(S, -1) @ Vh)
model = M().to(dev)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
for step in range(1, args.steps + 1):
x = torch.randint(0, Vv, (256, Ltr), device=dev)
y = model(x)[:, :-1] # 과제: 오른쪽 이웃 맞히기
loss = F.cross_entropy(y.reshape(-1, Vv), x[:, 1:].reshape(-1))
opt.zero_grad(); loss.backward(); opt.step()
for L in [Ltr, Lte]:
x = torch.randint(0, Vv, (4096, L), device=dev)
ok = (model(x)[:, :-1].argmax(-1) == x[:, 1:]).float()
line = f"pe={args.pe:7s} 길이 {L}: 전체 {ok.mean() * 100:5.1f}%"
if L > Ltr: # 진단: 본 위치 vs 새 위치를 분리 채점
line += (f" 본 위치(0~{Ltr - 2}) {ok[:, :Ltr - 1].mean() * 100:5.1f}%"
f" 새 위치({Ltr - 1}~) {ok[:, Ltr - 1:].mean() * 100:5.1f}%")
print(line)
먼저 이 소스를 살펴보면서 몇 가지 Attention is All you need 논문에서 제시한 몇가지 내용을 같이 떠올려보겠습니다.
제일 중요한 부분은 바로 sinusoidal 함수입니다. 왜 하필 복잡한 사인/코사인을 썼을까요? 이 실험은 그 수학적 비밀을 증명합니다. 언어에서는 단어의 '절대적인 위치(3번째냐 10번째냐)'보다 '두 단어가 몇 칸 떨어져 있느냐(상대적 간격)'가 훨씬 중요합니다. (예: 주어와 동사가 3칸 떨어져 있다)
def sinusoidal(L, d):
pos = torch.arange(L)[:, None].float() # 0부터 L-1까지의 위치(단어 순서)
i = torch.arange(0, d, 2).float() # 0, 2, 4... 짝수 차원 인덱스
ang = pos / 10000 ** (i / d) # 각도 계산 (주기가 달라짐)
pe = torch.zeros(L, d) # 빈 도화지 (길이 L x 차원 d)
pe[:, 0::2] = ang.sin() # 짝수 칸에는 사인(sin) 값
pe[:, 1::2] = ang.cos() # 홀수 칸에는 코사인(cos) 값
return pe
단순히 1, 2, 3, 4라는 숫자를 더해주면, 문장이 길어질수록 숫자가 무한히 커져서 학습이 망가집니다. 그래서 연구자들은 -1에서 1 사이를 반복하는 사인(sin)과 코사인(cos) 파동을 시곗바늘처럼 사용하기로 했습니다. 짝수 차원(0, 2, 4...)에는 사인파를, 홀수 차원(1, 3, 5...)에는 코사인파를 채워 넣습니다. 각 차원마다 파동의 출렁이는 속도(주기)가 달라서, 마치 시계의 초침, 분침, 시침이 조합되어 정확한 시간을 나타내듯 고유한 위치 벡터를 만들어냅니다.
즉, PE(i)·PE(j)를 모든 쌍에 대해 계산했을 때, 그 값이 절대 위치 i, j가 아니라 간격 i−j에만 의존한다면 상대 위치가 새겨져 있다는 뜻입니다.
실제로 테스트를 진행해 보겠습니다.
python pe_probe.py --exp property
|
간격 k
|
내적 평균
|
같은 간격 쌍들의 내적 표준편차 (기대: ≈ 0)
|
|
1
|
62.094
|
0.00000072
|
|
3
|
52.186
|
0.00000063
|
|
10
|
42.820
|
0.000000697
|
|
30
|
36.244
|
0.000000483
|
표준편차가 부동소수점 잡음 수준으로 나오면 증명 끝입니다 — 위치 0과 1의 내적이 위치 40과 41의 내적과 같다는 것, 즉 닷프로덕트 어텐션이 보게 되는 위치 신호가 "몇 번째냐"가 아니라 "얼마나 떨어졌느냐"를 담고 있다는 뜻입니다.
다음 실험은 외삽 대결입니다.
고정 길이 외삽 소스는 위에서 있는 소스 중에 다음 부분입니다.
if args.pe == "learned":
self.pos = nn.Parameter(torch.randn(Lte, d) * 0.02)
학습형 방식은 빈 도화지에 1번부터 40번까지의 위치를 컴퓨터가 스스로 그리게 합니다. 하지만 훈련할 때 20단어짜리 문장만 보았기 때문에, 1번부터 20번까지의 위치 정보는 완벽하게 학습(loss.backward())하지만, **21번부터 40번까지의 위치는 한 번도 훈련된 적 없는 초기화 상태의 '랜덤 쓰레기 값'**으로 남아있게 됩니다. 당연히 길이가 40인 시험 문제에서는 정답률이 바닥을 칠 것입니다.
진짜 그렇할까요?
"오른쪽 이웃 맞히기"(y_i = x_{i+1})라는 순수 상대 위치 과제를 길이 20으로만 학습한 뒤, 길이 40에서 시험합니다.
python pe_probe.py --exp extrapolate --pe learned
python pe_probe.py --exp extrapolate --pe sin
|
설정
|
길이 20 (학습 길이)
|
길이 40 (외삽)
|
|
학습형 PE
|
100 %
|
52.4 %
|
|
사인파 PE
|
100 %
|
50.8 % (문제 발생!)
|
어 그런데, Sinusoidal 로 진행한 경우도 확률이 엄청 떨어졌습니다.
그럼 성질 증명과 모순인가? 아닙니다 — 여기에 이번 편의 가장 깊은 교훈이 있습니다. property 실험이 증명한 것은 항등 내적 PE(i)·PE(j)가 간격만의 함수라는 것입니다.
그런데 어텐션이 실제로 쓰는 위치 점수는 PE(i)ᵀ(Wq Wkᵀ)PE(j) — 학습된 쌍선형 형식입니다.
Wq, Wk가 사인파의 회전 구조를 존중해야 할 강제는 어디에도 없고, SGD는 학습에서 본 위치 0~19만 맞으면 되는 아무 W나 찾고 멈춥니다. 그 W가 새 위치에서 작동할 이유는 없습니다.
한 문장으로예기한다면,
정보가 새겨져 있는 것과, 메커니즘이 그 정보만 쓰도록 강제되는 것은 다릅니다.
사인파는 전자만 보장합니다. 실제로 "사인파 트랜스포머는 길이 외삽에 실패한다"는 것은 이후 정식으로 보고된 사실이고(Press et al. 2021 — ALiBi의 출발점이 된 관찰), 우리는 그것을 장난감 크기에서 재현한 셈입니다.
진단이 나왔으니 처방도 명확합니다. 강제되지 않아 실패했다면, 강제하면 됩니다.
절대 좌표를 임베딩에 더하는 방식을 버리고, 어텐션 점수에 간격별 바이어스를 직접 더합니다(코드의 --pe rel, Shaw et al. 2018의 상대 위치 표현 계열). 바로 소스에 있는 이 부분을 활성화 하면 됩니다.
if args.pe == "rel": # 점수에 '간격별' 바이어스를 직접 더함
idx = torch.arange(L, device=x.device)
S = S + self.rel_bias[idx[None, :] - idx[:, None] + Lte - 1]
이 방식은 구글의 유명한 언어 모델인 T5 등에서 실제로 채택한 '상대적 위치 편향(Relative Positional Bias)' 기법의 핵심 뼈대입니다.
"기존 방식(절대 위치 임베딩)의 문제는 단어 벡터 자체에 '나는 1번', '나는 2번'이라는 번호표를 억지로 더해버려서, 이후 행렬 곱셈을 거치며 정보가 오염된다는 것이었습니다.
그래서 과학자들은 발상을 바꿨습니다. 단어 벡터에는 아무것도 더하지 말고 순수하게 놔두자. 대신, 나중에 두 단어가 서로 얼마나 친한지 점수(Attention Score)를 매길 때, 두 단어가 '몇 칸 떨어져 있는지'에 따라 가산점을 직접 더해주자! 이것이 바로 상대적 위치 편향(Relative PE)의 핵심입니다."
수정 사항은 다음과 같습니다.
(1) 덧셈 포기하기 (입력 보존)
가장 먼저, 입력 벡터 h에 위치 벡터를 더하는 과정을 생략합니다. 이제 Wq와 Wk 행렬은 위치 정보가 섞이지 않은 순수한 단어의 의미만 가지고 곱셈을 수행하게 됩니다. 오염이 발생할 원인을 원천 차단한 것입니다.
(2) 간격별 가산표 만들기
self.rel_bias = nn.Parameter(torch.zeros(2 * Lte - 1))
단어와 단어 사이의 '간격(거리)'마다 부여할 가산점 표를 만듭니다. 길이가 40(Lte)이라면, 나를 기준으로 왼쪽으로 39칸부터 오른쪽으로 39칸까지 총 77개의 간격이 존재합니다. 컴퓨터는 학습을 통해 "바로 옆 칸(-1)은 점수를 높여야지", "5칸 떨어져 있으면 점수를 깎아야지" 하고 이 점수판을 스스로 업데이트합니다.
(3) 최종 점수에 직접 더하기 (마법의 순간)
if args.pe == "rel": # 점수에 '간격별' 바이어스를 직접 더함
idx = torch.arange(L, device=x.device)
S = S + self.rel_bias[idx[None, :] - idx[:, None] + Lte - 1]
어텐션 점수 S가 계산된 직후, 앞서 만든 가산점 표에서 두 단어의 거리에 맞는 점수를 쏙 뽑아서 직접 더해줍니다(+). 이렇게 하면 복잡한 행렬 곱셈을 다 끝낸 후에 안전하게 위치 정보를 주입할 수 있습니다.
학습이 "간격 +1이면 점수를 크게"라는 규칙을 배우면, 그 규칙은 절대 위치를 아예 참조하지 않으므로 길이 40의 위치 30에서도 그대로 작동해야 합니다.
실제로 동작하는지 다시 한번 확인해 보겠습니다.
python pe_probe.py --exp extrapolate --pe rel
|
설정
|
길이 20 (학습 길이)
|
길이 40 (외삽)
|
새 위치(19~)만
|
|
상대 바이어스 (rel)
|
100 %
|
76.6 %
|
77 %
|

이 방식은 기존 절대 위치 임베딩의 문제를 훌륭하게 해결했지만, 치명적인 단점이 하나 있습니다.
만약 훈련할 때 한 번도 본 적 없는 '거리'(예: 훈련은 길이 20까지 해서 거리가 최대 19칸인데, 시험에서 30칸 떨어진 단어를 참조해야 하는 경우)가 등장하면, 그 간격에 대한 가산점(rel_bias)이 전혀 학습되어 있지 않아서 또다시 에러가 발생합니다.
이 문제를 완전히 끝내기 위해 '학습조차 필요 없이, 거리가 멀어질수록 수학적으로 가산점을 깎아버리는' ALiBi(알리바이), RoPE라는 기법이 등장하게 되는데, 이 코드는 그 발전 과정의 완벽한 중간 다리 역할을 합니다.
더할 것인가, 이어붙일 것인가 — 그리고 우리의 선택
마지막으로 자주 나오는 질문 하나. 위치 벡터를 토큰 임베딩에 왜 더하는가(concat이 아니라)? 즉 위치 벡터를 별도의 디멘션에 강제하여 제공하면 되지 않을까 하는 의문입니다.
그 이유는 별도의 디멘션으로 이어붙이면 위치와 의미가 차원에서 분리되어 깔끔하지만 차원이 커져 이후 모든 층의 비용이 늘어납니다. 더하기는 공짜인 대신 두 신호가 섞이는데, 고차원 공간에서는 학습이 위치 성분과 의미 성분을 사실상 다른 부분공간에 배치할 수 있어서 실전에서 문제가 되지 않습니다
우리 연재의 선택: 12편 baseline은 학습형 PE로 갑니다. GPT-2가 학습형을 썼고 우리 baseline은 GPT-2 계보(7편에서 정한 토크나이저처럼)이기 때문입니다.
사인파는 오늘로 은퇴하지만, 오늘 실험 3이 남긴 교훈 — 위치는 절대 좌표로 주는 것이 아니라 상대 관계로 메커니즘에 강제해야 외삽이 산다는 것 — 은 현대 LLM 위치 처리의 설계 원리 그 자체입니다.
향후에 그 계보의 완성형인 RoPE(회전 위치 임베딩)를 다룰 때, 오늘의 3파전 결과가 출발점이 됩니다.
토큰은 좌표를 얻었고 위치도 새겨졌습니다. 다음 편에는 5편에 만들었던 실험용 셀프어텐션을 12편까지 갈 정식 코드베이스의 모듈로 승격시킵니다. 배치와 인과 마스크, 드롭아웃을 갖춘 CausalSelfAttention을 단계별로 쌓아 올리며, 실험 스크립트와 프로덕션 코드의 차이가 어디서 생기는지도 함께 살펴 보도록 하겠습니다.