Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Lecture 18. 행렬식의 성질 — 파이썬 실습

Properties of Determinants — 실습

L18 서술 파트에서 열 개가 넘는 성질이 세 개의 공리에서 흘러나오는 것을 보았다.

증명을 읽기 전에 "정말 그런가"부터 확인해 두면 증명이 훨씬 잘 읽힌다. 이 노트북에서는 무작위 행렬 수백 개로 성질들을 한꺼번에 검산하고, 소거해서 얻은 피벗의 곱이 정말 np.linalg.det 와 같은지 직접 만들어 대조한다.

서술 파트의 내용여기서 확인하는 방법
det\lvert\det\rvert 는 넓이, 부호는 방향단위 정사각형이 어디로 가는지 그린다
세 공리기준·교환·한 행 선형성을 각각 확인
일곱 성질무작위 행렬 200개로 최대오차를 잰다
det(A+B)detA+detB\det(A+B) \neq \det A + \det B같은 표에서 이 줄만 오차가 크다
det=±\det = \pm 피벗의 곱소거를 직접 짜서 대조
큰 행렬식이 좋은 것은 아니다행렬식과 조건수를 나란히

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. 행렬식은 넓이이고, 부호는 방향이다

단위 정사각형이 MM 에 의해 어떤 평행사변형이 되는지 그려 보자. 꼭짓점에 번호를 붙여 두면 도는 방향이 뒤집히는지도 눈으로 따라갈 수 있다.

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   <- 넓이 배율
방향 : 그대로 (반시계)
Loading...

행을 교환하는 행렬은 넓이를 그대로 두고 방향만 뒤집는다. 그래서 행렬식이 -1 이다.

넓이실험(np.array([[0.0, 1.0], [1.0, 0.0]]), "행 교환 : 넓이는 같고 방향만 반전")
det = -1.0000   |det| = 1.0000   <- 넓이 배율
방향 : 뒤집힘 (시계)
Loading...

한 행에 다른 행의 배수를 더해도 넓이는 변하지 않는다. 모양만 밀린다.

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   <- 넓이 배율
방향 : 그대로 (반시계)
Loading...

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

전체를 tt 배 하면 행이 nn 개 있으므로 tnt^n 배가 된다. 공리 ③이 한 행에 대한 이야기라는 것이 여기서 드러난다.

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  <- 성립하지 않는다

마지막 줄만 오차가 어마어마하다. 행렬식은 덧셈에 대해 선형이 아니다. 공리 ③과 모순이 아닌 이유는, 공리가 한 행만 합일 때를 말하기 때문이다. A+BA + Bnn 개의 행이 전부 합이라 쪼개면 2n2^n 개의 항이 나온다.

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. 피벗의 곱으로 직접 구하기

서술 파트에서 detA=±(피벗의 곱)\det A = \pm(\text{피벗의 곱}) 이라고 했다. 소거를 직접 짜서 확인하자.

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

서술 파트에서는 행 교환 없이 피벗이 1,2,51, 2, 5det=10\det = 10 이라고 했다. 위 코드는 큰 것을 피벗으로 골라 행을 바꾸므로 피벗이 다르게 나오는데, 부호까지 합치면 같은 값이 된다. 행렬식은 소거 방법에 의존하지 않는다.

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에서 평행육면체를 눌러 납작하게 만들어 보았다. 그때의 부피가 바로 det|\det| 였다.

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
Loading...

v3\vv{v}_3v1\vv{v}_1v2\vv{v}_2 가 만드는 평면 쪽으로 내리면 부피가 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

공리 ③을 부피로 읽으면 이렇다. v3\vv{v}_3 를 두 배로 늘이면 높이가 두 배가 되므로 부피도 두 배이다. 한 행에 대한 선형성이 곧 한 방향에 대한 배율이다.

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

마치며...

서술 파트의 내용이 노트북의 코드
det\lvert\det\rvert 는 넓이, 부호는 방향넓이실험(M)
세 공리기준·교환·한 행 선형성을 각각 확인
det(tA)=tndetA\det(tA) = t^n \det Ann 을 바꿔 가며 비율 확인
일곱 성질성질검증() 의 최대오차 표
det=±\det = \pm 피벗의 곱피벗곱행렬식(A)
큰 행렬식 \neq 좋은 행렬행렬식과 조건수를 나란히
3차원에서는 부피평행육면체(v1, v2, v3)

더 해 볼 것

  1. 피벗곱행렬식 에서 부분 피벗팅(가장 큰 것을 고르는 부분)을 빼면 어떻게 되는가? 어떤 행렬에서 문제가 생기는가?

  2. 성질검증 에 “두 열이 같으면 0” 을 추가해 보자. 성립하는가? 왜 그런가?

  3. 6절에서 v1\vv{v}_1v2\vv{v}_2 는 그대로 두고 v3\vv{v}_3 만 여러 방향으로 돌려 보자. 부피가 최대가 되는 방향은 어디인가?

  4. n×nn \times n 무작위 행렬의 행렬식은 nn 이 커지면 어떻게 되는가? 여러 번 뽑아 분포를 보자.

다음 강의에서는 행렬식의 공식을 유도한다. 그리고 그 공식을 실제 계산에 쓰면 안 되는 이유도 함께 확인한다.