Machine Learning from scratch: Bare bones implementations in Python

Published: 2026-07-20

Machine learning from scratch means implementing algorithms using only basic Python—no scikit-learn, no TensorFlow, no PyTorch. Just NumPy for matrix math, and your own two hands. I've spent years teaching ML this way, and I've noticed something: the people who build things from scratch understand the field differently. They debug faster. They make better architectural decisions. They don't panic when a model does something unexpected.

Most tutorials skip this part. They jump straight to model.fit() and call it a day. But here's the problem. When something breaks—and it always does—you're stuck staring at a stack trace with no intuition for what went wrong.

I'm going to walk you through five algorithms you should implement yourself. Not because it's easy. Because it's the fastest way to actually understand what you're doing. And I'll show you exactly how I structure these implementations so you can build them this weekend.

Related: I've explored this before in Google supercharges machine learning tasks with TPU custo....

Why Bother Building ML Algorithms From Scratch?

I get this question constantly. "Why would I write my own linear regression when scikit-learn does it in three lines?" Fair question. Wrong framing.

Building from scratch isn't about replacing libraries. It's about building mental models. When you code backpropagation by hand, you understand why vanishing gradients happen—not just that they happen. When you implement a decision tree's splitting logic, you grasp exactly why random forests outperform single trees. According to a 2023 paper in the Journal of Machine Learning Research, practitioners who implemented core algorithms from scratch showed 40% faster debugging times on complex model failures compared to those who only used high-level APIs.

Related: This connects to what I wrote about best ai writing assistant software.

I've seen this play out in real teams. The engineer who built a neural network from scratch spots the vanishing gradient problem in 30 seconds. The one who only knows Keras spends two hours Googling.

Here's what you actually need: Python 3.8+, NumPy, and maybe Matplotlib if you want to visualize things. That's it. No GPU required. No cloud credits. Just your laptop and some patience.

Related: For more on this, see Carnegie Mellon Launches Undergraduate Degree in Artifici....

1. Linear Regression: The Gateway Drug

Start here. Always. Linear regression is simple enough to code in 20 minutes, but it teaches you the fundamental pattern that every ML algorithm follows: define a model, define a loss function, optimize.

Here's the core idea. You're fitting a line to data points. The line is defined by weights (w) and a bias (b). Your prediction is y_pred = X @ w + b. The loss is mean squared error: how far your predictions are from the actual values, squared, averaged.

The optimization part is where most people get lost. You need gradient descent. Here's what I do:

def gradient_descent(X, y, w, b, learning_rate, epochs):
    n = len(y)
    for epoch in range(epochs):
        y_pred = X @ w + b
        dw = (2/n) * X.T @ (y_pred - y)
        db = (2/n) * np.sum(y_pred - y)
        w -= learning_rate * dw
        b -= learning_rate * db
    return w, b

That's it. That's the whole thing. The derivative of MSE with respect to w gives you dw. The derivative with respect to b gives you db. You nudge the parameters in the opposite direction of the gradient. Repeat until convergence.

The thing that trips people up? Learning rate. Too high and your loss explodes. Too low and training takes forever. I usually start at 0.01 and adjust from there. Watch your loss curve—if it's oscillating, cut the learning rate in half. If it's barely moving, double it.

One more thing. Normalize your features before training. I learned this the hard way after watching a model refuse to converge for an hour because one feature ranged from 0 to 1 and another ranged from 0 to 10,000. (X - X.mean()) / X.std() solves this instantly.

2. Logistic Regression: It's Not Actually Regression

Despite the name, logistic regression is a classification algorithm. The name is a historical accident. Don't let it confuse you.

The only real difference from linear regression? You pass the linear output through a sigmoid function. sigmoid(z) = 1 / (1 + e^(-z)). This squashes any real number into a value between 0 and 1, which you interpret as a probability.

The loss function changes too. You can't use MSE for classification—it creates a non-convex optimization problem with lots of local minima. Instead, you use binary cross-entropy:

def binary_cross_entropy(y_true, y_pred):
    epsilon = 1e-15  # avoid log(0)
    y_pred = np.clip(y_pred, epsilon, 1 - epsilon)
    return -np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))

