본문 바로가기
TYLER SONGBlog
블로그 목록
LLM Research

선형대수 2: 행렬곱은 변환의 합성이고, SVD는 그 변환을 뜯어보는 도구다

행렬곱을 숫자 계산이 아니라 "공간을 변형시키는 두 동작을 순서대로 적용하는 것"으로 이해한다. rank로 행렬이 정보를 얼마나 담고 있는지 재고, SVD로 행렬을 분해해서 중요한 정보만 남기는 저차원 근사(low-rank approximation)를 실습한다. 이미지 압축 실험으로 rank와 복원 오차의 관계를 직접 눈으로 확인한다.

송민성8분 읽기

오늘의 목표

행렬곱이 단순한 숫자 곱셈이 아니라 "두 변환을 이어붙이는 것"이라는 감각을 잡고, 어떤 행렬이든 SVD라는 도구로 분해해서 중요한 부분만 남기는 저차원 근사를 할 수 있게 되는 것이 오늘 목표다.

개념과 직관

행렬곱은 변환의 합성이다

Day 앞에서 벡터를 "화살표" 혹은 "숫자 목록"으로 봤다면, 행렬(matrix)은 "벡터를 다른 벡터로 바꾸는 규칙"으로 보면 된다. 예를 들어 행렬 A를 벡터 x에 곱하면 Ax는 x를 회전시키거나 늘리거나 찌그러뜨린 새 벡터다.

그럼 두 행렬을 곱한 AB는 뭘까? "B라는 변환을 먼저 적용하고, 그 결과에 A라는 변환을 또 적용한다"는 뜻이다. 즉 (AB)x = A(Bx). 사진 편집에 비유하면, B는 "흑백 필터", A는 "확대 필터"라고 할 때, AB는 "먼저 흑백으로 만들고 그다음 확대하는 하나의 합성 필터"다.

여기서 왜 AB ≠ BA인지(교환법칙이 성립하지 않는 이유)가 자연스럽게 나온다. "먼저 확대하고 흑백으로 만드는 것"과 "먼저 흑백으로 만들고 확대하는 것"은 순서를 바꾸면 결과가 다를 수 있다. 필터를 적용하는 순서가 결과를 바꾸는 것처럼, 변환의 순서도 결과를 바꾼다.

행렬곱의 계산 규칙(텍스트 수식):

text
(AB)의 (i, j) 성분 = A의 i번째 행과 B의 j번째 열을 내적한 값
= 합_k ( A[i,k] * B[k,j] )

곱셈이 성립하려면 A의 열 개수와 B의 행 개수가 같아야 한다. 이게 안 맞으면 애초에 "이어붙일 수 없는 변환"이라서 계산 자체가 정의되지 않는다.

transpose (전치)

전치(transpose, 행과 열을 뒤바꾸는 연산)는 A의 (i,j) 성분을 (j,i) 자리로 옮기는 것이다. 표기는 A^T. 기하학적으로는 "행 공간과 열 공간을 맞바꾸는" 조작이라고 생각하면 된다. 나중에 나올 attention의 QK^T 계산에서도 전치가 그대로 등장한다(오늘은 그 내용까지 다루지 않고, 전치 연산 자체만 익힌다).

rank와 선형독립

rank(계수, 행렬이 가진 실제 독립적인 정보의 차원 수)는 "행렬의 행(또는 열)들 중에서 서로 다른 것들을 조합해서 만들어낼 수 없는, 진짜 독립적인 방향이 몇 개인가"를 뜻한다.

비유: 세 사람에게 각자 집 방향을 알려달라고 했는데, 한 명이 "나는 동쪽 3km"라 하고 다른 한 명이 "나는 동쪽 6km"라고 하면 이 둘은 사실 같은 방향(동쪽)을 가리키는 중복 정보다. 선형독립(linear independence, 다른 벡터들의 조합으로 만들 수 없는 새로운 방향)이 아닌 것이다. rank가 낮다는 건 행렬이 겉보기엔 숫자가 많아도 실제 담고 있는 "방향 정보"는 적다는 뜻이다.

