수학

MATH / 중급 2번

논문 수식 표기 규약: 스칼라·벡터·행렬·텐서를 구별해서 읽기

굵은 글씨와 대문자, 위·아래 첨자, 슬라이스와 ⊙·⊗·‖·‖가 각각 무엇을 뜻하는지 정리하고, 논문의 기호를 코드의 텐서 축에 하나씩 맞춰 봅니다.

PALDYN Team19 MIN READ

논문에 적힌 식을 그대로 코드로 옮겼는데 이런 게 뜹니다.

RuntimeError: mat1 and mat2 shapes cannot be multiplied (512x768 and 768x512)

식에는 분명히 y=Wxy = Wx라고 적혀 있었고, 그대로 W @ x라고 썼습니다. 그런데 안 됩니다. 순서를 바꿔 x @ W로 하면 되기도 하고 여전히 안 되기도 합니다. 이쯤 되면 셰이프를 하나씩 출력해 보며 맞을 때까지 전치를 붙였다 뗐다 하게 되는데, 그건 식을 읽은 게 아니라 맞춘 것입니다.

지난 글에서 어텐션 한 줄을 다섯 자리로 쪼개면서, 첫째 자리에 걸린 질문이 "QQ가 벡터인가 행렬인가"였습니다. 이 글이 그 질문을 답합니다. 논문은 어떤 기호가 몇 개의 축을 가진 대상인지를 글자 모양으로 알립니다. 그 규약을 알면 셰이프 에러의 절반은 코드를 돌리기 전에 사라집니다.

글꼴이 곧 자료형이다

대부분의 머신러닝 논문은 다음 네 층을 글자 모양으로 구별합니다.

스칼라·벡터·행렬·텐서의 표기 사다리

표기 대상 축 개수 코드에서의 셰이프 AI에서의 예
dd, nn, α\alpha 스칼라 0 () 학습률, 차원 수
x\mathbf{x}, q\mathbf{q} 벡터 1 (768,) 토큰 임베딩 하나
X\mathbf{X}, W\mathbf{W} 행렬 2 (512, 768) 문장 하나의 은닉 상태
X\mathcal{X}, T\mathcal{T} 텐서 3 이상 (8, 512, 768) 배치 전체의 은닉 상태

몇 가지 실전 요령이 있습니다.

굵게 쓰지 않는 논문도 많습니다. 특히 Attention Is All You Need처럼 굵은 글씨를 안 쓰는 논문에서는 QQ가 대문자라는 것 하나로 행렬임을 알아야 합니다. 대문자는 거의 항상 행렬 이상이라고 보면 맞습니다. 소문자면 스칼라 아니면 벡터인데, 그 둘은 문맥과 첨자로 갈립니다 — qiq_i처럼 첨자가 붙어 있으면 보통 벡터들의 모임에서 하나를 꺼낸 것이라 벡터입니다.

필기체 X\mathcal{X}는 텐서 말고 집합에도 씁니다. D\mathcal{D}가 데이터셋, V\mathcal{V}가 어휘 집합, N(0,1)\mathcal{N}(0, 1)이 정규분포를 뜻하는 식입니다. 무엇인지는 그 기호가 처음 정의된 문장에서 확인해야 하고, 그 문장이 바로 Notation 절에 있습니다.

희랍 문자는 대개 학습 대상이 아닌 값입니다. α\alpha(학습률), β\beta(모멘텀 계수), ϵ\epsilon(수치 안정용 작은 수), λ\lambda(정규화 세기), τ\tau(온도). 반면 θ\theta와 ϕ\phi는 예외적으로 "모델 파라미터 전부"를 한 글자로 묶은 것이고, 그래서 pθp_\theta는 "파라미터가 θ\theta인 모델이 주는 확률"로 읽습니다.

열벡터 관례 — 셰이프 에러의 진짜 원인

앞의 에러로 돌아갑니다. y=Wxy = Wx가 코드에서 W @ x로 안 되는 이유는, 논문의 xx가 열벡터이기 때문입니다.

