diff --git a/Week 2/feedback/2022-02-15 16:02:20.777293 UTC/5 Discrete and Fast Fourier Transforms.html b/Week 2/feedback/2022-02-15 16:02:20.777293 UTC/5 Discrete and Fast Fourier Transforms.html new file mode 100644 index 0000000..4f8caee --- /dev/null +++ b/Week 2/feedback/2022-02-15 16:02:20.777293 UTC/5 Discrete and Fast Fourier Transforms.html @@ -0,0 +1,13844 @@ + + +
+ + +See lecture notes and documentation on Brightspace for Python and Jupyter basics. If you are stuck, try to google or get in touch via Discord.
+Solutions must be submitted via the Jupyter Hub.
+Make sure you fill in any place that says YOUR CODE HERE or "YOUR ANSWER HERE".
In the following we will implement a DFT algorithm and, based on that, a FFT algorithm. Our aim is to experience the drastic improvement of computational time in the FFT case.
+ +import numpy as np
+from matplotlib import pyplot as plt
+import timeit
+Implement a Python function $\text{DFT(yk)}$ which returns the Fourier transform defined by
+\begin{equation} +\beta_j = \sum^{N-1}_{k=0} f(x_k) e^{-ij x_k} +\end{equation}with $x_k = \frac{2\pi k}{N}$ and $j = 0, 1, ..., N-1$. The $\text{yk}$ should represent the array corresponding to $y_k = f(x_k)$. Please note that this definition is slightly different to the one we introduced in the lecture. Here we follow the notation of Numpy and Scipy.
+Hint: try to write the sum as a matrix-vector product and use $\text{numpy.dot()}$ to evaluate it.
+ +def DFT(yk):
+ """
+ Return discrete fourier transform (DFT) of yk for N discrete frequency intervals.
+ """
+
+ N = len(yk)
+ xk = 2*np.pi*np.arange(N)/N
+ beta = np.dot(yk, np.exp(np.outer(-np.arange(N), xk*1j)))
+ return beta
+Make sure your function $\text{DFT(yk)}$ and Numpy’s FFT function $\text{numpy.fft.fft(yk)}$ return +the same data by plotting $|\beta_j|$ vs. $j$ for
+\begin{equation} + y_k = f(x_k) = e^{20i x_k} + e^{40 i x_k} +\end{equation}and +\begin{equation} + y_k = f(x_k) = e^{i 5 x_k^2} +\end{equation}
+using $N = 128$ for both routines.
+ +N = 128
+
+xk = 2*np.pi*np.arange(N)/N
+yk0 = np.exp(20j*xk) + np.exp(40j*xk)
+yk1 = np.exp(5j*xk*2)
+
+fig, ax = plt.subplots(1, 2, sharex=True, sharey=True, figsize=(16,6))
+
+ax[0].set_xlabel("j")
+ax[1].set_xlabel("j")
+
+ax[0].set_title("$y_k = e^{20ix_k} + e^{40ix_k}$")
+ax[0].plot(np.abs(DFT(yk0)), label="DFT")
+ax[0].plot(np.abs(np.fft.fft(yk0)), label="numpy.fft.fft")
+ax[0].legend(loc="upper right")
+
+ax[1].set_title("$y_k = e^{i5x^2_k}$")
+ax[1].plot(np.abs(DFT(yk1)), label="DFT")
+ax[1].plot(np.abs(np.fft.fft(yk1)), label="numpy.fft.fft")
+ax[1].legend(loc="upper right")
+
+# TODO: So the graphs overlap completely. Is this good enough?
+# To make it more clear, we could mirror one of the graphs (multiply by -1),
+# like what is often done in spectroscopy, or we could add the difference.
+
+fig.show()
+Analyze the evaluation-time scaling of your $\text{DFT(yk)}$ function with the help of the timeit +module. Base your code on the following example:
+import timeit
+
+tOut = timeit.repeat(stmt=lambda: DFT(yk), number=10, repeat=5)
+tMean = np.mean(tOut)
+This example evaluates $\text{DFT(yk)}$ 5 × 10 times and stores the resulting 5 evaluation times in tOut. Afterwards we calculate the mean value of these 5 repetitions. +Use this example to calculate and plot the evaluation time of your $\text{DFT(yk)}$ function for $N = 2^2, 2^3, ..., 2^M$. Depending on your implementation you might be able to go up to $M = 10$. Be careful and increase M just step by step!
+ +for M in range(2, 10+1):
+ N = 2**M
+ xk = 2*np.pi*np.arange(N)/N
+ # Using the first equation for yk from the previous exercise.
+ yk = np.exp(20j*xk) + np.exp(40j*xk)
+ tOut = timeit.repeat(stmt=lambda: DFT(yk), number=10, repeat=5)
+ tMean = np.mean(tOut)
+ print("M =", M, "gives")
+ print("tOut =", tOut)
+ print()
+A very simple FFT algorithm can be derived by the following separation of the sum from +above:
+\begin{align} + \beta_j = \sum^{N-1}_{k=0} f(x_k) e^{-ij \frac{2\pi k}{N}} + &= \sum^{N/2 - 1}_{k=0} f(x_{2k}) e^{-ij \frac{2\pi 2k}{N}} + + \sum^{N/2 - 1}_{k=0} f(x_{2k+1}) e^{-ij \frac{2\pi (2k+1)}{N}}\\ + &= \sum^{N/2 - 1}_{k=0} f(x_{2k}) e^{-ij \frac{2\pi k}{N/2}} + + \sum^{N/2 - 1}_{k=0} f(x_{2k+1}) e^{-ij \frac{2\pi k}{N/2}} e^{-ij \frac{2\pi}{N}}\\ + &= \beta^{\text{even}}_j + \beta^{\text{odd}}_j e^{-ij \frac{2\pi}{N}} +\end{align}where $\beta^{\text{even}}_j$ is the Fourier transform based on only even $k$ (or $x_k$) and $\beta^{\text{odd}}_j$ the Fourier transform based on only odd $k$. In case $N = 2^M$ this even/odd separation can be done again and again in a recursive way.
+Use the template below to implement a $\text{FFT(yk)}$ function, making use of your $\text{DFT(yk)}$ function from above. Make sure that you get the same results as before by comparing the results from $\text{DFT(yk)}$ +and $\text{FFT(yk)}$ for both functions defined in task 2.
+def FFT(yk):
+ """Don't forget to write a docstring ...
+ """
+ N = # ... get the length of yk
+
+ assert # ... check if N is a power of 2. Hint: use the % (modulo) operator
+
+ if(N <= 2):
+ return # ... call DFT with all yk points
+
+ else:
+ betaEven = # ... call FFT but using just even yk points
+ betaOdd = # ... call FFT but using just odd yk points
+
+ expTerms = np.exp(-1j * 2.0 * np.pi * np.arange(N) / N)
+
+ # Remember : beta_j is periodic in j !
+ betaEvenFull = np.concatenate([betaEven, betaEven])
+ betaOddFull = np.concatenate([betaOdd, betaOdd])
+
+ return betaEvenFull + expTerms * betaOddFull
+def FFT(yk):
+ """
+ Return the fast fourier transform (FFT) of array yk by considering odd
+ and even points and making use of discrete fourier transforms (DFTs).
+ """
+
+ N = len(yk)
+
+ # N should be a power of two
+ assert np.log2(N).is_integer()
+
+ if(N <= 2):
+ return DFT(yk)
+
+ else:
+ betaEven = FFT(yk[::2])
+ betaOdd = FFT(yk[1::2])
+
+ expTerms = np.exp(-1j * 2.0 * np.pi * np.arange(N) / N)
+
+ # Remember : beta_j is periodic in j !
+ betaEvenFull = np.concatenate([betaEven, betaEven])
+ betaOddFull = np.concatenate([betaOdd, betaOdd])
+
+ return betaEvenFull + expTerms * betaOddFull
+# N needs to be a power of two for FFT to work.
+N = 2**7
+
+xk = 2*np.pi*np.arange(N)/N
+yk = np.exp(20j*xk) + np.exp(40j*xk)
+
+fig, ax = plt.subplots()
+
+ax.set_xlabel("j")
+
+ax.set_title("$y_k = e^{20ix_k} + e^{40ix_k}$")
+ax.plot(np.abs(FFT(yk)), label="FFT")
+ax.plot(np.abs(DFT(yk)), label="DFT")
+ax.legend(loc="upper right")
+
+fig.show()
+Analyze the evaluation-time scaling of your $\text{FFT(yk)}$ function with the help of the timeit module and compare it to the scaling of the $\text{DFT(yk)}$ function.
+ +for M in range(2, 10+1):
+ N = 2**M
+ xk = 2*np.pi*np.arange(N)/N
+ # Using the first equation for yk from the second exercise.
+ yk = np.exp(20j*xk) + np.exp(40j*xk)
+ tOutDFT = timeit.repeat(stmt=lambda: DFT(yk), number=10, repeat=5)
+ tOutFFT = timeit.repeat(stmt=lambda: FFT(yk), number=10, repeat=5)
+ tMean = np.mean(tOut)
+ print("M =", M, "gives")
+ print("tOutDFT =", tOutDFT)
+ print("tOutFFT =", tOutFFT)
+ print()
+For small $M$, DFT is faster, but as $M$ increases, FFT gets a lot more efficient.
See lecture notes and documentation on Brightspace for Python and Jupyter basics. If you are stuck, try to google or get in touch via Discord.
+Solutions must be submitted via the Jupyter Hub.
+Make sure you fill in any place that says YOUR CODE HERE or "YOUR ANSWER HERE".
In the following we will implement the composite trapezoid and Simpson rules to calculate definite integrals. These rules are defined by
+\begin{align} + \int_a^b \, f(x)\, dx &\approx \frac{h}{2} \left[ f(a) + 2 \sum_{j=1}^{n-1} f(x_j) + f(b) \right] + &\text{trapezoid} \\ + &\approx \frac{h}{3} \left[ f(a) + 2 \sum_{j=1}^{n/2-1} f(x_{2j}) + 4 \sum_{j=1}^{n/2} f(x_{2j-1}) + f(b) \right] + &\text{Simpson} +\end{align}with $a = x_0 < x_1 < \dots < x_{n-1} < x_n = b$ and $x_k = a + kh$. Here $k = 0, \dots, n$ and $h = (b-a) / n$ is the step size.
+ +import numpy as np
+import scipy.integrate
+from matplotlib import pyplot as plt
+
+# And for printing the lambdas:
+import inspect
+Implement both integration schemes as Python functions $\text{trapz(yk, dx)}$ and $\text{simps(yk, dx)}$. The argument $\text{yk}$ is an array of length $n+1$ representing $y_k = f(x_k)$ and $\text{dx}$ is the step size $h$. Compare your results with Scipy's functions $\text{scipy.integrate.trapz(yk, xk)}$ and $\text{scipy.integrate.simps(yk, xk)}$ for a $f(x_k)$ of your choice.
+Try both even and odd $n$. What do you see? Why?
+Hint: go to the Scipy documentation. How are even and odd $n$ handled there?
+ +def trapz(yk, dx):
+ """
+ Return integration estimate for curve yk with steps dx
+ using the trapezoid algorithm.
+ """
+
+ a, b = yk[0], yk[-1]
+ h = dx
+ integral = h/2*(a + 2*np.sum(yk[1:-1]) + b)
+ return integral
+
+def simps(yk, dx):
+ """
+ Return integration estimate for curve yk with steps dx
+ using Simpson's algorithm.
+ """
+
+ a, b = yk[0], yk[-1]
+ h = dx
+ # Instead of summing over something with n/2, we use step size 2,
+ # thus avoiding any issues with 2 ∤ n.
+ integral = h/3*(a + 2*np.sum(yk[2:-1:2]) + 4*np.sum(yk[1:-1:2]) + b)
+ return integral
+def compare_integration(f, a, b, n):
+ """
+ Prints an analysis of integration estimates to function f(x)
+ over interval [a,b] in n steps using both the trapezoid and Simpson's
+ algorithm, self-implemented and Scipy implemented.
+ """
+
+ h = (b - a)/n
+ xk = np.linspace(a, b, n + 1)
+ yk = f(xk)
+
+ print("For function", inspect.getsource(f))
+ print("for boundaries a =", a, ", b =", b, "and steps n =", n, "the algorithms say:")
+ print("trapezoid:\t\t", trapz(yk, h))
+ print("Simpson:\t\t", simps(yk, h))
+ print("scipy.integrate.trapz:\t", scipy.integrate.trapz(yk, xk))
+ print("scipy.integrate.simps:\t", scipy.integrate.simps(yk, xk))
+ print()
+# We need a function to integrate, so here we go.
+f = lambda x: x**2
+
+n = 100001
+a, b = 0, 1
+compare_integration(f, a, b, n)
+compare_integration(f, a, b, n + 1)
+Implement at least one test function for each of your integration functions.
+ +# In the comparison of n even and n odd, and the testing of the integrations,
+# we have already tested the functions, but as it is asked, here we go again.
+
+def test_trapz():
+ fun = lambda x: x**3 + 6*x
+ a, b = 2, 16
+ n = 82198
+
+ h = (b - a)/n
+ xk = np.linspace(a, b, n + 1)
+ yk = f(xk)
+
+ trapz_our = trapz(yk, h)
+ trapz_scipy = scipy.integrate.trapz(yk, xk)
+
+ print("For function f(x) = x^3 + 6x")
+ print("for boundaries a =", a, ", b =", b, "and steps n =", n, "the algorithms say:")
+ print("trapezoid:\t\t", trapz_our)
+ print("scipy.integrate.trapz:\t", trapz_scipy)
+ print("with difference trapz(yk, h) - scipy.integrate.trapz(yk, xk) =", trapz_our - trapz_scipy)
+ print()
+
+def test_simps():
+ fun = lambda x: -x**3 + 6*x
+ a, b = 2, 17
+ n = 82228
+
+ h = (b - a)/n
+ xk = np.linspace(a, b, n + 1)
+ yk = f(xk)
+
+ simps_our = simps(yk, h)
+ simps_scipy = scipy.integrate.simps(yk, xk)
+
+ print("For function f(x) = -x^3 + 6x")
+ print("for boundaries a =", a, ", b =", b, "and steps n =", n, "the algorithms say:")
+ print("Simpson:\t\t", simps_our)
+ print("scipy.integrate.simps:\t", simps_scipy)
+ print("with difference simps(yk, h) - scipy.integrate.simps(yk, xk) =", simps_our - simps_scipy)
+ print()
+
+test_trapz()
+test_simps()
+Study the accuracy of these integration routines by calculating the following integrals for a variety of step sizes $h$:
+The integration error is defined as the difference (not the absolute difference) between your numerical results and the exact results. Plot the integration error as a function of $h$ for both integration routines and all listed functions. Comment on the comparison between both integration routines. Does the sign of the error match your expectations? Why?
+ +f1 = lambda x: x
+f2 = lambda x: x**2
+f3 = lambda x: x**(1/2)
+
+a, b = 0, 1
+h_list = np.logspace(-3, 1, 50)
+
+f1_simps = np.zeros(len(h_list))
+f1_trapz = np.zeros(len(h_list))
+f2_simps = np.zeros(len(h_list))
+f2_trapz = np.zeros(len(h_list))
+f3_simps = np.zeros(len(h_list))
+f3_trapz = np.zeros(len(h_list))
+
+for i in range(len(h_list)):
+ h = h_list[i]
+ xk = np.arange(a, b, h)
+ n = len(xk)
+
+ # The repetition could be reduced, but we deem that unnecessary.
+ f1_simps[i] = simps(f1(xk), h)
+ f1_trapz[i] = trapz(f1(xk), h)
+ f2_simps[i] = simps(f2(xk), h)
+ f2_trapz[i] = trapz(f1(xk), h)
+ f3_simps[i] = simps(f2(xk), h)
+ f3_trapz[i] = trapz(f1(xk), h)
+fig, ax = plt.subplots(1, 3, sharex=True, sharey=True, figsize=(16,6))
+
+fig.suptitle("Difference estimated integral minus true analytic value for three functions and two algorithms:")
+
+ax[0].set_xlabel("h")
+ax[1].set_xlabel("h")
+ax[2].set_xlabel("h")
+
+# We only need to set the scale and direction for one graph,
+# as we set sharex.
+ax[0].set_xscale("log")
+ax[0].invert_xaxis()
+
+ax[0].set_title(r"error in $\int_0^1xdx$")
+ax[0].plot(h_list, f1_trapz - 1/2, label="trapezoid")
+ax[0].plot(h_list, f1_simps - 1/2, label="Simpson")
+ax[0].legend(loc="lower right")
+
+ax[1].set_title(r"error in $\int_0^1x^2dx$")
+ax[1].plot(h_list, f2_trapz - 1/3, label="trapezoid")
+ax[1].plot(h_list, f2_simps - 1/3, label="Simpson")
+ax[1].legend(loc="lower right")
+
+ax[2].set_title(r"error in $\int_0^1x^\frac{1}{2}dx$")
+ax[2].plot(h_list, f3_trapz - 2/3, label="trapezoid")
+ax[2].plot(h_list, f3_simps - 2/3, label="Simpson")
+ax[2].legend(loc="lower right")
+
+fig.show()
+Somehow, the shape of the error functions seems to be similar, with peaks a similar pattern, for the three functions and the the algorithms. +The errors to the Simpson algorithm seems to be negative, thus the integration function gives lower estimates to the integrals. +This cannot be said about the trapezoid algorithm. +The trapezoid algorithms has the same trend, but becomes larger and positive in the latter two functions. +For Simpson's algorithm, as desired, over the range of decreasing $h$, the error decreases converging to around zero
+ +See lecture notes and documentation on Brightspace for Python and Jupyter basics. If you are stuck, try to google or get in touch via Discord.
+Solutions must be submitted via the Jupyter Hub.
+Make sure you fill in any place that says YOUR CODE HERE or "YOUR ANSWER HERE".
In the following you will implement the Gauss-Seidel (GS), Steepest Descent (SD) and the Conjugate Gradient (CG) algorithms to solve linear equation systems of the form
+$$A \mathbf{x} = \mathbf{b},$$ +with $A$ being an $n \times n$ matrix.
+ +import numpy as np
+import numpy.linalg as linalg
+from matplotlib import pyplot as plt
+import time
+First, you need to implement a Python function $\text{diff(a,b)}$, which returns the difference $\text{d}$ between two $n$-dimensional vectors $\text{a}$ and $\text{b}$ according to
+$$ d = || \mathbf{a} - \mathbf{b}||_\infty = \underset{i=1,2,\dots,n}{\operatorname{max}} |a_i - b_i|. $$ +def diff(a, b):
+ return np.max(np.abs(a - b))
+The Gauss-Seidel iteration scheme to solve the linear equation system
+$$A \mathbf{x} = \mathbf{b}$$is defined by
+$$x_i^{(k)} = \frac{1}{a_{ii}} \left[ -\sum_{j=0}^{i-1} a_{ij} x_j^{(k)} -\sum_{j=i+1}^{n-1} a_{ij} x_j^{(k-1)} + b_i \right].$$Note especially the difference in the sums: the first one involves $x_j^{(k)}$ and the second one $x_j^{(k-1)}$.
+Give the outline of the derivation in LaTeX math notation in the markdown cell below. (Double click on "YOUR ANSWER HERE" to open the cell, and ctrl+enter to compile.)
+Hint: Similar to the Jacobi scheme, start by seperating the matrix $A$ into its diagonal ($D$), lower triangular ($L$) and upper triangular ($U$) forms, such that $A = D - L - U$.
+ +We start from our linear equations:
+$$Ax = b$$We separate A into different components (diagonal, strictly lower triangular and strictly upper triangular):
+$$A = D - L - U$$We write $D - L$ as $L'$ to get:
+$$(L' - U)x = b$$We take the iterative process of the Gauss-Seidel method to write:
+$$ +L'x^k = b + Ux^{k-1}\\ +x^k = L'^{-1}(b + Ux^{k-1})\\ +$$If we write every component of the matrix $A$ as $a_{ij}$, we can use forward substitution to rewrite our previous equation to:
+$$x^k _i = \frac{1}{a_{ii}}\left[-\sum_{j=0}^{i-1}a_{ij}x_{j}^{k} -\sum_{j=i+1}^{n-1}a_{ij}x_{j}^{k-1} + b_i\right].$$ +Implement the Gauss-Seidel iteration scheme derived above
+$$x_i^{(k)} = \frac{1}{a_{ii}} \left[ -\sum_{j=0}^{i-1} a_{ij} x_j^{(k)} -\sum_{j=i+1}^{n-1} a_{ij} x_j^{(k-1)} + b_i \right],$$where $a_{ij}$ are the elements of the matrix $A$, and $x_i$ and $b_i$ the elements of vectors $\mathbf{x}$ and $\mathbf{b}$, respectively.
+Write a Python function $\text{GS(A, b, eps)}$, where $\text{A}$ represents the $n \times n$ $A$ matrix, $\text{b}$ represents the $n$-dimensional solution vector $\mathbf{b}$, and $\text{eps}$ is a scalar $\varepsilon$ defining the accuracy up to which the iteration is performed. Your function should return both the solution vector $\mathbf{x}^{(k)}$ from the last iteration step and the corresponding iteration index $k$.
+Use an assertion to make sure the diagonal elements of $A$ are all non-zero. Initialize your iteration with $\mathbf{x}^{(0)} = \mathbf{0}$ (or with $\mathbf{x}^{(1)} = D^{-1}\mathbf{b}$, with $D$ the diagonal of $A$) and increase $k$ until $|| \mathbf{x}^{(k)} - \mathbf{x}^{(k-1)}||_\infty < \varepsilon$.
+ +def GS(A, b, eps, k_max = 1000000):
+ """
+ Return the Gauss-Seidel algorithm estimate solution x to the problem
+ Ax = b and the number of iterations k it took to decrease maximum
+ norm error below eps or to exceed iteration maximum k_max.
+ """
+
+ # Assert n by n matrix.
+ assert len(A.shape) == 2 and A.shape[0] == A.shape[1]
+ n = len(A)
+
+ # First we decompose A = D - L - U.
+ D = np.diag(np.diag(A))
+ U = -np.triu(A) + D
+ L = -np.tril(A) + D
+
+ # We need non-zero diagonals elements.
+ assert np.all(np.diag(D) != 0)
+
+ x_prev = np.zeros(n)
+ x_cur = np.dot(linalg.inv(D), b)
+
+ k = 1
+ while diff(x_cur, x_prev) > eps and k < k_max:
+ k += 1
+ # We will have to copy, as the array elements will point to the same
+ # memory otherwise, and changes to one array will change the other aswell.
+ x_prev = x_cur.copy()
+ for i in range(n):
+ x_cur[i] = 1/A[i, i]*(-np.dot(A[i, :i], x_cur[:i]) - np.dot(A[i, i + 1:], x_prev[i + 1:]) + b[i])
+ return x_cur, k
+Verify your implementation by comparing your approximate result to an exact solution. Use $\text{numpy.linalg.solve()}$ to obtain the exact solution of the system
+$$ +\begin{align*} + \begin{pmatrix} + 10 & -1 & 2 & 0 \\ + -1 & 11 &-1 & 3 \\ + 2 & -1 & 10&-1 \\ + 0 & 3 & -1& 8 + \end{pmatrix} \mathbf{x}^* + = + \begin{pmatrix} + 6 \\ + 25 \\ + -11\\ + 15 + \end{pmatrix} +\end{align*} +$$Then compare you approximate result $\mathbf{\tilde{x}}$ to the exact result $\mathbf{x^*}$ by plotting $|| \mathbf{x}^* - \mathbf{\tilde{x}}||_\infty$ for different accuracies $\varepsilon = 10^{-1}, 10^{-2}, 10^{-3}, 10^{-4}$.
+Implement a unit test for your function using this system.
+ +A = np.array([[ 10, - 1, 2, 0],
+ [- 1, 11, - 1, 3],
+ [ 2, - 1, 10, - 1],
+ [ 0, 3, - 1, 8]])
+b = np.array( [ 6, 25, -11, 15] )
+x_exact = linalg.solve(A, b)
+
+eps_list = [1e-1, 1e-2, 1e-3, 1e-4]
+diff_list = []
+for eps in eps_list:
+ x, k = GS(A, b, eps)
+ diff_list.append(diff(x_exact, x))
+
+fig, ax = plt.subplots()
+
+ax.scatter(eps_list, diff_list)
+ax.set_xscale("log")
+ax.set_yscale("log")
+ax.set_xlabel("$\epsilon$")
+ax.set_ylabel("$||\\vec{x}^* - \\vec{\\tilde{x}}||_\infty$")
+
+fig.show()
+# As the three algorithm functions will have the same signature,
+# it makes sense to only write the test function once.
+
+def test_alg(alg, alg_name):
+ """
+ Check that function alg returns solutions for the example system Ax = b
+ within the error defined by the same eps as used for the iteration.
+ """
+
+ A = np.array([[ 10, - 1, 2, 0],
+ [- 1, 11, - 1, 3],
+ [ 2, - 1, 10, - 1],
+ [ 0, 3, - 1, 8]])
+ b = np.array( [ 6, 25, -11, 15] )
+ x_exact = linalg.solve(A, b)
+
+ print("Starting with A =")
+ print(A)
+ print("and b =", b)
+ print("We apply the {} algorithm to solve Ax = b.".format(alg_name))
+ print()
+
+ eps_list = [1e-1, 1e-2, 1e-3, 1e-4]
+ for eps in eps_list:
+ x, k = alg(A, b, eps)
+ print("For eps = {:.0e}\tafter k = {:d}\t iterations:".format(eps, k))
+ print("x =\t\t\t", x)
+ print("Ax =\t\t\t", np.dot(A, x))
+ print("diff(Ax, b) =\t\t", diff(A @ x, b))
+ print("diff(x, x_exact) =\t", diff(x, x_exact))
+ print()
+
+ assert diff(x, x_exact) < eps
+
+def test_GS():
+ """
+ Check that GS returns solutions for the example system Ax = b
+ within the error defined by the same eps as used for the iteration.
+ """
+
+ return test_alg(GS, "Gauss-Seidel")
+
+test_GS()
+Next, implement the Steepest Descent algorithm in a similar Python function $\text{SD(A, b, eps)}$, which calculates
+\begin{align*} + \mathbf{v}^{(k)} &= \mathbf{b} - A \mathbf{x}^{(k-1)} \\ + t_k &= \frac{ \langle \mathbf{v}^{(k)}, \mathbf{v}^{(k)} \rangle }{ \langle \mathbf{v}^{(k)}, A \mathbf{v}^{(k)}\rangle } \\ + \mathbf{x}^{(k)} &= \mathbf{x}^{(k-1)} + t_k \mathbf{v}^{(k)} . +\end{align*}Initialize your iteration again with $\mathbf{x}^{(0)} = \mathbf{0}$ and increase $k$ until $|| \mathbf{x}^{(k)} - \mathbf{x}^{(k-1)}||_\infty < \varepsilon$. Return the solution vector $\mathbf{x}^{(k)}$ from the last iteration step and the corresponding iteration index $k$. Implement a unit test for your implementation by comparing your result to the exact solution of the system in task 4. +Use $\text{numpy.dot()}$ for all needed vector/vector and matrix/vector products.
+ +def SD(A, b, eps, k_max = 1000000):
+ """
+ Return the Steepest Descent algorithm estimate solution x to the problem
+ Ax = b and the number of iterations k it took to decrease maximum
+ norm error below eps or to exceed iteration maximum k_max.
+ """
+
+ # Assert n by n matrix.
+ assert len(A.shape) == 2 and A.shape[0] == A.shape[1]
+
+ n = len(A)
+
+ x_cur = np.zeros(n)
+ x_prev = np.zeros(n)
+
+ k = 0
+ while diff(x_cur, x_prev) > eps and k < k_max or k == 0:
+ k += 1
+ x_prev = x_cur.copy()
+
+ v = b - A @ x_prev
+ t = np.dot(v, v)/np.dot(v, A @ v)
+ x_cur = x_prev.copy() + t*v
+
+ return x_cur, k
+def test_SD():
+ """
+ Check that SD returns solutions for the example system Ax = b
+ within the error defined by the same eps as used for the iteration.
+ """
+
+ return test_alg(SD, "Steepest Descent")
+
+test_SD()
+Finally, based on your steepest decent implementation from task 5, implement the Conjugate Gradient algorithm in a Python function $\text{CG(A, b, eps)}$ in the following way:
+Initialize your procedure with:
+\begin{align*} + \mathbf{x}^{(0)} &= \mathbf{0} \\ + \mathbf{r}^{(0)} &= \mathbf{b} - A \mathbf{x}^{(0)} \\ + \mathbf{v}^{(0)} &= \mathbf{r}^{(0)} +\end{align*}Then increase $k$ and repeat the following until $|| \mathbf{x}^{(k)} - \mathbf{x}^{(k-1)}||_\infty < \varepsilon$.
+\begin{align*} + t_k &= \frac{ \langle \mathbf{r}^{(k)}, \mathbf{r}^{(k)} \rangle }{ \langle \mathbf{v}^{(k)}, A \mathbf{v}^{(k)} \rangle } \\ + \mathbf{x}^{(k+1)} &= \mathbf{x}^{(k)} + t_k \mathbf{v}^{(k)} \\ + \mathbf{r}^{(k+1)} &= \mathbf{r}^{(k)} - t_k A \mathbf{v}^{(k)} \\ + s_k &= \frac{ \langle \mathbf{r}^{(k+1)}, \mathbf{r}^{(k+1)} \rangle }{ \langle \mathbf{r}^{(k)}, \mathbf{r}^{(k)} \rangle } \\ + \mathbf{v}^{(k+1)} &= \mathbf{r}^{(k+1)} + s_k \mathbf{v}^{(k)} +\end{align*}Return the solution vector $\mathbf{x}^{(k)}$ from the last iteration step and the corresponding iteration index $k$. Implement a unit test for your implementation by comparing your result to the exact solution of the system in task 4. +Use $\text{numpy.dot()}$ for all needed vector/vector and matrix/vector products.
+How do you expect the number of needed iteration steps to behave when changing the accuracy $\epsilon$? What do you see?
+ +def CG(A, b, eps, k_max = 1000000):
+ """
+ Return the Conjugate Gradient algorithm estimate solution x to the problem
+ Ax = b and the number of iterations k it took to decrease maximum
+ norm error below eps or to exceed iteration maximum k_max.
+ """
+
+ # Assert n by n matrix.
+ assert len(A.shape) == 2 and A.shape[0] == A.shape[1]
+
+ n = len(A)
+
+ x_cur = np.zeros(n)
+ x_prev = x_cur.copy()
+ r_cur = b - A @ x_cur
+ v = r_cur
+
+ k = 0
+ while diff(x_cur, x_prev) > eps and k < k_max or k == 0:
+ k += 1
+ x_prev = x_cur.copy()
+ r_prev = r_cur
+
+ t = np.dot(r_prev, r_prev)/np.dot(v, A @ v)
+ x_cur = x_prev + t*v
+ r_cur = r_prev - t*A @ v
+ s = np.dot(r_cur, r_cur)/np.dot(r_prev, r_prev)
+ v = r_cur + s*v
+
+ return x_cur, k
+def test_CG():
+ """
+ Check that CG returns solutions for the example system Ax = b
+ within the error defined by the same eps as used for the iteration.
+ """
+
+ return test_alg(CG, "Conjugate Gradient")
+
+test_CG()
+Apply all three methods to the following system
+\begin{align*} +\begin{pmatrix} +0.2& 0.1& 1.0& 1.0& 0.0 \\ +0.1& 4.0& -1.0& 1.0& -1.0 \\ +1.0& -1.0& 60.0& 0.0& -2.0 \\ +1.0& 1.0& 0.0& 8.0& 4.0 \\ +0.0& -1.0& -2.0& 4.0& 700.0 +\end{pmatrix} \mathbf{x}^* += +\begin{pmatrix} +1 \\ +2 \\ +3 \\ +4 \\ +5 +\end{pmatrix}. +\end{align*}Plot the number of needed iterations for each method as a function of $\varepsilon$, using $\varepsilon = 10^{-1}, 10^{-2}, ..., 10^{-8}$.
+Explain the observed behavior with the help of the condition number (which you can calculate using $\text{numpy.linalg.cond()}$).
+ +A = np.array([[ .2, .1, 1.0, 1.0, 0.0],
+ [ .1, 4.0, - 1.0, 1.0, - 1.0],
+ [ 1.0, -1.0, 60.0, .0, .0],
+ [ 1.0, 1.0, .0, 8.0, 4.0],
+ [ .0, -1.0, - 2.0, 4.0, 700.0]])
+b = np.array( [ 1 , 2 , 3 , 4 , 5 ] )
+x_exact = linalg.solve(A, b)
+
+eps_list = np.logspace(-8, -1, 8)
+
+fig, ax = plt.subplots()
+
+for alg, alg_name in [(GS, "Gauss-Seidel"), (SD, "Steepest Descent"), (CG, "Conjugate Gradient")]:
+ k_list = []
+ for eps in eps_list:
+ x, k = alg(A, b, eps)
+ k_list.append(k)
+ ax.plot(eps_list, k_list, label=alg_name)
+
+ax.set_xscale("log")
+ax.invert_xaxis()
+ax.set_yscale("log")
+ax.set_xlabel("$\epsilon$")
+ax.set_ylabel("$k$")
+ax.legend()
+
+fig.show()
+print("The condition number for A is K(A) =", linalg.cond(A), ">> 1,")
+print("so A is ill-conditioned, so the Conjugate Gradient method is highly susceptible to rounding errors.")
+print("This explains the great difference in order of required iterations k as observed above.")
+Try to get a better convergence behavior by pre-conditioning your matrix $A$. Instead of $A$ use
+$$ \tilde{A} = C A C,$$where $C = \sqrt{D^{-1}}$. If you do so, you will need to replace $\mathbf{b}$ by
+$$\mathbf{\tilde{b}} = C \mathbf{b}$$and the vector $\mathbf{\tilde{x}}$ returned by your function will have to be transformed back via
+$$\mathbf{x} = C \mathbf{\tilde{x}}.$$ +What is the effect of $C$ on the condition number and why?
+ +def CG_cond(A, b, eps, k_max = 1000000):
+ """
+ Return the Conjugate Gradient algorithm estimate solution x to the problem
+ Ax = b, after diagonal conditioning A, and the number of iterations k it
+ took to decrease maximum norm error below eps or to exceed iteration maximum
+ k_max.
+ """
+
+ D = np.diag(np.diag(A))
+ C = np.sqrt(linalg.inv(D))
+ A_tilde = C @ A @ C
+ b_tilde = C @ b
+
+ x_tilde, k = CG(A_tilde, b_tilde, eps, k_max)
+ x = C @ x_tilde
+ return x, k
+
+# Sorry for copying.
+
+fig, ax = plt.subplots(1, 2, sharex = True, figsize = (16,6))
+
+for alg, alg_name in [(GS, "Gauss-Seidel"), (SD, "Steepest Descent"), (CG, "Conjugate Gradient"), (CG_cond, "Conjugate Gradient (conditioned)")]:
+ k_list = []
+ t_list = []
+ for eps in eps_list:
+ t_start = time.time()
+ x, k = alg(A, b, eps)
+ t_list.append(time.time() - t_start)
+ k_list.append(k)
+ ax[0].plot(eps_list, k_list, label=alg_name)
+ ax[1].plot(eps_list, t_list, label=alg_name)
+
+ax[0].set_xscale("log")
+ax[0].invert_xaxis()
+ax[0].set_yscale("log")
+ax[0].set_xlabel("$\epsilon$")
+ax[0].set_ylabel("$k$")
+ax[0].legend()
+
+ax[1].set_yscale("log")
+ax[1].set_xlabel("$\epsilon$")
+ax[1].set_ylabel("runtime (seconds)")
+ax[1].legend()
+
+fig.show()
+
+print("The number of iterations is brought down by a lot due to the conditioning, bringing down the runtime.")
+