**Report Submission Information (must be completed before submitting report!)**

* Student 1 Full Name and Number :
* Student 2 Full Name and Number :
* Workshop day : e.g., Wednesday
* Workshop time : e.g., 12pm

#  Workshop 1 – Optimisation [2 weeks]

## Objectives:

* Learn how to formulate optimisation problems in practice.
* Familiarise yourself with practical software tools used for optimisation.
* Solve optimisation problems using Python Scipy and Matlab.
* Connect theoretical knowledge and practical usage by doing it yourself.

> __Common objectives of all workshops:__
> Gain hands-on experience and learn by doing! Understand how theoretical knowledge discussed in lectures relates to practice. Develop motivation for gaining further theoretical and practical knowledge beyond the subject material.

## Overview:
Another name for the field of “Optimisation” is “Mathematical Optimisation.” As the name indicates optimisation is an area of applied mathematics. It is possible to study optimisation entirely from a mathematical perspective. However, engineers are interested in solving real-world problems in a principled way. Many engineering problems can be and are formulated as optimisation problems. In those cases, mathematical optimisation provides a solid theoretical foundation for solving them in a principled way.

In this workshop, you will learn how to formulate and solve optimisation problems in practice. This will give you a chance to connect theoretical knowledge and practical usage by doing it yourself. You will familiarise yourself with practical optimisation tools for Python. These are chosen completely for educational reasons (simplicity, accessibility, cost). While the underlying mathematics is timeless, optimisation software evolves with time, and can be diverse. Fortunately, once you learn one or two, it should be rather easy to learn others now and in the future, because software designers often try to make it user friendly and take into account what people already know.

> In the future, you should consider and learn serious optimisation software for scalability and reliability. They can be complex and/or expensive but they get the job done for serious engineering. Teaching such software takes too much time and is beyond the scope of this subject.

## Workshop Preparation: [before you arrive to the lab]

You can come to the workshops as you are or you can prepare beforehand to learn much more!
We will give you a lot of time to finish the tasks but those are the bare minimums. Just like in the lectures, the topics we cover in the workshops are quite deep and we can only do so much in two hours. There is much more to learn and coming prepared to the workshop is one of the best ways to gain more knowledge! For example, there are a few questions in each workshop which you can answer beforehand.

> __Self-learning__ is one of the most important skills that you should acquire as a student. Today, self-learning is much easier than it used to be thanks to a plethora of online resources.
For this workshop, start by exploring the resource mentioned in the preparation steps below.

### Workshop Preparation Steps:

1. Common step for all workshops: read the Workshop Manual (Jupyter Notebook) beforehand!
2. Review relevant lecture slides on optimisation.
3. Read/check relevant reading material and links from LMS/Resources-Reading
4. Check the embedded links below hints and background.
5. _\[optional\]_ _You can start with workshop tasks and questions_

## Tasks and Questions:

Follow the procedures described below, perform the given tasks and answer the workshop questions __on the Python notebook itself!__ The marks associated with each question are clearly stated. Keep your answers to the point and complete to get full marks! Ensure that your code is clean and appropriately commented.

__The resulting notebook will be your Workshop Report!__

> __The goal is to learn__, NOT blindly follow the procedures in the fastest possible way! __Do not simply copy-paste answers (from Internet, friends, etc.). You can and should use all available resources but only to develop your own understanding. If you copy-paste, you will pay the price in the final exam!__

# Section 1: Convex Functions

Remember the definition of convex and concave functions from lecture slides. Functions are mathematical objects but they are used in engineering in very practical ways, for example, to represent the relationship between two quantities. Let's draw a function!

```python
%matplotlib notebook
import numpy as np
import matplotlib.pyplot as plt

# define function f(x)
def f(x):
    return 3*(x-1)**2

# define x and y
x = np.linspace(-20, 20, 100) # 100 equally spaced points on interval [-20,20]
y = f(x) # call function f(x) and set y to the function's return value

# Plot the function y=f(x)
plt.plot(x,y)
plt.xlabel('x')
plt.ylabel('y')
plt.title('$y=3(x-1)^2$')
plt.show()
```

    <IPython.core.display.Javascript object>

### Question 1.1. (1 pt)
Plot one concave and one nonconvex functions of your choosing (preferably in 3D). Provide their formulas below.

```python
''' Answer as code here '''
from mpl_toolkits.mplot3d.axes3d import Axes3D
from matplotlib import cm
import matplotlib.pyplot as plt
import numpy as np

fig = plt.figure()
# Plot convex figure
ax = fig.add_subplot(1, 2, 1, projection='3d')
X=np.arange(-10,10,1)
Y=np.arange(-10,10,1)
X, Y = np.meshgrid(X, Y)
Z = (2*X)**2 +(2*Y)**2
surf = ax.plot_surface(X, Y, Z, rstride=1, cstride=1,
cmap=cm.jet,linewidth=0.01, antialiased=False)
ax.set_zlim3d(0,1000)

# Plot non-convex figure
ax = fig.add_subplot(1, 2, 2, projection='3d')
X=np.arange(-10,10,1)
Y=np.arange(-10,10,1)
X, Y = np.meshgrid(X, Y)
Z = -(2*X)**2 +(2*Y)**2
surf = ax.plot_surface(X, Y, Z, rstride=1, cstride=1,
cmap=cm.jet,linewidth=0.01, antialiased=False)
ax.set_zlim3d(-1000,0)
fig.colorbar(surf, shrink=0.5, aspect=5)
plt.show()

```

**Answer as text here**

Convex Formula:

$$ z = 2x^2 + 2y^2$$

Non-convex Formula:

$$ z = -2x^2 + 2y^2$$

### Question 1.2. (1 pts)
How would you determine whether a single or multi-variate continuously differentiable function is convex or not?
> Note that the question becomes very tricky if you have a **parametric** multivariate polynomials of degree four or higher!

> *[Optional]* An interesting paper (for those who wish to go deeper) http://web.mit.edu/~a_a_a/Public/Publications/convexity_nphard.pdf

**Answer as text here**

Single continously differential function is convex if and only if:
\begin{equation}
\frac{\partial^2 f}{\partial^2 f} \geq 0 \\
\frac{\partial^2 f}{\partial^2 f} > 0 \Rightarrow strictly \ convex (slope \ increasing)
\end{equation}

Multi-variate continously differential function is convex if and only if eigin values of Hessian matrix is positive:
$$\triangledown^2 f(x) = H = Hessian \ matrix $$

### Question 1.3. (2 pts)
Why are convex optimisation problems considered to be easy to solve? Consider optimality conditions of unconstrained functions in your answer. Plot first and second order derivative functions of the concave and non-convex functions you have chosen above (as part of Question 1.1) to further support your argument.

**Answer as text here**

Convex optimisation problem are easier to solve because of existance of global maximum or minimum.

```python
''' Answer as code here '''
from mpl_toolkits.mplot3d.axes3d import Axes3D
import matplotlib.pyplot as plt
import numpy as np

x = Symbol('x')
y = Symbol('y')
z = (2*x )**2 +(2*y)**2
dzdx = z.diff(x)
dzdy = z.diff(y)
div_z = [dzdx, dzdy]
dzdxx = dzdx.diff(x)
dzdxy = dzdx.diff(y)
dzdyx = dzdy.diff(x)
dzdyy = dzdy.diff(y)
div2_z = [[dzdxx, dzdxy],[dzdyx, dzdyy]]

fig_2 = plt.figure()
ax_1 = fig_2.add_subplot(1,2,1, projection='3d')
X=np.arange(-10,10,1)
Y=np.arange(-10,10,1)
X,Y = np.meshgrid(X,Y)
# Z = div_z[0]+div_z[1]
Z = (2*X)**2 +(2*Y)**2
surf = ax_1.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=cm.jet,linewidth=0.01, antialiased=False)
Z = 8*X
surf = ax_1.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=cm.Purples,linewidth=0.01, antialiased=False)
Z = 8*Y
surf = ax_1.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=cm.Oranges,linewidth=0.01, antialiased=False)
Z = 8+0*X*Y
surf = ax_1.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=cm.Oranges,linewidth=0.01, antialiased=False)

ax_1.set_zlim3d(0,1000)
plt.show()

ax_2 = fig_2.add_subplot(122, aspect='auto')
X=np.arange(-10,10,1)
Y=np.arange(-10,10,1)
X, Y = np.meshgrid(X, Y)
Z = -(2*X)**2 -div_z[1]
#surf = ax_2.plot_surface(X, Y, Z, rstride=1, cstride=1,cmap=cm.jet,linewidth=0.01, antialiased=False)
ax.set_zlim3d(-1000,0)
fig.colorbar(surf, shrink=0.5, aspect=5)

```

# Section 2: Unconstrained Optimisation

## 2.1 _Example_. Aloha communication protocol

