LoginSignup
8
7

More than 3 years have passed since last update.

Juliaで『前処理による特異値分解の高速化』

Last updated at Posted at 2020-02-19

はじめに

以前『前処理による特異値分解の高速化』を書きました。他の言語で実装したことはあったのですが、最近Juliaの勉強を始めたので試しにJuliaで実装してみました。

通常の特異値分解

LinearAlgebraパッケージにSVDがあります。randn(2,3)で2×3の正規乱数行列を生成してSVDしてみます。

> using LinearAlgebra
> A = randn(2,3)
> U, s, V = svd(A)

SVD{Float64,Float64,Array{Float64,2}}
U factor:
2×2 Array{Float64,2}:
 -0.997385   0.0722677
  0.0722677  0.997385 
singular values:
2-element Array{Float64,1}:
 2.179336847718715 
 1.7295114827398714
Vt factor:
2×3 Array{Float64,2}:
 -0.477078   0.846436   0.236522
  0.0305668  0.284941  -0.958058

と結果が出ます。Aを再構成できるか確認してみましょう。

> A .-= U * Diagonal(s) * V'
2×3 Array{Float64,2}:
 -2.22045e-16  4.44089e-16  2.22045e-16
  7.63278e-17  0.0          2.22045e-16

Juliaで .(ピリオド)は要素に対する演算を全要素に一括で適用する記法です。Aを再構成できていることがわかります。もっと大きな行列で速度を測定してみましょう。速度測定にBenchmarkToolsパッケージを用います。

> using BenchmarkTools
> A = randn(1000,500)
> @benchmark U, s, V = svd(A)

BenchmarkTools.Trial: 
  memory estimate:  17.23 MiB
  allocs estimate:  17
  --------------
  minimum time:     213.673 ms (0.00% GC)
  median time:      221.038 ms (0.00% GC)
  mean time:        228.602 ms (2.98% GC)
  maximum time:     315.837 ms (28.63% GC)
  --------------
  samples:          22
  evals/sample:     1

となり、私の環境では1000×500の正規乱数行列のSVDに228ms程度掛かっています。

『前処理による特異値分解の高速化』のJuliaへの実装

『前処理による特異値分解の高速化』の復習

『前処理による特異値分解の高速化』を復習すると、

  • 行列$\boldsymbol{A} \in \mathbb{R}^{M \times N}$($M > N$)の正規直行基底$\boldsymbol{Q} \in \mathbb{R}^{M \times R}$を用いて圧縮した行列:$\boldsymbol{Q}^T \boldsymbol{A} \in \mathbb{R}^{R \times N}$についてSVDする。
  • 求まった$\tilde{\boldsymbol{U}}$, $\boldsymbol{\sigma}$, $\boldsymbol{V}$のうち、$\boldsymbol{\sigma}$, $\boldsymbol{V}$はそのままでよく、$\tilde{\boldsymbol{U}}$だけ左から$\boldsymbol{Q}$を掛ければ元の行列のSVDが求まる(!)
  • $R$は行列$\boldsymbol{A}$のランクで、$R \ll N$のとき大幅に高速化できる。
  • そして、行列$\boldsymbol{A}$の正規直行基底$\boldsymbol{Q}$は、乱数ベクトル$\boldsymbol{w}_r$を用いて$\boldsymbol{y}_r = \boldsymbol{A} \boldsymbol{w}_r$($r=1,\cdots,R$)を正規直行化すれば得られる(!)

というものでした。

Juliaへの実装

次のように実装してみました。

module RandomApprox

using LinearAlgebra
export svd

function svd(A::AbstractArray{Float64,2}, eta::Float64=1.0E-8)
    nrow = size(A,1)
    ncol = size(A,2)
    transposed = (nrow < ncol)
    if transposed
        A = transpose(A)
    end

    qs = orthonormalbases(A, eta)

    if length(qs) > 0
        Q = hcat(qs...)
        QT = transpose(Q)
        Ut, s, V = LinearAlgebra.svd(QT * A)
        U = Q * Ut
    else
        U = zeros(size(A,1))
        s = zeros(1)
        V = zeros(size(A,2))
    end

    if !transposed
        return U, s, V
    else
        return V, s, U
    end
end

function orthonormalbases(A::AbstractArray{Float64,2}, eta::Float64)
    qs = Vector{Vector{Float64}}(undef,0)

    N = size(A,2)
    for j in 1:N
        w = randn(N)
        q = A * w
        normv = gramschmidt!(q, qs)
        if normv < eta
            break
        end
        push!(qs, q)
    end

    return qs
end

function gramschmidt!(q::AbstractVector{Float64}, qs::AbstractVector{Vector{Float64}})
    #Orthogonalization
    for q2 in qs
        q .-= dot(q,q2) * q2
    end

    #Normalization
    normv = norm(q)
    q ./= normv

    return normv
end

end # module

以下、関数ごとに見ていきます。

svd(A, eta)

ここは

  • 行列$\boldsymbol{A} \in \mathbb{R}^{M \times N}$($M > N$)の正規直行基底$\boldsymbol{Q} \in \mathbb{R}^{M \times R}$を用いて圧縮した行列:$\boldsymbol{Q}^T \boldsymbol{A} \in \mathbb{R}^{R \times N}$についてSVDする。
  • 求まった$\tilde{\boldsymbol{U}}$, $\boldsymbol{\sigma}$, $\boldsymbol{V}$のうち、$\boldsymbol{\sigma}$, $\boldsymbol{V}$はそのままでよく、$\tilde{\boldsymbol{U}}$だけ左から$\boldsymbol{Q}$を掛ければ元の行列のSVDが求まる(!)

