疎行列データのダウンロード(後半のscipyで使える反復法にて使用)

import pyodide import os import tarfile URL = "https://data.arakaki.tokyo/static/blog/20220301/" FILES = [ "bcsstm22.tar.gz", "lns_3937.tar.gz", "orani678.tar.gz", "memplus.tar.gz" ] paths = list() for file in FILES: # 疎行列データ(tar.gz)ダウンロード with open(file, "w") as f: f.write(pyodide.open_url(f'{URL}{file}').read()) # tarファイルの展開 tar = tarfile.open(file) for path in tar.getnames(): paths.append(path) tar.extract(path) print(path)import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np import pandas as pd import scipy.io as scio import scipy.linalg as scli import scipy.sparse as scsp import scipy.sparse.linalg as la import time

はじめに

$$ % 変数名 \global\def\A{\boldsymbol{A}} \global\def\D{\boldsymbol{D}} \global\def\E{\boldsymbol{E}} \global\def\I{\boldsymbol{I}} \global\def\L{\boldsymbol{L}} \global\def\M{\boldsymbol{M}} \global\def\U{\boldsymbol{U}} \global\def\b{\boldsymbol{b}} \global\def\c{\boldsymbol{c}} \global\def\x{\boldsymbol{x}} \global\def\xk#1{\boldsymbol{x^{({#1})}}} \global\def\y{\boldsymbol{y}} \global\def\P{\boldsymbol{P}} % 数式 \global\def\EQ{\A\x = \b} % n番目の方程式 \global\def\eqn#1{a_{{#1}1} x_{1} + a_{{#1}2} x_{2} + \dots + a_{{#1}n} x_{n} = b_{#1}} % n番目の漸化式 \global\def\rn#1{x^{(k+1)}_{#1} = x^{(k)}_{#1} + \frac{1}{a_{{#1}{#1}}}(b_{#1} - \sum_{i=1}^{n} a_{{#1}i} x^{(k)}_{i})} % 行列・ベクトルの展開表記 \global\def\expA{ \begin{bmatrix} a_{11} & a_{12} & \cdots & a_{1n}\\ a_{21} & a_{22} & \cdots & a_{2n}\\ \vdots & \vdots & & \vdots \\ a_{n1} & a_{n2} & \cdots & a_{nn} \end{bmatrix} } \global\def\expL{ \begin{bmatrix} 0 & 0 & \dots & 0 \\ a_{21} & 0 & \ddots & \vdots \\ \vdots & \ddots & \ddots & 0 \\ a_{n1} & \dots & a_{nn-1} & 0 \end{bmatrix} } \global\def\exL{ \begin{bmatrix} & & \\ \vdots & \ddots & \\ a_{n1} & \dots & \end{bmatrix} } \global\def\expU{ \begin{bmatrix} 0 & a_{12} & \dots & a_{1n} \\ 0 & 0 & \ddots & \vdots \\ \vdots & \ddots & \ddots & a_{n-1n} \\ 0 & \dots & 0 & 0 \end{bmatrix} } \global\def\exU{ \begin{bmatrix} & \dots & a_{1n} \\ & \ddots & \vdots \\ & & \\ \end{bmatrix} } \global\def\expD{ \begin{bmatrix} a_{11} & 0 & \dots & 0 \\ 0 & a_{22} & \dots & \vdots \\ \vdots & \ddots & \ddots & 0 \\ 0 & \dots & 0 & a_{nn} \end{bmatrix} } \global\def\expx{\begin{bmatrix}x_1 \\ x_2 \\ \vdots\\ x_n\end{bmatrix}} \global\def\expxk#1{\begin{bmatrix}x^{({#1})}_1 \\ x^{({#1})}_2 \\ \vdots\\ x^{({#1})}_n\end{bmatrix}} \global\def\exx{\begin{bmatrix}x_1 \\ \vdots\\ x_n\end{bmatrix}} \global\def\expb{\begin{bmatrix}b_1 \\ b_2 \\ \vdots\\ b_n\end{bmatrix}} \global\def\exb{\begin{bmatrix}b_1 \\ \vdots\\ b_n\end{bmatrix}} $$

前回の記事に引き続き、今回は反復法による連立一次方程式(linear system of equations)の解法を見ていきます。

なお、以降を通じて扱う連立一次方程式を$\EQ$と書くことにします。ここで、
$\A = \expA\;,\;\; \b = \expb\;,\;\; \x = \expx\;.$

であり、$\A$と$\b$がそれぞれ既知であるとき、$\x$を求める問題です。

これら連立一次方程式を構成するパーツを

  • $\A$: 係数行列(coefficient matrix)
  • $\b$: 定数項、定数ベクトル(constant vector)
  • $\x$: 解、解ベクトル(solution)

と呼びます。

また、数値計算のプログラムではscipyの疎行列クラスを使用することとします。

ヤコビ法

考え方

2元連立1次方程式で考えます。

$$ % ここだけ定義 行列⇔連立一次方程式の部品 \global\def\Ga{ \begin{bmatrix} a_{11} & a_{12}\\ a_{21} & a_{22} \end{bmatrix} } \global\def\Gx{\begin{bmatrix}x_1 \\ x_2\end{bmatrix}} \global\def\Gb{\begin{bmatrix}b_1 \\ b_2\end{bmatrix}} \global\def\eqi {a_{11} x_{1} + a_{12} x_{2} = b_1} \global\def\eqii {a_{21} x_{1} + a_{22} x_{2} = b_2} $$

$$ \tag{1} {\Ga\Gx = \Gb \Leftrightarrow \begin{cases}\begin{aligned}\eqi\\ \eqii\end{aligned}\end{cases}} $$

$(1)$式を変形して$x_1, x_2$をそれぞれ左辺に持つ式を作ります。

$$ % ここだけ定義 漸化式の前段階 \global\def\eqxi {x_{1} = \frac{1}{a_{11}}(b_1 - a_{12} x_{2})} \global\def\eqxii {x_{2} = \frac{1}{a_{22}}(b_2 - a_{21} x_{1})} $$ $$ \tag{2} \begin{cases}\begin{aligned}\eqxi\\ \eqxii\end{aligned}\end{cases} $$