**Aloha** is a well-known random access or _MAC_ (Media/multiple Access Control) communication protocol. It enables multiple nodes sharing a broadcast channel without any additional signaling in a distributed manner. Unlike _FDMA_ or _TDMA_ (frequency or time-division multiple access), the channel is not divided into segments beforehand and collisions of packets due to simultaneous transmissions by nodes are allowed. In slotted Aloha, the nodes can only transmit at the beginning of time slots, which are kept by a global/shared clock. See [Aloha](https://en.wikipedia.org/wiki/ALOHAnet#Slotted_ALOHA) for further background information.

### Slotted Aloha Efficiency

For an $N$-node slotted Aloha system, where each node transmits with a probability $p$, the throughput of the system is given by
$$ S(p) = N p (1-p)^{N-1}$$

### Question 2.1. (2 pts)
Formally define the optimisation problem to find the optimal probability $p$ that maximises the throughput. Clearly identify the objective and decision variable(s). Is the objective convex or concave? Show through derivation.

Note that, there is the constraint $0 \leq p \leq 1$ on probability $p$ but we will ignore it for now.

**Answer as text here**

Optimization problem:
$$ \max_p S(p) = Np(1-P)^{N-1}$$ where $S(p)$ is objective and $p$ is decision variable.

Concave or Convex:
\begin{equation}
\begin{split}
S^{'}(p) &= \frac{\partial}{\partial p}Np(1 - p)^{N - 1} \\
&= N(1-p)^{N-1} + Np(N-1)(1-p)^{N-2}(-1) \\
&= N(1-p)^{N-2}(1-p - p(N-1)) \\
&= N(1-p)^{N-2}(1-p - pN + p)) \\
&= N(1-p)^{N-2}(1-pN)
\end{split}
\end{equation}

\begin{equation}
\begin{split}
S^{''}(p) &= \frac{\partial}{\partial p}S^{'}(p) \\
&= N(N-2)(1-p)^{N-3}(-1)(1-pN)+ N(1-p)^{N-2}(-N) \\
&= -N(1-p)^{N-3}((N-2)(1-pN) + N(1-p)) \\
&= -N(1-p)^{N-3}(N-pN^2-2+2pN+N-pN) \\
&= -N(1-p)^{N-3}(2N-pN^2-2+pN) \\
&= N(1-p)^{N-3}(p(N^2-N)+2-2N) \\
&\Rightarrow \ convex \ or \ concave \ depends \ on \ vlaue \ of \ p \ and \ N
\end{split}
\end{equation}

### Question 2.2. (2 pts)
Plot the performance function and its derivative for $N=10$ nodes. Is the objective function convex or concave? Determine using mathematical methods. Investigate the property of the derivative function of the objective. What do you call such functions?

**Answer as text here**

Concave or Convex with $N=10$:
\begin{equation}
\begin{split}
S^{''}(p) &= N(1-p)^{N-3}(p(N^2-N)+2-2N) \\
&= 10(1-p)^{7}(90p-18)
\end{split}
\end{equation}
$\Rightarrow$ the function is quasi-convex since it depends on $p$ for above value to be positive or negative.

```python
''' Answer as code here '''
```

    ' Answer as code here '

### Question 2.3. (2 pts)
Find the optimal probability $p$ for $N=10$ nodes. Use an appropriate package from [Scipy](https://docs.scipy.org/doc/scipy/reference/optimize.html). Cross-check your answer with a mathematical formula that you should derive by hand.

```python
from scipy import optimize
''' Answer as code here '''
```

    ' Answer as code here '

**Answer as text here**

## 2.2 Gradient Descent Algorithms

### Question 2.4. (10 pts)
Write your own gradient algorithm (with constant step size) to solve the problem
$$ \min_x x^T Q x + r^T x,$$
where $x,\, r \in \mathbb{R}^2$ and $Q$ is a $2\times 2$ positive definite matrix of your choice. Cross-check your answers using one of the standard optimisation packages, e.g. scipy or cvxpy.

1. If Q is positive definite, then what type of optimisation problem is this? Give your answer using mathematical tools learned in classroom. Would anything change if $Q$ were not positive definite? Plot both cases and comment.
2. Focusing on positive definite $Q$, what happens if you choose your fixed step size too large or too small? Observe and comment.
3. Choose $Q$ in such a way that is has a low [condition number] (https://www.encyclopediaofmath.org/index.php/Condition_number), [see also.](https://calculus.subwiki.org/wiki/Gradient_descent_with_constant_learning_rate_for_a_quadratic_function_of_multiple_variables) Next, choose a $Q$ with a high condition number. Compare the performance of your algorithm in both cases, and comment.
4. Now, solve both versions of the problem using (b) diminishing step size (c) Armijo rule/wolf test (line search). Discuss stopping criteria for all variants.
5. Plot your trajectories clearly showing the iterations of the gradient algorithm. You should also show either by displaying the level sets of the objective or the objective function itself (if resorting to a 3D plot).

**Answer as text here**
**Solution** at: https://en.wikipedia.org/wiki/Gradient_descent

Question: 2.4.1

Since $Q$ is positive definite, then $$x^TQx>0 \Rightarrow convex \ optimization$$ if $Q$ is not positive difinite then the problem is not convex optimization.

Question: 2.4.2,

If the fixed step size $\alpha$ is too small, it will take long time to converge and if the $\alpha$ is too big, we may miss the mimum point.

```python
''' Answer as code here '''
```

    ' Answer as code here '

###  Question 2.5. (2 pts)
Choose one of the versions of the problem and algorithms in **Question 2.4** that finds the correct solution.

1. Use objective function itself or a norm of its gradient as a descent (Lyapunov) function that establishes the convergence of the solution algorithm $A(x)$. Plot the value of the chosen descent function versus the trajectory or steps.
2. Calculate and plot $||x(n)-x^*||$ over iterations $n=0,\ldots$, to establish that your solution algorithm leads to a pseudo-contraction.

**Answer as text here**

```python
''' Answer as code here '''
```

    ' Answer as code here '

# Section 3: Constrained Optimisation

## 3.1 _Example_. Waterfilling in Communications

_by Robert Gowers, Roger Hill, Sami Al-Izzi, Timothy Pollington and Keith Briggs.
From the book by Boyd and Vandenberghe, Convex Optimization, Example 5.2 page 245._

$$\min_{x} \sum_{i=1}^N -\log(\alpha_i + x_i)$$

$$\text{subject to } x_i \geq 0, \; \forall i, \text{ and } \sum_{i=1}^N x_i = P $$

This problem arises in information/communication theory, in allocating power to a
set of $n$ communication channels. The variable $x_i$ represents the transmitter power
allocated to the _i-th_ channel, and $\log(\alpha_i + x_i)$ gives the capacity or communication rate of the channel, where $\alpha_i>0$ represents the floor above the baseline at which power can be added to the channel. The problem is to allocate a total power of one to the channels,
in order to maximize the total communication rate.

This can be solved using a classic [water filling algorithm](https://en.wikipedia.org/wiki/Water_filling_algorithm).

###  Question 3.1. (4 pts)

1. (1 pt) Is the problem in Example 3.1 convex? Formally explain/argue why or why not. What does this imply regarding the solution?
2. (2 pts) Solve the problem above for $N=8$ and a randomly chosen $\alpha$ vector. You can use for example _Cvxpy_ package. Cross-check your answer with another software (package), e.g. Matlab or Scipy.
3. (1 pt) Write the Lagrangian, KKT conditions, and find numerically the Lagrange multipliers associated with the solution (using the software package/function). Which constraints are active? Explain and discuss briefly.

**Answer as text here**

**Solution** at https://www.cvxpy.org/examples/applications/water_filling_BVex5.2.html

Question 3.1.1

Convex Optimization:
\begin{equation}
\begin{split}
f(x_i) &= \sum_{i=1}^N - \log(\alpha_i + x_i) \\
\Rightarrow f^{'}(x_i) &= \frac{\partial}{\partial x_i} \sum_{i=1}^N - \log(\alpha_i + x_i)
= -\sum_{i=1}^N \frac{1}{(\alpha_i + x_i)ln(10)}
= -\sum_{i=1}^N \frac{(\alpha_i + x_i)^{-1}}{ln(10)} \\
\end{split}
\end{equation}

\begin{equation}
\begin{split}
\Rightarrow f^{''}(x_i) = -\sum_{i=1}^N (-1) \frac{(\alpha_i + x_i)^{-2}}{ln(10)}
= -\sum_{i=1}^N \frac{1}{(\alpha_i + x_i)^2 ln(10)}
\end{split}
\end{equation}
Since both $x_i, \alpha_i > 0$, $\Rightarrow convex \ optimization \ problem$.

Question 3.1.3

Lagrangian condition:
\begin{equation}
\mathcal{L}(x, \lambda, \nu) = f_0 (x) + \sum_{i=1}^M \lambda_i f_i(x) + \sum_{i=1}^N \nu_i h_i(x) \\
\Rightarrow \mathcal{L}(x, \lambda, \nu) = \sum_{i=1}^N -log(\alpha_i + x_i) - \sum_{i=1}^N \lambda_i x_i + \sum_{i=1}^N \nu_i(x_i - P)
\end{equation}

KKT condition:
\begin{equation}
x^* \geq 0 \\
x^* = P \\
\lambda* \geq 0 \\
\lambda* x_i^* = 0, i=1,2..., N \\
-\frac{1}{(\alpha_i + x_i^*)} - \lambda^* + \nu^* = 0, i=1,2..., N
\end{equation}

```python
''' Answer as code here '''
#!/usr/bin/env python3
# @author: R. Gowers, S. Al-Izzi, T. Pollington, R. Hill & K. Briggs
import numpy as np
import cvxpy as cp

def water_filling(n, a, sum_x=1):
    '''
    Boyd and Vandenberghe, Convex Optimization, example 5.2 page 145
    Water-filling.

    This problem arises in information theory, in allocating power to a set of
    n communication channels in order to maximise the total channel capacity.
    The variable x_i represents the transmitter power allocated to the ith channel,
    and log(α_i+x_i) gives the capacity or maximum communication rate of the channel.
    The objective is to minimise -∑log(α_i+x_i) subject to the constraint ∑x_i = 1
    '''

    # Declare variables and parameters
    x = cp.Variable(shape=n)
    alpha = cp.Parameter(n, nonneg=True)
    alpha.value = a

    # Choose objective function. Interpret as maximising the total communication rate of all the channels
    obj = cp.Maximize(cp.sum(cp.log(alpha + x)))

    # Declare constraints
    constraints = [x >= 0, cp.sum(x) - sum_x == 0]

    # Solve
    prob = cp.Problem(obj, constraints)
    prob.solve()
    if(prob.status=='optimal'):
        return prob.status, prob.value, x.value
    else:
        return prob.status, np.nan, np.nan

# As an example, we will solve the water filling problem with 3 buckets, each with different α
np.set_printoptions(precision=3)
buckets = 3
alpha = np.array([0.8, 1.0, 1.2])

stat, prob, x = water_filling(buckets, alpha)
print('Problem status: {}'.format(stat))
print('Optimal communication rate = {:.4g} '.format(prob))
print('Transmitter powers:\n{}'.format(x))

```

## 3.2 _Example_. Economic Dispatch in Power Generation

The problem is formulated as
$$ \min_P \sum_{i=1}^N c_i P_i $$
$$\text{subject to } P_{i,max} \geq P_i \geq 0, \; \forall i, \text{ and } \sum_{i=1}^N P_i = P_{demand} $$

Here, $P_1,\ldots P_N$ are the power generated by Generators $1,\ldots,N$, $c_i$ is the per-unit generation cost of the i-_th_ generator, and $P_{demand}$ is the instantaneous power demand that needs to be satisfied by aggregate generation. More complex formulations take into account transmission, generator ramp-up and down constraints, and reactive power among other things.

### Question 3.2. (4 pts)

Let us get inspired from generation in Victoria with $N=12$ biggest generators that have more than 200MW capacity. Choose their maximum generation randomly or from the Victoria generator report if you wish to be more realistic. Generate a random cost vector $c$ varying between $10-50$ AUD per MWh. _(Optionally, you can search and find how much different generation types cost if you are interested)._ Let the demand be $P_{demand}=5000MW$.

Solve this simplified [economic dispatch](https://en.wikipedia.org/wiki/Economic_dispatch) problem defined above. The resulting [merit order](https://en.wikipedia.org/wiki/Merit_order) is the generation that would have been if there was no NEM (electricity market).

More about electricity market and generation at https://www.aemo.com.au/ See also this [NEM overview introductory document (right click to download)](./files/National_Electricity_Market_Fact_Sheet.pdf) and the [Victoria generator report as of January 2019](files/Generation_Information_VIC_January_2019.xlsx).

1. Solve the problem using _cvxpy._
2. What type of an optimisation problem is this? Briefly explain.
3. Formulate by hand the dual problem and solve it with _cvxpy._ Is there a duality gap? Explain briefly why or why not.
4. Briefly comment on simplex algorithm and solve the problem using [Scipy and simplex algorithm.](https://docs.scipy.org/doc/scipy/reference/optimize.linprog-simplex.html) Compare your results.

**Answer as text here**

```python
''' Answer as code here '''
```

    ' Answer as code here '

## 3.3 _Example_. Power Control in Wireless Communication

 *Adapted from Boyd, Kim, Vandenberghe, and Hassibi,* "[A Tutorial on Geometric Programming](https://web.stanford.edu/~boyd/papers/pdf/gp_tutorial.pdf)."

The [power control problem in wireless communications](http://winlab.rutgers.edu/~narayan/PAPERS/PC%20for%20Wireless%20Data.pdf) aims to minimise the total transmitter power available across $N$ trasmitters while concurrently achieving good (or a pre-defined minimum) performance.

The technical setup is as follows. Each transmitter $i$ transmits with a power level $P_i$ bounded below and above by a minimum and maximum level. The power of the signal received from transmitter $j$ at receiver $i$ is $G_{ij} P_{j}$, where $G_{ij} > 0$ represents the path gain (often loss) from transmitter $j$ to receiver $i$. The signal power at the intended receiver $i$ is $G_{ii} P_i$, and the interference power at receiver $i$ from other transmitters is given by $\sum_{k \neq i} G_{ik}P_k$. The (background) noise power at receiver $i$ is $\sigma_i$. Thus, the _Signal to Interference and  Noise Ratio (SINR)_ of the $i$th receiver-transmitter pair is

$$ S_i = \frac{G_{ii}P_i}{\sum_{k \neq i} G_{ik}P_k + \sigma_i }. $$

The minimum SINR represents a performance lower bound for this system, $S^{\text min}$.

The resulting optimisation problem is formulated as

$$
\begin{array}{ll}
\min_{P} & \sum_{i=1}^N P_i \\
\text{subject to} & P^{min} \leq P_i \leq P^{max}, \; \forall i \\
& \dfrac{G_{ii}P_i}{\sigma_i + \sum_{k \neq i} G_{ik}P_k} \geq S^{min} , \; \forall i \\
\end{array}
$$

### Question 3.3. (10 pts)

Let $N=6$, $P^{min}=0.1$, $P^{max}=5$, $\sigma=0.2$ (same for all). Create a random path loss matrix $G$, where off-diagonal elements are between $0.1$ and $0.9$ and the diagonal elements are equal to $1$.

1. (2 pts) Write down the Langrangian and KKT conditions of this problem.
2. (2 pts) Solve the problem first with $S^{min}=0$ using _cvxpy_. Plot the power levels and SINRs that you obtain.
3. (2 pts) What happens if you choose an $S^{min}$ that is larger? Solve the problem again and document your results. What happens if you choose a very large $S^{min}$? Observe and comment.
4. (4 pts) Solve the problem using a combination of active set and penalty function methods. Specifically, choose a penalty function to impose the $S^{min}$ constraint and use an active set method for $P^{min}$, $P^{max}$ power constraints. Choose tighter constraints to make the problem more interesting.

**Answer as text here**

```python
''' Answer as code here '''
import cvxpy as cp

# Create two scalar optimization variables.
x = cp.Variable()
y = cp.Variable()
print(x)
print(type(x))
"""
# Create two constraints.
constraints = [x + y == 1,
               x - y >= 1]

# Form objective.
obj = cp.Minimize((x - y)**2)

# Form and solve problem.
prob = cp.Problem(obj, constraints)
prob.solve()  # Returns the optimal value.
print("status:", prob.status)
print("optimal value", prob.value)
print("optimal var", x.value, y.value)
"""
```

    var28
    <class 'cvxpy.expressions.variables.variable.Variable'>

    '\n# Create two constraints.\nconstraints = [x + y == 1,\n               x - y >= 1]\n\n# Form objective.\nobj = cp.Minimize((x - y)**2)\n\n# Form and solve problem.\nprob = cp.Problem(obj, constraints)\nprob.solve()  # Returns the optimal value.\nprint("status:", prob.status)\nprint("optimal value", prob.value)\nprint("optimal var", x.value, y.value)\n'

## 3.4 (_Optional_ Bonus, 10 pts) Model Predictive Control

It is possible to formulate a **discrete-time, finite-horizon optimal-control** as a constrained optimisation problem. This is quite useful since it allows making use of powerful optimisation solvers in addressing the [control problem](https://en.wikipedia.org/wiki/Control_system). This formulation is called Model Predictive Control [(MPC).](https://en.wikipedia.org/wiki/Model_predictive_control)

Specifically, consider a system with a state vector $x_t\in {\bf R}^n$ that varies over the discrete time steps $t=0,\ldots,T$, and control actions $u_t\in {\bf R}^m$ that affect the state as part of a linear dynamical system formulated as

$$ x_{t+1} = A x_t + B u_t, $$
where $A \in {\bf R}^{n\times n}$ and $B \in {\bf R}^{n\times m}$ are system matrices.

The goal is to find the optimal actions $u_0,\ldots,u_{T-1}$ over the finite horizon $T$ by solving the optimization problems

\begin{array}{ll} \mbox{minimize} & \sum_{t=0}^{T-1} \ell (x_t,u_t) + \ell_T(x_T)\\
\mbox{subject to} & x_{t+1} = Ax_t + Bu_t\\%, \quad t=0, \ldots, T-1\\
& (x_t,u_t) \in \mathcal C, \quad x_T\in \mathcal C_T,
%, \quad \quad t=0, \ldots, T
\end{array}

where $\ell: {\bf R}^n \times {\bf R}^m\to {\bf R}$ is the stage cost, $\ell_T$ is the terminal cost,
$\mathcal C$ is the state/action constraints, and $\mathcal C_T$ is the terminal constraint.

1. Choose a simple linear dynamical system that you are interested in and formulate its state evolution as $x_{t+1} = A x_t + B u_t$. This can be a very well-known system, you don't need to be original.
2. Define the objective, i.e. ongoing (and if there are terminal) costs imposed on states and control actions (cost of good/bad states, cost of taking a control action).
3. Solve the problem over a finite horizon. Apply actions to compute and plot the evolution of states.

A recent research paper (which has won the best student paper award) using MPC formulation is [available here (right click to download).](files/MPC_paper.pdf)

### 3.5 (_Optional_  without bonus :) How do optimisation software handle non-standard problems?

_This is just for those of you, who are very interested and have spare time and bored and have nothing else to do!_

Try to solve Question 6 from Module 2, Lesson 2 (Constrained Optimisation) using the software packages (Python ones and Matlab). How do various software packages handle such non-standard situations? Observe and add to your report briefly with a sentence or two.

#  Workshop Report Submission Instructions

_You should ideally complete the workshop tasks and answer the questions within the respective session!_ The submission deadline is usually Friday, the week after. Submission deadlines will be announced on LMS.

It is **mandatory to follow all of the submissions guidelines** given below. _Don't forget the Report submission information on top of this notebook!_

1. The completed Jupyter notebook and its Pdf version (you can simply print-preview and then print as pdf from within your browser) should be uploaded to the right place in LMS by the announced deadline. _It is your responsibility to follow the announcements!_ **Late submissions will be penalised (up to 100% of the total mark depending on delay amount)!**
2. Filename should be “ELEN90061 Workshop **W: StudentID1-StudentID2** of session **Day-Time**", where **W** refers to the workshop number, **StudentID1-StudentID2** are your student numbers, **Day-Time** is your session day and time, e.g. *Tue-14*.
3. Answers to questions, simulation results and diagrams should be included in the Jupyter notebook as text, code, plots. *If you don't know latex, you can write formulas/text to a paper by hand, scan it and then include as image within Markdown cells.*
4. One report submission per group.

### Additional guidelines for your programs:

* Write modular code using functions.
* Properly indent your code. But Python forces you do that anyway ;)
* Heavily comment the code to describe your implementation and to show your understanding. No comments, no credit!
* Make the code your own! It is encouraged to find and get inspired by online examples but you should exactly understand, modify as needed, and explain your code via comments. There will be no credit for blind copy/paste even if it somehow works (and it is easier to detect it than you might think)!

```python

```


```python
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
```

One of the basic things we have to do in math is to express numbers. Numbers are the basic particles of essence so it makes sense to start with them first. We'll start with the *natural numbers* and work our way up through the whole spectrum of numbers finally arriving at the *complex numbers*.

We'll see that every type of number has a use. These numbers are not just cooked up because somebody figured that would be cool. No, every number serves a definite purpose and even though we might not always understand completely what that purpose is that does not mean we shouldn't use them in order to try a greater understanding.

Take for example the number $\sqrt{2}$ which is a perfectly fine number nowadays. However, how hard we might try, there's really no other way to write down this number. We could try for an approximation like $1.4142135\ldots$ but that's really just being sloppy. There *are* other ways to write this number but we will never get a *real* number out of it. Only approximations.

### natural numbers
Some people like to include the number zero in this set but we'll stick with with the numbers $1, 2, 3, \ldots, n$ where $n \gt 0$. They are basically all the whole numbers.

$$\mathbb{N} = {1, 2, 3, \ldots}$$

If we want to be really unambiguous about what we mean we could be extra explicit.

$$
\begin{align}
\mathbb{N^0} = \mathbb{N_0} & = {0, 1, 2, 3, \ldots}\\
\mathbb{N^*} = \mathbb{N^+} = \mathbb{N_1} = \mathbb{N_{\gt 0}} & = {1, 2, 3, \ldots}
\end{align}
$$

If we really want to include zero in this text we'll use a different set though.

### whole numbers
This is all of the numbers in $\mathbb{N}$ and the number zero. We don't have any cool letter for this though but we can say it's all the whole numbers $0, 1, 2, \ldots, n$ where $n \ge 0$.

### integers
Now this is an interesting set. If only because it's so prevalent in almost all math that we do. For example, computers love integers because they can be so easily represented by a sequence of bits.

Integers is the set of *whole numbers* but it also includes all the negatives of the *natural numbers*. So now were talking about $-n, \ldots, -2, -1, 0, 1, 2, \ldots, n$. This set is important enough to get its own symbol:

$$\mathbb{Z} = {-n, \ldots, -2, -1, 0, 1, 2, \ldots, n}$$

### interlude: integers
Even though we are not even half-way up our ladder of number systems those integers are already getting a bit interesting. Why do we like them so much in computing? Well, because they can easily be represented as a sequence of one and zero. How does this work though?

If we look at any integer, let's take for example $321$ and analyze what it means we can come to the insight that:

$$321 = (3 \times 100) + (2 \times 10) + (1 \times 1)$$

If we look a little deeper we can also see that $100 = 10^2$, $10 = 10^1$ and $1 = 10^0$ so in other words:

$$321 = (3 \times 10^2) + (2 \times 10^1) + (1 \times 10^0)$$

Our number system is called the *decimal* system and that's because we have base `10` numbers. There other number systems. Other sytems that are in commonly in use are *binary*, *hexadecimal* and sometimes *octal*. The binary system is popular because it aligns with electronic switches that can be either on or off. There are only two possibilities and that is what binary means. The octal system is sometimes used because it aligns nicely with the *byte* memory unit in computers but you don't see it much these days. However, the hexadecimal system is still prevalent and you'll see a lot, for example in color codes.

So how does the binary system work? Well remember that the decimal system operates on *powers* of ten so the binary system operates on power of two.

$$
\begin{align}
0 = 0 \times 2^0 & = 0\\
1 = 1 \times 2^0 & = 1 \\
2 = (1 \times 2^1) + (0 \times 2^0) & = 10 \\
3 = (1 \times 2^1) + (1 \times 2^0) & = 11 \\
4 = (1 \times 2^2) + (0 \times 2^1) + (0 \times 2^0) & = 100 \\
5 = (1 \times 2^2) + (0 \times 2^1) + (1 \times 2^0) & = 101 \\
6 = (1 \times 2^2) + (1 \times 2^1) + (0 \times 2^0) & = 110 \\
7 = (1 \times 2^2) + (1 \times 2^1) + (1 \times 2^1) & = 111
\end{align}
$$

### rational numbers
We get rational numbers when we need to express one integer as some part of another. When people started doing real math this problem soon cropped up. The easier way to deal with it is to kind of *not* deal with it and just say well it's this number expressed as some ratio of another. This might be a bit abstract so let's take an example.

When we first started doing divisions we took stuff like $\frac{3}{3} = 1$ and the world was good. As people started doing more fancy stuff with math and numbers things got a bit out of hand though. At some point we found ourselfs in the need to express something else than *integers*. As always, when math runs into a wall we'll just invent something to get over it. And as such we got *rational numbers* which are basically just *fractions* like $\frac{1}{3}$ or $\frac{1}{\pi}$.

At this point note that in order to get better numbers we just take some existing numbers we know and combine them in some way that makes sense but is somewhat unexpected. I mean, people are still drawing out proplems with triangles and such and now you're starting to abstract some of this stuff away. And it makes sense too because you don't want to lose any information. By keeping that number in its exact *ratio* $\frac{1}{3}$ you will have a clean number to calculate with.

Which leads us to the unfortunately necessary...

### real numbers
Let me start by saying that *real numbers* are unfortunately named because most of them are anything but real. Real numbers are supposed to be plotted along a line (usually the x-axis) and they are but when we *do have* to work with them they are usually just an approximation of a rational number.

We like to be pure as long as we can so we'll use rational numbers over real numbers but sometimes (especially dealing with computers) we'll *have* to convert our rational number to some *real* approximation. For most purposes you can just think of a *real* number as an *approximation* of some rational number that is to be used for real-life purposes.

### irrational numbers
These are interesting numbers because they are so called *real* numbers but we cannot express them as a rational number. As always, when we encounter such a thing in math we tend to give it a name or a convenient notation. Examples are $\pi$, our friend $\sqrt{2}$ and $e$.

Irrational numbers are awkward and in order to stay pure we sometimes can do no better than express something as a fraction of an irrational unit. Of course we could try to get a *real* number out but we have to remember that this will always be an approximiation. This might be fine though depending on our purposes.

### imaginary numbers
This is where things start to get really interesting (and strange). After hundreds of years of working on math puzzles mathematicians where getting annoyed by the fact that this $\sqrt{-1}$ kept popping up in their would-be solutions. And (at that time) there was no possible way to calculate a negative *square root* so they just gave up... Mostly. Finally they just decided to go with it and complete the calculations involving the negative square root $\sqrt{-1}$ and things turned out beautifully.

After a few more hundred years it has become so useful that we gave it a special notation and even a special algebra so it all makes sense as a number as well.

So the first thing we have to consider is that we have this new number now called $i$ which is defined as $i^2 = -1$ so $i = \sqrt{-1}$. Now we can just use regular algebra to express any negative root, for example if we need $\sqrt{-5}$ I could just go ${i \times \sqrt{5}}$ and this simple transformation allows us to actually calculate with those things.

Note that the name *imaginary* is actually a bad name. These numbers *are* real in the normal sense of that word. The fact that we describe them this way is just byproduct of the way we write math in general. It's better to look at any number and imagine it to have a so-called *imaginary* component. In a lot of cases this will just be $0 \times i$ (no imaginary component) but sometimes they do.

### complex numbers
In some sense, this is the best way to describe a number. This form of numbers is called *complex* buty they are not really that complex though. Again, this is kind of a misnomer and actually complex numbers are very easy. In fact, they are just numbers.

It's just so many numbers we deal with are on the x-axis of the *complex plane* that we don't even notice we are dealing with complex numbers at all. Thanks to a lot of evolution and schooling we can now reasonably *feel* how most numbers work up to and including rational numbers. However, *imaginary* and *complex numbers* are still a bit weird.

One of the best ways to show how complex numbers enter math is to show an innocent looking equation like: $y = x^2 + 1$. If we try to solve this for $y = 0$ we get $0 = x^2 + 1$. And going further we get $-1 = x^2 \implies x = \sqrt{-1}$. Before complex numbers there was no such thing as $\sqrt{-1}$ and we would have simply given up.

Nowadays we can say that the solution is $i$ (and $-i$ is a valid solution too).

```python

```


```python
%matplotlib inline
```

파이프라인 병렬화로 트랜스포머 모델 학습시키기
==============================================

**Author**: `Pritam Damania <https://github.com/pritamdamania87>`_
  **번역**: `백선희 <https://github.com/spongebob03>`_

이 튜토리얼은 파이프라인(pipeline) 병렬화(parallelism)를 사용하여 여러 GPU에 걸친 거대한 트랜스포머(transformer)
모델을 어떻게 학습시키는지 보여줍니다. `NN.TRANSFORMER 와 TORCHTEXT 로 시퀀스-투-시퀀스(SEQUENCE-TO-SEQUENCE) 모델링하기 <https://tutorials.pytorch.kr/beginner/transformer_tutorial.html>`__ 튜토리얼의
확장판이며 파이프라인 병렬화가 어떻게 트랜스포머 모델 학습에 쓰이는지 증명하기 위해 이전 튜토리얼에서의
모델 규모를 증가시켰습니다.

선수과목(Prerequisites):

    * `Pipeline Parallelism <https://pytorch.org/docs/stable/pipeline.html>`__
    * `NN.TRANSFORMER 와 TORCHTEXT 로 시퀀스-투-시퀀스(SEQUENCE-TO-SEQUENCE) 모델링하기 <https://tutorials.pytorch.kr/beginner/transformer_tutorial.html>`__

모델 정의하기
-------------

이번 튜토리얼에서는, 트랜스포머 모델을 두 개의 GPU에 걸쳐서 나누고 파이프라인 병렬화로 학습시켜 보겠습니다.
모델은 바로 `NN.TRANSFORMER 와 TORCHTEXT 로 시퀀스-투-시퀀스(SEQUENCE-TO-SEQUENCE) 모델링하기
<https://tutorials.pytorch.kr/beginner/transformer_tutorial.html>`__ 튜토리얼과
똑같은 모델이지만 두 단계로 나뉩니다. 대부분 파라미터(parameter)들은
`nn.TransformerEncoder <https://pytorch.org/docs/stable/generated/torch.nn.TransformerEncoder.html>`__ 계층(layer)에 포함됩니다.
`nn.TransformerEncoder <https://pytorch.org/docs/stable/generated/torch.nn.TransformerEncoder.html>`__ 는
`nn.TransformerEncoderLayer <https://pytorch.org/docs/stable/generated/torch.nn.TransformerEncoderLayer.html>`__ 의 ``nlayers`` 로 구성되어 있습니다.
결과적으로, 우리는 ``nn.TransformerEncoder`` 에 중점을 두고 있으며,
``nn.TransformerEncoderLayer`` 의 절반은 한 GPU에 두고
나머지 절반은 다른 GPU에 있도록 모델을 분할합니다. 이를 위해서 ``Encoder`` 와
``Decoder`` 섹션을 분리된 모듈로 빼낸 다음, 원본 트랜스포머 모듈을
나타내는 nn.Sequential을 빌드 합니다.

```python
import sys
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
import tempfile
from torch.nn import TransformerEncoder, TransformerEncoderLayer

if sys.platform == 'win32':
    print('Windows platform is not supported for pipeline parallelism')
    sys.exit(0)
if torch.cuda.device_count() < 2:
    print('Need at least two GPU devices for this tutorial')
    sys.exit(0)

class Encoder(nn.Module):
    def __init__(self, ntoken, ninp, dropout=0.5):
        super(Encoder, self).__init__()
        self.pos_encoder = PositionalEncoding(ninp, dropout)
        self.encoder = nn.Embedding(ntoken, ninp)
        self.ninp = ninp
        self.init_weights()

    def init_weights(self):
        initrange = 0.1
        self.encoder.weight.data.uniform_(-initrange, initrange)

    def forward(self, src):
        # 인코더로 (S, N) 포맷이 필요합니다.
        src = src.t()
        src = self.encoder(src) * math.sqrt(self.ninp)
        return self.pos_encoder(src)

class Decoder(nn.Module):
    def __init__(self, ntoken, ninp):
        super(Decoder, self).__init__()
        self.decoder = nn.Linear(ninp, ntoken)
        self.init_weights()

    def init_weights(self):
        initrange = 0.1
        self.decoder.bias.data.zero_()
        self.decoder.weight.data.uniform_(-initrange, initrange)

    def forward(self, inp):
        # 파이프라인 결과물을 위해 먼저 배치 차원 필요합니다.
        return self.decoder(inp).permute(1, 0, 2)
```

``PositionalEncoding`` 모듈은 시퀀스 안에서 토큰의 상대적인 또는 절대적인 포지션에 대한 정보를 주입합니다.
포지셔널 인코딩은 임베딩과 합칠 수 있도록 똑같은 차원을 가집니다. 여기서
다른 주기(frequency)의 ``sine`` 과 ``cosine`` 함수를 사용합니다.

```python
class PositionalEncoding(nn.Module):

    def __init__(self, d_model, dropout=0.1, max_len=5000):
        super(PositionalEncoding, self).__init__()
        self.dropout = nn.Dropout(p=dropout)

        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        pe = pe.unsqueeze(0).transpose(0, 1)
        self.register_buffer('pe', pe)

    def forward(self, x):
        x = x + self.pe[:x.size(0), :]
        return self.dropout(x)
```

데이터 로드하고 배치 만들기
---------------------------

학습 프로세스는 ``torchtext`` 의 Wikitext-2 데이터셋을 사용합니다.
torchtext 데이터셋에 접근하기 전에, https://github.com/pytorch/data 을 참고하여 torchdata를 설치하시기 바랍니다.

단어 오브젝트는 훈련 데이터셋으로 만들어지고, 토큰을 텐서(tensor)로 수치화하는데 사용됩니다.
시퀀스 데이터로부터 시작하여, ``batchify()`` 함수는 데이터셋을 열(column)들로 정리하고,
``batch_size`` 사이즈의 배치들로 나눈 후에 남은 모든 토큰을 버립니다.
예를 들어, 알파벳을 시퀀스(총 길이 26)로 생각하고 배치 사이즈를 4라고 한다면,
알파벳을 길이가 6인 4개의 시퀀스로 나눌 수 있습니다:

\begin{align}\begin{bmatrix}
  \text{A} & \text{B} & \text{C} & \ldots & \text{X} & \text{Y} & \text{Z}
  \end{bmatrix}
  \Rightarrow
  \begin{bmatrix}
  \begin{bmatrix}\text{A} \\ \text{B} \\ \text{C} \\ \text{D} \\ \text{E} \\ \text{F}\end{bmatrix} &
  \begin{bmatrix}\text{G} \\ \text{H} \\ \text{I} \\ \text{J} \\ \text{K} \\ \text{L}\end{bmatrix} &
  \begin{bmatrix}\text{M} \\ \text{N} \\ \text{O} \\ \text{P} \\ \text{Q} \\ \text{R}\end{bmatrix} &
  \begin{bmatrix}\text{S} \\ \text{T} \\ \text{U} \\ \text{V} \\ \text{W} \\ \text{X}\end{bmatrix}
  \end{bmatrix}\end{align}

이 열들은 모델에 의해서 독립적으로 취급되며, 이는
``G`` 와 ``F`` 의 의존성이 학습될 수 없다는 것을 의미하지만, 더 효율적인
배치 프로세싱(batch processing)을 허용합니다.

```python
import torch
from torchtext.datasets import WikiText2
from torchtext.data.utils import get_tokenizer
from torchtext.vocab import build_vocab_from_iterator

train_iter = WikiText2(split='train')
tokenizer = get_tokenizer('basic_english')
vocab = build_vocab_from_iterator(map(tokenizer, train_iter), specials=["<unk>"])
vocab.set_default_index(vocab["<unk>"])

def data_process(raw_text_iter):
  data = [torch.tensor(vocab(tokenizer(item)), dtype=torch.long) for item in raw_text_iter]
  return torch.cat(tuple(filter(lambda t: t.numel() > 0, data)))

train_iter, val_iter, test_iter = WikiText2()
train_data = data_process(train_iter)
val_data = data_process(val_iter)
test_data = data_process(test_iter)

device = torch.device("cuda")

def batchify(data, bsz):
    # 데이터셋을 bsz 파트들로 나눕니다.
    nbatch = data.size(0) // bsz
    # 깔끔하게 나누어 떨어지지 않는 추가적인 부분(나머지)은 잘라냅니다.
    data = data.narrow(0, 0, nbatch * bsz)
    # 데이터를 bsz 배치들로 동일하게 나눕니다.
    data = data.view(bsz, -1).t().contiguous()
    return data.to(device)

batch_size = 20
eval_batch_size = 10
train_data = batchify(train_data, batch_size)
val_data = batchify(val_data, eval_batch_size)
test_data = batchify(test_data, eval_batch_size)
```

입력과 타겟 시퀀스를 생성하기 위한 함수들
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

``get_batch()`` 함수는 트랜스포머 모델을 위한 입력과 타겟 시퀀스를
생성합니다. 이 함수는 소스 데이터를 ``bptt`` 길이를 가진 덩어리로 세분화합니다.
언어 모델링 과제를 위해서, 모델은 다음 단어인 ``Target`` 이 필요합니다. 에를 들어 ``bptt`` 의 값이 2라면,
``i`` = 0 일 때 다음의 2 개 변수(Variable)를 얻을 수 있습니다:

변수 덩어리는 트랜스포머 모델의 ``S`` 차원과 일치하는 0 차원에 해당합니다.
배치 차원 ``N`` 은 1 차원에 해당합니다.

```python
bptt = 25
def get_batch(source, i):
    seq_len = min(bptt, len(source) - 1 - i)
    data = source[i:i+seq_len]
    target = source[i+1:i+1+seq_len].view(-1)
    # 파이프라인 병렬화를 위해 먼저 배치 차원이 필요합니다.
    return data.t(), target
```

모델 규모와 파이프 초기화
-------------------------

파이프라인 병렬화를 활용한 대형 트랜스포머 모델 학습을 증명하기 위해서
트랜스포머 계층 규모를 적절히 확장시킵니다. 4096차원의 임베딩 벡터, 4096의 은닉 사이즈,
16개의 어텐션 헤드(attention head)와 총 12 개의 트랜스포머 계층
(``nn.TransformerEncoderLayer``)를 사용합니다. 이는 최대
**1.4억** 개의 파라미터를 갖는 모델을 생성합니다.

Pipe는 `RRef <https://pytorch.org/docs/stable/rpc.html#rref>`__ 를 통해
`RPC 프레임워크 <https://pytorch.org/docs/stable/rpc.html>`__ 에 의존하는데
이는 향후 호스트 파이프라인을 교차 확장할 수 있도록 하기 때문에
RPC 프레임워크를 초기화해야 합니다. 이때 RPC 프레임워크는 오직 하나의 하나의 worker로 초기화를 해야 하는데,
여러 GPU를 다루기 위해 프로세스 하나만 사용하고 있기 때문입니다.

그런 다음 파이프라인은 한 GPU에 8개의 트랜스포머와
다른 GPU에 8개의 트랜스포머 레이어로 초기화됩니다.

Note

효율성을 위해 ``Pipe`` 에 전달된 ``nn.Sequential`` 이
   오직 두 개의 요소(2개의 GPU)로만 구성되도록 합니다. 이렇게 하면
   Pipe가 두 개의 파티션에서만 작동하고
   파티션 간 오버헤드를 피할 수 있습니다.

```python
ntokens = len(vocab) # 단어 사전(어휘집)의 크기
emsize = 4096 # 임베딩 차원
nhid = 4096 # nn.TransformerEncoder 에서 순전파(feedforward) 신경망 모델의 차원
nlayers = 12 # nn.TransformerEncoder 내부의 nn.TransformerEncoderLayer 개수
nhead = 16 # multiheadattention 모델의 헤드 개수
dropout = 0.2 # dropout 값

from torch.distributed import rpc
tmpfile = tempfile.NamedTemporaryFile()
rpc.init_rpc(
    name="worker",
    rank=0,
    world_size=1,
    rpc_backend_options=rpc.TensorPipeRpcBackendOptions(
        init_method="file://{}".format(tmpfile.name),
        # _transports와 _channels를 지정하는 것이 해결 방법이며
        # PyTorch 버전 >= 1.8.1 에서는 _transports와 _channels를
        # 지정하지 않아도 됩니다.
        _transports=["ibv", "uv"],
        _channels=["cuda_ipc", "cuda_basic"],
    )
)

num_gpus = 2
partition_len = ((nlayers - 1) // num_gpus) + 1

# 처음에 인코더를 추가합니다.
tmp_list = [Encoder(ntokens, emsize, dropout).cuda(0)]
module_list = []

# 필요한 모든 트랜스포머 블록들을 추가합니다.
for i in range(nlayers):
    transformer_block = TransformerEncoderLayer(emsize, nhead, nhid, dropout)
    if i != 0 and i % (partition_len) == 0:
        module_list.append(nn.Sequential(*tmp_list))
        tmp_list = []
    device = i // (partition_len)
    tmp_list.append(transformer_block.to(device))

# 마지막에 디코더를 추가합니다.
tmp_list.append(Decoder(ntokens, emsize).cuda(num_gpus - 1))
module_list.append(nn.Sequential(*tmp_list))

from torch.distributed.pipeline.sync import Pipe

# 파이프라인을 빌드합니다.
chunks = 8
model = Pipe(torch.nn.Sequential(*module_list), chunks = chunks)

def get_total_params(module: torch.nn.Module):
    total_params = 0
    for param in module.parameters():
        total_params += param.numel()
    return total_params

print ('Total parameters in model: {:,}'.format(get_total_params(model)))
```

모델 실행하기
-------------

손실(loss)을 추적하기 위해 `CrossEntropyLoss <https://pytorch.org/docs/master/nn.html?highlight=crossentropyloss#torch.nn.CrossEntropyLoss>`__ 가
적용되며, 옵티마이저(optimizer)로서 `SGD <https://pytorch.org/docs/master/optim.html?highlight=sgd#torch.optim.SGD>`__
는 확률적 경사하강법(stochastic gradient descent method)을 구현합니다. 초기
학습률(learning rate)은 5.0로 설정됩니다. `StepLR <https://pytorch.org/docs/master/optim.html?highlight=steplr#torch.optim.lr_scheduler.StepLR>`__ 는
에폭(epoch)에 따라서 학습률을 조절하는 데 사용됩니다. 학습하는 동안,
기울기 폭발(gradient exploding)을 방지하기 위해 모든 기울기를 함께 조정(scale)하는 함수
`nn.utils.clip_grad_norm\_ <https://pytorch.org/docs/master/nn.html?highlight=nn%20utils%20clip_grad_norm#torch.nn.utils.clip_grad_norm_>`__
을 이용합니다.

```python
criterion = nn.CrossEntropyLoss()
lr = 5.0 # 학습률
optimizer = torch.optim.SGD(model.parameters(), lr=lr)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, 1.0, gamma=0.95)

import time
def train():
    model.train() # 훈련 모드로 전환
    total_loss = 0.
    start_time = time.time()
    ntokens = len(vocab)

    # 스크립트 실행 시간을 짧게 유지하기 위해서 50 배치만 학습합니다.
    nbatches = min(50 * bptt, train_data.size(0) - 1)

    for batch, i in enumerate(range(0, nbatches, bptt)):
        data, targets = get_batch(train_data, i)
        optimizer.zero_grad()
        # Pipe는 단일 호스트 내에 있고
        # forward 메서드로 반환된 ``RRef`` 프로세스는 이 노드에 국한되어 있기 때문에
        # ``RRef.local_value()`` 를 통해 간단히 찾을 수 있습니다.
        output = model(data).local_value()
        # 타겟을 파이프라인 출력이 있는
        # 장치로 옮겨야합니다.
        loss = criterion(output.view(-1, ntokens), targets.cuda(1))
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
        optimizer.step()

        total_loss += loss.item()
        log_interval = 10
        if batch % log_interval == 0 and batch > 0:
            cur_loss = total_loss / log_interval
            elapsed = time.time() - start_time
            print('| epoch {:3d} | {:5d}/{:5d} batches | '
                  'lr {:02.2f} | ms/batch {:5.2f} | '
                  'loss {:5.2f} | ppl {:8.2f}'.format(
                    epoch, batch, nbatches // bptt, scheduler.get_lr()[0],
                    elapsed * 1000 / log_interval,
                    cur_loss, math.exp(cur_loss)))
            total_loss = 0
            start_time = time.time()

def evaluate(eval_model, data_source):
    eval_model.eval() # 평가 모드로 전환
    total_loss = 0.
    ntokens = len(vocab)
    # 스크립트 실행 시간을 짧게 유지하기 위해 50 배치만 평가합니다.
    nbatches = min(50 * bptt, data_source.size(0) - 1)
    with torch.no_grad():
        for i in range(0, nbatches, bptt):
            data, targets = get_batch(data_source, i)
            output = eval_model(data).local_value()
            output_flat = output.view(-1, ntokens)
            # 타겟을 파이프라인 출력이 있는
            # 장치로 옮겨야합니다.
            total_loss += len(data) * criterion(output_flat, targets.cuda(1)).item()
    return total_loss / (len(data_source) - 1)
```

에폭을 반복합니다. 만약 검증 오차(validation loss)가 지금까지 관찰한 것 중 최적이라면
모델을 저장합니다. 각 에폭 이후에 학습률을 조절합니다.

```python
best_val_loss = float("inf")
epochs = 3 # 에폭 수
best_model = None

for epoch in range(1, epochs + 1):
    epoch_start_time = time.time()
    train()
    val_loss = evaluate(model, val_data)
    print('-' * 89)
    print('| end of epoch {:3d} | time: {:5.2f}s | valid loss {:5.2f} | '
          'valid ppl {:8.2f}'.format(epoch, (time.time() - epoch_start_time),
                                     val_loss, math.exp(val_loss)))
    print('-' * 89)

    if val_loss < best_val_loss:
        best_val_loss = val_loss
        best_model = model

    scheduler.step()
```

평가 데이터셋으로 모델 평가하기
-------------------------------

평가 데이터셋에서의 결과를 확인하기 위해 최고의 모델을 적용합니다.

```python
test_loss = evaluate(best_model, test_data)
print('=' * 89)
print('| End of training | test loss {:5.2f} | test ppl {:8.2f}'.format(
    test_loss, math.exp(test_loss)))
print('=' * 89)
```


## DW mid-terms 2018 solutions

### These solutions are student compiled and might contain errors (especially for qn 1 & 2)
 Credit goes to Team Anonymous on Piazza

 Part A

```python
x = 'aces'
y = 1.23
z = x
x = y
y = z
print(x, y)
```

### Q1(a) After the above code is executed, at line 6, what is seen on the screen is 1.23aces.

By explaining what each line of the code below (for lines 1 to 5) does, show how the code below switches the objects assigned to variables x and y.

Your explanation must state when objects are created and their data types, and also how names are assigned to these objects. Diagrams could be helpful in your explanation. (6 points)

**Sample answer 1:**

At line 1, string object 'aces' is assigned to frame 'x'. At line 2, float object of value 1.23 is assigned to frame 'y'. At line 3, a shallow copy of string object 'aces' from frame 'x' is assigned to frame 'z'. At line 4, a shallow copy of the float object of value 1.23 from frame 'y' is assigned to frame 'x' and overwirtes the string object 'aces'. At line 5, a shallow copy of string object 'aces' from frame 'z' is assigned to frame 'y' and overwrites the float object of value 1.23. The final result is:

Stack | Object after line 3 > 4 > 5

x	|  'aces' > 1.23 > 1.23

y	|  1.23 > 1.23 > 'aces'

z	|  'aces' > 'aces' > 'aces'

The contents of both x & y are fetched when print(x,y) is executed at line 6. Thus, 1.23aces is printed in the order of x then y.

### Q1(b) When the following code is executed, after line 12, what is seen on the screen is  True.

   i) Using how names are assigned to objects in memory, explain why. Diagrams could be helpful in your explanation. (3 points)

ii) The intention of the programmer is to create two lists showing the words for ‘seven’, ‘eight’ and ‘nine’ for both Greek and French. State one modification to the code at line 8 so that line 13 prints out the correct output. (1point)

```python
french= [ 'sept', 'huit', 'neuf'] # the words mean 'seven','eight','nine'.
greek = french # this is line 8
greek[0] = 'epta' # 'seven'
greek[1] = 'okto' # 'eight'
greek[2] = 'enea' # 'nine'
print(greek is french) # this is 'line 12'
print(french, greek)
```

**Sample answer 1:**

i) At line 1, a list object is created for frame 'french'. At line 2, frame 'greek' is assigned to refer to the same list object as frame 'french'. Thus, even after the contents of the list is changed, line 12's shallow equivalence check 'is' returns true as both frames 'french' and 'greek' still refers to the same list object in memory.

ii) I would repace line 8 with 'greek = list(french)' to another list object similar in contents to frame 'french' is created for frame 'greek'.

### Q2 [10 points]
### a) Is there anything wrong with the following program? If yes, what is wrong?  (2 points)

```python
import = int(input("Enter a number: "))
if import==2:
    print("Yes")
else:
    print("No")
```

**Sample solution 1:**

Synthax error. 'import' is a reserved keyword in python and neither be used as a variable name nor be assigned to an 'int' type object.

### b) If break is removed from the following programand the program is run, what will be printed out? (2 points)

```python
my_string = "Computing"
for character in my_string:
    print(character)
    if character == "u":
        print("Found 'u' :)")
```

**Solution:**

C

o

m

p

u

Found 'u' :)

t

i

n

g

### c) Look at the following function:

```python
def my_function(n):
    return_value = None
    if n == 0 or n == 1:
        return_value = False # not run
    i=2
    while i*0.5:
        if n%i==0:
            return_value = False # not run
            break # not run
        i += 1
        return_value = True
    return return_value
my_function(37)
```

### Let the function tested to be my_function(37)

a) What will be the output? (1 point)

b) Identify the lines of the program which will be executed when the input is 37. Do this by entering the codes from those lines to eDimension. (2 points)

**Solution:**

a) True.

b) (enter those lines of code not tagged by # not run)

### d) In the context of the 1D projects you have completed so far in this course, look at the following function:

```python
def forward(speed, duration):
    robot.wheels(speed, speed)
    robot.sleep(duration)
    robot.wheels(0,0)
```

### a) Why is it not necessary to have a return statement in this function? (1 point)

b) If we change  robot.wheels(speed, speed) to robot.wheels(speed1, speed2) and the function header is also modified to take in speed1 and speed2, how will the movement of the robot change? You can assume that speed1 and speed2 are different and both speed1 and speed2 are positive numbers. (1 point)

