%load_ext watermark

%watermark -a 'Sebastian Raschka' -p numpy

"""
Explanation: Accompanying code examples of the book "Introduction to Artificial Neural Networks and Deep Learning: A Practical Guide with Applications in Python" by Sebastian Raschka. All code examples are released under the MIT license. If you find this content useful, please consider supporting the work by buying a copy of the book.
Other code examples and content are available on GitHub. The PDF and ebook versions of the book are available through Leanpub.
Appendix F - Introduction to NumPy

Scientific Computing in NumPy
N-dimensional Arrays
Array Construction Routines
Array Indexing
Array Math and Universal Functions
Broadcasting
Advanced Indexing -- Memory Views and Copies
Random Number Generators
Reshaping Arrays
Linear Algebra with NumPy Arrays
Conclusion
End of explanation
"""


import numpy as np

lst = [[1, 2, 3], [4, 5, 6]]
ary1d = np.array(lst)
ary1d

"""
Explanation: Scientific Computing in NumPy
This appendix offers a quick tour of the NumPy library for working with multi-dimensional arrays in Python. NumPy (short for Numerical Python) was created by Travis Oliphant in 2005, by merging Numarray into Numeric. Since then, the open source NumPy library has evolved into an essential library for scientific computing in Python and has become a building block of many other scientific libraries, such as SciPy, scikit-learn, pandas, and others.
What makes NumPy so particularly attractive to the scientific community is that it provides a convenient Python interface for working with multi-dimensional array data structures efficiently; the NumPy array data structure is also called ndarray, which is short for n-dimensional array. 
In addition to being mostly implemented in C and using Python as glue, the main reason why NumPy is so efficient for numerical computations is that NumPy arrays use contiguous blocks of memory that can be efficiently cached by the CPU. In contrast, Python lists are arrays of pointers to objects in random locations in memory, which cannot be easily cached and come with a more expensive memory-look-up. However, the computational efficiency and low-memory footprint come at a cost: NumPy arrays have a fixed size and are homogenous, which means that all elements must have the same type. Homogenous ndarray objects have the advantage that NumPy can carry out operations using efficient C loops and avoid expensive type checks and other overheads of the Python API. While adding and removing elements from the end of a Python list is very efficient, altering the size of a NumPy array is very expensive since it requires to create a new array and carry over the contents of the old array that we want to expand or shrink. 
Besides being more efficient for numerical computations than native Python code, NumPy can also be more elegant and readable due to vectorized operations and broadcasting, which are features that we will explore in this appendix. While this appendix should be sufficient to follow the code examples in this book if you are new to NumPy, there are many advanced NumPy topics that are beyond the scope of this book. If you are interested in a more in-depth coverage of NumPy, I selected a few resources that could be useful to you:

Rougier, N.P., 2016. From Python to Numpy.
Oliphant, T.E., 2015. A Guide to NumPy: 2nd Edition. USA: Travis Oliphant, independent publishing.
Varoquaux, G., Gouillart, E., Vahtras, O., Haenel, V., Rougier, N.P., Gommers, R., Pedregosa, F., Jędrzejewski-Szmek, Z., Virtanen, P., Combelles, C. and Pinte, D., 2015. Scipy Lecture Notes.
The official NumPy documentation

N-dimensional Arrays
NumPy is built around ndarrays objects, which are high-performance multi-dimensional array data structures. Intuitively, we can think of a one-dimensional NumPy array as a data structure to represent a vector of elements -- you may think of it as a fixed-size Python list where all elements share the same type. Similarly, we can think of a two-dimensional array as a data structure to represent a matrix or a Python list of lists. While NumPy arrays can have up to 32 dimensions if it was compiled without alterations to the source code, we will focus on lower-dimensional arrays for the purpose of illustration in this introduction.
Now, let us get started with NumPy by calling the array function to create a two-dimensional NumPy array, consisting of two rows and three columns, from a list of lists:
End of explanation
"""


ary1d.dtype

