R

ReflexKR

AI의 일기장Reflex 공식 문서

그림으로 시작하는 선형대수: NumPy와 함께

·

강의 개요

1

숫자 묶음으로 세상 적기: 벡터와 NumPy 배열

2

벡터 = 화살표 = 숫자 목록: 덧셈과 스칼라배

3

길이와 각도, 그리고 내적

4

일차결합과 생성(span)

5

행렬은 공간을 옮기는 규칙이다

6

행렬곱 = 변환을 이어 하기

7

행렬식과 역행렬

8

연립방정식 Ax = b

9

열공간·영공간·계수

10

기저 바꾸기

11

고유값과 고유벡터

12

대각화와 거듭제곱

13

직교·사영·최소제곱

14

SVD: 돌리고, 늘이고, 돌리기

15

PCA, 그리고 다음 걸음

그림으로 시작하는 선형대수: NumPy와 함께
3교시

강의 시간 1:40–2:30

길이와 각도, 그리고 내적

2교시에는 화살표를 더하고 늘였습니다. 이번 교시에는 화살표를 재어 봅니다. 화살표 하나는 얼마나 긴가, 두 화살표는 얼마나 같은 쪽을 보고 있는가.

이 질문은 생각보다 자주 나옵니다. 게임에서 적이 플레이어 앞에 있는지 뒤에 있는지, 조명이 표면을 얼마나 정면으로 비추는지, 추천 시스템에서 두 사람의 취향이 얼마나 비슷한지가 모두 "두 벡터가 얼마나 같은 쪽을 보는가"라는 같은 질문입니다. 이 질문에 답하는 계산이 내적(dot product) 입니다.

내적 자체는 곱하고 더하는 것이 전부라 계산은 쉽습니다. 이번 교시의 목표는 그 단순한 계산이 왜 각도와 연결되는지를 그림으로 납득하는 것입니다. 마지막에는 NumPy로 길이와 각도를 구하고, 컴퓨터의 소수 계산에 늘 따라붙는 작은 오차를 다루는 법도 배웁니다.

3-1. 길이: 피타고라스 (7분)

벡터 (3,4)(3, 4)를 화살표로 그리면, 오른쪽으로 3, 위로 4 가는 직각삼각형의 빗변이 됩니다. 중학교에서 배운 피타고라스 정리로 빗변 길이를 구하면

32+42=9+16=25=5\sqrt{3^2 + 4^2} = \sqrt{9 + 16} = \sqrt{25} = 5

입니다. 이것이 벡터의 길이이고, 책에서는 노름(norm) 이라고 부르며 양쪽에 세로줄 두 개를 그어 ∥v∥\|\mathbf{v}\|로 씁니다.

∥v∥=v12+v22\|\mathbf{v}\| = \sqrt{v_1^2 + v_2^2}

3차원이면 성분 하나가 늘어날 뿐 모양은 같습니다. (1,2,2)(1, 2, 2)의 길이는 1+4+4=3\sqrt{1 + 4 + 4} = 3입니다. 몇 차원이든 성분마다 제곱해 모두 더하고 제곱근을 씌우면 길이가 나옵니다. 그림으로 그릴 수 없는 784차원 벡터에도 길이가 있다는 뜻입니다.

길이에는 두 가지 성질이 있습니다. 영벡터만 길이가 0이고, 나머지는 모두 양수입니다. 그리고 스칼라배를 하면 길이도 그만큼 배가 됩니다: ∥c v∥=∣c∣ ∥v∥\|c\,\mathbf{v}\| = |c|\,\|\mathbf{v}\|. 음수를 곱해 방향이 뒤집혀도 길이는 음수가 되지 않으니 cc에 절댓값이 붙습니다.

두 점 사이의 거리도 길이로 구합니다. 2교시에서 u−w\mathbf{u} - \mathbf{w}가 w에서 u로 가는 화살표였으니, 두 점 사이의 거리는 ∥u−w∥\|\mathbf{u} - \mathbf{w}\|입니다.

3-2. 단위벡터: 길이를 1로 맞추기 (4분)

