\subsection{Theoretical Background}
% - OLS for linear model, analytical solution
% - Bias variance tradeoff derivation from OLS
% - How number of dof effects the bias variance tradeoff
% - Bias variance tradeoff leads to penalty terms for large parameters in the model: Ridge, Lasso
% - Effective number of parameters of ridge (see ex.w.36 )
% - Minimization of analytically unsolvable cost problems requiring numerical approach
% - Minimization via Taylor expansion of multidim function and hessian -> Gradient Descent
% - maybe deriv. of 3 cost functions
% - Other numerical minimization algos: Momentum, Adagrad, RMSProp, Adam
% - Problem of expensive matrix multiplications (order of flops), gradient of subsample approaches gradient of entire sample in the limit -> stochastic gradient descent

% - Intro to bootstrapping and k-fold crossvalidation
To find the optimal solution to a linear model in the form
\begin{equation} \label{eq:linear_model}
    X \vec \theta = \vec y, \quad X \in \mathbb{R}^{n\times p}, \vec y \in \mathbb{R}^n, \vec \theta \in \mathbb{R}^p,
\end{equation}
the ordinary least squares (OLS) method can be used. The OLS method minimizes the squared deviation between the model prediction and the actual data\cite{hastie_elements_2009}. This is done by minimizing the cost function
\begin{equation} \label{eq:ols_cost}
    \cols(\vec \theta) = \frac{1}{n} \norm{X\vec \theta - \vec y}_2^2.
\end{equation}
This method has been widely used in the past ultimately also because it has an analytical solution in the case that the matrix product $X^TX$ is invertible, i.e. $\det(X^TX) \neq 0$. In this case the optimal parameters $\vec \theta_{OLS}$ can be found by solving the normal equations \cite{hastie_elements_2009,elstner_lecture_2025}
\begin{equation} \label{eq:ols_solution}
    \vec \theta_{OLS} = (X^TX)^{-1}X^T \vec y.
\end{equation}
Taking the derivative of the cost function in \cref{eq:ols_cost} with respect to the parameters $\vec \theta$ and inserting the solution in \cref{eq:ols_solution} shows that this is indeed a minimum of the cost function:
\begin{equation}
    \frac{\partial \cols}{\partial \vec \theta}\Big\rvert_{\vec \theta_{OLS}} = \frac{2}{n} X^T(X\vec \theta - \vec y)\Big\rvert_{\vec \theta_{OLS}} = 0.
\end{equation}

\subsubsection{Bias-Variance Tradeoff and Regularization} \label{sec:bias_variance_tradeoff}
The cost function of the ordinary least squares method can also be identified as the mean squared error (MSE) between the model prediction and the actual data
\begin{equation} \label{eq:mse}
    \mse(\vec y, \vec{\tilde y}) = \frac{1}{n} \sum_{i=0}^{n-1} (y_i - \tilde y_i)^2 = \E \left[(y - \tilde y)^2\right],
\end{equation}
where $\vec{\tilde y} = X \vec \theta$ is the model prediction. $\E$ denotes the expectation value. The MSE can be decomposed into three components, the squared bias, the variance and the irreducible error $\sigma^2$ as \cite{bishop_pattern_2006,hastie_elements_2009}
\begin{equation} \label{eq:bias_variance_decomp}
    \mse(\vec y, \vec{\tilde y}) = \bias^2(\vec y, \vec{\tilde y}) + \var(\vec{\tilde y}) + \sigma^2,
\end{equation}
where the bias is defined as
\begin{equation} \label{eq:bias}
    \bias(\vec y, \vec{\tilde y}) = \E[\vec y] - \E[\vec{\tilde y}],
\end{equation}
and the variance as
\begin{equation} \label{eq:variance}
    \var(\vec{\tilde y}) = \E\left[(\vec{\tilde y} - \E[\vec{\tilde y}])^2\right].
