import math
from math import sqrt
import numbers
import matrix as m
import numpy as np

def zeroes(height, width):
        """
        Creates a matrix of zeroes.
        """
        g = [[0.0 for _ in range(width)] for __ in range(height)]
        return Matrix(g)

def identity(n):
        """
        Creates a n x n identity matrix.
        """
        I = zeroes(n, n)
        for i in range(n):
            I.g[i][i] = 1.0
        return I

class Matrix(object):

    # Constructor
    def __init__(self, grid):
        self.g = grid
        self.h = len(grid)
        self.w = len(grid[0])

    #
    # Primary matrix math methods
    #############################

    def determinant(self):
        """
        Calculates the determinant of a 1x1 or 2x2 matrix.
        """
        if not self.is_square():
            raise(ValueError, "Cannot calculate determinant of non-square matrix.")
        if self.h > 2:
            raise(NotImplementedError, "Calculating determinant not implemented for matrices largerer than 2x2.")

        # TODO - your code here
        if self.h == 1:

            return self.g[0][0]
        if self.h == 2:

            return self.g[0][0]*self.g[1][1]-self.g[0][1]*self.g[1][0]

    def trace(self):
        """
        Calculates the trace of a matrix (sum of diagonal entries).
        """
        if not self.is_square():
            raise(ValueError, "Cannot calculate the trace of a non-square matrix.")

        # TODO - your code here
        summ=0
        for i in range(len(self.g)):
            for j in range(len(self.g[0])):
                if i == j:
                    summ+=self.g[i][j]

        return summ

    def inverse(self):
        """
        Calculates the inverse of a 1x1 or 2x2 Matrix.
        """
        if not self.is_square():
            raise(ValueError, "Non-square Matrix does not have an inverse.")
        if self.h > 2:
            raise(NotImplementedError, "inversion not implemented for matrices larger than 2x2.")

        # TODO - your code here
        inverse = []

        det = Matrix.determinant(self)
        if len(self.g)==1 or len(self.g[0])==1:
            inverse.append([1/self.g[0][0]])

        if len(self.g)==2 or len(self.g[0])==2:
            inverse = ([self.g[1][1]/det,-1*self.g[0][1]/det],[-1*self.g[1][0]/det,self.g[0][0]/det])
        invers = m.Matrix(inverse)
        return invers

    def T(self):

            matrix_transpose = [[0 for y in range(len(self.g))]for x in range(len(self.g[0]))]
            #matrix_transpose = np.zeros((len(matrix[0]),len(matrix)))
         #matrix_transpose = []
        #     print(len(matrix))
        #     print(len(matrix[0]))
            for i in range(len(self.g)):
                for j in range(len(self.g[0])):
                    matrix_transpose[j][i] = self.g[i][j]
            matrixTrans = m.Matrix(matrix_transpose)
            return matrixTrans

    def is_square(self):
        return self.h == self.w

    #
    # Begin Operator Overloading
    ############################
    def __getitem__(self,idx):
        """
        Defines the behavior of using square brackets [] on instances
        of this class.

        Example:

        > my_matrix = Matrix([ [1, 2], [3, 4] ])
        > my_matrix[0]
          [1, 2]

        > my_matrix[0][0]
          1
        """
        return self.g[idx]

    def __repr__(self):
        """
        Defines the behavior of calling print on an instance of this class.
        """
        s = ""
        for row in self.g:
            s += " ".join(["{} ".format(x) for x in row])
            s += "\n"
        return s

    def __add__(self,other):
        """
        Defines the behavior of the + operator
        """
        if self.h != other.h or self.w != other.w:
            raise(ValueError, "Matrices can only be added if the dimensions are the same")
        #
        # TODO - your code here
        #

        # initialize matrix to hold the results
        matrixSum = []

        # matrix to hold a row for appending sums of each element
        row = []

        # TODO: write a for loop within a for loop to iterate over
        # the matrices
        for i in range(len(self.g)):
            row=[]
            for j in range(len(self.g[0])):
        # TODO: As you iterate through the matrices, add matching
        # elements and append the sum to the row variable
                row.append(self.g[i][j]+other.g[i][j])
        # TODO: When a row is filled, append the row to matrixSum.
        # Then reinitialize row as an empty list
            matrixSum.append(row)
            matrixSummation = m.Matrix(matrixSum)
        return matrixSummation

    def __neg__(self):
        """
        Defines the behavior of - operator (NOT subtraction)

        Example:

        > my_matrix = Matrix([ [1, 2], [3, 4] ])
        > negative  = -my_matrix
        > print(negative)
          -1.0  -2.0
          -3.0  -4.0
        """
        #
        # TODO - your code here
        #
        matrixNeg = []

        # matrix to hold a row for appending sums of each element
        row = []

        # TODO: write a for loop within a for loop to iterate over
        # the matrices
        for i in range(len(self.g)):
            row=[]
            for j in range(len(self.g[0])):
        # TODO: As you iterate through the matrices, add matching
        # elements and append the sum to the row variable
                row.append(-1*self.g[i][j])
        # TODO: When a row is filled, append the row to matrixSum.
        # Then reinitialize row as an empty list
            matrixNeg.append(row)
            matrixNegative = m.Matrix(matrixNeg)
        return matrixNegative

    def __sub__(self, other):
        """
        Defines the behavior of - operator (as subtraction)
        """
        #
        # TODO - your code here
        #
        # initialize matrix to hold the results
        matrixSub = []

        # matrix to hold a row for appending sums of each element
        row = []

        # TODO: write a for loop within a for loop to iterate over
        # the matrices
        for i in range(len(self.g)):
            row=[]
            for j in range(len(self.g[0])):
        # TODO: As you iterate through the matrices, add matching
        # elements and append the sum to the row variable
                row.append(self.g[i][j]-other.g[i][j])
        # TODO: When a row is filled, append the row to matrixSum.
        # Then reinitialize row as an empty list
            matrixSub.append(row)
            matrixSubtract = m.Matrix(matrixSub)
        return matrixSubtract

    def __mul__(self, other):
        """
        Defines the behavior of * operator (matrix multiplication)
        """
        #
        # TODO - your code here
        #

        product = []
        current_rowA = []
        current_rowB = []
        Other = m.Matrix(other.g)
        transB = Other.T()
        row_results=[]
        for i in range(len(self.g)):
            for j in range(len(other.g[0])):
                result=0
                current_rowA=self.g[i]
                current_rowB=transB[j]
                for k in range(len(current_rowB)):
                    result += current_rowA[k]*current_rowB[k]
                row_results.append(result)
            product.append(row_results)
            row_results=[]
            matrixMul = m.Matrix(product)
        return matrixMul

    def __rmul__(self, other):
        """
        Called when the thing on the left of the * is not a matrix.

        Example:

        > identity = Matrix([ [1,0], [0,1] ])
        > doubled  = 2 * identity
        > print(doubled)
          2.0  0.0
          0.0  2.0
        """
        matrixRmul = []

        # matrix to hold a row for appending sums of each element
        row = []

        # TODO: write a for loop within a for loop to iterate over
        # the matrices
        for i in range(len(self.g)):
            row=[]
            for j in range(len(self.g[0])):
        # TODO: As you iterate through the matrices, add matching
        # elements and append the sum to the row variable
                row.append(other*self.g[i][j])
        # TODO: When a row is filled, append the row to matrixSum.
        # Then reinitialize row as an empty list
            matrixRmul.append(row)
            matrixRmult = m.Matrix(matrixRmul)
        return matrixRmult

        if isinstance(other, numbers.Number):
            pass
            #
            # TODO - your code here
            #