の部分です。

function svd(A::AbstractArray{Float64,2}, eta::Float64=1.0E-8)
    nrow = size(A,1)
    ncol = size(A,2)
    transposed = (nrow < ncol)
    if transposed
        A = transpose(A)
    end

    qs = orthonormalbases(A, eta)

    if length(qs) > 0
        Q = hcat(qs...)
        QT = transpose(Q)
        Ut, s, V = LinearAlgebra.svd(QT * A)
        U = Q * Ut
    else
        U = zeros(size(A,1))
        s = zeros(1)
        V = zeros(size(A,2))
    end

    if !transposed
        return U, s, V
    else
        return V, s, U
    end
end

最初に行列Aが横長だったら転置して縦長にしています。

qs = orthonormalbases(A, eta)

で正規直行基底を求めて、基底が1つ以上求まったら QT * A についてのSVDしています。このSVDで求まったUtについては左からQを掛けて、sとVはそのまま返しています。基底が1つも求まらなかったらゼロ行列ですので、とりあえず空のU, s, Vを返しています。

orthonormalbases(A, eta)

ここは

  • そして、行列$\boldsymbol{A}$の正規直行基底$\boldsymbol{Q}$は、乱数ベクトル$\boldsymbol{w}_r$を用いて$\boldsymbol{y}_r = \boldsymbol{A} \boldsymbol{w}_r$($r=1,\cdots,R$)を正規直行化すれば得られる(!)

の部分です。ランクRが小さいほど高速化の恩恵を受けられるので、フルランクを想定してN本の基底を求めに行くのではなく、インクリメンタルに基底を求めていきます。

function orthonormalbases(A::Array{Float64,2}, eta::Float64)
    qs = Vector{Vector{Float64}}(undef,0)

    N = size(A,2)
    for j in 1:N
        w = randn(N)
        q = A * w
        normv = gramschmidt!(q, qs)
        if normv < eta
            break
        end
        push!(qs, q)
    end

    return qs
end

まず正規乱数ベクトルwを生成し、q = A * w を計算しています。グラムシュミットの直行化:

normv = gramschmidt!(q, qs)

で、(j-1)ループ目までに求まっている基底qsで説明される分を差し引いています。このノルムが十分小さかったとき「(j-1)ループ目までで説明しきることができた」=「Aのランクに達した」と判定してループを抜けています。

gramschmidt!(q, qs)

(懐かしの)グラムシュミットの正規直行化です。

function gramschmidt!(q::AbstractVector{Float64}, qs::AbstractVector{Vector{Float64}})
    #Orthogonalization
    for q2 in qs
        q .-= dot(q,q2) * q2
    end

    #Normalization
    normv = norm(q)
    q ./= normv

    return normv
end

forループで、すでに求まっている基底qsで説明される分を差し引き、ノルムが1になるように正規化しています。ランクの判定のために正規化のために求めた(正規化前の)ノルムを返しています。

SVDできていることの確認

> using LinearAlgebra, RandomApprox
> A = randn(2,3)
> U, s, V = RandomApprox.svd(A)
> A .-= U * Diagonal(s) * V'
2×3 Array{Float64,2}:
 -2.88658e-15  3.55271e-15  5.55112e-15
  0.0          3.10862e-15  3.05311e-15

となり、行列Aを再構成できています。 Aが低ランクな場合にもSVDできていることを確認するため、低ランク行列を生成する次の関数を書きました。

function generatelowrankmat(nrow, ncol, rank)
    A = zeros(nrow, ncol)

    for i in 1:rank
        u = randn(nrow)
        v = randn(ncol)
        s = rand()
        A .+= s * (u * v')
    end

    return A
end

これを用いてもう少し大きい行列で試しても

> A = generatelowrankmat(100, 50, 10)
> U, s, V = RandomApprox.svd(A)
> A .-= U * Diagonal(s) * V'
> maximum(abs.(A))
  6.838973831690964e-14

と、Aを再構成できていることが確認できました。

速度比較

フルランクの場合

RandomApprox.svdはAのランクまで圧縮してLinearAlgebra.svdを呼び出しているので、Aがフルランクの場合には低次元化の恩恵は受けられない・・どころか無駄に正規直行基底を算出している分、より遅くなってしまうはずです。確認してみましょう。

> A = randn(1000,500)
> @benchmark U, s, V = LinearAlgebra.svd(A)
  211.089 ms (17 allocations: 17.23 MiB)

> @benchmark U, s, V = RandomApprox.svd(A)
  499.089 ms (125788 allocations: 993.91 MiB)

1000×500のAがフルランクのとき、通常のLinearAlgebra.svdで211msのところ、RandomApprox.svdでは499ms掛かってしまっています。

低ランクの場合

先ほど定義したgeneratelowrankmatで低ランクのAを生成してベンチマークしてみます。

> A = generatelowrankmat(1000, 500, 10)
> @btime U, s, V = LinearAlgebra.svd(A)
  182.693 ms (17 allocations: 17.23 MiB)

> @btime U, s, V = RandomApprox.svd(A)
  2.229 ms (108 allocations: 851.08 KiB)

この場合、RandomApprox.svdは2.20msとなり、LinearAlgebra.svdの182msの83倍速になりました!

おわりに

  • 『前処理による特異値分解の高速化』をJuliaで実装してみました。
  • 分解の対象となる行列が低ランクであれば、かなり高速になりますね!
8
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
7