"""
Explanation: By default, NumPy infers the type of the array upon construction. Since we passed Python integers to the array, the ndarray object ary1d should be of type int64 on a 64-bit machine, which we can confirm by accessing the dtype attribute:
End of explanation
"""


float32_ary = ary1d.astype(np.float32)
float32_ary

float32_ary.dtype

"""
Explanation: If we want to construct NumPy arrays of different types, we can pass an argument to the dtype parameter of the array function, for example np.int32 to create 32-bit arrays. For a full list of supported data types, please refer to the official NumPy documentation. Once an array has been constructed, we can downcast or recast its type via the astype method as shown in the following example:
End of explanation
"""


ary2d = np.array([[1, 2, 3], 
                  [4, 5, 6]], dtype='int64')
ary2d.itemsize

"""
Explanation: In the following sections we will cover many more aspects of NumPy arrays; however, to conclude this basic introduction to the ndarray object, let us take a look at some of its handy attributes.
For instance, the itemsize attribute returns the size of a single array element in bytes:
End of explanation
"""


ary2d.size

"""
Explanation: The code snippet above returned 8, which means that each element in the array (remember that ndarrays are homogeneous) takes up 8 bytes in memory. This result makes sense since the array ary2d has type int64 (64-bit integer), which we determined earlier, and 8 bits equals 1 byte. (Note that 'int64' is just a shorthand for np.int64.)
To return the number of elements in an array, we can use the size attribute, as shown below:
End of explanation
"""


ary2d.ndim

"""
Explanation: And the number of dimensions of our array (Intuitively, you may think of dimensions as the rank of a tensor) can be obtained via the ndim attribute:
End of explanation
"""


ary2d.shape

"""
Explanation: If we are interested in the number of elements along each array dimension (in the context of NumPy arrays, we may also refer to them as axes), we can access the shape attribute as shown below:
End of explanation
"""


np.array([1, 2, 3]).shape

"""
Explanation: The shape is always a tuple; in the code example above, the two-dimensional ary object has two rows and three columns, (2, 3), if we think of it as a matrix representation.
Similarly, the shape of the one-dimensional array only contains a single value:
End of explanation
"""


scalar = np.array(5)
scalar

scalar.ndim

scalar.shape

"""
Explanation: Instead of passing lists or tuples to the array function, we can also provide a single float or integer, which will construct a zero-dimensional array (for instance, a representation of a scalar):
End of explanation
"""


def generator():
    for i in range(10):
        if i % 2:
            yield i

gen = generator()
np.fromiter(gen, dtype=int)

# using 'comprehensions' the following
# generator expression is equivalent to
# the `generator` above

generator_expression = (i for i in range(10) if i % 2)
np.fromiter(generator_expression, dtype=int)

"""
Explanation: Array Construction Routines
In the previous section, we used the array function to construct NumPy arrays from Python objects that are sequences or nested sequences -- lists, tuples, nested lists, iterables, and so forth. While array is often our go-to function for creating ndarray objects, NumPy implements a variety of functions for constructing arrays that may come in handy in different contexts. In this section, we will take a quick peek at those that we use most commonly -- you can find a  more comprehensive list in the official documentation.
The array function works with most iterables in Python, including lists, tuples, and range objects; however, array does not support generator expression. If we want parse generators directly, however, we can use the fromiter function as demonstrated below:
End of explanation
"""


np.ones((3, 3))

np.zeros((3, 3))

"""
Explanation: Next, we will take at two functions that let us create ndarrays of consisting of either ones and zeros by only specifying the elements along each axes (here: three rows and three columns):
End of explanation
"""


np.eye(3)

np.diag((3, 3, 3))

"""
Explanation: Creating arrays of ones or zeros can also be useful as placeholder arrays, in cases where we do not want to use the initial values for computations but want to fill it with other values right away. If we do not need the initial values (for instance, '0.' or '1.'), there is also numpy.empty, which follows the same syntax as numpy.ones and np.zeros. However, instead of filling the array with a particular value, the empty function creates the array with non-sensical values from memory. We can think of zeros as a function that creates the array via empty and then sets all its values to 0. -- in practice, a difference in speed is not noticeable, though.  
NumPy also comes with functions to create identity matrices and diagonal matrices as ndarrays that can be useful in the context of linear algebra -- a topic that we will explore later in this appendix.
End of explanation
"""