열벡터 관례와 행 우선 코드

수학에서 아무 말 없이 x∈Rdx \in \mathbb{R}^d라고 쓰면 그것은 d×1d \times 1짜리 세로 벡터입니다. 그래서 W∈Rd′×dW \in \mathbb{R}^{d' \times d}를 왼쪽에 곱하면 (d′×d)(d×1)=d′×1(d' \times d)(d \times 1) = d' \times 1로 모양이 맞습니다.

W⏟d′×d  x⏟d×1=y⏟d′×1\underbrace{W}_{d' \times d} \; \underbrace{x}_{d \times 1} = \underbrace{y}_{d' \times 1}

그런데 코드에서 텐서 하나는 벡터 하나가 아니라 배치입니다. 토큰 512개를 한 번에 처리하니 x.shape == (512, 768)이고, 토큰 하나하나가 행으로 누워 있습니다. 행으로 누운 벡터에 변환을 걸려면 오른쪽에서 곱해야 합니다.

X⏟n×d  W⊤⏟d×d′=Y⏟n×d′\underbrace{X}_{n \times d} \; \underbrace{W^\top}_{d \times d'} = \underbrace{Y}_{n \times d'}

열벡터 관례와 행 우선 관례

즉 논문의 WxWx는 코드에서 x @ W.T입니다. 그리고 PyTorch의 nn.Linear는 이 전치를 안에서 대신 해 줍니다 — nn.Linear(d, d')의 weight 셰이프가 (d', d)인 것이 그 증거입니다. 논문의 WW와 같은 모양으로 저장해 두고, forward에서 x @ self.weight.T를 합니다.

여기서 한 가지가 따라 나옵니다. 어텐션 식이 QK⊤QK^\top인 것도 이 관례 때문입니다. QQ와 KK가 둘 다 토큰을 행에 쌓은 n×dkn \times d_k이므로, 모든 쌍의 내적을 얻으려면 오른쪽 것을 전치해야 합니다. 만약 논문이 토큰을 열에 쌓는 관례를 택했다면 같은 계산이 Q⊤KQ^\top K로 적혔을 겁니다. 식이 달라 보여도 계산은 같습니다. 논문을 읽을 때 가장 먼저 확인할 것은 "이 논문은 토큰을 행에 쌓는가 열에 쌓는가"입니다.

위 첨자와 아래 첨자 — 인덱스인가 라벨인가

첨자는 두 가지 일을 합니다. 하나는 인덱스(몇 번째인가), 다른 하나는 라벨(어느 것인가). 둘을 구별하는 규칙은 대체로 이렇습니다.

표기 읽는 법 코드 대응
xix_i ii번째 원소 또는 ii번째 벡터 x[i]
XijX_{ij} ii행 jj열 원소 (스칼라) X[i, j]
h(l)h^{(l)} ll번째 층의 은닉 상태 층 인덱스 — 거듭제곱이 아니다
x(i)x^{(i)} ii번째 데이터 샘플 batch[i]
WQW_Q, WKW_K 질의용·키용 가중치 (라벨) 서로 다른 파라미터
x2x^2 제곱 x ** 2
X⊤X^\top 전치 X.T
A−1A^{-1} 역행렬 np.linalg.inv(A)

가장 자주 헷갈리는 자리가 괄호 친 위 첨자입니다. h(l)h^{(l)}의 (l)(l)은 거듭제곱이 아니라 층 번호입니다. 괄호가 있으면 인덱스, 없으면 거듭제곱 — 이것이 관례고, 그래서 논문에서 h(l+1)=f(h(l))h^{(l+1)} = f(h^{(l)}) 같은 식이 자연스럽게 읽힙니다. 반면 아래 첨자에 붙은 알파벳 라벨(WQW_Q의 QQ)은 세지 않습니다. 그건 이름의 일부입니다.

