什麼是「線性代數」?
技術上的定義有很多,在 Wikipedia、教科書,以及像 3Blue1Brown 這樣優秀的網站上都找得到,但就我們的目的而言,可以講得更隨性一點。
線性代數談的是你能對向量和矩陣做的許多有趣(而且非常實用)的事情。
Julia(還有 Python)的維護者很愛這類東西。 Exercism 的管理團隊,就沒那麼熱衷了。
本篇概念只會涵蓋這個龐大主題中比較簡單的部分,但先提醒你:這無可避免地是個相當數學的概念。
這要看你問的是誰,以及你想怎麼視覺化一個相當抽象的東西。
Vector{T}只是Array{T, 1}的別名,而eltype T可以是任何型別。length和direction的箭頭,但沒有固定的位置。point,讓N個元素代表沿著N根軸與原點的距離。就目前的目的而言,請忽略字串或字元的向量。
本篇概念裡我們會使用數值型別:Int、Float或Complex。
同樣地,你會找到各式各樣的答案。
linear combination。transformation,就像function可以套用到其他輸入型別一樣。有些方陣很常見,以至於有專屬的名稱。
對角矩陣: 所有非零的元素都落在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
上三角矩陣: 對角線上以及對角線上方的值非零,下方為零。 下三角矩陣就留給你猜了。
如果你真的想把列和行對調,permutedims()這個函式可以辦到,並給你一個新的矩陣。
這牽涉到複製,在處理大型矩陣時又慢又耗記憶體。
就線性代數的用途來說,transpose()函式更有用,因為它能很快地在原始矩陣外建立一層惰性包裝。
更有用的是 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就能把它帶進命名空間,但我們不會在範例裡一再重複(畫面太雜了)。
這在向量運算概念中討論過。
運算子是.*,它會逐對處理輸入向量,產生的輸出大小和型別都與輸入相同。
julia> [1, 2] .* [3, 4]
2-element Vector{Int64}:
3
8
這個極為常見的運算在教科書裡寫成u ⋅ v,稱為「點」積。
點積等於逐元素乘積的_總和_。
兩個向量可以用一般的*運算子相乘,但前提是左邊的向量是adjoint:方便起見寫成u' * v。
這在後面談矩陣乘法的段落應該會更清楚。
置中的點號在 Julia 裡也能用(輸入\cdot後按 Tab),它是dot()函式的語法糖。
不需要特別指定伴隨矩陣,這個細節會自動處理。
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)就會失敗。
對以前修過電磁學課程的人來說,這有點懷舊! 對熟悉力矩或角動量向量計算的工程師也是如此。
點積把兩個向量變成一個scalar,叉積則把兩個三維向量變成第三個三維向量。
在向量空間的幾何表示中,新的向量_垂直_於包含這兩個輸入向量的平面。
如果這兩個輸入平行(差一個正負號),它們無法決定一個平面,因此輸出會是[0, 0, 0]。
順序很重要:u × v == -(v × u)。
這就是著名的右手定則,它讓我們許多人在空間中扭轉著拇指和兩根手指、盯著它們猛看(臉上常常滿是困惑)。
# with \times syntax
julia> [1, 2, 3] × [3, 4, 5]
3-element Vector{Int64}:
-2
4
-2
# with cross()
julia> cross([1, 2, 3], [3, 4, 5])
3-element Vector{Int64}:
-2
4
-2
注意這個運算僅限於長度為 3 的向量(等同於具有正交x, y, z軸的歐幾里得空間)。
數學家很樂意把它定義到其他維度,結果換來的是一堆令人費解的名詞大雜燴(高階張量、楔積、外積、多重向量……)。
我們大多數人在那個當下都會逃跑,或者趕快換個話題!
一個向量有多「大」?
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之間沒有簡單的關係。
對剛接觸的人來說,光靠文字大概很難想像矩陣乘法。 YouTube 上有很多用圖形示範的影片,搜尋「矩陣乘法」,挑一部你喜歡的風格、細節程度和語言的來看。
兩個向量的點積,前提是它們的長度相等。
推廣來說,矩陣乘法中左邊矩陣的_行數_必須等於右邊矩陣的_列數_。
把大小表示成(nrows, ncols)這樣的元組,也就是size(A)的輸出,我們有(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),而 Julia 會把(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有時被稱為_內積_。
(比較少見的)u * v'則對應地稱為_外積_。
這和張量積有關,不過那遠遠超出我們的範圍。
有一種特別常見的矩陣乘法是旋轉矩陣。
在二維中,有一個相對簡單的矩陣可以把向量以逆時針方向旋轉θ弧度。
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
三維的旋轉需要更複雜的矩陣,用到兩個角度。 這在 Markdown 文件裡不嵌入大量 LaTeX 很難呈現,公式就去看 Wikipedia 吧。
對三維圖形,例如 OpenGL 及其後繼者來說,慣例是使用齊次座標。
每個頂點用一個 4 維向量(或等價地說,矩陣的一行)表示:位於(x, y, z)的點就是[x, y, z, 1.0]。
這樣就能用更精細的轉換矩陣。
旋轉仍然在A[1:3, 1:3],平移是位於A[1:3, 4]的[Δx, Δy, Δz],縮放在對角線上,其他還有歪斜、透視等的可能。
全世界各地的資訊系裡,有很多博士論文都有一到幾個章節,在講矩陣乘法演算法一點一滴的小幅改進。 這_真的_很重要,各種組織都願意為了自己的利益出資贊助研究。
效能是個很大的主題,我們沒辦法深入探討,但為了說明,我們可以試著相乘各種大小的隨機矩陣。
下面的程式碼是快速粗略的估計(想要更好的做法請用 BenchmarkTools.jl)。
使用的系統是一台 430 美元的小型個人電腦:Ryzen 9 處理器、32GB RAM、Linux Mint 22.1、Julia 1.11.6。
julia> mmul(A) = A * A
mmul (generic function with 1 method)
julia> A = rand(Float64, 1_000, 1_000);
julia> @time mmul(A);
0.011055 seconds (3 allocations: 7.629 MiB)
julia> A = rand(Float64, 10_000, 10_000);
julia> @time mmul(A);
7.236284 seconds (1.61 k allocations: 763.027 MiB, 17.92% gc time, 0.15% compilation time)
大致上,一對百萬元素的矩陣花了幾毫秒,一億元素的矩陣花了幾秒。 再往上加好幾個數量級,就需要更好的硬體了……