Hidden Representations

Visualizing what hidden layers learn

7.1 What You Will Learn

In Chapter 2 we added a hidden layer and it solved XOR. In Chapter 6 we used 20 hidden neurons on a circle classification problem. So far we have been evaluating results, but we never looked at what those hidden neurons actually learned. In this chapter we open the “black box” and you will inspect hidden layer outputs, understand what each neuron detects, see how the network transforms input space into a space where classification is easy, and build tools to extract and compare representations. This chapter has no new math or concepts, it is about building intuition for what neural networks do internally.

7.2 The Idea

When an input passes through a hidden layer, it comes out the other side as a different set of numbers. Those new numbers are a representation of the original input, and the network learns to shape that representation so that whatever comes next, whether that is classification, prediction, or regression (regression is like prediction, but of a continuous number instead of a category, like classification will say this is Class A or B and regression will be like this is 3.7 or this 2.1), becomes as simple as possible. The easiest way to think about this is as a coordinate transform, you start with data that is tangled together in its original coordinates, impossible to separate with a straight line. The hidden layer rotates, stretches, and warps that space until the classes fall neatly apart. Once the data is rearranged like that, the output layer only has to draw a simple boundary through the transformed space to get the right answer.

We already saw this happen with XOR. The four input pairs, (0,0), (0,1), (1,0), (1,1), cannot be separated by a single line in their original 2D space. But after passing through the hidden layer, those same four points land in a new 2D space where they can. Let us go back to the XOR network and look carefully at what the hidden layer actually produces.

Figure 7-1. The same four inputs before and after the hidden layer

Figure 7-1 puts the two spaces side by side using the numbers the first program in this chapter prints. On the left are the four inputs where they started, with the two that should output 1 sitting on one diagonal and the two that should output 0 on the other, which is the arrangement no straight line can cut. On the right are the same four inputs plotted by their hidden activations h0 and h1 instead of by x1 and x2, and a single straight line now does the job.

Two details are worth pausing on. The hidden layer did not add a dimension, since both spaces are two dimensional, so the network did not solve XOR by giving itself more room to work in. It solved it by moving the points. And the two inputs that share an answer, (0,1) and (1,0), have been pushed to almost the same place, close enough that they overlap in the plot. The network has decided those two inputs are the same kind of thing, which is what a useful representation does. It throws away the distinction that does not matter and keeps the one that does.

7.3 XOR Hidden Layer Outputs

Let us train an XOR network and then print what the hidden neurons output for each input. We already did this briefly in Chapter 2, but now we will look more carefully. Setup the network, by this point you could do this in your sleep.

/* 039_XOR_Hidden.c */
#include <stdio.h>
#include <stdlib.h>
#include <math.h>

static float sigmoid(float z)
{
    return 1.0f / (1.0f + expf(-z));
}

typedef struct { float wh[2][2], 
    bh[2];
    float wo[2], bo;
    }
    Net;

static float forward(const Net *n, 
    const float x[2], float h[2])
{
    int i, j;
    for (i = 0; i < 2; i++) {
        float z = n->bh[i];
        for (j = 0; j < 2; j++) z += n->wh[i][j] * x[j];
        h[i] = sigmoid(z);
    }
    { float z = n->bo;
      for (i = 0; i < 2; i++) z += n->wo[i] * h[i];
      return sigmoid(z);
      }
}

static void backward(Net *n, const float x[2], 
    const float h[2], 
                      float y, float t, float lr)
{
    int i, j;
    float d_out = -2.0f * (t - y) * y * (1.0f - y);
    float d_h[2];
    for (i = 0; i < 2; i++)
        d_h[i] = d_out * n->wo[i] * h[i]
            * (1.0f - h[i]);
    for (i = 0; i < 2; i++)
        n->wo[i] -= lr * d_out * h[i];
    n->bo -= lr * d_out;
    for (i = 0; i < 2; i++) {
        for (j = 0; j < 2; j++)
            n->wh[i][j] -= lr * d_h[i] * x[j];
        n->bh[i] -= lr * d_h[i];
    }
}

