Module 3 Exercises — PyTorch: Tensors, Autograd, Training, and Deployment to the Edge

Back to the Course 2 syllabus. Read first: Module 3 lessons (the UvA notebook tutorial Introduction to PyTorch, PyTorch’s Learn the Basics, and the ONNX Runtime Python guide remain available as optional deep-dives).

Work in the labs repo’s python/ tree — scripts in python/src/ex-3-N.py, notebooks in python/notebooks/ex-3-N.ipynb, tests in python/tests/test_ex_3_N.py — with the root uv project’s ML group: uv sync --group ml, then uv run --group ml python python/src/ex-3-1.py or uv run --group ml jupyter lab. Record everything in m3/notes.md. Everything runs on the Mac (MPS or CPU); the RTX 4090 box, the Jetson (onnxruntime, trtexec), and the Pi 5 are optional rungs named where they apply. Predicted cells are filled in before running; observed cells at the machine — and no accuracy, timing, or size number on this page is a target, they are all yours to measure. Every exercise ends with a saved reference artifact (.npz + tolerance) that the C and Rust modules, and Course 3 Module 8, are verified against.

Exercises

Exercise 3.1 — Tensors, ndarrays, and devices. Write python/src/ex-3-1.py as a probe. (a) Interop: build a float64 NumPy array, convert with torch.from_numpy, mutate one element on each side, and confirm sharing; repeat via torch.tensor(np_arr) and via .to("mps") and show where sharing stops. Feed the float64 tensor to an nn.Linear and record the exact error, then the boundary fix. (b) Layout: take a Module 1 capture (np.frombuffer on a Course 3 captures/ file, or a synthetic 16 000-sample mono signal) and produce the (N, C, L) tensor a Conv1d accepts and the (N, C, H, W) tensor a Conv2d accepts from its log-mel; state each unsqueeze/permute and check .is_contiguous() and .stride() against your Module 1 predictions. (c) Devices: run a small matmul, an rfft, a Conv1d, and a float64 op on "cpu" and on the accelerator torch.accelerator reports; record which fall back, warn, or raise.

Probe Predicted (CPU) Predicted (MPS / CUDA) Observed
from_numpy shares memory after .to(device)?
float64 matmul
torch.fft.rfft on a (1, 1, 16000) tensor
Conv1d with padding="same"
.numpy() on a device tensor

Deliverable: the script, the table, and the version block (torch.__version__, torch.accelerator.current_accelerator(), numpy.__version__) in notes.md.

Exercise 3.2 — Autograd by hand, then by backward. For \(y = \operatorname{mean}\big((x + 2)^2 + 3\big)\) with \(x \in \mathbb{R}^3\), derive \(\partial y / \partial x\) on paper, then reproduce it with requires_grad and backward(). Extend to a two-layer network \(y = \mathbf{w}_2^{\mathsf T}\tanh(W_1 x + b_1) + b_2\) with a scalar MSE loss: derive the gradient with respect to \(W_1\) by the chain rule (Course 1 Lesson 7), implement a finite-difference check in NumPy (central differences, step \(h\) chosen from np.finfo(np.float64).eps), and compare all three — paper, finite difference, autograd — with assert_allclose at a tolerance you justify. Then demonstrate the three autograd traps from lessons §2.1 deliberately: gradient accumulation across two backward() calls without zero_grad, a second backward() on a freed graph, and an in-place op on a tensor the graph needs — record each message.

Quantity Paper Finite difference backward() Agree at tolerance?
\(\partial y/\partial x\) (3-vector)
\(\partial L/\partial W_1\) (Frobenius norm)

Deliverable: python/src/ex-3-2.py, python/tests/test_ex_3_2.py (the finite-difference check as a pytest), and the three trap messages in notes.md.

Exercise 3.3 — XOR from scratch: the loop you will reuse. Implement the UvA tutorial’s arc end to end in python/src/ex-3-3.py without any trainer library: XORDataset (lessons §5.1), SimpleClassifier (§3.1), BCEWithLogitsLoss, SGD, train_epoch and evaluate (§6.1), a held-out validation set, SummaryWriter logging of train loss and validation accuracy, best-checkpoint saving with state_dict, and a reload into a fresh model that reproduces the validation accuracy exactly. Then perform the four diagnostics the lessons promise: overfit a single batch of eight before training on the full set; remove model.eval() and observe the effect (none here — say why, and name a layer that would change that); remove optimizer.zero_grad() and observe; change the labels to int32 and record the error. Plot the decision boundary with Matplotlib and log it with add_figure.

Run Predicted outcome Observed
Single-batch overfit (8 samples) reaches 100 % train accuracy quickly
Full training, seed A vs. seed B same accuracy, different weights
No zero_grad()
int32 labels
Reload from best.pt identical validation accuracy

Deliverable: the script, the TensorBoard run directory, and the saved python/artifacts/ex-3-3.npz holding 64 inputs and the trained model’s logits (with the state_dict) — the first arbiter file; Module 5’s Rust MLP exercise and Module 4’s C port check against it.