\end{equation}
The irreducible error $\sigma^2$ is the variance of the noise in the data and cannot be reduced by any model. The bias-variance decomposition in \cref{eq:bias_variance_decomp} shows that there is a tradeoff between bias and variance when trying to minimize the MSE. A model with low complexity will have a high bias but low variance, while a model with high complexity will have a low bias but high variance. This is known as the bias-variance tradeoff. The OLS method can lead to overfitting of the data, especially when the number of parameters $p$ is large compared to the number of data points $n$. In this case, the model will fit the noise in the data rather than the underlying trend, leading to a low bias but high variance. On the contrary, a model with too few parameters will not be able to capture the underlying trend in the data, leading to a high bias but low variance\cite{elstner_lecture_2025,hastie_elements_2009}. To mitigate the effects of overfitting, regularization techniques such as Ridge and Lasso regression can be used. These techniques add a penalty term to the cost function that penalizes large values of the parameters $\vec \theta$\cite{lekhansh_lasso_2024}. The cost function for Ridge regression is given by
\begin{equation} \label{eq:ridge_cost}
    \colr(\vec \theta) = \frac{1}{n} \norm{X\vec \theta - \vec y}_2^2 + \lambda \norm{\vec \theta}_2^2,
\end{equation}
where $\lambda$ is the regularization parameter that controls the strength of the penalty term. The cost function for Lasso regression is given by
\begin{equation} \label{eq:lasso_cost}
    \coll(\vec \theta) = \frac{1}{n} \norm{X\vec \theta - \vec y}_2^2 + \lambda \norm{\vec \theta}_1,
\end{equation}
where the $L_1$ norm is defined as $\norm{\vec \theta}_1 = \sum_{i=0}^{p-1} |\theta_i|$\cite{lekhansh_lasso_2024,elstner_lecture_2025}. Both Ridge and Lasso regression can help to reduce the variance of the model by penalizing large values of the parameters $\vec \theta$, leading to a different bias-variance tradeoff. For Ridge regression, we can derive the effective number of parameters also called degrees of freedom as \cite{hastie_elements_2009}
\begin{equation} \label{eq:ridge_dof}
    n_\text{effective} = \sum_{i=1}^{n_\text{features}} \frac{d_i^2}{d_i^2 + \lambda},
\end{equation}
where $d_i$ are the singular values of the design matrix $X$. Thus regularization effectively reduces the number of parameters in the model, leading to a bias-variance tradeoff that can be tuned by the regularization parameter $\lambda$. While OLS models can be analytically optimized using \cref{eq:ols_solution} for the case where $X^TX$ is invertible, the same is true for Ridge regression in the case where $X^TX + \lambda I$ is invertible. The optimal parameters for Ridge regression can be found by solving the modified normal equations\cite{elstner_lecture_2025}
\begin{equation} \label{eq:ridge_solution}
    \vec \theta_\mathrm{Ridge} = (X^TX + \lambda I)^{-1}X^T \vec y.
\end{equation}

\subsubsection{Numerical Optimization} \label{sec:numerical_optimization}
However, for Lasso regression there is no analytical solution and numerical methods must be used to obtain the optimal parameters $\vec \theta_\mathrm{Lasso}$. All numerical optimization methods are based on the idea of iteratively minimizing the cost function. Around the current guess for the parameters $\vec \theta_i$ the cost function can be approximated by a Taylor expansion as \cite{hastie_elements_2009}
\begin{equation} \label{eq:taylor_expansion}
    \begin{aligned}
        \cols(\vec \theta) &= \cols(\vec \theta_i) + \nabla \cols(\vec \theta_i)^T (\vec \theta - \vec \theta_i) \\&\quad+ \frac{1}{2} (\vec \theta - \vec \theta_i)^T H(\vec \theta_i) (\vec \theta - \vec \theta_i) \\&\quad+ \mathcal{O}\left(\norm{\vec \theta - \vec \theta_i}_2^3\right),
    \end{aligned}