int main(void)
{
    float X[4][2] = { {0, 0}, {0, 1}, {1, 0}, {1, 1} };
    float T[4] = { 0, 1, 1, 0 };
    Net net;
    int epoch, s, i, j;

    srand(42);
    for (i = 0; i < 2; i++) {
        for (j = 0; j < 2; j++)
            net.wh[i][j] = ((float)rand()/RAND_MAX)*2-1;
        net.bh[i] = ((float)rand()/RAND_MAX)*2-1;
        net.wo[i] = ((float)rand()/RAND_MAX)*2-1;
    }
    net.bo = ((float)rand()/RAND_MAX)*2-1;

    for (epoch = 0; epoch < 10000; epoch++) {
        for (s = 0; s < 4; s++) {
            float h[2], y;
            y = forward(&net, X[s], h);
            backward(&net, X[s], h, y, T[s], 1.0f);
        }
    }

    printf("XOR hidden layer representations:\n\n");
    printf("  input     h0      h1      output  "
           "target\n");
    printf("  ------    ------  ------  ------  "
           "------\n");
    for (s = 0; s < 4; s++) {
        float h[2], y;
        y = forward(&net, X[s], h);
        printf("  (%.0f, %.0f)   %6.4f  %6.4f  %6.4f  "
               "%.0f\n",
               X[s][0], X[s][1], h[0], h[1], y, T[s]);
    }

    printf("\nNow look at just h0 and h1 as a 2D "
           "space:\n");
    printf("  The four input pairs map to four points "
           "in hidden space.\n");
    printf("  In hidden space, class 1 (XOR=1) is "
           "linearly separable\n");
    printf("  from class 0 (XOR=0).\n");

    return 0;
}
Figure 7-2. The four XOR inputs as four points in hidden space

Figure 7-2 places the four XOR inputs as four points in hidden space. Study the h0 and h1 columns. The four inputs map to four points in 2D hidden space. The key thing to notice: the two XOR=1 cases land in a different region of hidden space than the two XOR=0 cases. The hidden layer has rearranged the points so that the output neuron can draw a single line between them. Each hidden neuron has learned to detect a different feature of the input. The exact features depend on the random initialization, but the result is always the same: the transformed space is linearly separable.

7.4 Circle Classification Hidden Space

XOR only has four points, so there is not much to look at. A more interesting case is the circle classification problem from Chapter 6, where points inside a circle belong to one class and points outside belong to another. We will train a network on that problem and then print the hidden layer outputs for a grid of inputs. This lets us see how the hidden layer takes the original 2D space and warps it into something new, stretching and bending the coordinates until the two classes become easy to separate with a straight line.

/* 040_Circle_Hidden.c */
#include <stdio.h>
#include <stdlib.h>
#include <math.h>

static float sigmoid(float z)
{
    return 1.0f / (1.0f + expf(-z));
}
static float randf(void)
{
    return (float)rand() / RAND_MAX;
}

#define N_HID 4  /* small enough to inspect */
#define N_PARAMS (2*N_HID + N_HID + N_HID + 1)

static float forward(const float *p, const float x[2], 
    float h[N_HID])
{
    int i, j;
    for (i = 0; i < N_HID; i++) {
        float z = p[2*N_HID + i];
        for (j = 0; j < 2; j++) z += p[i*2+j] * x[j];
        h[i] = sigmoid(z);
    }
    { int bw = 3*N_HID; float z = p[bw+N_HID];
      for (i = 0; i < N_HID; i++) z += p[bw+i] * h[i];
      return sigmoid(z);
      }
}

static void backward(const float *p, const float x[2], 
    const float h[N_HID], 
                      float y, float t, float *g)
{
    int i, j, bh = 2*N_HID, bw = 3*N_HID;
    float d_out = -2.0f * (t - y) * y * (1.0f - y);
    for (i = 0; i < N_PARAMS; i++) g[i] = 0;
    for (i = 0; i < N_HID; i++) g[bw+i] = d_out * h[i];
    g[bw+N_HID] = d_out;
    for (i = 0; i < N_HID; i++) {
        float dh = d_out * p[bw+i] * h[i]
            * (1.0f - h[i]);
        for (j = 0; j < 2; j++) g[i*2+j] = dh * x[j];
        g[bh+i] = dh;
    }
}

typedef struct { float *m, *v; float b1, b2, eps, lr, 
    b1t, b2t;
    int n;
    }
    Adam;
static Adam adam_create(int n, float lr) {
    Adam o;
    o.m = (float*)calloc(n, sizeof(float));
    o.v = (float*)calloc(n, sizeof(float));
    o.b1 = 0.9f;
    o.b2 = 0.999f;
    o.eps = 1e-8f;
    o.lr = lr;
    o.b1t = 1;
    o.b2t = 1;
    o.n = n;
    return o;
    }