That epsilon trick is something you won't find in most textbooks. I added it after my model started returning NaN values because a prediction was exactly 0 or 1. Log(0) is undefined. The clip function prevents that. Small detail, big impact.

The gradient derivation looks intimidating, but the result is surprisingly clean. The gradient for logistic regression is identical in form to linear regression: dw = X.T @ (y_pred - y) / n. The only difference is that y_pred now includes the sigmoid transformation. Beautiful, right?

3. K-Nearest Neighbors: The Algorithm That Doesn't Learn

KNN is weird. It has no training phase. Zero. You just store the data and wait for inference time. This is called "lazy learning" and it's both a strength and a massive weakness.

Here's how it works. When you want to classify a new point, you calculate its distance to every point in your training set. You pick the K closest ones. You let them vote. Majority wins.

Implementation is straightforward, but the devil's in the details:

def knn_predict(X_train, y_train, X_test, k=5):
    predictions = []
    for test_point in X_test:
        distances = np.sqrt(np.sum((X_train - test_point) ** 2, axis=1))
        k_indices = np.argsort(distances)[:k]
        k_labels = y_train[k_indices]
        # majority vote
        prediction = np.bincount(k_labels).argmax()
        predictions.append(prediction)
    return np.array(predictions)

This works. It's also painfully slow on large datasets because you're computing distances for every single test point. The time complexity is O(n_train * n_test * n_features). For 100,000 training samples, you're doing 100,000 distance calculations per prediction. That adds up fast.

Three things I've learned about KNN. First, always normalize your features. Distance-based methods are incredibly sensitive to scale. Second, K matters more than you think. Odd K values avoid ties. K=1 overfits spectacularly. I usually start with K=sqrt(n_samples) and tune from there. Third, Euclidean distance isn't always the right choice. For high-dimensional data, cosine similarity often works better because it ignores magnitude and focuses on direction.

4. Decision Trees: Recursive Partitioning Done Right

Decision trees are where things get genuinely interesting. You're building a tree structure that splits data based on feature values, trying to create subsets that are as "pure" as possible. Pure means all samples in a leaf node belong to the same class.

The core algorithm is recursive. At each node, you try every possible split on every feature. You pick the split that maximizes information gain—the reduction in entropy after the split.

Entropy measures impurity. entropy = -sum(p_i * log2(p_i)) where p_i is the proportion of class i. If a node has 50% class A and 50% class B, entropy is 1 (maximum impurity). If it's 100% class A, entropy is 0 (pure).

Here's the recursive part that most implementations get wrong:

def build_tree(X, y, depth=0, max_depth=10):
    if depth >= max_depth or len(np.unique(y)) == 1:
        return np.bincount(y).argmax()  # leaf node
    
    best_gain = 0
    best_split = None
    
    for feature in range(X.shape[1]):
        thresholds = np.unique(X[:, feature])
        for threshold in thresholds:
            left_mask = X[:, feature] <= threshold
            gain = information_gain(y, y[left_mask], y[~left_mask])
            if gain > best_gain:
                best_gain = gain
                best_split = (feature, threshold, left_mask)
    
    if best_gain == 0:
        return np.bincount(y).argmax()
    
    feature, threshold, left_mask = best_split
    left_tree = build_tree(X[left_mask], y[left_mask], depth+1, max_depth)
    right_tree = build_tree(X[~left_mask], y[~left_mask], depth+1, max_depth)
    
    return (feature, threshold, left_tree, right_tree)

This will overfit like crazy without pruning. A fully-grown tree memorizes the training data. I've seen trees with 50 levels that get 100% training accuracy and 60% test accuracy. The max_depth parameter is your first line of defense. Start with max_depth=5 and increase slowly while monitoring validation performance.

One thing that surprised me: decision trees handle missing values terribly. Unlike neural networks that can learn to be robust, a single missing value breaks the split logic entirely. In production, you either impute aggressively or use a library implementation that handles this.

5. Neural Networks: Backpropagation From Scratch

This is the one everyone wants to build. And it's the one where most people give up. Not because it's conceptually harder than the others—it's not—but because the debugging is brutal. One wrong matrix dimension and nothing works, but you get no error message. Just terrible accuracy.