\end{equation}
where $\nabla \cols(\vec \theta_i)$ is the gradient and $H(\vec \theta_i)$ the Hessian of the cost function at the current guess $\vec \theta_i$. Based on this approximation, the next guess for the parameters can be found by minimizing the quadratic approximation of the cost function. Since the calculation of the Hessian is computationally expensive, it is often neglected in practice. This leads to the gradient descent algorithm, where the next guess for the parameters is given by \cite{goodfellow_deep_2016}
\begin{equation} \label{eq:gradient_descent}
    \vec \theta_{i+1} = \vec \theta_i - \eta \nabla \cols(\vec \theta_i),
\end{equation}
where $\eta$ is the learning rate that controls the step size of the update. The gradient descent algorithm can be further improved by using techniques such as momentum, Adagrad, RMSProp and Adam. These techniques adapt the learning rate based on the history of the gradients, leading to faster convergence and better performance.

\paragraph{Momentum in Gradient Descent}
The momentum technique in gradient descent helps to accelerate the convergence of the optimization process by adding a fraction of the previous update to the current update. This helps to smooth out the updates and reduces oscillations in the optimization process. The update rule for gradient descent with momentum is given by
\begin{equation} \label{eq:momentum}
    \begin{aligned}
        v_{i} &= \delta \cdot \left( \theta_i - \theta_{i-1} - \eta \nabla \cols(\vec \theta_i) \right)\\
        \vec \theta_{i+1} &= \vec \theta_i + v_i,
    \end{aligned}
\end{equation}
where $v_i$ is the velocity vector that accumulates the gradients, $\beta$ is a hyperparameter that controls the decay rate of the velocity, and the other symbols have the same meaning as in the gradient descent update rule \cite{goodfellow_deep_2016,noauthor_stochastic_2025}.

\paragraph{Adagrad}
The Adagrad algorithm adapts the learning rate for each parameter based on the history of the gradients. The update rule for Adagrad is given by
\begin{equation} \label{eq:adagrad}
    \begin{aligned}
        g_{i} &= g_{i-1} + \nabla \cols(\vec \theta_i) \odot \nabla \cols(\vec \theta_i), \\
        \vec \theta_{i+1} &= \vec \theta_i - \frac{\eta}{\sqrt{g_i + \epsilon}} \odot \nabla \cols(\vec \theta_i),
    \end{aligned}
\end{equation}
where $g_i$ is the accumulated squared gradient, $\odot$ denotes the element-wise product and $\epsilon$ is a small constant to prevent division by zero \cite{goodfellow_deep_2016,noauthor_stochastic_2025}.


\paragraph{RMSProp}
The RMSProp algorithm is a modification of Adagrad that aims to reduce its aggressive, monotonically decreasing learning rate. It does this by maintaining a moving average of the squared gradients, which allows it to adapt the learning rate for each parameter more effectively. The update rule for RMSProp is given by
\begin{equation} \label{eq:rmsprop}
    \begin{aligned}
        s_{i} &= \beta s_{i-1} + (1 - \beta) \nabla \cols(\vec \theta_i) \odot \nabla \cols(\vec \theta_i), \\
        \vec \theta_{i+1} &= \vec \theta_i - \frac{\eta}{\sqrt{s_i + \epsilon}} \odot \nabla \cols(\vec \theta_i),
    \end{aligned}
\end{equation}
where $s_i$ is the moving average of the squared gradients, $\beta$ is a hyperparameter that controls the decay rate of the moving average, and the other symbols have the same meaning as in the Adagrad update rule \cite{goodfellow_deep_2016,noauthor_stochastic_2025}.