dkd_k, dvd_v, dmodeld_{\text{model}}도 같은 이치입니다. dd에 kk를 곱한 것이 아니라, "키 벡터의 차원"이라는 이름 하나입니다.

원소와 슬라이스 — 콜론의 자리

한 축 전체를 가져오는 표기는 콜론입니다. 논문에서도 코드에서도 같습니다.

표기 뜻 셰이프 코드
XijX_{ij} 원소 하나 스칼라 X[i, j]
Xi,:X_{i,:} ii번째 행 전체 (d,)(d,) X[i, :]
X:,jX_{:,j} jj번째 열 전체 (n,)(n,) X[:, j]
Hb,t,:\mathcal{H}_{b,t,:} 배치 bb의 tt번 토큰 (d,)(d,) h[b, t, :]

콜론을 생략하는 논문도 많습니다. XiX_i라고만 써 놓고 그것이 ii번째 행을 뜻하는 경우가 흔한데, 앞에서 본 행 우선 관례 때문입니다. 토큰을 행에 쌓았으니 "ii번째 것"은 자연히 ii번째 행입니다.

여기서 읽기의 핵심은 셰이프가 줄어드는가 유지되는가입니다. 숫자 인덱스는 그 축을 없애고, 콜론은 그 축을 남깁니다. Hb,t,:\mathcal{H}_{b,t,:}는 세 축 중 둘이 사라져 벡터 하나가 되고, Hb,:,:\mathcal{H}_{b,:,:}는 한 축만 사라져 행렬이 됩니다. 이 감각이 있으면 식을 보고 결과의 셰이프를 미리 말할 수 있습니다.

연산 기호 다섯 개

논문에서 자주 나오면서 코드 연산자와 헷갈리는 기호들입니다.

⊙\odot — 아다마르 곱(원소별 곱). 같은 셰이프 두 개를 원소끼리 곱합니다. 행렬곱이 아닙니다.

(A⊙B)ij=AijBij(A \odot B)_{ij} = A_{ij} B_{ij}

코드로는 A * B입니다. LSTM의 게이트, 드롭아웃 마스크, LayerNorm의 스케일 파라미터 γ⊙x^\gamma \odot \hat{x}가 전부 이 기호입니다. NumPy에서 *가 행렬곱이 아니라 원소별 곱인 이유가 여기 있습니다 — 행렬곱은 @로 따로 뺐습니다.

⊗\otimes — 외적 또는 크로네커 곱. 벡터 두 개로 행렬을 만듭니다. a∈Rma \in \mathbb{R}^m, b∈Rnb \in \mathbb{R}^n일 때 a⊗ba \otimes b는 m×nm \times n 행렬이고 (a⊗b)ij=aibj(a \otimes b)_{ij} = a_i b_j입니다. 내적이 축을 없애 스칼라를 만든다면 외적은 축을 더해 행렬을 만듭니다. 코드로는 np.outer(a, b) 또는 a[:, None] * b[None, :]입니다.

∥⋅∥\lVert \cdot \rVert — 노름. 벡터의 크기입니다. 아래 첨자로 어떤 노름인지 밝힙니다: ∥x∥2\lVert x \rVert_2는 유클리드 길이, ∥x∥1\lVert x \rVert_1은 절댓값의 합. 아래 첨자가 없으면 대개 ∥x∥2\lVert x \rVert_2입니다. 그래디언트 클리핑, 가중치 감쇠, 코사인 유사도의 분모가 전부 이 기호이고, 다음 글이 이것만 다룹니다.

⟨⋅,⋅⟩\langle \cdot, \cdot \rangle — 내적. ⟨a,b⟩\langle a, b \rangle는 a⋅ba \cdot b나 a⊤ba^\top b와 같은 것을 다르게 쓴 것입니다. 세 표기가 논문마다 섞여 나오는데 전부 같은 계산입니다.

E\mathbb{E} — 기댓값. 아래 첨자에 무엇에 대한 평균인지를 적습니다. Ex∼p[f(x)]\mathbb{E}_{x \sim p}[f(x)]는 "pp에서 뽑은 xx에 대해 f(x)f(x)의 평균". 코드에서는 거의 항상 배치 평균, 즉 f(x).mean()으로 구현됩니다. 이 기호가 4단원부터 계속 나옵니다.

f: ℝⁿ → ℝᵐ 을 읽기

함수를 소개하는 한 줄이 논문 곳곳에 있습니다.

f:Rn→Rmf: \mathbb{R}^{n} \rightarrow \mathbb{R}^{m}

"ff는 nn차원 실수 벡터를 받아 mm차원 실수 벡터를 내놓는다"로 읽습니다. 화살표 왼쪽이 정의역(무엇을 받는가), 오른쪽이 공역(어디에 값이 놓이는가)입니다. 코드로는 함수의 입력 셰이프와 출력 셰이프를 한 줄로 선언한 것이고, 사실상 타입 힌트입니다.

여기서 자주 놓치는 구별이 공역과 상입니다. 공역은 "값이 놓일 수 있는 공간"이고, 상(image)은 "실제로 나오는 값들의 모임"입니다. 둘은 다를 수 있고, 그 차이가 의미를 가지는 자리가 있습니다.

softmax가 그렇습니다.

softmax:Rn→Rn\mathrm{softmax}: \mathbb{R}^{n} \rightarrow \mathbb{R}^{n}

공역은 Rn\mathbb{R}^n 전체지만, 실제로 나오는 값은 "모든 성분이 양수이고 합이 1인 벡터"뿐입니다. 그 부분집합을 확률 단체(simplex)라 부르고 Δn−1\Delta^{n-1}로 씁니다. 그래서 더 정확한 표기는 이렇습니다.

softmax:Rn→Δn−1\mathrm{softmax}: \mathbb{R}^{n} \rightarrow \Delta^{n-1}

이 구별이 실무에서 의미를 갖는 이유는, softmax의 출력이 절대 도달하지 못하는 값이 있기 때문입니다. 성분이 정확히 0이나 정확히 1인 벡터는 상에 없습니다 — eze^{z}는 어떤 실수 zz에 대해서도 0이 되지 않으니까요. 지난 글에서 본 마스킹이 −∞-\infty를 넣는 이유가 여기 있습니다. 유한한 값으로는 가중치를 정확히 0으로 만들 수 없고, 극한으로 밀어야만 0이 됩니다. 그리고 실제 구현이 −∞-\infty 대신 -1e9를 쓰는 순간 그 0은 "아주 작은 양수"가 됩니다.

손으로 따라가기 — LayerNorm 식을 셰이프까지 읽기

지금까지의 규약으로 실제 식 하나를 끝까지 읽어 봅니다. LayerNorm입니다.

LN(x)=γ⊙x−μσ2+ϵ+β\mathrm{LN}(x) = \gamma \odot \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}} + \beta

μ=1d∑i=1dxi,σ2=1d∑i=1d(xi−μ)2\mu = \frac{1}{d}\sum_{i=1}^{d} x_i, \qquad \sigma^2 = \frac{1}{d}\sum_{i=1}^{d} (x_i - \mu)^2

기호를 하나씩 분류합니다.

기호 무엇인가 셰이프 근거
xx 벡터 (토큰 하나의 은닉) (d,) 소문자에 첨자로 원소를 꺼냄
xix_i 스칼라 () 숫자 인덱스가 축을 없앰
μ\mu, σ2\sigma^2 스칼라 () dd개를 합쳐 하나로
ϵ\epsilon 스칼라 상수 () 희랍 문자, 학습 대상 아님
γ\gamma, β\beta 벡터 (학습 파라미터) (d,) ⊙\odot의 상대이므로 xx와 같은 셰이프
⊙\odot 원소별 곱 — 행렬곱이면 셰이프가 안 맞음

