Introduction to Artificial Intelligence - Homework Assignment 06 (5 pts.)¶

  • Name:
  • NETID:

Teaching the Computer to Look Due November 2, 2026.

This assignment covers:

  • Turning images into tensors, batches, and flattened feature vectors
  • Implementing an affine (linear) layer and choosing an initial weight scale
  • Why neural networks need nonlinear activations such as ReLU
  • Turning logits into probabilities and a numerically stable cross-entropy loss
  • Assembling a feed-forward network and checking it against PyTorch
Task Description Points
00 Load the character evidence 0.25
01 Turn images into tensors and batches 0.75
02 Build an affine layer; explain the initialization 0.875
03 Add a nonlinearity; explain the collapse 0.375
04 Turn logits into a stable loss; interpret the chance loss 1.125
05 Assemble the network 0.50
06 Check against PyTorch and inspect the predictions 0.75
07 Report the results to Caulfield 0.375
08 Export the notebook 0
Total 5.00

Run the cells in order and answer the written questions. This assignment uses PyTorch but does not train anything: every network you build this week keeps its random starting weights. In HW07, we will teach it.

Story Progression¶

Caulfield finally has readable pages, and your SVM tells him where the cut-out letters sit. That leaves a few hundred characters that still need to be read. The intern volunteered to read them all by hand, then asked whether the squiggly one was a "2" or a "Z". Caulfield would like a second opinion that doesn't drink his coffee.

The forensics lab keeps a reference archive of 9,400 handwritten characters, each already labeled by an analyst. There are 47 possible labels: ten digits, all 26 uppercase letters, and the 11 lowercase letters that don't look like their uppercase versions. If we can build a model that learns from this archive, we can point it at the recovered evidence.

This week we will build the forward pass of a feed-forward neural network: the calculation that turns a 28 × 28 image into one score per character. HW02 gave us a linear model for one number. HW03 gave us a linear model for two classes. Now we will stack several linear layers, add nonlinearities between them, and produce 47 class scores at once.

Our network will start with random weights, so its first guesses will be terrible. That is expected. This week we make sure the calculation is correct; in HW07, we make it accurate.

Evidence 1: A sample of the forensics lab's reference archive of handwritten characters.
Evidence 1: A sample of the forensics lab's reference archive of handwritten characters.

Task 00: Load the Character Evidence¶

Task 00-1: Description (0 pts.)¶

What Is in the Archive?¶

The archive is a balanced subset of EMNIST, the "extended" version of the classic MNIST handwritten-digit dataset. Every example is one 28 × 28 grayscale image of a single character:

  • Each pixel is an unsigned 8-bit integer (uint8) from 0 (black background) to 255 (bright ink).
  • Each label is an integer class ID from 0 to 46. classes.json maps those IDs back to characters: ID 10 means A, ID 36 means a, and so on.

Why only 11 lowercase letters? EMNIST's "balanced" split merges lowercase letters that are hard to tell apart from their uppercase versions when handwritten. A handwritten c and C are usually the same shape at a different size, so both are labeled C. The letters that stay separate (a b d e f g h n q r t) look different from their capitals. Keep this in mind when you inspect mistakes: some errors are genuinely ambiguous, even for a person.

File Contents When to use it
emnist_balanced_small_train.npz X: (9400, 28, 28) images, y: (9400,) labels Now: we split it into training and validation sets
emnist_balanced_small_test.npz 2,350 held-out images and labels Keep closed until HW07's final comparison
classes.json The ordered list of 47 class names Translating IDs into characters

The files already use the course's image orientation. Do not transpose or rotate them. (The raw EMNIST download is stored sideways; we've already fixed that.)

Your Task¶

The setup code loads the arrays for you. Complete one line:

  1. Count how many examples belong to each class with np.bincount. Pass minlength=len(classes) so a missing class would still appear as a zero.

The supplied code prints the counts, the first few labels, and a grid with one example of each class. A balanced dataset should have the same count for every class. Look closely at the printed labels: the file stores all 200 examples of one class, then all 200 of the next. That ordering will matter in Task 01.

Python Help¶

labels = np.array([0, 2, 2, 1, 2])
np.bincount(labels)               # array([1, 1, 3]): one 0, one 1, three 2s
np.bincount(labels, minlength=5)  # array([1, 1, 3, 0, 0])

Useful Links¶

  • EMNIST dataset description (NIST)
  • np.bincount documentation

Task 00-1: Code (0.25 pts.)¶

In [ ]:
import os
import shutil
import subprocess
from pathlib import Path

REPO_URL = 'https://github.com/wtheisen/nd-cse-30124-homeworks.git'

try:
    import google.colab
except ImportError:
    # Reuse a local checkout, including when launched from a homework subfolder.
    DATA_ROOT = next((p for p in [Path.cwd(), *Path.cwd().parents]
                      if (p / '.git').exists() and (p / 'evidence').is_dir()), None)
    if DATA_ROOT is None:
        DATA_ROOT = Path.cwd() / 'nd-cse-30124-homeworks'
else:
    # Leave the old checkout before deleting it, including on a setup-cell rerun.
    os.chdir('/content')
    DATA_ROOT = Path('/content/nd-cse-30124-homeworks')
    # Fetch fresh evidence every time this cell runs in Colab.
    if DATA_ROOT.exists():
        shutil.rmtree(DATA_ROOT)

if not DATA_ROOT.exists():
    subprocess.run(['git', 'clone', REPO_URL, str(DATA_ROOT)], check=True)

if not (DATA_ROOT / '.git').exists():
    raise RuntimeError(f'{DATA_ROOT} exists but is not a Git checkout. Choose a different clone location.')

# All later relative paths now start at the homework repository root.
os.chdir(DATA_ROOT)
DATA_ROOT = Path.cwd()

import json
import math
import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import TensorDataset, DataLoader

torch.manual_seed(42)

EVIDENCE_DIR = DATA_ROOT / 'evidence' / 'homework06'
required = ['emnist_balanced_small/emnist_balanced_small_train.npz', 'emnist_balanced_small/classes.json']
if any(not (EVIDENCE_DIR / name).is_file() for name in required):
    raise FileNotFoundError('HW06 evidence is missing. Rerun the setup cell with an internet connection, or check that your local repository contains the evidence/homework06 files.')
os.chdir(EVIDENCE_DIR)

raw = np.load('emnist_balanced_small/emnist_balanced_small_train.npz')
X_raw, y_raw = raw['X'], raw['y']
classes = json.loads(Path('emnist_balanced_small/classes.json').read_text())['classes']

# TODO: Count the examples in each class, including classes with zero examples.
counts = None

print('Images:', X_raw.shape, X_raw.dtype, '| pixel range:', X_raw.min(), 'to', X_raw.max())
print('Labels:', y_raw.shape, y_raw.dtype)
print('Classes:', len(classes), '->', ' '.join(classes))
print('Examples per class: min', counts.min(), '| max', counts.max())
print('First 8 labels:', y_raw[:8], '| labels 198-205:', y_raw[198:206])