ここで新たな変数$x^{(k)}_1, x^{(k)}_2\;(k=0,1,\cdots)$を用意し、$x^{(k)}_1, x^{(k)}_2$に関する漸化式を作ります。

$$ % ここだけ定義 漸化式 \global\def\ri {x^{(k+1)}_1 = \frac{1}{a_{11}}(b_1 - a_{12} x^{(k)}_{2})} \global\def\rii {x^{(k+1)}_{2} = \frac{1}{a_{22}}(b_2 - a_{21} x^{(k)}_{1})} $$ $$ \tag{3} \begin{cases}\begin{aligned}\ri\\ \rii\end{aligned}\end{cases} $$

さて、ここで適当な初期値$x^{(0)}_1, x^{(0)}_2$を定めて$k=0,1,\cdots$と順次計算していき、仮に$k \to \infty$で$x^{(k)}_1, x^{(k)}_2$の値がそれぞれ収束したとすると、その値は$(2)$式、すなわち$(1)$式を満たすので解であるということになります。
また、そのとき十分大きな$k$に対する$x^{(k)}_1, x^{(k)}_2$は解の近似値となり得ます。

という訳で、漸化式を順次計算して許容できる誤差で解の近似値を得ようというのがヤコビ法の基本的な考え方です。

実装に向けて

以下のn元連立一次方程式に拡張して考えてみましょう。

$$\begin{cases}\begin{matrix} \eqn{1}\\ \eqn{2}\\ \vdots\\ \eqn{n} \end{matrix}\end{cases} $$

上と同様に漸化式を作るのですが、少し修正を加えて【$x^{(k+1)}_n = x^{(k)}_n + ❓$】のような、前の近似値に修正項を加えていくような形にします。

そうすることで,収束する場合は修正項の大きさも減少していくため情報落ちを起こし, 近似値の変化も小さくなってついには全く変化しなくなることが期待出来,収束の判断がしやすく なる。

参照)Numerical Computation as Software | chap09

そうすると以下の漸化式が得られます。

$$\tag{4}{\begin{cases}\begin{matrix} \rn{1}\\ \rn{2}\\ \vdots\\ \rn{n} \end{matrix}\end{cases}} $$

ここで$x^{(k)}_1, x^{(k)}_2, \cdots, x^{(k)}_n$のベクトルを$\xk{k}$、$\A$の対角行列を$\D$として、$(4)$式を行列で表現すると以下のようになります。

$$\xk{k} = \expxk{k},\;\; D = \expD$$

$$\tag{5}{\xk{k+1} = \xk{k} + \D^{-1}(\b - \A\xk{k})}$$

実装

という訳で実装してみたのが下のjacobi関数です。$\A, \b$を受け取って漸化式の関数を返します。
漸化式の関数は$\x^{(k+1)}$だけではなく残差(residual: $\b - \A\x^{(k)}$)ベクトルのノルムも返すようにしました。

def jacobi(A, b): D = A.diagonal().reshape(-1, 1) def recurrence_relation(x): residual = b - A@x diff = residual / D return x+diff, np.linalg.norm(residual) return recurrence_relation

動作確認してみます。
ここではとりあえず初期値は0で始めて、scipy.sparse.linalgspsolve関数で求めた解程度の精度で反復を打ち切るようにします(残差基準1)。

●諸々の関数定義

# 縮小表示 def getAxb(n, issparse=True, d=2): '''疎行列クラスのA,x,bを返す RETURN ------ A: n x n sparse matrix 対角成分がd、上下の対角成分1の三重対角行列 x: n x 1 sparse matrix [[1], [2], ,,, [n]] b: n x 1 sparse matrix A@x ''' one = np.ones(n) diag = one * d data = [one, diag, one] if issparse: A = scsp.spdiags(data, [1, 0, -1], n, n).tocsr() x = scsp.csr_matrix(np.arange(1, n+1).reshape(-1, 1)) else: A = scsp.spdiags(data, [-1, 0, 1], n, n).toarray() x = np.arange(1, n+1).reshape(-1, 1) b = A@x return A, x, b def do_iter(f, x, tol=1e-10, max_times=100, allow_divergence=True): '漸化式の関数とxの初期値を受け取って反復法を実行する' resi_list = list() for times in range(max_times): new_x, resi= f(x) resi_list.append(resi) if resi < tol: break if not allow_divergence and len(resi_list) > 2 and resi > resi_list[-2]: break x = new_x return times, x, resi_list def mytime(p=True): '関数fの実行時間を測定する' def deco(f): begin = time.time() f() secs = time.time() - begin m, s = divmod(secs, 60) if p: print(f'{m:02g}:{s:06.3f}') return secs return deco

せっかく疎行列を使うので10万次元の行列でやってみます。また、参考までにspsolveの実行時間も計っておきます。

n=100_000 A, x, b = getAxb(n, d=3) print("A: \n", A[:10, :10].toarray()) print("x: \n", x[:10, :].toarray()) print("b: \n", b[:10, :].toarray()) print("spsolveの実行時間:", end="") @mytime() def _(): global tol tol = np.linalg.norm(b - A@la.spsolve(A, b).reshape(-1, 1)) @mytime() def _(): global resi_list times, ans, resi_list = do_iter(jacobi(A, b), np.zeros((n, 1)), tol=tol) print(f'反復回数:{times}')

残差ベクトルのノルムの推移は以下のようになっています。

fig, ax = plt.subplots() ax.semilogy() ax.set_title("残差ベクトルのノルム") ax.set_ylabel("ノルム") ax.set_xlabel("反復回数") ax.plot(resi_list) plt.show()

spsolveの3倍程度の時間でしょうか?今度は収束の遅い係数行列(対角成分が2.1)で実行してみます。

n=100_000 A, x, b = getAxb(n, d=2.1) print("spsolveの実行時間:", end="") @mytime() def _(): global tol tol = np.linalg.norm(b - A@la.spsolve(A, b).reshape(-1, 1)) @mytime() def _(): global resi_list times, ans, resi_list = do_iter(jacobi(A, b), np.zeros((n, 1)), tol=tol, max_times=800) print(f'反復回数:{times}') fig, ax = plt.subplots() ax.semilogy() ax.set_title("残差ベクトルのノルム") ax.set_ylabel("ノルム") ax.set_xlabel("反復回数") ax.plot(resi_list) plt.show()