#autor: Rodolfo Quispe
import numpy as np
import matplotlib.pyplot as plt

#define the original function
def f(x, w):
  return w[0]*(x**3) + w[1]*(x**2) + w[2]*(x) + w[3]

#define  squared sum as cost function
def cost(calculated_y, expected_y):
  return ((expected_y - calculated_y)**2).sum()

#given the weights w (a, b, c, d), x
#and y (training data), compute gradient
def gradient(x, y, w):
  fact = np.vstack( (x*x*x, x*x, x , np.ones(len(x))) )
  return 2 * (f(x, w) - y) * fact

# define the update function delta w
def delta_w(w_k, x, t, learning_rate):
  return learning_rate * gradient(x, t, w_k).sum(axis = 1)

def plot_results(x, y, w):
  fig = plt.figure(figsize=(12, 6))
  fig.add_subplot(111)

  # Plot the target t versus the input x
  plt.plot(x, y, 'o', label='data')
  # Plot the initial line
  x = list(range(-10, 10))
  plt.plot(x, [f(i,w) for i in x],'g-',label='f(x)')

  #set extra plot parameters
  plt.title('Question 2: Gradient Descent with learning rate 1e-4')
  plt.xlim(-6,6)
  plt.ylim(-60,60)
  plt.xlabel('x')
  plt.ylabel('f(x)', fontsize=15)
  plt.legend(loc=2)
  plt.grid()

  plt.show()
  fig.savefig("t01_2.png")

