1.4.2 · D2Python & Scientific Computing

Visual walkthrough — Functions, classes, and modules

2,286 words10 min readBack to topic

Before line one, three plain-word anchors so no symbol sneaks in unearned:

We are building toward the parent's DenseLayer. Prerequisites we lean on: Python basics - syntax, data types, control flow for variables and def, and NumPy arrays and vectorization for the @ matrix-multiply symbol. See also the parent Functions, classes, and modules.


Step 1 — Start with nothing but labelled boxes

WHAT. We want to represent a single neural-network layer. A layer, physically, is a table of numbers called weights (written ) and a short list called biases (written ). For now we store them the only way we know: separate variables.

layer1_weights = init_weights(100, 50)   # a 100×50 table of numbers
layer1_biases  = init_biases(50)          # a list of 50 numbers

WHY. This is the honest starting point — the parent note's "Attempt 1". A smart beginner reaches for named boxes first because that's all def-free Python gives you. We must feel its pain before we fix it.

PICTURE. Two loose drawers, each with a hand-written sticky label. Nothing connects them except that we remember they belong together.

  • (blue box) — the weight table, its shape written as : 100 inputs across, 50 outputs down.
  • (yellow box) — the bias list, length 50.
Figure — Functions, classes, and modules

The trouble is invisible in the picture — and that is the trouble: nothing in the code says these two boxes are one layer. The bond lives only in the prefix layer1_, which is a promise we make by hand.


Step 2 — Add the machine, and watch it forget

WHAT. A layer must do something: the forward pass, which computes output from input by the rule . We package that rule as a function.

Reading it term by term: is the incoming data (one row of numbers), is the box from Step 1, the multiply mixes every input into every output, and shifts each output. The symbol @ in code is this matrix multiply.

def layer1_forward(x):
    return x @ layer1_weights + layer1_biases

WHY. Because a function is exactly the right tool for "given input, produce output" — that is its whole job. But note the trap: the function has to reach outside itself for layer1_weights. It has no memory of its own.

PICTURE. The machine (green) sits between input arrow and output arrow , but dotted red "reach-out" arrows show it grabbing the loose boxes from Step 1. Those red arrows are the fragility.

Figure — Functions, classes, and modules

Step 3 — Bundle the boxes into one dictionary

WHAT. First repair attempt: put the two boxes from Step 1 inside one container so the bond is real, not just a naming promise. A dictionary is a bag of key→value pairs. We reuse the exact variables we already built — layer1_weights and layer1_biases — so nothing new is conjured.

layer1 = {'weights': layer1_weights, 'biases': layer1_biases}
 
def forward(layer, x):
    return x @ layer['weights'] + layer['biases']

WHY. Now layer1 is genuinely one thing you can pass around. The forward machine takes the whole bag as its first input, so no reaching outside. This is real progress — it is the parent's "Attempt 2". Note we keep the crate lean: only the two keys the forward rule actually reads ('weights' and 'biases'). Nothing sits in the crate that the machine never opens.

PICTURE. The two loose drawers now live inside one labelled crate. The forward machine takes the whole crate (solid arrow now, no dotted reach-out).

Figure — Functions, classes, and modules

What still hurts. Look closely — the machine still sits outside the crate. And nothing guarantees the crate has a 'weights' key: write layer['weght'] and Python only complains at run time, deep in training. The data is bundled; the behaviour is not, and the contract isn't enforced.


Step 4 — Move the machine INSIDE the box: the class

WHAT. The decisive step. We define a class — a blueprint that packs the boxes and the machines into one sealed unit. Each real layer built from it is an object (an instance).

class DenseLayer:
    def __init__(self, input_size, output_size):
        self.input_size  = input_size
        self.output_size = output_size
        self.weights = self._initialize_weights()
        self.biases  = self._initialize_biases()
 
    def forward(self, x):
        return x @ self.weights + self.biases

Two brand-new symbols, each earned right here:

  • __init__ is the constructor — the build recipe that runs once, automatically, when a new object is born. Think "assembly instructions stamped on the blueprint."
  • self is the word an object uses to say this particular one. It is the object pointing at its own drawers. self.weights means "the weights belonging to me, not to any other layer."

Here input_size and output_size finally earn their keep — unlike the lean dictionary of Step 3, the class uses them: they drive _initialize_weights so the weight table is stamped at exactly the right shape () the moment the object is born.

WHY. The machine forward now lives inside the crate, and it addresses its own data through self. The hand-made bond of Step 1 and the outside-the-crate machine of Step 3 both vanish — they are now the same sealed object.