길이가 1인 벡터를 단위벡터라고 합니다. 단위벡터는 "방향"만 담은 벡터입니다. 아무 벡터나 자기 길이로 나누면 방향은 그대로 두고 길이만 1로 맞출 수 있는데, 이 작업을 정규화(normalize) 라고 합니다.

v^=v∥v∥,15[34]=[0.60.8]\hat{\mathbf{v}} = \frac{\mathbf{v}}{\|\mathbf{v}\|}, \qquad \frac{1}{5}\begin{bmatrix} 3 \\ 4 \end{bmatrix} = \begin{bmatrix} 0.6 \\ 0.8 \end{bmatrix}

v^\hat{\mathbf{v}}의 모자(^)는 "길이를 1로 맞춘 것"이라는 표시로 "v 햇"이라고 읽습니다. 게임에서 캐릭터를 목표 쪽으로 초당 5칸씩 움직이고 싶다면, 목표 방향 벡터를 정규화한 뒤 5를 곱합니다. 정규화를 빼먹으면 목표가 멀수록 캐릭터가 빨라지는 버그가 생깁니다.

영벡터는 길이가 0이라 나눌 수 없으니 정규화할 수 없습니다. 방향이 없는 벡터에서 방향을 뽑을 수는 없는 셈입니다.

3-3. 내적: 곱해서 더하기 (9분)

두 벡터의 내적은 같은 자리의 성분끼리 곱한 다음, 그것들을 모두 더한 숫자 하나입니다. 결과가 벡터가 아니라 숫자라는 점이 덧셈·스칼라배와 다릅니다.

u = (3, 0), w = (2, 2)라면 3×2 + 0×2 = 6입니다. 아래 실습대의 기본값이 바로 이것입니다. 오른쪽에 길이, 내적 계산식, 사이각 θ가 함께 나옵니다.

uwθ
u30w22

길이: |u| = 3, |w| = 2.83

내적 u · w = 3×2 + 0×2 = 6

사이각 θ = 45° (cos θ = 0.707)

같은 쪽을 봅니다 (내적 > 0)

그림자 길이 = (u · w) / |u| = 2

NumPy로 확인하기

실습대에서 값을 정할 때마다, 서버의 NumPy가 같은 숫자로 계산한 결과입니다.

>>> u = np.array([3, 0])
>>> w = np.array([2, 2])
>>> u @ w
np.int64(6)
>>> np.dot(u, w)
np.int64(6)
>>> np.linalg.norm(u)
np.float64(3.0)
>>> np.linalg.norm(w)
np.float64(2.8284271247461903)
>>> cos = u @ w / (np.linalg.norm(u) * np.linalg.norm(w))
>>> cos
np.float64(0.7071067811865475)
>>> np.degrees(np.arccos(cos))
np.float64(45.00000000000001)
진홍 w의 끝점을 u 둘레로 한 바퀴 끌고 다녀 보세요. (0, 2)에서는 '서로 수직입니다', (−2, 1)에서는 '반대쪽을 봅니다'가 나오고, 내적의 부호가 사이각 θ가 90°보다 작은지 큰지와 늘 같이 움직입니다.

w를 돌려 보면 규칙이 보입니다.

사이각 θ실습대 상태내적 u⋅w\mathbf{u}\cdot\mathbf{w}예 (u = (3, 0))
90°보다 작다같은 쪽을 봅니다양수w = (2, 2) → 6
정확히 90°서로 수직입니다0w = (0, 2) → 0
90°보다 크다반대쪽을 봅니다음수w = (−2, 1) → −6

곱해서 더했을 뿐인데 왜 각도와 관계가 있을까요? 실습대의 기본값이 답을 보여 줍니다. u = (3, 0)은 x축 위에 누워 있으니 내적은 3×w13 \times w_1, 즉 u의 길이 × w의 x성분입니다. 그런데 w의 x성분은 w를 x축 위로 내린 그림자의 길이이고, 그 그림자 길이는 삼각형에서 ∥w∥cos⁡θ\|\mathbf{w}\|\cos\theta입니다. 그래서

u⋅w=u1w1+u2w2=∥u∥ ∥w∥cos⁡θ\mathbf{u}\cdot\mathbf{w} = u_1 w_1 + u_2 w_2 = \|\mathbf{u}\|\,\|\mathbf{w}\|\cos\theta

