벡터, 행렬과 연산
모든 신경망은 몇 단계를 더 얹은 행렬 곱셈입니다.
유형: Build
언어: Python, Julia
선수 지식: Phase 1, Lesson 01 (선형대수 직관)
예상 시간: 약 60분
학습 목표
- 원소별 연산(element-wise operation), 행렬 곱셈(matrix multiplication), 전치(transpose), 행렬식(determinant), 역행렬(inverse)을 포함한
Matrix class를 만듭니다.
- 원소별 곱셈(element-wise multiplication)과 행렬 곱셈(matrix multiplication)을 구분하고, 각각 언제 쓰이는지 설명합니다.
- 직접 만든
Matrix class만 사용해 dense neural network layer 하나, 즉 relu(W @ x + b)를 구현합니다.
- 브로드캐스팅 규칙(broadcasting rule)과 신경망 프레임워크(neural network framework)에서 편향 더하기(bias addition)가 어떻게 동작하는지 설명합니다.
문제
신경망을 만들고 싶다고 합시다. 코드를 읽다가 다음 줄을 봅니다.
output = activation(weights @ input + bias)
여기서 @는 행렬 곱셈입니다. weights는 행렬이고, input은 벡터입니다. 이 연산들이 무엇을 하는지 모르면 이 줄은 마법처럼 보입니다. 하지만 알고 나면 층(layer)의 전체 순전파(forward pass)가 세 가지 연산으로 표현된 것임을 알 수 있습니다.
모델이 처리하는 모든 이미지는 픽셀 값으로 된 행렬입니다. 모든 단어 임베딩(word embedding)은 벡터입니다. 모든 신경망의 모든 층은 행렬 변환입니다. 변수를 이해하지 못하면 코드를 쓸 수 없듯이, 행렬 연산에 익숙하지 않으면 AI 시스템을 만들 수 없습니다.
이 강의에서는 그 익숙함을 직접 구현하면서 만듭니다.
개념
벡터: 순서가 있는 숫자 목록
벡터는 방향과 크기를 가진 숫자 목록입니다. AI에서 벡터는 데이터 점(data point), 특징(feature), 파라미터(parameter)를 나타냅니다.
v = [3, 4] -- 2D vector
w = [1, 0, -2] -- 3D vector
2D 벡터 [3, 4]는 평면의 좌표 (3, 4)를 가리킵니다. 길이, 즉 크기(magnitude)는 5입니다. 3-4-5 삼각형입니다.
행렬: 숫자 격자
행렬은 2차원 격자(grid)입니다. 행(row)과 열(column)이 있습니다. m x n 행렬은 행이 m개, 열이 n개입니다.
A = | 1 2 3 | -- 2x3 matrix (2 rows, 3 columns)
| 4 5 6 |
신경망에서 가중치 행렬(weight matrix)은 입력 벡터를 출력 벡터로 변환합니다. 입력이 784개이고 출력이 128개인 층은 128x784 가중치 행렬을 사용합니다.
형태(shape)가 중요한 이유
행렬 곱셈에는 엄격한 규칙이 있습니다. (m x n) @ (n x p) = (m x p)입니다. 안쪽 차원(dimension)이 반드시 맞아야 합니다.
(128 x 784) @ (784 x 1) = (128 x 1)
weights input output
Inner dimensions: 784 = 784 -- valid
PyTorch에서 shape mismatch error를 만나면 대부분 이 규칙 때문입니다.
연산 지도
| 연산 | 하는 일 | 신경망에서의 사용 |
|---|
| Addition | 같은 위치의 element를 더함 | Output에 bias 더하기 |
| Scalar multiply | 모든 element를 scale | Learning rate * gradient |
| Matrix multiply | 벡터를 변환 | Layer forward pass |
| Transpose | Row와 column을 뒤집음 | Backpropagation |
| 행렬식(Determinant) | 행렬을 요약하는 하나의 숫자 | Invertibility 확인 |
| Inverse | 변환을 되돌림 | Linear system 풀기 |
| Identity | 아무것도 하지 않는 행렬 | Initialization, residual connection |
원소별 곱셈(element-wise multiplication)과 행렬 곱셈(matrix multiplication)
초보자가 자주 헷갈리는 지점입니다.
Element-wise multiplication은 같은 위치끼리 곱합니다. 두 행렬은 같은 shape이어야 합니다.
| 1 2 | | 5 6 | | 5 12 |
| 3 4 | * | 7 8 | = | 21 32 |
Matrix multiplication은 row와 column의 내적입니다. 안쪽 dimension이 맞아야 합니다.
| 1 2 | | 5 6 | | 1*5+2*7 1*6+2*8 | | 19 22 |
| 3 4 | @ | 7 8 | = | 3*5+4*7 3*6+4*8 | = | 43 50 |
서로 다른 연산이고, 결과도 다르며, 규칙도 다릅니다.
브로드캐스팅(Broadcasting)
Output matrix에 bias vector를 더할 때 shape가 딱 맞지 않을 수 있습니다. 브로드캐스팅(Broadcasting)은 더 작은 array를 필요한 모양으로 늘려 맞춥니다.
| 1 2 3 | + [10, 20, 30]
| 4 5 6 |
브로드캐스팅은 벡터를 row 방향으로 늘립니다.
| 1 2 3 | | 10 20 30 | | 11 22 33 |
| 4 5 6 | + | 10 20 30 | = | 14 25 36 |
현대 framework는 이를 자동으로 처리합니다. 브로드캐스팅을 이해하면 shape가 이상해 보이는데도 코드가 실행되는 상황을 이해할 수 있습니다.
직접 만들기
Step 1: Vector class
class Vector:
def __init__(self, data):
self.data = list(data)
self.size = len(self.data)
def __repr__(self):
return f"Vector({self.data})"
def __add__(self, other):
return Vector([a + b for a, b in zip(self.data, other.data)])
def __sub__(self, other):
return Vector([a - b for a, b in zip(self.data, other.data)])
def __mul__(self, scalar):
return Vector([x * scalar for x in self.data])
def dot(self, other):
return sum(a * b for a, b in zip(self.data, other.data))
def magnitude(self):
return sum(x ** 2 for x in self.data) ** 0.5
Step 2: 핵심 연산이 있는 Matrix class
class Matrix:
def __init__(self, data):
self.data = [list(row) for row in data]
self.rows = len(self.data)
self.cols = len(self.data[0])
self.shape = (self.rows, self.cols)
def __repr__(self):
rows_str = "\n ".join(str(row) for row in self.data)
return f"Matrix({self.shape}):\n {rows_str}"
def __add__(self, other):
return Matrix([
[self.data[i][j] + other.data[i][j] for j in range(self.cols)]
for i in range(self.rows)
])
def __sub__(self, other):
return Matrix([
[self.data[i][j] - other.data[i][j] for j in range(self.cols)]
for i in range(self.rows)
])
def scalar_multiply(self, scalar):
return Matrix([
[self.data[i][j] * scalar for j in range(self.cols)]
for i in range(self.rows)
])
def element_wise_multiply(self, other):
return Matrix([
[self.data[i][j] * other.data[i][j] for j in range(self.cols)]
for i in range(self.rows)
])
def matmul(self, other):
return Matrix([
[
sum(self.data[i][k] * other.data[k][j] for k in range(self.cols))
for j in range(other.cols)
]
for i in range(self.rows)
])
def transpose(self):
return Matrix([
[self.data[j][i] for j in range(self.rows)]
for i in range(self.cols)
])
def determinant(self):
if self.shape == (1, 1):
return self.data[0][0]
if self.shape == (2, 2):
return self.data[0][0] * self.data[1][1] - self.data[0][1] * self.data[1][0]
det = 0
for j in range(self.cols):
minor = Matrix([
[self.data[i][k] for k in range(self.cols) if k != j]
for i in range(1, self.rows)
])
det += ((-1) ** j) * self.data[0][j] * minor.determinant()
return det
def inverse_2x2(self):
det = self.determinant()
if det == 0:
raise ValueError("행렬이 singular라 inverse가 없습니다")
return Matrix([
[self.data[1][1] / det, -self.data[0][1] / det],
[-self.data[1][0] / det, self.data[0][0] / det]
])
@staticmethod
def identity(n):
return Matrix([
[1 if i == j else 0 for j in range(n)]
for i in range(n)
])
Step 3: 동작 확인
A = Matrix([[1, 2], [3, 4]])
B = Matrix([[5, 6], [7, 8]])
print("A + B =", (A + B).data)
print("A @ B =", A.matmul(B).data)
print("A^T =", A.transpose().data)
print("det(A) =", A.determinant())
print("A^-1 =", A.inverse_2x2().data)
I = Matrix.identity(2)
print("A @ A^-1 =", A.matmul(A.inverse_2x2()).data)
Step 4: Neural network와 연결하기
import random
inputs = Matrix([[0.5], [0.8], [0.2]])
weights = Matrix([
[random.uniform(-1, 1) for _ in range(3)]
for _ in range(2)
])
bias = Matrix([[0.1], [0.1]])
def relu_matrix(m):
return Matrix([[max(0, val) for val in row] for row in m.data])
pre_activation = weights.matmul(inputs) + bias
output = relu_matrix(pre_activation)
print(f"입력 shape: {inputs.shape}")
print(f"Weight shape: {weights.shape}")
print(f"출력 shape: {output.shape}")
print(f"출력: {output.data}")
이것이 하나의 dense layer입니다. output = relu(W @ x + b). 모든 신경망의 모든 dense layer는 정확히 이 일을 합니다.
사용해보기
NumPy는 위의 모든 일을 훨씬 적은 줄로, 훨씬 빠르게 처리합니다.
import numpy as np
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
print("A + B =\n", A + B)
print("A * B (element-wise multiplication) =\n", A * B)
print("A @ B (matrix multiplication) =\n", A @ B)
print("A^T =\n", A.T)
print("det(A) =", np.linalg.det(A))
print("A^-1 =\n", np.linalg.inv(A))
print("I =\n", np.eye(2))
inputs = np.random.randn(3, 1)
weights = np.random.randn(2, 3)
bias = np.array([[0.1], [0.1]])
output = np.maximum(0, weights @ inputs + bias)
print(f"\n신경망 layer: {weights.shape} @ {inputs.shape} = {output.shape}")
print(f"출력:\n{output}")
Python의 @ 연산자는 __matmul__을 호출합니다. NumPy는 C와 Fortran으로 작성된 최적화된 BLAS 루틴(routine)으로 이를 구현합니다. 같은 수학이지만 100배 이상 빠를 수 있습니다.
NumPy에서 브로드캐스팅(broadcasting)은 다음처럼 동작합니다.
matrix = np.array([[1, 2, 3], [4, 5, 6]])
bias = np.array([10, 20, 30])
print(matrix + bias)
NumPy는 1D 편향을 두 행 전체에 자동으로 브로드캐스트합니다. 모든 신경망 프레임워크에서 편향 더하기가 이런 방식으로 동작합니다.
산출물 만들기
이 강의의 산출물은 행렬 연산을 기하학적 직관으로 설명하도록 돕는 프롬프트입니다.
outputs/prompt-matrix-operations.md
또한 여기서 만든 Matrix class는 Phase 3, Lesson 10에서 만들 간단한 인공신경망 프레임워크(mini neural network framework)의 기반이 됩니다.
연습문제
- 역행렬 검증:
A @ A.inverse_2x2()를 곱해 항등행렬(identity matrix)이 나오는지 확인합니다. 서로 다른 2x2 행렬 세 개로 시도합니다. 행렬식이 0이면 어떤 일이 일어나는지 확인합니다.
- 3x3 역행렬 구현: 수반행렬(adjugate) 방법을 사용해 3x3 행렬 역행렬을 계산하도록
Matrix 클래스를 확장합니다. NumPy의 np.linalg.inv와 비교해 테스트합니다.
- 2층(two-layer) 신경망 만들기: NumPy 없이 직접 만든
Matrix 클래스만 사용해 input (3) -> hidden (4) -> output (2) 구조의 2층 신경망을 만듭니다. 무작위 가중치를 초기화하고 순전파를 실행한 뒤 모든 형태가 맞는지 확인합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 벡터(Vector) | 화살표 | 순서가 있는 숫자 목록입니다. AI에서는 고차원 공간(high-dimensional space)의 점입니다. |
| 행렬(Matrix) | 숫자 표 | 선형 변환(linear transformation)입니다. 벡터를 한 공간에서 다른 공간으로 매핑합니다. |
| 행렬 곱셈(Matrix multiplication) | 그냥 숫자를 곱하는 것 | 첫 번째 행렬의 각 행과 두 번째 행렬의 각 열 사이의 내적입니다. 순서가 중요합니다. |
| 전치(Transpose) | 뒤집기 | 행과 열을 바꿉니다. m x n 행렬을 n x m으로 바꾸며 역전파(backpropagation)에서 중요합니다. |
| 행렬식(Determinant) | 행렬에서 나오는 어떤 숫자 | 행렬이 면적(2D)이나 부피(3D)를 얼마나 스케일링하는지 나타냅니다. 0이면 변환이 한 차원을 눌러 없앤다는 뜻입니다. |
| 역행렬(Inverse) | 행렬을 되돌리는 것 | 변환을 되돌리는 행렬입니다. 행렬식이 0이 아닐 때만 존재합니다. |
| 항등행렬(Identity matrix) | 지루한 행렬 | 숫자 1을 곱하는 것과 같은 행렬입니다. 잔차 연결(residual connection, ResNet)에도 쓰입니다. |
| 브로드캐스팅(Broadcasting) | 마법 같은 형태 수정 | 작은 배열(array)을 누락된 차원(missing dimension) 방향으로 반복해 큰 배열에 맞추는 규칙입니다. |
| 원소별 연산(Element-wise) | 일반적인 곱셈 | 같은 위치끼리 곱합니다. 두 배열은 같은 형태이거나 브로드캐스트 가능해야 합니다. |
더 읽을거리