Kiru Lab / Foundations: Python as an Instrument / Python From First Principles
Functions: Naming a Piece of Behavior
The discipline of writing a function that does exactly one thing — and the training loop that results.
Code Walkthrough · about 35 minutes
A function gives a name to a block of behavior so it can be reused, tested, and reasoned about in isolation. You now have branches and loops; functions are what keep a program made of them from becoming one long unreadable script.
def train(model, data, epochs: int, lr: float):
history = []
for epoch in range(epochs):
total_loss = 0.0
for x, y in data:
prediction = model(x)
loss = (prediction - y) ** 2
model.step(x, prediction - y, lr)
total_loss += loss
history.append(total_loss / len(data))
if epoch > 0 and history[-1] > history[-2]:
print(f"epoch {epoch}: loss went up — check the learning rate")
return historyThat is the whole shape of deep learning training. Every framework you will use later — PyTorch, JAX, whatever replaces them — is an elaboration of this loop with better derivatives and faster hardware underneath.
Functions that fit in your head
- One job per function. If the name needs an "and", split it.
- Take what you need as arguments; do not reach for globals.
- Return a value rather than printing one — printing is a decision the caller should make.
- Fail loudly and early on bad input instead of returning something plausible and wrong.
def normalize(vector: list[float]) -> list[float]:
if not vector:
raise ValueError("cannot normalize an empty vector")
magnitude = sum(v * v for v in vector) ** 0.5
if magnitude == 0:
raise ValueError("cannot normalize the zero vector")
return [v / magnitude for v in vector]A model that returns a confident answer to a question it cannot answer is doing the same thing as a function that returns 0.0 instead of raising on an empty input. Hold that thought until the LLM track — it is the mechanical root of confabulation.
Hold on to
- A training loop is ordinary control flow, not magic
- Guard clauses keep the happy path flat and readable
- Returning a plausible wrong value is worse than raising
Work through
Try each one before opening the solution. Getting it wrong first is most of where the learning happens.
-
Rewrite a deeply nested validation function using guard clauses. Count the lines before and after.
Hint
Return early on the invalid cases, then write the main logic unindented.
Solution
The guard-clause version handles each invalid case in two lines at the top and leaves the real work at the bottom with no indentation. It is usually a third shorter and reads as a list of preconditions followed by the actual behavior.
# Nested version def process(user): if user is not None: if user.is_active: if user.email: return send(user.email) else: raise ValueError("no email") else: raise ValueError("inactive") else: raise ValueError("no user") # Guard clauses def process(user): if user is None: raise ValueError("no user") if not user.is_active: raise ValueError("inactive") if not user.email: raise ValueError("no email") return send(user.email) -
Write `train()` above against a toy linear model of your own and plot the loss history. Deliberately set the learning rate too high and describe what the history looks like.
Hint
A "model" here can be as simple as a single weight and a step method.
Solution
With too high a learning rate the loss history rises rather than falls, often to infinity or NaN within a few epochs — the step overshoots the minimum and lands further away each time, so the overshoot compounds. You will see the identical signature on a transformer in track four.
class ToyModel: def __init__(self): self.w = 0.0 def __call__(self, x): return self.w * x def step(self, x, error, lr): self.w -= lr * error * x data = [(1.0, 2.0), (2.0, 4.0), (3.0, 6.0)] # true w = 2 history = train(ToyModel(), data, epochs=20, lr=0.05) # converges history = train(ToyModel(), data, epochs=20, lr=1.0) # diverges
Sign in to track your progress through the lab.