static void adam_update(Adam *o, float *p, 
    const float *g) {
    int i;
    o->b1t *= o->b1;
    o->b2t *= o->b2;
    for(i = 0;i<o->n;i++) {
        o->m[i] = o->b1*o->m[i]+(1-o->b1)*g[i];
        o->v[i] = o->b2*o->v[i]+(1-o->b2)*g[i]*g[i];
        float mh = o->m[i]/(1-o->b1t), 
            vh = o->v[i]/(1-o->b2t);
        p[i] -= o->lr*mh/(sqrtf(vh)+o->eps);
        }
        }
static void adam_free(Adam *o)
{
    free(o->m);
    free(o->v);
}

typedef struct { float x[2]; float t; } Sample;

int main(void)
{
    Sample data[200];
    float params[N_PARAMS], grads[N_PARAMS], 
        ga[N_PARAMS];
    Adam opt;
    int epoch, s, i;

    srand(42);
    /* Generate circle data */
    for (i = 0; i < 200; i++) {
        data[i].x[0] = randf() * 4 - 2;
        data[i].x[1] = randf() * 4 - 2;
        float dist = data[i].x[0]*data[i].x[0]
            + data[i].x[1]*data[i].x[1];
        data[i].t = (dist < 1.44f) ? 1.0f : 0.0f;
        /* no noise for clarity */
    }
    for (i = 0; i < N_PARAMS; i++)
        params[i] = randf() * 0.4f - 0.2f;
    opt = adam_create(N_PARAMS, 0.01f);

    /* Train */
    for (epoch = 0; epoch < 1000; epoch++) {
        for (i = 0; i < N_PARAMS; i++) ga[i] = 0;
        for (s = 0; s < 200; s++) {
            float h[N_HID], y;
            y = forward(params, data[s].x, h);
            backward(params, data[s].x, h, y, 
                data[s].t, grads);
            for (i = 0; i < N_PARAMS; i++)
                ga[i] += grads[i];
        }
        for (i = 0; i < N_PARAMS; i++) ga[i] /= 200.0f;
        adam_update(&opt, params, ga);
    }

    /* Inspect: what does each hidden neuron respond
       to? */
    printf("Hidden neuron weights (what each neuron "
           "detects):\n\n");
    for (i = 0; i < N_HID; i++) {
        printf("  Neuron %d: w0=%+.3f  w1=%+.3f  "
               "bias=%+.3f\n",
               i, params[i*2], params[i*2+1], 
                   params[2*N_HID+i]);
    }

    /* Sample a few points and show their hidden
       representations */
    printf("\nHidden representations for selected "
           "points:\n\n");
    printf("  x0      x1     dist  ");
    for (i = 0; i < N_HID; i++) printf("  h%d    ", i);
    printf("  output  class\n");

    float test_points[][2] = {
        { 0.0f,  0.0f},  /* center */
        { 0.5f,  0.5f},  /* inside */
        {-0.8f,  0.3f},  /* inside */
        { 1.0f,  0.0f},  /* on boundary */
        { 1.5f,  0.0f},  /* outside */
        { 0.0f,  1.5f},  /* outside */
        {-1.5f, -1.5f},  /* far outside */
    };
    int n_test = 7;

    for (s = 0; s < n_test; s++) {
        float h[N_HID], y;
        float dist = test_points[s][0]*test_points[s][0]
                   + test_points[s][1]
                       *test_points[s][1];
        y = forward(params, test_points[s], h);
        printf("  %+5.1f   %+5.1f  %5.2f",
            test_points[s][0], test_points[s][1], dist);
        for (i = 0; i < N_HID; i++)
            printf("  %5.3f", h[i]);
        printf("  %5.3f   %s\n", y,
            dist < 1.44f ? "IN" : "OUT");
    }

    adam_free(&opt);
    return 0;
}
Figure 7-3. Four hidden neurons and what they output

Figure 7-3 lists four hidden neurons, their weights, and what each one outputs across the circle. Each hidden neuron has two weights and a bias, and those three numbers define a line in 2D space. On one side of that line the neuron fires strongly, on the other side it stays quiet. With four neurons you get four lines, and together those lines carve the input plane into regions. The network learns to position those lines so that points inside the circle produce one pattern of activations across the four neurons, and points outside produce a different pattern. The output neuron then just has to recognize which pattern it is looking at. You can see this in the table. The four IN points all share a similar signature: h0 through h3 are all low, near zero, and the output is high. The moment you cross the boundary, something changes. At (1.5, 0.0) neuron h0 jumps to 0.958 while the output drops to 0.078. At (0.0, 1.5) neuron h2 jumps to 0.594 and at (-1.5, −1.5) neuron h3 shoots up to 0.983. Each neuron is guarding a different side of the circle. When any of them fires, it is telling the output layer “this point is outside my boundary,” and the output responds by dropping toward zero.

