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와 함께
13교시

강의 시간 10:00–10:50

직교·사영·최소제곱

서버에 들어오는 요청 수와 응답 시간을 하루 동안 기록해 점으로 찍었다고 합시다. 점들은 대략 오른쪽 위로 올라가는 줄을 이루지만, 한 직선 위에 딱 맞게 놓이지는 않습니다. 측정에는 늘 잡음이 섞이기 때문입니다. 그래도 우리는 "요청이 100개 늘면 응답 시간이 대략 몇 ms 늘어나는가"를 알고 싶습니다. 점들을 가장 잘 지나는 직선 하나가 필요합니다.

8교시의 눈으로 보면 이것은 이상한 연립방정식입니다. 점 하나가 식 하나이니, 점이 100개면 식이 100개인데 모르는 수는 기울기와 절편 두 개뿐입니다. 이런 방정식은 거의 언제나 해가 없습니다. 이번 교시에는 해가 없을 때 "가장 가까운 답"을 찾는 방법, 곧 최소제곱법을 배웁니다. 통계의 선형 회귀, 머신러닝의 가장 기본 모델, 센서 보정, 그래프의 추세선이 모두 이 방법입니다.

열쇠는 3교시의 그림자(사영)와 "수직"이라는 생각입니다. 가장 가까운 점은 언제나 수직으로 내린 발에 있습니다.

13-1. 직교: 내적이 0 (5분)

3교시에서 두 벡터의 내적이 0이면 서로 수직이라고 배웠습니다. 선형대수에서는 수직을 직교(orthogonal) 라고 부릅니다. 예를 들어 (2,1)⋅(−1,2)=−2+2=0(2, 1) \cdot (-1, 2) = -2 + 2 = 0이니 두 벡터는 직교합니다.

책에 적힌 모양읽는 법뜻파이썬에서는
u⊥w\mathbf{u} \perp \mathbf{w}u와 w는 직교한다u⋅w=0\mathbf{u} \cdot \mathbf{w} = 0np.isclose(u @ w, 0)
uTw\mathbf{u}^\mathsf{T}\mathbf{w}u 전치 w내적을 행렬곱으로 쓴 것. u⋅w\mathbf{u} \cdot \mathbf{w}와 같음u @ w
ATA^\mathsf{T}A 전치행과 열을 바꾼 행렬(5교시)A.T
∥r∥2\lVert \mathbf{r} \rVert^2r의 노름 제곱성분 제곱의 합r @ r
x^\hat{\mathbf{x}}엑스 햇"가장 그럴듯한 값"으로 고른 xcoef
QTQ=IQ^\mathsf{T}Q = IQ 전치 Q는 IQ의 열들이 서로 직교하고 길이가 1np.allclose(Q.T @ Q, np.eye(2))

서로 직교하고 길이가 모두 1인 벡터 묶음을 정규직교(orthonormal) 라고 합니다. 표준기저 î, ĵ가 대표적이고, 그것을 돌린 회전된 기저도 정규직교입니다.

정규직교 기저가 편한 이유는 좌표를 구하는 일이 내적 한 번으로 끝나기 때문입니다. 10교시에서는 새 좌표를 구하려고 Bc=vB\mathbf{c} = \mathbf{v}를 풀어야 했습니다. 그런데 q1,q2\mathbf{q}_1, \mathbf{q}_2가 정규직교이면, v\mathbf{v}의 좌표는 그냥 c1=q1⋅vc_1 = \mathbf{q}_1 \cdot \mathbf{v}, c2=q2⋅vc_2 = \mathbf{q}_2 \cdot \mathbf{v}입니다. 각 축 위의 그림자 길이가 곧 좌표인 셈입니다. 이 편리함을 13-5에서 그대로 이용합니다.

13-2. 직선 위로 사영하기 (6분)

3교시에서 w\mathbf{w}를 u\mathbf{u} 위로 비춘 그림자의 길이가 u⋅w∥u∥\dfrac{\mathbf{u} \cdot \mathbf{w}}{\lVert \mathbf{u} \rVert}라고 배웠습니다. 이제 그림자를 길이가 아니라 벡터로 구해 봅시다. 그림자는 u\mathbf{u} 방향이니 u\mathbf{u}의 몇 배입니다.

p=u⋅wu⋅u u\mathbf{p} = \frac{\mathbf{u} \cdot \mathbf{w}}{\mathbf{u} \cdot \mathbf{u}}\,\mathbf{u}

