# Multi-objective Optimization using Pymoo Library

Without any loss of generality an optimization problem can be defined by:

\begin{align}
\begin{split}
\min \quad& f_{m}(x) \quad \quad \quad \quad m = 1,..,M  \\[4pt]
\text{s.t.}   \quad& g_{j}(x) \leq 0  \quad \; \; \,  \quad j = 1,..,J \\[2pt]
\quad& h_{k}(x) = 0        \quad  \; \; \quad k = 1,..,K \\[4pt]
\quad& x_{i}^{L} \leq x_{i} \leq x_{i}^{U}  \quad i = 1,..,N \\[2pt]
\quad& x \in \Omega
\end{split}
\end{align}

where xi represents the i-th variable to be optimized, xLi and xUi its lower and upper bound, fm the m-th objective function, gj the j−th inequality constraint and hk the k-th equality constraint.

The objective function(s) f are supposed to be minimized by satisfying all equality and inequality constraints. If a specific objective function is maximized (maxfi), one can redefine the problem to minimize its negative value (min−fi).

Instead of starting coding your problem immediately, it is recommendable to first think about the mathematical problem formulation. Doing so makes you being aware of the complete optimization problem. This also helps you to identify the challenging facets of your optimization problem and, thus, to select a suitable algorithm. In this guide, we will demonstrate an example of a multi-objective problem, use pymoo to obtain a solution set, and theoretically derive the optimum for verification purposes.

If you have some experience solving optimization problems, the following might already be apparent to you. However, if you are new to optimization, thinking about your problem’s characteristics is vital. In the following a few common facts and challenges to consider when intending to solve a (real-world) optimization problem are discussed:

Variable Types. The variables span the search space Ω of your optimization problem. Thus, the type of variables is an essential aspect of the problem to be paid attention to. Different variables types, such as continuous, discrete/integer, binary, or permutation, define the characteristics of the search space. In some cases, the variable types might be even mixed, which increases the complexity further.

Number of Variables. Not only the type but also the number of variables (N) is essential. For either a very small or large number, different algorithms are known to work more efficiently. You can imagine that solving a problem with only ten variables is fundamentally different from solving one with a couple of thousand. For large-scale optimization problems, even the second-order derivate becomes computationally very expensive, and efficiently handling the memory plays a more important role.

Number of Objectives. Some optimization problems have more than one conflicting objective (M>1) to be optimized. Before researchers have investigated multi-objective optimization, single-objective problems were the main focus. Single-objective optimization is only a particular case where M=1. In multi-objective optimization, the solution’s domination relation generalizes the comparison of two scalars in single-objective optimization. Moreover, having more than one dimension in the objective space, the optimum (most of the time) consists of a set of non-dominated solutions. Because a set of solutions should be obtained, population-based algorithms have mainly been used as solvers.

Constraints. Optimization problems have two types of constraints, inequality (g) and equality (h) constraints. From an end-user perspective, constraints have a priority over objective values. No matter how good the solution’s objectives are, it is considered infeasible if it turns out to violate just a single constraint. Constraints can have a big impact on the complexity of the problem. For instance, if only a few islands in the search space are feasible or a large number of constraints (|J|+|K|) need to be satisfied. For genetic algorithms satisfying equality constraints can be rather challenging. Thus, this needs to be addressed differently, for instance, by mapping the search space to a utility space where the equality constraints are always satisfied or injecting the knowledge of the equality constraint through customization.

Multi-modality. Most aspects discussed so far are most likely known or to be relatively easy to define. However, the nature of the fitness landscape is less obvious bet yet essential to be aware of. In the case of multi-modal fitness landscapes, optimization becomes inevitably more difficult due to the existence of a few or even many local optima. For the solution found, one must always ask if the method has explored enough regions in the search space to maximize the probability of obtaining the global optimum. A multi-modal search space quickly shows the limitation of local search, which can easily get stuck.

Differentiability. A function being differentiable implies the first or even second-order derivative can be calculated. Differentiable functions allow gradient-based optimization methods to be used, which can be a great advantage over gradient-free methods. The gradient provides a good indication of what direction shall be used for the search. Most gradient-based algorithms are point-by-point based and can be highly efficient for rather unimodal fitness landscapes. However, in practice, often functions are non-differentiable, or a more complicated function requires a global instead of a local search. The research field addressing problems without knowing their mathematical optimization is also known as black-box optimization.

Evaluation Time. Many optimization problems in practice consist of complicated and lengthy mathematical equations or domain-specific software to be evaluated. The usage of third-party software often results in a computationally expensive and time-consuming function for evaluating objectives or constraints. For those types of problems, the algorithm’s overhead for determining the next solutions to be evaluated is often neglectable. A commercial software performing an evaluation often comes with various more practical issues such as distributed computing, several instances to be used in parallel and software license, and the software’s possible failure for specific design variable combinations.

Uncertainty. Often it is assumed that the objective and constraint functions are of a deterministic manner. However, if one or multiple target functions are nondeterministic, this introduces noise or also referred to as uncertainty. One technique to address the underlying randomness is to repeat the evaluation for different random seeds and average the resulting values. Moreover, the standard deviation derived from multiple evaluations can be utilized to determine the performance and the reliability of a specific solution. In general, optimization problems with underlying uncertainty are investigated by the research field called stochastic optimization.

Of course, this shall not be an exhaustive list of problem characteristics but rather an idea of how fundamentally different optimization problems are. Being aware of possible challenges, one can make better decisions regarding a method and its suitability. In this tutorial, solving a constrained bi-objective optimization problem is demonstrated. This, and your problem’s characteristics, shall help you to use pymoo as a toolbox to tackle your optimization problem.

In the following, we investigate exemplarily a bi-objective optimization with two constraints. We have tried to select a suitable optimization problem with enough complexity for demonstration purposes, but not too difficult to lose track of the overall idea.

As already discussed in the Preface, let an optimization problem be defined by:

\begin{align}
\begin{split}
\min \quad& f_{m}(x) \quad \quad \quad \quad m = 1,..,M  \\[4pt]
\text{s.t.}   \quad& g_{j}(x) \leq 0  \quad \; \; \,  \quad j = 1,..,J \\[2pt]
\quad& h_{k}(x) = 0        \quad  \; \; \quad k = 1,..,K \\[4pt]
\quad& x_{i}^{L} \leq x_{i} \leq x_{i}^{U}  \quad i = 1,..,N \\[2pt]
\quad& x \in \Omega
\end{split}
\end{align}

The the example problem to be solved in this getting started guide is given by:

\begin{align}
\begin{split}
\min \;\; & f_1(x) = 100 \,(x_1^2 + x_2^2) \\
\max \;\; & f_2(x) = -(x_1-1)^2 - x_2^2 \\[1mm]
\text{s.t.} \;\; & g_1(x) = 2 \, (x_1 - 0.1) \, (x_1 - 0.9) \leq 0\\
& g_2(x) = 20 \, (x_1 - 0.4) \, (x_1 - 0.6) \geq 0\\[1mm]
& -2 \leq x_1 \leq 2 \\
& -2 \leq x_2 \leq 2\\[1mm]
& x \in \mathbb{R}
\end{split}
\end{align}

The problem consists of two objectives (M=2) where f1(x) is minimized and f2(x) maximized. The optimization is subject to two inequality constraints (J=2) where g1(x) is formulated as a less than and g2(x) as a greater than constraint. The problem is defined with respect to two variables (N=2), x1 and x2, both in the range [−2,2]. The problem does not contain any equality constraints (K=0).

Let us analyze where the Pareto-optimal solutions have to lie. The first objective f1 is minimized at (0,0), whereas the second object f2 at (1,0). Because both functions are of quadratic nature, the optimum is given by a straight line between the two optima. This means all Pareto-optimal solutions (ignoring the constraints for now) have in common that x2=0 and x1∈(0,1). The first constraint only relies on x1 and is satisfied if x1∈(0.1,0.9). The second constraint g2 is satisfied for x1∈(0.4,0.6). This means analytically, the pareto-optimal set is given by PS={(x1,x2)|(0.1≤x1≤0.4)∨(0.6≤x1≤0.9)∧x2=0}.

The figure below shows the problem’s functions in the design space and might help to see the relationship between the functions intuitively. The contour lines of the objective function f1(x) is represented by a solid and f2(x) by a dashed line. The constraints g1(x) and g2(x) are parabolas which intersect the x1-axis at (0.1,0.9) and (0.4,0.6). A thick orange line illustrates the Pareto-optimal set. When considering both constraints together, the Pareto-set shown in orange is split into two parts as analytically derived above.

## Setup

```python
!pip install -q pymoo
```

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

from pymoo.core.problem import ElementwiseProblem
from pymoo.algorithms.moo.nsga2 import NSGA2
from pymoo.factory import get_sampling, get_crossover, get_mutation
from pymoo.factory import get_termination
from pymoo.optimize import minimize
from pymoo.decomposition.asf import ASF
from pymoo.mcdm.pseudo_weights import PseudoWeights
from pymoo.util.misc import stack
from pymoo.optimize import minimize
from pymoo.indicators.hv import Hypervolume
from pymoo.util.running_metric import RunningMetric
from pymoo.util.running_metric import RunningMetric
from pymoo.indicators.igd import IGD
from pymoo.indicators.igd_plus import IGDPlus
```

## A Constrained Bi-objective Optimization Problem

```python
X1, X2 = np.meshgrid(np.linspace(-2, 2, 500), np.linspace(-2, 2, 500))

F1 = X1**2 + X2**2
F2 = (X1-1)**2 + X2**2
G = X1**2 - X1 + 3/16

G1 = 2 * (X1[0] - 0.1) * (X1[0] - 0.9)
G2 = 20 * (X1[0] - 0.4) * (X1[0] - 0.6)

plt.rc('font', family='serif')

levels = [0.02, 0.1, 0.25, 0.5, 0.8]
plt.figure(figsize=(7, 5))
CS = plt.contour(X1, X2, F1, levels, colors='black', alpha=0.5)
CS.collections[0].set_label("$f_1(x)$")

CS = plt.contour(X1, X2, F2, levels, linestyles="dashed", colors='black', alpha=0.5)
CS.collections[0].set_label("$f_2(x)$")

plt.plot(X1[0], G1, linewidth=2.0, color="green", linestyle='dotted')
plt.plot(X1[0][G1<0], G1[G1<0], label="$g_1(x)$", linewidth=2.0, color="green")

plt.plot(X1[0], G2, linewidth=2.0, color="blue", linestyle='dotted')
plt.plot(X1[0][X1[0]>0.6], G2[X1[0]>0.6], label="$g_2(x)$",linewidth=2.0, color="blue")
plt.plot(X1[0][X1[0]<0.4], G2[X1[0]<0.4], linewidth=2.0, color="blue")

plt.plot(np.linspace(0.1,0.4,100), np.zeros(100),linewidth=3.0, color="orange")
plt.plot(np.linspace(0.6,0.9,100), np.zeros(100),linewidth=3.0, color="orange")

plt.xlim(-0.5, 1.5)
plt.ylim(-0.5, 1)
plt.xlabel("$x_1$")
plt.ylabel("$x_2$")

plt.legend(loc='upper center', bbox_to_anchor=(0.5, 1.12),
          ncol=4, fancybox=True, shadow=False)

plt.tight_layout()
plt.show()
```

Next, we derive the Pareto-front by mapping the Pareto-set to the objective space. The Pareto-front equation is based on f2 depending on the variable of f1. We know that at the optimum x2=0 which means we can simplify the objective functions to f1(x)=100x21 and f2(x)=−(x1−1)2. The first objective f1 can be reformulated to x1=f1100−−−√ and then be put into the second objective which results in

$$f_2 = -\left(\sqrt{\frac{f_1}{100}}-1\right)^2$$

The equation defines the shape, however, next all possible values for f1 need to be defined. As shown before the Pareto-set is defined for (0.1≤x1≤0.4)∨(0.6≤x1≤0.9)∧x2=0. If we plug in the values for x1 into f1 we get the points of interest [1,16] and [36,81]. Thus the Pareto-front is given by:

```python
plt.figure(figsize=(7, 5))

f2 = lambda f1: - ((f1/100) ** 0.5 - 1)**2
F1_a, F1_b = np.linspace(1, 16, 300), np.linspace(36, 81, 300)
F2_a, F2_b = f2(F1_a), f2(F1_b)

plt.rc('font', family='serif')
plt.plot(F1_a,F2_a, linewidth=2.0, color="green", label="Pareto-front")
plt.plot(F1_b,F2_b, linewidth=2.0, color="green")

plt.xlabel("$f_1$")
plt.ylabel("$f_2$")

plt.legend(loc='upper center', bbox_to_anchor=(0.5, 1.10),
          ncol=4, fancybox=True, shadow=False)

