• Verge
  • ML
  • AI

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.