이것을 w\mathbf{w}의 u\mathbf{u} 위로의 사영(projection) 이라고 합니다. 분모가 ∥u∥\lVert\mathbf{u}\rVert가 아니라 u⋅u=∥u∥2\mathbf{u} \cdot \mathbf{u} = \lVert\mathbf{u}\rVert^2인 것은, 그림자 길이를 단위벡터 u/∥u∥\mathbf{u}/\lVert\mathbf{u}\rVert에 곱했기 때문입니다(∥u∥\lVert\mathbf{u}\rVert로 한 번 더 나눔).

예. u=(2,1)\mathbf{u} = (2, 1), w=(1,3)\mathbf{w} = (1, 3)이면 u⋅w=2+3=5\mathbf{u} \cdot \mathbf{w} = 2 + 3 = 5, u⋅u=4+1=5\mathbf{u} \cdot \mathbf{u} = 4 + 1 = 5이니 p=1⋅(2,1)=(2,1)\mathbf{p} = 1 \cdot (2, 1) = (2, 1)입니다.

남은 부분 e=w−p=(−1,2)\mathbf{e} = \mathbf{w} - \mathbf{p} = (-1, 2)를 오차라고 부르겠습니다. e⋅u=−2+2=0\mathbf{e} \cdot \mathbf{u} = -2 + 2 = 0, 오차는 직선과 직교합니다. 이것이 사영의 핵심 성질입니다.

직선 위의 점 가운데 w\mathbf{w}에 가장 가까운 점은 사영 p\mathbf{p}이고, 그때 오차 w−p\mathbf{w} - \mathbf{p}는 직선과 직교한다.

왜 그런지는 그림으로 바로 보입니다. 점에서 직선까지 가장 짧은 길은 수직으로 내려가는 길입니다. 비스듬히 가면 직각삼각형의 빗변을 타는 셈이라 언제나 더 깁니다(피타고라스). 이 "수직이면 가장 가깝다"는 생각을 직선이 아니라 더 큰 공간으로 넓힌 것이 최소제곱법입니다.

13-3. 풀리지 않는 Ax = b: 점들에 직선 맞추기 (7분)

작은 예로 시작합시다. 점 네 개 (0,1)(0, 1), (1,3)(1, 3), (2,2)(2, 2), (3,4)(3, 4)에 직선 y=mx+cy = mx + c를 맞추려고 합니다. 점마다 "그 점을 지나야 한다"는 식이 하나씩 나옵니다.

0m+c=11m+c=32m+c=23m+c=4⟹[01112131]⏟X[mc]=[1324]⏟y\begin{aligned} 0m + c &= 1 \\ 1m + c &= 3 \\ 2m + c &= 2 \\ 3m + c &= 4 \end{aligned} \qquad\Longrightarrow\qquad \underbrace{\begin{bmatrix} 0 & 1 \\ 1 & 1 \\ 2 & 1 \\ 3 & 1 \end{bmatrix}}_{X} \begin{bmatrix} m \\ c \end{bmatrix} = \underbrace{\begin{bmatrix} 1 \\ 3 \\ 2 \\ 4 \end{bmatrix}}_{\mathbf{y}}

행렬 XX의 1열은 점들의 xx 값, 2열은 모두 1입니다. 2열의 1은 절편 cc에 곱해지는 자리입니다. 식이 네 개, 모르는 수가 두 개입니다. 첫 두 식에서 c=1c = 1, m=2m = 2가 나오는데, 셋째 식에 넣으면 2⋅2+1=5≠22 \cdot 2 + 1 = 5 \neq 2입니다. 해가 없습니다.

9교시의 열 그림으로 보면 이유가 분명합니다. XcX\mathbf{c}로 갈 수 있는 곳(열공간)은 XX의 두 열이 만드는 평면 하나입니다. y\mathbf{y}는 4차원 공간의 점인데 그 평면 위에 있지 않습니다. 그러니 정확히 도착할 수는 없고, 할 수 있는 최선은 평면 위에서 y\mathbf{y}에 가장 가까운 점에 가는 것입니다. 13-2에 따르면 그 점은 y\mathbf{y}를 평면 위로 사영한 점입니다.

이때 각 점에서 직선까지의 세로 차이를 잔차(residual) 라고 합니다.

