diff --git a/.github/workflows/notebook-smoke.yml b/.github/workflows/notebook-smoke.yml
new file mode 100644
index 0000000..5fe207f
--- /dev/null
+++ b/.github/workflows/notebook-smoke.yml
@@ -0,0 +1,77 @@
+name: Notebook smoke
+
+on:
+ pull_request:
+ paths:
+ - "_notebooks/**/*.ipynb"
+ - "scripts/smoke_notebooks.py"
+ - "requirements-smoke.txt"
+ - ".github/workflows/notebook-smoke.yml"
+ schedule:
+ # Weekly full suite (Sunday 04:00 UTC)
+ - cron: "0 4 * * 0"
+ workflow_dispatch:
+ inputs:
+ only:
+ description: "Optional notebook prefixes (space-separated), e.g. 01 13. Empty = all."
+ required: false
+ default: ""
+
+concurrency:
+ group: notebook-smoke-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ smoke:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: requirements-smoke.txt
+
+ - name: Install smoke dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -r requirements-smoke.txt
+
+ - name: Cache pods / notebook data downloads
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.pods_data
+ _notebooks/data
+ key: pods-data-${{ runner.os }}-${{ hashFiles('requirements-smoke.txt') }}
+ restore-keys: |
+ pods-data-${{ runner.os }}-
+
+ - name: Run notebook smoke tests
+ env:
+ MPLBACKEND: Agg
+ JUPYTER_PLATFORM_DIRS: "1"
+ run: |
+ set -euo pipefail
+ ONLY="${{ github.event.inputs.only }}"
+ if [ -n "${ONLY}" ]; then
+ # shellcheck disable=SC2086
+ python scripts/smoke_notebooks.py --only ${ONLY}
+ else
+ python scripts/smoke_notebooks.py
+ fi
+
+ - name: Upload smoke results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: notebook-smoke-results
+ path: |
+ _notebooks/test-results/summary.json
+ _notebooks/test-results/*.ok
+ _notebooks/test-results/*.fail
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index 0715c8a..e5e5522 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,7 @@ _site/
# Ruby bundler
.bundle/
+
+# Local Python env and notebook smoke artefacts
+.venv/
+_notebooks/test-results/
diff --git a/README.md b/README.md
index 1598c45..a558abe 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,20 @@ Deep GP notebooks install teaching helpers via LaMD `\installcode{}` cells, typi
Pinball and related Deep GP teaching methods live in [`lawrennd/mlai`](https://github.com/lawrennd/mlai), not in PyDeepGP itself; lecture setup monkey-patches them onto `deepgp.DeepGP`.
+## Notebook smoke tests
+
+Execute every numbered lecture notebook under `_notebooks/` with CI-safe patches (Agg backend, mocked stdin, capped `optimize`, no widget hangs):
+
+```bash
+python -m venv .venv
+source .venv/bin/activate
+pip install -r requirements-smoke.txt
+python scripts/smoke_notebooks.py
+# subset: python scripts/smoke_notebooks.py --only 01 13
+```
+
+GitHub Actions runs the same harness on pull requests that touch notebooks or the smoke files, weekly on a schedule, and via **workflow_dispatch** (optional `--only` input). Results land in `_notebooks/test-results/` (gitignored) and as a workflow artifact.
+
## Layout
| Path | Role |
diff --git a/_lamd/_lamd.yml b/_lamd/_lamd.yml
index eeacb38..46a4ea2 100644
--- a/_lamd/_lamd.yml
+++ b/_lamd/_lamd.yml
@@ -10,7 +10,7 @@ layout: lecture
venue: Gaussian Process Summer School
talktheme: white
talkcss: mlatcl.css
-talkfontsize: small
+talkfontsize: smaller
bibdir: $HOME/lawrennd/bibliography/
people: ../_people/people.yml
postsdir: ../_lectures/
diff --git a/requirements-smoke.txt b/requirements-smoke.txt
new file mode 100644
index 0000000..db084a3
--- /dev/null
+++ b/requirements-smoke.txt
@@ -0,0 +1,27 @@
+# Runtime + execution harness for lecture notebook smoke tests.
+# Install: pip install -r requirements-smoke.txt
+
+# Notebook execution
+nbclient>=0.10,<1
+nbformat>=5.10,<6
+ipykernel>=6.29
+ipywidgets>=8.1
+ipython>=8.0
+
+# Teaching stack (match README notebook dependencies)
+GPy>=1.13
+git+https://github.com/SheffieldML/PyDeepGP.git#egg=DGP
+git+https://github.com/lawrennd/mlai.git#egg=mlai
+git+https://github.com/lawrennd/ods.git#egg=pods
+git+https://github.com/lawrennd/notutils.git#egg=notutils
+
+# Used by lecture notebooks
+matplotlib>=3.8
+numpy>=1.26
+scipy>=1.11
+pandas>=2.0
+scikit-learn>=1.3
+emukit>=0.4
+Pillow>=10.0
+# PGM diagrams (import name is still `daft`; PyPI `daft` is unrelated)
+daft-pgm>=0.1.2
diff --git a/scripts/smoke_notebooks.py b/scripts/smoke_notebooks.py
new file mode 100644
index 0000000..17d1563
--- /dev/null
+++ b/scripts/smoke_notebooks.py
@@ -0,0 +1,338 @@
+#!/usr/bin/env python3
+"""Execute GPSS lecture notebooks end-to-end for smoke testing.
+
+CI-oriented defaults:
+ - matplotlib Agg backend
+ - mock stdin (pods license, catapult distance, …)
+ - short-circuit ipywidgets.interact / notutils.display_plots (avoid hangs)
+ - cap GPy / DeepGP optimize iterations
+ - blank incomplete exercise cells that are not valid Python
+
+Usage (from repo root)::
+
+ python scripts/smoke_notebooks.py
+ python scripts/smoke_notebooks.py --only 01 02
+ python scripts/smoke_notebooks.py --notebooks-dir _notebooks --results-dir _notebooks/test-results
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import json
+import os
+import sys
+import time
+import traceback
+from copy import deepcopy
+from pathlib import Path
+
+import nbformat
+from nbclient import NotebookClient
+from nbclient.exceptions import CellExecutionError
+
+# Injected as the first code cell of every notebook under test.
+PRELUDE = r"""
+import builtins
+import numpy as np
+
+if not hasattr(np, "NaN"):
+ np.NaN = np.nan
+
+
+def _mock_input(prompt=""):
+ p = str(prompt).lower()
+ if "distance" in p:
+ print(f"[mock input] {prompt!r} -> 50.0")
+ return "50.0"
+ if (
+ (not prompt)
+ or ("yes" in p)
+ or ("proceed" in p)
+ or ("continue" in p)
+ or ("[y/n]" in p)
+ or ("[yes/no]" in p)
+ ):
+ print(f"[mock input] {prompt!r} -> y")
+ return "y"
+ print(f"[mock input] {prompt!r} -> 1.0")
+ return "1.0"
+
+
+builtins.input = _mock_input
+try:
+ ip = get_ipython()
+ if ip is not None and getattr(ip, "kernel", None) is not None:
+ ip.kernel._allow_stdin = True
+ ip.kernel.raw_input = _mock_input
+except Exception as e:
+ print("stdin patch skipped", e)
+
+import matplotlib
+
+matplotlib.use("Agg")
+
+# Lecture notebooks write SVGs under these relative dirs (cwd = notebooks dir).
+from pathlib import Path as _Path
+
+for _d in (
+ "./datasets",
+ "./deepgp",
+ "./deepnn",
+ "./dimred",
+ "./gp",
+ "./gplvm",
+ "./kern",
+ "./ml",
+ "./simulation",
+ "./uq",
+):
+ _Path(_d).mkdir(parents=True, exist_ok=True)
+
+
+# Avoid ipywidgets.interact deadlocks under nbclient (lecture 02).
+def _once_interact(func, **kwargs):
+ call = {}
+ for k, v in kwargs.items():
+ if hasattr(v, "value"):
+ call[k] = v.value
+ elif (
+ isinstance(v, tuple)
+ and len(v) >= 2
+ and all(isinstance(x, (int, float)) for x in v[:3] if not isinstance(x, bool))
+ ):
+ call[k] = v[0]
+ else:
+ call[k] = v
+ try:
+ return func(**call)
+ except Exception as e:
+ print("[smoke] interact once failed:", e)
+
+
+try:
+ import ipywidgets
+
+ ipywidgets.interact = _once_interact
+except Exception as e:
+ print("ipywidgets patch skipped", e)
+
+try:
+ import notutils
+
+ notutils.display_plots = lambda *a, **k: print(
+ "[smoke] skip display_plots", a[:1] if a else ""
+ )
+ if hasattr(notutils, "display_prediction"):
+
+ def _skip_pred(*a, **k):
+ print("[smoke] skip display_prediction")
+
+ notutils.display_prediction = _skip_pred
+except Exception as e:
+ print("notutils patch skipped", e)
+
+
+def _cap_optimize(cls, method_name="optimize", max_iters=40):
+ if not hasattr(cls, method_name):
+ return
+ orig = getattr(cls, method_name)
+
+ def wrapped(self, *args, **kwargs):
+ if "max_iters" in kwargs and kwargs["max_iters"] is not None:
+ kwargs["max_iters"] = min(int(kwargs["max_iters"]), max_iters)
+ else:
+ kwargs["max_iters"] = max_iters
+ if "messages" in kwargs:
+ kwargs["messages"] = False
+ if "max_f_eval" in kwargs and kwargs["max_f_eval"] is not None:
+ kwargs["max_f_eval"] = min(int(kwargs["max_f_eval"]), max_iters)
+ return orig(self, *args, **kwargs)
+
+ setattr(cls, method_name, wrapped)
+
+
+try:
+ import GPy
+
+ _cap_optimize(GPy.core.gp.GP, "optimize", 40)
+except Exception as e:
+ print("GPy patch skipped", e)
+
+try:
+ import deepgp
+
+ if hasattr(deepgp, "DeepGP"):
+ _cap_optimize(deepgp.DeepGP, "optimize", 25)
+ if hasattr(deepgp.DeepGP, "staged_optimize"):
+ orig = deepgp.DeepGP.staged_optimize
+
+ def staged(self, *args, **kwargs):
+ kwargs["messages"] = False
+ if "iters" in kwargs:
+ it = kwargs["iters"]
+ if isinstance(it, (list, tuple)):
+ kwargs["iters"] = tuple(min(int(x), 15) for x in it)
+ return orig(self, *args, **kwargs)
+
+ deepgp.DeepGP.staged_optimize = staged
+except Exception as e:
+ print("deepgp patch skipped", e)
+"""
+
+
+def blank_incomplete_exercises(nb: nbformat.NotebookNode) -> None:
+ for cell in nb.cells:
+ if cell.get("cell_type") != "code":
+ continue
+ src = cell.get("source", "")
+ if isinstance(src, list):
+ src = "".join(src)
+ if "Write code for you answer" not in src and "Write your answer to Exercise" not in src:
+ continue
+ try:
+ ast.parse(src)
+ except SyntaxError:
+ cell["source"] = "# skipped incomplete exercise cell for smoke test\npass\n"
+
+
+def prepare(nb: nbformat.NotebookNode) -> nbformat.NotebookNode:
+ nb = deepcopy(nb)
+ blank_incomplete_exercises(nb)
+ nb.cells.insert(0, nbformat.v4.new_code_cell(PRELUDE))
+ return nb
+
+
+def list_notebooks(nb_dir: Path, only: list[str] | None) -> list[str]:
+ names = sorted(p.name for p in nb_dir.glob("[0-9][0-9]-*.ipynb"))
+ if not only:
+ return names
+ selected = []
+ for token in only:
+ token = token.strip()
+ matches = [n for n in names if n.startswith(token) or n == token]
+ if not matches:
+ raise SystemExit(f"No notebook matches --only {token!r} under {nb_dir}")
+ for m in matches:
+ if m not in selected:
+ selected.append(m)
+ return selected
+
+
+def run_one(
+ name: str,
+ nb_dir: Path,
+ out_dir: Path,
+ default_timeout: int,
+ lecture02_timeout: int,
+) -> dict:
+ path = nb_dir / name
+ print(f"\n======== {name} ========", flush=True)
+ t0 = time.time()
+ try:
+ nb = nbformat.read(path, as_version=4)
+ nb = prepare(nb)
+ timeout = lecture02_timeout if name.startswith("02-") else default_timeout
+ client = NotebookClient(
+ nb,
+ timeout=timeout,
+ kernel_name="python3",
+ resources={"metadata": {"path": str(nb_dir)}},
+ allow_errors=False,
+ allow_stdin=True,
+ )
+ client.execute()
+ elapsed = time.time() - t0
+ print(f"OK ({elapsed:.1f}s)", flush=True)
+ (out_dir / f"{name}.ok").write_text(f"OK {elapsed:.1f}s\n")
+ fail = out_dir / f"{name}.fail"
+ if fail.exists():
+ fail.unlink()
+ return {"name": name, "status": "OK", "elapsed": elapsed, "error": ""}
+ except CellExecutionError as e:
+ elapsed = time.time() - t0
+ msg = str(e)
+ print(f"FAIL ({elapsed:.1f}s)\n{msg[:4000]}", flush=True)
+ (out_dir / f"{name}.fail").write_text(msg)
+ return {"name": name, "status": "FAIL", "elapsed": elapsed, "error": msg}
+ except Exception:
+ elapsed = time.time() - t0
+ msg = traceback.format_exc()
+ print(f"ERROR ({elapsed:.1f}s)\n{msg[:4000]}", flush=True)
+ (out_dir / f"{name}.fail").write_text(msg)
+ return {"name": name, "status": "ERROR", "elapsed": elapsed, "error": msg}
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--notebooks-dir",
+ type=Path,
+ default=Path("_notebooks"),
+ help="Directory containing NN-*.ipynb lecture notebooks",
+ )
+ parser.add_argument(
+ "--results-dir",
+ type=Path,
+ default=Path("_notebooks/test-results"),
+ help="Where to write .ok/.fail markers and summary JSON",
+ )
+ parser.add_argument(
+ "--only",
+ nargs="+",
+ default=None,
+ help="Restrict to notebooks whose names start with these prefixes (e.g. 01 13)",
+ )
+ parser.add_argument(
+ "--timeout",
+ type=int,
+ default=900,
+ help="Per-notebook cell execution timeout in seconds (default 900)",
+ )
+ parser.add_argument(
+ "--timeout-02",
+ type=int,
+ default=600,
+ help="Timeout for lecture 02 (widget-heavy; default 600 with smoke patches)",
+ )
+ parser.add_argument(
+ "--summary-name",
+ default="summary.json",
+ help="Summary JSON filename under --results-dir",
+ )
+ args = parser.parse_args(argv)
+
+ repo_root = Path.cwd()
+ nb_dir = (repo_root / args.notebooks_dir).resolve()
+ out_dir = (repo_root / args.results_dir).resolve()
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ mpl_dir = out_dir / "mpl"
+ mpl_dir.mkdir(parents=True, exist_ok=True)
+ os.environ.setdefault("MPLBACKEND", "Agg")
+ os.environ.setdefault("JUPYTER_PLATFORM_DIRS", "1")
+ os.environ.setdefault("MPLCONFIGDIR", str(mpl_dir))
+
+ if not nb_dir.is_dir():
+ print(f"Notebooks directory not found: {nb_dir}", file=sys.stderr)
+ return 2
+
+ order = list_notebooks(nb_dir, args.only)
+ print("Notebooks:", order, flush=True)
+
+ results = []
+ for name in order:
+ result = run_one(name, nb_dir, out_dir, args.timeout, args.timeout_02)
+ results.append(result)
+ (out_dir / args.summary_name).write_text(json.dumps(results, indent=2) + "\n")
+
+ print("\n==== SUMMARY ====")
+ for r in results:
+ print(f"{r['status']:5} {r['elapsed']:7.1f}s {r['name']}")
+ n_ok = sum(r["status"] == "OK" for r in results)
+ print(f"{n_ok}/{len(results)} passed")
+ return 0 if n_ok == len(results) and results else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/slides/01-uncertainty-and-modelling.slides.html b/slides/01-uncertainty-and-modelling.slides.html
index e0f2995..ba5c5e7 100644
--- a/slides/01-uncertainty-and-modelling.slides.html
+++ b/slides/01-uncertainty-and-modelling.slides.html
@@ -28,7 +28,7 @@
-
+