1.4.2 · D4Python & Scientific Computing

Exercises — Functions, classes, and modules

3,490 words16 min readBack to topic

Before we start, a few words we will use constantly, defined in plain language so no symbol, notation, or piece of jargon appears unearned:


Level 1 — Recognition

Goal: read code and say what it produces. No writing yet.

Exercise 1.1 (L1)

What does this print?

def add(a, b=10):
    return a + b
 
print(add(5))
print(add(5, 2))
Recall Solution

Here b=10 is the default argument we defined above: if the caller does not supply b, Python fills in 10.

  • add(5)b defaults to 105 + 10 = 15.
  • add(5, 2)b is given as 25 + 2 = 7.

Output:

15
7

Exercise 1.2 (L1)

Identify the attribute and the method:

class Counter:
    def __init__(self):
        self.count = 0
    def tick(self):
        self.count += 1
        return self.count
Recall Solution
  • Attribute: self.count — data the object remembers between calls.
  • Method: tick — an action; it changes the remembered data and returns it.
  • __init__ is the constructor we defined in the vocabulary above: it runs once when the object is created and sets the starting data (count = 0).

Exercise 1.3 (L1)

Given a file metrics.py containing a function accuracy, which line correctly uses it?

# (A)
import metrics
metrics.accuracy(y, yhat)
 
# (B)
from metrics import accuracy
accuracy(y, yhat)
 
# (C)
import accuracy from metrics
Recall Solution

(A) and (B) are both correct — they are two valid import styles.

  • (A) imports the whole module; you reach inside with metrics.accuracy.
  • (B) imports just the one name; you call accuracy directly.
  • (C) is invalid syntax — Python never writes import X from Y. The correct form is from Y import X.

Level 2 — Application

Goal: write short functions and call them correctly.

Exercise 2.1 (L2)

Write compute_mse(y_true, y_pred) returning the mean squared error, and evaluate it for y_true = [3, 5, 2], y_pred = [2, 5, 4].

Recall Solution

Mean squared error = average of the squared gaps between truth and prediction. Using the two notation pieces from the vocabulary (the fraction bar = "divide", the summation sign = "add up as walks to "), with points: In words: for each point, take (truth − prediction), square it, add them all, then divide by how many there are.

def compute_mse(y_true, y_pred):
    n = len(y_true)
    return sum((y_true[i] - y_pred[i])**2 for i in range(n)) / n

Plug in the numbers:

  • gaps: , , .
  • squares: . Sum . Divide by .

Exercise 2.2 (L2)

Extend it to compute_error(y_true, y_pred, squared=True) so squared=False returns the root MSE (RMSE). Evaluate RMSE for the data above.

Recall Solution
def compute_error(y_true, y_pred, squared=True):
    n = len(y_true)
    mse = sum((y_true[i] - y_pred[i])**2 for i in range(n)) / n
    return mse if squared else mse ** 0.5

RMSE just takes the square root: .

Exercise 2.3 (L2)

Write euclidean(x1, x2) for two equal-length numeric lists, using the distance formula, and compute the distance between [1, 2] and [4, 6].

Recall Solution

Euclidean distance is the straight-line length between two points. For vectors with parts indexed by (again, = "add up", the outer square root = "undo the squaring to get a length"), Why the square root? The squared differences give an area-like quantity; the root brings us back to a length in the same units as the coordinates.

def euclidean(x1, x2):
    return sum((a - b)**2 for a, b in zip(x1, x2)) ** 0.5

Differences: , . Squares . Root .

Read the figure below. Two points sit at the ends of a slanted line: the lower-left one is (1,2) and the upper-right one is (4,6). From (1,2) a horizontal leg (labelled dx = 3 in the figure) runs right, and a vertical leg (labelled dy = 4) runs up to reach (4,6); together they form a right angle. The slanted line closing the triangle is the distance itself, labelled d = 5. The formula is just the Pythagorean theorem on that right triangle: the slanted length is . Every leg, point, and angle carries a text label in the figure, so the colours are decoration, not the message — this is why Euclidean distance uses squares-then-root: it measures the hypotenuse of a right triangle whose legs are the coordinate differences.

Figure — Functions, classes, and modules

Level 3 — Analysis

Goal: explain why a design choice is correct, and spot the flaw in a broken one.

For this level we work with a small normalize_features function. So the page stays self-contained (no jumping back to the parent), here is the exact code and signature we analyze:

def normalize_features(X, feature_min=None, feature_max=None):
    """Min-max scale each column of X into the [0, 1] range.
    X is a 2-D NumPy array with shape (n_samples, n_features).
    If feature_min / feature_max are None, they are computed from X;
    otherwise the supplied (training) values are reused.
    Returns: X_norm, feature_min, feature_max
    """
    if feature_min is None:
        feature_min = X.min(axis=0)          # smallest value per column
    if feature_max is None:
        feature_max = X.max(axis=0)          # largest value per column
    range_vals = feature_max - feature_min   # width of each column's range
    range_vals[range_vals == 0] = 1          # guard constant columns
    X_norm = (X - feature_min) / range_vals
    return X_norm, feature_min, feature_max

Min-max scaling maps the smallest value in a column to 0, the largest to 1, and everything else proportionally in between, via .

Exercise 3.1 (L3)

Why does the test set pass in the training feature_min/feature_max (instead of recomputing its own)?

Recall Solution

Because recomputing on the test set = data leakage. Normalization asks "where does this value sit between the smallest and largest we have seen?" If the test set defines its own min/max, then information from the test set (its extremes) sneaks into how test samples are scaled — the model effectively "peeks" at data it is meant to be judged on. The honest pipeline: learn the scaling from training data, then apply that fixed scaling to test data. That is exactly the fit / transform split you see in scikit-learn API patterns.

Exercise 3.2 (L3)

Why does the function do range_vals[range_vals == 0] = 1? What breaks without it, and for which column?

Recall Solution

For a constant feature (every value in that column identical), . The scaling divides by that range, so you would divide by zero → nan/inf for that whole column. Setting the zero ranges to 1 makes the division safe. The numerator is also for a constant column, so : every value in that column becomes 0, which is the sensible "this feature carries no information" answer.

Exercise 3.3 (L3)

Two attempts store network layers. Which is more robust and why?

# (A) dictionary
layer = {'weights': W, 'biases': b}
def forward(layer, x): return x @ layer['weights'] + layer['biases']
 
# (B) class
class DenseLayer:
    def __init__(self, W, b): self.weights, self.biases = W, b
    def forward(self, x): return x @ self.weights + self.biases
Recall Solution

(B) the class is more robust.

  • A dictionary has no guarantee the right keys exist — a typo like layer['weight'] fails only at runtime, deep in a training loop.
  • The class's constructor (__init__) guarantees every DenseLayer has weights and biases, and forward is bound to its own data — layer1.forward(x) automatically uses layer1's weights, no manual passing.
  • Behavior travels with the data instead of floating in loose functions.

Level 4 — Synthesis

Goal: build a small class or function pipeline from scratch.

Exercise 4.1 (L4)

Complete a KNNClassifier with fit, a private _euclidean_distance, and predict (majority vote over the k closest training points). Specify the input-shape and return-type contract, validate inputs, and handle two edge cases explicitly: (1) k larger than the number of training samples, and (2) a tie in the vote. Then: with k=3, training points labelled

(1,1)→A, (2,2)→A, (3,3)→A, (6,6)→B, (7,7)→B, (8,8)→B

what does it predict for the query (2.5, 2.5)?

Recall Solution

Contracts.

  • Input: X is 2-D, shape (n_samples, n_features); each query row must have the same feature count as the training rows.
  • Return: predict returns a NumPy array of labels, one per query row (never a bare Python list) — so downstream code can index and vectorize it consistently, matching scikit-learn API patterns.
  • Edge cases: if k exceeds the number of training samples we clamp it down to that count (asking for more neighbours than exist is meaningless); ties in the vote are broken deterministically by picking the label that is smallest when sorted, so the same input always gives the same answer.
import numpy as np
from collections import Counter
 
class KNNClassifier:
    def __init__(self, k=3):
        self.k = k
    def fit(self, X, y):
        X = np.asarray(X)
        if X.ndim != 2:
            raise ValueError("X must be 2-D (n_samples, n_features)")
        self.X_train, self.y_train = X, np.asarray(y)
        return self                              # method chaining
    def _euclidean_distance(self, x1, x2):
        return np.sqrt(np.sum((x1 - x2)**2))
    def _vote(self, labels):
        counts = Counter(labels)
        top = max(counts.values())               # highest vote count
        tied = sorted(l for l, c in counts.items() if c == top)
        return tied[0]                            # deterministic tie-break
    def predict(self, X):
        X = np.asarray(X)
        if X.ndim != 2:
            raise ValueError("X must be 2-D (n_samples, n_features)")
        if X.shape[1] != self.X_train.shape[1]:
            raise ValueError("query features must match training features")
        k = min(self.k, self.X_train.shape[0])   # clamp k <= n_samples
        out = []
        for x in X:
            dists = [self._euclidean_distance(x, xt) for xt in self.X_train]
            nearest = np.argsort(dists)[:k]       # k closest indices
            out.append(self._vote([self.y_train[i] for i in nearest]))
        return np.array(out)                      # return-type contract

