位元詩人 [Golang] 程式設計教學:實作向量 (Vector) 和矩陣 (Matrix)

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

有時候我們要處理的不是單一的數字,而是多個數字所組成的向量 (vector) 或矩陣 (matrix)。向量是一維的資料,用於許多的數學問題中,像是資料探勘中單一樣本的特徵 (features)。除了向量,我們有時候也會將數字儲存在矩陣中,矩陣是線性代數基本的組件,用來代表多維的資料。

向量和矩陣是數學上的抽象概念,在電腦程式中會用適當的資料結構來實作。本文介紹 Go 如何實作向量和矩陣。

實作向量 (Vector)

不論是從語法或標準函式庫的角度來看,Go 並未支援向量運算。目前並沒有什麼最好的套件,一些科學運算的套件各自實作自己的向量。如果各位讀者需要現成的向量物件,可參考 gonum,這個專案內部使用人們所熟知的 BLAS 函式庫進行向量運算。

不過,必要時,自己實作向量並不會太困難。以下展示一個簡單的純 Go 語言向量實作,程式碼略長,請讀者稍微耐心看一下。

package vector

type Vector []float64

func New(args ...float64) *Vector {
    v := make(Vector, len(args))

    for i, e := range args {
        v.SetAt(i, e)
    }

    return &v
}

func WithSize(size int) *Vector {
    v := make(Vector, size)

    for i := 0; i < size; i++ {
        v.SetAt(i, 0.0)
    }

    return &v
}

func FromArray(arr []float64) *Vector {
    v := make(Vector, len(arr))

    for i, e := range arr {
        v.SetAt(i, e)
    }

    return &v
}

// The length of the vector
func (v *Vector) Len() int {
    return len(*v)
}

// Getter
func (v *Vector) At(i int) float64 {
    if i < 0 || i >= v.Len() {
        panic("Index out of range")
    }

    return (*v)[i]
}

// Setter
func (v *Vector) SetAt(i int, data float64) {
    if i < 0 || i >= v.Len() {
        panic("Index out of range")
    }

    (*v)[i] = data
}

// Vector addition
func Add(v1 *Vector, v2 *Vector) *Vector {
    _len := v1.Len()

    if !(_len == v2.Len()) {
        panic("Unequal vector size")
    }

    out := WithSize(_len)

    for i := 0; i < _len; i++ {
        a := v1.At(i)
        b := v2.At(i)

        out.SetAt(i, a+b)
    }

    return out
}

實作向量的基本資料結構是數列 (array)。我們會把該資料結構包成物件,方便我們日後加入新的方法 (method)。

在本範例中,我們實作三種不同的建構函式,這是為了方便向量使用者以不同的方式初始化向量。

在實作向量時,實作向量元素的存取函式是基本的步驟,日後要實作其他功能時,會以這些存取函式為基礎。

實作向量的目的在於預先寫好一些方法,之後在操作向量時,就不用每次從頭撰寫這些方法。在本範例中,為了節省版面,我們只實作向量加法;讀者如果想要練習,可以參考向量加法的部分,繼續實作其他的向量運算。

以下是根據上述向量所建立的使用範例:

package main

import (
    "log"
    "vector"
)

func main() {
    // Create two vectors.
    v1 := vector.New(1, 2, 3)
    v2 := vector.New(2, 3, 4)

    // Vector addition.
    v := vector.Add(v1, v2)

    if !(v.At(0) == 3.0) {
        log.Fatal("Wrong value")
    }

    if !(v.At(1) == 5.0) {
        log.Fatal("Wrong value")
    }

    if !(v.At(2) == 7.0) {
        log.Fatal("Wrong value")
    }
}

為了節省文章篇幅,我們這裡僅實作和使用向量加法,有興趣的讀者可參考我們的程式碼繼續擴充這個向量物件。

實作矩陣 (Matrix)

同樣的,Go 本身沒有內建的矩陣運算相關型別,我們前面提到的 gonum 套件也有實作矩陣及其相關的運算,有需要時可以拿來用。有一些比較小型、不知名的社群專案試著實作矩陣運算,基本上都沒有形成社群的共識。

必要時,重新實作一個矩陣不會太困難。在本範例中,我們展示一個純 Go 語言實作的矩陣,程式碼略長,請讀者耐心讀一下。

package matrix

type Matrix struct {
    row int
    col int
    mtx []float64
}

func New(mtx [][]float64) *Matrix {
    row := len(mtx)
    col := len(mtx[0])

    for i := 1; i < row; i++ {
        if len(mtx[i]) != col {
            panic("Unequal column size")
        }
    }

    m := WithSize(row, col)

    for i := 0; i < row; i++ {
        for j := 0; j < col; j++ {
            m.SetAt(i, j, mtx[i][j])
        }
    }

    return m
}

func WithSize(row int, col int) *Matrix {
    if row < 0 {
        panic("Invalid row size")
    }

    if col < 0 {
        panic("Invalid column size")
    }

    m := new(Matrix)

    m.row = row
    m.col = col
    m.mtx = make([]float64, m.row*m.col)

    return m
}

// The row size of the matrix.
func (m *Matrix) Row() int {
    return m.row
}

// The column size of the matrix.
func (m *Matrix) Col() int {
    return m.col
}

// Getter
func (m *Matrix) At(r int, c int) float64 {
    if r < 0 || r >= m.Row() {
        panic("Invalid row size")
    }

    if c < 0 || c >= m.Col() {
        panic("Invalid column size")
    }

    return m.mtx[r*m.Col()+c]
}

