## exercise 0.4.5
import numpy as np

# Setup two ararys
x = np.arange(1, 6)
y = np.arange(2, 12, 2)
# Have a look at them by typing 'x' and 'y' in the console

# There's a difference between matrix multiplication and elementwise
# multiplication, and specifically in Python its also important if you
# are using the multiply operator "*" on an array object or a matrix object!

# Use the * operator to multiply the two arrays:
x * y

# Now, convert the arrays into matrices -
x = np.asmatrix(np.arange(1, 6))
y = np.asmatrix(np.arange(2, 12, 2))
# Again, have a look at them by typing 'x' and 'y' in the console

# Try using the * operator just as before now (this should not work!):
x * y
# You should now get an error - try to explain why 
# (comment or remove the line to run the rest of the script).

# array and matrix are two data structures added by NumPy package to the list of
# basic data structures in Python (lists, tuples, sets). We shall use both
# array and matrix structures extensively throughout this course, therefore
# make sure that you understand differences between them
# (multiplication, dimensionality) and that you are able to convert them one
# to another (asmatrix(), asarray() functions).
# Generally speaking, array objects are used to represent scientific, numerical,
# N-dimensional data. matrix objects can be very handy when it comes to
# algebraic operations on 2-dimensional matrices.

# The ambiguity can be circumvented by using explicit function calls:
np.transpose(y)  # transposition/transpose of y
y.transpose()  # also transpose
y.T  # also transpose

np.multiply(x, y)  # element-wise multiplication

np.dot(x, y.T)  # matrix multiplication
x @ y.T  # also matrix multiplication


# There are various ways to make certain type of matrices.
a1 = np.array([[1, 2, 3], [4, 5, 6]])  # define explicitly
a2 = np.arange(1, 7).reshape(2, 3)  # reshape range of numbers
a3 = np.zeros([3, 3])  # zeros array
a4 = np.eye(3)  # diagonal array
a5 = np.random.rand(2, 3)  # random array
a6 = a1.copy()  # copy
a7 = a1  # alias
m1 = np.matrix("1 2 3; 4 5 6; 7 8 9")  # define matrix by string
m2 = np.asmatrix(a1.copy())  # copy array into matrix
m3 = np.mat(np.array([1, 2, 3]))  # map array onto matrix
a8 = np.asarray(m1)  # map matrix onto array

# It is easy to extract and/or modify selected items from arrays/matrices.
# Here is how you can index matrix elements:
m = np.matrix("1 2 3; 4 5 6; 7 8 9")
m[0, 0]  # first element
m[-1, -1]  # last element
m[0, :]  # first row
m[:, 1]  # second column
m[1:3, -1]  # view on selected rows&columns

# Similarly, you can selectively assign values to matrix elements or columns:
m[-1, -1] = 10000
m[0:2, -1] = np.matrix("100; 1000")
m[:, 0] = 0

# Logical indexing can be used to change or take only elements that
# fulfil a certain constraint, e.g.
m2[m2 > 0.5]  # display values in m2 that are larger than 0.5
m2[m2 < 0.5] = 0  # set all elements that are less than 0.5 to 0

# Below, several examples of common matrix operations,
# most of which we will use in the following weeks.
# First, define two matrices:
m1 = 10 * np.mat(np.ones([3, 3]))
m2 = np.mat(np.random.rand(3, 3))

m1 + m2  # matrix summation
m1 * m2  # matrix product
np.multiply(m1, m2)  # element-wise multiplication
m1 > m2  # element-wise comparison
m3 = np.hstack((m1, m2))  # combine/concatenate matrices horizontally
# note that this is not equivalent to e.g.
#   l = [m1, m2]
# in which case l is a list, and l[0] is m1
m4 = np.vstack((m1, m2))  # combine/concatenate matrices vertically
m3.shape  # shape of matrix
m3.mean()  # mean value of all the elements
m3.mean(axis=0)  # mean values of the columns
m3.mean(axis=1)  # mean values of the rows
m3.transpose()  # transpose, also: m3.T
m2.I  # compute inverse matrix