Please write the answer to the exercise below, 'Exercise 3'.

## 0 - Introduction

Welcome to the <span style="color:yellowgreen">Foundations of Machine Learning</span> (ECE5984_41) course!

This is the <span style="color:red">first</span> lab practice for this class. You will implement linear regression algorithm and apply it to the provided dataset.

### Outline
- [ 1 - Packages ](#1-packages)
- [ 2 - Problem Statement and Dataset ](#2-problem-statement-and-dataset)
- [ 3 - Linear Regression ](#3-linear-regression)
  - [ 3.1 Normal Equation ](#31-normal-equation) - Exercise 1
  - [ 3.2 Gradient Descent ](#32-gradient-descent) - Exercise 2, Exercise 3, Exercise 4
## 1. Packages 

You have to install and use below packages for your HW#1.
- [numpy](https://www.numpy.org): Fundamental package for matrix computation with python.
- [matplotlib](https://matplotlib.org): Package for visualization of graph with python.
- [pandas](https://pandas.pydata.org): Open source package for data analysis and manipulation.

**Do not use other machine learning packages in this homework, e.g., *tensorflow*, *pytorch*, *jax*, etc.**
import math
import copy
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
%matplotlib inline
## 2. Problem Statement and Dataset
### Problem Statement

[Student Marks Dataset](https://www.kaggle.com/datasets/nikhil7280/student-performance-multiple-linear-regression)

The dataset consists 100 samples of Marks(Grades) of students including their "study time" & "number of courses".

In this homework, we would like to fit the linear regression model on this dataset. For practice, we will only use "study time" attribute.
### Dataset

You will start by loading the dataset. Make sure the attached 'Student_Marks.csv' file is in your current directory path (can be downloaded from icampus).

Each column describes the below attributes:
- \[ time_study \]: Total hour the student has studied.
- \[ Marks \]: The final score the student has achieved.

Run below cells and take a look at the dataset!
# Load the dataset
dataset = pd.read_csv("./Student_Marks.csv")

# Display top 5 elements
dataset.head(5)
# check data type of each attributes
dataset.dtypes
# check dataset shape: (num data, num attributes)
dataset.shape
# Extract "Hours Studied" and "Performance Index" attributes. 
# Each attributes will be the data and label, respectively.
data = dataset["time_study"].to_numpy()
label = dataset["Marks"].to_numpy()

# Check data statistics
print("The shape of data is", data.shape)
print("The shape of label is", label.shape)
print()

print("The first Five elements of data are", data[:5])
print("The first Five elements of label are", label[:5])
print()

# Plot data
plt.scatter(data, label, marker='x', c='r')
plt.title("Study Time - Grade")
plt.xlabel("Study Time")
plt.ylabel("Grade")
plt.show()
## 3. Linear Regression
### 3.1 Normal Equation
### <span style="color:#ffd33d"> Exercise 1 </span>: Complete the code for the normal equation
# Complete the code for the normal equation
def normal_equation(data, label):
    """
    Directly compute optimal w* = (w, b) of the linear regression model
    
    Args:
        data    (np.ndarray): Shape (N, ) Input ot the model
        label   (np.ndarray): Shape (N, ) Label of the data
    
    Return:
        w       (np.ndarray): weight(slope) of the linear regression model
        b       (np.ndarray): bias of the linear regression model 
    """
    #[NOTE] write your from code here! 

    return w, b

w, b = normal_equation(data, label)
print(f"w: {w:.6f}, b: {b:.6f}")
Plot your linear regression model and check if it works well
# Obtain weight and bias of your linear regression model
w, b = normal_equation(data, label)

# Plot data
plt.scatter(data, label, marker='x', c='r')
plt.title("Study Time - Grade")
plt.xlabel("Study Time")
plt.ylabel("Grade")

x_min, x_max = 0, math.ceil(max(data))
y_min, y_max = 0, math.ceil(max(label))

x = np.array([x_min, x_max])
y = w * x + b
plt.plot(x, y)

plt.show()

### 3.2 Gradient Descent
### <span style="color:#ffd33d"> Exercise 2 </span>: Complete the code for computing the loss(cost)
\> Here, the <span style="color:skyblue">compute_loss()</span> method computes the loss of the linear regression model. 

Feel free to change or add variables, but do not modify the method name and its arguements.
def compute_loss(data, label, w, b):
    """
    Compute the loss(cost) of the linear regression model, given data and label.

    Args:
        data    (np.ndarray): Shape (N, ) Input to the model
        label   (np.ndarray): Shape (N, ) Label of the data
        w       (float): Weight of the linear regression model
        b       (float): Bias of the linear regression model
    
    Return:
        total_loss (float): Total loss of the linear regression model, given data and label
    """
    total_loss = 0

    #[NOTE] write your code here!

    return total_loss
### <span style="color:#ffd33d"> Exercise 3 </span>: Complete the code for computing the gradient of the loss w.r.t weights and bias
\> Here, the <span style="color:skyblue">compute_gradient()</span> method computes the gradient of the loss with respect to the parameters of the linear regression model.

Feel free to change or add variables, but do not modify the method name and its arguments.
def compute_gradient(data, label, w, b):
    """
    Compute the loss(cost) of the linear regression model, given data and label.

    Args:
        data    (np.ndarray): Shape (N, ) Input to the model
        label   (np.ndarray): Shape (N, ) Label of the data
        w       (float): Weight of the linear regression model
        b       (float): Bias of the linear regression model
    
    Return:
       grad_w   (float): The gradient of the loss w.r.t weight w
       grad_b   (float): The gradient of the loss w.r.t bias b
    """
    grad_w, grad_b = 0, 0

    #[NOTE] write your code here!

    return grad_w, grad_b
### <span style="color:#ffd33d"> Exercise 4 </span>: Complete the code of gradient descent algorithm
\> <span style="color:skyblue">gradient_descent()</span> method applies gradient descent on the given dataset. You should make 'loop' inside this method to iteratively update w and b. You __**must**__ use <span style="color:skyblue">compute_loss()</span> and <span style="color:skyblue">compute_gradient()</span> method to calculate loss and gradient, respectively.

Also, for the purpose of visualization of the training curve, this method returns some 'history' lists. You can just simply append updated values to the corresponding 'history' list.

Feel free to change or add variables, but do not modify the method name, its arguments, and return values.
def gradient_descent(data, label, w_init, b_init, iters=1500, lr=0.0001):
    """
    Performs batch gradient descent to obatain weight and bias of the linear regression model.
    
    Args:
      data    (np.ndarray): Shape (N,)
      label   (np.ndarray): Shape (N,)
      w_init  (float): Initial value of weight of the model
      b_init  (float): Initial values of bias of the model
      lr      (float): Learning rate
      iters   (int): Number of iterations to run gradient descent
    Returns
      w             (float): Weight of the 1D linear regression model obtained with BGD
      b             (float): Bias of the 1D linear regression model obtained with BGD
      loss_history  (list): loss values of every iteration steps
      w_history     (list): w values of every iteration steps
      b_history     (list): b values of every iteration steps
    """
    loss_history = []
    w_history = []
    b_history = []
    w = w_init
    b = b_init

    loss = 0
    for i in range(iters):
        #[NOTE] write your code here!

        # print loss for every 100 iterations
        if i % 100 == 99:
            print(f"[ {i + 1:4}/{iters} ] Loss: {loss:.4f} | w: {w:.4f} | b: {b:.4f}")
    
    return w, b, loss_history, w_history, b_history
Now apply your gradient descent algorithm. (Also try other hyperparameter values!)
# initial values of w and b
w_init, b_init = .0, .0

# hyperparameters for gradient descent algorithm
iters = 1500
learning_rate = 0.0001

# obtain w and b with gradient descent
w_gd, b_gd, loss_history, w_history, b_history = gradient_descent(
                                                data, label, w_init, b_init,
                                                iters=iters,
                                                lr=learning_rate,
                                            )

print("w,b found by gradient descent:", w_gd, b_gd)

# obtain w and b with normal equation
w_ne, b_ne = normal_equation(data, label)
print("w,b found by normal equation:", w_ne, b_ne)
loss_history = loss_history[100:] # it is okay to remove this line

# plot training curve
plt.plot(range(len(loss_history)), loss_history)
plt.title("Training Curve")
plt.xlabel("Iter")
plt.ylabel("Loss")
plt.show()