まぁまぁ時間が掛かった上に、残差ベクトルのノルムの減少が頭打ちになってしまいました。
これはすなわち「収束した」ということで、「収束したとしたらそれは解である」わけなんですが、簡単に実装した限りではこれぐらいの誤差は混入しうるということなんでしょう。
対策としては誤差そのものを減らすような方向と、ある程度の精度で妥協して誤差基準2で収束判定をする方向が考えられます。

このように反復法では問題の係数行列によって実行時間が大きく変動することになります。

ちなみに10万次元の行列を通常のndarrayでインスタンス化しようとすると74.5 GiBのメモリが必要です。疎行列クラスのありがたみが身にしみます。

# MemoryError: Unable to allocate 74.5 GiB for an array with shape (100000, 100000) and data type float64 getAxb(n, issparse=False)

Gauss-Seidel法

考え方

$$ % _がmdとして変換されるの回避 \global\def\foi {x^{(k+1)}_i} \global\def\foii {x^{(k+1)}_1} \global\def\foiii{x^{(k+1)}_2} \global\def\foiiii{x^{(k+1)}_{i-1}} $$

ヤコビ法にちょっとした修正を加え、$\foi$を求める際に既に求めた$\foii, \foiii, \dots, \foiiii$を使うようにします。
3元連立一次方程式だと以下のような漸化式になります。

$$ % ここだけ定義 漸化式 \global\def\axrc#1#2{a_{#1#2} x^{(k)}_{#2}} \global\def\axk#1#2{a_{#1#2} \color{red}{x^{(k+1)}_{#2}}\color{black}} \global\def\GSri {x^{(k+1)}_1 = x^{(k)}_1 + \frac{1}{a_{11}}\{b_1 - (\axrc{1}{1}\:\:\: + \axrc{1}{2}\:\:\: + \axrc{1}{3})\}} \global\def\GSrii {x^{(k+1)}_2 = x^{(k)}_2 + \frac{1}{a_{22}}\{b_2 - ( \axk{2}{1} + \axrc{2}{2}\:\:\: + \axrc{2}{3})\}} \global\def\GSriii{x^{(k+1)}_3 = x^{(k)}_3 + \frac{1}{a_{33}}\{b_3 - ( \axk{3}{1} + \axk{3}{2} + \axrc{3}{3})\}} $$ $$ \tag{6} \begin{cases}\begin{aligned}\GSri\\ \GSrii\\ \GSriii \end{aligned}\end{cases} $$

$k$時点よりも解に近いであろう$k+1$の値を用いることで、反復回数の減少が期待できます。

実装に向けて

$(6)$式の右辺を行列として見ると、上三角の項$a_{rc}(r \le c)$では$x^{(k)}$が、下三角の項$a_{rc}(r > c)$では$x^{(k+1)}$が使われていることが分かります。
従って漸化式全体を行列で表すと以下のようになります。

$$ \L = \expL,\;\; \D = \expD,\;\; \U = \expU\;\; とする。\\[4em] \begin{aligned} \xk{k+1} & = \xk{k} + \D^{-1}(\b - (\L\xk{k+1}+ (\D+\U)\xk{k})) \\ & = \D^{-1}\b - \D^{-1}\L\xk{k+1} - \D^{-1}\U\xk{k} \\ \xk{k+1}の項を移項\\[1em] \xk{k+1} + \D^{-1}\L\xk{k+1} & = \D^{-1}\b - \D^{-1}\U\xk{k} \\ (\I + \D^{-1}\L)\xk{k+1} & = \D^{-1}(\b - \U\xk{k}) & (7)\\ \\ \xk{k+1} & = (\I + \D^{-1}\L)^{-1}\D^{-1}(\b - \U\xk{k}) \\ & = (\D + \L)^{-1}(\b - \U\xk{k}) & (8)\\ \end{aligned} $$

$\xk{k+1}$の式が得られました。が、$(8)$式で逆行列が出てきてしまいました。というのも、$(7)$式が$\EQ$という連立一次方程式の形になってしまっています。

実装

連立一次方程式を解くための反復法の中でさらに連立一次方程式を解かなければならないとなると本末転倒なのでが、せっかくなのでちょっとズルをして実装してみたのが下のGS_cheat関数です。
どんなズルかと言うと、反復毎の連立一次方程式を$\I + \D^{-1}\L$が下三角行列なのでscipy.sparse.linalg.spsolve_triangularで解くようにしてみました。

def GS_cheat(A, b): L = scsp.tril(A, k=-1) D_i = scsp.diags(1/A.diagonal()) U = scsp.triu(A, k=1) new_A = scsp.eye(A.shape[0]) + D_i@L def recurrence_relation(x): new_b = D_i@(b - U@x) if scsp.issparse(new_b): new_b = new_b.toarray() new_x = la.spsolve_triangular(new_A, new_b, overwrite_b=True, unit_diagonal=True) return new_x, np.linalg.norm(b-A@x) return recurrence_relation

行列としてではなく、素朴に$(6)$式を実装したのが下のGS_simple関数です。

def GS_simple(A, b): def recurrence_relation(x): old_x = x.copy() for i in range(A.shape[0]): x[i, 0] += (b[i, 0] - (A[i]@x)[0, 0]) / A[i, i] return x, np.linalg.norm(b-A@old_x) return recurrence_relation

動作確認します。
結論を先取りすると、どちらもscipyの疎行列クラスとしてのうまみを活かせずに生のPythonだとめっちゃ遅いです。
なので100次元の行列で実行してみます。

n=100 A, x, b = getAxb(n, d=3) print("spsolveの実行時間:", end="") @mytime() def _(): global tol tol = np.linalg.norm(b - A@la.spsolve(A, b).reshape(-1, 1)) resis = { "(参考)jacobi": jacobi, "GS_cheat": GS_cheat, "GS_simple": GS_simple } for key, func in resis.items(): @mytime() def _(): times, ans, resis[key] = do_iter(func(A, b), np.zeros((n, 1)), tol=tol) print(f'{key}:\n反復回数:{times}') print()

GS_simpleが遅すぎる…。原理を理解するためとはいえ、jacobiの奮闘を見た後ではゲンナリしてしまいます。
それはさておき反復回数が約半分になったのはいいのですが、なぜかGS_cheatGS_simpleで1回の差があります。