# Supplied helper: draw a grid of labeled characters.
def show_characters(images, titles, columns=12, suptitle=None):
    rows = math.ceil(len(images) / columns)
    title_lines = max(str(title).count('\n') + 1 for title in titles)
    fig, axes = plt.subplots(rows, columns, figsize=(columns * 1.1, rows * (1.1 + 0.3 * title_lines)), squeeze=False)
    for ax in axes.flat:
        ax.axis('off')
    for ax, image, title in zip(axes.flat, images, titles):
        ax.imshow(image, cmap='gray', vmin=0, vmax=255 if image.max() > 1 else 1)
        ax.set_title(title, fontsize=9)
    if suptitle:
        fig.suptitle(suptitle)
    fig.tight_layout()
    plt.show()

# Supplied: one example of every class, in class-ID order.
first_of_each = [np.flatnonzero(y_raw == label)[0] for label in range(len(classes))]
show_characters(X_raw[first_of_each], [f'{classes[label]} ({label})' for label in range(len(classes))],
                suptitle='One archive example per class: character (class ID)')

Task 00-1: Expected Output (0 pts.)¶

Images: (9400, 28, 28) uint8 | pixel range: 0 to 255
Labels: (9400,) int64
Classes: 47 -> 0 1 2 3 4 5 6 7 8 9 A B C D E F G H I J K L M N O P Q R S T U V W X Y Z a b d e f g h n q r t
Examples per class: min 200 | max 200
First 8 labels: [45 45 45 45 45 45 45 45] | labels 198-205: [45 45 36 36 36 36 36 36]

One archive example per class

One archive example per class, labeled with its character and class ID. Compare the plot produced by your code with this reference image.

Task 01: Turn Images into Tensors and Batches¶

Task 01-1: Description (0 pts.)¶

From NumPy Arrays to Tensors¶

A PyTorch tensor is very much like a NumPy array: it has a shape, a data type (dtype), and supports @, broadcasting, slicing, and .sum(axis)-style reductions (PyTorch spells the argument dim). Tensors add two things we'll need: they can run on a GPU, and in HW07 they will be able to track gradients.