The original question the network had to answer was “is this point’s distance from the center less than 1.2?” That is a nonlinear question, and no single neuron can answer it. But four neurons, each watching a different edge of the circle, can collectively encode enough information that the output layer only has to ask a linear question: “are all the guard neurons quiet?” If yes, the point is inside. If any of them are firing, it is outside.

7.5 Watching Representations Form

The most illuminating thing is to watch the hidden representations change during training. Early on, the hidden outputs are random. As training proceeds, the representations reorganize until the classes become separable.

/* 041_Watching_Representations_Form.c */
#include <stdio.h>
#include <stdlib.h>
#include <math.h>

static float sigmoid(float z)
{
    return 1.0f / (1.0f + expf(-z));
}
static float randf(void)
{
    return (float)rand() / RAND_MAX;
}

#define N_HID 2  /* 2 hidden so we can think in 2D */
#define N_PARAMS (2*N_HID + N_HID + N_HID + 1)

static float forward(const float *p, const float x[2], 
    float h[N_HID])
{
    int i, j;
    for (i = 0; i < N_HID; i++) {
        float z = p[2*N_HID + i];
        for (j = 0; j < 2; j++) z += p[i*2+j] * x[j];
        h[i] = sigmoid(z);
    }
    { int bw = 3*N_HID; float z = p[bw+N_HID];
      for (i = 0; i < N_HID; i++) z += p[bw+i] * h[i];
      return sigmoid(z);
      }
}

static void backward(const float *p, const float x[2], 
    const float h[N_HID], 
                      float y, float t, float *g)
{
    int i, j, bh = 2*N_HID, bw = 3*N_HID;
    float d_out = -2.0f * (t - y) * y * (1.0f - y);
    for (i = 0; i < N_PARAMS; i++) g[i] = 0;
    for (i = 0; i < N_HID; i++) g[bw+i] = d_out * h[i];
    g[bw+N_HID] = d_out;
    for (i = 0; i < N_HID; i++) {
        float dh = d_out * p[bw+i] * h[i]
            * (1.0f - h[i]);
        for (j = 0; j < 2; j++) g[i*2+j] = dh * x[j];
        g[bh+i] = dh;
    }
}

typedef struct { float *m, *v; float b1, b2, eps, lr, 
    b1t, b2t;
    int n;
    }
    Adam;
static Adam adam_create(int n, float lr) {
    Adam o;
    o.m = (float*)calloc(n, sizeof(float));
    o.v = (float*)calloc(n, sizeof(float));
    o.b1 = 0.9f;
    o.b2 = 0.999f;
    o.eps = 1e-8f;
    o.lr = lr;
    o.b1t = 1;
    o.b2t = 1;
    o.n = n;
    return o;
    }
static void adam_update(Adam *o, float *p, 
    const float *g) {
    int i;
    o->b1t *= o->b1;
    o->b2t *= o->b2;
    for(i = 0;i<o->n;i++) {
        o->m[i] = o->b1*o->m[i]+(1-o->b1)*g[i];
        o->v[i] = o->b2*o->v[i]+(1-o->b2)*g[i]*g[i];
        float mh = o->m[i]/(1-o->b1t), 
            vh = o->v[i]/(1-o->b2t);
        p[i] -= o->lr*mh/(sqrtf(vh)+o->eps);
        }
        }
static void adam_free(Adam *o)
{
    free(o->m);
    free(o->v);
}