plt.tight_layout()
plt.show()
```

Researchers have developed all kinds of test problems and derived their optima from designing and comparing suitable optimization algorithms. However, deriving the Pareto-set and Pareto-front from a mathematical problem formulation can become quite challenging for more complicated problems or not even be possible. Also, not all algorithms can be put into a math equation and might be of a black-box nature. Thus, we use optimization algorithms to find (near-optimal) solutions using well-benchmarked algorithms.

## Find a Solution Set using Multi-objective Optimization

Most optimization frameworks commit to either minimize or maximize all objectives and to have only ≤ or ≥ constraints. In pymoo, each objective function is supposed to be minimized, and each constraint needs to be provided in the form of ≤0.

Thus, we need to multiple an objective that is supposed to be maximized by −1 and minimize it. This results in minimizing −f2(x) instead of maximizing f2(x).

Moreover, the inequality constraints need to be formulated as less than zero (≤0) constraint. For this reason, g2(x) is multiplied by −1 in order to flip inequality relation. Also, we recommend the normalization of constraints to make them operating on the same scale and giving them equal importance. For g1(x), the coefficient results in 2⋅(−0.1)⋅(−0.9)=0.18 and for g2(x) in 20⋅(−0.4)⋅(−0.6)=4.8, respectively. We achieve normalization of constraints by dividing g1(x) and g2(x) by its corresponding coefficient.

After these reformulations the problem to be implemented is given by:

\begin{align}
\begin{split}
\min \;\; & f_1(x) = 100 \, (x_1^2 + x_2^2) \\
\min \;\; & f_2(x) = (x_1-1)^2 + x_2^2 \\[1mm]
\text{s.t.} \;\; & g_1(x) = 2 \, (x_1 - 0.1) \, (x_1 - 0.9)  \, /  \,  0.18 \leq 0\\
& g_2(x) = - 20 \, (x_1 - 0.4) \, (x_1 - 0.6) \,  /  \,  4.8 \leq 0\\[1mm]
& -2 \leq x_1 \leq 2 \\
& -2 \leq x_2 \leq 2\\[1mm]
& x \in \mathbb{R}
\end{split}
\end{align}

### Implement the Problem

After having formulated the problem the right way, we can start implementing it. In this tutorial we the element-wise problem definition, which is one out of three different ways for implementing a problem. We define a new Python objective inheriting from ElementwiseProblem and set the correct attributes, such as the number of objectives (n_obj) and constraints (n_constr) and the lower (xl) and upper bounds (xu). The function being responsible for the evaluation is _evaluate which shall be implemented next. The function’s interface is the parameters x and out. For this element-wise implementation x is a one-dimensional NumPy array of length n_var which represents a single solution to be evaluated. The output is supposed to be written to the dictionary out. The objective values should be written to out["F"] as a list of NumPy array with length of n_obj and the constraints to out["G"] with length of n_constr (if the problem has constraints to be satisfied at all).

```python
class MyProblem(ElementwiseProblem):

    def __init__(self):
        super().__init__(n_var=2,
                         n_obj=2,
                         n_constr=2,
                         xl=np.array([-2,-2]),
                         xu=np.array([2,2]))

    def _evaluate(self, x, out, *args, **kwargs):
        f1 = 100 * (x[0]**2 + x[1]**2)
        f2 = (x[0]-1)**2 + x[1]**2

        g1 = 2*(x[0]-0.1) * (x[0]-0.9) / 0.18
        g2 = - 20*(x[0]-0.4) * (x[0]-0.6) / 4.8

        out["F"] = [f1, f2]
        out["G"] = [g1, g2]

problem = MyProblem()
```

A problem can be defined in a couple of different ways. Above, the implementation of an element-wise implementation is demonstrated, which means the _evaluate is called for each solution x at a time. Other ways of implementing a problem are vectorized, where x represents a whole set of solutions or a functional and probably more pythonic way by providing for each objective and constraint as a function. For more details, please have a look at the Problem tutorial.

### Initialize an Algorithm

The reason why you became aware of this framework, is probably the existence of an algorithm you like to use. pymoo follows an object oriented approach and, thus, we have to define an algorithm object next. Depending on the optimization problem, different algorithms will perform better or worse on different kind of problems. It is recommendable to first understand the intuition behind an algorithm and then select one which seems to be most suitable for solving your optimization problem. A list of algorithms which are available in pymoo is available here.

In our case, the optimization problem is rather simple, but the aspect of having two objectives and two constraints should be considered. Thus, let us select the well-known multi-objective algorithm NSGA-II. For the majority of algorithms you could either choose the default hyper-parameters or create your own version of the algorithm by modifying them. For instance, for this relatively simple problem we choose a population size of 40 (pop_size=40) and with only 10 (n_offsprings=10) in each generation. Such an implementation is a greedier variant and improves the convergence of simpler optimization problems without major difficulties regarding optimization, such as the existence of local Pareto-fronts. Moreover, we enable a duplicate check (eliminate_duplicates=True), making sure that the mating produces offsprings that are different from themselves and the existing population regarding their design space values.

```python
algorithm = NSGA2(
    pop_size=40,
    n_offsprings=10,
    sampling=get_sampling("real_random"),
    crossover=get_crossover("real_sbx", prob=0.9, eta=15),
    mutation=get_mutation("real_pm", eta=20),
    eliminate_duplicates=True
)
```

The algorithm object contains an implementation of NSGA-II with the custom configuration discussed above. The object can now be used to start an optimization run.

### Define a Termination Criterion

Furthermore, a termination criterion needs to be defined to start the optimization procedure. Most common ways of defining the termination is by limiting the overall number of function evaluations or simply the number of iterations of the algorithm. Moreover, some algorithms already have implemented their own, for instance Nelder-Mead when the simplex becomes degenerated or CMA-ES where a vendor library is used. Because of the simplicity of this problem we use a rather small number of 40 iteration of the algorithm.

```python
termination = get_termination("n_gen", 40)
```

### Optimize

Finally, we are solving the problem with the algorithm and termination we have defined. The functional interface uses the minimize method. By default, the minimize performs deep-copies of the algorithm and the termination object which guarantees they are not modified during the function call. This is important to ensure that repetitive function calls with the same random seed end up with the same results. When the algorithm has been terminated, the minimize function returns a Result object.

```python
res = minimize(problem,
               algorithm,
               termination,
               seed=1,
               save_history=True,
               verbose=True)

X = res.X
F = res.F
```

    =====================================================================================
    n_gen |  n_eval |   cv (min)   |   cv (avg)   |  n_nds  |     eps      |  indicator
    =====================================================================================
        1 |      40 |  0.00000E+00 |  2.36399E+01 |       1 |            - |            -
        2 |      50 |  0.00000E+00 |  1.15486E+01 |       1 |  0.00000E+00 |            f
        3 |      60 |  0.00000E+00 |  5.277918607 |       1 |  0.00000E+00 |            f
        4 |      70 |  0.00000E+00 |  2.406068542 |       2 |  1.000000000 |        ideal
        5 |      80 |  0.00000E+00 |  0.908316880 |       3 |  0.869706146 |        ideal
        6 |      90 |  0.00000E+00 |  0.264746300 |       3 |  0.00000E+00 |            f
        7 |     100 |  0.00000E+00 |  0.054063822 |       4 |  0.023775686 |        ideal
        8 |     110 |  0.00000E+00 |  0.003060876 |       5 |  0.127815454 |        ideal
        9 |     120 |  0.00000E+00 |  0.00000E+00 |       6 |  0.085921728 |        ideal
       10 |     130 |  0.00000E+00 |  0.00000E+00 |       7 |  0.015715204 |            f
       11 |     140 |  0.00000E+00 |  0.00000E+00 |       8 |  0.015076323 |            f
       12 |     150 |  0.00000E+00 |  0.00000E+00 |       7 |  0.026135665 |            f
       13 |     160 |  0.00000E+00 |  0.00000E+00 |      10 |  0.010026699 |            f
       14 |     170 |  0.00000E+00 |  0.00000E+00 |      11 |  0.011833783 |            f
       15 |     180 |  0.00000E+00 |  0.00000E+00 |      12 |  0.008294035 |            f
       16 |     190 |  0.00000E+00 |  0.00000E+00 |      14 |  0.006095993 |        ideal
       17 |     200 |  0.00000E+00 |  0.00000E+00 |      17 |  0.002510398 |        ideal
       18 |     210 |  0.00000E+00 |  0.00000E+00 |      20 |  0.003652660 |            f
       19 |     220 |  0.00000E+00 |  0.00000E+00 |      20 |  0.010131820 |        nadir
       20 |     230 |  0.00000E+00 |  0.00000E+00 |      21 |  0.005676014 |            f
       21 |     240 |  0.00000E+00 |  0.00000E+00 |      25 |  0.010464402 |            f
       22 |     250 |  0.00000E+00 |  0.00000E+00 |      25 |  0.000547515 |            f
       23 |     260 |  0.00000E+00 |  0.00000E+00 |      28 |  0.001050255 |            f
       24 |     270 |  0.00000E+00 |  0.00000E+00 |      33 |  0.003841298 |            f
       25 |     280 |  0.00000E+00 |  0.00000E+00 |      37 |  0.006664377 |        nadir
       26 |     290 |  0.00000E+00 |  0.00000E+00 |      40 |  0.000963164 |            f
       27 |     300 |  0.00000E+00 |  0.00000E+00 |      40 |  0.000678243 |            f
       28 |     310 |  0.00000E+00 |  0.00000E+00 |      40 |  0.000815766 |            f
       29 |     320 |  0.00000E+00 |  0.00000E+00 |      40 |  0.001500814 |            f
       30 |     330 |  0.00000E+00 |  0.00000E+00 |      40 |  0.014706442 |        nadir
       31 |     340 |  0.00000E+00 |  0.00000E+00 |      40 |  0.003554320 |        ideal
       32 |     350 |  0.00000E+00 |  0.00000E+00 |      40 |  0.000624123 |            f
       33 |     360 |  0.00000E+00 |  0.00000E+00 |      40 |  0.000203925 |            f
       34 |     370 |  0.00000E+00 |  0.00000E+00 |      40 |  0.001048509 |            f
       35 |     380 |  0.00000E+00 |  0.00000E+00 |      40 |  0.001121103 |            f
       36 |     390 |  0.00000E+00 |  0.00000E+00 |      40 |  0.000664461 |            f
       37 |     400 |  0.00000E+00 |  0.00000E+00 |      40 |  0.000761066 |            f
       38 |     410 |  0.00000E+00 |  0.00000E+00 |      40 |  0.000521906 |            f
       39 |     420 |  0.00000E+00 |  0.00000E+00 |      40 |  0.004652095 |        nadir
       40 |     430 |  0.00000E+00 |  0.00000E+00 |      40 |  0.000287847 |            f

If the verbose=True, some printouts during the algorithm’s execution are provided. This can very from algorithm to algorithm. Here, we execute NSGA2 on a problem where pymoo has no knowledge about the optimum. Each line represents one iteration. The first two columns are the current generation counter and the number of evaluations so far. For constrained problems, the next two columns show the minimum constraint violation (cv (min)) and the average constraint violation (cv (avg)) in the current population. This is followed by the number of non-dominated solutions (n_nds) and two more metrics which represents the movement in the objective space.

### Visualize

Analyzing the solutions being found by the algorithm is vital. Always a good start is visualizing the solutions to get a grasp of commonalities or if the Pareto-front is known to even check the convergence.

```python
xl, xu = problem.bounds()
plt.figure(figsize=(7, 5))
plt.scatter(X[:, 0], X[:, 1], s=30, facecolors='none', edgecolors='r')
plt.xlim(xl[0], xu[0])
plt.ylim(xl[1], xu[1])
plt.title("Design Space")
plt.show()
```

```python
plt.figure(figsize=(7, 5))
plt.scatter(F[:, 0], F[:, 1], s=30, facecolors='none', edgecolors='blue')
plt.title("Objective Space")
plt.show()
```

## Multi-Criteria Decision Making

Having now obtained a set of non-dominated solutions, one can ask how a decision-maker can nail down the set to only a few or even a single solution. This decision-making process for multi-objective problems is also known as Multi-Criteria Decision Making (MCDM). You should know that the main focus of pymoo lies in the optimization, not the MCDM part. However, the framework offers some rudimentary tools to find an appropriate solution.

The Pareto-optimal solutions obtained from the optimization procedure are given by:

```python
F = res.F
xl, xu = problem.bounds()
plt.figure(figsize=(7, 5))
plt.scatter(F[:, 0], F[:, 1], s=30, facecolors='none', edgecolors='blue')
plt.title("Objective Space")
plt.show()
```

Before we start using any technique, we should note that the objectives have a different scale. What has not been a problem for single-objective optimization because not more than one dimension existed now becomes fundamentally important to consider.

```python
fl = F.min(axis=0)
fu = F.max(axis=0)
print(f"Scale f1: [{fl[0]}, {fu[0]}]")
print(f"Scale f2: [{fl[1]}, {fu[1]}]")
```

    Scale f1: [1.3377795039158837, 74.97223429467643]
    Scale f2: [0.01809179532919018, 0.7831767823138299]

As one can observe, the lower and upper bounds of the objectives f1 and f2 are very different, and such normalization is required.

> Warning: Without normalization, we are comparing oranges with apples. The first objective will dominate any distance calculation in the objective space because of its larger scale. Handling different scales of objectives is an inherent part of any multi-objective algorithms, and, thus, we need to do the same for post-processing.

A common way is normalizing using the so-called ideal and nadir point. However, for the decision-making purpose here and the sake of generalization, we assume the ideal and nadir points (also referred to as boundary points) and the Pareto-front) are not known. Thus the points can be approximated by:

```python
approx_ideal = F.min(axis=0)
approx_nadir = F.max(axis=0)

