import numpy as np

"""
Explanation: Creando una sistema de Algebra Lineal
En esta tarea seran guiados paso a paso en como realizar un sistema de arrays en Python para realizar operaciones de algebra lineal. 
Pero antes... (FAQ)
Como se hace en la realidad? En la practica, se usan paqueterias funcionales ya probadas, en particular numpy, que contiene todas las herramientas necesarias para hacer computo numerico en Python.
Por que hacer esta tarea entonces? Python es un lenguage disenado para la programacion orientada a objetos. Al hacer la tarea desarrollaran experiencia en este tipo de programacion que les permitira crear objetos en el futuro cuando lo necesiten, y entender mejor como funciona numpy y en general, todas las herramientas de Python. Ademas, en esta tarea tambien aprenderan la forma de usar numpy simultaneamente.
Como comenzar con numpy? En la tarea necesitaremos importar la libreria numpy, que contiene funciones y clases que no son parte de Python basico. Recuerden que Python no es un lenguage de computo cientifico, sino de programacion de proposito general. No esta disenado para hacer algebra lineal, sin embargo, tiene librerias extensas y bien probadas que permiten lograrlo. Anaconda es una distribucion de Python que ademas de instalarlo incluye varias librerias de computo cientifico como numpy. Si instalaron Python por separado deberan tambien instalar numpy manualmente.
Antes de comenzar la tarea deberan poder correr:
End of explanation
"""


x = [1,2,3]
y = [4,5,6]
x + y

"""
Explanation: Lo que el codigo anterior hace es asociar al nombre np todas las herramientas de la libreria numpy. Ahora podremos llamar funciones de numpy como np.&lt;numpy_fun&gt;. El nombre np es opcional, pueden cambiarlo pero necesitaran ese nombre para acceder a las funciones de numpy como &lt;new_name&gt;.&lt;numpy_fun&gt;. Otra opcion es solo inlcuir import numpy, en cuya caso las funciones se llaman como numpy.&lt;numpy_fun&gt;. Para saber mas del sistema de modulos pueden revisar la liga https://docs.python.org/2/tutorial/modules.html
I. Creando una clase Array
Python incluye nativo el uso de listas (e.g. x = [1,2,3]). El problema es que las listas no son herramientas de computo numerico, Python ni siquiera entiende una suma de ellas. De hecho, la suma la entiende como concatenacion:
End of explanation
"""


B = np.array([[1,2,3], [4,5,6]]) # habiendo corrido import numpy as np

"""
Explanation: Vamos a construir una clase Array que incluye a las matrices y a los vectores. Desde el punto de vista computacional, un vector es una matriz de una columna. En clase vimos que conviene pensar a las matrices como transformacion de vectores, sin embargo, desde el punto de vista computacional, como la regla de suma y multiplicacion es similar, conviene pensarlos ambos como arrays, que es el nombre tradicional en programacion
Computacionalmente, que es un array? Tecnicamente, es una lista de listas, todas del mismo tamano, cada uno representando una fila (fila o columna es optativo, haremos filas porque asi lo hace numpy, pero yo previero columnas). Por ejemplo, la lista de listas
[[1,2,3],[4,5,6]]
Corresponde a la matriz
$$
\begin{bmatrix} 1 & 2 & 3 \ 4 & 5 & 6  \end{bmatrix}
$$
The numpy way
End of explanation
"""


B + 2*B # Python sabe sumar y multiplicar arrays como algebra lineal

"""
Explanation: Es posible sumar matrices y multiplicarlas por escalares
End of explanation
"""


np.matmul(B.transpose(), B) # B^t*B

"""
Explanation: Las matrices de numpy se pueden multiplicar con la funcion matmul dentro de numpy
End of explanation
"""


B[1,1]

"""
Explanation: Los arrays the numpy pueden accesarse con indices y slices
Una entrada especifica:
End of explanation
"""


B[1,:]

"""
Explanation: Una fila entera:
End of explanation
"""


B[:,2]

"""
Explanation: Una columna entera:
End of explanation
"""


B[0:2,0:2]

"""
Explanation: Un subbloque (notar que un slice n:m es n,n+1,...,m-1
End of explanation
"""


B.shape

"""
Explanation: En numpy podemos saber la dimension de un array con el campo shape de numpy
End of explanation
"""


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

"""
Explanation: Numpy es listo manejando listas simples como vectores
End of explanation
"""