残差ベクトルのノルムを見てみましょう。

fig, ax = plt.subplots(figsize=(15, 8)) ax.semilogy() ax.set_title("残差ベクトルのノルム") ax.set_ylabel("ノルム") ax.set_xlabel("反復回数") for key, resi in resis.items(): ax.plot(resi, label=key) # 目盛り幅 ax.xaxis.set_major_locator(mpl.ticker.MultipleLocator(5)) ax.xaxis.set_minor_locator(mpl.ticker.MultipleLocator(1)) # 目盛り(major, minor)に従ってグリッドを引く ax.grid(which='major') ax.grid(which='minor', ls=":") ax.legend() plt.show()

ほとんど重なっていますが44回目あたりで差が出ています。ここらへんは計算方法の違いによる誤差といったところでしょう。

SOR法

考え方

Gauss–Seidel法にさらに修正を加え、修正項に収束を早めるようなパラメータ$\omega$を掛けます。
3元連立一次方程式だと以下のような漸化式になります。

$$ % ここだけ定義 漸化式 % \global\def\axrc#1#2{a_{#1#2} x^{(k)}_{#2}} % \global\def\axk#1#2{a_{#1#2} \color{red}{x^{(k+1)}_{#2}}\color{black}} \global\def\w{\color{red}\omega\color{black}{}} \global\def\SORri {x^{(k+1)}_1 = x^{(k)}_1 + \w\frac{1}{a_{11}}\{b_1 - (\axrc{1}{1}\:\:\: + \axrc{1}{2}\:\:\: + \axrc{1}{3})\}} \global\def\SORrii {x^{(k+1)}_2 = x^{(k)}_2 + \w\frac{1}{a_{22}}\{b_2 - ( \axk{2}{1} + \axrc{2}{2}\:\:\: + \axrc{2}{3})\}} \global\def\SORriii{x^{(k+1)}_3 = x^{(k)}_3 + \w\frac{1}{a_{33}}\{b_3 - ( \axk{3}{1} + \axk{3}{2} + \axrc{3}{3})\}} $$ $$ \tag{9} \begin{cases}\begin{aligned}\SORri\\ \SORrii\\ \SORriii \end{aligned}\end{cases} $$

この係数$\omega$を加速パラメータ(acceleration parameter)というようです3

例によって行列で表現すると以下のようになります。

$$ \begin{aligned} \xk{k+1} & = \xk{k} + \omega\D^{-1}\Big(\b - \big(\L\xk{k+1}+ (\D+\U)\xk{k}\big)\Big) \\ & = (1-\omega)\xk{k} - \omega\D^{-1}\U\xk{k} + \omega\D^{-1}\b - \omega\D^{-1}\L\xk{k+1} \\ \xk{k+1}の項を移項\\[1em] \big(\I + \omega\D^{-1}\L\big)\xk{k+1} & = \big((1-\omega)\I - \omega\D^{-1}\U\big)\xk{k} + \omega\D^{-1}\b & (10)\\ \end{aligned} $$

実装

素朴に実装した方が遥かに簡単なのですが、GS_simpleがあまりにも遅いのでここでもズルをして$(10)$式を元に実装したのが下のSOR_cheatクラスです。

class SOR_cheat: def __init__(self, A, b): self.b = b self.L = scsp.tril(A, k=-1) self.D_i = scsp.diags(1/A.diagonal()) self.U = scsp.triu(A, k=1) self.I = scsp.eye(A.shape[0]) def get_function(self, w): ''' 漸化式: (I + wD^{-1}L)x^{(k+1)} = ((1-w)I - wD^{-1}U)x^k + wD^{-1}b E, F, G を以下とすると E = I + wD^{-1}L F = (1-w)I - wD^{-1}U g = wD^{-1}b 漸化式: Ex^{(k+1)} = Fx^k + g ''' E = self.I + w*self.D_i@self.L F = (1-w)*self.I - w*self.D_i@self.U g = w*self.D_i@self.b def recurrence_relation(x): new_x = la.spsolve_triangular(E, F@x+g, overwrite_b=True, unit_diagonal=True) return new_x, np.linalg.norm(b-A@x) return recurrence_relation

収束の遅い係数行列(その分次元を減らして20次元)を用意して試してみます。
まずは参考としてjacobi法とGauss-Seidel法でやってみましょう。

n=20 A, x, b = getAxb(n, d=2.1) tol = np.linalg.norm(b - A@la.spsolve(A, b).reshape(-1, 1)) resis = { "jacobi": jacobi, "GS_cheat": GS_cheat, } for key, func in resis.items(): times, ans, resis[key] = do_iter(func(A, b), np.zeros((n, 1)), tol=tol, max_times=600) print(f'{key}') print(f'反復回数:{times}') print(f'最終ノルム:{resis[key][-1]}') print() # 描画 fig, ax = plt.subplots(figsize=(15, 8)) ax.semilogy() ax.set_title("残差ベクトルのノルム") ax.set_ylabel("ノルム") ax.set_xlabel("反復回数") for key, resi in resis.items(): ax.plot(resi, label=key) ax.legend() plt.show()

本題のSOR法を$0 < \omega < 2$の範囲で$0.1$刻みに設定して実行してみます。

SOR_resis = dict() sor = SOR_cheat(A, b) for r in np.arange(0.1, 2, 0.1): key = f'{r:.1f}' times, ans, SOR_resis[round(r, 2)] = do_iter(sor.get_function(r), np.zeros((n, 1)), tol=tol, max_times=500) print(f'{key}, 反復回数:{times}')

🎨描画関数(plot_resis)定義🎨
$\omega = 1$、すなわちGauss-Seidel法の結果を太実線で示します。
それよりも収束が遅ければ点線、早ければ実線で示します。