plt.figure(figsize=(7, 5))
plt.scatter(F[:, 0], F[:, 1], s=30, facecolors='none', edgecolors='blue')
plt.scatter(approx_ideal[0], approx_ideal[1], facecolors='none', edgecolors='red', marker="*", s=100, label="Ideal Point (Approx)")
plt.scatter(approx_nadir[0], approx_nadir[1], facecolors='none', edgecolors='black', marker="p", s=100, label="Nadir Point (Approx)")
plt.title("Objective Space")
plt.legend()
plt.show()
```

Normalizing the obtained objective values regarding the boundary points is relatively simple by:

```python
nF = (F - approx_ideal) / (approx_nadir - approx_ideal)

fl = nF.min(axis=0)
fu = nF.max(axis=0)
print(f"Scale f1: [{fl[0]}, {fu[0]}]")
print(f"Scale f2: [{fl[1]}, {fu[1]}]")

plt.figure(figsize=(7, 5))
plt.scatter(nF[:, 0], nF[:, 1], s=30, facecolors='none', edgecolors='blue')
plt.title("Objective Space")
plt.show()
```

### Compromise Programming

Without going into too much detail in this getting started guide, one way for decision-making is using decomposition functions. They require the definition of weights that reflect the user’s wishes. A vector gives the weights with only positive float numbers summing up to one and a length equal to the number of objectives.

Here for a bi-objective problem, let us assume the first objective is less a bit less important than the second objective by setting the weights to

```python
weights = np.array([0.2, 0.8])
```

Next, we choose the decomposition method called Augmented Scalarization Function (ASF), a well-known metric in the multi-objective optimization literature.

```python
decomp = ASF()
```

Now let us obtain the best solution regarding the ASF. Because ASF is supposed to be minimized, we choose the minimum ASF values calculated from all solutions. You might be wondering why the weights are not passed directly, but 1/weights. For ASF, different formulations exist, one where the values are divided and one where they are multiplied. In pymoo, we divide, which does not reflect the idea of the user’s criteria. Thus, the inverse needs to be applied. No worries if this is too much detail for now; however, decision-making about decomposition techniques is vital.

```python
i = decomp.do(nF, 1/weights).argmin()
```

After having found a solution (i) we can operate on the original scale to represent the results:

```python
print("Best regarding ASF: Point \ni = %s\nF = %s" % (i, F[i]))
```

    Best regarding ASF: Point
    i = 21
    F = [43.28059434  0.12244878]

```python
plt.figure(figsize=(7, 5))
plt.scatter(F[:, 0], F[:, 1], s=30, facecolors='none', edgecolors='blue')
plt.scatter(F[i, 0], F[i, 1], marker="x", color="red", s=200)
plt.title("Objective Space")
plt.show()
```

### Pseudo-Weights

A simple way to chose a solution out of a solution set in the context of multi-objective optimization is the pseudo-weight vector approach proposed in. Respectively, the pseudo weight wi for the i-ith objective function can be calculated by:

\begin{equation}
w_i = \frac{(f_i^{max} - f_i {(x)}) \, /\,  (f_i^{max} - f_i^{min})}{\sum_{m=1}^M (f_m^{max} - f_m (x)) \, /\,  (f_m^{max} - f_m^{min})}
\end{equation}

This equation calculates the normalized distance to the worst solution regarding each objective i. Please note that for non-convex Pareto fronts, the pseudo weight does not correspond to the result of an optimization using the weighted sum. However, for convex Pareto-fronts, the pseudo weights indicate the location in the objective space.

```python
i = PseudoWeights(weights).do(nF)
```

```python
print("Best regarding Pseudo Weights: Point \ni = %s\nF = %s" % (i, F[i]))

plt.figure(figsize=(7, 5))
plt.scatter(F[:, 0], F[:, 1], s=30, facecolors='none', edgecolors='blue')
plt.scatter(F[i, 0], F[i, 1], marker="x", color="red", s=200)
plt.title("Objective Space")
plt.show()
```

## Analysis of Convergence

Great! So far, we have executed an algorithm and already obtained a solution set. But let us not stop here without knowing how the algorithm has performed. This will also answer how we should define a termination criterion if we solve the problem again. The convergence analysis shall consider two cases, i) the Pareto-front is not known, or ii) the Pareto-front has been derived analytically, or a reasonable approximation exists.

To further check how close the results match the analytically derived optimum, we have to convert the objective space values to the original definition where the second objective f2 was maximized. Plotting then the Pareto-front shows how close the algorithm was able to converge.

```python
class MyTestProblem(MyProblem):

    def _calc_pareto_front(self, flatten=True, *args, **kwargs):
        f2 = lambda f1: ((f1/100) ** 0.5 - 1)**2
        F1_a, F1_b = np.linspace(1, 16, 300), np.linspace(36, 81, 300)
        F2_a, F2_b = f2(F1_a), f2(F1_b)

        pf_a = np.column_stack([F1_a, F2_a])
        pf_b = np.column_stack([F1_b, F2_b])

        return stack(pf_a, pf_b, flatten=flatten)

    def _calc_pareto_set(self, *args, **kwargs):
        x1_a = np.linspace(0.1, 0.4, 50)
        x1_b = np.linspace(0.6, 0.9, 50)
        x2 = np.zeros(50)

        a, b = np.column_stack([x1_a, x2]), np.column_stack([x1_b, x2])
        return stack(a,b, flatten=flatten)

problem = MyTestProblem()
```

For IGD, the Pareto front needs to be known or to be approximated. In our framework, the Pareto front of test problems can be obtained by:

```python
pf_a, pf_b = problem.pareto_front(use_cache=False, flatten=False)
pf = problem.pareto_front(use_cache=False, flatten=True)

plt.figure(figsize=(7, 5))
plt.scatter(F[:, 0], F[:, 1], s=30, facecolors='none', edgecolors='b', label="Solutions")
plt.plot(pf_a[:, 0], pf_a[:, 1], alpha=0.5, linewidth=2.0, color="red", label="Pareto-front")
plt.plot(pf_b[:, 0], pf_b[:, 1], alpha=0.5, linewidth=2.0, color="red")
plt.title("Objective Space")
plt.legend()
plt.show()
```

Whether the optimum for your problem is known or not, we encourage all end-users of pymoo not to skip the analysis of the obtained solution set. Visualizations for high-dimensional objective spaces (in design and/or objective space) are also provided and shown here.

In Part II, we have run the algorithm without storing, keeping track of the optimization progress, and storing information. However, for analyzing the convergence, historical data need to be stored. One way of accomplishing that is enabling the save_history flag, which will store a deep copy of the algorithm object in each iteration and save it in the Result object. This approach is more memory-intensive (especially for many iterations) but has the advantage that any algorithm-dependent variable can be analyzed posteriorly.

A not negligible step is the post-processing after having obtained the results. We strongly recommend not only analyzing the final result but also the algorithm’s behavior. This gives more insights into the convergence of the algorithm.

For such an analysis, intermediate steps of the algorithm need to be considered. This can either be achieved by:

A Callback class storing the necessary information in each iteration of the algorithm.

Enabling the save_history flag when calling the minimize method to store a deep copy of the algorithm’s objective each iteration.

We provide some more details about each variant in our convergence tutorial. As you might have already seen, we have set save_history=True when calling the minmize method in this getting started guide and, thus, will you the history for our analysis. Moreover, we need to decide what metric should be used to measure the performance of our algorithm. In this tutorial, we are going to use Hypervolume and IGD. Feel free to look at our performance indicators to find more information about metrics to measure the performance of multi-objective algorithms.

```python
res = minimize(problem,
               algorithm,
               ("n_gen", 40),
               seed=1,
               save_history=True,
               verbose=False)

X, F = res.opt.get("X", "F")

hist = res.history
print(len(hist))
```

    40

From the history it is relatively easy to extract the information we need for an analysis.

```python
n_evals = []             # corresponding number of function evaluations\
hist_F = []              # the objective space values in each generation
hist_cv = []             # constraint violation in each generation
hist_cv_avg = []         # average constraint violation in the whole population

for algo in hist:

    # store the number of function evaluations
    n_evals.append(algo.evaluator.n_eval)

    # retrieve the optimum from the algorithm
    opt = algo.opt

    # store the least contraint violation and the average in each population
    hist_cv.append(opt.get("CV").min())
    hist_cv_avg.append(algo.pop.get("CV").mean())

    # filter out only the feasible and append and objective space values
    feas = np.where(opt.get("feasible"))[0]
    hist_F.append(opt.get("F")[feas])
```

### Constraint Satisfaction

First, let us quickly see when the first feasible solution has been found:

```python
k = np.where(np.array(hist_cv) <= 0.0)[0].min()

print(f"At least one feasible solution in Generation {k} after {n_evals[k]} evaluations.")
```

    At least one feasible solution in Generation 0 after 40 evaluations.

Because this problem does not have much complexity, a feasible solution was found right away. Nevertheless, this can be entirely different for your optimization problem and is also worth being analyzed first.

```python
# replace this line by `hist_cv` if you like to analyze the least feasible optimal solution and not the population
vals = hist_cv_avg

k = np.where(np.array(vals) <= 0.0)[0].min()

print(f"Whole population feasible in Generation {k} after {n_evals[k]} evaluations.")

plt.figure(figsize=(7, 5))
plt.plot(n_evals, vals,  color='black', lw=0.7, label="Avg. CV of Pop")
plt.scatter(n_evals, vals,  facecolor="none", edgecolor='black', marker="p")
plt.axvline(n_evals[k], color="red", label="All Feasible", linestyle="--")
plt.title("Convergence")
plt.xlabel("Function Evaluations")
plt.ylabel("Constraint Violation")
plt.legend()
plt.show()
```

### Pareto-front is unknown

If the Pareto-front is not known, we can not know if the algorithm has converged to the true optimum or not. At least not without any further information. However, we can see when the algorithm has made most of its progress during optimization and thus if the number of iterations should be less or more. Additionally, the metrics serve to compare two algorithms with each other.

In multi-objective optimization normalization the very important. For that reason, you see below that the Hypervolume is based on a normalized set normalized by the bounds (idea) More details about it will be shown in Part IV.

### Hypvervolume (HV)

Hypervolume is a very well-known performance indicator for multi-objective problems. It is Pareto-compliant and is based on the volume between a predefined reference point and the solution provided. Therefore, hypervolume requires defining a reference point ref_point, which shall be larger than the maximum value of the Pareto front.

```python
approx_ideal = F.min(axis=0)
approx_nadir = F.max(axis=0)

metric = Hypervolume(ref_point= np.array([1.1, 1.1]),
                     norm_ref_point=False,
                     zero_to_one=True,
                     ideal=approx_ideal,
                     nadir=approx_nadir)

hv = [metric.do(_F) for _F in hist_F]

plt.figure(figsize=(7, 5))
plt.plot(n_evals, hv,  color='black', lw=0.7, label="Avg. CV of Pop")
plt.scatter(n_evals, hv,  facecolor="none", edgecolor='black', marker="p")
plt.title("Convergence")
plt.xlabel("Function Evaluations")
plt.ylabel("Hypervolume")
plt.show()
```

> Note: Hypervolume becomes computationally expensive with increasing dimensionality. The exact hypervolume can be calculated efficiently for 2 and 3 objectives. For higher dimensions, some researchers use a hypervolume approximation, which is not available yet in pymoo.

### Running Metric
Another way of analyzing a run when the true Pareto front is not known is the recently proposed running metric. The running metric shows the difference in the objective space from one generation to another and uses the algorithm’s survival to visualize the improvement. This metric is also being used in pymoo to determine the termination of a multi-objective optimization algorithm if no default termination criteria have been defined.

For instance, this analysis reveals that the algorithm improved from the 4th to the 5th generation significantly.

```python
running = RunningMetric(delta_gen=5,
                        n_plots=3,
                        only_if_n_plots=True,
                        key_press=False,
                        do_show=True)

for algorithm in res.history[:15]:
    running.notify(algorithm)
