ML From Scratch Part 1
Introduction
I’ve recently started studying the course content for the HTB Certified Offensive AI Expert (COAE) and am really enjoying it. Already, I think I have a much better understanding of the tools and technology in AI after only a few modules. One thing I’ve noticied though is just how much mathematics and learnings have been hidden away in libraries that we probably take for granted, such as Numpy and Scikit, etc. It’s incredible that these libraries exist and what they do, however, my brain doesn’t just want to accept the libraries exist and use them; I have the urge to try building all these libraries from scratch and decipher some of the horrible looking mathematical gate-keeping lingo.
This series will just be me documenting how I’m going to relearn and understand the core mathematics and algorithms that went into ML and AI. I am NOT an AI expert (at least not certified yet!) and definitely not a mathematics expert. I quit university before finishing my degree because it was very boring, and I ended up with a job offer as a software developer just from provable aptitude during work experience. So although I did software development and cybersecurity for 10 years, I am NOT formally qualified and I have 0 idea of what the fuck I am talking about.
First Steps
It seems to me that machine learning is just a fancy term for tiny changes to the logic of a program through automated means to get inputs closer to producing desired outputs. It may eventually converge on the correct way to achieve the output from the input.
If we take a simple case of basic linear algebra as an example:
f(x) = 2x + 1
We get a line, and we could extract some points from this line (0,1), and (2,5) for instance.
Given we know that these points exist on the same line, and if we forgot the equation that brough us
them, can we recover the equation? Yep, absolutely. I used to hate calculus at school because I always
said “that shit won’t help me in real life”, but back then I thought I thought I would be dead by 20
and not nearly 30 having spent 10 years doing nerd stuff. But calculus is here to save the day,
annoyingly, very helpful.
import std.io
equation: f(x: i32) => i32 = 2 * x + 1
main: f() {
for x := 0..5 {
writeln("({x}, {equation(x)})")
}
}
The above Verge code (my project for a programming language, more on that another time, closed beta, syntax subject to significant change) generates the following:
verge run
(0, 1)
(1, 3)
(2, 5)
(3, 7)
(4, 9)
This is just a simple loop generating so example points for the equation.
What the derivative in calculus actually tells us is the rate of change at a point. When you calculate
the derivative for the aforementioned equation, you would simply get 2. Since there is no parameter here,
just the scalar value, the slope of the line is just changing at a rate of 2 which sounds bizarre
but there are no units needed. We know because we’re only dealing with 2 dimensions, x and y that
for every unit moved across x there will be a difference of 2 on the y. Now I don’t want to dive
so deep as to why a line in 2 dimensional space is defined as y = mx + b so you may have to do your
own research on that, but I remember that well enough from high school. But given this information
we have all we need to extrapolate the equation of the line. To find the y intercept, where the line
will cross the up down line on a graph (the y axis), we know that this will happen when x is 0 so
it resolves the equation as:
y = mx + b
y = m(0) + b
y = b
So the 0 multiples by m meaning it is 0, so y is b. From the point we have (0,1), we actually
already have b and know it is 1. Now to work out m we refer to the original point which was the
derivative is a calculation of slope, so m must be equivalent to the slope 2. And simply substituting
those in to the line equation gives us our original equation, y = 2x + 1.
We can show this process in code as an example:
import std.io
## A point object in 2d space
Point: object {
x: i32
y: i32
print: f() {
writeln("({.x}, {.y})")
}
}
## A Line object to represent lines in 2d space
Line: object {
m: i32
b: i32
print: f() {
writeln("f(x) = {.m}x + {.b}")
}
predict: f(x: i32) => Point {
Point {x: x, y: .m * x + .b}
}
}
## Create a new line given points a and b
new_line: f(a, b: Point) => Line {
x_dif := b.x - a.x
y_dif := b.y - a.y
slope := y_dif / x_dif
intercept := a.y - a.x * slope
Line {m: slope, b: intercept}
}
main: f() {
a := Point {x: - 1, y: - 1}
b := Point {
x: 3,
y: 7
}
# Create a new line from the 2 points
line := new_line(a, b)
line.print()
# Verify we can get a point elsewhere on the line
line.predict(8).print()
}
And running gives us this output verifying it works, even for negative numbers it seems:
verge run
f(x) = 2x + 1
(8, 17)
But this is a very simple case. We had an easy 2 points to deal with, one already on the y axis directly and one not far away to make calculations easy. But what happens if you have more points and not all from the same line, but you want a line that approximates all of them close enough? Furthermore, what if the points are not 2 dimensional and are even higher dimensional, so you’re no longer trying to fit a line, but some kind of curve or hyperplane? This example was not machine learning, it’s just calculation that we’ve baked into the system. To solve these questions, we can try and implement some machine learning techniques.
Although fitting a line works perfectly every time between 2 points, let’s look at if the points are from something like a parabolic curve instead.
This is where we get into a very important concept in ML called loss which is a measure of how off
from correct an output is. It is calculated by one of several functions from a class of error functions.
A simple loss function is the Mean Squared Error which calculates the error by taking an average
of the squares of an array of distances. I.e. for each difference, multiply by itself and add to a
running total, then take the average by dividing the total by the number of differences. There are a
bunch of different common accepted loss calculations, but this one is simple and intuitive and I’m sure
that for your own purposes, you create new ones that better fit the data you’re working with. It seems
that a lot of ML and AI is actually guesswork and approximation by mathematical means.
mean_squared_error: f(differences: []f64) => f64 {
res := 0.0
for diff := differences {
res += diff * diff
}
res / differences.length()
}
Parabolas are in the form y = ax^2 + bx + c. You could brute force nested loops across a, b, c
and the known x and y from given points and then calculate the error, and basically pick the a,
b and c that have the minimum error. This is computationally heavy already with only a few nested
loops and made significantly worse for every extra parameter you would solve for. Imagining a higher
dimensional space with not just x and y for example, would blow out this search to an absurd time complexity.
So rather than brute forcing the solution, we use calculus again to make the solution feasible. Since we know the derivative can give is the slope of a curve, we can actual take the derivative of the error function in reality. If you were to plot the error values, they’re likely going to have a curve shape, which means there is a direction (slope) that you can travel down to minimize error.
The error of the Mean Squared Error (MSE) is E = (ax^2 + bx + c - y)^2. We can see this because
the error function is the difference between the computed y (ax^2 + bx + c) and the actual y (simply y).
Finally, it gets squared, hence (ax^2 + bx + c - y)^2. To find the slope, we calculate the derivative by
using the power rule and chain rule with respect to the parameters we are trying to solve for, a, b and c.
In a partial derivative, you only care about what you are deriving with respect to and everything else
becomes a constant. Let’s just take a for example. Start off by applying chain rule so we treat
the entire inner paranthesis as one variable, called u leading us to solve E = u^2 instead. The
derivative of this is just 2u, then we solve the inner u (the parenthesis) separately with respect
to a only. That means everything else, even the x and y are just constants. Therefore, for ax^2
it becomes x^2 by the power rule. So we recombine the two results, 2u and what we know u (x^2) and
we get 2ux^2. Same logic for b and c to get x and 1 respectively. With this in mind, we have
enough capability to actual begin creating some learning behaviour.
For learning, since we are just trying to follow the error minimizing slope, we need to be careful how
far we “jump” in the direction of the slope. Too far, and we may bypass the minimum error and then
need to jump back in the opposite direction, and repeat this and never get to the minimim. Too small
a jump won’t get to the answer soon enough. We also can’t just keep jumping indefinitely towards a
minimal error since we may never converge on an answer; it needs to be bounded. This is why ML algorithms
usually have a learning rate (size of jumps down the error slope) and epochs (bounding; maximum jumps to make).
It’s rather straightforward to use this same math and concept to extend it to any curve (or line) for
the set of points as long as you can work out the curve you are trying to solve. Back to coding, we can
assume we have a set of points that come from a curve, (1,1),(2,4),(3,9),(4,16), and the goal is
to find a line that best approximates the curve represented by this sampling of points. We follow the
same math, but it’s even easier because it’s a line. E = (mx + b - y)^2 and the derivative in respect
to m becomes 2ux, and b is 2u. chain rule and power rule from calculus is AI and ML best friends.
I really wish I paid more attention to this at school. The thoughts of “I will never use this again in
real life” are just absolute cringe to me nowadays. Mathematicians have found the coolest shit. And
us lucky few with a passion for software development really ought to respect those nerds. Put it all together
for this algorithm:
main: f() {
best_fit := train_line([]Point {
{x: 1.0, y: 1.0},
{x: 2.0, y: 4.0},
{x: 3.0, y: 9.0},
{x: 4.0, y: 16.0}
}, 0.00025, 10000)
best_fit.print()
best_fit.predict(2.0).print()
best_fit.predict(2.5).print()
best_fit.predict(3.0).print()
}
train_line: f(points: []Point, learning_rate: f64, epochs: i32) => Line {
m, b := 0.0, 0.0 # We could pick anything for these I think
n := points.length()
for epoch := 0..epochs {
# For each learning epoch, we calculate the gradient and error accumulation
# for right where we currently are on the curve.
gradients := [2]f64 {0.0, 0.0}
error_total := 0.0
for point := points {
# Calculating current error as the difference between expected and actual
expected := m * point.x + b
err := expected - point.y
gradients[0] += 2 * err * point.x # Gradient of m as per `2ux`
gradients[1] += 2 * err # Gradient of b as per `2u`
error_total += err * err
}
m -= learning_rate * gradients[0] / n
b -= learning_rate * gradients[1] / n
if epoch % 1000 == 0 {
writeln("Epoch {epoch} error: {error_total/n}")
}
}
Line {m: m, b: b}
}
What’s amazing is we’ve actually just built what’s known as linear regression! And this is the output:
verge run
Epoch 0 error: 88.5
Epoch 1000 error: 6.2055
Epoch 2000 error: 5.46547
Epoch 3000 error: 4.84462
Epoch 4000 error: 4.31008
Epoch 5000 error: 3.84987
Epoch 6000 error: 3.45364
Epoch 7000 error: 3.1125
Epoch 8000 error: 2.81879
Epoch 9000 error: 2.56591
f(x) = 4.03364x + -2.1588
(2, 5.90849)
(2.5, 7.92531)
(3, 9.94214)
It correctly solves for m and b in such a way that minimizes the error over the learning epochs.
The points produced are reasonably accurate! So currently, our machine learning is just error functions
and gradients to minimize the error. Surprisingly powerful use of calculus.