r=y−Xc^,∥r∥2=r12+r22+r32+r42\mathbf{r} = \mathbf{y} - X\hat{\mathbf{c}}, \qquad \lVert \mathbf{r} \rVert^2 = r_1^2 + r_2^2 + r_3^2 + r_4^2

∥r∥2\lVert \mathbf{r} \rVert^2을 잔차 제곱합이라고 부르고, 이것을 가장 작게 만드는 c^\hat{\mathbf{c}}를 고르는 방법이 최소제곱(least squares) 입니다. 거리를 가장 짧게 = 거리의 제곱을 가장 작게 = 잔차 제곱합을 가장 작게, 모두 같은 말입니다.

잔차를 그냥 더하지 않고 제곱해서 더하는 이유는 두 가지입니다. 그냥 더하면 위로 벗어난 점(+)과 아래로 벗어난 점(−)이 서로 지워져 버립니다. 그리고 제곱이 피타고라스의 "거리"와 맞아떨어져서, 지금까지 배운 사영·직교의 도구를 그대로 쓸 수 있습니다.

아래 실습대에는 검정 데이터 점 6개가 있습니다. 파랑(인디고) 굵은 직선이 최소제곱 직선이고, 진홍 점선은 점마다의 잔차(세로 차이)입니다. 오른쪽에 직선의 식과 잔차 제곱합이 나옵니다. 처음에는 "y = 0.717x + 0.393", 잔차 제곱합 1.007입니다.

최소제곱 직선

y = 0.717x + 0.393

잔차 제곱합 = 1.007

NumPy로 확인하기

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

>>> x = np.array([-3.5, -2, -1, 0.5, 2, 3.5])
>>> y = np.array([-2, -1.5, 0, 0.5, 2.5, 2.5])
>>> X = np.column_stack([x, np.ones_like(x)])
>>> coef, res, rank, sv = np.linalg.lstsq(X, y)
>>> np.round(coef, 6)
array([0.716934, 0.393078])
>>> np.round(res, 6)
array([1.007417])
>>> int(rank)
2
검정 점 하나를 위아래로 크게 끌어 보세요. 직선이 그 점 쪽으로 기울고 잔차 제곱합이 커집니다. 이어서 '내 직선 (두 점을 끌어 맞춰 보기)'을 켜고 청록 점 두 개를 끌어 파랑 직선보다 잔차 제곱합을 작게 만들 수 있는지 도전해 보세요.

'내 직선'을 켜면 청록 점선 직선과 그 직선의 잔차 제곱합이 함께 나옵니다. 아무리 애써도 "최소제곱 직선이 이깁니다. 잔차 제곱합을 이보다 작게 만들 수는 없습니다"라는 문구가 바뀌지 않다가, 파랑 직선에 거의 겹치면 "거의 같아졌습니다!"가 뜹니다. 최소제곱 직선은 이름 그대로 잔차 제곱합이 가장 작은 직선이니, 이길 수는 없고 비길 수만 있습니다.

점 하나를 멀리 끌면 직선이 크게 휘는 것도 눈여겨보세요. 잔차를 제곱하기 때문에 크게 벗어난 점 하나가 전체 합을 크게 키우고, 직선은 그 점을 달래려고 끌려갑니다. 최소제곱은 이상치(outlier)에 약하다는 점을 기억해 두세요.

마지막으로, 잔차는 직선까지의 수직 거리가 아니라 세로 차이입니다. "x를 알 때 y를 맞히는" 문제라 y 방향의 오차만 재는 것입니다.

13-4. 정규방정식: 잔차는 모든 열과 직교한다 (7분)

13-2의 성질을 평면으로 넓히면 답을 계산할 수 있습니다. y\mathbf{y}에서 평면(열공간)에 가장 가까운 점으로 갔다면, 남은 잔차 r\mathbf{r}은 평면과 직교합니다. 평면과 직교한다는 것은 평면을 만드는 모든 열과 직교한다는 뜻입니다. 열들과의 내적을 한꺼번에 쓰면 XTrX^\mathsf{T}\mathbf{r}이니 다음과 같습니다.

XT(y−Xc^)=0⟹XTX c^=XTyX^\mathsf{T}(\mathbf{y} - X\hat{\mathbf{c}}) = \mathbf{0} \quad\Longrightarrow\quad X^\mathsf{T}X\,\hat{\mathbf{c}} = X^\mathsf{T}\mathbf{y}