```

Plotting until the final population shows the algorithm seems to have more a less converged, and only a slight improvement has been made.

```python
running = RunningMetric(delta_gen=10,
                        n_plots=4,
                        only_if_n_plots=True,
                        key_press=False,
                        do_show=True)

for algorithm in res.history:
    running.notify(algorithm)
```

### Pareto-front is known or approximated

**IGD/GD/IGD+/GD+**

The Pareto-front for a problem can either be provided manually or directly implemented in the Problem definition to analyze the run on the fly. Here, we show an example of using the history of the algorithm as an additional post-processing step.

For real-world problems, you have to use an approximation. An approximation can be obtained by running an algorithm a couple of times and extracting the non-dominated solutions out of all solution sets. If you have only a single run, an alternative is to use the obtained non-dominated set of solutions as an approximation. However, the result only indicates how much the algorithm’s progress in converging to the final set.

```python
metric = IGD(pf, zero_to_one=True)

igd = [metric.do(_F) for _F in hist_F]

plt.plot(n_evals, igd,  color='black', lw=0.7, label="Avg. CV of Pop")
plt.scatter(n_evals, igd,  facecolor="none", edgecolor='black', marker="p")
plt.axhline(10**-2, color="red", label="10^-2", linestyle="--")
plt.title("Convergence")
plt.xlabel("Function Evaluations")
plt.ylabel("IGD")
plt.yscale("log")
plt.legend()
plt.show()
```

```python
metric = IGDPlus(pf, zero_to_one=True)

igd = [metric.do(_F) for _F in hist_F]

plt.plot(n_evals, igd,  color='black', lw=0.7, label="Avg. CV of Pop")
plt.scatter(n_evals, igd,  facecolor="none", edgecolor='black', marker="p")
plt.axhline(10**-2, color="red", label="10^-2", linestyle="--")
plt.title("Convergence")
plt.xlabel("Function Evaluations")
plt.ylabel("IGD+")
plt.yscale("log")
plt.legend()
plt.show()
```


# Lane-Emden polytrope equation

We want to solve the Lane-Emden equation:
\begin{equation}
\frac{1}{\xi^2} \frac{d}{d\xi} \left (\xi^2 \frac{d\theta}{d\xi} \right ) = -\theta^n
\end{equation}
using a Runge-Kutta integration.  We will rewrite this as 2 first order equations:
\begin{eqnarray*}
\frac{dy}{d\xi} &=& z \\
\frac{dz}{d\xi} &=& -\frac{2}{\xi}z - y^n
\end{eqnarray*}

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

This is our main class that does the integration.  We initialize it with the polytopic index, and then it will integrate the system for us.  We can then plot it or get
the parameters $\xi_1$ and $-\xi_1^2 d\theta/d\xi |_{\xi_1}$

We estimate the radius, $\xi_1$ each step and make sure that the next step does not take us past that estimate.  This prevents us from having negative $\theta$ values.  Given a point $(\xi_0, y_0)$, and the derivative at that point, $z_0 = dy/d\xi |_{\xi_0}$, we can write the equation of a line as:
\begin{equation}
    y(\xi) - y_0 = z_0 (\xi - \xi_0)
\end{equation}
Then we can ask when does $y$ become zero, finding:
\begin{equation}
    \xi = -\frac{y_0}{z_0} + \xi_0
\end{equation}
This is our estimate of $\xi_1$.  We then make sure our stepsize $h$ is small enough that we do not go beyond this estimate.

This class uses 4th-order Runge-Kutta (or first-order Euler if we call with `first_order=True`) with a fixed stepsize, `h0`, until we reach the edge of the star.  We need to be careful to choose a value of `h0` small enough to get an accurate solution.  Ideally, we should implement a method that automatically does error estimation as it integrates, and varies the stepsize as needed.

```python
class Polytrope:
    """a polytrope of index n"""
    def __init__(self, n, first_order=False, h0=0.1, tol=1.e-12):
        self.n = n

        # storage for the solution history
        self.xi = []
        self.theta = []
        self.dtheta_dxi = []

        # solution for the current solution point (y, z) -- initialized to
        # the boundary conditions
        self.q = np.array([1.0, 0.0], dtype=np.float64)

        # already integrate the solution
        if first_order:
            self._integrate_euler(h0, tol)
        else:
            self._integrate_rk4(h0, tol)

        # convert the data to a numpy array so we can more easily
        # manipulate it
        self.xi = np.asarray(self.xi)
        self.theta = np.asarray(self.theta)
        self.dtheta_dxi = np.asarray(self.dtheta_dxi)

    def store_solution(self, xi, y, z):
        """store the current solution point in the history"""
        self.xi.append(xi)
        self.theta.append(y)
        self.dtheta_dxi.append(z)

    def _estimate_h(self, hin, xi):
        """estimate the new step size to ensure that theta does not
        go negative"""

        # Our systems is always convex (theta'' < 0), so the
        # intersection of theta' with the x-axis will always be
        # a conservative estimate of the radius of the star.
        # Make sure that the stepsize does not take us past that.
        R_est = xi - self.q[0]/self.q[1]
        if xi + hin > R_est:
            return -self.q[0]/self.q[1]
        return hin

    def _integrate_euler(self, h0, tol):
        """integrate the Lane-Emden system using first-order Euler"""

        xi = 0.0
        h = h0

        while h > tol:
            self.q += h * self._rhs(xi, self.q)
            xi += h

            h = self._estimate_h(h, xi)

            # store the solution
            self.store_solution(xi, self.q[0], self.q[1])

    def _integrate_rk4(self, h0, tol):
        """integrate the Lane-Emden system"""

        xi = 0.0
        h = h0

        while h > tol:
            # 4th order RK integration -- first find the slopes
            k1 = self._rhs(xi, self.q)
            k2 = self._rhs(xi+0.5*h, self.q+0.5*h*k1)
            k3 = self._rhs(xi+0.5*h, self.q+0.5*h*k2)
            k4 = self._rhs(xi+h, self.q+h*k3)

            # now update the solution to the new xi
            self.q += (h/6.0)*(k1 + 2*k2 + 2*k3 + k4)
            xi += h

            h = self._estimate_h(h, xi)

            # store the solution:
            self.store_solution(xi, self.q[0], self.q[1])

    def _rhs(self, xi, q):
        """ the righthand side of the LE system, q' = f"""

        f = np.zeros_like(q)

        # y' = z
        f[0] = q[1]

        # for z', we need to use the expansion if we are at xi = 0,
        # to avoid dividing by 0
        if xi == 0.0:
            f[1] = (2.0/3.0) - q[0]**self.n
        else:
            f[1] = -2.0*q[1]/xi - q[0]**self.n

        return f

    def get_params(self):
        """ return the standard polytrope parameters xi_1,
        and [-xi**2 theta']_{xi_1} """
        xi1 = self.xi[-1]
        p2 = -xi1**2 * self.dtheta_dxi[-1]
        return xi1, p2

    def plot(self):
        """ plot the solution """
        plt.plot(self.xi, self.theta, label=r"$\theta$")
        plt.plot(self.xi, self.theta**self.n, label=r"$\rho/\rho_c$")
        plt.xlabel(r"$\xi$")
        plt.legend(frameon=False)
        plt.show()
```

We can plot any of the polytrope solutions

```python
p = Polytrope(1.5)
p.plot()
```

```python
p = Polytrope(4)
p.plot()
```

### properties with varying polytropic index
Here's a table of the important parameters

```python
for i, nindex in enumerate([0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5]):
    p = Polytrope(nindex)
    params = p.get_params()
    if i == 0:
        print(f"{'n':4} : {'ξ_1':^20} {'-ξ_1**2 dθ/ξ |_{ξ_1}':^20}")
    print(f"{nindex:4} : {params[0]:20.10g} {params[1]:20.10g}")
```

    n    :         ξ_1          -ξ_1**2 dθ/ξ |_{ξ_1}
       0 :                 2.45          4.902041667
     0.5 :          2.752694496          3.787683423
       1 :          3.141591734          3.141593131
     1.5 :          3.653752765          2.714056597
       2 :          4.352873728          2.411044249
     2.5 :          5.355275716          2.187197386
       3 :          6.896852479          2.018233566
     3.5 :          9.535820161           1.89055466
       4 :           14.9716009          1.797227648
     4.5 :           31.8367713          1.737797137

### accuracy

We can look at the accuracy by varying the step size, $h$, we use in the Runge-Kutta integration.

```python
for i, h in enumerate([1, 0.5, 0.25, 0.125]):
    p = Polytrope(1.5, h0=h, first_order=False)
    params = p.get_params()
    if i == 0:
        print(f"{'h0':6} : {'ξ_1':^20} {'-ξ_1**2 dθ/ξ |_{ξ_1}':^20}")
    print(f"{h:6} : {params[0]:20.10g} {params[1]:20.10g}")
```

    h0     :         ξ_1          -ξ_1**2 dθ/ξ |_{ξ_1}
         1 :          3.633871294          2.743950221
       0.5 :           3.65249039          2.714155735
      0.25 :          3.653696012          2.714041475
     0.125 :          3.653751185          2.714057167

we see that, for this value of n, once we have a stepsize $h = 0.25$, our solution changes only in the 3rd decimal place.  So we are pretty accurate then.

## Alternate approach: SciPy

Instead of writing our own integrator, we can use those built into SciPy.  We do need to be careful to ensure that we stop when $\theta$ reaches 0.  The SciPy `solve_ivp()` integrator uses Runge-Kutta by default.  Additionally, it has "events" that can be used to stop the integration when we reach the surface of the star.

```python
from scipy import integrate
```

We define the righthand side function now.  It is almost the same as I did above, except that since the built in solver we go past the surface of the star as it seaches for the actual surface, we need to protect against taking a negative number to a fractional power.  I do this here by adding an `abs()` in the `zdot` term.

```python
def le_rhs(xi, yvec, n):
    y, z = yvec
    ydot = z
    if xi == 0.0:
        zdot = (2.0/3.0) - y**n
    else:
        zdot = -2.0*z/xi - abs(y)**n
    return [ydot, zdot]

```

Next we define an event that the integrator will use to determine when to stop.  Our function surface just returns our variable $y = \theta$.  We add 2 attributes to it (this is a little bit of a clumsy way of doing things).  The first says that when the event is met, the integration should stop.  The second says that the condition we are looking for is a change in the sign of the return value.

```python
def surface(xi, y, n):
    return y[0]
surface.terminal = True
surface.direction = -1
```

Now we setup the integration.  By default it will do Runge-Kutta, but it will vary the step size as needed to get a reasonable solution.  We specify `dense_output=True` so we can look at the entire solution.  We also need to pass an extra argument to our `le_rhs()` routine -- the polytropic index `n`, which we do using the `args` keyword argument.

```python
n = 1.5

# initial conditions
y0 = [1, 0]

# maximum possible xi value -- we'll likely stop before this
ximax = 100

sol = integrate.solve_ivp(le_rhs, [0, ximax], y0,
                          dense_output=True, events=surface, args=(n,))
```

Now that it is integrated, we can see the points where it actually needed the solution as `sol.t`.  $\xi_1$ would be the last of these points.

```python
xi1 = sol.t[-1]
print(xi1)
```

    3.65782463879207

Now we fill in the solution to get the dense output.

```python
xi = np.linspace(0, xi1, 100)
y = sol.sol(xi)
```

We can then plot this polytrope

```python
plt.plot(xi, y[0,:])
```

```python

