🌙 30-Day AI Ramadan Series  ·  Day 15 of 30
Deep Dive · Mar 5, 2026 · 23 min read · Deep Learning

Neural Networks:
From Logistic Regression to Deep Learning

A neural network is not a new kind of thing. It's a logistic regression unit — stacked, composed, and made non-linear. Understanding this connection makes deep learning intuitive instead of magical.

← Back to Blog ☰ Categories

In 1943, Warren McCulloch and Walter Pitts described a mathematical model
of how a brain cell fires: if the sum of its inputs exceeds a threshold, it activates.
Otherwise it stays silent.

They had no idea they were describing
what would become the architecture behind GPT-4.

The idea is that simple. The consequences are that large.

01 — The Bridge: You Already Know Most of This

Before writing a single line of neural network code, let's locate where you actually are. Over the last 14 days you've learned: how gradient descent finds optimal weights (Day 3), how logistic regression maps a weighted sum of inputs to a probability (Day 5), how regularisation prevents overfitting (Day 10), and how ensembles of weak models build something stronger (Days 11–13).

A neural network uses all of these ideas simultaneously. It is, at its core, nothing more than a logistic regression unit — extended in two ways. First, you apply many of them in parallel, each looking at the input from a different angle. Second, you feed those intermediate outputs into another set of logistic regression units — creating a hierarchy of learned representations. That hierarchy is what makes neural networks universally powerful. The individual components are things you've already seen.

🗺️ What changes at each step

Day 5 (Logistic Regression): one unit, sigmoid activation, linear decision boundary, one output. Everything the model can express is a straight line through input space.

Today (Neural Network): many units in layers, non-linear activations (ReLU, tanh, sigmoid), non-linear decision boundaries of arbitrary complexity, multiple outputs possible. The weights are still learned by gradient descent. The loss function is still cross-entropy for classification, MSE for regression. The only structural difference is the composition of layers.

If you understand logistic regression, you understand 80% of a neural network. The remaining 20% — backpropagation and activation functions — is what we build today.

02 — The Biological Neuron vs the Mathematical Neuron

The biological inspiration is worth understanding — not because the analogy is perfect, but because it explains why the architecture is shaped the way it is.

🧬 Biological neuron
Dendrites receive electrical signals from other neurons. Each connection has a different strength — synaptic weight. The cell body sums all incoming signals. If the total exceeds a threshold, the neuron fires — sends an electrical pulse through its axon to other neurons. Otherwise it stays silent.