\paragraph{Adam}
The Adam (Adaptive Moment Estimation) algorithm combines the ideas of momentum and RMSProp. It maintains a moving average of both the gradients and the squared gradients, allowing it to adapt the learning rate for each parameter based on both the first and second moments of the gradients. The update rule for Adam is given by
\begin{equation} \label{eq:adam}
    \begin{aligned}
        m_{i} &= \beta_1 m_{i-1} + (1 - \beta_1) \nabla \cols(\vec \theta_i), \\
        s_{i} &= \beta_2 s_{i-1} + (1 - \beta_2) \nabla \cols(\vec \theta_i) \odot \nabla \cols(\vec \theta_i), \\
        \vec \theta_{i+1} &= \vec \theta_i - \frac{\eta}{\sqrt{s_i + \epsilon}} \odot m_i,
    \end{aligned}
\end{equation}
where $m_i$ is the moving average of the gradients, $s_i$ is the moving average of the squared gradients, $\beta_1$ and $\beta_2$ are hyperparameters that control the decay rates of the moving averages, and the other symbols have the same meaning as in the previous update rules \cite{goodfellow_deep_2016,noauthor_stochastic_2025}.

\subsubsection{Stochastic Gradient Descent}
As the computation of the gradient $\nabla \cols(\vec \theta_i)$ can be computationally expensive, especially for large datasets, a common approach is to use stochastic gradient descent (SGD). In SGD, the gradient is approximated by calculating it on a small subset of the data, called a mini-batch. This leads to a noisy estimate of the gradient, but it can significantly reduce the computational cost of each update step. The main challenge with SGD is to choose an appropriate mini-batch size. A small mini-batch size leads to a noisy estimate of the gradient, which can slow down convergence. On the other hand, a large mini-batch size can diminish the benefits of using SGD, as it approaches the full gradient descent computation cost. In practice the optimal mini-batch size depends on the specific problem and dataset\cite{goodfellow_deep_2016,elstner_lecture_2025}.

While the high variance of models with a high number of parameters can be counteracted with a large amount of data, the computational cost of fitting such models can become very high. This is especially relevant in the context of big data, where datasets can contain millions or even billions of samples. In such cases, it can be infeasible to use optimization algorithms that require the computation of the gradient over the entire dataset, such as ordinary gradient descent. For a dataset with $\N$ samples and a model with $p$ parameters, the computational cost of calculating the gradient
\begin{equation}
    \nabla_\theta \cols = \frac{2}{N}\left( X^T X \vec \theta - X^T \vec{y} \right),
\end{equation}
is of order $\mathcal{O}(N p^2)$, as the matrix-vector products $X^T X$ needs to be computed. For very large datasets this can lead to very long computation times, as the entire dataset needs to be processed to compute the gradient. A common approach to counteract this problem is to use stochastic gradient descent (SGD) or mini-batch gradient descent, where the gradient is computed over a small subset of the data, called a batch. This leads to a significant reduction in computational cost, as the gradient can be computed in $\mathcal{O}(B p^2)$, where $B$ is the batch size. However, this comes at the cost of increased variance in the gradient estimate, as the gradient is only computed over a small subset of the data. This can lead to slower convergence and more oscillations in the optimization process. 

While the decrease in computation time is significant, the result is even less black and white, when taking a closer look at the details.
While conventional GD can benefit largely by precomputing the matrix products $X^T X$ and $X^T \vec{y}$, this is not possible for SGD, as the batches change in every epoch. This leads to a significant increase in computation time per epoch for SGD compared to GD. However, as the number of epochs needed to reach convergence is usually much lower for SGD than for GD, the overall computation time can still be significantly lower for SGD. This is especially relevant for very large datasets, where the computational cost of GD can become prohibitive. Because while GD needs to process the entire dataset and thus the computational cost is of order $\mathcal{O}(N p^2)$, SGD only needs to process a small batch of size $B$ and thus the computational cost is of order $\mathcal{O}(B p^2)$ and invariant of the size of the dataset, given the batch size is large enough to estimate the complete gradient well enough. This leads to a significant reduction in computation time for very large datasets, as the computational cost of SGD does not increase with the size of the dataset\cite{goodfellow_deep_2016}.

