신고 티켓
#03 · 세 가지 배
서점의 신고
"손님이 배 타고 섬에 가는 여행책을 찾길래 챗봇에 물어봤더니, 가을이 제철인 과일 배 이야기를 늘어놓고 과수원 에세이를 추천하더라고요." (항구서점 사장님)
오늘 추적할 것
토큰 번호가 모델 안에서 처음으로 숫자 목록(벡터)이 되는 정거장, 임베딩을 찍어 본다. 타는 배, 먹는 배, 몸의 배가 이 정거장에서 서로 다른 숫자인지 확인하고, 토큰의 자리 정보가 어떻게 더해지는지 본다.
도란 사무실 · 옛 인쇄소 건물 3층
금요일 아침, 보라 매니저가 노트북을 들고 하은의 자리로 옵니다. "항구서점이에요. 손님이 배 타고 가는 섬 여행책을 찾았는데, 챗봇이 과일 배 얘기를 했대요. 사장님은 '우리 서점 이름이 항구서점인데!' 하고 웃으셨지만, 또 그러면 곤란해요."
정 선임이 챗봇 기록을 엽니다. 도란의 챗봇은 질문을 바로 대형 언어 모델에 넘기지 않습니다. 먼저 도란이 직접 만든 책 찾기 단계가 서점의 책 목록에서 후보 몇 권을 고르고, 모델은 그 후보를 소개하는 글을 씁니다. 기록을 보니 이번 질문의 후보 맨 위에 이미 과수원 에세이가 올라 있었습니다. 모델은 받은 후보를 성실하게 소개했을 뿐입니다.
"책 찾기는 회사 초기에 급히 만든 거라 단순해. 낱말마다 숫자 목록을 하나씩 정해 둔 표를 써. 질문에 나온 낱말들의 숫자 목록을 평균 내서, 책 소개글의 평균과 가장 비슷한 책을 고르지." 정 선임의 설명을 듣고 하은이 추측합니다. "그럼 그 표에서 '배'가 과일 쪽 숫자로 학습된 거 아닐까요? 학습한 글에 과일 얘기가 많았다든가요."
정 선임이 고개를 젓습니다. "추측하지 말고 찍어 보자. 그 표는 언어 모델 입구에 있는 임베딩 표와 같은 종류야. 월요일에 보여 준 내 참고 모델에도 같은 표가 있고, 오늘 네 mylm에도 하나 만들 거야."
하은은 참고 모델의 토크나이저로 세 문장을 잘라 봅니다. '배를 타고 섬으로 갔다', '배가 고파서 밥을 먹었다', '배 한 개를 깎아 먹었다'. 세 문장 모두 맨 앞 토큰이 같은 번호, ▁배(1819번)입니다. "번호가 같으니까… 숫자 목록도 같겠네요?" 정 선임이 답합니다. "그게 오늘 확인할 거야. 그리고 모델은 '배'가 문장의 몇 번째에 있는지도 알아야 해. 그것도 같이 보자."
이번 교시에서는 토큰 번호가 임베딩 표의 한 행이 되는 과정, 두 숫자 목록이 얼마나 비슷한지 재는 코사인, 참고 모델이 실제로 배운 표의 모습, 그리고 위치 정보를 더해 모델이 받는 입력 행렬 T × d를 만드는 과정까지 따라갑니다.
2교시의 토크나이저는 글을 토큰 번호 목록으로 바꿨습니다. 참고 모델의 어휘는 2,585개이니 번호는 0부터 2,584까지입니다. 그런데 번호 자체에는 쓸 만한 정보가 없습니다. 1819번(▁배)과 1820번이 이웃 번호라고 해서 비슷한 토큰은 아닙니다. 번호는 어휘 목록에서의 자리일 뿐입니다.
그래서 모델은 입구에 임베딩 표(embedding table) 를 둡니다. 행이 어휘 수만큼, 열이 개인 숫자 표입니다. 참고 모델은 이니 표의 모양은 2,585 × 128이고, 숫자는 모두 330,880개입니다. 토큰 번호 가 들어오면 모델은 표의 번 행을 꺼냅니다. 그 128개 숫자가 그 토큰의 임베딩(embedding), 곧 숫자 목록(벡터)입니다.
| 단계 | 모양 | 예 |
|---|---|---|
| 토큰 | 글자 조각 | ▁배 |
| 토큰 번호 | 정수 하나 | 1819 |
| 임베딩 | 숫자 128개 | 표의 1819번 행: −0.080, 0.004, −0.452, … |
표의 숫자는 사람이 정하지 않습니다. 학습을 시작할 때는 작은 무작위 수(참고 모델은 평균 0, 표준편차 0.02)로 채우고, 다음 토큰을 맞히는 학습(9·10교시)을 하며 조금씩 고칩니다. 참고 모델의 매개변수 1,140,608개 중 29.0%가 이 표 하나입니다. 참고 모델은 이 표를 출구에서 다음 토큰의 점수를 낼 때도 다시 씁니다(6·7교시).
'행을 꺼낸다'는 것을 행렬 곱으로 쓸 수도 있습니다. 번 칸만 1이고 나머지는 0인 원-핫 벡터(one-hot vector) 에 표 를 곱하면 정확히 번 행이 나옵니다.
손 계산. 어휘 4개, 인 작은 표에서 2번 토큰의 원-핫 벡터는 (0, 0, 1, 0)입니다. 표의 각 열과 곱해 더하면 0번·1번·3번 행은 0이 곱해져 사라지고, 2번 행만 1이 곱해져 그대로 남습니다. 결과는 2번 행 그대로입니다.
계산 결과는 같지만, 실제 코드는 곱하지 않고 행을 바로 꺼냅니다. 원-핫 곱은 0을 곱하는 계산을 어휘 수만큼 되풀이하는 낭비이기 때문입니다.
NumPy로는
>>> import numpy as np
>>> E = np.array([[0.1, 0.2, 0.3],
... [0.4, 0.5, 0.6],
... [0.7, 0.8, 0.9],
... [1.0, 1.1, 1.2]]) # 어휘 4개 × d = 3
>>> E.shape
(4, 3)
>>> E[2] # 2번 토큰의 숫자 목록 = 2번 행
array([0.7, 0.8, 0.9])
>>> E[[2, 0, 2]] # 번호 목록을 넣으면 행을 한꺼번에
array([[0.7, 0.8, 0.9],
[0.1, 0.2, 0.3],
[0.7, 0.8, 0.9]])
>>> onehot = np.eye(4)[2]
>>> onehot
array([0., 0., 1., 0.])
>>> onehot @ E # 원-핫 벡터 × 표 = 같은 행
array([0.7, 0.8, 0.9])E[[2, 0, 2]]처럼 번호 목록으로 인덱싱하면 같은 번호는 같은 행이 몇 번이든 그대로 나옵니다. 이 줄이 오늘 만들 embed 함수의 절반입니다.
토큰마다 128개 숫자가 생겼으니, 두 토큰이 '비슷한지'를 숫자로 잴 수 있습니다. 두 가지 방법이 있습니다.
거리(distance). 두 점 사이의 직선 거리입니다. 칸마다 차이를 제곱해 더하고 제곱근을 씌웁니다. 2차원에서 쓰던 피타고라스 공식을 128칸으로 늘린 것입니다.
코사인 유사도(cosine similarity). 두 벡터가 가리키는 방향이 얼마나 같은지입니다. 칸끼리 곱해 더한 값(내적)을 두 벡터의 길이로 나눕니다.
코사인은 언제나 −1과 1 사이입니다. 1이면 같은 방향, 0이면 직각, −1이면 정반대 방향입니다. 길이로 나누기 때문에 벡터를 몇 배로 늘려도 코사인은 그대로입니다. 임베딩에서는 자주 나오는 토큰과 드문 토큰의 벡터 길이가 꽤 다를 수 있어서, 방향만 비교하는 코사인을 많이 씁니다.
손 계산. , 라고 합시다.
| 계산 | 값 |
|---|---|
| 내적 | 1×2 + 2×1 + 2×2 = 8 |
| 길이 , | , |
| 코사인 | 8 / (3 × 3) = 8/9 ≈ 0.889 |
| 거리 | ≈ 1.414 |
이제 를 10배로 늘려 으로 만들면, 내적은 80, 길이는 3과 30이라 코사인은 80/90 ≈ 0.889로 그대로입니다. 거리는 약 27.4로 크게 바뀝니다. 방향은 같고 길이만 다르면 코사인은 같다고 보고, 거리는 다르다고 봅니다.
NumPy로는
>>> a = np.array([1.0, 2.0, 2.0])
>>> b = np.array([2.0, 1.0, 2.0])
>>> float(a @ b), float(np.linalg.norm(a)), float(np.linalg.norm(b))
(8.0, 3.0, 3.0)
>>> def cosine(a, b):
... return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
>>> round(cosine(a, b), 3)
0.889
>>> round(cosine(a, 10 * b), 3) # 길이를 10배로 늘려도 그대로
0.889
>>> round(float(np.linalg.norm(a - b)), 3), round(float(np.linalg.norm(a - 10 * b)), 3)
(1.414, 27.368)a @ b는 칸끼리 곱해 더하는 내적, np.linalg.norm은 벡터의 길이입니다. 결과를 float로 바꾼 것은 화면에 np.float64(...) 대신 숫자만 보이게 하려는 것입니다.
이제 참고 모델이 학습으로 채운 진짜 표를 봅니다. 아래 실습대의 지도는 2,585 × 128 표를 평면에 펼친 것입니다. 128개 방향을 한 화면에 그릴 수는 없으니, 표의 숫자가 가장 넓게 흩어진 방향 두 개(주성분, principal component)를 골라 가로와 세로로 썼습니다. 이 방법을 주성분 분석(PCA)이라고 합니다. 점 하나가 토큰 하나이고, 처음에는 말뭉치에 많이 나온 토큰 300개를 찍습니다.
토큰을 고르면 오른쪽에 그 토큰의 번호와 말뭉치 등장 횟수, 그리고 128차원 표 전체로 계산한 코사인 이웃 8개가 나옵니다. 지도 아래 띠는 그 토큰의 숫자 128개입니다. 칸 하나가 숫자 하나이고, 양수는 남색, 음수는 주황, 0에 가까울수록 옅습니다.
모델을 불러오는 중…
실습대에서 잰 이웃(코사인, 128차원 전체로 계산)은 이렇습니다.
| 고른 토큰 | 코사인이 가장 큰 이웃 |
|---|---|
▁저녁 | ▁아침 0.693, ▁밤 0.574, ▁밥 0.459 |
▁어머니 | ▁아버지 0.563, ▁친구 0.431, ▁자기 0.420, ▁아내 0.418 |
을 | 이 0.572, 은 0.557, 으로 0.480, 이나 0.411 |
를 | 가 0.529, 로 0.406, 는 0.395 |
. | ! 0.533, ? 0.504, ……. 0.465 |
▁그는 | ▁엘리자베트는 0.616, ▁나는 0.523, ▁승재는 0.501, ▁초봉이는 0.497 |
몇 가지가 눈에 띕니다.
▁그는의 이웃에는 소설 속 인물 이름에 '는'이 붙은 토큰(엘리자베트, 승재, 초봉이)이 모였습니다. 문장에서 같은 역할을 하는 말들입니다.을의 이웃 이·은·으로·이나는 모두 받침 있는 말 뒤에 붙고, 를의 이웃 가·로·는은 받침 없는 말 뒤에 붙습니다. 모델에게 받침 규칙을 따로 알려 준 적은 없습니다. 다음 토큰을 맞히도록 숫자를 고치다 보니 이렇게 계산되었습니다.이웃이 반듯한 '뜻 사전'은 아닙니다. 이 모델은 162만 자짜리 말뭉치로 학습한 작은 모델이라, ▁저녁의 이웃 8개 안에는 녁(낱말 안 조각)이나 ▁약처럼 왜 거기 있는지 설명하기 어려운 토큰도 섞여 있습니다.
지도는 어떻게 읽어야 할까요? 실습대가 직접 계산해 보니, 두 주성분이 담은 몫은 표 전체 흩어짐(분산)의 8.5% 뿐입니다. 128개 방향 중 두 방향만 남기고 나머지는 버린 그림입니다. 두 방향이 무엇과 관련 있는지도 재 보았습니다.
| 지도의 방향 | 함께 움직이는 것 (상관계수) |
|---|---|
| 가로(주성분 1) | 말뭉치 등장 횟수의 로그: 0.93. 자주 나온 토큰일수록 오른쪽 |
| 세로(주성분 2) | 낱말의 첫 조각(▁로 시작)인지 여부: −0.62, 많이 나온 300개만 보면 −0.84. ▁ 조각은 아래, 낱말 안 조각은 위 |
곧 이 지도에서 가장 크게 보이는 차이는 뜻이 아니라 '얼마나 자주 나오나'와 '낱말의 첫 조각인가'입니다. 지도에서 뜻이 비슷한 말끼리 모여 보이는 곳이 있더라도, 그것은 이 두 방향이 우연히 갈라 준 것일 수 있습니다. 실제로 ▁어머니의 가장 가까운 이웃은 ▁아버지인데, 지도 위에서는 ▁어머니에 ▁아버지보다 더 가까이 찍힌 토큰이 103개나 있습니다. 이웃은 언제나 128차원의 진짜 표에서 재야 합니다.
정 선임의 한마디
지도는 길잡이일 뿐이야. '지도에서 가까우니 비슷하다'고 말하기 전에 128차원에서 코사인을 재 봐. 이번 지도는 두 방향이 분산의 8.5%밖에 못 담았어.
NumPy로는
실습대와 같은 계산을 내 컴퓨터에서 해 볼 수 있습니다. 참고 모델 파일 두 개(ref_model.json, ref_model.bin)를 사이트의 /llm/ 주소에서 받아 mylm 폴더에 두고, 오늘 만들 mylm/embed.py의 함수로 이웃을 찾습니다. 가중치는 float16으로 이어 붙여 저장되어 있고, json의 tensors 목록에 텐서마다 이름·모양·시작 위치가 적혀 있습니다.
import json
import numpy as np
from mylm.embed import cosine, nearest
meta = json.load(open("ref_model.json", encoding="utf-8"))
raw = np.fromfile("ref_model.bin", dtype="<f2")
spec = {t["name"]: t for t in meta["tensors"]}
def tensor(name):
t = spec[name]
n = int(np.prod(t["shape"]))
return raw[t["offset"]:t["offset"] + n].astype(np.float32).reshape(t["shape"])
E, P = tensor("tok.weight"), tensor("pos.weight")
vocab = meta["vocab"]
print(E.shape, P.shape)
i = vocab.index("▁저녁")
print(i, [vocab[j] for j in nearest(E, i, 3)])
print(round(cosine(E[i], E[vocab.index("▁아침")]), 3))실행 결과:
(2585, 128) (128, 128)
2088 ['▁아침', '▁밤', '▁밥']
0.693이제 신고 티켓의 '배'로 돌아갑니다. 실습대에서 ▁배를 고르면 이웃은 ▁피 0.393, ▁싸 0.371, ▁때 0.356, ▁애 0.355, ▁어머니 0.343, ▁머리 0.342 순입니다. 과일도, 탈것도, 바다도 없습니다. 이 행이 어느 한 뜻의 자리가 아니기 때문입니다.
참고 모델의 말뭉치에서 ▁배 토큰은 659번 나옵니다. 뒤따르는 조각으로 나눠 세어 보면 이렇습니다.
▁배 뒤에 온 것 | 횟수 | 무슨 '배'인가 |
|---|---|---|
| 가 / 를 / 는 / (낱말 끝) | 79 / 54 / 27 / 32 | 문장마다 다르다: 배가 고프다, 배를 타다, … |
| 고 (배고프다 등) | 32 | 몸의 배 |
| 따 (배따라기) | 22 | 김동인의 소설 「배따라기」, 뱃사람의 노래 |
| 암 (배암) | 40 | 뱀의 옛말 |
| 우·워·운·울·웠 (배우다) | 53 | 배우다의 첫 조각 |
| 짱 (배짱) / 추 (배추) | 22 / 10 | 배짱, 배추 |
타는 배, 먹는 배, 몸의 배뿐 아니라 '배우다'와 '배암'의 첫 조각까지 모두 같은 1819번 행을 씁니다. 표에는 토큰 번호마다 행이 딱 하나뿐이고, 그 행은 문장을 보기 전에 정해져 있습니다. 그러니 이 행은 여러 쓰임의 '평균 자리' 비슷한 곳에 놓일 수밖에 없습니다.
신고 티켓의 원인이 여기서 보입니다. 도란의 책 찾기가 쓰는 표도 낱말마다 숫자 목록이 하나뿐인 표입니다. '배 타고 가는 여행책'의 '배'와 과수원 에세이 소개글의 '배'는 그 표에서 완전히 같은 숫자입니다. 표만 보고는 둘을 가를 방법이 없습니다.
언어 모델은 어떻게 '배'의 뜻을 가를까요? 입구에서는 가르지 않습니다. 입구를 지난 뒤, 각 토큰의 벡터가 앞에 나온 토큰들의 벡터를 들여다보고 섞어 가며 바뀝니다. 그래서 '배'는 앞에 섞을 말이 있을 때 비로소 다른 숫자가 됩니다('바다에 나가려고 배를'과 '밥을 굶어서 배가'처럼). 문장 맨 앞의 '배'는 아직 섞을 것이 없으니, 뒤에 오는 '타고'와 '고파서'의 차이는 '배' 뒤의 자리들에 담깁니다. 이 과정이 어텐션(attention)이고, 4·5교시에서 따라갑니다.
정 선임의 한마디
입구의 임베딩에 뜻을 다 담으려고 하지 마. 거기는 토큰마다 출발점 하나를 주는 곳이야. 뜻을 가르는 일은 뒤쪽 층들이 문맥을 섞으면서 해.
'개가 사람을 물었다'와 '사람이 개를 물었다'는 쓰인 토큰이 거의 같은데 뜻은 정반대입니다. 순서가 중요합니다. 그런데 4교시에 볼 어텐션 계산은 토큰들을 순서 없는 묶음처럼 다룹니다. 토큰 벡터만 넣으면 모델은 어느 토큰이 몇 번째에 있었는지 알 길이 없습니다. 그래서 입구에서 자리 정보를 함께 넣어 줍니다.
참고 모델의 방법은 단순합니다. 토큰 임베딩 표 옆에 위치 임베딩 표(position embedding table) 를 하나 더 둡니다. 모양은 문맥 길이 × = 128 × 128입니다. 번 자리에 있는 토큰의 벡터에 의 번 행을 더합니다.
위치 표도 토큰 표처럼 무작위로 시작해 학습으로 채웁니다. 이런 방식을 학습된 위치 임베딩(learned position embedding) 이라고 합니다. 붙이지 않고 더하는 이유는 모양을 그대로 로 두기 위해서이고, 학습이 128칸 안에서 토큰 정보와 자리 정보를 함께 쓰는 방법을 찾습니다.
손 계산. 3-1의 작은 표에서 2번 토큰이 0번 자리와 2번 자리에 두 번 나오는 문장(번호 2, 0, 2)을 생각합니다. 위치 표의 0번 행이 (0.01, 0.02, 0.03), 2번 행이 (0.07, 0.08, 0.09)라면,
| 자리 | 토큰 행 | 위치 행 | 합 |
|---|---|---|---|
| 0 | (0.7, 0.8, 0.9) | (0.01, 0.02, 0.03) | (0.71, 0.82, 0.93) |
| 2 | (0.7, 0.8, 0.9) | (0.07, 0.08, 0.09) | (0.77, 0.88, 0.99) |
같은 토큰이지만 더한 결과는 다릅니다. 차이는 정확히 이고, 토큰과는 아무 상관이 없습니다.
참고 모델이 학습한 위치 표를 재 보았습니다(코사인).
| 두 자리의 간격 | 평균 코사인 |
|---|---|
| 1 (바로 옆 자리) | 0.971 |
| 10 | 0.883 |
| 20 | 0.665 |
| 50 | −0.291 |
| 100 | −0.639 |
아무도 '가까운 자리는 비슷하게'라고 가르치지 않았는데, 학습이 끝난 표에서는 가까운 자리일수록 벡터가 비슷하고, 멀어질수록 반대쪽을 향합니다. 또 하나, 0번 자리 벡터의 길이가 5.87로 유난히 깁니다. 1번은 3.31, 2번은 2.63으로 줄어들고, 128개 자리 전체의 중간값은 0.97입니다. 맨 앞 자리는 앞에 아무 토큰도 없는 특별한 자리라서 모델이 따로 표시해 둔 것으로 보이지만, 그 까닭을 이 교시에서 확정하지는 않습니다.
사인 위치 인코딩. 2017년 트랜스포머 논문은 위치 벡터를 학습하지 않고 정해진 식으로 만들었습니다. 칸마다 주기가 다른 사인과 코사인 물결을 씁니다.
앞쪽 칸은 빠르게, 뒤쪽 칸은 아주 느리게 출렁여서, 칸들을 함께 보면 자리마다 다른 무늬가 됩니다. 시계의 초침·분침·시침을 함께 보면 시각이 하나로 정해지는 것과 비슷합니다. 학습할 숫자가 없고, 학습 때보다 긴 자리에도 식으로 벡터를 만들 수 있다는 장점이 있습니다.
NumPy로는
>>> P = np.array([[0.01, 0.02, 0.03],
... [0.04, 0.05, 0.06],
... [0.07, 0.08, 0.09]]) # 자리 3개 × d = 3
>>> ids = [2, 0, 2]
>>> X = E[ids] + P[:len(ids)]
>>> X
array([[0.71, 0.82, 0.93],
[0.14, 0.25, 0.36],
[0.77, 0.88, 0.99]])
>>> X[0] - X[2] # 같은 토큰, 다른 자리: 차이 = P[0] - P[2]
array([-0.06, -0.06, -0.06])
>>> def sinusoid(ctx, d):
... pos = np.arange(ctx)[:, None]
... i2 = np.arange(0, d, 2)[None, :]
... angle = pos / 10000 ** (i2 / d)
... P = np.zeros((ctx, d))
... P[:, 0::2] = np.sin(angle)
... P[:, 1::2] = np.cos(angle)
... return P
>>> np.round(sinusoid(3, 4), 3)
array([[ 0. , 1. , 0. , 1. ],
[ 0.841, 0.54 , 0.01 , 1. ],
[ 0.909, -0.416, 0.02 , 1. ]])사인 위치 표의 앞 두 칸은 자리마다 크게 바뀌고, 뒤 두 칸은 거의 그대로입니다. 뒤 칸은 주기가 길어서, 자리가 아주 멀리 떨어져야 달라집니다.
문장 하나가 개 토큰이면, 토큰마다 128개 숫자의 행이 하나씩 생깁니다. 이 행들을 위에서 아래로 쌓은 것이 모델이 실제로 받는 입력 행렬 이고, 모양은 입니다. 4교시부터의 모든 계산은 이 행렬에서 시작합니다.
아래 실습대에는 문장 세 개가 있습니다. 문장 아래의 토큰 단추를 누르면 그 토큰이 세 문장에 나온 곳을 모두 찾아, 첫 번째와 비교한 코사인을 표로 보여 줍니다. '임베딩 행'은 표에서 꺼낸 행끼리, '입력 행'은 위치 행까지 더한 뒤의 행끼리 비교한 값입니다. 그 아래에는 고른 문장의 세 행렬, ① 꺼낸 임베딩 행, ② 위치 행, ③ 둘을 더한 입력 행렬 가 칸 색으로 펼쳐집니다. 칸 하나가 숫자 하나이고, 비교하는 토큰의 행에는 노란 테두리가 둘립니다.
모델을 불러오는 중…
세 묶음에서 ▁배의 행을 비교한 결과입니다(코사인, 첫 문장의 ▁배와 비교).
| 묶음 | 문장 (▁배의 자리) | 임베딩 행 | 입력 행 |
|---|---|---|---|
| 모두 맨 앞의 배 | 배를 타고 섬으로 갔다 (0) · 배가 고파서 밥을 먹었다 (0) · 배 한 개를 깎아 먹었다 (0) | 1.000 · 1.000 · 1.000 | 1.000 · 1.000 · 1.000 |
| 세 가지 배 | 배를 타고 섬으로 갔다 (0) · 배가 고파서 밥을 먹었다 (0) · 어머니가 배를 깎아 주셨다 (2) | 1.000 · 1.000 · 1.000 | 1.000 · 1.000 · 0.880 |
| 같은 배, 자리만 다르게 | 배를 탔다 (0) · 큰 배를 탔다 (1) · 아주 큰 배를 탔다 (2) | 1.000 · 1.000 · 1.000 | 1.000 · 0.935 · 0.880 |
이 표가 오늘의 결론입니다.
입구를 지난 직후의 모델은 각 행이 '무슨 토큰인가'와 '몇 번째인가'만 압니다. 옆에 '타고'가 있는지 '고파서'가 있는지는 아직 어느 행에도 들어 있지 않습니다.
mylm/embed.py에 네 함수를 채웁니다. 키트의 빈칸은 다음과 같습니다(파일 맨 위의 설명은 줄였습니다).
import numpy as np
def init_tables(vocab_size, d, ctx, seed=0):
"""임베딩 표 E와 위치 표 P를 무작위로 만든다.
- 난수기는 np.random.default_rng(seed) 하나만 만든다.
- E를 먼저, P를 나중에 뽑는다. 둘 다 평균 0, 표준편차 0.02인 정규분포(rng.normal).
- 돌려주는 값: (E, P). E의 모양은 (vocab_size, d), P의 모양은 (ctx, d).
"""
raise NotImplementedError("3교시에서 채웁니다")
def embed(ids, E, P):
"""토큰 번호 목록 ids(길이 T)를 입력 행렬 X(T × d)로 바꾼다.
- X[t] = E[ids[t]] + P[t] (t번째 토큰의 행에 t번 자리의 행을 더한다)
- ids는 파이썬 리스트일 수도, NumPy 배열일 수도 있다.
- T가 위치 표의 행 수(문맥 길이)보다 길면 ValueError를 낸다.
"""
raise NotImplementedError("3교시에서 채웁니다")
def cosine(a, b):
"""두 벡터의 코사인 유사도: a·b / (|a| |b|). 파이썬 float 하나를 돌려준다."""
raise NotImplementedError("3교시에서 채웁니다")
def nearest(E, i, k=5):
"""E의 i번 행과 코사인 유사도가 가장 큰 행 번호 k개를 큰 순서대로 담은 리스트(자기 자신 i는 뺀다)."""
raise NotImplementedError("3교시에서 채웁니다")init_tables의 난수 순서(E 먼저, P 나중)를 지켜야 확인 스크립트의 기준값과 같은 숫자가 나옵니다. nearest는 표 전체를 한 번에 계산하는 편이 빠릅니다. 모든 행을 길이로 나눠 길이 1로 만든 뒤 한 번의 행렬 곱으로 코사인을 구하고, 자기 자신은 맨 뒤로 보내면 됩니다. 다 채웠으면 mylm 폴더에서 확인 스크립트를 돌립니다.
$ python checks/check_03.py
3교시 확인: mylm/embed.py
[init_tables]
통과 init_tables(6, 4, 5, seed=3): E (6, 4), P (5, 4) 값이 기준과 같습니다
통과 참고 모델 크기 (2585 × 128, 128 × 128), E의 표준편차 0.0200 ≈ 0.02
통과 같은 seed면 같은 표, 다른 seed면 다른 표
[embed]
통과 embed([2, 0, 2]) = E[[2, 0, 2]] + P[:3], 모양 (3, 3)
통과 같은 토큰도 자리가 다르면 다른 행: X[0] − X[2] = P[0] − P[2]
통과 토큰 하나짜리 입력도 모양 (1, 3)
통과 위치 표(4자리)보다 긴 입력(5토큰)은 ValueError
[cosine]
통과 cosine: 8/9, 0, −1, 1 (길이와 상관없이 방향만 본다)
[nearest]
통과 nearest: 코사인이 큰 순서, 자기 자신 제외
통과 행의 길이를 바꿔도 순위가 그대로 (내적이 아니라 코사인)
모두 통과. 3교시 부품(임베딩 표 + 위치 표 → 입력 행렬)이 완성되었습니다.틀린 곳이 있으면 '실패' 줄이 어느 함수의 어느 칸이 다른지 알려 줍니다. 예를 들어 embed에서 위치 행을 더하는 것을 잊으면 "임베딩 표의 행만 꺼냈습니다. 자리마다 위치 표의 행(P[:T])을 더해야 합니다."가, 코사인 대신 내적으로 이웃을 고르면 "5번 행만 10배로 늘렸더니 … 내적이 아니라 길이로 나눈 코사인을 쓰세요."가 나옵니다.
| 기호 | 읽는 법 | 모양 (참고 모델) | 코드에서는 |
|---|---|---|---|
| 어휘 크기 | 2,585 | E.shape[0] | |
| 임베딩 차원, 벡터 하나의 숫자 개수 | 128 | E.shape[1] | |
| 임베딩 표 (토큰 표) | = 2,585 × 128 | E, 참고 모델 파일의 tok.weight | |
| 위치 임베딩 표 | 문맥 길이 × = 128 × 128 | P, 참고 모델 파일의 pos.weight | |
| 지금 문장의 토큰 수 (문맥 길이 이하) | 정수 | len(ids) | |
| 토큰 번호 목록 | ids | ||
| 꺼낸 임베딩 행들 | E[ids] | ||
| 앞에서부터 개 위치 행 | P[:T] | ||
| 입력 행렬 | embed(ids, E, P) | ||
| 코사인 유사도 | 수 하나 (−1–1) | cosine(a, b) |
| 참고 모델 | 대형 언어 모델 (자릿수) | |
|---|---|---|
| 어휘 크기 | 2,585 | 수만–수십만 (–) |
| 임베딩 차원 | 128 | 수천 (–) |
| 임베딩 표의 숫자 | 33만 () | 수억–수십억 (–) |
| 문맥 길이 | 128토큰 | 수천–수십만 토큰 이상 |
| 위치 정보 | 학습된 위치 표 (128 × 128) | 입구에서 더하는 대신 어텐션 안에서 자리 차이를 회전으로 넣는 방식(회전 위치 인코딩, RoPE) 등을 널리 쓴다 |
규모는 자릿수로 서너 단계 차이가 나지만, 입구에서 하는 일은 같습니다. 토큰 번호로 표의 행을 꺼내고, 어떤 방식으로든 자리 정보를 넣어 행렬을 만듭니다. 큰 모델에서도 입구의 행은 토큰마다 하나뿐이라, 세 가지 '배'는 그곳에서 같은 점입니다.
▁어머니 옆에는 ▁아버지보다 가까이 찍힌 토큰이 103개 있습니다. 비슷함은 128차원에서 코사인으로 잽니다.▁배는 타는 배, 먹는 배, 몸의 배에 '배우다'의 첫 조각까지 함께 씁니다. 뜻을 가르는 일은 뒤쪽 층에서 일어납니다.▁배는 입력 행이 같을까요, 다를까요? '아주 큰 배를 탔다'의 ▁배는 '배를 탔다'의 ▁배와 입력 행이 같을까요? 각각 이유도 말해 보세요.E[ids] + P[:200]의 모양을 따져 보세요.▁배는 둘 다 0번 자리라서 임베딩 행도 위치 행도 같고, 입력 행은 숫자 하나 다르지 않습니다(코사인 1.000). 뜻이 달라도 그렇습니다. '아주 큰 배를 탔다'의 ▁배는 2번 자리라서 더해지는 위치 행이 다르고, 입력 행의 코사인은 0.880입니다. 뜻은 같은데도 다릅니다. 입구의 숫자를 가르는 것은 뜻이 아니라 토큰 번호와 자리입니다.E[ids]는 200 × 128인데, 위치 표가 128행뿐이라 P[:200]은 128 × 128입니다. 두 모양이 맞지 않아 NumPy는 더하기에서 오류를 냅니다(오늘 만든 embed는 그 전에 ValueError를 냅니다). 참고 모델은 128번째 뒤의 자리를 배운 적이 없습니다. 그래서 실제로는 마지막 128토큰만 잘라서 넣습니다. 문맥 길이의 한계는 8교시에서 다시 다룹니다.오늘의 조립 풀이: mylm/embed.py
mylm/embed.py 정답입니다(키트의 빈칸을 채운 모습). 내 코드가 확인 스크립트를 통과했다면 다르게 써도 괜찮습니다.
import numpy as np
def init_tables(vocab_size, d, ctx, seed=0):
"""임베딩 표 E와 위치 표 P를 무작위로 만든다.
- 난수기는 np.random.default_rng(seed) 하나만 만든다.
- E를 먼저, P를 나중에 뽑는다. 둘 다 평균 0, 표준편차 0.02인 정규분포(rng.normal).
- 돌려주는 값: (E, P). E의 모양은 (vocab_size, d), P의 모양은 (ctx, d).
"""
rng = np.random.default_rng(seed)
E = rng.normal(0.0, 0.02, size=(vocab_size, d))
P = rng.normal(0.0, 0.02, size=(ctx, d))
return E, P
def embed(ids, E, P):
"""토큰 번호 목록 ids(길이 T)를 입력 행렬 X(T × d)로 바꾼다.
- X[t] = E[ids[t]] + P[t] (t번째 토큰의 행에 t번 자리의 행을 더한다)
- ids는 파이썬 리스트일 수도, NumPy 배열일 수도 있다.
- T가 위치 표의 행 수(문맥 길이)보다 길면 ValueError를 낸다.
"""
ids = np.asarray(ids)
T = len(ids)
if T > P.shape[0]:
raise ValueError(f"입력이 {T}토큰인데 위치 표는 {P.shape[0]}자리까지만 있습니다")
return E[ids] + P[:T]
def cosine(a, b):
"""두 벡터의 코사인 유사도: a·b / (|a| |b|). 파이썬 float 하나를 돌려준다."""
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
def nearest(E, i, k=5):
"""E의 i번 행과 코사인 유사도가 가장 큰 행 번호 k개를 큰 순서대로 담은 리스트(자기 자신 i는 뺀다)."""
unit = E / np.linalg.norm(E, axis=1, keepdims=True)
sims = unit @ unit[i]
sims[i] = -np.inf
return [int(j) for j in np.argsort(-sims)[:k]]추적 보고서
챗봇이 과일 배를 소개한 것은 모델이 '배'를 과일로 외워서가 아니었다. 책 찾기 단계가 넘긴 후보 맨 위에 이미 과수원 에세이가 있었다.
책 찾기는 낱말마다 숫자 목록이 하나뿐인 표(임베딩 표와 같은 종류)의 평균으로 책을 비교한다. 그 숫자는 문장을 보기 전에 정해져 있다.
참고 모델에서 확인: 타는 배·먹는 배·몸의 배는 모두 ▁배(1819번) 한 행이고, 같은 자리라면 위치를 더한 입력 행까지 똑같다(코사인 1.000).
위치 행은 자리만 알려 줄 뿐 뜻을 가르지 못한다. 자리가 달라 생긴 차이(코사인 0.935, 0.880)는 뜻과 상관없다.
조치: 책 찾기를 문장 전체의 문맥이 섞인 벡터로 비교하도록 바꾸기로 했다. 문맥이 섞이는 과정은 4·5교시에서 추적한다.
내 LLM: mylm/embed.py(init_tables, embed, cosine, nearest)를 채우고 check_03을 통과했다.
퇴근 무렵, 하은이 '배를 타고 섬으로 갔다'의 입력 행렬 9 × 128을 화면에 띄워 놓고 정 선임에게 묻습니다. "그럼 이 아홉 줄은 아직 서로를 모르는 거죠? '타고' 줄은 앞에 '배'가 있는 줄도 모르고요." "맞아. 다음 정거장에서 처음으로 줄끼리 서로를 들여다봐."
그때 보라 매니저가 새 티켓을 들고 옵니다. "이번엔 대화가 조금 길어지니까 문제래요. 손님이 처음에 책 제목을 말해 놓고 몇 마디 뒤에 '그 책 말고 비슷한 거요' 했더니, 챗봇이 무슨 책 말씀이냐고 되물었대요." 티켓 제목은 "앞에서 말한 책 제목을 잊어버려요". 4교시에서는 입력 행렬의 한 줄이 앞의 줄들 가운데 누구를 볼지 정하는 계산, 어텐션 점수를 칸칸이 따라갑니다.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.