# Performance Pandas

Python is slower than compiled languages for a variety of reasons:

### Python is Dynamically Typed rather than Statically Typed.

What this means is that at the time the program executes, the interpreter doesn't know the type of the variables that are defined. For example, the difference between a C variable and a Python variable is summarized by this diagram:

For a variable in C, the compiler knows the type by its very definition. For a variable in Python, all you know at the time the program executes is that it's some sort of Python object.

So if you write the following in C:

```C
int a = 1;
int b = 2;
int c = a + b;
```

the C compiler knows from the start that a and b are integers: they simply can't be anything else! With this knowledge, it can call the routine which adds two integers, returning another integer which is just a simple value in memory. As a rough schematic, the sequence of events looks like this:

**C Addition**

1. Assign <int> 1 to a
2. Assign <int> 2 to b
3. call binary_add<int, int>(a, b)
4. Assign the result to c

The equivalent code in Python looks like this:

```python
a = 1
b = 2
c = a + b
```

here the interpreter knows only that 1 and 2 are objects, but not what type of object they are. So the The interpreter must inspect PyObject_HEAD for each variable to find the type information, and then call the appropriate summation routine for the two types. Finally it must create and initialize a new Python object to hold the return value. The sequence of events looks roughly like this:

**Python Addition**

1. Assign 1 to a
    - Set a->PyObject_HEAD->typecode to integer
    - Set a->val = 1
2. Assign 2 to b
    - Set b->PyObject_HEAD->typecode to integer
    - Set b->val = 2
3. call binary_add(a, b)
    - find typecode in a->PyObject_HEAD
    - a is an integer; value is a->val
    - find typecode in b->PyObject_HEAD
    - b is an integer; value is b->val
    - call binary_add<int, int>(a->val, b->val)
    - result of this is result, and is an integer.
4. Create a Python object c
    - set c->PyObject_HEAD->typecode to integer
    - set c->val to result

The dynamic typing means that there are a lot more steps involved with any operation. This is a primary reason that Python is slow compared to C for operations on numerical data.

### Python is interpreted rather than compiled.

We saw above one difference between interpreted and compiled code. A smart compiler can look ahead and optimize for repeated or unneeded operations, which can result in speed-ups. Compiler optimization is its own beast, and I'm personally not qualified to say much about it, so I'll stop there.

### Python's object model can lead to inefficient memory access

We saw above the extra type info layer when moving from a C integer to a Python integer. Now imagine you have many such integers and want to do some sort of batch operation on them. In Python you might use the standard List object, while in C you would likely use some sort of buffer-based array.

A NumPy array in its simplest form is a Python object build around a C array. That is, it has a pointer to a contiguous data buffer of values. A Python list, on the other hand, has a pointer to a contiguous buffer of pointers, each of which points to a Python object which in turn has references to its data (in this case, integers). This is a schematic of what the two might look like:

You can see that if you're doing some operation which steps through data in sequence, the numpy layout will be much more efficient than the Python layout, both in the cost of storage and the cost of access.

## Speeding up statistical computations in Python

In the age of "big data" and sophisitcated Bayesian and statistical learning algorithms, many are interested in optimizing the performance of the high-level languages that we use to analyse data.

[NumPy](http://numpy.scipy.org/) gets us part of the way there on Python:

* Storage of multidimensional data
* Efficient data access
* Efficient in-memory storage
* Fast methods and functions for data manipulation

Ffor many applications, this is sufficient to drastically improve performance. However, there is plenty of scope for improving Python's performance in situations where speed matters.

Pure Python and Python with NumPy are not particularly fast. Below are some recent performance benchmarks comparing several computing languages (taken directly from the [Julia website](http://julialang.org)):

<colgroup>
<col class="name" />
<col class="relative" span="11" />
</colgroup>

	Fortran	Julia	Python	R	Matlab	Octave	Mathematica	JavaScript	Go	LuaJIT	Java

	gcc 5.1.1
	0.4.0
	3.4.3
	3.2.2
	R2015b
	4.0.0
	10.2.0
	V8 3.28.71.19
	go1.5
	gsl-shell 2.3.1
	1.8.0_45

fib	0.70	2.11	77.76	533.52	26.89	9324.35	118.53	3.36	1.86	1.71	1.21

parse_int	5.05	1.45	17.02	45.73	802.52	9581.44	15.02	6.06	1.20	5.77	3.35

quicksort	1.31	1.15	32.89	264.54	4.92	1866.01	43.23	2.70	1.29	2.03	2.60

mandel	0.81	0.79	15.32	53.16	7.58	451.81	5.13	0.66	1.11	0.67	1.35

pi_sum	1.00	1.00	21.99	9.56	1.00	299.31	1.69	1.01	1.00	1.00	1.00

rand_mat_stat	1.45	1.66	17.93	14.56	14.52	30.93	5.95	2.30	2.96	3.27	3.92

rand_mat_mul	3.48	1.02	1.14	1.57	1.12	1.12	1.30	15.07	1.42	1.16	2.36

Figure:
benchmark times relative to C (smaller is better, C performance = 1.0).

So, while fast relative to some scientific compution choices (*e.g.* R, Matlab), Python sometimes needs to be tweaked in order to make it a competitive choice for implementing modern statistical methods. We will cover two approachable ways of improving the performance of Python.

## Profiling

Before you barrel ahead and prematurely optimize your Python code, it is important to understand **why** and **where** your code is slow. This is achieved by systematically accounting for the resources that your code is using, such as memory, CPU time or data transfer. This process is broadly referred to as ***Profiling***, and it allows you to identify where the performance bottlenecks in your code lie.

Here, we will concentrate on optimizing performance for **CPU-bound** problems.

There are a number of tools to help you profile your code.

### `time`

For those of you on UNIX platforms, the built-in utility `time` can be used to assess how long your code takes to run.

```python
!time python ../examples/abc.py
```

The output from `time` can be interpreted as:

* `real`: elapsed (wall) time
* `user`: time spent in your code
* `sys`: time spent in system (kernel) functions

The last 2 quantities account for the cycles used by your program. The remaining `real` time is often due to waiting for information either from disk or a network connection (I/O).

Python also has a `time` module (and function) that is more rudimentary; it simply returns the time, in seconds from the Epoch (1/1/1970).

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

We can use this for profiling by differencing the times before and after running some code of interest:

```python
import numpy as np
start_time = time.time()
np.product(range(1, 100000))
end_time = time.time()

end_time - start_time
```

Note, however that it does not provide a breakdown of where the code spends its time.

### IPython magic: `%timeit`, `%run` and `%prun`

IPython has three built-in "magic" functions that are useful for profiling your code.

The `%timeit` magic executes a Python statement or expressions in a loop to see how long we expect it to take for any given call. Additionally, it repeats the loop a certain number of times, and returns the best result.

As an example, consider a Python implementation of the **trapezoidal rule**, a method from numerical analysis for approximating a definite integral. Specifically, it allows us to approximate:

$$\int_a^b f(x) dx$$

using the approximation:

$$\int_a^b f(x) dx \approx (b-a) \frac{f(b) + f(a)}{2}$$

Rather than use a single interval for this estimate, we break the interval down into $n$ subintervals, to obtain a more accurate approximation.

```python
def f(x):
    return 2*x*x + 3*x + 1

def trapez(a, b, n):
    h = (b-a)/float(n)
    sumy = 0
    x=a

    for i in range(n):
        x += h
        sumy += f(x)
    sumy += 0.5*(f(a) + f(b))
    return sumy*h
```

```python
trapez(1, 5, 10000)
```

To confirm that this works, we can compare this to the symbolic solution, using Sympy:

```python
import sympy as sym

xs = sym.symbols('xs')

fx = 2*xs*xs + 3*xs + 1

ifx = sym.integrate(fx, (xs, 1, 5))
ifx.evalf()
```

```python
%timeit trapez(1, 5, 10000)
```

`%timeit` tries to pick suitable values for the number of loops and repeats; these values can be overriden by specifying `-n` and `-r` values, respectively.

Profiling results can be saved to a variable by calling the %timeit magic with the `-o` flag:

    %timeit -o <expression>

This returns a `TimeitResult` object, which includes information about the %timeit run as attributes.

```python
trapez_prof = %timeit -o trapez(1, 5, 10000)
```

```python
trapez_prof.best
```

The `%run` command with a `-p` option allows you to run complete programs under the control of the Python profiler. It writes the output to the help pane, which opens at the bottom of the page.

```python
# This code redirects pager output to a regular cell
from IPython.core import page
page.page = print
```

```python
%run -p ../examples/abc.py
```

The profiling information includes the following information:

* `ncalls`: number of calls to function
* `tottime`: total time spent in the given function (excluding time in calls to sub-functions)
* `percall`: time per call
* `cumtime`: cumulative time spent in this and all subfunctions

We can see that most of the time in this example is spent inside of core NumPy functions and methods.

The `%prun` command does a similar job for single Python expressions (like function calls).

```python
%prun trapez(2, 6, 100000)
```

For even more fine-grained profiling information, we can use a line profiler to see how long it takes each line of a function to run.

```python
!pprofile ../examples/bisection.py
```

This output makes it clear that the biggest cost is in the repeated calling of the function $f$ for which the root is being found. If we could improve the speed of this function, it would be the easiest single way of improving the performance of the code.

## Speeding up Pandas by Being Idiomatic

When you have decided that your code is unacceptably slow, and have gone through the process of profiling to see if and where your program is experiencing a bottleneck, it can be easy to jump ahead and try speeding it up using external tools. There are several packages that will certainly improve Python's performance (and we will introduce some of them later), but the first place to look for better performance is in **refactoring** your implementation of whichever algorithm you happen to be using.

Effective pandas programming (and Python, in general) involves applying particular **idioms** effectively; these are idiosyncratic expressions that may only exist in Python or pandas, but when used appropriately they can make your code more readable, faster, or both. You have seen some of these already -- for example, the **list comprehension** as a means for succinctly implementing a `for` loop.

### Comprehensions

```python
def do_math(x):
    return 3 + x**3
```

```python
%%timeit
squares = []
for i in range(1000):
    squares.append(do_math(i))
```

```python
%timeit squares = [do_math(i) for i in range(1000)]
```

Here, not only is the list comprehension easier to write and read, it is also slightly faster.

### String concatenation

Just as you should avoid growing lists or arrays by concatenation or appending, iterating over strings and concatenating them manually is very inefficient. For example, let's say we want to concatente a list of strings into a single string:

```python
words = ["Six",
"days",
"in",
"to",
"what",
"should",
"be",
"a",
"greatest",
"two",
"months",
"of",
"my",
"life",
"and",
"it’s",
"turned",
"in",
"to",
"a",
"nightmare"]
```

One might be tempted to code the following:

```python
%%timeit
sentence = ""
for word in words:
    sentence += word
```

However, this is inefficient; since strings is immutable in Python, every `+` operation involves creating a new string and copying the old content. Instead, we can use the string method `join`, which is not only faster, but more flexible. Here, we would like to separate the words by spaces, which is easily done:

```python
' '.join(words)
```

```python
%timeit ' '.join(words)
```

### Concatenating DataFrames

An often-seen pattern in pandas is the combining of several imported datasets into a single DataFrame. Larger datasets are frequently stored in chunks on disk (*e.g.* multiple years of meteorological data).

One might instinctually want to instantiate an empty DataFrame of the appropriate dimension (in terms of columns), and iteratively add data to it. For example, consider the ebola data that we explored in a previous section. The data from Liberia consists of a directory of CSV files with identical structure.

We can use the IPython "bang" syntax to retrieve the list of files from this directory and assign them to a variable as a list.

```python
DATA_DIR = '../data/ebola/liberia_data/'
data_files = !ls $DATA_DIR
```

Here are the column names for each file:

```python
columns = ['Date','Variable','National','Bomi County','Bong County','Grand Kru',
           'Lofa County','Margibi County','Maryland County','Montserrado County',
           'Nimba County','River Gee County','RiverCess County','Sinoe County']
```

Under this strategy, we create an empty DataFrame and loop over the list of files, appending the contents of the file to the DataFrame. You might already be able to guess that this is not an efficient approach.

```python
%%timeit
liberia_data = pd.DataFrame(columns=columns)
for f in data_files:
    chunk = pd.read_csv(DATA_DIR+f)
    liberia_data = liberia_data.append(chunk)
```

```python
liberia_data.shape
```

```python
%%timeit
liberia_data = pd.concat([pd.read_csv(DATA_DIR+f) for f in data_files])
```

### Iteration and vectorization

```python
vessels = pd.read_csv("../data/AIS/vessel_information.csv", index_col='mmsi')
segments = pd.read_csv("../data/AIS/transit_segments.csv")
segments_merged = pd.merge(vessels, segments, left_index=True, right_on='mmsi')
```

```python
def top(df, column, n=5):
    return df.sort_values(by=column, ascending=False)[:n]
```

```python
segments_by_vessel = segments_merged.groupby('mmsi')
```

```python
%timeit -n 3 segments_by_vessel.apply(top, column='seg_length', n=3)[['names', 'seg_length']].head()
```

```python
%timeit -n 3 segments_by_vessel.seg_length.nlargest(3).head()
```

### Categorical variables

General advice for gaining speed and efficiency with pandas is to use appropriate data types within columns of a DataFrame or in a Series. When importing data, columns can end up with an `object` data type, which is very general, but also quite inefficient.

```python
vessels.dtypes
```

`object` data are manipulated using pure Python code, whereas various numneric types run using faster C code. With character data you are generally stuck with an `object` data type, though there is one exeption: **categorical** data.

Categorical data are strings with relatively few distict values relative to the number of elements in the data (also known as having loe cardinality). In pandas, you may want to represent such data using the `categorical` data type.

For example, consider the `flag` column in the vessel dataset:

```python
vessels.flag.unique().shape
```

```python
vessels.shape
```

```python
vessels['flag_cat'] = vessels.flag.astype('category')
vessels.flag_cat.head()
```

The categories are represented internally by unique integers, which is far more compact to store in memory.

```python
vessels.flag_cat.memory_usage(index=False)
```

```python
vessels.flag.memory_usage(index=False)
```

Not only are `categorical` variables more memory efficient than leaving them as `object` types, but they can appreciably speed up computations that use them as well.

```python
segments_merged['flag_cat'] = segments_merged.flag.astype('category')
```

```python
%timeit segments_merged.groupby('flag').seg_length.nlargest(10).sum()
```

```python
%timeit segments_merged.groupby('flag_cat').seg_length.nlargest(10).sum()
```

That is an appreciable speedup obtained simply by using a more appropriate data type.

## Fast array expression evaluation with `eval`

Since the performance of processors has outpaced that of memory in the past several decades, the CPU spends a lot of time waiting for memory to give it computations; this is the ***processor-memory performance gap***.

(graph courtesy http://www.techdesignforums.com)

CPU caches are often used to make up for this difference. CPU caches are more effective when the data are optimally located in memory to take advantage of cache performance. `numexpr` does this by moving contiguous blocks of data from memory to the CPU cache, reusing them as much as possible within the cache to more quickly give the CPU access to the data.

The [`numexpr`](http://code.google.com/p/numexpr/) package allows array expressions to be evaluated far faster that what can be achieved in Python using Numpy arrays. `numexpr` parses a string expression and optimizes and compiles the code on the fly, using a virtual machine that includes a [Just-in-time (JIT) compiler](http://en.wikipedia.org/wiki/Just-in-time_compilation).

In addition, `numexpr` offers direct support for parallel multi-threaded computations, since Python's global interpreter lock is bypassed.

> Python's global interpreter lock (GIL) ensures that only one thread runs in the interpreter at once. This simplifies many of the low-level activities, such as memory management, and allows for co-operative multi-tasking. But, since the currently-running thread holds onto the interpreter, it makes multi-core parallelization difficult.

Part the reason Python can be slow for array calculations is that it creates temporary arrays to store intermediate results from array element calculations, which wastes memory and cache. `numexpr` handles such calculations in manageable chunks, which accellerates computation.

The speedup over NumPy by using `numexpr` can be as high as 20x, but is typically in the range of 2-4x.

### pandas  `eval()`

The `eval` function in pandas implements `numexpr` for as an engine for expression evaluation with `Series` and `DataFrame` objects.

`eval` provides better efficiency for evaluation of large datasets, whereby large expressions are evaluated simultaneously by the `numexpr` engine.

The operations supported include:

- Arithmetic operations except for the left shift (<<) and right shift (>>) operators
    - `df + 2 * pi / s ** 4 % 42 - the_golden_ratio`
- Comparison operations, including chained comparisons
    - `2 < df < df2`
- Boolean operations
    - `df < df2 and df3 < df4 or not df_bool`
- `list` and `tuple` literals
    - `[1, 2] or (1, 2)`
- Attribute access
    - `df.a`
- Subscript expressions
    - `df[0]`
- Math functions: `sin, cos, exp, log, expm1, log1p, sqrt, sinh, cosh, tanh, arcsin, arccos, arctan, arccosh, arcsinh, arctanh, abs` and `arctan2`

Most complex Python syntax is **not** supported, including flow control statements, funciton calls (except math), generator expressions, dictionaries and sets, and lambda functions.

```python
NROWS, NCOLS = 10000, 1000

df1, df2, df3 = [pd.DataFrame(np.random.randn(NROWS, NCOLS)) for _ in range(3)]
```

```python
%timeit df1 + df2 + df3
```

```python
%timeit pd.eval('df1 + df2 + df3')
```

You can use a Python backend to `eval` rather than `numexp`, but it is not generally useful.

```python
%timeit pd.eval('df1 + df2 + df3', engine='python')
```

Let's do boolean operations now

```python
%timeit (df1 > df2) & (df2 > df3)
```

```python
%timeit pd.eval('(df1 > df2) & (df2 > df3)')
```

Valid expressions can also be evaluated using the `DataFrame.eval` method. This allows you to avoid prefixing dataframe names to the columns you want to operate on.

```python
df = pd.DataFrame(np.random.poisson(lam=10, size=(1000000, 2)), columns=['x', 'y'])
```

```python
df.eval('x + y')
```

```python
df = pd.DataFrame(np.random.normal(10, scale=5, size=(1000000, 2)), columns=['x', 'y'])

df.eval('1.5*x + 0.25*x**2 - 3.4*y + 0.75*y**2 - 10').head()
```

You can also use the `eval` method to perform assignment of columns within an expression, provided that the assignment target is a valid Python identifier.

This is one of the rare cases where `inplace=True` is not a bad idea (in fact, its the default).

```python
df.eval('z = x<10', inplace=True)
```

```python
df.head()
```

Multiple assignment can be achieved by using multi-line strings.

```python
df.eval('''z = x<10
w = (x**2 + y**2)**0.5''', inplace=False).head()
```

Local variables can be accessed using the `@` identifier.

```python
const = 0.001
```

```python
df.eval('x * @const').head()
```

Note that this does not work in the `eval` function.

```python
pd.eval('df.x + @const')
```

The larger the DataFrames and/or expression, the bigger gain in performance you will see.

### Exercise

Use both `eval` and Python to operate on data frames of different size, and report the performance of each.

- 0 to 10 million rows in increments of 1 million
- 0 to 50,000 rows in increments of 1000

Save the timings to a DataFrame and plot the relative performance in each case.

```python
# Write your answer here
```

## Cython

Python developers typically solve performance constraints by building Python extensions by wrapping code written in other languages (for example, SciPy contains more lines of C/C++/Fortran than Python). However, programming with the Python/C API is not straightforward for most users.

Cython is a language that allows Python programmers to write fast code without having to write C/C++/Fortran directly. It looks much like Python code, but with type declarations. Cython code is translated it to C (or C++ or others), which is then compiled to create a Python extension that we can import and use.

Using Cython, we can achieve speedups of several orders of magnitude, often *faster than hand-coded C code*. In addtion, Cython is compatible with core scientific programming tools like NumPy and IPython.

Cython has built-in support for multicore processing.

Cython is used to varying degrees by other packages in the Python scientific stack, such as sympy, scikit-learn, SciPy and pandas.

### Example: Numerical integration

Recall from above the function `trapez` for performing numerical integration using the trapezoidal rule.

```python
def f(x):
    return 2*x*x + 3*x + 1

def trapez(a, b, n):
    h = (b-a)/float(n)
    sumy = 0
    x=a

    for i in range(n):
        x += h
        sumy += f(x)
    sumy += 0.5*(f(a) + f(b))
    return sumy*h
```

Let's `apply` this function to a DataFrame of values:

```python
df = pd.DataFrame({'a': np.random.randn(1000),
                    'b': np.random.randn(1000),
                    'N': np.random.randint(100, 1000, (1000)),
                    'x': 'x'})
```

```python
%timeit df.apply(lambda x: trapez(x.a, x.b, x.N), axis=1)
```

Let's profile this to see where the code is slow.

```python
%prun -l 4 df.apply(lambda x: trapez(x.a, x.b, x.N), axis=1)
```

The majority of the time is spent inside either of our two functions, so it is worthwhile to convert them to Cython.

Perhaps the easiest way to use Cython, is via the IPython cython magic, which allows us to run Cython interactively:

```python
%load_ext Cython
```

Let's simply apply this magic to the functions as written, without changing anything.

```cython
%%cython

def f(x):
    return 2*x*x + 3*x + 1

def trapez2(a, b, n):
    h = (b-a)/float(n)
    sumy = 0
    x=a
    for i in range(n):
        x += h
        sumy += f(x)
    sumy += 0.5*(f(a) + f(b))
    return sumy*h
```

The Cython magic is doing a lot of work for you: it compiles the code into an extension module, and loads it into the notebook. This allows us to ignore all of the compilation details of building Cython extensions.

If we run `trapez2`, we can see a reasonable speedup simply by compiling it, unchanged, using Cython.

```python
%timeit df.apply(lambda x: trapez2(x.a, x.b, x.N), axis=1)
```

Under the hood, several things are happening in order to deliver this improved performance. The Cython source code is translated into C source code by `cython`. Then, this C source is compiled, using the appropriate compiler, flags and associated library files (if any), into a Python extension. This extension is then loaded by IPython into the current session.

C extensions can also be compiled manually, using a setup file. Here is an example for an extension called `dist` within a package called `probability`:

```python
from distutils.core import setup
from distutils.extension import Extension
from Cython.Distutils import build_ext

import numpy as np

setup(
    cmdclass = {'build_ext': build_ext},
    ext_modules = [Extension("dist", ["probability/src/dist.pyx"], include_dirs=[np.get_include()])]
)
```

It mainly uses machinery from a core Python package `distutils` that manages the build process.

To get a closer look at where Cython is improving our unchanged Python code, we can add an `--annotate` flag to the `%%cython` magic declaration:

```cython
%%cython --annotate

def f(x):
    return 2*x*x + 3*x + 1

def trapez2(a, b, n):
    h = (b-a)/float(n)
    sumy = 0
    x=a
    for i in range(n):
        x += h
        sumy += f(x)
    sumy += 0.5*(f(a) + f(b))
    return sumy*h
```

In the above, the line color indicates the "typedness" of the extension, where yellower lines are closer to Python, and therefore require calls to the Python C API, while whiter lines indicate code that is closer to pure C, hence requiring few, if any, Python API calls.

If you click on a line, it unravels to show you the C code that results from the call to `cython`.

The goal in speeding up code with Cython is to turn as many lines to white as we can. The easiest way to do this is to add type declarations to the Python code:

```cython
%%cython --annotate

# Add type to argument
def ff(double x):
    return 2*x*x + 3*x + 1

# Add types to arguments
def trapez3(double a, double b, int n):
    # Declare types of variables
    cdef double h, x, sumy
    cdef int i
    h = (b-a)/float(n)
    sumy = 0
    x=a
    for i in range(n):
        x += h
        sumy += ff(x)
    sumy += 0.5*(ff(a) + ff(b))
    return sumy*h
```

```python
%timeit df.apply(lambda x: trapez3(x.a, x.b, x.N), axis=1)
```

This gives us a considerable speedup. Let's have a look at the profiler report for the new function:

```python
%prun -l 4 df.apply(lambda x: trapez3(x.a, x.b, x.N), axis=1)
```

The next thing we might try is to *inline* the polynomial function. By inlining, we mean that we ask the compiler to perform an inline expansion of said function; that is, it will insert a copy of the function itself wherever the function is called, instead of calling the function wherever it is defined.

We do three things to the specification of `ff`:

* change `def` to `cdef`
* add a return type to the function
* add an `inline` keyword

```cython
%%cython --annotate

cdef inline double ff(double x):
    return 2*x*x + 3*x + 1

cpdef trapez4(double a, double b, int n):
    cdef double h, x, sumy
    cdef int i
    h = (b-a)/float(n)
    sumy = 0
    x=a
    for i in range(n):
        x += h
        sumy += ff(x)
    sumy += 0.5*(ff(a) + ff(b))
    return sumy*h
```

The `cdef` keyword declares a C object. Everything that follows it is therefore specified in terms of C; we are essentially writing C, but using a subset of Python's syntax rules. So, when we are creating a function `cdef ff` it is a C function, and is not available to you in Python.

`cpdef` is a hybrid declaration that creates both a C interface and a Python interface to the function.

Let's see how this performs.

```python
%timeit df.apply(lambda x: trapez4(x.a, x.b, x.N), axis=1)
```

Woof! That's a big speedup, and there's not much yellow left in the annotated code.

If you would like a very simple way of injecting types into your code with Cython, without modifying any of the code itelf, you can use the `@cython.locals` decorator. Note that you don't get as fast of a speedup as we have just achieved.

```cython
%%cython
import cython

@cython.locals(x=cython.double)
def f(x):
    return 2*x*x + 3*x + 1

@cython.locals(a=cython.double, b=cython.double, n=cython.int,
               h=cython.double, sumy=cython.double, i=cython.int,
               x=cython.double, func=cython.double)
def trapez5(a, b, n):
    h = (b-a)/float(n)
    sumy = 0
    x=a

    for i in range(n):
        x += h
        sumy += f(x)
    sumy += 0.5*(f(a) + f(b))
    return sumy*h
```

```python
%timeit df.apply(lambda x: trapez5(x.a, x.b, x.N), axis=1)
```

If you can stand to look at it, you can peek at all the C code that is generated by Cython just to optimize this short function.

```python
%load ../examples/trapezoid.c
```

### Using `ndarray`

If we profile the function now, our functions are not near the top of the list.

```python
%prun -l 4 df.apply(lambda x: trapez4(x.a, x.b, x.N), axis=1)
```

We notice, however, that `series` is being called a lot.

Each row is being turned into a `Series`

```cython
%%cython
cimport numpy as np
import numpy as np

cdef inline double ff(double x) except? -2:
    return 2*x*x + 3*x + 1

cpdef trapez4(double a, double b, int n):
    cdef double h, x, sumy
    cdef int i
    h = (b-a)/float(n)
    sumy = 0
    x=a
    for i in range(n):
        x += h
        sumy += ff(x)
    sumy += 0.5*(ff(a) + ff(b))
    return sumy*h

cpdef np.ndarray[double] apply_trapez(np.ndarray col_a, np.ndarray col_b, np.ndarray col_n):
    assert (col_a.dtype == np.float and col_b.dtype == np.float and col_n.dtype == np.int)

    cdef Py_ssize_t i, n = len(col_n)
    assert (len(col_a) == len(col_b) == n)
    cdef np.ndarray[double] res = np.empty(n)

    for i in range(len(col_a)):
        res[i] = trapez4(col_a[i], col_b[i], col_n[i])
    return res
```

```python
%timeit apply_trapez(df['a'].values, df['b'].values, df['N'].values)
```

This has cut the execution time yet again. We can see now that there appears to be little remaining to optimize.

```python
%prun -l 4 apply_trapez(df['a'].values, df['b'].values, df['N'].values)
```

### Compiler directives

Here's another simple example, using a function that calculates the Euclidean distance between two arrays:

```python
def euclidean(x, y):
    x = np.array(x)
    y = np.array(y)
    return np.sqrt(((x - y) ** 2).sum())
```

```python
%timeit euclidean(np.random.randn(10), np.random.randn(10))
```

In order to get a speedup under Cython, we need to iterate over the elements of each passed array, and aggregate them manually.

```cython
%%cython --annotate

import cython
cimport numpy as np
from libc.math cimport sqrt

@cython.boundscheck(False)
@cython.wraparound(False)
def euclidean2(np.ndarray[np.float64_t, ndim=1] x,
               np.ndarray[np.float64_t, ndim=1] y):
    cdef:
        double diff
        int i
    diff = 0
    for i in range(x.shape[0]):
        diff += (x[i] - y[i])**2
    return sqrt(diff)

```

```python
%timeit euclidean2(np.random.randn(10), np.random.randn(10))
```

The decorators for `euclidean2` are **compiler directives** that alter the behavior of Cython code. Setting `boundscheck` to False removes boundary checking for indexing operations, forcing us to ensure that we do not try to index arrays using index vlaues that are out of bounds. When we set `wraparound` to False, Cython will not support negative indexes, as is the case with Python. While these directives may increase the speed of our code, it can be dangerous; if we do not ensure that we index our arrays properly, it may cause segmentation faults or data corruption.

The full set of compiler directives are described in the [Cython docs](http://docs.cython.org/src/reference/compilation.html#compiler-directives).

Here is the same code using lists instead of NumPy arrays:

```cython
%%cython --annotate

from libc.math cimport sqrt

def euclidean3(list x, list y):
    cdef:
        double diff
        int i
    diff = 0
    for i in range(len(x)):
        diff += (x[i] - y[i])**2
    return sqrt(diff)

```

```python
%timeit euclidean3(np.random.randn(10).tolist(), np.random.randn(10).tolist())
```

### Exercise

Try using the compiler directives on the `apply_trapez` function to see if there are further performance gains to be had.

```python
# Write your answer here
```

## Exercise

Try using Cython to improve the performance of a gradient descent algorithm:

```python
from scipy import optimize

def gradient_descent(x0, f, f_prime, adapt=False):
    x_i, y_i = x0
    all_x_i = list()
    all_y_i = list()
    all_f_i = list()

    for i in range(1, 100):
        all_x_i.append(x_i)
        all_y_i.append(y_i)
        all_f_i.append(f([x_i, y_i]))
        dx_i, dy_i = f_prime(np.asarray([x_i, y_i]))
        if adapt:
            # Compute a step size using a line_search
            step = optimize.line_search(f, f_prime,
                                np.r_[x_i, y_i], -np.r_[dx_i, dy_i],
                                np.r_[dx_i, dy_i], c2=.05)
            step = step[0]
        else:
            step = 1
        x_i += -step*dx_i
        y_i += -step*dy_i
        if np.abs(all_f_i[-1]) < 1e-16:
            break
    return all_x_i, all_y_i, all_f_i
```

Here is a sample function to optimize. Recall from Section 3 that it returns both the quadratic function and its gradient.

```python
def quad(epsilon, ndim=2):
    def f(x):
        x = np.asarray(x)
        y = x.copy()
        y *= np.power(epsilon, np.arange(ndim))
        return .33*np.sum(y**2)

    def f_prime(x):
        x = np.asarray(x)
        y = x.copy()
        scaling = np.power(epsilon, np.arange(ndim))
        y *= scaling
        return .33*2*scaling*y

    return f, f_prime
```

```python
x0, y0 = 1.6, 1.1

f, f_prime = quad(0.8)
%timeit gd_x_i, gd_y_i, gd_f_i = gradient_descent([x0, y0], f, f_prime)
```

```python
# Write answer here
```

## Numba

Cython precompiles parts of Python code before running. Another approach is **Just-in-Time (JIT)** compilation. Numba is a compiler that runs Python code through an LLVM compiler to produce optimized bytecode for fast execution. Numba does not require a C/C++ compiler on your machine.

Numba's lone API is a **decorator**.

The `@jit` decorator runs the decorated function through bytecode analysis and the function arguments through a type inference engine, and generates an intermediate representation of your code, which is then passed to LLVM for compilation to bytecode.

```python
from numba import jit, autojit
```

```python
@jit
def nfibonacci(size):
    F = np.empty(size, 'int')
    a, b = 0, 1
    for i in range(size):
        F[i] = a
        a, b = b, a + b
    return F
```

```python
nfibonacci(50)
```

Numba is able to compile separate specializations depending on the input types.

If you want fine-grained control over types chosen by the compiler, you can tell Numba the function signature (types) to expect.

```python
from numba import int32

@jit(int32[:](int32))
def nfibonacci(size):
    F = np.empty(size, 'int')
    a, b = 0, 1
    for i in range(size):
        F[i] = a
        a, b = b, a + b
    return F
```

```python
nfibonacci(50)
```

Compilation is deferred until the first function execution. Numba will infer the argument types at call time, and generate optimized code based on this information.

```python
def pairwise_python(X):
    M = X.shape[0]
    N = X.shape[1]
    D = np.empty((M, M), dtype=np.float)
    for i in range(M):
        for j in range(M):
            d = 0.0
            for k in range(N):
                tmp = X[i, k] - X[j, k]
                d += tmp * tmp
            D[i, j] = np.sqrt(d)
    return D

X = np.random.random((1000, 3))

%timeit pairwise_python(X)
```

```python
@jit
def npairwise_python(X):
    M = X.shape[0]
    N = X.shape[1]
    D = np.empty((M, M), dtype=np.float)
    for i in range(M):
        for j in range(M):
            d = 0.0
            for k in range(N):
                tmp = X[i, k] - X[j, k]
                d += tmp * tmp
            D[i, j] = np.sqrt(d)
    return D
```

```python
%timeit npairwise_python(X)
```

Numba-compiled functions can call other compiled functions. In some situations, the optimizer may even inline the function in the machine code code.

```python
def square(x):
    return x ** 2

def hypot(x, y):
    return np.sqrt(square(x) + square(y))
```

```python
%timeit hypot(10, 8)
```

```python
@jit
def nsquare(x):
    return x ** 2

@jit
def nhypot(x, y):
    return np.sqrt(nsquare(x) + nsquare(y))
```

```python
%timeit nhypot(10, 8)
```

Numba can compile *most* NumPy functions, as well as generators.

Numba does *not* compile things like lists, sets, dictionaries (tuples are compiled), comprehensions, and string operations, so there will be no speedup for these.

As with all performance tools, the best strategy is not to apply the `@jit` decorator all over your code, but to use Python's profiling tools to identify "hotspots" in your program, and selectively apply `@jit`.

Numba can also be used to **vectorize** computations, meaning that one does not explictly have to loop over iterables of values.

```python
from numba import vectorize

@vectorize
def nsquare_vec(x):
    return x**2
```

```python
df
```

```python
%timeit nsquare(df.a)
```

```python
%timeit nsquare_vec(df.a.values)
```

Note that as of pandas 0.20, Numba only works on the underlying array in pandas data structures, and not DataFrames or Series themselves, hence the passing of `.values` above.

One performance caveat is that Numba will only speed up code that uses NumPy arrays (so-called `nopython` mode). When your code includes things like lists, strings or dictionaries, it will revert to `object` mode and not provide an appreciable speedup to your code. If you wish to have an exception thrown when `object` mode is used, you can apply the following argument to the `jit` decorator:

    @jit(nopython=True)

### Exercise

Use Numba to just-in-time compile the trapezoidal integration function we used above. See how it compares with Cython.

```python
# Write your answer here
```

## References

Augspurger, T. (2016) [Effective Pandas](https://leanpub.com/effective-pandas). Leanpub.

van der Plas, J. (2014) [Why Python is Slow: Looking Under the Hood](https://jakevdp.github.io/blog/2014/05/09/why-python-is-slow/)

van der Plas, J. (2015) [Optimizing Python in the Real World: NumPy, Numba, and the NUFFT](https://jakevdp.github.io/blog/2015/02/24/optimizing-python-with-numpy-and-numba/)

[A guide to analyzing Python performance](http://www.huyng.com/posts/python-performance-analysis/)

[Kurt Smith's Cython tutorial from SciPy 2013](https://www.youtube.com/watch?v=JKCjsRDffXo)


# Task III: Quantum Convolutional Neural Network (QCNN) Part
Your task is to setup and apply a quantum convolutional neural network (QCNN) on
particle physics data to perform binary classification on two types of objects
(electrons and photons). You should use TFQ for this task.
The electron-photon dataset (which can be found [here](https://github.com/ML4SCI/ML4SCI_GSoC/tree/main/QMLHEP/qcnn)) contains 100 samples for
training and another 100 for testing, laid out as follows:
● data["x_train"]: Training dataset of 100 32x32 images containing the particles'
energy (100, 32, 32)
● data["y_train"]:" Training labels, 0 = "photon", 1 = "electron" (100,)
● data["x_test"]: Test dataset of 100 32x32 images containing the particles'
energy (100, 32, 32)
● data["y_test"]:" Test labels, 0 = "photon", 1 = "electron" (100,)

The dataset labels are labelled 0 for photons and 1 for electrons. Your task is to
implement a QCNN model in Tensorflow Quantum that uses this dataset’s input and
performs binary classification. Please feel free to experiment with different ways of
encoding the classical data inputs into the qubits.
Specifically, show that the model fits the dataset and that your training loss
decreases over time. (Given the small dataset size, we will not be focusing on the
accuracy of your model).

## Downloading the dataset

```python
!wget https://github.com/ML4SCI/ML4SCI_GSoC/blob/main/QMLHEP/qcnn/electron-photon.npz?raw=true -O electron-photon.npz
```

    --2021-03-19 15:06:37--  https://github.com/ML4SCI/ML4SCI_GSoC/blob/main/QMLHEP/qcnn/electron-photon.npz?raw=true
    Resolving github.com (github.com)... 140.82.114.3
    Connecting to github.com (github.com)|140.82.114.3|:443... connected.
    HTTP request sent, awaiting response... 302 Found
    Location: https://github.com/ML4SCI/ML4SCI_GSoC/raw/main/QMLHEP/qcnn/electron-photon.npz [following]
    --2021-03-19 15:06:38--  https://github.com/ML4SCI/ML4SCI_GSoC/raw/main/QMLHEP/qcnn/electron-photon.npz
    Reusing existing connection to github.com:443.
    HTTP request sent, awaiting response... 302 Found
    Location: https://raw.githubusercontent.com/ML4SCI/ML4SCI_GSoC/main/QMLHEP/qcnn/electron-photon.npz [following]
    --2021-03-19 15:06:38--  https://raw.githubusercontent.com/ML4SCI/ML4SCI_GSoC/main/QMLHEP/qcnn/electron-photon.npz
    Resolving raw.githubusercontent.com (raw.githubusercontent.com)... 185.199.108.133, 185.199.109.133, 185.199.110.133, ...
    Connecting to raw.githubusercontent.com (raw.githubusercontent.com)|185.199.108.133|:443... connected.
    HTTP request sent, awaiting response... 200 OK
    Length: 821002 (802K) [application/octet-stream]
    Saving to: ‘electron-photon.npz’

    electron-photon.npz 100%[===================>] 801.76K  --.-KB/s    in 0.01s

    2021-03-19 15:06:38 (56.4 MB/s) - ‘electron-photon.npz’ saved [821002/821002]

## Setting up the required libraries

```python
!pip install -q tensorflow==2.3.1
!pip install -q tensorflow-quantum
import tensorflow as tf
import tensorflow_quantum as tfq

import cirq
import sympy
import numpy as np

%matplotlib inline
import matplotlib.pyplot as plt
from cirq.contrib.svg import SVGCircuit
```

         |████████████████████████████████| 320.4MB 51kB/s
         |████████████████████████████████| 460kB 20.5MB/s
         |████████████████████████████████| 20.1MB 102kB/s
    ERROR: datascience 0.10.6 has requirement folium==0.2.1, but you'll have folium 0.8.3 which is incompatible.
    ERROR: albumentations 0.1.12 has requirement imgaug<0.2.7,>=0.2.5, but you'll have imgaug 0.2.9 which is incompatible.
         |████████████████████████████████| 5.9MB 9.9MB/s
         |████████████████████████████████| 5.6MB 29.0MB/s
         |████████████████████████████████| 1.6MB 51.3MB/s

## Loading the data

```python
with np.load('./electron-photon.npz') as data:
  x_train = data['x_train']
  y_train = data['y_train']
  x_test = data['x_test']
  y_test = data['y_test']
```

```python
# plot a sample each from the training set
print(y_train[0])

plt.imshow(x_train[0])
plt.colorbar()
```

## Downscaling the images
An image size of 32x32 is much too large for current quantum computers. We will use PCA to downscale the images to have a feature dimension of 16 i.e., almost 99% reduction in size.

```python
def truncate_x(x_train, x_test, n_components=10):
  """Performs PCA on image dataset keeping the top `n_components` components."""
  n_points_train = tf.gather(tf.shape(x_train), 0)
  n_points_test = tf.gather(tf.shape(x_test), 0)

  # Flatten to 1D
  x_train = tf.reshape(x_train, [n_points_train, -1])
  x_test = tf.reshape(x_test, [n_points_test, -1])

  # Normalize
  feature_mean = tf.reduce_mean(x_train, axis=0)
  x_train_normalized = x_train - feature_mean
  x_test_normalized = x_test - feature_mean

  # Truncate
  eigen_vals, eigen_vectors = tf.linalg.eigh(
      tf.einsum('ji, jk->ik', x_train_normalized, x_train_normalized))
  return tf.einsum('ij, jk->ik', x_train_normalized, eigen_vectors[:, -n_components:]), \
  tf.einsum('ij, jk->ik', x_test_normalized, eigen_vectors[:, -n_components:])
```

```python
FEATURE_DIM = 16
x_train_small, x_test_small = truncate_x(x_train, x_test, FEATURE_DIM)
print('New Datapoint Dimension:', len(x_train_small[0]))
```

    New Datapoint Dimension: 16

```python
print(y_train[0])

print(x_train_small[0])
```

    1.0
    tf.Tensor(
    [-1.6053880e-03  2.3733489e-02  1.4729184e-02 -5.9277429e-03
      3.2285508e-05  2.0039830e-02 -5.3670205e-02 -1.6176773e-02
      1.2461127e-02  3.0327177e-02  1.7056542e-02  7.0775233e-02
      3.8512979e-02 -1.3234577e-01  3.6286876e-02 -3.3949080e-01], shape=(16,), dtype=float32)

## Encoding the data as quantum circuits

```python
THRESHOLD = 0

x_train_bin = np.array(x_train_small > THRESHOLD, dtype=np.float32)
x_test_bin = np.array(x_test_small > THRESHOLD, dtype=np.float32)
```

The qubits at pixel indices with values that exceed a threshold, are rotated through an  $X$  gate.

```python
def convert_to_circuit(image):
  """Encode truncated classical image into quantum datapoint"""
  values = np.ndarray.flatten(image)
  qubits = cirq.GridQubit.rect(1, 16) # LineQubit will give an error when converting to tensors
  circuit = cirq.Circuit()
  for i, value in enumerate(values):
    if value:
      circuit.append(cirq.X(qubits[i]))

  return circuit

x_train_circ = [convert_to_circuit(x) for x in x_train_bin]
x_test_circ = [convert_to_circuit(x) for x in x_test_bin]
```

```python
# Visualizing a sample quantum datapoint circuit
SVGCircuit(x_train_circ[0])
```

    findfont: Font family ['Arial'] not found. Falling back to DejaVu Sans.

```python
# Verifying the encoding
bin_img = x_train_bin[0]
indices = np.array(np.where(bin_img)).T
indices
```

    array([[ 1],
           [ 2],
           [ 4],
           [ 5],
           [ 8],
           [ 9],
           [10],
           [11],
           [12],
           [14]])

## Converting these Cirq circuits into tensors for TFQ

```python
x_train_tfcirc = tfq.convert_to_tensor(x_train_circ)
x_test_tfcirc = tfq.convert_to_tensor(x_test_circ)
```

## QCNN Layers

```python
def one_qubit_unitary(bit, symbols):
    """Make a Cirq circuit enacting a rotation of the bloch sphere about the X,
    Y and Z axis, that depends on the values in `symbols`.
    """
    return cirq.Circuit(
        cirq.X(bit)**symbols[0],
        cirq.Y(bit)**symbols[1],
        cirq.Z(bit)**symbols[2])

def two_qubit_unitary(bits, symbols):
    """Make a Cirq circuit that creates an arbitrary two qubit unitary."""
    circuit = cirq.Circuit()
    circuit += one_qubit_unitary(bits[0], symbols[0:3])
    circuit += one_qubit_unitary(bits[1], symbols[3:6])
    circuit += [cirq.ZZ(*bits)**symbols[6]]
    circuit += [cirq.YY(*bits)**symbols[7]]
    circuit += [cirq.XX(*bits)**symbols[8]]
    circuit += one_qubit_unitary(bits[0], symbols[9:12])
    circuit += one_qubit_unitary(bits[1], symbols[12:])
    return circuit

def two_qubit_pool(source_qubit, sink_qubit, symbols):
    """Make a Cirq circuit to do a parameterized 'pooling' operation, which
    attempts to reduce entanglement down from two qubits to just one."""
    pool_circuit = cirq.Circuit()
    sink_basis_selector = one_qubit_unitary(sink_qubit, symbols[0:3])
    source_basis_selector = one_qubit_unitary(source_qubit, symbols[3:6])
    pool_circuit.append(sink_basis_selector)
    pool_circuit.append(source_basis_selector)
    pool_circuit.append(cirq.CNOT(control=source_qubit, target=sink_qubit))
    pool_circuit.append(sink_basis_selector**-1)
    return pool_circuit
```

Printing circuit samples

```python
# one qubit unitary
SVGCircuit(one_qubit_unitary(cirq.GridQubit(0, 0), sympy.symbols('x0:3')))
```

```python
# two qubit unitary
SVGCircuit(two_qubit_unitary(cirq.GridQubit.rect(1, 2), sympy.symbols('x0:15')))
```

```python
# two qubit pool
SVGCircuit(two_qubit_pool(*cirq.GridQubit.rect(1, 2), sympy.symbols('x0:6')))
```

### Quantum Convolution
We define the 1D quantum convolution as the application of a two-qubit parameterized unitary to every pair of adjacent qubits with a stride of one.

```python
def quantum_conv_circuit(bits, symbols):
    """Quantum Convolution Layer following the above diagram.
    Return a Cirq circuit with the cascade of `two_qubit_unitary` applied
    to all pairs of qubits in `bits` as in the diagram above.
    """
    circuit = cirq.Circuit()
    for first, second in zip(bits[0::2], bits[1::2]):
        circuit += two_qubit_unitary([first, second], symbols)
    for first, second in zip(bits[1::2], bits[2::2] + [bits[0]]):
        circuit += two_qubit_unitary([first, second], symbols)
    return circuit
```

```python
SVGCircuit(
    quantum_conv_circuit(cirq.GridQubit.rect(1, 16), sympy.symbols('x0:15')))
```

### Quantum pooling
A quantum pooling layer pools from $N$ qubits to $\frac{N}{2}$ qubits using the two-qubit pool defined above.

```python
def quantum_pool_circuit(source_bits, sink_bits, symbols):
  """A layer that specifies a quantum pooling operation.
  A Quantum pool tries to learn to pool the relevant information from two
  qubits to 1.
  """
  circuit = cirq.Circuit()
  for source, sink in zip(source_bits, sink_bits):
    circuit += two_qubit_pool(source, sink, symbols)
  return circuit
```

```python
test_bits = cirq.GridQubit.rect(1, 16)
SVGCircuit(quantum_pool_circuit(test_bits[:8], test_bits[8:], sympy.symbols('x0:6')))
```

## Model Definition

```python
def create_model_circuit(qubits):
    """Create sequence of alternating convolution and pooling operators
    which gradually shrink over time."""
    model_circuit = cirq.Circuit()
    symbols = sympy.symbols('qconv0:84')
    # Cirq uses sympy.Symbols to map learnable variables. TensorFlow Quantum
    # scans incoming circuits and replaces these with TensorFlow variables.
    model_circuit += quantum_conv_circuit(qubits, symbols[0:15])
    model_circuit += quantum_pool_circuit(qubits[:8], qubits[8:],
                                          symbols[15:21])
    model_circuit += quantum_conv_circuit(qubits[8:], symbols[21:36])
    model_circuit += quantum_pool_circuit(qubits[8:12], qubits[12:],
                                          symbols[36:42])
    model_circuit += quantum_conv_circuit(qubits[12:], symbols[42:57])
    model_circuit += quantum_pool_circuit(qubits[12:14], qubits[14:],
                                          symbols[57:63])
    model_circuit += quantum_conv_circuit(qubits[14:], symbols[63:78])
    model_circuit += quantum_pool_circuit(qubits[14:15], qubits[15:],
                                          symbols[78:84])
    # model_circuit += quantum_conv_circuit(qubits[60:], symbols[84:99])
    # model_circuit += quantum_pool_circuit(qubits[60:62], qubits[62:],
    #                                       symbols[99:105])
    # model_circuit += quantum_conv_circuit(qubits[62:], symbols[105:120])
    # model_circuit += quantum_pool_circuit(qubits[62:63], qubits[63:],
    #                                       symbols[120:126])
    return model_circuit

# Creating qubits and readout operators in Cirq
input_bits = cirq.GridQubit.rect(1, 16)
readout_operators = cirq.Z(input_bits[-1])

input_tensors = tf.keras.Input(shape=(), dtype=tf.string) # since tfq converts Cirq circuits in tf.string dtype
quantum_model = tfq.layers.PQC(create_model_circuit(input_bits), readout_operators)(input_tensors)
qcnn_model = tf.keras.Model(inputs=[input_tensors], outputs=[quantum_model])

# Show the keras plot of the model
tf.keras.utils.plot_model(qcnn_model,
                          show_shapes=True,
                          show_layer_names=False,
                          dpi=70)
```

## Train the model

```python
@tf.function
def custom_accuracy(y_true, y_pred):
    y_true = tf.squeeze(y_true)
    y_pred = tf.map_fn(lambda x: 1.0 if x >= 0 else 0.0, y_pred)
    return tf.keras.backend.mean(tf.keras.backend.equal(y_true, y_pred))

qcnn_model.compile(
    loss=tf.losses.mse,
    optimizer=tf.keras.optimizers.Adam(learning_rate=0.001),
    metrics=[custom_accuracy])

print(qcnn_model.summary())
```

    Model: "functional_1"
    _________________________________________________________________
    Layer (type)                 Output Shape              Param #
    =================================================================
    input_1 (InputLayer)         [(None,)]                 0
    _________________________________________________________________
    pqc (PQC)                    (None, 1)                 84
    =================================================================
    Total params: 84
    Trainable params: 84
    Non-trainable params: 0
    _________________________________________________________________
    None

```python
EPOCHS = 100

history = qcnn_model.fit(x=x_train_tfcirc,
                         y=y_train,
                         batch_size=16,
                         epochs=EPOCHS,
                         verbose=1,
                         validation_data=(x_test_tfcirc, y_test))

qcnn_results = qcnn_model.evaluate(x_test_tfcirc, y_test)
```

    Epoch 1/100
    7/7 [==============================] - 18s 3s/step - loss: 0.5508 - custom_accuracy: 0.4821 - val_loss: 0.4601 - val_custom_accuracy: 0.5982
    Epoch 2/100
    7/7 [==============================] - 18s 3s/step - loss: 0.5459 - custom_accuracy: 0.5268 - val_loss: 0.4600 - val_custom_accuracy: 0.5536
    Epoch 3/100
    7/7 [==============================] - 18s 3s/step - loss: 0.5433 - custom_accuracy: 0.5893 - val_loss: 0.4601 - val_custom_accuracy: 0.5536
    Epoch 4/100
    7/7 [==============================] - 18s 3s/step - loss: 0.5405 - custom_accuracy: 0.6071 - val_loss: 0.4604 - val_custom_accuracy: 0.5268
    Epoch 5/100
    7/7 [==============================] - 17s 2s/step - loss: 0.5381 - custom_accuracy: 0.6250 - val_loss: 0.4608 - val_custom_accuracy: 0.5179
    Epoch 6/100
    7/7 [==============================] - 17s 2s/step - loss: 0.5363 - custom_accuracy: 0.6071 - val_loss: 0.4610 - val_custom_accuracy: 0.5268
    Epoch 7/100
    7/7 [==============================] - 17s 2s/step - loss: 0.5346 - custom_accuracy: 0.5893 - val_loss: 0.4611 - val_custom_accuracy: 0.5089
    Epoch 8/100
    7/7 [==============================] - 17s 2s/step - loss: 0.5324 - custom_accuracy: 0.5804 - val_loss: 0.4611 - val_custom_accuracy: 0.5089
    Epoch 9/100
    7/7 [==============================] - 18s 3s/step - loss: 0.5310 - custom_accuracy: 0.5536 - val_loss: 0.4609 - val_custom_accuracy: 0.5179
    Epoch 10/100
    7/7 [==============================] - 18s 3s/step - loss: 0.5296 - custom_accuracy: 0.5536 - val_loss: 0.4605 - val_custom_accuracy: 0.5179
    Epoch 11/100
    7/7 [==============================] - 17s 2s/step - loss: 0.5279 - custom_accuracy: 0.5625 - val_loss: 0.4600 - val_custom_accuracy: 0.5179
    Epoch 12/100
    7/7 [==============================] - 17s 2s/step - loss: 0.5266 - custom_accuracy: 0.6696 - val_loss: 0.4592 - val_custom_accuracy: 0.5179
    Epoch 13/100
    7/7 [==============================] - 17s 2s/step - loss: 0.5253 - custom_accuracy: 0.6161 - val_loss: 0.4589 - val_custom_accuracy: 0.5089
    Epoch 14/100
    7/7 [==============================] - 17s 2s/step - loss: 0.5239 - custom_accuracy: 0.6696 - val_loss: 0.4586 - val_custom_accuracy: 0.4911
    Epoch 15/100
    7/7 [==============================] - 18s 3s/step - loss: 0.5233 - custom_accuracy: 0.6429 - val_loss: 0.4584 - val_custom_accuracy: 0.5089
    Epoch 16/100
    7/7 [==============================] - 17s 2s/step - loss: 0.5214 - custom_accuracy: 0.6607 - val_loss: 0.4577 - val_custom_accuracy: 0.5089
    Epoch 17/100
    7/7 [==============================] - 18s 3s/step - loss: 0.5201 - custom_accuracy: 0.6696 - val_loss: 0.4572 - val_custom_accuracy: 0.5089
    Epoch 18/100
    7/7 [==============================] - 17s 2s/step - loss: 0.5188 - custom_accuracy: 0.7321 - val_loss: 0.4574 - val_custom_accuracy: 0.5268
    Epoch 19/100
    7/7 [==============================] - 18s 3s/step - loss: 0.5165 - custom_accuracy: 0.6786 - val_loss: 0.4568 - val_custom_accuracy: 0.5357
    Epoch 20/100
    7/7 [==============================] - 17s 2s/step - loss: 0.5146 - custom_accuracy: 0.7321 - val_loss: 0.4555 - val_custom_accuracy: 0.5357
    Epoch 21/100
    7/7 [==============================] - 18s 3s/step - loss: 0.5126 - custom_accuracy: 0.6964 - val_loss: 0.4544 - val_custom_accuracy: 0.5268
    Epoch 22/100
    7/7 [==============================] - 17s 2s/step - loss: 0.5104 - custom_accuracy: 0.6696 - val_loss: 0.4536 - val_custom_accuracy: 0.5446
    Epoch 23/100
    7/7 [==============================] - 17s 2s/step - loss: 0.5079 - custom_accuracy: 0.6786 - val_loss: 0.4532 - val_custom_accuracy: 0.5357
    Epoch 24/100
    7/7 [==============================] - 18s 3s/step - loss: 0.5054 - custom_accuracy: 0.6607 - val_loss: 0.4521 - val_custom_accuracy: 0.5357
    Epoch 25/100
    7/7 [==============================] - 18s 3s/step - loss: 0.5037 - custom_accuracy: 0.6071 - val_loss: 0.4518 - val_custom_accuracy: 0.5357
    Epoch 26/100
    7/7 [==============================] - 18s 3s/step - loss: 0.5013 - custom_accuracy: 0.6786 - val_loss: 0.4509 - val_custom_accuracy: 0.5357
    Epoch 27/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4991 - custom_accuracy: 0.6696 - val_loss: 0.4496 - val_custom_accuracy: 0.5446
    Epoch 28/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4973 - custom_accuracy: 0.6875 - val_loss: 0.4485 - val_custom_accuracy: 0.5179
    Epoch 29/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4946 - custom_accuracy: 0.6607 - val_loss: 0.4476 - val_custom_accuracy: 0.5268
    Epoch 30/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4930 - custom_accuracy: 0.6161 - val_loss: 0.4448 - val_custom_accuracy: 0.5268
    Epoch 31/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4898 - custom_accuracy: 0.6607 - val_loss: 0.4441 - val_custom_accuracy: 0.5446
    Epoch 32/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4879 - custom_accuracy: 0.6607 - val_loss: 0.4428 - val_custom_accuracy: 0.5357
    Epoch 33/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4865 - custom_accuracy: 0.5982 - val_loss: 0.4418 - val_custom_accuracy: 0.5446
    Epoch 34/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4850 - custom_accuracy: 0.6161 - val_loss: 0.4411 - val_custom_accuracy: 0.5357
    Epoch 35/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4823 - custom_accuracy: 0.6607 - val_loss: 0.4404 - val_custom_accuracy: 0.5536
    Epoch 36/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4810 - custom_accuracy: 0.6607 - val_loss: 0.4412 - val_custom_accuracy: 0.5446
    Epoch 37/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4795 - custom_accuracy: 0.6518 - val_loss: 0.4407 - val_custom_accuracy: 0.5446
    Epoch 38/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4776 - custom_accuracy: 0.6250 - val_loss: 0.4390 - val_custom_accuracy: 0.5536
    Epoch 39/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4757 - custom_accuracy: 0.6071 - val_loss: 0.4374 - val_custom_accuracy: 0.5625
    Epoch 40/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4749 - custom_accuracy: 0.5625 - val_loss: 0.4368 - val_custom_accuracy: 0.5536
    Epoch 41/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4736 - custom_accuracy: 0.6696 - val_loss: 0.4371 - val_custom_accuracy: 0.5714
    Epoch 42/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4724 - custom_accuracy: 0.5893 - val_loss: 0.4368 - val_custom_accuracy: 0.5625
    Epoch 43/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4716 - custom_accuracy: 0.6429 - val_loss: 0.4371 - val_custom_accuracy: 0.5625
    Epoch 44/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4714 - custom_accuracy: 0.6696 - val_loss: 0.4376 - val_custom_accuracy: 0.5536
    Epoch 45/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4691 - custom_accuracy: 0.5893 - val_loss: 0.4377 - val_custom_accuracy: 0.5357
    Epoch 46/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4676 - custom_accuracy: 0.6071 - val_loss: 0.4386 - val_custom_accuracy: 0.5536
    Epoch 47/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4671 - custom_accuracy: 0.6607 - val_loss: 0.4378 - val_custom_accuracy: 0.5536
    Epoch 48/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4660 - custom_accuracy: 0.5804 - val_loss: 0.4374 - val_custom_accuracy: 0.5446
    Epoch 49/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4650 - custom_accuracy: 0.5804 - val_loss: 0.4373 - val_custom_accuracy: 0.5625
    Epoch 50/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4638 - custom_accuracy: 0.6071 - val_loss: 0.4373 - val_custom_accuracy: 0.5536
    Epoch 51/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4627 - custom_accuracy: 0.6071 - val_loss: 0.4371 - val_custom_accuracy: 0.5714
    Epoch 52/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4622 - custom_accuracy: 0.6250 - val_loss: 0.4386 - val_custom_accuracy: 0.5804
    Epoch 53/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4622 - custom_accuracy: 0.5625 - val_loss: 0.4397 - val_custom_accuracy: 0.5625
    Epoch 54/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4613 - custom_accuracy: 0.5982 - val_loss: 0.4408 - val_custom_accuracy: 0.5804
    Epoch 55/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4596 - custom_accuracy: 0.5982 - val_loss: 0.4404 - val_custom_accuracy: 0.5804
    Epoch 56/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4584 - custom_accuracy: 0.5625 - val_loss: 0.4387 - val_custom_accuracy: 0.5625
    Epoch 57/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4575 - custom_accuracy: 0.5982 - val_loss: 0.4379 - val_custom_accuracy: 0.5625
    Epoch 58/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4572 - custom_accuracy: 0.5714 - val_loss: 0.4388 - val_custom_accuracy: 0.5625
    Epoch 59/100
    7/7 [==============================] - 19s 3s/step - loss: 0.4554 - custom_accuracy: 0.6071 - val_loss: 0.4396 - val_custom_accuracy: 0.5536
    Epoch 60/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4551 - custom_accuracy: 0.5893 - val_loss: 0.4400 - val_custom_accuracy: 0.5714
    Epoch 61/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4550 - custom_accuracy: 0.5714 - val_loss: 0.4401 - val_custom_accuracy: 0.5804
    Epoch 62/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4525 - custom_accuracy: 0.6339 - val_loss: 0.4400 - val_custom_accuracy: 0.5804
    Epoch 63/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4528 - custom_accuracy: 0.5625 - val_loss: 0.4395 - val_custom_accuracy: 0.5893
    Epoch 64/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4509 - custom_accuracy: 0.5625 - val_loss: 0.4406 - val_custom_accuracy: 0.5625
    Epoch 65/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4500 - custom_accuracy: 0.5804 - val_loss: 0.4427 - val_custom_accuracy: 0.5625
    Epoch 66/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4492 - custom_accuracy: 0.5982 - val_loss: 0.4440 - val_custom_accuracy: 0.5446
    Epoch 67/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4492 - custom_accuracy: 0.6607 - val_loss: 0.4443 - val_custom_accuracy: 0.5625
    Epoch 68/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4486 - custom_accuracy: 0.6250 - val_loss: 0.4435 - val_custom_accuracy: 0.5536
    Epoch 69/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4461 - custom_accuracy: 0.6071 - val_loss: 0.4441 - val_custom_accuracy: 0.5536
    Epoch 70/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4458 - custom_accuracy: 0.5982 - val_loss: 0.4442 - val_custom_accuracy: 0.5804
    Epoch 71/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4454 - custom_accuracy: 0.5982 - val_loss: 0.4431 - val_custom_accuracy: 0.5893
    Epoch 72/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4441 - custom_accuracy: 0.6250 - val_loss: 0.4431 - val_custom_accuracy: 0.5893
    Epoch 73/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4454 - custom_accuracy: 0.6250 - val_loss: 0.4456 - val_custom_accuracy: 0.5446
    Epoch 74/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4437 - custom_accuracy: 0.6250 - val_loss: 0.4442 - val_custom_accuracy: 0.5357
    Epoch 75/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4424 - custom_accuracy: 0.5893 - val_loss: 0.4450 - val_custom_accuracy: 0.5446
    Epoch 76/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4417 - custom_accuracy: 0.5268 - val_loss: 0.4451 - val_custom_accuracy: 0.5804
    Epoch 77/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4402 - custom_accuracy: 0.6071 - val_loss: 0.4459 - val_custom_accuracy: 0.5714
    Epoch 78/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4388 - custom_accuracy: 0.6250 - val_loss: 0.4455 - val_custom_accuracy: 0.5804
    Epoch 79/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4393 - custom_accuracy: 0.6339 - val_loss: 0.4457 - val_custom_accuracy: 0.5893
    Epoch 80/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4378 - custom_accuracy: 0.6429 - val_loss: 0.4450 - val_custom_accuracy: 0.6071
    Epoch 81/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4396 - custom_accuracy: 0.6518 - val_loss: 0.4441 - val_custom_accuracy: 0.5804
    Epoch 82/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4366 - custom_accuracy: 0.6339 - val_loss: 0.4443 - val_custom_accuracy: 0.5804
    Epoch 83/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4363 - custom_accuracy: 0.6161 - val_loss: 0.4450 - val_custom_accuracy: 0.5893
    Epoch 84/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4373 - custom_accuracy: 0.6429 - val_loss: 0.4451 - val_custom_accuracy: 0.5982
    Epoch 85/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4374 - custom_accuracy: 0.6339 - val_loss: 0.4449 - val_custom_accuracy: 0.5893
    Epoch 86/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4365 - custom_accuracy: 0.6518 - val_loss: 0.4454 - val_custom_accuracy: 0.5714
    Epoch 87/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4360 - custom_accuracy: 0.6339 - val_loss: 0.4460 - val_custom_accuracy: 0.5625
    Epoch 88/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4342 - custom_accuracy: 0.6875 - val_loss: 0.4465 - val_custom_accuracy: 0.5714
    Epoch 89/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4341 - custom_accuracy: 0.6607 - val_loss: 0.4455 - val_custom_accuracy: 0.5893
    Epoch 90/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4342 - custom_accuracy: 0.6607 - val_loss: 0.4458 - val_custom_accuracy: 0.6161
    Epoch 91/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4331 - custom_accuracy: 0.6607 - val_loss: 0.4449 - val_custom_accuracy: 0.5625
    Epoch 92/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4325 - custom_accuracy: 0.6607 - val_loss: 0.4437 - val_custom_accuracy: 0.5625
    Epoch 93/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4324 - custom_accuracy: 0.6518 - val_loss: 0.4441 - val_custom_accuracy: 0.5536
    Epoch 94/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4320 - custom_accuracy: 0.6518 - val_loss: 0.4462 - val_custom_accuracy: 0.6071
    Epoch 95/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4318 - custom_accuracy: 0.6518 - val_loss: 0.4464 - val_custom_accuracy: 0.5536
    Epoch 96/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4308 - custom_accuracy: 0.6250 - val_loss: 0.4473 - val_custom_accuracy: 0.5536
    Epoch 97/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4312 - custom_accuracy: 0.6429 - val_loss: 0.4490 - val_custom_accuracy: 0.5446
    Epoch 98/100
    7/7 [==============================] - 17s 2s/step - loss: 0.4307 - custom_accuracy: 0.6518 - val_loss: 0.4499 - val_custom_accuracy: 0.5446
    Epoch 99/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4325 - custom_accuracy: 0.6250 - val_loss: 0.4510 - val_custom_accuracy: 0.5536
    Epoch 100/100
    7/7 [==============================] - 18s 3s/step - loss: 0.4326 - custom_accuracy: 0.6339 - val_loss: 0.4512 - val_custom_accuracy: 0.5536
    4/4 [==============================] - 1s 169ms/step - loss: 0.4512 - custom_accuracy: 0.5469

```python
plt.plot(history.history['loss'][1:], label='Training')
plt.plot(history.history['val_loss'][1:], label='Validation')
plt.title('Training a Quantum CNN to Classify Photons from Electrons')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()
```

```python
plt.plot(history.history['custom_accuracy'][1:], label='Training')
plt.plot(history.history['val_custom_accuracy'][1:], label='Validation')
plt.title('Training a Quantum CNN to Classify Photons from Electrons')
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend()
plt.show()
```


# Problema de selección de portafolio con preferencias media-varianza

En la clase pasada hablamos acerca de:
- preferencias,
- funciones de utilidad,
- la actitud de los inversionistas de cara al riesgo,
- la aversión al riesgo, entre otros.

Todas ellas son piezas que necesitamos para responder la pregunta de ¿cómo un inversionista toma la decisión óptima de selección de portafolio?

En esta clase al fin estamos listos para ensamblar estos conceptos y escribir el problema de selección de portafolios.

En el camino aprenderemos acerca del concepto de **utilidad esperada**, que nos permite trabajar con incertidumbre en el modelado económico (una de las ideas más importantes en economía). Esta idea tiene más de 60 años, y básicamente dice que los individuos, cuando están de cara a incertidumbre, maximizan el valor esperado de su utilidad (solo cierto si somos homo economicus).

Además del concepto de utilidad esperada, aprenderemos acerca de **preferencias media-varianza**. Es decir, supondremos que los inversionistas toman decisiones basados en un tipo particular de preferencias.

Con lo anterior, estableceremos el problema de selección de portafolios.

**Objetivos:**

- ¿Qué es utilidad esperada?
- ¿Qué son las preferencias media-varianza?

*Referencia:*
- Notas del curso "Portfolio Selection and Risk Management", Rice University, disponible en Coursera.
___

## 1. Utilidad esperada
- Básicamente combina las probabilidades de los resultados con cómo los inversionistas se sienten con dichos resultados.
- En otras palabras, la utilidad esperada multiplica la probabilidad de suceso de un evento con la utilidad que genera dicho evento.

Recordemos que las *funciones de utilidad* permiten a los inversionistas expresar cómo se sienten con los resultados, especialmente en los malos ratos.

Entonces la *utilidad esperada* es una herramienta que nos permite cuantificar cómo nos sentimos en nuestros malos momentos económicos, capturando el riesgo con la probabilidad de ocurrencia de dichos malos momentos.

Dado este marco de trabajo, cualquier decisión se puede escribir como la maximización de la utilidad esperada:
\begin{align}
\max_{\theta} & \quad E[U(W)], \\
\end{align}
mediante la escogencia de cierta variable $\theta$ (gastos, planes de ahorro, compra de activos, planes de producción, etc.).

Para nuestros propósitos, la variable de decisión serán los pesos o ponderaciones del portafolio.

Adicionalmente, en el contexto de la decisión de distribución de la riqueza entre activos, el problema de maximización tendrá comúnmente las siguientes restricciones:
- universo de inversión,
- posición en los activos dados.

**Ejemplo.**

Supongamos que un inversionista debe determinar la composición óptima de su portafolio, que contiene activos y bonos. Supongamos que son los únicos instrumentos disponibles.

Sean:
- $w_s$: peso o ponderación de activos en el portafolio,
- $w_b$: peso o ponderación de bonos en el portafolio,
- $r_s$: rendimiento de los activos, y
- $r_b$: rendimiento de los bonos.

De manera que podemos escribir el problema de selección de portafolios como la maximización de la utilidad esperade de nuestra riqueza futura, la cual dependerá de nuestros rendimientos:

\begin{align}
\max_{w_s,w_b} &\quad E[U(W)]\\
\text{s. a.}   &\quad W=W_0(1+w_sr_s+w_br_b)\\
               &\quad w_s+w_b=1
\end{align}

Preguntas:
- ¿Qué significan las restricciones?
- Ya que tenemos planteado este problema básico, ¿qué haría falta para empezar a resolverlo?
___

## 2. Preferencias media-varianza

### 2.1. Utilidad media-varianza

Entonces, ¿qué funciones de utilidad deberíamos de usar en este problema de selección de portafolios?

- La respuesta es: **preferencias media-varianza**.
- Éstas serán representadas en términos de funciones de utilidad como: **utilidad media-varianza**.

Usamos la *utilidad media-varianza* en el problema de selección de portafolios dado que ésta decribe el "trade-off" entre riesgo y rendimiento que enfrentan los inversionistas. La *utilidad media-varianza* está dada por la siguiente expresión:

$$U=E[r_p]-\frac{1}{2}\gamma\sigma_p^2,$$

donde
- $E[r_p]$ es el rendimiento esperado del portafolio,
- $\sigma_p^2$ es la varianza del portafolio, y
- $\gamma$ es el coeficiente de aversión al riesgo.

#### Intuición acerca de la función de utilidad media-varianza:
- Sólo se preocupa por medias :) y varianzas :(.
- Incrementa con: si E[r] incrementa
- Decrece con:si incrementa la varianza
- Malos tiempos: varianza muy alta y rendimientos bajos
- Conecta bastante bien con la teoría moderna de portafolios, la cual caracteriza los rendimientos con medias y varianzas únicamente.
- Criticada por su limitación: supone que los inversionistas sólo se preocupan por medias y varianzas.

### 2.2. Curvas de indiferencia

*¿Recuerdan las curvas de nivel que se ven en cálculo de varias variables?*
- Bien, acá nos servirán para representar la utilidad media-varianza gráficamente.
- En el contexto de utilidad media-varianza, las curvas de nivel se llaman **curvas de indiferencia**.

Dados ciertos niveles de utilidad $U_1>U_2>U_3$, las curvas de indiferencia relativas a estos niveles de utilidad, son los lugares geométricos en el espacio de rendimiento esperado vs. volatilidad representados por las siguientes expresiones

$$U_1=E[r_p]-\gamma\sigma_p^2\Rightarrow E[r_p]=\gamma\sigma_p^2+U_1,$$

$$U_2=E[r_p]-\gamma\sigma_p^2\Rightarrow E[r_p]=\gamma\sigma_p^2+U_2,$$

$$U_3=E[r_p]-\gamma\sigma_p^2\Rightarrow E[r_p]=\gamma\sigma_p^2+U_3.$$

**Gráficamente**

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

```

```python
# Coeficiente de aversión al riesgo
g=3
# Niveles de utilidad
u1,u2,u3=3,2,1
# Vector de volatilidades
sp=np.linspace(.01,.7,100)
# Curvas de indiferencia
erp1,erp2,erp3=.5*g*sp**2+u1,.5*g*sp**2+u2,.5*g*sp**2+u3
```

```python
# Gráfica
plt.figure(figsize=(8,6))
plt.plot(sp,erp1,lw=3,label="$u_1$")
plt.plot(sp,erp2,lw=3,label="$u_2$")
plt.plot(sp,erp3,lw=3,label="$u_3$")
plt.xlabel('Volatilidad $\sigma$')
plt.ylabel('Rendimiento esperado $E[r]$')
plt.legend(loc='best')
plt.grid()
plt.show()
```

Bueno, ¿y porqué se llaman curvas de indiferencia?, ¿qué representa una curva de indiferencia?
- Sobre las combinaciones de riesgo-rendimiento en una misma curva, la utilidad es indiferente
- Representa las combinaciones de riesgo-rendimiento para las cuales la utilidad no cambia

Volviendo al problema de selección de portafolios, queremos la utilidad más alta.
- ¿Cuál de las anteriores curvas de indiferencia corresponde a la utilidad más alta? u1
- Intuitivamente, ¿porqué?
- Curvas de indiferencia para niveles de utilidad más altos, tendrán... rendimientos esperados mas altos, volatilidades mas bajas

Notamos además que las anteriores curvas de indiferencia son *paralelas* una con otra. Claro, las dibujamos con el mismo coeficiente de aversión al riesgo.

¿Cómo cambian estas curvas para coeficientes de aversión al riesgo más altos?

```python
# Coeficientes de aversión al riesgo
g1,g2,g3=3,5,7
# Nivel de utilidad
u=1
# Vector de volatilidades
sp=np.linspace(.01,.7,100)
# Curvas de indiferencia
erp1,erp2,erp3=.5*g1*sp**2+u,.5*g2*sp**2+u,.5*g3*sp**2+u
```

```python
# Gráfica
plt.figure(figsize=(8,6))
plt.plot(sp,erp1,lw=3,label="$g_1$")
plt.plot(sp,erp2,lw=3,label="$g_2$")
plt.plot(sp,erp3,lw=3,label="$g_3$")
plt.xlabel('Volatilidad $\sigma$')
plt.ylabel('Rendimiento esperado $E[r]$')
plt.legend(loc='best')
plt.grid()
plt.show()
```

¿Cómo interpretamos las anteriores gráficas?, ¿qué pasa con las personas más aversas al riesgo?
- para un nivel de volatilidad dado, una persona más aversa al riesgo requiere mayor rendimiento esperado para obtener el mismo nivel de utilidad
-

Con todo lo anterior, el problema de selección de portafolios se puede plantear como *"encontrar la curva de indeferencia más alta dado el conjunto de oportunidades de inversión y restricciones"*.

## 3. Problema de selección de portafolios: una ilustración

Ahora ilustraremos el problema de selección de portafolios con algunos datos.
- Por ahora solo queremos ilustrar gráficamente cómo se resuelve este problema. Trabajar en la intuición.
- En las siguientes dos clases nos enfocaremos en cómo resolverlo analíticamente.

Acá tenemos el rendimiento medio anual y la volatilidad para dos instrumentos usando datos de EU: instrumentos de deuda (bonos) y acciones. Supondremos que el inversionista solo puede invertir en estas dos clases de instrumentos.

```python
# Importamos pandas
import pandas as pd
```

```python
# Datos
data=pd.DataFrame(index=['Stocks','Bonds', 'CorrSB'], columns=['Mean', 'Std'])
data['Mean'] = np.array([0.119,0.0591,0.113])
data['Std'] = np.array([0.1915,0.0833,None])
data.round(4)
```

      Mean
      Std

      Stocks
      0.1190
      0.1915

      Bonds
      0.0591
      0.0833

      CorrSB
      0.1130
      None

Entonces, ¿cuál es la distribución de riqueza óptima?, o más bien, ¿cuál es la composición óptima del portafolio para un inversionista dado su nivel de aversión al riesgo?

**Primero.** Recordamos que, para dos activos, podemos trazar la frontera de mínima varianza tomando todas las posibles combinaciones de los dos activos.

De nuevo, sean:
- $w_s=w$: peso o ponderación de activos en el portafolio,
- $w_b=1-w$: peso o ponderación de bonos en el portafolio,
- $r_s$: rendimiento de los activos, y
- $r_b$: rendimiento de los bonos.

Entonces

$$E[r_p]=wE[r_{s}]+(1-w)E[r_b]$$

$$\sigma_p^2=w^2\sigma_{s}^2+(1-w)^2\sigma_b^2+2w(1-w)\rho_{s,b}\sigma_s\sigma_b$$

```python
# Vector de w variando entre 0 y 1 con n pasos
n=101
w=np.linspace(0,1,n)
# Rendimientos esperados individuales
ers,erb=data.loc['Stocks','Mean'],data.loc['Bonds','Mean']
# Volatilidades individuales
ss,sb=data.loc['Stocks','Std'],data.loc['Bonds','Std']
# Correlacion
rsb=data.loc['CorrSB','Mean']
```

```python
# Crear un DataFrame cuyas columnas sean rendimiento
# y volatilidad del portafolio para cada una de las w
# generadas
portafolios=pd.DataFrame(columns=['Ret','Vol'],index=w)
portafolios.Ret=w*ers+(1-w)*erb
portafolios.Vol=np.sqrt((w*ss)**2+((1-w)*sb)**2+2*w*(1-w)*rsb*ss*sb)
portafolios
```

      Ret
      Vol

      0.00
      0.059100
      0.083300

      0.01
      0.059699
      0.082705

      0.02
      0.060298
      0.082155

      0.03
      0.060897
      0.081650

      0.04
      0.061496
      0.081191

      0.05
      0.062095
      0.080779

      0.06
      0.062694
      0.080415

      0.07
      0.063293
      0.080099

      0.08
      0.063892
      0.079832

      0.09
      0.064491
      0.079614

      0.10
      0.065090
      0.079446

      0.11
      0.065689
      0.079328

      0.12
      0.066288
      0.079261

      0.13
      0.066887
      0.079244

      0.14
      0.067486
      0.079277

      0.15
      0.068085
      0.079361

      0.16
      0.068684
      0.079495

      0.17
      0.069283
      0.079679

      0.18
      0.069882
      0.079913

      0.19
      0.070481
      0.080195

      0.20
      0.071080
      0.080527

      0.21
      0.071679
      0.080907

      0.22
      0.072278
      0.081334

      0.23
      0.072877
      0.081808

      0.24
      0.073476
      0.082327

      0.25
      0.074075
      0.082892

      0.26
      0.074674
      0.083501

      0.27
      0.075273
      0.084153

      0.28
      0.075872
      0.084847

      0.29
      0.076471
      0.085582

      ...
      ...
      ...

      0.71
      0.101629
      0.140756

      0.72
      0.102228
      0.142414

      0.73
      0.102827
      0.144080

      0.74
      0.103426
      0.145755

      0.75
      0.104025
      0.147437

      0.76
      0.104624
      0.149128

      0.77
      0.105223
      0.150826

      0.78
      0.105822
      0.152532

      0.79
      0.106421
      0.154244

      0.80
      0.107020
      0.155964

      0.81
      0.107619
      0.157690

      0.82
      0.108218
      0.159422

      0.83
      0.108817
      0.161161

      0.84
      0.109416
      0.162905

      0.85
      0.110015
      0.164656

      0.86
      0.110614
      0.166412

      0.87
      0.111213
      0.168173

      0.88
      0.111812
      0.169940

      0.89
      0.112411
      0.171712

      0.90
      0.113010
      0.173489

      0.91
      0.113609
      0.175271

      0.92
      0.114208
      0.177057

      0.93
      0.114807
      0.178848

      0.94
      0.115406
      0.180643

      0.95
      0.116005
      0.182443

      0.96
      0.116604
      0.184246

      0.97
      0.117203
      0.186054

      0.98
      0.117802
      0.187866

      0.99
      0.118401
      0.189681

      1.00
      0.119000
      0.191500

101 rows × 2 columns

```python
# Gráfica
plt.figure(figsize=(8,6))
plt.plot(portafolios.Vol,portafolios.Ret,lw=3,label='Portafolios')
plt.xlabel('Volatilidad $\sigma$')
plt.ylabel('Rendimiento esperado $E[r]$')
plt.legend(loc='best')
plt.grid()
plt.show()
```

**Segundo.** Graficamos en la misma ventana, curvas de indiferencia.

```python
# Niveles de utilidad 0.04, 0.05, 0.06
u1,u2,u3=.07,.08,.09
# Coeficiente de aversión al riesgo
g=2
# Curvas de indiferencia
erp1,erp2,erp3=.5*g*portafolios.Vol**2+u1,.5*g*portafolios.Vol**2+u2,.5*g*portafolios.Vol**2+u3
```

```python
# Gráfica
plt.figure(figsize=(8,6))
plt.plot(portafolios.Vol,portafolios.Ret,lw=3,label='Portafolios')
plt.plot(portafolios.Vol,erp1,'--',lw=3,label="$u_1$")
plt.plot(portafolios.Vol,erp2,'--',lw=3,label="$u_2$")
plt.plot(portafolios.Vol,erp3,'--',lw=3,label="$u_3$")
plt.xlabel('Volatilidad $\sigma$')
plt.ylabel('Rendimiento esperado $E[r]$')
plt.legend(loc='best')
plt.grid()
plt.show()
```

**Tercero.** La elección óptima está dada por la curva de indiferencia para el nivel de utilidad más alto que es tangente a la frontera media-varianza.
- Claramente, esta selección depende del coeficiente de aversión al riesgo.

```python
# Gráfica con zoom

```

# Anuncios parroquiales

## 1. Quiz la siguiente clase.
## 2. Un par de artículos del WSJ y el NYT que discuten herramientas disponibles para la medición de su propia tolerancia al riesgo:
- [Artículo 1](https://www.nytimes.com/2016/02/13/your-money/as-stocks-fall-its-time-to-measure-your-risk-tolerance.html)
- [Artículo 2](https://www.wsj.com/articles/check-your-tolerance-for-investment-risk-now-before-markets-sag-1405619939)

Created with Jupyter by Esteban Jiménez Rodríguez.