# 縮小表示 from matplotlib import cm def plot_resis(resis): fig, ax = plt.subplots(figsize=(15, 8)) ax.semilogy() ax.set_title("残差ベクトルのノルム") ax.set_ylabel("ノルム") ax.set_xlabel("反復回数") for i, (key, resi) in enumerate(resis.items()): if 1.0 in resis.keys(): if len(resis[1.0]) == len(resi): ls = "-" if resis[1.0][-1] >= resi[-1] else ":" else: ls = "-" if len(resis[1.0]) >= len(resi) else ":" else: ls = "-" lw = 1.5 if key != 1.0 else 3 ax.plot( resi, color=cm.jet(i/len(resis)), label=f'{key:.3}', # alpha=alpha, ls=ls, lw=lw ) # 目盛り幅 # ax.xaxis.set_major_locator(mpl.ticker.MultipleLocator(5)) # ax.xaxis.set_minor_locator(mpl.ticker.MultipleLocator(1)) # 目盛り(major, minor)に従ってグリッドを引く # ax.grid(which='major') # ax.grid(which='minor', ls=":") ax.legend() return plt.show() plot_resis(SOR_resis)

SOR法($\omega = 1$)とGauss-Seidel法の推移はほぼ一致していることが分かります(下図)。

# 縮小表示 fig, ax = plt.subplots() ax.semilogy() ax.set_title("Gauss-Seidel法とSOR(ω = 1)法") ax.set_ylabel("ノルム") ax.set_xlabel("反復回数") ax.plot(resis["GS_cheat"], label="Gauss-Seidel") ax.plot(SOR_resis[1.0], label="SOR(w=1.0)") ax.legend() plt.show()

今回の係数行列の場合では$\omega = 1.5$で最も収束が早く、Gauss-Seidel法の約1/4となりました。
解くべき問題(係数行列)によって最適なパラメータ$\omega$は異なるので、どうにかして事前に見つけておきたいところですが、それがなかなか難しいようです。4

SOR法はGauss-Seidel法だけが対象か

ところで、SOR法の考え方はjacobi法にも適用できるのではないでしょうか?
学術的・実用的にどれだけ意味があるかは置いておいて、以下のSOR_jacobi関数で確認してみました。

def SOR_jacobi(A, b, w): D = A.diagonal().reshape(-1, 1) def recurrence_relation(x): residual = b - A@x diff = w * residual / D return x+diff, np.linalg.norm(residual) return recurrence_relation def do_sor_jacobi(*args, A, b, tol, max_times=500): resis = dict() for w in np.arange(*args): key = f'{w:.2f}' times, ans, resis[round(w, 3)] = do_iter(SOR_jacobi(A, b, w), np.zeros((n, 1)), tol=tol, max_times=max_times) print(f'{key}, 反復回数:{times}') return resis

結論を先取りして、収束する範囲の$\omega$で反復した結果が以下です。

plot_resis(do_sor_jacobi(0.1, 1.1, 0.1, A=A, b=b, tol=tol, max_times=600))

上で見た通常のSOR法とは収束するパラメータの範囲や最適な値が違えど、何となく雰囲気は似てるような気がします。
なによりjacobi法で頭打ちになった精度が許容範囲まで減少しているケースもあります($\omega = 0.8$)。

最適なパラメータを探すべく、もうちょっと細かく設定してみます。

plot_resis(do_sor_jacobi(0.8, 1.01, 0.01, A=A, b=b, tol=tol, max_times=600))

この結果では、$\omega = 0.98$で設定すれば精度と収束の速さ両取りできそうです。
通常のSOR法ほど大きな改善とは言えませんが、なかなか面白い結果ではないでしょうか。

ヤコビ法の収束条件

ここまで「収束するとしたら」という表現を使ってきましたが、実際に収束するための条件はなんなのでしょうか。
結論から言うと、係数行列$\A$が対角優位行列(優対角行列、Diagonally dominant matrix)であれば収束するらしいです。

$$ | a_{ii}| > \sum_{i \neq j}| a_{ij}|, \;\;\;\; i = 1, \dots, n $$

これは十分条件で、必要十分条件はもう少し緩めることができるらしいです。
ただし、その必要十分条件を解析的に解くのは不可能なので、実用的には十分条件が使われることが多いらしいです。
また、Gauss-Seidel法の収束条件もヤコビ法と同じらしいです。

ここですべて「らしい」というのは私が数学的な証明を理解できていないからなのですが、以下分からないことの記録として幸谷(2021, pp.126-129)(または幸谷(ウェブ教材))を参照して紹介します。

必要十分条件

ヤコビ法の漸化式を行列で表現した式を以下のように書き直します。

$$\begin{aligned} &\begin{aligned} \xk{k+1} & = \xk{k} + \D^{-1}(\b - \A\xk{k}) & (5)式再掲\\ & = (\I - \D^{-1}\A)\xk{k} + \D^{-1}\b & (11)\\ \end{aligned}\\[1em] &\M = \I - \D^{-1}\A,\;\; \c = \D^{-1}\b\;\; とすると、\\[1em] &\begin{cases} \xk{k+1} & = \M\xk{k}\;\;\, + \c\\ \xk{k} & = \M\xk{k-1} + \c\\ \xk{k-1} & = \M\xk{k-2} + \c\\ & \vdots\\ \xk{1} & = \M\xk{0}\;\;\, + \c\\ \end{cases} &(12)\\[1em] &解\xは(12)式を満たすので\\[1em] &\x = \M\x + \c &(13) \end{aligned}$$

ここで、任意の初期値$\xk{0}$に対して(12)式を用いて得られる列$\{\x_i\}^\infty_0$が収束するためには、行列$\M$のすべての固有値の絶対値が1より小さいことが必要十分条件である。

証明(⇒)

$$\begin{aligned} &k回目の反復によって得られる\xk{k}と\xとの差は\\ &\begin{aligned} \x - \xk{k} & = \M\big(\x - \xk{k-1}\big)\\ & = \M\big(\M\big(\x - \xk{k-2}\big)\big)\\ & \vdots \\ & = \M^k\big(\x - \xk{0}\big) \end{aligned}&(14)\\ &であるから\\ & \| \x - \xk{k} \| \le \| \M \|^k \| \x - \xk{0} \| & (15)\\ &を得る。これより\| \M \| \lt 1 であるためには、固有値はすべて1未満でなくてはならない。 \end{aligned}$$

Arakakiコメント:(14)まではまぁ分かる。(15)もそういうもんだと飲み込むとして、「$\| \M \| \lt 1 であるためには~$」のくだりが🤔❓である。$\| \M \| \lt 1$だったら$k \to \infty$のとき$\xk{k}-\x \to 0$で収束するのは分かるが、行列のノルムの性質や固有値との関係がよく分かっていない。