class Array:
    "Una clase minima para algebra lineal"    
    def __init__(self, list_of_rows): 
        "Constructor"
        self.data = list_of_rows
        self.shape = (len(list_of_rows), len(list_of_rows[0]))

A = Array([[1,2,3], [4,5,6]])
A.__dict__ # el campo escondido __dict__ permite acceder a las propiedades de clase de un objeto

A.data

A.shape

"""
Explanation: Comenzando desde cero...
End of explanation
"""


Array([[1,2,3], [4,5,6]])

print(Array([[1,2,3], [4,5,6]]))

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

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

"""
Explanation: El campo data de un Array almacena la lista de listas del array. Necesitamos implementar algunos metodos para que sea funcional como una clase de algebra lineal. 

Un metodo para imprimir una matriz de forma mas agradable 
Validador. Un metodo para validar que la lista de listas sea valida (columnas del mismo tamano y que las listas interiores sean numericas
Indexing Hacer sentido a expresiones A[i,j]
Iniciar matriz vacia de ceros Este metodos es muy util para preacolar espacio para guardar nuevas matrices
Transposicion B.transpose()
Suma A + B
Multiplicacion escalar y matricial 2 * A y A*B
Vectores (Opcional)

Con esto seria posible hacer algebra lineal
Metodos especiales de clase...
Para hacer esto es posible usar metodos especiales de clase __getitem, __setitem__, __add__, __mul__, __str__. Teoricamente es posible hacer todo sin estos metodos especiales, pero, por ejemplo, es mucho mas agradable escribir A[i,j] que A.get(i,j) o A.setitem(i,j,newval) que A[i,j] = newval.
1. Un metodo para imprimir mejor...
Necesitamos agregar un metodo de impresion. Noten que un array de numpy se imprime bonito comparado con el nuestro
End of explanation
"""


class TestClass:
    def __init__(self):
        pass # this means do nothing in Python
    def say_hi(self):
        print("Hey, I am just a normal method saying hi!")
    def __repr__(self):
        return "I am the special class method REPRESENTING a TestClass without printing"
    def __str__(self):
        return "I am the special class method for explicitly PRINTING a TestClass object"

x = TestClass()

x.say_hi()

x

print(x)

"""
Explanation: Por que estas diferencias? Python secretamente busca un metodo llamado __repr__ cuando un objeto es llamado sin imprimir explicitamente, y  __str__ cuando se imprime con print explicitamente. Por ejemplo:
End of explanation
"""


some_list = [1,2,3, 4, 5, 6]
[i**2 for i in some_list] # Elevar al cuadrado con listas de comprehension

"""
Explanation: <span style = "color: red"> EJERCICIO 1 </span>

Escribe los metodos __repr__ y __str__ para la clase Array de forma que se imprima legiblemente como en numpy arrays.

2. Un validador
Ok esta es la parte aburrida... Los voy a ayudar un poco. Pero no deberia ser cualquier cosa un array, pues todas las filas deberian ser del mismo tamano no? Ademas, las filas deben tener entradas solo numericas... Por ultimo, cuando en vez de listas de listas solo hay una lista, conviene pensarlo como un vector (una columna... por eso yo prefiero llenar por columnas, pero esa es la forma python...). 
Todo esto se puede lograr mejorando un poco el validador. 
 Una nota al margen  Para hacer el validador voy a usar una de las herramientas mas poderosas de Python, listas de comprehension. Son formas faciles y rapidas de iterar en un solo comando. Un ejemplo:
End of explanation
"""


class Array:
    "Una clase minima para algebra lineal"    
    def __init__(self, list_of_rows): 
        "Constructor y validador"
        # obtener dimensiones
        self.data = list_of_rows
        nrow = len(list_of_rows)
        #  ___caso vector: redimensionar correctamente
        if not isinstance(list_of_rows[0], list):
            nrow = 1
            self.data = [[x] for x in list_of_rows]
        # ahora las columnas deben estar bien aunque sea un vector
        ncol = len(self.data[0])
        self.shape = (nrow, ncol)
        # validar tamano correcto de filas
        if any([len(r) != ncol for r in self.data]):
            raise Exception("Las filas deben ser del mismo tamano")
            
    def __repr__(self):
        "Ejercicio"
        pass
    
    def __str__(self):
        "Ejercicio"
        pass
    

Array([[1,2,3], [4,5]])