예: 3x3 행렬인데 rank가 2라면, 이 행렬이 만들어내는 결과 벡터들은 전부 어떤 평면(2차원) 위에만 놓인다. 3차원 공간을 다 못 쓰고 납작하게 눌러버리는 셈이다.

SVD (특이값분해)

SVD(Singular Value Decomposition, 임의의 행렬을 회전·크기조절·회전 세 단계로 쪼개는 분해)는 어떤 행렬 A든 다음과 같이 쪼갤 수 있다는 정리다.

text
A = U Σ V^T
  • U: 정사각형 회전/반사 행렬 (열들이 서로 수직인 단위벡터)
  • Σ: 대각선에만 값이 있는 행렬. 이 값들을 특이값(singular value)이라 부르고, 큰 순서대로 정렬되어 있다.
  • V^T: 또 다른 회전/반사 행렬

직관적으로 "A라는 변환은 사실 회전 → 축별로 늘리거나 줄이기 → 다시 회전, 이 세 동작의 합성이다"라는 뜻이다. 특이값이 크다는 건 그 방향으로 변환이 크게 늘린다는 뜻이고, 특이값이 0에 가깝다는 건 그 방향의 정보가 거의 없어서 없애도 표가 잘 안 난다는 뜻이다.

low-rank 근사

특이값을 큰 것부터 k개만 남기고 나머지를 0으로 버리면, 원래 행렬 A를 rank k짜리 행렬로 근사할 수 있다. 이를 truncated SVD(잘라낸 SVD)라고 한다. 특이값이 작은 방향은 "거의 정보가 없는 방향"이라서 버려도 원본과 크게 다르지 않은 근사가 나온다. 이게 바로 이미지 압축, 그리고 나중에 나올 LoRA(Low-Rank Adaptation)의 핵심 아이디어다.

코드로 직접 해보기

1) 행렬곱을 직접 for문으로 만들어보고 NumPy와 비교

python
import numpy as np A = np.array([[1, 2], [3, 4], [5, 6]]) # 3x2 B = np.array([[1, 0, 1], [0, 1, 1]]) # 2x3 # 직접 구현 (원리 확인용, 실제로는 절대 이렇게 안 씀) def matmul_manual(A, B): n, k = A.shape k2, m = B.shape assert k == k2, "곱셈 불가: 안쪽 차원이 안 맞음" result = np.zeros((n, m)) for i in range(n): for j in range(m): for x in range(k): result[i, j] += A[i, x] * B[x, j] return result print(matmul_manual(A, B)) print(A @ B) # NumPy가 훨씬 빠르고 정확함

2) 교환법칙이 성립하지 않는 것 확인

python
C = np.array([[1, 2], [3, 4]]) D = np.array([[0, 1], [1, 0]]) print(C @ D) print(D @ C) # 결과가 다르다

3) rank 확인