이것을 정규방정식(normal equation) 이라고 합니다. "normal"은 여기서 "보통"이 아니라 "수직"이라는 뜻입니다. XX는 4×2라 정사각이 아니지만, XTXX^\mathsf{T}X는 2×2 정사각행렬이라 8교시처럼 풀 수 있습니다.

손으로 풀어 봅시다. 13-3의 점 네 개에서 계산하면 이렇습니다.

XTX=[0+1+4+90+1+2+30+1+2+31+1+1+1]=[14664],XTy=[0+3+4+121+3+2+4]=[1910]X^\mathsf{T}X = \begin{bmatrix} 0+1+4+9 & 0+1+2+3 \\ 0+1+2+3 & 1+1+1+1 \end{bmatrix} = \begin{bmatrix} 14 & 6 \\ 6 & 4 \end{bmatrix}, \qquad X^\mathsf{T}\mathbf{y} = \begin{bmatrix} 0+3+4+12 \\ 1+3+2+4 \end{bmatrix} = \begin{bmatrix} 19 \\ 10 \end{bmatrix}

14m+6c=1914m + 6c = 19, 6m+4c=106m + 4c = 10을 풀면 m=0.8m = 0.8, c=1.3c = 1.3입니다. 가장 잘 맞는 직선은 y=0.8x+1.3y = 0.8x + 1.3입니다.

검산. 직선의 값은 x=0,1,2,3x = 0, 1, 2, 3에서 1.3,2.1,2.9,3.71.3, 2.1, 2.9, 3.7이고, 잔차는 r=(−0.3, 0.9, −0.9, 0.3)\mathbf{r} = (-0.3,\ 0.9,\ -0.9,\ 0.3)입니다.

  • 2열(모두 1)과의 내적: −0.3+0.9−0.9+0.3=0-0.3 + 0.9 - 0.9 + 0.3 = 0. 잔차를 다 더하면 0, 곧 위아래로 벗어난 양이 균형을 이룹니다.
  • 1열(xx 값)과의 내적: 0+0.9−1.8+0.9=00 + 0.9 - 1.8 + 0.9 = 0.
  • 잔차 제곱합: 0.09+0.81+0.81+0.09=1.80.09 + 0.81 + 0.81 + 0.09 = 1.8.

잔차가 두 열과 모두 직교하니, 이 직선이 정말로 가장 가까운 답입니다.

13-5. QR 분해: 직교 기저로 바꾸면 쉬워진다 (6분)

정규방정식은 깔끔하지만 컴퓨터로 풀 때 약점이 있습니다. XTXX^\mathsf{T}X를 만드는 순간 숫자의 오차가 커지기 쉽습니다. 열들이 서로 비슷한 방향일수록 심해집니다. 13-1에서 본 "정규직교 기저에서는 좌표가 내적 한 번"이라는 성질을 쓰면 이 문제를 피할 수 있습니다.

그람-슈미트(Gram–Schmidt) 과정은 아무 열들이나 받아서 같은 평면을 만드는 정규직교 기저로 바꿉니다.

  1. 첫째 열 a1\mathbf{a}_1을 길이 1로 맞춥니다: q1=a1/∥a1∥\mathbf{q}_1 = \mathbf{a}_1 / \lVert\mathbf{a}_1\rVert.
  2. 둘째 열 a2\mathbf{a}_2에서 q1\mathbf{q}_1 방향의 그림자를 뺍니다: b=a2−(q1⋅a2) q1\mathbf{b} = \mathbf{a}_2 - (\mathbf{q}_1 \cdot \mathbf{a}_2)\,\mathbf{q}_1. 13-2의 오차처럼, 남은 b\mathbf{b}는 q1\mathbf{q}_1과 직교합니다.
  3. b\mathbf{b}를 길이 1로 맞춥니다: q2=b/∥b∥\mathbf{q}_2 = \mathbf{b} / \lVert\mathbf{b}\rVert.

열이 더 있으면 앞에서 만든 q\mathbf{q}들 방향의 그림자를 모두 빼고 길이를 맞추는 일을 반복합니다. 이렇게 만든 q\mathbf{q}들을 열로 세운 행렬을 QQ라고 하면, 원래 행렬을 다음처럼 쓸 수 있습니다.

X=QR,QTQ=I,R=[r11r120r22]X = QR, \qquad Q^\mathsf{T}Q = I, \qquad R = \begin{bmatrix} r_{11} & r_{12} \\ 0 & r_{22} \end{bmatrix}

