"선형대수"란 무엇일까요?
기술적인 정의는 [위키백과][linalg-wiki]를 비롯해 교과서, 그리고 [3Blue1Brown][3blue1brown] 같은 훌륭한 웹사이트에 많이 있어요. 하지만 여기서는 좀 더 편하게 이야기해볼게요.
선형대수는 벡터와 행렬로 할 수 있는 여러 가지 흥미롭고 (무척 유용한) 일들에 관한 학문이에요.
줄리아(그리고 파이썬) 유지보수자들은 이런 걸 아주 좋아해요. Exercism 경영진은 그다지.
이번 개념에서는 거대한 주제 가운데 비교적 단순한 부분만 다뤄요. 그런데 미리 알려둘게요. 이건 어쩔 수 없이 꽤 수학적인 개념이에요.
누구에게 묻는지, 그리고 꽤 추상적인 것을 어떻게 시각화하고 싶은지에 따라 답이 달라요.
Vector{T}는 그냥 Array{T, 1}의 별칭이고, eltype인 T는 무엇이든 될 수 있어요.length와 direction을 가진 화살표지만, 고정된 위치는 없어요.point로 나타낼 수 있고, N개의 원소가 N개의 축을 따라 원점에서 떨어진 거리를 각각 나타내요.지금 목적에서는 문자열이나 문자로 이루어진 벡터는 잊어버려요.
이번 개념에서는 숫자형, 즉 Int, Float, Complex를 다뤄요.
여기서도 다양한 답을 찾을 수 있어요.
linear combination.transformation. 마치 function을 다른 입력 타입에 적용하듯이요.몇몇 정사각 행렬은 특별한 이름을 가질 만큼 흔해요.
대각 행렬: 0이 아닌 모든 성분은 main diagonal(왼쪽 위에서 오른쪽 아래) 위에 있어요.
julia> [1 0 0; 0 2 0; 0 0 3]
3×3 Matrix{Int64}:
1 0 0
0 2 0
0 0 3
# a convenient shortcut
julia> diagm(1:3)
3×3 Matrix{Int64}:
1 0 0
0 2 0
0 0 3
단위 행렬: 대각선에 1만 있는 대각 행렬이에요(이유는 나중에 더 분명해질 거예요).
흔히 I로 줄여 써요.
julia> Matrix{Float64}(I, 3, 3)
3×3 Matrix{Float64}:
1.0 0.0 0.0
0.0 1.0 0.0
0.0 0.0 1.0
상삼각 행렬: 대각선 위와 대각선에 0이 아닌 값이 있고, 아래는 0이에요. 하삼각 행렬은 짐작할 수 있겠죠.
정말로 행과 열을 바꾸고 싶다면 permutedims() 함수가 그렇게 해주고 새 행렬을 돌려줘요.
이건 복사를 하기 때문에, 큰 행렬을 다룰 때는 느리고 메모리도 많이 잡아먹어요.
선형대수 목적에서는 transpose() 함수가 더 유용해요. 원래 행렬을 감싸는 지연 래퍼를 빠르게 만들어 주거든요.
adjoint() 함수는 더 유용한데, 복소수의 허수부 부호까지 바꿔줘요.
이 연산은 워낙 흔해서 변수 이름에 어퍼스트로피 '만 붙여서 adjoint를 만들 수 있어요.
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
# new, full copy
julia> permutedims(m)
3×2 Matrix{Int64}:
1 4
2 5
3 6
# lazy version
julia> transpose(m)
3×2 transpose(::Matrix{Int64}) with eltype Int64:
1 4
2 5
3 6
julia> mc = [1+2im 2+3im; 3+2im 1+2im]
2×2 Matrix{Complex{Int64}}:
1+2im 2+3im
3+2im 1+2im
# lazy conjugate transpose
julia> adjoint(mc)
2×2 adjoint(::Matrix{Complex{Int64}}) with eltype Complex{Int64}:
1-2im 3-2im
2-3im 1-2im
# syntactic sugar for adjoint
julia> mc'
2×2 adjoint(::Matrix{Complex{Int64}}) with eltype Complex{Int64}:
1-2im 3-2im
2-3im 1-2im
이 문서의 나머지 부분에는 LinearAlgebra 모듈의 기능이 나와요.
using LinearAlgebra 한 줄을 쓰면 이 모듈이 네임스페이스로 들어오지만, 예제마다 이걸 반복하지는 않을게요(시각적으로 너무 어수선하니까요).
이건 [벡터 연산][vector-ops] 개념에서 다뤘어요.
연산자는 .*이고, 입력 벡터에 대해 원소끼리 짝을 지어 연산해서 입력과 같은 크기와 타입의 결과를 내놓아요.
julia> [1, 2] .* [3, 4]
2-element Vector{Int64}:
3
8
이 아주 흔한 연산은 교과서에서 u ⋅ v로 쓰고, "내적"이라고 불러요.
내적은 원소별 곱의 _합_과 같아요.
두 벡터는 평소처럼 * 연산자로 곱할 수 있는데, 왼쪽 벡터가 adjoint일 때만 그래요. 편하게 u' * v라고 써요.
이건 뒤에 나오는 행렬 곱셈 부분에서 더 분명해질 거예요.
가운데에 놓이는 (중앙) 점은 줄리아에서 dot() 함수의 편의 문법으로 쓸 수 있어요(\cdot를 입력한 뒤 탭).
adjoint를 따로 지정할 필요는 없는데, 이 부분은 자동으로 처리되거든요.
julia> using LinearAlgebra
# with element-wise syntax
julia> sum([1, 2] .* [3, 4])
11
# with u' * v syntax
julia> [1, 2]' * [3, 4]
11
# with dot()
julia> dot([1, 2], [3, 4])
11
# with \cdot syntax
julia> [1, 2] ⋅ [3, 4]
11
복소수 벡터에서는 왼쪽 벡터가 켤레여야 해요(허수부의 부호를 바꾼 것).
dot 함수는 이걸 자동으로 해주고, u' 문법은 명시적으로 해주지만, u와 v가 복소수라면 sum(u .* v)는 실패해요.
벡터는 얼마나 "큰" 걸까요?
norm은 벡터를 적절한 스칼라로 줄여서 이걸 담아내려는 시도예요.
노름은 온 가족이 정의되어 있지만, 단연 가장 흔한 건 2-노름이고, √(v ⋅ v)예요.
이 제곱평균제곱근 연산은 (N차원 공간에서) 원점으로부터의 _피타고라스 거리_예요. 벡터를 꼬리가 원점에 있는 화살표로 시각화하면, 2-노름은 화살표의 _길이_예요.
모든 p-노름은 p를 두 번째 인자로 주면 계산할 수 있어요.
1-노름은 가끔 유용한데, 그냥 각 성분의 절댓값의 합이에요(그래서 아주 빠르고 계산하기 쉬워요).
# defaults to the 2-norm
julia> norm([1, 2, 3])
3.7416573867739413
# the 1-norm
julia> norm([1, -2, 3], 1)
6.0
세상 모든 응용 수학자가 지난 80년 동안 온갖 계산을 행렬 곱셈의 연속으로 바꾸는 데 많은 시간을 쏟은 것처럼 보일 때가 있어요.
이건 컴퓨터가 아주 잘하는 연산이에요:
세부 내용은 꽤 단순해요. 물론 처음 보면 그렇게 직관적이지는 않아요.
행렬 A에 벡터 v를 곱해서 결과 w를 얻는 경우를 생각해봐요(선형대수 관례상 행렬은 대문자, 벡터는 소문자로 써요).
A의 첫 번째 행과 v의 내적이 w의 첫 번째 원소가 되고, 두 번째 행이 두 번째 원소를, 이런 식으로 아래로 내려가요.
julia> A = [1 2; 3 4]
2×2 Matrix{Int64}:
1 2
3 4
julia> v = [5, 6]
2-element Vector{Int64}:
5
6
julia> A * v
2-element Vector{Int64}:
17 # equals [1, 2] ⋅ [5, 6]
39 # equals [3, 4] ⋅ [5, 6]
행렬 * 행렬 곱셈은 이걸 오른쪽 행렬의 각 열로 확장한 것이에요.
C = A * B에서, A가 B의 각 열을 곱해서 C의 대응하는 열을 만든다고 볼 수 있어요. 즉, 행렬-벡터 곱셈의 연속이에요.
마찬가지로, A의 첫 번째 행과 B의 각 열의 내적이 C의 첫 번째 행이 되고, 두 번째 행이 두 번째 행을, 이런 식으로 아래로 내려간다고 말할 수도 있어요.
julia> A
2×2 Matrix{Int64}:
1 2
3 4
julia> B = [5 7; 6 8]
2×2 Matrix{Int64}:
5 7
6 8
# left column is the same as A*v previously
julia> A * B
2×2 Matrix{Int64}:
17 23
39 53
julia> B * A
2×2 Matrix{Int64}:
26 38
30 44
위 예제에서 보듯이 행렬 곱셈은 교환 법칙이 성립하지 않아요. A*B와 B*A 사이에는 간단한 관계가 없어요.
행렬 곱셈은 처음 접하는 사람이라면 글만 읽어서는 시각화하기 어려울 거예요. 유튜브에는 이걸 그림으로 보여주는 영상이 많으니, "matrix multiplication"을 검색해서 원하는 스타일과 디테일 수준, 언어의 영상을 골라봐요.
두 벡터의 내적은 두 벡터의 길이가 같을 때 성립해요.
이를 확장하면, 행렬 곱셈에서는 왼쪽 행렬의 _열 개수_가 오른쪽 행렬의 _행 개수_와 같아야 해요.
크기를 size(A)가 출력하는 (nrows, ncols) 튜플로 나타내면 (a, b) * (b, c) -> (a, c)가 돼요.
"안쪽" 차원인 b와 b는 내적을 계산할 수 있게 서로 맞아요.
"바깥쪽" 차원인 a와 c는 결과의 차원을 결정해요.
직사각 행렬 예제:
julia> D = reshape(1:6, 2, 3)
2×3 reshape(::UnitRange{Int64}, 2, 3) with eltype Int64:
1 3 5
2 4 6
julia> E = reshape(1:12, 3, 4)
3×4 reshape(::UnitRange{Int64}, 3, 4) with eltype Int64:
1 4 7 10
2 5 8 11
3 6 9 12
julia> D * E
2×4 Matrix{Int64}:
22 49 76 103
28 64 100 136
# (3, 4) * (2, 3) not possible
julia> E * D
ERROR: DimensionMismatch: matrix A has axes (Base.OneTo(3),Base.OneTo(4)), matrix B has axes (Base.OneTo(2),Base.OneTo(3))
벡터 쌍을 행렬 방식으로 곱하는 방법은 두 가지가 있어요.
관례적으로는 내적과 같은 의미로 u' * v를 써요.
차원은 (1, 3) * (3, 1)이고, 줄리아는 (1, 1) 결과를 스칼라로 단순화해요(예를 들어 R과는 달라요).
또는 u * v'를 쓸 수 있는데, 차원은 (3, 1) * (1, 3) -> (3, 3)이고, 가능한 모든 원소 쌍을 곱해서 벡터를 행렬로 확장해요.
julia> u = [1, 2]
2-element Vector{Int64}:
1
2
julia> v = [3, 4]
2-element Vector{Int64}:
3
4
julia> u' * v
11
julia> u * v'
2×2 Matrix{Int64}:
3 4
6 8
u' * v는 가끔 _내적_이라고도 불러요.
특히 흔한 행렬 곱셈 유형 가운데 하나는 회전 행렬을 포함해요.
2차원에서는 벡터를 반시계 방향으로 θ 라디안만큼 회전시키는 비교적 간단한 행렬이 있어요.
julia> rot2d(θ, vec) = [cos(θ) -sin(θ); sin(θ) cos(θ)] * vec
rot2d (generic function with 1 method)
# unit vector in the x direction
julia> i_hat = [1, 0]
2-element Vector{Int64}:
1
0
# rotate 45 degrees
julia> rot2d(π/4, i_hat)
2-element Vector{Float64}:
0.7071067811865476
0.7071067811865475
# rotate 90 degrees -> unit vector in the y direction, j_hat
julia> rot2d(π/2, i_hat)
2-element Vector{Float64}:
6.123233995736766e-17 # zero, within numerical error
1.0
간단한 로봇을 개념 증명용으로 개발하는 로봇 스타트업에서 일하고 있어요. 로봇의 움직임을 제어하는 기능을 구현하는 일을 맡았죠.
로봇의 방향과 크기를 추적하기 위해, 로봇에는 중심에서 1 단위만큼 떨어진 세 개의 마커가 있어요. 위치를 초기화하려면, 세 개의 방향 벡터를 가져와 정규화한 다음 행렬에 넣어야 해요.
벡터 세 개로 이루어진 벡터를 받는 orientrobot(vectors) 함수를 구현해요.
정규화된 벡터들을 열로 가지는 2x3 행렬을 반환해요.
julia> orientrobot([[-1,1],[1,0],[-1,-1]])
2×3 Matrix{Float64}:
-0.707107 1.0 -0.707107
0.707107 0.0 -0.707107
다음으로, 움직이는 방향을 바꾸는 기능이 필요해요. 그러려면 로봇이 가고자 하는 곳을 바라보도록 회전시켜야 해요.
로봇의 방향 행렬과 반시계 방향으로 회전할 각도 θ를 받는 rotaterobot(orientation, θ) 함수를 구현해요.
새로운 방향 행렬을 반환해요.
julia> orientmatrix = initialize([[-1,1],[1,0],[-1,-1]]);
julia> rotaterobot(orientmatrix, π/2)
2×3 Matrix{Float64}:
-0.707107 6.12323e-17 0.707107
-0.707107 1.0 -0.707107
로봇을 한 위치에서 다른 위치로 움직이려면, 먼저 로봇을 움직이기 전에 방향이 올바른지 확인해야 해요. 방향 행렬의 두 번째 열은 앞을 향하는 방향을 나타내요.
방향 행렬과 상대 위치 벡터를 받는 robotoriented(orientation, direction) 함수를 구현해요.
로봇이 상대 위치 벡터와 같은 방향을 향하고 있으면 (반올림 오차 범위 내에서) true를 반환해요.
julia> orientmatrix = initialize([[-1,1],[1,0],[-1,-1]]);
julia> robotoriented(orientmatrix, [5, 0])
true
julia> robotoriented(orientmatrix, [0, 5])
false
julia> robotoriented(orientmatrix, [-5, 0])
false
방향 행렬은 로봇의 모양도 함께 추적하기 때문에, 로봇을 움직인 뒤에 이 점들이 원점을 기준으로 어디에 있는지 알아야 해요. 이는 로봇이 돌아다닐 때 다른 물체와 충돌하는 것을 피하는 데 도움이 돼요.
방향 행렬과 로봇 중심의 현재 위치를 받는 bodylocation(orientation, position) 함수를 구현해요.
평행 이동된 방향 행렬을 반환해요.
julia> orientmatrix = initialize([[-1,1],[1,0],[-1,-1]]);
julia> bodylocation(orientmatrix, [5, 3])
2×3 Matrix{Float64}:
4.29289 6.0 4.29289
3.70711 3.0 2.29289
Exercism에 가입하고 Julia 트랙을 개념 35개연습 문제 128개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.