devol.dev

The perceptron

A neuron takes an input vector p\mathbf{p}, weights it, adds a bias, and passes the result through a transfer function:

a=f(Wp+b)a = f(\mathbf{W}\mathbf{p} + b)

That is the whole computation. The tool below runs it, trains it, and draws what it decides.

Neural networks / perceptron

One neuron draws one line

The network on the left decides the picture on the right. Click a neuron to change its transfer function, click a connection to cut it, and train. Start with no hidden layer on AND, then ask the same neuron for XOR.

Epoch0
Error
Correct
Rule

Start on AND with no hidden neurons. Press train.

A worked example

Take two inputs, weights W=[11]\mathbf{W} = \begin{bmatrix} 1 & 1 \end{bmatrix}, bias b=1.5b = -1.5, and the hard limit transfer function

f(n)={0n<01n0f(n) = \begin{cases} 0 & n < 0 \\ 1 & n \ge 0 \end{cases}

Run the four corners of the unit square through it:

p\mathbf{p}n=Wp+bn = \mathbf{W}\mathbf{p} + baa
[0,0][0, 0]1.5-1.50
[0,1][0, 1]0.5-0.50
[1,0][1, 0]0.5-0.50
[1,1][1, 1]0.50.51

That is AND, computed by one neuron with three numbers.

The line

The output changes where nn changes sign, so the boundary is

Wp+b=0\mathbf{W}\mathbf{p} + b = 0

which for the numbers above is p1+p2=1.5p_1 + p_2 = 1.5. A straight line, with [1,1][1,1] on one side and the other three corners on the other.

Two facts about that line are worth holding onto, and both are visible in the tool:

W\mathbf{W} is perpendicular to it. The weight vector is drawn in the input space, and it points at the region that outputs 1. Changing W\mathbf{W} rotates the boundary.

bb shifts it without turning it. The distance from the origin is b/W-b / \lVert \mathbf{W} \rVert. A neuron without a bias can only draw lines through the origin, which is most of what the bias is for.

The transfer function

The sum is linear. The transfer function is the only place a neuron can be anything else, and the choice decides what the neuron is for.

  • hardlim returns 0 or 1. A decision, with no indication of confidence.
  • logsig returns a value in (0,1)(0, 1), and f(n)=a(1a)f'(n) = a(1 - a).
  • tansig returns a value in (1,1)(-1, 1), and f(n)=1a2f'(n) = 1 - a^2.
  • poslin passes positives through and clamps negatives to zero.
  • purelin returns nn unchanged, which makes the whole network linear.

Click any neuron in the tool to cycle it. The layer keeps one function, written fmf^m for layer mm, which is the convention Hagan uses and the reason superscripts appear everywhere in this subject.

Training one neuron

The perceptron rule needs no calculus. Present a sample, compare, correct:

e=ta,Wnew=Wold+epT,bnew=bold+ee = t - a, \qquad \mathbf{W}^{new} = \mathbf{W}^{old} + e\,\mathbf{p}^T, \qquad b^{new} = b^{old} + e

Take W=[11]\mathbf{W} = \begin{bmatrix} -1 & -1 \end{bmatrix}, b=0b = 0, and the sample p=[1,1]\mathbf{p} = [1, 1] with target t=1t = 1:

n=2,a=0,e=10=1n = -2, \quad a = 0, \quad e = 1 - 0 = 1 Wnew=[11]+1[11]=[00],bnew=1\mathbf{W}^{new} = \begin{bmatrix} -1 & -1 \end{bmatrix} + 1 \cdot \begin{bmatrix} 1 & 1 \end{bmatrix} = \begin{bmatrix} 0 & 0 \end{bmatrix}, \qquad b^{new} = 1

Now n=1n = 1 and a=1a = 1. Fixed in one step.

The geometry explains why. Adding epTe\mathbf{p}^T rotates W\mathbf{W} toward p\mathbf{p} when the target is 1 and away when it is 0, which turns the boundary until the point falls on the correct side. When every point is already correct, every ee is zero and the weights stop moving. Convergence is a fixed point, and it is reached in finite time whenever a separating line exists.

What one line cannot do

Switch the problem to XOR and train.

The boundary keeps moving and never settles, because no straight line puts [0,1][0,1] and [1,0][1,0] on one side with [0,0][0,0] and [1,1][1,1] on the other. This is not slow convergence. There is no assignment of two weights and a bias that classifies those four points, so the rule cannot find one.

Every problem a single neuron can solve is one a line can separate, and most problems are not like that.

Two layers, and the derivative that costs

Add hidden neurons. Each draws its own line, and the output neuron combines them, so the regions it can carve are intersections and unions of half-planes rather than a single half-plane. Watch the shading in the tool bend.

Training them is the harder part. The error depends on a hidden weight only through everything downstream of it, so the derivative has to be carried backwards. Define the sensitivity of layer mm as sm=E/nm\mathbf{s}^m = \partial E / \partial \mathbf{n}^m. Then

sM=2F˙M(nM)(ta)\mathbf{s}^M = -2\,\dot{\mathbf{F}}^M(\mathbf{n}^M)(\mathbf{t} - \mathbf{a}) sm=F˙m(nm)(Wm+1)Tsm+1\mathbf{s}^m = \dot{\mathbf{F}}^m(\mathbf{n}^m)\left(\mathbf{W}^{m+1}\right)^T \mathbf{s}^{m+1}

and the gradient of any weight is sm(am1)T\mathbf{s}^m (\mathbf{a}^{m-1})^T. One backward sweep reaches every weight in the network, which is the only reason training a network of any size is affordable.

Notice what the recursion requires: F˙\dot{\mathbf{F}}, the derivative of the transfer function. Try setting a hidden layer to hardlim. The tool refuses to train, because s\mathbf{s} is zero everywhere and no weight ever moves. The perceptron rule exists precisely because that derivative does not.

Where it stops being tidy

Set two hidden neurons on XOR, press new weights, and train. Repeat.

Some starts solve it. Others settle at two of four correct and stay there. Two neurons are enough to represent XOR and not enough to make finding it reliable, because gradient descent goes downhill from where it happens to begin and stops at the first flat place it meets. Three neurons find it from nearly any start.

Nothing in the mathematics promises otherwise. Backpropagation computes a gradient exactly; a gradient is a local statement, and the surface it descends was never claimed to have one minimum.

The code

The rule from “Training one neuron”, for the one neuron it actually trains:

struct Perceptron {
    std::vector<double> w;
    double b = 0.0;
};

// n = w . p + b
double netInput(const Perceptron& neuron, const std::vector<double>& p) {
    double n = neuron.b;
    for (std::size_t j = 0; j < neuron.w.size(); j++) n += neuron.w[j] * p[j];
    return n;
}

// 0 below zero, 1 at and above, same as transfer.js's hardlim. No usable
// derivative, which is why the update below isn't gradient descent.
double hardlim(double n) { return n < 0.0 ? 0.0 : 1.0; }

double predict(const Perceptron& neuron, const std::vector<double>& p) {
    return hardlim(netInput(neuron, p));
}

struct Sample {
    std::vector<double> p;
    double t;
};

// e = t - a, then W += e*p, b += e. Skipped when e is already zero, which
// is also the convergence check: once every sample agrees, nothing here
// moves the weights again.
void perceptronUpdate(Perceptron& neuron, const Sample& sample) {
    const double a = predict(neuron, sample.p);
    const double e = sample.t - a;
    if (e == 0.0) return;
    for (std::size_t j = 0; j < neuron.w.size(); j++) neuron.w[j] += e * sample.p[j];
    neuron.b += e;
}

// One pass over the samples, updating after each.
void trainEpoch(Perceptron& neuron, const std::vector<Sample>& samples) {
    for (const auto& sample : samples) perceptronUpdate(neuron, sample);
}