Machine Learning From Scratch in Pure C
Module I: Supervised Learning & Model Validation
When studying machine learning, Python and libraries like Scikit-Learn or PyTorch often hide the low-level mechanics under high-level abstractions. Implementing these foundational algorithms in pure ANSI C exposes the core mathematics: mean deviations, 2x2 matrix determinants, Euclidean spatial metrics, Laplace conditional distributions, and gradient descent updates.
Every program in this lab guide is self-contained, written in standard C (<stdio.h> and <math.h>), and designed with clean, step-by-step logic suitable for lab exams, technical viva voce, and fast handwritten code reproduction.
Module I Syllabus & Lab Mapping
| Lab # | Topic / Algorithm | Type | Core Mathematical Concept |
|---|---|---|---|
| Lab 01 | Simple Linear Regression | Supervised (Regression) | Ordinary Least Squares (OLS) & Mean Deviations |
| Lab 02 | Multiple Linear Regression | Supervised (Regression) | Deviation Product System & 2x2 Closed-Form Determinants |
| Lab 03 | K-Nearest Neighbors (KNN) | Supervised (Classification) | Instance-based Euclidean Distance & Majority Voting |
| Lab 04 | Naïve Bayes Classifier | Supervised (Classification) | Conditional Independence, Prior Probabilities & Laplace Smoothing |
| Lab 05 | Logistic Regression | Supervised (Classification) | Sigmoid Hypothesis & Batch Gradient Descent Optimization |
| Lab 06 | Leave-One-Out CV (LOOCV) | Model Evaluation | Exhaustive Resampling & Mean Squared Error (MSE) |
Lab 01: Simple Linear Regression (Ordinary Least Squares)
1. Problem Statement
Implement Simple Linear Regression using Ordinary Least Squares (OLS). Given a training set of input features $x$ and continuous target outcomes $y$, compute:
- Feature and target sample means ($\bar{x}, \bar{y}$)
- Regression slope ($b_1$) and y-intercept ($b_0$)
- The fitted linear model $\hat{y} = b_0 + b_1x$
- Output predictions for unseen query values.
2. Core Mathematical Formulation
Sample Means:
$$\bar{x} = \frac{1}{n}\sum_{i=1}^{n} x_i, \quad \bar{y} = \frac{1}{n}\sum_{i=1}^{n} y_i$$Slope Coefficient ($b_1$):
$$b_1 = \frac{\sum_{i=1}^{n} (x_i - \bar{x})(y_i - \bar{y})}{\sum_{i=1}^{n} (x_i - \bar{x})^2}$$Y-Intercept ($b_0$):
$$b_0 = \bar{y} - b_1\bar{x}$$
3. Pure C Implementation
#include <stdio.h>
int main() {
int n = 4, i;
// Training dataset: Study hours (x) vs Exam marks (y)
float x[] = {1.0, 2.0, 3.0, 4.0};
float y[] = {2.0, 3.0, 5.0, 6.0};
float sum_x = 0.0, sum_y = 0.0;
float mx, my;
float num = 0.0, den = 0.0;
float b0, b1;
float test_x = 5.0, pred_y;
// Step 1: Calculate arithmetic means
for (i = 0; i < n; i++) {
sum_x += x[i];
sum_y += y[i];
}
mx = sum_x / n;
my = sum_y / n;
// Step 2: Compute deviation products (num) and squared deviations (den)
for (i = 0; i < n; i++) {
num += (x[i] - mx) * (y[i] - my);
den += (x[i] - mx) * (x[i] - mx);
}
// Step 3: Compute slope (b1) and intercept (b0)
b1 = num / den;
b0 = my - (b1 * mx);
// Step 4: Make prediction for new observation
pred_y = b0 + (b1 * test_x);
printf("=== Lab 01: Simple Linear Regression ===\n");
printf("Mean X = %.2f, Mean Y = %.2f\n", mx, my);
printf("Slope (b1) = %.2f\n", b1);
printf("Intercept (b0) = %.2f\n", b0);
printf("Fitted Equation: y = %.2f + %.2fx\n", b0, b1);
printf("Prediction for x = %.1f -> y = %.2f\n", test_x, pred_y);
return 0;
}
4. Implementation Walkthrough & Memory Triggers
-
Two-Pass Loop Strategy: The first loop computes the means (
mxandmy). The second loop calculates the centered deviations (x[i] - mx) and accumulates the covariance numerator and variance denominator. -
Calculation Sequence: Always resolve
b1 = num / denbefore computingb0 = my - b1 * mx, since $b_0$ explicitly depends on $b_1$. - Time & Space Complexity: Time Complexity is $\mathcal{O}(n)$, Space Complexity is $\mathcal{O}(1)$ auxiliary space.
5. Sample Execution Output
=== Lab 01: Simple Linear Regression ===
Mean X = 2.50, Mean Y = 4.00
Slope (b1) = 1.40
Intercept (b0) = 0.50
Fitted Equation: y = 0.50 + 1.40x
Prediction for x = 5.0 -> y = 7.50
Lab 02: Multiple Linear Regression (Two Features)
1. Problem Statement
Implement Multiple Linear Regression with two independent continuous predictor variables ($X_1, X_2$) and one dependent target variable ($Y$). Solve the linear equation:
$$\hat{Y} = b_0 + b_1X_1 + b_2X_2$$
using closed-form deviation products and 2x2 determinants.
2. Core Mathematical Formulation
Centering Variables (Mean Deviations):
$$x_1 = X_1 - \bar{X}_1, \quad x_2 = X_2 - \bar{X}_2, \quad y = Y - \bar{Y}$$Coefficient Solution via System Determinants:
$$b_1 = \frac{(\sum x_1 y)(\sum x_2^2) - (\sum x_2 y)(\sum x_1 x_2)}{(\sum x_1^2)(\sum x_2^2) - (\sum x_1 x_2)^2}$$
$$b_2 = \frac{(\sum x_2 y)(\sum x_1^2) - (\sum x_1 y)(\sum x_1 x_2)}{(\sum x_1^2)(\sum x_2^2) - (\sum x_1 x_2)^2}$$Model Intercept ($b_0$):
$$b_0 = \bar{Y} - b_1\bar{X}_1 - b_2\bar{X}_2$$
3. Pure C Implementation
#include <stdio.h>
int main() {
int n = 3, i;
// Input features X1, X2 and Target Y
float X1[] = {1.0, 2.0, 3.0};
float X2[] = {1.0, 2.1, 2.9};
float Y[] = {3.0, 5.8, 9.2};
float m1 = 0.0, m2 = 0.0, my = 0.0;
float s11 = 0.0, s22 = 0.0, s12 = 0.0, s1y = 0.0, s2y = 0.0;
float den, b0, b1, b2;
float test_x1 = 4.0, test_x2 = 3.8, pred_y;
// Step 1: Calculate feature and target sample means
for (i = 0; i < n; i++) {
m1 += X1[i];
m2 += X2[i];
my += Y[i];
}
m1 /= n; m2 /= n; my /= n;
// Step 2: Accumulate centered sum-of-squares and cross-products
for (i = 0; i < n; i++) {
float x1 = X1[i] - m1;
float x2 = X2[i] - m2;
float y = Y[i] - my;
s11 += x1 * x1;
s22 += x2 * x2;
s12 += x1 * x2;
s1y += x1 * y;
s2y += x2 * y;
}
// Step 3: Compute determinant denominator and weights
den = (s11 * s22) - (s12 * s12);
b1 = ((s1y * s22) - (s2y * s12)) / den;
b2 = ((s2y * s11) - (s1y * s12)) / den;
b0 = my - (b1 * m1) - (b2 * m2);
// Step 4: Output model and evaluate new data point
pred_y = b0 + (b1 * test_x1) + (b2 * test_x2);
printf("=== Lab 02: Multiple Linear Regression ===\n");
printf("b0 (Intercept) = %.2f\n", b0);
printf("b1 (Weight X1) = %.2f\n", b1);
printf("b2 (Weight X2) = %.2f\n", b2);
printf("Model Equation: Y = %.2f + (%.2f * X1) + (%.2f * X2)\n", b0, b1, b2);
printf("Prediction for (%.1f, %.1f) = %.2f\n", test_x1, test_x2, pred_y);
return 0;
}
4. Implementation Walkthrough & Memory Triggers
-
5 Deviation Accumulators: Remember
s11($\sum x_1^2$),s22($\sum x_2^2$),s12($\sum x_1 x_2$),s1y($\sum x_1 y$), ands2y($\sum x_2 y$). -
Cross Matching Rule:
b1pairss1ywiths22minuss2y * s12. Conversely,b2pairss2ywiths11minuss1y * s12. Both share the exact same denominatorden = s11*s22 - s12*s12.
5. Sample Execution Output
=== Lab 02: Multiple Linear Regression ===
b0 (Intercept) = 0.00
b1 (Weight X1) = 5.00
b2 (Weight X2) = -2.00
Model Equation: Y = 0.00 + (5.00 * X1) + (-2.00 * X2)
Prediction for (4.0, 3.8) = 12.40
Lab 03: K-Nearest Neighbors (KNN Classification)
1. Problem Statement
Implement the K-Nearest Neighbors (KNN) algorithm from scratch for binary classification. Given a query point $Q(q_1, q_2)$:
- Calculate the Euclidean distance between $Q$ and all training vectors.
- Sort the training instances in ascending order of proximity while maintaining label synchronicity.
- Apply majority voting over the top $K$ nearest neighbors to classify $Q$.
2. Core Mathematical Formulation
Euclidean Distance Metric:
$$d(p, q) = \sqrt{(p_1 - q_1)^2 + (p_2 - q_2)^2}$$Majority Voting Function:
$$\hat{Y} = \begin{cases} 1 & \text{if } \sum_{i=1}^{K} y_{(i)} > \frac{K}{2} \ 0 & \text{otherwise} \end{cases}$$
3. Pure C Implementation
#include <stdio.h>
#include <math.h>
int main() {
int n = 6, k = 3, i, j;
// 2D training coordinates and binary classes (0 or 1)
float x1[] = {1.0, 1.5, 2.0, 5.0, 5.5, 6.0};
float x2[] = {1.0, 2.0, 1.5, 5.0, 6.0, 5.5};
int label[] = {0, 0, 0, 1, 1, 1};
float d[6];
float q1 = 2.0, q2 = 2.5; // Test query point
int votes = 0;
// Step 1: Compute Euclidean distance to all training instances
for (i = 0; i < n; i++) {
d[i] = sqrt(((x1[i] - q1) * (x1[i] - q1)) + ((x2[i] - q2) * (x2[i] - q2)));
}
// Step 2: Sort distances in ascending order while keeping labels synchronized
for (i = 0; i < n - 1; i++) {
for (j = 0; j < n - i - 1; j++) {
if (d[j] > d[j + 1]) {
float temp_d = d[j];
d[j] = d[j + 1];
d[j + 1] = temp_d;
int temp_l = label[j];
label[j] = label[j + 1];
label[j + 1] = temp_l;
}
}
}
// Step 3: Tabulate votes across the top K neighbors
for (i = 0; i < k; i++) {
votes += label[i];
}
int pred_class = (votes > (k / 2)) ? 1 : 0;
printf("=== Lab 03: K-Nearest Neighbors (KNN) ===\n");
printf("Query Point: (%.1f, %.1f), K = %d\n", q1, q2, k);
printf("Top %d Nearest Distances: %.2f, %.2f, %.2f\n", k, d[0], d[1], d[2]);
printf("Top %d Neighbor Labels: %d, %d, %d\n", k, label[0], label[1], label[2]);
printf("Predicted Class Label: %d\n", pred_class);
return 0;
}
4. Implementation Walkthrough & Memory Triggers
-
Parallel Swap in Bubble Sort: Never swap
d[j]alone. Swapping distances without simultaneously swappinglabel[j]destroys class mapping. - Odd $K$ Advantage: Choosing an odd value for $K$ ($K=3, 5$) avoids ties in two-class binary classification.
5. Sample Execution Output
=== Lab 03: K-Nearest Neighbors (KNN) ===
Query Point: (2.0, 2.5), K = 3
Top 3 Nearest Distances: 0.71, 0.71, 1.12
Top 3 Neighbor Labels: 0, 0, 0
Predicted Class Label: 0
Lab 04: Naïve Bayes Classifier (Laplace Smoothing)
1. Problem Statement
Implement a Naïve Bayes classifier with additive Laplace smoothing for discrete binary text features (e.g., spam email detection). Evaluate prior and likelihood probabilities to calculate class posterior scores for an incoming message.
2. Core Mathematical Formulation
Bayes' Theorem with Independence Assumption:
$$P(C \mid X) \propto P(C) \prod_{j=1}^{d} P(X_j \mid C)$$Laplace Smoothing Operator ($k = 2$ for binary attributes):
$$P(X_j = v \mid C) = \frac{\text{count}(X_j = v \text{ and } C) + 1}{N_C + 2}$$
where $N_C$ is total occurrences of class $C$.
3. Pure C Implementation
#include <stdio.h>
int main() {
int n = 10, i;
// Feature 1: Keyword "Free" present (1) or absent (0)
int feat_free[] = {0, 1, 1, 1, 1, 0, 0, 0, 0, 0};
// Feature 2: Keyword "Offer" present (1) or absent (0)
int feat_offer[] = {1, 1, 1, 1, 0, 0, 0, 0, 0, 0};
// Ground Truth Target: Spam = 1, Ham/Not-Spam = 0
int target[] = {1, 1, 1, 1, 1, 0, 0, 0, 0, 0};
// Test sample: Contains "Offer" (1), but no "Free" (0)
int test_free = 0, test_offer = 1;
int n_spam = 0, n_ham = 0;
int c_free_spam = 0, c_offer_spam = 0;
int c_free_ham = 0, c_offer_ham = 0;
// Step 1: Accumulate class totals and matching feature frequencies
for (i = 0; i < n; i++) {
if (target[i] == 1) {
n_spam++;
if (feat_free[i] == test_free) c_free_spam++;
if (feat_offer[i] == test_offer) c_offer_spam++;
} else {
n_ham++;
if (feat_free[i] == test_free) c_free_ham++;
if (feat_offer[i] == test_offer) c_offer_ham++;
}
}
// Step 2: Calculate Class Prior Probabilities
float p_spam = (float)n_spam / n;
float p_ham = (float)n_ham / n;
// Step 3: Compute Laplace-smoothed conditional likelihoods: (count + 1) / (N + 2)
float l_free_spam = (float)(c_free_spam + 1) / (n_spam + 2);
float l_offer_spam = (float)(c_offer_spam + 1) / (n_spam + 2);
float l_free_ham = (float)(c_free_ham + 1) / (n_ham + 2);
float l_offer_ham = (float)(c_offer_ham + 1) / (n_ham + 2);
// Step 4: Multiply Prior * Likelihoods to obtain posterior scores
float post_spam = p_spam * l_free_spam * l_offer_spam;
float post_ham = p_ham * l_free_ham * l_offer_ham;
printf("=== Lab 04: Naive Bayes with Laplace Smoothing ===\n");
printf("Priors: P(Spam) = %.2f, P(Ham) = %.2f\n", p_spam, p_ham);
printf("Unnormalized Posterior Spam = %.4f\n", post_spam);
printf("Unnormalized Posterior Ham = %.4f\n", post_ham);
printf("Classification Result = %s\n", (post_spam > post_ham) ? "SPAM" : "HAM");
return 0;
}
4. Implementation Walkthrough & Memory Triggers
-
The Zero-Probability Solution: In standard probability, if $P(\text{Offer}=\text{Yes} \mid \text{Ham}) = 0$, the entire posterior collapses to zero. Laplace smoothing fixes this by adding $1$ to numerator and $k=2$ to denominator (
(count + 1.0) / (N + 2)). -
Float Casting: Always write
(float)(count + 1) / (N + 2)to avoid integer division returning zero.
5. Sample Execution Output
=== Lab 04: Naive Bayes with Laplace Smoothing ===
Priors: P(Spam) = 0.50, P(Ham) = 0.50
Unnormalized Posterior Spam = 0.1020
Unnormalized Posterior Ham = 0.0612
Classification Result = SPAM
Lab 05: Logistic Regression (Sigmoid & Gradient Descent)
1. Problem Statement
Implement binary Logistic Regression using gradient descent. Train a linear combination model $z = b_0 + b_1x$ passed through a continuous Sigmoid activation function $P = \frac{1}{1 + e^{-z}}$ to predict classification probabilities.
2. Core Mathematical Formulation
Sigmoid Hypothesis:
$$P(Y = 1 \mid x) = \sigma(z) = \frac{1}{1 + e^{-(b_0 + b_1x)}}$$Batch Gradient Descent Weight Update:
$$b_0 \leftarrow b_0 - \alpha \frac{1}{m}\sum_{i=1}^{m}(P^{(i)} - y^{(i)})$$
$$b_1 \leftarrow b_1 - \alpha \frac{1}{m}\sum_{i=1}^{m}(P^{(i)} - y^{(i)})x^{(i)}$$
where $\alpha$ is the learning rate and $m$ is the sample count.
3. Pure C Implementation
#include <stdio.h>
#include <math.h>
int main() {
int m = 6, epoch, i;
// Data: Study Hours (x) vs Exam Result Pass=1 / Fail=0 (y)
float x[] = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0};
float y[] = {0.0, 0.0, 0.0, 1.0, 1.0, 1.0};
float b0 = 0.0, b1 = 0.0;
float alpha = 0.1; // Learning rate
int epochs = 500;
float test_x = 5.0, z_test, p_test;
// Step 1: Batch Gradient Descent Optimization Loop
for (epoch = 0; epoch < epochs; epoch++) {
float grad_b0 = 0.0;
float grad_b1 = 0.0;
for (i = 0; i < m; i++) {
float z = b0 + (b1 * x[i]);
float p = 1.0 / (1.0 + exp(-z)); // Sigmoid activation
float err = p - y[i];
grad_b0 += err;
grad_b1 += err * x[i];
}
// Apply parameter updates
b0 -= alpha * (grad_b0 / m);
b1 -= alpha * (grad_b1 / m);
}
// Step 2: Make prediction for new observation
z_test = b0 + (b1 * test_x);
p_test = 1.0 / (1.0 + exp(-z_test));
int pred_class = (p_test >= 0.5) ? 1 : 0;
printf("=== Lab 05: Logistic Regression ===\n");
printf("Trained Weights: b0 = %.3f, b1 = %.3f\n", b0, b1);
printf("Test Input x = %.1f -> Logit z = %.2f\n", test_x, z_test);
printf("Predicted Probability P(Y=1) = %.4f\n", p_test);
printf("Predicted Decision Class = %d (%s)\n", pred_class, pred_class ? "PASS" : "FAIL");
return 0;
}
4. Implementation Walkthrough & Memory Triggers
-
Gradient Symmetry: Notice the gradient formula matches linear regression MSE gradient, but the prediction $P$ is squashed through
1.0 / (1.0 + exp(-z)). - Standard Threshold: Probabilities $\ge 0.5$ map to Class 1, while $< 0.5$ map to Class 0.
5. Sample Execution Output
=== Lab 05: Logistic Regression ===
Trained Weights: b0 = -3.894, b1 = 1.113
Test Input x = 5.0 -> Logit z = 1.67
Predicted Probability P(Y=1) = 0.8416
Predicted Decision Class = 1 (PASS)
Lab 06: Model Validation via Leave-One-Out Cross-Validation (LOOCV)
1. Problem Statement
Implement Leave-One-Out Cross-Validation (LOOCV) for a regression model in C. Partition a dataset of $n$ instances into $n$ separate folds. In each iteration, train on $n - 1$ samples, evaluate on the single held-out instance, and compute the final generalization Mean Squared Error ($\text{MSE}_{\text{LOO}}$).
2. Core Mathematical Formulation
Exhaustive Partitioning:
$$\text{Fold } k: \quad \mathcal{D}{\text{train}} = { (x_i, y_i) \mid i \neq k }, \quad \mathcal{D}{\text{test}} = { (x_k, y_k) }$$Cross-Validation Mean Squared Error:
$$\text{MSE}{\text{LOO}} = \frac{1}{n} \sum{k=1}^{n} (y_k - \hat{y}_{(k)})^2$$
3. Pure C Implementation
#include <stdio.h>
int main() {
int n = 4, test_idx, i;
float x[] = {1.0, 2.0, 3.0, 4.0};
float y[] = {2.0, 3.0, 5.0, 6.0};
float total_sq_err = 0.0;
printf("=== Lab 06: Leave-One-Out Cross-Validation (LOOCV) ===\n");
// Outer Loop: Hold out each sample once
for (test_idx = 0; test_idx < n; test_idx++) {
float sum_x = 0.0, sum_y = 0.0;
float mx, my, num = 0.0, den = 0.0;
float b0, b1, pred_y, err;
int train_size = n - 1;
// Step 1: Compute means over the (n - 1) training instances
for (i = 0; i < n; i++) {
if (i == test_idx) continue; // Skip held-out test instance
sum_x += x[i];
sum_y += y[i];
}
mx = sum_x / train_size;
my = sum_y / train_size;
// Step 2: Fit model on the training fold
for (i = 0; i < n; i++) {
if (i == test_idx) continue;
num += (x[i] - mx) * (y[i] - my);
den += (x[i] - mx) * (x[i] - mx);
}
b1 = num / den;
b0 = my - (b1 * mx);
// Step 3: Evaluate on held-out sample
pred_y = b0 + (b1 * x[test_idx]);
err = y[test_idx] - pred_y;
total_sq_err += err * err;
printf("Fold %d: Left out (%.1f, %.1f) -> Pred: %.2f, Sq Error: %.4f\n",
test_idx + 1, x[test_idx], y[test_idx], pred_y, err * err);
}
// Step 4: Average squared loss across all folds
float loo_mse = total_sq_err / n;
printf("Final LOOCV Mean Squared Error (MSE) = %.4f\n", loo_mse);
return 0;
}
4. Implementation Walkthrough & Memory Triggers
-
The
continueStatement: Usingif (i == test_idx) continue;eliminates the need to allocate separate temporary arrays for every cross-validation fold. -
Division Factors: Train means divide by
train_size = n - 1. The final overall cross-validation error divides by the total sample sizen.
5. Sample Execution Output
=== Lab 06: Leave-One-Out Cross-Validation (LOOCV) ===
Fold 1: Left out (1.0, 2.0) -> Pred: 1.83, Sq Error: 0.0278
Fold 2: Left out (2.0, 3.0) -> Pred: 3.50, Sq Error: 0.2500
Fold 3: Left out (3.0, 5.0) -> Pred: 4.50, Sq Error: 0.2500
Fold 4: Left out (4.0, 6.0) -> Pred: 6.67, Sq Error: 0.4444
Final LOOCV Mean Squared Error (MSE) = 0.2431
Conclusion & Next Steps
This concludes Module I: Basic Supervised Learning & Validation written in pure C. You can compile any of these programs directly using GCC or Clang:
gcc -O2 lab_01.c -o lab_01
gcc -O2 lab_03.c -lm -o lab_03 # Note: link math library (-lm) for KNN / Logistic
./lab_01
Top comments (2)
Building the foundations in C is valuable because it forces every hidden convenience into the open: layout, numerical stability, ownership, and the boundary between a correct formula and a robust implementation. A nice companion to the derivations would be property-style tests against a known reference implementation, especially around edge cases and gradient checks.
Thanks, Alex! I completely agree. Writing it in C really forces you to understand the underlying mechanics that higher-level languages abstract away. Property-style tests against a reference implementation are a fantastic idea, especially for catching those tricky edge cases in the gradients. I'll definitely look into adding that as a next step!