논문에 적힌 식을 그대로 코드로 옮겼는데 이런 게 뜹니다.
RuntimeError: mat1 and mat2 shapes cannot be multiplied (512x768 and 768x512)
식에는 분명히 라고 적혀 있었고, 그대로 W @ x라고 썼습니다. 그런데 안 됩니다. 순서를 바꿔 x @ W로 하면 되기도 하고 여전히 안 되기도 합니다. 이쯤 되면 셰이프를 하나씩 출력해 보며 맞을 때까지 전치를 붙였다 뗐다 하게 되는데, 그건 식을 읽은 게 아니라 맞춘 것입니다.
지난 글에서 어텐션 한 줄을 다섯 자리로 쪼개면서, 첫째 자리에 걸린 질문이 "가 벡터인가 행렬인가"였습니다. 이 글이 그 질문을 답합니다. 논문은 어떤 기호가 몇 개의 축을 가진 대상인지를 글자 모양으로 알립니다. 그 규약을 알면 셰이프 에러의 절반은 코드를 돌리기 전에 사라집니다.
글꼴이 곧 자료형이다
대부분의 머신러닝 논문은 다음 네 층을 글자 모양으로 구별합니다.
| 표기 | 대상 | 축 개수 | 코드에서의 셰이프 | AI에서의 예 |
|---|---|---|---|---|
| , , | 스칼라 | 0 | () |
학습률, 차원 수 |
| , | 벡터 | 1 | (768,) |
토큰 임베딩 하나 |
| , | 행렬 | 2 | (512, 768) |
문장 하나의 은닉 상태 |
| , | 텐서 | 3 이상 | (8, 512, 768) |
배치 전체의 은닉 상태 |
몇 가지 실전 요령이 있습니다.
굵게 쓰지 않는 논문도 많습니다. 특히 Attention Is All You Need처럼 굵은 글씨를 안 쓰는 논문에서는 가 대문자라는 것 하나로 행렬임을 알아야 합니다. 대문자는 거의 항상 행렬 이상이라고 보면 맞습니다. 소문자면 스칼라 아니면 벡터인데, 그 둘은 문맥과 첨자로 갈립니다 — 처럼 첨자가 붙어 있으면 보통 벡터들의 모임에서 하나를 꺼낸 것이라 벡터입니다.
필기체 는 텐서 말고 집합에도 씁니다. 가 데이터셋, 가 어휘 집합, 이 정규분포를 뜻하는 식입니다. 무엇인지는 그 기호가 처음 정의된 문장에서 확인해야 하고, 그 문장이 바로 Notation 절에 있습니다.
희랍 문자는 대개 학습 대상이 아닌 값입니다. (학습률), (모멘텀 계수), (수치 안정용 작은 수), (정규화 세기), (온도). 반면 와 는 예외적으로 "모델 파라미터 전부"를 한 글자로 묶은 것이고, 그래서 는 "파라미터가 인 모델이 주는 확률"로 읽습니다.
열벡터 관례 — 셰이프 에러의 진짜 원인
앞의 에러로 돌아갑니다. 가 코드에서 W @ x로 안 되는 이유는, 논문의 가 열벡터이기 때문입니다.
수학에서 아무 말 없이 라고 쓰면 그것은 짜리 세로 벡터입니다. 그래서 를 왼쪽에 곱하면 로 모양이 맞습니다.
그런데 코드에서 텐서 하나는 벡터 하나가 아니라 배치입니다. 토큰 512개를 한 번에 처리하니 x.shape == (512, 768)이고, 토큰 하나하나가 행으로 누워 있습니다. 행으로 누운 벡터에 변환을 걸려면 오른쪽에서 곱해야 합니다.
즉 논문의 는 코드에서 x @ W.T입니다. 그리고 PyTorch의 nn.Linear는 이 전치를 안에서 대신 해 줍니다 — nn.Linear(d, d')의 weight 셰이프가 (d', d)인 것이 그 증거입니다. 논문의 와 같은 모양으로 저장해 두고, forward에서 x @ self.weight.T를 합니다.
여기서 한 가지가 따라 나옵니다. 어텐션 식이 인 것도 이 관례 때문입니다. 와 가 둘 다 토큰을 행에 쌓은 이므로, 모든 쌍의 내적을 얻으려면 오른쪽 것을 전치해야 합니다. 만약 논문이 토큰을 열에 쌓는 관례를 택했다면 같은 계산이 로 적혔을 겁니다. 식이 달라 보여도 계산은 같습니다. 논문을 읽을 때 가장 먼저 확인할 것은 "이 논문은 토큰을 행에 쌓는가 열에 쌓는가"입니다.
위 첨자와 아래 첨자 — 인덱스인가 라벨인가
첨자는 두 가지 일을 합니다. 하나는 인덱스(몇 번째인가), 다른 하나는 라벨(어느 것인가). 둘을 구별하는 규칙은 대체로 이렇습니다.
| 표기 | 읽는 법 | 코드 대응 |
|---|---|---|
| 번째 원소 또는 번째 벡터 | x[i] |
|
| 행 열 원소 (스칼라) | X[i, j] |
|
| 번째 층의 은닉 상태 | 층 인덱스 — 거듭제곱이 아니다 | |
| 번째 데이터 샘플 | batch[i] |
|
| , | 질의용·키용 가중치 (라벨) | 서로 다른 파라미터 |
| 제곱 | x ** 2 |
|
| 전치 | X.T |
|
| 역행렬 | np.linalg.inv(A) |
가장 자주 헷갈리는 자리가 괄호 친 위 첨자입니다. 의 은 거듭제곱이 아니라 층 번호입니다. 괄호가 있으면 인덱스, 없으면 거듭제곱 — 이것이 관례고, 그래서 논문에서 같은 식이 자연스럽게 읽힙니다. 반면 아래 첨자에 붙은 알파벳 라벨(의 )은 세지 않습니다. 그건 이름의 일부입니다.
, , 도 같은 이치입니다. 에 를 곱한 것이 아니라, "키 벡터의 차원"이라는 이름 하나입니다.
원소와 슬라이스 — 콜론의 자리
한 축 전체를 가져오는 표기는 콜론입니다. 논문에서도 코드에서도 같습니다.
| 표기 | 뜻 | 셰이프 | 코드 |
|---|---|---|---|
| 원소 하나 | 스칼라 | X[i, j] |
|
| 번째 행 전체 | X[i, :] |
||
| 번째 열 전체 | X[:, j] |
||
| 배치 의 번 토큰 | h[b, t, :] |
콜론을 생략하는 논문도 많습니다. 라고만 써 놓고 그것이 번째 행을 뜻하는 경우가 흔한데, 앞에서 본 행 우선 관례 때문입니다. 토큰을 행에 쌓았으니 "번째 것"은 자연히 번째 행입니다.
여기서 읽기의 핵심은 셰이프가 줄어드는가 유지되는가입니다. 숫자 인덱스는 그 축을 없애고, 콜론은 그 축을 남깁니다. 는 세 축 중 둘이 사라져 벡터 하나가 되고, 는 한 축만 사라져 행렬이 됩니다. 이 감각이 있으면 식을 보고 결과의 셰이프를 미리 말할 수 있습니다.
연산 기호 다섯 개
논문에서 자주 나오면서 코드 연산자와 헷갈리는 기호들입니다.
— 아다마르 곱(원소별 곱). 같은 셰이프 두 개를 원소끼리 곱합니다. 행렬곱이 아닙니다.
코드로는 A * B입니다. LSTM의 게이트, 드롭아웃 마스크, LayerNorm의 스케일 파라미터 가 전부 이 기호입니다. NumPy에서 *가 행렬곱이 아니라 원소별 곱인 이유가 여기 있습니다 — 행렬곱은 @로 따로 뺐습니다.
— 외적 또는 크로네커 곱. 벡터 두 개로 행렬을 만듭니다. , 일 때 는 행렬이고 입니다. 내적이 축을 없애 스칼라를 만든다면 외적은 축을 더해 행렬을 만듭니다. 코드로는 np.outer(a, b) 또는 a[:, None] * b[None, :]입니다.
— 노름. 벡터의 크기입니다. 아래 첨자로 어떤 노름인지 밝힙니다: 는 유클리드 길이, 은 절댓값의 합. 아래 첨자가 없으면 대개 입니다. 그래디언트 클리핑, 가중치 감쇠, 코사인 유사도의 분모가 전부 이 기호이고, 다음 글이 이것만 다룹니다.
— 내적. 는 나 와 같은 것을 다르게 쓴 것입니다. 세 표기가 논문마다 섞여 나오는데 전부 같은 계산입니다.
— 기댓값. 아래 첨자에 무엇에 대한 평균인지를 적습니다. 는 "에서 뽑은 에 대해 의 평균". 코드에서는 거의 항상 배치 평균, 즉 f(x).mean()으로 구현됩니다. 이 기호가 4단원부터 계속 나옵니다.
f: ℝⁿ → ℝᵐ 을 읽기
함수를 소개하는 한 줄이 논문 곳곳에 있습니다.
"는 차원 실수 벡터를 받아 차원 실수 벡터를 내놓는다"로 읽습니다. 화살표 왼쪽이 정의역(무엇을 받는가), 오른쪽이 공역(어디에 값이 놓이는가)입니다. 코드로는 함수의 입력 셰이프와 출력 셰이프를 한 줄로 선언한 것이고, 사실상 타입 힌트입니다.
여기서 자주 놓치는 구별이 공역과 상입니다. 공역은 "값이 놓일 수 있는 공간"이고, 상(image)은 "실제로 나오는 값들의 모임"입니다. 둘은 다를 수 있고, 그 차이가 의미를 가지는 자리가 있습니다.
softmax가 그렇습니다.
공역은 전체지만, 실제로 나오는 값은 "모든 성분이 양수이고 합이 1인 벡터"뿐입니다. 그 부분집합을 확률 단체(simplex)라 부르고 로 씁니다. 그래서 더 정확한 표기는 이렇습니다.
이 구별이 실무에서 의미를 갖는 이유는, softmax의 출력이 절대 도달하지 못하는 값이 있기 때문입니다. 성분이 정확히 0이나 정확히 1인 벡터는 상에 없습니다 — 는 어떤 실수 에 대해서도 0이 되지 않으니까요. 지난 글에서 본 마스킹이 를 넣는 이유가 여기 있습니다. 유한한 값으로는 가중치를 정확히 0으로 만들 수 없고, 극한으로 밀어야만 0이 됩니다. 그리고 실제 구현이 대신 -1e9를 쓰는 순간 그 0은 "아주 작은 양수"가 됩니다.
손으로 따라가기 — LayerNorm 식을 셰이프까지 읽기
지금까지의 규약으로 실제 식 하나를 끝까지 읽어 봅니다. LayerNorm입니다.
기호를 하나씩 분류합니다.
| 기호 | 무엇인가 | 셰이프 | 근거 |
|---|---|---|---|
| 벡터 (토큰 하나의 은닉) | (d,) |
소문자에 첨자로 원소를 꺼냄 | |
| 스칼라 | () |
숫자 인덱스가 축을 없앰 | |
| , | 스칼라 | () |
개를 합쳐 하나로 |
| 스칼라 상수 | () |
희랍 문자, 학습 대상 아님 | |
| , | 벡터 (학습 파라미터) | (d,) |
의 상대이므로 와 같은 셰이프 |
| 원소별 곱 | — | 행렬곱이면 셰이프가 안 맞음 |
가 벡터라는 것은 식 어디에도 안 적혀 있습니다. 가 원소별 곱이고 상대가 (d,)이므로 도 (d,)여야 한다는 것으로 알아냅니다. 이렇게 연산자가 셰이프를 강제하는 관계를 따라가는 것이 수식 읽기의 실제 작업입니다.
이제 배치로 확장합니다. 코드에서 x.shape == (B, T, d)라면 어느 축으로 평균을 낼까요? 식의 가 답입니다. 에 대해서만 더하므로 마지막 축입니다.
import numpy as np
B, T, d = 2, 4, 8
rng = np.random.default_rng(0)
x = rng.normal(size=(B, T, d))
gamma = rng.normal(size=(d,))
beta = rng.normal(size=(d,))
eps = 1e-5
mu = x.mean(axis=-1, keepdims=True) # (B, T, 1)
var = x.var(axis=-1, keepdims=True) # (B, T, 1)
out = gamma * (x - mu) / np.sqrt(var + eps) + beta
print(out.shape) # (2, 4, 8)
print(np.allclose(((x - mu) / np.sqrt(var + eps)).mean(-1), 0, atol=1e-12))
print(np.allclose(((x - mu) / np.sqrt(var + eps)).std(-1), 1, atol=1e-4))
keepdims=True가 필요한 이유도 표기에서 나옵니다. 는 스칼라지만 에서는 개 성분 전부에서 빼야 합니다. 수식은 이 반복을 안 적고 넘어가지만 — 스칼라를 벡터에서 빼는 것을 당연하게 봅니다 — 코드에서는 축을 남겨 두어야 브로드캐스팅이 그 반복을 대신합니다. 논문 식이 생략한 것을 코드가 명시해야 하는 대표적인 자리입니다.
다시 그 에러로
처음의 에러를 다시 봅니다.
RuntimeError: mat1 and mat2 shapes cannot be multiplied (512x768 and 768x512)
이제 이 메시지는 버그가 아니라 관례 충돌로 읽힙니다. 논문의 는 로 정의됐고 코드의 x는 토큰을 행에 쌓은 입니다. 를 그대로 옮기면 축이 안 맞고, 옳은 번역은 x @ W.T입니다. 셰이프를 출력해 가며 전치를 붙였다 뗐다 할 필요가 없습니다 — 식을 보고 미리 알 수 있습니다.
이 글의 규약으로 아직 못 읽는 것이 하나 남아 있습니다. 같은 합 기호에서, 어떤 첨자가 결과에 남고 어떤 첨자가 합쳐져 사라지는지입니다. LayerNorm에서는 가 사라지고 남는 축이 없어 스칼라가 됐지만, 처럼 첨자가 셋 이상 얽히면 눈으로 따라가기 어렵습니다. 다음 글이 그 규칙을 정하고, 그것을 einsum 한 줄로 옮기는 방법까지 갑니다.
지난 글: 어텐션 식 한 줄에 들어 있는 수학 전부: 이 커리큘럼의 지도
다음 글: Σ와 첨자, 그리고 einsum: 수식을 그대로 코드로 옮기기
읽어주셔서 감사합니다. 😊