What happens for the query (2.5, 2.5)? Its three closest training points are (2,2), (3,3), (1,1) — all class A (the two diagonal points nearest (2.5,2.5) are (2,2) and (3,3), both a tiny diagonal step away; the next closest is (1,1); every B point sits far off along the diagonal). With all three nearest labelled A, the majority vote is unambiguous: A. No tie arises here, but had it, _vote would still return a single deterministic label.

Read the figure below. It plots the six training points and the query on the same axes. The three class-A points cluster near the lower-left; the three class-B points cluster near the upper-right; the query (drawn as a star, with the text label "query (2.5, 2.5)") sits right beside the class-A cluster. A dashed circle is drawn around the query enclosing exactly its 3 nearest neighbours — all of them class A. Each cluster and the query are labelled in text, so the picture reads even without colour: KNN works because points close together tend to share a label.

Figure — Functions, classes, and modules

Exercise 4.2 (L4)

Why does fit return self? Show one line where this pays off.

Recall Solution

Returning the object enables method chaining: fit hands back the same instance, so you can immediately call the next method on it.

preds = KNNClassifier(k=3).fit(X_train, y_train).predict(X_test)

This is exactly the convention in scikit-learn API patterns — every estimator's fit returns self.


Level 5 — Mastery

Goal: combine functions, classes, defaults, and edge cases in one design.

Exercise 5.1 (L5)

Build a tiny StandardScaler class with fit, transform, and fit_transform, using the standardization formula where (the Greek letter "mu") is the column mean and (the Greek letter "sigma") the column standard deviation (a measure of typical spread). State the input-shape contract; guard against for the general multi-column case; reject empty input; and make transform raise a clear error if it is called before fit. Then: fit on the single column [[2], [4], [6], [8]], and give the transformed value corresponding to the raw input x = 4.

Recall Solution

Why standardize? Subtracting centers the data at ; dividing by (the typical spread) makes "one unit" mean "one standard deviation," so features on wildly different scales become comparable.

Shape contract: X is always 2-D, shape (n_samples, n_features), with n_samples >= 1. Keeping X 2-D means X.mean(axis=0) returns a 1-D array of per-column means (one entry per feature), so self.sigma is always an array we can index with self.sigma == 0 — never a bare scalar. That removes the single-feature edge case entirely.

import numpy as np
class StandardScaler:
    def fit(self, X):
        X = np.asarray(X, dtype=float)
        if X.ndim != 2:
            raise ValueError("X must be 2-D (n_samples, n_features)")
        if X.shape[0] < 1:
            raise ValueError("X must contain at least one row")
        self.mu    = X.mean(axis=0)          # 1-D: one mean per column
        self.sigma = X.std(axis=0)           # 1-D: one std  per column
        self.sigma[self.sigma == 0] = 1      # guard constant columns
        return self
    def transform(self, X):
        if not hasattr(self, "mu"):
            raise RuntimeError("call fit() before transform()")
        X = np.asarray(X, dtype=float)
        if X.ndim != 2 or X.shape[1] != self.mu.shape[0]:
            raise ValueError("X shape must match the fitted feature count")
        return (X - self.mu) / self.sigma
    def fit_transform(self, X):
        return self.fit(X).transform(X)      # reuse, no duplicate logic

Two deliberate edge-case guards: X.shape[0] < 1 rejects empty input before we try to average nothing, and the hasattr(self, "mu") check turns a call-before-fit into a readable RuntimeError instead of an obscure AttributeError. (A single row is allowed: its std is 0 for every column, and the guard turns those into 1, so every value maps to 0 — the sensible "no spread" answer.)

Hand-compute for the column [[2],[4],[6],[8]].

  • Mean .
  • Variance , so .
  • For : .

Exercise 5.2 (L5)

Suppose you split this scaler across a module preprocessing.py and a training script. Sketch the imports, and state the one property that guarantees the test set is scaled with training statistics.

Recall Solution
# preprocessing.py
class StandardScaler: ...
 
# train.py
from preprocessing import StandardScaler
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)   # learns mu, sigma from TRAIN
X_test_s  = scaler.transform(X_test)        # reuses those mu, sigma

The guaranteeing property: fit stores as attributes on the object, and transform reads those stored attributes rather than recomputing. Calling only transform (never fit) on the test set means the training statistics are reused — no leakage. This is the same discipline as Data pipelines and preprocessing and underlies clean Software engineering for ML.


Recall Quick self-check (cloze)

A function that produces a value must end with ::: return The special method that runs when an object is created (the constructor) ::: __init__ Scaling a test set with its own min/max causes ::: data leakage fit returns self to enable ::: method chaining To remember data across method calls, store it on ::: self Calling transform before fit should raise ::: a clear error (e.g. RuntimeError) KNN with an even k may hit a ::: tie, which must be broken deterministically