## Machine Learning Introduction

### Machine Learning Modeling in Python with scikit-learn Pipelines

#### Michael Pyrcz, Associate Professor, University of Texas at Austin

##### [Twitter](https://twitter.com/geostatsguy) | [GitHub](https://github.com/GeostatsGuy) | [Website](http://michaelpyrcz.com) | [GoogleScholar](https://scholar.google.com/citations?user=QVZ20eQAAAAJ&hl=en&oi=ao) | [Book](https://www.amazon.com/Geostatistical-Reservoir-Modeling-Michael-Pyrcz/dp/0199731446) | [YouTube](https://www.youtube.com/channel/UCLqEr-xV-ceHdXXXrTId5ig)  | [LinkedIn](https://www.linkedin.com/in/michael-pyrcz-61a648a1) | [GeostatsPy](https://github.com/GeostatsGuy/GeostatsPy)

###  Machine Learning Modeling Pipelines

Machine learning workflows can be complicated, with various steps:

* data preparation, feature engineering transformations

* model parameter fitting

* model hyperparameter tuning

* modeling method selection

* searching over a large combinatorial of hyperparameters

* training and testing model runs

Pipelines are a scikit-learn class that allows for the encapsilation of a seuqence of data preparation and modeling steps

* then we can treat the pipeline as an object in our much condensed workflow

The pipeline class allows us to:

* improve code readability and to keep everything straight

* avoid common workflow problems like data leakage, testing data informing model parameter training

* abstract common machine learning modeling and focus on building the best model possible

The fundamental philosophy is to treat machine learning as a combinatorial search to find the best model (AutoML)

#### Machine Learning-based Prediction

Predictive machine learning methods with supervised learning:

**Prediction**

* non-parametric method for regression and classification

* a function $\hat{f}$ of the nearest $k$ training data in predictor feature space such that we predict a response feature $Y$ from a set of predictor features $X_1,\ldots,X_m$.

* the prediction is of the form $\hat{Y} = \hat{f}(X_1,\ldots,X_m)$

**Suppervised Learning**

* the response feature label, $Y$, is available over the training and testing data

#### The Machine Learning Models

This workflow includes the following models:

* linear regression
* k-nearest neighbor regression
* decision tree regression
* random forest regression
* tree-based gradient boosting regression

#### Getting Started

Here's the steps to get setup in Python with the GeostatsPy package:

