# Quantum Game Theory

__Notes:
(1) The answers to the exercises in this notebook do not show up when looking at this notebook on GitHub. In order to see them, please open up this notebook using Jupyter.
(2) Before running the notebook in Jupyter, please clone the github repo and run pip install -e .__

```python
# Run this cell once to load all the relevant packages and settings
%load_ext autoreload
%autoreload 2

from qiskit import QuantumCircuit
from qiskit.visualization import plot_histogram
import matplotlib.pyplot as plt
from qiskit.quantum_info import Operator
from qiskit.extensions import XGate, HGate
from backend.logic import Game
import pandas as pd
import numpy as np
```

## What you will learn in this tutorial
* __Game Theory__ - What a "game theory" game is.
* __Quantum Game Theory__ - What a "quantum game theory" game is and how you can study these types of games using Qiskit.
* __Classical vs Quantum Games__ - How the way we think about strategies change when you go from the classical to the quantum regime.

## Game Theory
Game Theory is defined as "the study of mathematical models of strategic interaction among rational decision-makers" [1], which is a very formal way of saying "it's the study of how games are played!". Although modern game theory was in fact first constructed using a [formal mathematical framework](https://link.springer.com/article/10.1007/BF01448847), we believe that the best way to get introduced to game theory is to start playing a game, so let's dive straight in!

### The Prisoner's Dilemma
Probably the most famous game theoretical game is the [Prisoner's Dilemma](https://en.wikipedia.org/wiki/Prisoner%27s_dilemma). There are many variants to this game each with their own story, so here's our story for the game:

Both you and I team up to break into a zoo and steal a panda (just for a little bit, we are of course going to give it back!). Unfortunately we both get caught, but not quite red handed, so it isn't such an open-and-shut case for the police. In order to try and get a confession out of both of us, the police separate us into different rooms and give each of us two options:
* To remain silent and take our chances - we shall call this strategy '0'
* To confess and maybe receive a reduced sentence - we shall call this strategy '1'

So together, there are 4 possible outcomes that could happen: '00' (where we both remain silent), '01' (where you remain silent and I confess), '10' (where you confess and I remain silent) and '11' (where we both confess).
We are told that if we both remain silent (i.e. end up with the '00' outcome), the police still have enough evidence sentence us both to 1 month in prison. However, if one of us confesses (i.e. the outcome is either '01' or '10') the player who chose the '1' strategy goes free and the other player (who chose the strategy '0') receives a 3 month prison sentence. Finally, if we both confess, (i.e. the outcome is '11') then we each get a 2 month prison sentence. These strategies are represented below in a payoff table.

Throughout this notebook, we will be using the 'QuantumGameTheory' package. So without further ado, let us create the prisoner's dilemma game object by simply calling it:

```python
prisoner_game = Game(game_name='prisoner', num_players=2, protocol="Classical")
```

The 'game_name' indicates which game to load and for now we will be using the 'Classical' protocol. Now before we play the game, let's have a look at the possible payoffs:

```python
prisoner_game.display_payoffs()
```

This shows us the outcomes represented in bits and the payoff for each outcome (this table is the same as the above payoff table). Notice that the payoff representing a 3 month prison sentence is -3 and not 3. This is because players are always trying to maximize their payoff, so the more months in prison will give you more negative results.

Now let's play! For this game there are two players, P1 and P2; you and me.  To play the game, we can simply input our chosen strategy (0 or 1) into P1_strategy and P2_strategy to see who the winner is.

```python
P1_strategy = 1# pick your strategy
P2_strategy = 1 # I pick strategy '1'
combined_strategies = [P1_strategy, P2_strategy]
outcomes, results = prisoner_game.play_game(combined_strategies, n_times=1)
prisoner_game.show_results()
```

__Exercise 1__: What strategy would you choose if you didn't know what strategy I was going to choose? (This can be a bit tricky but it is worth taking the time to think about it before looking at the answer!)

Click here for the answer to Exercise 1
No matter what strategy I choose, the optimal strategy for you is 1 (i.e. to confess). To understand this, let's look at the options starting with my strategies:

First option: I choose strategy 0. If you also choose 0, your payoff is -1 whereas if you choose 1, your payoff is 0. Therefore it is better for you to choose 1 in this case.

Second option: I choose strategy 1. If you choose 0, your payoff is -3 whereas if you also choose 1, your payoff is -2. Therefore it is also better for you to choose 1 in this case.

So it is always better for you to choose 1. By symmetry, this logic works for both of us. Therefore, rationally speaking, we should both always choose option 1. This leads to the concept of the 'dominant strategy' - the strategy that we will choose regardless of what the other person chooses (in this case 1 for the both of us). We say that this game has a 'dominant strategy equilibrium state' with the outcome 11 and payoffs [-2, -2].

### Nash Equilibria

In this section, we investigate the most popular type of equilibrium that games can have - the Nash equilibrium. This concept was discovered by the famous mathematician John Nash (portrayed by Russel Crowe in the [Beautiful Mind](https://en.wikipedia.org/wiki/A_Beautiful_Mind_(film)) \). As a complete aside, if you haven't yet seen the [incredible recommendation letter](https://www.reddit.com/r/pics/comments/38nmzm/princeton_university_just_posted_john_nashs/) he received when applying for the Princeton PhD program, I would highly recommend reading it (it's only a few lines)!

Now back to game theory. The best way to understand Nash equilibria is to see them using a game. In particular, we will look at a game called "Bach or Stravinsky" [2]. The game is as follows: Two people (Alya and Bai) want to go out to a concert. Alya prefers Bach and Bai, Stravinksy. However, they both would prefer to go together rather than to either concert alone. Each of them individually has the option of going to the Bach concert - strategy '0' - or going to the Stravinsky concert - strategy '1'. To see the outcomes and the payoffs, let's look at the payoff table.

```python
BoS_game = Game(game_name='BoS', num_players=2, protocol="Classical")
BoS_game.display_payoffs()
```

To start with, we see that there is no dominant strategy equilibrium - neither player has a strategy that will give them the best outcome regardless of the other player's strategy. On the other hand, we know that there are some strategies that are better than others _if a player knows what the other player is going to do_. In this example, say Bai knew that Alya was going to the Bach concert (strategy 0), then he will also choose to go to the Bach concert (because he would rather get a payoff of 2 than 0).

The question now becomes: are there pairs of strategies for which no player would change if the other one was fixed? I.e. is there a combined strategy 'a' and 'b' such that:
1. If Bai knows that Alya chose strategy 'a', then he will choose strategy 'b' _and_
2. If Alya knows that Bai chose strategy 'b', then she will choose strategy 'a'?

If this is the case, we say that Alya and Bai are in a Nash equilibrium.

__Exercise 2__: What are the Nash equilibria in the Bach or Stravinsky game?

Click here for the answer to Exercise 2
The two strategies that Alya and Bai can choose that lead to Nash equilibria are '00' or '11'. Let's look at the first one from each of the players' points of view:

 If Alya knew that Bai was going to pick 0 (i.e. go to the Bach concert), then she would also pick 0 to maximize her payoff.

 If Bai knew that Alya was going to pick 0, then he would also pick 0 to maximize his payoff.

Therefore, neither player will deviate from their strategies. The same applies for the '11' strategy

__Exercise 3__: Are dominant strategy equilibria Nash equilibria? What about vice-versa? (If you're not sure what dominant strategy equilibria are, have a look at the answer to Exercise 1)

Click here for the answer to Exercise 3
    Dominant strategy equilibria are also always Nash equilibria - if the players each choose the dominant strategy, they also will not change their strategies if they know what the other player is going to do, which by definition puts them in a Nash equilibrium. On the other hand, not all Nash equilibria are dominant strategy equilibria as can be seen using the Bach or Stravinsky game above.

### General 2x2 Games

The prisoner's dilemma and Bach or Stravinksy game are known as a "2x2 games" - games in which there are 2 players and they each have the option between 2 strategies. So immediately we can see how to make any 2x2 game in principle. Just fill out the payoff table below and you have a game you can then play by filling in the P1_choice and P2_choice.

```python
my_game_name = '' # fill in game name
my_payoff_table = {'00': [#, #],
                   '01': [#, #],
                   '10': [#, #],
                   '11': [#, #]} # fill in payoffs
my_game = Game(my_game_name, protocol="Classical", payoff_table=my_payoff_table)
my_game.display_payoffs()
```

```python
P1_choice = # fill in here
P2_choice = # fill in here
choices = [P1_choice, P2_choice]
outcomes, results = my_game.play_game(choices, n_times=1)
my_game.show_results()
```

Here ends our basic introduction to Game Theory. There are [many](https://www.youtube.com/watch?v=3Y1WpytiHKE), [many](http://web.mit.edu/rgibbons/www/Gibbons_An%20Introduction%20to%20Applicable%20Game%20Theory.pdf) [good](http://www.rasmusen.org/GI/download.htm) [resources](https://econweb.ucsd.edu/~jsobel/200Cs09/Kartik_Notes.pdf) out there to learn more about classical game theory. As far as this tutorial is concerned, we will end our discussion on classical game theory here. Now let's go quantum!

## Quantum Game Theory

Quantum Game Theory is a field that was first introduced by D. Meyer [3] in 1999 but it wasn't until later that year that a formal quantum game theory protocol was invented by Eisert, Wilkens & Lewenstein [4] and hence is called the "EWL quantization protocol". Since then there have been many contributions to the field, for which we refer the reader to a [comprehensive review of the field by Khan et al.](https://link.springer.com/article/10.1007/s11128-018-2082-8) But for now, let us start by looking at the EWL quantization protocol.

### The EWL Quantization Protocol

Just as reminder, here is the protocol for a classical 2x2 game:
1. Initialization - we start with two '0' bits
2. Strategy - each player receives a bit and decides to either keep it as a 0 or flip it to a 1
3. Outcome - the outcome is the combination of the two bits
4. Payoff - the payoff table is used to find the payoff for each player given the outcome

Notice that for all 2x2 games, the choice of strategies and the final payoffs are the only things that depend on the game - the the steps to take and the order to take them remain the same.

When dealing with _quantum_ game theory, we require the EWL quantum protocol to follow two rules: __Rule 1:__ It has to distribute maximally entangled qubits to the players and __Rule 2:__ it has to generalize the classical version of the game. We will first explain the recipe to turn a classical game theory game into a quantum game and then walk through how to implement it in Qiskit. Let's look at how to turn a 2x2 classical game into a quantum game.

__1. Initialization__ - We start with two $|0\rangle$ qubits and two classical bits that we will use to store measurement results. Ignoring the classical bits for now, this gives us $$|00\rangle $$
__2. Entanglement__ - We want the qubits to be maximally entangled to each other by an operator before we hand them off to the players. For now we will call this entangling operator $J$ (and explain it further later on). This gives us the state $$J|00\rangle $$
__3. Strategy__ - Each player is then given a qubit and they each apply a single-qubit unitary operator (or 'strategy') of their choice to their qubit (as opposed to the classical version where they can only choose between keeping it or flipping it). Let's call player 1's strategy $U_1$ and player 2's strategy $U_2$. We then end up with $$U_1U_2J|00\rangle $$
__4. 'Disentanglement'__- The qubits are then put through a 'disentangling' gate $J^{\dagger}$ (we will get to why later on). We now have $$J^{\dagger}U_1U_2J|00\rangle $$
__5. Measurement__ - The qubits are measured in the computational basis and the measurements are stored in the two classical bits

__6. Outcome__ - The outcome is the combination of the two (classical) bits

__7. Payoff__ - The payoff table is used to find the payoff for each player given the outcome

Similar to the 2x2 classical games, only the strategies taken and payoff tables differ depending on the specific game - the steps to be taken and the order that they are taken in remain same independent of the specific 2x2 game.

#### The Entangling Operator $J$

The entangling and 'disentangling' operators ($J$ and $J^{\dagger}$) are introduced to satisfy the two rules mentioned above. Here is a more detailed description of the rules:

__Rule 1__ : We need to pick an operator that leads to a maximally entangled state before distributing the qubits to each individual player

__Rule 2__ : When playing a quantum game, if both players' strategies are just combinations of either 'keeping it the same' i.e $I$, or 'flipping it' i.e. $X$, then the outcome of the game should be the same as the classical version of the game. This ensures that the classical game is contained within the quantum game.

This leaves us with many possibilities for the $J$ operator. We use the operator chosen by Benjamin & Hayden [5]: $$J = \frac{1}{\sqrt{2}} (I^{\otimes 2} + iX^{\otimes 2})$$ where $I$ is the identity operator and $X$ is the Pauli-X gate. The reason for going with Benjamin & Hayden's choice of the $J$ operator is that it is equivalent to the original one by Eisert, Wilkens and Lewenstein (they both satisfy __Rule 1__ and __Rule 2__) but is also more intuitive and easier to implement.

Applying this operator to the state $|00\rangle$ results in the state $$\frac{1}{\sqrt{2}}\left(|00\rangle + i|11\rangle\right)$$ which is a maximally entangled 2 qubit state.

__Exercise 4__: Show that adding the $J^{\dagger}$ operator at step 4 satisfies __Rule 2__. Hint: start by showing that  $J^{\dagger}$ commutes with $I^{\otimes 2}$ and $X{^\otimes 2}$.

Click here for the answer to Exercise 4
First we show the commutation relations in the hint.

$\begin{align}
    [J^{\dagger}, I^{\otimes 2}] &= J^{\dagger}I^{\otimes 2} - I^{\otimes 2}J^{\dagger} \\
    &= J^{\dagger} - J^{\dagger} \\
    &= 0 \\
    [J^{\dagger}, X^{\otimes 2}] &= J^{\dagger}X^{\otimes 2} - X^{\otimes 2}J^{\dagger} \\
    &= \frac{1}{\sqrt{2}} (I - iX^{\otimes 2})X^{\otimes 2} - X^{\otimes 2}\frac{1}{\sqrt{2}} (I^{\otimes 2} - iX^{\otimes 2}) \\
    &= \frac{1}{\sqrt{2}} (X^{\otimes 2} - iI^{\otimes 2}) - \frac{1}{\sqrt{2}} (X^{\otimes 2} - iI^{\otimes 2}) \\
    &= 0
\end{align}$

And since $X$ and $I$ commute, this means that $J^{\dagger}$ commutes with any combination of $I$ and $X$ (i.e. $I^{\otimes 2}$, $IX$, $XI$ and $X^{\otimes 2}$).
Now if we look back at step 4 of the protocol $$J^{\dagger}U_1U_2J|00\rangle $$ and impose that $U_1$ and $U_2$ are a combination of $I$ and $X$ gates, we can then move move then interchange the $J^{\dagger}$ operator with the $U_1$ and $U_2$ operators (since they all commute) which gives us $$U_1U_2J^{\dagger}J|00\rangle = U_1U_2|00\rangle.$$ This results in the final state being qubits that have either been 'kept the same' or 'flipped' without any entanglement or superposition states, which is exactly the same as what would happen in the classical version. This is why step for is called 'disentanglement' in quotation marks - the $J^{\dagger}$ gate disentangles the qubits in the case when only the $I$ or $X$ operators are used as strategies.

### Building a 2x2 Quantum Game

It's finally Qiskit time! In this section, we will be implementing steps 1 to 5 (above). The code has been written in a way that each cell corresponding to the individual steps also includes all the code to of the previous steps such that it can be run on its own.

#### 1. Initialization
Initialize a circuit with two '0' qubits and 2 classical bits that we will use to store measurement results. This gives a starting state $|00\rangle$.

```python
circ = QuantumCircuit(2,2)
circ.draw(output='mpl')
```

#### 2. Entanglement
We maximally entangle the qubits by inserting the $J$ operator (defined as $J = \frac{1}{\sqrt{2}} (I^{\otimes 2} + iX^{\otimes 2})$) which results in the state $J|00\rangle = \frac{1}{\sqrt{2}}\left(|00\rangle + i|11\rangle\right)$.

```python
I_2 = np.identity(4)

X_2 = np.matrix([[0,0,0,1],
                   [0,0,1,0],
                   [0,1,0,0],
                   [1,0,0,0]])
J_2 = Operator(1 / np.sqrt(2) * (I_2 + 1j *X_2))

circ = QuantumCircuit(2,2)
circ.append(J_2, [0,1])
circ.draw(output='mpl')
```

__Exercise 5__: Calculate the 4x4 matrix for the $J$ operator and show that $J$ is a unitary operator

Click here for the answer to Exercise 5
Computing $J$:
$$I^{\otimes 2} =  \left(\begin{array}{cccc} 1&0&0&0\\
                          0&1&0&0\\
                          0&0&1&0\\
                          0&0&0&1\end{array}\right)$$
$$iX^{\otimes 2} =  \left(\begin{array}{cccc} 0&0&0&i\\
                          0&0&i&0\\
                          0&i&0&0\\
                          i&0&0&0\end{array}\right)$$
Now substituting these in to the equation for the J operator:
$$J = \frac{1}{\sqrt{2}} (I^{\otimes 2} + iX^{\otimes 2}) = \frac{1}{\sqrt{2}}\left(\begin{array}{cccc} 1&0&0&i\\
                                                                                0&1&i&0\\
                                                                                0&i&1&0\\
                                                                                i&0&0&1\end{array}\right)$$

Showing J is unitary:

$\begin{align}J^{\dagger}J &= \frac{1}{\sqrt{2}} (I^{\otimes 2} - iX^{\otimes 2}) \frac{1}{\sqrt{2}} (I^{\otimes 2} + iX^{\otimes 2}) \\
&= \frac{1}{2}(I^{\otimes 2} - iX^{\otimes 2} + iX^{\otimes 2} + I^{\otimes 2}) \\
&= I
\end{align}
$

#### 3. Strategy
Each person can perform a unitary operator on their individual qubit, e.g. player 1 chooses the $X$ and player 2 chooses the $S$ gate (the $S$ gate changes $|1\rangle \rightarrow i|1\rangle$ and does not do anything to $|0\rangle$).

```python
circ = QuantumCircuit(2,2)
circ.append(J_2, [0,1])
circ.x(0) # assigns the X gate to player 1
circ.s(1) # assigns the S gate to player 2
circ.draw(output='mpl')
```

#### 4. 'Disentanglement'
We now apply the $J^{\dagger}$ gate to satisfy __Rule 2__.

```python
J_2_dg = Operator(1 / np.sqrt(2) * (I_2 - 1j *X_2))

circ = QuantumCircuit(2,2)
circ.append(J_2, [0,1])
circ.x(0) # assigns the X gate to player 1
circ.s(1) # assigns the S gate to player 2
circ.append(J_2_dg, [0,1])
circ.draw(output='mpl')
```

__Exercise 6__: What is the state of the qubits at this stage of the circuit?

Click here for the answer to Exercise 6
$\begin{align}
J^{\dagger}J &= J^{\dagger}X_1S_2J|00\rangle \\
&= J^{\dagger}X_1S_2\frac{1}{\sqrt{2}}\left(|00\rangle + i|11\rangle\right) \\
&= J^{\dagger}\frac{1}{\sqrt{2}}\left(|10\rangle - |01\rangle\right) \\
&= \frac{1}{\sqrt{2}} (I^{\otimes 2} - iX^{\otimes 2})\frac{1}{\sqrt{2}}\left(|10\rangle - |01\rangle\right) \\
&= \frac{1}{2}\left(|10\rangle - |01\rangle - i|01\rangle + i|10\rangle\right) \\
&= \frac{1+i}{2}(|10\rangle -|01\rangle)
\end{align}$

So even though player 1 used the $X$ operator to flip their bit, they finally ended in a state that could lead to their final outcome being 0 or 1 with a 50% probability!

#### 5. 'Measurement'
And we then measure both qubits in the computational basis, saving the answers into the classical registers.

```python
circ = QuantumCircuit(2,2)
circ.append(J_2, [0,1])
circ.x(0) # assigns the X gate to player 1
circ.s(1) # assigns the S gate to player 2
circ.append(J_2_dg, [0,1])
circ.measure([0,1], [0,1])
circ.draw(output='mpl')
```

Since the last two steps (outcome and payoff) do not involve any quantum processes we are done with building the quantum circuit for a 2x2 quantum game!

### Quantum Prisoner's Dilemma

Now that we have gone through how to write the circuit ourselves, we can use the inbuilt QuantumGameTheory package from now on to simplify the coding going forward, which basically implements the above for you. Let's spin up a quantum version of prisoner's dilemma.

```python
prisoner_game_quantum = Game(game_name='prisoner', num_players=2, protocol="EWL")
prisoner_game_quantum.display_payoffs()
```

Notice that the payoff table is identical to the classical version (as expected given the protocol described above). Now let us run the same strategies as above in this game.

```python
P1_strategy = 'X'
P2_strategy = 'S'
combined_strategies = [P1_strategy, P2_strategy]
outcomes, results = prisoner_game_quantum.play_game(combined_strategies, n_times=1)
prisoner_game_quantum._quantum_game.circ.draw(output='mpl')
```

```python
prisoner_game_quantum.show_results()
```

This gives us the outcome of a single game. Let us play the game many times over (say 100) and see the results.

```python
outcomes_sim, results_sim = prisoner_game_quantum.play_game(combined_strategies, n_times = 100)
prisoner_game_quantum.show_results()
```

We can now easily visualise the results in the histogram below.

```python
plot_histogram(outcomes_sim)
```

As you can see, we have about an even split between 01 and 10. Does the outcome make sense? At this point if you haven't done Exercise 6 yet, have a look at the solution to see why the outcome is like this and why, even though player one chose $X$ as their strategy, they still end up with an outcome of '0' half the time!

### Playing on an actual quantum computer

__If you haven't setup your IBMQ account, please click [here](https://github.com/Qiskit/qiskit-iqx-tutorials/blob/master/qiskit/fundamentals/1_getting_started_with_qiskit.ipynb) and go to the section titled 'Running circuits from the IBM Q account'.__

So far we have been running everything on the QASM simulator (under the hood). Let's rerun the same game (quantum prisoner's dilemma) with the same strategies (player 1 choose $X$ and player 2 chooses $S$) on a real quantum computer this time! We can do this by simply adding the group and backend of the machine we desire when spinning up the game (this might take a while to run depending on the queue of the machine you select).

```python
prisoner_game_quantum = Game(game_name='prisoner', num_players = 2, protocol="EWL", group='open', backend="real_machine")
P1_strategy = 'X'
P2_strategy = 'S'
combined_strategies = [P1_strategy, P2_strategy]
outcomes_q, results_q = prisoner_game_quantum.play_game(combined_strategies, n_times = 100)
prisoner_game_quantum.show_results()
```

And let's plot the two results (from the QASM simulator and from the actual machine) side-by-side

```python
plot_histogram([outcomes_sim,outcomes_q], legend=['simulator', 'real_machine'])
```

We see that the outcomes '00' and '11' sometimes appear due to the noise of the real machine. Playing around with different systems and/or strategies, one can get a feel for how different machines perform due to their different types and levels of noise.

## Classical vs Quantum Games

So by now you hopefully have a good notion of basic 2x2 classical and quantum games. But what is the real difference between these games? More specifically, if I play the classical game but allow my choices to be randomized, can I reproduce the quantum game? To answer this question, we have to introduce the notion of mixed (i.e. probabilistic) strategies.

### Mixed Strategies
A mixed strategy is just a strategy which includes classical stochasticity in it, for example "I choose '0' with 50% probability and '1' with 50% probability". This works for quantum strategies too e.g. "I choose 'X' with 33% probability, 'H' with 33% probability and 'S' with 33% probability."  Already we can start to see that classical games (even when allowing for stochasticity) cannot reproduce quantum games (how do we model the effect of stochastic unitary gates with only classical probabilities?). Let's see it in a concrete example: back to our old friend the Prisoner's Dilemma.

### Prisoner's Dilemma Revisited
Let's think of the prisoner's dilemma again but this time using mixed strategies. Let's start by spinning up another prisoner's dilemma game to look at the payoff table.

```python
prisoner_game = Game('prisoner', num_players = 2, protocol="Classical")
prisoner_game.display_payoffs()
```

You may remember from Exercise 1 that there is a dominant strategy for both players in this game. So mixed strategies are not useful in this case. So how about for the quantum game? Let's just consider pure strategies (i.e. strategies that are not classically stochastic). Is there a dominant strategy there? Naively one could guess that the $X$ strategy is the dominant one, as it corresponds to the classical dominant strategy of choosing '1'. However if a player - say P1 - chooses $X$, then P2 can choose $Z$ which will cause the result to go to their favor 100% of the time:

```python
prisoner_game.set_protocol("EWL", backend = 'simulator') # we can simply change the protocol of an exisiting game instead of having to spin up a new game every time
P1_strategy = 'X'
P2_strategy = 'Z'
combined_strategies = [P1_strategy, P2_strategy]
outcomes_q, results_q = prisoner_game.play_game(combined_strategies, n_times = 100)
prisoner_game.show_results()
```

So therefore a quantum version of the game is a very different playing field to the original classical game!

### Nash equilibria in Quantum Games
It turns out that for any 2x2 quantum game in which players only use pure strategies, there is no Nash equilibrium since every strategy always has at least one perfect counter-strategy [6].

__Exercise 7__: We just saw that, for the prisoner's dilemma, a perfect counter-strategy to $X$ is $Z$. What is a perfect counter-strategy to $Z$?

Click here for the answer to Exercise 7
If we say that P1 uses $Z$ and P2 is trying to find the perfect counter-strategy $U_c$, we define without loss of generality

$\begin{align}
U_c =  \left(\begin{array}{cc} \cos\left(\frac{\theta}{2}\right)&-e^{i\lambda}\sin\left(\frac{\theta}{2}\right)\\
                          e^{i\phi}\sin\left(\frac{\theta}{2}\right)&e^{i\phi+i\lambda}\cos\left(\frac{\theta}{2}\right)\end{array}\right)
\end{align}$

So now we have

$\begin{align}
J^{\dagger}J &= J^{\dagger}Z_1U_cJ|00\rangle \\
&= J^{\dagger}Z_1U_c\frac{1}{\sqrt{2}}\left(|00\rangle + i|11\rangle\right) \\
&= J^{\dagger}U_c\frac{1}{\sqrt{2}}\left(|00\rangle - i|11\rangle\right) \\
&= J^{\dagger}\frac{1}{\sqrt{2}}\left[|0\rangle\left(\cos\left(\frac{\theta}{2}\right)|0\rangle
                                                +e^{i\phi}\sin\left(\frac{\theta}{2}\right)|1\rangle\right)
                                       - i|1\rangle\left(-e^{i\lambda}\sin\left(\frac{\theta}{2}\right)|0\rangle+
                                                    e^{i\phi+i\lambda}\cos\left(\frac{\theta}{2}\right)|1\rangle\right)\right] \\
&= J^{\dagger}\frac{1}{\sqrt{2}}\left(\cos\left(\frac{\theta}{2}\right)|00\rangle
                                      +e^{i\phi}\sin\left(\frac{\theta}{2}\right)|01\rangle
                                      +ie^{i\lambda}\sin\left(\frac{\theta}{2}\right)|10\rangle
                                      -ie^{i\phi+i\lambda}\cos\left(\frac{\theta}{2}\right)|11\rangle\right) \\
&= \frac{1}{2}\left(\cos\left(\frac{\theta}{2}\right)(|00\rangle-i|11\rangle)
                    +e^{i\phi}\sin\left(\frac{\theta}{2}\right)(|01\rangle-i|10\rangle)
                    +ie^{i\lambda}\sin\left(\frac{\theta}{2}\right)(|10\rangle-i|01\rangle)
                    -ie^{i\phi+i\lambda}\cos\left(\frac{\theta}{2}\right)(|11\rangle-i|00\rangle)\right) \\
\end{align}$

P2's optimal outcome is '01', so we want the coefficient of the $|01\rangle$ terms to be at a maximum, so looking at the $|01\rangle$ coefficient we can set

$\begin{align}
    \theta=\pm\pi \\
    e^{i\phi} = -e^{i\lambda}
\end{align}$

So we can set $\theta=\pi$ and $\phi = -\lambda = \pi$ Which gives us

$\begin{align}
U_c &= \left(\begin{array}{cc} 0&1\\
                              -1&0\end{array}\right)\\
    &= Z \cdot X
\end{align}$

Feel free to test it by changing a the following lines in the cell above

P1_strategy = 'Z'
P2_strategy = ['Z', 'X']

What about allowing for mixed quantum strategies? Well in that case, there are indeed Nash equilibria that emerge for the quantum prisoner's dilemma [6]. Finding Nash equilibria for quantum games is an active area of research, and we will now demonstrate a really neat result by Benjamin & Hayden from Ref [5] which discusses the 4-minority game.

The 4-minority game is a simple 4 player game in which a player wins the game if their outcome is different to everyone else's, i.e. if their outcome is 1 and everyone else's is 0 or vice versa, where their outcome is 0 and everyone else's is 1. As usual, let's start by looking at the payoff table.

```python
minority_game = Game('minority', num_players = 4, protocol="Classical")
minority_game.display_payoffs()
```

In the classical game, the best that one can do is pick the strategy 0 or 1 randomly with a 50% probability and hope for the best. This is indeed a Nash equilibrium and gives each player a 1/8 chance of winning. However, things get very interesting in the quantum world...

It turns out that there are non-trivial pure strategy Nash equilibria in the quantum world. An example is every player deciding to apply the following strategy: $R_z(-3\pi/8) \cdot R_y(pi/2) \cdot R_z(\pi/2)$.

```python
minority_game.set_protocol("EWL", backend = 'simulator')
P1_strategy = ['Rz(-3*pi/8)', 'Ry(pi/2)', 'Rz(pi/2)']
P2_strategy = ['Rz(-3*pi/8)', 'Ry(pi/2)', 'Rz(pi/2)']
P3_strategy = ['Rz(-3*pi/8)', 'Ry(pi/2)', 'Rz(pi/2)']
P4_strategy = ['Rz(-3*pi/8)', 'Ry(pi/2)', 'Rz(pi/2)']
combined_strategies = [P1_strategy, P2_strategy, P3_strategy, P4_strategy]
outcomes, results = minority_game.play_game(combined_strategies, n_times=1000)
minority_game._quantum_game.draw_circuit()
```

```python
minority_game.show_results()
```

Notice that the $J$ gate has now become a 4-qubit gate with the form $$J = \frac{1}{\sqrt{2}} (I^{\otimes 4} + iX^{\otimes 4})$$
Now let us look at the results after playing this game 1000 times.

```python
plot_histogram(outcomes)
```

This result is rather remarkable since it shows that every player has a 1/4 chance of winning despite using only pure strategies! This is unachievable in the classical version of the game even when using mixed strategies. And what is even more intriguing is that in the 3-player version of this game 1) there are no pure strategy Nash equilibria and 2) the players each have a 1/4 chance of winning when in a mixed strategy Nash equilibrium, which is also true for the Nash equilibrium solution for the classical version of the game. So basically, we can't do any better in the 3-player version of the game by making it quantum, but we _can_ in the 4-player version of the game. Why should this be the case? And in what cases of games can we do better in the quantum version compared to the classical ones? The answer to these questions that no one exactly knows yet... welcome to the wonderful and weird world of quantum game theory!

## What next?
Sadly (or maybe finally!) this tutorial has come to an end. But of course, this notebook only scratches the very surface of quantum game theory. Thank you for taking the time to go through this tutorial and we hope you enjoyed it, and maybe even learnt a little something :)

If you are interested in exploring the field of quantum game theory further, but are maybe wondering where to start, we have a few suggestions as to what you could do going forward

### 1. Explore and help build the Quantum Game Theory package
Although we have showcased the Quantum Game Theory package in this tutorial, the code is still very much in progress and there are a many more improvements that could be made to it. Some ideas include
* integrate using mixed strategies into the package (currently the randomization has to be done before inputting the strategies into the Game object)
* Implement the use of arbitrary rotation and/or unitary gates as strategies (currently only certain fixed gates can be used as strategies)
* Implement the EWL protocol with varying degrees of initial entanglement

And of course whatever you think would be an interesting addition to the package! We are always happy to hear new ideas and collaborate so please feel free to reach out to us :)

### 2. Play around with the Quantum Game Theory package and start finding interesting results
We have only shown the very basics of quantum game theory, and there is so much left to explore, ranging from different protocols (try using the ['MW' protocol](https://www.sciencedirect.com/science/article/abs/pii/S0375960100004412?via%3Dihub) in our package!), to multiplayer and multidecision games, finding applications in optimization problems etc. A good place to start looking for inspiration might be the [review by Khan et al](https://link.springer.com/article/10.1007/s11128-018-2082-8) on quanutm game theory. We hope that as we add more features to the Quantum Game Theory package that people will be able to use it as tool to study this fascinating field. Play on playas!

## References
[1] “Chapter 1: Decision-Theoretic Foundations.” [_Game Theory: Analysis of Conflict_](https://www.jstor.org/stable/j.ctvjsf522), by Roger B. Myerson, Harvard University Press, 1997.

[2] “Chapter 2: Nash Equilibrium.” [_A Course in Game Theory_](http://ebour.com.ar/pdfs/A%20Course%20in%20Game%20Theory.pdf), by Martin J. Osborne and Ariel Rubinstein, MIT Press, 1994.

[3] Meyer, David A. “Quantum Strategies.” _Physical Review Letters_ , vol. 82, no. 5, 1999, pp. 1052–1055., doi:10.1103/physrevlett.82.1052.

[4] Eisert, Jens, et al. “Quantum Games and Quantum Strategies.” _Physical Review Letters_ , vol. 83, no. 15, 1999, pp. 3077–3080., doi:10.1103/physrevlett.83.3077.

[5] Benjamin, Simon C., and Patrick M. Hayden. “Multiplayer Quantum Games.” _Physical Review A_ , vol. 64, no. 3, 2001, doi:10.1103/physreva.64.030301.

[6] Benjamin, Simon C., and Patrick M. Hayden. “Comment on ‘Quantum Games and Quantum Strategies''.” _Physical Review Letters_ , vol. 87, no. 6, 2001, doi:10.1103/physrevlett.87.069801.


# Práctica 3 - Dinámica de manipuladores

En esta práctica nuestro objetivo será simular el comportamiento de un manipulador tipo PUMA, empecemos importando las liberrias necesarias:

```python
from sympy.physics.mechanics import mechanics_printing
mechanics_printing()
```

```python
from sympy import var, Function, pi
```

```python
var("l1:4")
```

```python
var("m1:4")
```

```python
var("g t")
```

```python
q1 = Function("q1")(t)
q2 = Function("q2")(t)
q3 = Function("q3")(t)
```

Y copiando la función para generar matrices de transformación homogéneas a partir de los parametros DH:

```python
def DH(params):
    from sympy import Matrix, sin, cos
    a, d, α, θ = params
    A = Matrix([[cos(θ), -sin(θ)*cos(α), sin(θ)*sin(α), a*cos(θ)],
                [sin(θ), cos(θ)*cos(α), -cos(θ)*sin(α), a*sin(θ)],
                [0, sin(α), cos(α), d],
                [0, 0, 0, 1]])
    return A
```

```python
A1 = DH([0, l1, pi/2, q1])
A2 = DH([l2, 0, 0, q2])
A3 = DH([l3, 0, 0, q3])

As = [A1, A2, A3]
As
```

He guardado todas las matrices de transformación homgénea en un solo arreglo, de tal manera que puedo hacer una función que tome todas las transformaciones de cada eslabon, y me devuelva las transformaciones a cada articulacion:

```python
def transf_art(transformaciones):
    from sympy import eye, simplify
    Hs = [eye(4)]
    for trans in transformaciones:
        Hs.append(simplify(Hs[-1]*trans))

    return Hs[1:]
```

```python
Hs = transf_art(As)
Hs
```

Una vez obtenido esto, puedo obtener las posiciones de cada articulación con una *List comprehension*:

```python
ps = [H[0:3, 3:4] for H in Hs]
ps
```

---
## Ejercicio

* Genera una lista que contenga todas las matrices de rotación de cada articulación usando list comprehensions

```python
# ESCRIBE TU CODIGO AQUI
raise NotImplementedError
Rs
```

```python
from nose.tools import assert_equal
from sympy import Matrix, sin, cos, var

R1 = Matrix([[cos(q1), 0, sin(q1)],
             [sin(q1), 0, -cos(q1)],
             [0, 1, 0]])
R2 = Matrix([[cos(q1)*cos(q2), -sin(q2)*cos(q1), sin(q1)],
             [sin(q1)*cos(q2), -sin(q2)*sin(q1), -cos(q1)],
             [sin(q2), cos(q2), 0]])
R3 = Matrix([[cos(q1)*cos(q2+q3), -sin(q2+q3)*cos(q1), sin(q1)],
             [sin(q1)*cos(q2+q3), -sin(q2+q3)*sin(q1), -cos(q1)],
             [sin(q2+q3), cos(q2+q3), 0]])

assert_equal(Rs[0], R1)
assert_equal(Rs[1], R2)
assert_equal(Rs[2], R3)
```

Si ahora declaramos un vector con todos los grados de libertad:

```python
q = [q1, q2, q3]
```

podemos obtener el Jacobiano traslacional de cada articulacion con:

```python
ps[1].jacobian(q)
```

---
## Ejercicio

* Genera una lista con los Jacobianos traslacionales

```python
# ESCRIBE TU CODIGO AQUI
raise NotImplementedError
Jvs
```

```python
from nose.tools import assert_equal
assert_equal(Jvs[0], ps[0].jacobian(q))
assert_equal(Jvs[1], ps[1].jacobian(q))
assert_equal(Jvs[2], ps[2].jacobian(q))
```

Un paso que tenemos que hacer manualmente es definir los vectores de orientación (compuesto por $\phi$, $\theta$ y $\psi$) ya que se tiene un sistema sobrerestringido, pero son lo suficientemente faciles de obtener:

```python
o1 = Matrix([[0], [0], [q1]])
o1
```

```python
o2 = Matrix([[0], [q2], [q1]])
o2
```

```python
o3 = Matrix([[0], [q2 + q3], [q1]])
o3
```

y si se guarda una lista con cada uno de estos vectores, se puede obtener el jacobiano rotacional de la misma manera que el traslacional:

```python
os = [o1, o2, o3]
```

---
## Ejercicio

* Genera una lista con los Jacobianos rotacionales

```python
# ESCRIBE TU CODIGO AQUI
raise NotImplementedError
Jωs
```

```python
from nose.tools import assert_equal
assert_equal(Jωs[0], os[0].jacobian(q))
assert_equal(Jωs[1], os[1].jacobian(q))
assert_equal(Jωs[2], os[2].jacobian(q))
```

Otra cosa que podemos hacer en automatico es definir los tensores de inercia necesarios para el manipulador, ya que esto solo depende del numero de grados de libertad, defino la función que va a tomar el vector con el estado del sistema, $q$, y va a calcular una lista con los tensores:

```python
def tens_iner(q):
    from sympy import Matrix
    Is = []
    for i in range(len(q)):
        Js = [var("J_{" + str(i+1) + "_" + eje + "}") for eje in "xyz"]
        I = Matrix([[Js[0], 0, 0], [0, Js[1], 0], [0, 0, Js[2]]])
        Is.append(I)
    return Is
```

```python
Is = tens_iner(q)
Is
```

definiré una lista con todas las masas de los eslabones:

```python
ms = [m1, m2, m3]
```

De tal manera que podamos hacer una función que tome estas, los jacobianos y los tensores de inercia,para calcular la matriz de masas:

```python
def matriz_masas(ms, Jvs, Is, Jωs):
    from sympy import zeros, expand, simplify
    M = zeros(len(ms))
    for m, Jv, I, Jω in zip(ms, Jvs, Is, Jωs):
        M += simplify(expand(m*Jv.T*Jv + Jω.T*I*Jω))
    return M
```

```python
M = matriz_masas(ms, Jvs, Is, Jωs)
M
```

mmm... un poco grande, tratemos de simplificar un poco:

```python
from sympy import simplify
```

```python
simplify(M)
```

mmm... un poco mejor, pero aun no es viable; los terminos del segundo y tercer grado de libertad son simples, el problema es el primero, tratemos de simplificar solo ese termino, intentaremos factorizar $l_2^2$ y $l_3^2$ y despues simplificar:

```python
M[0].collect(l2**2).collect(l3**2).collect(m3).simplify()
```

esto se ve aceptable, apliquemoslo a toda la matriz:

```python
M = simplify(M.applyfunc(lambda M: collect(M, l2**2)).applyfunc(lambda M: collect(M, l3**2)).applyfunc(lambda M: collect(M, m3)))
M
```

---
## Ejercicio

* Escribe el código de una función, que dada la matriz de masas, una lista con los grados de libertad y los indices de posición, calcule el símbolo de Christoffel pedido, recuerde que la formula es:

$$
c_{ijk} = \frac{1}{2}\left\{\frac{\partial M_{kj}}{\partial q_i} + \frac{\partial M_{ki}}{\partial q_j} - \frac{\partial M_{ij}}{\partial q_k}\right\}
$$

```python
def christoffel(M, q, i, j, k):
    from sympy import Rational, simplify
    # ESCRIBE TU CODIGO AQUI
    raise NotImplementedError
    return simplify(simbolo)
```

```python
from nose.tools import assert_equal
from sympy import Rational, expand
assert_equal(christoffel(M, q, 0,0,1), expand(Rational(1,2)*((m2+m3)*l2**2*sin(2*q2) + m3*l3**2*sin(2*(q2+q3))) + m3*l2*l3*sin(2*q2+q3)))
assert_equal(christoffel(M, q, 0,0,0), 0)
```

Con esta función podemos calcular cualquier simbolo de Christoffel (recordando que los indices en Python empiezan en $0$:

```python
c113 = christoffel(M, q, 0,0,2)
c113
```

y crear una función que calcule todos los simbolos de Christoffel a partir de esta función:

```python
def simbolos_chris(M, q):
    simbolos = []
    for i in range(len(q)):
        sim = []
        for j in range(len(q)):
            s = [christoffel(M, q, i, j, k) for k in range(len(q))]
            sim.append(s)
        simbolos.append(sim)
    return simbolos
```

```python
simbolos_christoffel = simbolos_chris(M, q)
```

```python
simbolos_christoffel[0][0][2]
```

Y ya con los simbolos de Christoffel, calcular la matriz de Coriolis:

```python
def matriz_coriolis(simbolos, q̇):
    from sympy import Matrix
    coriolis = []
    for k in range(len(simbolos)):
        cor = []
        for j in range(len(simbolos)):
            c=0
            for i in range(len(simbolos)):
                c+= simbolos[i][j][k]*q̇[i]
            cor.append(c)
        coriolis.append(cor)
    return Matrix(coriolis)
```

```python
C = simplify(matriz_coriolis(simbolos_christoffel, q̇))
C
```

En este punto tenemos un resultado lo suficientemente compacto para copiarlo a las definiciones numéricas, por lo que seguimos al vector de gravedad:

```python
def ener_pot(params):
    m, h = params
    U = m*g*h
    return U
```

Y calculando las energías potenciales:

```python
h1, h2, h3 = ps[0][2], ps[1][2], ps[2][2]
```

```python
U1 = ener_pot([m1, h1])
U2 = ener_pot([m2, h2])
U3 = ener_pot([m3, h3])
```

```python
U = U1 + U2 + U3
```

```python
def vector_grav(U, q):
    from sympy import Matrix
    return Matrix([[U]]).jacobian(q).T
```

```python
G = vector_grav(U, q)
G
```

Una vez que hemos concluido este proceso, podemos pasar al documento llamado ```numerico.ipynb```


# For Double-pendulum, the Hamiltonian is

Double-pendulum system has two masses, $m_1, m_2$, two strings $L_1, L_2$ and two angles $\phi_1, \phi_2$

Through calculation for each mass's poential energy and kinetic energy, the $\mathcal{H}$ is simply $T + V$.

$\mathcal{H}$ is simply $T + V$.  Hamilton's equations are

$\begin{align}
  \dot\phi_1 &=  \frac{p_\phi(1)}{m_1L_1} \\
  \dot\phi_2 &=  \frac{p_\phi(2)}{m_2L_2} \\
  \dot p_\phi(1) &= -\frac{\partial\mathcal{H}}{\partial \phi_1} = m_2L_1L_2\dot\phi_1\dot\phi_2sin(\phi_1-\phi_2)-(m_1+m_2)gL_1sin(\phi_1)
  \\
   \dot p_\phi(2) &= -\frac{\partial\mathcal{H}}{\partial \phi_2} =-m_2L_1L_2\dot\phi_1\dot\phi_2sin(\phi_1-\phi_2)-m_2gL_2sin(\phi_2)
  \;.
\end{align}$

```python
%matplotlib inline
```

```python
import numpy as np
from scipy.integrate import solve_ivp

import matplotlib.pyplot as plt

# Import explicitly the widgets we might use
import ipywidgets as widgets
from ipywidgets import HBox, VBox, Layout, Tab, Label, Checkbox
from ipywidgets import FloatSlider, IntSlider, Play, Dropdown, HTMLMath
from IPython.display import display

# Animation
from matplotlib import animation, rc
from IPython.display import HTML
```

```python
# The dpi (dots-per-inch) setting will affect the resolution and how large
#  the plots appear on screen and printed.  So you may want/need to adjust
#  the figsize when creating the figure.
plt.rcParams['figure.dpi'] = 100.    # this is the default for notebook

# Change the common font size (smaller when higher dpi)
font_size = 10
plt.rcParams.update({'font.size': font_size})
```

## Pendulum class and utility functions

```python
class HamiltonianPendulum():
    """
    Pendulum class implements the parameters and Hamilton's equations for
     a simple pendulum (no driving or damping).

    Parameters
    ----------
    omega_0 : float
        natural frequency of the pendulum (\sqrt{g/l} where l is the
        pendulum length)
    mass : float
        mass of pendulum

    Methods
    -------
    dy_dt(t, y)
        Returns the right side of the differential equation in vector y,
        given time t and the corresponding value of y.
    """
    def __init__(self, L1=1., L2=1., m1=1., m2=1., g=1.):
        self.L1 = L1
        self.L2 = L2
        self.m1 = m1
        self.m2 = m2
        self.g = g
    def dy_dt(self, t, y):
        """
        This function returns the right-hand sides of Hamilton's eqs
        [dq1/dt dq2/dt dp1/dt dp2/dt] --> [dphi1/dt dphi2/dt dp_phi1/dt dp_phi2/dt]

        Parameters
        ----------
        t : float
            time
        y : float
            A 2-component vector with y[0] = phi1(t) y[1] = phi2(t) y[2]= p_phi1(t) y[3]= p_phi2(t)

        Returns
        -------

        """
        return [y[2]/(self.m1*self.L1), y[3]/(self.m2*self.L2), self.m2 * self.L1 * self.L2 * y[2]/self.m1 * y[3]/self.m2 * np.sin(y[0]-y[1])\
               -(self.m1+self.m2)*self.g * self.L1 * np.sin(y[0]), -self.m2 * self.L1 * self.L2 * y[2]/self.m1 * y[3]/self.m2\
                * np.sin(y[0]-y[1])-self.m2*self.g * self.L2 * np.sin(y[1]) ]

    def solve_ode(self, t_pts, q1_0, q2_0, p1_0, p2_0,
                  abserr=1.0e-10, relerr=1.0e-10):
        """
        Solve the ODE given initial conditions.
        Specify smaller abserr and relerr to get more precision.
        """
        y = [q1_0, q2_0, p1_0, p2_0]
        solution = solve_ivp(self.dy_dt, (t_pts[0], t_pts[-1]),
                             y, t_eval=t_pts,
                             atol=abserr, rtol=relerr)
        q1, q2, p1, p2 = solution.y

        return q1, q2, p1, p2
```

```python
def plot_y_vs_x(x, y, axis_labels=None, label=None, title=None,
                color=None, linestyle=None, semilogy=False, loglog=False,
                ax=None):
    """
    Generic plotting function: return a figure axis with a plot of y vs. x,
    with line color and style, title, axis labels, and line label
    """
    if ax is None:        # if the axis object doesn't exist, make one
        ax = plt.gca()

    if (semilogy):
        line, = ax.semilogy(x, y, label=label,
                            color=color, linestyle=linestyle)
    elif (loglog):
        line, = ax.loglog(x, y, label=label,
                          color=color, linestyle=linestyle)
    else:
        line, = ax.plot(x, y, label=label,
                    color=color, linestyle=linestyle)

    if label is not None:    # if a label if passed, show the legend
        ax.legend()
    if title is not None:    # set a title if one if passed
        ax.set_title(title)
    if axis_labels is not None:  # set x-axis and y-axis labels if passed
        ax.set_xlabel(axis_labels[0])
        ax.set_ylabel(axis_labels[1])

    return ax, line
```

```python
def start_stop_indices(t_pts, plot_start, plot_stop):
    start_index = (np.fabs(t_pts-plot_start)).argmin()  # index in t_pts array
    stop_index = (np.fabs(t_pts-plot_stop)).argmin()  # index in t_pts array
    return start_index, stop_index
```

```python

```

## Make simple pendulum plots

```python
# Common plotting time (generate the full time then use slices)
t_start = 0.
t_end = 50.
delta_t = 0.001

t_pts = np.arange(t_start, t_end+delta_t, delta_t)

L1 = 1.
L2 = 1.
g = 1.
m1 = 1.
m2= 1.
# Instantiate a pendulum
p1 = HamiltonianPendulum(L1=L1, L2=L2, m1=m1, m2=m2, g=g)

```

```python
# both plots: same initial conditions
phi1_0 = 0.6
phi2_0 = 0.61
p_phi1_0 = 0.
p_phi2_0 = 0.

phi1, phi2, p_phi1, p_phi2  = p1.solve_ode(t_pts, phi1_0, phi2_0, p_phi1_0, p_phi2_0)

# start the plot!
fig = plt.figure(figsize=(15,5))

# first plot: phi1 plot
ax_a = fig.add_subplot(3,2,1)

start, stop = start_stop_indices(t_pts, t_start, t_end)
plot_y_vs_x(t_pts[start : stop], phi1[start : stop],
            color='blue',
            label=None,
            title=r'$\phi1(t)$',
            ax=ax_a)

# second plot: phi2 plot
ax_b = fig.add_subplot(3,2,2)

start, stop = start_stop_indices(t_pts, t_start, t_end)
plot_y_vs_x(t_pts[start : stop], phi2[start : stop],
            color='blue',
            label=None,
            title=r'$\phi2(t)$',
            ax=ax_b)

# third plot: p1 plot
ax_c = fig.add_subplot(3,2,3)

start, stop = start_stop_indices(t_pts, t_start, t_end)
plot_y_vs_x(t_pts[start : stop], p_phi1[start : stop],
            color='blue',
            label=None,
            title=r'$p1(t)$',
            ax=ax_c)

# Fourth plot: p2 plot
ax_d = fig.add_subplot(3,2,4)

start, stop = start_stop_indices(t_pts, t_start, t_end)
plot_y_vs_x(t_pts[start : stop], p_phi2[start : stop],
            color='blue',
            label=None,
            title=r'$p2(t)$',
            ax=ax_d)

fig.tight_layout()
fig.savefig('Double_Pendulum_Hamiltonian.png', bbox_inches='tight')
```

Now trying the power spectrum, plotting only positive frequencies and cutting off the lower peaks:

```python
start, stop = start_stop_indices(t_pts, t_start, t_end)
signal = phi1[start:stop]
power_spectrum = np.abs(np.fft.fft(signal))**2
freqs = np.fft.fftfreq(signal.size, delta_t)
idx = np.argsort(freqs)

fig_ps = plt.figure(figsize=(5,5))
ax_ps = fig_ps.add_subplot(1,1,1)
ax_ps.semilogy(freqs[idx], power_spectrum[idx], color='blue')
ax_ps.set_xlim(0, 1.)
ax_ps.set_ylim(1.e3, 1.e12)
ax_ps.set_xlabel('frequency')
ax_ps.set_title('Power Spectrum')
ax_ps.axvline(1./(2.*np.pi))
```

```python
start, stop = start_stop_indices(t_pts, t_start, t_end)
signal = phi2[start:stop]
power_spectrum = np.abs(np.fft.fft(signal))**2
freqs = np.fft.fftfreq(signal.size, delta_t)
idx = np.argsort(freqs)

fig_ps = plt.figure(figsize=(5,5))
ax_ps = fig_ps.add_subplot(1,1,1)
ax_ps.semilogy(freqs[idx], power_spectrum[idx], color='blue')
ax_ps.set_xlim(0, 1.)
ax_ps.set_ylim(1.e3, 1.e12)
ax_ps.set_xlabel('frequency')
ax_ps.set_title('Power Spectrum')
ax_ps.axvline(1./(2.*np.pi))
```

```python
# Here we plot the delta_phi in log scale
phi1_0_C = 0.601
phi1_C, phi2, p_phi1, p_phi2  = p1.solve_ode(t_pts, phi1_0_C, phi2_0, p_phi1_0, p_phi2_0)
delta_phi=phi1_C-phi1

fig = plt.figure(figsize=(10,5))

# The plot is delta_phi vs. time in logscale
ax_a = fig.add_subplot(1,2,1)
start, stop = start_stop_indices(t_pts, t_start, t_end)
plot_y_vs_x(t_pts[start : stop], delta_phi[start : stop],
            color='blue',
            label=None,
            title=r'$\delta\phi(t)$ in log scale',
            ax=ax_a)
plt.semilogy()

ax_a = fig.add_subplot(1,2,2)
start, stop = start_stop_indices(t_pts, t_start, t_end)
plot_y_vs_x(t_pts[start : stop], delta_phi[start : stop],
            color='blue',
            label=None,
            title=r'$\delta\phi(t)$',
            ax=ax_a)
```

# Chaotic motion

From the above power spectrum plots, it's clear that both of them are having numbers of frequencies that none of them are not repeated themselves. If the time line extends, they will also not repeat which can be foreseen even though they have a constant external force(The gravitational force). That point is also shown in the $\phi(t)$ plots above, $\phi_1$ and $\phi_2$ have no periods for themselves to repeat.

Besides, they are sensitive to the initial conditions, I changed initial conditions with $1$% of their values and the results seem to be obviously different from before. $\delta\phi$ shows the results after I changed $\phi_1$ at time $t=0$ from $0.6$ to $0.601$. The results cause quite a lot difference which is more clear in the log scale plot. Thus, the motion is clearly sensitive to the initial conditions, the motion is chaotic.

```python
#Get the widget
def update_plot(phi1_0, phi2_0, p_phi1_0, p_phi2_0):

    phi1, phi2, p_phi1, p_phi2  = p1.solve_ode(t_pts, phi1_0, phi2_0, p_phi1_0, p_phi2_0)

    fig = plt.figure(figsize=(15,5))
    ax_a = fig.add_subplot(1,4,1)
    start, stop = start_stop_indices(t_pts, t_start, t_end)
    plot_y_vs_x(t_pts[start : stop], phi1[start : stop],
            color='blue',
            label=None,
            title=r'$\phi1(t)$',
            ax=ax_a)

    ax_b = fig.add_subplot(1,4,2)
    start, stop = start_stop_indices(t_pts, t_start, t_end)
    plot_y_vs_x(t_pts[start : stop], phi2[start : stop],
                color='blue',
                label=None,
                title=r'$\phi2(t)$',
                ax=ax_b)

    ax_c = fig.add_subplot(1,4,3)

    start, stop = start_stop_indices(t_pts, t_start, t_end)
    plot_y_vs_x(t_pts[start : stop], p_phi1[start : stop],
                color='blue',
                label=None,
                title=r'$p1(t)$',
                ax=ax_c)

    ax_d = fig.add_subplot(1,4,4)

    start, stop = start_stop_indices(t_pts, t_start, t_end)
    plot_y_vs_x(t_pts[start : stop], p_phi2[start : stop],
                color='blue',
                label=None,
                title=r'$p2(t)$',
                ax=ax_d)

phi1_0_w = FloatSlider(value=1.0, min=0.1, max=10., step=0.1,
                      description=r'$\phi1_0_w$ :')
phi2_0_w = FloatSlider(value=1.0, min=0.1, max=10., step=0.1,
                      description=r'$\phi2_0_w$ :')
p_phi1_0_w = FloatSlider(value=1.0, min=0., max=10., step=0.1,
                      description=r'$\p_phi1_0_w$ :')
p_phi2_0_w = FloatSlider(value=1.0, min=0., max=10., step=0.1,
                      description=r'$\p_phi2_0_w$ :')
plot_out = widgets.interactive_output(update_plot,
                                      dict(
                                      phi1_0=phi1_0_w,
                                      phi2_0=phi2_0_w,
                                      p_phi1_0=p_phi1_0_w,
                                      p_phi2_0=p_phi2_0_w,
                                      )
                                     )

hbox_1 = HBox([phi1_0_w, phi2_0_w])
hbox_2 = HBox([p_phi1_0_w, p_phi2_0_w])
UI_box = VBox([hbox_1, hbox_2, plot_out])
display(UI_box)
```

    VBox(children=(HBox(children=(FloatSlider(value=1.0, description='$\\phi1_0_w$ :', max=10.0, min=0.1), FloatSl…

```python

```

```python

```

```python

```

```python

```


# Clase 23: EDOs - EDOs de alto orden, estabilidad

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

## Sistemas de ecuaciones diferenciales

Podemos aplicar todo lo que hemos estudiado a sistemas de ecuaciones del tipo

$$
\begin{align}
y_1'(t) &= f_1(t, y_1, y_2, ..., y_n) \\
y_2'(t) &= f_2(t, y_1, y_2, ..., y_n) \\
\vdots &\phantom{=}\quad \vdots \\
y_n'(t) &= f_n(t, y_1, y_2, ..., y_n)
\end{align}
$$

para $a \leq t \leq b$ con condiciones iniciales

$$
y_1(a) = \alpha_1\,,\quad y_2(a) = \alpha_2\,,\quad ...\,, \quad y_n(a) = \alpha_n\,.
$$

En los desarrollos teóricos sólo tenemos que generalizar la condición de Lipfschitz

$$
|f(t, u_1, ..., u_n) - f(t, w_1, ..., w_n)| \leq L \sum_{j = 1}^n |u_j - w_j|
$$

Que se cumple siempre que

$$
\left|\frac{\partial f}{\partial u_i}\right| \leq L
$$

para todo $u_i$. Cuando se satisface esta condición de Lpfschitz, el sistema tene una solución única.

Generalizar los métodos ya vistos es directo.

```python
def rk4s(f, a, b, yo, N):

    h = (b-a)/N
    t = a
    w = yo
    m = len(yo)
    ts = np.zeros(N+1)
    sol = np.zeros((m,N+1))

    for i in range(N):
        ts[i] = t
        sol[:,i] = w
        k1 = h*f(t, w)
        k2 = h*f(t+h/2, w+k1/2)
        k3 = h*f(t+h/2, w+k2/2)
        k4 = h*f(t + h, w + k3)
        w = w + (k1 + 2*k2 + 2*k3 + k4)/6
        t = t + h

    ts[N] = t
    sol[:,N] = w

    return ts, sol
```

## Ecuaciones de alto orden

Con lo visto arriba, una ecuación de alto orden es fácil de resolver, consideremos

$$
y''(t) = f(t, y, y') \,,\quad a \leq t \leq b\,,\quad y(a) = y_o\,,\quad y'(a) = y'_o\,,
$$

este se puede transformar en un sistema haciendo $y_1 = y$, $y_2 = y'$.

$$
\begin{align}
y_1'(t) &= y_2 \\
y_2'(t) &= f(t, y_1, y_2) \,.
\end{align}
$$

Similarmente para ecuaciones de orden más alto.

Resolvamos el siguiente problema de valor inicial

$$
y''(t) - 2y'(t) + 2y(t) = e^{2t}\sin(t)\,,\quad 0\leq t\leq 1\,,\quad y(0) = -0.4\,,\quad y'(0)= -0.6
$$

```python
def f(t, y):
    return np.array([y[1], 2*y[1] - 2*y[0] + np.exp(2*t)*np.sin(t)])

a = 0
b = 1
yo = np.array([-0.4, -0.6])
```

```python
rk4s(f, a, b, yo, 10)
```

    (array([0. , 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1. ]),
     array([[-0.4       , -0.46173334, -0.52555988, -0.58860144, -0.64661231,
             -0.69356666, -0.7211519 , -0.71815295, -0.66971133, -0.5564429 ,
             -0.35339886],
            [-0.6       , -0.63163124, -0.64014895, -0.61366381, -0.53658203,
             -0.3887381 , -0.14438087,  0.22899702,  0.7719918 ,  1.53478148,
              2.57876634]]))

```python
def exacta(t):
    return 0.2*np.exp(2*t)*(np.sin(t) - 2*np.cos(t))
```

```python
sol = rk4s(f, a, b, yo, 10)
plt.plot(sol[0], sol[1][0])
```

```python
plt.plot(sol[0], sol[1][0] - exacta(sol[0]))
```

## Estabilidad

Hasta ahora hemos discutido el error cometido a cada paso. Pero nos preocupa que el error total al $N$-ésimo paso pueda ser grande (y de hecho hemos visto casos donde lo es), ya que un pequeño error en un paso puede llevar a errores grandes si el método no es estable.

Un método se dice **consistente** si todos los errores tienden a cero cuando el tamaño del paso tiende a cero

$$
\lim_{h\rightarrow 0} \max_{1 \leq i \leq N}|\tau_i(h)| = 0\,.
$$

Un método se dice **convergente** si la aproximación tiende a la verdadera solución cuando el tamaño del paso tiende a cero

$$
\lim_{h\rightarrow 0} \max_{1 \leq i \leq N}|y(t_i) - w_i| = 0\,.
$$

Por ejemplo el método de Euler es convergente porque la fórmula de error es

$$
\frac{hM}{2L}(e^{L(t_i - a)} - 1)
$$

tal que tiende a cero cuando $h$ tiende a cero.

**Teorema:** Considere el problema de condición inicial

$$
y'(t) = f(t, y)\,,\quad a \leq t \leq b \,,\quad y(a) = \alpha\,,
$$

que se aproxima usando el método dado por

$$
w_{i+1} = w_i + h\phi(t_i, w_i, h)\,,
$$

si $\phi$ satisface la condición de Lipfshitz en el conjunto $\{(t, w, h) | a \leq t \leq b\,,\, -\infty \leq w \leq \infty\,,\, 0\leq h \leq h_o \}$ entonces

1. El método es estable (pequeñas perturbaciones en la condición inicial implican pequeñas perturbaciones en la solución).

2. El método es convergente si y sólo si es consistente, lo que es equivalente a la condición $\phi(t, y, 0) = f(t, y)$.

3. Si una función $\tau(h)$ existe tal que para cada paso el error de truncación local satisface $|\tau_i(h)| \leq \tau(h)$ cuando $0 \leq h \leq h_o$, entonces

$$
|y(t_i) - w_i| \leq \frac{\tau(h)}{L}e^{L(t_i - a)}\,.
$$

Un análisis similar para métodos de múltiples pasos se puede encontrar en el libro de Burden.

## Ecuaciones diferenciales rígidas

```python
def f(t, y):
    return np.array([9*y[0] + 24*y[1] + 5*np.cos(t) - (1/3)*np.sin(t),
                    -24*y[0] - 51*y[1] - 9*np.cos(t) + (1/3)*np.sin(t)])

yo = np.array([4/3, 2/3])
a = 0
b = 1
```

```python
def exacta(t):
    return [2*np.exp(-3*t) - np.exp(-39*t) + (1/3)*np.cos(t),
            -np.exp(-3*t) + 2*np.exp(-39*t) - (1/3)*np.cos(t)]
```

```python
sol_10 = rk4s(f, a, b, yo, 10)
sol_20 = rk4s(f, a, b, yo, 20)
```

```python
plt.plot(sol_10[0], sol_10[1][0] - exacta(sol_10[0])[0])
plt.plot(sol_20[0], sol_20[1][0] - exacta(sol_20[0])[0])
```

```python
plt.plot(sol_10[0], sol_10[1][1] - exacta(sol_10[0])[1])
plt.plot(sol_20[0], sol_20[1][1] - exacta(sol_20[0])[1])
```

Lo que está pasando aquí es que en la solución analítica aparece el término $e^{-39t}$ que tiende rápidamente a cero. Pero sus derivadas son grandes, tal que el error de un método de alto orden, como el Runge-Kutta, puede ser grande y necesario escoger un $h$ que lo compense. Este tipo de ecuaciones se llaman **rígidas**.

Para evaluar la utilidad de un método para resolver ecuaciones rígidas se puede considerar la ecuación más sencilla

$$
y'(t) = \lambda y(t)\,,\quad y(0) = \alpha\,,\quad \lambda < 0.
$$

Por ejemplo, para el método de Euler, el error absoluto al $j$-ésimo paso está dado por

$$
|y(t_j) - w_j| = |e^{j\lambda h} - (1 + h\lambda)^j||\alpha|
$$

tal que la convergencia del método dependerá de cómo $(1 + h\lambda)$ aproxima la función $e^{h\lambda}$. Para $j$ grande $e^{jh\lambda}$ tiende rápidamente a cero, pero esto sólo será verdad para $(1 + h\lambda)^j$ si $|1 + h\lambda| < 1$ tal que es necesario tomar $0 < h < 2/|\lambda|$.

En general un método de un paso, la aproximación se puede escribir

$$
w_{j+1} = Q(h\lambda)w_j
$$

para alguna función $Q$ y será estable si se escoge el paso $h$ tal que

$$
|Q(h\lambda)| < 1.
$$

Una alternativa es buscar métodos que sean estables para $\lambda < 0$ y cualquier $h$. O mejor aún para $\text{Re} \lambda < 0$. A los métodos que satisfacen esta segunda condición se los llama A-estables. Un método A-estable es el método implícito dado por la regla del trapecio

$$
\begin{align}
w_o &= \alpha \\
w_{j+1} &= w_j + \frac{h}{2} (f(t_{j+1}, w_{j+1}) + f(t_j, w_j))\,,
\end{align}
$$

el problema es que se necesita saber $w_{j+1}$ que requiere despejar esta ecuación. Para ese despeje se puede usar el método de Newton.

```python
class MaxIterations(Exception):
    pass

def tn(f, df, a, b, yo, tol, N, M = 1000):

    h = (b - a)/N
    t = a
    w = yo

    ws = np.zeros(N+1)
    ts = np.zeros(N+1)
    ws[0] = w
    ts[0] = t

    for i in range(N):
        k1 = w + h*f(t, w)/2
        wo = k1

        j = 1
        while True:
            w = wo - (wo - h*f(t + h, wo)/2 - k1)/(1 - h*df(t + h, wo)/2)
            if abs(w - wo) < tol:
                break
            else:
                j = j+1
                wo = w
                if j > M:
                    raise MaxIterations('Se excedió el número máximo de iteraciones')

        t += h
        ts[i+1] = t
        ws[i+1] = w

    return ts, ws
```

Resolvamos el problema

$$
y'(t) = 5e^{5t}(y - t)^2 - 1\,,\quad 0\leq t\leq 1\,,\quad y(0) = -1\,.
$$

La solución analítica es $y(t) = t - e^{-5t}$.

```python
def f(t, y):
    return 5*np.exp(5*t)*(y - t)**2 - 1

def df(t, y):
    return 10*np.exp(5*t)*(y - t)

a = 0
b = 1
yo = -1

def exacta(t):
    return t - np.exp(-5*t)
```

```python
def rk4(f, a, b, yo, N):

    h = (b-a)/N
    t = a
    w = yo
    ts = np.zeros(N+1)
    sol = np.zeros(N+1)

    for i in range(N):
        ts[i] = t
        sol[i] = w
        k1 = h*f(t, w)
        k2 = h*f(t+h/2, w+k1/2)
        k3 = h*f(t+h/2, w+k2/2)
        k4 = h*f(t + h, w + k3)
        w = w + (k1 + 2*k2 + 2*k3 + k4)/6
        t = t + h

    ts[N] = t
    sol[N] = w

    return ts, sol
```

```python
sol_rk4_5 = rk4(f, a, b, yo, 5)
sol_rk4_20 = rk4(f, a, b, yo, 20)
sol_tn_5 = tn(f, df, a, b, yo, 1e-6, 5)
sol_tn_20 = tn(f, df, a, b, yo, 1e-6, 20)
```

```python
exacta(sol_rk4_5[0])
```

    array([-1.        , -0.16787944,  0.26466472,  0.55021293,  0.78168436,
            0.99326205])

```python
sol_rk4_5
```

    (array([0. , 0.2, 0.4, 0.6, 0.8, 1. ]),
     array([-1.00000000e+000, -3.58615674e-001,  1.83292601e-001,
             1.41234549e+000,  5.67600244e+015,  2.94453567e+275]))

```python
sol_tn_5
```

    (array([-1. ,  0.2,  0.4,  0.6,  0.8,  1. ]),
     array([-1.        , -0.32515935,  0.12634786,  0.44482793,  0.70962255,
             0.94633261]))

```python
exacta(sol_rk4_20[0])
```

    array([-1.        , -0.72880078, -0.50653066, -0.32236655, -0.16787944,
           -0.0365048 ,  0.07686984,  0.17622606,  0.26466472,  0.34460078,
            0.417915  ,  0.48607214,  0.55021293,  0.61122579,  0.66980262,
            0.72648225,  0.78168436,  0.83573577,  0.888891  ,  0.9413483 ,
            0.99326205])

```python
sol_rk4_20
```

    (array([0.  , 0.05, 0.1 , 0.15, 0.2 , 0.25, 0.3 , 0.35, 0.4 , 0.45, 0.5 ,
            0.55, 0.6 , 0.65, 0.7 , 0.75, 0.8 , 0.85, 0.9 , 0.95, 1.  ]),
     array([-1.        , -0.80690858, -0.62937   , -0.46857821, -0.32416489,
            -0.19475982, -0.07848497,  0.02667647,  0.12264169,  0.21109906,
             0.29345978,  0.37086223,  0.44420564,  0.51419443,  0.58138089,
             0.64619865,  0.70898195,  0.76995827,  0.82915925,  0.88599789,
             0.93870176]))

```python
sol_tn_20
```

    (array([-1.  ,  0.05,  0.1 ,  0.15,  0.2 ,  0.25,  0.3 ,  0.35,  0.4 ,
             0.45,  0.5 ,  0.55,  0.6 ,  0.65,  0.7 ,  0.75,  0.8 ,  0.85,
             0.9 ,  0.95,  1.  ]),
     array([-1.        , -0.80709301, -0.62950844, -0.46857579, -0.3240176 ,
            -0.19450837, -0.07818057,  0.02699155,  0.12294135,  0.21137304,
             0.29370967,  0.37109688,  0.44443764,  0.5144387 ,  0.58165595,
             0.64653193,  0.70942415,  0.77062537,  0.83037797,  0.88888453,
             0.94631557]))

Un método muy usado para tratar ecuaciones rígidas usan lo que se llama "diferencias hacia atrás" (backward difference) de orden $s$. La lógica es resolver una ecuación explícita del estilo

$$
\sum_{k = 0}^s a_k y_{i + k} = h\beta f(t, y_{i + s})
$$

Para encontrar los coeficientes $\beta$ y $a_k$ se escribe

$$
y'(t_{i+s}) = P_s(t_{i+s})
$$

donde $P_s(t)$ es el polinomio interpolante de Lagrange que usa los puntos $(t_i, y_i), ..., (t_{i+s}, y_{i+s})$ para aproximar $f(t_{i+s}, y(t_{i + s}))$.

Esto da:

$$
\begin{align}
y_{i + 1} - y_{i} &= hf(t_{i+1}, y_{i+1})\,,\\
y_{i+1} - \frac{4}{3}y_i + \frac{1}{3}y_{i - 1} &= \frac{2}{3}hf(t_{i+1}, y_{i+1}) \,,\\
y_{i+1} - \frac{18}{11}y_{i} + \frac{9}{11}y_{i-1} - \frac{2}{11}y_{i-2} &= \frac{6}{11}hf(t_{i+1}, y_{i+1})\,.
\end{align}
$$

## Scipy

Como siempre, ejemplos copiados de la documentación de scipy: https://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.solve_ivp.html#scipy.integrate.solve_ivp

Scipy puede usar varios métodos:

- El método por defecto es el RK45 que es el Runge-Kutta-Fehlberg. Cuando se pide 'dense output' usa un polinomio interpolante de orden 4.

- El método RK23 usa un Runge-Kutta de orden 2 para el control de error. El polinomio interpolante es un Hermiite.

- El método BDF es un tipo de método de diferencias hacias atrás, pero con control de error y orden variable. Ideal para ecuaciones rígidas.

- El método LSODA es un método de Adams que detecta cuándo una ecuación es rígida y cambia hacia un método de diferencias hacia atrás. No lo recomiendan en la documentación ya que el código usa un pedazo en Fortran envuelto en Python... (tal vez no es fácil de editar o tal vez no es eficiente, no lo especifican).

Usa otros métodos que también son combinaciones de cosas que hemos visto en la clase.

        scipy.integrate.solve_ivp(fun, t_span, y0, method='RK45', t_eval=None, dense_output=False, events=None, vectorized=False, args=None, **options)

```python
from scipy.integrate import solve_ivp
def exponential_decay(t, y): return -0.5 * y
sol = solve_ivp(exponential_decay, [0, 10], [2, 4, 8])
```

```python
print(sol.t)
```

    [ 0.          0.11487653  1.26364188  3.06061781  4.81611105  6.57445806
      8.33328988 10.        ]

```python
print(sol.y)
```

    [[2.         1.88836035 1.06327177 0.43319312 0.18017253 0.07483045
      0.03107158 0.01350781]
     [4.         3.7767207  2.12654355 0.86638624 0.36034507 0.14966091
      0.06214316 0.02701561]
     [8.         7.5534414  4.25308709 1.73277247 0.72069014 0.29932181
      0.12428631 0.05403123]]

```python
def lotkavolterra(t, z, a, b, c, d):
    x, y = z
    return [a*x - b*x*y, -c*y + d*x*y]
```

```python
sol = solve_ivp(lotkavolterra, [0, 15], [10, 5], args=(1.5, 1, 3, 1),
                dense_output=True)
```

```python
t = np.linspace(0, 15, 300)
z = sol.sol(t)
import matplotlib.pyplot as plt
plt.plot(t, z.T)
plt.xlabel('t')
plt.legend(['x', 'y'], shadow=True)
plt.title('Lotka-Volterra System')
plt.show()
```

## Tareas

### Tarea 12.1

Estudiemos el péndulo rígido. Sabemos que la aceleración actúa en la componente vertical de la velocidad y que además debe satisfacer la ecuación de ligadura que el radio del movimiento sea constante. Esto lleva a la siguiente ecuación diferencial

$$
\frac{d^2\theta}{dt^2} = -\frac{g}{L}\sin\theta
$$

donde $g = 9.81\,\text{m}/\text{s}^2$ es la aceleración de la gravedad y $L = 1\,\text{m}$ es la longitud del péndulo.

Grafique la solución a esta ecuación tomando $\theta'(0) = 0$ para $\theta(0) = 0.1$, $\theta(0) = 1$,  $\theta(0) = 3$ entre $0\,\text{s} \leq t \leq 6\,\text{s}$. Compare con el caso de pequeñas oscilaciones ($\sin\theta \approx \theta$).

### Tarea 12.2

Implemente un código para resolver una ecuación de orden $n$ usando el método de Runge-Kutta de orden 4.

$$
y^{(n)}(t) = f(t, y, y',...,y^{(n-1)})\,,
$$

Este código debería recibir una función $f(t, y, y', ..., y^{(n-1)})$, los valores inical $a$ y final $b$ del parámetro $t$, junto con un arreglo de condiciones iniciales $(y(a),...,y^{(n-1)}(a))$ y el número de pasos. El código debe dar la solución $y(t_i)$.

Para probar que funciona, resuelva la siguiente ecuación diferencial

$$
y'' - 2y' + y = te^t - t\,,\quad 0\leq t\leq 1\,,\quad y(0) = y'(0) = 0\,.
$$

Esta tiene solución analítica $y(t) = (1/6)t^3e^t - te^t + 2e^t - t - 2$.

### Tarea 12.3

Así como implementamos el método de Runge Kutta de orden 4 para resolver sistemas de ecuaciones diferenciales, implemente el método de Runge Kutta Fehlberg para esos sistemas de ecuaciones.

Note que para modificar el tamaño del paso es necesario exigir que todas las soluciones alcancen la tolerancia pedida.

Para probar su código, resuelva el siguiente problema de valor inicial:

$$
\begin{align}
y_1'(t) &= -4y_1 - 2y_2 + \cos t + 4 \sin t\,,\\
y_2'(t) &= 3y_1 + y_2 - 3\sin t\,,
\end{align}
$$

para $0 \leq t \leq 2$ con condiciones iniciales

$$
y_1(0) = 0\,,\quad y_2(t) = -1\,.
$$

Este sistema tiene soluciones analíticas

$$
\begin{align}
u_1(t) &= 2e^{-t} - 2e^{-2t} + \sin t\,,\\
u_2(t) &= -3e^{-t} + 2e^{-2t}\,.
\end{align}
$$

### Tarea 12.4

Escriba un código que implemente el algoritmo de diferencias hacia atrás

$$
y_{i+1} - \frac{18}{11}y_{i} + \frac{9}{11}y_{i-1} - \frac{2}{11}y_{i-2} = \frac{6}{11}hf(t_{i+1}, y_{i+1})
$$

como este es un método implícito, es necesario implementar un método de Newton similar a lo hecho para la regla del trapecio.

Aplique este algoritmo a la solución del problema visto en clase:

### Tarea 12.5

Grafique la solución numérica a la siguiente ecuación diferencial, usando menos de 100 pasos

$$
y' = -30(y + y^2) + \sin(t/2)\,,\quad 0\leq t \leq 20\,,\quad y(0) = 0\,.
$$

Clase 23 - EDOs - EDOs de alto orden, estabilidad.ipynb


# Linear Regression

## Agenda:

- Introduction of Regression Problems

- Introducing `Advertising` Dataset

- Train-Test Dataset split

- Discussion of Liner Regression in the Simple Setting

    - Model
    - Hypothesis Function
    - Cost Function
    - Solution - Least Squares Approach

- MultiLinear Regression

- Residual Plots

## Dataset

- We will be using the `advertising` dataset from ISLR

- This data contains advertising budget in (Thousand $\$$) and product sales (Millions $\$$)

- Let's take a look at the dataset

```python
import pandas as pd # for manipulating data
import matplotlib.pyplot as plt # for visualizations
import seaborn as sns # for visualizations
import requests # for loading data from an online resource
from io import StringIO # for reading inputs

```

```python
orig_url = "https://drive.google.com/file/d/1XdOaGHuGWgEMVMEiJ0dbwdPh6mH6PQZi/view?usp=sharing"

file_id = orig_url.split('/')[-2]

dwn_url='https://drive.google.com/uc?export=download&id=' + file_id

url = requests.get(dwn_url).text

csv_raw = StringIO(url)

advertising = pd.read_csv(csv_raw, index_col= 0)
```

```python
advertising.head()
```

      TV
      Radio
      Newspaper
      Sales

      1
      230.1
      37.8
      69.2
      22.1

      2
      44.5
      39.3
      45.1
      10.4

      3
      17.2
      45.9
      69.3
      9.3

      4
      151.5
      41.3
      58.5
      18.5

      5
      180.8
      10.8
      58.4
      12.9

```python
advertising.shape
```

    (200, 4)

__Goal__

Goal for this project is to be able to predict `sales` given the advertising budgets of the company in some other city.

__Note__

- This is a `supervised learning` problem: ( We are given `right answers` )

- More particularly this is a Regression Problem (Predicting a real-valued output)

__Training vs Test Datasets__

- We want `machine` to learn from our training data.
- Also don't forget the ultimate goal: Accurately predicting sales for an unseen data(New city) when we apply the algorithm to this case.
- But `machine`'s success on training data might be misleading! (Do you see why?)

- Can you think of a solution?

```python
## train test split

from sklearn.model_selection import train_test_split

X_train, X_test = train_test_split(advertising, shuffle = True,  test_size = 0.2,random_state= 9152020 ) # splits the data into two parts with 1:4 ratio

X_train.head()
```

      TV
      Radio
      Newspaper
      Sales

      76
      16.9
      43.7
      89.4
      8.7

      13
      23.8
      35.1
      65.9
      9.2

      16
      195.4
      47.7
      52.9
      22.4

      165
      117.2
      14.7
      5.4
      11.9

      178
      170.2
      7.8
      35.2
      11.7

```python
print(X_train.shape)
X_train[["TV", "Sales"]].head()
```

    (160, 4)

      TV
      Sales

      18
      281.4
      24.4

      64
      102.7
      14.0

      185
      253.8
      17.6

      32
      112.9
      11.9

      74
      129.4
      11.0

```python
print(X_test.shape)
X_test[["TV", "Sales"]].head()
```

    (40, 4)

      TV
      Sales

      151
      280.7
      16.1

      179
      276.7
      11.8

      173
      19.6
      7.6

      5
      180.8
      12.9

      19
      69.2
      11.3

```python
class display(object):
    """Display HTML representation of multiple objects"""
    template = """<div style="float: left; padding: 10px;">
    <p style='font-family:"Courier New", Courier, monospace'>{0}</p>{1}
    </div>"""
    def __init__(self, *args):
        self.args = args

    def _repr_html_(self):
        return '\n'.join(self.template.format(a, eval(a)._repr_html_())
                         for a in self.args)

    def __repr__(self):
        return '\n\n'.join(a + '\n' + repr(eval(a))
                           for a in self.args)
```

```python
print("Train Size", X_train.shape, "Test Size: ", X_test.shape, sep = "\n")
display('X_train[["TV", "Sales"]].head()', 'X_test[["TV", "Sales"]].head()')

```

    Train Size
    (160, 4)
    Test Size:
    (40, 4)

X_train[["TV", "Sales"]].head()

      TV
      Sales

      18
      281.4
      24.4

      64
      102.7
      14.0

      185
      253.8
      17.6

      32
      112.9
      11.9

      74
      129.4
      11.0

X_test[["TV", "Sales"]].head()

      TV
      Sales

      151
      280.7
      16.1

      179
      276.7
      11.8

      173
      19.6
      7.6

      5
      180.8
      12.9

      19
      69.2
      11.3

## Linear Regression: Simple Setting

[Image is inspired from](https://www.youtube.com/watch?v=kHwlB_j7Hkc&ab_channel=ArtificialIntelligence-AllinOne)

__Question__

- How do we represent $f$?

- In Linear Regression Problems:

$$ f(x) = \beta_{0} + \beta_{1} x $$

```python
## let's use seaborn to plot a regression line
sns.lmplot(x = 'TV', y = 'Sales', data = advertising, ci = 0)
plt.draw()

```

Goal: Minimize the errors!

- What errors?

- How?

__Toy case__

Note that

$$ e_i = y_{i} - (\beta_{0} + \beta_{1}x_{i})$$

__Errors__

__What is the total sum of errors?__

$$ e_{1} + e_{2} + \cdots + e_{N}$$

- Can you see the problem with this?

__Better Way to Measure Errors__

__Residual Sum of Squares__

$$ RSS = e_{1}^{2} + e_{2}^{2} + \cdots + e_{N}^{2} $$

__Still room for improvement__ (Mean Squared Error)

$$ MSE = \frac{1}{N}\left(e_{1}^{2} + e_{2}^{2} + \cdots + e_{N}^{2} \right) $$

__Or in short__

$$ MSE = \frac{1}{N}\left( \sum_{i =1}^{N} e_{i}^{2} \right) $$

__Note that `MSE` is a function of $\beta_0, \beta_{1}$:__

$$ \mbox{MSE} = \frac{1}{N} (y_1 -\beta_{0} -\beta_{1}x_{1} )^{2} + (y_2 -\beta_{0} -\beta_{1}x_{2} )^{2} + \cdots + (y_N -\beta_{0} -\beta_{1}x_{N} )^{2}$$

__Hypothesis Function__
(Fix $\beta_0, \beta_{1}$ function of $x$)

$$ y = f(x) = \beta_{0} + \beta_{1} x $$

__Cost Function__
(This is a function of $\beta_0, \beta_{1}$)

$$ \mbox{MSE} = \frac{1}{N} (y_1 -\beta_{0} -\beta_{1}x_{1} )^{2} + (y_2 -\beta_{0} -\beta_{1}x_{2} )^{2} + \cdots + (y_N -\beta_{0} -\beta_{1}x_{N} )^{2}$$

__How :)__ (Least Squares Approach)

[Image Source: Andrew Ng](https://www.youtube.com/watch?v=0kns1gXLYg4&ab_channel=ArtificialIntelligence-AllinOne)

```python
import matplotlib.image as mpimg

f, axes = plt.subplots(nrows= 1, ncols = 3)
for i in range(3):
    axes[i].axis('off')
    if i==0:
        img = mpimg.imread(f'images/simple{i}.jpg')
    else:
        img = mpimg.imread(f'images/simple{i}.png')
    axes[i].imshow(img)
plt.gcf().set_size_inches(20, 20)
```

Mathematically we will write:

$$\boldsymbol{y}  = X\cdot \boldsymbol{\beta} $$

__Seriously How!__

 - Direct Solution!
$$ \boldsymbol{\beta} = (X^{T}X)^{-1}X^{T}\boldsymbol{y}$$

```python

def least_squares(X,y):
    x_bar = X.mean()
    y_bar = y.mean()
    b1_num = (X - x_bar).dot(y - y_bar)
    b1_denom = ((X - x_bar)**2).sum()
    b1 = b1_num /b1_denom
    b0 = y_bar - b1*x_bar
    MSE = (((y - (b0 + b1*X))**2)).sum()
    return  b0,b1, (MSE**0.5)*1/200

least_squares(X_train.TV, X_train.Sales)
```

    (6.946387530916522, 0.04774217741156741, 0.20446309192793596)

```python
## we could use numpy to solve this directly

import numpy as np
X = np.array(X_train.TV)

ones = np.ones(shape = X.shape)

X = np.c_[ones, X]
X_Trans = X.T

np.linalg.inv( X_Trans.dot(X)).dot(X_Trans).dot(X_train.Sales)
```

    array([7.24935754, 0.0480755 ])

__Or Gradient Descent and its Variations__

__Or Using Sklearn__

```python
## Four Step workflow with sklearn
## 1. import the tool
from sklearn.linear_model import LinearRegression

## 2. instantiate the tool you would like to use.
lr = LinearRegression()

## 3. fit the model -- In this step machine 'learns':
lr.fit(X_train[["TV"]], X_train.Sales)

## 4. check-out the results or make predictions
print(lr.intercept_, lr.coef_)
```

    6.946387530916525 [0.04774218]

## Part-II: More than One Variables

- When we have more than one variable our linear model will look like as:

$$ Y = \beta_{0} + \beta_{1} X_{1} + \beta_{2}X_{2} + \cdots \beta_{p}X_{p} + \epsilon$$

__Notation:__

- $X_{1}, \cdots, X_{p}$ :  Columns of the dataset (or features or predictors or independent variables)

- $Y$ : target column in the dataset (or target variable or dependent variable)

- $\epsilon$ : Irreducible error.

__Goal:__ Given a dataset $X$ we would like to find coefficients $\beta_{1}, \cdots, \beta_{p}$ such that the sum of squared errors are minimum (Least Squares Approach!)

- Cost function (objective function) become:

\begin{align}
RSS &= \sum\limits_{i=1}^{n} (y_{i} - \hat{y}_{i})^{2} \\
&= \sum\limits_{i=1}^{n} (y_{i} - \beta_{0} - \beta_{1} x_{i1}  - \beta_{2}x_{i2} - \cdots  - \beta_{p}x_{ip})^{2}
\end{align}

```python
## This time we will use statsmodels api

import statsmodels.api as sm

X = X_train[['TV', 'Radio', 'Newspaper']]
y = X_train.Sales
X = sm.add_constant(X)
mod = sm.OLS(y, X, hasconst= True) ## OLS: Ordinary Least Square
res = mod.fit()
print(res.summary())

## R-squared: In one variable r
```

                                OLS Regression Results
    ==============================================================================
    Dep. Variable:                  Sales   R-squared:                       0.896
    Model:                            OLS   Adj. R-squared:                  0.894
    Method:                 Least Squares   F-statistic:                     447.9
    Date:                Tue, 15 Sep 2020   Prob (F-statistic):           2.06e-76
    Time:                        18:52:51   Log-Likelihood:                -311.53
    No. Observations:                 160   AIC:                             631.1
    Df Residuals:                     156   BIC:                             643.4
    Df Model:                           3
    Covariance Type:            nonrobust
    ==============================================================================
                     coef    std err          t      P>|t|      [0.025      0.975]
    ------------------------------------------------------------------------------
    const          2.9906      0.344      8.705      0.000       2.312       3.669
    TV             0.0454      0.002     28.871      0.000       0.042       0.048
    Radio          0.1859      0.010     18.976      0.000       0.167       0.205
    Newspaper      0.0017      0.006      0.269      0.788      -0.011       0.014
    ==============================================================================
    Omnibus:                       49.973   Durbin-Watson:                   1.883
    Prob(Omnibus):                  0.000   Jarque-Bera (JB):              122.986
    Skew:                          -1.311   Prob(JB):                     1.97e-27
    Kurtosis:                       6.402   Cond. No.                         436.
    ==============================================================================

    Warnings:
    [1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

```python
advertising.corr()
```

      TV
      Radio
      Newspaper
      Sales

      TV
      1.000000
      0.054809
      0.056648
      0.782224

      Radio
      0.054809
      1.000000
      0.354104
      0.576223

      Newspaper
      0.056648
      0.354104
      1.000000
      0.228299

      Sales
      0.782224
      0.576223
      0.228299
      1.000000

__Is this a good model?__

```python
Xtest = X_test[['TV', 'Radio', 'Newspaper']]
ytest = X_test.Sales
Xtest = sm.add_constant(Xtest)

res.predict(Xtest);
```

```python
## To diagnose a linear model
## it's always a good idea to check residual plots

residuals = res.resid

y_pred = res.predict(X)
plt.scatter(y_pred, residuals)
plt.axhline(y=0, color = 'red', label = '0')

plt.xlabel('predicted values')

plt.ylabel('residuals')

plt.tight_layout()
```

- For how to solve some of the potential problems in linear regression: check 3.3.3 of ISLR

## Review

## Further Reading

- [What is the parameter copy_X in sklearn](https://stackoverflow.com/questions/52541717/what-is-the-parameter-copy-x-in-sklearn-linear-regression)

- https://newonlinecourses.science.psu.edu/stat501/node/252/

- https://towardsdatascience.com/simple-and-multiple-linear-regression-in-python-c928425168f9

- https://www.statsmodels.org/stable/examples/notebooks/generated/ols.html

- https://lagunita.stanford.edu/assets/courseware/v1/e4f2f4823252ead8c1a68722e3415c28/c4x/HumanitiesSciences/StatLearning/asset/linear_regression-handout.pdf

"Some of the figures in this presentation are taken from "An Introduction to Statistical Learning, with applications in R"  (Springer, 2013) with permission from the authors: G. James, D. Witten,  T. Hastie and R. Tibshirani "


# Introduction

This notebook demonstrates

- how to derive the equations of motion for the cart-mounted triple plendulum,
- how to interactively check the kinematics and
- how to simulate and animate the final system.

```python
import time
time.ctime()
```

    'Fri May  8 14:58:10 2020'

```python
%load_ext ipydex.displaytools

import sys
import os
import pickle
import numpy as np
from scipy.integrate import odeint

import sympy as sp
from sympy import sin, cos, Function
from sympy.interactive import printing
from matplotlib import pyplot as plt

import symbtools as st
from symbtools import modeltools as mt
import symbtools.visualisation as vt

printing.init_printing()
np.set_printoptions(precision=3, suppress=True)

```

Bild:

```python
# system parameters
params = sp.symbols('m0, m1, m2, m3, J1, J2, J3, l1, l2, l3, a1, a2, a3, g, d1, d2, d3')
st.make_global(params)

params_values = [(m0, 3.34), (m1, 0.8512), (m2, 0.8973), (m3, 0.5519),
                 (J1, 0.01980194), (J2, 0.02105375), (J3, 0.01818537),
                 (l1, 0.32), (l2, 0.419), (l3, 0.485),
                 (a1, 0.20001517), (a2, 0.26890449), (a3, 0.21666087), (g, 9.81),
                 (d1, 0.00715294), (d2, 1.9497e-06), (d3, 0.00164642)]

F = sp.Symbol('F')  # Force  on the cart

Np = 3  # number of passive coordinates
Nq = 1  # number of active coordinates
n = Np + Nq
pp = st.symb_vector("p1:{0}".format(Np+1))  # passive coordinates
qq = st.symb_vector("q1:{0}".format(Nq+1))  # active coordinates

aa = st.symb_vector("a1:{0}".format(Nq+1))  # accelerations

ttheta = st.row_stack(pp, qq) ##:T   # all coordinates together
tthetad = st.time_deriv(ttheta, ttheta) ##:T
tthetadd = st.time_deriv(ttheta, ttheta, order=2) ##:T
st.make_global(ttheta, tthetad)

```

Bedeutung der Variablen

$[p_1, p_2, p_3, q_1] = [\varphi_1, \varphi_2, \varphi_3, x_0]$

### Definition of the geometric relations of the system

```python
# unit vectors
ex = sp.Matrix([1, 0])
ey = sp.Matrix([0, 1])

# rotation matrix
Rz = mt.Rz
Rz(p1)  ##:
```

```python
# centers of masses (Si) and joints (Gi)

S0 = G0 = ex*q1 ##:

G1 = G0 + Rz(p1)*ey*l1 ##:
S1 = G0 + Rz(p1)*ey*a1 ##:

G2 = G1 + Rz(p2)*ey*l2 ##:
S2 = G1 + Rz(p2)*ey*a2 ##:

G3 = G2 + Rz(p3)*ey*l3 ##:
S3 = G2 + Rz(p3)*ey*a2 ##:

```

```python
# time derivatives of the coordinates of the centers of masses
S0dt, S1dt, S2dt, S3dt = st.col_split(st.time_deriv(st.col_stack(S0, S1, S2, S3), ttheta)) ##:
```

```python
# kinetic energy of the cart
T0 = 0.5 * m0 * qdot1**2
# kinetic energy of pendulum1
T1 = 0.5 * m1 * (S1dt.T * S1dt)[0] + 0.5 * J1 * pdot1**2
# kinetic energy of pendulum2
T2 = 0.5 * m2 * (S2dt.T * S2dt)[0] + 0.5 * J2 * pdot2**2
# kinetic energy of pendulum3
T3 = 0.5 * m3 * (S3dt.T * S3dt)[0] + 0.5 * J3 * pdot3**2

# total kinetic energy
T = T0 + T1 + T2 + T3

# total potential energy
V = g * (m1 * S1[1] + m2 * S2[1] + m3 * S3[1])

# velocity dependent friction
R = 0.5 * d1 * pdot1**2 + 0.5 * d2 * (pdot2 - pdot1)**2 + 0.5 * d3 * (pdot3 - pdot2)**2
```

### Interactive visualization of kinematics

```python
vis = vt.Visualiser(ttheta, xlim=(-4, 4), ylim=(-2, 2))
vis.add_linkage(st.col_stack(G0, G1, G2, G3).subs(params_values))
vis.interact(theta2=(-3.14, 3.14, 0.01))
```

    interactive(children=(FloatSlider(value=0.0, description='p1', max=5.0, min=-5.0), FloatSlider(value=0.0, desc…

```python
external_forces = [0, 0, 0, F] ##:
```

```python
if 1:
    %time mod = mt.generate_symbolic_model(T, V, ttheta, external_forces)
    # %time mod.calc_coll_part_lin_state_eq()
else:
    # save some time
    with open("model_triple_pendulum.pcl", "rb") as pfile:
        mod = pickle.load(pfile)
```

    CPU times: user 28.8 s, sys: 16 ms, total: 28.8 s
    Wall time: 28.9 s

```python
%time mod.calc_state_eq(simplify=False)

mod.f.co ##:T
```

```python
simmod = st.SimulationModel(mod.f, mod.g, mod.xx, model_parameters=params_values)

%time rhs = simmod.create_simfunction(use_sp2c=True)
```

    gcc -c -fPIC -lm /media/workcard/workstickdir/projekte/rst_python/symbtools-TUD-RST-Account/symbtools/vf_f.c -o /media/workcard/workstickdir/projekte/rst_python/symbtools-TUD-RST-Account/symbtools/vf_f.o

    gcc -shared /media/workcard/workstickdir/projekte/rst_python/symbtools-TUD-RST-Account/symbtools/vf_f.o -o /media/workcard/workstickdir/projekte/rst_python/symbtools-TUD-RST-Account/symbtools/vf_f.so

    loading  /media/workcard/workstickdir/projekte/rst_python/symbtools-TUD-RST-Account/symbtools/vf_f.so
    gcc -c -fPIC -lm /media/workcard/workstickdir/projekte/rst_python/symbtools-TUD-RST-Account/symbtools/matrix_G.c -o /media/workcard/workstickdir/projekte/rst_python/symbtools-TUD-RST-Account/symbtools/matrix_G.o

    gcc -shared /media/workcard/workstickdir/projekte/rst_python/symbtools-TUD-RST-Account/symbtools/matrix_G.o -o /media/workcard/workstickdir/projekte/rst_python/symbtools-TUD-RST-Account/symbtools/matrix_G.so

    loading  /media/workcard/workstickdir/projekte/rst_python/symbtools-TUD-RST-Account/symbtools/matrix_G.so
    CPU times: user 8.09 s, sys: 68 ms, total: 8.16 s
    Wall time: 9.18 s

```python
%%time

xx0 = np.array([1, 2, 3, -3,  0, 0, 0, 1])

# Simulate 5s
tt = np.linspace(0, 5, 1000)
res = odeint(rhs, xx0, tt)  ##:S
```

```python
plt.plot(tt, res)
```

```python
# Create object for Animation

simanim = vt.SimAnimation(mod.xx[:4], tt, res[:, :4], figsize=(14, 7))
simanim.add_visualiser(vis)

# plot first frame
simanim.plot_frame(0)
```

```python
# this is probably platform dependent:

plt.rcParams["animation.codec"] = "libvpx-vp9"  # codec for webm
plt.rcParams['animation.ffmpeg_path'] = os.path.join(os.getenv("CONDA_PREFIX"), "bin", "ffmpeg")
# plt.rcParams['animation.ffmpeg_path'] = '/usr/bin/ffmpeg'

```

```python
# render animation to video file

fname = 'triple_pendulum_animation_c.mp4'
# simanim.save(fname, dpi=50)
```

```python
vt.display_video_file(fname, width=800)
```