Exercise 3.4 — A 1-D CNN keyword classifier on log-mel features. The precursor to Course 3 Lab 8.2. Build a dataset of 3–5 classes from either recorded words (sounddevice + soundfile, Module 2) or synthesized DTMF pairs plus noise; write the Dataset so that featurization is torchaudio.transforms.MelSpectrogram + AmplitudeToDB inside the model (lessons §7.1), and first pin that featurizer against the librosa log-mel of Module 2 with assert_allclose (record which defaults — center, window, mel scale, power — you had to align). Define a small conv net from conv_blocks with an adaptive-pool head, count its parameters, and state the edge budget you are designing to before training. Train with Adam, CrossEntropyLoss, early stopping on validation accuracy; log curves and a confusion matrix; add one augmentation (time shift or FrequencyMasking) and report its effect. Finally, split the model into featurizer and classifier and show that the classifier alone accepts a precomputed log-mel — the shape both Course 3 boards will feed it.

Item Predicted Observed
Featurizer parity with librosa (max abs error, dB)
Parameter count vs. budget
Validation accuracy, no augmentation / with augmentation
Worst class in the confusion matrix

Deliverable: python/src/ex-3-4.py, the state_dict, python/artifacts/ex-3-4.npz (a batch of log-mel patches and their logits, plus a batch of raw waveforms and their logits through the full model), and the confusion-matrix figure.

Exercise 3.5 — A small learned denoiser. The precursor to Course 3 Lab 8.3. Generate noisy/clean pairs by mixing clean speech or tones with recorded bench noise at sampled SNRs (Module 2’s mixer); implement a spectral-mask network — STFT with torch.stft inside the graph, a few Conv2d or GRU layers producing a mask in \([0, 1]\) over the magnitude, istft back to a waveform — with an L1 loss on the masked magnitude and a waveform loss, and compare the two. Evaluate SNR improvement against the classical Wiener filter baseline from Module 2 (or scipy.signal.wiener as a stand-in) on the same noisy inputs, and listen (sounddevice.play). Report the algorithmic latency implied by your STFT window and hop, in samples and milliseconds at your sample rate — a hand calculation, not a measurement.

Item Predicted Observed
SNR improvement, Wiener baseline (dB)
SNR improvement, mask net, magnitude loss / waveform loss (dB)
Algorithmic latency (window + hop) hand-derived
Musical-noise artifacts audible?

Deliverable: python/src/ex-3-5.py, the state_dict, python/artifacts/ex-3-5.npz (noisy input frames and the network’s mask and output), and a Module 1-style figure set: spectrograms of noisy, Wiener, and network outputs.

Exercise 3.6 — The quantization ladder. Take the Exercise 3.4 classifier and walk it down the ladder of lessons §8: (a) fp16 by casting (model.half() on CUDA, or an fp16 ONNX via the converter on CPU); (b) dynamic int8 — record which API your installed version exposes (torch.ao.quantization.quantize_dynamic or the torchao equivalent) and what it quantized (Linear only?); (c) static post-training int8 with a calibration set of a few hundred log-mel patches, done on the exported ONNX with onnxruntime.quantization.quantize_static so the same file later feeds TensorRT’s calibration on the Jetson; (d) optional: a short QAT fine-tune through the PT2-export flow if the installed torchao supports it. For each rung measure validation accuracy, the file size on disk, and the maximum logit deviation from the fp32 reference. Predict the ordering of the four rungs on each axis before measuring; explain any inversion.

Rung Predicted accuracy order Observed accuracy Predicted size order Observed size Max logit deviation
fp32 (reference) 1 4 0
fp16
dynamic int8
static int8 (calibrated)
QAT int8 (optional)

Deliverable: the script, the four (or five) model files, the table, and one paragraph in notes.md connecting the accuracy–size curve to Course 1 Lesson 45.

Exercise 3.7 — Export, parity, and a profile. Export the Exercise 3.4 classifier (featurizer excluded, fixed input shape) with torch.onnx.export(..., dynamo=True), save the ONNXProgram, and run onnx.checker. Load it in onnxruntime on the Mac and establish parity with the PyTorch model on the saved reference batch using np.testing.assert_allclose; tighten rtol/atol until the check fails and record the tightest passing pair — that pair is the tolerance Course 3 Lab 8.5 and Lab 8.6 inherit. Then try the legacy exporter (dynamo=False) and a dynamic batch dimension, and record what changes in the graph (onnx node count, opset). Profile the PyTorch model for a few forward passes with torch.profiler on CPU and on the accelerator: identify the top three operators by self time and whether the DataLoader or the host↔︎device copy dominates a training step. Optional rungs: copy the ONNX to the Jetson and run onnxruntime there (same .npz, same tolerance), then trtexec --fp16 and compare the engine’s outputs against the same reference; run the ONNX on the Pi 5 with the CPU provider.

Check Predicted Observed
onnx.checker passes, opset
Tightest passing (rtol, atol) vs. PyTorch on the Mac
dynamo=True vs. dynamo=False node count
Top-3 operators by self time (CPU)
Top-3 operators by self time (accelerator)
Jetson onnxruntime parity at the same tolerance (optional)
Jetson fp16 engine max deviation (optional)

Deliverable: python/src/ex-3-7.py, kws.onnx, the updated python/artifacts/ex-3-4.npz with the ONNX outputs added and the tolerance recorded in its metadata, the profiler table excerpt, and — the module’s closing note in m3/notes.md — half a page on what the Python side now guarantees to the C and Rust modules: which files, which shapes, which tolerance.