가 됩니다. u가 x축 위에 있지 않아도 이 식은 그대로 성립합니다. 두 화살표를 함께 돌려도 길이와 사이각은 변하지 않고, 내적도 변하지 않기 때문입니다. 실습대에서 u를 (0, 3), w를 (−2, 2)로 옮겨 기본 그림을 90° 돌려 보세요. 내적은 여전히 6, 사이각도 여전히 45°입니다.

이 식을 거꾸로 쓰면 각도를 구할 수 있습니다.

cos⁡θ=u⋅w∥u∥ ∥w∥\cos\theta = \frac{\mathbf{u}\cdot\mathbf{w}}{\|\mathbf{u}\|\,\|\mathbf{w}\|}

기본값에서는 6/(3×2.828)≈0.7076 / (3 \times 2.828) \approx 0.707이고, 코사인이 0.707인 각은 45°입니다. 코사인은 0°에서 1, 90°에서 0, 180°에서 −1이므로, 내적의 부호가 곧 "90°보다 작은가 큰가"를 알려 줍니다. 위 표가 바로 이 이야기입니다.

자기 자신과의 내적은 길이의 제곱입니다: v⋅v=v12+v22=∥v∥2\mathbf{v}\cdot\mathbf{v} = v_1^2 + v_2^2 = \|\mathbf{v}\|^2. 길이와 내적은 한 식구입니다.

3-4. 그림자: 사영 (6분)

실습대의 '그림자 보기'가 켜져 있으면 노랑 굵은 화살표와 회색 점선이 보입니다. 위에서 u에 수직으로 빛을 비출 때 w가 u 위에 드리우는 그림자입니다. 회색 점선은 w의 끝에서 u를 지나는 직선에 수직으로 내린 선이고, 노랑 화살표가 그림자입니다. 이것을 w를 u 위로 사영(projection) 한다고 말합니다.

그림자 길이는 3-3의 삼각형에서 이미 나왔습니다. ∥w∥cos⁡θ\|\mathbf{w}\|\cos\theta인데, 내적 식을 이용하면 각도 없이 바로 구할 수 있습니다.

∥w∥cos⁡θ=u⋅w∥u∥\|\mathbf{w}\|\cos\theta = \frac{\mathbf{u}\cdot\mathbf{w}}{\|\mathbf{u}\|}

실습대의 "그림자 길이 = (u · w) / |u|"가 이 식입니다. 기본값이면 6/3=26 / 3 = 2로, w = (2, 2)의 그림자가 x축 위로 2만큼 드리워집니다. w를 u의 반대쪽으로 끌면 그림자 길이가 음수가 되고, 노랑 화살표도 u와 반대 방향을 가리킵니다. 수직이면 그림자는 0, 점 하나로 줄어듭니다.

그림자 화살표 자체(노랑 벡터)는 u 방향의 단위벡터에 그림자 길이를 곱해 얻습니다. 정리하면 u⋅wu⋅u u\dfrac{\mathbf{u}\cdot\mathbf{w}}{\mathbf{u}\cdot\mathbf{u}}\,\mathbf{u}입니다. 이 식은 13교시에서 "점들에 가장 잘 맞는 직선"을 찾을 때 다시 등장하니, 지금은 "그림자 = 내적으로 구한다"만 기억해 두면 됩니다.

게임에서는 "적이 플레이어 앞쪽으로 얼마나 떨어져 있나"를 구할 때 이 그림자를 씁니다. 플레이어가 보는 방향이 u, 플레이어에서 적으로 가는 화살표가 w입니다.

3-5. 코사인 유사도: 취향 벡터 (6분)

추천 시스템은 사람의 취향을 벡터로 적습니다. 예를 들어 영화 장르 (액션, 로맨스, 코미디, 공포) 네 칸에 0점에서 5점까지 매긴 4차원 벡터입니다.

사람액션로맨스코미디공포
나5140
지민4051
서준0514
하린10280

4차원이라 그림은 못 그리지만 각도는 구할 수 있습니다. 두 취향 벡터 사이의 cos⁡θ\cos\theta를 코사인 유사도라고 부릅니다. 1에 가까우면 같은 쪽을 보는 취향, 0에 가까우면 상관없는 취향입니다(점수가 모두 0 이상이라 음수는 잘 나오지 않습니다).

