Module 2 Exercises — Processing, Statistics, Classical ML, and the Bridge to C and Rust
Back to the Course 2 syllabus. Read first: Module 2 lessons (the librosa, OpenCV-Python, scikit-learn, scipy.stats, PyO3, and Numba documentation remain available as optional deep-dives).
Work in the labs repo’s python/ tree from the repo root with the uv project: scripts as python/src/ex-2-N.py (uv run python python/src/ex-2-N.py), notebooks as python/notebooks/ex-2-N.ipynb (uv run jupyter lab), tests as python/tests/test_ex_2_N.py (uv run pytest python/tests); Exercise 2.6 also builds in c/host, and Exercise 2.7 adds a PyO3 crate under rust/pyext/. Record predictions, results, and the tolerance derivations in m2/notes.md. Everything runs on the Mac; the CuPy rung of Exercise 2.5 is Jetson-only and optional. Every exercise ends the same way: the Python implementation saves a reference file python/references/ex-2-N.npz (inputs, outputs, parameters, seed) and a pytest asserts the agreement it claims with a tolerance derived in the notes (lessons §9) — those files are what the C and Rust versions of later modules are checked against. Predicted cells are filled in before running; observed cells at the machine.
Exercises
Exercise 2.1 — Audio pipeline: WAV → STFT → mel → MFCC, library vs. by hand. Record (or synthesize with a fixed seed: two tones plus band-limited noise, 16 kHz, int16) a 2 s WAV with sounddevice/soundfile. Build the feature pipeline twice: once with librosa (stft with center=False, melspectrogram, power_to_db, mfcc), once by hand with sliding_window_view, scipy.signal.get_window, np.fft.rfft, librosa.filters.mel, and scipy.fft.dct. Before running, list every convention the two must share (window periodicity, padding, mel scale, filterbank norm, log floor, DCT type and norm) and predict which ones will disagree on first try. Derive the tolerance for the log-mel comparison in dB from the linear rounding error (lessons §9). Then load the same file with librosa.load without sr=None and record what changed.
| Stage | Convention pinned (argument, value) | Predicted agreement (rtol / atol) | Observed max abs diff |
|---|---|---|---|
| Frames (count, first sample of frame 3) | … | exact | … |
|STFT|² |
… | … | … |
| Mel power | … | … | … |
| Log-mel (dB) | … | … | … |
| MFCC (13) | … | … | … |
Deliverable: the two implementations, a Matplotlib figure (waveform, spectrogram, log-mel, MFCC — four axes, savefig to m2/), python/references/ex-2-1.npz, and the passing test. This is the reference for Course 3 Lab 8.2’s firmware front end.
Exercise 2.2 — Convolution and edges, three ways. Take a grayscale image (a Course 3 bench photo, cv2.IMREAD_GRAYSCALE) and a \(3\times3\) Gaussian kernel and a horizontal Sobel kernel. Compute the filtered images with cv2.filter2D, scipy.ndimage.convolve/correlate, and scipy.signal.convolve2d, and by hand with sliding_window_view + einsum (zero border). Before running, fill the prediction column from lessons §3.2’s table: which pairs agree on the interior, which differ by a sign, which differ at the border only, and where the uint8 saturating cast bites. Then add cv2.Canny and cv2.Sobel(…, cv2.CV_32F, …) and reconstruct Canny’s magnitude stage from your Sobel outputs.
| Pair | Kernel | Predicted: interior agreement | Predicted: border agreement | Observed (interior max diff / border max diff) |
|---|---|---|---|---|
filter2D vs ndimage.correlate |
Gaussian | … | … | … |
filter2D vs ndimage.convolve |
Sobel-x | … | … | … |
ndimage.convolve vs convolve2d |
Sobel-x | … | … | … |
by-hand (zero border) vs convolve2d(boundary="fill") |
both | … | … | … |
filter2D uint8 out vs float32 out |
Sobel-x | … | … | … |
Deliverable: the four implementations, the table, a figure of the five outputs with the RGB/BGR order stated in the caption, python/references/ex-2-2.npz with the chosen border policy recorded as a string field, and a test asserting bit-exact interior agreement for the integer path. This is Lab 9.4’s arbiter; the border policy you record is the one the C version must implement.
Exercise 2.3 — A noise floor with an interval. Load a Course 3 ADC noise record (or synthesize white Gaussian noise plus a 60 Hz line and a fixed seed). Estimate the PSD with scipy.signal.welch (Hann, nperseg=1024, 50% overlap) and attach the \(\chi^2\) confidence band from lessons §4.2 assuming \(\nu = 2 n_d\). Then estimate the effective degrees of freedom empirically: split the record into \(K\) disjoint pieces, compute the Welch estimate on each, and fit the spread of the per-bin estimates to a \(\chi^2_\nu/\nu\) model with scipy.stats.chi2.fit (fix loc=0). Separately, bootstrap the noise-floor statistic (median PSD over the flat band) with scipy.stats.bootstrap and by hand with rng.integers. Predict first: whether the effective \(\nu\) is below or above \(2 n_d\) and by roughly what factor for a Hann window at 50% overlap, and whether the bootstrap distribution of the median looks Gaussian.
| Quantity | Predicted | Observed |
|---|---|---|
| Nominal \(\nu = 2 n_d\) | … | … |
| Effective \(\nu\) (fit) | … | … |
| 95% band width, nominal (dB) | … | … |
| 95% band width, effective (dB) | … | … |
| Bootstrap 95% CI of the floor (dB) | … | … |
stats.probplot verdict on the time samples |
… | … |
Deliverable: the script, a figure with the PSD and both bands, the bootstrap histogram with a fitted normal overlaid, and a paragraph in notes.md on which interval Lab 6.4’s Measured cells should carry and why.
Exercise 2.4 — Features → PCA → classifier → ROC. Build a small labeled set the way Lab 8.2 or Lab 8.4 will: either \(\ge 40\) short recordings of two spoken keywords, or synthetic “healthy”/“faulty” vibration windows (a base tone with harmonics plus a fault-band burst, seeded). Extract features with Exercise 2.1’s pipeline (pooled log-mel or MFCC statistics) or §5.3’s vibration set (band powers, crest factor, kurtosis, spectral centroid). Split with stratify=y, build make_pipeline(StandardScaler(), PCA(n_components=k), clf) for LogisticRegression, SVC, and KNeighborsClassifier, choose k and the model knobs by GridSearchCV on the training fold only, and report the confusion matrix, per-class recall, and the ROC curve with AUC on the held-out set. Predict before training: which classifier a firmware port could afford (count its inference multiplies from the fitted attributes), and whether PCA to 2 components is enough to separate the classes by eye in a scatter plot.
| Model | Predicted inference cost (multiplies/sample) | CV accuracy (mean ± sd) | Held-out AUC | Confusion matrix |
|---|---|---|---|---|
| Logistic regression | … | … | … | … |
| SVC (rbf) | … | … | … | … |
| k-NN | … | … | … | … |
Deliverable: the notebook, the ROC figure (the Course 3 Lab 6.7 theory \(P_D\)–\(P_{FA}\) axes labeled as such), joblib dump of the best pipeline, and python/references/ex-2-4.npz holding the scaler mean/scale, the PCA components, and the logistic-regression weights as float32 — the tables a firmware classifier copies.
Exercise 2.5 — Speed without leaving Python. Implement the Goertzel detector (Course 1 Course 3 Lab 6.3 theory; Course 3 Lab 6.5) four ways: a pure-Python loop, a NumPy vectorization (the recurrence as a filter via scipy.signal.lfilter with the Goertzel coefficients), a @numba.njit loop, and — on the Jetson, optional — a CuPy batch over many blocks. Verify all four against each other with a tolerance derived from float64 recurrence error over \(N\) steps. Time with timeit (many repetitions, first call excluded, synchronize before any GPU timestamp) at block sizes 64, 1024, and 65 536; predict the ordering at each size and where Numba’s first-call cost and CuPy’s launch cost dominate.
| Block size | Pure Python | NumPy/lfilter |
Numba (2nd call) | Numba (1st call) | CuPy (Jetson) |
|---|---|---|---|---|---|
| 64 | … | … | … | … | … |
| 1 024 | … | … | … | … | … |
| 65 536 | … | … | … | … | … |
Deliverable: the table (predicted ordering written above it in notes.md), the @njit source — which is line-for-line the C kernel Module 4 will write — and python/references/ex-2-5.npz.
Exercise 2.6 — A C kernel called from NumPy. Add a SHARED library target to c/host containing Module 0’s dot_q15 and a 16-tap FIR (int16_t samples, int16_t coefficients, int32_t accumulator, arithmetic shift by 15 with rounding, saturating to int16_t). Bind both with ctypes + np.ctypeslib.ndpointer (dtype, ndim, flags checked) and once more with cffi in ABI mode. Write the NumPy reference for each in float64 and in exact integer arithmetic (np.int64 accumulation, then the same rounding and saturation). Derive the tolerance for the float64-vs-Q15 comparison from lessons §9’s Q15 row with \(M = 16\) and predict it; assert the integer-vs-integer comparison bit-exact. Then deliberately pass a strided view, a float64 array, and an int32 array, and record the diagnostic each binding produces.
| Input | Predicted: ctypes/ndpointer reaction |
Predicted: cffi reaction |
Observed |
|---|---|---|---|
Contiguous int16 |
… | … | … |
x[::2] view |
… | … | … |
float64 array |
… | … | … |
int32 array |
… | … | … |
| FIR Q15 vs float64 | atol = … (derived) | — | max abs diff … |
Optional rung: stream the same blocks to the STM32 over pyserial (Course 3 Lab 9.1’s harness) and run the identical test against the firmware’s output.
Deliverable: the CMake target, both bindings, the reference module, the table, python/references/ex-2-6.npz, and the test — the first instance of the arbiter pattern the C modules reuse.
Exercise 2.7 — A Rust kernel called from NumPy. Create the workspace member rust/pyext/ (a cdylib with PyO3 and the numpy crate; #![forbid(unsafe_code)]) exposing the same dot_q15 and 16-tap Q15 FIR as Exercise 2.6, with PyReadonlyArray1<i16> inputs and a PyReadwriteArray1<i16> output buffer for the FIR. Build it into the uv environment with maturin develop --release, and reuse Exercise 2.6’s pytest unchanged against the Rust module. Predict, then record: what happens when the Python side passes a non-contiguous view (which layer reports it, and as what exception type); whether the Rust and C FIR outputs are bit-exact against each other (they share the reference — if they differ, one of them rounds differently, and the notes must say which); and the cargo size-style comparison of the same dot_q15 body compiled for thumbv7em-none-eabihf in the mcu crate, to make the point that one function serves both the arbiter and the firmware.
| Check | Predicted | Observed |
|---|---|---|
| Non-contiguous input → exception type and layer | … | … |
| Rust FIR vs C FIR (integer path) | bit-exact / differs at … | … |
| Rust FIR vs float64 reference | passes at the derived atol | … |
Same body in mcu: instruction count of the inner loop at --release |
… | … |
Deliverable: the crate, the build recipe in m2/notes.md, and the test run passing for both the C and the Rust bindings from one test file — the bridge that Modules 5 and 12 use to validate every Rust kernel against its Python reference.