LESSON 04 / DEPTH, GRADIENTS & THE LEARNING PATH

Network depth and gradient propagation

Train four networks on the same 32 examples. Compare predictions, gradients and optimization trajectories as depth changes.

Paused · epoch 0Explore the 3D path ↓

Changing depths, activation, width, curve, noise, or seed resets all models. Learning rate and speed apply live. Speed is a ceiling; slower devices may run below it.

Network A

2 parameters · linear baseline

inout
Training MSE
0.1114
Clean-curve MSE
0.1101
Network B

25 parameters · 8 neurons per hidden layer

in8out
Training MSE
0.2136
Clean-curve MSE
0.2182
Network C

169 parameters · 8 neurons per hidden layer

in888out
Training MSE
0.1567
Clean-curve MSE
0.1591
Network D

385 parameters · 8 neurons per hidden layer

in888888out
Training MSE
0.0764
Clean-curve MSE
0.0734

What did they learn?

-101−1Input x1

Dots: the 32 training examples. Dashed line: the clean target. Solid lines: network predictions. The view is fixed at −1.5 to +1.5 vertically; predictions outside it are clipped.

Learning from epoch 0

1e-21e-11e00Epoch 0

Training mean squared error on a logarithmic axis: lower is better. The chart always spans epoch 0 to now. Older checkpoints are thinned without dropping the start.

How much gradient reaches each layer?

RMS of the weight gradients from the last complete training pass, before its update. Bars share a logarithmic scale. Bias gradients are not included. A small gradient can reflect saturation, a good fit, or cancellation across examples.

Network A · 0 hidden

Out
—

Network B · 1 hidden

H1
—
Out
—

Network C · 3 hidden

H1
—
H2
—
H3
—
Out
—

Network D · 6 hidden

H1
—
H2
—
H3
—
H4
—
H5
—
H6
—
Out
—

H1 is nearest the input. Out is the linear output layer. Bar values below 10⁻¹⁰ use the minimum visible width.

THE ACTUAL OPTIMIZATION TRAJECTORY

Optimization trajectory

Two fixed directions through this network’s parameter space form the floor. Training loss is the height. Rotate the graph to follow the updates from their starting point.

Direction uDirection vLoss (log)Epoch 0: loss 0.0764, u 0, v 0Start · 0Start training to draw the path.
Drag to rotate, or use the buttons.u: 0 … 0.0010v: 0 … 0.0010MSE: 0.0764 … 0.09621 checkpoints · latest MSE 0.0764

This is a projection of an actual trajectory, not the full loss landscape. Distinct weight configurations can project to the same spot. Height uses log₁₀(MSE), floored at 10⁻¹²; higher means more error. Axes rescale to fit the full recorded path. Projection magnitudes are not directly comparable across architectures.

Try these comparisons

Start with the wavy curve and tanh for 2,000 epochs. Then reset with sigmoid and inspect the gradients in the deepest model. Finally, switch to a straight line: extra layers are not necessary to represent that task. Add noise and compare training error with error against the known clean curve.

What is being compared?

Depth means the number of hidden layers; the output is always linear. Each hidden layer uses the selected width and activation. The zero-hidden-layer model is a straight-line regression model with two parameters.

h(0)=[x],h(l)=ϕ(Wlh(l−1)+bl),y^=Wd+1h(d)+bd+1\mathbf{h}^{(0)}=[x],\quad\mathbf{h}^{(l)}=\phi(W_l\mathbf{h}^{(l-1)}+\mathbf{b}_l),\quad\hat y=W_{d+1}\mathbf{h}^{(d)}+b_{d+1}

For hidden width m and depth d ≥ 1, the parameter count includes input weights, connections between hidden layers, output weights, and all biases:

P=3m+1+(d−1)(m2+m),d≥1P=3m+1+(d-1)(m^2+m),\qquad d\geq1

All models see the same 32 training points in each epoch and use the same learning rate. One epoch computes the average gradient over all 32 examples, then updates every parameter once. Deeper networks have more parameters and require more computation: equal epochs do not mean equal time or equal capacity.

L(θ)=132∑i=132(y^i−yi)2,θ←θ−η∇θLL(\theta)=\frac1{32}\sum_{i=1}^{32}(\hat y_i-y_i)^2,\qquad\theta\leftarrow\theta-\eta\nabla_\theta L