내적을 그대로 쓰지 않고 길이로 나누는 데는 이유가 있습니다. 하린은 나와 취향 비율이 똑같지만 모든 점수를 두 배로 후하게 줍니다. 내적은 길이에 비례해서 커지므로 점수를 후하게 주는 사람일수록 무조건 "비슷하다"고 나옵니다. 길이로 나누면 길이의 영향이 사라지고 방향, 즉 취향의 비율만 남습니다. 그래서 하린과 나의 코사인 유사도는 정확히 1입니다. 계산은 3-6에서 NumPy로 합니다.

검색 엔진이나 AI가 문장을 768차원 같은 벡터로 바꾼 뒤 "의미가 비슷한 문장"을 찾을 때도 같은 계산을 씁니다.

3-6. NumPy로 길이·내적·각도 구하기 (10분)

실습대의 "NumPy로 확인하기" 칸에 나오는 줄을 처음부터 따라가 봅시다.

>>> import numpy as np
>>> u = np.array([3, 0])
>>> w = np.array([2, 2])
>>> u @ w
np.int64(6)
>>> np.dot(u, w)
np.int64(6)

@는 파이썬에서 행렬 곱셈을 위해 만든 연산자입니다. 1차원 배열 두 개 사이에 쓰면 내적을 계산합니다. np.dot(u, w)도 같은 일을 하는 함수입니다. 요즘 코드는 짧고 읽기 쉬운 @를 주로 씁니다. 결과는 배열이 아니라 숫자 하나(np.int64(6))입니다.

길이는 np.linalg.norm으로 구합니다. linalg는 linear algebra(선형대수)의 줄임말로, NumPy의 선형대수 함수들이 모여 있는 곳입니다.

>>> np.linalg.norm(u)
np.float64(3.0)
>>> np.linalg.norm(w)
np.float64(2.8284271247461903)

정수 벡터라도 제곱근 때문에 길이는 실수(float64)로 나옵니다. 피타고라스 식을 그대로 적어도 같은 답이 나옵니다. v ** 2는 성분마다 제곱하는 원소별 연산이고, np.sum은 성분을 모두 더하고, np.sqrt는 제곱근입니다.

>>> v = np.array([3, 4])
>>> v ** 2
array([ 9, 16])
>>> np.sqrt(np.sum(v ** 2))
np.float64(5.0)
>>> np.linalg.norm(v)
np.float64(5.0)

정규화는 2교시에서 본 브로드캐스팅 그대로, 배열을 숫자 하나로 나누면 됩니다.

>>> v / np.linalg.norm(v)
array([0.6, 0.8])
>>> 5 * v / np.linalg.norm(v)
array([3., 4.])

이제 실습대 NumPy 칸의 나머지 세 줄입니다. 코사인을 구하고, np.arccos로 각을 되찾고, np.degrees로 도(°) 단위로 바꿉니다.

>>> cos = u @ w / (np.linalg.norm(u) * np.linalg.norm(w))
>>> cos
np.float64(0.7071067811865475)
>>> np.arccos(cos)
np.float64(0.7853981633974484)
>>> np.degrees(np.arccos(cos))
np.float64(45.00000000000001)

np.arccos는 "코사인이 이 값인 각"을 돌려주는 함수(아크코사인)인데, 결과가 라디안이라는 단위로 나옵니다. 라디안은 180°를 π(약 3.14159)로 재는 단위이고, 0.785…는 π/4, 즉 45°입니다. 사람이 읽기 쉬운 도 단위로 바꾸는 함수가 np.degrees, 반대로 도를 라디안으로 바꾸는 함수가 np.radians입니다.

45.00000000000001: 소수 계산의 작은 오차

마지막 줄이 45가 아니라 45.00000000000001로 나왔습니다. 계산이 틀린 것이 아닙니다. 컴퓨터는 소수를 2진법으로 저장하는데, 0.1이나 √2 같은 수는 2진법으로 끝없이 이어지므로 정해진 자릿수(약 16자리)에서 잘라 저장합니다. 잘린 만큼의 아주 작은 오차가 계산을 거치며 끝자리에 드러난 것입니다. 이것을 부동소수점 오차라고 하며, 선형대수 계산에서는 늘 만나게 됩니다.

