Week 8 — Homework: Uncertainty and robustness (do BEFORE the Thursday exercise)
This week braids three lectures’ content onto a single dataset:
MFML Week 8 — Probabilistic view of learning. Aleatoric vs epistemic uncertainty; Gaussian noise; MLE = MSE; MAP = regularised MLE; Bayes for predictive distributions; calibration; conformal prediction.
ML-PC Week 8 — Generalisation, robustness, and process windows. Out-of-condition generalisation; sensitivity to noise and parameter drift; the process window as a region of input space where the model can still be trusted.
MG Week 8 — Local atomic environments + universal ML force fields. Bias-variance, split design, and small-data discipline transfer to materials regression; SOAP+MACE-MP-0 appear in the Thursday session.
Red thread.Fitting a regression model is making a probabilistic claim: “given \(\mathbf{x}\), my best guess is \(\hat{y}\) with spread \(\hat{\sigma}\).” Today’s homework does the algebra that links MSE to a Gaussian likelihood, sweeps complexity to see the bias-variance U-curve on real lab data, and quantifies what happens when you forget that test data should come from a different process condition than training data. Thursday will then ask: how much of that spread is irreducible noise vs lack of data, and where in input space is the model still trustworthy?
Time: ~75 minutes.
What this homework is
Five short workouts on the same dataset (TensileTestDataset — strain → stress at three process temperatures), each anchored on one core idea:
Part
Min
Topic
Lecture anchor
A
20
MLE for a Gaussian regression: log-likelihood ⇔ MSE; recover \(\hat{\sigma}^2\) from residuals
MFML §“Likelihood and MLE”
B
20
Bias-variance U-curve: polynomial degree sweep with and without ridge
MG §“Dataset size vs model complexity” + MFML §“Bias-variance”
C
25
Process-condition split: random vs leave-T-out on the 3-temperature dataset; quantify the leakage gap
Crystals as graphs: PBC neighbour list → Gaussian-RBF edges → message passing by hand → optional pretrained MLIP
MG Unit 7 §§2–6, §7 (over-smoothing), §9 (case studies)
What you must hand in (or be able to show on Thursday)
Part A: residual histogram + a printed line showing \(\hat{\sigma}^2 \approx \mathrm{MSE}_{\text{train}}\) to 3 decimal places.
Part B: train vs test RMSE plotted against polynomial degree for OLS and for ridge (\(\alpha = 1.0\)). Show the U.
Part C: a small table of test RMSE under (i) random 80/20 split over all three temperatures, (ii) leave-\(T=600\)-out, (iii) leave-\(T=0\)-out. Comment on why (i) is misleading.
Part D: your reflection paragraph (4–6 sentences).
Part E: the empirical conformal coverage printed for \(\alpha = 0.1\) on a fresh test split — it should land within sampling noise of the target \(1 - \alpha = 0.9\).
Part F (MG track, optional for MLPC-only students): the printed edge count + unique nearest-neighbour distance for FCC Cu, the RBF expansion plot, and the over-smoothing trajectory plot from manual message passing on the 3-atom toy graph.
Part G (MFML extensions, ~10 min): the KL-between-Gaussians table for the three given pairs, and the Student-t vs Gaussian fit on the outlier-contaminated residuals (printed \(\nu\) and the overlay plot).
# Standard imports for the whole homework. Same idiom as weeks 2-6.import numpy as npimport torchimport matplotlib.pyplot as pltfrom sklearn.linear_model import LinearRegression, Ridgefrom sklearn.metrics import mean_squared_errorfrom sklearn.preprocessing import StandardScalerfrom ai4mat.datasets import TensileTestDatasetnp.random.seed(0)torch.manual_seed(0)
<torch._C.Generator at 0x7f68dcd6e050>
Helper: load one temperature as numpy arrays
TensileTestDataset returns torch tensors; for the linear-algebra parts of this homework, plain numpy is easier to read and debug. We keep the helper here because every Part needs it.
def load_tensile_np(temperature: int):"""Return (strain, stress) as 1-D numpy arrays at the given temperature.""" ds = TensileTestDataset(temperature=temperature) X = ds.X.numpy().reshape(-1) # (350,) strain y = ds.y.numpy().reshape(-1) # (350,) stressreturn X, y
Part A — MLE for a Gaussian regression
We model the stress at strain \(\varepsilon\) as \[ y \mid \varepsilon \sim \mathcal{N}(f_\theta(\varepsilon),\ \sigma^2), \] where \(f_\theta\) is whatever function class we are fitting (today: a polynomial in \(\varepsilon\)). Maximising the log-likelihood over \(\theta\) gives \[
\log p(y \mid \varepsilon, \theta, \sigma)
= -\frac{1}{2\sigma^2}\sum_i (y_i - f_\theta(\varepsilon_i))^2 + \text{const}.
\]The first term is \(-\frac{1}{2\sigma^2} \cdot N \cdot \mathrm{MSE}\). Maximising it over \(\theta\) is exactly minimising the MSE. That is why every regression in this course can be read either as least squares or as Gaussian MLE — the two procedures find the same optimum.
The MLE for the noise variance \(\sigma^2\), computed after the coefficient MLE, is \[ \hat{\sigma}^2 = \frac{1}{N}\sum_i (y_i - f_{\hat\theta}(\varepsilon_i))^2 = \mathrm{MSE}_\text{train}. \]
Your task. Fit a degree-3 polynomial in strain to the \(T=600\,°\mathrm{C}\) tensile curve, compute the MLE residual variance, and verify that it equals the training MSE.
Part A:
Number of samples N = 350
Fitted polynomial coefficients (degree 0..3): [ 1.47951246e+01 1.84759245e+03 -7.42735629e+04 -2.17853215e+03]
MLE residual variance sigma_hat^2 = 12.8781
Training MSE = 12.8781
Difference (should be ~ 0) = 0.00e+00
# Diagnostic: residual histogram with a Gaussian overlay at sigma_hat.fig, ax = plt.subplots(figsize=(6, 4))ax.hist(residuals, bins=30, density=True, alpha=0.6, label="residuals")xs = np.linspace(residuals.min(), residuals.max(), 200)gauss = (1.0/ np.sqrt(2* np.pi * sigma2_mle)) * np.exp(-(xs **2) / (2* sigma2_mle))ax.plot(xs, gauss, "r-", lw=2, label=fr"$\mathcal{{N}}(0,\hat\sigma^2={sigma2_mle:.2f})$")ax.set_xlabel("residual y - y_hat (MPa)")ax.set_ylabel("density")ax.set_title("Part A — residuals vs MLE Gaussian")ax.legend()plt.tight_layout()plt.show()
Part A deliverable: the residual histogram and the printed sigma_hat^2 ≈ MSE_train check. The Gaussian overlay should follow the histogram reasonably closely; any tail asymmetry is a hint that the Gaussian-noise assumption is approximate (later: Student-t robustness).
Part B — Bias-variance: polynomial degree sweep
The Gaussian likelihood from Part A says nothing about which function class to use. Take it too small and you under-fit (high bias); take it too large and you over-fit on the 350 training samples (high variance). The classical visual is the U-curve of test error against complexity.
We sweep polynomial degree from 1 to 7 with two configurations: 1. OLS — closed-form least squares with no regularisation. 2. Ridge (\(\alpha = 1.0\)) — least squares with a Gaussian prior on the coefficients (we will derive that this is the same as MAP under a Gaussian prior in Thursday’s Block 3).
Standardising the polynomial features matters: \(\varepsilon^7\) is many orders of magnitude larger than \(\varepsilon\), and ridge would treat the raw columns very unequally.
fig, axes = plt.subplots(1, 2, figsize=(11, 4.5), sharey=True)for ax, tr_curve, te_curve, title inzip( axes, [rmse_ols_tr, rmse_rdg_tr], [rmse_ols_te, rmse_rdg_te], ["OLS (no regularisation)", r"Ridge ($\alpha = 1.0$)"],): ax.plot(degrees, tr_curve, "o-", label="train RMSE") ax.plot(degrees, te_curve, "s-", label="test RMSE") ax.set_xlabel("polynomial degree") ax.set_title(title) ax.grid(alpha=0.3) ax.legend()axes[0].set_ylabel("RMSE (MPa)")fig.suptitle(f"Part B — bias-variance on T={600} °C tensile (N_train={n_train}, N_test={n-n_train})")plt.tight_layout()plt.show()print(f"\nPart B test RMSE per degree:")print(f"{'deg':>4} | {'OLS test':>10} | {'Ridge test':>10}")for d, ot, rt inzip(degrees, rmse_ols_te, rmse_rdg_te):print(f"{d:>4} | {ot:>10.3f} | {rt:>10.3f}")
Part B test RMSE per degree:
deg | OLS test | Ridge test
1 | 3.960 | 3.961
2 | 3.322 | 3.331
3 | 2.641 | 3.159
4 | 2.161 | 3.045
5 | 1.595 | 3.048
6 | 1.282 | 3.059
7 | 1.223 | 3.054
Part B deliverable: the two-panel figure and the table above.
Things to notice (you will need them on Thursday):
OLS test error tends to climb steeply at high degrees while train error keeps dropping — that is the variance side of the U.
Ridge keeps high-degree fits well-behaved; the U flattens. The penalty acts like a Gaussian prior pulling coefficients toward 0.
At low degrees both curves are close, because regularisation does not matter when there are no spare parameters to misuse.
Part C — Process-condition split: leakage you cannot see in Part B
Part B used a random 80/20 split within one temperature. Real process-monitoring data comes from runs at different conditions: temperatures, rates, machines, days. If you train on a mix of conditions and evaluate on a mix of conditions, you measure interpolation between conditions you have already seen. You do not measure what happens on a new condition you have not.
The tensile dataset has three process conditions: \(T \in \{0, 400, 600\}\) °C. Each curve is genuinely different — at \(T=0\) the response is brittle and nearly linear, at \(T=600\) it is highly nonlinear. We compare three splits:
Split
Train
Test
(i) random 80/20 over all three temperatures
random 80%
held-out 20%
(ii) leave-\(T=600\)-out
T=0 + T=400
T=600
(iii) leave-\(T=0\)-out
T=400 + T=600
T=0
We fit the same model (degree-5 ridge) under each split and compare the test RMSE. The gap between (i) and (ii)/(iii) is the leakage gap.
data_by_T = {T: load_tensile_np(T) for T in [0, 400, 600]}def fit_eval(strain_tr, stress_tr, strain_te, stress_te, degree=5, alpha=1.0):"""Standardise polynomial features on train, fit Ridge, return test RMSE.""" Phi_tr = poly_features(strain_tr, degree) Phi_te = poly_features(strain_te, degree) scaler = StandardScaler().fit(Phi_tr[:, 1:]) Phi_tr_s = np.hstack([Phi_tr[:, :1], scaler.transform(Phi_tr[:, 1:])]) Phi_te_s = np.hstack([Phi_te[:, :1], scaler.transform(Phi_te[:, 1:])]) m = Ridge(alpha=alpha, fit_intercept=False).fit(Phi_tr_s, stress_tr)return np.sqrt(mean_squared_error(stress_te, m.predict(Phi_te_s))), m, scaler# (i) random over all threeall_strain = np.concatenate([data_by_T[T][0] for T in [0, 400, 600]])all_stress = np.concatenate([data_by_T[T][1] for T in [0, 400, 600]])rng = np.random.default_rng(1)N =len(all_strain)idx = rng.permutation(N)tr_i, te_i = idx[: int(0.8* N)], idx[int(0.8* N) :]rmse_random, _, _ = fit_eval(all_strain[tr_i], all_stress[tr_i], all_strain[te_i], all_stress[te_i])# (ii) leave-T=600-outtr_strain = np.concatenate([data_by_T[T][0] for T in [0, 400]])tr_stress = np.concatenate([data_by_T[T][1] for T in [0, 400]])te_strain, te_stress = data_by_T[600]rmse_loo600, _, _ = fit_eval(tr_strain, tr_stress, te_strain, te_stress)# (iii) leave-T=0-outtr_strain = np.concatenate([data_by_T[T][0] for T in [400, 600]])tr_stress = np.concatenate([data_by_T[T][1] for T in [400, 600]])te_strain, te_stress = data_by_T[0]rmse_loo000, _, _ = fit_eval(tr_strain, tr_stress, te_strain, te_stress)print("Part C — test RMSE under three split protocols (degree-5 ridge):")print(f" (i) random 80/20 over T in {{0, 400, 600}}: RMSE = {rmse_random:7.2f} MPa")print(f" (ii) leave-T=600-out (train 0+400, test 600): RMSE = {rmse_loo600:7.2f} MPa")print(f" (iii) leave-T=0-out (train 400+600, test 0): RMSE = {rmse_loo000:7.2f} MPa")print()print(f" Leakage gap (ii)/(i) = {rmse_loo600 / rmse_random:.1f}x")print(f" Leakage gap (iii)/(i) = {rmse_loo000 / rmse_random:.1f}x")
Part C — test RMSE under three split protocols (degree-5 ridge):
(i) random 80/20 over T in {0, 400, 600}: RMSE = 15.07 MPa
(ii) leave-T=600-out (train 0+400, test 600): RMSE = 26.99 MPa
(iii) leave-T=0-out (train 400+600, test 0): RMSE = 23.27 MPa
Leakage gap (ii)/(i) = 1.8x
Leakage gap (iii)/(i) = 1.5x
# Visualise why the leakage exists: the three curves are different shapes.fig, ax = plt.subplots(figsize=(7, 5))for T in [0, 400, 600]: s, st = data_by_T[T] order = np.argsort(s) ax.plot(s[order], st[order], "o-", ms=3, alpha=0.7, label=fr"$T = {T}$ °C")ax.set_xlabel("strain")ax.set_ylabel("stress (MPa)")ax.set_title("Three process conditions in the tensile dataset")ax.legend(); ax.grid(alpha=0.3)plt.tight_layout(); plt.show()
Part C deliverable: the printed table and the three-curve plot.
Things to notice:
Split (i) lets stress observations at \(T=600\) leak into training, because the model memorises the \(T=600\) shape from points adjacent to the held-out 20% of \(T=600\) samples. Test RMSE looks great.
Split (ii) and (iii) measure the real generalisation question: can the model handle a process condition it has not seen at all? The RMSE typically blows up by a factor of 5-50x.
This is the same lesson Week 3 taught for sample-vs-specimen splits, now in the engineering-process flavour ML-PC emphasises.
Part D — Reflection: when does the U-curve track the leakage gap?
In Part B you saw a bias-variance U inside one process condition. In Part C you saw a leakage gap across process conditions. The two diagnostics measure different things, and they can disagree:
A model can sit perfectly at the bottom of its in-condition U (Part B says “this is the right complexity!”) and still fail catastrophically under leave-condition-out (Part C says “this model has no idea about \(T=0\)”).
Conversely, a heavily under-fit model can have a small leakage gap (because it predicts roughly the same wrong thing in every condition) while still being useless.
Your task (~10 min, write 4-6 sentences):
Pick a single materials-science scenario you have worked on or know well — a microscopy classifier, a property predictor, a process monitor, whatever you like — and answer two questions:
What is the analogue of “process condition” in your scenario? (e.g. sample, specimen, day, instrument, alloy family, temperature, dose…)
If you only had time to run one validation experiment before deploying the model, would you run a Part-B-style bias-variance sweep or a Part-C-style group-out test, and why?
Bring this paragraph to Thursday; we will pick two volunteers to read theirs aloud at the start of Block 1.
Hand in: your written paragraph (Markdown cell below).
(your reflection paragraph here)
Part E — Distribution-free coverage: split conformal
Parts A–D gave you Gaussian error bars: \(\hat{y} \pm z\,\hat\sigma\) with the spread derived from a probabilistic model. That spread is only meaningful if (i) the noise really is Gaussian and (ii) the model is well specified. Split-conformal prediction[@angelopoulos_2023_conformal] drops both assumptions: given any black-box point predictor \(\hat{f}\) and a held-out calibration set, it builds intervals with a finite-sample, distribution-free coverage guarantee of \(\ge 1 - \alpha\), as long as the calibration and test data are exchangeable (a strictly weaker assumption than i.i.d.).
The recipe in five lines:
Carve a fresh calibration set and a fresh test set out of the pool, both disjoint from the predictor’s training data.
Compute non-conformity scores \(s_i = |y_i - \hat{f}(x_i)|\) on calibration.
Take \(\hat{q}\) = empirical \(\lceil (n_{\text{cal}} + 1)(1 - \alpha)
\rceil / n_{\text{cal}}\)-quantile of the scores (for \(\alpha = 0.1\), target coverage \(= 0.9\)).
Emit intervals \(\hat{f}(x) \pm \hat{q}\) on test.
Measure empirical coverage on test; it should land near \(1 - \alpha\).
We exercise the recipe with the simplest possible predictor — an OLS polynomial — on TensileTestDataset(T=600). Thursday’s in-class will layer the same recipe on top of a Bayesian / ensemble predictor and tie it to the process-window question.
Theory vs measurement. Split-conformal prediction guarantees marginal coverage \(\Pr[|y - \hat{f}(x)| \le \hat{q}] \ge 1 - \alpha\) in finite samples, distribution-free, provided \((x_i, y_i)\) in calibration and test are exchangeable[@angelopoulos_2023_conformal]. The empirical coverage printed above should land near the target \(1 - \alpha = 0.9\), within sampling noise of the guarantee.
What changes if exchangeability fails? If the test distribution drifts (e.g. you calibrate at \(T = 600\) and deploy at \(T = 0\)), the guarantee evaporates: empirical coverage can collapse far below the nominal level, and you need either a robust conformal variant (weighted, adaptive) or a re-calibration step on fresh in-distribution data. We will revisit this with a conformalized quantile regression (CQR) example on materials data in Week 12.
Part E deliverable: the printed empirical coverage and the diagnostic scatter plot above.
Part F (MG track) — Crystals as graphs
Skip this if you are MLPC-only and the conformal block above already took you over 75 min. MG-track students: this is the homework anchor for MG Unit 7 (Graph-Based Representations).
Red thread. The descriptors you used in MG Unit 6 (Magpie, RDF, SOAP) compress an entire crystal into a fixed-length vector. Graph neural networks instead keep the atomistic structure as a graph — atoms are nodes, neighbour relations are edges — and learn the pooling step. This Part walks the three core mechanics:
F.1 Build a periodic neighbour list (PBC discipline; MG U7 §§6–10).
F.2 Encode pair distances with a Gaussian-RBF basis (§11–12).
F.3 Run one round of message passing on a 3-atom toy and stack layers until features over-smooth (§16–18, §24).
F.4 Optional: forward-only inference with a pretrained universal MLIP (M3GNet) on the same Cu cell (§33 case studies). Requires pip install matgl.
None of this needs a GPU. Blocks F.1–F.3 use pymatgen + raw PyTorch.
F.1 — Periodic neighbour list for FCC copper
A crystal is not a finite point cloud: each unit-cell atom has neighbours in adjacent images. Brute-force way to build the edges: loop over a small range of integer lattice translations \((n_x, n_y,
n_z)\) and keep every pair whose minimum-image distance is below the cutoff \(r_{\text{cut}}\).
Cu FCC primitive cell has one atom and twelve nearest neighbours at \(d = a / \sqrt{2}\). We expect exactly 12 edges at \(r_{\text{cut}} = 3.0\) Å with \(a_{\text{Cu}} = 3.61\) Å.
from pymatgen.core import Lattice, StructureA_CU =3.61# Angstrom; experimental Cu lattice parameter# Primitive FCC lattice vectors: face-diagonal basis.lat_cu = Lattice([[0.0, A_CU /2, A_CU /2], [A_CU/2, 0.0, A_CU /2], [A_CU/2, A_CU /2, 0.0]])cu = Structure(lat_cu, ["Cu"], [[0.0, 0.0, 0.0]])print(f"primitive volume = {cu.volume:.3f} ų (= a³/4 = {A_CU**3/4:.3f})")def periodic_neighbours(structure, r_cut: float, image_range: int=2):"""Brute-force PBC neighbour list. Returns ------- src, dst : (E,) int arrays of node indices for each edge. dist : (E,) float array of edge lengths in Å. shift : (E, 3) int array of lattice-image translations applied to dst. For each ordered pair (i, j) and each lattice translation (n_x, n_y, n_z) in [-image_range, image_range]³, an edge is kept if |r_j + n · L - r_i| ≤ r_cut. The (i, j, (0,0,0)) self-edge is skipped. """ cart = structure.cart_coords L = np.array(structure.lattice.matrix) N =len(cart) src, dst, dists, shifts = [], [], [], []for i inrange(N):for j inrange(N):for nx inrange(-image_range, image_range +1):for ny inrange(-image_range, image_range +1):for nz inrange(-image_range, image_range +1):if i == j and (nx, ny, nz) == (0, 0, 0):continue shift = nx * L[0] + ny * L[1] + nz * L[2] d =float(np.linalg.norm(cart[j] + shift - cart[i]))if d <= r_cut: src.append(i); dst.append(j) dists.append(d); shifts.append((nx, ny, nz))return (np.array(src, dtype=int), np.array(dst, dtype=int), np.array(dists, dtype=float), np.array(shifts, dtype=int))src, dst, dist, shift = periodic_neighbours(cu, r_cut=3.0, image_range=2)print(f"edges within r_cut = 3.0 Å: {len(src)} (expected 12 for FCC nn shell)")print(f"unique edge lengths: {sorted({round(d, 4) for d in dist})}")print(f"theoretical FCC nn distance a/√2 = {A_CU / np.sqrt(2):.4f} Å")
primitive volume = 11.761 ų (= a³/4 = 11.761)
edges within r_cut = 3.0 Å: 12 (expected 12 for FCC nn shell)
unique edge lengths: [np.float64(2.5527)]
theoretical FCC nn distance a/√2 = 2.5527 Å
What changes if you increase the cutoff. Try \(r_{\text{cut}} = 4.5\) Å — you should pick up the second nn shell at \(d = a = 3.61\) Å (6 edges) and, depending on the cutoff, the third shell at \(d = a
\sqrt{3/2} \approx 4.42\) Å.
for r in (2.6, 3.0, 3.7, 4.5): s, _, dd, _ = periodic_neighbours(cu, r_cut=r, image_range=2) shells =sorted({round(d, 3) for d in dd})print(f" r_cut = {r:.1f} Å → {len(s):3d} edges shells: {shells}")
r_cut = 2.6 Å → 12 edges shells: [np.float64(2.553)]
r_cut = 3.0 Å → 12 edges shells: [np.float64(2.553)]
r_cut = 3.7 Å → 18 edges shells: [np.float64(2.553), np.float64(3.61)]
r_cut = 4.5 Å → 42 edges shells: [np.float64(2.553), np.float64(3.61), np.float64(4.421)]
Pitfall — image_range too small. Setting image_range=1 (only ±1 cell) silently misses long edges when \(r_{\text{cut}}\) exceeds the shortest lattice vector. Always set image_range ≥ ceil(r_cut / min_lattice_spacing). This is the single most common bug in student PBC implementations.
F.2 — Encode distance on a Gaussian-RBF basis
Atomistic networks treat each pair distance \(d_{ij}\) as a bag of RBF features — soft, smooth, differentiable. With \(K\) centres \(\mu_k\) uniformly spaced in \([d_{\text{min}}, r_{\text{cut}}]\) and a common width \(\sigma\),
# Visualise the basis: 8 Gaussians + a vertical line for the actual NN distance.fig, ax = plt.subplots(figsize=(7, 3.2))d_grid = np.linspace(0, 4, 400)for c in centres: ax.plot(d_grid, np.exp(-0.5* ((d_grid - c) /0.3) **2), color="#1f77b4", alpha=0.55, lw=1.4)ax.axvline(dist[0], color="k", linestyle="--", lw=1.2, label=f"FCC Cu first edge: d = {dist[0]:.3f} Å")ax.set_xlabel("pair distance d (Å)")ax.set_ylabel("RBF activation")ax.set_title("Gaussian-RBF distance basis (K=8, σ=0.3)")ax.legend(loc="upper right", fontsize=9)plt.tight_layout()plt.show()
Diagnostic. The activation at the dashed line is the RBF feature vector for one Cu–Cu edge. Two centres dominate; the rest are near zero. That sparseness is what the linear layer in F.3 then mixes.
F.3 — One round of message passing, by hand
To isolate the algorithm from the crystal we switch to a tiny open chain (3 atoms, 4 directed edges) matching MG U7 slide 18. The update rule we run is the GCN template with degree-normalised mean aggregation
with \(W_s, W_n \in \mathbb{R}^{4 \times 4}\), frozen-random weights, tanh activation, and aggregation via index_add_ + degree division. Degree normalisation matters: without it, node 1 (degree 2) would accumulate twice the signal of nodes 0 and 2 each round and the trajectory diverges instead of smoothing. We stack 10 rounds to see over-smoothing — the inter-node spread collapses by an order of magnitude (MG U7 §24).
import torchimport torch.nn as nntorch.manual_seed(0)# 3-atom open chain: edges 0-1 and 1-2, both directions.edge_index = torch.tensor([[0, 1, 1, 2], # src [1, 0, 2, 1]]) # dst# Frozen random initial node features and weight matrices.F =4h0 = torch.randn(3, F)W_self = nn.Linear(F, F, bias=False)W_neigh = nn.Linear(F, F, bias=False)for p in (*W_self.parameters(), *W_neigh.parameters()): p.requires_grad_(False)def gcn_round(h, edge_index):"""One GCN-style update with degree-normalised mean aggregation.""" src, dst = edge_index messages = W_neigh(h[src]) # (E, F) agg = torch.zeros_like(h) agg.index_add_(0, dst, messages) # sum messages per dst deg = torch.zeros(h.shape[0]) deg.index_add_(0, dst, torch.ones(dst.shape[0])) # degree per node agg = agg / deg.clamp(min=1).unsqueeze(-1) # mean aggregationreturn torch.tanh(W_self(h) + agg)h_history = [h0.clone()]h = h0for _ inrange(10): h = gcn_round(h, edge_index) h_history.append(h.detach().clone())# Stack to (rounds+1, N, F) for easy plotting.H = torch.stack(h_history).numpy()spread = H[:, :, 0].max(axis=1) - H[:, :, 0].min(axis=1) # per-round spread of feature 0print(f"node-feature trajectory: shape {H.shape} (rounds+1, N, F)")print(f"feature-0 initial spread: {spread[0]:.3f} → final spread: {spread[-1]:.4f}")print(f"~{spread[0] /max(spread[-1], 1e-9):.0f}× reduction over {H.shape[0] -1} rounds")
node-feature trajectory: shape (11, 3, 4) (rounds+1, N, F)
feature-0 initial spread: 2.626 → final spread: 0.0251
~105× reduction over 10 rounds
What you should observe. The left panel shows the three node trajectories crowding together with depth; the right panel is the punchline — the spread of feature-0 across nodes drops roughly an order of magnitude over 10 rounds (linear convergence rate set by the spectral gap of the normalised propagation operator). In a deeper GNN this is the over-smoothing pathology: stacking too many message-passing layers makes all node features indistinguish- able, which kills the model’s ability to predict node-level properties. Practical workarounds: residual / skip connections (CGCNN), Jumping Knowledge Networks (Xu 2018), PairNorm / DropEdge, or simply don’t stack more than 3–6 layers. MG U7 slide 24 is the home of this discussion.
Skip if you do not want to install another package. Universal machine-learning interatomic potentials (M3GNet, MACE-MP-0, CHGNet, ORB, MatterSim) are GNNs pretrained on millions of DFT energies and forces and are usable as a calculator on any crystal without further training. We do forward inference only here — no training, no fine-tuning. Install with:
pip install matgl
Then the snippet below runs in ~5 s on CPU.
try:import matglfrom ase.build import bulkfrom matgl.ext.ase import M3GNetCalculator cu_atoms = bulk("Cu", "fcc", a=A_CU) pot = matgl.load_model("M3GNet-MP-2021.2.8-DIRECT-PES") cu_atoms.calc = M3GNetCalculator(potential=pot) e_total =float(cu_atoms.get_potential_energy()) e_per_atom = e_total /len(cu_atoms)print(f"M3GNet pretrained Cu primitive cell:")print(f" total energy = {e_total:.4f} eV")print(f" per-atom = {e_per_atom:.4f} eV/atom")print(f" DFT (PBE) ref ≈ -4.0 eV/atom for FCC Cu; M3GNet should land near it.")exceptExceptionas exc: # noqa: BLE001 — stretch goal, optional depprint(f"F.4 skipped: {type(exc).__name__}: {exc}")print("Install `matgl` with `pip install matgl` to run this block.")
F.4 skipped: ModuleNotFoundError: No module named 'matgl'
Install `matgl` with `pip install matgl` to run this block.
Part F deliverables.
F.1: edge count 12 at r_cut=3.0 Å and the printed shell table for r_cut ∈ {2.6, 3.0, 3.7, 4.5} Å.
F.2: the RBF-basis plot with the FCC nn distance overlay.
F.3: the over-smoothing plot — feature-0 trajectories for the 3 nodes over 5 rounds.
F.4 (optional): the M3GNet per-atom energy for Cu, or the matgl not installed skip message.
Bring two questions to Thursday. One conceptual question about message passing (what’s the role of W_self vs W_neigh?) and one practical question about graph construction (when does a single r_cut value break — which crystal classes need a chemistry-aware cutoff like CrystalNN?). Pick whichever question kept you stuck longest in F.1–F.3.
Part G — MFML extensions: KL divergence and robust likelihoods
MFML W8 hits two ideas the rest of this homework only brushes past: the KL divergence between two Gaussians (used later for the VAE ELBO in Week 11), and Student’s t as a heavy-tailed likelihood that survives outliers better than a Gaussian. Each part is short — the goal is to do the algebra once with your hands so the symbols are yours when they reappear.
G.1 — KL divergence between two univariate Gaussians
The closed form (MFML §“KL divergence between Gaussians”) is:
Implement it in one line, then evaluate it on three contrasting pairs and read off the three intuitions the formula encodes: (i) KL is zero when the distributions match, (ii) KL grows with the squared mean shift, (iii) KL is asymmetric — swapping the arguments generally changes the answer.
G.1 deliverable. The three-row table above. Check: the identical row prints 0.0 in both columns. The shifted mean row is symmetric (both directions give the same number when only the mean differs and the variances match). The scaled variance row is not symmetric — that asymmetry is why the VAE ELBO uses \(\mathrm{KL}(q\|p)\) and not the other direction.
G.2 — Student’s t fits the outliers that ruin a Gaussian
MFML §“Robustness: Student’s t”. On real lab data the Gaussian likelihood used in Part A breaks the moment a few measurements are corrupted (sensor glitch, mislabeled specimen, transcription typo). A Student’s \(t\)-likelihood with low degrees-of-freedom \(\nu\) has heavier tails and pulls less hard on outliers.
Take the degree-1 residuals from a single tensile temperature, inject ~5 % outliers at 3× the natural noise std, then fit both a Gaussian and a Student’s \(t\) by maximum likelihood and overlay them.
Gaussian fit: mu = -0.087 sigma = 12.508
Student-t fit: nu = 2.40 mu = +2.001 sigma = 7.039
G.2 deliverable. The printed \(\nu\) value plus the overlay plot. Check: the Student-\(t\) MLE should land at \(\nu \lesssim 5\) (heavy tails are needed); \(\sigma_t\) should be smaller than \(\sigma_g\) because the Gaussian is forced to inflate its scale to cover the outliers, while the \(t\) pushes them into the tail.
Reflection (one line). Which of your tensile-data predictions from Part C would have benefited most from a Student-\(t\) likelihood: the same-temperature 80/20 split, or the across-temperature split? Why?