vec = Array([1,2,3])
vec.data

"""
Explanation: En el validador uso la lista de comprehension para verificar que todas las filas tengo el mismo tamano. Tambien creo un error si no se cumple con el comando raise. Este es un uso avanzado, entonces si no tienen experiencia en Python no se preocupen mucho por los detalles.
Checar que todas las entradas son numericas lo dejamos como un ejericio optativo... no vale la pena deternos mucho en eso...
End of explanation
"""


A = Array([[1,2], [3,4]])
A[0,0]

A[0,0] = 8

"""
Explanation: 3. Indexing and Item assignment
Tomaria varias lineas de codigo hacer un indexing/slicing tan complejo como el de numpy, pero podemos dar una version sencilla....
Queremos que las siguientes expresiones tengan sentido 
```python
A = Array([[1,2],[3,4]])
A[0,0]
A[0,0] = 8
````
Por el momento obtenemos errores
End of explanation
"""


class Array:
    "Una clase minima para algebra lineal"    
    def __init__(self, list_of_rows): 
        "Constructor y validador"
        # obtener dimensiones
        self.data = list_of_rows
        nrow = len(list_of_rows)
        #  ___caso vector: redimensionar correctamente
        if not isinstance(list_of_rows[0], list):
            nrow = 1
            self.data = [[x] for x in list_of_rows]
        # ahora las columnas deben estar bien aunque sea un vector
        ncol = len(self.data[0])
        self.shape = (nrow, ncol)
        # validar tamano correcto de filas
        if any([len(r) != ncol for r in self.data]):
            raise Exception("Las filas deben ser del mismo tamano")
    
    def __getitem__(self, idx):
        return self.data[idx[0]][idx[1]]        

A = Array([[1,2],[3,4]])
A[0,1]

"""
Explanation: Para poder acceder a un index un metodo __getitem__.
End of explanation
"""


np.zeros((3,6))

"""
Explanation: <span style = "color: red"> EJERCICIO 2 </span>

Escribe el metodo __setitem__ para que el codigo A[i,j] = new_value cambie el valor de la entrada (i,j) del array. El esqueleto de la funcion es
python
class Array:
    #
    #
    def __setitem__(self, idx, new_value):
        "Ejercicio"
    #

4. Iniciar una matriz en ceros
Este no es un metodo de la clase, mas bien conviene que sea una funcion que crea un objeto de la clase en ceros. Vamos! Es el ejercicio mas facil, se los dejo a ustedes. Queremos que el resultado sea similar a la siguiente la funcion zeros de numpy
End of explanation
"""


np.array([[1,2], [3,4]]).transpose()

"""
Explanation: <span style = "color: red"> EJERCICIO 3 </span>

Implementa una funcion de zeros para crear arrays "vacios"
python
def zeros(shape):
    "Implementame por favor"
Hint, encontraras utiles las listas de comprehension, por ejemplo el codigo [0. for x in range(5)] crea una lista de 5 ceros.
Implementa una funcion eye(n) que crea la matriz identidad de $n\times n$ (es decir, la matriz que tiene puros ceros y unos en la diagonal). El nombre eye es tradicional en software de algebra lineal, aunque no es muy intuitivo.

5. Transposicion
Otro ejercicio muy facil! Debe funcionar igual que numpy!
End of explanation
"""


"hola " + "tu"

[1,2,3] + [2,3,4]

np.array([1,2,3]) + np.array([2,3,4])

np.array([1,2,3]) + 10 # Broadcasted sum, es muy util

class Array:
    "Una clase minima para algebra lineal"    
    def __init__(self, list_of_rows): 
        "Constructor y validador"
        # obtener dimensiones
        self.data = list_of_rows
        nrow = len(list_of_rows)
        #  ___caso vector: redimensionar correctamente
        if not isinstance(list_of_rows[0], list):
            nrow = 1
            self.data = [[x] for x in list_of_rows]
        # ahora las columnas deben estar bien aunque sea un vector
        ncol = len(self.data[0])
        self.shape = (nrow, ncol)
        # validar tamano correcto de filas
        if any([len(r) != ncol for r in self.data]):
            raise Exception("Las filas deben ser del mismo tamano")
        
    def __add__(self, other):
        "Hora de sumar"
        if isinstance(other, Array):
            if self.shape != other.shape:
                raise Exception("Las dimensiones son distintas!")
            rows, cols = self.shape
            newArray = Array([[0. for c in range(cols)] for r in range(rows)])
            for r in range(rows):
                for c in range(cols):
                    newArray.data[r][c] = self.data[r][c] + other.data[r][c]
            return newArray
        elif isinstance(2, (int, float, complex)): # en caso de que el lado derecho sea solo un numero
            rows, cols = self.shape
            newArray = Array([[0. for c in range(cols)] for r in range(rows)])
            for r in range(rows):
                for c in range(cols):
                    newArray.data[r][c] = self.data[r][c] + other
            return newArray
        else:
            return NotImplemented # es un tipo de error particular usado en estos metodos