int main(void)
{
    /* XOR so we can use 2 hidden neurons */
    float X[4][2] = { {0, 0}, {0, 1}, {1, 0}, {1, 1} };
    float T[4] = { 0, 1, 1, 0 };
    float params[N_PARAMS], grads[N_PARAMS], 
        ga[N_PARAMS];
    Adam opt;
    int epoch, s, i;
    int snapshots[] = { 0, 10, 50, 200, 1000, 5000 };
    int n_snaps = 6;
    int snap_idx = 0;

    srand(42);
    for (i = 0; i < N_PARAMS; i++)
        params[i] = randf() * 2 - 1;
    opt = adam_create(N_PARAMS, 0.01f);

    printf("How hidden representations form during "
           "training (2 hidden neurons):\n\n");

    for (epoch = 0; epoch <= 5000; epoch++) {
        /* Print snapshot */
        if (snap_idx < n_snaps
                && epoch == snapshots[snap_idx]) {
            printf("Epoch %d:\n", epoch);
            printf("  input     h0      h1      "
                   "output\n");
            for (s = 0; s < 4; s++) {
                float h[N_HID], y;
                y = forward(params, X[s], h);
                printf("  (%.0f, %.0f)   %6.4f  %6.4f  "
                       "%6.4f  (target %.0f)\n",
                       X[s][0], X[s][1], h[0], 
                           h[1], y, T[s]);
            }
            printf("\n");
            snap_idx++;
        }

        /* Train one epoch */
        for (i = 0; i < N_PARAMS; i++) ga[i] = 0;
        for (s = 0; s < 4; s++) {
            float h[N_HID], y;
            y = forward(params, X[s], h);
            backward(params, X[s], h, y, T[s], grads);
            for (i = 0; i < N_PARAMS; i++)
                ga[i] += grads[i];
        }
        for (i = 0; i < N_PARAMS; i++) ga[i] /= 4.0f;
        adam_update(&opt, params, ga);
    }

    adam_free(&opt);
    return 0;
}
Figure 7-4. Hidden representations at four points in training

Figure 7-4 samples the hidden representation at four points in training, and it goes from jumbled to separated. At epoch 0 the hidden outputs are essentially random. The four inputs produce h0 and h1 values that are jumbled together with no clear structure, and the output sits around 0.5 for everything. The network knows nothing yet.

By epoch 200, things are starting to shift. The two class-0 inputs, (0,0) and (1,1), are drifting apart from the two class-1 inputs in hidden space, but the separation is still messy. The outputs are in the 0.2 to 0.5 range, showing the network has a rough sense of the answer but is not confident.

By epoch 1000 the pattern is clear. The two XOR=1 cases, (0,1) and (1,0), have nearly identical hidden representations: both have h0 near 0.02 and h1 near 0.07. Meanwhile the two XOR=0 cases have pushed to opposite corners, with (0,0) at high h0, low h1 and (1,1) at low h0, high h1. The network has learned that “both neurons low” means the answer is 1, and “either neuron high” means the answer is 0. The outputs are above 0.93 for the correct class-1 inputs and below 0.07 for class 0.

By epoch 5000 the separation has sharpened further. The h0 and h1 values for the XOR=1 cases are pushed even closer to zero, while the XOR=0 cases are pushed harder toward their respective corners. The outputs are now above 0.98 for class 1 and below 0.01 for class 0. The network found a 2D arrangement of the four points where a single line can separate the two classes, and then kept pushing those points further apart to increase its confidence.

This reorganization is learning, the network is not simply memorizing input and output pairs. It is discovering a coordinate system in which the problem is easy.

7.6 What Individual Neurons Detect

Each hidden neuron computes sigmoid(w0x0 + w1x1 + b), and the weights and bias define a line in 2D input space where w0x0 + w1x1 + b = 0. On one side of that line the neuron outputs a value near 1, and on the other side it outputs near 0. The weight vector (w0, w1) points perpendicular to the line, toward the side where the neuron activates strongly. The output weight connecting each hidden neuron to the output layer tells us how that neuron’s vote gets used. A positive output weight means the neuron is voting “inside the circle” when it fires, and a negative output weight means it is voting “outside.” The angle of the weight vector tells us which direction in the input plane that neuron is watching.

We will train on the circle problem again and then print each neuron’s decision line equation, its angle, and whether it votes for or against the inside class.

/* 042_Neuron_Lines.c */
#include <stdio.h>
#include <stdlib.h>
#include <math.h>

static float sigmoid(float z)
{
    return 1.0f / (1.0f + expf(-z));
}
static float randf(void)
{
    return (float)rand() / RAND_MAX;
}

#define N_HID 4
#define N_PARAMS (2*N_HID + N_HID + N_HID + 1)

static float forward(const float *p, const float x[2], 
    float h[N_HID])
{
    int i, j;
    for (i = 0; i < N_HID; i++) {
        float z = p[2*N_HID + i];
        for (j = 0; j < 2; j++) z += p[i*2+j] * x[j];
        h[i] = sigmoid(z);
    }
    { int bw = 3*N_HID; float z = p[bw+N_HID];
      for (i = 0; i < N_HID; i++) z += p[bw+i] * h[i];
      return sigmoid(z);
      }
}