(⇐)

$$\begin{aligned} &仮定より、\| \M \| \lt 1 であるから\\ &\begin{aligned} \xk{k} & = \M\xk{k-1} + \c \\ & = \M\big(\M\xk{k-2} + \c \big) + \c \\ & \vdots \\ & = \M^k\xk{0} + \big(\sum^{k-1}_{i = 1}\M^i\big)\c & \;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;(16) \end{aligned}\\ &を得る。よって、\lim_{k \to \infty}\xk{k} = \big(\I - \M\big)^{-1}\c となる。\;\;\;\; (17) \end{aligned}$$

Arakakiコメント:上述の繰り返しだが「$仮定より、\| \M \| \lt 1 である$」のがまずわからん。(16)式のシグマは$i=0$では?(17)で$\M^k = 0$として消えているが、$\| \M \| \lt 1$なら$\lim_{k \to \infty}\M^k = 0$となるのは当たり前なの?

十分条件

Gerschgorinの定理を用いて十分条件を得ます。

まず$\M$の成分を書き下すと以下のようになります。

$$ \M = \begin{bmatrix} 0 & -\frac{a_{12}}{a_{11}} & \cdots & -\frac{a_{1n}}{a_{11}}\\ -\frac{a_{21}}{a_{22}} & 0 & \ddots & \vdots\\ \vdots & \ddots & \ddots & -\frac{a_{n-1,n}}{a_{n-1,n-1}} \\ -\frac{a_{n1}}{a_{nn}} & \cdots & -\frac{a_{n,n-1}}{a_{nn}} & 0 \end{bmatrix} $$

$$ \begin{aligned} & Gerschgorinの定理 \\ &\;\;\;\; 正方行列A = [a_{ij}](i,j = 1, 2, \dots,n)に対し、複素平面\mathbb{C}において\\ &\;\;\;\; 中心a_{ii}、半径\sum^n_{j=1,j \ne i}|a_{ij}|の円S_i(i=1,2,\dots,n)を定義した時、\\ &\;\;\;\; 全てのAの固有値\lambdaは\cup^n_{i=1}S_i内に存在する。これは任意のAの固有値\lambdaに対し\\ &\;\;\;\; |a_{ii} - \lambda| \le \sum^n_{j=1,j\ne i}|a_{ij}| \\ &\;\;\;\; を満たすi \in \{1,2,\dots,n\}が必ず存在することを意味する。\\ & \\ & \\ & そうすれば任意の\Mの固有値\lambdaに対して\\ & |\lambda| \le \max_i \sum^n_{j=1, j \ne i}\left|\frac{a_{ij}}{a_{ii}}\right| & (18) \\ & だから \\ & \max_j \sum^n_{i=1, i \ne j}\left|\frac{a_{ij}}{a_{ii}}\right| \le 1 & (19)\\ & が収束のための十分条件となる。これは同時に\| \M \|_1 \le 1 となる条件も満たしている。 \\ & また、\| \cdot \|_\inftyを用いて\|\M\|_\infty \le 1 となる条件を満たすためには、\\ & \max_i \sum^n_{j=1, j\ne i}\left|\frac{a_{ij}}{a_{ii}}\right| \le 1 & (20)\\ & となればよい。 \end{aligned}$$

Arakakiコメント:(19)は「行列$\M$のすべての固有値の絶対値が1より小さい」から?なんで不等号と行列の方向が入れ替わるの…?ここでpノルムを指定しだしたが、無印のノルムはなんだったんだ?やっぱり行列ノルムの理解が欠けてる。

SOR法はヤコビ法で収束しない問題にも使える?

ちょっとした疑問なのですが、WikipediaのSOR法のページで「$\omega$が1より小さいとき ガウス=ザイデル法より収束が遅くなる。ただし、ガウス=ザイデル法で収束しないような問題には使える」と書いてありました。5

という訳で、検証してみます。

係数行列として、非ゼロ要素が全て1の3重対角行列を用意します。
ヤコビ法とGauss-Seidel法で実行すると以下のようになります。

n=20 A, x, b = getAxb(n, d=1) resis = { "jacobi": jacobi, "GS_cheat": GS_cheat, } for key, func in resis.items(): times, ans, resis[key] = do_iter(func(A, b), np.zeros((n, 1))) fig, ax = plt.subplots() ax.semilogy() ax.set_title("残差ベクトルのノルム") ax.set_ylabel("ノルム") ax.set_xlabel("反復回数") for key, resi in resis.items(): ax.plot(resi, label=key) ax.legend() plt.show()

Gauss-Seidel法では一瞬収束するかのような素振りを見せましたが、結局発散してしまいました。
続いてSOR法($\omega < 1$)で実行してみます。

SOR_resis = dict() sor = SOR_cheat(A, b) for r in np.arange(0.1, 1.1, 0.1): key = f'{r:.2f}' times, ans, SOR_resis[round(r, 2)] = do_iter(sor.get_function(r), np.zeros((n, 1)), max_times=25) plot_resis(SOR_resis)

むむむ…。これは果たして「使える」と言っていいレベルなのだろうか…
もうちょっと粘って最適値を探してみました。今度は発散に転じると反復を打ち切るようにします。

SOR_resis = dict() sor = SOR_cheat(A, b) for r in np.arange(0.01, 0.21, 0.01): key = f'{r:.2f}' times, ans, SOR_resis[round(r, 2)] = do_iter(sor.get_function(r), np.zeros((n, 1)),allow_divergence=False, max_times=200) print(f'{key}, 反復回数:{times:2}, 残差ベクトルのノルムの最小値:{min(SOR_resis[round(r, 2)])}') plot_resis(SOR_resis)

$\omega = 0.2$で得られた解を見てみましょう。

from pprint import pp pp(ans)

真の解は1,2,...,20なので、15個目ぐらいまではまぁ…?、いや贔屓目に見てもこの精度では「使える」とは言えないでしょう。

という訳で結論:使えない。

scipyで使える反復法

scipy.sparse.linalgパッケージでは、共役勾配法に代表される非定常反復法(クリロフ部分空間法)の様々なアルゴリズムが利用できます。6

