軌道
/
Julia
Julia
/
課程大綱
/
線性代數基礎
線性

線性代數基礎 在 Julia

1 個練習

關於 線性代數基礎

什麼是「線性代數」?

技術上的定義有很多,在 Wikipedia、教科書,以及像 3Blue1Brown 這樣優秀的網站上都找得到,但就我們的目的而言,可以講得更隨性一點。

Note

線性代數談的是你能對向量和矩陣做的許多有趣(而且非常實用)的事情。

Julia(還有 Python)的維護者很愛這類東西。 Exercism 的管理團隊,就沒那麼熱衷了。

本篇概念只會涵蓋這個龐大主題中比較簡單的部分,但先提醒你:這無可避免地是個相當數學的概念。

什麼是向量?

這要看你問的是誰,以及你想怎麼視覺化一個相當抽象的東西。

  • 在 Julia 裡,Vector{T}只是Array{T, 1}的別名,而eltype T可以是任何型別。
  • 在(大部分)物理學中,向量是 N 維空間中帶有length和direction的箭頭,但沒有固定的位置。
  • 在線性代數中,物理學家的箭頭固定於空間中,尾端落在原點。這樣一來箭桿就顯得多餘了,所以我們可以把向量表示成 N 維空間中的一個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 年來大部分時間都在把各種計算變成一連串的矩陣乘法。

這是一種電腦非常擅長的運算:

  • 它高度重複。
  • 它可以有效率地平行化。
  • 為了讓它更快,人們開發了許多專門的硬體,從 1970 年代造價 8000 萬美元的 Cray-1,到很可能已經內建在你筆電裡的 GPU。

細節其實相當簡單,雖然第一眼看起來不太直覺。

假設有一個矩陣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)

大致上,一對百萬元素的矩陣花了幾毫秒,一億元素的矩陣花了幾秒。 再往上加好幾個數量級,就需要更好的硬體了……

透過 GitHub 編輯 連結會在新視窗或分頁中開啟

學習 線性代數基礎