The clean-curve MSE uses 101 points from the known target function without noise. It checks interpolation against that function; it is not evidence of performance on a different problem. Training noise is uniform between minus and plus the selected amplitude.

Why depth can change learning

More hidden layers compose more nonlinear transformations, but they also lengthen the chain of derivatives. Gradients can shrink or grow along that chain (Goodfellow et al., 2016, §8.2.5). Sigmoid derivatives are at most ¼; tanh saturates at large magnitudes; ReLU has zero derivative on its negative side. In this implementation, the derivative at ReLU’s exact zero is also taken as zero.

δl=(Wl+1Tδl+1)⊙ϕ′(zl)\boldsymbol\delta_l=(W_{l+1}^{\mathsf T}\boldsymbol\delta_{l+1})\odot\phi'(\mathbf{z}_l)

The bars show the root mean square of the last batch’s weight gradients in each layer. Because a batch gradient averages across examples, cancellation can reduce it too. The bars are a diagnostic, not a proof of vanishing gradients.

Tanh and sigmoid use Xavier uniform initialization, with weights drawn from ±√(6/(nin + nout)) (Glorot & Bengio, 2010, §4.2.1). ReLU hidden layers use He uniform initialization, ±√(6/nin): a uniform distribution with the variance 2/nin that He and colleagues derived for rectifiers, who used a Gaussian (He et al., 2015, §2.2). Every linear output uses Xavier. Biases start at zero. Initialization and activation both affect training, as explored in Glorot and Bengio’s study of deep feedforward networks (Glorot & Bengio, 2010).

Seeded initialization makes resets reproducible. Different architectures still have different shapes and parameter values. Try multiple seeds before drawing a conclusion. There is no dropout, batch normalization, momentum, or adaptive optimizer here.

What the 3D learning path actually means

A model with P parameters lives in a P-dimensional parameter space. A full update moves through that space. The graph chooses two deterministic, random orthonormal directions u and v, then projects the parameter displacement from initialization onto them:

Xt=uT(θt−θ0),Yt=vT(θt−θ0),Zt=log⁡10 ⁣(max⁡(L(θt),10−12))X_t=\mathbf{u}^{\mathsf T}(\theta_t-\theta_0),\quad Y_t=\mathbf{v}^{\mathsf T}(\theta_t-\theta_0),\quad Z_t=\log_{10}\!\left(\max(L(\theta_t),10^{-12})\right)

The directions are fixed for the run and independent of its outcomes; they are not fitted to make the trajectory look smooth. The loss height is measured at the actual full parameter vector. We draw no surface because, for P > 2, a projected coordinate does not uniquely determine a model or its loss.

Crossing paths in this view need not mean the same weights were revisited. Two networks with different widths or depths use different parameter spaces, so comparing the absolute lengths of their projected paths is not meaningful. The two-parameter linear model is the exception: its two projection coordinates preserve the entire parameter displacement.

Checkpoints begin at epoch zero. Long runs are progressively downsampled, keeping the beginning and the full time span. The latest state is added to the display even between stored checkpoints. Lines connect sampled states; they do not represent every intermediate update. Axis ranges expand to include the complete displayed path.

Read the complete training and projection code

This is the implementation running in the experiment. A model is stopped if an update becomes nonfinite or a parameter magnitude exceeds one million; other models can continue. Reset with a smaller learning rate to try again.

import { randomSource } from './star-training';

export type Activation = 'tanh' | 'relu' | 'sigmoid';
export type Task = 'wave' | 'line';
export type Point = { x: number; y: number };
export const targetValue = (x: number, task: Task) => task === 'line' ? 0.7 * x + 0.1 : 0.6 * Math.sin(Math.PI * x) + 0.3 * Math.sin(3 * Math.PI * x);
export function makeData(task: Task, noise: number, seed: number): Point[] {
  const random = randomSource(seed);
  return Array.from({ length: 32 }, (_, i) => {
    const x = -1 + i * 2 / 31;
    return { x, y: targetValue(x, task) + (random() * 2 - 1) * noise };
  });
}
const activate = (z: number, kind: Activation) => kind === 'tanh' ? Math.tanh(z) : kind === 'relu' ? Math.max(0, z) : 1 / (1 + Math.exp(-z));
const derivative = (a: number, kind: Activation) => kind === 'tanh' ? 1 - a * a : kind === 'relu' ? Number(a > 0) : a * (1 - a);
type Layer = { weights: number[][]; biases: number[] };