これらは前処理によって大幅に反復回数が改善したり、それぞれが得意とする問題が異なったりするようですが、ここではとりあえず触ってみる程度に留めます。

●全てのアルゴリズムを実行する関数(do_all)定義

# 縮小表示 def do_all(A, b, tol, maxiter=10**4, exclude=list()): solvers = [ # solver args [la.spsolve, dict()], [la.bicg, dict(maxiter=maxiter, tol=tol, atol=0)], [la.bicgstab, dict(maxiter=maxiter, tol=tol, atol=0)], [la.cg, dict(maxiter=maxiter, tol=tol, atol=0)], [la.cgs, dict(maxiter=maxiter, tol=tol, atol=0)], [la.gmres, dict(maxiter=maxiter, tol=tol, atol=0)], [la.lgmres, dict(maxiter=maxiter, tol=tol, atol=0)], [la.minres, dict(maxiter=maxiter, tol=tol)], [la.qmr, dict(maxiter=maxiter, tol=tol, atol=0)], [la.gcrotmk, dict(maxiter=maxiter, tol=tol, atol=0)], ] for f in exclude: solvers.remove(next(filter(lambda solver: solver[0] == f, solvers))) result = list() for method, args in solvers: print(f'{method.__name__:10}: ', end="") def f(): global ret ret = method(A, b, **args) elapsed = mytime()(f) ans = ret[0] if len(ret) == 2 else ret result.append([method.__name__, elapsed, np.linalg.norm(b-A@ans)]) return pd.DataFrame(result, columns=["名前", "実行時間", "残差ベクトルのノルム"])

🐼描画関数(plot_df)定義🐼

# 縮小表示 def plot_df(df): fig, ax = plt.subplots(figsize=(12, 8)) w = 0.4 ax1 = df.plot(ax=ax, x=0, y=1, width=-w, kind="bar", align="edge", logy=False, color="orange") # barの上に値を表示 # ax1.bar_label(ax1.containers[0]) ax2 = df.plot(ax=ax, x=0, y=2, width=w, kind="bar", align="edge", logy=True, secondary_y=True) # x軸のticklabelを水平表示 fig.autofmt_xdate(rotation=0, ha="center") # 図の左が狭い(マイナスwidth + align="edge"が反映されてない?) ax.set_xlim(left=-0.65) # logy=False が反映されない ax1.set_yscale("linear") # 軸ラベル設定 ax.set_ylabel("\n".join(df.columns[1])+"\n(秒)", rotation=0, va="center", labelpad=20) ax2.set_ylabel("\n".join(df.columns[2]), rotation=0, va="center", labelpad=20) ax.set_xlabel("") return plt.show() # for pycell

色んな係数行列で実行してみます。

なお収束判定のtolは$\frac{\|\b - \A\xk{k}\|}{\|\b\|} < \varepsilon$という形で使われます(相対的な残差基準)。
今回も例によってspsolve関数を基準に設定してみますが、それ以前に反復回数が上限(maxiter)に達したら反復を打ち切ります(do_all関数内参照)。

🌸収束の遅い係数行列(n = 500, 精度:spsolveの1/10程度)

n = 500 A, x, b = getAxb(n, d=2) b = b.toarray().flatten() tol = np.linalg.norm(b - A@la.spsolve(A, b)) / np.linalg.norm(b) df = do_all(A, b, tol*10, maxiter=500) df plot_df(df)

🌸収束の早い係数行列(n = 100,000, 精度:spsolveの1/2程度、ブラウザだとgcrotmkがめっちゃ遅いので除外)

n = 100_000 A, x, b = getAxb(n, d=3) b = b.toarray().flatten() tol = np.linalg.norm(b - A@la.spsolve(A, b)) / np.linalg.norm(b) df = do_all(A, b, tol*2, exclude=[la.gcrotmk]) df plot_df(df)

🌸発散する係数行列(n = 100, 精度:spsolveの1/100程度)

n = 500 A, x, b = getAxb(n, d=1.1) b = b.toarray().flatten() tol = np.linalg.norm(b - A@la.spsolve(A, b)) / np.linalg.norm(b) df = do_all(A, b, tol*100, exclude=[la.gmres]) df plot_df(df)

各アルゴリズムの得手不得手が出てそうですが、総じてspsolveが最強では…?

実際のデータを係数行列としてやってみます。

# MMファイルを読み込んでdataにまとめる data = dict() for p in paths: m = scio.mmread(p) # 正方行列だけ扱う if m.shape[0] == m.shape[1]: name, ext = os.path.splitext(os.path.basename(p)) data[name] = m pp(data) fig = plt.figure(figsize=[10,10], tight_layout=True) for i, (k, m) in enumerate(data.items()): ax = fig.add_subplot(2, 2, i+1) ax.spy(m, markersize=1) ax.set_title(k) plt.show()

bcsstm22

A = data["bcsstm22"].tocsr() n = A.shape[0] x = np.arange(1, n+1).reshape((n, -1)) b = (A@x).flatten() tol = np.linalg.norm(b - A@la.spsolve(A, b)) / np.linalg.norm(b) result = do_all(A, b, 10e-15) df = pd.DataFrame(result, columns=["名前", "実行時間", "残差ベクトルのノルム"]) df plot_df(df)

orani678(gmres, lgmres, gcrotmkが遅いので除外)

A = data["orani678"].tocsr() n = A.shape[0] x = np.arange(1, n+1).reshape((n, -1)) b = (A@x).flatten() tol = np.linalg.norm(b - A@la.spsolve(A, b)) / np.linalg.norm(b) result = do_all(A, b, tol*100, maxiter=2000, exclude=[la.gmres, la.lgmres, la.gcrotmk]) df = pd.DataFrame(result, columns=["名前", "実行時間", "残差ベクトルのノルム"]) df plot_df(df)

lns_3937

A = data["lns_3937"].tocsr() n = A.shape[0] x = np.arange(1, n+1).reshape((n, -1)) b = (A@x).flatten() tol = np.linalg.norm(b - A@la.spsolve(A, b)) / np.linalg.norm(b) result = do_all(A, b, tol*100, exclude=[la.lgmres, la.gcrotmk], maxiter=500) df = pd.DataFrame(result, columns=["名前", "実行時間", "残差ベクトルのノルム"]) df plot_df(df)

