From 49734cd1930fe64622735a0cc82afc9ff325335d Mon Sep 17 00:00:00 2001 From: Neil Lawrence Date: Sat, 17 Oct 2026 18:42:12 +0100 Subject: [PATCH 1/3] Add lecture notebook smoke harness and GitHub Actions workflow. Pins a CI-safe nbclient runner with stdin/widget/optimize caps so PRs that touch notebooks can catch runtime regressions without hanging on interactive cells. --- .github/workflows/notebook-smoke.yml | 77 +++++++ .gitignore | 4 + README.md | 14 ++ requirements-smoke.txt | 26 +++ scripts/smoke_notebooks.py | 321 +++++++++++++++++++++++++++ 5 files changed, 442 insertions(+) create mode 100644 .github/workflows/notebook-smoke.yml create mode 100644 requirements-smoke.txt create mode 100644 scripts/smoke_notebooks.py 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/requirements-smoke.txt b/requirements-smoke.txt new file mode 100644 index 0000000..1c3c9ee --- /dev/null +++ b/requirements-smoke.txt @@ -0,0 +1,26 @@ +# 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 +daft>=0.1.2 diff --git a/scripts/smoke_notebooks.py b/scripts/smoke_notebooks.py new file mode 100644 index 0000000..1243d66 --- /dev/null +++ b/scripts/smoke_notebooks.py @@ -0,0 +1,321 @@ +#!/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") + + +# 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()) From e4067212384881360161716b1ad914e931fdfcd2 Mon Sep 17 00:00:00 2001 From: Neil Lawrence Date: Sat, 17 Oct 2026 18:45:56 +0100 Subject: [PATCH 2/3] Use extra-small slide font size so dense maths fits better. --- _lamd/_lamd.yml | 2 +- slides/01-uncertainty-and-modelling.slides.html | 2 +- slides/02-gaussian-distributions-to-processes.slides.html | 2 +- slides/03-covariance-functions.slides.html | 2 +- slides/04-optimizing-parameters.slides.html | 2 +- slides/05-multi-output-gps.slides.html | 2 +- slides/06-approximate-gps.slides.html | 2 +- slides/07-non-gaussian-likelihoods.slides.html | 2 +- slides/08-unsupervised-learning-with-gps.slides.html | 2 +- slides/09-latent-force-models.slides.html | 2 +- slides/10-bayesian-learning-gplvm.slides.html | 2 +- slides/11-deep-gps-I.slides.html | 2 +- slides/12-deep-gps-II.slides.html | 2 +- slides/13-emulation.slides.html | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) 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/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 @@ - +