1. Install Anaconda 3 on your machine (https://www.anaconda.com/download/).
2. From Anaconda Navigator (within Anaconda3 group), go to the environment tab, click on base (root) green arrow and open a terminal.
3. In the terminal type: pip install geostatspy.
4. Open Jupyter and in the top block get started by copy and pasting the code block below from this Jupyter Notebook to start using the geostatspy functionality.

If you want to work locally you will need to first copy the data file to your working directory. It is available here:

* Tabular data - unconv_MV.csv found [here](https://github.com/GeostatsGuy/GeoDataSets/blob/master/unconv_MV.csv).

You don't have to download the data if you want to load directly from my github account with the code below.

#### Import Required Packages

We will also need some standard packages. These should have been installed with Anaconda 3.

```python
import os                                                   # to set current working directory
import math                                                 # basic calculations like square root
from sklearn.neighbors import KNeighborsRegressor           # for nearest k neighbours
from sklearn import tree                                    # decision trees
from sklearn import metrics                                 # measures to check our models
from sklearn.model_selection import cross_val_score         # cross validation methods
import pandas as pd                                         # DataFrames and plotting
import pandas.plotting as pd_plot
import numpy as np                                          # arrays and matrix math
import matplotlib.pyplot as plt                             # plotting
from matplotlib import gridspec
from subprocess import check_call
from sklearn.model_selection import train_test_split        # train and test split
from sklearn.preprocessing import StandardScaler            # data transformation
from sklearn.pipeline import Pipeline                       # machine learning modeling pipeline
from sklearn.pipeline import make_pipeline                  # machine learning modeling pipeline
from sklearn.model_selection import GridSearchCV            # model hyperparameter grid search
from sklearn.model_selection import KFold                   # k-fold cross validation
seed = 73073                                                # random number seed
```

If you get a package import error, you may have to first install some of these packages. This can usually be accomplished by opening up a command window on Windows and then typing 'python -m pip install [package-name]'. More assistance is available with the respective package docs.

#### Declare functions

Let's define a couple of functions to streamline plotting correlation matrices and visualization of a decision tree regression model.

```python
def visualize_model(model,xfeature,x_min,x_max,x_label,yfeature,y_min,y_max,y_label,response,z_min,z_max,resp_label,title,legend=True):# plots the data points and the decision tree prediction
    n_classes = 10
    cmap = plt.cm.RdYlBu
    plot_step = 0.02
    xx, yy = np.meshgrid(np.arange(x_min, x_max, plot_step),
                     np.arange(y_min, y_max, plot_step))
    Z = model.predict(np.c_[xx.ravel(), yy.ravel()])
    Z = np.clip(Z,z_min,z_max)
    Z = Z.reshape(xx.shape)
    cs = plt.contourf(xx, yy, Z, cmap=cmap,vmin=z_min, vmax=z_max, levels=np.linspace(z_min, z_max, 100))

    im = plt.scatter(xfeature,yfeature,s=None, c=response, marker=None, cmap=cmap, norm=None, vmin=z_min, vmax=z_max, alpha=0.8, linewidths=0.3, verts=None, edgecolors="black")
    plt.title(title)
    plt.xlabel(x_label); plt.ylabel(y_label)
    plt.xlim(x_min,x_max); plt.ylim(y_min,y_max)
    if legend == True:
      cbar = plt.colorbar(im, orientation = 'vertical')
      cbar.set_label(resp_label, rotation=270, labelpad=20)

def visualize_model_sub(ax,model,xfeature,x_min,x_max,yfeature,y_min,y_max,response,z_min,z_max,title,legend=True):# plots the data points and the decision tree prediction
    n_classes = 10
    cmap = plt.cm.RdYlBu
    plot_step = 0.02
    xx, yy = np.meshgrid(np.arange(x_min, x_max, plot_step),
                     np.arange(y_min, y_max, plot_step))
    Z = model.predict(np.c_[xx.ravel(), yy.ravel()])
    Z = np.clip(Z,z_min,z_max)
    Z = Z.reshape(xx.shape)
    cs = ax.contourf(xx, yy, Z, cmap=cmap,vmin=z_min, vmax=z_max, levels=np.linspace(z_min, z_max, 100))

    im = ax.scatter(xfeature,yfeature,s=None, c=response, marker=None, cmap=cmap, norm=None, vmin=z_min, vmax=z_max, alpha=0.8, linewidths=0.3, verts=None, edgecolors="black")
    ax.set_title(title); ax.set_xlabel(xfeature.name); ax.set_ylabel(yfeature.name)
    ax.set_xlim(x_min,x_max)
    ax.set_ylim(y_min,y_max)
    if legend == True:
      cbar = plt.colorbar(im, orientation = 'vertical')
      cbar.set_label(response.name, rotation=270, labelpad=20)

def visualize_model_wrap(model,ix1,ix2,iy,title,trans=True):
    if trans == True:
        visualize_model(model,X[features[ix1]],vmin_trans[ix1],vmax_trans[ix1],str(features[ix1]) + ' (' + str(units[ix1]) + ') ',
                              X[features[ix2]],vmin_trans[ix2],vmax_trans[ix2],str(features[ix2]) + ' (' + str(units[ix2]) + ') ',
                              y[features[iy]],vmin[iy],vmax[iy],str(features[iy]) + ' (' + str(units[iy]) + ') ',
                              title,legend=True)# plots the data points and the decision tree prediction
    if trans == False:
        visualize_model(model,X_orig[features[ix1]],vmin[ix1],vmax[ix1],str(features[ix1]) + ' (' + str(units[ix1]) + ') ',
                              X_orig[features[ix2]],vmin[ix2],vmax[ix2],str(features[ix2]) + ' (' + str(units[ix2]) + ') ',
                              y[features[iy]],vmin[iy],vmax[iy],str(features[iy]) + ' (' + str(units[iy]) + ') ',
                              title,legend=True)# plots the data points and the decision tree prediction

def visualize_tuned_model(k_tuned,k_mat,score_mat):
    plt.scatter(k_mat,score_mat,s=5.0, c="red", marker=None, cmap=None, norm=None, vmin=None, vmax=None, alpha=0.2, linewidths=0.5, verts=None, edgecolors="black")
    plt.plot([k_tuned,k_tuned],[0,10000000],color='black',linestyle=(6, (2,3)),label='tuned',zorder=1)
    plt.title('k-fold Cross Validation Error (MSE) vs. k Nearest Neighbours'); plt.xlabel('Number of Nearest Neighbours'); plt.ylabel('Mean Square Error')
    plt.xlim(k_min,k_max); plt.ylim(0,np.max(score_mat))

def visualize_tuned_model2(k_tuned,k_mat,score_mat):
    plt.scatter(k_mat[0:len(k_mat):2],score_mat[0:len(k_mat):2],s=5.0, c="red", marker=None, cmap=None, norm=None, vmin=None, vmax=None, alpha=0.4, linewidths=0.5, verts=None, edgecolors="black",label='uniform',zorder=3)
    plt.scatter(k_mat[1:len(k_mat):2],score_mat[1:len(k_mat):2],s=5.0, c="blue", marker=None, cmap=None, norm=None, vmin=None, vmax=None, alpha=0.4, linewidths=0.5, verts=None, edgecolors="black",label='distance',zorder=2)
    plt.plot([k_tuned,k_tuned],[0,10000000],color='black',linestyle=(6, (2,3)),label='tuned',zorder=1)
    plt.legend(loc='lower right')
    plt.title('k-fold Cross Validation Error (MSE) vs. k Nearest Neighbours'); plt.xlabel('Number of Nearest Neighbours'); plt.ylabel('Mean Square Error')
    plt.xlim(k_min,k_max); plt.ylim(0,np.max(score_mat))
```

#### Set the working directory

I always like to do this so I don't lose files and to simplify subsequent read and writes (avoid including the full address each time).

```python
os.chdir("c:/PGE383")                                       # set the working directory
```

You will have to update the part in quotes with your own working directory and the format is different on a Mac (e.g. "~/PGE").

#### Read the data table

We use a data "unconv_MV.csv" comma delimited file from my GitHub [respository](https://github.com/GeostatsGuy/GeoDataSets).

* this code loads the data directly from my GitHub repository.
* if you have the data file available locally (i.e. you are not connected to the internet) set the working directory to the location with the data file and use the second line below

```python
my_data = pd.read_csv("https://raw.githubusercontent.com/GeostatsGuy/GeoDataSets/master/unconv_MV.csv")   # load the comma delimited data file
#my_data = pd.read_csv("unconv_MV.csv")                     # load the comma delimited data file locally
```

Let's visualize the first several rows of our data stored in a DataFrame so we can make sure we successfully loaded the data file.

```python
my_data[:7]                                                 # preview the first 7 rows of the dataframe
```

      WellIndex
      Por
      LogPerm
      AI
      Brittle
      TOC
      VR
      Production

      0
      1
      15.91
      1.67
      3.06
      14.05
      1.36
      1.85
      177.381958

      1
      2
      15.34
      1.65
      2.60
      31.88
      1.37
      1.79
      1479.767778

      2
      3
      20.45
      2.02
      3.13
      63.67
      1.79
      2.53
      4421.221583

      3
      4
      11.95
      1.14
      3.90
      58.81
      0.40
      2.03
      1488.317629

      4
      5
      19.53
      1.83
      2.57
      43.75
      1.40
      2.11
      5261.094919

      5
      6
      19.47
      2.04
      2.73
      54.37
      1.42
      2.12
      5497.005506

      6
      7
      12.70
      1.30
      3.70
      43.03
      0.45
      1.95
      1784.266285

Let's remove the well index and check the summary summary statistics.

```python
my_data = my_data.iloc[:,1:8]                               # copy all rows and columns 1 through 8, note 0 column is removed
my_data.describe().transpose()                              # calculate summary statistics for the data
```

      count
      mean
      std
      min
      25%
      50%
      75%
      max

      Por
      1000.0
      14.950460
      3.029634
      5.400000
      12.85750
      14.98500
      17.080000
      24.65000

      LogPerm
      1000.0
      1.398880
      0.405966
      0.120000
      1.13000
      1.39000
      1.680000
      2.58000

      AI
      1000.0
      2.982610
      0.577629
      0.960000
      2.57750
      3.01000
      3.360000
      4.70000

      Brittle
      1000.0
      49.719480
      15.077006
      -10.500000
      39.72250
      49.68000
      59.170000
      93.47000

      TOC
      1000.0
      1.003810
      0.504978
      -0.260000
      0.64000
      0.99500
      1.360000
      2.71000

      VR
      1000.0
      1.991170
      0.308194
      0.900000
      1.81000
      2.00000
      2.172500
      2.90000

      Production
      1000.0
      2247.295809
      1464.256312
      2.713535
      1191.36956
      1976.48782
      3023.594214
      12568.64413

It is good that we checked the summary statistics, because we have some negative values for brittleness and total organic carbon. The is physically imposible.  The values must be in error. We know the lowest possible values are 0.0, so we will truncate on 0.0.  We use the *get_numerical_data()* DataFrame member function to get a shallow copy of the data from the DataFrame.  Since it is a shallow copy, any changes we make to the copy are made to the data in the original DataFrame.  This allows us to apply this simple conditional statement to all the data values in the DataFrame all at once.

```python
num = my_data._get_numeric_data()                           # get the numerical values
num[num < 0] = 0                                            # truncate negative values to 0.0
my_data.describe().transpose()                              # calculate summary statistics for the data
```

      count
      mean
      std
      min
      25%
      50%
      75%
      max

      Por
      1000.0
      14.950460
      3.029634
      5.400000
      12.85750
      14.98500
      17.080000
      24.65000

      LogPerm
      1000.0
      1.398880
      0.405966
      0.120000
      1.13000
      1.39000
      1.680000
      2.58000

      AI
      1000.0
      2.982610
      0.577629
      0.960000
      2.57750
      3.01000
      3.360000
      4.70000

      Brittle
      1000.0
      49.731480
      15.033593
      0.000000
      39.72250
      49.68000
      59.170000
      93.47000

      TOC
      1000.0
      1.006170
      0.499838
      0.000000
      0.64000
      0.99500
      1.360000
      2.71000

      VR
      1000.0
      1.991170
      0.308194
      0.900000
      1.81000
      2.00000
      2.172500
      2.90000

      Production
      1000.0
      2247.295809
      1464.256312
      2.713535
      1191.36956
      1976.48782
      3023.594214
      12568.64413

#### Let's Build a Predictive Machine Learning Model

We will complete the entire data transformation, model parameter training and model hyperparameter tuning workflow:

1. step-by-step with low level code
2. with a concise high level code using **pipelines**

Here's our machine learning prediction model:

\begin{equation}
y = f(X_1,X_2) + \epsilon
\end{equation}

We will use k-nearest neighbors regression, if you are interested in more details I have a lecture and examples online:

* k-nearest nearbours [Lecture](https://www.youtube.com/watch?v=lzmeChSYvv8&list=PLG19vXLQHvSC2ZKFIkgVpI9fCjkN38kwf&index=30)

* I have a lot of lectures available on machine learning in the Subsurface Machine Learning [Playlist](https://www.youtube.com/watch?v=5kBS5ThMHcU&list=PLG19vXLQHvSC2ZKFIkgVpI9fCjkN38kwf)

The general workflow is:

A. **Data Preparation and Feature Engineering** - visualize the data, store feature labels and units, select 2 predictor features and standardize the predictor features to avoid biased predictions due to difference in the units. You can also add noise to the response feature to better observe model overfit.

B. select 2 features so we can visualize the model

Note, we select 2 features for ease of model visualization and to provide the opportunity to explore different models with the same dataset.

### A. Data Preparation and Feature Engineering

The k-nearest neighbour etc. method uses a nearest training sample search in feature space (like k-means clustering). To remove the impact feature range from the approach we standardize the features.

* we will standardize our predictor features to have a mean of zero and a variance of one.
* we use the scikikt learn preprocessing to simplify this step and to provide a reverse transform.

We will also store the names, units and minimum and maximum values in arrays.

```python
transform = StandardScaler();
my_data_trans = my_data.copy(deep = True)
my_data_trans.iloc[:,:] = transform.fit_transform(my_data)
my_data_trans.describe().transpose()                              # calculate summary statistics for the data
```

      count
      mean
      std
      min
      25%
      50%
      75%
      max

      Por
      1000.0
      1.186273e-16
      1.0005
      -3.153925
      -0.691175
      0.011406
      0.703255
      3.203157

      LogPerm
      1000.0
      -2.957079e-16
      1.0005
      -3.151793
      -0.662653
      -0.021885
      0.692819
      2.910864

      AI
      1000.0
      4.085621e-16
      1.0005
      -3.503327
      -0.701684
      0.047442
      0.653670
      2.974660

      Brittle
      1000.0
      1.345590e-16
      1.0005
      -3.309679
      -0.666107
      -0.003426
      0.628143
      2.910842

      TOC
      1000.0
      -1.093570e-17
      1.0005
      -2.014001
      -0.732944
      -0.022358
      0.708244
      3.410473

      VR
      1000.0
      -3.831380e-16
      1.0005
      -3.542302
      -0.588138
      0.028665
      0.588658
      2.950365

      Production
      1000.0
      -4.163336e-17
      1.0005
      -1.533683
      -0.721496
      -0.185038
      0.530431
      7.052394

#### Feature Attributes

Let's store the features' attributes:

* name
* units
* minimum value
* maximum value

To assist with all the plots and associated labels and color bars.

```python
features = np.array(my_data_trans.columns.tolist())               # array of feature names
units = ['%','Log(mD)','kg/m2s*10^6','%','%','%','MCFPD']   # list of feature units
vmin = my_data.min(); vmax = my_data.max() # list of feature minimums and maximums
vmin_trans = my_data_trans.min(); vmax_trans = my_data_trans.max() # list of feature minimums and maximums

print('Available features are ' + str(features) + '.')
print('Available features\' units are ' + str(features) + '.')
print('Available features\' minimum values are ' + str(vmin.values) + '.')
print('Available features\' maximum values are ' + str(vmax.values) + '.')
print('Available transformed features\' minimum values are ' + str(vmin_trans.values) + '.')
print('Available transformed features\' maximum values are ' + str(vmax_trans.values) + '.')
```

    Available features are ['Por' 'LogPerm' 'AI' 'Brittle' 'TOC' 'VR' 'Production'].
    Available features' units are ['Por' 'LogPerm' 'AI' 'Brittle' 'TOC' 'VR' 'Production'].
    Available features' minimum values are [5.4        0.12       0.96       0.         0.         0.9
     2.71353485].
    Available features' maximum values are [2.46500000e+01 2.58000000e+00 4.70000000e+00 9.34700000e+01
     2.71000000e+00 2.90000000e+00 1.25686441e+04].
    Available transformed features' minimum values are [-3.15392477 -3.15179313 -3.50332658 -3.30967893 -2.0140009  -3.54230176
     -1.53368327].
    Available transformed features' maximum values are [3.20315665 2.91086412 2.97466048 2.91084154 3.41047254 2.95036531
     7.0523943 ].

This dataset has variables from 1,000 unconventional wells including well average porosity, log transform of permeability (to linearize the relationships with other variables), accoustic impedance (kg/m2s*10^6), brittness ratio (%), total organic carbon (%), vitrinite reflectance (%), and initial production 90 day average (MCFPD).  Note, the dataset is synthetic.

#### Working with Only Two Predictor Features

Let's simplify the problem:

* to 2 predictor features, Porosity and Brittleness to predict Production rate.
* this is very easy to visualize the prediction model as a map over the predictor feature space.

Select the 2 predictor features and the 1 response feature by index below with this code:

```python
ix1 = 0                                                     # select the predictor features
ix2 = 3
iy = 6                                                      # select the response feature
```

Make sure the indices are between 0 and do not repeat.

```python
ix1 = 0                                                     # select the predictor features
ix2 = 3
iy = 6                                                      # select the response feature
X = my_data_trans.iloc[:,[ix1,ix2]]                         # use transformed predictor features
X_orig = my_data.iloc[:,[ix1,ix2]]                          # include nonstransformed predictor features for pipeline
y = pd.DataFrame(data = my_data.iloc[:,iy])                 # use original response features
print('Predictor features are ' + str(features[[ix1,ix2]]) + ', and the response feature is ' + str(features[iy]) + '.')
```

    Predictor features are ['Por' 'Brittle'], and the response feature is Production.

#### Optional: Add Random Error to the Response Feature, $y$

This dataset is quite well-behaved and error free.

* to observe issues of overfit in model training let's add some error

```python
standardized_error_standard_deviation = 1000.0
y_old = y.copy(deep = True)
y[features[iy]] = y[features[iy]] + np.random.normal(loc = 0, scale = standardized_error_standard_deviation, size = y.shape[0])
```

#### Date Univariate Distributions

Let's first check the univariate statistics of the 2 predictor features, $X_1$ and $X_2$ and the 1 response feature, $y$.

```python
plt.subplot(131)
plt.hist(X[features[ix1]], alpha = 0.2, color = 'red', edgecolor = 'black', bins=20,density=True)
plt.xlabel(str(features[ix1]) + ' (' + str(units[ix1]) + ') '); plt.ylabel('Frequency')

plt.subplot(132)
plt.hist(X[features[ix2]], alpha = 0.2, color = 'red', edgecolor = 'black', bins=20,density=True)
plt.xlabel(str(features[ix2]) + ' (' + str(units[ix2]) + ') '); plt.ylabel('Frequency')

plt.subplot(133)
plt.hist(y[features[iy]], alpha = 0.2, color = 'red', edgecolor = 'black', bins=20,density=True,label = 'With Error')
plt.hist(y_old[features[iy]], alpha = 0.2, color = 'yellow', edgecolor = 'black', bins=20,density=True,label = 'Original')
plt.xlabel(str(features[iy]) + ' (' + str(units[iy]) + ') '); plt.ylabel('Frequency')
plt.legend(loc = 'upper right')

plt.subplots_adjust(left=0.0, bottom=0.0, right=3.0, top=1.2, wspace=0.2, hspace=0.2)
plt.show()
```

#### Bivariate Distributions

The distributions are well behaved, we cannot observe obvious gaps nor truncations.  Let's look at a scatter plot of $X_1$ vs. $X_2$ with points colored by $y$.

```python
plt.subplot(121)
im = plt.scatter(X[features[ix1]],X[features[ix2]],s=None, c=y[features[iy]], marker=None, cmap=None, norm=None, vmin=vmin[iy], vmax=vmax[iy], alpha=0.8, linewidths=0.3, verts=None, edgecolors="black")
plt.title('Training ' + str(features[iy]) + ' vs. ' + str(features[ix1]) + ' and ' + str(features[ix2]))
plt.xlabel(str(features[ix1]) + ' (' + str(units[ix1]) + ') '); plt.ylabel(str(features[ix2]) + ' (' + str(units[ix2]) + ') ')
plt.xlim(vmin_trans[ix1],vmax_trans[ix1]); plt.ylim(vmin_trans[ix2],vmax_trans[ix2])
cbar = plt.colorbar(im, orientation = 'vertical')
cbar.set_label(str(features[iy]) + ' (' + str(units[iy]) + ')', rotation=270, labelpad=20)

plt.subplot(122)
im = plt.scatter(X[features[ix1]],X[features[ix2]],s=None, c=y_old[features[iy]], marker=None, cmap=None, norm=None, vmin=vmin[iy], vmax=vmax[iy], alpha=0.8, linewidths=0.3, verts=None, edgecolors="black")
plt.title('Original Training ' + str(features[iy]) + ' vs. ' + str(features[ix1]) + ' and ' + str(features[ix2]))
plt.xlabel(str(features[ix1]) + ' (' + str(units[ix1]) + ') '); plt.ylabel(str(features[ix2]) + ' (' + str(units[ix2]) + ') ')
plt.xlim(vmin_trans[ix1],vmax_trans[ix1]); plt.ylim(vmin_trans[ix2],vmax_trans[ix2])
cbar = plt.colorbar(im, orientation = 'vertical')
cbar.set_label(str(features[iy]) + ' (' + str(units[iy]) + ')', rotation=270, labelpad=20)

plt.subplots_adjust(left=0.0, bottom=0.0, right=2.0, top=1.2, wspace=0.2, hspace=0.2)
plt.show()
```

This problem looks complicated and could not be modeled with simple linear regression.  It appears there is a sweet spot for Brittleness and increasing Porosity is always beneficial for Production.

#### Instantiate, Tune and then Predict with $k$ Nearest Neighbour

Let's instantiate, fit and predict with a $k$ nearest neighbour model.

* loop over the hyperparameter, $k = k_{min}, \ldots, k_{max}$

    * apply k-fold cross validation
    * calculate the average error over the k-folds

* plot the error vs. the k hyperparameter

* select the minimum error k in k-fold cross validation

* rebuild the model, training with all data and the tuned k hyperparameter

Recal that k hyperparameter:

* **k** - number of nearest training data to use for the estimate
* **low k** - high model complexity / flexibility
* **high k** - low model complexity / flexibility

To adjust the range of considered k hyperparameter, change this line of code below:

```python
k_min = 2                                                   # hyperparameter range to explore
k_max = 500
```

```python
folds = 4                                                   # number of folds for k-fold cross validation
k_min = 1                                                   # hyperparameter range to explore
k_max = 200
#k_max = int(len(my_data)*(folds-1)/(folds))                # use this to select the maximum k possible
np.random.seed(seed = seed)

k_mat = []                                                  # declare lists for k and score
score_mat = []
for k in range(k_min,k_max):                                # loop over k hyperparameter
    neigh_dist = KNeighborsRegressor(weights = 'distance',metric='euclidean',n_neighbors=k, p = 2) # instantiate the model
    scores = cross_val_score(estimator=neigh_dist, X=X,y=y,
              cv=KFold(n_splits=folds,shuffle=False),
              n_jobs=4,scoring = "neg_mean_squared_error")
    k_mat.append(k); score_mat.append(abs(np.average(scores))) # store k and average fold error

min_error, index_tuned = min((error, index) for (index, error) in enumerate(score_mat)) # find the best k
k_tuned = k_mat[index_tuned]

plt.subplot(121)
visualize_tuned_model(k_tuned,k_mat,score_mat)              # visualize the error vs. k

neigh_dist_tuned = KNeighborsRegressor(weights = 'distance', n_neighbors=k_tuned, p = 2) # refit tuned model
neigh_dist_tuned.fit(X,y)

plt.subplot(122)                                            # visualize the tuned model
visualize_model_wrap(neigh_dist_tuned,ix1,ix2,iy,'Tuned k Nearest Neighbours Prediction Model',trans=True)

plt.subplots_adjust(left=0.0, bottom=0.0, right=2.0, top=1.2, wspace=0.2, hspace=0.2)
plt.show()
```

```python

```

#### Same Complete Workflow with Pipelines

Let's repeat the above workflow with a pipline from scikit-learn. Here some general comments:

* **pipeline workflow steps** - list of steps, custom labels and the associated scikit-learn classes
* **choices and hyperparameters** - as a dictionary with combined step custom labels and hyperparameter names
* **workflow scenarios** - we can specify lists of choices and hyperparameters
* **consistent iteration** - the entire workflow is repeated for the full combinatorial
* **best model selected** - best combination of model hyperparameters and choices are refit with all data

```python
pipe = Pipeline([                                           # the machine learning workflow as a pipeline object
    ('scaler', StandardScaler()),
    ('knear', KNeighborsRegressor())
])

params = {                                                  # the machine learning workflow method's parameters
    'scaler': [StandardScaler()],
    'knear__n_neighbors': np.arange(k_min,k_max+1,1,dtype = int),
    'knear__metric': ['euclidean'],
    'knear__p': [2],
    'knear__weights': ['distance']
}

grid_cv_tuned = GridSearchCV(pipe, params, scoring = 'neg_mean_squared_error', # grid search cross validation
                             cv=KFold(n_splits=folds,shuffle=False),
                             refit = True)
grid_cv_tuned.fit(X_orig,y)                                 # fit model with tuned hyperparameters

plt.subplot(121)
visualize_tuned_model(grid_cv_tuned.best_params_['knear__n_neighbors'], # visualize the error vs. k
                      grid_cv_tuned.cv_results_['param_knear__n_neighbors'],
                      abs(grid_cv_tuned.cv_results_['mean_test_score']))

plt.subplot(122)                                            # visualize the tuned model
visualize_model_wrap(grid_cv_tuned,ix1,ix2,iy,'Tuned k Nearest Neighbours Prediction Model',trans=False)

plt.subplots_adjust(left=0.0, bottom=0.0, right=2.0, top=1.2, wspace=0.2, hspace=0.2);
plt.show()
```

#### Discussion

Let's compare and contrast the without pipeline and with pipeline workflows.

* **Code Readability** - **With Pipeline**, this is a great advantage for the pipeline workflow. The list of machine learning steps are included in the pipe isntantiation and then the model choices and hyperparameters are listed.

* **Book Keeping** - **With Pipeline**, the pipeline workflow makes this effortless.
    * *.Fit()* operator conducts cross validation runs over all hyperparameters after the data transformation(s)and then the best model is refit with all the data.
    * *.Predict()* operator encapsulates the data transformations and prediction with the best, refit model.
    * *best_params_* stores the best model hyperparameters and choices.

* **Experimentation** - **With Pipeline**, the pipeline workflow make this extremely compact. You can run any variety of model scenarios and select the best model.

* **Control** - **Without Pipeline**, there is a lot of flexibility with the current pipeline class, but you may be challenged for highly custom workflows and methods ot use the pipeline workflow.

#### Retrieve the Tuned Model Hyperparameters

We can access the best model hyperparameters and choices as the best_params_.

```python
print('The best model hyperparameters:')
grid_cv_tuned.best_params_
```

    The best model hyperparameters:

    {'knear__metric': 'euclidean',
     'knear__n_neighbors': 31,
     'knear__p': 2,
     'knear__weights': 'distance',
     'scaler': StandardScaler(copy=True, with_mean=True, with_std=True)}

#### More Complicate Model Tuning with Pipelines

Let's expand the with pipeline workflow and demonstrate more experimentation.

* Let's iterate over more model hyperparameters and choices by including uniform and distance weighting
* We find the best combination of hyperparameters to minimize error in k-fold cross validation

```python
params = {                                                  # the machine learning workflow method's parameters
    'scaler': [StandardScaler()],
    'knear__n_neighbors': np.arange(k_min,k_max+1,1,dtype = int),
    'knear__metric': ['euclidean'],
    'knear__p': [2],
    'knear__weights': ['uniform','distance']
}

grid_cv_tuned = GridSearchCV(pipe, params, scoring = 'neg_mean_squared_error', # grid search cross validation
                             cv=KFold(n_splits=folds,shuffle=False),
                             refit = True)
grid_cv_tuned.fit(X_orig,y)                                 # fit model with tuned hyperparameters

plt.subplot(111)
visualize_tuned_model2(grid_cv_tuned.best_params_['knear__n_neighbors'], # visualize the error vs. k
                      grid_cv_tuned.cv_results_['param_knear__n_neighbors'],
                      abs(grid_cv_tuned.cv_results_['mean_test_score']))
```

#### Retrieve the Tuned Model Hyperparameters

We can access the best model hyperparameters and choices as the best_params_

```python
print('The best model hyperparameters:')
grid_cv_tuned.best_params_
```

    {'scaler': StandardScaler(copy=True, with_mean=True, with_std=True),
     'tree__max_depth': 7,
     'tree__max_leaf_nodes': 35,
     'tree__min_samples_leaf': 5}

#### One More Time

Now let's switch to a decision tree model and find the best fit hyperparameters.

* we can use a variety of hyperparameters

* we will do full combinatorial, grid search again

```python
pipe_tree = Pipeline([                                           # the machine learning workflow as a pipeline object
    ('tree', tree.DecisionTreeRegressor())
])

params_tree = {                                                  # the machine learning workflow method's parameters
    'tree__max_depth': np.arange(1,10,1,dtype = int),
    'tree__max_leaf_nodes': np.arange(2,30,1,dtype = int),
    'tree__min_samples_leaf': np.arange(1,10,1,dtype = int),
}

grid_cv_tuned_tree = GridSearchCV(pipe_tree, params_tree, scoring = 'neg_mean_squared_error', # grid search cross validation
                             cv=KFold(n_splits=folds,shuffle=False),
                             refit = True)

grid_cv_tuned_tree.fit(X_orig,y)                                 # fit model with tuned hyperparameters

plt.subplot(111)                                                 # visualize the tuned model
visualize_model_wrap(grid_cv_tuned_tree,ix1,ix2,iy,'Tuned k Nearest Neighbours Prediction Model',trans=False)

plt.subplots_adjust(left=0.0, bottom=0.0, right=1.0, top=1.2, wspace=0.2, hspace=0.2);
plt.show()
```

#### Retrieve the Tuned Model Hyperparameters

We can access the best model hyperparameters and choices as the best_params_

```python
grid_cv_tuned_tree.best_params_
```

    {'tree__max_depth': 9, 'tree__max_leaf_nodes': 29, 'tree__min_samples_leaf': 5}

#### Comments

This is provided for an opportunity to try out pipelines for building machine learning models.

* this is a basic introduction, much more could be done

I hope this was helpful,

*Michael*

Michael Pyrcz, Ph.D., P.Eng. Associate Professor The Hildebrand Department of Petroleum and Geosystems Engineering, Bureau of Economic Geology, The Jackson School of Geosciences, The University of Texas at Austin
On twitter I'm the @GeostatsGuy.

***

#### More on Michael Pyrcz and the Texas Center for Geostatistics:

### Michael Pyrcz, Associate Professor, University of Texas at Austin
*Novel Data Analytics, Geostatistics and Machine Learning Subsurface Solutions*

With over 17 years of experience in subsurface consulting, research and development, Michael has returned to academia driven by his passion for teaching and enthusiasm for enhancing engineers' and geoscientists' impact in subsurface resource development.

For more about Michael check out these links:

#### [Twitter](https://twitter.com/geostatsguy) | [GitHub](https://github.com/GeostatsGuy) | [Website](http://michaelpyrcz.com) | [GoogleScholar](https://scholar.google.com/citations?user=QVZ20eQAAAAJ&hl=en&oi=ao) | [Book](https://www.amazon.com/Geostatistical-Reservoir-Modeling-Michael-Pyrcz/dp/0199731446) | [YouTube](https://www.youtube.com/channel/UCLqEr-xV-ceHdXXXrTId5ig)  | [LinkedIn](https://www.linkedin.com/in/michael-pyrcz-61a648a1)

#### Want to Work Together?

I hope this content is helpful to those that want to learn more about subsurface modeling, data analytics and machine learning. Students and working professionals are welcome to participate.

* Want to invite me to visit your company for training, mentoring, project review, workflow design and / or consulting? I'd be happy to drop by and work with you!

* Interested in partnering, supporting my graduate student research or my Subsurface Data Analytics and Machine Learning consortium (co-PIs including Profs. Foster, Torres-Verdin and van Oort)? My research combines data analytics, stochastic modeling and machine learning theory with practice to develop novel methods and workflows to add value. We are solving challenging subsurface problems!

* I can be reached at mpyrcz@austin.utexas.edu.

I'm always happy to discuss,

*Michael*

Michael Pyrcz, Ph.D., P.Eng. Associate Professor The Hildebrand Department of Petroleum and Geosystems Engineering, Bureau of Economic Geology, The Jackson School of Geosciences, The University of Texas at Austin

#### More Resources Available at: [Twitter](https://twitter.com/geostatsguy) | [GitHub](https://github.com/GeostatsGuy) | [Website](http://michaelpyrcz.com) | [GoogleScholar](https://scholar.google.com/citations?user=QVZ20eQAAAAJ&hl=en&oi=ao) | [Book](https://www.amazon.com/Geostatistical-Reservoir-Modeling-Michael-Pyrcz/dp/0199731446) | [YouTube](https://www.youtube.com/channel/UCLqEr-xV-ceHdXXXrTId5ig)  | [LinkedIn](https://www.linkedin.com/in/michael-pyrcz-61a648a1)

```python

```


# Aula 4 - SciPy

Observação: As notas a seguir estão em constante desenvolvimento, aperfeiçoamento e revisões.

Conteúdo

1  Introdução

2  Álgebra Linear computacional (solução de sistemas lineares)

2.1  Solução de sistemas lineares (abordagem densa)

2.2  Solucionando com Fatoração LU

2.3  Solução de sistemas esparsos

3  Encontrando raízes de equações com scipy

3.1  Resolvendo com métodos livres de informação de derivadas

3.2  Resolvendo com métodos que utilizam derivadas

4  Resolvendo sistemas não-lineares

5  Problemas de Otimização Não-linear

5.1  Problemas irrestritos

5.1.1  Minimização local

5.1.2  Minimização global

5.2  Problemas com restrições

6  Interpolação

7  Ajuste de curvas

7.1  Ajuste por minimização de quadrado do resíduo

7.2  Ajuste de curva com a interface do scipy

8  Quadraturas Numéricas

8.1  Integral simples

8.2  Integrais duplas

8.3  Integrais triplas

9  Equações Diferenciais Ordinárias

9.1  Um problema mais interessante: Sistema de Lorenz

9.2  Van der Pol rígido

9.3  Apocalipse zumbi

10  Próximos passos...

## Introdução
----------------------------------------

Essa aula é baseada principalmente no material disponibilizado gratuitamente em [http://scipy-lectures.org/intro/scipy.html](http://scipy-lectures.org/intro/scipy.html) sob a licença [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).

* **O que é?**

O pacote SciPy contém um conjunto de toolboxes para solução dos problemas mais frequentes da Computação Científica. Cada um de seus sub-módulos é dedicado a um problema específico. A utilização da SciPy foi feita para ser aplicada eficientemente com a NumPy.

* **Por que eu deveria usar?**

Antes de se implementar algo objetivando solucionar um problema, é interessante verificar se já não existe uma solução disponibilizada, ou mesmo parte da solução do problema, objetivando ganhar tempo ou se focar apenas no que se deseja adicionar (a sua contribuição original). No meio acadêmico, composto em vasta maioria por programadores não-profissionais, existe uma tendência a **reinventar a roda**, e isso leva à criação de códigos que apresentam bugs, não-otimizados, difíceis de compartilhar para outros usuários, com desenvolvimento sem elaboração de testes apropriados e com baixa manutenibilidade, todos esses atributos indesejados em termos de Arquitetura e Projeto de Software. Por outro lado, a SciPy oferece rotinas **otimizadas e testadas**, o que torna seu uso como preferencial sempre que possível.

## Álgebra Linear computacional (solução de sistemas lineares)

Esse módulo da `scipy` oferece recursos para solucionamento de sistemas lineares e operações em matrizes. Ele basicamente herda todas as funcionalidades disponibilizadas pela `numpy`, com ligeiras mudanças, e algumas rotinas adicionais.

```python
import scipy.linalg as linalg

# Alguns pré-requisitos
import numpy as np
import numpy.random as rnd
```

### Solução de sistemas lineares (abordagem densa)

A rotina é bastante similar ao equivalente em `numpy`, inclusive é um wrapper da LAPACK.

```python
n = 100
shape = (n, n)
A = rnd.randint(2, size=shape)
b = rnd.randint(5, size=(n,))

A, b
```

    (array([[1, 0, 1, ..., 1, 1, 0],
            [1, 0, 1, ..., 0, 0, 1],
            [0, 1, 0, ..., 1, 1, 0],
            ...,
            [0, 0, 0, ..., 0, 1, 0],
            [1, 0, 0, ..., 1, 0, 0],
            [0, 0, 1, ..., 1, 1, 0]]),
     array([2, 3, 3, 2, 1, 3, 3, 3, 1, 4, 2, 3, 4, 4, 0, 0, 0, 2, 0, 2, 1, 2,
            4, 4, 0, 3, 0, 2, 2, 0, 3, 4, 1, 2, 0, 2, 4, 4, 4, 3, 1, 0, 2, 0,
            4, 4, 0, 1, 4, 4, 1, 4, 0, 4, 1, 3, 0, 2, 0, 2, 3, 2, 3, 4, 4, 1,
            4, 4, 4, 1, 2, 2, 3, 1, 2, 2, 2, 4, 4, 4, 2, 1, 4, 1, 3, 2, 1, 0,
            4, 0, 3, 4, 1, 1, 4, 3, 1, 1, 2, 3]))

```python
linalg.det(A)
```

    -1.9738270951972973e+47

```python
import matplotlib.pyplot as plt

plt.spy(A)
plt.show()
```

Solucionando:

```python
x = linalg.solve(A, b)

x
```

    array([ -82.7164806 ,   -2.96270617, -151.06487509,  -19.08967755,
              8.83862836,   11.34653803,   -5.44776064,   26.92565362,
            158.83444399,  -51.12905166, -134.08341032,  -17.66280145,
              0.80419445,  -11.68831808,  -29.90892537,  -12.23240838,
             29.86457556,  -46.29789116,   72.93453583,  -42.22197583,
             13.79321434,   56.09927433,   59.40283784,   16.94974588,
            -65.30227781,   -3.25734712,   62.61785503,   51.00830103,
            102.76151776,  -17.12401128,  150.30331322,   83.10413748,
            -15.60689603,  -15.07398606, -137.37444284,   24.86303914,
             48.89025745,  -41.41852994,  -47.71515084,   73.19304835,
            -37.72700976,   26.9577809 ,  -83.98343844, -189.55226606,
             35.29619078,   15.41808593,  -41.59848702,    0.78453521,
            -64.84137661,  -31.87531852,   13.0402689 ,  -24.99819604,
            101.63022238,   45.9238573 , -109.83604935,   61.88247869,
             72.5279913 ,   27.88962286,  -38.20705127,  -19.0390928 ,
           -108.84023019,   24.44441981,    5.58167811,  -55.41652727,
             12.70834201,  -47.58408435,   -5.67149441,   30.72536621,
            -63.37401229,   30.03328435,   71.65085963,   73.60675895,
             -5.42522681,   32.80862951,   -7.87702417,   85.98257389,
             23.84319741,   16.62380602,  -11.1597425 ,  -91.69084818,
              7.66973906,   33.8967038 ,  -42.05255591,   31.82730897,
             14.11558382,  -67.7580847 ,  -25.11368995,   48.8321148 ,
            -49.46817067,   13.35482847,    0.29641158,   57.19453605,
            -70.51908646,  -24.5865773 ,  -18.50695833,   25.92815729,
             43.63689662,   47.7744739 ,   81.81421388,   12.51963215])

```python
np.allclose(np.dot(A, x), b)
```

    True

### Solucionando com Fatoração LU

A fatoração é feita da seguinte forma: $A \equiv P L U$. Para resolver um sistema linear com fatoração $LU$, os seguintes passos são aplicados:

1. Resolver o sistema $L y = P b$ para obter $y$;
2. Por fim, obtemos $x$ resolvendo $U x = y$.

Vamos obter os fatores $P L U$ (permutador de linhas, diag. inferior, diag. superior):

```python
P, L, U = linalg.lu(A)

P, L, U
```

    (array([[1., 0., 0., ..., 0., 0., 0.],
            [0., 0., 0., ..., 0., 0., 0.],
            [0., 1., 0., ..., 0., 0., 0.],
            ...,
            [0., 0., 0., ..., 0., 0., 0.],
            [0., 0., 0., ..., 0., 0., 0.],
            [0., 0., 0., ..., 0., 0., 0.]]),
     array([[ 1.        ,  0.        ,  0.        , ...,  0.        ,
              0.        ,  0.        ],
            [ 0.        ,  1.        ,  0.        , ...,  0.        ,
              0.        ,  0.        ],
            [ 1.        ,  1.        ,  1.        , ...,  0.        ,
              0.        ,  0.        ],
            ...,
            [ 1.        ,  1.        , -0.        , ...,  1.        ,
              0.        ,  0.        ],
            [ 0.        ,  1.        , -1.        , ..., -0.32079621,
              1.        ,  0.        ],
            [ 1.        ,  0.        , -0.        , ...,  0.9338438 ,
              0.10281833,  1.        ]]),
     array([[ 1.        ,  0.        ,  1.        , ...,  1.        ,
              1.        ,  0.        ],
            [ 0.        ,  1.        ,  0.        , ...,  1.        ,
              1.        ,  0.        ],
            [ 0.        ,  0.        , -1.        , ..., -1.        ,
             -1.        ,  0.        ],
            ...,
            [ 0.        ,  0.        ,  0.        , ..., -3.96413061,
              2.17530996,  0.89069924],
            [ 0.        ,  0.        ,  0.        , ...,  0.        ,
             -0.22062118,  1.37243093],
            [ 0.        ,  0.        ,  0.        , ...,  0.        ,
              0.        , -0.65385452]]))

```python
np.allclose(A - P @ L @ U, np.zeros(shape))
```

    True

```python
plt.spy(L)
```

```python
plt.spy(U)
```

```python
plt.spy(P)
```

* Passo 1:

```python
y = np.dot(linalg.inv(L) @ linalg.inv(P), b)

y
```

    array([ 2.00000000e+00,  3.00000000e+00, -3.00000000e+00,  1.00000000e+00,
            0.00000000e+00,  3.00000000e+00,  5.00000000e+00, -7.00000000e+00,
            1.25000000e+00,  9.09090909e-02,  7.46031746e-01,  1.91812865e+00,
           -5.09248555e+00, -3.34549878e+00, -1.18552036e+00, -1.64918970e+00,
           -4.37334485e+00,  5.68544385e+00,  2.23095651e+00, -6.88061992e-01,
           -6.63810108e-01,  1.37091003e+00, -3.29277133e+00,  1.46491231e+00,
           -4.15189629e-01,  8.27074413e-02,  5.12804057e+00, -5.02379511e-01,
           -3.38545905e+00,  1.99528521e+00, -5.21577765e+00, -9.96031400e+00,
           -5.02156203e+00,  8.73090859e+00, -1.20800766e+01, -8.33179578e+00,
            1.60915685e+00,  2.96917710e+00, -8.29789524e-01,  5.79510066e+00,
           -3.43599097e+00,  1.56036856e+00,  4.73979046e+00,  2.47906052e+00,
           -6.43738781e+00,  3.09555037e+00, -2.33941211e+00, -1.23910986e+00,
            7.41871640e-01, -8.25333255e+00,  1.00820310e+01,  4.39949192e+00,
           -2.00962641e+00,  1.18822812e-01,  6.05320961e+00, -2.53885729e+00,
           -6.01627560e-01,  5.12565866e-03, -3.99906934e+00, -2.69806990e+00,
           -2.08615118e+00,  2.05599280e+00,  3.03696550e+00,  1.19372849e+00,
           -3.16060549e+00, -2.08547544e+00, -2.28442021e+00, -3.16178591e+00,
           -4.30598592e+00,  3.27770729e+00, -8.80404019e-01, -9.24769571e-01,
           -6.72847811e+00, -6.69723268e+00, -5.40733747e+00,  6.91752292e+00,
            7.11884419e+00, -1.18182432e+00,  6.98971354e+00, -9.46066366e-01,
           -5.00368343e+00, -5.02265003e+00,  6.85190500e+00,  7.98481987e+00,
            6.16031424e+00, -1.39713794e+01, -7.10369209e+00, -6.68454489e+00,
           -9.12625079e+00,  4.41008589e+00, -2.53760305e-01, -6.24515354e+00,
           -2.99698779e+00, -2.40608179e+00, -3.38808780e+00, -3.04715189e+00,
           -3.98671724e-01, -2.61753197e-01, -8.67617993e-01, -8.18601803e+00])

* Passo 2:

```python
x = np.dot(linalg.inv(U), y)

x
```

    array([ -82.7164806 ,   -2.96270617, -151.06487509,  -19.08967755,
              8.83862836,   11.34653803,   -5.44776064,   26.92565362,
            158.83444399,  -51.12905166, -134.08341032,  -17.66280145,
              0.80419445,  -11.68831808,  -29.90892537,  -12.23240838,
             29.86457556,  -46.29789116,   72.93453583,  -42.22197583,
             13.79321434,   56.09927433,   59.40283784,   16.94974588,
            -65.30227781,   -3.25734712,   62.61785503,   51.00830103,
            102.76151776,  -17.12401128,  150.30331322,   83.10413748,
            -15.60689603,  -15.07398606, -137.37444284,   24.86303914,
             48.89025745,  -41.41852994,  -47.71515084,   73.19304835,
            -37.72700976,   26.9577809 ,  -83.98343844, -189.55226606,
             35.29619078,   15.41808593,  -41.59848702,    0.78453521,
            -64.84137661,  -31.87531852,   13.0402689 ,  -24.99819604,
            101.63022238,   45.9238573 , -109.83604935,   61.88247869,
             72.5279913 ,   27.88962286,  -38.20705127,  -19.0390928 ,
           -108.84023019,   24.44441981,    5.58167811,  -55.41652727,
             12.70834201,  -47.58408435,   -5.67149441,   30.72536621,
            -63.37401229,   30.03328435,   71.65085963,   73.60675895,
             -5.42522681,   32.80862951,   -7.87702417,   85.98257389,
             23.84319741,   16.62380602,  -11.1597425 ,  -91.69084818,
              7.66973906,   33.8967038 ,  -42.05255591,   31.82730897,
             14.11558382,  -67.7580847 ,  -25.11368995,   48.8321148 ,
            -49.46817067,   13.35482847,    0.29641158,   57.19453605,
            -70.51908646,  -24.5865773 ,  -18.50695833,   25.92815729,
             43.63689662,   47.7744739 ,   81.81421388,   12.51963215])

```python
np.allclose(np.dot(A, x), b)
```

    True

**Observação:** Não precisamos fazer isso na mão, a `linalg.solve()` nada mais é do que uma fatoração LU por baixo dos panos, toda otimizada e testada, wrapper do LAPACK.

### Solução de sistemas esparsos

Em aplicações científicas, frequentemente nos deparamos com sistemas esparsos (com grande número de entradas nulas). Um exemplo típico de uma matriz advinda da discretização por elementos finitos seria:

```python
import scipy.io as io

A_lshp = io.mmread('data/lshp3466.mtx')
plt.spy(A_lshp, markersize=1)
plt.show()
```

Esta matriz foi retirada do [Matrix Market](https://math.nist.gov/MatrixMarket/data/Harwell-Boeing/lshape/lshp3466.html), um conhecido site de matrizes para benchmarks de solvers.

Podemos notar que apenas algumas entradas (marcadas em azul) são não-nulas. As outras entradas ocupam igual espaço na memória, o que é bastante desnecessário, já que são entradas nulas. Inclusive estas podem adicionar erros numéricos ao resultado. O scipy já realiza a leitura da matriz do formato Matrix Market (`.mtx`) como uma estrutura adequada à representação de matrizes esparsas.

```python
import scipy.sparse as sp

sp.isspmatrix(A_lshp)
```

    True

A quantidade de entradas não-nulas é:

```python
nnz = A_lshp.getnnz()

nnz
```

    23896

E o número total de entradas (incluindo as nulas) seria:

```python
n = A_lshp.shape[0]
n_entries = n * n

n_entries
```

    12013156

Ou seja, a porcentagem de entradas não-nulas é:

```python
nnz / n_entries * 100
```

    0.19891525590777312

Menos de 1%!!!

Agora, para solucionar esse sistema, vamos criar um vetor $b$ unitário apenas para testar.

```python
b = np.ones(n)
```

E, para comparação, vamos pegar a matriz cheia:

```python
A_full = A_lshp.toarray()
```

E vamos tranformar de COO (formato padrão de leitura do `mmread`) para o CRS (Compressed Row Storage), que é necessário para o solver da SciPy:

```python
A_lshp = A_lshp.tocsr()
```

* Solucionando o sistema denso:

```python
x_dense = linalg.solve(A_full, b)
```

```python
x_dense
```

    array([ 0.19755386, -0.0824912 ,  0.14322   , ...,  0.60159886,
            0.36378233, -0.42355086])

Agora, para avaliar a performance em termos de custo computacional temporal:

```python
%%timeit
x_dense = linalg.solve(A_full, b)
```

    880 ms ± 41 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

* Solucionando o sistema esparso:

Vamos utilizar o solver esparso `linalg.spsolve()` equivalente ao solver denso:

```python
import scipy.sparse.linalg as slinalg
```

Solucionando:

```python
x_sparse = slinalg.spsolve(A_lshp, b)
```

```python
x_sparse
```

    array([ 0.19755386, -0.0824912 ,  0.14322   , ...,  0.60159886,
            0.36378233, -0.42355086])

```python
np.allclose(x_dense, x_sparse)
```

    True

E avaliando a performance em termos de tempo de execução:

```python
%%timeit
slinalg.spsolve(A_lshp, b)
```

    16.8 ms ± 915 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Disso, vemos que a diferença em tempo é quase 100 vezes menor! E esse não é o maior dos sistemas, obviamente.

Caso deseje obter maior profundidade do que é possível fazer em termos de solucionamento de sistemas esparsos e manipulações, a [documentação oficial](https://docs.scipy.org/doc/scipy-0.14.0/reference/sparse.linalg.html) é excelente. Maiores informações sobre a classe de matrizes esparsas da scipy pode ser encontrada [lá também](https://docs.scipy.org/doc/scipy/reference/sparse.html#module-scipy.sparse). Aqui, mostramos apenas o básico para efeitos de conhecimento.

## Encontrando raízes de equações com `scipy`

As rotinas de solucionar equações fazem parte do módulo de otimização da SciPy `scipy.optimize`. Isso se deve principal pela relação entre a relação entre problemas de otimização não-linear com problemas de sistemas de equações não-lineares.

```python
import scipy.optimize as opt
```

Vamos usar como problema base a equação de Colebrook-White para o fator de fricção de escoamento turbulento em tubos:

$$
\frac{1}{\sqrt{f}} = 1.14 - 2 \log_{10}\left(\frac{\varepsilon}{D} + \frac{9.35}{\text{Re}\sqrt{f}}\right)
$$

onde $f$ é o fator de fricção, $\varepsilon$ é a rugosidade absoluta, $D$ é o diâmetro interno e Re é o número de Reynolds associado ao escoamento do fluido. Essa correlação - uma equação transcendental - é válida para regimes onde $\text{Re} > 4000$.

Por ser uma equação transcendental, é impossível isolar nossa quantidade de interesse, que é o fator de fricção, por meio de manipulações matemáticas. A única forma de solucionar esse problema é numericamente. Então, vamos formular esse problema como um problema de encontrar raízes:

```python
def colebrook_white(f, eps, D, Re):
    return 1.14 - 2 * np.log10(eps / D + 9.35 / (Re * np.sqrt(f))) - 1 / np.sqrt(f)
```

Os parâmetros serão dados para um duto de ferro fundido:

```python
eps = 0.26  # Em mm
D = 200.0  # Em mm
```

E o número de Reynolds:

```python
Re = 10000.
```

### Resolvendo com métodos livres de informação de derivadas

De início, vamos começar com métodos sem informações de derivadas, que tem garantia de convergência dado um intervalo de busca. Para exemplificar, veremos uma simples bisseção:

```python
x_min, x_max = 1e-3, 1.0  # Intervalo de busca
xsol = opt.bisect(colebrook_white, x_min, x_max, args=(eps, D, Re,))

xsol
```

    0.032825352944912074

Caso interesse verificar informações do processo de solucionamento, basta fazer:

```python
xsol, log = opt.bisect(colebrook_white, x_min, x_max, args=(eps, D, Re,), full_output=True)

log
```

          converged: True
               flag: 'converged'
     function_calls: 41
         iterations: 39
               root: 0.032825352944912074

Dentre os métodos livres de derivada disponíveis pela scipy, o melhor é o [TOMS 748](http://na.math.kit.edu/alefeld/download/1995_Algorithm_748_Enclosing_Zeros_of_Continuous_Functions.pdf), com taxa de convergência chegando até 2.7, superior ao método de Newton, que utiliza derivadas. Veremos o TOMS 748 em ação:

```python
xsol, log = opt.toms748(colebrook_white, x_min, x_max, args=(eps, D, Re,), full_output=True)

log
```

          converged: True
               flag: 'converged'
     function_calls: 18
         iterations: 7
               root: 0.03282535294547174

Bem melhor, hein? A principal vantagem desse método é a necessidade de definir apenas o intervalo de busca (sem necessidade de estimativa inicial), além de que a convergência será atingida, mesmo que seja em um dos limites.

### Resolvendo com métodos que utilizam derivadas

Para resolver com o método de Newton, em vez de utilizar um intervalo de busca, fornecemos uma estimativa inicial para a solução.

```python
x0 = 0.05
xsol, log = opt.newton(colebrook_white, x0, args=(eps, D, Re,), full_output=True)

log
```

          converged: True
               flag: 'converged'
     function_calls: 8
         iterations: 7
               root: 0.03282535294547166

Em geral, a taxa de convergência de métodos que utilizam derivadas, em geral, é maior. Porém, a convergência é condicionada à qualidade do chute inicial, podendo não convergir.

## Resolvendo sistemas não-lineares

Para sistemas não-lineares, a interface muda ligeiramente em relação ao caso de uma única variável independente. Neste caso, se usa a interface geral `optimize.root()`. O método padrão dessa chamada é uma modificação do método de Powell híbrido da MINPACK.

$$
\left\{
\begin{align}
&x + 0.5 (x - y)^3 = 1\\
&0.5 (y - x)^3 + y = 0
\end{align}
\right.
$$

```python
def func(x):
    f = (
        x[0]  + 0.5 * (x[0] - x[1])**3 - 1.0,  # Eq 1
        0.5 * (x[1] - x[0])**3 + x[1]  # Eq 2
    )  # retorna uma lista onde cada entrada é uma linha do sistema
    return f
```

```python
x0 = [0.0, 0.0]
solution = opt.root(func, x0)

solution.x
```

    array([0.8411639, 0.1588361])

## Problemas de Otimização Não-linear

### Problemas irrestritos

Aproveitando o embalo, já que é no mesmo sub-módulo, vamos logo ver como solucionar problema de otimização (local e global). Como primeiro exemplo, vamos tomar a função de Rosenbrock como função-objetivo, que é um conhecido benchmark para problemas de otimização. Para efeitos práticos, vamos tomar a seguinte forma em 2D:

$$
f(x, y) := (a - x)^2 + b \left(y - x^2\right)^2
$$

O mínimo local (que é único e global neste caso) ocorre quando $f(x, y) = 0$, que é obtido com $(x, y) = (a, a^2)$. Aqui usaremos $a = 1$ e $b = 100$.

```python
rosenbrock = lambda x: (1 - x[0])**2.0 + 100 * (x[1] - x[0]**2.0)**2.0
```

#### Minimização local

Para resolver o problema de otimização local, usamos a interface genérica `optimize.minimize()`. Com ela, podemos acessar todos os métodos baseados em derivadas e os livres de derivada, mas para otimização local.

```python
x0 = [-10, 10]
result = opt.minimize(rosenbrock, x0)

result
```

          fun: 2.6957239738060108e-11
     hess_inv: array([[0.49170495, 0.98335213],
           [0.98335213, 1.97158867]])
          jac: array([-4.63905237e-07, -4.81389906e-07])
      message: 'Optimization terminated successfully.'
         nfev: 504
          nit: 98
         njev: 126
       status: 0
      success: True
            x: array([0.99999481, 0.99998961])

Lembrando que o mínimo local, nesse caso, é global e ocorre em $(1, 1)$. Para acessar apenas o resultado, acesse o atributo:

```python
result.x
```

    array([0.99999481, 0.99998961])

O método padrão é o BFGS, um método de quasi-Newton. Digamos que desejamos resolver com Gradiente Conjugado, então devemos passar no argumento opcional `method`:

```python
x0 = [-10, 10]
result = opt.minimize(rosenbrock, x0, method='CG')

result
```

         fun: 3.2706500031004414e-11
         jac: array([-7.33855221e-06,  2.42827904e-06])
     message: 'Optimization terminated successfully.'
        nfev: 292
         nit: 30
        njev: 73
      status: 0
     success: True
           x: array([0.99999428, 0.99998857])

Uma lista completa de métodos e argumentos possíveis está disponibilizado [aqui](https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html#scipy.optimize.minimize).

#### Minimização global

A SciPy oferece algumas opções de métodos de Otimização Global. Um dos métodos que estão disponibilizados é bastante utilizado (e desenvolvido em pesquisas!) no LNCC, que é o Evolução Diferencial. Por isso, em homenagem aos colegas, vamos esse método. Trata-se de um método meta-heurístico estocástico que não é bioinspirado, porém bastante eficiente, paralelizável e (inesperadamente) simples. Vamos ver em ação!

Para utilizá-lo, devemos fornecer o intervalo de busca em formato de hipercubo.

```python
bounds = [(0, 2), (0, 2)]  # Informando o intervalo de busca em cada componente
```

```python
result = opt.differential_evolution(rosenbrock, bounds)

result
```

         fun: 0.0
     message: 'Optimization terminated successfully.'
        nfev: 3783
         nit: 125
     success: True
           x: array([1., 1.])

A estratégia padrão de evolução utilizada é a `best1bin`. Digamos que o usuário deseje utilizar a estratégia clássica e originalmente proposta no primeiro paper de Storn e Price, que seria a `rand1bin`. Além disso, desejamos utilizar uma população de 50 indivíduos, sendo que o padrão é 15. Para termos essa configuração, basta utilizar os parâmetros opcionais como abaixo:

```python
result = opt.differential_evolution(rosenbrock, bounds, popsize=50, strategy='best1bin', mutation=0.2)

result
```

         fun: 0.0
     message: 'Optimization terminated successfully.'
        nfev: 4003
         nit: 39
     success: True
           x: array([1., 1.])

### Problemas com restrições

Vamos tomar como exemplo o problema:

Achar:

$$
\min_{x_1, x_2 \in \mathbb{R}}\, (x_1 + 1)^2 + (x_2 - 1)^2
$$

sujeito a

$$
\begin{align}
2 x_2 - 1 &= 0\\
(1- x_1)\left(4 - x_1^2 - x_2^2\right) &\leq 0\\
2x_1^2 + x_2^2 - 100 &\leq 0
\end{align}
$$

que possui o mínimo global marcado em `x` (sem restrições) na figura abaixo. As regiões preenchidas e ao longo da linha roxa são o conjunto viável.

Sob esse conjunto de restrições, a função possui dois mínimos locais em

$$
x_1^* = \left(1, \frac{1}{2} \right); \quad x_2^* = \left(-\frac{\sqrt{15}}{2}, \frac{1}{2}\right)
$$

Porém, $f_{objetivo}(x_1^*) > f_{objetivo}(x_2^*)$.

A função-objetivo e sua Jacobiana é definida como:

```python
def f_obj(x):
    return (x[0] + 1)**2 + (x[1] - 1)**2

def f_deriv(x):
    dfdx0 = 2 * x[0] + 2
    dfdx1 = 2 * x[1] - 2
    return np.array([dfdx0, dfdx1])
```

As restrições são passadas para a rotina com a seguinte forma:

```python
constraints = (
    {'type': 'eq', 'fun': lambda x: np.array([2 * x[1] - 1])},
    {'type': 'ineq', 'fun': lambda x: np.array([-(1 - x[0]) * (4 - x[0]**2 - x[1]**2)])},
    {'type': 'ineq', 'fun': lambda x: np.array([-1 * (2 * x[0]**2 + x[1]**2 - 100)])}
)
```

Por fim, obtemos o mínimo como:

```python
result = opt.minimize(f_obj, [-3.0, 1.0], jac=f_deriv,
                  constraints=constraints, method='SLSQP', options={'disp': True})
result.x
```

    Optimization terminated successfully.    (Exit mode 0)
                Current function value: 1.127016654329262
                Iterations: 6
                Function evaluations: 6
                Gradient evaluations: 6

    array([-1.93649167,  0.5       ])

Onde foi utilizado o método Sequential Least SQuares Programming. Neste exemplo, podemos ver como passar restrições de igualdade e desigualdade. É importante notar que as desigualdades são fornecidas na forma $g(x_1, x_2) \ge 0$, ao contrário de como está na formulação do problema.

## Interpolação

Interpolação é uma técnica de aproximação que pode ser tanto usada para obter funções mais "simples" (em algum sentido) a partir de funções mais complicadas ou obter uma função a partir de um conjunto de dados experimentais (que também pode ser feito com técnicas de ajuste. Aqui, vamos ver apenas um simples caso para conhecermos a ferramenta pela `scipy`.

Vamos simular dados experimentais que tem comportamento similar à função seno ao longo do tempo:

```python
measured_points_number = 10
measured_time = np.linspace(0, 1, measured_points_number)
noise = np.random.random(measured_points_number) * 2 - 1
measures = np.sin(2 * np.pi * measured_time) + noise
```

Vamos agora importar a função de interpolação linear da `scipy`:

```python
from scipy.interpolate import interp1d
```

E realizar a interpolação:

```python
linear_interp = interp1d(measured_time, measures)
```

```python
linear_interp(0.5)
```

    array(-0.30457695)

Agora temos o interpolador montado a partir dos dados medidos (`measures`). Com isso, podemos entrar com um novo conjunto de pontos (dentro do intervalo dos pontos, já que é uma interpolação) para ver os resultados interpolados desse novo conjunto:

```python
interpolation_time = np.linspace(0, 1, 50)
linear_results = linear_interp(interpolation_time)
```

Podemos fazer uma rápida visualização dos dados experimentais virtuais e da interpolação da seguinte maneira:

```python
import matplotlib.pyplot as plt

plt.figure(figsize=(5, 3))
plt.plot(measured_time, measures, 'o', label='Experimental')
plt.plot(interpolation_time, linear_results, '-', label='Linear interp')
plt.legend()
plt.xlabel('time')
plt.ylabel('y')
plt.grid(True)
plt.savefig('figura.png')
plt.show()
```

Lembrando que a interpolação é um procedimento "exato" nos pontos evaluados, o que pode gerar uma característica indesejada chamada overfitting, que prejudica fortemente a capacidade preditiva da interpolação.

Para complementar, vamos ver como seria uma interpolação cúbica. Para isso, usamos a rotina genérica de interpolação 1D `interp1d`.

```python
cubic_interp = interp1d(measured_time, measures, kind='cubic')
cubic_results = cubic_interp(interpolation_time)
```

E agora, vamos visualizar novamente:

```python
import matplotlib.pyplot as plt

plt.plot(measured_time, measures, 'o', label='Experimental')
plt.plot(interpolation_time, linear_results, '-', label='Linear interp')
plt.plot(interpolation_time, cubic_results, '-', label='Cubic interp')
plt.legend()
plt.xlabel('time')
plt.ylabel('y')
plt.show()
```

Caso seja do interesse de alguém da audiência, uma série de outras possibilidades estão disponíveis na SciPy para interpolação, inclusive com multivariáveis. Para maiores detalhes, verifique a [documentação](https://docs.scipy.org/doc/scipy/reference/interpolate.html).

## Ajuste de curvas

Outra forma de obtermos funções a partir de dados experimentais é por meio de ajuste de curvas. Pela SciPy, podemos fazer isso a partir de algumas maneiras. As mais notáveis são ajustes de parâmetros de uma função por minimização (global) de alguma métrica de erro, ou fazendo o ajuste de curvas já pronto pela SciPy.

### Ajuste por minimização de quadrado do resíduo

Essa é uma forma praticamente _black box_ de solucionar o problema com otimização global. Dizemos qual o modelo e os parâmetros que desejamos ajustar nesse modelo. Aqui, por conveniência, escolhemos uma função seno para efeitos de simplificação. O problema que desejamos resolver é o seguinte:

Achar os parâmetros $a, b, c \in \mathbb{R}$ tal que
$$
\min \sum_{i=1}^{N_{exp}} (y_i - f(x, a, b, c))^2
$$

sendo $y_i$ os valores medidos experimentalmente e

$$
f(x, a, b) := a \cdot \sin(b x)
$$

a função que desejamos ajustar. Note que essa função poderia ser qualquer coisa que dependa de parâmetros que desejamos encontrar para minimizar o quadrado do resíduo. Vamos definir aqui essas funções como:

```python
def square_error(par, x, f_exp):
    a, b, c = par
    residual = f_exp - f(x, a, b, c)
    return np.sum(residual**2.0)

def f(x, a, b, c):
    return a * np.sin(b * x) + c
```

E resolver o problema de minimização global da forma:

```python
bounds = [(-10, 10), (-10, 10), (-10, 10)]
result = opt.differential_evolution(
    square_error,
    bounds=bounds,
    args=(measured_time, measures,),
    popsize=50,
    strategy='rand1bin',
    tol=1e-3,
    recombination=0.9,
    mutation=0.2
)

result
```

         fun: 2.3595463311342963
         jac: array([ 1.15463195e-06, -3.10862447e-07, -1.19904087e-06])
     message: 'Optimization terminated successfully.'
        nfev: 5728
         nit: 37
     success: True
           x: array([-1.09337942, -6.76241452, -0.31493112])

Pegando os parâmetros ajustados:

```python
a, b, c = result.x
```

Por fim, visualizando o resultado dos ajustes em relação aos dados experimentais:

```python
import matplotlib.pyplot as plt

f_fitted_DE = f(measured_time, a, b, c)
plt.plot(measured_time, measures, 'o', label='Experimental')
plt.plot(measured_time, f_fitted_DE, '-', label='DE fitting')
plt.legend()
plt.xlabel('time')
plt.ylabel('y')
plt.show()
```

Note que, neste tipo de abordagem, ao contrário da interpolação, temos um problema de overfitting bem menor.

### Ajuste de curva com a interface do `scipy`

Agora vamos realizar o ajuste de uma dada curva em dados experimentais a partir da interface da `scipy` que é a `optimize.curve_fit`. Nela, também se ajusta utilizando mínimos quadrados. No entanto, o método que utilizaremos aqui no coração da rotina é o Levenberg-Marquardt.

```python
p0 = (3., 3., 2.)
params, params_covariance = opt.curve_fit(f, measured_time, measures, p0=p0, method='lm')

params, params_covariance
```

    (array([-0.95009848,  2.24907996,  0.37840766]),
     array([[ 0.94577318, -0.22953039, -0.64684902],
            [-0.22953039,  1.46367239,  0.14041387],
            [-0.64684902,  0.14041387,  0.5375403 ]]))

```python
a, b, c = params
```

```python
import matplotlib.pyplot as plt

f_fitted_LM = f(measured_time, a, b, c)
plt.plot(measured_time, measures, 'o', label='Experimental')
plt.plot(measured_time, f_fitted_LM, '-', label='DE fitting')
plt.plot(measured_time, f_fitted_DE, '-', label='LM fitting')
plt.legend()
plt.xlabel('time')
plt.ylabel('y')
plt.show()
```

O que mostra que um solver local pode não ser adequado para todos os problemas de ajuste. Portanto, **muita atenção**! Com frequência, o adequado e mais robusto pode ser um otimizador global. Que tal testar a estimativa inicial para o Levenberger-Marquardt com $(3, 3)$. O que foi obtido?

## Quadraturas Numéricas

Em computação científica, frequentemente temos que calcular integrais. Por meio da SciPy, podemos fazer isso de maneira numérica muito facilmente.

```python
from scipy import integrate
```

### Integral simples
Vamos utilizar aqui a rotina `integrate.quad()`, que vem da implementação do QUADPACK em Fortran.

Um simples exemplo:

$$
F = \int_0^{\infty} e^{-x} dx
$$

```python
f = lambda x: np.exp(-x)
F, err = integrate.quad(f, 0, np.inf)

F, err
```

    (1.0000000000000002, 5.842606742906004e-11)

Com erro absoluto

```python
err
```

    5.842606742906004e-11

Ou ainda, podemos fazer uma integração de uma função que tenha vários parâmetros. Por exemplo,

$$
F = \int_0^1 (a x + b) dx
$$

```python
f = lambda x, a, b: a * x + b
F, err = integrate.quad(f, 0, 1, args=(1, 1,))

F, err
```

    (1.5, 1.6653345369377348e-14)

### Integrais duplas

A função `integrate.dblquad()` trata da integração dupla. Agora, temos que fornecer, em sequência nos argumentos, os limites de integração em `x` e depois os limites em `y`, porém os de `y` são em forma de generalizada de funções $gf(x)$ até $hf(x)$.

Como exemplo simples, vamos integrar:

$$
F = \int_{gf(x) = 0}^{hf(x) = 1}\int_0^1 x y^2 dx dy
$$

```python
f = lambda y, x: x * y**2
F, err = integrate.dblquad(
    f,
    0,  # x_0
    1,  # x_1
    lambda x: 0,  # gf(x)
    lambda x: 1   # hf(x)
)

F, err
```

    (0.16666666666666669, 3.6927075527489535e-15)

### Integrais triplas

A lógica da integral dupla se mantém e agora temos os limites em `z` que dependerá das variáveis integradas anteriormente. Portanto, os limites em `z` são definidos genericamente por funções $qf(x, y)$ e $rf(x, y)$.

Como exemplo, tomemos o caso:

$$
F = \int_{qf(x, y) = 0}^{rf(x, y) = 1}\int_{gf(x) = 2}^{hf(x) = 3}\int_1^2 x y z dx dy dz
$$

```python
f = lambda z, y, x: x * y * z
F, err = integrate.tplquad(f, 1, 2, lambda x: 2, lambda x: 3,
                           lambda x, y: 0, lambda x, y: 1)

F, err
```

    (1.8750000000000002, 3.324644794257407e-14)

## Equações Diferenciais Ordinárias

No mesmo módulo que estão as quadraturas, a `scipy` também disponibiliza uma série de métodos para resolução de EDOs tanto para problemas rígidos como não-rígidos. A interface é bem simples. Considere o problema genérico de valor inicial:

$$
\dot{y} = f(t, y)
$$

que pode ser inclusive um sistema de EDOs. Para a `scipy`, o usuário basicamente tem que fornecer o lado direito. Ou seja, deve fornecer $f(t, y)$.

Inicialmente, vamos considerar um dos problemas mais simples possível:

$$
\dot{y} = -2 y
$$
com $y(t = 0) = 1$. Este é um problema dito autônomo, pois o lado direito não contém dependência temporal. Ou seja, $f(t, y) \equiv f(y)$.

Portanto, vamos definir o lado direito inicialmente:

```python
def rhs(ypos, time):
    return -2 * ypos
```

Note que mesmo para problemas autônomos, devemos colocar o tempo como argumento.

Aqui, vamos usar a `integrate.odeint()`:

```python
from scipy.integrate import odeint
```

Por fim, integramos a EDO no tempo de 0 até 4, com 40 passos de tempo:

```python
time = np.linspace(0, 4, 40)
y0 = 1
y = odeint(rhs, y0=y0, t=time)
```

Agora uma rápida visualização:

```python
import matplotlib.pyplot as plt

plt.plot(time, y)
plt.xlabel('time')
plt.ylabel('y')
plt.show()
```

### Um problema mais interessante: Sistema de Lorenz

Vamos considerar agora o problema do atrator de Lorenz, um sistema caótico que nasceu da modelagem de fluidos, mais especificamente em convecção atmosférica em torno da década de 1960. O sistema contém 3 equação diferenciais ordinárias não-lineares acopladas e é escrito da forma:

$$
\left\{
\begin{align}
&\dot{u} = \sigma (v - u)\\
&\dot{v} = \rho u - v - uw\\
&\dot{w} = u v - \beta w\\
\end{align}
\right.
$$

Como todo bom sistema dinâmico não-linear (e caótico), é um problema rígido (_stiff_) e requer **muita atenção** na hora de resolvê-lo numericamente. Se o método não for adequado e capaz de resolver essa classe de problemas, a solução simplesmente pode não condizer com a "realidade" por motivos numéricos. Ou seja, é um problema de verificação numérica. Podemos estar resolvendo o modelo certo, porém o método o corrompe.

Para essa classe de problema, em geral se escolhe métodos implícitos do tipo Runge-Kutta ou BDF (Backward
Differentiation Formulas), dentre outros. Aqui, vamos utilizar a implementação de Runge-Kutta implícito de quinta ordem conhecida como RADAU-IIA e fornecida pela SciPy.

Aqui vamos utilizar $\sigma = 10$, $\beta = 8 / 3$ e $\rho = 28$.

* Condições iniciais:

```python
u0, v0, w0 = 0.5, 0, 0.1
```

* O lado direito do sistema:

```python
def lorenz(t, X):
    sigma, beta, rho = 10, 8 / 3, 28  # parâmetros do modelo
    u, v, w = X
    u_prime = -sigma * (u - v)
    v_prime = rho * u - v - u * w
    w_prime = -beta * w + u * v
    return u_prime, v_prime, w_prime
```

* Parâmetros da integração no tempo:

```python
t0 = 0  # tempo inicial
tmax = 100  # tempo máximo
t_points = 500  # número de pontos no tempo
```

* Solucionando o problema com RAUDAU-IIA e com RK45 (Runge-Kutta explícito de ordem 5) para efeitos comparativos:

```python
solution_RADAU = integrate.solve_ivp(lorenz, t_span=(t0, tmax), y0=(u0, v0, w0,), method='Radau')
solution_RK = integrate.solve_ivp(lorenz, t_span=(t0, tmax), y0=(u0, v0, w0,), method='RK45')

t_computed_RADAU, y_computed_RADAU = solution_RADAU.t, solution_RADAU.y
t_computed_RK, y_computed_RK = solution_RK.t, solution_RK.y
# Separando as soluções
u_RADAU, v_RADAU, w_RADAU = y_computed_RADAU
u_RK, v_RK, w_RK = y_computed_RK
```

* Visualizando a famosa "borbuleta de Lorenz":

RADAU:

```python
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

fig = plt.figure(figsize=(12, 12))
ax = fig.gca(projection='3d')
ax.plot(u_RADAU, v_RADAU, w_RADAU)
# ax.set_axis_off()
plt.show()
```

RK45:

```python
fig = plt.figure(figsize=(12, 12))
ax = fig.gca(projection='3d')
ax.plot(u_RK, v_RK, w_RK)
# ax.set_axis_off()
plt.show()
```

* Analisando o comportamento separado das componentes da solução em função do tempo:

u:

```python
plt.plot(t_computed_RADAU, u_RADAU, label='RADAU')
plt.plot(t_computed_RK, u_RK, label='RK45')
plt.xlabel('time')
plt.ylabel('u')
plt.legend()
plt.show()
```

v:

```python
plt.plot(t_computed_RADAU, v_RADAU, label='RADAU')
plt.plot(t_computed_RK, v_RK, label='RK45')
plt.xlabel('time')
plt.ylabel('v')
plt.legend()
plt.show()
```

w:

```python
plt.plot(t_computed_RADAU, w_RADAU, label='RADAU')
plt.plot(t_computed_RK, w_RK, label='RK')
plt.xlabel('time')
plt.ylabel('w')
plt.legend()
plt.show()
```

### Van der Pol rígido

Outro exemplo conhecido (baseado [daqui](https://www.mathworks.com/help/matlab/math/solve-stiff-odes.html)) de sistemas de EDOs que apresentam certa dificuldade é o problema de Van der Pol, um problema de oscilação não-conservativa. Originalmente, se apresenta como uma EDO de segunda ordem.

$$
y_1'' - \mu (1 - y_1^2)y_1' + y_1 = 0
$$

Essa formulação é, por meio de manipulações simples, transformada em um sistema de primeira ordem com duas equações:

$$
\left\{
\begin{align}
&y_1' = y_2\\
&y_2' = \mu (1 - y_1^2)y_2 - y_1
\end{align}\right.
$$

Esse problema, quando $\mu \approx \mathcal{O}(1)$, se apresenta como não rígido. No entanto, para valores elevados de $\mu$, como por exemplo $\mu = 500$, a dinâmica não-linear se torna bastante dominante, e isso torna o problema rígido. No primeiro, métodos explícitos tradicionais (que são para problemas não-rígidos), como o RK45, dão conta do recado. Porém, para problemas com dinâmica não-linear dominante, o problema se apresenta rígido e requer solucionamento numérico por métodos adequados, como o método RADAU-IIA que já vimos. Novamente vamos utilizar as funcionalidades da SciPy e comparar o solucionamento numérico do problema.

* Definindo o lado direito:

```python
def van_der_pol(t, X):
    mu = 500.
    y1, y2 = X
    y1_prime = y2
    y2_prime = mu * (1 - y1**2.0) * y2 - y1
    return y1_prime, y2_prime
```

* Parâmetros temporais:

```python
t_init = 0
t_end = 3000
```

* Condição Inicial:

```python
y1_0, y2_0 = 2, 0
```

* Integrando no tempo:

RADAU-IIA:

```python
%%time
solution_RADAU = integrate.solve_ivp(van_der_pol, t_span=(t_init, t_end), y0=(y1_0, y2_0,), method='Radau')

t_computed_RADAU, y_computed_RADAU = solution_RADAU.t, solution_RADAU.y
# Separando as soluções
y1_RADAU, y2_RADAU = y_computed_RADAU
```

    CPU times: user 437 ms, sys: 30.9 ms, total: 468 ms
    Wall time: 439 ms

RK4(5):

```python
%%time
solution_RK = integrate.solve_ivp(van_der_pol, t_span=(t_init, t_end), y0=(y1_0, y2_0,), method='RK45')

t_computed_RK, y_computed_RK = solution_RK.t, solution_RK.y
# Separando as soluções
y1_RK, y2_RK = y_computed_RK
```

    CPU times: user 1min 40s, sys: 235 ms, total: 1min 41s
    Wall time: 1min 41s

* Visualizando a solução ($y_1$):

```python
plt.plot(t_computed_RADAU, y1_RADAU, label='RADAU')
plt.plot(t_computed_RK, y1_RK, label='RK45')
plt.legend()
plt.xlabel('time')
plt.xlabel(r'$y_1$')
plt.show()
```

### Apocalipse zumbi

Um caso lúdico, porém ainda com sua validade teórica, é o [Apocalipse Zumbi](http://citeseerx.ist.psu.edu/viewdoc/download;jsessionid=45A7CC3ABC2B8D050235DA267EEB1300?doi=10.1.1.159.6699&rep=rep1&type=pdf). É um modelo do similar ao SIR, que descreve a dinâmica de doenças infecciosas. No caso, S identifica a população suscetível, I é a população infectada e R são os recuperados ou imunes.

A disseminação de Zumbis num cenário apocalíptico pode ser modelado por meio de um sistemas de EDOs similar. No caso, baseando-se no documento linkado um pouco acima, vamos considerar aqui as seguintes populações de indivíduos:

* S - suscetível
* Z - zumbi
* R - removido

Os indivíduos suscetíveis podem ser eliminados por causas naturais, essa ocorrência será representado pelo parâmetro $\delta$. Já os indivíduos da classe R podem ter morrido tanto por causa dos zombis, tanto por causas naturais. Humanos da classe R podem ressurgir como Zumbis (parâmetro $\zeta$). Os suscetíveis podem se tornar Zumbis devido ao contato com eles (parâmetro $\beta$). Também é considerado que os humanos são os pratos favoritos dos Zumbis, portanto eles não se alimentam de mais nenhum outro ser vivo. Os humanos nascem numa taxa constante $\Pi$. Os Zumbis se tornam R apenas quando derrotados, e essa taxa está incluída no parâmetro $\alpha$. Vamos admitir também que zumbis não se matam nem brigam entre si. Com essas considerações, formulamos então o modelo abaixo:

$$
\left\{
\begin{align}
&\dot{S} = \Pi - \beta S Z - \delta S\\
&\dot{Z} = \beta S Z + \zeta R - \alpha S Z\\
&\dot{R} = \delta S + \alpha S Z - \zeta R
\end{align}
\right.
$$

Aqui, iremos utilizar os seguintes valores nos parâmetros:

| Parâmetro | Valor  |
|:---------:|--------|
| $\alpha$  | 0.005  |
| $\beta$   | 0095   |
| $\zeta$   | 0.0001 |
| $\delta$  | 0.0001 |
| $\Pi$     | 0      |

Note que estamos assumindo que o apocalipse Zumbi aconteceu muito rápido, então estamos negligenciado a taxa de natalidade dos humanos.

* Definindo o lado direito:

```python
def zumbi(t, X):
    alpha, beta, zeta, delta, Pi = 0.005, 0.0095, 0.0001, 0.0001, 0.0
    S, Z, R = X
    S_prime = Pi - beta * S * Z - delta * S
    Z_prime = beta * S * Z + zeta * R - alpha * S * Z
    R_prime = delta * S + alpha * S * Z - zeta * R
    return S_prime, Z_prime, R_prime
```

* Parâmetros temporais:

```python
t_init = 0.0
t_end = 5.0
```

* Condições Iniciais:

```python
S0, Z0, R0 = 500., 1., 0.
```

* Integração no tempo:

```python
solution_RADAU = integrate.solve_ivp(zumbi, t_span=(t_init, t_end), y0=(S0, Z0, R0,), method='Radau')

t_computed_RADAU, y_computed_RADAU = solution_RADAU.t, solution_RADAU.y
# Separando as soluções
S_RADAU, Z_RADAU, R_RADAU = y_computed_RADAU
```

* Visualizando os resultados das populações de indivíduos:

```python
plt.plot(t_computed_RADAU, S_RADAU, label='S')
plt.plot(t_computed_RADAU, Z_RADAU, label='Z')
plt.plot(t_computed_RADAU, R_RADAU, label='R')
plt.legend()
plt.xlabel('time')
plt.ylabel('Population Values (1000\'s)')
plt.show()
```

Bom... parece que as expectativas não são das melhores para a humanidade...

## Próximos passos...

Bom, agora acredito que vocês conhecem um básico da SciPy. Esse é só o começo! Existe muitas outras coisas não exploradas, como módulos de processamento e tratamento de imagens, assim como um módulo estatístico. Infelizmente o tempo e curto e temos que continuar o curso! Quem quiser se aventurar mais, recomendo o dar uma olhada em [http://scipy-lectures.org/](http://scipy-lectures.org/), dá para se divertir bastante!

Espero que estejam curtindo a viagem!


```python
import numpy as np
```

```python
num_pulleys = 4
```

### Representation used for this code development ###

#### `get_li` ####

For getting inital $L_i$, we assume that the bot is at the `top-center`. With this assumption, we only need to consider `B & D` metrics for calculating $L_i$

```python
def get_Li(B, D):
    """
    The inital length of strings based on B and D
    """
    return 0.5*np.sqrt(B**2 + D**2)
```

### TODO
 - [ ] We need to account for 360 degree rotation here
    - Are you talking about the decrease in radius each time the pulley rotates completely?
 - [x] We need to have a reverse calculation done, i.e. given a $x_i,y_i,z_i$ & $x_f,y_f,z_f$ we need to find out what the inital and final thetas. But more on that later.

#### `get_final_string_length` ####
Considering that we have an inital length available, `delta_thetas` given, this function would provide what would be the final length if the pulleys are moved by `delta_theta`

$$ L_f = L_i + r_i\Delta\theta_i $$

#### `get_theta_from_string_length` ####
If we have final length $L_f$ and inital string length $L_i$ and the radius of the pulleys, then we can find the `delta_thetas` moved by the pulleys

NOTE

Remember, $L_i$ and & $L_f$ are array/list, so the output would also be array/list each corresponding to individual pulleys.

```python
def get_final_string_length(Li, r, delta_thetas=np.zeros((1,num_pulleys))):
    """
    The final length of strings which need to be achieved for
    reaching a specific location (based on thetas).
    """
    return Li + r*delta_thetas

def get_theta_from_string_length(Lf, Li, r):
    """
    Given some string lengths Lf and initial lengths Li, return
    the rotation of the pulleys
    """
    return (Lf - Li)/r
```

#### `get_coordinate` ####
Assumption:
We start from the top of the board, therefore the $L_i$ is same for all strings

1. We take the initial $L_i$ for all strings
2. We are given the `thetas` that the pulley moved
3. We use initial $L_i$ and `thetas` to get final lengths

Then we use following formulas for getting coordinates of the final place the bot moved to

\begin{align}
y &= \frac{l_1^2 - l_2^2 + D^2}{2D} \\
x &= \frac{l_2^2 - l_3^2 + B^2}{2B}
\end{align}

\begin{aligned}
l_1^2 &= (0 - x)^2 + (0 - y)^2 + (H - z)^2 \\ &= x^2 + y^2 + (H - z)^2
\end{aligned}

\begin{aligned}
(H - z)^2 = l_1^2 - x^2 - y^2\\
\end{aligned}

$(H - z)^2$ = Hz2 (Python variable)

```python
def get_coordinate(thetas, B, D, H, r):
    """
    thetas: numpy array of rotations, shape: (num_arrays, )
    B, D, H, r: scalar constants
    """
    Li = get_Li(B, D)
    l = get_final_string_length(Li, r, thetas)
    x = (l[1]**2 - l[2]**2 + B**2)/(2*B)
    y = (l[0]**2 - l[1]**2 + D**2)/(2*D)
    Hz2 = l[0]**2 - x**2 - y**2 # (H-z)^2, eqn 1
    # Is it taut?
    # print(l[0]**2 - l[3]**2, l[1]**2 - l[2]**2)
    z = H - np.sqrt(Hz2)
    return np.array([x, y, z])
```

### `get_thetas` ###

We assume that we always start from the `top` center of the construct that you see on the top.

With the above assumption, the inital length of strings would be taken from `get_Li(B,D)` function.
If we are given the final co-ordinates (x,y,z), we can fund the $Lf$ using following formulas

\begin{align}
\begin{aligned}
l_1^2 &= x^2 + y^2 + (H - z)^2
\end{aligned} \\
\begin{aligned}
l_2^2 &= x^2 + (D - y)^2 + (H - z)^2
\end{aligned} \\
\begin{aligned}
l_3^2 &= (B - x)^2 + (D - y)^2 + (H - z)^2
\end{aligned} \\
\begin{aligned}
l_4^2 &= (B - x)^2 + y^2 + (H - z)^2
\end{aligned} \\
\end{align}

python variable $Hz2 = (H - Z)^2$

```python
def get_thetas(coords, B, D, H, r):
    """
    coords: (x, y, z) coordinates
    B, D, H, r: scalar constants
    """
    l = np.zeros((4,))
    x, y, z = coords
    Hz2 = (H - z)**2 # common term
    l[0] = np.sqrt(x**2 + y**2 + Hz2)
    l[1] = np.sqrt(x**2 + (D - y)**2 + Hz2)
    l[2] = np.sqrt((B - x)**2 + (D - y)**2 + Hz2)
    l[3] = np.sqrt((B - x)**2 + y**2 + Hz2)
    return get_theta_from_string_length(l, get_Li(B, D), r)
```

```python
# Box dimensions
B, D, H = 100, 200, 300
# Pulley radius
r = 0.1
# Pulley locations
Pulleys = [(0, 0, H), (0, D, H), (B, D, H), (B, 0, H)]
```

Taking some random values of $\Theta$, we can get the corresponding coordinates from `get_coordinates()`.
If $\Theta$ is such that the ropes are taut, we will get back the original $\Theta$ from `get_thetas()`.
If they aren't, the values will be different. Both are demonstrated in the following example. In the first case, $\theta_4$ is clearly different from the value we set, but in the second case, all angles are exactly the same.

### Below is just a cross-check function. ###
1. Each pulley rotated `[10,20,30,40]` rotations respectively, (i.e 1st pulley, 2nd pulley etc.)
2. With these rotations we get the final co-ordinates, remember we assume we start from top of the structure area.
3. While using the final_cordinates we use `get_thetas` function, i.e how much theta is supposed to be moved, this should come back to thetas we decided earlier.

```python
def display(rotations, coords, theta):
    print(f'For rotation = {rotations/(2*np.pi)}')
    x, y, z = coords
    print(f'\tx = {x}, y = {y}, z = {z}')
    print('\tΘ = ', theta)
    print()

# Pulleys rotated 10 revolutions, 20, 30, and 40, respectively
rotations = np.array([10, 20, 30, 40])*2*np.pi
coords = get_coordinate(rotations, B, D, H, r)
theta = get_thetas(coords, B, D, H, r)/(2*np.pi)
display(rotations, coords, theta)

# Pulleys rotated 10 revolutions, 20, 20, and 10, respectively
rotations = np.array([10, 20, 20, 10])*2*np.pi
coords = get_coordinate(rotations, B, D, H, r)
theta = get_thetas(coords, B, D, H, r)/(2*np.pi)
display(rotations, coords, theta)
```

    For rotation = [10. 20. 30. 40.]
    	x = 41.98822482885034, y = 96.19150450244695, z = 245.88322994588324
    	Θ =  [10.        20.        30.        20.5045589]

    For rotation = [10. 20. 20. 10.]
    	x = 50.0, y = 96.19150450244695, z = 253.18508971098808
    	Θ =  [10. 20. 20. 10.]

## A plot of the ropes
Here's how the system should look after the pulleys have done $(100, 200, 200, 100)$ revolutions:

Play around with the `rotations` variable below to see how the position varies.

### Note
The following blocks need Jupyter Lab with [the `jupyter-matplotlib` extension](https://github.com/matplotlib/jupyter-matplotlib#installation) installed. This allows interactive plots, so we can pan and zoom on the 3D plots and get more interesting viewpoints.

```python
%matplotlib widget
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from mpl_toolkits.mplot3d import Axes3D

rotations = np.array([100, 200, 200, 100])*2*np.pi
coords = get_coordinate(rotations, B, D, H, r)
x, y, z = coords

fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
for p in Pulleys:
    ax.plot((p[0], x), (p[1], y), zs=[p[2], z])
for p in Pulleys:
    ax.plot((p[0], p[0]), (p[1], p[1]), zs=[p[2], 0], color='black')

ax.set_xlim3d(0, B)
ax.set_ylim3d(0, D)
ax.set_zlim3d(0, H)
ax.set_xlabel('X')
ax.set_ylabel('Y')
ax.set_zlabel('Z')

# manually tweaked to get a nicer POV
ax.view_init(elev=7, azim=-109)
ax.dist = 7.5
```

    Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …

## An animation of movement of the robot

As stated in the Equations notebook, any motion of the robot is the result a sequence of steps of the pulley stepper motors. We can generate a sequence of steps for each pulley where each step is one of:

- $1$, indicating the pulley rotated forward one step
- $0$, indicating the pulley didn't rotate
- $-1$, indicating the pulley rotated backward one step

Then, if the pulley rotates by $d\theta_{step}$ for each step, we can get sequence of $d\Theta$s, and taking some random initial $\Theta$, a sequence of $\Theta$.

Let us plot the $(x, y, z)$ for each $\Theta$.

As example, the following function rotates two pulleys every step, and two pulleys every other step. So the first two pulleys get rotated 2x more than the other two pulleys. The effect is that the robot traces a parabola.

This is how the final frame of the animation should look like:

The violet line traces the path of the robot.

```python
def get_random_steps(N_steps):
    """
    Example stepper. Each step can be one of three values:
    rotate forward (1), don't move (0) or rotate back (-1)
    """
    for i in range(N_steps):
        yield(np.array([1, 1, i%2, i%2]))

dtheta_step = 10*(np.pi/180) # say, 10°
r = 0.1 # radius of pulley

np.random.seed(17648) # fixed seed to generate same steps each time

N_steps = 100
Theta = np.random.randint(3*np.pi, 5*np.pi, (4,))
Point = get_coordinate(Theta, B, D, H, r)

Thetas = [Theta]
Points = [Point]
steps = []
for step in get_random_steps(N_steps):
    steps.append(step)
    # since steps are too small to show in the figure,
    # let's scale the steps by a factor of 5
    dtheta = (dtheta_step*step)*10
    Theta = Thetas[-1] + dtheta
    Point = get_coordinate(Theta, B, D, H, r)
    Points.append(Point)
    Thetas.append(Theta)
```

```python
print(3*np.pi, 5*np.pi)
print(np.random.randint(3*np.pi, 5*np.pi, (4,)))
```

    9.42477796076938 15.707963267948966
    [ 9  9 10 11]

```python
import mpl_toolkits.mplot3d.axes3d as p3

def animate_points(Points):
    N_steps = len(Points) - 1
    fig = plt.figure()
    ax = fig.add_subplot(111, projection='3d')

    # Initial settings for the plot
    P0 = Points[0]
    lines = [ax.plot((P[0], P0[0]), (P[1], P0[1]), zs=[P[2], P0[2]])[0] for P in Pulleys]
    path = ax.plot((P0[0], P0[0]), (P0[1], P0[1]), zs=[P0[2], P0[2]])[0]
    # Pulley rods
    plot_base = 200
    for p in Pulleys:
        ax.plot((p[0], p[0]), (p[1], p[1]), zs=[p[2], plot_base], color='black')

    # Creating the animation function and object
    def update_lines(num, Points, lines, path):
        Point = Points[num]
        for i in range(num_pulleys):
            # Redraw each rope
            line = lines[i]
            Pulley = Pulleys[i]
            line.set_data_3d((Pulley[0], Point[0]),
                             (Pulley[1], Point[1]),
                             (Pulley[2], Point[2]))
        # Trace the path of the robot
        x, y, z = path.get_data_3d()
        if num == 0:
            path.set_data_3d([Point[0]], [Point[1]], [Point[2]])
        else:
            path.set_data_3d(np.append(x, Point[0]),
                             np.append(y, Point[1]),
                             np.append(z, Point[2]))
        return lines
    line_ani = animation.FuncAnimation(fig, update_lines, N_steps, fargs=(Points, lines, path),
                                       repeat=False, blit=False)

    ax.set_xlabel('X')
    ax.set_ylabel('Y')
    ax.set_zlabel('Z')
    ax.set_xlim3d(0, B)
    ax.set_ylim3d(0, D)
    ax.set_zlim3d(plot_base, H)
    # manually tweaked to get a nicer POV
    ax.view_init(elev=8, azim=-108)
    ax.dist = 8
    return line_ani, fig
```

```python
ani, fig = animate_points(Points)
ani.save('anim.gif', writer='imagemagick', fps=10)
```

    Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …

A GIF of the above animation:

Ultimately, what we need is a sequence of steps that gets us from point A to point B. This is complicated by many things:

- maintaining stability of the platform
- maintaining tension in the ropes
- accounting for rope elasticity in the above
- varying pulley radii, etc.

Keeping reality aside, and keeping to the simple model we have used so far, a simple way to get the steps would be:

1. Get $\Theta$ for both points, and from there, $\Delta\Theta$.
2. Scale $\Delta\Theta$ by $d\theta_{step}$, to get the total step count for each pulley.
3. Take the maximum step count, call it in $N$. This will be the number of elements in the output sequence.
4. Distribute each step count over $N$ elements evenly.

Let's take an example to better understand what (4) means. Let the total step count obtained in (2) be $(6, 3, 3, 1)$. So, pulley 1 has to rotate 6 steps, pulley 2 rotates 3 steps, etc.

Our output, which will be a sequence of individual steps made by each pulley, will have at least 6 elements. It could look like

```
(1, 1, 1, 1)
(1, 1, 1, 0)
(1, 1, 1, 0)
(1, 0, 0, 0)
(1, 0, 0, 0)
(1, 0, 0, 0)
```
But, we'd like the output to be more evenly distributed:

```
(1, 1, 1, 1)        (1, 1, 0, 1)
(1, 0, 0, 0)        (1, 0, 1, 0)
(1, 1, 1, 0)        (1, 1, 0, 0)
(1, 0, 0, 0)   or   (1, 0, 1, 0)
(1, 1, 1, 0)        (1, 1, 0, 0)
(1, 0, 0, 0)        (1, 0, 1, 0)

```

The following simple function does this.

```python
def get_steps_from_dTheta(total_step_counts):
    """
    Return evenly distributed sequence of 1s and 0s
    that sum up to the step counts needed for dTheta
    """
    N = np.int64(np.ceil(max(total_step_counts)))
    Nt = len(total_step_counts)
    steps = [np.zeros(total_step_counts.size) for i in range(N)]
    for j in range(Nt):
        # np.linspace provides evenly spaced numbers
        # we can use these numbers as indexes where steps are made
        for i in np.linspace(0, N - 1, abs(total_step_counts[j])):
            steps[np.int64(i)][j] = 1 if total_step_counts[j] > 0 else -1
    return steps
```

```python
coords1 = np.array([50, 100, 300])
coords2 = np.array([20, 60, 270])

Theta1 = get_thetas(coords1, B, D, H, r)
Theta2 = get_thetas(coords2, B, D, H, r)

dtheta_step2 = dtheta_step*10
steps = get_steps_from_dTheta((Theta2 - Theta1)/dtheta_step2)
steps[1:10]
```

    [array([-1.,  1.,  1.,  0.]),
     array([-1.,  0.,  1.,  0.]),
     array([-1.,  1.,  1.,  0.]),
     array([0., 1., 1., 0.]),
     array([-1.,  0.,  1.,  0.]),
     array([-1.,  1.,  1.,  0.]),
     array([-1.,  0.,  1., -1.]),
     array([-1.,  1.,  1.,  0.]),
     array([0., 1., 1., 0.])]

```python
Theta = Theta1
Point = get_coordinate(Theta, B, D, H, r)

Thetas = [Theta]
Points2 = [Point]
for step in steps:
    # since steps are too small to show in the figure,
    # let's scale the steps by a factor of 5
    dtheta = (dtheta_step2*step)
    Theta = Thetas[-1] + dtheta
    Point = get_coordinate(Theta, B, D, H, r)
    Points2.append(Point)
    Thetas.append(Theta)

print(Theta2, Thetas[-1])
print(coords1, Points2[0])
print(coords2, Points2[-1])
```

    [-418.03398875  327.64924073  522.08795794  -74.00333786] [-417.13369123  326.37657012  521.85344635  -73.30382858]
    [ 50 100 300] [ 50. 100. 300.]
    [ 20  60 270] [ 19.85455314  60.12348411 269.94046493]

    /Users/muru/dev/agritech/ml/lib/python3.7/site-packages/ipykernel_launcher.py:13: RuntimeWarning: invalid value encountered in sqrt
      del sys.path[0]

As you can see, the final point is quite close to the target point. Let's plot the animation for this. The function isn't perfect - at some steps, the pulleys have been rotated to invalid places. If you look closely, you cna also see that the movement is quite jerky.

```python
ani2, fig2 = animate_points(Points2)
```

    Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …