 # Defining Functions 
 
Matlab allows you to define inline functions, known as function handles or anonymous functions. You may want to do this because you didn't want to use a separate code block to define a single function and it is more clear to be able to "see" all of the implementation in a single script. In Matlab, ODE routines often take these function handles as inputs, which makes defining anonymous functions commonplace. In MatLab, to define the function, 

\begin{equation}
f(x) = \frac{1}{x^2}
\end{equation}

inline would look something like the following:

```
f = @(x) 1. / x.^2
```

where the dot operators would make the function *vectorized*. We can do the same thing in Python, but there are several ways we can implement the same behavior. The imperative approach would be to define a function, which in Python looks like the following:


```python
def f(x):
    """Returns the inverse of the square of x."""
    return 1.0 / x**2
```

We can then use this function in any of the Cells of our notebook, but only after we Run the cell defining the function. Notice how `In [ ]:` in the left margin shows the execution order of the cells. To use our function,


```python
print(f(5))
```

    0.04


Python provides an excellent set of tools for inspection. The most obvious and useful is `help`. We can use it like this.


```python
help(f)
```

    Help on function f in module __main__:
    
    f(x)
        Returns the inverse of the square of x.
    


You can also define a function as a *lambda* expression, just like how anonymous function are defined in Matlab. Anonymous functions, lambda functions, lambda expressions, function literals are all the same thing. They orginate from the concepts of lambda calculus, which defines a framework for encoding any computation in a function. In Python, we use the key word `lambda`


```python
one_over_xsquared = lambda x: 1 / x**2
one_over_xsquared(5)
```




    0.04



While there can be instances where lambda expressions are useful, there are usually *better*, more Pythonic ways of doing the same thing. Generator expressions and list comprehensions are often more expressive. In fact, the style guide for Python code defined by PEP 8 states the following:


PEP 8 : Always use a def statement instead of an assignment statement that binds a lambda expression directly to an    identifier. [Source](https://www.python.org/dev/peps/pep-0008/#programming-recommendations)

Consider these motivating examples.

- To filter out the even numbers in a sequence


```python
even = lambda x: x%2 == 0
list(filter(even, range(11)))
```




    [0, 2, 4, 6, 8, 10]




```python
[x for x in range(11) if x%2 == 0]
```




    [0, 2, 4, 6, 8, 10]



- To capitalize the first letter in a list of words


```python
list(map(lambda x: x.capitalize(), ['cat', 'dog', 'cow']))
```




    ['Cat', 'Dog', 'Cow']




```python
[x.capitalize() for x in ['cat', 'dog', 'cow']]
```




    ['Cat', 'Dog', 'Cow']



 # Linear Algebra and NumPy


```python
import numpy as np
```

Python has many built-in data structures, such as lists, tuples, dictionaries, etc. These data structures, however, are not effecient for computing numerical operations. Instead, we need to use NumPy arrays. They may look similar, but be aware they are very different.


```python
a_list = [1,2,3,4]
print(f'This is a list: {a_list}. It has type: {type(a_list)}')
```

    This is a list: [1, 2, 3, 4]. It has type: <class 'list'>



```python
a = np.array([1,2,3,4])
print(f'This is a numpy array: {a}. It has type: {type(a)}')
```

    This is a numpy array: [1 2 3 4]. It has type: <class 'numpy.ndarray'>



```python
# We define a matrix  like this
b = np.array([[0,1],[1,2],[2,3],[3,4]])

# We can print the shape of the Numpy arrays
print(f'The shape of a is: {a.shape}')
print(f'The shape of b is: {b.shape}')
```

    The shape of a is: (4,)
    The shape of b is: (4, 2)


Arrays of different sizes cannot be added or subtracted, and for multiplication we need the shapes of the two arrays to conform to the rules of inner/outer products. There are often times when we would like to work around these requirements. NumPy does this by broadcasting.

    The term broadcasting describes how numpy treats arrays with different shapes during arithmetic operations. Subject to certain constraints, the smaller array is “broadcast” across the larger array so that they have compatible shapes.
    
NumPy does broadcasting in a memory and computationally efficient way. This concept is often used in machine learning libraries like Theano or Tensorflow.


```python
# Let's define a scalar
c = 5
```

What doe we expect to happen, when we add `c` to the array `a`?


```python
print(f'a = {a}')
print(f'c = {c}\n')

a_plus_c = a + c
print(f'The shape of the sum of the numpy array, "a", and the scalar, `c`, is: {a_plus_c.shape}')
print(f'a + c = {a_plus_c}')
```

    a = [1 2 3 4]
    c = 5
    
    The shape of the sum of the numpy array, "a", and the scalar, `c`, is: (4,)
    a + c = [6 7 8 9]


What about the sum of a two-dimensional array and a scalar?


```python
print(f'b = {b}')
print(f'c = {c}\n')

print(f'b + c = {b + c}')
```

    b = [[0 1]
     [1 2]
     [2 3]
     [3 4]]
    c = 5
    
    b + c = [[5 6]
     [6 7]
     [7 8]
     [8 9]]


Broadcasting can only be done if the shape of the arrays are equal or one of the arrays has the dimension size of 1. If the shapes on the two arrays do not match, then Python first "pads" the inner dimension, and then stretches that dimension to match the other array.


```python
M = np.ones((2,3))
a = np.arange(3)

# Equivalently, we could have used the function `np.linspace` to 
# create a, which gives you further control over the creation
# of the array.
#
# a = np.linspace(0,2,3, dtype=np.int)
#

print(f'M is a matrix of shape: {M.shape}')
print(f'a is a vector of shape: {a.shape}\n')

print(f'M = {M}')
print(f'a = {a}')
```

    M is a matrix of shape: (2, 3)
    a is a vector of shape: (3,)
    
    M = [[1. 1. 1.]
     [1. 1. 1.]]
    a = [0 1 2]


To sum the matrix and vector, the first step is the pad the inner dimension

- `M.shape => (2,3)`
- `a.shape => (1,3)`

Next, the inner dimension is stretched so that the matrix and vector have the same shape:

- `M.shape => (2,3)`
- `a.shape => (2,3)`

As a result the sum of the matrix, `M`, and the vector, `a` is


```python
print(M + a)
print(f'The shape of M + a is: {(M+a).shape}')
```

    [[1. 2. 3.]
     [1. 2. 3.]]
    The shape of M + a is: (2, 3)



```python
print(a.shape)
print(a)
```

    (3,)
    [0 1 2]


Think about what happens in the following example. Let `M` be a column vector.

\begin{equation}
M = \begin{bmatrix} 0 \\ 1 \\ 2 \end{bmatrix}
\end{equation}

and let `a` be a row vector

\begin{equation}
a = \begin{bmatrix} 0 & 1 & 2 \end{bmatrix}
\end{equation}

What is the sum of `M` and `a`?


```python
M = np.arange(3).reshape((3, 1))
a = np.arange(3)

print(f'M is a matrix of shape: {M.shape}')
print(f'a is a vector of shape: {a.shape}\n')

print(f'M = {M}')
print(f'a = {a}')
```

    M is a matrix of shape: (3, 1)
    a is a vector of shape: (3,)
    
    M = [[0]
     [1]
     [2]]
    a = [0 1 2]



```python
M_plus_a = M + a

print(f'The shape is {M_plus_a.shape} with the result: \n {M_plus_a}')
```

    The shape is (3, 3) with the result: 
     [[0 1 2]
     [1 2 3]
     [2 3 4]]


7449868107001f114768f26be67908ec193052e1
ipynb
Jupyter Notebook
Notebooks/Basics/Functions-Numpy.ipynb
jcschulz/ae269
5c467a6e70808bb00e27ffdb8bb0495e0c820ca0
MIT
Notebooks/Basics/Functions-Numpy.ipynb
jcschulz/ae269
5c467a6e70808bb00e27ffdb8bb0495e0c820ca0
MIT
Notebooks/Basics/Functions-Numpy.ipynb
jcschulz/ae269
5c467a6e70808bb00e27ffdb8bb0495e0c820ca0
MIT
Qwen/Qwen-72B
1. YES
2. YES