```


# Mutual Information Estimation Example

This repository implements some models and test-cases to estimate the mutual information and conditional mutual information, based on a set of samples from the joint distribution of the associated random variables (e.g. timepoints from some time-series data). The general idea, as discussed below, was around for quite some time, but the idea of actually using it in this way, and experiments comparing this approach with other approaches to estimate the (conditional) mutual information between high-dimensional random variables, were (to the best of my knowledge, please correct me if I missed some important work) first published by Mukherjee et al. (2019; https://arxiv.org/abs/1906.01824) and Ghimire et al. (2020, https://arxiv.org/abs/2002.11187).

The following deep-learning and sampling based approach to calculate the mutual information between samples of two high-dimensional variables rests on the following idea: the mutual information $MI(X,Y)$ of two variables $x$, $y$ is the Kullback-Leibler divergence between their joint probability density $p(x,y)$ and the product of their marginals $p(x)p(y)$:

$$MI(X,Y) = D_{KL}(p(x,y)||p(x)p(y))$$

The Kullback-Leibler divergence of two densities $p$, $q$ is defined as

$$D_{KL}(p(x)||q(x)) = \int p(x) \log \left(\frac{p(x)}{q(x)}\right)\mathrm{d}x$$

It is zero if, and only if, $p(x) = q(x)$ for all $x$.

Recent articles about the discriminator based learning of probability densities (https://arxiv.org/abs/1406.2661, https://arxiv.org/abs/1610.03483, https://arxiv.org/abs/1702.08235, https://arxiv.org/abs/1701.04722, https://arxiv.org/abs/1702.08896) realized and built upon a really cool fact:

We can train a deep neural network, $D$, to discriminate between samples $x_i$ from two distributions, $p(x)$ and $q(x)$, using the cross-entropy loss

$$ BCE(\{x_i, y_i\}) = - \sum_{i=1}^n y_i\log(\sigma(D(x_i))) + (1 - y_i)log(1-\sigma(D(x_i)))$$

where the labels $y_i = y(x_i) = 1$ if $x_i$ was drawn from $p(x)$, and $y_i = y(x_i) = 0$ if $x_i$ was drawn from $q(x)$.

If the training of this network converges, and if the network has enough capacity (in terms of numbers of layers and hidden units), $D(x)$ will converge to the log-likelihood ratio $D(x)\approx \log\frac{p(x)}{q(x)}$.

Thus, if the network training converged, we can use a large number of samples $x_i$ from $p(x)$ to calculate

$$\frac{1}{n}\sum_{i=1}^n D(x_i) \approx \int p(x) \log\frac{p(x)}{q(x)} \mathrm{d}x = D_{KL}(p(x)||q(x))$$.

To really understand this fact, which I'm just stating here, I can heartly recommend the paper Mescheder, Nowozin and Geiger (https://arxiv.org/abs/1701.04722), who nicely explain the underlying maths and give the relevant proofs. But please keep in mind that this and similar ideas were unsurprisingly also developed and discussed in many other great papers; e.g. https://arxiv.org/abs/1406.2661, https://arxiv.org/abs/1610.03483, https://arxiv.org/abs/1702.08235, https://arxiv.org/abs/1702.08896.

To use this fact to calculate the mutual information between samples from two high-dimensional random variables, $x$ and $y$, we implement a special case of this approach, where the discriminator learns to discriminate between samples from the joint distribution $p(x,y)$ and samples from the product of the marginal distributions $p(x)p(y)$. By evaluating and averaging the trained classifier, using samples from $p(x,y)$, we can approximates the KL-divergence between $p(x,y)$ and $p(x)p(y)$, $D_{KL}(p(x,y)||p(x)p(y))$, which is just the mutual information MI(X,Y) between the variables $x$ and $y$.

Further down, we built on this to implement a sampling based approximation of the conditional mutual information $CMI(X,Y|Z)$ of three random variables $x$, $y$, and $z$.

But let us first check out, how the estimation of the mutual information between two variables $x$ and $y$ works in practice using this approach.

All we need for this is a sufficient amount of samples of their *joint* distribution, $p(x,y)$. I.e., if $x$ and $y$ where sets of channels from time series data, we need a set of paired values of $x$ and $y$ at the *same time points*.

First create samples from one of the implemented example datasets, by running one of the two following cells.

```python
# Example dataset 1: Points on a disk-segment, where the correlation between x and y is zero,
# but the mutual information is not (at least in cartesian coordinates)

# Show the matplotlib plots within the jupyter notebook
%matplotlib inline

# Matplotlib for plotting and numpy for matrix manipulations and random numbers
import numpy as np
import matplotlib.pyplot as plt

# Number of samples (e.g. time points) in our synthetic dataset
n_samples = 100000

# Dimensions of x and y, in theory, they could be quite large. In practice, the sampling
# based approximation should work for tens of dimensions
dimX = 1
dimY = 1

# We are going to sample points (x,y) from a disk-segment of the radius range (0.9, 1.0) and
# all angles

r = np.random.rand(n_samples)*0.1 + 0.9
phi = np.random.rand(n_samples)*2*np.pi

# The important structure entering our mutual-information estimator is a single data matrix,
# where each row contains a single sample of the joint distribution p(x,y)

# Create empty data matrix
data = np.zeros((n_samples,2))

# First column of the data matrix will contain the x-values of the points
data[:,0] = np.cos(phi)*r
# Second column of the data matrix will contain the y-values of the points
data[:,1] = np.sin(phi)*r

# Plot the samples from the disk-segment
plt.figure()
plt.title('Example Dataset 1')
plt.scatter(data[:,0],data[:,1])
plt.show()

```

```python
# Example dataset 2: Multivariate Gaussian with non-diagonal Covariance Matrix
# In this special case, the Mutual Information between subsets of the Gaussian's dimensions
# can be explicitly calculated, in terms of the determinants of the corresponding sub-matrices
# of the covariance matrix

# show matplotlin plots directly in the jupyter notebook
%matplotlib inline

# matplotlib for plotting, numpy for matrix manipulation and random numbers,
# scipy for some little extra linear algebra
import matplotlib.pyplot as plt
import numpy as np
from scipy.stats import norm
import scipy

# number of samples (e.g. time points) in our synthetic dataset
n_samples = 100000

# dimensionality of random variable X
dimX = 3
#dimensionality of random variable Y
dimY = 5

# We are going to draw samples from a multi-variate normal distribution,
# with dimensionality n = dimX + dim Y.
# We use a zero mean and a random covariance matrix, whose off-diagonal elements
# will introduce dependencies between the first dimX dimensions,
# which we will use as random variable X, and the last dimY dimensions,
# which we will use as random variable Y.
# The nice thing is that in this case, there is a closed formula to directly
# calculate the mutual information between X and Y from the covariance matrix
# of this multi-variate normal distribution.

# dimensionality of multi-variate normal distribution
n = dimX + dimY

# random, but positive definite covariance matrix
L = np.random.randn(n,n)
cov = np.matmul(L, np.transpose(L))

# mean vector of zeros
mu = np.zeros(n)

# samples
data = np.random.multivariate_normal(mu,cov,n_samples)

# Exact expression for the mutual information as function of the determinant
# of the whole covariance matrix and the sub-matrices corresponding to random
# variables X and Y
MI_exact = 0.5*np.log(np.linalg.det(cov[:dimX,:dimX])*np.linalg.det(cov[-dimY:,-dimY:])/np.linalg.det(cov))

print('Exact mutual information for this instance of example dataset 2: %f\n' % MI_exact)

#### A SPECIAL CASE of the above is the MI as function of
#### the correlation between two one-dimensional Gaussian variables
#
# dim1 = 1
# dim2 = 1
#
# sigma = 0.1
# corr = 0.9
#
# x = np.random.multivariate_normal([0,0],[[sigma, sigma*corr],[sigma*corr, sigma]],100000)
#
# plt.scatter(x[:,0],x[:,1])
# plt.show()
#
# MI_exact = -0.5*np.log(1-corr**2)
#
# print('exact MI: %f' % MI_exact)
#
```

    Exact Mutual Information for this instance of example dataset 2: 4.149142

Now you can run two sampling based estimators of the mutual information, by running the following cell.

```python
# Use one of the cells above to create example data, then use this cell to run
# both versions of the sampling based approximation of mutual information
# (without RKHS regularisation, c.f. https://arxiv.org/abs/1906.01824;
# and with RKHS regularisation, c.f. https://arxiv.org/abs/2002.11187)

# We implemented two sampling based estimators, based on
# https://arxiv.org/abs/1906.01824 ("Vanilla")
# and https://arxiv.org/abs/2002.11187 (using RKHS-regularisation)
from sampling_based_mutual_information import mutual_information_rkhs, mutual_information

# Our main input is a single data matrix, where each row is a sample from the joint
# distribution p(x,y), i.e. one time point of a simultaneous recording of x and y.
# So compare this shape with the dimensionalities dimX and dimY of the datasets created above.
print('Shape of input data matrix:')
print(data.shape)

# Parameters of the deep-learning based discriminator network and the training procedure

# Learning rate: If the loss-functions oscillate too much, reduce it.
# Reducing the learning rate will require more training iterations.
learning_rate = 1e-3
# Number of neurons in each of the 2 hidden layers of the discriminator networks
n_hidden = 100
# Number of samples per mini-batch, which is used in the gradient-based training
# algorithm
batch_size = 1000
# Number of training iterations
n_iter = 5000
# Clip large gradients during training. This will increase stability, but also slow down
# training
clip_grad = False
# Norm, to which the gradient estimates will be clipped, if clip_grad = True
clip_norm = 0.1
# If log_mi_every > 0, every log_mi_every iteration the current estimate of the
# mutual information will be evaluated and stored. Using this is highly recommended
# to examine the convergence of the training procedure.
log_mi_every = 100
# Number of samples from p(x,y), which will be used for the running estimate of
# the mutual information
log_mi_samples = 10000

# mutual_information_rkhs implements an additional penalty on the complexity of
# the discriminator, which aims to stabilise the training and the resulting
# mutual information estimates (for details please consult: https://arxiv.org/abs/2002.11187)
# Effectively, and additional penalty is added to the categorisation loss
# (i.e. the binary cross entropy loss of a classifier, discriminating between
# samples from p(x,y) and samples from p(x)p(y).
# The additional regularisation cost is weighted by the following parameter.
penalty_rkhs = 1e-4

# Create the estimator using the RKHS-based complexity regularisation
RMI = mutual_information_rkhs(dimX, dimY, data,
                              learning_rate = learning_rate, n_hidden = n_hidden, penalty = penalty_rkhs,
                              clip_grad = clip_grad, clip_norm = clip_norm,
                              log_mi_every = log_mi_every, log_mi_samples = log_mi_samples)

# Start training
print('Training Regularized Discriminator')
RMI.train(n_iter = n_iter, batch_size = batch_size)

# Create the "Vanilla" estimator
MI = mutual_information(dimX, dimY, data,
                        learning_rate = learning_rate, n_hidden = n_hidden,
                        clip_grad = clip_grad, clip_norm = clip_norm,
                        log_mi_every = log_mi_every, log_mi_samples = log_mi_samples)

# Start training
print('Training Vanilla Discriminator')
MI.train(n_iter = n_iter, batch_size = batch_size)

# Calculate the mutual information estimates, using the learned discriminator networks
print('Regularized Approximation: %f\n' % RMI.approximate())
print('Vanilla Approximation: %f\n' % MI.approximate())
```

    Shape of input data matrix:
    (100000, 8)
    Training Regularized Discriminator

    HBox(children=(FloatProgress(value=0.0, max=5000.0), HTML(value='')))

    Training Vanilla Discriminator

    HBox(children=(FloatProgress(value=0.0, max=5000.0), HTML(value='')))

    Regularized Approximation: 5.073546

    Vanilla Approximation: 4.262749

It is really important to always plot the learning curves of the discriminators. This allows you to have a look at the evolution of the loss function and the learned mutual information estimates during training, which in turn allows you to spot failures of the discriminator to converge.

```python
# Plot the curves of the training losses.
# THIS IS AN IMPORTANT STEP TO DIAGNOSE CONVERGENCE, WHICH ALWAYS SHOULD BE DONE,
# WHENEVER YOU ARE USING DEEP LEARNING OR SAMPLING BASED APPROXIMATIONS
# Remember: Here you're using *both*.

plt.figure()
plt.plot(MI.losses)
plt.plot(RMI.losses)
plt.title('Loss')
plt.legend(['Vanilla','RKHS'])
plt.xlabel('training iteration')
plt.ylabel('loss')
plt.show()

plt.figure()
plt.plot(MI.approximations)
plt.plot(RMI.approximations)
plt.title('MI estimate')
plt.legend(['Vanilla','RKHS'])
plt.xlabel('training iteration/log_mi_every')
plt.ylabel('loss')
plt.show()
```

# Conditional Mutual Information Estimation Example

Here we show how to use a sampling-based approximation of the conditional mutual information, as proposed by Mukherjee et al. (2019; https://arxiv.org/abs/1906.01824).

The basic idea is the following: We can calculate the conditional mutual information of $x$ and $y$, given a third random variable $z$, $CMI(X,Y|Z)$, via the differences:

$$CMI(X,Y|Z) = MI(X,YZ) - MI(X,Z) = MI(Y,XZ) - MI(Y,Z)$$

Due to the symmetry of the conditional mutual information in $x$ and $y$, and the symmetry of the mutual information in the two arguments, both differences should in principle give the same answer and work equally well.

*In practice*, however, the discriminator networks, which are learned to approximate the individual mutual informations, might differ substantially in their convergence properties. Thus, I'd recommed using the "both_dir = True" option in the constructor of the conditional_mutual_information class. This way, all four mutual informations of the equation above will be estimated and both ways of partitioning the conditional mutual information into a difference of two mutual informations will be used. They *should* give the same answer, if all four networks - $MI(X,YZ)$, $MI(X,Z)$, $MI(Y,XZ)$, and $MI(Y,Z)$ - converged.

First create samples from an example distribution p(x,y,z), following Mukherjee et al. (2019; https://arxiv.org/abs/1906.01824)

```python
%matplotlib inline

import matplotlib.pyplot as plt
import numpy as np
from scipy.stats import norm