**Sample solution 1:**

a) In the above function to move the robot forward at some speed for some duration, no values are expected to be reused or caught by the function. Hence, no return statement is required.

b) If speed1 > speed2, the robot will still travel forward but with a leftwards dispalcement. If speed1 < speed2, the robot will still travel forward but with a rightwards dispalcement.

### Part B

 Q3 [10 points]

 A frustum is a parallel truncation of a right pyramid. A piece of metal is in the shape of a frustum with a square base. The side length of the top square is s1 and the side length of the bottom square is s2. The height of the frustum is H.

The volume of the frustum is given by the formula:
 $$Volume = \frac{H}{3}(A1 + A2 + \sqrt{A1 \text{ x } A2})$$ where A1 is the area of the upper square, A2 is the area of the lower square, and H is the height of the frustum.

### a) Write a python function area_square(s) that takes the side of a square as an input argument s, and returns the area of the square.

b) Write a python function vol_frustum(top_area, bottom_area, height) that takes three arguments, a top area, a bottom area and a height in that order, and returns the volume of the frustum.

c) Write a python function get_volume(s1, s2, height) that takes three arguments, a top side length, a bottom side length and a height and returns the volume of a frustum based on those dimensions. This function should first call area_square to obtain the two needed areas, and then call vol_frustum to evaluate the volume.

All input arguments and return values are floats. Please round only your final output of get_volume to three decimal places. Please use math.sqrt() to compute the square root in your python code. Note that you get only full marks if your get_volume function makes use of the other two functions.

```python
import math
def area_square(s):
    return s**2.

def vol_frustum(top_area, bottom_area, height):
    return (height/3)*(top_area + bottom_area + math.sqrt(top_area*bottom_area))

def get_volume(s1, s2, height):
    return round(vol_frustum(area_square(s1), area_square(s2), height), 3)

## TEST CASES ##
print('{:.3f}'.format(area_square(2)))
print('{:.3f}'.format(area_square(3)))
print('{:.3f}'.format(vol_frustum(1,4,2)))
print('{:.3f}'.format(vol_frustum(2,2,2)))
print('{:.3f}'.format(get_volume(1,2,2)))
print('{:.3f}'.format(get_volume(1.5,3.3,5.0)))
print('{:.3f}'.format(get_volume(3.6,6.4,4.0)))
```

### Q4 [10 points]

Implement a function determinant(matrix) that takes a matrix as input (represented as a nested list) and returns its determinant as output. The function should satisfy the following requirements:

1) If the input matrixis not of dimension n x n (for 1 ≤n ≤3), the function should return None

 2) The function is to be implemented withoutimporting any libraries.