A = Array([[1,2], [3,4]])
B = Array([[5,6], [7,8]])
C = A + B
C.data

D = A + 10

D.data

"""
Explanation: <span style = "color: red"> EJERCICIO 4 </span>

Implementa la funcion de transposicion
python
class Array:
    ###
    ###
    ###
    def transpose(self):
        "Implementame :)"
        ###

6. Suma
Ok aqui llegamos al primer punto dificil! Pongan mucha atencion porque ustedes van a hacer la multiplicacion!!!! 
Muchas clases tienen una nocion de suma, como vimos la suma en listas y en strings de Python es concatenacion, pero la suma de Arrays debe ser suma entrada por entrada, como en los arrays de numpy. Como logramos eso? Definiendo metodos para __add__ que reciben como argumentos self y other
End of explanation
"""


Array([[1,2], [3,4]]) + Array([[5,6, 5], [7,8,3]])

"""
Explanation: HONESTAMENTE NO PODRIA SER MAS COOL!
Ahora veamos el error si las dimensiones no cuadran.
End of explanation
"""


class Vector(Array): # declara que Vector es un tipo de Array
    def __init__(self, list_of_numbers):
        self.vdata = list_of_numbers
        list_of_rows = [[x] for x in list_of_numbers]
        return Array.__init__(self, list_of_rows)
    def __repr__(self):
        return "Vector(" + str(self.vdata) + ")"
    def __str__(self):
        return str(self.vdata)
    def __add__(self, other):
        new_arr = Array.__add__(self, other)
        return Vector([x[0] for x in new_arr.data])

Vector([1,2,3]).__dict__

Vector([1,2,3])

Vector([1,2,3]) + Vector([5,-2,0])

Vector([1,2,3]) + 10

"""
Explanation: <span style = "color: red"> EJERCICIO 5</span>

(dificil) En nuestra clase Array la expresion A + 1 tiene sentido para un Array A, sin embargo la expresion inversa falla, por ejemplo 
python
1 + Array([[1,2], [3,4]])
entrega un error. Investiga como implementar el metodo de clase __radd__ para resolver este problema.
Nuestro metodo de suma no sabe restar, implementa el metodo de clase __sub__ similar a la suma para poder calcular expresiones como A - B para A y B arrays o numeros.

7. Multiplicacion Matricial
Ahora si su ejercicio mas dificil y mas importante. Queremos que A * B sea multiplicacion matricial y a*A ser multiplicacion escalar cuando a es un numero. 
NOTA IMPORTANTE!!!! Esta es la primera cosa que no esta implementada tal cual como numpy, pero es mucho mas padre hacerlo asi para algebra lineal. Numpy, al igual que las matrices de R, asumen que la multiplicacion debe ser entrada por entrada como += la suma. Otros lenguages como Julia y Matlab disenados para computo cientifico asumen que la multiplicacion de matrices es la default.
<span style = "color: red"> EJERCICIO 6 </span>
Implementa las funciones __mul__ y __rmul__ para hacer multiplicacion matricial (y por un escalar). Hint. Entiende y modifica el codigo de suma del punto anterior
8. Vectores (Herencia!!!)
La clase de Arrays ya hace todo lo necesario para hacer algebra lineal, pero es un poco incomoda para trabajar con vectores. Quisieramos tener una clase Vector que heredara el comportamiento de los Arrays pero con facilidades adicionales para trabajar con vectores. Este es el concepto de "herencia" en programacion orientada a objetos. Lo poderoso de la herencia es que permite trabajar con objetos mas especializados, que pueden tener campos o metodos adicionales a los de su clase padre, pero heredan el comportamiento. Tambien puede hacer un override de metodos de los padres.
End of explanation
"""