// Setter
func (m *Matrix) SetAt(r int, c int, data float64) {
    if r < 0 || r >= m.Row() {
        panic("Invalid row size")
    }

    if c < 0 || c >= m.Col() {
        panic("Invalid column size")
    }

    m.mtx[r*m.Col()+c] = data
}

// Matrix element-wise addition
func Add(m1 *Matrix, m2 *Matrix) *Matrix {
    row := m1.Row()
    col := m1.Col()

    if row != m2.Row() {
        panic("Unequal row size")
    }

    if col != m2.Col() {
        panic("Unequal column size")
    }

    out := WithSize(row, col)

    for i := 0; i < row; i++ {
        for j := 0; j < col; j++ {
            a := m1.At(i, j)
            b := m2.At(i, j)

            out.SetAt(i, j, a+b)
        }
    }

    return out
}

在我們這個實作中,我們將資料存在一維的切片中,而非二維的切片的切片,因為前者的運算效率會稍微好一些。在這個前提下,我們必需將索引值 (index value) 由二維轉換為一維。轉換時可以使用 row major 或是 column major 來轉換索引,此處採用 row major。

矩陣要考慮其大小,所以需要實作取得 row 和 column 的方法。像是六個元素的矩陣,可能是 1x62x33x26x1 等多個不同的大小。

同樣地,我們可以預先實作一些矩陣運算,日後就可以直接使用,不用每次都重寫。為了避免篇幅過長,我們這裡僅實作矩陣加法;讀者可參考矩陣加法的部分,自行實作其他的矩陣運算。

我們根據以上的矩陣撰寫簡單的使用範例:

package main

import (
    "log"
    "matrix"
)

func main() {
    m1 := matrix.New(
        [][]float64{
            []float64{1.0, 2.0, 3.0},
            []float64{4.0, 5.0, 6.0},
        },
    )

    m2 := matrix.New(
        [][]float64{
            []float64{2.0, 3.0, 4.0},
            []float64{5.0, 6.0, 7.0},
        },
    )

    m := matrix.Add(m1, m2)

    if !(m.At(0, 0) == 3.0) {
        log.Fatal("Wrong value")
    }

    if !(m.At(0, 1) == 5.0) {
        log.Fatal("Wrong value")
    }

    if !(m.At(0, 2) == 7.0) {
        log.Fatal("Wrong value")
    }

    if !(m.At(1, 0) == 9.0) {
        log.Fatal("Wrong value")
    }

    if !(m.At(1, 1) == 11.0) {
        log.Fatal("Wrong value")
    }

    if !(m.At(1, 2) == 13.0) {
        log.Fatal("Wrong value")
    }
}

平均數 (Mean)、中位數 (Median)、眾數 (Mode)

有向量型別後,我們就可以進一步計算平均數 (mean)、中位數 (median)、眾數 (mode) 等基本的統計學指標;基本上 R 語言和 Python 的發展模式也是差不多這樣。參考實作如下:

// Declare Vector as above.

func (v *Vector) Sort() IVector {
    if v.Len() == 0 {
        return v
    }

    arr := make([]float64, 1)
    arr[0] = v.At(0)

    for i := 1; i < v.Len(); i++ {
        inserted := false

        for j := 0; j < len(arr); j++ {
            if v.At(i) < arr[j] {
                if j == 0 {
                    arr = append([]float64{v.At(i)}, arr...)
                } else {
                    arr = append(arr[:j], append([]float64{v.At(i)}, arr[j:]...)...)
                }

                inserted = true
                break
            }
        }

        if !inserted {
            arr = append(arr, v.At(i))
        }
    }

    return FromArray(arr)
}

func Mean(v IVector) float64 {
    return Sum(v) / float64(v.Len())
}

func Median(v IVector) float64 {
    if v.Len() == 0 {
        panic("No valid vector data")
    } else if v.Len() == 1 {
        return v.At(0)
    }

    sorted := v.Sort()
    _len := sorted.Len()

    if _len%2 == 0 {
        i := _len / 2
        return (sorted.At(i-1) + sorted.At(i)) / float64(2)
    } else {
        i := _len / 2
        return sorted.At(i)
    }
}

func Mode(v IVector) IVector {
    if v.Len() == 0 {
        panic("No valid vector data")
    } else if v.Len() == 1 {
        return v
    }

    m := make(map[string]int)

    max := -1
    for i := 0; i < v.Len(); i++ {
        f := strconv.FormatFloat(v.At(i), 'E', -1, 64)
        _, ok := m[f]
        if !ok {
            m[f] = 0
        } else {
            m[f]++
        }

        if m[f] > max {
            max = m[f]
        }
    }

    arr := make([]float64, 0)
    for k, _ := range m {
        if m[k] >= max {
            f, _ := strconv.ParseFloat(k, 64)
            arr = append(arr, f)
        }
    }

    return FromArray(arr).Sort()
}

在建立向量和矩陣型別後,我們就可以進一步發展統計和科學運算的套件。不過,這已經超出這篇文章的範圍,有興趣的讀者可參考相關的數理公式,自行實作看看。

結語

在 Go 語言中,我們可以用 gonum 等套件來處理向量或矩陣運算,不過,目前 Go 社群尚未對此議題擬聚共識。目前 Go 社群缺乏像 Python 的 NumPy 這種套件,每個團隊必需重新實作一些基礎的功能,對於科算運算套件來說相對不利。

在 Go 使用向量和矩陣運算很難像 R 或 MATLAB 那麼方便,因為後者將向量和矩陣的資料結構直接內建在語言中;此外,Go 也缺乏良好的互動式環境 (REPL),不利於交互式使用。在探索式分析 (explorative analyses) 時,我們通常會用 R、Python 或 MATLAB 等工具,而將 Go 語言保留在在批次處理或是建立應用程式時使用。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。