Tensor Flow 📂 Neural Network From Scratch · 2 of 6 32 min read

Write a Dense Layer From Scratch: Weights, Bias and Initialisation

Build a Dense layer with tf.Module: y = x @ W + b, its shapes and parameter count. See why zero weights make twin neurons and why the wrong scale makes signals vanish or explode. Compare Glorot and He initialisation. Compute outputs neuron by neuron in the calculator and follow the signal through a deep net.

Section 01

The Story — A Panel of Judges

Every Judge Weighs Every Clue
A cooking contest has 3 judges. Each dish is described by 4 clues: taste, smell, look and texture. Each judge cares about the clues differently. One judge puts a big weight on taste. Another cares mostly about the look. Each judge also has a personal mood — a bias — that pushes every score up or down a little.

Each judge's score = (taste × weight) + (smell × weight) + … + bias.

A Dense layer is exactly this panel. Every input connects to every output. The weights are a table with one column per judge. The whole panel works out all its scores with one matrix multiply: y = x @ W + b.

Section 02

The Maths and the Shapes

Dense Layer
y = x @ W + b
x: (batch, in) · W: (in, out) · b: (out,) → y: (batch, out)
Parameters
in × out + out
4 inputs → 3 outputs: 4 × 3 weights + 3 biases = 15 parameters.
import tensorflow as tf

class Dense(tf.Module):
    def __init__(self, in_features, out_features, name=None):
        super().__init__(name=name)
        with self.name_scope:
            self.w = tf.Variable(tf.random.normal([in_features, out_features], stddev=0.1), name="w")
            self.b = tf.Variable(tf.zeros([out_features]), name="b")

    def __call__(self, x):
        return x @ self.w + self.b

tf.random.set_seed(0)
layer = Dense(4, 3, name="judges")
x = tf.constant([[8.0, 6.0, 9.0, 7.0],       # dish 1: taste, smell, look, texture
                 [3.0, 5.0, 2.0, 4.0]])      # dish 2
print("scores shape:", layer(x).shape)
print(layer(x).numpy().round(3))
OUTPUT
scores shape: (2, 3) [[ 0.994 0.172 0.186] [ 0.172 -0.536 0.057]]

Section 03

Try It — Dense Layer Calculator

🧮 Watch Every Output Being Computed Interactive

Set the sizes, type the inputs, and click any weight or bias to change it. Line colour shows the sign of a weight (blue +, red −); thickness shows its size. Press Compute to watch each output neuron add up its inputs.

W (inputs × outputs) — click to edit

b (one per output)


Section 04

Why Initialisation Matters

Problem 1: All Zeros — Every Neuron Becomes a Twin

If all weights start equal, every output neuron computes the same thing and gets the same gradient. They stay twins forever, so 64 neurons act like 1.

w = tf.Variable(tf.zeros([3, 4]))                   # 4 neurons, all zero
x = tf.constant([[1.0, 2.0, 3.0]])
with tf.GradientTape() as tape:
    loss = tf.reduce_sum(tf.nn.sigmoid(x @ w) * [1.0, 2.0, 3.0, 4.0])
grad = tape.gradient(loss, w)
print("gradient columns (one per neuron):")
print(grad.numpy().round(3))
print("every column the same shape of numbers -> the neurons can never become different")
OUTPUT
gradient columns (one per neuron): [[0.25 0.5 0.75 1. ] [0.5 1. 1.5 2. ] [0.75 1.5 2.25 3. ]] every column the same shape of numbers -> the neurons can never become different
👥
Symmetry Breaking

Each column above is just a scaled copy of [1, 2, 3]. The neurons move in lock-step. Random starting weights "break the symmetry" so each neuron can learn a different feature. Biases can safely start at zero.

Problem 2: Wrong Scale — Signals Vanish or Explode

Each layer multiplies the signal by its weights. If the weights are a little too small, the signal shrinks layer by layer. A little too big, and it explodes.

def signal_through(depth, stddev_fn, act=tf.nn.relu, width=256):
    tf.random.set_seed(1)
    h = tf.random.normal([512, width])
    for _ in range(depth):
        W = tf.random.normal([width, width], stddev=stddev_fn(width))
        h = act(h @ W)
    return float(tf.math.reduce_std(h))

schemes = {
    "stddev 0.01  ": lambda n: 0.01,
    "stddev 1.0   ": lambda n: 1.0,
    "He sqrt(2/n) ": lambda n: (2.0 / n) ** 0.5,
}
for name, fn in schemes.items():
    print(name, "std after 10 relu layers:", f"{signal_through(10, fn):.3e}")
OUTPUT
stddev 0.01 std after 10 relu layers: 2.657e-10 stddev 1.0 std after 10 relu layers: 2.657e+10 He sqrt(2/n) std after 10 relu layers: 7.734e-01
⚖️
Glorot / Xavier uniform
Keeps the signal steady for tanh and sigmoid. The Keras default.
U(−√(6/(in+out)), +√(6/(in+out)))
⚡
He / Kaiming normal
ReLU zeros half the values, so it doubles the variance to make up for it.
N(0, √(2 / in))
❌
Zeros or a fixed scale
Zeros: twin neurons. A fixed stddev: fine for 1–2 layers, breaks in deep nets.
avoid for weights

Section 05

Try It — Initialisation Explorer

📈 Follow the Signal Through a Deep Net Interactive

Pick an initialiser, an activation, the depth and the width. Press Run. Each bar is the spread (standard deviation) of the activations after that layer. A healthy net keeps the bars roughly level.


Section 06

The Final Dense Layer

Now put it all together: He or Glorot initialisation, zero bias, an optional activation, and a helper for the number of parameters. We will use this class for the rest of the module.

import math

class Dense(tf.Module):
    def __init__(self, in_features, out_features, activation=None, init="glorot", name=None):
        super().__init__(name=name)
        if init == "he":
            w0 = tf.random.normal([in_features, out_features], stddev=math.sqrt(2.0 / in_features))
        else:  # glorot uniform
            limit = math.sqrt(6.0 / (in_features + out_features))
            w0 = tf.random.uniform([in_features, out_features], -limit, limit)
        with self.name_scope:
            self.w = tf.Variable(w0, name="w")
            self.b = tf.Variable(tf.zeros([out_features]), name="b")
        self.activation = activation

    def __call__(self, x):
        z = x @ self.w + self.b
        return self.activation(z) if self.activation else z

    @property
    def num_params(self):
        return int(tf.size(self.w) + tf.size(self.b))

tf.random.set_seed(42)
hidden = Dense(784, 128, activation=tf.nn.relu, init="he", name="hidden")
output = Dense(128, 10, name="logits")
batch = tf.random.normal([32, 784])
print("output shape:", output(hidden(batch)).shape)
print("parameters  :", hidden.num_params + output.num_params)
print("W std (He)  :", round(float(tf.math.reduce_std(hidden.w)), 4), "~ sqrt(2/784) =", round(math.sqrt(2 / 784), 4))
OUTPUT
output shape: (32, 10) parameters : 101770 W std (He) : 0.0505 ~ sqrt(2/784) = 0.0505

Section 07

Golden Rules

🧮 Dense Layers — Rules to Remember
1
A Dense layer is y = x @ W + b with W of shape (in, out) and b of shape (out,).
2
Parameters = in × out + out. The batch size never changes this.
3
Never start weights at zero. Random weights break the symmetry. Biases may start at zero.
4
Use He init before ReLU and Glorot before tanh/sigmoid/linear.
5
If deep-net activations shrink to 0 or blow up, check the initialisation scale first.