QQ의 열은 정규직교이고, RR은 대각선 아래가 0인 위삼각행렬입니다. RR에는 "원래 열을 q\mathbf{q}들로 어떻게 조합했는가"가 들어 있는데, a1\mathbf{a}_1은 q1\mathbf{q}_1만으로, a2\mathbf{a}_2는 q1,q2\mathbf{q}_1, \mathbf{q}_2로 만들어지니 위삼각 모양이 됩니다. 이것을 QR 분해라고 합니다.

정규방정식에 X=QRX = QR을 넣으면 QTQ=IQ^\mathsf{T}Q = I 덕분에 식이 줄어듭니다.

RTQTQR c^=RTQTy⟹R c^=QTyR^\mathsf{T}Q^\mathsf{T}QR\,\hat{\mathbf{c}} = R^\mathsf{T}Q^\mathsf{T}\mathbf{y} \quad\Longrightarrow\quad R\,\hat{\mathbf{c}} = Q^\mathsf{T}\mathbf{y}

QTyQ^\mathsf{T}\mathbf{y}는 "y\mathbf{y}의 정규직교 좌표", 곧 내적 몇 번입니다. RR이 위삼각이니 마지막 줄부터 하나씩 풀면 끝납니다(맨 아래 식은 모르는 수가 하나뿐). XTXX^\mathsf{T}X를 만들지 않으니 오차도 덜 커집니다. 얼마나 덜 커지는지는 13-6에서 숫자로 봅니다.

13-6. NumPy로 최소제곱 (12분)

실습대의 NumPy 칸 읽기

실습대의 처음 화면에서 NumPy 칸에는 이런 코드가 나옵니다.

>>> import numpy as np
>>> x = np.array([-3.5, -2, -1, 0.5, 2, 3.5])
>>> y = np.array([-2, -1.5, 0, 0.5, 2.5, 2.5])
>>> X = np.column_stack([x, np.ones_like(x)])
>>> coef, res, rank, sv = np.linalg.lstsq(X, y)
>>> np.round(coef, 6)
array([0.716934, 0.393078])
>>> np.round(res, 6)
array([1.007417])
>>> int(rank)
2

한 줄씩 보겠습니다. x와 y는 점 6개의 x 좌표와 y 좌표입니다. -2처럼 정수로 적은 것이 섞여 있어도 3.5 같은 소수가 하나라도 있으니 배열 전체가 float64입니다(1교시).

np.ones_like(x)는 "x와 같은 모양, 같은 dtype으로 모두 1인 배열"입니다. 13-3에서 본 절편 자리의 1을 점 개수만큼 만드는 것입니다. 4교시의 np.column_stack으로 두 배열을 열로 세우면 13-3의 행렬 X가 됩니다.

>>> x.dtype
dtype('float64')
>>> np.ones_like(x)
array([1., 1., 1., 1., 1., 1.])
>>> X
array([[-3.5,  1. ],
       [-2. ,  1. ],
       [-1. ,  1. ],
       [ 0.5,  1. ],
       [ 2. ,  1. ],
       [ 3.5,  1. ]])
>>> X.shape
(6, 2)

X는 6행 2열입니다. 식 6개, 모르는 수 2개입니다.

np.linalg.lstsq(X, y)는 Xc ≈ y의 최소제곱 해를 구합니다. 이름은 least squares를 줄인 것입니다. 결과가 네 개라서 이름 네 개로 나눠 받습니다.

이름뜻이 예에서
coef최소제곱 해 ĉ = (m, c)기울기 0.717, 절편 0.393
res잔차 제곱합 (길이 1인 배열)1.007
rankX의 계수(9교시)2: 두 열이 일차독립
svX의 특이값14교시에서 다룸

반올림하지 않은 값도 보고, 기울기와 절편을 따로 받아 봅시다.

>>> coef
array([0.71693449, 0.39307787])
>>> m, c = coef
>>> res
array([1.00741656])
>>> sv
array([5.81025009, 2.44765069])

rank를 칸에서 int(rank)로 보여 주는 이유가 있습니다. rank를 그대로 출력하면 Windows에서는 np.int32(2), Linux에서는 np.int64(2)처럼 운영체제마다 정수 종류가 다르게 나옵니다. 값은 똑같이 2이니, int(…)로 파이썬 정수로 바꿔 보면 어디서나 같은 모양이 됩니다.