```python
def determinant(matrix):
    M = matrix
    try:
        a = len(M)
        a = len(M[0])
    except:
        return None

    if len(M) == 1:
        for row in M:
            if len(row) != 1:
                return None
        return M[0][0]
    if len(M) == 2:
        for row in M:
            if len(row) != 2:
                return None
        return M[0][0] * M[1][1] - M[0][1]*M[1][0]
    if len(M) == 3:
        for row in M:
            if len(row) != 3:
                return None
        return (M[0][0] * M[1][1] * M[2][2]
                + M[1][0] * M[2][1] * M[0][2]
                + M[2][0] * M[0][1] * M[1][2]
                - M[0][2] * M[1][1] * M[2][0]
                - M[1][0] * M[0][1] * M[2][2]
                - M[0][0] * M[2][1] * M[1][2])

## TEST CASES ##
print(determinant([[100]]))
print(determinant([[-5, -4],[-2, -3]]))
print(determinant([[2, -3, 1], [2, 0, -1],[1, 4, 5]]))
print(determinant([[0, 3, 5],[5, 5, 2],[3, 4, 3]]))
print(determinant([[23], [-4, 4]]))
```

### Q5 [15 points]

The Newton-Raphson (NR) method is an iterative method that approximates the root of a function. The accuracy of the answer is enhanced in successive iterations.

You need two create two functions: nrootand nroot_complex. The function nroot(n, i, num) is to determine the root of non-negative num. The function nroot_complex(n,i,num) to determine the root of negative num. Thefunction nroot_complex should call nroot to do the NR approximation. Note the output should give a constant$*$1j where j is the imaginary square root of -1. For odd n the output should give a negative value instead of constant$*$1j. This means that:

• When num is a non-negative number, nroot_complex should give the same result as nroot.
• When num is a negative number and n is even, nroot_complex should give a complex number with no real part, and its magnitude is the same as the output of nrootwhen num is positive.
• When numis a negative number and n is odd, nroot_complex should give a negative real number, and its magnitude is the same as the output of nroot when numis positive.