\subsubsection{Resampling Methods}
To better understand the performance of a model, e.g. in terms of the bias-variance tradeoff, resampling methods such as bootstrapping and k-fold cross-validation can be used. These methods allow to estimate the performance of a model on unseen data by repeatedly splitting the data into training and test sets.
\paragraph{Bootstrapping}
In bootstrapping, multiple datasets are generated by randomly sampling the original training dataset with replacement. Each of these datasets is then used to train a model, and the performance of the model is evaluated on the unmodified test dataset. This process is repeated multiple times, and the performance metrics are averaged to obtain an estimate of the model's performance. Bootstrapping can be used to estimate the bias and variance of a model by analyzing the distribution of the performance metrics across the different bootstrap samples\cite{hastie_elements_2009}. The number of bootstrap samples is arbitrary, but a thousand samples are used in this work.
\paragraph{k-Fold Cross-Validation}
In k-fold cross-validation, the original dataset is divided into $k$ equally sized folds. The model is then trained on $k-1$ folds and evaluated on the remaining fold. This process is repeated $k$ times, with each fold being used as the test set once. The performance metrics are then averaged across the $k$ iterations to obtain an estimate of the model's performance\cite{elstner_lecture_2025}. 
The process of k-fold cross-validation is illustrated in \cref{fig:crossvalidation}. For this work $k=5$ folds are used.

\begin{figure}
    \centering
    \begin{tikzpicture}[scale=0.8]
  % parameters
  \def\nfolds{5}
  \def\squaresize{0.8}
  \def\vspace{0.15} % extra space between rows (relative to squaresize)

  % loop over folds
  \foreach \row in {0,...,4} {
    % vertical position with extra spacing
    \pgfmathsetmacro{\y}{-(\row*(\squaresize+\vspace))}
    \foreach \col in {0,...,4} {
      % decide color
      \ifnum\row=\col
        \fill[turq] (\col*\squaresize, \y) 
             rectangle ++(\squaresize,\squaresize);
      \else
        \fill[white] (\col*\squaresize, \y) 
             rectangle ++(\squaresize,\squaresize);
      \fi
      % draw border
      \draw[black] (\col*\squaresize, \y) 
           rectangle ++(\squaresize,\squaresize);
    }
    % label fold on the left
    \node[left] at (-0.2, \y+0.5*\squaresize) {Fold \the\numexpr\row+1\relax};
    % MSE_i label on the right
    \node[right] at (5*\squaresize+0.2, \y+0.5*\squaresize) {$\mathrm{MSE}_{\the\numexpr\row+1\relax}$};
  }

  % curly brace summarizing MSEs
   % compute top and bottom y positions for the brace
  \pgfmathsetmacro{\ytop}{0.5*\squaresize}
  \pgfmathsetmacro{\ybottom}{-(4*(\squaresize+\vspace))+0.5*\squaresize}

  % curly brace summarizing MSEs
  \draw[decorate,decoration={brace,amplitude=10pt}] 
    (6.8*\squaresize,\ytop) -- 
    (6.8*\squaresize,\ybottom)
    node[midway,xshift=1.5cm]{$\displaystyle \frac{1}{5}\sum_{i=1}^{5}\mathrm{MSE}_i$};
\end{tikzpicture}
    \caption{Illustration of 5-fold cross-validation. Each row corresponds to one fold: the turquoise block marks the held-out test set while the white blocks form the training set. The models error $\mathrm{MSE}_i$ is computed for each fold, and the final performance is the average of all $\mathrm{MSE}_i$.}
    \label{fig:crossvalidation}
\end{figure}


\subsection{Software Tools and Implementation}
\subsubsection{Programming Language and Libraries}