잔차가 열들과 직교하는지 확인하기

13-4의 성질을 직접 확인합니다. X @ coef는 직선 위의 값(사영된 점), y - X @ coef가 잔차입니다.

>>> r = y - X @ coef
>>> r
array([ 0.11619283, -0.4592089 ,  0.32385661, -0.25154512,  0.67305315,
       -0.40234858])
>>> r @ r
np.float64(1.007416563658838)
>>> np.allclose(X.T @ r, 0)
True

r @ r은 성분 제곱의 합이니 잔차 제곱합이고, res와 같습니다. X.T @ r은 잔차와 두 열의 내적을 한꺼번에 계산한 것으로, 0이 나옵니다. 정확한 0이 아니라 아주 작은 찌꺼기가 남을 수 있어 np.allclose로 비교했습니다.

"내 직선"과 비교도 해 봅시다. 실습대의 '내 직선' 처음 위치는 점 (−3, 0)과 (3, 1)을 지나는 y = x/6 + 0.5입니다. 잔차 제곱합을 구하는 작은 함수를 만들어 비교합니다.

>>> def sse(m, c):
...     r = y - (m * x + c)
...     return r @ r
>>> sse(m, c)
np.float64(1.007416563658838)
>>> sse(1/6, 0.5)
np.float64(11.354166666666668)
>>> sse(0.7, 0.4)
np.float64(1.0175)

눈대중으로 꽤 잘 맞춘 (0.7, 0.4)도 1.0175로, 최소제곱 직선의 1.0074보다 조금 큽니다.

정규방정식을 solve로 풀기

13-4의 XᵀX ĉ = Xᵀy를 8교시의 np.linalg.solve로 풀어 lstsq와 같은지 봅니다.

>>> X.T @ X
array([[33.75, -0.5 ],
       [-0.5 ,  6.  ]])
>>> X.T @ y
array([24.,  2.])
>>> np.linalg.solve(X.T @ X, X.T @ y)
array([0.71693449, 0.39307787])
>>> np.allclose(np.linalg.solve(X.T @ X, X.T @ y), coef)
True

13-3의 점 네 개도 같은 방법으로 풀어, 손으로 구한 y = 0.8x + 1.3을 확인합니다.

>>> xs = np.array([0, 1, 2, 3])
>>> ys = np.array([1, 3, 2, 4])
>>> Xs = np.column_stack([xs, np.ones_like(xs)])
>>> Xs.T @ Xs
array([[14,  6],
       [ 6,  4]])
>>> Xs.T @ ys
array([19, 10])
>>> np.linalg.solve(Xs.T @ Xs, Xs.T @ ys)
array([0.8, 1.3])
>>> np.linalg.lstsq(Xs, ys)[1]
array([1.8])

lstsq(…)[1]은 결과 네 개 가운데 둘째(res)만 꺼낸 것입니다. 손으로 구한 잔차 제곱합 1.8과 같습니다.

QR 분해: np.linalg.qr

>>> Q, R = np.linalg.qr(X)
>>> Q.shape, R.shape
((6, 2), (2, 2))
>>> np.round(Q, 4)
array([[-0.6025, -0.3873],
       [-0.3443, -0.3964],
       [-0.1721, -0.4024],
       [ 0.0861, -0.4115],
       [ 0.3443, -0.4206],
       [ 0.6025, -0.4297]])
>>> np.round(R, 4)
array([[ 5.8095, -0.0861],
       [ 0.    , -2.448 ]])
>>> np.allclose(Q.T @ Q, np.eye(2))
True
>>> np.allclose(Q @ R, X)
True
>>> np.linalg.solve(R, Q.T @ y)
array([0.71693449, 0.39307787])

Q는 X와 같은 6행 2열이고, 두 열이 정규직교라 Q.T @ Q가 2×2 항등행렬입니다. R은 대각선 아래가 0인 위삼각행렬입니다. R ĉ = Qᵀy를 풀면 같은 답이 나옵니다.

Q와 R에 음수가 많이 보이는 것은 부호의 자유 때문입니다. Q의 한 열과 R의 같은 행의 부호를 함께 뒤집어도 곱 QR은 그대로라서, NumPy는 계산하기 편한 쪽을 고릅니다(11교시의 고유벡터 부호와 같은 이야기). 13-5의 그람-슈미트를 직접 따라 해서 Q와 비교하면, 부호만 빼고 같습니다.

