L34 서술 파트의 결론은 둘이었다. 다섯 조건은 사슬이 아니다. 그리고 네 부분공간의 그림 한 장에 전부가 있다.
이 노트북에서는 다섯분해 함수 하나를 만들어 같은 행렬에 다섯을 전부 걸어 보고,
어느 것이 걸리고 어느 것이 통과하는지 표로 확인한다. 그다음 네 부분공간 최종본을
회전 가능한 그림으로 그리고, 마지막으로 이 책에 나온 행렬을 전부 모아 한 번씩
통과시킨다.
| 서술 파트의 내용 | 여기서 확인하는 방법 |
|---|---|
| 다섯 개의 분해 | 다섯분해(A) 한 줄 |
| 는 다섯을 다 통과한다 | 아래 세 줄의 숫자가 같아진다 |
| 조건은 사슬이 아니다 | 반례 셋을 직접 만든다 |
| 인구조사 80 / 80 / 20 / 60 / 100 | 무작위 행렬 2000개 |
손으로 세운 값과 svd 대조 | |
| 는 로 얻는다 | 따로 구하면 부호가 깨진다 |
| 네 부분공간 최종본 | 회전 가능한 3차원 그림 두 장 |
| 투영행렬 성질 | |
| 옳은 공식과 쓰는 공식 | 연산 횟수와 조건수 |
| 좋은 기저 | FFT 와 대각화 |
| 조건이 없는 도구 | 복소 고윳값 88% |
| 행렬 동물원 | 마지막으로 한 번씩 |
0. 준비¶
import numpy as np
import plotly.graph_objects as go
from scipy.linalg import lu
from linalg_viz import COLORS, arrow, layout3d, line, plane, show_matrix, slider_figure
np.set_printoptions(precision=4, suppress=True)
rng = np.random.default_rng(34)
print("numpy", np.__version__)numpy 2.5.2
# 이 강의에서 계속 쓸 두 행렬
T = np.array([[2.0, -1.0, 0.0],
[-1.0, 2.0, -1.0],
[0.0, -1.0, 2.0]]) # L27 의 삼중대각행렬
A = np.array([[1.0, 1.0, 2.0],
[1.0, 0.0, 1.0],
[0.0, 1.0, 1.0]]) # L29 의 C 에 열 하나를 더한 것
print(show_matrix(T, "T 다섯을 전부 통과한다"))
print(show_matrix(A, "A 마지막 그림의 주인공. 3열 = 1열 + 2열"))
print("A 의 앞 두 열이 L29 의 앵커 C 인가 :",
np.allclose(A[:, :2], np.array([[1.0, 1], [1, 0], [0, 1]])))T 다섯을 전부 통과한다
[ 2 -1 0 ]
[ -1 2 -1 ]
[ 0 -1 2 ]
A 마지막 그림의 주인공. 3열 = 1열 + 2열
[ 1 1 2 ]
[ 1 0 1 ]
[ 0 1 1 ]
A 의 앞 두 열이 L29 의 앵커 C 인가 : True
1. 다섯분해 — 한 행렬에 다섯을 전부 건다¶
각 줄이 무엇을 요구하고 무엇을 돌려주는지를 그대로 코드로 옮긴다. 요구가 맞지 않으면 왜 맞지 않는지도 함께 적는다.
def 다섯분해(M, 이름="A", 출력=True):
"""같은 행렬을 다섯 가지로 쪼개고, 각각이 무엇을 알려주는지 한눈에 본다.
돌려주는 것은 {분해이름: (가능한가, 설명, 조각들)} 형태의 사전이다.
"""
M = np.asarray(M, dtype=float)
m, n = M.shape
r = np.linalg.matrix_rank(M)
정방 = m == n
보고 = {}
# 1) A = LU — 정방이고 행 교환 없이 소거가 되어야 한다.
# 선행 주소행렬식으로 재면 안 된다. 그것은 피벗이 n 개 다 잡히는 것까지
# 요구해서 [[1,2],[2,4]] 같은 특이행렬을 떨어뜨린다. 그 행렬은 교환 없이
# 소거되고 L @ U 가 정확히 원래 행렬이다. 그래서 직접 소거해 본다.
if not 정방:
보고["A = LU"] = (False, "정방이 아니다", ())
else:
허용 = 1e-11 * max(1.0, float(np.abs(M).max()))
Ue, Le, 걸린곳 = M.copy(), np.eye(n), 0
for k in range(n):
if abs(Ue[k, k]) <= 허용:
if np.all(np.abs(Ue[k + 1:, k]) <= 허용):
continue # 바꿀 행이 없다. 자유열이니 넘어간다
걸린곳 = k + 1
break
곱수 = Ue[k + 1:, k] / Ue[k, k]
Le[k + 1:, k] = 곱수
Ue[k + 1:, k:] -= np.outer(곱수, Ue[k, k:])
피벗 = np.diag(Ue)
곱 = float(np.prod(피벗))
곱 = 0.0 if 곱 == 0.0 else 곱 # -0.0 이 찍히는 것을 막는다
if 걸린곳:
보고["A = LU"] = (False, f"{걸린곳}번째 피벗 자리가 0이라 행을 바꿔야 한다",
lu(M))
else:
꼬리 = ("" if float(np.abs(피벗).min()) > 허용
else " (0 인 피벗이 있어 후진 대입은 못 한다)")
보고["A = LU"] = (True,
f"피벗 {np.round(피벗, 4)}, 곱 {곱:.4f}{꼬리}",
(np.eye(n), Le, Ue))
# 2) A = QR — 열이 독립이어야 한다
Q, R = np.linalg.qr(M)
if r == n:
보고["A = QR"] = (True, f"|det R| = {abs(np.prod(np.diag(R))):.4f}", (Q, R))
else:
보고["A = QR"] = (False, f"열이 종속 (랭크 {r} < 열 {n})", (Q, R))
# 3) S = Q L Q^T — 대칭이어야 한다
if 정방 and np.allclose(M, M.T):
w, V = np.linalg.eigh(M)
순 = np.argsort(w)[::-1]
부호 = "전부 양수" if w.min() > 1e-12 else ("전부 음이 아님" if w.min() > -1e-12
else "부호가 섞임")
보고["S = Q L Q^T"] = (True, f"lambda {np.round(w[순], 4)}, {부호}",
(V[:, 순], w[순]))
else:
보고["S = Q L Q^T"] = (False, "대칭이 아니다" if 정방 else "정방이 아니다", ())
# 4) A = S L S^-1 — 독립인 고유벡터가 n 개여야 한다
if 정방:
w2, S = np.linalg.eig(M)
cond = np.linalg.cond(S)
복소 = np.max(np.abs(w2.imag)) > 1e-9
if cond < 1e8:
꼬리 = " (복소수에서만)" if 복소 else ""
보고["A = S L S^-1"] = (True, f"cond(S) = {cond:.4g}{꼬리}", (S, w2))
else:
보고["A = S L S^-1"] = (False, f"고유벡터가 모자란다 (cond(S) = {cond:.2e})",
(S, w2))
else:
보고["A = S L S^-1"] = (False, "정방이 아니다", ())
# 5) A = U S V^T — 요구하는 것이 없다
U_, s, Vt = np.linalg.svd(M)
조건수 = s[0] / s[-1] if s[-1] > 1e-14 else np.inf
보고["A = U S V^T"] = (True, f"sigma {np.round(s, 4)}, cond {조건수:.4g}",
(U_, s, Vt))
if 출력:
print(f"{이름} ({m}x{n}, 랭크 {r}, "
f"{'대칭' if 정방 and np.allclose(M, M.T) else '비대칭'})")
print("-" * 78)
for 줄, (됨, 설명, _) in 보고.items():
print(f" {줄:<14}{'O' if 됨 else 'X'} {설명}")
통과 = sum(1 for 됨, _, _ in 보고.values() if 됨)
print(f" {'':<14} -> 다섯 중 {통과}개 통과")
return 보고보T = 다섯분해(T, "T = tridiag(-1, 2, -1)")T = tridiag(-1, 2, -1) (3x3, 랭크 3, 대칭)
------------------------------------------------------------------------------
A = LU O 피벗 [2. 1.5 1.3333], 곱 4.0000
A = QR O |det R| = 4.0000
S = Q L Q^T O lambda [3.4142 2. 0.5858], 전부 양수
A = S L S^-1 O cond(S) = 1
A = U S V^T O sigma [3.4142 2. 0.5858], cond 5.828
-> 다섯 중 5개 통과
보A = 다섯분해(A, "A = [[1,1,2],[1,0,1],[0,1,1]]")A = [[1,1,2],[1,0,1],[0,1,1]] (3x3, 랭크 2, 비대칭)
------------------------------------------------------------------------------
A = LU O 피벗 [ 1. -1. 0.], 곱 0.0000 (0 인 피벗이 있어 후진 대입은 못 한다)
A = QR X 열이 종속 (랭크 2 < 열 3)
S = Q L Q^T X 대칭이 아니다
A = S L S^-1 O cond(S) = 6.029
A = U S V^T O sigma [3. 1. 0.], cond inf
-> 다섯 중 3개 통과
서술 파트의 숫자와 대조¶
P, L, U = 보T["A = LU"][2]
print("T 의 피벗 :", np.diag(U), " 서술의 2, 1.5, 4/3 인가 :",
np.allclose(np.diag(U), [2.0, 1.5, 4/3]))
print("행 교환이 있었는가 :", not np.allclose(P, np.eye(3)),
" L @ U 가 T 인가 :", np.allclose(L @ U, T))
print("피벗의 곱 :", np.prod(np.diag(U)), " det T :", np.linalg.det(T))
print()
Q, R = 보T["A = QR"][2]
print("|det R| :", abs(np.prod(np.diag(R))), " <- 직교행렬은 부피를 안 바꾼다")
print()
_, s, _ = 보T["A = U S V^T"][2]
print("T 의 특이값 :", s)
print("2+sqrt2, 2, 2-sqrt2 :", [2 + np.sqrt(2), 2.0, 2 - np.sqrt(2)])
print("cond(T) :", np.linalg.cond(T), " 3 + 2 sqrt2 :", 3 + 2*np.sqrt(2))
print("sigma 의 곱 :", np.prod(s), " |det T| :", abs(np.linalg.det(T)))T 의 피벗 : [2. 1.5 1.3333] 서술의 2, 1.5, 4/3 인가 : True
행 교환이 있었는가 : False L @ U 가 T 인가 : True
피벗의 곱 : 4.0 det T : 4.0
|det R| : 4.0 <- 직교행렬은 부피를 안 바꾼다
T 의 특이값 : [3.4142 2. 0.5858]
2+sqrt2, 2, 2-sqrt2 : [np.float64(3.414213562373095), 2.0, np.float64(0.5857864376269049)]
cond(T) : 5.8284271247461925 3 + 2 sqrt2 : 5.82842712474619
sigma 의 곱 : 3.9999999999999973 |det T| : 4.0
아래 세 줄은 정말 같은 것인가¶
부호는 관례일 뿐이므로, 열마다 0이 아닌 첫 성분을 양수로 맞춘 뒤에 견준다.
def 부호맞춤(M):
"""열마다 0이 아닌 첫 성분이 양수가 되도록 부호를 고른다."""
M = np.array(M, dtype=float)
for j in range(M.shape[1]):
큰것 = np.flatnonzero(np.abs(M[:, j]) > 1e-8)
if 큰것.size and M[큰것[0], j] < 0:
M[:, j] *= -1
return MQs, 람 = 보T["S = Q L Q^T"][2]
S2, 람2 = 보T["A = S L S^-1"][2]
순2 = np.argsort(람2.real)[::-1]
Uu, 시그, Vt = 보T["A = U S V^T"][2]
Qs, S2, Uu, Vv = (부호맞춤(Qs), 부호맞춤(S2[:, 순2].real),
부호맞춤(Uu), 부호맞춤(Vt.T))
print(show_matrix(Qs, "Q (eigh)"))
print(show_matrix(S2, "S (eig)"))
print(show_matrix(Uu, "U (svd)"))
print(show_matrix(Vv, "V (svd)"))
print("Q == S :", np.allclose(Qs, S2))
print("Q == U :", np.allclose(Qs, Uu))
print("U == V :", np.allclose(Uu, Vv))
print("lambda == sigma :", np.allclose(np.sort(람)[::-1], 시그))
print()
print("-> 대칭 양정치라서 세 분해가 하나로 무너진다.")Q (eigh)
[ 0.5 0.707 0.5 ]
[ -0.707 -3.12e-16 0.707 ]
[ 0.5 -0.707 0.5 ]
S (eig)
[ 0.5 0.707 0.5 ]
[ -0.707 -4.05e-16 0.707 ]
[ 0.5 -0.707 0.5 ]
U (svd)
[ 0.5 0.707 0.5 ]
[ -0.707 3.89e-16 0.707 ]
[ 0.5 -0.707 0.5 ]
V (svd)
[ 0.5 0.707 0.5 ]
[ -0.707 -1.11e-16 0.707 ]
[ 0.5 -0.707 0.5 ]
Q == S : True
Q == U : True
U == V : True
lambda == sigma : True
-> 대칭 양정치라서 세 분해가 하나로 무너진다.
그런데 에서는 둘이 걸린다¶
for 줄 in ("A = LU", "A = QR", "S = Q L Q^T", "A = S L S^-1", "A = U S V^T"):
됨, 설명, _ = 보A[줄]
print(f"{줄:<14}{'통과' if 됨 else '실패':<6}{설명}")
print()
print("A 의 고윳값 :", np.sort(np.linalg.eigvals(A).real))
print("0, 1-sqrt2, 1+sqrt2 :", sorted([0.0, 1 - np.sqrt(2), 1 + np.sqrt(2)]))
print()
print("-> 랭크가 모자라 QR 은 안 되는데, 고윳값이 서로 달라 대각화는 된다.")
print(" LU 는 통과한다. 교환 없이 소거가 끝나고 마지막 피벗만 0 이다.")A = LU 통과 피벗 [ 1. -1. 0.], 곱 0.0000 (0 인 피벗이 있어 후진 대입은 못 한다)
A = QR 실패 열이 종속 (랭크 2 < 열 3)
S = Q L Q^T 실패 대칭이 아니다
A = S L S^-1 통과 cond(S) = 6.029
A = U S V^T 통과 sigma [3. 1. 0.], cond inf
A 의 고윳값 : [-0.4142 -0. 2.4142]
0, 1-sqrt2, 1+sqrt2 : [np.float64(-0.41421356237309515), 0.0, np.float64(2.414213562373095)]
-> 랭크가 모자라 QR 은 안 되는데, 고윳값이 서로 달라 대각화는 된다.
LU 는 통과한다. 교환 없이 소거가 끝나고 마지막 피벗만 0 이다.
2. 조건은 사슬이 아니다¶
서술 파트의 반례 셋을 코드로 확인한다.
반례 = (
("대칭인데 LU 가 없다", np.array([[0.0, 1.0], [1.0, 0.0]]), "S = Q L Q^T", "A = LU"),
("LU 는 되는데 대각화가 안 된다", np.array([[3.0, 1.0], [0.0, 3.0]]),
"A = LU", "A = S L S^-1"),
("대각화는 되는데 QR 이 없다", A, "A = S L S^-1", "A = QR"),
)
for 제목, M, 되는것, 안되는것 in 반례:
보 = 다섯분해(M, 제목, 출력=False)
print(f"{제목}")
print(f" {되는것:<14}{'O' if 보[되는것][0] else 'X'} {보[되는것][1]}")
print(f" {안되는것:<14}{'O' if 보[안되는것][0] else 'X'} {보[안되는것][1]}")
print()
print("-> 어느 조건도 다른 조건을 품지 않는다.")대칭인데 LU 가 없다
S = Q L Q^T O lambda [ 1. -1.], 부호가 섞임
A = LU X 1번째 피벗 자리가 0이라 행을 바꿔야 한다
LU 는 되는데 대각화가 안 된다
A = LU O 피벗 [3. 3.], 곱 9.0000
A = S L S^-1 X 고유벡터가 모자란다 (cond(S) = 3.00e+15)
대각화는 되는데 QR 이 없다
A = S L S^-1 O cond(S) = 6.029
A = QR X 열이 종속 (랭크 2 < 열 3)
-> 어느 조건도 다른 조건을 품지 않는다.
참인 포함은 하나뿐이다¶
대칭이면 반드시 대각화된다. 그 역은 거짓이다.
print("대칭 행렬 500개를 만들어 전부 대각화되는지 본다")
깨진것 = 0
for _ in range(500):
n = int(rng.integers(2, 6))
B = rng.normal(size=(n, n))
Sym = B + B.T
_, V = np.linalg.eigh(Sym)
if not np.allclose(V @ V.T, np.eye(n)):
깨진것 += 1
print(" 고유벡터가 정규직교가 아닌 경우 :", 깨진것, "/ 500")
print()
print("역은 거짓인가 : M = [[2,1],[0,3]]")
M = np.array([[2.0, 1.0], [0.0, 3.0]])
w, V = np.linalg.eig(M)
print(" 고윳값", w, " cond(V)", f"{np.linalg.cond(V):.4f}", " -> 대각화 O")
print(" 대칭인가 :", np.allclose(M, M.T), " -> 대칭 X")대칭 행렬 500개를 만들어 전부 대각화되는지 본다
고유벡터가 정규직교가 아닌 경우 : 0 / 500
역은 거짓인가 : M = [[2,1],[0,3]]
고윳값 [2.+0.j 3.+0.j] cond(V) 2.4142 -> 대각화 O
대칭인가 : False -> 대칭 X
인구조사 — 무작위 행렬 2000개¶
대각화 가능 여부는 만들 때 정해 둔 사실을 쓴다. numpy 로 판정하면 결함 행렬을 대각화 가능이라고 잘못 보고하기 때문이다(L28). 그 오보율도 함께 잰다.
def 무작위행렬(종류, rng):
"""다섯 족(族)에서 하나씩 뽑는다. (행렬, 대각화 가능한가) 를 돌려준다."""
n = int(rng.integers(2, 6))
if 종류 == "일반 정방":
return rng.normal(size=(n, n)), True
if 종류 == "대칭":
B = rng.normal(size=(n, n))
return B + B.T, True
if 종류 == "결함": # 조르당 블록을 닮음변환으로 감춘다
J = np.eye(n) * rng.normal()
for i in range(n - 1):
J[i, i + 1] = 1.0
Mv = rng.normal(size=(n, n))
while abs(np.linalg.det(Mv)) < 1e-2:
Mv = rng.normal(size=(n, n))
return Mv @ J @ np.linalg.inv(Mv), False
if 종류 == "랭크부족":
r = max(1, n - 1)
return rng.normal(size=(n, r)) @ rng.normal(size=(r, n)), None
if 종류 == "직사각":
m = n + int(rng.integers(1, 4))
return rng.normal(size=(m, n)), False
raise ValueError(종류)종류들 = ["일반 정방", "대칭", "결함", "랭크부족", "직사각"]
줄이름 = ["A = LU", "A = QR", "S = Q L Q^T", "A = S L S^-1", "A = U S V^T"]
N = 400
속은횟수, 조건수들 = 0, []
집계 = {}
rng2 = np.random.default_rng(34)
for 종류 in 종류들:
합 = np.zeros(5)
for _ in range(N):
M, 참 = 무작위행렬(종류, rng2)
보 = 다섯분해(M, 출력=False)
표 = [보[k][0] for k in 줄이름]
if 참 is not None: # 대각화는 만들 때의 사실을 쓴다
표[3] = bool(참) and M.shape[0] == M.shape[1]
합 += np.array(표, dtype=float)
if 종류 == "결함":
_, V = np.linalg.eig(M)
조건수들.append(np.linalg.cond(V))
속은횟수 += int(np.linalg.matrix_rank(V) == M.shape[0])
집계[종류] = 합 / N * 100
print(f"{'':>10}" + "".join(f"{k:>14}" for k in 줄이름))
for 종류 in 종류들:
print(f"{종류:>10}" + "".join(f"{v:>13.1f}%" for v in 집계[종류]))
전체 = np.mean([집계[k] for k in 종류들], axis=0)
print(f"{'전체':>10}" + "".join(f"{v:>13.1f}%" for v in 전체))
print()
print("서술 파트의 80 / 80 / 20 / 60 / 100 과 맞는가 :", np.round(전체, 1))
print("LU 와 QR 이 똑같이 80% 인데, LU 는 직사각에서 QR 은 랭크부족에서 걸린다.") A = LU A = QR S = Q L Q^T A = S L S^-1 A = U S V^T
일반 정방 100.0% 100.0% 0.0% 100.0% 100.0%
대칭 100.0% 100.0% 100.0% 100.0% 100.0%
결함 100.0% 100.0% 0.0% 0.0% 100.0%
랭크부족 100.0% 0.0% 0.0% 100.0% 100.0%
직사각 0.0% 100.0% 0.0% 0.0% 100.0%
전체 80.0% 80.0% 20.0% 60.0% 100.0%
서술 파트의 80 / 80 / 20 / 60 / 100 과 맞는가 : [ 80. 80. 20. 60. 100.]
LU 와 QR 이 똑같이 80% 인데, LU 는 직사각에서 QR 은 랭크부족에서 걸린다.
print(f"결함 행렬 {N}개에 numpy 를 그대로 믿으면")
print(f" matrix_rank(V) 가 '꽉 찼다'고 본 비율 : {속은횟수/N*100:.1f}%")
print(f" 고유벡터 행렬 조건수 중앙값 : {np.median(조건수들):.3e}")
print(f" 그중 가장 작은 것 : {min(조건수들):.3e}")
print()
print("-> 조르당 형은 수치적으로 존재하지 않는다는 L28 의 결론 그대로다.")결함 행렬 400개에 numpy 를 그대로 믿으면
matrix_rank(V) 가 '꽉 찼다'고 본 비율 : 95.0%
고유벡터 행렬 조건수 중앙값 : 2.398e+11
그중 가장 작은 것 : 3.606e+07
-> 조르당 형은 수치적으로 존재하지 않는다는 L28 의 결론 그대로다.
3. 네 부분공간 최종본¶
이 강의의 결론이다. 의 SVD 를 손으로 세운 값과 대조하고, 네 칸을 채우고, 와 의 화살표를 양쪽으로 그린다.
print(show_matrix(A.T @ A, "A^T A"))
print("눈으로 찾은 고유벡터로 확인")
for v, 배 in (([1.0, 1, 2], 9), ([1.0, -1, 0], 1), ([1.0, 1, -1], 0)):
v = np.array(v)
print(f" A^T A {str(v):<12} = {str(A.T @ A @ v):<16} = {배} x {v} "
f"{np.allclose(A.T @ A @ v, 배 * v)}")
print()
print("특이값 :", np.linalg.svd(A, compute_uv=False))
print("sqrt(9), sqrt(1), sqrt(0) :", [3.0, 1.0, 0.0])A^T A
[ 2 1 3 ]
[ 1 2 3 ]
[ 3 3 6 ]
눈으로 찾은 고유벡터로 확인
A^T A [1. 1. 2.] = [ 9. 9. 18.] = 9 x [1. 1. 2.] True
A^T A [ 1. -1. 0.] = [ 1. -1. 0.] = 1 x [ 1. -1. 0.] True
A^T A [ 1. 1. -1.] = [0. 0. 0.] = 0 x [ 1. 1. -1.] True
특이값 : [3. 1. 0.]
sqrt(9), sqrt(1), sqrt(0) : [3.0, 1.0, 0.0]
v1 = np.array([1.0, 1, 2]) / np.sqrt(6)
v2 = np.array([1.0, -1, 0]) / np.sqrt(2)
v3 = np.array([1.0, 1, -1]) / np.sqrt(3)
시그마 = np.array([3.0, 1.0, 0.0])
u1 = A @ v1 / 시그마[0] # u 는 반드시 A v / sigma 로 얻는다
u2 = A @ v2 / 시그마[1]
u3 = np.array([-1.0, 1, 1]) / np.sqrt(3)
V손 = np.column_stack([v1, v2, v3])
U손 = np.column_stack([u1, u2, u3])
print("u1 =", u1, " (2,1,1)/sqrt6 =", np.array([2.0, 1, 1])/np.sqrt(6))
print("u2 =", u2, " (0,1,-1)/sqrt2 =", np.array([0.0, 1, -1])/np.sqrt(2))
print()
print("V 가 직교인가 :", np.allclose(V손.T @ V손, np.eye(3)))
print("U 가 직교인가 :", np.allclose(U손.T @ U손, np.eye(3)))
print("U S V^T 로 A 가 복원되는가 :",
np.allclose(U손 @ np.diag(시그마) @ V손.T, A),
" 오차", f"{np.linalg.norm(U손 @ np.diag(시그마) @ V손.T - A):.2e}")
print()
print("A v3 =", np.round(A @ v3, 12), " (영공간)")
print("A^T u3 =", np.round(A.T @ u3, 12), " (좌영공간)")u1 = [0.8165 0.4082 0.4082] (2,1,1)/sqrt6 = [0.8165 0.4082 0.4082]
u2 = [ 0. 0.7071 -0.7071] (0,1,-1)/sqrt2 = [ 0. 0.7071 -0.7071]
V 가 직교인가 : True
U 가 직교인가 : True
U S V^T 로 A 가 복원되는가 : True 오차 6.89e-16
A v3 = [0. 0. 0.] (영공간)
A^T u3 = [0. 0. 0.] (좌영공간)
를 따로 구하면 어떻게 되는가¶
L29에서 경고한 그 함정이다. 에서 를 따로 뽑으면 부호가 와 맞는다는 보장이 없다.
w, U따로 = np.linalg.eigh(A @ A.T)
순 = np.argsort(w)[::-1]
U따로 = U따로[:, 순]
print(show_matrix(U따로, "A A^T 의 고유벡터를 따로 뽑은 것"))
print(show_matrix(U손, "A v / sigma 로 얻은 것"))
print()
복원 = U따로 @ np.diag(시그마) @ V손.T
print("따로 뽑은 U 로 복원하면 A 가 되는가 :", np.allclose(복원, A))
print("복원 오차 :", f"{np.linalg.norm(복원 - A):.4f}")
print("어느 열의 부호가 뒤집혔는가 :",
[j for j in range(3) if not np.allclose(U따로[:, j], U손[:, j])])A A^T 의 고유벡터를 따로 뽑은 것
[ -0.816 0 -0.577 ]
[ -0.408 -0.707 0.577 ]
[ -0.408 0.707 0.577 ]
A v / sigma 로 얻은 것
[ 0.816 0 -0.577 ]
[ 0.408 0.707 0.577 ]
[ 0.408 -0.707 0.577 ]
따로 뽑은 U 로 복원하면 A 가 되는가 : False
복원 오차 : 6.3246
어느 열의 부호가 뒤집혔는가 : [0, 1]
화살표를 양쪽으로¶
A플러스 = np.linalg.pinv(A)
print(show_matrix(A플러스 * 9, "9 A^+ <- 서술의 [[1,5,-4],[1,-4,5],[2,1,1]] 인가"))
print("정수 행렬과 같은가 :",
np.allclose(A플러스 * 9, [[1, 5, -4], [1, -4, 5], [2, 1, 1]]))
print()
print(f"{'':>6}{'A v = sigma u':>34}{'A^+ u = v / sigma':>34}")
for i, (v, u, s) in enumerate(zip([v1, v2, v3], [u1, u2, u3], 시그마), 1):
앞 = np.allclose(A @ v, s * u)
뒤 = np.allclose(A플러스 @ u, v / s) if s > 0 else np.allclose(A플러스 @ u, 0)
print(f" i={i} {str(np.round(A @ v, 4)):>28}{str(np.round(A플러스 @ u, 4)):>34}"
f" {앞 and 뒤}")
print()
print("i=3 은 양쪽 다 0 이다. 그것이 편도라는 뜻이다.")9 A^+ <- 서술의 [[1,5,-4],[1,-4,5],[2,1,1]] 인가
[ 1 5 -4 ]
[ 1 -4 5 ]
[ 2 1 1 ]
정수 행렬과 같은가 : True
A v = sigma u A^+ u = v / sigma
i=1 [2.4495 1.2247 1.2247] [0.1361 0.1361 0.2722] True
i=2 [ 0. 0.7071 -0.7071] [ 0.7071 -0.7071 -0. ] True
i=3 [0. 0. 0.] [-0. 0. 0.] True
i=3 은 양쪽 다 0 이다. 그것이 편도라는 뜻이다.
왼투영, 오른투영 = A플러스 @ A, A @ A플러스
print(show_matrix(왼투영, "A^+ A 행공간으로의 투영"))
print(show_matrix(오른투영, "A A^+ 열공간으로의 투영"))
print("I - v3 v3^T 와 같은가 :", np.allclose(왼투영, np.eye(3) - np.outer(v3, v3)))
print("I - u3 u3^T 와 같은가 :", np.allclose(오른투영, np.eye(3) - np.outer(u3, u3)))
print()
for 이름, P in (("A^+A", 왼투영), ("AA^+", 오른투영)):
print(f" {이름} : P^2=P {np.allclose(P@P, P)}, P^T=P {np.allclose(P.T, P)}, "
f"고윳값 {np.round(np.linalg.eigvalsh(P), 10)}")
print()
print("-> 살렸다, 살렸다, 죽였다.")A^+ A 행공간으로의 투영
[ 0.667 -0.333 0.333 ]
[ -0.333 0.667 0.333 ]
[ 0.333 0.333 0.667 ]
A A^+ 열공간으로의 투영
[ 0.667 0.333 0.333 ]
[ 0.333 0.667 -0.333 ]
[ 0.333 -0.333 0.667 ]
I - v3 v3^T 와 같은가 : True
I - u3 u3^T 와 같은가 : True
A^+A : P^2=P True, P^T=P True, 고윳값 [0. 1. 1.]
AA^+ : P^2=P True, P^T=P True, 고윳값 [0. 1. 1.]
-> 살렸다, 살렸다, 죽였다.
회전 가능한 그림 두 장¶
정의역 쪽 과 치역 쪽 을 따로 그린다. 끌어서 돌려 보면 평면과 직선이 정말 수직인지 보인다.
traces = [plane(spans=[v1, v2], extent=2.4, color=COLORS["colspace"],
opacity=0.30, name="행공간 (v1, v2 의 평면)"),
line(v3, extent=2.8, color=COLORS["nullspace"], name="영공간 (v3 방향)")]
traces += arrow(np.zeros(3), 1.4 * v1, color=COLORS["input"], name="v1")
traces += arrow(np.zeros(3), 1.4 * v2, color=COLORS["second"], name="v2")
traces += arrow(np.zeros(3), 1.4 * v3, color=COLORS["nullspace"], name="v3")
go.Figure(traces, layout=layout3d("정의역 R^3 — 행공간(평면) + 영공간(직선)",
extent=2.6, axis_names=("x1", "x2", "x3")))traces = [plane(spans=[u1, u2], extent=2.4, color=COLORS["output"],
opacity=0.25, name="열공간 (u1, u2 의 평면)"),
line(u3, extent=2.8, color=COLORS["nullspace"], name="좌영공간 (u3 방향)")]
traces += arrow(np.zeros(3), 3.0 * u1, color=COLORS["output"], name="A v1 = 3 u1")
traces += arrow(np.zeros(3), 1.0 * u2, color=COLORS["second"], name="A v2 = 1 u2")
traces += arrow(np.zeros(3), 1.4 * u3, color=COLORS["nullspace"], name="u3 (아무것도 안 온다)")
go.Figure(traces, layout=layout3d("치역 R^3 — 열공간(평면) + 좌영공간(직선)",
extent=2.6, axis_names=("y1", "y2", "y3")))공간이 눌리는 것을 슬라이더로¶
구면 위의 점을 로 옮긴다. 에서 전부 열공간의 평면 위에 앉는다.
표본 = rng.normal(size=(600, 3))
표본 /= np.linalg.norm(표본, axis=1, keepdims=True)
프레임, 이름표 = [], []
for t in np.linspace(0.0, 1.0, 11):
Y = (1 - t) * 표본 + t * (표본 @ A.T)
프레임.append([
plane(spans=[u1, u2], extent=2.6, color=COLORS["output"], opacity=0.22,
grid=6, name="열공간"), # 평평한 면이라 격자는 성기게
go.Scatter3d(x=Y[:, 0], y=Y[:, 1], z=Y[:, 2], mode="markers",
marker=dict(size=2.4, color=COLORS["input"]),
name=f"점 600개 (t = {t:.1f})"),
])
이름표.append(f"{t:.1f}")
배치 = layout3d("구면이 열공간의 평면으로 눌린다", extent=3.2,
axis_names=("y1", "y2", "y3"))
배치["height"] = 600
slider_figure(프레임, 이름표, 배치, prefix="t = ", initial=0)Y = 표본 @ A.T
거리 = np.abs(Y @ u3) # 열공간까지의 거리
print("t = 1 에서 모든 점이 열공간 위에 있는가")
print(" u3 방향 성분의 최댓값 :", f"{거리.max():.3e}")
print(" (0 이면 평면 위에 정확히 앉았다는 뜻이다)")
print()
print("부피는 얼마나 남았는가 : |det A| =", f"{abs(np.linalg.det(A)):.3e}")
print(" 0 이다. 3차원 덩어리가 2차원 평면이 되었으니 부피가 남을 리 없다.")t = 1 에서 모든 점이 열공간 위에 있는가
u3 방향 성분의 최댓값 : 2.505e-16
(0 이면 평면 위에 정확히 앉았다는 뜻이다)
부피는 얼마나 남았는가 : |det A| = 0.000e+00
0 이다. 3차원 덩어리가 2차원 평면이 되었으니 부피가 남을 리 없다.
import math
print(f"{'n':>4}{'크래머 (n+1)n!':>18}{'여인수 n!':>16}{'소거 n^3/3':>14}{'배수':>12}")
for n in (5, 10, 15, 20, 25):
크 = float(n + 1) * math.factorial(n)
여 = float(math.factorial(n))
소 = n ** 3 / 3
print(f"{n:>4}{크:>18.3e}{여:>16.3e}{소:>14.1f}{크/소:>12.2e}")
print()
print("-> n = 20 에서 5.1e19 대 2667 이다. 아름다움에는 값이 붙는다.") n 크래머 (n+1)n! 여인수 n! 소거 n^3/3 배수
5 7.200e+02 1.200e+02 41.7 1.73e+01
10 3.992e+07 3.629e+06 333.3 1.20e+05
15 2.092e+13 1.308e+12 1125.0 1.86e+10
20 5.109e+19 2.433e+18 2666.7 1.92e+16
25 4.033e+26 1.551e+25 5208.3 7.74e+22
-> n = 20 에서 5.1e19 대 2667 이다. 아름다움에는 값이 붙는다.
print("조르당 형 : eps 를 흔들면 고유벡터 행렬이 얼마나 나빠지는가")
print(f"{'eps':>10}{'고윳값':>28}{'cond(S)':>14}{'1/sqrt(eps)':>14}")
for e in (0.0, 1e-12, 1e-10, 1e-8, 1e-6, 1e-4):
J = np.array([[3.0, 1.0], [e, 3.0]])
w, V = np.linalg.eig(J)
이론 = np.inf if e == 0 else 1 / np.sqrt(e)
갈림 = f"3 +- {abs(w[0] - w[1]) / 2:.3e}" if abs(w[0] - w[1]) > 0 else "3, 3 (구별 못 함)"
print(f"{e:>10.0e}{갈림:>28}{np.linalg.cond(V):>14.3e}{이론:>14.3e}")
print()
print("-> cond(S) 가 1/sqrt(eps) 를 따라간다. L28 의 그 법칙이다.")조르당 형 : eps 를 흔들면 고유벡터 행렬이 얼마나 나빠지는가
eps 고윳값 cond(S) 1/sqrt(eps)
0e+00 3, 3 (구별 못 함) 3.002e+15 inf
1e-12 3 +- 1.000e-06 1.000e+06 1.000e+06
1e-10 3 +- 1.000e-05 1.000e+05 1.000e+05
1e-08 3 +- 1.000e-04 1.000e+04 1.000e+04
1e-06 3 +- 1.000e-03 1.000e+03 1.000e+03
1e-04 3 +- 1.000e-02 1.000e+02 1.000e+02
-> cond(S) 가 1/sqrt(eps) 를 따라간다. L28 의 그 법칙이다.
print("정규방정식 : 조건수가 정말 제곱되는가")
print(f"{'cond(A)':>12}{'cond(A^T A)':>16}{'cond(A)^2':>16}{'비':>10}")
for k in (1e2, 1e4, 1e6, 1e8):
m, n = 40, 6
Uq, _ = np.linalg.qr(rng.normal(size=(m, m)))
Vq, _ = np.linalg.qr(rng.normal(size=(n, n)))
X = Uq[:, :n] @ np.diag(np.logspace(0, -np.log10(k), n)) @ Vq.T
k1, k2 = np.linalg.cond(X), np.linalg.cond(X.T @ X)
print(f"{k1:>12.4e}{k2:>16.4e}{k1**2:>16.4e}{k2/k1**2:>10.4f}")
print()
print("-> 마지막 칸이 1 근처다. inv(A.T @ A) @ A.T 를 손으로 쓰지 마라.")정규방정식 : 조건수가 정말 제곱되는가
cond(A) cond(A^T A) cond(A)^2 비
1.0000e+02 1.0000e+04 1.0000e+04 1.0000
1.0000e+04 1.0000e+08 1.0000e+08 1.0000
1.0000e+06 1.0000e+12 1.0000e+12 1.0000
1.0000e+08 1.0621e+16 1.0000e+16 1.0621
-> 마지막 칸이 1 근처다. inv(A.T @ A) @ A.T 를 손으로 쓰지 마라.
좋은 기저를 고르면 어려운 문제가 쉬워진다¶
print("같은 일을 어느 기저에서 하느냐")
print(f"{'N':>10}{'직접 DFT N^2':>16}{'FFT N log2 N':>16}{'배수':>12}")
for N in (2**10, 2**12, 2**16, 2**20):
직 = float(N) ** 2
빠 = N * np.log2(N)
print(f"{N:>10}{직:>16.3e}{빠:>16.3e}{직/빠:>12.1f}")
print()
print("대각화 : A^100 을 어떻게 구하는가")
B = np.array([[0.9, 0.2], [0.1, 0.8]]) # 마코브 행렬 (L24)
w, S = np.linalg.eig(B)
직접 = np.linalg.matrix_power(B, 100)
기저 = (S @ np.diag(w ** 100) @ np.linalg.inv(S)).real
print(" 행렬곱 99번 :", np.round(직접, 8).tolist())
print(" 스칼라 100제곱 2번 :", np.round(기저, 8).tolist())
print(" 같은가 :", np.allclose(직접, 기저))
print(" 고윳값 :", w, " -> |lambda|<1 인 쪽이 사라지고 1 인 쪽만 남는다")같은 일을 어느 기저에서 하느냐
N 직접 DFT N^2 FFT N log2 N 배수
1024 1.049e+06 1.024e+04 102.4
4096 1.678e+07 4.915e+04 341.3
65536 4.295e+09 1.049e+06 4096.0
1048576 1.100e+12 2.097e+07 52428.8
대각화 : A^100 을 어떻게 구하는가
행렬곱 99번 : [[0.66666667, 0.66666667], [0.33333333, 0.33333333]]
스칼라 100제곱 2번 : [[0.66666667, 0.66666667], [0.33333333, 0.33333333]]
같은가 : True
고윳값 : [1. +0.j 0.7+0.j] -> |lambda|<1 인 쪽이 사라지고 1 인 쪽만 남는다
조건이 없는 도구가 가장 강하다¶
개수, 복소인것 = 400, 0
시그마모음 = []
rng34 = np.random.default_rng(34) # 그림 4와 같은 표본을 쓴다
for _ in range(개수):
B = rng34.normal(size=(4, 4))
w = np.linalg.eigvals(B)
시그마모음.extend(np.linalg.svd(B, compute_uv=False))
복소인것 += int(np.max(np.abs(w.imag)) > 1e-9)
print(f"무작위 4x4 실행렬 {개수}개 중 복소 고윳값을 가진 것 : "
f"{복소인것} ({복소인것/개수*100:.0f}%)")
print("특이값 중 음수이거나 복소인 것 :",
int(np.sum(np.array(시그마모음) < 0)), "개")
print()
X = rng34.normal(size=(4, 3))
print("4x3 행렬의 고윳값을 구하려 하면")
try:
np.linalg.eigvals(X)
except np.linalg.LinAlgError as 오류:
print(" LinAlgError :", 오류)
print(" 특이값은 :", np.linalg.svd(X, compute_uv=False))무작위 4x4 실행렬 400개 중 복소 고윳값을 가진 것 : 351 (88%)
특이값 중 음수이거나 복소인 것 : 0 개
4x3 행렬의 고윳값을 구하려 하면
LinAlgError : Last 2 dimensions of the array must be square
특이값은 : [1.9209 1.0529 0.6578]
print("랭크가 떨어지는 순간 무엇이 무너지는가")
print(f"{'t':>10}{'세 번째 피벗':>16}{'|R33|':>14}{'sigma_3':>14}{'sigma_3/t':>12}")
for t in (1e-1, 1e-3, 1e-5, 1e-7, 1e-9, 0.0):
M = A.copy()
M[2, 2] += t
_, _, Um = lu(M)
_, Rm = np.linalg.qr(M)
s3 = np.linalg.svd(M, compute_uv=False)[-1]
비 = s3 / t if t else float("nan")
print(f"{t:>10.0e}{abs(Um[2,2]):>16.3e}{abs(Rm[2,2]):>14.3e}{s3:>14.3e}"
f"{비:>12.4f}")
print()
print("-> 셋 다 0 으로 간다. 다만 LU 와 QR 은 '나눌 수 없다'로 끝나고,")
print(" sigma_3 은 '얼마나 가까운가'를 숫자로 남긴다.")랭크가 떨어지는 순간 무엇이 무너지는가
t 세 번째 피벗 |R33| sigma_3 sigma_3/t
1e-01 1.000e-01 5.774e-02 3.294e-02 0.3294
1e-03 1.000e-03 5.774e-04 3.333e-04 0.3333
1e-05 1.000e-05 5.774e-06 3.333e-06 0.3333
1e-07 1.000e-07 5.774e-08 3.333e-08 0.3333
1e-09 1.000e-09 5.773e-10 3.333e-10 0.3333
0e+00 0.000e+00 0.000e+00 1.622e-16 nan
-> 셋 다 0 으로 간다. 다만 LU 와 QR 은 '나눌 수 없다'로 끝나고,
sigma_3 은 '얼마나 가까운가'를 숫자로 남긴다.
5. 행렬 동물원, 마지막으로 한 번¶
이 책에 나온 행렬을 전부 모아 다섯 분해에 통과시킨다.
동물원 = [
("L2 치환 [[0,1],[1,0]]", np.array([[0.0, 1.0], [1.0, 0.0]])),
("L11 랭크 1 [[1,2],[2,4]]", np.array([[1.0, 2.0], [2.0, 4.0]])),
("L16 최소제곱 3x2", np.array([[1.0, 1], [1, 2], [1, 3]])),
("L20 [[3,0],[4,5]]", np.array([[3.0, 0.0], [4.0, 5.0]])),
("L21 90도 회전", np.array([[0.0, -1.0], [1.0, 0.0]])),
("L24 마코브 [[.9,.2],[.1,.8]]", np.array([[0.9, 0.2], [0.1, 0.8]])),
("L27 S = [[5,4],[4,5]]", np.array([[5.0, 4.0], [4.0, 5.0]])),
("L27 T = tridiag", T),
("L28 결함 [[3,1],[0,3]]", np.array([[3.0, 1.0], [0.0, 3.0]])),
("L29 C = 3x2", np.array([[1.0, 1], [1, 0], [0, 1]])),
("L34 A = 3x3 랭크 2", A),
]
줄이름 = ["A = LU", "A = QR", "S = Q L Q^T", "A = S L S^-1", "A = U S V^T"]
머리 = ["LU", "QR", "QLQ^T", "SLS^-1", "USV^T"]
print(f"{'':>30}" + "".join(f"{h:>9}" for h in 머리) + f"{'통과':>7}")
합계 = np.zeros(5)
for 이름, M in 동물원:
보 = 다섯분해(M, 출력=False)
표 = np.array([보[k][0] for k in 줄이름], dtype=float)
합계 += 표
print(f"{이름:>30}" + "".join(f"{'O' if v else 'X':>9}" for v in 표)
+ f"{int(표.sum()):>7}")
print(f"{'합계':>30}" + "".join(f"{int(v):>9}" for v in 합계)
+ f"{'/ ' + str(len(동물원)):>7}")
print()
print("-> 마지막 열만 꽉 찼다. 이것이 34강의 결론이다.")
print(" (SLS^-1 열은 복소수까지 허용해 센 것이다. 90도 회전이 그런 경우다.)") LU QR QLQ^T SLS^-1 USV^T 통과
L2 치환 [[0,1],[1,0]] X O O O O 4
L11 랭크 1 [[1,2],[2,4]] O X O O O 4
L16 최소제곱 3x2 X O X X O 2
L20 [[3,0],[4,5]] O O X O O 4
L21 90도 회전 X O X O O 3
L24 마코브 [[.9,.2],[.1,.8]] O O X O O 4
L27 S = [[5,4],[4,5]] O O O O O 5
L27 T = tridiag O O O O O 5
L28 결함 [[3,1],[0,3]] O O X X O 3
L29 C = 3x2 X O X X O 2
L34 A = 3x3 랭크 2 O X X O O 3
합계 7 9 4 8 11 / 11
-> 마지막 열만 꽉 찼다. 이것이 34강의 결론이다.
(SLS^-1 열은 복소수까지 허용해 센 것이다. 90도 회전이 그런 경우다.)
print("마지막으로, 이 책의 행렬 전부에 대해 네 부분공간의 차원을 세어 본다")
print(f"{'':>30}{'m':>4}{'n':>4}{'r':>4}{'행공간':>8}{'영공간':>8}"
f"{'열공간':>8}{'좌영공간':>10}")
for 이름, M in 동물원:
m, n = M.shape
r = np.linalg.matrix_rank(M)
print(f"{이름:>30}{m:>4}{n:>4}{r:>4}{r:>8}{n-r:>8}{r:>8}{m-r:>10}")
print()
print("네 칸의 차원이 전부 (m, n, r) 하나로 정해진다. L10 의 차원의 기본정리다.")마지막으로, 이 책의 행렬 전부에 대해 네 부분공간의 차원을 세어 본다
m n r 행공간 영공간 열공간 좌영공간
L2 치환 [[0,1],[1,0]] 2 2 2 2 0 2 0
L11 랭크 1 [[1,2],[2,4]] 2 2 1 1 1 1 1
L16 최소제곱 3x2 3 2 2 2 0 2 1
L20 [[3,0],[4,5]] 2 2 2 2 0 2 0
L21 90도 회전 2 2 2 2 0 2 0
L24 마코브 [[.9,.2],[.1,.8]] 2 2 2 2 0 2 0
L27 S = [[5,4],[4,5]] 2 2 2 2 0 2 0
L27 T = tridiag 3 3 3 3 0 3 0
L28 결함 [[3,1],[0,3]] 2 2 2 2 0 2 0
L29 C = 3x2 3 2 2 2 0 2 1
L34 A = 3x3 랭크 2 3 3 2 2 1 2 1
네 칸의 차원이 전부 (m, n, r) 하나로 정해진다. L10 의 차원의 기본정리다.
마치며...¶
| 서술 파트의 내용 | 이 노트북의 코드 |
|---|---|
| 다섯 개의 분해 | 다섯분해(A) 가 요구와 결과를 한 줄씩 적는다 |
| 의 피벗 | 곱이 4, 그리고 |
| 전부 실수, 전부 양수 | |
| 아래 세 줄이 무너진다 | 부호를 맞추면 |
| 는 대각화되는데 이 없다 | 고윳값 가 서로 다르다 |
| 조건은 사슬이 아니다 | 반례 셋이 전부 재현된다 |
| 인구조사 | |
| 조르당은 수치적으로 없다 | numpy 가 결함 행렬의 95%를 놓친다 |
| 의 고윳값 | |
| 따로 뽑으면 두 열의 부호가 뒤집혀 복원이 깨진다 | |
| 는 정수 행렬 | |
| 고윳값 | |
| 위쪽은 왕복, 아래쪽은 편도 | 에서 양쪽 다 |
| 구면이 평면으로 눌린다 | 방향 성분이 10-16 |
| 옳은 공식과 쓰는 공식 | 에서 대 2667 |
| 좋은 기저 | 에서 52429 배 |
| 조건이 없는 도구 | 복소 고윳값 88%, 특이값은 언제나 실수 |
| 마지막 열만 꽉 찬다 | 동물원 11개 전부 통과 |
더 해 볼 것¶
다섯분해에 여섯째 줄을 붙여 보자. 촐레스키 (L27) 를 추가하면 요구하는 것은 무엇이고, 동물원에서 몇 개가 통과하는가?인구조사의 다섯 족에 직교행렬을 하나 더해 보자. 다섯 줄 중 몇 개를 통과하는가? 특이값을 계산하지 않고 미리 말할 수 있는가?
3절의 앵커에서 의 셋째 열을 으로 바꿔 보자. 이 줄어들 때 과 은 어디로 가는가? 네 부분공간의 차원은 언제 바뀌는가?
구면이 눌리는 그림에서 대신 를 넣어 보자. 어느 평면으로 눌리는가? 그다음 를 넣으면 무엇이 보이는가?
동물원에 자기 행렬을 하나 넣어 보자. 손에 있는 데이터 행렬이면 더 좋다. 다섯 줄 중 몇 개가 통과하는가? 조건수는 얼마인가?
여기까지다. 서른네 편에 걸쳐 만든 도구가 전부 이 노트북 한 장 안에서 돌아간다.
긴 여정을 함께해 주어 고맙다.