Round the output of nroot to 3 decimal places.Use x = 1 as your initial value.

```python
def nroot(n,t,num):
    x = 1
    for i in range(t):
        x -= ((x**n - num)/(n*(x**(n-1))))
    return round(x,3)

def nroot_complex(n,t,num):
    if num > 0 or num == 0:
        return nroot(n,t,num)
    if num < 0 and n%2 == 1:
        return -nroot(n,t,-num)
    else:
        return str(nroot(n,t,-num)) + 'j'

## TEST CASES ##
print(nroot(2,5,2))
print(nroot_complex(2,5,-4))
print(nroot_complex(3,5,-8))
```

### Q6 [30 points]

In this problem you will write a program to find a path through MRT stations from a starting station to an ending station with a maximum of one interchange.

 The overall function is called find_path and it takes three arguments: (1) a file object to a text file containing the MRT lines and stations, (2) the starting station, and (3) the ending station.

This function should return a list of stations from a starting station to an ending station with a maximum of one interchange. The problem is decomposed by writing several other functions, described in the following parts.

 For simplicity, the information given to you in this question is limited to the North South line and the East West line. Also, the branch line to Changi Airport is treated as a separate line. Hence the three lines are labelled in this question as follows: (1) NorthSouthLine (2) EastWestLine (EW) and (3) EastWestLine (CG).

a) read_stations(f): This function takes in a file object and returnsa dictionary. The dictionary has the MRT lines as its keys. The value of each key is a list of stations in that MRT line.

b) get_stationline(mrt): This function takes in a dictionary of MRT lines (i.e. the output of part (a) ). The function returns another dictionary which contains all the stations as its keys. The value for each key is a list of the MRT lines for that particular station. Note that if the station is an interchange, the list should contain all the lines calling at that station.

c) get_interchange(stationline): This function takes in a dictionary of stations and their lines (i.e. the output of part (b)). The function returns another dictionary which contains all the interchange stations as its keys. The value for each key is a list of the MRT lines for that particular interchange stations.

d) find_path(f, start, end): This function takes in three arguments: (1) the file object to a text file containing all the MRT lines and stations, (2) the startingstation, and (3) the endingstation. The function should return a list of stations starting from the starting station to the ending station with a maximum of one interchange.If there are more than one possible paths, the following considerations should be taken into account:

• If there is a path without changing MRT lines, the result should return this path.
• If the path must involve changing MRT lines, it will return the path with a minimum number of stations and containing only one interchange station.
• If no such path can be found as above, it will return None.

```python
## PART A ##
def read_stations(s):
    st = ''.join(s.readlines()).split("=")
    ans = {}
    for i in range(int((len(st)-1)/2)):
        ans[st[2*i+1]] = (st[2*(i+1)].strip("\n")).split(", ")
    return ans

## PART B ##
def get_stationline(mrt):
    ans = {}
    for lines in mrt:
        for station in mrt[lines]:
            if station not in ans:
                ans[station] = []
                ans[station].append(lines)
            else:
                ans[station].append(lines)
    return ans

## PART C ##
def get_interchange(stationline):
    ans = {}
    for stations in stationline:
        if len(stationline[stations]) > 1:
            ans[stations] = stationline[stations]
    return ans

## PART D ##
def create_graph(f):
    stations = read_stations(f)
    stationline = get_stationline(stations)
    interchange = [*get_interchange(stationline)]
    network = {}
    # Create a dictionary where station (key) is linked to all its connected stations (values)
    for line in stations:
        stations_ = stations.get(line)
        network[stations_[-1]]=[stations_[-2]]  # End stations only conencted to one other station
        for station in range(0,len(stations_)-1):
            network.setdefault(stations_[station],[stations_[station-1]]).append(stations_[station+1])
        if stations_[0] not in interchange:
             network[stations_[0]]=[stations_[1]] # Removing non interchange connections that loops
    network['City Hall'].append('Dhoby Ghaut')   # Add this pesky back edge
    for key in network:
        network[key] = set(network.get(key)) # Apply set structure to dictionary values
    return network

def bfs_paths(graph, start, goal):
    # Since we only want the shortest path, we use Breath First Search on a queue structure for efficiency
    queue = [(start, [start])]
    while queue:
        (vertex, path) = queue.pop(0)
        for next in graph[vertex] - set(path):
            if next == goal:
                yield path + [next]
            else:
                queue.append((next, path + [next]))

import collections as c
def find_path(f, start, end):
   try:
    graph = create_graph(f)
    possible_paths = list(bfs_paths(graph,start,end))
    f.seek(0) # remember to reset readlines() counter to 0
    stations = read_stations(f)
    stationline = get_stationline(stations)
    interchange = [*get_interchange(stationline)]
    ans = []
    for path in possible_paths:
        line_counter = []
        for station in path:
            line_counter.append(stationline.get(station)[0])
        # We count the total number of line types present. More than 1 interchange used if line type > 2
        if len(c.Counter(line_counter)) <= 2:
                ans.append(path)
    return ans[0] # Since we used BFS, first path found is always the shortest
   except:
    # A general catch block to return None
    return None

## TEST CASES ##
print('Test 1')
f=open('mrt_lines_short.txt','r') # Make sure directory is correct
ans=find_path(f,'Boon Lay', 'Clementi')
print(ans)
f.close()
print('Test 2')
f=open('mrt_lines_short.txt','r') # Make sure directory is correct
ans=find_path(f,'Changi Airport', 'Orchard')
print(ans)
f.close()
print('Test 3')
f=open('mrt_lines_short.txt','r') # Make sure directory is correct
ans=find_path(f,'Boon Lay', 'Bukit Gombak')
print(ans)
f.close()
print('Test 4')
f=open('mrt_lines_short.txt','r') # Make sure directory is correct
ans=find_path(f,'Tanah Merah', 'Orchard')
print(ans)
f.close()
```

### Q7 [15 points]

 Write a function decompose(pence), that takes as input some number of pence (as an integer), and returns as output an integer expressing how many different ways that the amount can be made up by using the available coins. At present, there are eight coins in general circulation:

1p, 2p, 5p, 10p, 20p, 50p, £1, and £2

Note that the function decompose(pence) can be implemented in a number of different ways, including by using brute force (i.e. exhaustive search). However, brute force implementations may only score a maximum of 12 points; the full 15 are only available for more elegant/efficient solutions.