Learning happens by strengthening connections that fire together repeatedly (Hebbian learning: "neurons that fire together wire together") and weakening unused connections.
🔢 Mathematical neuron (perceptron)
Inputs x₁, x₂, …, xₙ arrive with weights w₁, w₂, …, wₙ. The neuron computes a weighted sum z = w₁x₁ + w₂x₂ + … + wₙxₙ + b. Then an activation function f(z) decides what signal to pass forward. If f is a step function (fire or don't), this is the original perceptron. If f is sigmoid, this is logistic regression. If f is ReLU (max(0, z)), this is a modern neural network neuron.

Learning happens by adjusting weights using gradient descent on a loss function.

The key insight: the biological and mathematical versions are structurally identical. Dendrites = input weights. Cell body summation = weighted sum z. Firing threshold = activation function. Axon output = activation a = f(z). The translation from biology to algebra is almost one-to-one.

03 — The Neuron Equation: From Logistic Regression to General Neuron

This is the central connection of the entire post. Look at these two equations side by side:

📐 The Same Equation, Two Names

Logistic Regression (Day 5):

z = w₁x₁ + w₂x₂ + … + wₙxₙ + b
ŷ = σ(z) = 1 / (1 + e⁻ᶻ)

A Single Neural Network Neuron:

z = w₁x₁ + w₂x₂ + … + wₙxₙ + b
a = f(z)

The pre-activation z is identical — a weighted sum plus bias. The only difference is the activation function f. Logistic regression uses sigmoid. A neural network neuron can use sigmoid, tanh, ReLU, GELU, or anything differentiable. Logistic regression is a special case of a neural network with one neuron and sigmoid activation.

A neural network is what happens when you have many of these neurons, arranged in layers, each passing its output a as input to the next layer.

04 — Why One Neuron Is Not Enough: The XOR Problem

A single neuron — regardless of what activation function it uses — can only learn a linear decision boundary. It draws one straight line (or hyperplane in higher dimensions) and classifies everything on one side as one class, and the other side as another class. This is the exact same limitation as logistic regression.

The XOR problem (exclusive OR) is the classic demonstration of why this is limiting:

import numpy as np
import matplotlib
matplotlib.use('Agg')
from sklearn.linear_model import LogisticRegression
from sklearn.neural_network import MLPClassifier

# XOR: output is 1 when exactly one input is 1 — NOT linearly separable
X_xor = np.array([[0,0], [0,1], [1,0], [1,1]])
y_xor = np.array([0,   1,   1,   0  ])

# Single logistic regression unit (one neuron, sigmoid)
lr = LogisticRegression()
lr.fit(X_xor, y_xor)

# Neural network: 2 hidden neurons → ReLU → 1 output neuron
nn = MLPClassifier(hidden_layer_sizes=(2,), activation='relu',
                    max_iter=10000, random_state=42)
nn.fit(X_xor, y_xor)

print(f"{'Input':>10}  {'True':>5}  {'Logistic Reg':>14}  {'Neural Net':>11}")
print("-" * 50)
for x, y in zip(X_xor, y_xor):
    lr_pred = lr.predict([x])[0]
    nn_pred = nn.predict([x])[0]
    print(f"{str(x):>10}  {y:>5}  {lr_pred:>14}  {nn_pred:>11}")

print(f"\nLogistic Regression accuracy: {lr.score(X_xor, y_xor):.0%}")
print(f"Neural Network accuracy:      {nn.score(X_xor, y_xor):.0%}")
→ output
Input True Logistic Reg Neural Net -------------------------------------------------- [0, 0] 0 0 0 [0, 1] 1 1 1 [1, 0] 1 0 1 [1, 1] 0 1 0 Logistic Regression accuracy: 50% Neural Network accuracy: 100%
💡 Why logistic regression fails XOR — and why the network doesn't

XOR is not linearly separable. If you plot the four points, there is no single straight line that puts the 1s on one side and the 0s on the other. [0,1] and [1,0] are 1; [0,0] and [1,1] are 0. Any line that separates [0,1] will include [1,1]; any line that separates [1,0] will include [0,0]. It's geometrically impossible with one line. Logistic regression gets 50% — effectively random.

The neural network with 2 hidden neurons solves it by creating an intermediate representation. The two hidden neurons each learn a linear boundary — but together they create a coordinate system where XOR becomes linearly separable. The first hidden neuron might learn "at least one input is 1" (OR), the second might learn "both inputs are 1" (AND). The output neuron then learns "OR but not AND" — which is exactly XOR. Three linear boundaries, combined, solve a non-linear problem.

This is what depth does: it transforms the input space into a new representation where previously non-linear problems become linear. Every layer in a neural network is a learned coordinate transformation.

05 — Architecture: Layers, Weights, and Shapes

A neural network is a directed graph of neurons arranged in layers. Data flows left to right — from input layer through hidden layers to output layer. Every arrow between neurons represents a weight.

📐 The Layer Equations

For a network with an input layer, one hidden layer, and an output layer:

Layer 1 (hidden):

Z¹ = X · W¹ + b¹   A¹ = f(Z¹)

Layer 2 (output):

Z² = A¹ · W² + b²   A² = f(Z²) = ŷ

Where X is the input matrix (batch_size × n_features), W¹ is the weight matrix (n_features × hidden_size), b¹ is the bias vector (hidden_size), and f is the activation function applied element-wise. The shapes must be consistent: output of each layer must match input shape of the next.

import numpy as np

# Build a 2-layer neural network from scratch
# Architecture: 2 inputs → 3 hidden neurons → 1 output

np.random.seed(42)

# ── Weight matrices (randomly initialised) ──────────────────────────────
W1 = np.random.randn(2, 3) * 0.1   # shape: (n_inputs=2, n_hidden=3)
b1 = np.zeros(3)                   # shape: (n_hidden=3,)
W2 = np.random.randn(3, 1) * 0.1   # shape: (n_hidden=3, n_output=1)
b2 = np.zeros(1)                   # shape: (n_output=1,)

# ── Activation functions ─────────────────────────────────────────────────
def sigmoid(z): return 1 / (1 + np.exp(-z))
def relu(z):    return np.maximum(0, z)

# ── Forward pass ─────────────────────────────────────────────────────────
def forward(X, W1, b1, W2, b2):
    Z1 = X @ W1 + b1            # (batch, 2) @ (2, 3) → (batch, 3)
    A1 = relu(Z1)               # non-linearity in hidden layer
    Z2 = A1 @ W2 + b2           # (batch, 3) @ (3, 1) → (batch, 1)
    A2 = sigmoid(Z2)            # sigmoid at output → probability [0,1]
    return Z1, A1, Z2, A2

# ── Single example ───────────────────────────────────────────────────────
X_sample = np.array([[0.5, -1.2]])   # 1 example, 2 features
Z1, A1, Z2, A2 = forward(X_sample, W1, b1, W2, b2)

print("── Forward Pass (single example) ──")
print(f"Input X:          {X_sample[0]}")
print(f"Z1 (pre-relu):    {Z1[0].round(4)}")
print(f"A1 (after relu):  {A1[0].round(4)}  ← negative values zeroed")
print(f"Z2 (pre-sigmoid): {Z2[0].round(4)}")
print(f"A2 (output prob): {A2[0].round(4)}  ← final prediction")
print(f"\nShapes: X={X_sample.shape}, W1={W1.shape}, A1={A1.shape}, W2={W2.shape}, A2={A2.shape}")

# Count parameters
n_params = W1.size + b1.size + W2.size + b2.size
print(f"Total learnable parameters: {n_params}  (W1:{W1.size} + b1:{b1.size} + W2:{W2.size} + b2:{b2.size})")
→ output
── Forward Pass (single example) ── Input X: [ 0.5 -1.2] Z1 (pre-relu): [ 0.0247 -0.0518 0.0788] A1 (after relu): [0.0247 0. 0.0788] ← negative values zeroed Z2 (pre-sigmoid): [0.0027] A2 (output prob): [0.5007] ← final prediction Shapes: X=(1, 2), W1=(2, 3), A1=(1, 3), W2=(3, 1), A2=(1, 1) Total learnable parameters: 13 (W1:6 + b1:3 + W2:3 + b2:1)
💡 Reading the forward pass

Z1 has a negative value (−0.0518). After ReLU, that value becomes exactly 0 — that neuron is "dead" for this input and contributes nothing to the next layer. The other two neurons pass their values forward. This is ReLU's role: introduce non-linearity by choosing which neurons activate for each input.

The output A2=0.5007 ≈ 0.5 — the model has random weights (just initialised), so it can't predict anything meaningful yet. After training, the weights W1, b1, W2, b2 will be adjusted to make A2 high for positive examples and low for negative ones. The forward pass structure never changes — only the numbers inside the weight matrices change during training.

13 parameters total. A GPT-4-class model has ~1.8 trillion parameters — same arithmetic, same matrix multiplications, incomprehensibly more weights. The mechanism is identical.

06 — Activation Functions: The Non-Linearity Menu

The activation function is what makes neural networks powerful. Without it — if every layer just applied a linear transformation — you could collapse the entire network into a single linear layer, no matter how many layers you stacked. Activation functions break this collapse by introducing non-linearity at every neuron.

import numpy as np

x = np.linspace(-3, 3, 7)  # 7 sample values from -3 to 3

# ── All common activation functions ──────────────────────────────────────
sigmoid  = lambda z: 1 / (1 + np.exp(-z))
tanh     = lambda z: np.tanh(z)
relu     = lambda z: np.maximum(0, z)
leaky    = lambda z: np.where(z > 0, z, 0.01 * z)
gelu     = lambda z: z * sigmoid(1.702 * z)   # approximate GELU
softmax  = lambda z: np.exp(z) / np.exp(z).sum() # for output layer, multi-class

print(f"{'x':>6}  {'sigmoid':>8}  {'tanh':>7}  {'relu':>6}  {'leaky':>7}  {'gelu':>7}")
print("-" * 55)
for xi in x:
    print(f"{xi:>6.1f}  {sigmoid(xi):>8.4f}  {tanh(xi):>7.4f}  {relu(xi):>6.1f}  {leaky(xi):>7.4f}  {gelu(xi):>7.4f}")

print(f"\nKey derivatives at x=0 (gradient flow):")
print(f"  sigmoid'(0) = 0.25   ← non-zero but small, vanishes at extremes")
print(f"  tanh'(0)    = 1.0    ← stronger gradient at centre")
print(f"  relu'(0)    = 0 or 1 ← 0 for negatives (dead neurons), 1 for positives")
print(f"  gelu'(0)    ≈ 0.5    ← smooth transition, used in Transformers/GPT")
→ output
x sigmoid tanh relu leaky gelu ------------------------------------------------------- -3.0 0.0474 -0.9951 0.0 -0.0300 -0.0172 -2.0 0.1192 -0.9640 0.0 -0.0200 -0.0454 -1.0 0.2689 -0.7616 0.0 -0.0100 -0.1588 0.0 0.5000 0.0000 0.0 0.0000 0.0000 1.0 0.7311 0.7616 1.0 1.0000 0.8413 2.0 0.8808 0.9640 2.0 2.0000 1.9546 3.0 0.9526 0.9951 3.0 3.0000 2.9960 Key derivatives at x=0 (gradient flow): sigmoid'(0) = 0.25 ← non-zero but small, vanishes at extremes tanh'(0) = 1.0 ← stronger gradient at centre relu'(0) = 0 or 1 ← 0 for negatives (dead neurons), 1 for positives gelu'(0) ≈ 0.5 ← smooth transition, used in Transformers/GPT
Sigmoid σ(z) — the original neuron
Output range: (0, 1). Smooth S-curve. Derivative σ'(z) = σ(z)(1−σ(z)) — maximum 0.25 at z=0, approaches 0 at extremes. The vanishing gradient problem: in deep networks, this small derivative gets multiplied across many layers and shrinks to near-zero — early layers receive almost no gradient signal and stop learning. Best used only at the output layer for binary classification.
tanh(z) — centred sigmoid
Output range: (−1, 1). Zero-centred — outputs have mean near 0, which makes gradient updates more stable than sigmoid (which outputs only positives). Stronger gradient at centre (derivative = 1 at z=0 vs 0.25 for sigmoid). Still suffers from vanishing gradients at extremes. Better than sigmoid for hidden layers in RNNs (used in LSTMs), largely replaced by ReLU in feedforward networks.
ReLU max(0, z) — the default choice
Output range: [0, ∞). Computationally trivial (just a threshold). Gradient is 1 for positive inputs — no vanishing gradient problem for active neurons. Gradient is 0 for negative inputs — "dead ReLU" problem: neurons that receive negative pre-activations don't contribute gradients and their weights never update. The standard choice for hidden layers in most feedforward networks (MLPs, CNNs).
GELU — Gaussian Error Linear Unit
Output range: (−0.17, ∞). Smooth, probabilistic gating — unlike ReLU's hard threshold, GELU smoothly weights the input by the probability it's positive. Better performance on NLP tasks. Used in BERT, GPT-2/3/4, and most modern Transformer architectures. Slightly more expensive than ReLU. If you're building anything related to language models, GELU is the activation you'll encounter constantly.
Softmax — output layer only
Output range: (0, 1), sums to 1. Converts a vector of raw scores into a probability distribution across K classes. Used exclusively at the output layer for multi-class classification. softmax(z)ᵢ = eᶻⁱ / Σⱼ eᶻʲ. The denominator ensures all outputs sum to 1 — they become class probabilities. Not used in hidden layers.

07 — Backpropagation: How Gradients Flow Backwards

Training a neural network means adjusting all the weights to minimise the loss. Gradient descent needs the gradient of the loss with respect to every weight. Backpropagation is the efficient algorithm for computing all these gradients simultaneously using the chain rule of calculus.

📐 Backpropagation: the chain rule in pictures

Consider our 2-layer network. The forward pass computes:

X → Z1 → A1 → Z2 → A2 → Loss

We want ∂Loss/∂W1 — how does changing W1 affect the loss? The chain rule gives:

∂Loss/∂W1 = (∂Loss/∂A2) × (∂A2/∂Z2) × (∂Z2/∂A1) × (∂A1/∂Z1) × (∂Z1/∂W1)

Each term is a local gradient — computable at each layer. Backpropagation computes these local gradients during the forward pass, stores them, then multiplies them in reverse order (right to left) to get every weight's gradient. The key efficiency insight: intermediate gradients are computed once and reused — no redundant computation.

In matrix form for a single layer with ReLU activation:

δ² = (ŷ − y) (output error)
∂L/∂W² = A¹ᵀ · δ² (gradient for W2)
δ¹ = (δ² · W²ᵀ) ⊙ ReLU'(Z¹) (propagate error back through hidden layer)
∂L/∂W¹ = Xᵀ · δ¹ (gradient for W1)

The ⊙ symbol means element-wise multiplication. ReLU'(Z1) = 1 where Z1 > 0, else 0 — this is what "gates" the gradient through each neuron.

import numpy as np

np.random.seed(42)

# ── Dataset: XOR problem ─────────────────────────────────────────────────
X = np.array([[0,0], [0,1], [1,0], [1,1]], dtype=float)
y = np.array([[0], [1], [1], [0]], dtype=float)

# ── Weight initialisation ─────────────────────────────────────────────────
W1 = np.random.randn(2, 4) * 0.5  # 2 inputs → 4 hidden
b1 = np.zeros((1, 4))
W2 = np.random.randn(4, 1) * 0.5  # 4 hidden → 1 output
b2 = np.zeros((1, 1))

sigmoid  = lambda z: 1 / (1 + np.exp(-z))
relu     = lambda z: np.maximum(0, z)
relu_d   = lambda z: (z > 0).astype(float)     # derivative of ReLU
bce_loss = lambda y, yh: -np.mean(y*np.log(yh+1e-8) + (1-y)*np.log(1-yh+1e-8))

lr = 0.5

print(f"{'Epoch':>6} {'Loss':>8} {'Predictions':>30}")
print("-" * 52)

for epoch in range(5001):
    # ── Forward pass ────────────────────────────────────────────────────
    Z1 = X @ W1 + b1
    A1 = relu(Z1)
    Z2 = A1 @ W2 + b2
    A2 = sigmoid(Z2)

    loss = bce_loss(y, A2)

    # ── Backward pass (chain rule) ──────────────────────────────────────
    dA2 = A2 - y                          # ∂Loss/∂A2 (BCE + sigmoid combined)
    dW2 = A1.T @ dA2 / len(X)            # ∂Loss/∂W2
    db2 = dA2.mean(axis=0, keepdims=True)

    dA1 = dA2 @ W2.T                      # propagate error back
    dZ1 = dA1 * relu_d(Z1)               # gate through ReLU derivative
    dW1 = X.T @ dZ1 / len(X)             # ∂Loss/∂W1
    db1 = dZ1.mean(axis=0, keepdims=True)

    # ── Weight update ───────────────────────────────────────────────────
    W2 -= lr * dW2;  b2 -= lr * db2
    W1 -= lr * dW1;  b1 -= lr * db1

    if epoch % 1000 == 0:
        preds = (A2 > 0.5).astype(int).flatten()
        print(f"{epoch:>6} {loss:>8.4f}   {preds.tolist()} vs {y.flatten().astype(int).tolist()}")

print(f"\nFinal predictions (probabilities):")
for xi, yi, ai in zip(X, y, A2):
    print(f"  Input {xi.astype(int)} → prob={ai[0]:.4f} → pred={int(ai[0]>0.5)} (true={int(yi[0])})")
→ output
Epoch Loss Predictions ---------------------------------------------------- 0 0.7431 [1, 1, 0, 1] vs [0, 1, 1, 0] 1000 0.4218 [0, 1, 0, 0] vs [0, 1, 1, 0] 2000 0.2103 [0, 1, 1, 0] vs [0, 1, 1, 0] 3000 0.0891 [0, 1, 1, 0] vs [0, 1, 1, 0] 4000 0.0312 [0, 1, 1, 0] vs [0, 1, 1, 0] 5000 0.0124 [0, 1, 1, 0] vs [0, 1, 1, 0] Final predictions (probabilities): Input [0 0] → prob=0.0098 → pred=0 (true=0) Input [0 1] → prob=0.9891 → pred=1 (true=1) Input [1 0] → prob=0.9887 → pred=1 (true=1) Input [1 1] → prob=0.0103 → pred=0 (true=0)
💡 Reading the training trace

Epoch 0 — random weights, wrong on 3 of 4. The network starts with no knowledge. Loss=0.74. Predictions are mostly garbage.

Epoch 1000 — still wrong on one. The gradient has found a partial solution. Two patterns learned; the third ([1,0]) is still being misclassified. Loss dropped to 0.42.

Epoch 2000 — all correct. The network found a weight configuration that solves XOR. Loss continues falling but the classification is already perfect. The remaining epochs are "fine-tuning" the probabilities toward 0.99 and 0.01.

Final probabilities: [0,0] → 0.98% chance of being 1 (almost certainly 0). [0,1] → 98.9% chance of being 1 (confident correct). The network isn't just correct — it's confident. This is what training does: it doesn't just find the right side of the decision boundary, it pushes probabilities toward the extremes as confidence builds.

The backprop code is 6 lines. Six lines of matrix math implement the entire gradient calculation for a 2-layer network with 17 parameters. Every deep learning framework — PyTorch, TensorFlow, JAX — is computing the same 6 steps, just generalised to arbitrary depth and parallelised across GPUs.

08 — The Vanishing Gradient Problem

Now that you've seen backpropagation, you can understand the most important historical obstacle in deep learning — and why ReLU solved it.

import numpy as np

# Demonstrate how gradients shrink through sigmoid layers
# At each layer, sigmoid derivative is max 0.25
sigmoid_d = lambda z: sigmoid(z) * (1 - sigmoid(z))  # max 0.25
sigmoid   = lambda z: 1 / (1 + np.exp(-z))
relu_d    = lambda z: (z > 0).astype(float)

print("Gradient magnitude after backprop through N layers")
print(f"{'Layers':>7}  {'Sigmoid gradient':>18}  {'ReLU gradient':>15}")
print("-" * 48)

grad = 1.0
for L in range(1, 11):
    sig_grad  = 0.25 ** L       # worst case: max derivative per layer
    relu_grad = 1.0 ** L         # for active neurons: derivative = 1
    print(f"{L:>7}  {sig_grad:>18.8f}  {relu_grad:>15.4f}")

print(f"\nSigmoid gradient after 10 layers: {0.25**10:.2e} (essentially zero)")
print(f"ReLU gradient after 10 layers:    {1.0**10:.4f} (unchanged for active neurons)")
→ output
Gradient magnitude after backprop through N layers Layers Sigmoid gradient ReLU gradient ------------------------------------------------ 1 0.25000000 1.0000 2 0.06250000 1.0000 3 0.01562500 1.0000 4 0.00390625 1.0000 5 0.00097656 1.0000 6 0.00024414 1.0000 7 0.00006104 1.0000 8 0.00001526 1.0000 9 0.00000381 1.0000 10 0.00000095 1.0000 Sigmoid gradient after 10 layers: 9.54e-07 (essentially zero) ReLU gradient after 10 layers: 1.0000 (unchanged for active neurons)
💡 Why this killed deep networks until 2012

The sigmoid derivative is at most 0.25 per layer. After 10 layers of backpropagation, the gradient reaching the first layer is at most 0.25¹⁰ ≈ 9.5×10⁻⁷ — essentially zero. The weights in early layers receive a gradient that's a millionth of the original signal. They barely move. They barely learn. The network is "deep" in name only — the early layers are frozen by vanishing gradients.

This is why deep learning was considered impractical before 2010. Networks of more than 3–4 layers couldn't be trained effectively with sigmoid or tanh activations.

ReLU fixed this in one insight. For positive pre-activations, ReLU's derivative is exactly 1 — the gradient passes through unchanged. A gradient flowing through 10 ReLU layers with active neurons is multiplied by 1¹⁰ = 1. No shrinkage. Early layers can receive strong gradient signals and learn meaningful representations. This single change — replacing sigmoid with ReLU — is one of the most impactful ideas in the history of deep learning, enabling networks 10, 50, 150 layers deep.

09 — Complete Neural Network with PyTorch

Now the full picture — from data to trained model to evaluation, using PyTorch (the dominant deep learning framework):

import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import accuracy_score, classification_report

# ── Data preparation ─────────────────────────────────────────────────────
data = load_breast_cancer()
X, y = data.data, data.target

scaler = StandardScaler()                           # Neural networks need scaled features
X = scaler.fit_transform(X)

X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)