export class DepthNetwork {
  layers: Layer[];
  gradientRms: number[];
  epochs = 0;
  failed = false;
  constructor(public depth: number, public width: number, public activation: Activation, seed: number) {
    const random = randomSource(seed);
    const sizes = [1, ...Array(depth).fill(width), 1];
    this.layers = sizes.slice(1).map((outputs, i) => {
      const inputs = sizes[i];
      const isHidden = i < depth;
      // He uniform for ReLU hidden layers; Xavier uniform otherwise.
      const bound = activation === 'relu' && isHidden ? Math.sqrt(6 / inputs) : Math.sqrt(6 / (inputs + outputs));
      return { weights: Array.from({ length: outputs }, () => Array.from({ length: inputs }, () => (2 * random() - 1) * bound)), biases: Array(outputs).fill(0) };
    });
    this.gradientRms = Array(this.layers.length).fill(0);
  }
  get parameterCount() {
    return this.layers.reduce((total, layer) => total + layer.weights.length * layer.weights[0].length + layer.biases.length, 0);
  }
  parameters() { return this.layers.flatMap(layer => [...layer.weights.flat(), ...layer.biases]); }
  forward(x: number) {
    const activations = [[x]];
    for (let l = 0; l < this.layers.length; l++) {
      const layer = this.layers[l];
      const values = layer.weights.map((row, j) => {
        const z = row.reduce((sum, w, i) => sum + w * activations[l][i], layer.biases[j]);
        return l === this.depth ? z : activate(z, this.activation);
      });
      activations.push(values);
    }
    return activations;
  }
  predict(x: number) { return this.forward(x).at(-1)![0]; }
  loss(data: Point[]) { return data.reduce((sum, point) => sum + (this.predict(point.x) - point.y) ** 2, 0) / data.length; }
  gradients(data: Point[]) {
    const gradients = this.layers.map(layer => ({ weights: layer.weights.map(row => row.map(() => 0)), biases: layer.biases.map(() => 0) }));
    for (const point of data) {
      const a = this.forward(point.x);
      // MSE derivative, averaged over the same complete dataset for every model.
      let delta = [2 * (a.at(-1)![0] - point.y) / data.length];
      for (let l = this.layers.length - 1; l >= 0; l--) {
        const layer = this.layers[l];
        for (let j = 0; j < delta.length; j++) {
          gradients[l].biases[j] += delta[j];
          for (let i = 0; i < a[l].length; i++) gradients[l].weights[j][i] += delta[j] * a[l][i];
        }
        if (l > 0) {
          delta = a[l].map((value, i) => layer.weights.reduce((sum, row, j) => sum + row[i] * delta[j], 0) * derivative(value, this.activation));
        }
      }
    }
    return gradients;
  }
  train(data: Point[], rate: number) {
    if (this.failed) return;
    const gradients = this.gradients(data);
    const next = this.layers.map((layer, l) => ({ weights: layer.weights.map((row, j) => row.map((w, i) => w - rate * gradients[l].weights[j][i])), biases: layer.biases.map((b, j) => b - rate * gradients[l].biases[j]) }));
    if (next.some(layer => [...layer.weights.flat(), ...layer.biases].some(value => !Number.isFinite(value) || Math.abs(value) > 1e6))) {
      this.failed = true; return;
    }
    this.gradientRms = gradients.map(layer => {
      const weights = layer.weights.flat();
      return Math.sqrt(weights.reduce((sum, value) => sum + value ** 2, 0) / weights.length);
    });
    this.layers = next;
    this.epochs++;
  }
}

export type DepthConfig = { depths: number[]; width: number; activation: Activation; task: Task; noise: number; seed: number };
export const DEFAULT_DEPTH_CONFIG: DepthConfig = { depths: [0, 1, 3, 6], width: 8, activation: 'tanh', task: 'wave', noise: 0, seed: 42 };

// A fixed orthonormal pair in parameter space, chosen independently of training.
// Using two directions leaves the third display axis available for actual loss.
export function projectionBasis(size: number, seed = 918) {
  const random = randomSource(seed);
  const normalize = (vector: number[]) => { const length = Math.hypot(...vector); return vector.map(value => value / length); };
  const u = normalize(Array.from({ length: size }, () => random() * 2 - 1));
  const raw = Array.from({ length: size }, () => random() * 2 - 1);
  const dot = raw.reduce((sum, value, i) => sum + value * u[i], 0);
  const v = normalize(raw.map((value, i) => value - dot * u[i]));
  return { u, v };
}
export function projectParameters(values: number[], initial: number[], basis: ReturnType<typeof projectionBasis>) {
  return values.reduce((point, value, i) => ({ x: point.x + (value - initial[i]) * basis.u[i], y: point.y + (value - initial[i]) * basis.v[i] }), { x: 0, y: 0 });
}
export type DepthCheckpoint = { epoch: number; losses: number[]; positions: { x: number; y: number }[] };
export class DepthExperiment {
  models: DepthNetwork[];
  data: Point[];
  clean: Point[];
  epoch = 0;
  history: DepthCheckpoint[] = [];
  stride = 10;
  initial: number[][];
  bases: ReturnType<typeof projectionBasis>[];
  constructor(public config: DepthConfig) {
    this.data = makeData(config.task, config.noise, config.seed);
    this.clean = Array.from({ length: 101 }, (_, i) => { const x = -1 + i * 2 / 100; return { x, y: targetValue(x, config.task) }; });
    this.models = config.depths.map(depth => new DepthNetwork(depth, config.width, config.activation, config.seed));
    this.initial = this.models.map(model => model.parameters());
    this.bases = this.initial.map(values => projectionBasis(values.length));
    this.record();
  }
  step(rate: number) {
    this.models.forEach(model => model.train(this.data, rate));
    this.epoch++;
    if (this.epoch % this.stride === 0) this.record();
  }
  checkpoint(): DepthCheckpoint {
    return { epoch: this.epoch, losses: this.models.map(model => model.failed ? NaN : model.loss(this.data)), positions: this.models.map((model, i) => projectParameters(model.parameters(), this.initial[i], this.bases[i])) };
  }
  record() {
    this.history.push(this.checkpoint());
    // Keep the origin and the whole time span with bounded memory.
    if (this.history.length > 201) { this.history = this.history.filter((_, i) => i % 2 === 0); this.stride *= 2; }
  }
}
History: gradient descent, depth and initialization

Gradient descent is older than neural networks. In 1847 Augustin-Louis Cauchy proposed it for astronomical calculations: to solve a system of equations, decrease a non-negative function by stepping against its partial derivatives, then repeat (Lemaréchal, 2012). Cauchy chose the step by making it small or by minimizing along the direction; this lesson uses a fixed learning rate. The gradients themselves come from backpropagation, which the backpropagation lesson works through by hand.

Why add hidden layers at all? The universal approximation theorems of 1989 (Cybenko, 1989) (Hornik et al., 1989) showed that a network with one hidden layer of squashing units can approximate a broad class of functions to any accuracy, given enough units. They do not say how many units are needed, or whether training will find the right weights (Goodfellow et al., 2016, §6.4.1). Later results show that depth can be much cheaper than width for some functions. Montúfar and colleagues showed that the number of linear regions a rectifier network can carve out grows exponentially with depth (Montúfar et al., 2014). Telgarsky constructed networks with many narrow layers that shallower networks cannot approximate unless they are exponentially large (Telgarsky, 2016). These are statements about what networks can represent. They do not predict which model here fits 32 noisy points first.

Training deep networks is a separate problem. Glorot and Bengio found that the logistic sigmoid is poorly suited to deep networks with random initialization, because its mean value can drive the top hidden layer into saturation. They proposed scaling initial weights by the sizes of both adjacent layers, a compromise between keeping activation variances and back-propagated gradient variances constant, derived under a linear approximation (Glorot & Bengio, 2010, §4.2.1) (Goodfellow et al., 2016, §8.4). He, Zhang, Ren and Sun repeated the variance calculation for rectifiers. A ReLU passes on only the positive half of a symmetric input, so the weight variance must double to 2/n; with this scaling, a 30-layer model converged in their experiments where Xavier initialization did not (He et al., 2015, §2.2).

Rectification dates back at least to Fukushima’s cognitron of 1975, though not yet as today’s rectified linear unit. Sigmoids largely replaced it in the 1980s, and rectified linear units came back into common use from about 2009 (Goodfellow et al., 2016, §6.6). Goodfellow and colleagues cite Nair and Hinton among the sources for recommending ReLU (Goodfellow et al., 2016, §6.1); that paper used noisy rectified linear units as the hidden units of restricted Boltzmann machines (Nair & Hinton, 2010).

References and further reading

References

  1. George Cybenko (1989). Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2(4), 303–314.Publisher record; full text may require access · Read: Crossref bibliographic record; theorem as summarized in Goodfellow et al. (2016) §6.4.1 · Checked 2026-09-13
  2. Xavier Glorot & Yoshua Bengio (2010). Understanding the difficulty of training deep feedforward neural networks. Proceedings of AISTATS 2010, PMLR 9, 249–256.Open access · open copy · Read: Full paper read, including §3.1 sigmoid experiments and §4.2.1 normalized initialization (eq. 16) · Checked 2026-09-13
  3. Ian Goodfellow, Yoshua Bengio & Aaron Courville (2016). Deep Learning. MIT Press, 1st ed.Open access · Read: Authors’ online edition: chapter 6 (deep feedforward networks), including §6.3 hidden units, §6.4.1 universal approximation and depth, §6.5 back-propagation and §6.6 historical notes; chapter 8 §8.2.5 long-term dependencies and §8.4 parameter initialization; chapter 9 §9.2 motivation for convolution · Checked 2026-09-13
  4. Kaiming He, Xiangyu Zhang, Shaoqing Ren & Jian Sun (2015). Delving Deep into Rectifiers: Surpassing Human-Level Performance on ImageNet Classification. Proceedings of the IEEE International Conference on Computer Vision (ICCV 2015), 1026–1034.Publisher record; full text may require access · open copy · Read: CVF open-access version: abstract and §2.2 initialization of filter weights for rectifiers (forward and backward variance derivation) · Checked 2026-09-13
  5. Kurt Hornik, Maxwell Stinchcombe & Halbert White (1989). Multilayer feedforward networks are universal approximators. Neural Networks 2(5), 359–366.Publisher record; full text may require access · Read: Crossref bibliographic record; theorem as summarized in Goodfellow et al. (2016) §6.4.1 · Checked 2026-09-13
  6. Claude Lemaréchal (2012). Cauchy and the Gradient Method. Documenta Mathematica, Extra Volume ISMP: Optimization Stories, 251–254.Open access · open copy · Read: Full chapter read; a historical review quoting and translating Cauchy’s 1847 Comptes rendus note (25, 536–538), which was not read directly · Checked 2026-09-13
  7. Guido Montúfar, Razvan Pascanu, Kyunghyun Cho & Yoshua Bengio (2014). On the Number of Linear Regions of Deep Neural Networks. Advances in Neural Information Processing Systems 27 (NIPS 2014).Open access · open copy · Read: Proceedings record; abstract and introduction of arXiv version 2; main theorem as summarized in Goodfellow et al. (2016) §6.4.1 · Checked 2026-09-13
  8. Vinod Nair & Geoffrey E. Hinton (2010). Rectified Linear Units Improve Restricted Boltzmann Machines. Proceedings of the 27th International Conference on Machine Learning (ICML 2010).Open access · Read: Abstract and introduction of the conference paper · Checked 2026-09-13
  9. Matus Telgarsky (2016). Benefits of depth in neural networks. Proceedings of the 29th Conference on Learning Theory (COLT 2016), PMLR 49, 1517–1539.Open access · Read: Proceedings record, abstract and §1 main results statement · Checked 2026-09-13

Further reading

  • Yann LeCun, Léon Bottou, Genevieve B. Orr & Klaus-Robert Müller (1998). Efficient BackProp. In G. B. Orr & K.-R. Müller (eds.), Neural Networks: Tricks of the Trade, Lecture Notes in Computer Science 1524, Springer, 9–50.Practical advice on input normalization, activation choice, initialization and learning rates for backpropagation, with the reasons behind each.Publisher record; full text may require access · Checked 2026-09-13