**Sample Solution 1: Naive for loops (Brute force; Don't even try to time it)**

```python
def decompose(pence):
    coins = [1,2,5,10,20,50,100,200]
    count = 0
    for x1 in range(pence):
        for x2 in range(pence):
            for x3 in range(pence):
                for x4 in range(pence):
                    for x5 in range(pence):
                        for x6 in range(pence):
                            for x7 in range(pence):
                                for x8 in range(pence):
                                    if (x1 * coins[0] + x2 * coins[1] + x3 * coins[2] + x4 * coins[3]
                                     + x5 * coins[4] + x6 * coins[5] + x7 * coins[6] + x8 * coins[7])
                                      == pence:
                                        count += 1 # Cancer

    return count + 1

## TEST CASES ##
import time
start_time = time.time()
print (decompose(1))
print (decompose(5))
print (decompose(7))
print (decompose(130))
print (decompose(200))
print (decompose(700))
print("--- %s seconds ---" % (time.time() - start_time))
```

**Sample Solution 2: Pure recursion (Exhasutive; ~10mins)**

```python
def decompose(pence, num_types = len(coins)):
    coins = [1,2,5,10,20,50,100,200]
    # If pence = 0 then there is only 1 solution
    if (pence == 0):
        return 1

    # If n is less than 0 then no solution exists
    if (pence < 0):
        return 0;

    # If there are no coins and n is greater than 0, then no solution exist
    if (num_types <=0 and pence >= 1):
        return 0

    # Recursion step
    return decompose( pence, num_types - 1 ) + decompose( pence - coins[num_types-1], num_types);

## TEST CASES ##
import time
start_time = time.time()
print (decompose(1))
print (decompose(5))
print (decompose(7))
print (decompose(130))
print (decompose(200))
print (decompose(700))
print("--- %s seconds ---" % (time.time() - start_time))
```

**Sample Solution 3: Recursion with some memomisation (Exhasutive with some elegance; ~5mins)**

```python
def decompose(pence, coins = [1,2,5,10,20,50,100,200]):

    # If pence = 0 then there is only 1 solution
    if pence == 0:
        return 1

    # If n is less than 0 then no solution exists
    if pence < 0:
        return 0
    num_ways = 0

    # Store previously computed sub-problems in a dictionary to avoid re-computing it
    dic_ways = {}
    for i in range(len(coins)):
        coin = coins[i]
        if pence-coin not in dic_ways:
            # Recursion step
            num_ways += decompose(pence - coin, coins[i:])
            dic_ways[pence-coin] = True
    return num_ways

## TEST CASES ##
import time
start_time = time.time()
print (decompose(1))
print (decompose(5))
print (decompose(7))
print (decompose(130))
print (decompose(200))
print (decompose(700))
print("--- %s seconds ---" % (time.time() - start_time))
```

**Sample solution 4: Expansion of partition equation into series (Elegant but inefficient; ~30s)**

```python
from sympy import *
def decompose(pence):
    x = symbols('x')
    partition_series = series(1/(( 1 - x)*(1-x**2)*(1-x**5)*(1-x**10)
    *(1-x**20)*(1-x**50)*(1-x**100)*(1-x**200)), n = pence+2)
    coef = Poly(partition_series.removeO(),x)
    return coef.all_coeffs()[1]

## TEST CASES ##
import time
start_time = time.time()
print (decompose(1))
print (decompose(5))
print (decompose(7))
print (decompose(130))
print (decompose(200))
print (decompose(700))
print("--- %s seconds ---" % (time.time() - start_time))
```

**Sample solution 5: Smart brute force (Efficent but inelegant; 1.5s)**

```python
def decompose(pence):
    count = 0
    for x1 in range(0,pence+1,200):
        for x2 in range(x1,pence+1,100):
            for x3 in range(x2,pence+1,50):
                for x4 in range(x3,pence+1,20):
                    for x5 in range(x4,pence+1,10):
                        for x6 in range(x5,pence+1,5):
                            for x7 in range(x6,pence+1,2):
                                count+=1
    return count

## TEST CASES ##
import time
start_time = time.time()
print (decompose(1))
print (decompose(5))
print (decompose(7))
print (decompose(130))
print (decompose(200))
print (decompose(700))
print("--- %s seconds ---" % (time.time() - start_time))
```

**Sample solution 6: Top-down Dynamic Programming; Recursion with memoization & decorators (Efficient & quite elegant; ~0.002s)**

```python
def memoize(func):
    cache = dict()
    def memoized_func(*args):
        if args in cache:
            return cache[args]
        result = func(*args)
        cache[args] = result
        return result
    return memoized_func
 ## The above code is a quick and dirty memo fucntion that can be used widely ##
 ## to speed up problems with overlapping sub-problems (avoid recomputation) ##

@memoize
## Pure recursion from sample solution 2 ##
def decompose(pence, num_types = len(coins)):
    coins = [1,2,5,10,20,50,100,200]
    # If pence = 0 then there is only 1 solution
    if (pence == 0):
        return 1

    # If n is less than 0 then no solution exists
    if (pence < 0):
        return 0;

    # If there are no coins and n is greater than 0, then no solution exist
    if (num_types <=0 and pence >= 1):
        return 0

    # Recursion step
    return decompose( pence, num_types - 1 ) + decompose( pence - coins[num_types-1], num_types);

## TEST CASES ##
import time
start_time = time.time()
print (decompose(1))
print (decompose(5))
print (decompose(7))
print (decompose(130))
print (decompose(200))
print (decompose(700))
print("--- %s seconds ---" % (time.time() - start_time))
```

**Sample Solution 7: Bottom-up Dynamic Programming (Most elegant & efficient; ~0.001s)**

```python
def decompose(pence):
   try:
    coins = [1,2,5,10,20,50,100,200]
    num_types = len(coins)
    # table[i] will be storing the number of solutions for
    # value i. We need n+1 rows as the table is constructed
    # in bottom up manner using the base case (pence = 0)
    # We first initialize all table values as 0
    table = [0 for accumulative_num_of_ways in range(pence+1)]

    # If pence = 0 then there is only 1 solution
    table[0] = 1

    # Pick all coins one by one and update the table[] values
    # after the index greater than or equal to the value of the
    # picked coin
    for type_ in range(0,num_types):
        for value in range(coins[type_],pence+1):
            table[value] += table[value-coins[type_]]
    # We only want the number of ways to find change for value = pence
    return table[pence]
   except:
    # Our bottom up approach innately deals with special cases
    # this line catches invalid arguments
    return 0

## TEST CASES ##
import time
start_time = time.time()
print (decompose(1))
print (decompose(5))
print (decompose(7))
print (decompose(130))
print (decompose(200))
print (decompose(700))
print("--- %s seconds ---" % (time.time() - start_time))
```

```python

```

```python

```


```python
from IPython.core.display import HTML
def css_styling():
    styles = open("./styles/custom.css", "r").read()
    return HTML(styles)
css_styling()
```

### BEFORE YOU DO ANYTHING...
In the terminal:
1. Navigate to __inside__ your ILAS_Python repository.
2. __COMMIT__ any un-commited work on your personal computer.
3. __PULL__ any changes *you* have made using another computer.
4. __PULL__ textbook updates (including homework answers).

1. __Open Jupyter notebook:__   Start >> Programs (すべてのプログラム) >> Programming >> Anaconda3 >> JupyterNotebook
1. __Navigate to the ILAS_Python folder__.
1. __Open Seminar 7___  by clicking on 7_Numerical_computation_with_Numpy.

Numerical Computation with Numpy

Lesson Goal

Compose programs to solve simple mathematical problems using the Python Numpy package.

## Objectives
- Represent data using the `array` data structure for numerical computation.
- Use 1D and 2D arrays to represent vectors and matrices.
- Manipulate arrays (indexing, slicing, vectorising etc)
- Perform familiar numerical operations using Python.
- Compare efficiency of vectorised and non-vectorised functions.

## Why are we studying this?
Numerical computation is central to almost all scientific and engineering problems.

There are programming languages specifically designed for numerical computation:
- Fortran
- MATLAB

There are libaries dedicated to efficient numerical computations:
- Numpy
- Scipy
- Sympy ...

NumPy (http://www.numpy.org/)
 - The most widely used Python library for numerical computations.
 - Large, extensive library of data structures and functions for numerical computation.
 - Useful for perfoming operation you will learn on mathematics-based courses.

Scipy (https://www.scipy.org/)
- Builds on Numpy, additional functionality
- More specialised data structures and functions over NumPy.

If you are familiar with MATLAB, NumPy and SciPy provide similar functionality.

Last week we covered an introduction to some basic functions of Numpy.

NumPy is a very extensisve library.

This seminar will:
- Introduce some useful functions
- Briefly discuss how to search for additional functions you may need.

your best resources are search engines, such as http://stackoverflow.com/.

## Importing the NumPy module

To make NumPy functions and variables available to use in our program in our programs, we need to __import__ it using.

`import numpy`

We typically import all modules at the start of a program or notebook.

```python
import numpy as np
```

The shortened name `np` is often used for numpy.

All Numpy functions can be called using `np.function()`.

## Data Structure: The Numpy `array`

### Why do we need another data structure?

Python lists hold 'arrays' of data.

Lists are very flexible. e.g. holding mixed data type.

There is a trade off between flexibility and performance e.g. speed.

Science engineering and mathematics problems often involve large amounts of data and numerous operations.

We therefore use specialised functions and data structures for numerical computation.

## Numpy array

A numpy array is a grid of values, *all of the same type*.

To create an array we use the Numpy `array` function.

It takes a list as an argument.

```python
a = np.array([1, 2, 3])

print(type(a))

print(a.dtype)
```

    <class 'numpy.ndarray'>
    int64

```python
b = [4.0, 5, 6.0]

c = np.array(b)

print(type(b))
print(type(c))
print(c.dtype)
```

    <class 'list'>
    <class 'numpy.ndarray'>
    float64

## Multi-dimensional arrays.

Unlike the data types we have studied so far, arrays can have multiple dimensions.

__`rank`:__ the number of *dimensons* of the array.

__`shape`:__ a *tuple* of *integers* giving the *size* of the array along each *dimension*.

```python
# 1-dimensional array
a = np.array([1, 2, 3])

# 2-dimensional array
b = np.array([[1, 2, 3], [4, 5, 6]])

b = np.array([[1, 2, 3],
              [4, 5, 6]])

print(a.shape)
print(b.shape)
```

    (3,)
    (2, 3)

```python
# 3-dimensional array

c = np.array(
    [[[1, 1],
      [1, 1]],

     [[1, 1],
      [1, 1]]])

print(c.shape)

c = np.array(
    [[[1, 1],
      [1, 1]],

     [[1, 1],
      [1, 1]],

     [[1, 1],
      [1, 1]]])

print(c.shape)
```

    (2, 2, 2)
    (3, 2, 2)

```python
# 3-dimensional array

c = np.array(
    [[[1, 1],
      [1, 1]],

     [[1, 1],
      [1, 1]]])

# 4-dimensional array
d = np.array(
    [[[[1, 1],
       [1, 1]],

      [[1, 1],
       [1, 1]]],

      [[[1, 1],
       [1, 1]],

      [[1, 1],
       [1, 1]]]])

print(c.shape)
print(d.shape)
```

    (2, 2, 2)
    (2, 2, 2, 2)

## Creating a numpy array.

There are several other ways we can create an array

```python
# Create an array of all zeros
# The zeros() function argument is the shape.
# Shape: tuple of integers giving the size along each dimension.

a = np.zeros(5)
print(a)

print()

a = np.zeros((2,2))
print(a)
```

    [ 0.  0.  0.  0.  0.]

    [[ 0.  0.]
     [ 0.  0.]]

```python
# Create an array of all ones

b = np.ones(5)
print(b)

print()

b = np.ones((1, 4))
print(b)
```

```python
# Create a constant array
# The second function argument is the constant value

c = np.full(6, 8)
print(c)

print()

c = np.full((2,2,2), 7)
print(c)

```

    [8 8 8 8 8 8]

    [[[7 7]
      [7 7]]

     [[7 7]
      [7 7]]]

## Subpackages
Packages can also have subpackages.

The `numpy` package has a subpackage called `random`.

It contains functions to deal with random variables.

If the `numpy` package is imported with `import numpy as np`, functions in the `random` subpackage can be called using `np.random.function()`.

```python
# Create an array filled with random values

e = np.random.rand(1)
print(e)
print()

e = np.random.rand(3,2,1)
print(e)
print()

e = np.random.random((2,2))
print(e)
```

    [ 0.1925726]

    [[[ 0.79990402]
      [ 0.63879192]]

     [[ 0.38519791]
      [ 0.0073061 ]]

     [[ 0.9293842 ]
      [ 0.77903214]]]

    [[ 0.09380194  0.32369848]
     [ 0.72059049  0.03877658]]

```python
# Create an array filled with random integrer values
```

```python
e = np.random.randint(16, size=(4,4))
print(e)

print()

e = np.random.randint(8, size=(2, 2, 2))
print(e)
```

## Indexing.

We can index into an array exactly the same way as the other data structures we have studied.

```python

```

```python
x = np.array([1, 2, 3, 4, 5])

# Select a single element
print(x[4])

# Select elements from 2 to the end
print(x[2:])
```

    5
    [3, 4, 5]

For an n-dimensional (nD) matrix we need n index values to address an element or range of elements.

Example: The index of a 2D array is specified with two values:
- first the row index
- then the column index.

Note the order in which dimensions are addressed.

```python
# 2 dimensional array

y = np.array([[1, 2, 3],
              [4, 5, 6]])

# Select a single element
print(y[1,2])

# Select elements that are both in rows 1 to the end AND columns 0 to 2
print(y[1:, 0:2])
```

    6
    [[4 5]]

We can address elements by selecting a range with a step by addig a

For example the index:

`z[0, 0:]`

selects every element of row 0 in array, `z`

The index:

`z[0, 0::2]`

selects every *other* element of row 0 in array, `z`

```python
# 2 dimensional array

z = np.zeros((4,8))

# Change every element of row 0
z[0, 0:] = 10

# Change every other element of row 1
z[1, 0::2] = 10

print(z)
```

    [[ 10.  10.  10.  10.  10.  10.  10.  10.]
     [ 10.   0.  10.   0.  10.   0.  10.   0.]
     [  0.   0.   0.   0.   0.   0.   0.   0.]
     [  0.   0.   0.   0.   0.   0.   0.   0.]]

```python
z = np.zeros((4,8))

# Change the last 4 elements of row 2, in negative direction
# You MUST include a step to count in the negative direction
z[2, -1:-5:-1] = 10

# Change every other element of the last 6 elements of row 3
# in negative direction
z[3, -2:-7:-2] = 10

print(z)
```

    [[  0.   0.   0.   0.   0.   0.   0.   0.]
     [  0.   0.   0.   0.   0.   0.   0.   0.]
     [  0.   0.   0.   0.  10.  10.  10.  10.]
     [  0.   0.  10.   0.  10.   0.  10.   0.]]

```python
# 3-dimensional array

c = np.array(
    [[[2, 1, 4],
      [2, 6, 8]],

     [[0, 1, 5],
      [7, 8, 9]]])

print(c[0, 1, 2])

```

    8

```python

```

```python

```

Where we want to select all elements in one dimension we can use :

__Exception__: If it is the last element , we can omit it.

```python
print(c[0, 1])

print(c[0, :, 1])
```

    [2 6 8]
    [1 6]

## Iterating over multi-dimensional arrays.
We can iterate over a 1D array in the same way as the data structures we have previously studied.

```python
A = np.array([1, 2, 3, 4, 5])
```

```python
for a in A:
    print(a)
```

    1
    2
    3
    4
    5

To loop through individual elements of a multi-dimensional array, we use a nested loop for each dimension of the array.

```python
B = np.array([[1, 2, 3],
              [4, 5, 6]])

for row in B:
    print("-----")
    for col in row:
        print(col)
```

    -----
    1
    2
    3
    -----
    4
    5
    6

## Manipulating arrays
We can use many of the same operations to manipulate arrays as we use for lists.

```python
# Length of an array

a = np.array([1, 3, 4, 17, 3, 21, 2, 12])

b = ([1, 3, 4, 17],
     [3, 21, 2, 12])

print(len(a))
print(len(b))

```

    8
    2

Note the length is the length of the first dimension (e.g. indexing).

```python
# Sort an array

a = np.array([1, 3, 4, 17, 3, 21, 2, 12])

# The method sort() and function sorted() give the same result
a.sort()

a = sorted(a)

print(a)
```

    [1, 2, 3, 3, 4, 12, 17, 21]

Arrays are *immutable* (unchangeable).

Technically you cannot delete an item from it.

However, you can make a new array without the values you don't want:

```python
# Remove items from an array

z = np.array([1, 3, 4, 17, 3, 21, 2, 12])

z = np.delete(z, 3)
print(z)

z = np.delete(z, [0, 1, 2])
print(z)

```

    [ 1  3  4  3 21  2 12]
    [ 3 21  2 12]

```python
# Add items to an array

a = ([1, 2, 3])
a = np.insert(a, 1, 4)
print(a)
```

    [1 4 2 3]

```python
# Add items to an array

b = np.array([[1, 1],
              [2, 2],
              [3, 3]])

b = np.insert(b, 1, 4)
print(b)
```

    [1 4 1 2 2 3 3]

```python
# Add items to an array

b = np.array([[1, 1],
              [2, 2],
              [3, 3]])

b = np.insert(b, 1, 4, axis=1)
print(b)
```

    [[1 4 1]
     [2 4 2]
     [3 4 3]]

```python
# Change items in an array

c = np.array([1, 2, 3])
c[1] = 4
print(c)
```

    [1 4 3]

### Boolean array indexing

Recall that we can use *conditional operators* to check the value of a single variable against a condition.

The value returned is a Boolean True or False value.

```python
a = 4
print('a < 2:', a < 2)
print('a > 2:', a > 2)
```

    a < 2: False
    a > 2: True

If we instead use *conditional operators* to check the value of an array against a condition.

The value returned is an *array* of Boolean True or False values.

```python
a = np.array([[1,2],
              [3, 4],
              [5, 6]])

idx = a > 2

print(idx)
```

    [[False False]
     [ True  True]
     [ True  True]]

A particular elements of an array can be are specified by using a boolean array as an index.

Only the values of the array where the boolean array is `True` are selected.

The varaible `idx` can therefore now be used as the index to select all elements greater than 2.

```python
print(a[idx])
```

    [3 4 5 6]

To do the whole process in a single step

```python
print(a[a > 2])
```

    [3 4 5 6]

Use shape to reshape the matrix?

Another example

```python
a = np.arange(5)
print('the total array:', a)
print('values less than 3:', a[a < 3])
```

    the total array: [0 1 2 3 4]
    values less than 3: [0 1 2]

## Mathematics with arrays.

Unlike lists, NumPy arrays support common arithmetic operations, such as addition of two arrays.

```python
# To add the elements of two lists we need the Numpy function: add
a = [1, 2, 3]
b = [4, 5, 6]

c = a + b
print(c)

c = np.add(a, b)
print(c)
```

    [1, 2, 3, 4, 5, 6]
    [5 7 9]

To add the elements of two arrays we can just use regular arithmetic operators.

```python
a = np.array([1, 2, 3])
b = np.ones((1,3))

c = a + b
print(c)
```

    [[ 2.  3.  4.]]

Algebraic operations are appled *elementwise* to an array.

This means the function is applied individually to each element in the list.

For addition and subtraction arrays behave like vectors and matrices.

For example, if you were to add or subtract two vectors/matrices in MATLAB.

```python
a = np.array([1.0, 0.2, 1.2])
b = np.array([2.0, 0.1, 2.1])

print(a - b)

print(np.subtract(a, b))
```

But it is important to remember that arrays ARE NOT vectors and matrices.

Regular mathematical operators perform *elementwise* operations on arrays.

```python
a = np.array([1.0, 0.2, 1.2])
b = np.array([2.0, 0.1, 2.1])

# Elementwise multiplication of a and b
print(a * b)
print(np.multiply(a, b))

print()

# Elementwise division of a and b
print(a / b)
print(np.divide(a, b))
```

    [ 2.    0.02  2.52]
    [ 2.    0.02  2.52]

    [ 0.5         2.          0.57142857]
    [ 0.5         2.          0.57142857]

If the number of __columns in A__

is the same as number of __rows in B__,

we can find the matrix product of $\mathbf{A}$ and $\mathbf{B}$.

```python
\begin{equation*}
\underbrace{
\begin{bmatrix}
1 & 2 & 3 \\
4 & 5 & 6 \\
7 & 8 & 9 \\
\end{bmatrix}
}_{\mathbf{A} \text{ 3 rows} \text{ 3 columns}}
\times
\underbrace{
\begin{bmatrix}
10 & 11 \\
12 & 13 \\
14 & 15 \\
\end{bmatrix}
}_{\mathbf{B} \text{  3 rows} \text{  2 columns}}
=\underbrace{
\begin{bmatrix}
(1 \cdot 10 + 2 \cdot 12 + 3 \cdot 14) \quad
(1 \cdot 11 + 2 \cdot 13 + 3 \cdot 15) \\
(4 \cdot 10 + 5 \cdot 12 + 6 \cdot 14) \quad
(4 \cdot 11 + 5 \cdot 13 + 6 \cdot 15) \\
(7 \cdot 10 + 8 \cdot 12 + 9 \cdot 14) \quad
(7 \cdot 11 + 8 \cdot 13 + 9 \cdot 15) \\
\end{bmatrix}
}_{\mathbf{C} \text{  3 rows} \text{  2 columns}}
=\underbrace{
\begin{bmatrix}
76  & 82 \\
184 & 199 \\
292 & 316 \\
\end{bmatrix}
}_{\mathbf{C} \text{  3 rows} \text{  2 columns}}
\end{equation*}
```

## Mathematics with Vectors (1D arrays)
Let's look at a  previous example for computing the dot product of two vectors.

The dot product of two $n$-length-vectors:

 $ \mathbf{A} = [A_1, A_2, ... A_n]$

 $ \mathbf{B} = [B_1, B_2, ... B_n]$

\begin{align}
\mathbf{A} \cdot \mathbf{B} = \sum_{i=1}^n A_i B_i.
\end{align}

We learnt to solve this very easily using a Python `for` loop.

With each iteration of the loop we increase the value of `dot_product` (initial value = 0.0) by the product of `a` and `b`.

```python
A = [1.0, 3.0, -5.0]
B = [4.0, -2.0, -1.0]

# Create a variable called dot_product with value, 0.
dot_product = 0.0

for a, b in zip(A, B):
    dot_product += a * b

print(dot_product)
```

Using Numpy arrays we can solve the dot product using the Numpy function `dot`.

```python
A = np.array([1.0, 3.0, -5.0])
B = np.array([4.0, -2.0, -1.0])

print(np.dot(A,B))
```

    3.0

__Try it yourself__

Recap the Seminar 5: Functions; in the cell below write a function that takes two lists and returns the dot product using the code from Seminar 4: Data Structures (shown above).

Use the magic function `%timeit` to compare the speed of the for loop with the Numpy `dot()` function for solving the dot product.

```python
# Write a function for the dot product of two vectors expressed as lists
# Compare the speed of your function to the Numpy function
```

## Mathematics with Matrices (2D arrays)
If you have previously studied matrices, the operations in this section will be familiar.

If you have not yet studied matrices, you may want to refer back to this section once matrices have been covered in your mathematics classes.

Material from this section will not be included in the exam.

2D arrays are a convenient way to represents matrices.

For example, to create the matrix

$$
A =
\begin{bmatrix}
3 & 5 & 7\\
2 & 4 & 6
\end{bmatrix}
$$

```python
A = np.array([[3, 5, 7],
              [2, 4, 6]])
print(A)
```

    [[3 5 7]
     [2 4 6]]

Recall, the method `shape()` tells us the dimensions of an array.

This gives us the number of rows and the number of columns, expressed as a tuple.

By describe the dimensions of a matrix as as "rows" by "columns" matrix.

```python
print(A.shape)
print(f"Number of rows is {A.shape[0]}, number of columns is {A.shape[1]}")
print(f"A is an {A.shape[0]} by {A.shape[1]} matrix")
```

    (2, 3)
    Number of rows is 2, number of columns is 3

### Matrix multiplication

If the number of __columns in A__

is the same as number of __rows in B__,

we can find the matrix product of $\mathbf{A}$ and $\mathbf{B}$.

\begin{align}
\mathbf{A} \mathbf{B} = \mathbf{C}
\end{align}

We multiply each __row__ in \mathbf{A} by each __column__ in \mathbf{B}

\begin{equation*}
\underbrace{
\begin{bmatrix}
a_{11} & a_{12} & a_{13} \\
a_{21} & a_{22} & a_{23} \\
a_{31} & a_{32} & a_{33} \\
\end{bmatrix}
}_{\mathbf{A} \text{ 3 rows} \text{ 3 columns}}
\times
\underbrace{
\begin{bmatrix}
b_{11} \\
b_{21} \\
b_{31} \\
\end{bmatrix}
}_{\mathbf{B} \text{  3 rows} \text{  1 column}}
=\underbrace{
\begin{bmatrix}
a_{11}b_{11} + a_{12}b_{21} + a_{13}b_{31} \\
a_{21}b_{11} + a_{22}b_{21} + a_{23}b_{31} \\
a_{31}b_{11} + a_{32}b_{21} + a_{33}b_{31} \\
\end{bmatrix}
}_{\mathbf{C} \text{  3 rows} \text{  1 column}}
\end{equation*}

In matrix $\mathbf{C}$, the element in

__row $i$__,

__column $j$__

is equal to the dot product of

__$i$th row__ of $\mathbf{A}$,

__$j$th column__ of $\mathbf{B}$.

Matrix $\mathbf{C}$ therefore has

the same number of __rows as A__,

the same number of __columns as B__.

#### Example 1: THIS EXAMPLE IS WRONG MAKE A GOOD ONE!!!

#### Example 2:
\begin{equation*}
\underbrace{
\begin{bmatrix}
1 & 2 & 3 \\
4 & 5 & 6 \\
7 & 8 & 9 \\
\end{bmatrix}
}_{\mathbf{A} \text{ 3 rows} \text{ 3 columns}}
\times
\underbrace{
\begin{bmatrix}
10 & 11 \\
12 & 13 \\
14 & 15 \\
\end{bmatrix}
}_{\mathbf{B} \text{  3 rows} \text{  2 columns}}
=\underbrace{
\begin{bmatrix}
(1 \cdot 10 + 2 \cdot 12 + 3 \cdot 14) \quad
(1 \cdot 11 + 2 \cdot 13 + 3 \cdot 15) \\
(4 \cdot 10 + 5 \cdot 12 + 6 \cdot 14) \quad
(4 \cdot 11 + 5 \cdot 13 + 6 \cdot 15) \\
(7 \cdot 10 + 8 \cdot 12 + 9 \cdot 14) \quad
(7 \cdot 11 + 8 \cdot 13 + 9 \cdot 15) \\
\end{bmatrix}
}_{\mathbf{C} \text{  3 rows} \text{  2 columns}}
=\underbrace{
\begin{bmatrix}
76  & 82 \\
184 & 199 \\
292 & 316 \\
\end{bmatrix}
}_{\mathbf{C} \text{  3 rows} \text{  2 columns}}
\end{equation*}

```python
#Example 1
A = np.array([[1, 1, 2],
              [2, 1, 3],
              [1, 4, 2]])

B = np.array([[3],
              [1],
              [2]])

C = A.dot(B)
print(C)

print()

C = np.dot(A,B)
print(C)
```

    [[ 8]
     [13]
     [11]]

    [[ 8]
     [13]
     [11]]

```python
#Example 2
A = np.array([[1, 2, 3],
              [4, 5, 6],
              [7, 8, 9]])

B = np.array([[10, 11],
              [12, 13],
              [14, 15]])

C = A.dot(B)
print(C)

print()

C = np.dot(A,B)
print(C)
```

    [[ 76  82]
     [184 199]
     [292 316]]

    [[ 76  82]
     [184 199]
     [292 316]]

We can find the inverse $\mathbf{A}^{-1}$, of a sqaure matrix, $\mathbf{A}$.

```python
A = np.array([[1,2],
              [3, 4]])

Ainv = np.linalg.inv(A)

print(f"A = \n {A}")
print(f"Inverse of A = \n {Ainv}")
```

    A =
     [[1 2]
     [3 4]]
    Inverse of A =
     [[-2.   1. ]
     [ 1.5 -0.5]]

We can find the determinant, $\textrm{det}(\mathbf{A})$, of a sqaure matrix, $\mathbf{A}$.

```python
A = np.array([[1,2],
              [3, 4]])

Adet = np.linalg.det(A)

print(f"A = \n {A}")
print(f"Determinant of A = {round(Adet, 2)}")
```

    A =
     [[1 2]
     [3 4]]
    Determinant of A = -2.0

We can generate an *identity matrix*.

```python
I = np.eye(2)
print(I)

print()

I = np.eye(4)
print(I)
```

    [[ 1.  0.]
     [ 0.  1.]]

    [[ 1.  0.  0.  0.]
     [ 0.  1.  0.  0.]
     [ 0.  0.  1.  0.]
     [ 0.  0.  0.  1.]]

```python
print(np.sqrt(a))
print(a ** (1/2))

```

## Vectorising Functions

Numpy functions applied to a single array, will be performed on each element in the array.

The function takes an array of values as an input argument.

```python
print(np.sqrt(a))
print(a ** (1/2))

```

For example, we can apply trigonometric functions, elementwise, to arrays, lists and tuples.

```python
x = np.array([0.0, np.pi/2, np.pi, 3*np.pi/2])
y = [0.0, np.pi/2, np.pi, 3*np.pi/2]
z = (0.0, np.pi/2, np.pi, 3*np.pi/2)

print(np.sin(x))
print(np.cos(y))
print(np.tan(z))

```

    [  0.00000000e+00   1.00000000e+00   1.22464680e-16  -1.00000000e+00]
    [  1.00000000e+00   6.12323400e-17  -1.00000000e+00  -1.83697020e-16]
    [  0.00000000e+00   1.63312394e+16  -1.22464680e-16   5.44374645e+15]

An array of values does not work as an input for all functions.

```python
def func(x):
    if x < 0:
        f = 2 * x
    else:
        f = 3 * x
    return f

x = np.array([2,2])
y = func(x) # Run this line after removing the # to se
```

This doesn't work because Python doesn't know what to do with the line

`if x < 0`

when `x` contains many values.

For some values of `x` the `if` statement may be `True`, for others it may be `False`.

A simple way around this problem is to vectorise the function.

We create a new function that is a *vectorized* form of the original function.

The new function and can be called with an array as an argument.

```python
funcvec = np.vectorize(func)

x = np.random.randint(4,size =(2,2))

y = funcvec(x)
print(x)
print(y)
```

    [[3 0]
     [2 3]]
    [[9 0]
     [6 9]]

## Broadcasting

```python
a = np.array([1, 2, 3, 4])
b = np.ones((3,1))

c = a + b

print(b)
print()
print(c)
```

    [[ 1.]
     [ 1.]
     [ 1.]]

    [[ 2.  3.  4.  5.]
     [ 2.  3.  4.  5.]
     [ 2.  3.  4.  5.]]

## Review Exercises

The folowing exercises are provided to practise what you have learnt in today's seminar.

There are some extension excercises for you to complete if you are familiar with using matrices and want to practise matrix manipulation using Python.

If you have not yet studeied matrices, you can come back to this section when the mathematics used is more familiar to you.

### Review Exercise: Arrays and indices

In the cell below:

1. Create an array of zeros with length 25.

2. Change the first 10 values to 5.

3. Change the next 10 values to a sequence starting at 12 and increasig with steps of 2 to 30 - do this with one command.

4. Set the final 5 values to 30.

```python

```

### Review Exercise:  Two-dimensional array indices

In the cell below, for the array `x`, write code to print:

* the first row of `x`
* the first column of `x`
* the third row of `x`
* the last two columns of `x`
* the four values in the upper right hand corner of `x`
* the four values at the center of `x`

```python
x = np.array([[4, 2, 3, 2],
              [2, 4, 3, 1],
              [2, 4, 1, 3],
              [4, 1, 2, 3]])
```

### Review Exercise:  Fix the error
The code below, is supposed to:
- change the last 5 values of the array `x` to the values [50, 52, 54, 56, 58]
- print the result

There are some errors in the code.

Remove the comment markers and run the code to see the error message.

Then fix the code and run it again.

```python

```