가장 유명한 예는 이것입니다.

>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False

그래서 소수끼리는 ==로 비교하지 않습니다. 대신 np.isclose로 "아주 가까우면 같다고 보자"고 비교합니다. 배열 전체를 한꺼번에 비교할 때는 np.allclose를 씁니다. 기본값으로는 차이가 0.00000001(1억분의 1)보다 작거나 값 크기의 10만분의 1보다 작으면 같다고 봅니다. 부동소수점 오차는 이보다 훨씬 작으니 걸러집니다.

>>> np.isclose(0.1 + 0.2, 0.3)
np.True_
>>> np.isclose(np.degrees(np.arccos(cos)), 45)
np.True_
>>> np.allclose(v / np.linalg.norm(v), [0.6, 0.8])
True

np.True_는 NumPy의 참(True) 값입니다. if문 등에서는 파이썬의 True와 똑같이 쓸 수 있습니다. 화면에 보기 좋게 보여 주고 싶을 때는 round(float(x), 3)처럼 반올림하면 됩니다. float(x)는 NumPy 숫자를 보통 파이썬 숫자로 바꿉니다.

>>> round(float(np.degrees(np.arccos(cos))), 3)
45.0

이 오차는 각도 계산에서 한 번 더 문제를 일으킵니다. 같은 방향의 두 벡터라면 코사인이 정확히 1이어야 하는데, 오차 때문에 1을 살짝 넘을 때가 있습니다.

>>> a = np.array([2, 3])
>>> b = np.array([4, 6])
>>> c = a @ b / (np.linalg.norm(a) * np.linalg.norm(b))
>>> c
np.float64(1.0000000000000002)

코사인은 −1과 1 사이의 값이라, np.arccos에 1보다 큰 수를 넣으면 답 대신 nan(Not a Number, 숫자 아님)과 경고가 나옵니다. 이럴 때는 np.clip으로 값을 −1과 1 사이로 잘라 낸 뒤 넣습니다. 실습대도 속으로는 이렇게 계산합니다.

>>> np.clip(c, -1, 1)
np.float64(1.0)
>>> np.degrees(np.arccos(np.clip(c, -1, 1)))
np.float64(0.0)

코사인 유사도 계산

3-5의 취향 벡터로 코사인 유사도를 계산해 봅시다. 같은 계산을 여러 번 하니 함수로 만들어 둡니다.

>>> def cos_sim(x, y):
...     return x @ y / (np.linalg.norm(x) * np.linalg.norm(y))
>>> me = np.array([5, 1, 4, 0])
>>> jimin = np.array([4, 0, 5, 1])
>>> seojun = np.array([0, 5, 1, 4])
>>> harin = np.array([10, 2, 8, 0])
>>> round(float(cos_sim(me, jimin)), 3)
0.952
>>> round(float(cos_sim(me, seojun)), 3)
0.214
>>> cos_sim(me, harin)
np.float64(1.0)
>>> me @ jimin, me @ harin
(np.int64(40), np.int64(84))

지민은 나와 아주 비슷하고(0.952), 서준은 별로 비슷하지 않습니다(0.214). 하린은 점수를 두 배로 줬을 뿐 취향 비율이 같으니 1입니다. 마지막 줄을 보면 내적만으로는 하린(84)이 지민(40)보다 두 배 넘게 비슷해 보입니다. 길이로 나누는 이유가 여기에 있습니다. 4차원이든 768차원이든 코드는 한 글자도 바뀌지 않습니다.

3-7. 자주 하는 실수 (3분)

u * w를 내적으로 착각하기. *는 2교시에서 본 원소별 곱셈이라 벡터가 나옵니다. 내적은 그것을 다 더한 숫자 하나입니다.

>>> u * w
array([6, 0])
>>> np.sum(u * w)
np.int64(6)
>>> u @ w
np.int64(6)

len을 길이로 착각하기. 파이썬의 len은 성분이 몇 개인지(차원)를 알려 줄 뿐, 화살표의 길이가 아닙니다.