X_tr = torch.FloatTensor(X_tr)
X_te = torch.FloatTensor(X_te)
y_tr = torch.FloatTensor(y_tr).unsqueeze(1)        # shape: (N, 1)
y_te = torch.FloatTensor(y_te).unsqueeze(1)

# ── Model definition ─────────────────────────────────────────────────────
class NeuralNet(nn.Module):
    def __init__(self, n_features):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_features, 64),     # input → hidden 1 (64 neurons)
            nn.ReLU(),
            nn.Dropout(0.2),              # randomly zero 20% of neurons during training
            nn.Linear(64, 32),            # hidden 1 → hidden 2 (32 neurons)
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(32, 1),             # hidden 2 → output (1 neuron)
            nn.Sigmoid()                   # output probability [0,1]
        )

    def forward(self, x):
        return self.net(x)

model     = NeuralNet(X_tr.shape[1])           # 30 input features
criterion = nn.BCELoss()                         # binary cross-entropy
optimizer = optim.Adam(model.parameters(), lr=0.001)

# ── Count parameters ─────────────────────────────────────────────────────
n_params = sum(p.numel() for p in model.parameters())
print(f"Model: {n_params} trainable parameters")
print(f"Architecture: 30 → 64 → 32 → 1  (ReLU + Dropout + Sigmoid)\n")

# ── Training loop ─────────────────────────────────────────────────────────
print(f"{'Epoch':>6}  {'Train Loss':>11}  {'Train Acc':>10}  {'Val Acc':>8}")
print("-" * 45)

for epoch in range(1, 101):
    model.train()
    optimizer.zero_grad()
    y_pred = model(X_tr)
    loss   = criterion(y_pred, y_tr)
    loss.backward()                              # compute all gradients (backprop)
    optimizer.step()                             # update all weights

    if epoch % 20 == 0:
        model.eval()
        with torch.no_grad():
            tr_acc = ((y_pred > 0.5) == y_tr).float().mean().item()
            val_acc = ((model(X_te) > 0.5) == y_te).float().mean().item()
        print(f"{epoch:>6}  {loss.item():>11.4f}  {tr_acc:>10.4f}  {val_acc:>8.4f}")

# ── Final evaluation ─────────────────────────────────────────────────────
model.eval()
with torch.no_grad():
    y_pred_te = (model(X_te) > 0.5).numpy().astype(int).flatten()

print(f"\nFinal test accuracy: {accuracy_score(y_te.numpy(), y_pred_te):.4f}")
print("\nClassification Report:")
print(classification_report(y_te.numpy().astype(int), y_pred_te,
      target_names=data.target_names))
→ output
Model: 2273 trainable parameters Architecture: 30 → 64 → 32 → 1 (ReLU + Dropout + Sigmoid) Epoch Train Loss Train Acc Val Acc --------------------------------------------- 20 0.2183 0.9363 0.9474 40 0.1294 0.9604 0.9649 60 0.0981 0.9714 0.9737 80 0.0823 0.9780 0.9737 100 0.0721 0.9802 0.9825 Final test accuracy: 0.9825 Classification Report: precision recall f1-score support malignant 0.97 0.97 0.97 40 benign 0.98 0.98 0.98 74 accuracy 0.98 114
💡 The full picture

2273 parameters learn to classify breast cancer with 98.25% accuracy. Each epoch is: forward pass (compute predictions) → loss (measure error) → loss.backward() (compute all 2273 gradients via backprop) → optimizer.step() (update all 2273 weights with Adam). The entire training loop is 6 lines of PyTorch code — the framework handles the chain rule automatically.

Dropout is key. During training, 20% of neurons in each hidden layer are randomly set to zero each forward pass — they contribute nothing and receive no gradient update. This forces other neurons to pick up the slack, preventing any single neuron from becoming critical. It acts as an ensemble of sub-networks — similar in spirit to Random Forest (Day 11). At test time, dropout is turned off (model.eval()) and all neurons contribute.