memplus(gmresが遅いので除外)

A = data["memplus"].tocsr() n = A.shape[0] x = np.arange(1, n+1).reshape((n, -1)) b = (A@x).flatten() tol = np.linalg.norm(b - A@la.spsolve(A, b)) / np.linalg.norm(b) result = do_all(A, b, tol, maxiter=4000, exclude=[la.gmres, la.gcrotmk]) df = pd.DataFrame(result, columns=["名前", "実行時間", "残差ベクトルのノルム"]) df plot_df(df)

結局、前処理しなかったらspsolveがほぼ最強じゃないですか。
係数行列の性格によって内部でアルゴリズムを使い分けたりしてるのかしら🤔

おまけ:幾何的に理解する

jacobi法を2元連立1次方程式で反復して描画すると色んな形になって面白いです。

🎨描画関数定義🎨

# 縮小表示 def plot_jacobi2d(A, x, b, x_list, ax): def gen_line(a, b, c): '''return x and y of ax + by = c Refer outer defined variables - minx - maxx - miny - maxy ''' if b == 0: y = np.array([miny, maxy]) x = np.array([c / a] * y.size) return x, y else: x = np.array([minx, maxx]) if a == 0: y = np.array([c / b] * x.size) else: y = (c - a*x) / b return x, y # 描画範囲: x(2直線の交点)を中心に minx = x[0] - 10 maxx = x[0] + 10 miny = x[1] - 10 maxy = x[1] + 10 # 2直線のデータ x1, y1 = gen_line(A[0, 0], A[0, 1], b[0]) x2, y2 = gen_line(A[1, 0], A[1, 1], b[1]) # 範囲指定 ax.axis('scaled') ax.set_xlim(minx, maxx) ax.set_ylim(miny, maxy) # 目盛り幅 ax.xaxis.set_major_locator(mpl.ticker.MultipleLocator(5)) ax.yaxis.set_major_locator(mpl.ticker.MultipleLocator(5)) ax.xaxis.set_minor_locator(mpl.ticker.MultipleLocator(1)) ax.yaxis.set_minor_locator(mpl.ticker.MultipleLocator(1)) # 目盛り(major, minor)に従ってグリッドを引く ax.grid(which='major') ax.grid(which='minor', ls=":") # 軸ラベルの設定 ax.set_xlabel("x_1", loc="right", labelpad=0) ax.set_ylabel("x_2", loc="top", labelpad=-30, rotation=0) # 軸が(0, 0)を通るように #ax.spines[:].set_position("zero") # pyodideのmpl(v.3.3.3)では非対応 [s.set_position("zero") for s in ax.spines.values()] # plot ax.plot(x1, y1, label=f"{A[0, 0]}x_1 + {A[0, 1]}x_2 = {b[0, 0]}") ax.plot(x2, y2, label=f"{A[1, 0]}x_1 + {A[1, 1]}x_2 = {b[1, 0]}") ax.plot(x_list[:, 0, 0], x_list[:, 1, 0], "o-") # 凡例 ax.legend(bbox_to_anchor=(0, 0), loc="upper left", edgecolor="white") return ax

「Run」する度に異なる図になります。

# 適当なA,x,bを作成 rng = np.random.default_rng() while True: A = rng.integers(-9, 9, 4).reshape(2, 2) x = rng.integers(-9, 9, 2).reshape(2, 1) b = A@x if np.all(A.diagonal() != 0): break # jacobi法を10回反復 f = jacobi(A, b) working_x = np.zeros((2, 1)) x_list = list([working_x]) for _ in range(10): new_x, _ = f(working_x) x_list.append(new_x) working_x = new_x # 描画 fig, ax = plt.subplots() plot_jacobi2d(A, x, b, np.array(x_list), ax) plt.show()

パターン別にサンプルを集めてみました。
皆さんはどんな形が好きですか?

data_Ax = [ [ # ぐるぐる発散 np.array([[ 3, 4], [-2, 2]]), np.array([0, 4]).reshape(2, 1), ],[ # ぐるぐる発散2 np.array([[ 3, 4], [-3, 3]]), np.array([-4, 3]).reshape(2, 1), ],[ # ぎざぎざ発散 np.array([[-2, 4], [ 3, -4]]), np.array([-1, -1]).reshape(2, 1,) ],[ # ぎざぎざ発散2 np.array([[-4, -4], [-3, -2]]), np.array([-2, 2]).reshape(2, 1), ],[ # 振動 np.array([[-2, 1], [-2, -1]]), np.array([-2, 1]).reshape(2, 1), ],[ # ぐるぐる収束 np.array([[-3, -3], [ 3, -4]]), np.array([4, 0]).reshape(2, 1), ],[ #ぎざぎざ収束 np.array([[ 3, 3], [-3, -4]]), np.array([-4, 2]).reshape(2, 1), ] ] fig = plt.figure(figsize=(10, 20), tight_layout=True) COL = 2 ROW = len(data_Ax)//COL + 1 for i, (A, x) in enumerate(data_Ax): ax = fig.add_subplot(ROW, COL, i+1) b = A@x f = jacobi(A, b) working_x = np.zeros((2, 1)) x_list = list([working_x]) for _ in range(10): new_x, _ = f(working_x) x_list.append(new_x) working_x = new_x plot_jacobi2d(A, x, b, np.array(x_list), ax) plt.show()

自由欄


参考文献


  1. 残差基準:$\|b-Ax^{(k)}\| < \varepsilon$ ならば計算を打ち切る。(渡部, 1999)。
     

  2. 誤差基準:$\|x^{(k)} - x^{(k+1)}\| < \varepsilon$ ならば計算を打ち切る。(渡部, 1999)
     

  3. 英Wikiでは"relaxation factor"とされていたりするなど、表現に若干の揺れがあります。Successive over-relaxation - Wikipedia
     

  4. 最適なパラメータ:数学的な導出方法は、例えば幸谷(ウェブ教材)に詳しいが、Arakakiは解説できるほどの知識がありません…。また、今回参照した範囲では実践的な設定方法などを見つけることはできませんでした。
     

  5. その出典が個人サイトで、そこでも詳しい説明がない時点で信憑性はお察しなのですが…。
     

  6. 種々の方法の簡単な紹介は例えば渡部(1995)を参照