>>> a1, a2 = X[:, 0], X[:, 1]
>>> q1 = a1 / np.linalg.norm(a1)
>>> b = a2 - (q1 @ a2) * q1
>>> q2 = b / np.linalg.norm(b)
>>> round(float(q1 @ q2), 10)
0.0
>>> np.allclose(np.abs(Q), np.abs(np.column_stack([q1, q2])))
True

np.abs는 원소마다 절댓값을 취해 부호를 지우는 함수입니다.

13-5에서 말한 "XᵀX를 만들면 오차가 커지기 쉽다"를 숫자로 보는 방법도 있습니다. np.linalg.cond는 조건수라는 값으로, "입력의 작은 오차가 답에서 최대 몇 배로 커질 수 있는가"를 나타냅니다.

>>> round(float(np.linalg.cond(X)), 3)
2.374
>>> round(float(np.linalg.cond(X.T @ X)), 3)
5.635

2.374² ≈ 5.635, XᵀX의 조건수는 X의 조건수의 제곱입니다. 이 예에서는 둘 다 작아 문제가 없지만, X의 조건수가 10⁸이면 XᵀX는 10¹⁶이 되어 부동소수점의 정밀도(약 16자리)를 다 잡아먹습니다. 그래서 lstsq는 정규방정식을 직접 풀지 않고 더 안정적인 방법(SVD 기반)을 씁니다. 조건수는 다음 강의인 수치해석의 중심 주제입니다.

np.polyfit: 추세선 한 줄로

데이터에 다항식을 맞추는 np.polyfit(x, y, 차수)도 속에서는 같은 최소제곱을 풉니다. 차수 1이면 직선이고, 결과는 높은 차수부터 [기울기, 절편] 순서입니다.

>>> np.polyfit(x, y, 1)
array([0.71693449, 0.39307787])
>>> np.allclose(np.polyfit(x, y, 1), coef)
True

곡선도 같은 방법입니다. y = ax² + bx + c를 맞추고 싶으면 X에 x² 열을 하나 더 세우면 됩니다. 모르는 수 a, b, c에 대해서는 여전히 "일차" 식이라 선형대수로 풀립니다.

>>> xq = np.array([-2, -1, 0, 1, 2])
>>> yq = np.array([4.1, 1.2, 0.1, 0.9, 4.2])
>>> np.linalg.lstsq(np.column_stack([xq**2, xq, np.ones_like(xq)]), yq)[0]
array([ 1.02142857, -0.01      ,  0.05714286])
>>> np.polyfit(xq, yq, 2)
array([ 1.02142857, -0.01      ,  0.05714286])

y ≈ 1.02x² − 0.01x + 0.06, 거의 y = x²인 포물선을 찾았습니다.

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

정사각이 아닌 X를 solve에 넣기. solve는 식과 모르는 수의 개수가 같은 정사각 연립방정식(8교시)만 풉니다. 식이 더 많으면 lstsq를 써야 합니다.

>>> np.linalg.solve(X, y)
Traceback (most recent call last):
  ...
numpy.linalg.LinAlgError: Last 2 dimensions of the array must be square

1의 열을 빠뜨리기. np.ones_like(x) 열 없이 x만 넣으면 y = mx, 곧 원점을 지나는 직선만 찾습니다. 절편을 0으로 못 박은 셈이라 기울기도 달라집니다.

>>> np.linalg.lstsq(x.reshape(-1, 1), y)[0]
array([0.71111111])

x.reshape(-1, 1)은 x를 6행 1열 행렬로 세우는 방법입니다(-1은 "개수는 알아서"). 기울기가 0.717이 아니라 0.711로 나왔습니다.

res가 비어 있을 때. X의 계수가 열 개수보다 작으면(예: 모든 점의 x가 같음) res가 빈 배열로 나옵니다. 실습대에서 점 6개를 같은 세로줄에 모으면 "x가 모두 같아 직선을 하나로 정할 수 없습니다"라는 경고가 뜨는 경우입니다. 이때도 lstsq는 오류 없이 답 하나를 내놓지만(가능한 답 가운데 가장 짧은 것), 직선이 하나로 정해진 것이 아닙니다. res가 비어 있으면 rank부터 확인하세요.

>>> xv = np.array([1.0, 1, 1, 1, 1, 1])
>>> coef2, res2, rank2, sv2 = np.linalg.lstsq(np.column_stack([xv, np.ones_like(xv)]), y)
>>> res2
array([], dtype=float64)
>>> int(rank2)
1