np.arange(4., 10.)

"""
Explanation: Lastly, I want to mention two very useful functions for creating sequences of numbers within a specified range, namely, arange and linspace. NumPy's arange function follows the same syntax as Python's range objects: If two arguments are provided, the first argument represents the start value and the second value defines the stop value of a half-open interval:
End of explanation
"""


np.arange(5)

"""
Explanation: Notice that arange also performs type inference similar to the array function. If we only provide a single function argument, the range object treats this number as the endpoint of the interval and starts at 0:
End of explanation
"""


np.arange(1., 11., 2)

"""
Explanation: Similar to Python's range, a third argument can be provided to define the step (the default step size is 1). For example, we can obtain an array of all uneven values between one and ten as follows:
End of explanation
"""


np.linspace(0., 1., num=5)

"""
Explanation: The linspace function is especially useful if we want to create a particular number of evenly spaced values in a specified half-open interval:
End of explanation
"""


ary = np.array([1, 2, 3])
ary[0]

"""
Explanation: Array Indexing
In this section, we will go over the basics of retrieving NumPy array elements via different indexing methods. Simple NumPy indexing and slicing works similar to Python lists, which we will demonstrate in the following code snippet, where we retrieve the first element of a one-dimensional array:
End of explanation
"""


ary[:2] # equivalent to ary[0:2]

"""
Explanation: Also, the same Python semantics apply to slicing operations. The following example shows how to fetch the first two elements in ary:
End of explanation
"""


ary = np.array([[1, 2, 3],
                [4, 5, 6]])

ary[0, 0] # upper left

ary[-1, -1] # lower right

ary[0, 1] # first row, second column

"""
Explanation: If we work with arrays that have more than one dimension or axis, we separate our indexing or slicing operations by commas as shown in the series of examples below:
End of explanation
"""


ary[0] # entire first row

ary[:, 0] # entire first column

ary[:, :2] # first two columns

ary[0, 0]

"""
Explanation: 
End of explanation
"""


lst = [[1, 2, 3], [4, 5, 6]]

for row_idx, row_val in enumerate(lst):
    for col_idx, col_val in enumerate(row_val):
        lst[row_idx][col_idx] += 1
lst

"""
Explanation: Array Math and Universal Functions
In the previous sections, you learned how to create NumPy arrays and how to access different elements in an array. It is about time that we introduce one of the core features of NumPy that makes working with ndarray so efficient and convenient: vectorization. While we typically use for-loops if we want to perform arithmetic operations on sequence-like objects, NumPy provides vectorized wrappers for performing element-wise operations implicitly via so-called ufuncs -- short for universal functions.
As of this writing, there are more than 60 ufuncs available in NumPy; ufuncs are implemented in compiled C code and very fast and efficient compared to vanilla Python. In this section, we will take a look at the most commonly used ufuncs, and I recommend you to check out the official documentation for a complete list.
To provide an example of a simple ufunc for element-wise addition, consider the following example, where we add a scalar (here: 1) to each element in a nested Python list:
End of explanation
"""


lst = [[1, 2, 3], [4, 5, 6]]
[[cell + 1 for cell in row] for row in lst]

"""
Explanation: This for-loop approach is very verbose, and we could achieve the same goal more elegantly using list comprehensions:
End of explanation
"""


ary = np.array([[1, 2, 3], [4, 5, 6]])
ary = np.add(ary, 1)
ary

"""
Explanation: We can accomplish the same using NumPy's ufunc for element-wise scalar addition as shown below:
End of explanation
"""


ary + 1

ary**2

"""
Explanation: The ufuncs for basic arithmetic operations are add, subtract, divide, multiply, and exp (exponential). However, NumPy uses operator overloading so that we can use mathematical operators (+, -, /, *, and **) directly:
End of explanation
"""