PICTURE. One green sealed box labelled DenseLayer. Inside: the blue drawer, the yellow drawer, and the green forward machine — and a red curved arrow labelled self looping from the machine back to its own drawers.

Figure — Functions, classes, and modules

Step 5 — Stamp out two objects and see them stay separate

WHAT. From ONE blueprint we build TWO real layers with different sizes.

layer1 = DenseLayer(100, 50)   # its own W is 100×50
layer2 = DenseLayer(50, 10)    # its own W is 50×10
 
out   = layer1.forward(input_data)
final = layer2.forward(out)

WHY. This is the payoff the loose approach could never give: layer1 and layer2 are stamped from the same recipe yet each carries its own private state. When you call layer1.forward(...), Python quietly hands layer1 in as self, so the machine automatically uses layer1's weights — never layer2's. The typo-across-layers bug of Step 2 is now impossible.

PICTURE. The blueprint (grey) on the left; two sealed coloured boxes stamped from it on the right, each holding its own differently-shaped . The data flows layer1 layer2 output, output of one becoming input of the next.

Figure — Functions, classes, and modules

Step 6 — The degenerate cases for forward (the guardrails our own method must survive)

Good derivations must cover the ugly inputs, not just the clean ones. Every corner here is about our own forward(self, x) method — no outside examples.

Case A — the freshly-built object before its drawers are filled. Suppose __init__ had set self.weights = None (a valid "not initialised yet" state). Calling forward now would try x @ None and fail loudly. The lesson: a fresh object can legitimately hold empty drawers, so forward must only run once construction is complete.

Case B — bias broadcasting across a whole batch. In practice x is not one row but a batch of rows: shape . Then has shape , while is just length . They are added by broadcasting: NumPy stretches the single bias row down all rows so every sample gets the same bias.

The bias is copied — never reshaped by hand — so one short list correctly offsets every row of the batch.

Case C — the input-shape mismatch (the loud, healthy failure). If the incoming x has the wrong width — say when this layer expects — the multiply is simply undefined, because matrix multiply demands the columns of equal the rows of . NumPy raises a shape error immediately. This is a feature, not a bug: the object refuses a nonsensical call at the door rather than producing silent garbage.

PICTURE. Three panels: an empty-drawer object that refuses to run; a single bias row stretched down a batch of rows; and two mismatched blocks whose inner numbers () fail to meet.

Figure — Functions, classes, and modules

The one-picture summary

This final figure compresses all six steps into one road: loose drawers → machine that reaches out → bundled crate → sealed class with its machine inside → two independent stamped objects → the forward guardrails.

Figure — Functions, classes, and modules
Recall Feynman retelling — say it like a story

I started with a layer as two loose labelled boxes: a weight table and a bias list. They only felt like one layer because I typed the same prefix on both — a promise, not a fact. Then I needed a doer, so I wrote a function for the forward rule ; but that function had to reach outside itself to grab the boxes, and the moment I wanted a second layer everything threatened to collide by copy-paste. So I bundled the two boxes into one crate (a dictionary) — real bonding at last, keeping only the keys the forward rule actually reads — but the doer still stood outside and nobody checked the crate had the right labels. The fix that finally worked: define a blueprint — a class — that puts the boxes and the doer inside one sealed unit, where the doer says self to mean "my own boxes," and where input_size/output_size are finally used to stamp the weights at the right shape. Now I stamp out layer1 and layer2 from the same blueprint and each keeps its own private weights automatically, because Python hands the object left of the dot in as self. And I checked forward's own ugly corners: a fresh object may hold empty drawers (so don't call forward before setup), a whole batch of rows shares one bias by broadcasting, and an input of the wrong width fails loudly at the multiply because the inner dimensions must match. That whole journey — loose boxes to living object — is what "a class bundles state and behaviour" means.

Recall

What does self refer to inside a method? ::: The specific object the method was called on — "this particular instance," pointing at its own attributes. Why did the loose-variables approach (Step 1/2) fail as layers multiplied? ::: The bond between data and its function was only a naming promise, so copy-pasted layers collided and typos became silent bugs. What does Python secretly do when you write layer1.forward(x)? ::: It rewrites it as DenseLayer.forward(layer1, x), passing the object left of the dot in as the first argument self. How does one length-output_size bias offset a whole batch of N rows? ::: By broadcasting — NumPy copies the single bias row down all N rows so every sample gets the same offset. Why is an input of the wrong width (80 vs expected 100) a healthy failure? ::: Matrix multiply requires x's columns to equal W's rows; the mismatch raises a shape error immediately instead of producing silent garbage.

Related depth: Object-oriented programming in ML, Building neural networks from scratch, scikit-learn API patterns.