개념
텐서(Tensor)란 무엇인가
텐서(tensor)는 균일한 데이터 타입(uniform data type)을 가진 다차원 배열(multi-dimensional array)입니다. 차원(dimension) 수를 계수(rank) 또는 차수(order)라고 부릅니다. 각 차원은 축(axis)입니다. 형태(shape)는 각 축의 크기(size)를 나열한 튜플(tuple)입니다.
graph LR
S["Scalar<br/>rank 0<br/>shape: ()"] --> V["Vector<br/>rank 1<br/>shape: (3,)"]
V --> M["Matrix<br/>rank 2<br/>shape: (2,3)"]
M --> T3["3D Tensor<br/>rank 3<br/>shape: (2,2,2)"]
T3 --> T4["4D Tensor<br/>rank 4<br/>shape: (B,C,H,W)"]
전체 원소 수(total elements)는 모든 크기의 곱(product)입니다. 형태 (2, 3, 4)는 2 * 3 * 4 = 24개 원소(element)를 가집니다.
딥러닝(Deep Learning)에서의 텐서 형태
데이터 타입마다 관례적인 텐서 형태가 있습니다.
graph TD
subgraph Vision
V1["(B, C, H, W)<br/>32, 3, 224, 224"]
end
subgraph NLP
N1["(B, T, D)<br/>16, 128, 768"]
end
subgraph Attention
A1["(B, H, T, D)<br/>16, 12, 128, 64"]
end
subgraph Weights
W1["Linear: (out, in)<br/>Conv2D: (out_c, in_c, kH, kW)<br/>Embedding: (vocab, dim)"]
end
PyTorch는 NCHW, 즉 채널 우선(channels-first)을 사용합니다. TensorFlow는 기본적으로 NHWC, 즉 채널 후행(channels-last)을 사용합니다. 메모리 배치(layout)가 일치하지 않으면 조용한 성능 저하(slowdown)나 오류가 발생합니다.
메모리 배치(Memory Layout)와 스트라이드(Stride)
2차원 배열도 메모리에서는 1차원 바이트(byte) 시퀀스입니다. 스트라이드(stride)는 각 축에서 한 칸 움직일 때 몇 개의 원소를 건너뛰어야 하는지 알려 줍니다.
graph LR
subgraph "Row-major (C order)"
R["a b c d e f<br/>strides: (3, 1)"]
end
subgraph "Column-major (F order)"
C["a d b e c f<br/>strides: (1, 2)"]
end
전치(transpose)는 데이터를 옮기지 않습니다. 스트라이드를 맞바꿀 뿐입니다. 그래서 텐서가 비연속(non-contiguous) 상태가 됩니다. 한 행의 원소가 메모리에서 더 이상 인접하지 않게 됩니다.
브로드캐스팅 규칙(Broadcasting Rule)
브로드캐스팅은 서로 다른 형태의 텐서를 데이터 복사 없이 함께 연산하게 해 줍니다. 형태를 오른쪽에서부터 맞춥니다. 두 차원은 값이 같거나 둘 중 하나가 1이면 호환(compatible)됩니다. 차원 수가 부족하면 왼쪽에 1을 채워(pad) 넣습니다.
Tensor A: (8, 1, 6, 1)
Tensor B: (7, 1, 5)
Padded B: (1, 7, 1, 5)
Result: (8, 7, 6, 5)
아인슈타인 표기법(Einsum): 보편 텐서 연산(Universal Tensor Operation)
아인슈타인 표기법(Einsum, Einstein summation)은 각 축에 문자를 붙입니다. 입력(input)에는 있지만 출력(output)에는 없는 축은 합산되어(summed) 사라집니다. 양쪽 모두에 있는 축은 유지됩니다.
graph LR
subgraph "matmul: ik,kj -> ij"
A["A(I,K)"] --> |"sum over k"| C["C(I,J)"]
B["B(K,J)"] --> |"sum over k"| C
end
주요 패턴(pattern):
i,i->: 내적(dot product)
i,j->ij: 외적(outer product)
ii->: 대각합(trace)
ij->ji: 전치(transpose)
bij,bjk->bik: 배치 행렬곱(batch matmul)
bhtd,bhsd->bhts: 어텐션 스코어(attention score)
직접 만들기
코드는 code/tensors.py에 있습니다. 각 단계는 해당 구현을 참조합니다.
단계 1: 텐서 저장소(Tensor Storage)와 스트라이드
텐서는 평탄한(flat) 리스트와 형태 메타데이터(metadata)를 저장합니다. 스트라이드는 다차원 인덱스(multi-dimensional index)를 평탄한 위치(flat position)로 매핑합니다.
class Tensor:
def __init__(self, data, shape=None):
if isinstance(data, (list, tuple)):
self._data, self._shape = self._flatten_nested(data)
elif isinstance(data, np.ndarray):
self._data = data.flatten().tolist()
self._shape = tuple(data.shape)
else:
self._data = [data]
self._shape = ()
if shape is not None:
total = reduce(lambda a, b: a * b, shape, 1)
if total != len(self._data):
raise ValueError(
f"Cannot reshape {len(self._data)} elements into shape {shape}"
)
self._shape = tuple(shape)
self._strides = self._compute_strides(self._shape)
형태 (3, 4)의 스트라이드는 (4, 1)입니다. 행(row)을 한 칸 이동하려면 4개 원소를, 열(column)을 한 칸 이동하려면 1개 원소를 건너뜁니다.
단계 2: 형태 변경(reshape), 축 제거(squeeze), 축 삽입(unsqueeze)
형태 변경(reshape)은 원소 순서를 바꾸지 않고 형태만 바꿉니다. 전체 원소 수는 같아야 합니다. -1은 해당 차원의 크기를 자동으로 추론(infer)하라는 뜻입니다.
t = Tensor(list(range(12)), shape=(2, 6))
r = t.reshape((3, 4))
r = t.reshape((-1, 3))
축 제거(squeeze)는 크기 1인 축을 제거합니다. 축 삽입(unsqueeze)은 축을 하나 추가합니다. 축 삽입은 브로드캐스팅에 매우 중요합니다. 편향 벡터(bias vector) (D,)를 배치 (B, T, D)에 더하려면 (1, 1, D)로 축 삽입해야 합니다.
t = Tensor(list(range(6)), shape=(1, 3, 1, 2))
s = t.squeeze()
v = Tensor([1, 2, 3])
u = v.unsqueeze(0)
단계 3: 전치(transpose)와 순열 변경(permute)
전치(transpose)는 두 축을 맞바꿉니다. 순열 변경(permute)은 모든 축의 순서를 바꿉니다. NCHW와 NHWC 사이를 변환할 때 사용합니다.
mat = Tensor(list(range(6)), shape=(2, 3))
tr = mat.transpose(0, 1)
t4d = Tensor(list(range(24)), shape=(1, 2, 3, 4))
perm = t4d.permute((0, 2, 3, 1))
전치나 순열 변경 후 텐서는 메모리에서 비연속(non-contiguous) 상태가 됩니다. PyTorch에서 view는 비연속 텐서에 대해 실패합니다. reshape를 사용하거나 먼저 .contiguous()를 호출해야 합니다.
단계 4: 원소별 연산(Element-wise Operation)과 축약(Reduction)
원소별 연산(덧셈, 곱셈, 뺄셈)은 각 원소에 독립적으로 적용되고 형태를 보존합니다. 축약 연산(reduction; 합, 평균, 최댓값)은 하나 이상의 축을 합쳐(collapse) 없앱니다.
a = Tensor([[1, 2], [3, 4]])
b = Tensor([[10, 20], [30, 40]])
c = a + b
d = a * 2
s = a.sum(axis=0)
합성곱 신경망(CNN)의 전역 평균 풀링(global average pooling)은 (B, C, H, W).mean(axis=[2, 3])으로 (B, C)를 만듭니다. 자연어 처리(NLP)의 시퀀스 평균 풀링(sequence mean pooling)은 (B, T, D).mean(axis=1)로 (B, D)를 만듭니다.
단계 5: NumPy 브로드캐스팅(Broadcasting)
tensors.py의 demo_broadcasting_numpy()는 핵심 패턴을 보여 줍니다.
activations = np.random.randn(4, 3)
bias = np.array([0.1, 0.2, 0.3])
result = activations + bias
images = np.random.randn(2, 3, 4, 4)
scale = np.array([0.5, 1.0, 1.5]).reshape(1, 3, 1, 1)
result = images * scale
a = np.array([1, 2, 3]).reshape(-1, 1)
b = np.array([10, 20, 30, 40]).reshape(1, -1)
outer = a * b
쌍별 거리(pairwise distance)도 브로드캐스팅으로 계산합니다. (M, 2)를 (M, 1, 2)로, (N, 2)를 (1, N, 2)로 형태 변경한 뒤 빼고(subtract), 제곱하고(square), 마지막 축에 대해 합산(sum)한 다음 제곱근(square root)을 취하면 결과는 (M, N)이 됩니다.
단계 6: 아인슈타인 표기법(Einsum) 연산
demo_einsum()과 demo_einsum_gallery()는 자주 사용하는 패턴을 순서대로 보여 줍니다.
a = np.array([1.0, 2.0, 3.0])
b = np.array([4.0, 5.0, 6.0])
dot = np.einsum("i,i->", a, b)
A = np.array([[1, 2], [3, 4], [5, 6]], dtype=float)
B = np.array([[7, 8, 9], [10, 11, 12]], dtype=float)
matmul = np.einsum("ik,kj->ij", A, B)
batch_A = np.random.randn(4, 3, 5)
batch_B = np.random.randn(4, 5, 2)
batch_mm = np.einsum("bij,bjk->bik", batch_A, batch_B)
축약(contraction) 연산 비용은 유지되는 인덱스와 합산되는 인덱스 크기의 곱입니다. bij,bjk->bik에서 B=32, I=128, J=64, K=128이면 32 * 128 * 64 * 128 = 33,554,432번의 곱셈-누산(multiply-add)이 필요합니다.
단계 7: 아인슈타인 표기법으로 어텐션 메커니즘(Attention Mechanism) 구현
demo_attention_einsum()은 멀티헤드 어텐션을 끝까지 구현합니다.
B, H, T, D = 2, 4, 8, 16
E = H * D
X = np.random.randn(B, T, E)
W_q = np.random.randn(E, E) * 0.02
Q = np.einsum("bte,ek->btk", X, W_q)
Q = Q.reshape(B, T, H, D).transpose(0, 2, 1, 3)
scores = np.einsum("bhtd,bhsd->bhts", Q, K) / np.sqrt(D)
weights = softmax(scores, axis=-1)
attn_output = np.einsum("bhts,bhsd->bhtd", weights, V)
concat = attn_output.transpose(0, 2, 1, 3).reshape(B, T, E)
output = np.einsum("bte,ek->btk", concat, W_o)
모든 단계가 텐서 연산입니다. 사영(projection)은 아인슈타인 표기법으로 표현한 행렬곱이고, 헤드 분리(head split)는 형태 변경과 전치, 어텐션 스코어는 아인슈타인 표기법 기반의 배치 행렬곱, 가중합(weighted sum)도 아인슈타인 표기법 기반의 배치 행렬곱, 헤드 결합(head merge)은 전치와 형태 변경, 출력 사영(output projection)도 아인슈타인 표기법으로 표현한 행렬곱입니다.
사용해보기
직접 구현 vs NumPy
| 연산 | 직접 구현(Tensor 클래스) | NumPy |
|---|
| 생성 | Tensor([[1,2],[3,4]]) | np.array([[1,2],[3,4]]) |
| 형태 변경 | t.reshape((3,4)) | a.reshape(3,4) |
| 전치 | t.transpose(0,1) | a.T 또는 a.transpose(0,1) |
| 축 제거 | t.squeeze(0) | np.squeeze(a, 0) |
| 합산 | t.sum(axis=0) | a.sum(axis=0) |
| 아인슈타인 표기법 | N/A | np.einsum("ij,jk->ik", a, b) |
직접 구현 vs PyTorch
import torch
t = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.float32)
t.shape
t.stride()
t.is_contiguous()
t.reshape(3, 2)
t.unsqueeze(0)
t.transpose(0, 1)
t.transpose(0, 1).contiguous()
torch.einsum("ik,kj->ij", A, B)
PyTorch는 자동 미분(autograd), GPU 지원, 최적화된 BLAS 커널(kernel)을 추가로 제공합니다. 형태 의미론(shape semantics)은 동일합니다. 직접 구현한 버전을 이해하면 PyTorch의 형태 오류 메시지를 읽어낼 수 있습니다.
인공신경망 계층(layer)을 텐서 연산으로 보기
| 연산 | 텐서 형태 | 아인슈타인 표기법 |
|---|
| 선형 계층(linear layer) | Y = X @ W.T + b | "bd,od->bo" + 편향(bias) |
| 어텐션 QKV | Q = X @ W_q | "btd,dh->bth" |
| 어텐션 스코어 | Q @ K.T / sqrt(d) | "bhtd,bhsd->bhts" |
| 어텐션 출력 | softmax(scores) @ V | "bhts,bhsd->bhtd" |
| 배치 정규화(batch norm) | (X - mu) / sigma * gamma | 원소별 연산 + 브로드캐스트 |
| 소프트맥스(softmax) | exp(x) / sum(exp(x)) | 원소별 연산 + 축약 |
산출물 만들기
이 lesson의 검수 대상은 아래 세 가지입니다.
code/tensors.py: 직접 구현한 텐서 클래스, 브로드캐스팅, 아인슈타인 표기법, 어텐션 형태 시연
outputs/prompt-tensor-shapes.md: 텐서 형태 불일치(shape mismatch)를 진단하고 자주 쓰이는 연산별 해결책을 찾는 프롬프트
outputs/prompt-tensor-debugger.md: 형태 오류 메시지와 텐서 형태를 넣어 단계별(step-by-step) 해결책을 받아 보는 프롬프트
연습문제
- 쉬움 -- 형태 변경 왕복(reshape round-trip). 형태
(2, 3, 4)인 텐서를 (6, 4), (24,), 다시 (2, 3, 4)로 형태 변경합니다. 각 단계에서 평탄한 데이터를 출력(print)해 원소 순서가 보존되는지 확인합니다.
- 중간 -- 브로드캐스팅 구현.
Tensor 클래스에 목표 형태(target shape)에 맞춰 크기 1인 차원을 확장(expand)하는 broadcast_to(shape) 메서드(method)를 추가합니다. 그런 다음 _elementwise_op가 자동 브로드캐스팅을 수행하도록 수정합니다. 형태 (3, 1)과 (1, 4)가 (3, 4)를 만드는지 시험(test)합니다.
- 어려움 -- 아인슈타인 표기법 직접 구현. 내적(
i,i->), 행렬곱(ij,jk->ik), 외적(i,j->ij), 전치(ij->ji)를 최소한 처리하는 einsum(subscripts, *tensors) 함수를 구현합니다. 첨자 문자열(subscript string)을 파싱(parse)하고 축약되는 인덱스를 찾아 모든 인덱스 조합을 순회(loop)합니다. 결과를 np.einsum과 비교합니다.
- 어려움 -- 어텐션 형태 추적기(attention shape tracker).
batch_size, seq_len, embed_dim, num_heads를 받아 멀티헤드 어텐션의 각 단계 형태를 출력하는 함수를 작성합니다. 입력, Q/K/V 사영, 헤드 분리, 어텐션 스코어, 소프트맥스 가중치, 가중합, 헤드 결합, 출력 사영을 모두 확인합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 텐서(Tensor) | 차원이 더 많은 행렬 | 균일한 타입, 정해진 형태, 스트라이드, 연산을 가진 다차원 배열 |
| 계수(Rank) | 차원 수 | 축의 개수. 행렬의 rank(계급)와는 다르며, 행렬 텐서는 계수 2다 |
| 형태(Shape) | 텐서의 크기 | 각 축의 크기를 나열한 튜플. (2, 3)은 행 2개, 열 3개를 뜻한다 |
| 스트라이드(Stride) | 메모리 배치 | 각 축에서 한 칸 이동하기 위해 건너뛰어야 하는 원소 수 |
| 브로드캐스팅(Broadcasting) | 형태가 달라도 연산이 됨 | 오른쪽부터 정렬하고 차원이 같거나 하나가 1이어야 하는 엄격한 규칙 |
| 연속(Contiguous) | 텐서가 정상 배치임 | 논리적 배치와 메모리 저장 순서가 일치해 빈틈이나 재배열이 없는 상태 |
| 아인슈타인 표기법(Einsum) | 행렬곱을 멋지게 쓰는 법 | 텐서 축약, 외적, 대각합, 전치를 한 줄로 표현하는 일반 표기법 |
| 뷰(View) | 형태 변경(reshape)과 같아 보임 | 같은 메모리 버퍼를 공유하면서 형태/스트라이드 메타데이터만 다른 텐서. 비연속 데이터에서는 실패한다 |
| 축약(Contraction) | 인덱스에 대해 합산 | 텐서 사이 공유 인덱스를 곱하고 합산해 더 낮은 계수의 결과를 만드는 연산 |
| NCHW / NHWC | PyTorch와 TensorFlow의 형식 차이 | 이미지 텐서 메모리 배치 관례. NCHW는 채널을 공간 차원 앞에, NHWC는 뒤에 둔다 |
더 읽을거리