ary = np.array([[1, 2, 3], 
                [4, 5, 6]])

np.add.reduce(ary) # column sumns

"""
Explanation: Above, we have seen examples of binary ufuncs, which are ufuncs that take two arguments as an input. In addition, NumPy implements several useful unary ufuncs, such as log (natural logarithm), log10 (base-10 logarithm), and sqrt (square root).
Often, we want to compute the sum or product of array element along a given axis. For this purpose, we can use a ufunc's reduce operation. By default, reduce applies an operation along the first axis (axis=0). In the case of a two-dimensional array, we can think of the first axis as the rows of a matrix. Thus, adding up elements along rows yields the column sums of that matrix as shown below:
End of explanation
"""


np.add.reduce(ary, axis=1) # row sums

"""
Explanation: To compute the row sums of the array above, we can specify axis=1:
End of explanation
"""


ary.sum(axis=0) # column sums

"""
Explanation: While it can be more intuitive to use reduce as a more general operation, NumPy also provides shorthands for specific operations such as product and sum. For example, sum(axis=0) is equivalent to add.reduce:
End of explanation
"""


ary.sum()

"""
Explanation: As a word of caution, keep in mind that product and sum both compute the product or sum of the entire array if we do not specify an axis:
End of explanation
"""


ary1 = np.array([1, 2, 3])
ary2 = np.array([4, 5, 6])

ary1 + ary2

"""
Explanation: Other useful unary ufuncs are:

mean (computes arithmetic average)
std (computes the standard deviation)
var (computes variance)
np.sort (sorts an array)
np.argsort (returns indices that would sort an array)
np.min (returns the minimum value of an array)
np.max (returns the maximum value of an array)
np.argmin (returns the index of the minimum value)
np.argmax (returns the index of the maximum value)
array_equal (checks if two arrays have the same shape and elements)

Broadcasting
A topic we glanced over in the previous section is broadcasting. Broadcasting allows us to perform vectorized operations between two arrays even if their dimensions do not match by creating implicit multidimensional grids. You already learned about ufuncs in the previous section where we performed element-wise addition between a scalar and a multidimensional array, which is just one example of broadcasting. 

Naturally, we can also perform element-wise operations between arrays of equal dimensions:
End of explanation
"""


ary3 = np.array([[4, 5, 6], 
                 [7, 8, 9]])

ary3 + ary1 # similarly, ary1 + ary3

"""
Explanation: In contrast to what we are used from linear algebra, we can also add arrays of different shapes. In the example above, we will add a one-dimensional to a two-dimensional array, where NumPy creates an implicit multidimensional grid from the one-dimensional array ary1:
End of explanation
"""


try: 
    ary3 + np.array([1, 2])
except ValueError as e:
    print('ValueError:', e)

"""
Explanation: Keep in mind though that the number of elements along the explicit axes and the implicit grid have to match so that NumPy can perform a sensical operation. For instance, the following example should raise a ValueError, because NumPy attempts to add the elements from the first axis of the left array (2 elements) to the first axis of the right array (3 elements):
End of explanation
"""


ary3 + np.array([[1], [2]])

np.array([[1], [2]]) + ary3

"""
Explanation: So, if we want to add the 2-element array to the columns in ary3, the 2-element array must have two elements along its first axis as well:
End of explanation
"""


ary = np.array([[1, 2, 3],
                [4, 5, 6]])

first_row = ary[0]
first_row += 99
ary

"""
Explanation: Advanced Indexing -- Memory Views and Copies
In the previous sections, we have used basic indexing and slicing routines. It is important to note that basic integer-based indexing and slicing create so-called views of NumPy arrays in memory. Working with views can be highly desirable since it avoids making unnecessary copies of arrays to save memory resources. To illustrate the concept of memory views, let us walk through a simple example where we access the first row in an array, assign it to a variable, and modify that variable:
End of explanation
"""


ary = np.array([[1, 2, 3],
                [4, 5, 6]])

first_row = ary[:1]
first_row += 99
ary

ary = np.array([[1, 2, 3],
                [4, 5, 6]])

center_col = ary[:, 1]
center_col += 99
ary

"""
Explanation: As we can see in the example above, changing the value of first_row also affected the original array. The reason for this is that ary[0] created a view of the first row in ary, and its elements were then incremented by 99. The same concept applies to slicing operations:
End of explanation
"""


second_row = ary[1].copy()
second_row += 99
ary

"""
Explanation: If we are working with NumPy arrays, it is always important to be aware that slicing creates views -- sometimes it is desirable since it can speed up our code by avoiding to create unnecessary copies in memory. However, in certain scenarios we want force a copy of an array; we can do this via the copy method as shown below:
End of explanation
"""


ary = np.array([[1, 2, 3],
                [4, 5, 6]])

first_row = ary[:1]
first_row += 99
ary

np.may_share_memory(first_row, ary)

second_row = ary[1].copy()
second_row += 99
ary

np.may_share_memory(second_row, ary)

"""
Explanation: One way to check if two arrays might share memory is to use the may_share_memory function from NumPy. However, be aware that it uses a heuristic and can return false negatives or false positives. The code snippets shows an example of may_share_memory applied to the view (first_row) and copy (second_row) of the array elements from ary:
End of explanation
"""


ary = np.array([[1, 2, 3],
                [4, 5, 6]])

ary[:, [0, 2]] # first and and last column

this_is_a_copy = ary[:, [0, 2]]
this_is_a_copy += 99
ary

ary[:, [2, 0]] # first and and last column

"""
Explanation: In addition to basic single-integer indexing and slicing operations, NumPy supports advanced indexing routines called fancy indexing. Via fancy indexing, we can use tuple or list objects of non-contiguous integer indices to return desired array elements. Since fancy indexing can be performed with non-contiguous sequences, it cannot return a view -- a contiguous slice from memory. Thus, fancy indexing always returns a copy of an array -- it is important to keep that in mind. The following code snippets show some fancy indexing examples:
End of explanation
"""


ary = np.array([[1, 2, 3],
                [4, 5, 6]])

greater3_mask = ary > 3
greater3_mask

"""
Explanation: Finally, we can also use Boolean masks for indexing -- that is, arrays of True and False values. Consider the following example, where we return all values in the array that are greater than 3:
End of explanation
"""


ary[greater3_mask]

"""
Explanation: Using these masks, we can select elements given our desired criteria:
End of explanation
"""


ary[(ary > 3) & (ary % 2 == 0)]

"""
Explanation: We can also chain different selection criteria using the logical and operator '&' or the logical or operator '|'. The example below demonstrates how we can select array elements that are greater than 3 and divisible by 2:
End of explanation
"""


np.random.seed(123)
np.random.rand(3)

"""
Explanation: Note that indexing using Boolean arrays is also considered "fancy indexing" and thus returns a copy of the array.
Random Number Generators
In machine learning and deep learning, we often have to generate arrays of random numbers -- for example, the initial values of our model parameters before optimization. NumPy has a random subpackage to create random numbers and samples from a variety of distributions conveniently. Again, I encourage you to browse through the more comprehensive numpy.random documentation for a more comprehensive list of functions for random sampling.
To provide a brief overview of the pseudo-random number generators that we will use most commonly, let's start with drawing a random sample from a uniform distribution:
End of explanation
"""


rng1 = np.random.RandomState(seed=123)
rng1.rand(3)

"""
Explanation: In the code snippet above, we first seeded NumPy's random number generator. Then, we drew three random samples from a uniform distribution via random.rand in the half-open interval [0, 1). I highly recommend the seeding step in practical applications as well as in research projects, since it ensures that our results are reproducible. If we run our code sequentially -- for example, if we execute a Python script -- it should be sufficient to seed the random number generator only once at the beginning to enforce reproducible outcomes between different runs. However, it is often useful to create separate RandomState objects for various parts of our code, so that we can test methods of functions reliably in unit tests. Working with multiple, separate RandomState objects can also be useful if we run our code in non-sequential order -- for example if we are experimenting with our code in interactive sessions or Jupyter Notebook environments. 
The example below shows how we can use a RandomState object to create the same results that we obtained via np.random.rand in the previous code snippet:
End of explanation
"""


rng2 = np.random.RandomState(seed=123)
z_scores = rng2.randn(100, 2)

"""
Explanation: Another useful function that we will often use in practice is randn, which returns a random sample of floats from a standard normal distribution $N(\mu, \sigma^2)$, where the mean, ($\mu$) is zero and unit variance ($\sigma = 1$). The example below creates a two-dimensional array of such z-scores:
End of explanation
"""


%matplotlib inline
import matplotlib.pyplot as plt

plt.scatter(z_scores[:, 0], z_scores[:, 1])
#plt.savefig('images/numpy-intro/random_1.png', dpi=600)
plt.show()

"""
Explanation: NumPy's random functions rand and randn take an arbitrary number of integer arguments, where each integer argument specifies the number of elements along a given axis -- the z_scores array should now refer to an array of 100 rows and two columns. Let us now visualize how our random sample looks like using matplotlib:
End of explanation
"""


rng3 = np.random.RandomState(seed=123)
scores = 2. * rng3.randn(100, 2) + 5.

plt.scatter(scores[:, 0], scores[:, 1])
#plt.savefig('images/numpy-intro/random_2.png', dpi=600)
plt.show()

"""
Explanation: If we want to draw a random sample from a non-standard normal distribution, we can simply add a scalar value to the array elements to shift the mean of the sample, and we can multiply the sample by a scalar to change its standard deviation. The following code snippet will change the properties of our random sample as if it has been drawn from a Normal distribution $N(5, 4)$:
End of explanation
"""


ary1d = np.array([1, 2, 3, 4, 5, 6])
ary2d_view = ary1d.reshape(2, 3)
ary2d_view

np.may_share_memory(ary2d_view, ary1d)

"""
Explanation: Note that in the example above, we multiplied the z-scores by a standard deviation of 2 -- the standard deviation of a sample is the square root of the variance $\sigma^2$. Also, notice that all elements in the array were updated when we multiplied it by a scalar or added a scalar. In the next section, we will discuss NumPy's capabilities regarding such element-wise operations in more detail.
Reshaping Arrays
In practice, we often run into situations where existing arrays do not have the right shape to perform certain computations. As you might remember from the beginning of this appendix, the size of NumPy arrays is fixed. Fortunately, this does not mean that we have to create new arrays and copy values from the old array to the new one if we want arrays of different shapes -- the size is fixed, but the shape is not. NumPy provides a reshape methods that allow us to obtain a view of an array with a different shape. 
For example, we can reshape a one-dimensional array into a two-dimensional one using reshape as follows:
End of explanation
"""


ary1d.reshape(2, -1)

ary1d.reshape(-1, 2)

"""
Explanation: While we need to specify the desired elements along each axis, we need to make sure that the reshaped array has the same number of elements as the original one. However, we do not need to specify the number elements in each axis; NumPy is smart enough to figure out how many elements to put along an axis if only one axis is unspecified (by using the placeholder -1):
End of explanation
"""


ary2d = np.array([[1, 2, 3],
                  [4, 5, 6]])

ary2d.reshape(-1)

"""
Explanation: We can, of course, also use reshape to flatten an array:
End of explanation
"""


ary2d.ravel()

"""
Explanation: Note that NumPy also has a shorthand for that called ravel:
End of explanation
"""


np.may_share_memory(ary2d.flatten(), ary2d)

np.may_share_memory(ary2d.ravel(), ary2d)

"""
Explanation: A function related to ravel is flatten. In contrast to ravel, flatten returns a copy, though:
End of explanation
"""


ary = np.array([1, 2, 3])

# stack along the first axis
np.concatenate((ary, ary)) 

ary = np.array([[1, 2, 3]])

# stack along the first axis (here: rows)
np.concatenate((ary, ary), axis=0) 

# stack along the second axis (here: column)
np.concatenate((ary, ary), axis=1) 

"""
Explanation: Sometimes, we are interested in merging different arrays. Unfortunately, there is no efficient way to do this without creating a new array, since NumPy arrays have a fixed size. While combining arrays should be avoided if possible -- for reasons of computational efficiency -- it is sometimes necessary. To combine two or more array objects, we can use NumPy's concatenate function as shown in the following examples:
End of explanation
"""


row_vector = np.array([1, 2, 3])
row_vector

"""
Explanation: Linear Algebra with NumPy Arrays
Most of the operations in machine learning and deep learning are based on concepts from linear algebra. In this section, we will take a look how to perform basic linear algebra operations using NumPy arrays.

I want to mention that there is also a special matrix type in NumPy. NumPy matrix objects are analogous to NumPy arrays but are restricted to two dimensions. Also, matrices define certain operations differently than arrays; for instance, the * operator performs matrix multiplication instead of element-wise multiplication. However, NumPy matrix is less popular in the science community compared to the more general array data structure. Since we are also going to work with arrays that have more than two dimensions (for example, when we are working with convolutional neural networks), we will not use NumPy matrix data structures in this book.

Intuitively, we can think of one-dimensional NumPy arrays as data structures that represent row vectors:
End of explanation
"""


column_vector = np.array([[1, 2, 3]]).reshape(-1, 1)
column_vector

"""
Explanation: Similarly, we can use two-dimensional arrays to create column vectors:
End of explanation
"""


row_vector[:, np.newaxis]

"""
Explanation: Instead of reshaping a one-dimensional array into a two-dimensional one, we can simply add a new axis as shown below:
End of explanation
"""


row_vector[:, None]

"""
Explanation: Note that in this context, np.newaxis behaves like None:
End of explanation
"""


matrix = np.array([[1, 2, 3], 
                   [4, 5, 6]])

np.matmul(matrix, column_vector)

"""
Explanation: All three approaches listed above, using reshape(-1, 1), np.newaxis, or None yield the same results -- all three approaches create views not copies of the row_vector array.
As we remember from the Linear Algebra appendix, we can think of a column vector as a matrix consisting only of one column. To perform matrix multiplication between matrices, we learned that number of columns of the left matrix must match the number of rows of the matrix to the right. In NumPy, we can perform matrix multiplication via the matmul function:
End of explanation
"""


np.matmul(matrix, row_vector)

"""
Explanation: However, if we are working with matrices and vectors, NumPy can be quite forgiving if the dimensions of matrices and one-dimensional arrays do not match exactly -- thanks to broadcasting. The following example yields the same result as the matrix-column vector multiplication, except that it returns a one-dimensional array instead of a two-dimensional one:
End of explanation
"""


np.matmul(row_vector, row_vector)

"""
Explanation: Similarly, we can compute the dot-product between two vectors (here: the vector norm)
End of explanation
"""


np.dot(row_vector, row_vector)

np.dot(matrix, row_vector)

np.dot(matrix, column_vector)

"""
Explanation: NumPy has a special dot function that behaves similar to matmul on pairs of one- or two-dimensional arrays -- its underlying implementation is different though, and one or the other can be slightly faster on specific machines and versions of BLAS:
End of explanation
"""


matrix = np.array([[1, 2, 3], 
                   [4, 5, 6]])

matrix.transpose()

"""
Explanation: Similar to the examples above we can use matmul or dot to multiply two matrices (here: two-dimensional arrays). In this context, NumPy arrays have a handy transpose method to transpose matrices if necessary:
End of explanation
"""


np.matmul(matrix, matrix.transpose())

"""
Explanation: 
End of explanation
"""


matrix.T

"""
Explanation: While transpose can be annoyingly verbose for implementing linear algebra operations -- think of PEP8's 80 character per line recommendation -- NumPy has a shorthand for that: T:
End of explanation
"""