Before the network can use the images, we have to fix three things:

  1. The data type. uint8 arithmetic wraps around instead of producing large numbers. Weights and activations need fractional values, so we use 32-bit floats.
  2. The scale. Dividing by 255 puts every pixel in [0, 1]. Large, unscaled inputs make the outputs of the first layer large too, which (as we'll see in Task 02) makes a network harder to work with.
  3. The shape. Image models in PyTorch expect (batch, channels, height, width). Our grayscale images have one channel, so (9400, 28, 28) becomes (9400, 1, 28, 28). The FFN won't use the channel dimension, but HW07's CNN will, and keeping the standard layout means both models can share the same data.

Labels are different: they are class IDs, not measurements. A class ID of 12 is not "twice" class 6. PyTorch's loss functions expect class IDs as 64-bit integers (torch.long), so we keep them as integers.

Worked Example¶

pixels = np.array([[200, 100], [0, 255]], dtype=np.uint8)
pixels + pixels               # [[144, 200], [0, 254]]   <- 200 + 200 wrapped around!
t = torch.from_numpy(pixels.copy()).float() / 255.0
# tensor([[0.7843, 0.3922],
#         [0.0000, 1.0000]])
t.unsqueeze(0).shape          # torch.Size([1, 2, 2]): a new dimension of size 1 at position 0

Convert to float before dividing, and divide exactly once. Dividing twice silently produces images whose brightest pixel is 1/255, and every later result changes without an error message.

Your Task¶

Complete to_image_tensor(X):

  • Input: a uint8 NumPy array with shape (n, 28, 28).
  • Return: a torch.float32 tensor with shape (n, 1, 28, 28) and values in [0, 1].

Then convert y_raw into a torch.long tensor named labels.

torch.from_numpy shares memory with the NumPy array. We call .copy() first so later changes to one can't silently change the other.

Python Help¶

a = np.zeros((3, 4), dtype=np.uint8)
torch.from_numpy(a.copy()).float().dtype   # torch.float32
torch.from_numpy(a.copy()).unsqueeze(1).shape   # torch.Size([3, 1, 4])
torch.from_numpy(np.array([3, 1])).long().dtype  # torch.int64 (also called torch.long)

Useful Links¶

  • PyTorch tensor tutorial
  • torch.from_numpy · Tensor.unsqueeze

Task 01-1: Code (0.25 pts.)¶

In [ ]:
def to_image_tensor(X):
    # TODO: Convert to float, normalize to [0, 1] exactly once, and add a channel dimension.
    return None

# TODO: Keep the labels as integer class IDs.
labels = None
images = to_image_tensor(X_raw)

print('Images:', tuple(images.shape), images.dtype)
print('Pixel range:', float(images.min()), 'to', float(images.max()))
print('Labels:', tuple(labels.shape), labels.dtype)

Task 01-1: Expected Output (0 pts.)¶

Images: (9400, 1, 28, 28) torch.float32
Pixel range: 0.0 to 1.0
Labels: (9400,) torch.int64

Task 01-2: Description (0 pts.)¶

Splitting, Batching, and Flattening¶

Splitting. Just as in HW02–HW04, we need data the model never learns from. Remember that the file is grouped by class: if we simply took the first 940 rows for validation, the validation set would contain only five characters, and the training set would contain none of them. So we shuffle the row numbers once with a fixed seed and split the shuffled order. We randomly hold out 940 images (10%) for validation and keep 8,460 for training. The split uses a fixed seed so HW06 and HW07 see exactly the same examples. The test file stays closed until the very end of HW07: if we peeked at it while making decisions, it would stop being an honest estimate of performance on new evidence.

Batching. Neural networks are usually fed a minibatch of examples at a time rather than one example or the entire dataset. Two PyTorch tools handle this:

  • TensorDataset(images, labels) pairs each image with its label, so dataset[i] returns (image_i, label_i).
  • DataLoader(dataset, batch_size=..., shuffle=...) walks through a dataset and returns one batch at a time. Each batch is a pair of tensors: images with shape (B, 1, 28, 28) and labels with shape (B,).

With shuffle=True, the loader visits the examples in a new random order on each pass. That matters for training in HW07. Shuffling changes the order of the examples, never which examples belong to the training or validation set. The last batch may be shorter than B: 940 validation images in batches of 128 give seven full batches and one batch of 44.

Flattening. A feed-forward network's first layer expects each example to be a single vector. We unroll each 28 × 28 image, row after row, into 784 numbers. The important detail is to flatten every dimension except the batch dimension:

Worked Example¶

batch = torch.arange(8.).reshape(2, 1, 2, 2)   # two tiny 2x2 "images"
batch.flatten().shape     # torch.Size([8])    <- both images merged into one vector!
batch.flatten(1).shape    # torch.Size([2, 4]) <- one row of 4 pixels per image
batch.flatten(1)
# tensor([[0., 1., 2., 3.],
#         [4., 5., 6., 7.]])

flatten(1) means "flatten starting at dimension 1", leaving dimension 0 (the batch) alone. Row i of the result still belongs to label i.

Notice what flattening throws away: the FFN no longer knows that pixel 0 and pixel 28 are vertical neighbors. It must learn any spatial relationships from data. HW07's CNN will keep that structure.

Your Task¶

The split and the make_loader helper are supplied.

  1. Create train_loader (shuffled) and val_loader (not shuffled) with make_loader. Use the default batch size of 128.
  2. Take the first validation batch, xb, yb = next(iter(val_loader)). (This line is supplied.)
  3. Flatten xb into flat_batch with shape (B, 784).

Python Help¶

loader = make_loader(some_dataset, shuffle=True)
for xb, yb in loader:     # one batch per loop iteration
    ...
xb, yb = next(iter(loader))   # just the first batch

Useful Links¶

  • PyTorch Datasets & DataLoaders tutorial
  • torch.flatten

Task 01-2: Code (0.5 pts.)¶

In [ ]:
# Supplied: keep the same split across HW06 and HW07. Test data are opened only in HW07.
order = torch.randperm(len(images), generator=torch.Generator().manual_seed(42))
val_idx, train_idx = order[:940], order[940:]
train_data = TensorDataset(images[train_idx], labels[train_idx])
val_data = TensorDataset(images[val_idx], labels[val_idx])

def make_loader(data, shuffle=False, seed=42, batch_size=128):
    return DataLoader(data, batch_size=batch_size, shuffle=shuffle,
                      generator=torch.Generator().manual_seed(seed), num_workers=0)

# TODO: Build a shuffled training loader and an unshuffled validation loader.
train_loader = None
val_loader = None

xb, yb = next(iter(val_loader))

# TODO: Flatten every dimension except the batch dimension.
flat_batch = None

print('Train / validation:', len(train_data), '/', len(val_data))
print('Batches per pass: train', len(train_loader), '| validation', len(val_loader))
print('Last validation batch size:', len(val_data) % 128)
print('Image batch:', tuple(xb.shape), '| label batch:', tuple(yb.shape))
print('Flattened batch:', tuple(flat_batch.shape))
print('Row 0 is still image 0:', torch.equal(flat_batch[0], xb[0].reshape(-1)))

Task 01-2: Expected Output (0 pts.)¶

Train / validation: 8460 / 940
Batches per pass: train 67 | validation 8
Last validation batch size: 44
Image batch: (128, 1, 28, 28) | label batch: (128,)
Flattened batch: (128, 784)
Row 0 is still image 0: True

Figure 1. The network we will build this week. The batch dimension B stays separate through every layer; only the feature dimension changes. Numbers in parentheses are each layer's trainable parameters.

Figure 1. The network we will build this week. The batch dimension B stays separate through every layer; only the feature dimension changes. Numbers in parentheses are each layer's trainable parameters.

Task 02: Build an Affine Layer¶

Task 02-1: Description (0 pts.)¶

The Same Score, 256 Times at Once¶

In HW02 and HW03, one prediction was a weighted sum of the features plus a bias: $s = x\cdot w + b$. A linear layer (more precisely an affine layer, because of the bias) computes many of these weighted sums at once. Each output unit $j$ has its own weight vector $w_j$ and bias $b_j$:

$$h_j = x \cdot w_j + b_j .$$

Stack the weight vectors as the rows of a matrix $W$, and stack the examples as the rows of $X$. Then every output for every example comes from one matrix product:

$$Y = XW^{T} + b .$$

Symbol Meaning Shape
$X$ A batch of inputs, one example per row (B, D)
$W$ One row of weights per output unit (H, D)
$b$ One bias per output unit (H,)
$Y$ One row of outputs per example (B, H)

Here $D$ is the number of input features and $H$ the number of output units. We store $W$ as (H, D), "outputs by inputs", because that is also how PyTorch's nn.Linear stores it. That convention is why we multiply by $W^T$: (B, D) @ (D, H) gives (B, H).

The bias has shape (H,), but $XW^T$ has shape (B, H). Broadcasting adds the same bias row to every example, just as NumPy did in HW02.

Worked Example¶

Take $D=2$ inputs and $H=3$ output units:

$$W=\begin{bmatrix}1&2\\-1&1\\0&3\end{bmatrix},\qquad b=\begin{bmatrix}0&1&-1\end{bmatrix},\qquad x=\begin{bmatrix}1&2\end{bmatrix}.$$

Each output is the dot product of $x$ with one row of $W$, plus that unit's bias:

  • Unit 0: $(1)(1) + (2)(2) + 0 = 5$
  • Unit 1: $(1)(-1) + (2)(1) + 1 = 2$
  • Unit 2: $(1)(0) + (2)(3) - 1 = 5$

So $y = [5, 2, 5]$. A second example $x=[3, 4]$ gives $[11, 2, 11]$. With both examples stacked as rows of $X$, one call to X @ W.T + b produces both rows.

Why Store the Input?¶

Our forward method also saves its input as self.X. Nothing uses it this week. In HW07, the gradient of the loss with respect to $W$ depends on the input that produced the output, so each layer must remember what it saw. Store it now and HW07's backward pass will slot into the same class.

Your Task¶

Complete LinearLayer.forward(X):

  • Input: X with shape (B, D).
  • Return: X @ W.T + b with shape (B, H).
  • Store the input in self.X before returning.

Use ordinary tensor operations: no nn.Linear and no automatic differentiation. The constructor is supplied for now; Task 02-2 explains its weight scale.

Python Help¶

A = torch.tensor([[1., 2.], [3., 4.]])   # (2, 2)
B = torch.tensor([[1., 0.], [0., 1.], [1., 1.]])   # (3, 2)
A @ B.T                                  # (2, 3): row i of A dotted with row j of B
A @ B.T + torch.tensor([10., 20., 30.])  # the bias is added to every row

Useful Links¶

  • nn.Linear documentation: note the shape of its weight.

Task 02-1: Code (0.5 pts.)¶

In [ ]:
class LinearLayer:
    def __init__(self, input_dim, output_dim, device='cpu'):
        self.W = torch.randn(output_dim, input_dim, device=device) * math.sqrt(2 / input_dim)
        self.b = torch.zeros(output_dim, device=device)

    def forward(self, X):
        # TODO: Cache the input for HW07, then apply the affine transformation.
        self.X = None
        return None

layer = LinearLayer(2, 3)
layer.W = torch.tensor([[1., 2.], [-1., 1.], [0., 3.]])
layer.b = torch.tensor([0., 1., -1.])
X_toy = torch.tensor([[1., 2.], [3., 4.]])

print(layer.forward(X_toy))
print('Output shape:', tuple(layer.forward(X_toy).shape))

first_layer = LinearLayer(784, 256)
print('First real layer:', tuple(flat_batch.shape), '->', tuple(first_layer.forward(flat_batch).shape))

Task 02-1: Expected Output (0 pts.)¶

tensor([[ 5.,  2.,  5.],
        [11.,  2., 11.]])
Output shape: (2, 3)
First real layer: (128, 784) -> (128, 256)

Task 02-2: Description (0 pts.)¶

Where Should the Weights Start?¶

In HW03 we started the perceptron's weights at zero. That will not work for a network with hidden layers. If every weight in a layer starts at the same value, every unit in that layer computes the same output for every example. In HW07, they would also receive the same update, so they would stay identical forever. A layer of 256 identical units is no better than one unit. Random starting weights break the symmetry.

How large should the random weights be? Each output of a linear layer adds up $D$ products. If the weights are too large, those sums grow layer after layer; if they're too small, they shrink toward zero. Either way, the last layer receives values that are huge or vanishingly small, and in HW07 the training updates will inherit the same problem.

Kaiming He and colleagues showed that for layers followed by ReLU, drawing weights from a normal distribution with variance $2/D$ keeps the typical size of the activations roughly steady from layer to layer:

$$W_{jk} \sim \mathcal{N}\!\left(0,\ \tfrac{2}{D}\right),\qquad b_j = 0 .$$

torch.randn draws standard normal values (variance 1); multiplying by $\sqrt{2/D}$ gives variance $2/D$. That is exactly what the supplied constructor does. The factor of 2 compensates for ReLU, which zeroes roughly half of its inputs.

Worked Example: How Big Does a Sum Get?¶

Suppose all $D=784$ inputs are about $0.5$ and the weights are random with standard deviation $\sigma$. A sum of $D$ independent random terms has a standard deviation about $\sqrt{D}$ times larger than one term:

  • $\sigma = 1$: one output has standard deviation about $\sqrt{784}\times 0.5 = 14$.
  • $\sigma = \sqrt{2/784} \approx 0.05$: about $\sqrt{784}\times 0.05\times 0.5 \approx 0.7$.

Stack three layers with $\sigma=1$ and the effect compounds: each layer multiplies the typical size again, by roughly a factor of 10 for our widths.

Your Task¶

  1. Complete the function he_std(D): return the weight standard deviation that LinearLayer uses for an input width D. (Read it off the supplied constructor in Task 02-1.)
  2. Run the supplied experiment. It pushes the real validation batch through three ReLU(linear) layers of width 256 using three weight scales and prints the standard deviation of the activations after each layer. It also builds a layer whose weights are all zero and checks whether its four units produce different outputs.

Compare the three rows, then answer Task 02-3.

Useful Links¶

  • He et al. (2015), Delving Deep into Rectifiers, Section 2.2
  • torch.randn

Task 02-2: Code (0.25 pts.)¶

In [ ]:
def he_std(D):
    # TODO: Return the weight standard deviation LinearLayer uses for an input width D.
    return None

# Supplied: the weight standard deviation for each of the three starting rules.
def weight_std(rule, D):
    if rule == 'too small (0.01)':
        return 0.01
    if rule == 'too large (1.0)':
        return 1.0
    return he_std(D)

def activation_scales(X, rule, widths=(256, 256, 256), seed=0):
    generator = torch.Generator().manual_seed(seed)
    stds, H = [], X
    for width in widths:
        W = torch.randn(width, H.shape[1], generator=generator) * weight_std(rule, H.shape[1])
        H = (H @ W.T).clamp_min(0)
        stds.append(round(float(H.std()), 3))
    return stds

for rule in ['too small (0.01)', 'He: sqrt(2 / D)', 'too large (1.0)']:
    print(f'{rule:>17}: activation std after layers 1, 2, 3 = {activation_scales(flat_batch, rule)}')

zero_layer = LinearLayer(784, 4)
zero_layer.W = torch.zeros(4, 784)
print('Zero-initialized units produce identical outputs:',
      bool((zero_layer.forward(flat_batch) == zero_layer.forward(flat_batch)[:, :1]).all()))

Task 02-2: Reference Output (0 pts.)¶

 too small (0.01): activation std after layers 1, 2, 3 = [0.061, 0.006, 0.001]
  He: sqrt(2 / D): activation std after layers 1, 2, 3 = [0.308, 0.277, 0.25]
  too large (1.0): activation std after layers 1, 2, 3 = [6.09, 61.951, 633.286]
Zero-initialized units produce identical outputs: True

Task 02-3: Written Response (0.125 pts.)¶

  1. Using your printed standard deviations, describe what happens to the activations as they pass through three layers with each weight scale. Why might the "too large" row cause trouble for the final scores, and why might the "too small" row?
    • [ANSWER]
  2. The HW03 perceptron started at zero weights and learned perfectly well. Why does a zero start fail for a layer with 256 hidden units?
    • [ANSWER]

Task 03: Add a Nonlinearity¶

Task 03-1: Description (0 pts.)¶

Why Can't We Just Stack Linear Layers?¶

It's tempting to think that stacking more linear layers makes a more powerful model. It doesn't. Two linear layers in a row are still just one linear layer:

$$\big(XW_1^T + b_1\big)W_2^T + b_2 \;=\; X\underbrace{(W_2W_1)^T}_{\text{one matrix}} + \underbrace{\big(b_1W_2^T + b_2\big)}_{\text{one bias}} .$$

No matter how many we stack, the result can only draw the same kind of straight (hyperplane) boundary as HW03's logistic regression. HW04 handled curved boundaries by swapping in an RBF kernel. Neural networks take a different route: they insert a nonlinear activation function between the linear layers, so the composition can no longer be multiplied out into one matrix.

ReLU¶

The rectified linear unit keeps positive values and replaces negative values with zero, independently for each entry:

$$\operatorname{ReLU}(z)=\max(0, z).$$

It is cheap to compute, and (as you'll see in HW07) its derivative is simply 1 for positive inputs and 0 for negative ones. Each hidden unit becomes a little "detector" that is either silent or responds in proportion to how strongly its pattern is present.

Worked Example¶

z = torch.tensor([[-2.0, 0.0, 3.0],
                  [ 1.5, -0.1, -7.0]])
z.clamp_min(0)
# tensor([[0.0, 0.0, 3.0],
#         [1.5, 0.0, 0.0]])

The shape never changes: ReLU acts on each entry separately. Like LinearLayer, our ReLU stores its input. In HW07, the backward pass needs to know which entries were positive.

Your Task¶

Complete ReLU.forward(X):

  • Store the input in self.X.
  • Return a tensor of the same shape, with negative entries replaced by zero.

The supplied code then checks the algebra above. It builds two small random linear layers, merges them into one layer, and compares the outputs with and without a ReLU in between.

Python Help¶

torch.tensor([-1., 2.]).clamp_min(0)          # tensor([0., 2.])
torch.maximum(torch.tensor([-1., 2.]), torch.tensor(0.))   # same result
torch.allclose(a, b, atol=1e-6)               # True when every entry is (nearly) equal

Task 03-1: Code (0.25 pts.)¶

In [ ]:
class ReLU:
    def forward(self, X):
        # TODO: Cache the input for HW07, then keep only its positive values.
        self.X = None
        return None

print(ReLU().forward(torch.tensor([[-2.0, 0.0, 3.0], [1.5, -0.1, -7.0]])))

# Supplied: two stacked linear layers versus one merged layer.
torch.manual_seed(0)
first, second = LinearLayer(4, 5), LinearLayer(5, 3)
first.b, second.b = torch.randn(5), torch.randn(3)
merged = LinearLayer(4, 3)
merged.W = second.W @ first.W
merged.b = first.b @ second.W.T + second.b
X_small = torch.randn(6, 4)

stacked = second.forward(first.forward(X_small))
with_relu = second.forward(ReLU().forward(first.forward(X_small)))
print('Two linear layers equal one merged layer:', torch.allclose(stacked, merged.forward(X_small), atol=1e-5))
print('With ReLU in between, still equal:      ', torch.allclose(with_relu, merged.forward(X_small), atol=1e-5))

Task 03-1: Expected Output (0 pts.)¶

tensor([[0.0000, 0.0000, 3.0000],
        [1.5000, 0.0000, 0.0000]])
Two linear layers equal one merged layer: True
With ReLU in between, still equal:       False

Task 03-2: Written Response (0.125 pts.)¶

Our network will be Linear → ReLU → Linear → ReLU → Linear. Why is there no ReLU after the final layer? (Hint: think about what a negative final score should be allowed to mean for one of the 47 characters.) In one sentence, compare the role of ReLU here with the role of the RBF kernel in HW04.

  • [ANSWER]

Story Progression¶

The intern has been watching over your shoulder. "So it's just multiplying and adding, then throwing away the negatives? I could do that with a calculator." Caulfield points out that a single image needs about 240,000 multiplications. The intern goes to find a bigger calculator.

The network can now produce 47 numbers for each image. Before we assemble it, we need to decide what those numbers mean and how to measure whether they're any good.

Evidence 2: The intern's attempt to read the letters by hand.
Evidence 2: The intern's attempt to read the letters by hand.

Task 04: Turn Logits into a Stable Loss¶

Task 04-1: Description (0 pts.)¶

From Scores to Probabilities¶

The final layer produces one unrestricted real number per class, called a logit. Larger means "more likely this class", but logits are not probabilities: they can be negative, and they don't add up to one.

In HW03, logistic regression turned one score $s$ into a probability with the sigmoid, $\sigma(s)=\frac{1}{1+e^{-s}}$. With 47 classes we need 47 probabilities that sum to one. The softmax function does this:

$$p_j=\frac{e^{z_j}}{\sum_{k} e^{z_k}} .$$

Exponentiating makes every value positive; dividing by the total makes them sum to one. A logit that is 1 larger than another gets $e \approx 2.7$ times the probability.

Softmax generalizes the sigmoid. With two classes and logits $[s, 0]$:

$$p_0=\frac{e^{s}}{e^{s}+e^{0}}=\frac{1}{1+e^{-s}}=\sigma(s).$$

So HW03's logistic regression is exactly a one-layer network with two output classes.

Worked Example¶

For logits $z=[2, 1, 0]$:

Class 0 Class 1 Class 2 Total
$z_j$ 2 1 0
$e^{z_j}$ 7.389 2.718 1.000 11.107
$p_j$ 0.665 0.245 0.090 1.000

The Overflow Problem¶

A 32-bit float can't represent numbers larger than about $3.4\times10^{38}$, and $e^{89}$ already exceeds that. A logit of 1000 is unusual but entirely possible early in training:

torch.exp(torch.tensor([1000., 1001., 999.]))   # tensor([inf, inf, inf])
# inf / inf = nan, and one nan contaminates every later calculation.

The fix uses a property of softmax: adding the same constant to every logit doesn't change the probabilities, because $e^{c}$ cancels from the numerator and the denominator. If we subtract the largest logit first, the largest exponent becomes $e^0 = 1$ and nothing can overflow:

$$[1000,1001,999] - 1001 = [-1, 0, -2]\ \Rightarrow\ p = [0.245,\ 0.665,\ 0.090].$$

Working in Log Space¶

The loss in Task 04-2 needs $\log p_j$, not $p_j$. Taking the log of the softmax gives

$$\log p_j = z_j - \log\sum_k e^{z_k} .$$

The second term is called log-sum-exp. Computing $\log p$ this way, with the max subtracted first, never forms a tiny probability only to take its log. For $[2,1,0]$: $\log(11.107)=2.408$, so $\log p = [-0.408, -1.408, -2.408]$.

Your Task¶

Complete log_softmax(logits) for a (B, C) tensor of logits, one row per example:

  1. Subtract each row's maximum logit from that row (use keepdim=True so the shapes broadcast).
  2. Compute each row's log-sum-exp of the shifted logits: exponentiate, sum across classes, take the log. Do not call torch.logsumexp or torch.log_softmax; the point is to see how the stable version works.
  3. Return the shifted logits minus that row's log-sum-exp.

The supplied code compares your result with PyTorch's F.log_softmax, including on logits large enough to overflow a naive softmax.

Python Help¶

Z = torch.tensor([[2., 1., 0.], [5., 7., 6.]])
Z.max(dim=1, keepdim=True).values   # tensor([[2.], [7.]]): shape (2, 1), one max per row
Z.exp().sum(dim=1, keepdim=True)    # one total per row, shape (2, 1)

Useful Links¶

  • torch.Tensor.max · F.log_softmax

Task 04-1: Code (0.5 pts.)¶

In [ ]:
def log_softmax(logits):
    # TODO: Subtract each row's maximum logit.
    shifted = None
    # TODO: Subtract each row's log-sum-exp from the shifted logits.
    return None

small = torch.tensor([[2., 1., 0.]])
huge = torch.tensor([[1000., 1001., 999.]])
print('Naive softmax of huge logits:', torch.exp(huge) / torch.exp(huge).sum())
print('Log probabilities of [2, 1, 0]:', log_softmax(small).numpy().round(3))
print('Probabilities of [2, 1, 0]:', log_softmax(small).exp().numpy().round(3))
print('Probabilities of [1000, 1001, 999]:', log_softmax(huge).exp().numpy().round(3))

random_logits = torch.randn(5, 47) * 30
print('Each row sums to one:', torch.allclose(log_softmax(random_logits).exp().sum(dim=1), torch.ones(5)))
print('Matches F.log_softmax:', torch.allclose(log_softmax(random_logits), F.log_softmax(random_logits, dim=1), atol=1e-5))

two_class = log_softmax(torch.tensor([[1.5, 0.]])).exp()[0, 0]
print(f'Two-class softmax {float(two_class):.4f} vs sigmoid {float(torch.sigmoid(torch.tensor(1.5))):.4f}')

Task 04-1: Expected Output (0 pts.)¶

Naive softmax of huge logits: tensor([[nan, nan, nan]])
Log probabilities of [2, 1, 0]: [[-0.408 -1.408 -2.408]]
Probabilities of [2, 1, 0]: [[0.665 0.245 0.09 ]]
Probabilities of [1000, 1001, 999]: [[0.245 0.665 0.09 ]]
Each row sums to one: True
Matches F.log_softmax: True
Two-class softmax 0.8176 vs sigmoid 0.8176

Figure 2. Softmax turns logits into probabilities. Shifting every logit by the same amount (middle) leaves the probabilities unchanged; multiplying them by a constant (right) makes the prediction more or less confident.

Figure 2. Softmax turns logits into probabilities. Shifting every logit by the same amount (middle) leaves the probabilities unchanged; multiplying them by a constant (right) makes the prediction more or less confident.

Task 04-2: Description (0 pts.)¶

How Wrong Was That Prediction?¶

In HW03 we measured a probability's quality with the log loss: $-\log p$ of the outcome that actually happened. Cross-entropy is the same idea for many classes. For one example whose true class is $y$,

$$\ell = -\log p_{y} = -\big(z_y - \log\textstyle\sum_k e^{z_k}\big).$$

Only the probability assigned to the correct class matters. If the model gives the true class probability 1, the loss is 0. As that probability falls toward 0, the loss grows without bound: a confident wrong answer is punished heavily. For a batch, we report the mean loss over the $B$ examples:

$$L = \frac{1}{B}\sum_{i=1}^{B} -\log p_{i,\,y_i} .$$

With two classes, this is exactly HW03's log loss.

Worked Example¶

Using the log probabilities from Task 04-1 for logits $[2,1,0]$:

  • If the true class is 0: $\ell = -(-0.408) = 0.408$. The model's favorite is correct.
  • If the true class is 2: $\ell = -(-2.408) = 2.408$. The correct class got only 9% probability.

For a batch containing both examples, $L = (0.408 + 2.408)/2 = 1.408$.

Selecting One Entry per Row¶

log_softmax(logits) has shape (B, C). We need entry [i, y_i] from each row i. Paired integer indexing does that in one step: index with a tensor of row numbers and a tensor of column numbers of the same length.

log_p = torch.tensor([[-0.4, -1.4, -2.4],
                      [-0.4, -1.4, -2.4]])
targets = torch.tensor([0, 2])
log_p[torch.arange(2), targets]   # tensor([-0.4, -2.4]): entries [0, 0] and [1, 2]

Note that log_p[:, targets] is not the same thing: it returns a (2, 2) table containing columns 0 and 2 for every row.

A Word on PyTorch's Version¶

F.cross_entropy(logits, targets) does all of this internally: it takes raw logits, applies the stable log-softmax, and selects the correct classes. A very common bug is to apply softmax first and pass probabilities to F.cross_entropy. That applies softmax twice and quietly computes the wrong loss. Always give it logits.

Your Task¶

Complete cross_entropy(logits, targets):

  • Input: logits with shape (B, C) and targets with shape (B,) containing integer class IDs.
  • Return: a scalar tensor: the mean negative log probability of each example's correct class.

Use your log_softmax. The supplied code compares your loss with F.cross_entropy on the worked example and on the huge logits from Task 04-1.

Useful Links¶

  • F.cross_entropy documentation
  • PyTorch indexing with tensors · torch.arange

Task 04-2: Code (0.5 pts.)¶

In [ ]:
def cross_entropy(logits, targets):
    # TODO: Select each example's correct-class log probability and average the negatives.
    log_p = None
    return None

pair = torch.tensor([[2., 1., 0.], [2., 1., 0.]])
pair_targets = torch.tensor([0, 2])
print(f'Worked example loss: ours {float(cross_entropy(pair, pair_targets)):.4f} | '
      f'PyTorch {float(F.cross_entropy(pair, pair_targets)):.4f}')

huge_pair = torch.tensor([[1000., 1001., 999.], [-1000., -1001., -999.]])
huge_targets = torch.tensor([1, 2])
print(f'Huge logits loss:    ours {float(cross_entropy(huge_pair, huge_targets)):.4f} | '
      f'PyTorch {float(F.cross_entropy(huge_pair, huge_targets)):.4f}')

probabilities = F.softmax(pair, dim=1)
print(f'Common bug, softmax passed to F.cross_entropy: {float(F.cross_entropy(probabilities, pair_targets)):.4f}')

uniform = torch.zeros(4, 47)   # every class gets the same logit
print(f'Loss when all 47 classes are equally likely: {float(cross_entropy(uniform, torch.tensor([0, 5, 20, 46]))):.4f}')

Task 04-2: Expected Output (0 pts.)¶

Worked example loss: ours 1.4076 | PyTorch 1.4076
Huge logits loss:    ours 0.4076 | PyTorch 0.4076
Common bug, softmax passed to F.cross_entropy: 1.0848
Loss when all 47 classes are equally likely: 3.8501

Task 04-3: Written Response (0.125 pts.)¶

The last printed line is the loss of a model that assigns every class the same probability. Show where the number 3.8501 comes from. In HW07, a training loss that stays near this value after many updates tells us something specific about the model. What?

  • [ANSWER]

Task 05: Assemble the Network¶

Task 05-1: Description (0 pts.)¶

Putting the Pieces in Order¶

Our feed-forward network (also called a multilayer perceptron) is

$$\texttt{784}\xrightarrow{\text{Linear}}\texttt{256}\xrightarrow{\text{ReLU}}\texttt{256}\xrightarrow{\text{Linear}}\texttt{128}\xrightarrow{\text{ReLU}}\texttt{128}\xrightarrow{\text{Linear}}\texttt{47 logits}.$$

The two middle widths (256 and 128) are hyperparameters: design choices, like k in HW01 or C in HW04. The first and last widths are not choices: 784 is the number of pixels and 47 the number of classes. As Task 03-2 discussed, the last layer has no ReLU.

The forward pass is just function composition: flatten the batch, then feed each layer's output into the next. Every example's row stays independent of the other rows, so a batch of 4 and a batch of 128 use exactly the same code.

Worked Example: Counting Parameters¶

Each linear layer has one weight per (output, input) pair plus one bias per output: $H\times D + H$. ReLU has nothing to learn.

Layer Weights Biases Parameters
Linear 784 → 256 784 × 256 = 200,704 256 200,960
Linear 256 → 128 256 × 128 = 32,768 128 32,896
Linear 128 → 47 128 × 47 = 6,016 47 6,063
Total 239,919

Almost 84% of the parameters are in the first layer: one weight for every pixel for every one of the 256 units. Training will adjust all 239,919 numbers. For comparison, HW04's SVM learned 7 (six feature weights and a bias, for the linear kernel).

Your Task¶

Complete ScratchFFN.forward(X):

  • Input: an image batch with shape (B, 1, 28, 28).
  • Return: logits with shape (B, 47).
  1. Flatten everything except the batch dimension.
  2. Pass the result through each layer in self.layers, in order, calling each layer's forward.

The supplied code traces the shape after every layer, counts the parameters, and evaluates the loss of the untrained network on one batch.

Python Help¶

toy_layers = [LinearLayer(2, 3), ReLU(), LinearLayer(3, 1)]
H = torch.ones(5, 2)            # 5 examples, 2 features each
for layer in toy_layers:
    H = layer.forward(H)        # each layer's output becomes the next layer's input
print(H.shape)                  # torch.Size([5, 1])

Task 05-1: Code (0.5 pts.)¶

In [ ]:
class ScratchFFN:
    def __init__(self, device='cpu'):
        self.layers = [LinearLayer(784, 256, device), ReLU(),
                       LinearLayer(256, 128, device), ReLU(),
                       LinearLayer(128, 47, device)]

    def forward(self, X):
        # TODO: Flatten each image, then apply every layer in order.
        return None

torch.manual_seed(42)
model = ScratchFFN()
xb4, yb4 = next(iter(make_loader(val_data, batch_size=4)))

activations = xb4.flatten(1)
shapes = [tuple(activations.shape)]
for layer in model.layers:
    activations = layer.forward(activations)
    shapes.append((type(layer).__name__, tuple(activations.shape)))
print('Input:', shapes[0])
for name, shape in shapes[1:]:
    print(f'  after {name:<11} {shape}')

parameter_count = sum(layer.W.numel() + layer.b.numel() for layer in model.layers if hasattr(layer, 'W'))
print('Trainable parameters:', parameter_count)
print('forward() output shape:', tuple(model.forward(xb4).shape))
print(f'Untrained loss on the first validation batch: {float(cross_entropy(model.forward(xb), yb)):.4f}')

Task 05-1: Expected Output (0 pts.)¶

The architecture lines are exact. The loss depends on the random starting weights; yours should match when you run the cells in order.

Input: (4, 784)
  after LinearLayer (4, 256)
  after ReLU        (4, 256)
  after LinearLayer (4, 128)
  after ReLU        (4, 128)
  after LinearLayer (4, 47)
Trainable parameters: 239919
forward() output shape: (4, 47)
Untrained loss on the first validation batch: 3.9775

Story Progression¶

The network is assembled: 239,919 numbers, every one of them chosen at random. Caulfield wants to know whether it works. The honest answer is that we don't know yet whether it computes what we think it computes, never mind whether it reads handwriting.

Those are two different questions, and the next task separates them. First we'll check our scratch implementation against PyTorch's. Then we'll see what an untrained network actually predicts.

Evidence 3: The evidence board for our three-layer network. Every one of those strings is a weight.
Evidence 3: The evidence board for our three-layer network. Every one of those strings is a weight.

Task 06: Check Against PyTorch and Inspect the Predictions¶

Task 06-1: Description (0 pts.)¶

The Same Network, Written the Library Way¶

In practice, nobody writes LinearLayer by hand. PyTorch provides the same building blocks:

Our scratch version PyTorch version Notes
X.flatten(1) nn.Flatten() Flattens from dimension 1 by default, keeping the batch
LinearLayer(D, H) nn.Linear(D, H) .weight has shape (H, D) and .bias shape (H,), exactly like our W and b
ReLU() nn.ReLU()
list of layers + loop nn.Sequential(...) Calls each module in order
cross_entropy F.cross_entropy Takes raw logits

A PyTorch model is a class that inherits from nn.Module, creates its layers in __init__, and defines forward. You call the model like a function, reference(xb), and PyTorch calls forward for you.

Same Architecture Is Not the Same Function¶

nn.Linear draws its own random starting weights, using a different rule than ours. So two networks with identical architecture will produce different logits until they also have identical parameters. To test our implementation, we copy every weight and bias from the scratch model into the PyTorch model, then feed both the same images. If the logits agree (to within floating-point rounding), our forward pass computes the same function as PyTorch's.

When copying, wrap the code in torch.no_grad(). PyTorch parameters normally track every operation for HW07's automatic differentiation; an in-place copy into them is only allowed when that tracking is turned off.

Your Task¶

  1. Complete TorchFFN.__init__: build self.layers with nn.Sequential, matching the scratch architecture exactly: Flatten, three Linear layers, and a ReLU after each of the first two.
  2. Complete copy_to_torch(scratch, reference). The supplied code pairs the scratch linear layers with the PyTorch linear layers in order. For each pair, copy W into .weight and b into .bias with .copy_().

The supplied code prints the largest difference between the two models' logits.

Python Help¶

net = nn.Sequential(nn.Linear(3, 2), nn.ReLU())
net[0].weight.shape                 # torch.Size([2, 3])
with torch.no_grad():
    net[0].weight.copy_(torch.ones(2, 3))   # overwrite in place

Useful Links¶

  • Build the neural network (PyTorch tutorial)
  • nn.Sequential · nn.Linear · torch.no_grad

Task 06-1: Code (0.5 pts.)¶

In [ ]:
class TorchFFN(nn.Module):
    def __init__(self):
        super().__init__()
        # TODO: Match the scratch architecture exactly.
        self.layers = None

    def forward(self, X):
        return self.layers(X)

def copy_to_torch(scratch, reference):
    scratch_linear = [layer for layer in scratch.layers if isinstance(layer, LinearLayer)]
    torch_linear = [layer for layer in reference.layers if isinstance(layer, nn.Linear)]
    with torch.no_grad():
        for ours, theirs in zip(scratch_linear, torch_linear):
            # TODO: Copy this layer's weights and bias into the PyTorch layer.
            pass

reference = TorchFFN()
print(reference)

with torch.no_grad():
    before = (model.forward(xb) - reference(xb)).abs().max()
    copy_to_torch(model, reference)
    after = (model.forward(xb) - reference(xb)).abs().max()
print(f'Largest logit difference before copying weights: {float(before):.2f}')
print('Largest logit difference after copying is below 1e-5:', bool(after < 1e-5))

Task 06-1: Expected Output (0 pts.)¶

The difference before copying depends on PyTorch's random initialization and will vary; it should be well above zero. The other lines are exact.

TorchFFN(
  (layers): Sequential(
    (0): Flatten(start_dim=1, end_dim=-1)
    (1): Linear(in_features=784, out_features=256, bias=True)
    (2): ReLU()
    (3): Linear(in_features=256, out_features=128, bias=True)
    (4): ReLU()
    (5): Linear(in_features=128, out_features=47, bias=True)
  )
)
Largest logit difference before copying weights: 2.04
Largest logit difference after copying is below 1e-5: True

Task 06-2: Description (0 pts.)¶

What Does an Untrained Network Predict?¶

Our two implementations agree. That tells us the calculation is right. It tells us nothing about whether the predictions are any good, because neither model has learned anything.

To measure that, we evaluate the model on the whole validation set. An evaluation function has to be careful about one detail: the last batch is shorter. Averaging eight per-batch mean losses would give the 44-example batch the same weight as each 128-example batch. Instead, add up the total loss and the number correct over every batch, and divide by the total number of examples once at the end:

$$\text{accuracy}=\frac{\sum_{\text{batches}}\#\text{correct}}{\sum_{\text{batches}}B},\qquad \text{loss}=\frac{\sum_{\text{batches}}\sum_{i}\ell_i}{\sum_{\text{batches}}B}.$$

F.cross_entropy(..., reduction='sum') returns the batch total instead of the mean. The predicted class is the one with the largest logit, logits.argmax(dim=1). We don't need softmax for this: softmax never changes which entry is largest.

Worked Example¶

Two batches: 3 of 4 correct with total loss 2.0, then 0 of 1 correct with loss 4.0.

  • Correct: accuracy $= (3+0)/(4+1) = 0.60$; loss $= (2.0+4.0)/5 = 1.2$.
  • Averaging the batch accuracies instead: $(0.75 + 0)/2 = 0.375$. That gives the one-example batch the same weight as the four-example batch.

What Should We Expect?¶

Guessing uniformly among 47 classes is right about $1/47 \approx 2.1\%$ of the time, with loss $\log 47 \approx 3.85$ (Task 04-3). A particular random network won't hit those numbers exactly. It might favor a few classes for most images, or be slightly more confident than uniform guessing. Its predicted "confidence" (the largest softmax probability) should still be low, because nothing in its weights has been fitted to handwriting.

Your Task¶

Complete the loop inside evaluate(model, loader) so it accumulates total_loss, correct, and count across batches. It works for both the scratch model and the PyTorch model.

Python Help¶

logits = torch.tensor([[0.1, 2.0], [3.0, -1.0], [0.0, 0.5]])
targets = torch.tensor([1, 1, 1])
logits.argmax(dim=1)                                  # tensor([1, 0, 1])
int((logits.argmax(dim=1) == targets).sum())          # 2 correct
float(F.cross_entropy(logits, targets, reduction='sum'))   # total, not mean

Task 06-2: Code (0.25 pts.)¶

In [ ]:
@torch.no_grad()
def evaluate(model, loader):
    total_loss, correct, count = 0., 0, 0
    for xb_eval, yb_eval in loader:
        logits = model(xb_eval) if isinstance(model, nn.Module) else model.forward(xb_eval)
        # TODO: Accumulate the summed loss, number correct, and number of examples.
        total_loss += 0
        correct += 0
        count += 0
    return {'loss': round(total_loss / count, 4), 'accuracy': round(correct / count, 4)}

print('Untrained scratch FFN, validation:', evaluate(model, val_loader))
print('Untrained PyTorch FFN, validation:', evaluate(reference, val_loader))
print(f'Uniform guessing: loss {math.log(47):.4f}, accuracy {1 / 47:.4f}')

with torch.no_grad():
    probabilities = log_softmax(model.forward(xb)).exp()
    confidence, predicted = probabilities.max(dim=1)
print(f'Average confidence in its own prediction: {float(confidence.mean()):.3f}')
print('Distinct classes predicted for 128 images:', len(predicted.unique()))

# Supplied: show the first 12 validation images with the untrained model's guesses.
show_characters(xb[:12, 0].numpy(),
                [f'true {classes[int(t)]}\nguess {classes[int(p)]} ({float(c):.2f})'
                 for t, p, c in zip(yb[:12], predicted[:12], confidence[:12])],
                columns=6, suptitle='The untrained network\'s guesses (and confidence)')

Task 06-2: Reference Output (0 pts.)¶

Your numbers should match when you run the cells in order. Small floating-point differences across machines are normal.

Untrained scratch FFN, validation: {'loss': 3.9704, 'accuracy': 0.0234}
Untrained PyTorch FFN, validation: {'loss': 3.9704, 'accuracy': 0.0234}
Uniform guessing: loss 3.8501, accuracy 0.0213
Average confidence in its own prediction: 0.056
Distinct classes predicted for 128 images: 17

The untrained network's guesses

The untrained network's guesses and confidence on the first 12 validation images.

Task 07: Report the Results to Caulfield¶

Task 07-1: Written Response (0.375 pts.)¶

  1. Our scratch network and PyTorch's network agreed to within $10^{-5}$, yet both get only a few percent of the validation set right. Explain why both statements can be true. What did the comparison verify, and what did it not verify?
    • [ANSWER]
  2. Report the untrained validation loss and accuracy. Compare them with uniform guessing ($\log 47$ and $1/47$). Why isn't a random network exactly at chance?
    • [ANSWER]
  3. Before computing the loss, we keep the network's output as logits rather than converting it to probabilities. Give two reasons, using what you saw in Task 04.
    • [ANSWER]

Story Progression¶

Your report goes to Caulfield: the network computes exactly what it's supposed to, and it reads handwriting about as well as a person picking characters out of a hat. The intern feels much better about his own accuracy.

Caulfield isn't discouraged. A calculation we can trust is the foundation; what's missing is learning. Every one of those 239,919 weights needs to be nudged, a little at a time, in the direction that lowers the loss you just implemented. That requires knowing how the loss changes when each weight changes.

In HW07, we'll implement the backward pass, train the network on the archive, compare it with PyTorch's trained version and a convolutional network, and finally point it at the recovered letters.

Evidence 4: Caulfield's evidence board: the recovered cut-out pages, the letters we still can't read, and the untrained network's results.
Evidence 4: Caulfield's evidence board: the recovered cut-out pages, the letters we still can't read, and the untrained network's results.

Task 08: Export the Completed Notebook (0 pts.)¶

  1. Complete your code and written responses, and run the cells above in order. Compare your results with the expected and reference outputs.
  2. Save your notebook, including its outputs. Set GENERATE = True below when you are ready to export.
  3. In Colab, the cell captures the current notebook and its outputs. Locally, it reads the saved notebook at NOTEBOOK_PATH; adjust that path if you saved your copy elsewhere.
  4. Download homework06.html and open it in a browser. Check that your name, NETID, code, written answers, and outputs appear.

Submit only homework06.html on Canvas. The exporter does not rerun your code.

Task 08-1: Code (0 pts.)¶

In [ ]:
GENERATE = False
NOTEBOOK_PATH = DATA_ROOT / 'homeworks' / 'homework06' / 'homework06.ipynb'

if GENERATE:
    import json
    import nbformat
    from nbconvert import HTMLExporter
    from pathlib import Path

    try:
        from google.colab import _message, files
    except ImportError:
        current = nbformat.read(NOTEBOOK_PATH, as_version=4)
        html_path = NOTEBOOK_PATH.with_name('homework06.html')
        in_colab = False
    else:
        captured = _message.blocking_request('get_ipynb', timeout_sec=10)
        current = nbformat.reads(json.dumps(captured['ipynb']), as_version=4)
        html_path = Path('/content/homework06.html')
        in_colab = True

    exporter = HTMLExporter()
    exporter.embed_images = True
    report_html, _ = exporter.from_notebook_node(current)
    html_path.write_text(report_html, encoding='utf-8')
    print(f'Exported {html_path.name}. Open it in a browser before submitting.')

    if in_colab:
        files.download(str(html_path))
    else:
        print(f'Saved to: {html_path}')
else:
    print('Export disabled. Set GENERATE = True when ready.')

Task 08-1: Expected Output (0 pts.)¶

Export disabled. Set GENERATE = True when ready.