# Demo model, also used in Mukherjee et al. (2019; https://arxiv.org/abs/1906.01824)
# The ground truth value of 2.3 was calculated using numerical integration by the authors
# of this paper (c.f. Fig. 2a).

# We want to compute MI(X,Y|Z) later.

n_samples = 1000000

dimZ = 20

# X is drawn from a one-dimensional standard normal distribution
x = np.random.randn(n_samples).reshape(n_samples,1)

# Z is drawn from a dimZ-dimensional uniform distribution on [-0.5,0.5]^dimZ
z = np.random.rand(n_samples, dimZ) - 0.5

sigma_epsilon = 0.1

# Epsilon is drawn from a one-dimensional normal distribution with mean z_1 and standard
# deviation 0.1
epsilon = (np.random.randn(n_samples)*sigma_epsilon + z[:,0]).reshape(n_samples,1)

# Y is the sum of x and epsilon
y = x + epsilon

plt.figure()
plt.title('Y vs X')
plt.scatter(x,y)
plt.xlabel('x')
plt.ylabel('y')
plt.show()
```

Now train a sampling based extimator of the conditional mutual information CMI(X,Y|Z), whose true value was estimated by Mukherjee et al. (2019; https://arxiv.org/abs/1906.01824) to about 2.3.

```python
from sampling_based_mutual_information import conditional_mutual_information

data = np.concatenate((x,y,z),axis = 1)

# Learning rate: If the loss-functions oscillate too much, reduce it.
# Reducing the learning rate will require more training iterations.
learning_rate = 1e-3
# Number of neurons in each of the 2 hidden layers of the discriminator networks
n_hidden = 100
# Number of samples per mini-batch, which is used in the gradient-based training
# algorithm
batch_size = 1000
# Number of training iterations
n_iter = 5000
# Clip large gradients during training. This will increase stability, but also slow down
# training
clip_grad = False
# Norm, to which the gradient estimates will be clipped, if clip_grad = True
clip_norm = 0.1
# If log_mi_every > 0, every log_mi_every iteration the current estimate of the
# mutual information will be evaluated and stored. Using this is highly recommended
# to examine the convergence of the training procedure.
log_mi_every = 100
# Number of samples from p(x,y), which will be used for the running estimate of
# the mutual information
log_mi_samples = 10000

# mutual_information_rkhs implements an additional penalty on the complexity of
# the discriminator, which aims to stabilise the training and the resulting
# mutual information estimates (for details please consult: https://arxiv.org/abs/2002.11187)
# Effectively, and additional penalty is added to the categorisation loss
# (i.e. the binary cross entropy loss of a classifier, discriminating between
# samples from p(x,y) and samples from p(x)p(y).
# The additional regularisation cost is weighted by the following parameter.
penalty_rkhs = 1e-3

CMI = conditional_mutual_information(1, 1, dimZ, data,
                                     learning_rate = learning_rate, n_hidden = n_hidden,
                                     clip_grad = clip_grad, clip_norm = clip_norm,
                                     use_rkhs = use_rkhs, rkhs_penalty = rkhs_penalty,
                                     both_dir = both_dir,
                                     log_mi_every = log_mi_every, log_mi_samples = log_mi_samples)

CMI.train(n_iter = n_iter, batch_size = batch_size)

print('Sampling based approximation or approximations (both_dir = True: both equivalent decompositions of conditional mutual information as difference of mutual informations):')
print(CMI.approximate())

```

Plotting the training curves is absolutely essential, to check if the discriminator training really converged. Just by examining the final conditional mutual information estimates, it is not possible to spot convergence failure.

```python
%matplotlib inline

plt.figure()
plt.plot(CMI.MI_X_YZ.losses)
plt.plot(CMI.MI_X_Z.losses)
plt.title('forward loss')
plt.legend(['MI(X,YZ)','MI(X,Z)'])
plt.xlabel('training iteration')
plt.ylabel('loss')
plt.show()

plt.figure()
plt.plot(CMI.MI_Y_XZ.losses)
plt.plot(CMI.MI_Y_Z.losses)
plt.title('backward loss')
plt.legend(['MI(Y,XZ)','MI(Y,Z)'])
plt.xlabel('training iteration')
plt.ylabel('loss')
plt.show()

plt.figure()
plt.plot(CMI.MI_X_YZ.approximations)
plt.plot(CMI.MI_X_Z.approximations)
plt.plot(np.asarray(CMI.MI_X_YZ.approximations) - np.asarray(CMI.MI_X_Z.approximations))
plt.title('forward')
plt.legend(['MI(X,YZ)','MI(X,Z)','CMI(X,Y|Z)'])
plt.xlabel('training iteration/log_mi_every')
plt.ylabel('loss')
plt.show()

plt.figure()
plt.plot(CMI.MI_Y_XZ.approximations)
plt.plot(CMI.MI_Y_Z.approximations)
plt.plot(np.asarray(CMI.MI_Y_XZ.approximations) - np.asarray(CMI.MI_Y_Z.approximations))
plt.title('backward')
plt.legend(['MI(Y,XZ)','MI(Y,Z)','CMI(X,Y|Z)'])
plt.xlabel('training iteration/log_mi_every')
plt.ylabel('loss')
plt.show()
```

# Some intuition and connections w.r.t. the Binary Cross-Entropy loss

## Binary cross entropy as log-likelihood of a Bernoulli model

Let's say we have a dataset consisting of high-dimensional datapoints $x_i \in \mathbb{R}^d$, which were drawn from two different distributions $p(x|y)$, where $y\in \{ 0,1 \}$.

Let us describe the true, underlying data distribution $p(x,y) = p(y|x)p(x)$ in terms of the a priori probabiliy of a sample having a specific value of $p(x)$ and the conditional probability on the class labels $p(y|x)$.

We want to learn a conditional Bernoulli distribution, using a deep neural network $D$, to give us an estimate of the conditional probability $p_\mathrm{est}(y = 1|x_i) = \hat{y}_i = \sigma(D(x_i))$ for a given datapoint $x_i$, where $\sigma(x) = \frac{1}{1 + e^{-x}}$ is the sigmoid function.

Assuming i.i.d samples from the data distribution, we can write down the likelihood of a sample of datapoints $(x_i, y_i), i=1, ..., n$ under this model:

$$L(\{(x_i, y_i)\}) = \prod_{i=1}^n p(y_i | x_i)$$

Where the likelihood of a single datapoint $L(x_i, y_i)$ is given by $L(x_i, y_i) = p_\mathrm{est}(y=y_i|x_i) = \begin{cases}
\sigma(D(x)), \text{if } y = 1\\
1 - \sigma(D(x)), \text{if } y = 0
\end{cases}$

Thus, we see that the binary cross entropy loss just corresponds to the log-likelihood of the data under the model $p_\mathrm{est}(y|x)$:
$$ BCE(\{x_i, y_i\} = \sum_{i=1}^n \log L(x_i, y_i) = \log L(\{(x_i, y_i)\})$$

## Binary cross entropy as Kullback-Leibler divergence between true posterior on class labels and modeled Bernoulli distribution

Just to give a short intuition, w.r.t. the binary cross entropy loss above: You could also see it as the average KL-divergence of the learned and the true categorical distribution of labels $y$, given a data point $x$. Where
Continueing with the definitions above, we can interpret the true class labels $y(x)$ as defining the true posterior distribution $p(y|x)$, via:

$$y(x) = p(y=1|x) = \begin{cases}
                    1, \text{if } x ~ p(x)\\
                    0, \text{if } x ~ q(x)
                    \end{cases}$$

Remember, that we want to use our deep neural network to model a conditional Bernouilli distribution on the class labels, via $p_\mathrm{est}(y=1|x) = \hat{y} = \sigma(D(x))$.

Thus, we can write

$$
\begin{align}
D_\mathrm{KL}(p(x,y)||p_\mathrm{est}(x,y)) & = & \int_x \left(  p(y=1|x)p(x)\log\left(\frac{p(y=1|x)p(x)}{p_\mathrm{est}(y=1|x)p(x)}\right) + p(y=0|x)p(x)\log\left(\frac{p(y=0|x)p(x)}{p_\mathrm{est}(y=0|x)p(x)}\right) \right) \mathrm{d}x\\
& = & \int_x \left(  p(y=1|x)\log\left(\frac{p(y=1|x)}{p_\mathrm{est}(y=1|x)}\right) + p(y=0|x)\log\left(\frac{p(y=0|x)}{p_\mathrm{est}(y=0|x)}\right) \right)p(x) \mathrm{d}x\\
& \approx & \sum_{x_i}\left[ p(y=1|x_i)\log\left(\frac{p(y=1|x_i)}{p_\mathrm{est}(y=1|x_i)}\right) + p(y=0|x_i)\log\left(\frac{p(y=0|x_i)}{p_\mathrm{est}(y=0|x_i)}\right) \right]\\
& = & - \sum_{x_i}\left[ y_i \log\left(p_\mathrm{est}(y=1|x_i)\right) + (1-y_i)\log\left(p_\mathrm{est}(y=0|x_i)\right) + \underbrace{H(p(y|x_i))}_{=0}\right]\\
& = & - \sum_{x_i}\left[ y_i \log\left(\sigma(D(x_i))\right) + (1-y_i)\log\left(1-\sigma(D(x_i)\right)\right]\\
& = & BCE(\{x_i, y_i\}
\end{align}
$$
where $y_i = y(x_i) = p(y_i = 1|x_i)$ are the true labels, i.e. $p(y_i = 0 | x_i) = 1 - y_i$, and the model gives $p_\mathrm{est}(y=1|x_i) = \hat{y}_i = \sigma(D(x_i))$ and $p_\mathrm{est}(y=0|x_i) = 1 - \hat{y}_i = 1 - \sigma(D(x_i))$ .

Thus, assuming i.i.d samples from the data distribution, we can also write
$$ BCE(\{x_i, y_i\} = \sum_{i=1}^n \log L(x_i, y_i)$$
I.e. the binary cross entropy can also be seen as the log-likelihood of the data $\{x_i, y_i\}$ under the learned model distribution $p_\mathrm{est}(y|x)$.

```python

```


# Conditional non-linear systems of equations
Sometimes when performing modelling work in physical sciences we use different sets of equations to describe our system depending on conditions. Sometimes it is not known beforehand which of those formulations that will be applicable (only after having solved the system of equations can we reject or accept the answer). ``pyneqsys`` provides facilities to handle precisely this situation.

```python
from __future__ import (absolute_import, division, print_function)
from functools import reduce
from operator import mul
import sympy as sp
import numpy as np
import matplotlib.pyplot as plt
from pyneqsys.symbolic import SymbolicSys, linear_exprs
sp.init_printing()
```

Let's consider precipitation/dissolution of NaCl:
$$
\rm NaCl(s) \rightleftharpoons Na^+(aq) + Cl^-(aq)
$$

```python
init_concs = iNa_p, iCl_m, iNaCl = [sp.Symbol('i_'+str(i), real=True, negative=False) for i in range(3)]
c = Na_p, Cl_m, NaCl = [sp.Symbol('c_'+str(i), real=True, negative=False) for i in range(3)]
prod = lambda x: reduce(mul, x)
texnames = [r'\mathrm{%s}' % k for k in 'Na^+ Cl^- NaCl'.split()]
```

if the solution is saturated, then the [solubility product](https://en.wikipedia.org/wiki/Solubility_equilibrium) will be constant:

$$
K_{\rm sp} = \mathrm{[Na^+][Cl^-]}
$$

in addition to this (conditial realtion) we can write equations for the preservation of atoms and charge:

```python
stoichs = [[1, 1, -1]]
Na = [1, 0, 1]
Cl = [0, 1, 1]
charge = [1, -1, 0]
preserv = [Na, Cl, charge]
eq_constants = [Ksp] = [sp.Symbol('K_{sp}', real=True, positive=True)]

def get_f(x, params, saturated):
    init_concs = params[:3] if saturated else params[:2]
    eq_constants = params[3:]
    le = linear_exprs(preserv, x, linear_exprs(preserv, init_concs), rref=True)
    return le + ([Na_p*Cl_m - Ksp] if saturated else [NaCl])