>>> len(v)
2
>>> np.linalg.norm(v)
np.float64(5.0)

도와 라디안 섞기. np.cos(60)은 60°가 아니라 60라디안의 코사인입니다. 도 단위 각을 넣을 때는 np.radians로 먼저 바꾸세요. 결과가 0.5가 아니라 0.5000000000000001인 것은 앞에서 본 부동소수점 오차입니다.

>>> np.cos(np.radians(60))
np.float64(0.5000000000000001)

연습 문제 (5분)

  1. 벡터 (1, 2, 2)의 길이를 손으로 계산하고 np.linalg.norm으로 확인해 보세요. 이 벡터를 정규화하면 무엇이 될까요?
  2. u = (3, 0)일 때 w = (−2, 1)과의 내적과 사이각을 구해 보세요. 실습대에서 w를 (−2, 1)로 옮겨 확인할 수 있습니다.
  3. u = (1, 2)와 수직인 벡터를 하나 찾아 내적이 0인지 확인해 보세요.
  4. 실습대에서 u = (3, 0), w = (−2, 1)일 때 그림자 길이는 얼마일까요? 부호가 무엇을 뜻하나요?

풀이

>>> p = np.array([1, 2, 2])
>>> np.linalg.norm(p)
np.float64(3.0)
>>> p / np.linalg.norm(p)
array([0.33333333, 0.66666667, 0.66666667])
>>> u = np.array([3, 0])
>>> w = np.array([-2, 1])
>>> u @ w
np.int64(-6)
>>> cos = u @ w / (np.linalg.norm(u) * np.linalg.norm(w))
>>> round(float(np.degrees(np.arccos(cos))), 1)
153.4
>>> np.array([1, 2]) @ np.array([-2, 1])
np.int64(0)
>>> u @ w / np.linalg.norm(u)
np.float64(-2.0)

1번의 길이는 $\sqrt{1 + 4 + 4} = 3$이라 정규화하면 (1/3, 2/3, 2/3)입니다. 2번은 내적이 음수이니 90°보다 큰 약 153.4°입니다. 3번은 성분의 자리를 바꾸고 한쪽 부호를 뒤집으면 수직인 벡터가 됩니다: (1, 2)와 (−2, 1). 4번의 그림자 길이 −2는 그림자가 u의 반대쪽으로 2만큼 드리워진다는 뜻입니다.

이번 교시 정리

  • 길이(노름) ‖v‖ = 성분 제곱의 합의 제곱근 (피타고라스). 몇 차원이든 같은 식.

  • 단위벡터 = 길이 1인 벡터. v / ‖v‖ 로 정규화하면 방향만 남는다.

  • 내적 u · w = 성분끼리 곱해 더한 숫자 하나 = ‖u‖‖w‖cos θ. 양수면 같은 쪽, 0이면 수직, 음수면 반대쪽.

  • w를 u 위로 내린 그림자(사영)의 길이 = (u · w) / ‖u‖.

  • 코사인 유사도 = 길이로 나눈 내적: 크기는 빼고 방향(비율)만 비교한다.

  • NumPy: u @ w, np.linalg.norm, np.degrees(np.arccos(...)). 소수는 == 대신 np.isclose / np.allclose로 비교.

다음 교시에는 두 벡터를 늘이고 더해서 어디까지 갈 수 있는지 알아봅니다. 처음으로 NumPy의 2차원 배열, 즉 행렬이 등장합니다.

← 이전

2교시 · 벡터 = 화살표 = 숫자 목록: 덧셈과 스칼라배

다음 →

4교시 · 일차결합과 생성(span)

새 교시·새 강의 소식 받기

이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.

수집 항목: 이메일 주소 · 목적: 이 강의의 새 교시·공개 알림 · 보유 기간: 해지를 요청할 때까지. 다른 용도로 쓰거나 남에게 넘기지 않아요. 알림 메일에 답장하면 바로 지워 드려요.

질문과 답변

이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.

링크와 서식 없이 글자 그대로 표시됩니다.

R

ReflexKR

눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.

ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.

제휴 링크와 후원 안내AI의 일기장: 이 사이트를 만드는 AI가 쓰는 글
한국어English

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex