import random
import numpy as np
import pandas as pd
from typing import List
from numpy import random as rd
from timeit import default_timer

a = [1, 2, 3]
b = [2, 5, 3]
print(f'List a is of type {type(a)}.')
print(f'List b is of type {type(b)}.\n')
print(f'The sum of lists a and b is {a + b}. \n\n-> This is the concatenation of both lists.')

def sum_list(l1: List[int], l2: List[int]):
"""Sum two lists term-by-term
>>> sum_list([1,2,3], [2, 5, 3])
[3, 7, 6]
"""
if len(l1) != len(l2):
raise Exception('Lists need to be of equal size.')
return [x + y for x, y in zip(l1, l2)]
assert sum_list(a,b) == [3, 7, 6]

start = default_timer()
for _ in range(10000):
a = [random.random() for _ in range(100)]
b = [random.random() for _ in range(100)]
sum_list(a, b)
end = default_timer()
time_q3 = end - start
print('This function computes the time it takes to sum two lists of 100 random numbers 10,000 times.\n')
print(f'-> In this case, it takes {time_q3} seconds.')

a = np.array([1, 2, 3])
b = np.array([2, 5, 3])
print(f'Matrix a is of type {type(a)}.')
print(f'Matrix b is of type {type(b)}.\n')
print(f'The sum of a and b is {a + b}.')
print(f'The sum of 3a and 2b is {3*a + 2*b}.\n')

start = default_timer()
for _ in range(10000):
random_array1 = np.random.random(size = 100)
random_array2 = np.random.random(size = 100)
result = random_array1 + random_array2
end = default_timer()
time_q5 = end - start
print(f'It takes {time_q5} seconds to perform 10,000 additions of randomly generated arrays of size 100.\n')
print(f'The difference between the time taken in this question and that in question 3 is {time_q5 - time_q3}.')

# question 6.a
print('6.a')
a = np.array([
[1, 2, 3],
[4, 5, 6]
])
rows, cols = a.shape
print(f'{rows} rows and {cols} columns')
# question 6.b
print('\n6.b')
b = np.array([
[1, 2],
[3, 4],
[5, 6]
])
print(b)
print('\n6.c')
try:
print (a+b)
except Exception as e:
print(f'error: {e}')
print('\n-> Explanation: We cannot sum matrices of different sizes.')
print('\n6.d')
c = np.array([
[4, 1, -1],
[4, 0, 3]
])
print('a + c =')
print(a + c)
print('\nc + a =')
print(c + a)
print("\n-> Matrix addition indeed commutative.")

mat1 = np.random.randint(10, size = (2,2))
mat2 = np.random.randint(10, size = (2,2))
print(f'Let mat1 be: \n\n{mat1}\n')
print(f'and mat2 be: \n\n{mat2}\n')

print(f'Let mat1 and mat2 be the following two matrices: \n\n{mat1}\n\n{mat2}\n')
print(f'The result of mat1 * mat2 is: \n\n{mat1 * mat2}\n\n-> It is the result of element-wise multiplication.\n')
print(f'The result of mat1 @ mat2 is: \n\n{mat1 @ mat2}\n\n-> It is the result of standard matrix multiplicaiton.\n')

print(f'Let mat1 and mat2 be the following two matrices: \n\n{mat1}\n\n{mat2}\n')
print(f'The product of mat1 and mat2 is: \n\n{mat1 @ mat2}\n')
print(f'The product of mat2 and mat1 is: \n\n{mat2 @ mat1}\n')
print('-> Because these products are unequal, matrix multiplication is not commutative.')

a = np.array([
[1],
[1]
])
print(f'Let matrix a be: \n\n{a}\n')
print('We try to multiply it by itself. The result is therefore the product of a (2x1) matrix and a (2x1) matrix.')
print('-> This multiplication is not valid because row count and column count are not equal (i.e. 2 != 1).')
try:
print(a @ a)
except Exception as e:
print(f'\nerror: {e}')
print('-> We cannot multiply matrices of incompatible sizes.')

#Question 11
mat3 = np.random.randint(0, 20, size = (3, 4))
print(f'11 - Let mat3 be the following matrix: \n\n{mat3}\n')
print(f'The transpose of mat3 is: \n\n{np.transpose(mat3)}\n\n-> It is a 4 by 3 matrix.\n')
#Question 12
print(f'12 - The transpose of the transpose of mat3 (i.e. merely mat3) is: \n\n{np.transpose(np.transpose(mat3))}\n')
#Question 13
# (a)
mat4 = np.random.randint(0, 20, size = (3,4))
mat5 = np.random.randint(0, 20, size = (4,2))
print(f'13.a - Let mat4 and mat5 be the following matrices, respectively: \n\n{mat4}\n \n{mat5}\n')
# (b)
a = np.transpose(2 * mat3 + 3 * mat4)
b = 2 * np.transpose(mat3) + 3 * np.transpose(mat4)
print(f'13.b - Let matrix a be the result of (2mat3 + 3mat4)^T, that is a = \n\n{a}\n')
print(f'Let matrix b be the result of 2(mat3)^T + 3(mat4)^T, that is b = \n\n{b}\n')
print('The function M -> M^T is a linear mapping because it preserves the operations of addition and scalar multiplication.\n')
# (c)
c = np.transpose(np.dot(mat3, mat5))
d = np.dot(np.transpose(mat5), np.transpose(mat3))
print(f'13.c - Let matrix c be the result of (mat3mat5)^T, that is c = \n\n{c}\n')
print(f'Let matrix d be the result of (mat5)^T(mat3)^T, that is d = \n\n{d}\n')
print('-> The transpose of the product of two matrices is equal to the product of the transposes of the two matrices (but in the reverse order).')

M = np.random.randint(0, 20, size = (2, 2))
N = np.random.randint(0, 20, size = (2, 2))
print('14.a - This formula is true.\n')
print(f'tr(M + N) = {np.trace(M + N)}')
print(f'tr(M) + tr(N) = {np.trace(M) + np.trace(N)}\n')
print('14.b - This formula is not true.\n')
print(f'tr(MN) = {np.trace(M @ N)}')
print(f'tr(M)tr(N) = {np.trace(M) * np.trace(N)}\n')
print('14.c - This formula is true.\n')
print(f'tr(MN) = {np.trace(M @ N)}')
print(f'tr(NM) = {np.trace(N @ M)}\n')
print('14.d - This formula is true.\n')
print(f'tr(M^T) = {np.trace(np.transpose(M))}')
print(f'tr(M) = {np.trace(M)}')