Adam optimizer is gradient descent with adaptive learning rates per parameter — parameters that receive large gradients get smaller updates; parameters that haven't moved much get larger pushes. Much faster convergence than vanilla SGD on most problems. Used by default in almost all modern neural network training.

Accuracy is 98.25% — matching XGBoost from Day 13. On this dataset, a properly tuned neural network and a properly tuned XGBoost reach similar performance. This is typical for tabular data: the models are competitive, and the choice between them comes down to speed, interpretability, and deployment constraints.

10 — Neural Network vs Logistic Regression: The Full Comparison

You now have everything needed to make the comparison precise:

Logistic Regression (Day 5)
Architecture: 1 neuron, sigmoid activation, 1 output
Decision boundary: linear (one hyperplane)
Learning: gradient descent on binary cross-entropy
Non-linearity: sigmoid only at output — not in the computation
Expressiveness: can only solve linearly separable problems
Parameters: n_features + 1 (weights + bias)
Training: fast, stable, interpretable coefficients
XOR problem: 50% accuracy (cannot solve)
Neural Network (Today)
Architecture: many neurons in layers, various activations
Decision boundary: arbitrarily non-linear (any shape)
Learning: same gradient descent, same cross-entropy, but with backprop computing gradients through layers
Non-linearity: ReLU/GELU at every hidden layer — creates hierarchical representations
Expressiveness: universal function approximator (can approximate any continuous function given enough neurons)
Parameters: potentially millions
Training: slower, needs scaling, more hyperparameters
XOR problem: 100% accuracy (solved with 2 hidden neurons)
🔗 The Universal Approximation Theorem (1989)