static void backward(const float *p, const float x[2], 
    const float h[N_HID], 
                      float y, float t, float *g)
{
    int i, j, bh = 2*N_HID, bw = 3*N_HID;
    float d_out = -2.0f * (t - y) * y * (1.0f - y);
    for (i = 0; i < N_PARAMS; i++) g[i] = 0;
    for (i = 0; i < N_HID; i++) g[bw+i] = d_out * h[i];
    g[bw+N_HID] = d_out;
    for (i = 0; i < N_HID; i++) {
        float dh = d_out * p[bw+i] * h[i]
            * (1.0f - h[i]);
        for (j = 0; j < 2; j++) g[i*2+j] = dh * x[j];
        g[bh+i] = dh;
    }
}

typedef struct { float *m, *v; float b1, b2, eps, lr, 
    b1t, b2t;
    int n;
    }
    Adam;
static Adam adam_create(int n, float lr) {
    Adam o;
    o.m = (float*)calloc(n, sizeof(float));
    o.v = (float*)calloc(n, sizeof(float));
    o.b1 = 0.9f;
    o.b2 = 0.999f;
    o.eps = 1e-8f;
    o.lr = lr;
    o.b1t = 1;
    o.b2t = 1;
    o.n = n;
    return o;
    }
static void adam_update(Adam *o, float *p, 
    const float *g) {
    int i;
    o->b1t *= o->b1;
    o->b2t *= o->b2;
    for(i = 0;i<o->n;i++) {
        o->m[i] = o->b1*o->m[i]+(1-o->b1)*g[i];
        o->v[i] = o->b2*o->v[i]+(1-o->b2)*g[i]*g[i];
        float mh = o->m[i]/(1-o->b1t), 
            vh = o->v[i]/(1-o->b2t);
        p[i] -= o->lr*mh/(sqrtf(vh)+o->eps);
        }
        }
static void adam_free(Adam *o)
{
    free(o->m);
    free(o->v);
}

typedef struct { float x[2]; float t; } Sample;

int main(void)
{
    Sample data[200];
    float params[N_PARAMS], grads[N_PARAMS], 
        ga[N_PARAMS];
    Adam opt;
    int epoch, s, i;

    srand(42);
    for (i = 0; i < 200; i++) {
        data[i].x[0] = randf() * 4 - 2;
        data[i].x[1] = randf() * 4 - 2;
        float dist = data[i].x[0]*data[i].x[0]
            + data[i].x[1]*data[i].x[1];
        data[i].t = (dist < 1.44f) ? 1.0f : 0.0f;
    }
    for (i = 0; i < N_PARAMS; i++)
        params[i] = randf() * 0.4f - 0.2f;
    opt = adam_create(N_PARAMS, 0.01f);

    for (epoch = 0; epoch < 2000; epoch++) {
        for (i = 0; i < N_PARAMS; i++) ga[i] = 0;
        for (s = 0; s < 200; s++) {
            float h[N_HID], y;
            y = forward(params, data[s].x, h);
            backward(params, data[s].x, h, y, 
                data[s].t, grads);
            for (i = 0; i < N_PARAMS; i++)
                ga[i] += grads[i];
        }
        for (i = 0; i < N_PARAMS; i++) ga[i] /= 200.0f;
        adam_update(&opt, params, ga);
    }

    printf("Each hidden neuron defines a decision "
           "line:\n");
    printf("  w0*x0 + w1*x1 + b = 0\n\n");

    for (i = 0; i < N_HID; i++) {
        float w0 = params[i*2];
        float w1 = params[i*2+1];
        float b = params[2*N_HID+i];
        float angle = atan2f(w1, 
            w0) * 180.0f / 3.14159f;

        printf("  Neuron %d: %+.3f*x0 + %+.3f*x1 + "
               "%+.3f = 0\n",
               i, w0, w1, b);
        printf("            direction: "
               "%.0f degrees", angle);

        /* Output weight tells us if this neuron
           votes for or against */
        float ow = params[3*N_HID + i];
        printf("  output weight: %+.3f (%s)\n",
               ow, ow > 0 ? "votes IN" : "votes OUT");
    }

    /* Accuracy check */
    int correct = 0;
    for (s = 0; s < 200; s++) {
        float h[N_HID], y;
        y = forward(params, data[s].x, h);
        int pred = y > 0.5f ? 1 : 0;
        int actual = data[s].t > 0.5f ? 1 : 0;
        if (pred == actual) correct++;
    }
    printf("\nAccuracy: %d/200 (%.1f%%)\n", correct,
        correct / 2.0f);

    adam_free(&opt);
    return 0;
}
Figure 7-5. Each hidden neuron as a line, and the direction it faces