The methods described in the previous section have been implemented in Python. The implementation is structured in a modular way, allowing to easily switch between different optimization algorithms and resampling methods. The code is available on GitHub at \url{https://github.uio.no/larsbog/FYSSTK-Project1}. The following libraries have been used in the implementation:
\begin{description}
    \item[\texttt{numpy}\cite{harris_array_2020}] It is a fundamental package for scientific computing in Python. It provides support for large, multi-dimensional arrays and matrices, along with a collection of mathematical functions to operate on these arrays efficiently. It is used in this project for all matrix and vector operations, as well as for generating random numbers and performing statistical calculations.
    \item[\texttt{scikit-learn}\cite{pedregosa_scikit-learn_2011}] It is a machine learning library for Python that provides simple and efficient tools for data mining and data analysis. It includes implementations of various machine learning techniques, like data manipulation and performance evaluation metrics.
    It is used in this project for rescaling the data, splitting the data into training and test sets, and for implementing the resampling methods. Furthermore, it provides the implementation of the mean squared error (MSE) metric used to evaluate the performance of the models.
    \item[\texttt{matplotlib}\cite{hunter_matplotlib_2007}] It is a plotting library for Python that provides a wide range of tools for creating different types of plots and visualizations. It is used in this project to visualize the results of the different methods and to create plots for the report.
\end{description}

\subsubsection{Other Software Tools}
The code has been developed using the \texttt{Visual Studio Code} editor, which provides a wide range of features for Python development, including syntax highlighting, code completion, and debugging tools. The version control system \texttt{Git} has been used to manage the codebase and track changes. The Large Language Model (LLM) \texttt{GitHub Copilot} has been used to assist in the development of the code by providing code suggestions and autocompletions based on the context of the code being written. All LLM-generated code has been reviewed and modified as necessary to ensure its correctness and suitability for the project.
The LLM ChatGPT-4 has been used to create \cref{fig:crossvalidation}. The LaTeX package \texttt{TikZ} has been used to create the figure. The LLM has been prompted to generate the TikZ code for the figure, which has then been modified as necessary to ensure its correctness and suitability for the report. The conversation can be found at the root of the GitHub repository.


\subsubsection{Implementation Details}
To evaluate the performance of the different methods, a synthetic dataset has been generated using the Runge function.
The Runge function is defined as
\begin{equation} \label{eq:runge_function}
    f(x) = \frac{1}{1 + 25x^2}, \quad x \in [-1, 1].
\end{equation}
The $x$-values are sampled uniformly in the interval $[-1, 1]$, and the corresponding $y$-values are generated by adding Gaussian noise with mean 0 and standard deviation $\sigma = 1$, unless otherwise specified. To evaluate the performance in dependance of the degrees of freedom, the $x$-values are expanded into multiple polynomial features up to a specified degree: $X_i = \left\{x_i^0, x_i^1, \dots, x_i^p\right\}$. All features and outputs are then rescaled to have zero mean and unit variance using the \texttt{StandardScaler} from \texttt{scikit-learn}. The mean and variance of the training data are used for rescaling the test data to avoid data leakage. 
There are analytical implementations of the OLS and Ridge regression methods, as described in \cref{eq:ols_solution} and \cref{eq:ridge_solution}. Furthermore numerical optimizers are implemented from scratch based on a class inheritance structure to reduce code duplication. The base class \texttt{GradientDescent} implements a fitting procedure consisting of a precomputation step, an optimization procedure over multiple iterations and a memory of the cost function values. The different optimization algorithms, i.e. vanilla gradient descent, momentum based gradient descent, Adagrad, RMSProp and Adam, are implemented as subclasses that override the update rule for the parameters $\vec \theta$ in each iteration. The cost functions for OLS, Ridge and Lasso regression are also implemented as separate classes that provide methods to calculate the cost and its gradient. The stochastic gradient descent methods are implemented as further subclasses that modify the data used for fitting in each iteration. In the stochastic gradient descent method, the cost history property is furthermore modified to return the per epoch average of the cost function values. The resampling methods, i.e. bootstrapping and k-fold cross-validation, are implemented as methods with identical interfaces which provide multiple sets of training and test data. This allows to easily switch between the different resampling methods when evaluating the performance of a model.