python
E = np.array([[1, 2, 3], [2, 4, 6], # 첫 번째 행의 2배 -> 새 정보 없음 [0, 1, 1]]) print(np.linalg.matrix_rank(E)) # 2가 나옴 (3x3인데 rank는 2)

4) SVD와 truncated SVD로 이미지(또는 임의 행렬) 압축

python
import numpy as np np.random.seed(0) # 임의의 100x100 행렬을 "이미지"라고 가정 img = np.random.rand(100, 100) U, S, Vt = np.linalg.svd(img, full_matrices=False) print(U.shape, S.shape, Vt.shape) def truncated_svd_reconstruct(U, S, Vt, k): return U[:, :k] @ np.diag(S[:k]) @ Vt[:k, :] for k in [5, 20, 50]: approx = truncated_svd_reconstruct(U, S, Vt, k) error = np.linalg.norm(img - approx) / np.linalg.norm(img) # 상대 오차 print(f"rank={k}, 상대 복원 오차={error:.4f}")

오늘의 실험

rank를 5, 20, 50으로 바꿔가며 truncated SVD로 복원한 행렬과 원본의 오차를 비교한다.

  • 실험 대상: 가능하면 무작위 행렬 대신 실제 흑백 이미지(예: scipy.datasets.face()를 흑백 변환해서 사용하거나, 직접 준비한 이미지)를 써보면 오차가 눈으로도 확인된다.
  • 관찰 포인트:

- k가 커질수록 상대 오차가 어떻게 줄어드는지 (k vs 오차를 그래프로 그려보면 좋다 — matplotlib으로 plt.plot(k_list, error_list)) - 특이값 자체를 plt.plot(S)로 그려서, 특이값이 얼마나 빨리 0에 가까워지는지 확인 (특이값이 빨리 줄어드는 행렬일수록 낮은 rank로도 잘 압축된다) - 원본 이미지 크기(100x100=10000개 숫자)와 rank=20일 때 필요한 숫자 개수(U 일부 + S 일부 + Vt 일부)를 비교해서 "압축이 실제로 얼마나 되는지" 계산해보기

  • 기록: k, 오차, 필요한 저장 숫자 개수를 표로 정리해두면 다음에 LoRA를 배울 때 "왜 rank를 낮추면 파라미터가 줄어드는지" 바로 연결된다.

왜 이걸 배우나 (LLM / Post-training 연결)

신경망의 각 층은 결국 행렬곱의 연속이고, 학습된 가중치 행렬(weight matrix)도 결국 하나의 행렬이다. LoRA(Low-Rank Adaptation)는 오늘 배운 "큰 행렬을 낮은 rank로 근사해도 정보 손실이 크지 않다"는 바로 이 아이디어를 이용해서, 거대 LLM을 파인튜닝할 때 전체 가중치 대신 작은 저차원 행렬 두 개만 학습시켜 메모리와 연산을 크게 줄인다. Post-training 단계에서 효율적인 파인튜닝 기법을 이해하려면 오늘의 rank·SVD 감각이 필수 기반이 된다.

초보자가 막히는 지점

  • 차원이 안 맞는 에러: A @ B에서 ValueError: shapes ... not aligned 를 보면, A의 열 개수와 B의 행 개수가 같은지부터 확인한다. 헷갈리면 항상 A.shape, B.shape를 찍어본다.
  • AB와 BA를 같다고 착각: 스칼라(숫자) 곱셈에 익숙해서 행렬곱도 순서가 안 중요하다고 오해하기 쉽다. 순서를 바꾸면 차원 자체가 안 맞아 에러가 날 수도 있고, 차원이 맞아도 값이 달라진다는 걸 코드로 직접 확인해야 감이 잡힌다.
  • rank가 항상 정사각형 크기와 같다고 착각: n x n 행렬이라고 rank도 n인 건 아니다. 행/열 사이에 중복(선형종속)이 있으면 rank는 더 작다.
  • SVD의 U, S, Vt 차원 헷갈림: np.linalg.svd는 기본적으로 full_matrices=True라서 U, Vt가 정사각형으로 나온다. 오늘처럼 truncated 계산을 하려면 full_matrices=False를 꼭 켜야 차원이 깔끔하게 맞는다.
  • 특이값 S가 벡터로 나온다는 점: np.linalg.svd는 Σ를 대각행렬이 아니라 1차원 벡터로 반환한다. 행렬곱에 쓰려면 np.diag(S)로 변환해줘야 한다.

알고리즘 (병행 1시간)

LeetCode Easy 1문항 + Medium 1문항(수학 카테고리 위주로 선택 — 예: 최대공약수, 소수 판별, 행렬 관련 문제 등 오늘 배운 것과 무관해도 괜찮다).

풀이 후 반드시 기록할 것:

  • 접근 방법: 어떤 아이디어로 풀었는지 한두 문장
  • 시간복잡도: Big-O로 표현하고 왜 그렇게 되는지
  • 틀렸다면 이유: 엣지 케이스를 놓쳤는지, 자료구조 선택이 잘못됐는지, 인덱스 계산 실수인지 등 구체적으로 남겨둔다. 이 기록이 나중에 비슷한 실수를 반복하지 않게 해준다.
© 2026 Tyler Song