if __name__ == "__main__":

  x_train = np.array([-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5])
  y_train = np.array([-53.9, -28.5, -20.7, -3.6, -9.8, 5.0,
                        4.2, 5.1, 11.4, 27.4, 44.0])

  # Set the initial weight parameter
  w = np.array([0, 0, 0, 0])
  # Set the learning rate
  learning_rate = 1.0e-4

  #number of gradient descent iterations
  nb_of_iterations = 50

  # Start performing the gradient descent updates,
  # and print the weights and cost:

  # Lists to store the weight,costs values
  w_cost = [ cost(f(x_train, w), y_train)]
  w_status = [w]
  for i in range(nb_of_iterations):
      #Get the delta w update
      dw = delta_w(w, x_train, y_train, learning_rate)
      #Update the current weight parameter
      w = w - dw
      #Add weight,cost to lists
      w_cost.append(cost(f(x_train, w), y_train))
      w_status.append(w)

  print ('iterations', nb_of_iterations)
  print ('cost', w_cost[-1])
  print ('best parameters', w_status[-1])
  plot_results(x_train, y_train, w_status[-1])


import numpy as np
import matplotlib.pyplot as plt
import scipy.integrate as integrate
from lib.printer import printer

def func(x):
    return np.exp(x)

def trapezoidal(a, b, y=None, N=10, f=func):
    integral = 0
    x = np.linspace(a, b, N + 1)
    if y is None:
        y = f(x)
    y = np.array(y)
    l = len(y)
    if len(x) != len(y):
        raise ValueError("length of x must be same as y")
    for i in range(len(x)):
        if i == 0 or i == len(x) - 1:
            integral += y[i]
        else:
            integral += 2 * y[i]
    integral *= (b - a) / (2 * N)

    # Alternatively,
    """
    y_1=y[:l-1:2]
    y_2=y[1:l:2]

    integral = (b-a)/(N*2) * (np.sum(y_1 + y_2))
    """
    return integral

def simpson(a, b, y=None, N=10, f=func):
    integral = 0
    x = np.linspace(a, b, N + 1)
    if y is None:
        y = f(x)
    y = np.array(y)
    l = len(y)
    if len(x) != l:
        raise ValueError("length of x must be same as y")

    for i in range(len(x)):
        if i == 0 or i == len(x) - 1:
            integral += y[i]
        elif i % 2 == 0:
            integral += 2 * y[i]
        else:
            integral += 4 * y[i]

    integral *= (b - a) / (N * 3)
    # Alternatively,
    """
    y_1=y[:l-2:2]
    y_2=y[1:l-1:2]
    y_3=y[2:l:2]

    integral = (b-a)/(N*3) * (np.sum(y_1 + 4*y_2 + y_3))
    """
    return integral