```

Our two sets of reactions are then:

```python
get_f(c, init_concs + eq_constants, False)
```

```python
f_true = get_f(c, init_concs + eq_constants, True)
f_false = get_f(c, init_concs + eq_constants, False)
f_true, f_false
```

We have one condition (a boolean describing whether the solution is saturated or not). We provide two conditionals, one for going from non-saturated to saturated (forward) and one going from saturated to non-saturated (backward):

```python
from pyneqsys.core import ConditionalNeqSys
cneqsys = ConditionalNeqSys(
    [
        (lambda x, p: (x[0] + x[2]) * (x[1] + x[2]) > p[3],  # forward condition
         lambda x, p: x[2] >= 0)                             # backward condition
    ],
    lambda conds: SymbolicSys(
        c, f_true if conds[0] else f_false, init_concs+eq_constants
    ),
    latex_names=['[%s]' % n for n in texnames], latex_param_names=['[%s]_0' % n for n in texnames]
)
```

```python
c0, K = [0.5, 0.5, 0], [1]  # Ksp for NaCl(aq) isn't 1 in reality, but used here for illustration
params = c0 + K
```

Solving for inital concentrations below the solubility product:

```python
cneqsys.solve([0.5, 0.5, 0], params)
```

no surprises there (it is of course trivial).

In order to illustrate its usefulness, let us consider addition of a more soluable sodium salt (e.g. NaOH) to a chloride rich solution (e.g. HCl):

```python
%matplotlib inline
ax_out = plt.subplot(1, 2, 1)
ax_err = plt.subplot(1, 2, 2)
xres, sols = cneqsys.solve_and_plot_series(
    c0, params, np.linspace(0, 3), 0, 'kinsol',
    {'ax': ax_out}, {'ax': ax_err}, fnormtol=1e-14)
_ = ax_out.legend()
```

note the (expected) discontinuity at $\mathrm{[Na^+]_0 = 2}$ at which point the solution became saturated


# 微分方程式の計算について　　　N0.5　－1　直接積分、変数分離形

###  学籍番号[＿＿＿＿＿＿＿＿＿]クラス[＿＿＿＿＿]　クラス番号[＿＿＿＿＿] 名前[＿＿＿＿＿＿＿＿＿＿＿＿＿＿＿]

###########   1階常微分方程式　直接積分形

$$ \frac{dy}{dx}=f(x) $$
$$ y = \int f(x)dx = F(X)+C  $$    f(x) の原始関数を F(x) とする ,C は積分定数

##########   1階微分方程式　変数分離形

$$ \frac{dy}{dx}=f(x)g(y) $$
左をｙのみ、右をｘのみに変形して分離する   両辺をそれぞれ不定積分する
$$ \int \frac{1}{g(y)}dy = \int f(x)dx  $$    この積分からｙの解を求める

```python
from sympy import *
x, n , a ,C1, C2 ,C3 = symbols('x n a C1 C2 C3')
f,g,y = symbols('f g y' ,cls=Function)
init_printing()
m ='5///1'
i =0
```

```python
diffeq = Eq(y(x).diff(x, 1) ,  3*y(x))
i=i+1
print( 'No.',m,'---',i)
diffeq
```

```python
dsolve(diffeq, y(x))
```

```python
diffeq = Eq(y(x).diff(x, 1) , y(x)/(x*(x+2)))
i=i+1
print( 'No.',m,'---',i)
diffeq
```

```python
dsolve(diffeq, y(x))
```

```python
diffeq = Eq(y(x).diff(x, 1) , 2*y(x)*x)
i=i+1
print( 'No.',m,'---',i)
diffeq
```

```python
dsolve(diffeq, y(x))
```

```python
diffeq = Eq(y(x).diff(x, 1) , y(x)**2/x**2)
i=i+1
print( 'No.',m,'---',i)
diffeq
```

```python
dsolve(diffeq, y(x))
```

```python
diffeq = Eq(y(x).diff(x, 1) ,2*y(x)/(x+2))
i=i+1
print( 'No.',m,'---',i)
diffeq
```

```python
simplify(dsolve(diffeq, y(x)))
```

```python
diffeq = Eq(y(x).diff(x, 1) ,1+3*y(x)/x)
i=i+1
print( 'No.',m,'---',i)
diffeq
```

```python
simplify(dsolve(diffeq, y(x)))
```

```python
diffeq = Eq(y(x).diff(x, 1)+2*y(x) ,0)
i=i+1
print( 'No.',m,'---',i)
diffeq
```

```python
reseq = dsolve(diffeq, y(x))
reseq
```

```python
f0 = Eq(reseq.rhs.subs(x,2),6)
c = solve(f0)
c
```

```python
reseq01 = reseq.subs(C1, c[0])
reseq01
```

```python
diffeq = Eq(y(x).diff(x, 1)+2*y(x)*x ,0)
i=i+1
print( 'No.',m,'---',i)
diffeq
```

```python
reseq = dsolve(diffeq, y(x))
reseq
```

```python
f0 = Eq(reseq.rhs.subs(x,0),1)
c = solve(f0)
c
```

```python
reseq01 = reseq.subs(C1, c[0])
reseq01
```

```python

```


Polynomial Fitting

### This demo shows how an n-th order polynomial can be used to generate a smooth trajectory using initial and final system constraints over given period of time.

```python
import sympy as sp
import numpy as np
from matplotlib import pyplot as plt
from IPython.display import display
```

```python
t0, t, tf = sp.symbols('t_0, t, t_f')
a0, a1, a2, a3, a4, a5 = sp.symbols('a_0, a_1, a_2, a_3, a_4, a_5')
q0, v0, qf, vf, alpha0, alphaf = sp.symbols('q_0, v_0, q_f, v_f, \\alpha_0, \\alpha_f')
```

### Here we choose 6th order (degree 5) polynomial to define 6 constraints. In which first 3 are over initial position, velocity and acceleration and later 3 are over final position, velocity and acceleration

```python
q0 = a0 + a1*t0 + a2*t0**2 + a3*t0**3 + a4*t0**4 + a5*t0**5
v0 = sp.diff(q0, t0)
alpha0 = sp.diff(v0, t0,)
qf = a0 + a1*tf + a2*tf**2 + a3*tf**3 + a4*tf**4 + a5*tf**5
vf = sp.diff(qf, tf)
alphaf = sp.diff(vf, tf)
```

### Using above equations we create a matrix and multiply it's inverse with the constraint vector to get all the coefficient values at given time.

#### We define constraints such that
- Initial position of vehicle is 2
- Initially the vehicle is at stand still i.e velocity and acceleration is 0
- Final position of vehicle should be 10
- And at that position vehicle should stop i.e. final velocity and acceleration should be 0

```python
# Constraints
q0 = 2
v0 = 0
alpha0 = 0
qf = 10
vf = 0
alphaf = 0

M = sp.Matrix([[1, t0, t0**2,   t0**3,    t0**4,    t0**5],
               [0,  1,  2*t0, 3*t0**2,  4*t0**3,  5*t0**4],
               [0,  0,     2,    6*t0, 12*t0**2, 20*t0**3],
               [1, tf, tf**2,   tf**3,    tf**4,    tf**5],
               [0,  1,  2*tf, 3*tf**2,  4*tf**3,  5*tf**4],
               [0,  0,     2,    6*tf, 12*tf**2, 20*tf**3]])
b = sp.Matrix([[q0], [v0], [alpha0], [qf], [vf], [alphaf]])

a = M.inv() @ b
```

```python
q     =   a[0] +   a[1]*t +    a[2]*t**2 +    a[3]*t**3 +   a[4]*t**4 + a[5]*t**5
v     =   a[1] + 2*a[2]*t +  3*a[3]*t**2 +  4*a[4]*t**3 + 5*a[5]*t**4
alpha = 2*a[2] + 6*a[3]*t + 12*a[4]*t**2 + 20*a[5]*t**3
```

```python
# Time limits
startTime = 0
stopTime = 1

qd = list()
vd = list()
alphad = list()
i_d = np.linspace(0, 1, 50)
for i in i_d:
    qd.append(q.subs([(t0, startTime), (t, i), (tf, stopTime)]))
    vd.append(v.subs([(t0, startTime), (t, i), (tf, stopTime)]))
    alphad.append(alpha.subs([(t0, startTime), (t, i), (tf, stopTime)]))

fig, axs = plt.subplots(1, 3, figsize=(15, 4))
axs[0].set(title="Position")
axs[0].plot(i_d[:], qd[:], 'b', lw=2)

axs[1].set(title="Velocity")
axs[1].plot(i_d[:], vd[:], 'b', lw=2)

axs[2].set(title="Acceleration")
axs[2].plot(i_d[:], alphad[:], 'b', lw=2)
plt.show()
```


```python
import sys, os
sys.path.insert(0, os.path.join(os.pardir, 'src'))
import sympy as sym
from approx1D import least_squares, comparison_plot
from math import pi
import matplotlib.pyplot as plt

x = sym.Symbol('x')
f = sym.sin(x)
N = 7
psi_bases = [[x**i for i in range(1, N+1, 2)],  # V_1
             [x**i for i in range(0, N+1)]]     # V_2
symbolic = False

for V, psi in enumerate(psi_bases):
    for domain_no in range(1, 3):
        for k in range(2, 6):
            if symbolic:
                Omega = [0, k*sym.pi] if domain_no == 1 else \
                        [-k*sym.pi/2, k*sym.pi/2]
            else:
                # cannot use sym.pi with numerical sympy computing
                Omega = [0, k*pi] if domain_no == 1 else \
                        [-k*pi/2, k*pi/2]

            u, c = least_squares(f, psi, Omega, symbolic=symbolic)

            comparison_plot(
                f, u, Omega,
                ymin=-2, ymax=2,
                filename='tmp_N%d_V%dOmega%dk%d' %
                (N, V, k, domain_no),
                plot_title='sin(x) on [0,%d*pi/2] by %s' %
                (k, ','.join([str(p) for p in psi])))
            # Need to kill the plot to proceed!
        for ext in 'png', 'pdf':
            cmd = 'doconce combine_images -2 ' + \
                  ' '.join(['tmp_N%d_V%dOmega%dk%d.' %
                            (N, V, k, domain_no) + ext
                            for k in range(2, 6)]) + \
                  ' sin_powers_N%d_V%d_Omega%d.' % (N, V, domain_no) + ext
            print(cmd)
            os.system(cmd)

# Show the standard Taylor series approximation
from math import factorial, pi
import time
Omega = [0, 12*pi/2.]
u = 0
for k in range(0,N+1):
    u = u + ((-1)**k*x**(1+2*k))/float(factorial(1+2*k))
# Shorter: u = sum(((-1)**k*x**(1+2*k))/float(factorial(1+2*k))
# for k in range(0,10))
comparison_plot(f, u, Omega, 'sin_taylor%d' % k,
                ymin=-1.5, ymax=1.5)

```

```python

```

Data Science and Machine Learning/Machine-Learning-In-Python-THOROUGH/EXAMPLES/FINITE_ELEMENTS/INTRO/EXERCICES/21_SIN_POWERS.ipynb


```python
# Required to load webpages
from IPython.display import IFrame
```

[Table of contents](../toc.ipynb)

# SymPy

* SymPy is a symbolic mathematics library for Python.
* It is a very powerful computer algebra system, which is easy to include in your Python scripts.
* Please find the documentation and a tutorial here [https://www.sympy.org/en/index.html](https://www.sympy.org/en/index.html)

## SymPy live

There is a very nice SymPy live shell in [https://live.sympy.org/](https://live.sympy.org/), where you can try SymPy without installation.

```python
IFrame(src='https://live.sympy.org/', width=1000, height=600)
```

## SymPy import and basics

```python
import sympy as sp

%matplotlib inline
```

Symbols can be defined with `sympy.symbols` like:

```python
x, y, t = sp.symbols('x, y, t')
```

These symbols and later equations are rendered with LaTeX, which makes pretty prints.

```python
display(x)
```

$\displaystyle x$

Expressions can be easily defined, and equations with left and right hand side are defined with `sympy.Eq` function.

```python
expr = x**2
expr
```

$\displaystyle x^{2}$

```python
eq = sp.Eq(3*x, -10)
eq
```

$\displaystyle 3 x = -10$

Plots are done with `sympy.plot` and the value range can be adjusted.

```python
sp.plot(expr, (x, -5, 5))
```

## Why should you consider symbolic math at all?

The power of symbolic computation is their precision. Just compare these two results.

```python
import math
math.sqrt(8)
```

    2.8284271247461903

```python
sp.sqrt(8)
```

$\displaystyle 2 \sqrt{2}$

You can simplify expressions and equations and also expand them.

```python
eq = sp.sin(x)**2 + sp.cos(x)**2
eq
```

$\displaystyle \sin^{2}{\left(x \right)} + \cos^{2}{\left(x \right)}$

```python
sp.simplify(eq)
```

$\displaystyle 1$

```python
eq = x*(x + y)
eq
```

$\displaystyle x \left(x + y\right)$

```python
sp.expand(eq)
```

$\displaystyle x^{2} + x y$

```python
sp.factor(eq)
```

$\displaystyle x \left(x + y\right)$

Differentiation and integration are built in of course.

```python
eq = sp.sin(x) * sp.exp(x)
eq
```

$\displaystyle e^{x} \sin{\left(x \right)}$

```python
sp.diff(eq, x)
```

$\displaystyle e^{x} \sin{\left(x \right)} + e^{x} \cos{\left(x \right)}$

```python
sp.integrate(eq, x)
```

$\displaystyle \frac{e^{x} \sin{\left(x \right)}}{2} - \frac{e^{x} \cos{\left(x \right)}}{2}$

Or define an explicit interval for the integration.

```python
sp.integrate(eq, (x, -10, 10))
```

$\displaystyle \frac{e^{10} \sin{\left(10 \right)}}{2} + \frac{\cos{\left(10 \right)}}{2 e^{10}} + \frac{\sin{\left(10 \right)}}{2 e^{10}} - \frac{e^{10} \cos{\left(10 \right)}}{2}$

We can also easily substitute one variable of an expression.

```python
eq.subs(x, 2)
```

$\displaystyle e^{2} \sin{\left(2 \right)}$

Solve one equation. $x^2 + 3 x = 10$.

```python
sp.solve(x**2 + 3*x - 10, x)
```

    [-5, 2]

## More advanced topics

Here, we will solve a linear system of equations.

```python
e1 = sp.Eq(3*x + 4*y, -20)
e2 = sp.Eq(4*y, -3)