Figure 7-5 draws each hidden neuron as a line and marks the direction it faces. The four neurons have positioned their decision lines at 32, −44, 141, and −118 degrees, which means they are spread roughly 90 degrees apart around the circle. Each one is watching a different quadrant of the input plane. All four have negative output weights, so they are all voting “outside” when they fire. The output neuron has learned that when none of the hidden neurons are firing strongly, the point must be inside the circle, and when any of them activate, the point is outside. This is the same “guard neuron” pattern we saw earlier.

Since each neuron contributes one straight decision line, four neurons can only approximate the circle as a rough polygon. You are fitting a curved boundary with flat edges. More hidden neurons would give more edges and a tighter fit to the actual circle, but that also means more parameters, which is exactly why larger networks are more prone to the overfitting we covered in Chapter 6. There is always a tradeoff between how complex a boundary the network can represent and how well it generalizes to new data. The network hit 200/200 accuracy on the training set, which means even a crude four-sided approximation is enough to correctly classify every point in this dataset. But remember, this is the training set. Whether those four lines generalize to new points depends on whether the polygon they form has enough margin around the true circle boundary.

7.7 Representation Similarity

A powerful way to understand representations is to ask: which inputs does the network consider similar? Two inputs with similar hidden layer outputs will get similar predictions. Let us compute the distance between hidden representations of different inputs.

/* 043_Representation_Similarity.c */
#include <stdio.h>
#include <stdlib.h>
#include <math.h>

static float sigmoid(float z)
{
    return 1.0f / (1.0f + expf(-z));
}
static float randf(void)
{
    return (float)rand() / RAND_MAX;
}

#define N_HID 8
#define N_PARAMS (2*N_HID + N_HID + N_HID + 1)

static float forward(const float *p, const float x[2], 
    float h[N_HID])
{
    int i, j;
    for (i = 0; i < N_HID; i++) {
        float z = p[2*N_HID + i];
        for (j = 0; j < 2; j++) z += p[i*2+j] * x[j];
        h[i] = sigmoid(z);
    }
    { int bw = 3*N_HID; float z = p[bw+N_HID];
      for (i = 0; i < N_HID; i++) z += p[bw+i] * h[i];
      return sigmoid(z);
      }
}

static void backward(const float *p, const float x[2], 
    const float h[N_HID], 
                      float y, float t, float *g)
{
    int i, j, bh = 2*N_HID, bw = 3*N_HID;
    float d_out = -2.0f * (t - y) * y * (1.0f - y);
    for (i = 0; i < N_PARAMS; i++) g[i] = 0;
    for (i = 0; i < N_HID; i++) g[bw+i] = d_out * h[i];
    g[bw+N_HID] = d_out;
    for (i = 0; i < N_HID; i++) {
        float dh = d_out * p[bw+i] * h[i]
            * (1.0f - h[i]);
        for (j = 0; j < 2; j++) g[i*2+j] = dh * x[j];
        g[bh+i] = dh;
    }
}

typedef struct { float *m, *v; float b1, b2, eps, lr, 
    b1t, b2t;
    int n;
    }
    Adam;
static Adam adam_create(int n, float lr) {
    Adam o;
    o.m = (float*)calloc(n, sizeof(float));
    o.v = (float*)calloc(n, sizeof(float));
    o.b1 = 0.9f;
    o.b2 = 0.999f;
    o.eps = 1e-8f;
    o.lr = lr;
    o.b1t = 1;
    o.b2t = 1;
    o.n = n;
    return o;
    }
static void adam_update(Adam *o, float *p, 
    const float *g) {
    int i;
    o->b1t *= o->b1;
    o->b2t *= o->b2;
    for(i = 0;i<o->n;i++) {
        o->m[i] = o->b1*o->m[i]+(1-o->b1)*g[i];
        o->v[i] = o->b2*o->v[i]+(1-o->b2)*g[i]*g[i];
        float mh = o->m[i]/(1-o->b1t), 
            vh = o->v[i]/(1-o->b2t);
        p[i] -= o->lr*mh/(sqrtf(vh)+o->eps);
        }
        }
static void adam_free(Adam *o)
{
    free(o->m);
    free(o->v);
}

static float dist(const float *a, const float *b, int n)
{
    float sum = 0;
    int i;
    for (i = 0; i < n; i++) {
        float d = a[i] - b[i];
        sum += d * d;
    }
    return sqrtf(sum);
}

int main(void)
{
    float data_x[200][2], data_t[200];
    float params[N_PARAMS], grads[N_PARAMS], 
        ga[N_PARAMS];
    Adam opt;
    int epoch, s, i;

    srand(42);
    for (i = 0; i < 200; i++) {
        data_x[i][0] = randf() * 4 - 2;
        data_x[i][1] = randf() * 4 - 2;
        float d = data_x[i][0]*data_x[i][0]
            + data_x[i][1]*data_x[i][1];
        data_t[i] = (d < 1.44f) ? 1.0f : 0.0f;
    }
    for (i = 0; i < N_PARAMS; i++)
        params[i] = randf() * 0.4f - 0.2f;
    opt = adam_create(N_PARAMS, 0.01f);

    for (epoch = 0; epoch < 2000; epoch++) {
        for (i = 0; i < N_PARAMS; i++) ga[i] = 0;
        for (s = 0; s < 200; s++) {
            float h[N_HID], y;
            y = forward(params, data_x[s], h);
            backward(params, data_x[s], h, y, 
                data_t[s], grads);
            for (i = 0; i < N_PARAMS; i++)
                ga[i] += grads[i];
        }
        for (i = 0; i < N_PARAMS; i++) ga[i] /= 200.0f;
        adam_update(&opt, params, ga);
    }

    /* Pick a probe point and find nearest/farthest
       in hidden space */
    float probe[2] = { 0.3f, 0.4f };
    float h_probe[N_HID];
    forward(params, probe, h_probe);

    printf("Probe point: (%+.1f, %+.1f)\n",
        probe[0], probe[1]);
    printf("  Input distance vs Hidden distance:\n\n");
    printf("  point       input_dist  hidden_dist  "
           "same_class?\n");

    float test_pts[][2] = {
        { 0.4f,  0.5f},  /* nearby, same class */
        {-0.3f, -0.4f},  /* opposite side, same class */
        /* nearby-ish, different class */
        { 1.8f, 0.0f}, 
        {-1.8f, -1.8f},  /* far, different class */
        { 0.0f,  0.0f},  /* center, same class */
    };
    int n_test = 5;

    for (s = 0; s < n_test; s++) {
        float h_test[N_HID];
        forward(params, test_pts[s], h_test);

        float id = dist(probe, test_pts[s], 2);
        float hd = dist(h_probe, h_test, N_HID);
        float probe_dist = probe[0]*probe[0]
            + probe[1]*probe[1];
        float test_dist = test_pts[s][0]
            *test_pts[s][0] + test_pts[s][1]
            *test_pts[s][1];
        int same = ((probe_dist < 1.44f)
                    == (test_dist < 1.44f));

        printf("  (%+4.1f,%+4.1f)  %8.3f    %8.3f     "
               "%s\n",
               test_pts[s][0], test_pts[s][1], id, hd, 
               same ? "YES" : "NO");
    }

    printf("\nPoints in the same class should have "
           "small hidden distance,\n");
    printf("even if their input distance is large. The "
           "network has learned\n");
    printf("that (-0.3,-0.4) and (0.3,0.4) are 'the "
           "same kind of thing.'\n");

    adam_free(&opt);
    return 0;
}
Figure 7-6. Input distance against hidden distance

Figure 7-6 plots input distance against hidden distance for points around one probe. The key insight here is that input distance and hidden distance are different. Two points can be far apart in input space (opposite sides of the circle) but close in hidden space (both clearly inside). The network has learned that what matters is not position but relationship to the boundary. This is what representation learning means, that the network discovers the relevant features of the data and discards the irrelevant ones.

7.8 Why This Matters

Everything we build from here on comes back to representations. Convolutional networks learn representations of images where edges, textures, and shapes are explicitly encoded in the hidden layers. Recurrent networks learn representations of sequences where temporal patterns, the relationship between what came before and what comes next, get captured in a hidden state that evolves over time. Transformers learn representations of tokens where the meaning of a word shifts depending on the words around it. In every case, the quality of the hidden representation is what determines whether the network actually works. You can have millions of parameters and train for days, but if the network fails to find a useful way to represent the input internally, it will not solve the problem. On the other hand, a network that learns rich and informative representations can generalize well even when the training data is limited, because it has found the underlying structure rather than memorizing surface details.

When researchers say a model “understands” something, what they really mean is that its hidden representations have captured the relevant structure of the data. The model does not understand the way a person does, but the information is organized internally in a way that makes the right answers accessible. When we look inside a transformer later in this book, we will see exactly this principle at work, just operating over millions of parameters instead of four hidden neurons on a circle problem.

7.9 Key Takeaways

7.10 Exercises