def integration(a, b, ni, function=None):
    trueval = integrate.quad(function, a, b)
    trapval = trapezoidal(a, b, N=ni, f=function)
    simpval = simpson(a, b, N=ni, f=function)

    # PRINTING
    printer(
        np.array(
            [
                ["Method used", "Integral", "Error constant "],
                ["Quad method scipy", trueval[0], trueval[1]],
                ["Trapezoidal rule", trapval, trueval[1] - (trapval - trueval[0])],
                ["Simpson's rule", simpval, trueval[1] - (simpval - trueval[0])],
            ]
        )
    )

    # PLOTTING CONVERGENCE GRAPH

    y_data_2, y_data, y_data_3 = [], [], []
    n_array = np.arange(40, 370, 2)
    h_array = (b - a) / n_array

    for n in n_array:
        x_data = np.linspace(a, b, n + 1)
        y_data.append(trapezoidal(a, b, N=n, f=function))
        y_data_2.append(simpson(a, b, N=n, f=function))
        y_data_3.append(integrate.simpson(func(x_data), x_data))

    plt.xscale("log")
    plt.plot(h_array, y_data, label="Trapezoidal rule")
    plt.scatter(h_array, y_data, label="Trapezoidal rule")
    plt.plot(h_array, y_data_2, label="Simpson's rule")
    plt.scatter(h_array, y_data_2, label="Trapezoidal rule")
    plt.plot(h_array, y_data_3, linestyle="--", label="scipy's simpson implementation")
    plt.legend()

if __name__ == "__main__":

    # n=int(input("Enter the number of sub-intervals : "))
    # a=int(input("Enter the lower-limit(a) : "))
    # b=int(input("Enter the upper-limit(b) : "))
    """
    PART 3(a)
    """
    V = [0, 0.5, 2.0, 4.05, 8, 12.5, 18, 24.5, 32, 40.5, 50]
    trapval = trapezoidal(0, 1, y=V, N=10)
    simpval = simpson(0, 1, y=V, N=10)

    printer(
        np.array([["Trapezoidal rule", trapval], ["Simpson's rule", simpval]]),
        column=False,
    )

    """
    PART 3(b)
    https://github.com/scipy/scipy/blob/v1.7.1/scipy/integrate/_quadrature.py#L433-L555
    """
    integration(0, 13, 100, function=func)
    plt.show()


#sympy

from pylab import *
import sympy as sp

print(pi)
print(sp.pi)

#there is a symbolic pi, which we can evaluate using the evalf method.

#get 32 digits of pi
print(sp.pi.evalf(32))

#There are other kinds of numeric objects

p = sp.Rational(3,7)
q = sp.Rational(2,3)
print(p+q)
print(p*q)

#when dealing with symbolic results, things can get ugly
print(sp.integrate('x*sin(3/x)'))

#that is ugly. Sympy has a "pretty print" function.
sp.pprint(sp.integrate('x*sin(3/x)'))

#In general, we want to use symbolic variables.
sp.var('x,y')
sp.pprint(x/2)

#now we can define expressions in terms of symbolic variables.
s = x*sp.sin(3/x)

#Now we can evaluate this expression anywhere.
print(s.subs(x,pi))

#kcooper does song and dance between 'f' and 'f(x)'
#f(x) is NOT a function, it's the value of the function. It's the value of function
# f is just the function and is a rule for mapping one input to another.
#function you give it an input and get an output.
#s is just an expression and is a collection of symbols you cannot do s(x)
print(s.subs(x,pi).evalf(50))

#We have noted that s is an expression. Let's make a function.

t = lambda x: x*sp.sin(3/x)

#y is a symbolic variable.
#Note now the difference between the expression s and the function t
sp.pprint(s(y))
sp.pprint(t(y))

#Now let's define a new expression.
#don't care about 64.0 or anything because they are just symbolic numbers and we could use evalf() to evalute to arbitrary precision
u = 64*x**4-100*x**3 + 22*x**2 - 65*x+4

#didn't have to tell him how many points to choose or anything.
sp.plot(u,(x,-.5,1.5))
#sympy plot doesn't need the show() command

3h = arange(-0.5,1.5,0.001)

h = (1.5 -(-.5))/1000.0
x_vec = arange(-.5,1.5+h,h)

u_vec = zeros(len(x_vec))

#have to evaluate u at 1001 points.
for i in arange(len(x_vec)):
	u_vec[i] = u.subs(x, x_vec[i])

plot(x_vec, u_vec)
show()

#***********************************NOVEMBER 30TH LECTURE NOTES *******************************