A neural network is just a series of linear transformations with nonlinear activations in between. For a two-layer network: h = activation(X @ W1 + b1), output = softmax(h @ W2 + b2).

Backpropagation is the chain rule applied repeatedly. You compute the gradient of the loss with respect to each parameter by working backward from the output. Here's the pattern I use:

# Forward pass
z1 = X @ W1 + b1
a1 = relu(z1)
z2 = a1 @ W2 + b2
a2 = softmax(z2)

# Backward pass
dz2 = a2 - y_one_hot  # gradient of cross-entropy + softmax
dW2 = a1.T @ dz2 / n
db2 = np.sum(dz2, axis=0) / n

da1 = dz2 @ W2.T
dz1 = da1 * (z1 > 0)  # derivative of ReLU
dW1 = X.T @ dz1 / n
db1 = np.sum(dz1, axis=0) / n

The combined softmax + cross-entropy gradient simplifies to a2 - y_one_hot. This is not obvious. I spent three hours deriving it the first time. Now I just memorize it. The ReLU derivative is a mask: 1 where z1 > 0, 0 elsewhere. Dead simple.

Things that will go wrong: vanishing gradients with sigmoid activations (use ReLU instead), exploding gradients with deep networks (clip gradients to a max norm), and the classic "my loss isn't decreasing" problem. For that last one, check your learning rate first. Then check that you're actually computing gradients correctly by comparing against numerical gradients using finite differences. I've caught more bugs with gradient checking than with any other technique.

3 Mistakes I Made So You Don't Have To

First, I didn't shuffle my training data. My model saw all class A examples, then all class B examples. It learned to predict A for everything, then B for everything, oscillating wildly. Shuffle your data every epoch. np.random.shuffle is your friend.

Second, I used the test set for hyperparameter tuning. This is cheating, and it makes your results look better than they really are. Split your data three ways: training (60%), validation (20%) for tuning, and test (20%) for final evaluation. Never touch the test set until you're completely done iterating.

Third, I ignored the bias term. "It's just one parameter, how important can it be?" Very important, it turns out. Without bias, your decision boundary is forced through the origin. This works for centered data and fails for everything else. Always include bias terms.

Building these implementations from scratch takes time. A weekend if you're focused, a couple weeks if you're doing it evenings. The payoff is real though. You'll read research papers differently. You'll understand architecture decisions instead of just memorizing them. You'll debug model failures by thinking about what the gradients are doing, not by randomly tweaking hyperparameters.

Of course, there's a practical reality here. Once you've built these for learning, you probably don't want to maintain custom implementations for production work. That's where tools come in. AI-Mind handles content generation about technical topics like this without requiring you to craft prompts—you just describe what you need and pick a content type. For writing tutorials, documentation, or explanations of algorithms you've implemented, it's genuinely useful. The first 30 generations are free, which is enough to test whether it fits your workflow.

But don't let tools replace understanding. The engineers I respect most are the ones who can build things from scratch and choose not to—not the ones who can't build them at all. There's a difference.

Key Takeaways

Sources

Frequently Asked Questions

Do I need a GPU to implement machine learning from scratch?

No. All five algorithms in this article run fine on a CPU. Linear regression, logistic regression, KNN, and decision trees don't benefit from GPUs at all. Neural networks with small architectures (under 10,000 parameters) train in seconds on a modern laptop CPU. GPUs become necessary when you scale to deep networks with millions of parameters and large datasets.

Why use NumPy instead of pure Python lists?

NumPy operations are implemented in C and run 10-100x faster than equivalent Python loops. Matrix multiplication with X.T @ y uses optimized BLAS routines under the hood. Pure Python would require nested loops that are painfully slow for any dataset larger than a few hundred samples. NumPy is the one dependency worth keeping.

How do I know if my from-scratch implementation is correct?

Compare your results against scikit-learn on the same dataset. For linear and logistic regression, coefficients should match within floating-point precision. For KNN, predictions should be identical. For decision trees and neural networks, accuracy should be within 1-2% given the same architecture. Gradient checking with finite differences also verifies your backpropagation math.

Try AI-Mind for free. No prompts needed — just describe what you want and get professional content in seconds.

Start Generating Free