system_of_eq = [e1, e2]

from sympy.solvers.solveset import linsolve

linsolve(system_of_eq, (x, y))
```

$\displaystyle \left\{\left( - \frac{17}{3}, \  - \frac{3}{4}\right)\right\}$

Also differential equations can be used. Let us solve $y'' - y = e^t$ for instance.

```python
y = sp.Function('y')
sp.dsolve(sp.Eq(y(t).diff(t, t) - y(t), sp.exp(t)), y(t))
```

$\displaystyle y{\left(t \right)} = C_{2} e^{- t} + \left(C_{1} + \frac{t}{2}\right) e^{t}$

Finally, we will have a short look at matrices.

```python
A = sp.Matrix([[0, 1],
               [1, 0]])
A
```

$\displaystyle \left[\begin{matrix}0 & 1\\1 & 0\end{matrix}\right]$

```python
A = sp.eye(3)
A
```

$\displaystyle \left[\begin{matrix}1 & 0 & 0\\0 & 1 & 0\\0 & 0 & 1\end{matrix}\right]$

```python
A = sp.zeros(2, 3)
A
```

$\displaystyle \left[\begin{matrix}0 & 0 & 0\\0 & 0 & 0\end{matrix}\right]$

Inversion of a matrix is done with `**-1` or better readable with `.inv()`.

```python
A = sp.eye(2) * 4
A.inv()
```

$\displaystyle \left[\begin{matrix}\frac{1}{4} & 0\\0 & \frac{1}{4}\end{matrix}\right]$

```python
A[-2] = x
A
```

$\displaystyle \left[\begin{matrix}4 & 0\\x & 4\end{matrix}\right]$

## To sum up

* SymPy is a very powerful computer algebra package!
* It is light, small, and easy to install through pip and conda.
* Simple to integrate in your Python project.


# Clase 3

## Más funciones

Aunque en principio pareciera que las funciones solo son una herramienta para ahorrarnos reescribir código que vayamos a utilizar varias veces, en realidad en Julia tienen una función más importante.

En Julia, aunque es un lenguaje interpretado, **todas las funciones pasan por un compilador que, entre otras cosas, las optimiza**. Así, al parecer Julia no es solamente interpretado, también es un lenguaje compilado de alguna manera.

Ya que las funciones se compilan, y por lo tanto se optimizan, eso quiere decir que lo mejor para hacer código en Julia **es meter todo el código en funciones y solo ejecutar dichas funciones**. En particular, la mayoría del código que les voy a pedir y que deben de entrar va a estar compuesto puramente de funciones.

### Ejemplo de función: implementar la fórmula de Herón para el área de un triángulo

Si tenemos un triangulo con lados de longitud $a,b,c$, entonces podemos calcular su área con la siguiente ecuación:

\begin{equation}
    Area = \sqrt{s(s-a)(s-b)(s-c)}
\end{equation}

Con $s = \frac{1}{2}(a+b+c)$

Vamos a hacer una función que implemente dicha fórmula

```julia
function areaTriangulo(a,b,c)
    # calcular el valor de s
    s = (1/2)*(a + b + c)
    # calcularmos el area
    area = sqrt(s*(s-a)*(s-b)*(s-c))
    # lo regresamos
    return area
end
```

    areaTriangulo (generic function with 1 method)

Para un triangulo rectangulo inscrito en un cuadrado de lado 1, sabemos que su area es 1/2. Hay que probar la función `areaTriangulo` con este resultado. Para ese triangulo, sabemos $a=1$, $b=1$ y $c = \sqrt{2}$

```julia
areaTriangulo(1,1,sqrt(2))
```

    0.49999999999999983

Todo parece estar correcto!

## Enunciados más interesantes: estructuras If

Para hacer código más interesante, nos gustaría tener una estructura que nos permitiera ejecutar distinto código dependiendo de si una **expresión booleana** sea verdadera o no

Dicha estructura existe y se llama una **Estructura If**. hagamos un ejemplo muy sencillo: hagamos una función que nos calcule la función $f(x) = |x|$. Recordando la definición:

\begin{equation}
|x| = \begin{cases}
x & x \geq 0 \\
 -x & x< 0
\end{cases}
\end{equation}

```julia
function valorAbsoluto(x)
    if x >= 0
        # se ejecuta cuando la expresion es verdadera
        return x
    else
        # se ejecuta cuando la expresion es falsa
        return -x
    end
end
```

    valorAbsoluto (generic function with 1 method)

```julia
x = valorAbsoluto(-9821)
println(x)
```

    9821

```julia
function problemasCurso(alumnos,sillas)
    if alumnos < 5
        println("No se abre el grupo")
    else
        println("El grupo si se abre")
        if alumnos > sillas
            println("Algunos van a estar parados")
        else
            println("Todos van a estar sentados")
        end
    end
end
```

    problemasCurso (generic function with 1 method)

```julia
problemasCurso(36,20)
```

    El grupo si se abre
    Algunos van a estar parados

Para sintetizar, la estructura if tiene la siguiente sintaxis

```Julia
if expresionBooleana
    # se ejecuta solo si expresionBooleana es verdadero
    instruc1
    instruc2
end
```
Cuando queremos que algo más se ejecute si la expresión es falsa
```Julia
if expresionBooleana
    # se ejecuta solo si expresionBooleana es verdadero
    instruc1
    instruc2
else
    # se ejecuta si expresionBooleana es falsa
    instruc3
    instruc4
end
```

## Ciclos While

¿Qué sucede si, en lugar de ejecutar **una vez** un pedazo de código dependiendo de si es verdadera o no una expresión boolena, quisiera ejectuar unas lineas de código **mientras  una expresión sea verdadera**?

Existe una estructura que permite eso. Es la llamada **Estructura While** o **Ciclo While**

```julia
function ejemploSimpleWhile(lim)
    # defino `c` para que cuente cuantas veces estoy en el while
    c = 1
    # `lim` es el límite de repeticiones de while
    while c <= lim
        println("Estoy en el while")
        print("c vale: ")
        println(c)
        # aumenta el valor de c en 1
        c = c + 1
    end
    println("Ya salí del While")
    print("c vale: ")
    println(c)
end
```

    ejemploSimpleWhile (generic function with 1 method)

```julia
ejemploSimpleWhile(5)
```

    Estoy en el while
    c vale: 1
    Estoy en el while
    c vale: 2
    Estoy en el while
    c vale: 3
    Estoy en el while
    c vale: 4
    Estoy en el while
    c vale: 5
    Ya salí del While
    c vale: 6

### ¿Qué pasa si la expresión booleana del While nunca cambia y siempre es verdadera?

En ese caso, el código se ejecuta **infinitamente**

```julia
function whileMalo(lim)
    c = 1
    while c <= lim
        println("Estoy en el while")
    end
    println("salí del while")
end
```

    whileMalo (generic function with 1 method)

Cuando se ejecuta infinitamente el código, por lo general el intérprete se traba y no podemos trabajar con él. **Hay que ser muy muy cuidadosos a la hora usar ciclos While**

### ¿No hay algo menos peligroso que un ciclo While que permita que cierto código se repita?

Si, si lo hay. Eso se llama un **ciclo For** y eso es el tema de la siguiente clase.

Para terminar, recordemos que la sintaxis del While es

```Julia
while expresionBooleana
    instruccion1
    instruccion2
end
```

Se repite mientras la condicion booleana sea cierta.

## Introducción a las arreglos, listas y rangos

En la clase 1 vimos que los **arreglos** eran una sucesión finita de objetos del mismo tipo. Para definir un arreglo, utilizamos la sintaxis siguiente:

```Julia
arreglo = [obj1,obj2,obj3]
```

En Julia, las posiciones se empiezan a contar desde 1. Es decir:
* arreglo[1] = obj1
* arreglo[2] = obj2

```julia
arreglo1 = [1,2,3,4,5,6,7,8]
println(typeof(arreglo1))
println(arreglo1[1])
arreglo2 = [2.3,4.0,4.4,-9.23,8.57]
println(typeof(arreglo2))
println(arreglo2[2])
# si utilizamos `end` accedemos al último elemento
println(arreglo2[end])
```

    Array{Int64,1}
    1
    Array{Float64,1}
    4.0
    8.57

Las **listas** son sucesiones finitas de objetos de disntito tipo. Se definen igual que un arreglo.

```julia
lista1 = [1,"Aldo",false,9.87]
println(typeof(lista1))
println(lista1[2])
println(lista1[end])
```

    Array{Any,1}
    Aldo
    9.87

En Julia, hay algo todavía más sofisticado. Los **rangos**. Un rango es un intervalo de números definido por tres valores: el menor valor posible, el mayor valor posible, y la separación entre cada elemento

La sintaxis de un rango es:

```Julia
rango1 = inicial:separacion:final
```

```julia
rango1 = 1:1:10
println(collect(rango1))
rango2 = 1:1.2:10
println(collect(rango2))
# si no especifica la separacion, se asume que la separacion es 1
rango3 = 3.3:5.3
println(collect(rango3))
```

    [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    [1.0, 2.2, 3.4, 4.6, 5.8, 7.0, 8.2, 9.4]
    [3.3, 4.3, 5.3]

### ¿Un rango es igual a un arreglo?

**No**. Mientras que un arreglo de $n$ elementos, se tiene que representar en la memoria como $n$ elementos distintos, un rango que puede tener muchos elementos solo se representa en la memoria como tres cosas: el valor incial, la separacion y el limite superior.

**Esto ahora mucha memoria** y nos permite describir más rápidamente un arreglo

```julia
println(collect(1:8))
```

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

```julia
# imprimirlos así no me da mucha información
println(1:8)
```

    1:8

La función `collect(ran)` convierte explícitamente un rango `ran` en un arreglo

```julia
println(typeof(1:8))
# lo convierto con collect a un arreglo
println(typeof(collect(1:8)))
```

    UnitRange{Int64}
    Array{Int64,1}

# Ejercicios

1. Escribe una función que tome como argumento dos números reales $r$ y  $h$ y nos regrese el valor del volumen de un cono circular de radio $r$ y altura $h$.

2. En un laboratorio se mide la concentración de monóxido de carbono en el aire. Si la concentración es menor a 0.05, no hay ningún problema de oxígenación. Si la concentración es mayor a 0.50, entonces hay un problema de oxigenación. Si la oxigenación está entre 0.05 y 0.50, entonces hay dos casos:

* Si hay más de 20 personas en el laboratorio, entonces hay un riesgo de oxigenación.
* Si hay menos de 20, entonces no hay ningún riesgo.

Escribe una función que tome como argumento dos números `co` y `personas`, que representan, respectivamente, la concentración de monóxido de carbono y el número de personas en el cuarto, y nos imprima si hay o no un riesgo de oxigenación en el laboratorio.

3. Escribe una función que tome como argumento un número real $x$ y regrese el valor de la función $f$, definida por

\begin{equation}
    f(x) = \begin{cases}
    \sqrt{x-3} & x \geq 3 \\
    -3x^2 & -1 \leq x < 3 \\
    2x - 1 & x < -1 \\
    \end{cases}
\end{equation}

4. Escribe una función que tome como argumento un numero entero positivo `edad` e imprima el texto `saludos, tengo x años`, donde `x` es remplazado por cada uno de tus años, empezando en 1.

**Sugerencia:** usar un ciclo While y utilizar las funciones `print` y `println`

5. Mediante el uso de un ciclo While, escribe una función que tome como argumento tres números reales $a,b$ y $l$ con $a<b$ y $0<l<b-a$ e imprima en la pantalla todos los números reales $r$ de la forma $r = a+nl$ con $n \in \mathbb{N}$ tales que $a\leq r \leq b$.

**Sugerencia:** primero construir a mano los valores toma que $r$, después pensar como irlos construyendo partiendo de $a$ y luego implementar eso desde un ciclo While.

```julia

```