γ\gamma가 벡터라는 것은 식 어디에도 안 적혀 있습니다. ⊙\odot가 원소별 곱이고 상대가 (d,)이므로 γ\gamma도 (d,)여야 한다는 것으로 알아냅니다. 이렇게 연산자가 셰이프를 강제하는 관계를 따라가는 것이 수식 읽기의 실제 작업입니다.

이제 배치로 확장합니다. 코드에서 x.shape == (B, T, d)라면 어느 축으로 평균을 낼까요? 식의 ∑i=1d\sum_{i=1}^{d}가 답입니다. dd에 대해서만 더하므로 마지막 축입니다.

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가 필요한 이유도 표기에서 나옵니다. μ\mu는 스칼라지만 x−μx - \mu에서는 dd개 성분 전부에서 빼야 합니다. 수식은 이 반복을 안 적고 넘어가지만 — 스칼라를 벡터에서 빼는 것을 당연하게 봅니다 — 코드에서는 축을 남겨 두어야 브로드캐스팅이 그 반복을 대신합니다. 논문 식이 생략한 것을 코드가 명시해야 하는 대표적인 자리입니다.

다시 그 에러로

처음의 에러를 다시 봅니다.

RuntimeError: mat1 and mat2 shapes cannot be multiplied (512x768 and 768x512)

이제 이 메시지는 버그가 아니라 관례 충돌로 읽힙니다. 논문의 WW는 d′×dd' \times d로 정의됐고 코드의 x는 토큰을 행에 쌓은 n×dn \times d입니다. WxWx를 그대로 옮기면 축이 안 맞고, 옳은 번역은 x @ W.T입니다. 셰이프를 출력해 가며 전치를 붙였다 뗐다 할 필요가 없습니다 — 식을 보고 미리 알 수 있습니다.

이 글의 규약으로 아직 못 읽는 것이 하나 남아 있습니다. ∑i=1d\sum_{i=1}^{d} 같은 합 기호에서, 어떤 첨자가 결과에 남고 어떤 첨자가 합쳐져 사라지는지입니다. LayerNorm에서는 ii가 사라지고 남는 축이 없어 스칼라가 됐지만, QK⊤QK^\top처럼 첨자가 셋 이상 얽히면 눈으로 따라가기 어렵습니다. 다음 글이 그 규칙을 정하고, 그것을 einsum 한 줄로 옮기는 방법까지 갑니다.


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

LATEST

수학의 최신 글

수학2026.09.07

양자화 오차: 격자 사상, 오차 분산, 이상치 채널

실수를 2^b개 격자에 사상할 때 오차의 분산이 왜 Δ²/12인지 유도하고, 그것이 비트당 6.02dB라는 SNR로 번역되는 과정을 실측과 대조했습니다. 이상치 하나가 나머지 값의 유효 비트를 어떻게 먹는지, 그리고 int4에서 성능이 무너지는 지점을 오차 예산으로 미리 계산하는 법까지.

중급18 MIN
수학2026.09.07

수치적으로 안정한 계산 패턴 모음

최댓값 빼기, 로그 공간, log1p·expm1, 분산의 두 공식, 정규화의 ε, fp32 누산, 역행렬 대신 solve — 프레임워크가 몰래 해 주는 일곱 가지를 하나씩 꺼내 각각 어떤 고장을 막는지 직접 재 봤습니다. 수식을 그대로 옮긴 코드가 왜 라이브러리보다 나쁜지에 대한 목록입니다.

중급22 MIN
수학2026.09.07

부동소수점은 어디서 새는가: 반올림, 상쇄, 더하는 순서

0.1 + 0.2가 0.3이 아닌 이유부터 시작해 머신 엡실론을 유도하고, 같은 16비트인데 fp16과 bf16이 서로 다른 지점에서 터지는 이유, 비슷한 수를 뺄 때 유효자리가 사라지는 파괴적 상쇄, 그리고 1,000만 개를 순서만 바꿔 더했을 때 오차가 백만 배 갈리는 실험까지 직접 재 봤습니다.

중급23 MIN