#trying to find the root of the polynomial. K Cooper designed it to have the root at x = 1/16
print(u.subs(x,sp.Rational(1,16)))
first_root = sp.Rational(1,16)

v = sp.simplify(u / (x-first_root))
sp.pprint(v)

#simplify always checks to divide out polynomial factors, and execute trig identities.
#x^2 + 2x + 1 is a perfect square.
sp.pprint((x**2 + 2*x + 1) / (x + 1) )
sp.pprint(sp.simplify((x**2 + 2*x + 1) / (x + 1)))
sp.pprint((sp.sin(x)**2 + sp.cos(x)**2 / sp.cos(x)))u

#We can factor polynomials
sp.pprint(u.factor())

#we can also expand polynomials
w = u.factor()
sp.pprint(w)
sp.pprint(sp.expand(w))

sp.var('a')
p = (x -1) * (x-a) * (x +a)

sp.pprint(p.expand().collect(x))
sp.pprint(p.expand().collect(a))
sp.pprint(sp.horner(p,x))

#sympy can do calculus

#this limit would be 1
sp.pprint(sp.limit(sp.sin(x) / x , x, 0))

#limit would be zero(0) because it's 0 * bounded function
sp.pprint(sp.limit( x * sp.sin(3/x), x, 0))

#you can take a limit of a limit
sp.pprint(sp.limit(sp.limit((x**2+1) / (a*x**2 + 2*x + 1), x , sp.oo), a, 97)

#sympy can take derivatives
#differentiate u with respect to x
sp.pprint(sp.diff(u,x))

#we can take a second derivative.
sp.pprint(sp.diff(u,x,2))

u_prime = sp.diff(u,x)
sp.pprint(u_prime)

u_double_prime = sp.diff(u,x,2)
sp.pprint(u_double_prime.expand() - sp.diff(u_prime,x))

#the things inside would have to be REAL
arc_length_integrand = sp.sqrt(1 + sp.diff(u,x)**2)

arc_length = sp.integrate(arc_length_integrand, (x,0,1))

#it doesn't know how to evaluate the integral so it'll just print the last known correct answer.
#but we need to force it to do the integral
sp.pprint(arc_length)

#can get 10 digits if you want.
arc_length = sp.integrate(arc_length_integrand, (x,0,1)).evalf(10)
sp.pprint(arc_length)

#more integrals
#u is a quartic polynomial
sp.pprint(sp.integrate(u))

sp.pprint(sp.integrate(arc_length) )

#showing to use a tuple going from negative infinity to positive infinity.
#use .evalf() to make him do it with 20 digits of accuracy.
sp.pprint(sp.integrate(sp.exp(-x**2), [x,-sp.oo,sp.oo]).evalf(20))
#solution is sqrt(pi)

# ******************* SERIES **********************88
#why do we learn series? because polynomials are what we can evluate on a computer.
#anything we can write as a series we can integrate.

s = sp.series(sp.sin(x), x)

sp.pprint(s)

#big O denotes what the dominant term is. Exampel is  O<x^6> means that x^6 is the dominant term

#can expand something other than 0.
s = sp.series(sp.sin(x), x, 3)

#now what if we want 16 terms from the series.
s = sp.series(sp.sin(x), x, 3, 16)

#this would be expanding around 0
s = sp.series(sp.sin(x), x, 0, 16)

#you get a list of ordered terms. We could reconstruct the function by taking the sum of these terms in the list.
t = s.as_ordered_terms()

#if you give it a negative index it will start at the end.
t = sum(s.as_ordered_terms()[0:-1])

sp.pprint(t)

h = 0.01
x_vec = arange(-1.0, 1.0 + h, h)

#y_vec must be numbers!
y_vec = zeros(len(x_vec))

for i in arange(len(x_vec)):
	y_vec[i] = t.subs(x, x_vec[i]).evalf()

plot(x_vec, y_vec, x_vec, sin(x_vec))
show()

#********************* DECEMBER 4th NOTES ***************

#we could use less terms to make it look worse.


# 6.1 David C. Lay Inner Product

# An inner product is a generalization of the dot product. In a vector space, it is a
# way to multiply vectors together, with the result of this multiplication being a scalar.

'''
Example: Compute u.v and v.u for u = col(2, -5, -1), v = col(3, 2, -3)

u.v = transpose(u).v = (3 * 2) + (- 5 * 2) + (-1 * -3) = -1
v.u = 10 Because u.v = v.u
'''

u, v = [2, -5, -1], [4, 2, -3]

inner_prod = 0

for i in range(len(u)):
    inner_prod += u[i] * v[i]

print(inner_prod)

# numpy library
import numpy as np

u = np.array([2, -5, -1]);
v = np.array([4, 2, -3]);

inner_prod = np.inner(v, u)

print(inner_prod)

# for 1-d array np.inner(u, v) is same as sum(u[:] * v[:])

inner_prod = sum(u[:] * v[:])
print(inner_prod)

# or  simply

inner_prod = u * v
inner_prod = sum(inner_prod)
print(inner_prod)

x = np.array([[1], [2]])
x_trans = np.transpose(x)
out = np.dot(x_trans, x)

print(sum(out)[0])

# for multi-dimentional array
a = np.array([[1,2], [3,4]])
b = np.array([[11, 12], [13, 14]])

print(np.inner(a, b))
'''
    In the above case, the inner product is calculated as −

    1*11+2*12, 1*13+2*14
    3*11+4*12, 3*13+4*14
'''


'''

hypothesis
    O^T * x = O0X0 + O1X1 + ... + OmXm
    h(O) = O^T * x

cost function
    cost(h(x), y) = (h(x) - y)^2
    J(O) = (1/2*m) * sum(cost(h(x), y))

fit parameters with gradient descent
    Oj = Oj - learning_rate * partial_derivative(J(O))
    partial_derivative = 1/m * sum((h(xi) - yi) * xij)

'''

class LinearRegression:
    def __init__(self, learning_rate=0.001, iters=10000, fit_intercept=True, theta=None):
        self.learning_rate = learning_rate
        self.iters = iters
        self.fit_intercept = fit_intercept
        self.theta = theta

    def __add_intercept(self, X):
        intercept = np.ones((X.shape[0], 1))
        return np.concatenate((intercept, X), axis=1)

    def __cost(self, h, y):
        return np.sum((h - y) * (h - y)) / (2*y.size)

    def fit(self, X, y):
        if self.fit_intercept:
            X = self.__add_intercept(X)

        if self.theta is None:
            self.theta = np.zeros(X.shape[1])

        for i in range(self.iters):
            h = np.dot(X, self.theta)
            cost = self.__cost(h, y)
            for j in range(len(self.theta)):
                gradient = np.dot(h-y, X[:, j])
                self.theta[j] = self.theta[j] - (self.learning_rate / y.size * gradient)
        print(cost)

if __name__ == '__main__':
    import numpy as np
    hours_studied = np.array([[2], [4], [6], [8], [10], [13], [13.5], [14], [16], [19], [25]])
    grades = np.array([1, 1, 1, 2, 2, 3, 3, 3, 4, 4, 5])

    print("GRADIENT DESCENT")
    model = LinearRegression()
    model.fit(hours_studied, grades)

    '''
    gradients, cost = linear_regression(hours_studied, grades)
    print(gradients)
    print(cost)

    predict = lambda x: gradients[1]*x + gradients[0]
    print(predict(21))

    '''

    '''
    NORMAL EQUATION METHOD
    theta = (X^T * X)^-1 * X^T * y
    '''

    print("NORMAL EQ")
    hours_studied_ones = np.concatenate((np.ones((len(hours_studied), 1)), hours_studied), axis=1)
    theta = np.dot(np.dot(np.linalg.inv(np.dot(np.transpose(hours_studied_ones), hours_studied_ones)), np.transpose(hours_studied_ones)), grades)
    model = LinearRegression(theta=theta, iters=1)
    model.fit(hours_studied, grades)

'''
SHORT SELLING
ABC share 54$

borrow 5 abc shares and sell them for 270$

ABC share 45$

buy 5 abc shares for 225$ and give them back to creditor

profit = 270$ - 45$ = 25$
'''