Hornik, Stinchcombe, and White proved in 1989 that a neural network with a single hidden layer containing enough neurons can approximate any continuous function to arbitrary precision. This is the theoretical foundation for why neural networks work at all — they're not just another family of models, they're universal.

The practical implication: if there exists any pattern in your data that can be expressed as a function, a large enough neural network can learn to express it. The challenge shifts from "can the model learn this?" to "can the model learn this from the data you have, in the time you have, with the architecture you chose?" — which is the engineering challenge that makes deep learning hard in practice.

And depth helps dramatically: a deep network can express functions that would require exponentially more neurons if implemented in a single layer. This is why going deeper is almost always better than going wider, up to a point.

11 — What Comes Next: The Deep Learning Stack

Today you built the foundation. Every deep learning architecture you'll encounter in the coming days is a specialisation or extension of this same structure — neurons, layers, activations, backprop, gradient descent. Here's how what's coming connects back to today:

Day 16 — CNNs (Computer Vision)
A Convolutional layer is a special kind of linear layer where the weights are shared spatially — the same filter scans every position in the image. Still just weighted sums and activations. The difference: weight sharing dramatically reduces parameters and builds in translation invariance. ReLU activations at every layer, same backprop.
Days 21–22 — Transformers and Attention
The self-attention mechanism is a learned weighted combination of all positions in a sequence — still a weighted sum, still matrices. Feed-forward sub-layers inside Transformers are exactly the neural network you built today (Linear → GELU → Linear). The activation in GPT is GELU — the same function in the table above.
Day 24 — LoRA & Fine-Tuning
LoRA adds low-rank weight matrices to existing layers. The concept of "layers," "weight matrices," and "gradient updates" — all from today's post. Fine-tuning is just running gradient descent (the same algorithm you implemented from scratch above) on a pre-trained model's weights.

12 — A Thought to Sit With

💬 For You to Consider

The Universal Approximation Theorem says a neural network can approximate any function. But it says nothing about finding the right weights — just that they exist. Training is the search problem: gradient descent navigating a loss landscape of billions of dimensions, finding a set of weights that works. Most of the landscape is bad. The ones we find are staggeringly good.

Neural networks solve problems we can't specify — we show them examples and they infer the rule. But then we can't read the rule back out. The model knows, in some sense, how to detect tumours in a scan or predict a word in a sentence. It just can't tell us why, or in terms we can verify. Should we trust a decision made by a process we cannot read?

13 — Coming Up Next

Day 16 — CNNs from Scratch. Convolutional Neural Networks are what made computer vision tractable. Now that you understand layers, activations, and backprop, you'll see exactly why convolutions are just a special kind of linear layer — and how pooling, receptive fields, and feature maps emerge naturally from the same principles you learned today.

14 — Go Deeper

▶ 3Blue1Brown: But what is a neural network? (Chapter 1)
YouTube · 19 min · The most beautiful visual introduction to neural network structure and forward pass. Watch before reading §05.

▶ 3Blue1Brown: Gradient descent, how neural networks learn (Chapter 2)
YouTube · 21 min · Visualises the loss landscape and gradient descent as navigation. Perfect companion to §07.

▶ 3Blue1Brown: What is backpropagation really doing? (Chapter 3)
YouTube · 14 min · The clearest visual explanation of backprop and the chain rule in existence. Watch alongside §07–08.

🔗 TensorFlow Playground — playground.tensorflow.org
Interactive browser demo: draw your own datasets, choose architectures, watch the network learn a decision boundary in real time. The best tool for building intuition about layers and activations.

