# Neuromatch Academy: Week 3, Day 2, Tutorial 1
# Sequential Probability Ratio Test

__Content creators:__ Yicheng Fei

__Content reviewers:__ John Butler, Matt Krause, Spiros Chavlis, Michael Waskom, Jesse Livezey, and Byron Galbraith

---
#Tutorial Objectives

In this tutorial, we will consider a simplified random dot motion task. On each trial $i$, we are shown a dot moving at velocity $v_i$, either in a leftward ($v<0$) or rightward ($v>0$) direction. Although the dots' velocity varies from trial to trial, the set of all $v_i$ are  generated by a fixed probability distribution, which we know to be either:
$$
\\
\begin{eqnarray}
p_L &=& \mathcal{N}\left(-1,\; \sigma^2\right) \\
&&\textrm{or} \\
p_R &=& \mathcal{N}\left(+1,\; \sigma^2\right) \\
\end{eqnarray}
\\
$$
We want to determine whether $p_L$ or $p_R$ is the true data generating distribution.

In W2D1, we learned how to combine the sensory evidence and our prior experience with Bayes' Theorem, producing a posterior probability distribution that would let us choose between the most probable of these *two* options: accepting hypothesis $H_L$, that the data comes from the $p_L$ distribution, or accepting $H_R$, that it comes from $p_R$.

Here, we add a *third* option: choosing to collect more evidence before making a decision.

---

In this notebook we will perform a *Sequential Probability Ratio Test* between two hypotheses $H_L$ and $H_R$ by running simulations of a *Drift Diffusion Model (DDM)*.

As independent and identically distributed (*i.i.d*) samples from the true data-generating distribution coming in, we accumulate our evidence linearly until a certain criterion is met before deciding which hypothesis to accept.  Two types of stopping criterion/stopping rule will be implemented: after seeing a fixed amount of data, and after the likelihood ratio passes a pre-defined threshold. Due to the noisy nature of observations, there will be a *drifting* term governed by expected mean output and a *diffusion* term governed by observation noise.

In this tutorial, you will

* Simulate Drift-Diffusion Model with different stopping rules.
* Observe the relation between accuracy and reaction time, get an intuition about the speed/accuracy tradeoff.

```python
#@title Video 1: Tutorial Objectives
# Insert the ID of the corresponding youtube video
from IPython.display import YouTubeVideo
video = YouTubeVideo(id="DGoPoLkDiUw", width=854, height=480, fs=1)
print("Video available at https://youtu.be/" + video.id)
video
```

---
# Setup

```python
# Imports
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt
```

```python
#@title Figure settings
import ipywidgets as widgets       # interactive display
%config InlineBackend.figure_format = 'retina'
plt.style.use("https://raw.githubusercontent.com/NeuromatchAcademy/course-content/master/nma.mplstyle")
```

