L18 서술 파트에서 열 개가 넘는 성질이 세 개의 공리에서 흘러나오는 것을 보았다.
증명을 읽기 전에 "정말 그런가"부터 확인해 두면 증명이 훨씬 잘 읽힌다. 이 노트북에서는
무작위 행렬 수백 개로 성질들을 한꺼번에 검산하고, 소거해서 얻은 피벗의 곱이 정말
np.linalg.det 와 같은지 직접 만들어 대조한다.
| 서술 파트의 내용 | 여기서 확인하는 방법 |
|---|---|
| 는 넓이, 부호는 방향 | 단위 정사각형이 어디로 가는지 그린다 |
| 세 공리 | 기준·교환·한 행 선형성을 각각 확인 |
| 일곱 성질 | 무작위 행렬 200개로 최대오차를 잰다 |
| 같은 표에서 이 줄만 오차가 크다 | |
| 피벗의 곱 | 소거를 직접 짜서 대조 |
| 큰 행렬식이 좋은 것은 아니다 | 행렬식과 조건수를 나란히 |
0. 준비¶
import numpy as np
import plotly.graph_objects as go
from scipy.linalg import hilbert
from linalg_viz import COLORS, arrow, layout2d, layout3d, show_matrix
np.set_printoptions(precision=3, suppress=True)
rng = np.random.default_rng(18)
print("numpy", np.__version__)numpy 2.5.2
1. 행렬식은 넓이이고, 부호는 방향이다¶
단위 정사각형이 에 의해 어떤 평행사변형이 되는지 그려 보자. 꼭짓점에 번호를 붙여 두면 도는 방향이 뒤집히는지도 눈으로 따라갈 수 있다.
def 넓이실험(M, 제목=""):
"""단위 정사각형과 그 상을 함께 그린다. 꼭짓점 번호로 방향을 추적한다."""
M = np.asarray(M, dtype=float)
사각형 = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=float).T
변환 = M @ 사각형
행렬식 = np.linalg.det(M)
print(f"det = {행렬식:+.4f} |det| = {abs(행렬식):.4f} <- 넓이 배율")
print("방향 :", "그대로 (반시계)" if 행렬식 > 0 else "뒤집힘 (시계)")
자료 = [
go.Scatter(x=사각형[0], y=사각형[1], fill="toself", mode="lines+markers",
line=dict(color=COLORS["nullspace"], dash="dash"),
name="원본 (넓이 1)"),
go.Scatter(x=변환[0], y=변환[1], fill="toself", mode="lines+markers+text",
line=dict(color=COLORS["output"]),
text=["1'", "2'", "3'", "4'", ""], textposition="top right",
name=f"변환 후 (넓이 {abs(행렬식):.2f})"),
]
바탕 = layout2d(제목, extent=3)
return go.Figure(data=자료, layout=바탕)넓이실험(np.array([[2.0, 1.0], [1.0, 2.0]]), "M = [2 1 ; 1 2] : 넓이가 3배")det = +3.0000 |det| = 3.0000 <- 넓이 배율
방향 : 그대로 (반시계)
행을 교환하는 행렬은 넓이를 그대로 두고 방향만 뒤집는다. 그래서 행렬식이 -1 이다.
넓이실험(np.array([[0.0, 1.0], [1.0, 0.0]]), "행 교환 : 넓이는 같고 방향만 반전")det = -1.0000 |det| = 1.0000 <- 넓이 배율
방향 : 뒤집힘 (시계)
한 행에 다른 행의 배수를 더해도 넓이는 변하지 않는다. 모양만 밀린다.
M = np.array([[2.0, 1.0], [1.0, 2.0]])
M밀기 = M.copy()
M밀기[1] = M밀기[1] - M밀기[0] # 소거 한 번
print("원래 det :", np.linalg.det(M))
print("소거 후 det :", np.linalg.det(M밀기))
print("같은가 :", np.isclose(np.linalg.det(M), np.linalg.det(M밀기)))
넓이실험(M밀기, "소거 후 : 모양은 달라졌지만 넓이는 그대로")원래 det : 2.9999999999999996
소거 후 det : 2.9999999999999996
같은가 : True
det = +3.0000 |det| = 3.0000 <- 넓이 배율
방향 : 그대로 (반시계)
2. 세 공리¶
공리 자체를 먼저 확인한다.
A = rng.integers(-3, 4, (4, 4)).astype(float)
print(show_matrix(A, "A"))
# ① 기준
print("det(I) =", np.linalg.det(np.eye(4)))
# ② 교환
바꾼A = A.copy()
바꾼A[[0, 2]] = 바꾼A[[2, 0]]
print(f"det(A) = {np.linalg.det(A):+.4f}")
print(f"두 행 교환 후 = {np.linalg.det(바꾼A):+.4f} 부호만 바뀌었는가 :",
np.isclose(np.linalg.det(바꾼A), -np.linalg.det(A)))A
[ 3 -1 -2 2 ]
[ 3 -2 -1 -3 ]
[ 3 3 1 0 ]
[ 0 1 2 1 ]
det(I) = 1.0
det(A) = -114.0000
두 행 교환 후 = +114.0000 부호만 바뀌었는가 : True
# ③ 한 행에 대한 선형성 : 상수배
t = 3.0
배수A = A.copy()
배수A[1] = t * 배수A[1]
print(f"한 행만 {t:g} 배 -> det 가 {t:g} 배인가 :",
np.isclose(np.linalg.det(배수A), t * np.linalg.det(A)))
# ③ 한 행에 대한 선형성 : 합
r새 = rng.integers(-3, 4, 4).astype(float)
왼 = A.copy(); 왼[1] = A[1] + r새
오른1 = A.copy()
오른2 = A.copy(); 오른2[1] = r새
print("한 행이 합일 때 det 도 합인가 :",
np.isclose(np.linalg.det(왼), np.linalg.det(오른1) + np.linalg.det(오른2)))한 행만 3 배 -> det 가 3 배인가 : True
한 행이 합일 때 det 도 합인가 : True
전체를 배 하면 행이 개 있으므로 배가 된다. 공리 ③이 한 행에 대한 이야기라는 것이 여기서 드러난다.
print(f"{'n':>4}{'det(2A) / det(A)':>20}{'2^n':>8}")
for n in (2, 3, 4, 5):
X = rng.normal(size=(n, n))
print(f"{n:>4}{np.linalg.det(2 * X) / np.linalg.det(X):>20.4f}{2 ** n:>8}") n det(2A) / det(A) 2^n
2 4.0000 4
3 8.0000 8
4 16.0000 16
5 32.0000 32
3. 일곱 성질을 한꺼번에 검산¶
무작위 행렬 200개로 각 성질의 최대오차를 잰다. 하나만 빼고 전부 기계 정밀도여야 한다.
def 성질검증(n=4, 반복=200, 씨앗=0):
"""행렬식의 성질들을 수치로 확인하고 항목별 최대오차를 돌려준다."""
rng2 = np.random.default_rng(씨앗)
오차 = {}
def 재기(이름, 값):
오차.setdefault(이름, []).append(abs(값))
for _ in range(반복):
X = rng2.standard_normal((n, n))
Y = rng2.standard_normal((n, n))
det = np.linalg.det
같은행 = X.copy(); 같은행[2] = 같은행[0]
재기("같은 행이 둘 -> 0", det(같은행))
영행 = X.copy(); 영행[1] = 0.0
재기("0 인 행 -> 0", det(영행))
소거 = X.copy(); 소거[1] = 소거[1] + 3.0 * 소거[0]
재기("소거해도 불변", det(소거) - det(X))
삼각 = np.triu(X)
재기("삼각행렬 = 대각의 곱", det(삼각) - np.prod(np.diag(삼각)))
특이 = X.copy(); 특이[3] = 2.0 * 특이[0] - 특이[1]
재기("특이 -> 0", det(특이))
재기("det(AB) = det A det B", det(X @ Y) - det(X) * det(Y))
재기("det(A^T) = det(A)", det(X.T) - det(X))
재기("det(A+B) = det A + det B ?", det(X + Y) - (det(X) + det(Y)))
return {이름: max(값) for 이름, 값 in 오차.items()}for 이름, 값 in 성질검증().items():
표시 = " <- 성립하지 않는다" if 값 > 1e-6 else ""
print(f"{이름:<30} 최대오차 {값:.2e}{표시}")같은 행이 둘 -> 0 최대오차 1.17e-15
0 인 행 -> 0 최대오차 0.00e+00
소거해도 불변 최대오차 8.88e-15
삼각행렬 = 대각의 곱 최대오차 8.88e-16
특이 -> 0 최대오차 5.33e-15
det(AB) = det A det B 최대오차 8.53e-14
det(A^T) = det(A) 최대오차 7.11e-15
det(A+B) = det A + det B ? 최대오차 8.21e+01 <- 성립하지 않는다
마지막 줄만 오차가 어마어마하다. 행렬식은 덧셈에 대해 선형이 아니다. 공리 ③과 모순이 아닌 이유는, 공리가 한 행만 합일 때를 말하기 때문이다. 는 개의 행이 전부 합이라 쪼개면 개의 항이 나온다.
I2 = np.eye(2)
print("det(I) + det(I) =", np.linalg.det(I2) + np.linalg.det(I2))
print("det(I + I) =", np.linalg.det(I2 + I2), " = det(2I) = 2^2 x 1")det(I) + det(I) = 2.0
det(I + I) = 4.0 = det(2I) = 2^2 x 1
4. 피벗의 곱으로 직접 구하기¶
서술 파트에서 이라고 했다. 소거를 직접 짜서 확인하자.
def 피벗곱행렬식(A, 보기=False):
"""소거해서 얻은 피벗의 곱과 행 교환 횟수로 행렬식을 계산한다."""
U = np.asarray(A, dtype=float).copy()
n = U.shape[0]
교환 = 0
for k in range(n):
축 = k + int(np.argmax(np.abs(U[k:, k]))) # 가장 큰 것을 피벗으로
if abs(U[축, k]) < 1e-12:
return 0.0 # 피벗이 없으면 특이
if 축 != k:
U[[k, 축]] = U[[축, k]]
교환 += 1
U[k + 1:, k:] -= np.outer(U[k + 1:, k] / U[k, k], U[k, k:])
피벗 = np.diag(U)
if 보기:
print(show_matrix(U, "U"))
print("피벗 :", 피벗, " 행 교환 :", 교환, "회")
return (-1.0) ** 교환 * float(np.prod(피벗))A2 = np.array([[1.0, 2.0, 1.0],
[3.0, 8.0, 1.0],
[0.0, 4.0, 1.0]]) # L2 에서 쓰던 행렬
print(show_matrix(A2, "A"))
값 = 피벗곱행렬식(A2, 보기=True)
print()
print("피벗의 곱으로 :", 값)
print("np.linalg.det :", np.linalg.det(A2))
print("같은가 :", np.isclose(값, np.linalg.det(A2)))A
[ 1 2 1 ]
[ 3 8 1 ]
[ 0 4 1 ]
U
[ 3 8 1 ]
[ 0 4 1 ]
[ 0 0 0.833 ]
피벗 : [3. 4. 0.833] 행 교환 : 2 회
피벗의 곱으로 : 10.0
np.linalg.det : 9.999999999999998
같은가 : True
서술 파트에서는 행 교환 없이 피벗이 라 이라고 했다. 위 코드는 큰 것을 피벗으로 골라 행을 바꾸므로 피벗이 다르게 나오는데, 부호까지 합치면 같은 값이 된다. 행렬식은 소거 방법에 의존하지 않는다.
print(f"{'m x n':>8}{'피벗의 곱':>16}{'numpy':>16}{'차이':>12}")
for _ in range(8):
n = int(rng.integers(2, 7))
X = rng.integers(-4, 5, (n, n)).astype(float)
내값, 참값 = 피벗곱행렬식(X), np.linalg.det(X)
print(f"{f'{n} x {n}':>8}{내값:>16.4f}{참값:>16.4f}{abs(내값 - 참값):>12.1e}") m x n 피벗의 곱 numpy 차이
2 x 2 -4.0000 -4.0000 0.0e+00
5 x 5 -535.0000 -535.0000 2.3e-13
2 x 2 0.0000 0.0000 0.0e+00
6 x 6 -4342.0000 -4342.0000 5.5e-12
4 x 4 172.0000 172.0000 5.7e-14
4 x 4 -26.0000 -26.0000 1.4e-14
6 x 6 29072.0000 29072.0000 1.1e-11
6 x 6 3456.0000 3456.0000 3.2e-12
5. 행렬식이 작다고 위험한 것은 아니다¶
행렬식은 가역 여부만 말해 줄 뿐 풀기가 얼마나 안정적인지와는 상관이 없다. 행렬식이 거의 0인 두 행렬을 비교해 보자.
얌전한 = 1e-2 * np.eye(10) # 모든 방향을 똑같이 줄인다
까다로운 = hilbert(10) # 힐베르트 행렬
print(f"{'':>10}{'det':>14}{'조건수':>14}")
for 이름, M in (("1e-2 x I", 얌전한), ("Hilbert", 까다로운)):
print(f"{이름:>10}{np.linalg.det(M):>14.2e}{np.linalg.cond(M):>14.2e}") det 조건수
1e-2 x I 1.00e-20 1.00e+00
Hilbert 2.16e-53 1.60e+13
둘 다 행렬식이 사실상 0인데 성격이 정반대이다. 앞의 것은 조건수가 1이라 완벽하게 잘 풀리고, 뒤의 것은 조건수가 커서 풀면 답을 믿기 어렵다. 실제로 풀어 보면 차이가 드러난다.
for 이름, M in (("1e-2 x I", 얌전한), ("Hilbert", 까다로운)):
참해 = np.ones(10)
b = M @ 참해
구한해 = np.linalg.solve(M, b)
print(f"{이름:>10} 해의 최대 오차 : {np.abs(구한해 - 참해).max():.2e}") 1e-2 x I 해의 최대 오차 : 0.00e+00
Hilbert 해의 최대 오차 : 1.69e-04
6. 3차원에서는 부피이다¶
L9에서 평행육면체를 눌러 납작하게 만들어 보았다. 그때의 부피가 바로 였다.
def 평행육면체(v1, v2, v3, 제목=""):
"""세 벡터가 만드는 평행육면체와 그 부피를 그린다."""
v1, v2, v3 = (np.asarray(v, dtype=float) for v in (v1, v2, v3))
꼭짓 = np.array([a * v1 + b * v2 + c * v3
for a in (0, 1) for b in (0, 1) for c in (0, 1)])
부피 = abs(np.linalg.det(np.column_stack([v1, v2, v3])))
자료 = [go.Mesh3d(x=꼭짓[:, 0], y=꼭짓[:, 1], z=꼭짓[:, 2], alphahull=0,
color=COLORS["colspace"], opacity=0.35, name="평행육면체")]
for v, 색, 이름 in ((v1, COLORS["input"], "v1"), (v2, COLORS["second"], "v2"),
(v3, COLORS["third"], "v3")):
자료 += arrow([0, 0, 0], v, 색, 이름)
return go.Figure(data=자료,
layout=layout3d(f"{제목} 부피 = |det| = {부피:.3f}", extent=3)), 부피v1 = np.array([2.0, 0.0, 0.0])
v2 = np.array([0.0, 2.0, 0.0])
v3 = np.array([1.0, 1.0, 2.0])
그림, 부피 = 평행육면체(v1, v2, v3, "독립인 세 벡터")
print("det =", np.linalg.det(np.column_stack([v1, v2, v3])))
print("부피 =", 부피)
그림det = 7.999999999999998
부피 = 7.999999999999998
를 과 가 만드는 평면 쪽으로 내리면 부피가 0으로 간다. 그 순간이 세 벡터가 종속이 되는 순간이고, 행렬식이 0이 되는 순간이다.
print(f"{'v3 의 z 성분':>14}{'det':>12}{'특이인가':>12}")
for z in (2.0, 1.0, 0.5, 0.1, 0.0):
V = np.column_stack([v1, v2, [1.0, 1.0, z]])
d = np.linalg.det(V)
print(f"{z:>14.1f}{d:>12.3f}{str(np.isclose(d, 0)):>12}") v3 의 z 성분 det 특이인가
2.0 8.000 False
1.0 4.000 False
0.5 2.000 False
0.1 0.400 False
0.0 0.000 True
공리 ③을 부피로 읽으면 이렇다. 를 두 배로 늘이면 높이가 두 배가 되므로 부피도 두 배이다. 한 행에 대한 선형성이 곧 한 방향에 대한 배율이다.
for 배 in (1.0, 2.0, 3.0):
V = np.column_stack([v1, v2, 배 * v3])
print(f"v3 를 {배:g} 배 하면 det = {np.linalg.det(V):7.3f}")v3 를 1 배 하면 det = 8.000
v3 를 2 배 하면 det = 16.000
v3 를 3 배 하면 det = 24.000
마치며...¶
| 서술 파트의 내용 | 이 노트북의 코드 |
|---|---|
| 는 넓이, 부호는 방향 | 넓이실험(M) |
| 세 공리 | 기준·교환·한 행 선형성을 각각 확인 |
| 을 바꿔 가며 비율 확인 | |
| 일곱 성질 | 성질검증() 의 최대오차 표 |
| 피벗의 곱 | 피벗곱행렬식(A) |
| 큰 행렬식 좋은 행렬 | 행렬식과 조건수를 나란히 |
| 3차원에서는 부피 | 평행육면체(v1, v2, v3) |
더 해 볼 것¶
피벗곱행렬식에서 부분 피벗팅(가장 큰 것을 고르는 부분)을 빼면 어떻게 되는가? 어떤 행렬에서 문제가 생기는가?성질검증에 “두 열이 같으면 0” 을 추가해 보자. 성립하는가? 왜 그런가?6절에서 과 는 그대로 두고 만 여러 방향으로 돌려 보자. 부피가 최대가 되는 방향은 어디인가?
무작위 행렬의 행렬식은 이 커지면 어떻게 되는가? 여러 번 뽑아 분포를 보자.
다음 강의에서는 행렬식의 공식을 유도한다. 그리고 그 공식을 실제 계산에 쓰면 안 되는 이유도 함께 확인한다.