연습 문제 (4분)

  1. w=(3,1)\mathbf{w} = (3, 1)을 u=(1,1)\mathbf{u} = (1, 1) 위로 사영한 p\mathbf{p}와 오차 w−p\mathbf{w} - \mathbf{p}를 구하고, 오차가 u\mathbf{u}와 직교하는지 확인하세요.
  2. 점 (1,2)(1, 2), (2,3)(2, 3), (3,5)(3, 5), (4,6)(4, 6)에 맞는 최소제곱 직선을 lstsq와 polyfit 두 가지로 구하고, 잔차 제곱합도 구하세요.
  3. 2번의 잔차가 XX의 두 열과 직교하는지 X.T @ r로 확인하세요. 잔차를 모두 더하면 얼마일까요?

풀이

1번: u⋅w=4\mathbf{u} \cdot \mathbf{w} = 4, u⋅u=2\mathbf{u} \cdot \mathbf{u} = 2이니 p=2 (1,1)=(2,2)\mathbf{p} = 2\,(1, 1) = (2, 2), 오차는 (1,−1)(1, -1)이고 (1,−1)⋅(1,1)=0(1, -1) \cdot (1, 1) = 0입니다.

2번: y=1.4x+0.5y = 1.4x + 0.5, 잔차는 (0.1,−0.3,0.3,−0.1)(0.1, -0.3, 0.3, -0.1), 잔차 제곱합은 0.01+0.09+0.09+0.01=0.20.01 + 0.09 + 0.09 + 0.01 = 0.2입니다.

3번: 2열이 모두 1이니 "2열과의 내적 = 잔차의 합"이고, 0입니다.

>>> u = np.array([1, 1])
>>> w = np.array([3, 1])
>>> p = (u @ w) / (u @ u) * u
>>> p
array([2., 2.])
>>> (w - p) @ u
np.float64(0.0)
>>> xe = np.array([1, 2, 3, 4])
>>> ye = np.array([2, 3, 5, 6])
>>> Xe = np.column_stack([xe, np.ones_like(xe)])
>>> coef, res, rank, sv = np.linalg.lstsq(Xe, ye)
>>> coef
array([1.4, 0.5])
>>> res
array([0.2])
>>> np.polyfit(xe, ye, 1)
array([1.4, 0.5])
>>> re = ye - Xe @ coef
>>> re
array([ 0.1, -0.3,  0.3, -0.1])
>>> np.allclose(Xe.T @ re, 0)
True

p를 구하는 줄은 13-2의 공식을 그대로 옮긴 것입니다. (u @ w) / (u @ u)가 숫자 하나(배율)이고, 거기에 벡터 u를 곱했습니다.

이번 교시 정리

  • 직교 = 내적이 0. 서로 직교하고 길이가 1인 정규직교 기저에서는 좌표가 내적 한 번으로 나온다.

  • w를 u 위로 사영하면 p = (u·w / u·u) u. 오차 w − p는 u와 직교하고, p가 직선 위에서 가장 가까운 점이다.

  • 식이 모르는 수보다 많은 Ax = b는 보통 해가 없다. 잔차 제곱합 ‖y − Xc‖²을 가장 작게 하는 c가 최소제곱 해다.

  • 최소제곱 해에서 잔차는 X의 모든 열과 직교한다: 정규방정식 XᵀX ĉ = Xᵀy.

  • QR 분해 X = QR(Q는 정규직교, R은 위삼각)을 쓰면 R ĉ = Qᵀy만 풀면 되고, 오차도 덜 커진다.

  • np.linalg.lstsq(X, y)는 coef, res, rank, sv를 준다. rank는 int(rank)로. np.polyfit(x, y, 1)도 같은 답.

다음 교시에는 모든 행렬을 "돌리고, 늘이고, 다시 돌리기"로 쪼개는 특이값 분해(SVD) 를 배웁니다. 오늘 lstsq가 돌려준 sv의 정체, 그리고 lstsq가 속에서 쓰는 안정적인 방법이 바로 그것입니다.

← 이전

12교시 · 대각화와 거듭제곱

다음 →

14교시 · SVD: 돌리고, 늘이고, 돌리기

새 교시·새 강의 소식 받기

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

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

질문과 답변

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

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

R

ReflexKR

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

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

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

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

Built with Reflex