```python
#@title Helper functions

def log_likelihood_ratio(xvec, p0, p1):
  """Given a sequence(vector) of observed data, calculate the log of
  likelihood ratio of p1 and p0

  Args:
    xvec (numpy vector):           A vector of scalar measurements
    p0 (Gaussian random variable): A normal random variable with `logpdf'
                                    method
    p1 (Gaussian random variable): A normal random variable with `logpdf`
                                    method

  Returns:
    llvec: a vector of log likelihood ratios for each input data point
  """
  return p1.logpdf(xvec) - p0.logpdf(xvec)

def simulate_and_plot_SPRT_fixedtime(sigma, stop_time, num_sample,
                                     verbose=True):
  """Simulate and plot a SPRT for a fixed amount of time given a std.

  Args:
    sigma (float): Standard deviation of the observations.
    stop_time (int): Number of steps to run before stopping.
    num_sample (int): The number of samples to plot.
    """

  evidence_history_list = []
  if verbose:
    print("#Trial\tTotal_Evidence\tDecision")
  for i in range(num_sample):
    evidence_history, decision, data = simulate_SPRT_fixedtime(sigma, stop_time)
    if verbose:
      print("{}\t{:f}\t{}".format(i, evidence_history[-1], decision))
    evidence_history_list.append(evidence_history)

  fig, ax = plt.subplots()
  maxlen_evidence = np.max(list(map(len,evidence_history_list)))
  ax.plot(np.zeros(maxlen_evidence), '--', c='red', alpha=1.0)
  for evidences in evidence_history_list:
    ax.plot(np.arange(len(evidences)), evidences)
    ax.set_xlabel("Time")
    ax.set_ylabel("Cumulated log likelihood ratio")
    ax.set_title("Log likelihood ratio trajectories under the fixed-time " +
                  "stopping rule")

  plt.show(fig)

def simulate_and_plot_accuracy_vs_stoptime(sigma, stop_time_list, num_sample):
  """Simulate and plot a SPRT for a fixed amount of times given a std.

  Args:
    sigma (float): Standard deviation of the observations.
    stop_time_list (int): List of number of steps to run before stopping.
    num_sample (int): The number of samples to plot.
  """
  accuracy_list, _ = simulate_accuracy_vs_stoptime(sigma, stop_time_list,
                                                   num_sample)

  fig, ax = plt.subplots()
  ax.plot(stop_time_list, accuracy_list)
  ax.set_xlabel('Stop Time')
  ax.set_ylabel('Average Accuracy')

  plt.show(fig)

def simulate_and_plot_SPRT_fixedthreshold(sigma, num_sample, alpha,
                                          verbose=True):
  """Simulate and plot a SPRT for a fixed amount of times given a std.

  Args:
    sigma (float): Standard deviation of the observations.
    num_sample (int): The number of samples to plot.
    alpha (float): Threshold for making a decision.
  """
  # calculate evidence threshold from error rate
  threshold = threshold_from_errorrate(alpha)

  # run simulation
  evidence_history_list = []
  if verbose:
    print("#Trial\tTime\tCumulated Evidence\tDecision")
  for i in range(num_sample):
    evidence_history, decision, data = simulate_SPRT_threshold(sigma, threshold)
    if verbose:
      print("{}\t{}\t{:f}\t{}".format(i, len(data), evidence_history[-1],
                                      decision))
    evidence_history_list.append(evidence_history)

  fig, ax = plt.subplots()
  maxlen_evidence = np.max(list(map(len,evidence_history_list)))
  ax.plot(np.repeat(threshold,maxlen_evidence + 1), c="red")
  ax.plot(-np.repeat(threshold,maxlen_evidence + 1), c="red")
  ax.plot(np.zeros(maxlen_evidence + 1), '--', c='red', alpha=0.5)

  for evidences in evidence_history_list:
      ax.plot(np.arange(len(evidences) + 1), np.concatenate([[0], evidences]))

  ax.set_xlabel("Time")
  ax.set_ylabel("Cumulated log likelihood ratio")
  ax.set_title("Log likelihood ratio trajectories under the threshold rule")

  plt.show(fig)

def simulate_and_plot_accuracy_vs_threshold(sigma, threshold_list, num_sample):
  """Simulate and plot a SPRT for a set of thresholds given a std.

  Args:
    sigma (float): Standard deviation of the observations.
    alpha_list (float): List of thresholds for making a decision.
    num_sample (int): The number of samples to plot.
  """
  accuracies, decision_speeds = simulate_accuracy_vs_threshold(sigma,
                                                               threshold_list,
                                                               num_sample)

  # Plotting
  fig, ax = plt.subplots()
  ax.plot(decision_speeds, accuracies, linestyle="--", marker="o")
  ax.plot([np.amin(decision_speeds), np.amax(decision_speeds)],
          [0.5, 0.5], c='red')
  ax.set_xlabel("Average Decision speed")
  ax.set_ylabel('Average Accuracy')
  ax.set_title("Speed/Accuracy Tradeoff")
  ax.set_ylim(0.45, 1.05)

  plt.show(fig)

def threshold_from_errorrate(alpha):
  """Calculate log likelihood ratio threshold from desired error rate `alpha`

  Args:
    alpha (float): in (0,1), the desired error rate

  Return:
    threshold: corresponding evidence threshold
  """
  threshold = np.log((1. - alpha) / alpha)
  return threshold
```

---

# Section 1: Introduction to the SPRT

### Sequential Probability Ratio Test(SPRT)

Suppose we receive a sequence of independent samples from distribution $p$. We know that $p$ is from $\{p_0,p_1\}$ determined by a binary latent variable $x$ and need to test between the two hyptheses:

$H_L: p=p_L \text{ or } x=0$

$H_R: p=p_R \text{ or } x=1$

When we see $n$ samples $\{x_{1}...x_n\}$, we want to calculate the total log likelihood ratio as our evidence for decision:

$$S_n = \log \frac{\prod_{i=1}^n p_R(x_i)}{\prod_{i=1}^n p_L(x_i)} = \sum_{i=1}^n \log p_R(x_i) - \sum_{i=1}^n \log p_L(x_i) \tag{1}$$

Due to the independence of samples, this can be calculated in a incremental way when new data points come in sequentially:

$$ S_n =  S_{n-1} + \log \frac{p_R(x_n)}{p_L(x_n)} = S_{n-1} + \log \Lambda_n \tag{2}$$

The stopping rule can be implemented in two ways:

1. Fixed time

Make a decision based on $S_n$ immediately when we have collected $n$ samples. That is, accept $H_R$ if $S_n > 0$, accept $H_L$ if $S_n < 0$, and accept $H_R$ with probability $\frac{1}{2}$ if $S_n = 0$. The significance level or desired error rate $\alpha$ can then be determined as

$$\alpha = \frac{1}{1+\exp(|S_n|)} \tag{4}$$

2. Thresholding

We assume equal probability to make a false positive decision and to make a false negative decision, and denote it with $\alpha$. Then we accept hypothesis $H_R$ if $S_n \ge b$ or accept hypothesis $H_L$ if $S_n \le a$ where the thresholds are determined by

$$a=\log \frac{\alpha}{1-\alpha} \;\; \; b=\log \frac{1-\alpha}{\alpha}
\tag{3}$$
(Note that these are the same value with opposite signs: $a = -b).$

## Section 1.1: SPRT as a Drift Diffusion Model (DDM)

```python
#@title Video 2: SPRT and the Random Dot Motion Task
from IPython.display import YouTubeVideo
video = YouTubeVideo(id="7WBB4M_Vf58", width=854, height=480, fs=1)
print("Video available at https://youtu.be/" + video.id)
video
```

SPRT as a Drift Diffusion Model (DDM)

Let's assume two different Gaussian observation models conditioned on discrete latent variable $z$

$$p_L(x|z=0) = \mathcal{N}\left(\mu_L,\sigma_L^2\right)$$

$$p_R(x|z=1) = \mathcal{N}\left(\mu_R,\sigma_R^2\right)$$

Then the log likelihood ratio for a single data point $x_i$ is

$$ \log \Lambda_i = \log \frac{\sigma_L}{\sigma_R} -0.5 \left[\frac{\left(x_i-\mu_R\right)^2}{\sigma_R^2} - \frac{(x_i-\mu_L)^2}{\sigma_L^2}\right] \tag{5}$$

Without loss of generality, let's further assume the true data generating distribution is $p_R$. In this case $x_i$ can be expressed as $x_i = \mu_R + \sigma_R \epsilon$ where $\epsilon$ comes from a standard Gaussian. The foregoing formula can then be rewritten as

$$
\log \Lambda_i = \left( \log \frac{\sigma_L}{\sigma_R} + 0.5 \frac{\left(\mu_R-\mu_L\right)^2}{\sigma_L^2} \right) + \left( \frac{\mu_R-\mu_L}{\sigma_L^2}\epsilon -0.5\left[1-\left(\frac{\sigma_R}{\sigma_L}\right)^2\right]\epsilon^2  \right) \tag{6}
$$

where the first two constant terms serve as the *drifting* part and the last terms are the *diffusion* part. If we further let $\sigma_L=\sigma_R$, we can get rid of the quadratic term and this reduces to the classical discrete drift-diffusion equation where we have analytical solutions for mean and expected auto-covariance:

$$
\log \Lambda_i = 0.5 \frac{(\mu_R-\mu_L)^2}{\sigma_L^2}  + \frac{\mu_R-\mu_L}{\sigma_L^2}\epsilon, \text{where } \epsilon \sim \mathcal{N}(0,1)
$$

## Section 1.2: Simulating DDM with fixed-time stopping rule

```python
#@title Video 3: Simulate the DDM with a fixed-time stopping rule
from IPython.display import YouTubeVideo
video = YouTubeVideo(id="9WNAZnEa64Y", width=854, height=480, fs=1)
print("Video available at https://youtu.be/" + video.id)
video
```

Exercise 1: Simulating an SPRT model
---

Assume we are performing a random dot motion task and at each time we see a moving dot with velocity $x_t$. All data points are sampled from the same distribution $p$, which is either $p_L=\mathcal{N}\left(-\mu,\sigma^2\right)$ or $p_R=\mathcal{N}\left(\mu,\sigma^2\right)$. Let's now generate some simulated data under this setting and perform SPRT using the fixed time stopping rule.

In this exercise, without loss of generality, we assume the true data-generating model is $p_R$.

* Complete the code in function `simulate_SPRT_fixedtime` to create two Gaussian random variables to represent our observation models.
* Complete the function `log_likelihood_ratio` to calculate log likelihood ratio for a sequence of data points
* Complete the code in function `simulate_SPRT_fixedtime` to calculate cumulated evidence given a list of individual evidences
* Run 10 simulations and plot the DDM traces by commenting out our provided code

```python
def simulate_SPRT_fixedtime(sigma, stop_time, true_dist=1):
  """Simulate a Sequential Probability Ratio Test with fixed time stopping
  rule. Two observation models are 1D Gaussian distributions N(1,sigma^2) and
  N(-1,sigma^2).

  Args:
    sigma (float):      Standard deviation
    stop_time (int):    Number of samples to take before stopping
    true_dist (0 or 1): Which state is the true state.

  Returns:
    evidence_history (numpy vector): the history of cumulated evidence given
                                      generated data
    decision (int): 1 for pR, 0 for pL
    data (numpy vector): the generated sequences of data in this trial
  """
  muL = -1.0
  muR = 1.0
  ############################################################################
  # Insert your code here to:
  #      Create two Gaussian variables `pL` and `pR` with mean `muL` and
  #      `muR` respectively and same std. `sigma`
  #      Hint: using `stats.norm(loc=..., scale=...)` to construct an
  #      instance of 1D Gaussian distribution
  raise NotImplementedError("`simulate_SPRT_fixedtime` is incomplete")
  ############################################################################
  pL = stats.norm(loc=..., scale=...)
  pR = stats.norm(loc=..., scale=...)

  # Generate a random sequence of data
  if true_dist == 1:
    data = pR.rvs(size=stop_time)
  else:
    data = pL.rvs(size=stop_time)

  # Calculate cumulated evidence
  ll_ratio_vec = log_likelihood_ratio(data, pL, pR)

  ############################################################################
  # Insert your code here to:
  #      Calculate cumulated evidence given a vector of individual evidences
  #      Hint: use `np.cumsum`
  ############################################################################
  evidence_history = ...

  # Make decision
  if evidence_history[-1] > 0:
    decision = 1
  elif evidence_history[-1] < 0:
    decision = 0
  else:
    decision = np.random.randint(2)

  return evidence_history, decision, data

np.random.seed(100)
sigma = 3.5  # standard deviation for pL and pR
num_sample = 10  # number of simulations to run for each stopping time
stop_time = 150 # stopping time

################################################################################
# Un-comment the following code block after completing this exercise
################################################################################
# simulate_and_plot_SPRT_fixedtime(sigma, stop_time, num_sample)
```

```python
# to_remove solution
def simulate_SPRT_fixedtime(sigma, stop_time, true_dist=1):
  """Simulate a Sequential Probability Ratio Test with fixed time stopping
  rule. Two observation models are 1D Gaussian distributions N(1,sigma^2) and
  N(-1,sigma^2).

  Args:
    sigma (float): Standard deviation
    stop_time (int): Number of samples to take before stopping
    true_dist (0 or 1): Which state is the true state.

  Returns:
    evidence_history (numpy vector): the history of cumulated evidence given
                                      generated data
    decision (int): 1 for pR, 0 for pL
    data (numpy vector): the generated sequences of data in this trial
  """
  muL = -1.0
  muR = 1.0

  pL = stats.norm(loc=-1, scale=sigma)
  pR = stats.norm(loc=1, scale=sigma)

  # Generate a random sequence of data
  if true_dist == 1:
    data = pR.rvs(size=stop_time)
  else:
    data = pL.rvs(size=stop_time)

  # Calculate cumulated evidence
  ll_ratio_vec = log_likelihood_ratio(data, pL, pR)

  evidence_history = np.cumsum(ll_ratio_vec)

  # Make decision
  if evidence_history[-1] > 0:
    decision = 1
  elif evidence_history[-1] < 0:
    decision = 0
  else:
    decision = np.random.randint(2)

  return evidence_history, decision, data

np.random.seed(100)
sigma = 3.5  # standard deviation for pL and pR
num_sample = 10  # number of simulations to run for each stopping time
stop_time = 150 # stopping time

with plt.xkcd():
  simulate_and_plot_SPRT_fixedtime(sigma, stop_time, num_sample)
```

### Interactive Demo: Trajectories under the fixed-time stopping rule

Now let's look at how the dynamics change if you change the noise level and stopping time.

* Play with different noise levels and stopping times and observe the corresponding trajectories. Once you have completed exercise 1, check the box to enable the demo.

```python
#@title

#@markdown Make sure you execute this cell to enable the widget!
np.random.seed(100)
num_sample = 10

@widgets.interact
def plot(sigma=(0.05, 10.0, 0.05), stop_time=(5, 500, 1)):
  simulate_and_plot_SPRT_fixedtime(sigma, stop_time, num_sample, verbose=False)
```

---
# Section 2: Accuracy vs. Stopping time

```python
#@title Video 4: Speed/Accuracy Tradeoff
from IPython.display import YouTubeVideo
video = YouTubeVideo(id="E8lvgFeIGQM", width=854, height=480, fs=1)
print("Video available at https://youtu.be/" + video.id)
video
```

### Exercise 2: The Speed/Accuracy Tradeoff

If you stop taking samples too early, (e.g., make a decision after only seeing 5 samples), or there's a huge amount of observation noise that buries the signal, you are likely to be driven by observation noise to a negative cumulated log likelihood ratio and thus make a wrong decision. You could get a sense of this by increasing noise level or decreasing stopping time in the last exercise.

Now let's look at how decision accuracy varies with the number of samples we see quantitatively. First we'll fix our observation noise level. In this exercise you will run several repeated simulations for a certain stopping time to calculate the average decision accuracy. Accuracy is simply defined as the proportion of correct trials across our repeated simulations: $\frac{\# \textrm{ correct decisions}}{\# \textrm{ total simulation runs}}$

Do this for a range of stopping times and plot the relation between average decision accuracy and stopping time. You should get a positive correlation between these two quantities.

* Choose a noise level. For example, $\sigma=3$
* Complete the function `simulate_accuracy_vs_stoptime` to simulate and compute corresponding average accuracies for a list of stopping times.
* Plot accuracy versus stopping time using the pre-written codes

```python
sigma = 4.65  # standard deviation for observation noise
num_sample = 200  # number of simulations to run for each stopping time
stop_time_list = np.arange(1, 150, 10) # Stopping times to play with

def simulate_accuracy_vs_stoptime(sigma, stop_time_list, num_sample):
  """Calculate the average decision accuracy vs. stopping time by running
  repeated SPRT simulations for each stop time.

  Args:
      sigma (float): standard deviation for observation model
      stop_list_list (list-like object): a list of stopping times to run over
      num_sample (int): number of simulations to run per stopping time

  Returns:
      accuracy_list: a list of average accuracies corresponding to input
                      `stop_time_list`
      decisions_list: a list of decisions made in all trials
  """
  accuracy_list = []
  decisions_list = []
  for stop_time in stop_time_list:
    decision_list = []
    ########################################################################
    # Insert your code here to:
    #      * Run `num_sample` repeated simulations, collect decision into
    #        `decision_list`
    #      * Calculate average decision accuracy as `accuracy`
    #      * Hint: use the function you wrote in the last exercise
    raise NotImplementedError("`simulate_accuracy_vs_stoptime` is incomplete")
    ########################################################################
    for i in range(num_sample):
      _, decision, _= ...
      decision_list.append(decision)

    # Calculate accuracy given the true decision is 1
    accuracy = ...
    accuracy_list.append(accuracy)
    decisions_list.append(decision_list)

  return accuracy_list, decisions_list

np.random.seed(100)
################################################################################
# Un-comment the following code after completing this exercise
################################################################################
# simulate_and_plot_accuracy_vs_stoptime(sigma, stop_time_list, num_sample)
```

```python
# to_remove solution
sigma = 4.65  # standard deviation for observation noise
num_sample = 200  # number of simulations to run for each stopping time
stop_time_list = np.arange(1, 150, 10) # Stopping times to play with

def simulate_accuracy_vs_stoptime(sigma, stop_time_list, num_sample):
  """Calculate the average decision accuracy vs. stopping time by running
  repeated SPRT simulations for each stop time.

  Args:
      sigma (float): standard deviation for observation model
      stop_list_list (list-like object): a list of stopping times to run over
      num_sample (int): number of simulations to run per stopping time

  Returns:
      accuracy_list: a list of average accuracies corresponding to input
                      `stop_time_list`
      decisions_list: a list of decisions made in all trials
  """
  accuracy_list = []
  decisions_list = []
  for stop_time in stop_time_list:
    decision_list = []
    for i in range(num_sample):
      _, decision, _= simulate_SPRT_fixedtime(sigma, stop_time)
      decision_list.append(decision)

    # Calculate accuracy given the true decision is 1
    accuracy = sum(decision_list) / len(decision_list)
    accuracy_list.append(accuracy)
    decisions_list.append(decision_list)

  return accuracy_list, decisions_list

np.random.seed(100)
with plt.xkcd():
  simulate_and_plot_accuracy_vs_stoptime(sigma, stop_time_list, num_sample)
```

### Interactive Demo: Accuracy versus stop-time

**Suggestions**

* Play with difference values of  noise level `sigma` and observe how that affects the curve. What does that mean?

```python
#@title

#@markdown Make sure you execute this cell to enable the widget!
np.random.seed(100)
num_sample = 100
stop_time_list = np.arange(1, 150, 10)

@widgets.interact
def plot(sigma=(0.05, 10.0, 0.05)):
  simulate_and_plot_accuracy_vs_stoptime(sigma, stop_time_list, num_sample)
```

---
# Section 3: DDM with fixed thresholds

### Exercise 3: Simulating the DDM with fixed thresholds

In this exercise, we will use thresholding as our stopping rule and observe the behavior of the DDM.

With thresholding stopping rule, we define a desired error rate and will continue making measurements until that error rate is reached. Experimental evidence suggested that evidence accumulation and thresholding stopping strategy happens at neuronal level (see [this article](https://www.annualreviews.org/doi/full/10.1146/annurev.neuro.29.051605.113038) for further reading).

* Complete the function `threshold_from_errorrate` to calculate the evidence threshold from desired error rate $\alpha$ as described in the formulas below. The evidence thresholds $th_L$ and $th_R$ for $p_L$ and $p_R$ are opposite of each other as shown below, so you can just return the absolute value.
$$
\begin{align}
 th_{L} &= \log \frac{\alpha}{1-\alpha} &= -th_{R} \\
 th_{R} &= \log \frac{1-\alpha}{\alpha} &= -th{_L}\\
 \end{align}
 $$

* Complete the function `simulate_SPRT_threshold` to simulate an SPRT with thresholding stopping rule given noise level and desired threshold

* Run repeated simulations for a given noise level and a desired error rate visualize the DDM traces using our provided code

```python
def simulate_SPRT_threshold(sigma, threshold , true_dist=1):
  """Simulate a Sequential Probability Ratio Test with thresholding stopping
  rule. Two observation models are 1D Gaussian distributions N(1,sigma^2) and
  N(-1,sigma^2).

  Args:
    sigma (float): Standard deviation
    threshold (float): Desired log likelihood ratio threshold to achieve
                        before making decision

  Returns:
    evidence_history (numpy vector): the history of cumulated evidence given
                                      generated data
    decision (int): 1 for pR, 0 for pL
    data (numpy vector): the generated sequences of data in this trial
  """
  muL = -1.0
  muR = 1.0

  pL = stats.norm(muL, sigma)
  pR = stats.norm(muR, sigma)

  has_enough_data = False

  data_history = []
  evidence_history = []
  current_evidence = 0.0

  # Keep sampling data until threshold is crossed
  while not has_enough_data:
    if true_dist == 1:
      data = pR.rvs()
    else:
      data = pL.rvs()

    ########################################################################
    # Insert your code here to:
    #      * Calculate the log-likelihood ratio for the new sample
    #      * Update the accumulated evidence
    raise NotImplementedError("`simulate_SPRT_threshold` is incomplete")
    ########################################################################
    # individual log likelihood ratios
    ll_ratio = log_likelihood_ratio(...)
    # cumulated evidence for this chunk
    evidence_history.append(...)
    # update the collection of all data
    data_history.append(data)
    current_evidence = evidence_history[-1]

    # check if we've got enough data
    if abs(current_evidence) > threshold:
      has_enough_data = True

  data_history = np.array(data_history)
  evidence_history = np.array(evidence_history)

  # Make decision
  if evidence_history[-1] > 0:
    decision = 1
  elif evidence_history[-1] < 0:
    decision = 0
  else:
    decision = np.random.randint(2)

  return evidence_history, decision, data_history

np.random.seed(100)
sigma = 2.8
num_sample = 10
log10_alpha = -6.5 # log10(alpha)
alpha = np.power(10.0, log10_alpha)

################################################################################
# Un-comment the following code after completing this exercise
################################################################################
# simulate_and_plot_SPRT_fixedthreshold(sigma, num_sample, alpha)
```

```python
# to_remove solution
def simulate_SPRT_threshold(sigma, threshold , true_dist=1):
  """Simulate a Sequential Probability Ratio Test with thresholding stopping
  rule. Two observation models are 1D Gaussian distributions N(1,sigma^2) and
  N(-1,sigma^2).

  Args:
    sigma (float): Standard deviation
    threshold (float): Desired log likelihood ratio threshold to achieve
                        before making decision

  Returns:
    evidence_history (numpy vector): the history of cumulated evidence given
                                      generated data
    decision (int): 1 for pR, 0 for pL
    data (numpy vector): the generated sequences of data in this trial
  """
  muL = -1.0
  muR = 1.0

  pL = stats.norm(muL, sigma)
  pR = stats.norm(muR, sigma)

  has_enough_data = False

  data_history = []
  evidence_history = []
  current_evidence = 0.0

  # Keep sampling data until threshold is crossed
  while not has_enough_data:
    if true_dist == 1:
      data = pR.rvs()
    else:
      data = pL.rvs()

    # individual log likelihood ratios
    ll_ratio = log_likelihood_ratio(data, pL, pR)
    # cumulated evidence for this chunk
    evidence_history.append(ll_ratio + current_evidence)
    # update the collection of all data
    data_history.append(data)
    current_evidence = evidence_history[-1]

    # check if we've got enough data
    if abs(current_evidence) > threshold:
      has_enough_data = True

  data_history = np.array(data_history)
  evidence_history = np.array(evidence_history)

  # Make decision
  if evidence_history[-1] > 0:
    decision = 1
  elif evidence_history[-1] < 0:
    decision = 0
  else:
    decision = np.random.randint(2)

  return evidence_history, decision, data_history

np.random.seed(100)
sigma = 2.8
num_sample = 10
log10_alpha = -6.5 # log10(alpha)
alpha = np.power(10.0, log10_alpha)

with plt.xkcd():
  simulate_and_plot_SPRT_fixedthreshold(sigma, num_sample, alpha)
```

### Interactive Demo: DDM with fixed threshold

**Suggestion**

* Play with difference values of `alpha` and `sigma` and observe how that affects the dynamics of Drift-Diffusion Model.

```python
#@title

#@markdown Make sure you execute this cell to enable the widget!
np.random.seed(100)
num_sample = 10

@widgets.interact
def plot(sigma=(0.05, 10.0, 0.05), log10_alpha=(-8, -1, .1)):
  alpha = np.power(10.0, log10_alpha)
  simulate_and_plot_SPRT_fixedthreshold(sigma, num_sample, alpha, verbose=False)
```

---

# Section 4: Accuracy vs. Threshold

### Exercise 4: Speed/Accuracy Tradeoff Revisited

The faster you make a decision, the lower your accuracy often is. This phenomenon is known as the **speed/accuracy tradeoff**. Humans can make this tradeoff in a wide range of situations, and many animal species, including ants, bees, rodents, and monkeys also show similar effects.

To illustrate the speed/accuracy tradeoff under thresholding stopping rule, let's run some simulations under different thresholds and look at how average decision "speed" (1/length) changes with average decision accuracy. We use speed rather than accuracy because in real experiments, subjects can be incentivized to respond faster or slower; it's much harder to precisely control their decision time or error threshold.

* Complete the function `simulate_accuracy_vs_threshold` to simulate and compute average accuracies vs. average decision lengths for a list of error thresholds. You will need to supply code to calculate average decision 'speed' from the lengths of trials. You should also calculate the overall accuracy across these trials.

* We've set up a list of error thresholds. Run repeated simulations and collect average accuracy with average length for each error rate in this list, and use our provided code to visualize the speed/accuracy tradeoff. You should see a positive correlation between length and accuracy.

```python
def simulate_accuracy_vs_threshold(sigma, threshold_list, num_sample):
  """Calculate the average decision accuracy vs. average decision length by
  running repeated SPRT simulations with thresholding stopping rule for each
  threshold.

  Args:
      sigma (float): standard deviation for observation model
      threshold_list (list-like object): a list of evidence thresholds to run
                                          over
      num_sample (int): number of simulations to run per stopping time

  Returns:
      accuracy_list: a list of average accuracies corresponding to input
                      `threshold_list`
      decision_speed_list: a list of average decision speeds
  """
  decision_speed_list = []
  accuracy_list = []
  for threshold in threshold_list:
    decision_time_list = []
    decision_list = []
    for i in range(num_sample):
      # run simulation and get decision of current simulation
      _, decision, data = simulate_SPRT_threshold(sigma, threshold)
      decision_time = len(data)
      decision_list.append(decision)
      decision_time_list.append(decision_time)

    ########################################################################
    # Insert your code here to:
    #      * Calculate mean decision speed given a list of decision times
    #      * Hint: Think about speed as being inversely proportional
    #        to decision_length. If it takes 10 seconds to make one decision,
    #        our "decision speed" is 0.1 decisions per second.
    #      * Calculate the decision accuracy
    raise NotImplementedError("`simulate_accuracy_vs_threshold` is incomplete")
    ########################################################################
    # Calculate and store average decision speed and accuracy
    decision_speed = ...
    decision_accuracy = ...
    decision_speed_list.append(decision_speed)
    accuracy_list.append(decision_accuracy)

  return accuracy_list, decision_speed_list

################################################################################
# Un-comment the following code block after completing this exercise
################################################################################
# np.random.seed(100)
# sigma = 3.75
# num_sample = 200
# alpha_list = np.logspace(-2, -0.1, 8)
# threshold_list = threshold_from_errorrate(alpha_list)
# simulate_and_plot_accuracy_vs_threshold(sigma, threshold_list, num_sample)
```

```python
# to_remove solution
def simulate_accuracy_vs_threshold(sigma, threshold_list, num_sample):
  """Calculate the average decision accuracy vs. average decision speed by
  running repeated SPRT simulations with thresholding stopping rule for each
  threshold.

  Args:
      sigma (float): standard deviation for observation model
      threshold_list (list-like object): a list of evidence thresholds to run
                                          over
      num_sample (int): number of simulations to run per stopping time

  Returns:
      accuracy_list: a list of average accuracies corresponding to input
                      `threshold_list`
      decision_speed_list: a list of average decision speeds
  """
  decision_speed_list = []
  accuracy_list = []
  for threshold in threshold_list:
    decision_time_list = []
    decision_list = []
    for i in range(num_sample):
      # run simulation and get decision of current simulation
      _, decision, data = simulate_SPRT_threshold(sigma, threshold)
      decision_time = len(data)
      decision_list.append(decision)
      decision_time_list.append(decision_time)

    # Calculate and store average decision speed and accuracy
    decision_speed = np.mean(1. / np.array(decision_time_list))
    decision_accuracy = sum(decision_list) / len(decision_list)
    decision_speed_list.append(decision_speed)
    accuracy_list.append(decision_accuracy)

  return accuracy_list, decision_speed_list

np.random.seed(100)
sigma = 3.75
num_sample = 200
alpha_list = np.logspace(-2, -0.1, 8)
threshold_list = threshold_from_errorrate(alpha_list)
with plt.xkcd():
  simulate_and_plot_accuracy_vs_threshold(sigma, threshold_list, num_sample)
```

### Interactive Demo: Speed/Accuracy with a threshold rule

**Suggestions**

* Play with difference values of  noise level `sigma` and observe how that affects the speed/accuracy tradeoff.

```python
#@title

#@markdown Make sure you execute this cell to enable the widget!
np.random.seed(100)
num_sample = 100
alpha_list = np.logspace(-2, -0.1, 8)
threshold_list = threshold_from_errorrate(alpha_list)

@widgets.interact
def plot(sigma=(0.05, 10.0, 0.05)):
  alpha = np.power(10.0, log10_alpha)
  simulate_and_plot_accuracy_vs_threshold(sigma, threshold_list, num_sample)
```

---
# Summary

Good job! By simulating Drift Diffusion Models to perform decision making, you have learnt how to

1. Calculate individual sample evidence as the log likelihood ratio of two candidate models, accumulate evidence from new data points, and make decision based on current evidence in `Exercise 1`
2. Run repeated simulations to get an estimate of decision accuraries in `Exercise 2`
3. Implement the thresholding stopping rule where we can control our error rate by taking adequate amounts of data, and calculate the evidence threshold from desired error rate in `Exercise 3`
4. Explore and gain intuition about the speed/accuracy tradeoff for perceptual decision making in `Exercise 4`


```python
from sympy.physics.continuum_mechanics.beam import Beam
from sympy import symbols
```

# 👩💻 Workbook 02: String and list manipulation

This workbook is about learning how to manually manipulate string data that is read from files.

To do this, you will be reading data from three files: "beam_1.txt", "beam_2.txt", and "beam_3.txt".

The file format of each text file is as follows. Values shown in square brackets `[]` are optional data and may or may not appear in the file.

```python
Length, [E, I] # Line 0
Support locations # Line 1
Load Magnitude, Load Start, Load Order, [Load End] # Line 2
Load Magnitude, Load Start, Load Order, [Load End] # Line 3 ...
... # etc.
```

All the lines below the first two lines will be loads. There can be zero, one, or many lines of loads.

## Task A

Use the "file opening" template to open the file, "beam_1.txt". Assign the `file.read()` data to a variable named `beam_1_data`. Use `print()` to print `beam_1_data` to the notebook.

Review the description of the file format above and mentally identify each piece of data in the file with what it represents in the description of the file format.

Do the same with "beam_2.txt", except with a variable called `beam_2_data`, after you print it to the notebook, also.

## Task A response

```python
file_name = "beam_1.txt" # Same dir as notebook file
with open(file_name, 'r') as file:
    beam_1_data = file.read()

print(beam_1_data)
```

    4800
    0, 3000
    10, 2000, 0

```python
file_name = "beam_2.txt" # Same dir as notebook file
with open(file_name, 'r') as file:
    beam_2_data = file.read()

print(beam_2_data)
```

    228, 28000, 756
    63, 100, 200
    15, 0, 0
    35, 0, 0

# Task B

Use `.split()` to split the string in `beam_1_data` into separate lines. Access each line in the resulting list by using indexing and print each line to the notebook separately.

Following that, if you see that any of the lines in the list is a string with comma-separated values, split those values up into another list by splitting that line with `.split()` using the appropriate character sequence.

## Task B response

```python
beam_1_lines = beam_1_data.split("\n")
print(beam_1_lines[0])
print(beam_1_lines[1])
print(beam_1_lines[2])
```

    4800
    0, 3000
    10, 2000, 0

# Task C

Using a combination of splitting and indexing, assign each piece of data in the file to an appropriately named variable.

For example, you might extract the length from the file and assign it to a variable called `length`. If there are two support locations, they could be `support_location_1` and `support_location_2`, etc.

Try reducing the amount you have to type by assigning intermediary values (e.g. the list of split lines) to intermediary variable names.

Check the type of your variables by printing the result of `type(<variable_name>)`. Are your "numbers" numbers?

You can convert strings of integers to actual integers by using the `int()` function.

e.g.
```python
length = int(length)
```

## Task C response

```python
length = int(beam_1_lines[0])
supports = beam_1_lines[1].split(", ")
support_location_1 = int(supports[0])
support_location_2 = int(supports[1])

print(type(length), length)
print(type(supports), supports)
print(type(support_location_1), support_location_1)
print(type(support_location_2), support_location_2)
```

    <class 'int'> 4800
    <class 'list'> ['0', '3000']
    <class 'int'> 0
    <class 'int'> 3000

# Task D

By combining the work you have done in the cells above, copy-and-paste them into the cell below to create the beginning of a script to read beam data from a text file and then create a `Beam` object with `sympy`.

Feel free to refer back to your Workbook 01 and the sympy beam reference guide.

Putting it all together, you will be performing the following:

1. Read the contents of the file using `.read()` and store the resulting string in a variable called `beam_1_data`.
2. Using the information about the file format described above, use string methods, list methods, and indexing to extract data from the file. Create the following variables:
* `length`
* `E`
* `I`
    * If `E` and `I` are not in the file, create and assign `E` and `I` as sympy symbols.
* Supports
    * Create separate variables for each support location, e.g. `support_location_1`, `support_location_2`, etc.
    * `support_location_...`
* Loads
    * Create separate variables for each load, e.g. `load_magnitude_1`, `load_magnitude_2`, etc.
    * `load_magnitude_...`
    * `load_start_...`
    * `load_order_...`
    * `load_end_...` (if applicable)

3. Create a new `Beam` object using the variables containing the data you read from the file. **Assume all supports are vertical point loads supports.** _Remember_, in addition to creating a sympy `symbol` for each unknown, you will also need to create a `.bc_deflection` list of restraints (refer to your Workbook 01 and the sympy beams reference guide).

4. Solve the `Beam` for reaction loads. Print reaction loads, plot the shear force and bending moment diagrams. If `E` and `I` are in the file, also plot the displacement.

## Beam: Two supports, one load

```python
with open("beam_1.txt", 'r') as file:
    beam_1_data = file.read()

b1_lines = beam_1_data.split("\n")

length = int(beam_1_lines[0])

supports = beam_1_lines[1].split(", ")

support_location_1 = int(supports[0])
support_location_2 = int(supports[1])

load_magnitude_1 = b1_lines[2].split(",")[0]
load_start_1 = b1_lines[2].split(",")[1]
load_order_1 = b1_lines[2].split(",")[2]

load_magnitude_1 = int(load_magnitude_1)
load_start_1 = int(load_start_1)
load_order_1 = int(load_order_1)

E, I = symbols("E I")
R1, R2 = symbols("R1 R2")

b1_beam = Beam(length, E, I)
b1_beam.apply_load(R1, support_location_1, -1)
b1_beam.apply_load(R2, support_location_2, -1)
b1_beam.apply_load(load_magnitude_1, load_start_1, load_order_1)
b1_beam.bc_deflection = [(support_location_1, 0), (support_location_2, 0)]

b1_beam.solve_for_reaction_loads(*[R1, R2])
print(b1_beam.reaction_loads)
b1_beam.plot_shear_force()
b1_beam.plot_bending_moment()
```

## Task 01 Response

# Task E

Perform the same instructions from **Task 1** with "beam_2.txt" except call your variable `beam_2_data`.

Save yourself some time by copy-and-paste-ing your code from **Task D** and try modifying it to make it work. Note, the number of supports and number of loads are different in "beam_2.txt".

## Beam: Three supports, two loads

```python
with open("beam_2.txt", 'r') as file:
    beam_2_data = file.read()

b2_lines = beam_2_data.split("\n")

length, E, I = b2_lines[0].split(", ")
length = int(length)

support_location_1 = b2_lines[1].split(", ")[0]
support_location_2 = b2_lines[1].split(",")[1]
support_location_3 = b2_lines[1].split(", ")[2]

support_location_1 = int(support_location_1)
support_location_2 = int(support_location_2)
support_location_3 = int(support_location_3)

load_magnitude_1 = b2_lines[2].split(",")[0]
load_start_1 = b2_lines[2].split(",")[1]
load_order_1 = b2_lines[2].split(",")[2]

load_magnitude_1 = float(load_magnitude_1)
load_start_1 = int(load_start_1)
load_order_1 = int(load_order_1)

load_magnitude_2 = b2_lines[3].split(",")[0]
load_start_2 = b2_lines[3].split(",")[1]
load_order_2 = b2_lines[3].split(",")[2]

load_magnitude_2 = float(load_magnitude_2)
load_start_2 = int(load_start_2)
load_order_2 = int(load_order_2)

R1, R2, R3 = symbols("R1 R2 R3")

b2_beam = Beam(length, E, I)
b2_beam.apply_load(R1, support_location_1, -1)
b2_beam.apply_load(R2, support_location_2, -1)
b2_beam.apply_load(R3, support_location_3, -1)
b2_beam.apply_load(load_magnitude_1, load_start_1, load_order_1)
b2_beam.apply_load(load_magnitude_2, load_start_2, load_order_2)
b2_beam.bc_deflection = [(support_location_1, 0), (support_location_2, 0), (support_location_3, 0)]

b2_beam.solve_for_reaction_loads(*[R1, R2, R3])
print(b2_beam.reaction_loads)
b2_beam.plot_shear_force()
b2_beam.plot_bending_moment()
```

# Task F

1. Make a duplicate of the file "beam_1.txt" and call it "beam_3.txt".
2. Make a duplicate of "beam_2.txt" and call it "beam_4.txt"
3. Rename the title of **Task D Response** to **Beam: Two supports, one load**
4. Rename the title of **Task E Response** to **Beam: Three supports, two loads**
5. Change some of the values in "beam_3.txt" and change your "Two supports, one load" script to run the data in "beam_3.txt" to see some new results
6. Do the same with "beam_4.txt" and "Three supports, two loads" to see some new results
7. Now, try running your "beam_1.txt" on your "Three supports, two loads" script. Did it work? What happened?
8. Try to run your "beam_4.txt" on your "Two supports, one load". Did it work? What happened?
9. Ponder the question: "Is there some way to write _one_ script that would work with _all_ beam files"?

# 🦸 Submit Workbook 02
Whew! Congratulations on getting through Workbook 02. Was some of that work repetitive?  Maybe you started asking yourself, "Is there a better way to do this?"

Well...yes, there is. We will start on that next week.

For now, just email your completed workbook to me at `cferster@rjc.ca` with the subject line `Workbook 02 Submission`

Oh, but if you are keen, I have also included a "Workbook_02 Extra Practice" notebook if you wanted to practice more but with non-structural exercises.

```python

```


# `gpytorch`

Examples of using the `gpytorch` package for Gaussian Processes in PyTorch.
Documention for the package can be found [here](https://github.com/cornellius-gp/gpytorch).

```python
%matplotlib inline
%colors linux
%load_ext autoreload
%autoreload 2
```

## Regression Tutorial

This tutorial is an example from the `gpytorch` documentation.
The original can be found [here](https://github.com/cornellius-gp/gpytorch/blob/master/examples/01_Simple_GP_Regression/Simple_GP_Regression.ipynb)

In this example, the features of `gpytorch` will be illustrated within the context of a simple regression problem.
Here we will train a Radial Basis Function (RBF) Kernel Gaussian Process to model

$$
\begin{align}
    y &= \sin(2 \pi x) + \varepsilon \\
    \varepsilon &\sim \mathcal{N}(0, 0.2)
\end{align}
$$

We will have $11$ training examples, and evaluate the performance on $51$ test examples.

```python
import math
import torch
import gpytorch
from matplotlib import pyplot as plt
from tqdm import tqdm_notebook as tqdm
```

In the next cell we will set up the training data for the example.
We'll be using 11 regularly spaced points in $[0, 1]$ which we evaluate the fucntion on and add Gaussian noise to get the training labels.

```python
train_x = torch.linspace(0, 1, 100)
train_y = torch.sin(train_x * (2 * math.pi)) + torch.randn(train_x.size()) * 0.2
```

Unlike other Gaussian Process packages, `gpytorch` does not provide a full Gaussian Process model, but instead provides tools to quickly construct one.
This design philosophy is inline with `pytorch`, preferring flexibility to include whatever components are necessary (such as Deep Kernel Learning).

For most __Gaussian Process__ models you will need the following `gpytorch` objects:

1. A __Gaussian Process Model__ - `gpytorch.models.ExactGP` - This handles most of the inference
2. A __Likelihood__ - `gpytorch.likelihoods.GaussianLikelihood` - THis is the most common likelihood used for __GP__ regression
3. A __Mean__ - Defines the prior mean for the __Gaussian Process__
    - If you don't know which to use, `gpytorch.means.ConstantMean()` is a good place to start.
4. A __Kernel__ - Defines the prior covariance of the __GP__
    - If you don't know which to use, `gpytorch.kernels.ScaleKernel(gpytorch.kernels.RBFKernel())` is a good first choice.
5. A __Multivariate Normal Distribution__ - `gpytorch.distributions.MultivariateNormal` - This is the object used to represent multivariate normal distributions

Similar to `pytorch`, a user defined __Gaussian Process__ will include an `__init__` method, as well as a `forward` method.
The forward method takes in $n \times d$ data `x`, and returns an instance of a `gpytorch.distributions.MultivariateNormal` object with the _prior_ mean and covariance matrix evaluated at `x`.

```python
class ExactGPModel(gpytorch.models.ExactGP):
    def __init__(self, train_x, train_y, likelihood):
        super(ExactGPModel, self).__init__(train_x, train_y, likelihood)
        self.mean_module = gpytorch.means.ConstantMean()
        self.covar_module = gpytorch.kernels.ScaleKernel(gpytorch.kernels.RBFKernel())

    def forward(self, x):
        mean_x = self.mean_module(x)
        covar_x = self.covar_module(x)

        return gpytorch.distributions.MultivariateNormal(mean_x, covar_x)
```

```python
likelihood = gpytorch.likelihoods.GaussianLikelihood()
model = ExactGPModel(train_x, train_y, likelihood)
```

Like `pytorch`, `ExactGP` (the parent of our user-defined `ExactGPModel`) has `.train()` and `.eval()` settings.

## Training

To train the model, we use Type-II MLE to train the hyperparameters of the __Gaussian Process__.

With `gpytorch`, we define a training loop similar to `torch`.
The standard `torch` optimizers from `torch.optim`, and all trainable parameters for your model should be of the type `torch.nn.Parameter`.

More often than not the boilerplate code here will work well.
It has the same basic components of a training loop for a standard `torch` model.

1. Zero all parameter gradients
2. Call the model and compute the loss
3. Call `.backward()` on the loss to fill gradients
4. Take a step on the optimizer

However, defining custom training loops allows for greater flexibility.
For example, it is easy to save the parameters at each step of training, or use heterogeneous learning-rates for different parameters (useful for Deep Kernel Learning).

### Initiate training mode

```python
model.train()
likelihood.train();
```

### Instatiate the optimizer

```python
optimizer = torch.optim.Adam(
    [{'params': model.parameters()}],  # includes the GaussianLikelihood parameters (see model.__init__())
    lr=0.1,
)
```

### Instantiate Loss

```python
mll = gpytorch.mlls.ExactMarginalLogLikelihood(likelihood, model)
```

### Define training loop

```python
n = 50  # number of iterations
pbar = tqdm(range(n))

for i in pbar:
    optimizer.zero_grad()  # zero gradients

    output = model(train_x)  # forward pass on the data

    loss = -mll(output, train_y)
    loss.backward()  # back propagation

    log_lengthscale = model.covar_module.base_kernel.log_lengthscale.item()
    log_noise = model.likelihood.log_noise.item()

    print(
        f"{i+1:3}/{n} | Loss: {loss.item(): .2f}, log_lengthscale: {log_lengthscale: .2f}, log_noise: {log_noise: .2f}"
    )

    optimizer.step()

```

    HBox(children=(IntProgress(value=0, max=50), HTML(value='')))

      1/50 | Loss:  1.09, log_lengthscale:  0.00, log_noise:  0.00
      2/50 | Loss:  1.05, log_lengthscale: -0.10, log_noise: -0.10
      3/50 | Loss:  1.01, log_lengthscale: -0.19, log_noise: -0.20
      4/50 | Loss:  0.97, log_lengthscale: -0.29, log_noise: -0.30
      5/50 | Loss:  0.92, log_lengthscale: -0.39, log_noise: -0.40
      6/50 | Loss:  0.88, log_lengthscale: -0.48, log_noise: -0.50
      7/50 | Loss:  0.82, log_lengthscale: -0.58, log_noise: -0.60
      8/50 | Loss:  0.76, log_lengthscale: -0.67, log_noise: -0.70
      9/50 | Loss:  0.70, log_lengthscale: -0.77, log_noise: -0.80
     10/50 | Loss:  0.64, log_lengthscale: -0.87, log_noise: -0.90
     11/50 | Loss:  0.58, log_lengthscale: -0.96, log_noise: -1.00
     12/50 | Loss:  0.54, log_lengthscale: -1.06, log_noise: -1.10
     13/50 | Loss:  0.49, log_lengthscale: -1.15, log_noise: -1.21
     14/50 | Loss:  0.45, log_lengthscale: -1.22, log_noise: -1.31
     15/50 | Loss:  0.42, log_lengthscale: -1.28, log_noise: -1.41
     16/50 | Loss:  0.38, log_lengthscale: -1.34, log_noise: -1.51
     17/50 | Loss:  0.34, log_lengthscale: -1.38, log_noise: -1.61
     18/50 | Loss:  0.30, log_lengthscale: -1.42, log_noise: -1.71
     19/50 | Loss:  0.28, log_lengthscale: -1.45, log_noise: -1.82
     20/50 | Loss:  0.24, log_lengthscale: -1.46, log_noise: -1.92
     21/50 | Loss:  0.20, log_lengthscale: -1.46, log_noise: -2.02
     22/50 | Loss:  0.16, log_lengthscale: -1.45, log_noise: -2.11
     23/50 | Loss:  0.13, log_lengthscale: -1.43, log_noise: -2.21
     24/50 | Loss:  0.10, log_lengthscale: -1.41, log_noise: -2.31
     25/50 | Loss:  0.08, log_lengthscale: -1.39, log_noise: -2.40
     26/50 | Loss:  0.04, log_lengthscale: -1.35, log_noise: -2.50
     27/50 | Loss:  0.02, log_lengthscale: -1.32, log_noise: -2.59
     28/50 | Loss: -0.01, log_lengthscale: -1.28, log_noise: -2.68
     29/50 | Loss: -0.02, log_lengthscale: -1.25, log_noise: -2.77
     30/50 | Loss: -0.04, log_lengthscale: -1.21, log_noise: -2.85
     31/50 | Loss: -0.05, log_lengthscale: -1.17, log_noise: -2.93
     32/50 | Loss: -0.06, log_lengthscale: -1.13, log_noise: -3.01
     33/50 | Loss: -0.06, log_lengthscale: -1.10, log_noise: -3.08
     34/50 | Loss: -0.06, log_lengthscale: -1.09, log_noise: -3.15
     35/50 | Loss: -0.07, log_lengthscale: -1.09, log_noise: -3.22
     36/50 | Loss: -0.07, log_lengthscale: -1.11, log_noise: -3.28
     37/50 | Loss: -0.07, log_lengthscale: -1.14, log_noise: -3.33
     38/50 | Loss: -0.07, log_lengthscale: -1.18, log_noise: -3.38
     39/50 | Loss: -0.07, log_lengthscale: -1.22, log_noise: -3.43
     40/50 | Loss: -0.07, log_lengthscale: -1.26, log_noise: -3.46
     41/50 | Loss: -0.07, log_lengthscale: -1.31, log_noise: -3.49
     42/50 | Loss: -0.06, log_lengthscale: -1.35, log_noise: -3.51
     43/50 | Loss: -0.06, log_lengthscale: -1.38, log_noise: -3.53
     44/50 | Loss: -0.05, log_lengthscale: -1.41, log_noise: -3.54
     45/50 | Loss: -0.06, log_lengthscale: -1.44, log_noise: -3.55
     46/50 | Loss: -0.06, log_lengthscale: -1.46, log_noise: -3.55
     47/50 | Loss: -0.06, log_lengthscale: -1.47, log_noise: -3.54
     48/50 | Loss: -0.06, log_lengthscale: -1.48, log_noise: -3.53
     49/50 | Loss: -0.07, log_lengthscale: -1.49, log_noise: -3.52
     50/50 | Loss: -0.06, log_lengthscale: -1.49, log_noise: -3.50

## Inference

As stated above, a `gpytorch` model returns a `MultivariateNormal` object containing the prior mean and covariance from the `.forward()` method.
With these, we can sample fucntions from the __GP__ at the given test points with calls like:
f_preds = model(test_x)
y_preds = likelihood(model(test_x))

f_mean = f_preds.mean
f_var = f_preds.variance
f_covar = f_preds.covariance_matrix
f_samples = f_preds.sample(sample_shape=torch.Size(1000,))
### Generating Predictions

```python
model.eval()  # set model and likelihood to eval mode
likelihood.eval()

with torch.no_grad():
    test_x = torch.linspace(0, 1, 51)
    observed_pred = likelihood(model(test_x))
```

The `gpytorch.fast_pred_var` context manager can be used to generate faster predictive distributions using the [LOVE](https://arxiv.org/abs/1803.06058) algorithm.
This is demonstrated in the notebook `gpytorch-LOVE.ipynb`.

### Plot model fit

```python
with torch.no_grad():
    f, ax = plt.subplots(1, 1, figsize=(10, 6), dpi=1024)
    lower, upper = observed_pred.confidence_region()  # helper for +/- 2 standard deviations
    ax.plot(train_x.numpy(), train_y.numpy(), 'k+')
    ax.plot(test_x.numpy(), observed_pred.mean.numpy(), 'b')
    ax.fill_between(test_x.numpy(), lower.numpy(), upper.numpy(), alpha=0.5)
    ax.set_xlim([0, 1])
    ax.set_ylim([-3, 3])
    ax.legend(['Observed Data', 'Mean', 'Confidence'])
```

```python
with torch.no_grad(), gpytorch.fast_pred_var():
    x_test = torch.tensor([0.2])
    pred = likelihood(model(x_test))

print(f"Prediction: f({x_test.numpy()[0]:.2f}) = {pred.mean.numpy()[0]: .2f}")
```

    Prediction: f(0.20) =  1.01

```python

```


# Linear Algebra Finals Exam

```python
import numpy as np
```

---

## Problem 1
Student A, Student B, and Student C have a total of Php 89 cash in their banks. Student A has 6 less than Student C. Student B has 3 times what Student C has. How much does each student have?

Given:
\begin{equation}
A + B + C = 89
\end{equation}
\begin{equation}
A = C - 9
\end{equation}
\begin{equation}
B = 3C
\end{equation}
\
\
Rearrangging...
\begin{equation}
A + B + C = 89
\end{equation}
\begin{equation}
A - C = 9
\end{equation}
\begin{equation}
B - 3C = 0
\end{equation}
\
\
Matrix Form:
\begin{equation}
A =
\begin{bmatrix}
1 & 1 & 1 \\
1 & 0 & -1 \\
0 & 1 & -3
\end{bmatrix}
=
\begin{bmatrix}
89\\ 9 \\ 0
\end{bmatrix}
\end{equation}

```python
## Declaringg the matrix
A = np.array([[1,1,1], [1,0,-1], [0,1,-3]])
B = np.array([89, -6, 0])

## Solving for the values of A, B, & C
x = np.linalg.solve(A,B).astype(int)

## Output
print("\nResult:")
print("Student A has Php", x[0])
print("Student B has Php", x[1])
print("Student C has Php", x[2])
```

    Result:
    Student A has Php 13
    Student B has Php 57
    Student C has Php 19

---

## Problem 2

Solve each system

3x - y + z = 5

9x - 3y +3z = 15

-12x +4y -4z = -20

```python
## Declaring the matrices
A = np.array([[3, -1, 1], [9, -3, 3], [-12, 4, -4]])
B = np.array([5, 15, -20])

A_det = np.linalg.det(A)
print("Determinant: ", A_det)
```

    Determinant:  0.0

**Answer:** The determinant of matrix A is 0, meaning that
the system has linearly dependent equations.

---

## Problem 3
Problem 3. Consider the matrix,

\begin{equation}
A =
\begin{bmatrix}
    8 & 5 & -6 \\
    -12 & -9 & 12 \\
    -3 & -3 & 5
    \end{bmatrix}
\end{equation}

1.   Find the eigenvalue/s
2.   Find the eigenvectors

```python
## Decalaring the matrix
A = np.array([[8,5,-6], [-12,-9,12], [-3,-3,5]])
## Getting the eigenvalue(s) and eigenvetors
w,v = np.linalg.eig(A)

## Printing matrix A
print("Matrix A:\n", A, "\n")

## Printing the eigenvalue(s) and eigenvetors
print("Eigenvalue/s:\n", w.astype(int) , "\n")   # np.astype was used to convert float to int
print("Eigenvectors:\n", v.astype(int) , "\n")

```

    Matrix A:
     [[  8   5  -6]
     [-12  -9  12]
     [ -3  -3   5]]

    Eigenvalue/s:
     [0 2 2]

    Eigenvectors:
     [[0 0 0]
     [0 0 0]
     [0 0 0]]


Table of Contents

1  Actividad 1. Introducción a Machine Learning

1.1  Finalidad de la práctica

1.2  Sobre Scikit-Learn

1.3  ¿Qué es el Machine Learning?

1.3.1  Primera pregunta (3 puntos)

1.3.2  a) Habiendo definido la función que representa la regresión lineal, representar una regresión lineal a 50 puntos con una pendiente de 1.5 y un punto de corte con ordenadas de 2.0. (1 punto)

1.3.3  b) Modifique el ruido gausiano incluido a los datos sobre los que se realiza la regresión lineal en la función plot_linear_regression (2 puntos)

1.3.4  ¿Qué observa en los puntos aleatoriamente generados si la desviación típica es $\sigma = 5.0$ manteniendo la desviación de la media en $\mu = 0$ ? ¿Qué efectos tiene en la recta ajustada por el estimador LinearRegressor?

1.3.5  ¿Qué efecto en los puntos aleatoriamente generados si la desviación típica es  $\sigma=0$  cambiando la desviación de la media a $\mu=5$? ¿Qué efectos tiene en la recta ajustada por el estimador LinearRegressor?

1.4  Representación de datos con Scikit-Learn

1.5  Un ejemplo sencillo: el Iris flower data set

1.5.1  Cargando el Iris Dataset con Scikit-Learn

1.5.2  Segunda pregunta (4 puntos):

1.5.3  a) Si queremos diseñar un algoritmo de clasificación que reconozca las especies de lirios, ¿qué tipos de datos de nuestro dataset vamos a necesitar? (1 punto)

1.5.4  b) ¿Dentro de qué paradigma de aprendizaje caería la clasificación por mapeo características-etiquetas del dataset Iris Setosa? Justifique brevemente su respuesta (1 punto)

1.5.5  c) ¿Se podría hacer uso de aprendizaje no-supervisado para clasificar este dataset? De ser así, ¿qué familia de algoritmos se podría usar para ese fin? (1 punto)

1.5.6  d) ¿A qué se refiere n_samples con el caso del dataset de los lirios? (0,5 puntos)

1.5.7  e) ¿Con qué se equipara n_features en el caso del dataset de los lirios? (0,5 puntos)

1.5.8  Tercera pregunta (3 puntos)

<a href="#¿Cuál-parece-el-mejor-gráfico-para-diferenciar-las-muestras-del-Iris-flower-data-set?--Cambie-x_index-e-y_index-en-la-última-pieza-de-código-para-encontrar-una-combinación-de-parámetros-que-maximice-la-separación-entre-clases.-Incluya-abajo-el-código-necesario-para-representar-las-diferentes-clases-lo-más-separadas-posible-y-ejecute-para-representar-la-gráfica." data-toc-modified-id="¿Cuál-parece-el-mejor-gráfico-para-diferenciar-las-muestras-del-Iris-flower-data-set?--Cambie-x_index-e-y_index-en-la-última-pieza-de-código-para-encontrar-una-combinación-de-parámetros-que-maximice-la-separación-entre-clases.-Incluya-abajo-el-código-necesario-para-representar-las-diferentes-clases-lo-más-separadas-posible-y-ejecute-para-representar-la-gráfica.-1.5.9">1.5.9  ¿Cuál parece el mejor gráfico para diferenciar las muestras del Iris flower data set?  Cambie x_index e y_index en la última pieza de código para encontrar una combinación de parámetros que maximice la separación entre clases. Incluya abajo el código necesario para representar las diferentes clases lo más separadas posible y ejecute para representar la gráfica.

# Actividad 1. Introducción a Machine Learning

Esta sesión cubrirá los conceptos básicos de Scikit-Learn, un paquete popular que contiene una colección de herramientas para el aprendizaje automático escritas en Python. Ver más en http://scikit-learn.org.

## Finalidad de la práctica

Esta práctica tiene como finalidad introducir los conceptos centrales del aprendizaje automático y cómo se pueden aplicar en Python utilizando el paquete Scikit-Learn.

- Familiarizarse con el manejo de un entorno **Python**, **Jupyter Notebook** (ficheros con extensión `.ipynb`) y las principales librerías de cálculo numérico como **numpy**.
- Afianzar las definiciones de Machine Learning a través de ejemplo muy sencillos.
- Introducción a la representación de los datos en **Scikit-Learn**.
- Introducción al uso de **Scikit-Learn API**.

## Sobre Scikit-Learn

[Scikit-Learn](http://github.com/scikit-learn/scikit-learn) es un paquete de Python diseñado para dar acceso a algoritmos de **Aprendizaje Automático** bien conocidos dentro del código **Python**, a través de una API limpia y bien pensada. Ha sido construido por cientos de colaboradores de todo el mundo y se usa en la industria y el mundo académico.

**Scikit-Learn** esá basada en las librerías [NumPy (Numerical Python)](http://numpy.org) y [SciPy (Scientific Python)](http://scipy.org), que permiten la computación numérica y científica eficiente en Python.

Para esta breve introducción, nos centraremos en visualzación y análisis de conjuntos de datos pequeños o medianos con **Scikit-Learn**.

```python
from IPython.core.display import HTML
```

```python
print("Scikit-learn Logo\n")
```

    Scikit-learn Logo

## ¿Qué es el Machine Learning?

En esta sección comenzaremos a explorar los principios básicos del aprendizaje automático. El aprendizaje automático consiste en crear programas con **parámetros ajustables** (normalmente una matriz de valores numéricos) que se ajustan automáticamente para mejorar su comportamiento mediante la adaptación **a los datos vistos anteriormente**.

```python
print("Artificial Intelligence, Machine Learning and Deep Learning relationships\n")
```

    Artificial Intelligence, Machine Learning and Deep Learning relationships

El aprendizaje automático puede considerarse un subcampo de **Inteligencia Artificial** desde que los algoritmos se pueden ver como bloques de construcción para que las máquinas aprendan a comportarse de una manera más inteligente a través de la **generalización** en lugar de simplemente almacenar y recuperar elementos de datos como haría un sistema de base de datos.

Vamos a echar un vistazo a dos tareas muy simples de aprendizaje automático aquí. La primera es una tarea de **clasificación**: la figura muestra una recopilación de datos bidimensionales, coloreados según dos clases diferentes. Se puede usar un algoritmo de clasificación para dibujar un límite de división entre los dos grupos de puntos:

```python
# En esta celda se incluye un script que representa las pipelines de los principales paradigmas del Machine Learning.

import numpy as np
import pylab as pl
from matplotlib.patches import Circle, Rectangle, Polygon, Arrow, FancyArrow
%matplotlib inline

def create_base(box_bg = '#CCCCCC',
                arrow1 = '#88CCFF',
                arrow2 = '#88FF88',
                supervised=True):
    fig = pl.figure(figsize=(9, 6), facecolor='w')
    ax = pl.axes((0, 0, 1, 1),
                 xticks=[], yticks=[], frameon=False)
    ax.set_xlim(0, 9)
    ax.set_ylim(0, 6)

    patches = [Rectangle((0.3, 3.6), 1.5, 1.8, zorder=1, fc=box_bg),
               Rectangle((0.5, 3.8), 1.5, 1.8, zorder=2, fc=box_bg),
               Rectangle((0.7, 4.0), 1.5, 1.8, zorder=3, fc=box_bg),

               Rectangle((2.9, 3.6), 0.2, 1.8, fc=box_bg),
               Rectangle((3.1, 3.8), 0.2, 1.8, fc=box_bg),
               Rectangle((3.3, 4.0), 0.2, 1.8, fc=box_bg),

               Rectangle((0.3, 0.2), 1.5, 1.8, fc=box_bg),

               Rectangle((2.9, 0.2), 0.2, 1.8, fc=box_bg),

               Circle((5.5, 3.5), 1.0, fc=box_bg),

               Polygon([[5.5, 1.7],
                        [6.1, 1.1],
                        [5.5, 0.5],
                        [4.9, 1.1]], fc=box_bg),

               FancyArrow(2.3, 4.6, 0.35, 0, fc=arrow1,
                          width=0.25, head_width=0.5, head_length=0.2),

               FancyArrow(3.75, 4.2, 0.5, -0.2, fc=arrow1,
                          width=0.25, head_width=0.5, head_length=0.2),

               FancyArrow(5.5, 2.4, 0, -0.4, fc=arrow1,
                          width=0.25, head_width=0.5, head_length=0.2),

               FancyArrow(2.0, 1.1, 0.5, 0, fc=arrow2,
                          width=0.25, head_width=0.5, head_length=0.2),

               FancyArrow(3.3, 1.1, 1.3, 0, fc=arrow2,
                          width=0.25, head_width=0.5, head_length=0.2),

               FancyArrow(6.2, 1.1, 0.8, 0, fc=arrow2,
                          width=0.25, head_width=0.5, head_length=0.2)]

    if supervised:
        patches += [Rectangle((0.3, 2.4), 1.5, 0.5, zorder=1, fc=box_bg),
                    Rectangle((0.5, 2.6), 1.5, 0.5, zorder=2, fc=box_bg),
                    Rectangle((0.7, 2.8), 1.5, 0.5, zorder=3, fc=box_bg),
                    FancyArrow(2.3, 2.9, 2.0, 0, fc=arrow1,
                               width=0.25, head_width=0.5, head_length=0.2),
                    Rectangle((7.3, 0.85), 1.5, 0.5, fc=box_bg)]
    else:
        patches += [Rectangle((7.3, 0.2), 1.5, 1.8, fc=box_bg)]

    for p in patches:
        ax.add_patch(p)

    pl.text(1.45, 4.9, "Training\nText,\nDocuments,\nImages,\netc.",
            ha='center', va='center', fontsize=14)

    pl.text(3.6, 4.9, "Feature\nVectors",
            ha='left', va='center', fontsize=14)

    pl.text(5.5, 3.5, "Machine\nLearning\nAlgorithm",
            ha='center', va='center', fontsize=14)

    pl.text(1.05, 1.1, "New Text,\nDocument,\nImage,\netc.",
            ha='center', va='center', fontsize=14)

    pl.text(3.3, 1.7, "Feature\nVector",
            ha='left', va='center', fontsize=14)

    pl.text(5.5, 1.1, "Predictive\nModel",
            ha='center', va='center', fontsize=12)

    if supervised:
        pl.text(1.45, 3.05, "Labels",
                ha='center', va='center', fontsize=14)

        pl.text(8.05, 1.1, "Expected\nLabel",
                ha='center', va='center', fontsize=14)
        pl.text(8.8, 5.8, "Supervised Learning Model",
                ha='right', va='top', fontsize=18)

    else:
        pl.text(8.05, 1.1,
                "Likelihood\nor Cluster ID\nor Better\nRepresentation",
                ha='center', va='center', fontsize=12)
        pl.text(8.8, 5.8, "Unsupervised Learning Model",
                ha='right', va='top', fontsize=18)

def plot_supervised_chart(annotate=False):
    create_base(supervised=True)
    if annotate:
        fontdict = dict(color='r', weight='bold', size=14)
        pl.text(1.9, 4.55, 'X = vec.fit_transform(input)',
                fontdict=fontdict,
                rotation=20, ha='left', va='bottom')
        pl.text(3.7, 3.2, 'clf.fit(X, y)',
                fontdict=fontdict,
                rotation=20, ha='left', va='bottom')
        pl.text(1.7, 1.5, 'X_new = vec.transform(input)',
                fontdict=fontdict,
                rotation=20, ha='left', va='bottom')
        pl.text(6.1, 1.5, 'y_new = clf.predict(X_new)',
                fontdict=fontdict,
                rotation=20, ha='left', va='bottom')

def plot_unsupervised_chart():
    create_base(supervised=False)

if __name__ == '__main__':
    plot_supervised_chart(True)
    plot_unsupervised_chart()
    pl.show()
```

```python
# Importamos las librerías

%matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
from sklearn.linear_model import SGDClassifier
from sklearn.datasets import make_blobs

plt.style.use('seaborn')
```

**Definimos una función de clasificación**:

```python
def plot_sgd_separator(num, groups, sep):
    # We create num separable points
    X, Y = make_blobs(n_samples=num, centers=groups,
                      random_state=0, cluster_std=sep)

    # Fit the model
    clf = SGDClassifier(loss="hinge", alpha=0.01,
                        max_iter=200, fit_intercept=True)
    clf.fit(X, Y)

    # Plot the line, the points, and the nearest vectors to the plane
    xx = np.linspace(-1, 5, 10)
    yy = np.linspace(-1, 5, 10)

    X1, X2 = np.meshgrid(xx, yy)
    Z = np.empty(X1.shape)
    for (i, j), val in np.ndenumerate(X1):
        x1 = val
        x2 = X2[i, j]
        p = clf.decision_function(np.array([x1, x2]).reshape(1, -1))
        Z[i, j] = p[0]
    levels = [-1.0, 0.0, 1.0]
    linestyles = ['dashed', 'solid', 'dashed']
    colors = 'k'

    ax = plt.axes()
    ax.contour(X1, X2, Z, levels, colors=colors, linestyles=linestyles)
    ax.scatter(X[:, 0], X[:, 1], c=Y, cmap=plt.cm.Paired)

    ax.axis('tight')
```

```python
# Ejecutamos la anterior función para 2 grupos de 100 puntos, por ejemplo:
plot_sgd_separator(num=100, groups=2, sep=0.6)
```

Esto puede parecer una tarea trivial, pero es una versión simple de un concepto muy importante.
Al dibujar esta línea de separación, hemos aprendido un modelo que puede **generalizar** nuevos
datos: si tuviera que colocar otro punto en el plano sin etiquetar, este algoritmo
ahora podría **predecir** si es un punto azul o rojo.

La siguiente tarea simple que veremos es una tarea de **regresión**: una línea simple que se adapta mejor a un conjunto de datos:

```python
import numpy as np
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
```

**Definimos una función de regresión lineal**:

```python
def plot_linear_regression(a, b, num_points):
    # The slope (a) and the interception with y-axis (bias) are defined as a function input.

    # x from 0 to 10
    x = 10 * np.random.random(50)

    # y = a * x + b with gaussian noise
    y = a * x + b + np.random.normal(size=x.shape)

    # Create a linear regression classifier
    clf = LinearRegression()
    clf.fit(x[:, None], y)

    # Predict y from the data
    x_new = np.linspace(0, 10, 100)
    y_new = clf.predict(x_new[:, None])

    # Plot the results
    ax = plt.axes()
    ax.scatter(x, y, s=10)
    ax.plot(x_new, y_new, c='k', linewidth=1.)

    ax.set_xlabel('x')
    ax.set_ylabel('y')

    ax.axis('tight')
```

Por ejemplo, para ejecutar la función de arriba con una pendiente $a=1.0$ y un corte de ordenadas $b=1.0$ para $50$ puntos:

```python
plot_linear_regression(a=1.0, b=1.0, num_points=50)
plt.show()
```

De nuevo, este es un ejemplo de ajuste de un modelo a los datos, de manera que el modelo puede hacer generalizaciones sobre nuevos datos. El modelo ha **aprendido** a partir del training dataset, y se puede utilizar para predecir el resultado de los datos de testeo.

Aquí, se nos puede dar un valor de $x$, y el modelo nos permitirá predecir el valor de $y$. Una vez más, esto puede parecer un problema trivial, pero es un ejemplo básico de un tipo de operación que resulta fundamental para Tareas de Machine Learning.

--------------------------------------

### Primera pregunta (3 puntos)

### a) Habiendo definido la función que representa la **regresión lineal**, representar una regresión lineal a `50 puntos` con una pendiente de `1.5` y un punto de corte con ordenadas de `2.0`. (1 punto)

**Respuesta:**

```python
plot_linear_regression(a=1.5, b=2.0, num_points=50)
plt.show()
```

### b) Modifique el ruido gausiano incluido a los datos sobre los que se realiza la regresión lineal en la función `plot_linear_regression` (2 puntos)

Recuerde que la distribución normal sigue el siguiente perfil:

\begin{equation}\label{eq:}
p(x) = \frac{1}{\sqrt{2\pi \sigma^2}} e^{-\frac{(x-\mu)^2}{2\sigma^2}}
\end{equation}

donde $\mu$ es la media y $\sigma$ es la desviación típica de la distribución.

Pista en el [siguiente enlace](https://docs.scipy.org/doc/numpy-1.14.1/reference/generated/numpy.random.normal.html#numpy.random.normal).

```python
def plot_linear_regression(a, b, num_points, noise_mean=0.0, noise_deviation=1.0):
    # The slope (a) and the interception with y-axis (bias) are defined as a function input.

    # x from 0 to 10
    x = 10 * np.random.random(50)

    # y = a * x + b with gaussian noise
    y = a * x + b + np.random.normal(loc=noise_mean, scale=noise_deviation, size=x.shape)

    # Create a linear regression classifier
    clf = LinearRegression()
    clf.fit(x[:, None], y)

    # Predict y from the data
    x_new = np.linspace(0, 10, 100)
    y_new = clf.predict(x_new[:, None])

    # Plot the results
    ax = plt.axes()
    ax.scatter(x, y, s=10)
    ax.plot(x_new, y_new, c='k', linewidth=1.)

    ax.set_xlabel('x')
    ax.set_ylabel('y')

    ax.axis('tight')
```

**Respuesta:** Se modifica el método para **recibir como parámetros la media y desviación típica** para que la función pueda ser usada con distintas combinaciones de estos parámetros en las siguientes instrucciones.

```python
plot_linear_regression(a=1.5, b=2.0, num_points=50, noise_deviation=0.5)
plt.show()
```

**Respuesta:** Para este caso, la desviación típica es menor a la del ejemplo, por tanto, los valores de entrenamiento contiene menor ruido respecto a la recta, por lo que el modelo hace prediciones con más precisión.

### ¿Qué observa en los puntos aleatoriamente generados si la desviación típica es $\sigma = 5.0$ manteniendo la desviación de la media en $\mu = 0$ ? ¿Qué efectos tiene en la recta ajustada por el estimador `LinearRegressor`?

```python
plot_linear_regression(a=1.5, b=2.0, num_points=50, noise_deviation=5.0)
plt.show()
```

**Respuesta:** Para este caso, la desviación típica es más grande, por lo que los valores de entrenamiento de **y** contienen mayor ruido y el modelo entrenado predice con **menos precisión**.

### ¿Qué efecto en los puntos aleatoriamente generados si la desviación típica es  $\sigma=0$  cambiando la desviación de la media a $\mu=5$? ¿Qué efectos tiene en la recta ajustada por el estimador `LinearRegressor`?

```python
plot_linear_regression(a=1.5, b=2.0, num_points=50, noise_mean=5.0, noise_deviation=0.0)
plt.show()
```

**Respuesta:** Cuando la desviación típica es 0, la función **np.random.normal** siempre devuelve la media que es 5, y al parametro **b** se suma esta media cortando la recta con el eje y en el valor 7. En este caso, los valores de la predicción del modelo coincide *casi exactamente* con la recta **y = 1.5x + 7**

-------------------------------------------------

## Representación de datos con Scikit-Learn

El **Aprendizaje Automático** consiste en crear modelos a partir de datos: por esa razón, comenzaremos por analizar cómo se pueden representar los datos para que nuestro modelo pueda comprenderlos. Junto con esto, desarrollaremos nuestros ejemplos de matplotlib de la sección anterior y mostraremos algunos ejemplos de cómo visualizar datos.

La mayoría de los algortimos de Machine Learning esperan que los datos estén almacenados en tensores, generalmente de orden 2 u orden 3. En este caso trabajaremos con **matrices bidimensionales**.  Los arrays, por lo general, si se trabaja con librerías como Scikit-Learn se pueden contruir con  ``numpy`` arrays o ``scipy.sparse``.
La dimensaionalidad de esta matriz es `[n_samples, n_features]`

- **n_samples:**   La cantidad de muestras: cada muestra es un elemento para procesar (por ejemplo, clasificar). Una muestra puede ser un documento, una imagen, un sonido, un video, un objeto astronómico, una fila en la base de datos o un archivo CSV, o lo que pueda describir con un conjunto fijo de rasgos cuantitativos.
- **n_features:**  La cantidad de características o rasgos distintos que se pueden usar para describir cada elemento de manera cuantitativa. Las características son generalmente de valor real, pero pueden ser booleanas o de valor discreto en algunos casos.

El número de características debe estar fijado de antemano. Debe haber un **número fijo** de características o features para cada muestra, mientras que cada número ``i`` de una característica debe ser de un orden de magnitud similar para todas las muestras. Sin embargo, puede tener dimanesionalidad muy elevada (por ejemplo, millones de características) y la mayoría de ellas son ceros para una muestra determinada. Este es un caso en el que las matrices `scipy.sparse` pueden ser útiles, ya que son mucho más eficientes en memoria que las matrices `numpy`.

(Imagen obtenida de [Python Data Science Handbook](https://github.com/jakevdp/PythonDataScienceHandbook))

## Un ejemplo sencillo: el Iris flower data set

Como ejemplo de un conjunto de datos simple, vamos a echar un vistazo a los datos del iris almacenados por scikit-learn.
Los datos consisten en mediciones de tres especies diferentes de lirios.
Hay tres especies de iris en el conjunto de datos, que podemos ver aquí:

A continuación, puede ver de izquierda a derecha muestras de lirios **setosa**, **versicolor** y **virgínica** respectivamente.

### Cargando el Iris Dataset con Scikit-Learn

Scikit-Learn cuenta con un gran número de datasets que se pueden importar de manera rápida y directa. entre ellos se encuentra el dataset de las diferentes especies de lirios. Nuestros datos consisten en:

- **Características del Iris dataset**:
  1. Longitud del sépalo en cm
  2. Anchura del sépalo en cm
  3. Longitud del pétalo en cm
  4. Anchura del pétalo en cm

- **Las clases soportadas en este dataset son**:

Apuntamos a los dos primeros números naturales y al 0.
  1. Iris Setosa $\rightarrow 0$
  2. Iris Versicolour $\rightarrow 1$
  3. Iris Virginica $\rightarrow 2$

``scikit-learn`` embebe una copia del archivo `csv` de iris junto con una función auxiliar para cargarlo en matrices numpy:

```python
from sklearn.datasets import load_iris
iris = load_iris()
```

Ya tenemos cargado en la variable `iris` toda la información de nuestro dataset. Podremos acceder a todas las características de nuestro dataset como si lo tuviéramos almacenados en columnas.

```python
iris.keys()
```

    dict_keys(['data', 'target', 'frame', 'target_names', 'DESCR', 'feature_names', 'filename'])

Por ejemplo, las **longitudes y anchuras** de las primeras 5 flores son:

```python
iris.data[0:5]
```

    array([[5.1, 3.5, 1.4, 0.2],
           [4.9, 3. , 1.4, 0.2],
           [4.7, 3.2, 1.3, 0.2],
           [4.6, 3.1, 1.5, 0.2],
           [5. , 3.6, 1.4, 0.2]])

Mientras que las **clases** de esas cinco corresponden a:

```python
iris.target[0:5]
```

    array([0, 0, 0, 0, 0])

Por lo que estas primeras 5 son todas `setosa`.

Como veremos a continuación, contamos con una matris de **150 muestras**, cada una con **4 características**.

```python
n_samples, n_features = iris.data.shape
print('Dimensionalidad del dataset: ',(n_samples, n_features))
print(iris.data[0])
```

    Dimensionalidad del dataset:  (150, 4)
    [5.1 3.5 1.4 0.2]

```python
print(iris.data.shape)
print(iris.target.shape)
```

    (150, 4)
    (150,)

Las etiquetas que le facilitamos a un modelo de clasificación para que las aprenda a generalizar son:

```python
print(iris.target)
```

    [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
     0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
     1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2
     2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
     2 2]

```python
print(iris.target_names)
```

    ['setosa' 'versicolor' 'virginica']

Aunque este dataset sea cuente con **cuatro características** y su representación sea imposible en un gráfico, se puede visualizar una proyección en dos dimensiones en un **scatter-plot** simple:

```python
x_index = 0
y_index = 2

# Esta línea de código es sólo para crear una barra de leyenda para las clases
formatter = plt.FuncFormatter(lambda i, *args: iris.target_names[int(i)])

plt.scatter(iris.data[:, x_index], iris.data[:, y_index],
            c=iris.target, cmap=plt.cm.get_cmap('RdYlBu', 3))
plt.colorbar(ticks=[0, 1, 2], format=formatter)
plt.clim(-0.5, 2.5)
plt.xlabel(iris.feature_names[x_index])
plt.ylabel(iris.feature_names[y_index]);
```

------------------------------------------------------------

### Segunda pregunta (4 puntos):

### a) Si queremos diseñar un algoritmo de clasificación que reconozca las especies de lirios, ¿qué tipos de datos de nuestro dataset vamos a necesitar? (1 punto)

**Respuesta:** Para entrenar un modelo de clasificación es necesario disponer de una característica que contenga las etiquetas en la que están agrupados las muestras. Para este dataset tenemos los valores de estas etiquetas en el array `iris.target` como **0, 1 y 2**, y el nombre de las etiquetas en `iris.target_names`.

```python
print(iris.target)
print(iris.target_names)
```

    [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
     0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
     1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2
     2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
     2 2]
    ['setosa' 'versicolor' 'virginica']

### b) ¿Dentro de qué paradigma de aprendizaje caería la clasificación por mapeo características-etiquetas del dataset Iris Setosa? Justifique brevemente su respuesta (1 punto)

**Respuesta:** El aprendizaje supervisado es el paradigma idóneo para entrenar un modelo que identifique la especie de lirios a partir las dimensiones del pétalo y sépalo, ya que se cuenta con una característica clasificadora que requiere este tipo de aprendizaje, en el arreglo `iris.target`.

```python
from sklearn.linear_model import LogisticRegression
log_reg = LogisticRegression(solver="liblinear", random_state=37)
log_reg.fit(iris.data, iris.target)

# Se crear un conjunto de datos cercano al grupo de datos de la especie virginica
x = np.array([5.8, 3.2, 4.9, 1.9]).reshape(1, -1)
print("Valores para la clasificación: ", end='')
print(x[0])

# Predecir el resultado de la clasificación a partir del valor generado
y = log_reg.predict(x)
print("Especie predecida: " + iris.target_names[y[0]])
```

    Valores para la clasificación: [5.8 3.2 4.9 1.9]
    Especie predecida: virginica

### c) ¿Se podría hacer uso de aprendizaje no-supervisado para clasificar este dataset? De ser así, ¿qué familia de algoritmos se podría usar para ese fin? (1 punto)

**Respuesta:** Si no se cuenta con las etiquetas para la clasificación de las muestras y por tanto usar el aprendizaje supervizado, se puede usar un algoritmo del paradigma no-supervizado como K-means para identificar los clusters en las que se puede agrupar las especies de lirios. Este caso podemos definir el número de clusters a partir del número de especies que se desea clasificar, es decir, 3.

```python
from sklearn.cluster import KMeans
k_means = KMeans(n_clusters=3, random_state=37)
y_km = k_means.fit_predict(iris.data)

print("Agrupación por cada muestra:")
print(k_means.labels_)
print("Etiqueta de cada muestra:")
print(iris.target)

# Se crear un conjunto de datos cercano al grupo de datos de la especie virginica
x = np.array([5.8, 3.2, 4.9, 1.9]).reshape(1, -1)
print("Valores para la clasificación: ", end='')
print(x[0])

y = k_means.predict(x)
print("Especie predecida: " + iris.target_names[y[0]])
```

    Agrupación por cada muestra:
    [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
     1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 0 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
     2 2 2 0 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 0 2 0 0 0 0 2 0 0 0 0
     0 0 2 2 0 0 0 0 2 0 2 0 2 0 0 2 2 0 0 0 0 0 2 0 0 0 0 2 0 0 0 2 0 0 0 2 0
     0 2]
    Etiqueta de cada muestra:
    [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
     0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
     1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2
     2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
     2 2]
    Valores para la clasificación: [5.8 3.2 4.9 1.9]
    Especie predecida: virginica

La primera diferencia que observamos entre los clusters que genera K-means y las etiquetas originales, es que los valores no coinciden, por ejemplo el valor de las setosas en K-means es 1 y en las etiquetas es 0.

Lo segundo que podemos ver, es que aunque K-means calcula exactamente las muestras de setosa, se produce algunas diferencias en las muestras de Versicolour y muchas en las muestras de Virginica.

Recordemos: vamos a necesitar una matriz de dimensión `[n_samples x n_features]`.

### d) ¿A qué se refiere `n_samples` con el caso del dataset de los lirios? (0,5 puntos)

**Respuesta:** Es el número de muestras que contiene el dataset. Para este caso se tiene de 150 muestras:

```python
print("Número de muestras: " + str(iris.data.shape[0]))
print("Los primeros 10 valores: ")
print(iris.data[:10])
```

    Número de muestras: 150
    Los primeros 10 valores:
    [[5.1 3.5 1.4 0.2]
     [4.9 3.  1.4 0.2]
     [4.7 3.2 1.3 0.2]
     [4.6 3.1 1.5 0.2]
     [5.  3.6 1.4 0.2]
     [5.4 3.9 1.7 0.4]
     [4.6 3.4 1.4 0.3]
     [5.  3.4 1.5 0.2]
     [4.4 2.9 1.4 0.2]
     [4.9 3.1 1.5 0.1]]

### e) ¿Con qué se equipara `n_features` en el caso del dataset de los lirios? (0,5 puntos)

**Respuesta:** Es el número de características que contiene el dataset. Para este caso se tiene 4 features:

```python
iris.feature_names
```

    ['sepal length (cm)',
     'sepal width (cm)',
     'petal length (cm)',
     'petal width (cm)']

-----------------------------------------------

### Tercera pregunta (3 puntos)

### ¿Cuál parece el mejor gráfico para diferenciar las muestras del Iris flower data set?  Cambie `x_index` e `y_index` en la última pieza de código para encontrar una combinación de parámetros que maximice la separación entre clases. Incluya abajo el código necesario para representar las diferentes clases lo más separadas posible y ejecute para representar la gráfica.

```python
x_index = 2
y_index = 3

# Esta línea de código es sólo para crear una barra de leyenda para las clases
formatter = plt.FuncFormatter(lambda i, *args: iris.target_names[int(i)])

plt.scatter(iris.data[:, x_index], iris.data[:, y_index],
            c=iris.target, cmap=plt.cm.get_cmap('RdYlBu', 3))
plt.colorbar(ticks=[0, 1, 2], format=formatter)
plt.clim(-0.5, 2.5)
plt.xlabel(iris.feature_names[x_index])
plt.ylabel(iris.feature_names[y_index]);
```

```python
import pandas as pd
import seaborn as sns

df = pd.DataFrame(iris.data, columns=iris.feature_names)
corr = df.corr()
plt.subplots(figsize=(10,10))
sns.heatmap(corr, annot=True,
            xticklabels=corr.columns.values,
            yticklabels=corr.columns.values)
plt.show()
```

**Respuesta:** El par de características que mejor representa la diferentes clases son la longitud del pétalo y el ancho del pétalo con una correlación de 0.96, por tanto el gráfico se visualiza claramente la frontera de entre las clases.

--------------------------------------

Nota: La problemática encontrada dada la dimensionalidad del dataset se resolvería haciendo uso de técnicas de reducción de dimensionalidad. Aunque no entra dentro del scope del presente jupyter notebook, pueden encontrar un resumen de estas técnicas con sus aplicaciones en los siguientes dos enlaces para ir adelantando:
- [Principal Component Analysis : Dimensionality Reduction Technique — Step by Step Approach](https://code.likeagirl.io/principal-component-analysis-dimensionality-reduction-technique-step-by-step-approach-ffd46623ff67)
- [Visualising high-dimensional datasets using PCA and t-SNE in Python](https://medium.com/@luckylwk/visualising-high-dimensional-datasets-using-pca-and-t-sne-in-python-8ef87e7915b)

----------------------------------------------------------------------

¿Ha hecho un backup? ¡¡¡No olvide guardar el Jupyter Notebook una vez realizado antes de su envío!!!