From e935890e2cdee9f5d33071ce68848bfe7257a556 Mon Sep 17 00:00:00 2001 From: Cmanjah <116933114+Cmanjah@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:28:09 +0000 Subject: [PATCH] Add self-contained Colab notebook for the digit tutorial (POC) Rework the digit domain-adaptation tutorial.ipynb into a self-contained, lightweight notebook that runs top-to-bottom on a free CPU and opens directly in Google Colab: - Inline the config and model build; no sibling imports (config.py/model.py/configs), so Colab -- which loads only the .ipynb -- can run it. - Clean Colab install cell: pip install "pykale[image]" matplotlib, no-op elsewhere. - Plain-language narrative; ~3 epochs on a data subset (a few minutes on CPU). - Verified end-to-end headless: runs with no errors, USPS target accuracy ~0.73. Wire the site to it: tutorial_actions points Colab/Notebook at each example's own tutorial.ipynb and enables the buttons per-tutorial when that file exists (conf.py checks for it), so the rollout to the other examples is one notebook at a time. 96 tests pass; validate and the strict -W build are green. --- conf.py | 6 +- .../digit_domain_adaptation/tutorial.ipynb | 786 +++++++----------- scripts/tutorial_actions.py | 53 +- tests/test_tutorial_actions.py | 18 +- 4 files changed, 329 insertions(+), 534 deletions(-) diff --git a/conf.py b/conf.py index 26be3e0..e6f2d0f 100644 --- a/conf.py +++ b/conf.py @@ -224,12 +224,14 @@ def _nav_context(app, pagename, templatename, context, doctree): if pagename in _LANDING_PAGES: context["body_class"] = "pky-landing" - # Tutorial pages: derive the action-toolbar links from the catalogue path. + # Tutorial pages: derive the action-toolbar links from the catalogue path. A tutorial's Colab and + # Notebook buttons go live as soon as it ships a self-contained tutorial.ipynb in its own folder. mapped = gallery_pagename_to_tutorial(pagename) if mapped: rel_path, slug = mapped draft = _DRAFT_BY_SLUG.get(slug, False) - context["pky_tutorial"] = tutorial_actions(rel_path, slug, draft=draft) + has_notebook = (REPO_ROOT / rel_path / "tutorial.ipynb").exists() + context["pky_tutorial"] = tutorial_actions(rel_path, slug, draft=draft, has_notebook=has_notebook) context["pky_tutorial_draft"] = draft # Left-sidebar context label ("Tutorials in General" / "More General tutorials"), data-driven. diff --git a/others/image_classification/digit_domain_adaptation/tutorial.ipynb b/others/image_classification/digit_domain_adaptation/tutorial.ipynb index 797311e..9b2d693 100644 --- a/others/image_classification/digit_domain_adaptation/tutorial.ipynb +++ b/others/image_classification/digit_domain_adaptation/tutorial.ipynb @@ -1,502 +1,288 @@ { - "nbformat": 4, - "nbformat_minor": 5, - "metadata": {}, - "cells": [ - { - "metadata": {}, - "source": [ - "# PyKale Tutorial: Domain Adaptation on Digits with Lightning\n", - "\n", - "| [Open in Colab](https://colab.research.google.com/github/pykale/pykale/blob/main/examples/digits_dann/tutorial.ipynb) (click `Runtime`\u2006\u2192\u2006`Run all (Ctrl+F9)` | [Launch Binder](https://mybinder.org/v2/gh/pykale/pykale/HEAD?filepath=examples%2Fdigits_dann%2Ftutorial.ipynb) (click `Run`\u2006\u2192\u2006`Run All Cells`) |\n", - "\n", - "If using [Google Colab](https://colab.research.google.com), a free GPU can be enabled to save time via setting `Runtime`\u2006\u2192\u2006`Change runtime type` \u2192 `Hardware accelerator: GPU`\n", - "\n", - "## Introduction\n", - "\n", - "[Domain Adaptation](https://en.wikipedia.org/wiki/Domain_adaptation) takes a model trained and evaluated on one set of data (the source) and adapts it to another (the target). In this tutorial, a model is trained on one digits dataset (source) and adapted to another (target). This tutorial is constructed based on the `digits_dann` example `main.py`, which is in turn refactored from the [ADA: (Yet) Another Domain Adaptation library](https://github.com/criteo-research/pytorch-ada). It has been put together to run interactively on online hosting platforms including [Google Colab](https://colab.research.google.com) or [myBinder](https://mybinder.org), but can also be downloaded and run locally. Follow the [PyKale installation instructions](https://pykale.readthedocs.io/en/latest/installation.html) for this." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "## Setup\n", - "\n", - "The first few blocks of code are necessary to set up the notebook execution environment and import the required modules, including PyKale.\n", - "\n", - "This checks if the notebook is running on Google Colab and installs required packages." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "if 'google.colab' in str(get_ipython()):\n", - " print('Running on CoLab')\n", - " !pip uninstall --yes imgaug && pip uninstall --yes albumentations && pip install git+https://github.com/aleju/imgaug.git\n", - " !pip install numpy>=2.0.0\n", - " !git clone https://github.com/pykale/pykale.git\n", - " %cd pykale\n", - " !pip install .[image,example] \n", - " %cd examples/digits_dann\n", - " !pip install tensorboard\n", - "else:\n", - " print('Not running on CoLab')" - ], - "cell_type": "code", - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "source": [ - "This imports required modules." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "from config import get_cfg_defaults\n", - "import pytorch_lightning as pl\n", - "import torch\n", - "\n", - "from model import get_model\n", - "from pytorch_lightning.callbacks import ModelCheckpoint, TQDMProgressBar\n", - "from pytorch_lightning.loggers import TensorBoardLogger\n", - "\n", - "from kale.loaddata.image_access import DigitDataset, ImageAccess\n", - "from kale.loaddata.multi_domain import MultiDomainAccess, MultiDomainDataset\n", - "from kale.utils.seed import set_seed" - ], - "cell_type": "code", - "outputs": [ - { - "output_type": "stream", - "name": "stderr", - "text": [ - "/home/shuo/anaconda3/envs/pykale/lib/python3.12/site-packages/torch/cuda/__init__.py:619: UserWarning: Can't initialize NVML\n", - " warnings.warn(\"Can't initialize NVML\")\n" - ] - } - ], - "execution_count": null - }, - { - "metadata": {}, - "source": [ - "## Configuration\n", - "\n", - "In this tutorial we modify the [default configuration for domain adaptation problems](https://github.com/pykale/pykale/blob/main/examples/digits_dann/config.py) with a customized [`.yaml` file for the specific application in this tutorial](https://github.com/pykale/pykale/blob/main/examples/digits_dann/configs/TUTORIAL.yaml). The configuration is summarized below the following cell." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "cfg_path = \"./configs/tutorial.yaml\" # Path to `.yaml` config file\n", - "\n", - "cfg = get_cfg_defaults()\n", - "cfg.merge_from_file(cfg_path)\n", - "cfg.freeze()\n", - "print(cfg)" - ], - "cell_type": "code", - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "COMET:\n", - " API_KEY: \n", - " ENABLE: False\n", - " EXPERIMENT_NAME: DigitDANN\n", - " PROJECT_NAME: Digit DANN\n", - "DAN:\n", - " METHOD: DANN\n", - " RANDOM_DIM: 1024\n", - " USERANDOM: False\n", - "DATASET:\n", - " DIMENSION: 784\n", - " NAME: digits\n", - " NUM_CLASSES: 10\n", - " NUM_REPEAT: 1\n", - " ROOT: ./data\n", - " SIZE_TYPE: source\n", - " SOURCE: mnist\n", - " TARGET: usps\n", - " VALID_SPLIT_RATIO: 0.5\n", - " WEIGHT_TYPE: natural\n", - "OUTPUT:\n", - " OUT_DIR: outputs\n", - " PB_FRESH: 0\n", - " VERBOSE: False\n", - "SOLVER:\n", - " AD_LAMBDA: True\n", - " AD_LR: True\n", - " BASE_LR: 0.001\n", - " INIT_LAMBDA: 1.0\n", - " LOG_EVERY_N_STEPS: 10\n", - " MAX_EPOCHS: 3\n", - " MIN_EPOCHS: 0\n", - " MOMENTUM: 0.9\n", - " NESTEROV: True\n", - " NUM_WORKERS: 0\n", - " SEED: 2025\n", - " TEST_BATCH_SIZE: 200\n", - " TRAIN_BATCH_SIZE: 150\n", - " TYPE: SGD\n", - " WEIGHT_DECAY: 0.0005\n" - ] - } - ], - "execution_count": null - }, - { - "metadata": {}, - "source": [ - "## Check if a GPU is available\n", - "\n", - "If a CUDA GPU is available, this should be used to accelerate the training process. The code below checks and reports on this." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "device = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n", - "print(\"Using: \" + device)\n", - "devices = 1 if device == \"cuda\" else 0" - ], - "cell_type": "code", - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Using: cpu\n" - ] - } - ], - "execution_count": null - }, - { - "metadata": {}, - "source": [ - "## Select Datasets\n", - "\n", - "Source and target datasets are specified using `DigitDataset.get_source_target` from values in the configuration (`cfg`) above. In this tutorial, we specify a subset of classes (1, 3 and 8) to make training and testing quicker." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "# Use the MultiDomainAccess to customize a multidomain data access\n", - "data_src = DigitDataset(cfg.DATASET.SOURCE.upper())\n", - "data_tgt = DigitDataset(cfg.DATASET.TARGET.upper())\n", - "num_channels = max(DigitDataset.get_channel_numbers(data_src), DigitDataset.get_channel_numbers(data_tgt))\n", - "data_access = MultiDomainAccess(\n", - " {\n", - " cfg.DATASET.SOURCE.upper(): DigitDataset.get_access(data_src, cfg.DATASET.ROOT)[0],\n", - " cfg.DATASET.TARGET.upper(): DigitDataset.get_access(data_tgt, cfg.DATASET.ROOT)[0],\n", - " },\n", - " cfg.DATASET.NUM_CLASSES,\n", - " return_domain_label=True,\n", - ")\n", - "# Alternatively, use the ImageAccess API to create a multidomain data access for digits data\n", - "# data_access = ImageAccess.get_multi_domain_images(\n", - "# \"DIGITS\",\n", - "# cfg.DATASET.ROOT,\n", - "# sub_domain_set=[cfg.DATASET.SOURCE.upper(), cfg.DATASET.TARGET.upper()],\n", - "# return_domain_label=True,\n", - "# )\n", - "dataset = MultiDomainDataset(data_access)" - ], - "cell_type": "code", - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "source": "cfg.DATASET.SOURCE.upper()", - "cell_type": "code", - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "source": [ - "## Set Seed\n", - "\n", - "Some algorithms used in model training require generation of pseudo-random numbers. Setting the seed from which these are generated ensures reproducibility." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "seed = cfg.SOLVER.SEED\n", - "# seed_everything in pytorch_lightning did not set torch.backends.cudnn\n", - "set_seed(seed)" - ], - "cell_type": "code", - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "source": [ - "## Setup Model\n", - "\n", - "Here, we use the previously defined configuration and dataset to set up the model we will subsequently train." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "%time model, train_params = get_model(cfg, dataset, num_channels)" - ], - "cell_type": "code", - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "CPU times: user 6.1 s, sys: 304 ms, total: 6.4 s\n", - "Wall time: 6.21 s\n" - ] - } - ], - "execution_count": null - }, - { - "metadata": {}, - "source": [ - "Output reports on data file use." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "## Setup Logger\n", - "\n", - "A Tensorboard logger is used to store output generated during model training. This information can be used to assess the effectiveness of the training and to identify problems. The output model is stored at `cfg.OUTPUT.OUT_DIR`." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "tb_logger = TensorBoardLogger(cfg.OUTPUT.OUT_DIR, name=\"seed{}\".format(seed))" - ], - "cell_type": "code", - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "source": [ - "## Setup Checkpoint\n", - "\n", - "A `ModelCheckpoint` is used to save the model and some quantitative measure(s) periodically." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "checkpoint_callback = ModelCheckpoint(filename=\"{epoch}-{step}-{valid_loss:.4f}\", monitor=\"valid_loss\", mode=\"min\",)" - ], - "cell_type": "code", - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "source": [ - "A `TQDMProgressBar` is used to set the progress bar. `PB_FRESH` determines at which rate (in number of batches) the progress bars get updated. Set it to ``0`` to disable the display." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "progress_bar = TQDMProgressBar(cfg.OUTPUT.PB_FRESH)" - ], - "cell_type": "code", - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "source": [ - "## Setup Trainer\n", - "\n", - "A trainer object is used to determine and store model parameters. Here, one is configured with information on how a model should be trained, and what hardware will be used." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "trainer = pl.Trainer(\n", - " min_epochs=cfg.SOLVER.MIN_EPOCHS,\n", - " max_epochs=cfg.SOLVER.MAX_EPOCHS,\n", - " callbacks=[checkpoint_callback, progress_bar],\n", - " logger=tb_logger,\n", - " accelerator=\"gpu\" if devices != 0 else \"cpu\")" - ], - "cell_type": "code", - "outputs": [ - { - "output_type": "stream", - "name": "stderr", - "text": [ - "GPU available: False, used: False\n", - "TPU available: False, using: 0 TPU cores\n", - "HPU available: False, using: 0 HPUs\n" - ] - } - ], - "execution_count": null - }, - { - "metadata": {}, - "source": [ - "Output reports on available GPU and TPU resources." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "## Train Model\n", - "\n", - "Optimize model parameters using the trainer." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "%time trainer.fit(model)" - ], - "cell_type": "code", - "outputs": [ - { - "output_type": "stream", - "name": "stderr", - "text": [ - "Missing logger folder: outputs/seed2025\n", - "\n", - " | Name | Type | Params | Mode \n", - "------------------------------------------------------------------\n", - "0 | feat | SmallCNNFeature | 309 K | train\n", - "1 | classifier | ClassNetSmallImage | 24.4 K | train\n", - "2 | domain_classifier | DomainNetSmallImage | 13.3 K | train\n", - "------------------------------------------------------------------\n", - "347 K Trainable params\n", - "0 Non-trainable params\n", - "347 K Total params\n", - "1.389 Total estimated model params size (MB)\n", - "/home/shuo/anaconda3/envs/pykale/lib/python3.12/site-packages/pytorch_lightning/trainer/connectors/data_connector.py:424: The 'val_dataloader' does not have many workers which may be a bottleneck. Consider increasing the value of the `num_workers` argument` to `num_workers=19` in the `DataLoader` to improve performance.\n", - "/home/shuo/anaconda3/envs/pykale/lib/python3.12/site-packages/pytorch_lightning/trainer/connectors/data_connector.py:424: The 'train_dataloader' does not have many workers which may be a bottleneck. Consider increasing the value of the `num_workers` argument` to `num_workers=19` in the `DataLoader` to improve performance.\n", - "`Trainer.fit` stopped: `max_epochs=3` reached.\n" - ] - }, - { - "output_type": "stream", - "name": "stdout", - "text": [ - "CPU times: user 15min 2s, sys: 0 ns, total: 15min 2s\n", - "Wall time: 1min 15s\n" - ] - } - ], - "execution_count": null - }, - { - "metadata": {}, - "source": [ - "## Test Optimized Model\n", - "\n", - "Check performance of model optimized with training data against test data which was not used in training." - ], - "cell_type": "markdown" - }, - { - "metadata": {}, - "source": [ - "# test scores\n", - "%time trainer.test()" - ], - "cell_type": "code", - "outputs": [ - { - "output_type": "stream", - "name": "stderr", - "text": [ - "/home/shuo/anaconda3/envs/pykale/lib/python3.12/site-packages/pytorch_lightning/trainer/connectors/checkpoint_connector.py:145: `.test(ckpt_path=None)` was called without a model. The best model of the previous `fit` call will be used. You can pass `.test(ckpt_path='best')` to use the best model or `.test(ckpt_path='last')` to use the last model. If you pass a value, this warning will be silenced.\n", - "Restoring states from the checkpoint path at outputs/seed2025/version_0/checkpoints/epoch=2-step=1212-valid_loss=1.5365.ckpt\n", - "Loaded model weights from the checkpoint at outputs/seed2025/version_0/checkpoints/epoch=2-step=1212-valid_loss=1.5365.ckpt\n", - "/home/shuo/anaconda3/envs/pykale/lib/python3.12/site-packages/pytorch_lightning/trainer/connectors/data_connector.py:424: The 'test_dataloader' does not have many workers which may be a bottleneck. Consider increasing the value of the `num_workers` argument` to `num_workers=19` in the `DataLoader` to improve performance.\n" - ] - }, - { - "output_type": "stream", - "name": "stdout", - "text": [ - "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", - " Test metric DataLoader 0\n", - "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", - " test_domain_acc 0.49561181434599155\n", - " test_domain_div_loss 1.392730712890625\n", - " test_loss 1.525232195854187\n", - " test_source_acc 0.9740084388185654\n", - " test_source_domain_acc 0.3648945147679325\n", - " test_target_acc 0.8388185654008439\n", - " test_target_domain_acc 0.6263291139240507\n", - " test_task_loss 0.13262920081615448\n", - "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", - "CPU times: user 21 s, sys: 2.88 ms, total: 21 s\n", - "Wall time: 1.76 s\n" - ] - }, - { - "output_type": "execute_result", - "data": { - "text/plain": [ - "[{'test_source_acc': 0.9740084388185654,\n", - " 'test_target_acc': 0.8388185654008439,\n", - " 'test_domain_acc': 0.49561181434599155,\n", - " 'test_source_domain_acc': 0.3648945147679325,\n", - " 'test_target_domain_acc': 0.6263291139240507,\n", - " 'test_loss': 1.525232195854187,\n", - " 'test_task_loss': 0.13262920081615448,\n", - " 'test_domain_div_loss': 1.392730712890625}]" - ] - }, - "metadata": {}, - "execution_count": 27 - } - ], - "execution_count": null - }, - { - "metadata": {}, - "source": [ - "Outputs are defined as:\n", - "\n", - "* 'test_domain_acc': Accuracy on classifying the domain (source or target) from which data came.\n", - "* 'test_source_acc': Accuracy on test data drawn from the source dataset.\n", - "* 'test_target_acc': Accuracy on test data drawn from the target dataset.\n", - "* 'test_loss': Loss function value on the test data." - ], - "cell_type": "markdown" - } - ] + "cells": [ + { + "cell_type": "markdown", + "id": "c13da8c5", + "metadata": {}, + "source": [ + "# Domain adaptation on digit images\n", + "\n", + "**Goal:** train a digit classifier on one dataset (MNIST) and make it work well on a *different* one\n", + "(USPS) — **without using any USPS labels**. That is the problem *domain adaptation* solves: the two\n", + "datasets show the same ten digits, but the images look different, so a model trained on one usually\n", + "stumbles on the other.\n", + "\n", + "In this tutorial you will:\n", + "\n", + "1. load MNIST (the **source**, with labels) and USPS (the **target**, labels hidden);\n", + "2. build a small network with a **DANN** adapter (Domain-Adversarial Neural Network);\n", + "3. train it, and check how well it reads the unlabelled USPS digits.\n", + "\n", + "It runs top-to-bottom in a few minutes on a free CPU — just press **Run in Colab** and go.\n", + "This is a lightweight teaching version; the full experiment lives next to this notebook in `main.py`." + ] + }, + { + "cell_type": "markdown", + "id": "d50a9a5b", + "metadata": {}, + "source": [ + "## 1. Set up PyKale\n", + "\n", + "On Google Colab, the next cell installs PyKale. Everywhere else it does nothing." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a85a7d76", + "metadata": {}, + "outputs": [], + "source": [ + "# On Google Colab this installs PyKale (with its image extras). It is a no-op elsewhere.\n", + "import importlib.util\n", + "import sys\n", + "\n", + "if \"google.colab\" in sys.modules and importlib.util.find_spec(\"kale\") is None:\n", + " !pip install --quiet \"pykale[image]\" matplotlib\n", + " print(\"PyKale installed. If an import fails, use Runtime > Restart session, then run this again.\")" + ] + }, + { + "cell_type": "markdown", + "id": "987ab3b6", + "metadata": {}, + "source": [ + "## 2. Imports\n", + "\n", + "Everything comes from PyKale (`kale.*`) and PyTorch Lightning — nothing from other files, so this\n", + "notebook is fully self-contained." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ebe9dd41", + "metadata": {}, + "outputs": [], + "source": [ + "import pytorch_lightning as pl\n", + "\n", + "import kale.pipeline.domain_adapter as domain_adapter\n", + "from kale.embed.image_cnn import SmallCNNFeature\n", + "from kale.loaddata.image_access import DigitDataset\n", + "from kale.loaddata.multi_domain import MultiDomainAccess, MultiDomainDataset\n", + "from kale.predict.class_domain_nets import ClassNetSmallImage, DomainNetSmallImage\n", + "from kale.utils.seed import set_seed" + ] + }, + { + "cell_type": "markdown", + "id": "a366100d", + "metadata": {}, + "source": [ + "## 3. Settings\n", + "\n", + "A handful of plain values instead of a config file, so you can see (and tweak) everything at a glance.\n", + "The epoch count is deliberately small for a quick run — raise `MAX_EPOCHS` for better accuracy." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7c2160da", + "metadata": {}, + "outputs": [], + "source": [ + "SOURCE = \"mnist\" # labelled dataset we train on\n", + "TARGET = \"usps\" # unlabelled dataset we want to do well on\n", + "NUM_CLASSES = 10 # digits 0-9\n", + "DATA_ROOT = \"./data\" # datasets download here on first run (MNIST ~12 MB, USPS ~2 MB)\n", + "\n", + "SEED = 2025\n", + "MAX_EPOCHS = 3 # small for a quick CPU run; the full example trains far longer\n", + "BATCH_SIZE = 150\n", + "BASE_LR = 0.001\n", + "\n", + "set_seed(SEED)" + ] + }, + { + "cell_type": "markdown", + "id": "0688d70b", + "metadata": {}, + "source": [ + "## 4. Load the data\n", + "\n", + "MNIST is the **source** (we use its labels); USPS is the **target** (we pretend we have no labels).\n", + "`MultiDomainDataset` serves images from both so the adapter can learn what is *common* to them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "972e1e81", + "metadata": {}, + "outputs": [], + "source": [ + "source_data = DigitDataset(SOURCE.upper())\n", + "target_data = DigitDataset(TARGET.upper())\n", + "\n", + "# Grayscale MNIST and USPS are 1-channel; this keeps the network's first layer right.\n", + "num_channels = max(\n", + " DigitDataset.get_channel_numbers(source_data),\n", + " DigitDataset.get_channel_numbers(target_data),\n", + ")\n", + "\n", + "data_access = MultiDomainAccess(\n", + " {\n", + " SOURCE.upper(): DigitDataset.get_access(source_data, DATA_ROOT)[0],\n", + " TARGET.upper(): DigitDataset.get_access(target_data, DATA_ROOT)[0],\n", + " },\n", + " NUM_CLASSES,\n", + " return_domain_label=True,\n", + ")\n", + "dataset = MultiDomainDataset(data_access)\n", + "print(f\"Loaded {SOURCE.upper()} (source) and {TARGET.upper()} (target).\")" + ] + }, + { + "cell_type": "markdown", + "id": "075a8292", + "metadata": {}, + "source": [ + "## 5. Build the model\n", + "\n", + "Three small pieces:\n", + "\n", + "- a **feature extractor** (`SmallCNNFeature`) that turns an image into a vector;\n", + "- a **classifier** that reads the digit from that vector;\n", + "- a **domain critic** that tries to tell *which dataset* an image came from.\n", + "\n", + "**DANN** trains the feature extractor to fool the critic — so the features it learns look the same for\n", + "MNIST and USPS. The classifier then works on both, even though it only ever saw MNIST labels." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3c395685", + "metadata": {}, + "outputs": [], + "source": [ + "feature_extractor = SmallCNNFeature(num_channels)\n", + "feature_dim = feature_extractor.output_size()\n", + "\n", + "task_classifier = ClassNetSmallImage(feature_dim, NUM_CLASSES)\n", + "domain_critic = DomainNetSmallImage(feature_dim)\n", + "\n", + "train_params = {\n", + " \"adapt_lambda\": True,\n", + " \"adapt_lr\": True,\n", + " \"lambda_init\": 1.0,\n", + " \"nb_adapt_epochs\": MAX_EPOCHS,\n", + " \"nb_init_epochs\": 0,\n", + " \"init_lr\": BASE_LR,\n", + " \"batch_size\": BATCH_SIZE,\n", + " \"num_workers\": 0,\n", + " \"optimizer\": {\n", + " \"type\": \"SGD\",\n", + " \"optim_params\": {\"momentum\": 0.9, \"weight_decay\": 0.0005, \"nesterov\": True},\n", + " },\n", + "}\n", + "\n", + "model = domain_adapter.create_dann_like(\n", + " method=domain_adapter.Method(\"DANN\"),\n", + " dataset=dataset,\n", + " feature_extractor=feature_extractor,\n", + " task_classifier=task_classifier,\n", + " critic=domain_critic,\n", + " target_domain=TARGET.upper(),\n", + " **train_params,\n", + ")\n", + "print(\"Built a DANN model.\")" + ] + }, + { + "cell_type": "markdown", + "id": "862a05e9", + "metadata": {}, + "source": [ + "## 6. Train\n", + "\n", + "The classifier learns digits from MNIST while the adapter quietly pulls the MNIST and USPS features\n", + "together. We train on a fraction of the data for speed — drop `limit_train_batches` for the full run." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "69f8a325", + "metadata": {}, + "outputs": [], + "source": [ + "trainer = pl.Trainer(\n", + " max_epochs=MAX_EPOCHS,\n", + " accelerator=\"cpu\",\n", + " devices=1,\n", + " limit_train_batches=0.2, # a fifth of each epoch -> a few minutes on CPU\n", + " limit_val_batches=0.2,\n", + " logger=False,\n", + " enable_checkpointing=False,\n", + ")\n", + "trainer.fit(model)" + ] + }, + { + "cell_type": "markdown", + "id": "a6332d29", + "metadata": {}, + "source": [ + "## 7. How well does it read USPS?\n", + "\n", + "The score below is accuracy on the **target** (USPS) digits — the ones the model never saw labels for.\n", + "Even this short run should beat a model trained on MNIST alone; more epochs push it higher." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7a188bc2", + "metadata": {}, + "outputs": [], + "source": [ + "results = trainer.test(model)\n", + "results" + ] + }, + { + "cell_type": "markdown", + "id": "adf56873", + "metadata": {}, + "source": [ + "## What next\n", + "\n", + "- **Train for real.** Run the full example beside this notebook:\n", + " `python main.py --cfg configs/MN2UP-DANN.yaml --devices 1`\n", + "- **Try other methods.** This example also ships CDAN, DAN, JAN, WDGRL and MME — swap the config in\n", + " `configs/` and compare.\n", + "- **Use your own data.** The same recipe — feature extractor + classifier + domain critic — adapts\n", + " between any two image sets that share the same labels.\n", + "\n", + "PyKale docs: https://pykale.readthedocs.io · Library: https://github.com/pykale/pykale" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 } diff --git a/scripts/tutorial_actions.py b/scripts/tutorial_actions.py index 2d81da2..49a8d12 100644 --- a/scripts/tutorial_actions.py +++ b/scripts/tutorial_actions.py @@ -1,9 +1,9 @@ """Derive the per-tutorial action links (GitHub source, Notebook download, Run in Colab). -All URLs are derived from the tutorial's catalogue path and ``site_meta`` -- never hard-coded per -tutorial. Colab and Notebook are only offered as working links when notebooks are actually published -(``site_meta.NOTEBOOKS_PUBLISHED``); otherwise they are returned disabled with an explanatory reason, -so the UI never shows a working-looking dead link. +All URLs are derived from the tutorial's catalogue path -- never hard-coded per tutorial. Each tutorial +ships a self-contained, Colab-runnable ``tutorial.ipynb`` *in its own folder*; Colab and Notebook are +offered as working links for the tutorials that have one (``has_notebook``) and returned disabled with +an explanatory reason for those that do not yet, so the UI never shows a working-looking dead link. ``conf.py`` calls :func:`tutorial_actions` from an html-page-context hook for each gallery page and puts the result in ``context["pky_tutorial"]``; ``source/_templates/tutorial-actions.html`` renders it. @@ -13,10 +13,10 @@ from typing import Dict, Optional, TypedDict -from site_meta import FEEDBACK_REPO, NOTEBOOKS_PUBLISHED, REPO_BRANCH +from site_meta import FEEDBACK_REPO, REPO_BRANCH -# Where CI is expected to publish sphinx-gallery notebooks (see source/_shared/launch/colab.py). -NOTEBOOK_DIR = "notebooks" +# The self-contained notebook each tutorial folder publishes, next to its narrative and example code. +NOTEBOOK_FILENAME = "tutorial.ipynb" class TutorialAction(TypedDict): @@ -35,14 +35,19 @@ def github_source_url(rel_path: str, slug: str, repo: str = FEEDBACK_REPO, branc return f"https://github.com/{repo}/blob/{branch}/{source_path(rel_path, slug)}" -def notebook_url(rel_path: str, slug: str, repo: str = FEEDBACK_REPO, branch: str = REPO_BRANCH) -> str: - """Raw GitHub URL of the published notebook (valid only once notebooks are published).""" - return f"https://github.com/{repo}/blob/{branch}/{NOTEBOOK_DIR}/{rel_path}/plot_{slug}.ipynb" +def notebook_path(rel_path: str) -> str: + """Repo path of a tutorial's self-contained notebook, e.g. ``others/.../cifar_isonet/tutorial.ipynb``.""" + return f"{rel_path}/{NOTEBOOK_FILENAME}" -def colab_url(rel_path: str, slug: str, repo: str = FEEDBACK_REPO, branch: str = REPO_BRANCH) -> str: - """Colab URL for the published notebook.""" - return f"https://colab.research.google.com/github/{repo}/blob/{branch}/{NOTEBOOK_DIR}/{rel_path}/plot_{slug}.ipynb" +def notebook_url(rel_path: str, repo: str = FEEDBACK_REPO, branch: str = REPO_BRANCH) -> str: + """GitHub blob URL of the tutorial's self-contained notebook.""" + return f"https://github.com/{repo}/blob/{branch}/{notebook_path(rel_path)}" + + +def colab_url(rel_path: str, repo: str = FEEDBACK_REPO, branch: str = REPO_BRANCH) -> str: + """Google Colab URL that opens the tutorial's self-contained notebook straight from GitHub.""" + return f"https://colab.research.google.com/github/{repo}/blob/{branch}/{notebook_path(rel_path)}" def tutorial_actions( @@ -50,37 +55,37 @@ def tutorial_actions( slug: str, *, draft: bool = False, - notebooks_published: bool = NOTEBOOKS_PUBLISHED, + has_notebook: bool = False, ) -> Dict[str, TutorialAction]: """Build the action set for one tutorial. Args: rel_path: Tutorial directory relative to the repo root (e.g. ``others/.../cifar_isonet``). slug: Tutorial slug. - draft: Whether the narrative is still a draft (affects the Notebook tooltip only). - notebooks_published: Whether generated notebooks are publicly available. + draft: Whether the narrative is still a draft (affects the disabled tooltip only). + has_notebook: Whether this tutorial ships a ``tutorial.ipynb`` in its folder. Returns: dict: ``{action: {"url", "enabled", "reason"}}`` for ``github``, ``notebook`` and ``colab``. - ``github`` is always enabled; the other two are enabled only when notebooks are published, - and otherwise carry a human-readable ``reason`` for the disabled state. + ``github`` is always enabled; Notebook and Colab are enabled for tutorials that have a + ``tutorial.ipynb`` and otherwise carry a human-readable ``reason`` for the disabled state. """ github: TutorialAction = {"url": github_source_url(rel_path, slug), "enabled": True, "reason": None} - if notebooks_published: - notebook: TutorialAction = {"url": notebook_url(rel_path, slug), "enabled": True, "reason": None} - colab: TutorialAction = {"url": colab_url(rel_path, slug), "enabled": True, "reason": None} + if has_notebook: + notebook: TutorialAction = {"url": notebook_url(rel_path), "enabled": True, "reason": None} + colab: TutorialAction = {"url": colab_url(rel_path), "enabled": True, "reason": None} else: reason = ( - "Notebook will be available when this draft becomes executable." + "A runnable notebook is coming once this draft's narrative is written." if draft - else "Notebook will be available once tutorials are published." + else "A runnable notebook is being prepared for this tutorial." ) notebook = {"url": None, "enabled": False, "reason": reason} colab = { "url": None, "enabled": False, - "reason": "Run in Colab will be available once notebooks are published.", + "reason": "Run in Colab will be available once this tutorial's notebook is published.", } return {"github": github, "notebook": notebook, "colab": colab} diff --git a/tests/test_tutorial_actions.py b/tests/test_tutorial_actions.py index 8481a0b..2415126 100644 --- a/tests/test_tutorial_actions.py +++ b/tests/test_tutorial_actions.py @@ -14,18 +14,18 @@ def test_github_source_url_points_at_the_tutorial_script(): assert not url.rstrip("/").endswith("tutorials") # never the repo homepage -def test_notebook_url_shape(): - assert notebook_url(REL, SLUG).endswith("/notebooks/" + REL + "/plot_cifar_isonet.ipynb") +def test_notebook_url_points_at_the_in_folder_notebook(): + assert notebook_url(REL).endswith("/" + REL + "/tutorial.ipynb") def test_actions_github_always_enabled(): - actions = tutorial_actions(REL, SLUG, draft=True, notebooks_published=False) + actions = tutorial_actions(REL, SLUG, draft=True, has_notebook=False) assert actions["github"]["enabled"] is True assert actions["github"]["url"].startswith("https://github.com/") -def test_colab_and_notebook_disabled_when_unpublished(): - actions = tutorial_actions(REL, SLUG, draft=False, notebooks_published=False) +def test_colab_and_notebook_disabled_without_a_notebook(): + actions = tutorial_actions(REL, SLUG, draft=False, has_notebook=False) assert actions["colab"]["enabled"] is False assert actions["colab"]["url"] is None assert actions["colab"]["reason"] # a human-readable reason is present @@ -34,15 +34,17 @@ def test_colab_and_notebook_disabled_when_unpublished(): def test_draft_notebook_reason_mentions_draft(): - actions = tutorial_actions(REL, SLUG, draft=True, notebooks_published=False) + actions = tutorial_actions(REL, SLUG, draft=True, has_notebook=False) assert "draft" in actions["notebook"]["reason"].lower() -def test_actions_enabled_when_published(): - actions = tutorial_actions(REL, SLUG, draft=False, notebooks_published=True) +def test_actions_enabled_when_notebook_present(): + actions = tutorial_actions(REL, SLUG, draft=False, has_notebook=True) assert actions["colab"]["enabled"] is True assert "colab.research.google.com" in actions["colab"]["url"] + assert actions["colab"]["url"].endswith("/" + REL + "/tutorial.ipynb") assert actions["notebook"]["enabled"] is True + assert actions["notebook"]["url"].endswith("/" + REL + "/tutorial.ipynb") def test_pagename_mapping():