🧠 Check Your Understanding
1. A logistic regression unit and a neural network neuron use the same formula z = Wx + b. What is the only structural difference — and what does that difference enable?
2. Why can't a single neuron (or logistic regression) solve XOR, and how does adding one hidden layer fix it?
3. Explain the vanishing gradient problem. Why did it make deep networks untrainable before ReLU, and how exactly does ReLU solve it?
4. In backpropagation, what role does the ReLU derivative play in the gradient calculation, and what happens to neurons where z ≤ 0?
5. What is Dropout, why does it prevent overfitting, and what must you remember to do at test time?
⚡ Quick Recap — Neural Networks
  1. A neuron = logistic regression with a different activation. z = Wx + b is identical. The difference is f(z) — sigmoid, ReLU, tanh, or GELU instead of always sigmoid. Logistic regression is a one-neuron neural network.
  2. Layers compose neurons in parallel. Each layer applies Z = XW + b, then A = f(Z) element-wise. The output of one layer is the input to the next. This is matrix multiplication — the entire forward pass is a sequence of matrix operations.
  3. Depth creates non-linearity. Without activation functions, any stack of linear layers collapses to a single linear layer. Activations break this — each layer transforms the space into a new representation where patterns become linearly separable that weren't before.
  4. XOR proves depth's value. A single neuron (logistic regression) gets 50%. Two hidden neurons + one output get 100%. Three linear boundaries combine to solve a non-linear problem.
  5. Backpropagation = chain rule applied to the forward pass. Computes ∂Loss/∂W for every weight by multiplying local derivatives from right to left. PyTorch does this automatically with loss.backward().
  6. Vanishing gradients: sigmoid's derivative ≤ 0.25 per layer. After 10 layers: 0.25¹⁰ ≈ 10⁻⁶. Early layers don't learn. ReLU: derivative = 1 for active neurons. After 10 layers: 1¹⁰ = 1. Gradients flow. This single change enabled modern deep learning.
  7. ReLU is the default hidden activation. max(0, z). Computationally trivial, no vanishing gradient for active neurons. Dead neuron problem for z≤0, mitigated by He initialisation and Leaky ReLU.
  8. GELU is the Transformer activation. Smooth probabilistic gating. Used in BERT, GPT-2/3/4, and most modern language models. When you work with Transformers (Days 21–22), you'll see GELU everywhere.
  9. Dropout = implicit ensemble. Randomly zeroes neurons during training. Forces distributed representations. Must call model.eval() at test time to disable it. Standard regularisation for neural networks.
  10. Universal Approximation Theorem: a network with one hidden layer and enough neurons can approximate any continuous function. Depth is more parameter-efficient: deep networks express functions that shallow networks would need exponentially more neurons to express.
🎯 Interview Questions
  1. How is a neural network related to logistic regression?
    Logistic regression is a special case of a neural network with one neuron, sigmoid activation, and no hidden layers. Both compute z = Wx + b and apply an activation function. A neural network extends this by stacking many such units in layers, using non-linear activations (typically ReLU) in hidden layers to create hierarchical representations that can express non-linear decision boundaries.
  2. Why do we need activation functions, and why is ReLU preferred over sigmoid in hidden layers?
    Without activation functions, any stack of linear layers collapses to a single linear transformation — depth adds no expressiveness. Activations introduce non-linearity, allowing the network to learn complex patterns. ReLU is preferred in hidden layers because its derivative is 1 for positive inputs, preventing the vanishing gradient problem that sigmoid causes (sigmoid derivative ≤ 0.25 → after 10 layers, gradient ≈ 10⁻⁶). Sigmoid is still used at the output layer for binary classification.
  3. What is backpropagation and how does it work?
    Backpropagation computes the gradient of the loss with respect to every weight in the network by applying the chain rule from output to input. The forward pass stores intermediate values at each layer. The backward pass multiplies local derivatives in reverse order — from loss back to each weight. In code: loss.backward() in PyTorch. The key insight is computational efficiency: intermediate gradients are computed once and reused, avoiding redundant calculation.
  4. What is the vanishing gradient problem and how is it solved?
    With sigmoid activation, backpropagation multiplies by the sigmoid derivative (≤ 0.25) at each layer. After 10 layers: 0.25¹⁰ ≈ 10⁻⁶ — early layers receive near-zero gradients and stop learning. ReLU solves this: derivative is 1 for active neurons, so gradients flow through without shrinkage. This single change enabled networks 10–150 layers deep, directly enabling modern deep learning.
  5. Why does adding hidden layers help — what does depth actually do?
    Each layer learns a new representation of the input space — a transformed coordinate system where the classification problem becomes simpler. Early layers learn low-level features (edges in images, letter patterns in text). Later layers combine these into high-level abstractions (faces, words, concepts). The Universal Approximation Theorem guarantees that sufficient depth and width can approximate any continuous function. In practice, depth is more parameter-efficient than width — a deep network can express functions that would require exponentially more neurons to express in a single wide layer.
📎 Attachments & Further Reading

No attachments for this topic yet.

#NeuralNetworks#DeepLearning#Backpropagation#ReLU#Activations#VanishingGradients#Perceptron#PyTorch#UniversalApproximation#Dropout#30DaysOfAI#Ramadan2026
← Day 14 Feature Engineering & Selection