diff --git a/.coverage b/.coverage new file mode 100644 index 00000000..aea114fc Binary files /dev/null and b/.coverage differ diff --git a/.gitignore b/.gitignore index c7f25cb7..65a29764 100644 --- a/.gitignore +++ b/.gitignore @@ -11,11 +11,15 @@ venv runs data MagicMock +drop +htmlcov +htmlcov-report # Ignore hidden directories .history .lh .claude +.coverage .chat .ai .pytest_cache diff --git a/CHANGELOG.md b/CHANGELOG.md index b538b1c6..bf0e34b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1 @@ -# Changelog - 2026-07-07 v1.3.2 +# Changelog - 2026-07-10 v1.3.3-dev0 diff --git a/docs/_static/custom.css b/docs/_static/custom.css index e74b634b..a9ec21fb 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -277,6 +277,35 @@ body[data-theme="dark"] .wl-only-dark { margin-bottom: 1rem; } +.wl-eg-cardwrap { + position: relative; + display: flex; +} + +.wl-eg-cardwrap > .wl-eg-card { + flex: 1; +} + +/* "Open in Colab" logo, pinned top-right, above the card link. */ +.wl-eg-colab { + position: absolute; + right: 0.9rem; + top: 0.9rem; + z-index: 2; + line-height: 0; + transition: transform 0.12s; +} + +.wl-eg-colab:hover { + transform: scale(1.1); +} + +.wl-eg-colab img { + height: 22px; + width: 22px; + display: block; +} + .wl-eg-card { display: flex; flex-direction: column; diff --git a/docs/_static/examples-gallery.js b/docs/_static/examples-gallery.js index 24cb5a07..c5a6a609 100644 --- a/docs/_static/examples-gallery.js +++ b/docs/_static/examples-gallery.js @@ -10,41 +10,49 @@ ]; // URLs are root-relative (from the doc root), prefixed with content_root at render time + // Base for "Open in Colab" links (notebooks live under examples/Notebooks). + var COLAB = 'https://colab.research.google.com/github/GrayboxTech/weightslab/blob/main/weightslab/examples/Notebooks/'; + var EXAMPLES = [ { badge: 'PyTorch', color: 'pytorch', title: 'Classification — MNIST', desc: 'CNN digit classifier on MNIST. Register hyperparameters, monitor per-sample loss, and use the deny-aware sampler to focus on hard examples.', tags: ['classification', 'supervised', 'mnist', 'cnn'], - url: 'examples/pytorch/classification.html' + url: 'examples/pytorch/classification.html', + colab: COLAB + 'PyTorch/ws-classification.ipynb' }, { badge: 'PyTorch', color: 'pytorch', title: 'Segmentation — BDD100k', desc: 'Per-pixel semantic segmentation with a UNet. Track per-sample IoU and visualise mask overlays directly in the studio.', tags: ['segmentation', 'semantic', 'bdd100k', 'masks', 'dense prediction'], - url: 'examples/pytorch/segmentation.html' + url: 'examples/pytorch/segmentation.html', + colab: COLAB + 'PyTorch/ws-segmentation.ipynb' }, { badge: 'PyTorch', color: 'pytorch', title: 'Detection — Penn-Fudan', desc: 'Bounding-box detection on Penn-Fudan pedestrians. Per-instance multi-index dataframe with (sample_id, annotation_id) keys.', tags: ['detection', 'object detection', 'bounding boxes', 'penn-fudan'], - url: 'examples/pytorch/detection.html' + url: 'examples/pytorch/detection.html', + colab: COLAB + 'PyTorch/ws-detection.ipynb' }, { badge: 'PyTorch', color: 'pytorch', title: 'Clustering — Face Recognition', desc: 'Metric learning with triplet loss on face datasets. Store and explore high-dimensional embeddings per sample in the studio.', tags: ['clustering', 'unsupervised', 'embeddings', 'face recognition', 'metric learning'], - url: 'examples/pytorch/clustering.html' + url: 'examples/pytorch/clustering.html', + colab: COLAB + 'PyTorch/ws-clustering.ipynb' }, { badge: 'PyTorch', color: 'pytorch', title: 'Generation / Anomaly Detection', desc: 'Unsupervised anomaly detection on MVTec with a multi-task UNet. Monitor reconstruction quality and per-sample anomaly scores.', tags: ['anomaly detection', 'generation', 'unsupervised', 'mvtec', 'reconstruction'], - url: 'examples/pytorch/generation.html' + url: 'examples/pytorch/generation.html', + colab: COLAB + 'PyTorch/ws-generation.ipynb' }, { badge: 'Lightning', color: 'lightning', @@ -72,7 +80,8 @@ title: 'Loss-Shape Classification', desc: 'Dynamic subscribed signal that classifies each sample\'s loss trajectory (monotonic, U-shape, spiked, …) and auto-tags it.', tags: ['loss analysis', 'signal', 'categorical tag', 'per-sample', 'trajectory'], - url: 'examples/usecases/loss_shape_classification.html' + url: 'examples/usecases/loss_shape_classification.html', + colab: COLAB + 'Usecases/ws-segmentation-loss-shapes.ipynb' } ]; @@ -94,15 +103,25 @@ }).join(''); var search = (ex.title + ' ' + ex.tags.join(' ')).toLowerCase(); var href = baseUrl + '/' + ex.url; + // "Open in Colab" badge, bottom-right, as a sibling of the card link (an + // cannot be nested inside another ). Only for examples with a notebook. + var colabHtml = ex.colab + ? '' + + 'Open in Colab' + + '' + : ''; + // The wrapper carries the filter data so the whole card (badge included) + // shows/hides together. return ( - '' + - '' + esc(ex.badge) + '' + - '

' + esc(ex.title) + '

' + - '

' + esc(ex.desc) + '

' + - '
' + tagsHtml + '
' + - '
' + '
' + + '' + + '' + esc(ex.badge) + '' + + '

' + esc(ex.title) + '

' + + '

' + esc(ex.desc) + '

' + + '
' + tagsHtml + '
' + + '
' + + colabHtml + + '
' ); } @@ -166,7 +185,7 @@ var totalVisible = 0; document.querySelectorAll('.wl-eg-section').forEach(function (section) { - var cards = section.querySelectorAll('.wl-eg-card'); + var cards = section.querySelectorAll('.wl-eg-cardwrap'); var sectionVisible = 0; cards.forEach(function (c) { var matchText = !q || c.dataset.search.indexOf(q) !== -1; diff --git a/docs/examples/lightning/classification.rst b/docs/examples/lightning/classification.rst index be70b9f8..acbde286 100644 --- a/docs/examples/lightning/classification.rst +++ b/docs/examples/lightning/classification.rst @@ -11,7 +11,7 @@ Classification — MNIST (PyTorch Lightning) pytorch lightning -**Example:** ``weightslab/examples/Lightning/ws-classification/main.py`` +**Example:** ``weightslab/examples/Lightning/wl-classification/main.py`` **Task:** MNIST digit classification with a CNN, training loop managed by ``pl.Trainer``. diff --git a/docs/examples/pytorch/classification.rst b/docs/examples/pytorch/classification.rst index 77da73e0..fafa0476 100644 --- a/docs/examples/pytorch/classification.rst +++ b/docs/examples/pytorch/classification.rst @@ -11,7 +11,7 @@ Classification — MNIST (PyTorch) cnn -**Example:** ``weightslab/examples/PyTorch/ws-classification/main.py`` +**Example:** ``weightslab/examples/PyTorch/wl-classification/main.py`` **Task:** 10-class digit classification on MNIST with a small CNN. @@ -159,3 +159,12 @@ debug values, custom distances, etc. weightslab ui launch # 1. deploy the studio weightslab start example --cls # 2. start the classification demo + + +.. raw:: html + +
+ + Open In Colab + +
diff --git a/docs/examples/pytorch/clustering.rst b/docs/examples/pytorch/clustering.rst index 9bd7ba6d..423e3449 100644 --- a/docs/examples/pytorch/clustering.rst +++ b/docs/examples/pytorch/clustering.rst @@ -12,7 +12,7 @@ Clustering — Face Recognition (PyTorch) metric learning -**Example:** ``weightslab/examples/PyTorch/ws-clustering/main.py`` +**Example:** ``weightslab/examples/PyTorch/wl-clustering/main.py`` **Task:** Metric learning with triplet loss on the Olivetti / LFW face dataset. The goal is to train an embedding network so that embeddings from the same @@ -95,3 +95,12 @@ silhouette score to identify consistently confused identities. weightslab ui launch # 1. deploy the studio weightslab start example --clus # 2. start the clustering demo + + +.. raw:: html + +
+ + Open In Colab + +
diff --git a/docs/examples/pytorch/detection.rst b/docs/examples/pytorch/detection.rst index a4daaa3f..ab201c04 100644 --- a/docs/examples/pytorch/detection.rst +++ b/docs/examples/pytorch/detection.rst @@ -11,7 +11,7 @@ Detection — Penn-Fudan Pedestrians (PyTorch) penn-fudan -**Example:** ``weightslab/examples/PyTorch/ws-detection/main.py`` +**Example:** ``weightslab/examples/PyTorch/wl-detection/main.py`` **Task:** Bounding-box detection on the Penn-Fudan pedestrian dataset with a small ResNet-backbone detector. @@ -127,3 +127,12 @@ pipeline. See :ref:`good-practice-get-items` for the recommended signature. weightslab ui launch # 1. deploy the studio weightslab start example --det # 2. start the detection demo + + +.. raw:: html + +
+ + Open In Colab + +
diff --git a/docs/examples/pytorch/generation.rst b/docs/examples/pytorch/generation.rst index 020efa81..58923714 100644 --- a/docs/examples/pytorch/generation.rst +++ b/docs/examples/pytorch/generation.rst @@ -12,7 +12,7 @@ Generation / Anomaly Detection — MVTec (PyTorch) reconstruction -**Example:** ``weightslab/examples/PyTorch/ws-generation/main.py`` +**Example:** ``weightslab/examples/PyTorch/wl-generation/main.py`` **Task:** Unsupervised anomaly detection on MVTec capsule images with a multi-task UNet (classification head + reconstruction head + contrastive loss). @@ -101,3 +101,12 @@ filter by pair distance to find the hardest negatives. weightslab ui launch # 1. deploy the studio weightslab start example --gen # 2. start the generation / anomaly demo + + +.. raw:: html + +
+ + Open In Colab + +
diff --git a/docs/examples/pytorch/segmentation.rst b/docs/examples/pytorch/segmentation.rst index 14fb5c00..c5bb53cf 100644 --- a/docs/examples/pytorch/segmentation.rst +++ b/docs/examples/pytorch/segmentation.rst @@ -12,7 +12,7 @@ Segmentation — BDD100k (PyTorch) dense prediction -**Example:** ``weightslab/examples/PyTorch/ws-segmentation/main.py`` +**Example:** ``weightslab/examples/PyTorch/wl-segmentation/main.py`` **Task:** Per-instance semantic segmentation on BDD100k (6 classes) with a small UNet. @@ -114,3 +114,12 @@ these signals over training steps and lets you sort samples by them. weightslab ui launch # 1. deploy the studio weightslab start example --seg # 2. start the segmentation demo + + +.. raw:: html + +
+ + Open In Colab + +
diff --git a/docs/examples/ultralytics/detection.rst b/docs/examples/ultralytics/detection.rst index 59577aac..14168e47 100644 --- a/docs/examples/ultralytics/detection.rst +++ b/docs/examples/ultralytics/detection.rst @@ -16,7 +16,7 @@ The full Ultralytics integration documentation is at :doc:`/ultralytics`. This page summarises what ``WLAwareTrainer`` handles automatically and where the example lives. -**Example:** ``weightslab/examples/Ultralytics/ws-detection/main.py`` +**Example:** ``weightslab/examples/Ultralytics/wl-detection/main.py`` What the example does --------------------- diff --git a/docs/examples/usecases/lidar_detection.rst b/docs/examples/usecases/lidar_detection.rst index b1b667e6..d7eec585 100644 --- a/docs/examples/usecases/lidar_detection.rst +++ b/docs/examples/usecases/lidar_detection.rst @@ -14,8 +14,8 @@ LiDAR Detection — 2D and 3D (PyTorch) **Examples:** -- ``weightslab/examples/Usecases/ws-2d-lidar-detection/main.py`` -- ``weightslab/examples/Usecases/ws-3d-lidar-detection/main.py`` +- ``weightslab/examples/Usecases/wl-2d-lidar-detection/main.py`` +- ``weightslab/examples/Usecases/wl-3d-lidar-detection/main.py`` **Task:** Object detection on LiDAR point clouds — 2D pillar-grid (BEV) and full 3D bounding boxes (KITTI-format). diff --git a/docs/examples/usecases/loss_shape_classification.rst b/docs/examples/usecases/loss_shape_classification.rst index 6864322a..822bef81 100644 --- a/docs/examples/usecases/loss_shape_classification.rst +++ b/docs/examples/usecases/loss_shape_classification.rst @@ -12,7 +12,7 @@ Loss-Shape Classification per Sample trajectory -**Example:** ``weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/`` +**Example:** ``weightslab/examples/Usecases/wl-loss_shapes_classification_per_sample/`` This use case builds on :doc:`../pytorch/detection` (same Penn-Fudan dataset, same model, same ``guard_training_context`` pattern) and adds one new feature: @@ -208,3 +208,12 @@ Workflow in the studio To run the full loss-shape example (with the ``@wl.signal(subscribe_to=...)`` classifier active), use the direct path above. + + +.. raw:: html + +
+ + Open In Colab + +
diff --git a/docs/pytorch_lightning.rst b/docs/pytorch_lightning.rst index 3a0aa64b..5643983d 100644 --- a/docs/pytorch_lightning.rst +++ b/docs/pytorch_lightning.rst @@ -3,7 +3,7 @@ PyTorch Lightning Integration Weightslab is compatible with PyTorch Lightning and already includes a full example: -``weightslab/examples/PyTorch_Lightning/ws-classification/main.py`` +``weightslab/examples/PyTorch_Lightning/wl-classification/main.py`` This page explains how to integrate Weightslab in a Lightning workflow and scale to multiple GPUs. @@ -109,7 +109,7 @@ Optional YAML-driven trainer config The Lightning example already includes a ready template at: -``weightslab/examples/PyTorch_Lightning/ws-classification/config.yaml`` +``weightslab/examples/PyTorch_Lightning/wl-classification/config.yaml`` .. code-block:: yaml diff --git a/docs/segmentation_usecase.rst b/docs/segmentation_usecase.rst index 38738a54..5fe8b83b 100644 --- a/docs/segmentation_usecase.rst +++ b/docs/segmentation_usecase.rst @@ -3,7 +3,7 @@ Segmentation Use Case — Per-instance & Per-sample Signals (PyTorch) This page walks through the segmentation integration from: -``weightslab/examples/PyTorch/ws-segmentation/main.py`` +``weightslab/examples/PyTorch/wl-segmentation/main.py`` It builds on the classification :doc:`usecases` page and focuses on what is **specific to segmentation**: a *list of per-instance masks* per sample, a custom diff --git a/docs/ultralytics.rst b/docs/ultralytics.rst index 18af0fd3..0dc5a5aa 100644 --- a/docs/ultralytics.rst +++ b/docs/ultralytics.rst @@ -8,7 +8,7 @@ without touching the model or YOLO's training loop. The full example lives at: -``weightslab/examples/Ultralytics/ws-detection/`` +``weightslab/examples/Ultralytics/wl-detection/`` How it works ------------ @@ -207,7 +207,7 @@ Running the bundled example .. code-block:: bash - python weightslab/examples/Ultralytics/ws-detection/main.py + python weightslab/examples/Ultralytics/wl-detection/main.py 5. Open ``http://localhost:5173`` to monitor training, inspect per-sample signals, tag difficult images, and discard outliers. diff --git a/docs/usecases.rst b/docs/usecases.rst index 307f167c..8de11fbb 100644 --- a/docs/usecases.rst +++ b/docs/usecases.rst @@ -3,7 +3,7 @@ Use Case Example (PyTorch) This page walks through the real MNIST classification integration from: -``weightslab/examples/PyTorch/ws-classification/main.py`` +``weightslab/examples/PyTorch/wl-classification/main.py`` Goal ---- diff --git a/docs/user_commands.rst b/docs/user_commands.rst index 0b3d3fcb..ebe35fb4 100644 --- a/docs/user_commands.rst +++ b/docs/user_commands.rst @@ -26,7 +26,7 @@ so it's available anywhere the package is installed — no ``python -m`` needed. .. code-block:: text - weightslab {se,ui,start,cli,help} ... + weightslab {se,ui,start,cli,tunnel,help} ... Running ``weightslab``, ``weightslab -h`` / ``--help``, or ``weightslab help`` with no further arguments prints the banner and this same command summary. @@ -44,6 +44,8 @@ with no further arguments prints the banner and this same command summary. - Run a bundled PyTorch example in the foreground. * - ``weightslab cli`` - Open an interactive console connected to a running experiment. + * - ``weightslab tunnel`` + - Forward a remote gRPC backend (e.g. a Colab run) to a local port so the UI can reach it. * - ``weightslab help`` - Show the help/banner (same as no command, or ``-h``). @@ -212,6 +214,84 @@ no arguments). weightslab cli --port 60000 # connect to a specific port weightslab cli --host 10.0.0.5 --port 60000 +weightslab tunnel +~~~~~~~~~~~~~~~~~~ + +**Syntax** + +.. code-block:: bash + + weightslab tunnel [ENDPOINT] [--listen-port N] [--listen-host H] [--remote-port N] + +Forwards a **remote** gRPC training backend to a **local** TCP port so the +Weights Studio UI — whose Envoy proxy dials ``localhost:50051`` — connects to +it as if it were local. This is what lets you **train on a remote machine (e.g. +Google Colab) and watch it live in Studio running on your laptop**: Colab has no +Docker daemon, so you run the UI locally and bridge the remote backend to it. + +It is a raw byte forwarder (no protocol parsing) because the browser speaks +gRPC-Web to Envoy and Envoy speaks native HTTP/2 gRPC to its upstream — those +HTTP/2 frames must pass through untouched. Two consequences: + +- The remote tunnel must be **raw TCP**, *not* an HTTP/gRPC-Web tunnel. A + zero-signup option is `bore `_ with its free + public relay: ``bore local 50051 --to bore.pub`` (prints ``bore.pub:``). + ``ngrok tcp 50051`` also works but now requires a credit card on the free tier. +- The backend must run **plaintext** — the default ``weightslab ui launch`` + (no ``--certs``) — so no TLS terminates mid-path. + +**Arguments** + +- ``ENDPOINT`` *(positional, optional)* — the remote backend as ``host:port`` + (e.g. ``0.tcp.ngrok.io:12345``); a ``tcp://`` prefix is accepted and + stripped. Default: the ``WEIGHTSLAB_TUNNEL_ENDPOINT`` environment variable, so + a bare ``weightslab tunnel`` works once that is exported. +- ``--listen-port``, ``-p`` *(int)* — local port to expose. Default: **50051** + (the port the bundled Envoy upstream dials — leave it unless you changed + ``GRPC_BACKEND_PORT``). +- ``--listen-host`` *(str)* — interface to bind. Default: **auto** — + ``127.0.0.1`` on Windows/macOS (Docker Desktop reaches host loopback via + ``host.docker.internal``), ``0.0.0.0`` on Linux (compose ``host-gateway`` + resolves to the bridge IP, which cannot reach a loopback-only listener). +- ``--remote-port`` *(int)* — the remote port, when ``ENDPOINT`` has only a + host and no ``:port``. + +**Examples** + +.. code-block:: bash + + weightslab tunnel bore.pub:12345 # bridge remote backend -> localhost:50051 + weightslab tunnel tcp://bore.pub:12345 # tcp:// prefix is fine + weightslab tunnel # uses $WEIGHTSLAB_TUNNEL_ENDPOINT + weightslab tunnel host.example.com --remote-port 50051 + weightslab tunnel host:50051 -p 50055 # expose locally on a different port + +**Typical workflow** (Colab backend, local UI): + +.. code-block:: bash + + # 1) In Colab: expose the training backend over raw TCP (prints bore.pub:) + # !bore local 50051 --to bore.pub + + # 2) On your machine, in two terminals: + weightslab ui launch # plaintext HTTP (default) + weightslab tunnel bore.pub:12345 # the host:port bore printed + + # 3) Open http://localhost:5173 — Studio streams live from Colab. + +.. note:: + + Step 1 can be done for you: call ``wl.serve(serving_grpc=True, + serving_bore=True)`` in the training script. It downloads ``bore``, opens the + relay, and prints the exact ``weightslab tunnel bore.pub:`` line to run + on your machine — see ``serve`` in :doc:`user_functions`. + +The command probes the remote on startup (warning, not fatal, if it isn't up +yet), re-resolves the endpoint per connection (so a changing tunnel IP is picked +up), and runs until ``Ctrl+C``. See the classification Colab notebook +(``examples/Notebooks/PyTorch/ws-classification.ipynb``) for the end-to-end +setup. + .. _cli-console: Interactive CLI console diff --git a/docs/user_functions.rst b/docs/user_functions.rst index e3a614ad..fe314032 100644 --- a/docs/user_functions.rst +++ b/docs/user_functions.rst @@ -323,6 +323,7 @@ signal name: str, subscribe_to: str, compute_every_n_steps: int = 1, + min_step: int = 0, include_history: bool = False, include_history_metadata: bool = False ) @@ -345,6 +346,11 @@ sorting and root-cause analysis in the studio). ``ctx.subscribed_value``. If omitted, the signal is **static**. - ``compute_every_n_steps``: throttle for dynamic signals (e.g. ``10`` = compute on every 10th step the subscribed metric is produced). +- ``min_step``: minimum training step before a dynamic signal starts firing. + While ``current_step < min_step`` the signal is skipped. Defaults to ``0`` + (fire from the start). Use it when a signal needs enough history to be + meaningful — e.g. a loss-shape classifier that should only run once each + sample has a trajectory (``min_step=505``). **Static vs dynamic** @@ -511,7 +517,7 @@ Spiked Sudden jump at some step — data/augmentation/version change. the verdict also live as a per-sample ``signals//loss_shape_classifier`` column; the human-readable label lives on the ``loss_shape`` categorical tag. - See the detection use case (``examples/PyTorch/ws-detection/src/main.py``) for + See the detection use case (``examples/PyTorch/wl-detection/src/main.py``) for this signal wired into a real training loop. compute_signals diff --git a/tests/backend/test_ui_docker_bridge.py b/tests/backend/test_ui_docker_bridge.py index c43f2bd5..bb2e29fe 100644 --- a/tests/backend/test_ui_docker_bridge.py +++ b/tests/backend/test_ui_docker_bridge.py @@ -583,9 +583,9 @@ def test_example_start_runs_classification(self, mock_run): cmd = mock_run.call_args.args[0] self.assertEqual(cmd[0], sys.executable) main_py = cmd[1].replace("\\", "/") - self.assertTrue(main_py.endswith("examples/PyTorch/ws-classification/main.py"), main_py) + self.assertTrue(main_py.endswith("examples/PyTorch/wl-classification/main.py"), main_py) cwd = mock_run.call_args.kwargs["cwd"].replace("\\", "/") - self.assertTrue(cwd.endswith("examples/PyTorch/ws-classification"), cwd) + self.assertTrue(cwd.endswith("examples/PyTorch/wl-classification"), cwd) self.assertTrue(any("classification (cls) example" in m for m in log_context.output)) @patch("weightslab.ui_docker_bridge.subprocess.run") @@ -603,7 +603,7 @@ def test_example_start_errors_when_missing(self, _mock_dir): def test_example_dir_points_at_bundled_example(self): # The bundled classification example must actually ship with the package. - self.assertTrue((_get_example_dir("ws-classification") / "main.py").exists()) + self.assertTrue((_get_example_dir("wl-classification") / "main.py").exists()) @patch("weightslab.ui_docker_bridge.subprocess.run") def test_example_start_seg_runs_segmentation(self, mock_run): @@ -611,7 +611,7 @@ def test_example_start_seg_runs_segmentation(self, mock_run): with self.assertLogs("weightslab.ui_docker_bridge", level="INFO") as log_context: example_start(argparse.Namespace(example_kind="seg")) main_py = mock_run.call_args.args[0][1].replace("\\", "/") - self.assertTrue(main_py.endswith("examples/PyTorch/ws-segmentation/main.py"), main_py) + self.assertTrue(main_py.endswith("examples/PyTorch/wl-segmentation/main.py"), main_py) self.assertTrue(any("segmentation (seg) example" in m for m in log_context.output)) @patch("weightslab.ui_docker_bridge.subprocess.run") @@ -620,7 +620,7 @@ def test_example_start_defaults_to_cls_when_flag_absent(self, mock_run): mock_run.return_value = MagicMock(returncode=0) example_start(argparse.Namespace()) main_py = mock_run.call_args.args[0][1].replace("\\", "/") - self.assertTrue(main_py.endswith("examples/PyTorch/ws-classification/main.py"), main_py) + self.assertTrue(main_py.endswith("examples/PyTorch/wl-classification/main.py"), main_py) class TestInstallExampleRequirements(unittest.TestCase): diff --git a/tests/components/test_checkpoint_workflow.py b/tests/components/test_checkpoint_workflow.py index 671676ca..e4cb5e99 100644 --- a/tests/components/test_checkpoint_workflow.py +++ b/tests/components/test_checkpoint_workflow.py @@ -308,7 +308,7 @@ def setUpClass(cls): cls.temp_dir = tempfile.mkdtemp(prefix="checkpoint_v3_test_") cls.log_dir = os.path.join(cls.temp_dir, "experiments") - # Initialize config from YAML-like dict (similar to ws-classification) + # Initialize config from YAML-like dict (similar to wl-classification) cls.config = { 'experiment_name': EXP_NAME, 'device': DEVICE, diff --git a/tests/general/test_signal_refinements.py b/tests/general/test_signal_refinements.py new file mode 100644 index 00000000..681c9970 --- /dev/null +++ b/tests/general/test_signal_refinements.py @@ -0,0 +1,163 @@ +"""Unit tests for the signal-refinements changes: per-sample query cache, +value-at-step read + staging fast path, cycle detection, ctx.logits, freshness, +and the reactive-derived gather skip.""" +import unittest + +import numpy as np +import torch + +import weightslab as wl +from weightslab.backend.logger import LoggerQueue +from weightslab.src import ( + _REGISTERED_SIGNALS, _detect_signal_cycles, _gather_inputs_fresh, + BatchSignalContext, SignalContext, StaleSignalError, +) + + +def _lg(): + return LoggerQueue(register=False) + + +class TestQueryCache(unittest.TestCase): + def test_cached_and_fresh_copy(self): + lg = _lg() + lg._stage_sample_row("m", "h", "1", 0, 1.5) + lg._stage_sample_row("m", "h", "2", 0, 2.5) + r1 = lg.query_per_sample("m", sample_ids=["1", "2"]) + r2 = lg.query_per_sample("m", sample_ids=["1", "2"]) + self.assertEqual(len(r1), 2) + self.assertIsNot(r1, r2) # fresh list each call + self.assertGreaterEqual(lg._qps_cache.cache_info().hits, 1) # 2nd read hit + + def test_invalidated_on_write(self): + lg = _lg() + lg._stage_sample_row("m", "h", "1", 0, 1.0) + self.assertEqual(len(lg.query_per_sample("m", sample_ids=["1"])), 1) + lg._stage_sample_row("m", "h", "1", 1, 9.0) # new row bumps version + self.assertEqual(len(lg.query_per_sample("m", sample_ids=["1"])), 2) + + def test_step_scoped_clear(self): + lg = _lg() + lg._stage_sample_row("m", "h", "1", 0, 1.0) + lg.query_per_sample("m", sample_ids=["1"]) + lg._stage_sample_row("m", "h", "2", 1, 2.0) # step advance -> clear + self.assertEqual(lg._qps_cache_step, 1) + + +class TestValueAtStep(unittest.TestCase): + def test_staging_fast_path(self): + lg = _lg() + lg._stage_sample_row("m", "h", "5", 3, 4.0) # staged, not flushed + at = dict((int(s), v) for s, v in lg.query_per_sample_at_step("m", [5], 3)) + self.assertEqual(at, {5: 4.0}) + + def test_absent_at_other_step(self): + lg = _lg() + lg._stage_sample_row("m", "h", "5", 3, 4.0) + self.assertEqual(lg.query_per_sample_at_step("m", [5], 99), []) + + +class TestCycleDetection(unittest.TestCase): + def setUp(self): _REGISTERED_SIGNALS.clear() + def tearDown(self): _REGISTERED_SIGNALS.clear() + + def test_self_loop_and_two_cycle(self): + @wl.signal(name="sig/self", inputs=["sig/self"], batched=True) + def _s(b): return b + @wl.signal(name="sig/X", inputs=["sig/Y"], batched=True) + def _x(b): return b + @wl.signal(name="sig/Y", inputs=["sig/X"], batched=True) + def _y(b): return b + keys = {frozenset(c) for c in _detect_signal_cycles()} + self.assertIn(frozenset(["sig/self"]), keys) + self.assertIn(frozenset(["sig/X", "sig/Y"]), keys) + + def test_dag_has_no_cycle(self): + @wl.signal(name="sig/a", inputs=["train/loss"], batched=True) # base leaf + def _a(b): return b + @wl.signal(name="sig/b", inputs=["sig/a"], batched=True) + def _b(b): return b + self.assertEqual(_detect_signal_cycles(), []) + + +class TestContexts(unittest.TestCase): + def test_batch_context_carries_logits(self): + b = BatchSignalContext(sample_ids=[1, 2], subscribed_values=[0.1, 0.2], + logits=torch.tensor([[1., 2., 3.], [4., 5., 6.]])) + self.assertEqual(tuple(b.logits.shape), (2, 3)) + + def test_sample_context_carries_logits(self): + c = SignalContext(sample_id=1, dataframe=None, logits=torch.tensor([1., 2., 3.])) + self.assertEqual(tuple(c.logits.shape), (3,)) + + def test_stale_signal_raises(self): + b = BatchSignalContext(sample_ids=[1], subscribed_values=[0.0], logger=_lg(), step=5) + with self.assertRaises(StaleSignalError): + b.latest("sig/never", require_fresh=True) + + +class TestGatherFreshCache(unittest.TestCase): + def setUp(self): _REGISTERED_SIGNALS.clear() + def tearDown(self): _REGISTERED_SIGNALS.clear() + + def test_reactive_derived_not_queried_when_absent(self): + @wl.signal(name="sig/derived", inputs=["x"], batched=True) # reactive + def _d(b): return b + lg = _lg() + calls = {"n": 0} + orig = lg.query_per_sample_at_step + lg.query_per_sample_at_step = lambda *a, **k: (calls.__setitem__("n", calls["n"] + 1), orig(*a, **k))[1] + res = _gather_inputs_fresh(lg, ["sig/derived"], [1], 0, fresh_cache={}) + self.assertIsNone(res) # not fired yet -> skip + self.assertEqual(calls["n"], 0) # and NOT a ledger query (no flush) + + def test_reactive_derived_read_from_cache(self): + @wl.signal(name="sig/derived", inputs=["x"], batched=True) + def _d(b): return b + res = _gather_inputs_fresh(_lg(), ["sig/derived"], [1, 2], 0, + fresh_cache={"sig/derived": np.array([1.0, 2.0])}) + self.assertIsNotNone(res) + np.testing.assert_array_equal(res["sig/derived"], [1.0, 2.0]) + + +class TestDefaultShapeClassifier(unittest.TestCase): + def test_labels(self): + self.assertEqual(wl.classify_loss_shape([2, 1.5, 1, 0.5, 0.05]), "monotonic") + self.assertEqual(wl.classify_loss_shape([2, 2, 2, 2, 2]), "Flat_high") + self.assertEqual(wl.classify_loss_shape([0.1, 0.1, 0.1, 3.0, 0.1]), "Spiked") + + def test_too_short_is_none(self): + self.assertIsNone(wl.classify_loss_shape([1, 2, 3])) + self.assertIsNone(wl.classify_loss_shape([1, 0.5, 0.1], min_points=5)) + + def test_thresholds_configurable(self): + traj = [1.0, 0.9, 0.8, 0.75, 0.7] # net drop 0.3 + self.assertNotEqual(wl.classify_loss_shape(traj), "monotonic") # default 0.4 unmet + self.assertEqual(wl.classify_loss_shape(traj, drop_learned=0.25), "monotonic") + + def test_trajectory_stats_reusable(self): + s = wl.trajectory_stats([2.0, 1.0, 0.5, 0.5, 0.4]) + self.assertAlmostEqual(s["drop"], (2.0 - 0.4) / 2.0, places=5) + self.assertIn("cv", s); self.assertIn("tail_cv", s); self.assertIn("argmin_frac", s) + self.assertIsNone(wl.trajectory_stats([1.0])) # < 2 points + + def test_enable_live_signal_registers(self): + _REGISTERED_SIGNALS.clear() + try: + wl.enable_loss_shape_signal(loss_signal="loss_sample", name="sig/live_shape", every=5) + self.assertIn("sig/live_shape", _REGISTERED_SIGNALS) + meta = _REGISTERED_SIGNALS["sig/live_shape"]._wl_signal_meta + self.assertEqual(meta.get("inputs"), ["loss_sample"]) + self.assertEqual(meta.get("compute_every_n_steps"), 5) + finally: + _REGISTERED_SIGNALS.clear() + + def test_exports(self): + for name in ("classify_loss_shape", "trajectory_stats", "write_loss_shapes", + "write_signal_shapes", "enable_loss_shape_signal"): + self.assertTrue(hasattr(wl, name), name) + self.assertIn("monotonic", wl.LOSS_SHAPES) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/general/test_signals.py b/tests/general/test_signals.py index 0d32127d..f9281417 100644 --- a/tests/general/test_signals.py +++ b/tests/general/test_signals.py @@ -4,15 +4,20 @@ import numpy as np from unittest.mock import MagicMock, patch import weightslab as wl -from weightslab.src import _REGISTERED_SIGNALS, compute_signals, wrappered_fwd +from weightslab.src import ( + _REGISTERED_SIGNALS, _REACTIVE_FIRED, _react_dependents, + SignalContext, BatchSignalContext, compute_signals, wrappered_fwd, +) class TestSignals(unittest.TestCase): def setUp(self): # Clear registered signals before each test _REGISTERED_SIGNALS.clear() + _REACTIVE_FIRED.clear() def tearDown(self): _REGISTERED_SIGNALS.clear() + _REACTIVE_FIRED.clear() # ========================================================================= # Input Type Tests - Different signal and data input formats @@ -251,6 +256,70 @@ def dynamic_sig(sample_id, value, dataframe=None, origin="train"): losses_data = call_args[1]['losses'] self.assertIn('signals//dynamic_sig', losses_data) + def test_context_exposes_inputs_attribute(self): + """SignalContext / BatchSignalContext always expose an `inputs` dict.""" + # Defaults to an empty dict (never AttributeError, even off the reactive path). + sctx = SignalContext(sample_id=1, dataframe=None) + self.assertEqual(sctx.inputs, {}) + bctx = BatchSignalContext(sample_ids=[1, 2], subscribed_values=[0.1, 0.2]) + self.assertEqual(bctx.inputs, {}) + + # Populated via the constructor (as the reactive dispatch does). + cols = {"loss_sample": np.array([1.0, 2.0]), "sig/entropy": np.array([0.3, 0.4])} + bctx2 = BatchSignalContext(sample_ids=[1, 2], subscribed_values=cols["loss_sample"], inputs=cols) + self.assertIn("loss_sample", bctx2.inputs) + self.assertIn("sig/entropy", bctx2.inputs) + np.testing.assert_array_equal(bctx2.inputs["loss_sample"], [1.0, 2.0]) + + def test_reactive_inputs_populated_and_chained(self): + """A @wl.signal(inputs=[...]) reads its inputs via ctx.inputs[name], and a + derived input (another signal) is visible to a downstream signal.""" + class FakeLogger: + def __init__(self, data): # {sig: {sid: {step: val}}} + self.d = data + def query_per_sample_at_step(self, name, ids, step): + return [(sid, self.d[name][sid][step]) for sid in ids + if name in self.d and sid in self.d[name] + and step in self.d[name][sid]] + def query_per_sample(self, name, sample_ids=None): + rows = [] + for sid in (sample_ids or []): + for st, v in sorted(self.d.get(name, {}).get(sid, {}).items()): + rows.append((sid, st, v, None)) + return rows + + captured = {} + + @wl.signal(name="sig/loss_norm", inputs=["loss_sample"], batched=True) + def loss_norm(b): + captured["norm"] = dict(b.inputs) + return b.inputs["loss_sample"] / (float(np.mean(b.inputs["loss_sample"])) + 1e-8) + + @wl.signal(name="sig/hardness", inputs=["loss_sample", "sig/loss_norm"], batched=True) + def hardness(b): + captured["hard"] = dict(b.inputs) + return b.inputs["loss_sample"] * b.inputs["sig/loss_norm"] + + fake = FakeLogger({"loss_sample": {1: {10: 2.0}, 2: {10: 4.0}}}) + saved = {} + + def fake_save_signals(signals=None, **kw): + saved.update(signals or {}) + + with patch("weightslab.src.get_logger", return_value=fake), \ + patch("weightslab.src.get_dataframe", return_value=None), \ + patch("weightslab.src.save_signals", side_effect=fake_save_signals): + _react_dependents(["loss_sample"], batch_ids=[1, 2], step=10, origin="train") + + # loss_norm read its base-metric input through ctx.inputs + np.testing.assert_array_equal(captured["norm"]["loss_sample"], [2.0, 4.0]) + # hardness saw BOTH the base metric AND the derived signal via ctx.inputs + self.assertIn("loss_sample", captured["hard"]) + self.assertIn("sig/loss_norm", captured["hard"]) + # Both fired and were persisted + self.assertIn("sig/loss_norm", saved) + self.assertIn("sig/hardness", saved) + def test_frequency_control(self): """Test compute_every_n_steps.""" with patch("weightslab.src.list_models", return_value=["main"]), \ diff --git a/tests/integrations/ultralytics/ddp/ddp_ablation.py b/tests/integrations/ultralytics/ddp/ddp_ablation.py index 3c268e84..6e9a863a 100644 --- a/tests/integrations/ultralytics/ddp/ddp_ablation.py +++ b/tests/integrations/ultralytics/ddp/ddp_ablation.py @@ -31,10 +31,10 @@ import torch.distributed as dist import torch.multiprocessing as mp -# This harness lives under tests/ but drives the ws-detection usecase: put the usecase +# This harness lives under tests/ but drives the wl-detection usecase: put the usecase # src on the path (for yolo_pipeline / utils.*) and resolve config/data/ddp_run vs IT. _USECASE_SRC = os.path.abspath(os.path.join( - os.path.dirname(__file__), "../../../../examples/PyTorch/ws-detection/src")) + os.path.dirname(__file__), "../../../../examples/PyTorch/wl-detection/src")) sys.path.insert(0, _USECASE_SRC) _HERE = _USECASE_SRC _HOST = "127.0.0.1" diff --git a/tests/integrations/ultralytics/ddp/ddp_test_suite.py b/tests/integrations/ultralytics/ddp/ddp_test_suite.py index c9608081..280ef57e 100644 --- a/tests/integrations/ultralytics/ddp/ddp_test_suite.py +++ b/tests/integrations/ultralytics/ddp/ddp_test_suite.py @@ -40,10 +40,10 @@ import grpc import sys -# Harness lives under tests/ — put the ws-detection usecase src on the path so the +# Harness lives under tests/ — put the wl-detection usecase src on the path so the # usecase modules (yolo_pipeline, utils.*) and its config/data/ddp_run resolve. sys.path.insert(0, os.path.abspath(os.path.join( - os.path.dirname(__file__), "../../../../examples/PyTorch/ws-detection/src"))) + os.path.dirname(__file__), "../../../../examples/PyTorch/wl-detection/src"))) import yolo_pipeline # reuse _build_pipeline / _decode_preds_to_6col / _HERE / _LOSS_PARTS import weightslab.proto.experiment_service_pb2 as pb2 diff --git a/tests/model/test_constraint_generation.py b/tests/model/test_constraint_generation.py index ddd00a94..506089aa 100644 --- a/tests/model/test_constraint_generation.py +++ b/tests/model/test_constraint_generation.py @@ -15,9 +15,27 @@ from weightslab.utils.modules_dependencies import DepType from weightslab.backend.model_interface import ModelInterface +from weightslab.backend import ledgers from weightslab.utils.computational_graph import _detect_layer_constraints +class _RegistryIsolation: + """Clean the global model registry after each test. + + ``get_dependencies_onnx`` builds a throwaway ``ModelInterface`` (in ``eval`` + mode, for ONNX dependency extraction) which registers itself as the global + experiment model and is never cleaned up. Left behind, ``get_model()`` keeps + returning that eval-mode model, and anything that reads it — e.g. + ``LoggerQueue._get_audit_mode`` (``get_model().audit_mode``) — sees + ``audit_mode == True``, leaking into unrelated tests run later in the suite. + """ + def tearDown(self): + try: + ledgers.GLOBAL_LEDGER.unregister_model() + except Exception: + pass + + def get_dependencies_onnx(model: nn.Module, dummy_input: torch.Tensor) -> List[Tuple[nn.Module, nn.Module, DepType]]: """Extract dependencies using ONNX export""" try: @@ -96,8 +114,8 @@ def forward(self, x): return x -@unittest.skip("Constraint detection and propagation tests are currently skipped due to ongoing refactor and potential changes in the underlying implementation. Will be re-enabled once the new system is in place more modeling.") -class TestConstraintDetection(unittest.TestCase): +# @unittest.skip("Constraint detection and propagation tests are currently skipped due to ongoing refactor and potential changes in the underlying implementation. Will be re-enabled once the new system is in place more modeling.") +class TestConstraintDetection(_RegistryIsolation, unittest.TestCase): """Test constraint detection on individual modules""" def test_grouped_conv_detection(self): @@ -136,8 +154,8 @@ def test_batchnorm_no_constraint(self): self.assertEqual(len(constraints), 0) -@unittest.skip("Constraint detection and propagation tests are currently skipped due to ongoing refactor and potential changes in the underlying implementation. Will be re-enabled once the new system is in place more modeling.") -class TestConstraintPropagation(unittest.TestCase): +# @unittest.skip("Constraint detection and propagation tests are currently skipped due to ongoing refactor and potential changes in the underlying implementation. Will be re-enabled once the new system is in place more modeling.") +class TestConstraintPropagation(_RegistryIsolation, unittest.TestCase): """Test constraint propagation through dependency graphs""" def test_grouped_conv_propagation(self): @@ -201,8 +219,8 @@ def test_multi_grouped_propagation(self): self.assertEqual(model.model.g_conv3.wl_constraints[1]['cons_group_size'], 8) -@unittest.skip("Constraint detection and propagation tests are currently skipped due to ongoing refactor and potential changes in the underlying implementation. Will be re-enabled once the new system is in place more modeling.") -class TestConstraintReporting(unittest.TestCase): +# @unittest.skip("Constraint detection and propagation tests are currently skipped due to ongoing refactor and potential changes in the underlying implementation. Will be re-enabled once the new system is in place more modeling.") +class TestConstraintReporting(_RegistryIsolation, unittest.TestCase): """Test constraint reporting and information retrieval""" def test_constraint_source_tracking(self): diff --git a/tests/model/test_model_with_ops.py b/tests/model/test_model_with_ops.py index 827fb703..66935955 100644 --- a/tests/model/test_model_with_ops.py +++ b/tests/model/test_model_with_ops.py @@ -25,7 +25,7 @@ TMP_DIR = '/tmp/utests/'; os.makedirs('/tmp/utests/', exist_ok=True) -@unittest.skip("Constraint detection and propagation tests are currently skipped due to ongoing refactor and potential changes in the underlying implementation. Will be re-enabled once the new system is in place more modeling.") +# @unittest.skip("Constraint detection and propagation tests are currently skipped due to ongoing refactor and potential changes in the underlying implementation. Will be re-enabled once the new system is in place more modeling.") class NetworkWithOpsTest(unittest.TestCase): def setUp(self) -> None: print(f"\n--- Start {self._testMethodName} ---\n") @@ -206,8 +206,8 @@ def test_train_freeze(self): # Set Tracker self.dummy_network.set_tracking_mode(TrackingMode.TRAIN) - # Train for like 10 epochs - for _ in trange(10, desc="Training.."): + # Train for like 5 epochs + for _ in trange(5, desc="Training.."): self._train_one_epoch(cutoff=20) # Operate on the first layer - FREEZE diff --git a/tests/model/test_model_with_ops_unit.py b/tests/model/test_model_with_ops_unit.py index dba4df34..88a3d902 100644 --- a/tests/model/test_model_with_ops_unit.py +++ b/tests/model/test_model_with_ops_unit.py @@ -24,7 +24,7 @@ def __init__(self): self.l2 = _Layer(20) -@unittest.skip("Constraint detection and propagation tests are currently skipped due to ongoing refactor and potential changes in the underlying implementation. Will be re-enabled once the new system is in place more modeling.") +# @unittest.skip("Constraint detection and propagation tests are currently skipped due to ongoing refactor and potential changes in the underlying implementation. Will be re-enabled once the new system is in place more modeling.") class TestModelWithOpsUnit(unittest.TestCase): def test_reverse_index_and_tracking_mode(self): net = _Net() diff --git a/tests/model/test_tracking.py b/tests/model/test_tracking.py index 411730f5..33a789d7 100644 --- a/tests/model/test_tracking.py +++ b/tests/model/test_tracking.py @@ -148,7 +148,7 @@ def test_triggers_tracker_all_ops_plus_save_and_load(self): self.assertEqual(self.triggers_tracker, replica_triggers_tracker) -@unittest.skip("Constraint detection and propagation tests are currently skipped due to ongoing refactor and potential changes in the underlying implementation. Will be re-enabled once the new system is in place more modeling.") +# @unittest.skip("Constraint detection and propagation tests are currently skipped due to ongoing refactor and potential changes in the underlying implementation. Will be re-enabled once the new system is in place more modeling.") class TriggersTrackerClazzAndSampleIDTest(unittest.TestCase): """ Tests the TriggersTrackerClazzAndSampleID for the neuron operations and persistency. diff --git a/weightslab/__init__.py b/weightslab/__init__.py index e41340b6..6f8d222a 100644 --- a/weightslab/__init__.py +++ b/weightslab/__init__.py @@ -11,7 +11,7 @@ import logging import threading -from .src import watch_or_edit, start_training, serve, keep_serving, save_signals, save_instance_signals, save_group_signals, tag_samples, register_categorical_tag, set_categorical_tag, discard_samples, get_samples_by_tag, get_discarded_samples, signal, eval_fn, compute_signals, SignalContext, clear_all, run_pending_evaluation, trigger_pending_evaluation_async, query_signal_history, query_sample_history, query_instance_history, write_history, write_dataframe, get_current_experiment_hash, pointcloud_thumbnail, pointcloud_boxes +from .src import watch_or_edit, start_training, serve, keep_serving, save_signals, save_instance_signals, save_group_signals, tag_samples, register_categorical_tag, set_categorical_tag, discard_samples, get_samples_by_tag, get_discarded_samples, signal, eval_fn, compute_signals, SignalContext, BatchSignalContext, StaleSignalError, drain_signals, clear_all, run_pending_evaluation, trigger_pending_evaluation_async, query_signal_history, query_sample_history, query_instance_history, write_history, write_dataframe, classify_loss_shape, trajectory_stats, write_loss_shapes, write_signal_shapes, enable_loss_shape_signal, LOSS_SHAPES, get_current_experiment_hash, pointcloud_thumbnail, pointcloud_boxes from .backend.ledgers import GLOBAL_LEDGER as ledger from .art import _BANNER from .utils.logs import setup_logging, set_log_directory, is_main_process @@ -148,6 +148,9 @@ def _clean(v: str) -> str: "get_samples_by_tag", "get_discarded_samples", "SignalContext", + "BatchSignalContext", + "StaleSignalError", + "drain_signals", "clear_all", "seed_everything", "run_pending_evaluation", @@ -166,6 +169,12 @@ def _clean(v: str) -> str: "write_history", "write_dataframe", + "classify_loss_shape", + "write_loss_shapes", + "write_signal_shapes", + "trajectory_stats", + "enable_loss_shape_signal", + "LOSS_SHAPES", "pointcloud_thumbnail", "pointcloud_boxes", diff --git a/weightslab/backend/ledgers.py b/weightslab/backend/ledgers.py index 76e77ea7..8c829dc9 100644 --- a/weightslab/backend/ledgers.py +++ b/weightslab/backend/ledgers.py @@ -582,7 +582,19 @@ def __len__(self): def __getitem__(self, idx): if self._obj is None: raise TypeError("Proxy target not set") - return self._obj[idx] + value = self._obj[idx] + # Mirror .get(): subscript access on a mapping returns a live key proxy + # for "simple" values, so ``b['test']`` tracks studio edits exactly like + # ``b.get('test')``. Lists/slices and non-mapping containers, plus + # list/dict/callable values, are returned raw (unchanged behavior). + if (hasattr(self._obj, "get") and hasattr(self._obj, "keys") + and not (isinstance(value, (list, dict)) or callable(value))): + vp = Proxy._ValueProxy(self, idx) + # str / torch.device are handed back plain (see _plain_get_value); + # other simple types stay live proxies so studio edits keep tracking. + plain = _plain_get_value(vp._resolve()) + return vp if plain is _KEEP_AS_PROXY else plain + return value def __setitem__(self, key, value): """Support item assignment: proxy[key] = value""" diff --git a/weightslab/backend/logger.py b/weightslab/backend/logger.py index 4e6f95b1..a29bdf07 100644 --- a/weightslab/backend/logger.py +++ b/weightslab/backend/logger.py @@ -29,9 +29,12 @@ staging appends and flushes take the same lock. """ +import functools import json +import os import threading import time +from collections import defaultdict import duckdb import pandas as pd @@ -81,6 +84,20 @@ def __init__(self, register: bool = True, db_path: str = ":memory:") -> None: self._stage_sample: list = [] self._stage_instance: list = [] self._seq = 0 + + # Per-sample query cache. Many consumers read the SAME (signal, ids) in + # one step (e.g. reactive signals all reading the loss); memoize so N + # identical reads cost 1 scan. Keyed by (signal, ids, [step,] hash, + # version[signal]); staging a row bumps that signal's version to + # invalidate. Step-scoped: _stage_sample_row clears both caches when the + # step advances (keys never recur across steps, so old entries are dead). + # Cache size is env-configurable (WL_QUERY_CACHE_MAXSIZE, default 2048). + _qps_maxsize = int(os.environ.get("WL_QUERY_CACHE_MAXSIZE", "2048")) + self._qps_version: dict = defaultdict(int) + self._qps_cache_step: int = -1 + self._qps_cache = functools.lru_cache(maxsize=_qps_maxsize)(self._query_per_sample_uncached) + self._qps_step_cache = functools.lru_cache(maxsize=_qps_maxsize)(self._query_per_sample_at_step_uncached) + self._ensure_tables() self._restore_runtime_state_from_db() @@ -167,7 +184,10 @@ def _maybe_autoflush(self) -> None: self._flush_stage() def _flush_stage(self) -> None: - """Bulk-insert all staged rows into DuckDB and clear the buffers.""" + """Bulk-insert all staged rows into DuckDB and clear the buffers. + + Uses register(pandas)->INSERT SELECT->unregister (DuckDB's fast bulk + path). A row-wise executemany was measured ~6x slower — don't switch.""" with self._lock: if self._stage_signals: df = pd.DataFrame(self._stage_signals, columns=_SIGNAL_COLS) @@ -198,11 +218,22 @@ def _stage_signal_row(self, graph_name, exp_hash, step, metric_value, timestamp, self._maybe_autoflush() def _stage_sample_row(self, graph_name, exp_hash, sample_id, step, value): + # New step -> last step's cache entries can't recur; drop them. + if int(step) > self._qps_cache_step: + self._invalidate_qps_cache() + self._qps_cache_step = int(step) self._stage_sample.append(( graph_name, exp_hash, str(sample_id), int(step), float(value), self._next_seq(), )) + self._qps_version[graph_name] += 1 # invalidate this signal's cached reads self._maybe_autoflush() + def _invalidate_qps_cache(self) -> None: + """Drop both query caches + versions (step advance; bulk delete/clear).""" + self._qps_cache.cache_clear() + self._qps_step_cache.cache_clear() + self._qps_version.clear() + def _stage_instance_row(self, graph_name, exp_hash, sample_id, annotation_id, step, value): self._stage_instance.append(( graph_name, exp_hash, str(sample_id), int(annotation_id), int(step), @@ -244,6 +275,7 @@ def clear_signal_histories(self): self._conn.execute("DELETE FROM per_instance") self._current_step_buffer.clear() self._buffered_step = None + self._invalidate_qps_cache() def _to_float(self, value): if isinstance(value, th.Tensor): @@ -443,6 +475,7 @@ def remove_evaluation_hash(self, eval_hash: str) -> None: self._flush_stage() self._conn.execute("DELETE FROM signals WHERE experiment_hash = ?", [eval_hash]) self._conn.execute("DELETE FROM per_sample WHERE experiment_hash = ?", [eval_hash]) + self._invalidate_qps_cache() # Drop queued points that reference this hash. self._pending_queue = [ @@ -712,19 +745,75 @@ def query_per_sample(self, graph_name: str, sample_ids=None, exp_hash=None): Returns a list of ``(sample_id, step, value, experiment_hash)`` tuples, filtered by *sample_ids* and optionally *exp_hash* (``None`` = all hashes). + + Cached (memoized until *graph_name* is next staged). Returns a fresh list. """ + ids_key = tuple(str(s) for s in sample_ids) if sample_ids is not None else None + cached = self._qps_cache(graph_name, ids_key, exp_hash, self._qps_version[graph_name]) + return list(cached) + + def _query_per_sample_uncached(self, graph_name, ids_key, exp_hash, _version): + """DuckDB read behind :meth:`query_per_sample`. ``_version`` is a cache + key only (bumped on write -> recompute). Returns an immutable tuple.""" with self._lock: self._flush_stage() params = [graph_name] sql = "SELECT sample_id, step, value, experiment_hash FROM per_sample WHERE metric_name = ?" sql += self._hash_filter(exp_hash, params) - if sample_ids is not None: + if ids_key is not None: sql += " AND sample_id IN (SELECT UNNEST(?))" - params.append([str(s) for s in sample_ids]) + params.append(list(ids_key)) sql += " ORDER BY seq" rows = self._conn.execute(sql, params).fetchall() - return [(sid, int(step), float(val), h) for (sid, step, val, h) in rows] + return tuple((sid, int(step), float(val), h) for (sid, step, val, h) in rows) + + def query_per_sample_at_step(self, graph_name: str, sample_ids, step, exp_hash=None): + """``(sample_id, value)`` for *graph_name* at exactly *step* — O(batch), + not O(history). Keeps the reactive gather flat as history grows. Cached.""" + ids_key = tuple(str(s) for s in sample_ids) if sample_ids is not None else None + cached = self._qps_step_cache(graph_name, ids_key, int(step), exp_hash, + self._qps_version[graph_name]) + return list(cached) + + def _query_per_sample_at_step_uncached(self, graph_name, ids_key, step, exp_hash, _version): + """DuckDB read behind :meth:`query_per_sample_at_step` (``_version`` = cache key). + + Fast path: the current step's value is usually still in the in-memory + staging buffer, so scan it and skip the flush->register->INSERT->SELECT + round-trip. Fall through to DuckDB only if an id isn't staged.""" + step = int(step) + with self._lock: + if ids_key is not None: + ids_set = set(ids_key) + at = {} + # Scan from the tail (append-ordered by step); first value per id + # wins, stop when all found or once we drop below `step`. + for row in reversed(self._stage_sample): + s = row[3] + if s < step: + break + if s == step and row[0] == graph_name \ + and (exp_hash is None or row[1] == exp_hash): + sid = row[2] + if sid in ids_set and sid not in at: + at[sid] = row[4] + if len(at) == len(ids_set): + break + if len(at) == len(ids_set): + return tuple((sid, float(val)) for sid, val in at.items()) + + # Fallback: not fully in the staging buffer -> flush + query DuckDB. + self._flush_stage() + params = [graph_name, step] + sql = "SELECT sample_id, value FROM per_sample WHERE metric_name = ? AND step = ?" + sql += self._hash_filter(exp_hash, params) + if ids_key is not None: + sql += " AND sample_id IN (SELECT UNNEST(?))" + params.append(list(ids_key)) + rows = self._conn.execute(sql, params).fetchall() + + return tuple((sid, float(val)) for (sid, val) in rows) def query_per_instance( self, diff --git a/weightslab/backend/model_interface.py b/weightslab/backend/model_interface.py index 6f6fc367..a3f02bc2 100755 --- a/weightslab/backend/model_interface.py +++ b/weightslab/backend/model_interface.py @@ -314,6 +314,32 @@ def audit_mode(self) -> bool: return False + @staticmethod + def _active_model_in_audit_mode() -> bool: + """Return True iff the *currently registered* experiment model is in audit mode. + + The backward/optimizer overrides below are installed on the torch classes + exactly once, process-wide. They must therefore consult whichever model is + active *now* (resolved from the ledger, the same way GuardContext does) — + NOT the ModelInterface instance that happened to install the patch first. + Capturing a single instance in the closure is a bug: a transient eval-mode + model (e.g. one built only for dependency extraction) would then make every + backward()/step() in the whole process a silent no-op. + """ + try: + model = ledgers.get_model() + except Exception: + return False + # get_model() may hand back a Proxy(None) placeholder when nothing is + # registered; unwrap it and treat "no model" as "not in audit mode". + try: + model = object.__getattribute__(model, '_obj') + except AttributeError: + pass + if model is None: + return False + return bool(getattr(model, 'audit_mode', False)) + def _setup_backward_override(self): """Set up monkey-patch to disable backward() when model is in audit/eval mode. @@ -323,12 +349,11 @@ def _setup_backward_override(self): """ # Store the original backward method original_backward = th.Tensor.backward - model_interface_ref = self def backward_override(tensor_self, gradient=None, retain_graph=False, create_graph=False, inputs=None): """Override backward to be a no-op in audit mode.""" - # Check if model is in audit mode via the audit_mode property - if model_interface_ref.audit_mode: + # Resolve the active model at call time (never a stale captured ref). + if ModelInterface._active_model_in_audit_mode(): return # Otherwise, call the original backward @@ -355,12 +380,11 @@ def _setup_optimizer_step_override(self): """ # Store the original step method original_step = th.optim.Optimizer.step - model_interface_ref = self def step_override(self, closure=None): """Override step to be a no-op in audit mode.""" - # Check if the model is in audit mode via the audit_mode property - if model_interface_ref.audit_mode: + # Resolve the active model at call time (never a stale captured ref). + if ModelInterface._active_model_in_audit_mode(): return # Otherwise, call the original step diff --git a/weightslab/components/checkpoint_manager.py b/weightslab/components/checkpoint_manager.py index 0166a4f9..0c029179 100644 --- a/weightslab/components/checkpoint_manager.py +++ b/weightslab/components/checkpoint_manager.py @@ -1473,6 +1473,36 @@ def _load_manager_state(self): except Exception as e: logger.warning(f"Failed to load manager state: {e}") + def _resolve_device(self, model: Optional[th.nn.Module] = None) -> th.device: + """Resolve the torch device to deserialize a checkpoint onto. + + Priority: the device of an already-loaded model, then the device + configured in the ledger's hyperparameters, then CPU. This avoids + ``torch.load`` defaulting to the checkpoint's original (possibly + CUDA) device on a machine where that device isn't available. + """ + if model is None: + try: + model = ledgers.get_model() + if model is not None and hasattr(model, 'get') and callable(model.get): + model = model.get() + except Exception: + model = None + if model is not None and hasattr(model, 'device'): + return model.device + + try: + configured_device = (ledgers.get_hyperparams() or {}).get('device') + except Exception: + configured_device = None + if configured_device: + try: + return th.device(configured_device) + except Exception: + logger.warning(f"Could not parse configured device {configured_device!r}; defaulting to CPU") + + return th.device('cpu') + def load_latest_checkpoint( self, model: Optional[th.nn.Module] = None, @@ -1516,7 +1546,8 @@ def load_latest_checkpoint( logger.info(f"Loading checkpoint: {latest_checkpoint.name}") try: - checkpoint = th.load(latest_checkpoint, weights_only=False) + device = self._resolve_device(model) + checkpoint = th.load(latest_checkpoint, weights_only=False, map_location=device) # Load model state if model is None: @@ -1685,7 +1716,7 @@ def load_checkpoint(self, if checkpoint_files: latest_checkpoint = checkpoint_files[-1] try: - checkpoint = th.load(latest_checkpoint, weights_only=False) + checkpoint = th.load(latest_checkpoint, weights_only=False, map_location=self._resolve_device()) rng_state = checkpoint.get('rng_state') if rng_state: result['rng_state'] = rng_state @@ -1725,7 +1756,10 @@ def load_checkpoint(self, if checkpoint_file_to_load: try: - result['weights'] = th.load(checkpoint_file_to_load, weights_only=False) + result['weights'] = th.load( + checkpoint_file_to_load, weights_only=False, + map_location=self._resolve_device(result.get('model')), + ) result['loaded_components'].add('weights') step = result['weights'].get('step', 0) diff --git a/weightslab/data/data_samples_with_ops.py b/weightslab/data/data_samples_with_ops.py index 8236fe5f..d5ef1f5b 100644 --- a/weightslab/data/data_samples_with_ops.py +++ b/weightslab/data/data_samples_with_ops.py @@ -128,7 +128,7 @@ def __init__( wrapped_dataset: Dataset, root_log_dir: Optional[str] = None, is_training: bool = True, - compute_hash: bool = True, + compute_hash: bool = False, use_tags: bool = False, tags_mapping: Optional[Dict[str, int]] = None, stats_store: Optional[H5DataFrameStore] = None, diff --git a/weightslab/data/dataframe_manager.py b/weightslab/data/dataframe_manager.py index 011c190f..0fef7c37 100644 --- a/weightslab/data/dataframe_manager.py +++ b/weightslab/data/dataframe_manager.py @@ -2151,7 +2151,7 @@ def get_combined_df( return df - def get_collapse_annotations_to_samples_df(self, iid: str = None) -> pd.DataFrame: + def get_collapse_annotations_to_samples_df(self) -> pd.DataFrame: """Collapse a (sample_id, annotation_id) multi-index df to one row per sample. The shared dataframe manager now expands every sample into one row per @@ -2423,14 +2423,14 @@ def flush_if_needed_nonblocking(self, force: bool = False): self._buffer = {} if buffered: - logger.info(f"Flushing {len(buffered)} buffered records to DataFrame (non-blocking).") + logger.debug(f"Flushing {len(buffered)} buffered records to DataFrame (non-blocking).") self._apply_buffer_records_nonblocking(buffered) - logger.info(f"Applied {len(buffered)} buffered records to DataFrame (non-blocking).") + logger.debug(f"Applied {len(buffered)} buffered records to DataFrame (non-blocking).") # Always check H5 flush even when buffer was empty (pending rows must drain too). - logger.info(f"Checking if flush to H5 is needed (non-blocking). Pending count: {len(self._pending)}.") + logger.debug(f"Checking if flush to H5 is needed (non-blocking). Pending count: {len(self._pending)}.") self._flush_to_h5_if_needed(force=force) - logger.info(f"Completed non-blocking flush check. Pending count after flush: {len(self._pending)}.") + logger.debug(f"Completed non-blocking flush check. Pending count after flush: {len(self._pending)}.") def flush(self): """Blocking flush: buffer → DF → H5. @@ -2446,14 +2446,14 @@ def flush(self): # Step 2: apply to DF (blocking _lock acquisition; outside buffer lock). if buffered: - logger.info(f"Flushing {len(buffered)} buffered records to DataFrame.") + logger.debug(f"Flushing {len(buffered)} buffered records to DataFrame.") self._apply_buffer_records(buffered) - logger.info(f"Applied {len(buffered)} buffered records to DataFrame.") + logger.debug(f"Applied {len(buffered)} buffered records to DataFrame.") # Step 3: flush DF → H5 (outside buffer lock). - logger.info(f"Checking if flush to H5 is needed. Pending count: {len(self._pending)}.") + logger.debug(f"Checking if flush to H5 is needed. Pending count: {len(self._pending)}.") self._flush_to_h5_if_needed(force=True, blocking=True) - logger.info(f"Completed flush. Pending count after flush: {len(self._pending)}.") + logger.debug(f"Completed flush. Pending count after flush: {len(self._pending)}.") # Create global instance with config-driven parameters def create_ledger_manager(): diff --git a/weightslab/data/h5_array_store.py b/weightslab/data/h5_array_store.py index 346e3b86..440e6e59 100644 --- a/weightslab/data/h5_array_store.py +++ b/weightslab/data/h5_array_store.py @@ -578,9 +578,17 @@ def save_arrays_batch( with h5py.File(str(self._path), 'a') as f_main: with h5py.File(str(tmp_path), 'r') as f_tmp: for sample_group_name in f_tmp: - if sample_group_name in f_main: - del f_main[sample_group_name] - f_tmp.copy(sample_group_name, f_main) + if sample_group_name not in f_main: + f_main.create_group(sample_group_name) + dest_group = f_main[sample_group_name] + src_group = f_tmp[sample_group_name] + # Replace only the keys present in this batch + # (e.g. "prediction") so sibling keys written + # by an earlier flush (e.g. "target") survive. + for key_name in src_group: + if key_name in dest_group: + del dest_group[key_name] + f_tmp.copy(f"{sample_group_name}/{key_name}", dest_group) path_refs: Dict[int, Dict[str, str]] = {} for sample_group_name, key_data in prepared.items(): @@ -661,7 +669,7 @@ def load_array(self, path_ref: str) -> Optional[np.ndarray]: return None # Use read lock for concurrent read access (multiple threads can load in parallel) - # self._rw_lock.acquire_read() + self._rw_lock.acquire_read() try: try: with h5py.File(str(self._path), 'r', locking=False) as f: diff --git a/weightslab/docker/docker/utils/test-deployment.sh b/weightslab/docker/docker/utils/test-deployment.sh index 11c22ef3..c58487b6 100644 --- a/weightslab/docker/docker/utils/test-deployment.sh +++ b/weightslab/docker/docker/utils/test-deployment.sh @@ -1,6 +1,6 @@ #!/bin/bash # Comprehensive deployment test for weights_studio frontend + backend -# Tests communication between frontend and Python backend (ws-segmentation example) +# Tests communication between frontend and Python backend (wl-segmentation example) # Runs both unsecured (HTTP) and secured (HTTPS) test scenarios # set -e @@ -16,7 +16,7 @@ NC='\033[0m' # No Color CURRENT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" DOCKER_DIR="$CURRENT_DIR/docker" UTILS_DIR="$DOCKER_DIR/utils" -EXAMPLE_DIR="$CURRENT_DIR/tests/playwright/examples/ws-segmentation" +EXAMPLE_DIR="$CURRENT_DIR/tests/playwright/examples/wl-segmentation" GRPC_PORT=50051 ENVOY_PORT=8080 FRONTEND_PORT=5173 diff --git a/weightslab/examples/Lightning/ws-classification/config.yaml b/weightslab/examples/Lightning/wl-classification/config.yaml similarity index 100% rename from weightslab/examples/Lightning/ws-classification/config.yaml rename to weightslab/examples/Lightning/wl-classification/config.yaml diff --git a/weightslab/examples/Lightning/ws-classification/main.py b/weightslab/examples/Lightning/wl-classification/main.py similarity index 100% rename from weightslab/examples/Lightning/ws-classification/main.py rename to weightslab/examples/Lightning/wl-classification/main.py diff --git a/weightslab/examples/Notebooks/PyTorch/ws-classification.ipynb b/weightslab/examples/Notebooks/PyTorch/ws-classification.ipynb new file mode 100644 index 00000000..29386845 --- /dev/null +++ b/weightslab/examples/Notebooks/PyTorch/ws-classification.ipynb @@ -0,0 +1,503 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "\n", + " \n", + " \"WeightsLab\n", + "\n", + " \"License\"\n", + " \"Stars\"\n", + " \"Version\"\n", + "
\n", + " \"Open\n", + "\n", + " Welcome to the WeightsLab image-classification notebook! WeightsLab is an open-source PyTorch tool for dataset debugging, mislabel detection, and mid-training data curation. Browse the Docs for details, and raise an issue on GitHub for support.
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Image Classification with WeightsLab\n", + "\n", + "This notebook trains a small CNN on **MNIST** and instruments it with WeightsLab so every training signal is traced **back to the exact samples** producing it.\n", + "\n", + "Most data problems (mislabels, outliers, class imbalance) stay invisible until your model tells you through the loss. By wrapping your training objects with `wl.watch_or_edit(...)`, WeightsLab records **per-sample** loss and metrics live, so you can rank the worst samples, spot bad labels, and curate the dataset **without restarting training**.\n", + "\n", + "### What you'll do\n", + "1. Install WeightsLab.\n", + "2. Set every knob in one **config** dict (like a `config.yaml`).\n", + "3. Wrap the model, optimizer, dataloaders, loss, and metric with the SDK.\n", + "4. Train while per-sample signals are captured.\n", + "5. Rank the highest-loss samples inline, and (optionally) open the live **Weights Studio** UI." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Install WeightsLab from PyPI. On Colab the free **T4 GPU** runtime is plenty for this demo (`Runtime -> Change runtime type -> T4 GPU`)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\"PyPI\n", + "\"PyPI\n", + "\"PyPI" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%pip install -q weightslab\n", + "# Override these for Colab compatibility.\n", + "!pip install --upgrade \"protobuf>=6.31.1\"\n", + "%pip install \"torchvision>=0.16\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Imports\n", + "\n", + "`weightslab` is imported as `wl`. The two `guard_*_context` managers scope a block as training vs. evaluation so signals are attributed to the right phase." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import tempfile\n", + "import logging\n", + "\n", + "import torch\n", + "import torch.nn as nn\n", + "import torch.optim as optim\n", + "from torch.utils.data import Dataset\n", + "from torchvision import datasets, transforms\n", + "from torchmetrics.classification import Accuracy\n", + "from tqdm.auto import tqdm\n", + "\n", + "import weightslab as wl\n", + "from weightslab.examples.utils.baseline_models.pytorch.models import FashionCNN as CNN\n", + "from weightslab.components.global_monitoring import (\n", + " guard_training_context,\n", + " guard_testing_context,\n", + ")\n", + "\n", + "logging.basicConfig(level=logging.ERROR)\n", + "device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n", + "print(f\"Using device: {device}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. A dataset that carries sample identity\n", + "\n", + "WeightsLab attributes every signal to a **sample id**. This thin wrapper around torchvision's MNIST returns `(image, id, label)` and attaches a virtual filepath per sample, so signals can be traced back to individual images in the UI." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class MNISTCustomDataset(Dataset):\n", + " \"\"\"MNIST that returns (image, index, label) and tracks a virtual filepath.\"\"\"\n", + "\n", + " def __init__(self, root, train=True, download=False, transform=None, max_samples=None):\n", + " self.mnist = datasets.MNIST(root=root, train=train, download=download, transform=None)\n", + " self.transform = transform\n", + " self.train = train\n", + " self.max_samples = max_samples\n", + " split = \"train\" if train else \"test\"\n", + " self.filepaths = {}\n", + " for idx in range(len(self.mnist)):\n", + " if max_samples is not None and idx >= max_samples:\n", + " break\n", + " label = self.mnist.targets[idx].item()\n", + " self.filepaths[idx] = os.path.join(\n", + " \"MNIST\", \"processed\", split, f\"class_{label}\", f\"sample_{idx:05d}.pt\"\n", + " )\n", + "\n", + " def __len__(self):\n", + " if self.max_samples is not None:\n", + " return min(len(self.mnist), self.max_samples)\n", + " return len(self.mnist)\n", + "\n", + " def __getitem__(self, idx):\n", + " image, label = self.mnist[idx]\n", + " if self.transform:\n", + " image = self.transform(image)\n", + " return image, idx, label" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Configuration\n", + "\n", + "Every tunable lives here, in one dict, like a `config.yaml` with comments. Wrapping it with `flag=\"hyperparameters\"` lets the Studio UI read (and live-edit) these values while training." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "log_dir = tempfile.mkdtemp(prefix=\"weightslab_mnist_\")\n", + "\n", + "config = {\n", + " # -- Experiment -------------------------------------------------------\n", + " \"experiment_name\": \"mnist_classification\", # name shown in Weights Studio\n", + " \"device\": str(device), # \"cuda\" if a GPU is available, else \"cpu\"\n", + " \"root_log_dir\": log_dir, # where signal history / dataframes are written\n", + " \"num_classes\": 10, # MNIST digits 0-9\n", + "\n", + " # -- Training schedule ------------------------------------------------\n", + " \"training_steps_to_do\": 2000, # total optimizer steps (raise for a longer live run)\n", + " \"eval_full_to_train_steps_ratio\": 100, # run a full eval every N steps\n", + " \"write_export_ratio\": 100, # export signal history + dataframe every N steps\n", + "\n", + " # -- Optimizer --------------------------------------------------------\n", + " \"learning_rate\": 0.01, # Adam learning rate\n", + "\n", + " # -- Data loaders -----------------------------------------------------\n", + " \"train_batch_size\": 16, # training batch size\n", + " \"test_batch_size\": 128, # evaluation batch size\n", + " \"max_train_samples\": None, # cap train set (None = full 60k)\n", + " \"max_test_samples\": None, # cap test set (None = full 10k)\n", + "\n", + " # -- Services ---------------------------------------------------------\n", + " \"serving_grpc\": True, # expose the gRPC backend for Weights Studio\n", + "\n", + " # -- Live UI tunnel (see the Expose section) -------------------------\n", + " \"expose_ui\": True, # open a bore tunnel so a local Studio can connect\n", + " \"backend_port\": 50051, # gRPC port the tunnel forwards\n", + "}\n", + "\n", + "wl.watch_or_edit(config, flag=\"hyperparameters\", poll_interval=1.0)\n", + "print(\"Experiment logs ->\", log_dir)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Wrap the training objects\n", + "\n", + "This is the heart of WeightsLab. Each object is passed through `wl.watch_or_edit(...)` with a `flag` describing its role. The returned objects behave exactly like the originals, but now report their state and per-sample signals to WeightsLab." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "data_root = os.path.join(log_dir, \"data\")\n", + "os.makedirs(data_root, exist_ok=True)\n", + "\n", + "train_ds = MNISTCustomDataset(root=data_root, train=True, download=True,\n", + " transform=transforms.ToTensor(),\n", + " max_samples=config[\"max_train_samples\"])\n", + "test_ds = MNISTCustomDataset(root=data_root, train=False, download=True,\n", + " transform=transforms.ToTensor(),\n", + " max_samples=config[\"max_test_samples\"])\n", + "\n", + "# Model + optimizer\n", + "model = wl.watch_or_edit(CNN().to(device), flag=\"model\", device=device)\n", + "optimizer = wl.watch_or_edit(\n", + " optim.Adam(model.parameters(), lr=config[\"learning_rate\"]), flag=\"optimizer\")\n", + "\n", + "# Tracked dataloaders\n", + "train_loader = wl.watch_or_edit(\n", + " train_ds, flag=\"data\", loader_name=\"train_loader\",\n", + " batch_size=config[\"train_batch_size\"], shuffle=True, is_training=True,\n", + " compute_hash=False, preload_labels=True,\n", + " preload_metadata=False, enable_h5_persistence=True,\n", + ")\n", + "test_loader = wl.watch_or_edit(\n", + " test_ds, flag=\"data\", loader_name=\"test_loader\",\n", + " batch_size=config[\"test_batch_size\"], shuffle=False, is_training=False,\n", + " compute_hash=False, preload_labels=True,\n", + " preload_metadata=False, enable_h5_persistence=True,\n", + ")\n", + "\n", + "# Watched losses + metric (they log themselves per sample)\n", + "train_criterion = wl.watch_or_edit(nn.CrossEntropyLoss(reduction=\"none\"),\n", + " flag=\"loss\", signal_name=\"train-loss-CE\", log=True)\n", + "test_criterion = wl.watch_or_edit(nn.CrossEntropyLoss(reduction=\"none\"),\n", + " flag=\"loss\", signal_name=\"test-loss-CE\", log=True)\n", + "metric = wl.watch_or_edit(\n", + " Accuracy(task=\"multiclass\", num_classes=config[\"num_classes\"]).to(device),\n", + " flag=\"metric\", signal_name=\"metric-ACC\", log=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Train and evaluate steps\n", + "\n", + "The `guard_training_context` / `guard_testing_context` blocks tell WeightsLab which phase it's in. `criterion(..., batch_ids=ids, preds=preds)` passes the sample ids so the loss is stored **per sample**, and `wl.save_signals(...)` logs a custom per-sample accuracy signal during evaluation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def train(loader, model, optimizer, criterion, device):\n", + " with guard_training_context:\n", + " inputs, ids, labels = next(loader)\n", + " inputs, labels = inputs.to(device), labels.to(device)\n", + "\n", + " optimizer.zero_grad()\n", + " logits = model(inputs)\n", + " preds = logits.argmax(dim=1, keepdim=True)\n", + "\n", + " loss = criterion(logits.float(), labels.long(), batch_ids=ids, preds=preds)\n", + " total_loss = loss.mean()\n", + " total_loss.backward()\n", + " optimizer.step()\n", + " return total_loss.detach().cpu().item()\n", + "\n", + "\n", + "def test(loader, model, criterion, metric, device, n_batches):\n", + " losses = torch.tensor(0.0, device=device)\n", + " for inputs, ids, labels in loader:\n", + " with guard_testing_context:\n", + " inputs, labels = inputs.to(device), labels.to(device)\n", + " logits = model(inputs)\n", + " preds = logits.argmax(dim=1, keepdim=True)\n", + "\n", + " loss = criterion(logits, labels, batch_ids=ids, preds=preds)\n", + " losses += loss.mean()\n", + " metric.update(logits, labels)\n", + "\n", + " correct = (preds.view(-1) == labels.view(-1)).float()\n", + " wl.save_signals(\n", + " preds_raw=logits, targets=labels, batch_ids=ids, preds=preds,\n", + " signals={\n", + " \"test_metric/Accuracy_per_sample\": correct,\n", + " \"test_metric/Inverse_Accuracy_per_sample\": 1.0 - correct,\n", + " },\n", + " )\n", + " return (losses / n_batches).item(), (metric.compute() * 100).item()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. (Optional) Expose the backend for the live UI\n", + "\n", + "Skip this if you only want the inline results below. To watch training **live in Weights Studio on your own machine**, keep `config[\"expose_ui\"] = True`.\n", + "\n", + "This opens a **raw-TCP** tunnel over [`bore`](https://github.com/ekzhang/bore) and its free public relay (`bore.pub`) - **no signup, no account, no credit card** - and prints the exact `weightslab tunnel ...` command to run locally.\n", + "\n", + "> Note: raw TCP is required so gRPC's HTTP/2 frames pass through untouched (this is also why we avoid `ngrok`, which now needs a credit card for TCP endpoints).\n", + ">\n", + "> Note: `bore.pub` is a shared public relay; the random port is the only thing protecting your endpoint. Fine for a demo, not for sensitive data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import re, tarfile, threading, urllib.request, subprocess\n", + "\n", + "endpoint = None\n", + "if config[\"expose_ui\"]:\n", + " BORE = \"v0.6.0\"\n", + " if not os.path.exists(\"bore\"):\n", + " urllib.request.urlretrieve(\n", + " f\"https://github.com/ekzhang/bore/releases/download/{BORE}/bore-{BORE}-x86_64-unknown-linux-musl.tar.gz\",\n", + " \"bore.tar.gz\")\n", + " with tarfile.open(\"bore.tar.gz\") as t:\n", + " t.extractall()\n", + " os.chmod(\"bore\", 0o755)\n", + "\n", + " proc = subprocess.Popen(\n", + " [\"./bore\", \"local\", str(config[\"backend_port\"]), \"--to\", \"bore.pub\"],\n", + " stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)\n", + " for line in proc.stdout:\n", + " m = re.search(r\"bore\\.pub:(\\d+)\", line)\n", + " if m:\n", + " endpoint = f\"bore.pub:{m.group(1)}\"\n", + " break\n", + " threading.Thread(target=lambda: [None for _ in proc.stdout], daemon=True).start()\n", + "\n", + " print(\"=\" * 60)\n", + " print(\"Backend exposed at:\", endpoint)\n", + " print(\"On your OWN machine (Docker running), run these two commands:\")\n", + " print(\" weightslab ui launch\")\n", + " print(f\" weightslab tunnel {endpoint}\")\n", + " print(\"=\" * 60)\n", + "else:\n", + " print(\"expose_ui is False - inline results only, no tunnel.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. Serve and train\n", + "\n", + "`wl.serve(serving_grpc=True)` starts the background gRPC server (non-blocking) that Weights Studio connects to. `wl.start_training(...)` flips the experiment into the *training* state, then we run the loop, periodically evaluating and exporting signals.\n", + "\n", + "Leave this cell **running** while you watch it stream in the UI." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "wl.serve(serving_grpc=config[\"serving_grpc\"])\n", + "wl.start_training(timeout=3)\n", + "\n", + "steps = config[\"training_steps_to_do\"]\n", + "eval_ratio = config[\"eval_full_to_train_steps_ratio\"]\n", + "export_ratio = config[\"write_export_ratio\"]\n", + "n_test_batches = len(test_loader)\n", + "\n", + "test_loss = test_acc = None\n", + "pbar = tqdm(range(steps), desc=\"Training\")\n", + "for step in pbar:\n", + " age = model.get_age() if hasattr(model, \"get_age\") else step\n", + "\n", + " train_loss = train(train_loader, model, optimizer, train_criterion, device)\n", + "\n", + " if age > 0 and age % eval_ratio == 0:\n", + " test_loss, test_acc = test(test_loader, model, test_criterion, metric, device, n_test_batches)\n", + "\n", + " if age > 0 and age % export_ratio == 0:\n", + " wl.write_history()\n", + " wl.write_dataframe()\n", + "\n", + " postfix = {\"loss\": f\"{train_loss:.3f}\"}\n", + " if test_acc is not None:\n", + " postfix[\"test_acc\"] = f\"{test_acc:.1f}%\"\n", + " pbar.set_postfix(postfix)\n", + "\n", + "wl.write_history()\n", + "wl.write_dataframe()\n", + "print(\"Training complete. Logs at:\", log_dir)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. Which samples hurt the most?\n", + "\n", + "WeightsLab exports a per-sample dataframe to `root_log_dir`. Ranking by loss surfaces the samples the model struggles with - the usual suspects for **mislabels and outliers**. This is the same signal the Studio UI visualizes; here we peek at it inline." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import glob\n", + "import pandas as pd\n", + "\n", + "paths = sorted(glob.glob(os.path.join(log_dir, \"**\", \"*.json\"), recursive=True),\n", + " key=os.path.getmtime)\n", + "if paths:\n", + " df = pd.read_json(paths[-1])\n", + " print(f\"Loaded {paths[-1]} (shape={df.shape})\")\n", + " loss_cols = [c for c in df.columns if \"loss\" in c.lower()]\n", + " if loss_cols:\n", + " display(df.sort_values(loss_cols[-1], ascending=False).head(50))\n", + " else:\n", + " display(df.head())\n", + "else:\n", + " print(\"No export found - run the training cell first.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. See it live in Weights Studio\n", + "\n", + "Everything above ran headless. The real payoff is the **Weights Studio** UI, where you browse the highest-loss images, filter by class, and curate the dataset mid-training.\n", + "\n", + "Studio runs as a local Docker stack (a static frontend + an Envoy gRPC-Web proxy). **Colab has no Docker daemon**, so you don't run the UI *inside* Colab - you run Studio on your own machine and point it at this notebook's backend using the `bore.pub:` endpoint **printed in Section 6**.\n", + "\n", + "**On your machine** (with Docker Desktop):\n", + "```bash\n", + "pip install weightslab\n", + "\n", + "# Terminal 1 - launch the UI (plaintext HTTP, the default)\n", + "weightslab ui launch # opens http://localhost:5173\n", + "\n", + "# Terminal 2 - bridge the Colab backend to localhost:50051\n", + "weightslab tunnel bore.pub:12345 # the host:port printed in Section 6\n", + "```\n", + "\n", + "Then open **http://localhost:5173** - Studio connects through your local Envoy -> `weightslab tunnel` -> `bore.pub` -> this Colab backend, and you watch training stream live.\n", + "\n", + "> Note: keep it plaintext end-to-end (the default `weightslab ui launch`, raw-TCP `bore`) so gRPC's HTTP/2 frames pass through untouched.\n", + "\n", + "Prefer to keep it all local? Run this same example on your own machine (`weightslab start example --cls`) and launch the UI next to it - no tunnel required." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "
\n", + "Crafted by GrayboxTech - if WeightsLab helps you catch a bad label, drop us a star on GitHub.\n", + "
" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "name": "ws-classification.ipynb", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/weightslab/examples/Notebooks/PyTorch/ws-clustering.ipynb b/weightslab/examples/Notebooks/PyTorch/ws-clustering.ipynb new file mode 100644 index 00000000..4dfcd835 --- /dev/null +++ b/weightslab/examples/Notebooks/PyTorch/ws-clustering.ipynb @@ -0,0 +1,241 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "\n", + " \n", + " \"WeightsLab\n", + "\n", + " \"License\"\n", + " \"Stars\"\n", + " \"Version\"\n", + "
\n", + " \"Open\n", + "\n", + " Welcome to the WeightsLab Face Recognition (triplet loss) notebook! WeightsLab traces training signals back to the exact samples producing them. Browse the Docs for details.
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Face Recognition (triplet loss) with WeightsLab\n", + "\n", + "Trains a ResNet-18 embedding head with online batch-hard **triplet loss** and tracks verification / retrieval / similarity signals per sample.\n", + "\n", + "> Data: Defaults to the **Olivetti Faces** dataset (via scikit-learn) which works **offline** - no download needed. Switch to LFW or a folder dataset in the config.\n", + "\n", + "Unlike the self-contained classification demo, this example uses local helper modules\n", + "(`utils/`) and bundled/downloaded data, so the notebook **clones the repo** and runs the\n", + "example in place." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "On Colab, pick a **T4 GPU** runtime (`Runtime -> Change runtime type -> T4 GPU`)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\"PyPI\n", + "\"PyPI\n", + "\"PyPI" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Clone the repo (for the example's utils/ + data) and install WeightsLab.\n", + "import os\n", + "if not os.path.isdir(\"weightslab\"):\n", + " !git clone --branch torch_collab_examples https://github.com/GrayboxTech/weightslab.git\n", + "%pip install -q ./weightslab\n", + "!pip install --upgrade \"protobuf>=6.31.1\"\n", + "%pip install \"torchvision>=0.16\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Move into the example directory so its `utils/` and config.yaml resolve.\n", + "%cd weightslab/weightslab/examples/PyTorch/ws-clustering\n", + "# Install the example's own extra requirements, if any.\n", + "import os\n", + "if os.path.exists(\"requirements.txt\"):\n", + " !pip install -q -r requirements.txt" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Configuration\n", + "\n", + "Every tunable lives in **`config.yaml`** (already commented). We load it into a `config` dict\n", + "so you can tweak values here; the changes are written back before training." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import yaml\n", + "\n", + "with open(\"config.yaml\") as f:\n", + " config = yaml.safe_load(f)\n", + "\n", + "# --- tweak any parameter here, e.g. ---\n", + "# config[\"eval_full_to_train_steps_ratio\"] = 5\n", + "config[\"serving_grpc\"] = True # expose the gRPC backend for Weights Studio\n", + "\n", + "with open(\"config.yaml\", \"w\") as f:\n", + " yaml.safe_dump(config, f, sort_keys=False)\n", + "\n", + "print(yaml.safe_dump(config, sort_keys=False))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## (Optional) Expose the backend for the live UI\n", + "\n", + "To watch training **live in Weights Studio on your own machine**, run this cell first (it opens a\n", + "raw-TCP [`bore`](https://github.com/ekzhang/bore) tunnel over the free public relay `bore.pub` -\n", + "**no signup, no card**), then start training below.\n", + "\n", + "> `bore.pub` is a shared public relay; the random port is the only thing protecting your endpoint.\n", + "> Fine for a demo, not for sensitive data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, re, tarfile, threading, urllib.request, subprocess\n", + "\n", + "EXPOSE_UI = True # set False for a headless run (no tunnel)\n", + "BACKEND_PORT = 50051 # gRPC port to forward\n", + "\n", + "endpoint = None\n", + "if EXPOSE_UI:\n", + " bore = os.path.join(os.getcwd(), \"bore\")\n", + " if not os.path.exists(bore):\n", + " BORE = \"v0.6.0\"\n", + " urllib.request.urlretrieve(\n", + " f\"https://github.com/ekzhang/bore/releases/download/{BORE}/bore-{BORE}-x86_64-unknown-linux-musl.tar.gz\",\n", + " \"bore.tar.gz\")\n", + " with tarfile.open(\"bore.tar.gz\") as t:\n", + " t.extractall()\n", + " os.chmod(bore, 0o755)\n", + "\n", + " proc = subprocess.Popen(\n", + " [bore, \"local\", str(BACKEND_PORT), \"--to\", \"bore.pub\"],\n", + " stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)\n", + " for line in proc.stdout:\n", + " m = re.search(r\"bore\\.pub:(\\d+)\", line)\n", + " if m:\n", + " endpoint = f\"bore.pub:{m.group(1)}\"\n", + " break\n", + " threading.Thread(target=lambda: [None for _ in proc.stdout], daemon=True).start()\n", + "\n", + " print(\"=\" * 60)\n", + " print(\"Backend exposed at:\", endpoint)\n", + " print(\"On your OWN machine (Docker running), run:\")\n", + " print(\" weightslab ui launch\")\n", + " print(f\" weightslab tunnel {endpoint}\")\n", + " print(\"=\" * 60)\n", + "else:\n", + " print(\"EXPOSE_UI is False - no tunnel.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Train\n", + "\n", + "This runs the example's `main.py`, which wraps the model / optimizer / loaders / losses with\n", + "`wl.watch_or_edit(...)`, starts the gRPC backend, and trains while streaming per-sample signals.\n", + "\n", + "> This example trains **open-ended** (until you interrupt the cell). Interrupt the cell (stop button) to stop training; the exported history and\n", + "> dataframe are written to a temp `root_log_dir` (printed at startup)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!python main.py" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## See it live in Weights Studio\n", + "\n", + "**Colab has no Docker daemon**, so run Studio on your own machine and point it at this notebook's\n", + "backend using the `bore.pub:` printed in the Expose section:\n", + "\n", + "```bash\n", + "pip install weightslab\n", + "weightslab ui launch # Terminal 1 - opens http://localhost:5173\n", + "weightslab tunnel bore.pub:12345 # Terminal 2 - the host:port from the Expose section\n", + "```\n", + "\n", + "Then open **http://localhost:5173**. Prefer local-only? Run this example directly on your machine\n", + "(`weightslab start example` selects a bundled example) and launch the UI next to it - no tunnel." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "
\n", + "Crafted by GrayboxTech - if WeightsLab helps you catch a bad label, drop us a star on GitHub.\n", + "
" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "name": "ws-clustering.ipynb", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/weightslab/examples/Notebooks/PyTorch/ws-detection.ipynb b/weightslab/examples/Notebooks/PyTorch/ws-detection.ipynb new file mode 100644 index 00000000..69c72d74 --- /dev/null +++ b/weightslab/examples/Notebooks/PyTorch/ws-detection.ipynb @@ -0,0 +1,241 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "\n", + " \n", + " \"WeightsLab\n", + "\n", + " \"License\"\n", + " \"Stars\"\n", + " \"Version\"\n", + "
\n", + " \"Open\n", + "\n", + " Welcome to the WeightsLab Object Detection (Penn-Fudan) notebook! WeightsLab traces training signals back to the exact samples producing them. Browse the Docs for details.
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Object Detection (Penn-Fudan) with WeightsLab\n", + "\n", + "Trains a small detector (MobileNetV3-Small backbone + detection head) on the **Penn-Fudan** pedestrian dataset, tracking per-sample detection loss and per-instance IoU so each predicted box is traceable in Studio.\n", + "\n", + "> Data: The Penn-Fudan dataset (~170 images) is **downloaded on first run** under the example's `data/`.\n", + "\n", + "Unlike the self-contained classification demo, this example uses local helper modules\n", + "(`utils/`) and bundled/downloaded data, so the notebook **clones the repo** and runs the\n", + "example in place." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "On Colab, pick a **T4 GPU** runtime (`Runtime -> Change runtime type -> T4 GPU`)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\"PyPI\n", + "\"PyPI\n", + "\"PyPI" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Clone the repo (for the example's utils/ + data) and install WeightsLab.\n", + "import os\n", + "if not os.path.isdir(\"weightslab\"):\n", + " !git clone --branch torch_collab_examples https://github.com/GrayboxTech/weightslab.git\n", + "%pip install -q ./weightslab\n", + "!pip install --upgrade \"protobuf>=6.31.1\"\n", + "%pip install \"torchvision>=0.16\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Move into the example directory so its `utils/` and config.yaml resolve.\n", + "%cd weightslab/weightslab/examples/PyTorch/ws-detection\n", + "# Install the example's own extra requirements, if any.\n", + "import os\n", + "if os.path.exists(\"requirements.txt\"):\n", + " !pip install -q -r requirements.txt" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Configuration\n", + "\n", + "Every tunable lives in **`config.yaml`** (already commented). We load it into a `config` dict\n", + "so you can tweak values here; the changes are written back before training." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import yaml\n", + "\n", + "with open(\"config.yaml\") as f:\n", + " config = yaml.safe_load(f)\n", + "\n", + "# --- tweak any parameter here, e.g. ---\n", + "# config[\"eval_full_to_train_steps_ratio\"] = 5\n", + "config[\"serving_grpc\"] = True # expose the gRPC backend for Weights Studio\n", + "\n", + "with open(\"config.yaml\", \"w\") as f:\n", + " yaml.safe_dump(config, f, sort_keys=False)\n", + "\n", + "print(yaml.safe_dump(config, sort_keys=False))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## (Optional) Expose the backend for the live UI\n", + "\n", + "To watch training **live in Weights Studio on your own machine**, run this cell first (it opens a\n", + "raw-TCP [`bore`](https://github.com/ekzhang/bore) tunnel over the free public relay `bore.pub` -\n", + "**no signup, no card**), then start training below.\n", + "\n", + "> `bore.pub` is a shared public relay; the random port is the only thing protecting your endpoint.\n", + "> Fine for a demo, not for sensitive data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, re, tarfile, threading, urllib.request, subprocess\n", + "\n", + "EXPOSE_UI = True # set False for a headless run (no tunnel)\n", + "BACKEND_PORT = 50051 # gRPC port to forward\n", + "\n", + "endpoint = None\n", + "if EXPOSE_UI:\n", + " bore = os.path.join(os.getcwd(), \"bore\")\n", + " if not os.path.exists(bore):\n", + " BORE = \"v0.6.0\"\n", + " urllib.request.urlretrieve(\n", + " f\"https://github.com/ekzhang/bore/releases/download/{BORE}/bore-{BORE}-x86_64-unknown-linux-musl.tar.gz\",\n", + " \"bore.tar.gz\")\n", + " with tarfile.open(\"bore.tar.gz\") as t:\n", + " t.extractall()\n", + " os.chmod(bore, 0o755)\n", + "\n", + " proc = subprocess.Popen(\n", + " [bore, \"local\", str(BACKEND_PORT), \"--to\", \"bore.pub\"],\n", + " stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)\n", + " for line in proc.stdout:\n", + " m = re.search(r\"bore\\.pub:(\\d+)\", line)\n", + " if m:\n", + " endpoint = f\"bore.pub:{m.group(1)}\"\n", + " break\n", + " threading.Thread(target=lambda: [None for _ in proc.stdout], daemon=True).start()\n", + "\n", + " print(\"=\" * 60)\n", + " print(\"Backend exposed at:\", endpoint)\n", + " print(\"On your OWN machine (Docker running), run:\")\n", + " print(\" weightslab ui launch\")\n", + " print(f\" weightslab tunnel {endpoint}\")\n", + " print(\"=\" * 60)\n", + "else:\n", + " print(\"EXPOSE_UI is False - no tunnel.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Train\n", + "\n", + "This runs the example's `main.py`, which wraps the model / optimizer / loaders / losses with\n", + "`wl.watch_or_edit(...)`, starts the gRPC backend, and trains while streaming per-sample signals.\n", + "\n", + "> This example trains **open-ended** (until you interrupt the cell). Interrupt the cell (stop button) to stop training; the exported history and\n", + "> dataframe are written to a temp `root_log_dir` (printed at startup)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!python main.py" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## See it live in Weights Studio\n", + "\n", + "**Colab has no Docker daemon**, so run Studio on your own machine and point it at this notebook's\n", + "backend using the `bore.pub:` printed in the Expose section:\n", + "\n", + "```bash\n", + "pip install weightslab\n", + "weightslab ui launch # Terminal 1 - opens http://localhost:5173\n", + "weightslab tunnel bore.pub:12345 # Terminal 2 - the host:port from the Expose section\n", + "```\n", + "\n", + "Then open **http://localhost:5173**. Prefer local-only? Run this example directly on your machine\n", + "(`weightslab start example` selects a bundled example) and launch the UI next to it - no tunnel." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "
\n", + "Crafted by GrayboxTech - if WeightsLab helps you catch a bad label, drop us a star on GitHub.\n", + "
" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "name": "ws-detection.ipynb", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/weightslab/examples/Notebooks/PyTorch/ws-generation.ipynb b/weightslab/examples/Notebooks/PyTorch/ws-generation.ipynb new file mode 100644 index 00000000..7efea18e --- /dev/null +++ b/weightslab/examples/Notebooks/PyTorch/ws-generation.ipynb @@ -0,0 +1,241 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "\n", + " \n", + " \"WeightsLab\n", + "\n", + " \"License\"\n", + " \"Stars\"\n", + " \"Version\"\n", + "
\n", + " \"Open\n", + "\n", + " Welcome to the WeightsLab Image Generation (VAD U-Net) notebook! WeightsLab traces training signals back to the exact samples producing them. Browse the Docs for details.
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Image Generation (VAD U-Net) with WeightsLab\n", + "\n", + "Trains a U-Net-style generator and tracks per-sample reconstruction signals.\n", + "\n", + "> Data: **This example needs your own image dataset** - set `data_root` in the config to a folder of images before running (there is no bundled/downloaded dataset for it).\n", + "\n", + "Unlike the self-contained classification demo, this example uses local helper modules\n", + "(`utils/`) and bundled/downloaded data, so the notebook **clones the repo** and runs the\n", + "example in place." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "On Colab, pick a **T4 GPU** runtime (`Runtime -> Change runtime type -> T4 GPU`)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\"PyPI\n", + "\"PyPI\n", + "\"PyPI" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Clone the repo (for the example's utils/ + data) and install WeightsLab.\n", + "import os\n", + "if not os.path.isdir(\"weightslab\"):\n", + " !git clone --branch torch_collab_examples https://github.com/GrayboxTech/weightslab.git\n", + "%pip install -q ./weightslab\n", + "!pip install --upgrade \"protobuf>=6.31.1\"\n", + "%pip install \"torchvision>=0.16\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Move into the example directory so its `utils/` and config.yaml resolve.\n", + "%cd weightslab/weightslab/examples/PyTorch/ws-generation\n", + "# Install the example's own extra requirements, if any.\n", + "import os\n", + "if os.path.exists(\"requirements.txt\"):\n", + " !pip install -q -r requirements.txt" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Configuration\n", + "\n", + "Every tunable lives in **`config.yaml`** (already commented). We load it into a `config` dict\n", + "so you can tweak values here; the changes are written back before training." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import yaml\n", + "\n", + "with open(\"config.yaml\") as f:\n", + " config = yaml.safe_load(f)\n", + "\n", + "# --- tweak any parameter here, e.g. ---\n", + "# config[\"eval_full_to_train_steps_ratio\"] = 5\n", + "config[\"serving_grpc\"] = True # expose the gRPC backend for Weights Studio\n", + "\n", + "with open(\"config.yaml\", \"w\") as f:\n", + " yaml.safe_dump(config, f, sort_keys=False)\n", + "\n", + "print(yaml.safe_dump(config, sort_keys=False))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## (Optional) Expose the backend for the live UI\n", + "\n", + "To watch training **live in Weights Studio on your own machine**, run this cell first (it opens a\n", + "raw-TCP [`bore`](https://github.com/ekzhang/bore) tunnel over the free public relay `bore.pub` -\n", + "**no signup, no card**), then start training below.\n", + "\n", + "> `bore.pub` is a shared public relay; the random port is the only thing protecting your endpoint.\n", + "> Fine for a demo, not for sensitive data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, re, tarfile, threading, urllib.request, subprocess\n", + "\n", + "EXPOSE_UI = True # set False for a headless run (no tunnel)\n", + "BACKEND_PORT = 50051 # gRPC port to forward\n", + "\n", + "endpoint = None\n", + "if EXPOSE_UI:\n", + " bore = os.path.join(os.getcwd(), \"bore\")\n", + " if not os.path.exists(bore):\n", + " BORE = \"v0.6.0\"\n", + " urllib.request.urlretrieve(\n", + " f\"https://github.com/ekzhang/bore/releases/download/{BORE}/bore-{BORE}-x86_64-unknown-linux-musl.tar.gz\",\n", + " \"bore.tar.gz\")\n", + " with tarfile.open(\"bore.tar.gz\") as t:\n", + " t.extractall()\n", + " os.chmod(bore, 0o755)\n", + "\n", + " proc = subprocess.Popen(\n", + " [bore, \"local\", str(BACKEND_PORT), \"--to\", \"bore.pub\"],\n", + " stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)\n", + " for line in proc.stdout:\n", + " m = re.search(r\"bore\\.pub:(\\d+)\", line)\n", + " if m:\n", + " endpoint = f\"bore.pub:{m.group(1)}\"\n", + " break\n", + " threading.Thread(target=lambda: [None for _ in proc.stdout], daemon=True).start()\n", + "\n", + " print(\"=\" * 60)\n", + " print(\"Backend exposed at:\", endpoint)\n", + " print(\"On your OWN machine (Docker running), run:\")\n", + " print(\" weightslab ui launch\")\n", + " print(f\" weightslab tunnel {endpoint}\")\n", + " print(\"=\" * 60)\n", + "else:\n", + " print(\"EXPOSE_UI is False - no tunnel.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Train\n", + "\n", + "This runs the example's `main.py`, which wraps the model / optimizer / loaders / losses with\n", + "`wl.watch_or_edit(...)`, starts the gRPC backend, and trains while streaming per-sample signals.\n", + "\n", + "> Training runs for `training_steps` (default 200000); interrupt the cell to stop early. Interrupt the cell (stop button) to stop training; the exported history and\n", + "> dataframe are written to a temp `root_log_dir` (printed at startup)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!python main.py" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## See it live in Weights Studio\n", + "\n", + "**Colab has no Docker daemon**, so run Studio on your own machine and point it at this notebook's\n", + "backend using the `bore.pub:` printed in the Expose section:\n", + "\n", + "```bash\n", + "pip install weightslab\n", + "weightslab ui launch # Terminal 1 - opens http://localhost:5173\n", + "weightslab tunnel bore.pub:12345 # Terminal 2 - the host:port from the Expose section\n", + "```\n", + "\n", + "Then open **http://localhost:5173**. Prefer local-only? Run this example directly on your machine\n", + "(`weightslab start example` selects a bundled example) and launch the UI next to it - no tunnel." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "
\n", + "Crafted by GrayboxTech - if WeightsLab helps you catch a bad label, drop us a star on GitHub.\n", + "
" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "name": "ws-generation.ipynb", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/weightslab/examples/Notebooks/PyTorch/ws-segmentation.ipynb b/weightslab/examples/Notebooks/PyTorch/ws-segmentation.ipynb new file mode 100644 index 00000000..fc97f425 --- /dev/null +++ b/weightslab/examples/Notebooks/PyTorch/ws-segmentation.ipynb @@ -0,0 +1,241 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "\n", + " \n", + " \"WeightsLab\n", + "\n", + " \"License\"\n", + " \"Stars\"\n", + " \"Version\"\n", + "
\n", + " \"Open\n", + "\n", + " Welcome to the WeightsLab Semantic Segmentation (BDD100k) notebook! WeightsLab traces training signals back to the exact samples producing them. Browse the Docs for details.
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Semantic Segmentation (BDD100k) with WeightsLab\n", + "\n", + "Trains a small U-Net on a **BDD100k** subset and instruments per-**instance** and per-**sample** Dice (metric) and BCE (loss), so every mask's quality is traced back to the exact sample and annotation producing it.\n", + "\n", + "> Data: The BDD100k subset (`BDD_subset/`) **ships in the repo**, so no download is needed.\n", + "\n", + "Unlike the self-contained classification demo, this example uses local helper modules\n", + "(`utils/`) and bundled/downloaded data, so the notebook **clones the repo** and runs the\n", + "example in place." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "On Colab, pick a **T4 GPU** runtime (`Runtime -> Change runtime type -> T4 GPU`)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\"PyPI\n", + "\"PyPI\n", + "\"PyPI" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Clone the repo (for the example's utils/ + data) and install WeightsLab.\n", + "import os\n", + "if not os.path.isdir(\"weightslab\"):\n", + " !git clone --branch torch_collab_examples https://github.com/GrayboxTech/weightslab.git\n", + "%pip install -q ./weightslab\n", + "!pip install --upgrade \"protobuf>=6.31.1\"\n", + "%pip install \"torchvision>=0.16\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Move into the example directory so its `utils/` and config.yaml resolve.\n", + "%cd weightslab/weightslab/examples/PyTorch/ws-segmentation\n", + "# Install the example's own extra requirements, if any.\n", + "import os\n", + "if os.path.exists(\"requirements.txt\"):\n", + " !pip install -q -r requirements.txt" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Configuration\n", + "\n", + "Every tunable lives in **`config.yaml`** (already commented). We load it into a `config` dict\n", + "so you can tweak values here; the changes are written back before training." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import yaml\n", + "\n", + "with open(\"config.yaml\") as f:\n", + " config = yaml.safe_load(f)\n", + "\n", + "# --- tweak any parameter here, e.g. ---\n", + "# config[\"eval_full_to_train_steps_ratio\"] = 5\n", + "config[\"serving_grpc\"] = True # expose the gRPC backend for Weights Studio\n", + "\n", + "with open(\"config.yaml\", \"w\") as f:\n", + " yaml.safe_dump(config, f, sort_keys=False)\n", + "\n", + "print(yaml.safe_dump(config, sort_keys=False))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## (Optional) Expose the backend for the live UI\n", + "\n", + "To watch training **live in Weights Studio on your own machine**, run this cell first (it opens a\n", + "raw-TCP [`bore`](https://github.com/ekzhang/bore) tunnel over the free public relay `bore.pub` -\n", + "**no signup, no card**), then start training below.\n", + "\n", + "> `bore.pub` is a shared public relay; the random port is the only thing protecting your endpoint.\n", + "> Fine for a demo, not for sensitive data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, re, tarfile, threading, urllib.request, subprocess\n", + "\n", + "EXPOSE_UI = True # set False for a headless run (no tunnel)\n", + "BACKEND_PORT = 50051 # gRPC port to forward\n", + "\n", + "endpoint = None\n", + "if EXPOSE_UI:\n", + " bore = os.path.join(os.getcwd(), \"bore\")\n", + " if not os.path.exists(bore):\n", + " BORE = \"v0.6.0\"\n", + " urllib.request.urlretrieve(\n", + " f\"https://github.com/ekzhang/bore/releases/download/{BORE}/bore-{BORE}-x86_64-unknown-linux-musl.tar.gz\",\n", + " \"bore.tar.gz\")\n", + " with tarfile.open(\"bore.tar.gz\") as t:\n", + " t.extractall()\n", + " os.chmod(bore, 0o755)\n", + "\n", + " proc = subprocess.Popen(\n", + " [bore, \"local\", str(BACKEND_PORT), \"--to\", \"bore.pub\"],\n", + " stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)\n", + " for line in proc.stdout:\n", + " m = re.search(r\"bore\\.pub:(\\d+)\", line)\n", + " if m:\n", + " endpoint = f\"bore.pub:{m.group(1)}\"\n", + " break\n", + " threading.Thread(target=lambda: [None for _ in proc.stdout], daemon=True).start()\n", + "\n", + " print(\"=\" * 60)\n", + " print(\"Backend exposed at:\", endpoint)\n", + " print(\"On your OWN machine (Docker running), run:\")\n", + " print(\" weightslab ui launch\")\n", + " print(f\" weightslab tunnel {endpoint}\")\n", + " print(\"=\" * 60)\n", + "else:\n", + " print(\"EXPOSE_UI is False - no tunnel.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Train\n", + "\n", + "This runs the example's `main.py`, which wraps the model / optimizer / loaders / losses with\n", + "`wl.watch_or_edit(...)`, starts the gRPC backend, and trains while streaming per-sample signals.\n", + "\n", + "> This example trains **open-ended** (until you interrupt the cell). Interrupt the cell (stop button) to stop training; the exported history and\n", + "> dataframe are written to a temp `root_log_dir` (printed at startup)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!python main.py" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## See it live in Weights Studio\n", + "\n", + "**Colab has no Docker daemon**, so run Studio on your own machine and point it at this notebook's\n", + "backend using the `bore.pub:` printed in the Expose section:\n", + "\n", + "```bash\n", + "pip install weightslab\n", + "weightslab ui launch # Terminal 1 - opens http://localhost:5173\n", + "weightslab tunnel bore.pub:12345 # Terminal 2 - the host:port from the Expose section\n", + "```\n", + "\n", + "Then open **http://localhost:5173**. Prefer local-only? Run this example directly on your machine\n", + "(`weightslab start example` selects a bundled example) and launch the UI next to it - no tunnel." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "
\n", + "Crafted by GrayboxTech - if WeightsLab helps you catch a bad label, drop us a star on GitHub.\n", + "
" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "name": "ws-segmentation.ipynb", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/weightslab/examples/Notebooks/Usecases/ws-segmentation-loss-shapes-classification.ipynb b/weightslab/examples/Notebooks/Usecases/ws-segmentation-loss-shapes-classification.ipynb new file mode 100644 index 00000000..dccbdf01 --- /dev/null +++ b/weightslab/examples/Notebooks/Usecases/ws-segmentation-loss-shapes-classification.ipynb @@ -0,0 +1,522 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "\n", + " \n", + " \"WeightsLab\n", + "\n", + " \"License\"\n", + " \"Stars\"\n", + " \"Version\"\n", + "
\n", + " \"Open\n", + "\n", + " Welcome to the WeightsLab custom decorated-signal usecase! This notebook trains segmentation and adds a user-defined, decorated per-sample signal that classifies each sample's loss trajectory. See the Docs for details.
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Segmentation + a custom decorated per-sample signal\n", + "\n", + "This usecase trains the **BDD100k segmentation** example *in the notebook kernel* and layers on a\n", + "**user-defined signal** decorated with `@wl.signal`. The signal **subscribes** to the per-sample\n", + "segmentation loss, classifies each sample's loss **trajectory shape** (monotonic, plateaued,\n", + "spiked, ...), and writes the verdict back as a categorical tag - all live, per sample.\n", + "\n", + "It also demonstrates the `min_step` parameter of `@wl.signal`: the classifier only starts firing\n", + "once each sample has enough history (here `min_step=505`).\n", + "\n", + "> Why in-kernel (not `!python main.py`)? A decorated `@wl.signal` must be registered in the **same\n", + "> process** that runs training, so we build and train here rather than shelling out." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Pick a **T4 GPU** runtime on Colab. We clone the repo (for the segmentation `utils/` and the\n", + "bundled `BDD_subset/` data) and install WeightsLab from the clone (needed for the `min_step`\n", + "parameter)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\"PyPI\n", + "\"PyPI\n", + "\"PyPI" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "if not os.path.isdir(\"weightslab\"):\n", + " !git clone --branch torch_collab_examples https://github.com/GrayboxTech/weightslab.git\n", + "%pip install -q ./weightslab\n", + "!pip install --upgrade \"protobuf>=6.31.1\"\n", + "%pip install \"torchvision>=0.16\"\n", + "%cd weightslab/weightslab/examples/PyTorch/ws-segmentation" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Imports\n", + "\n", + "We reuse the segmentation example's helper modules (`utils/`) for the dataset, model, and the\n", + "per-sample / per-instance Dice + BCE criterions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import torch\n", + "from torch import optim\n", + "from tqdm.auto import tqdm\n", + "\n", + "import weightslab as wl\n", + "from weightslab.components.global_monitoring import (\n", + " guard_training_context, guard_testing_context,\n", + ")\n", + "from utils.data import BDD100kSegDataset, seg_collate\n", + "from utils.model import SmallUNet\n", + "from utils.criterions import (\n", + " PerSampleDice, PerInstanceDice, PerSampleBCE, PerInstanceBCE,\n", + ")\n", + "\n", + "device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n", + "print(\"Using device:\", device)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Configuration\n", + "\n", + "All tunables in one dict (like a `config.yaml`, with comments)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "config = {\n", + " # -- Experiment ----------------------------------------------------------\n", + " \"experiment_name\": \"seg_loss_shapes\", # name shown in Weights Studio\n", + " \"root_log_dir\": None, # None -> a temp dir is created\n", + "\n", + " # -- Data (bundled BDD_subset) -------------------------------------------\n", + " \"data_root\": \"BDD_subset\", # ships in the repo\n", + " \"num_classes\": 6, # BDD label set\n", + " \"class_names\": [\"Background\", \"Ego Road\", \"Driveable Area\",\n", + " \"Lane Line 1\", \"Lane Line 2\", \"Lane Line 3\"],\n", + " \"ignore_index\": 255, # void pixels\n", + " \"image_size\": 128, # images resized to image_size^2\n", + " \"max_train_samples\": 256, # cap for a quick demo\n", + " \"max_test_samples\": 100,\n", + " \"train_batch_size\": 2,\n", + " \"test_batch_size\": 2,\n", + "\n", + " # -- Optimizer -----------------------------------------------------------\n", + " \"learning_rate\": 1e-3,\n", + "\n", + " # -- Training schedule ---------------------------------------------------\n", + " \"training_steps_to_do\": 800, # > min_step so the classifier fires\n", + " \"eval_full_to_train_steps_ratio\": 100, # full eval every N steps\n", + " \"write_export_ratio\": 100, # export history + dataframe every N steps\n", + "\n", + " # -- Custom decorated signal --------------------------------------------\n", + " \"shape_subscribe_to\": \"train_bce/sample\", # per-sample loss the classifier watches\n", + " \"shape_compute_every_n_steps\": 25, # throttle the classifier\n", + " \"shape_min_step\": 505, # WARM-UP: don't classify before this step\n", + "\n", + " # -- Services / live UI --------------------------------------------------\n", + " \"serving_grpc\": True,\n", + " \"expose_ui\": True,\n", + " \"backend_port\": 50051,\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Build data, model, optimizer, and the tracked seg signals\n", + "\n", + "Same wrapping pattern as the segmentation example: dataloaders, model, optimizer, and the\n", + "per-sample / per-instance Dice (metric) + BCE (loss) signals are all passed through\n", + "`wl.watch_or_edit(...)`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "train_ds = BDD100kSegDataset(root=config[\"data_root\"], split=\"train\",\n", + " num_classes=config[\"num_classes\"], class_names=config[\"class_names\"],\n", + " ignore_index=config[\"ignore_index\"], image_size=config[\"image_size\"],\n", + " max_samples=config[\"max_train_samples\"])\n", + "val_ds = BDD100kSegDataset(root=config[\"data_root\"], split=\"val\",\n", + " num_classes=config[\"num_classes\"], class_names=config[\"class_names\"],\n", + " ignore_index=config[\"ignore_index\"], image_size=config[\"image_size\"],\n", + " max_samples=config[\"max_test_samples\"])\n", + "\n", + "wl.watch_or_edit(config, flag=\"hyperparameters\", name=config[\"experiment_name\"],\n", + " defaults=config, poll_interval=1.0)\n", + "\n", + "train_loader = wl.watch_or_edit(\n", + " train_ds, flag=\"data\", loader_name=\"train_loader\",\n", + " batch_size=config[\"train_batch_size\"], shuffle=True, is_training=True,\n", + " compute_hash=False, array_autoload_arrays=False, array_return_proxies=True,\n", + " array_use_cache=True, preload_labels=False, collate_fn=seg_collate)\n", + "test_loader = wl.watch_or_edit(\n", + " val_ds, flag=\"data\", loader_name=\"test_loader\",\n", + " batch_size=config[\"test_batch_size\"], shuffle=False, is_training=False,\n", + " compute_hash=False, array_autoload_arrays=False, array_return_proxies=True,\n", + " array_use_cache=True, preload_labels=True, collate_fn=seg_collate)\n", + "\n", + "model = wl.watch_or_edit(\n", + " SmallUNet(in_channels=3, num_classes=config[\"num_classes\"],\n", + " image_size=config[\"image_size\"]).to(device),\n", + " flag=\"model\", device=device, compute_dependencies=True)\n", + "optimizer = wl.watch_or_edit(\n", + " optim.Adam(model.parameters(), lr=config[\"learning_rate\"]), flag=\"optimizer\")\n", + "\n", + "\n", + "def make_seg_signals(split):\n", + " return {\n", + " \"dice_sample\": wl.watch_or_edit(PerSampleDice(), flag=\"metric\",\n", + " name=f\"{split}_dice/sample\", per_sample=True, log=True),\n", + " \"dice_instance\": wl.watch_or_edit(PerInstanceDice(), flag=\"metric\",\n", + " name=f\"{split}_dice/instance\", per_instance=True, log=True),\n", + " \"bce_sample\": wl.watch_or_edit(PerSampleBCE(), flag=\"loss\",\n", + " name=f\"{split}_bce/sample\", per_sample=True, log=True),\n", + " \"bce_instance\": wl.watch_or_edit(PerInstanceBCE(), flag=\"loss\",\n", + " name=f\"{split}_bce/instance\", per_instance=True, log=True),\n", + " }\n", + "\n", + "train_sig = make_seg_signals(\"train\")\n", + "test_sig = make_seg_signals(\"test\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. The custom, user-defined decorated signal\n", + "\n", + "This is the point of the usecase. `classify_loss_shape` is **your** function, decorated with\n", + "`@wl.signal`. Because it sets `subscribe_to=`, WeightsLab calls it **once per\n", + "sample** each time that loss is logged. It pulls the sample's full loss history, classifies the\n", + "**shape** of the curve, and tags the sample.\n", + "\n", + "Note `min_step=505`: the classifier is **skipped until training step 505**, so it only runs once\n", + "each sample has a meaningful trajectory. `compute_every_n_steps=25` then throttles it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Allowed categorical values (registered so the UI shows all choices up front).\n", + "LOSS_SHAPE_LABELS = [\"monotonic\", \"plateaued\", \"Flat_high\",\n", + " \"high_variance\", \"U_Shape\", \"Spiked\"]\n", + "LOSS_SHAPE_CODES = {label: i for i, label in enumerate(LOSS_SHAPE_LABELS)}\n", + "wl.register_categorical_tag(\"loss_shape\", LOSS_SHAPE_LABELS)\n", + "\n", + "_MIN_HISTORY = 5\n", + "\n", + "def _classify_loss_shape(values):\n", + " \"\"\"Classify a per-sample loss trajectory (scale-invariant thresholds).\"\"\"\n", + " y = np.asarray(values, dtype=float)\n", + " if y.size < _MIN_HISTORY:\n", + " return None\n", + " n = y.size\n", + " first, last = float(y[0]), float(y[-1])\n", + " ymin, ymax = float(y.min()), float(y.max())\n", + " rng = max(ymax - ymin, 1e-8)\n", + " mean = float(y.mean())\n", + " cv = float(y.std()) / (abs(mean) + 1e-8)\n", + " drop = (first - last) / (abs(first) + 1e-8)\n", + " argmin = int(np.argmin(y))\n", + " rebound = (last - ymin) / rng\n", + " max_up_jump = float(np.diff(y).max()) / rng\n", + " tail = y[int(0.6 * n):]\n", + " tail_flat = (float(tail.std()) / (abs(float(tail.mean())) + 1e-8)) < 0.1\n", + " if max_up_jump > 0.5:\n", + " return \"Spiked\"\n", + " if cv > 0.5:\n", + " return \"high_variance\"\n", + " if 0.2 * n < argmin < 0.8 * n and rebound > 0.3:\n", + " return \"U_Shape\"\n", + " if drop > 0.4:\n", + " return \"monotonic\"\n", + " if drop > 0.15 and tail_flat:\n", + " return \"plateaued\"\n", + " return \"Flat_high\"\n", + "\n", + "\n", + "@wl.signal(\n", + " name=\"seg_loss_shape_classifier\",\n", + " subscribe_to=config[\"shape_subscribe_to\"],\n", + " compute_every_n_steps=config[\"shape_compute_every_n_steps\"],\n", + " min_step=config[\"shape_min_step\"], # <-- warm-up gate: skip until this step\n", + " log=False, # side-effecting: we tag samples, no aggregate curve\n", + ")\n", + "def classify_loss_shape(ctx: wl.SignalContext) -> int:\n", + " \"\"\"Per-sample: classify the loss curve's shape and tag the sample.\"\"\"\n", + " history = wl.query_sample_history(\n", + " ctx.sample_id, signal_name=config[\"shape_subscribe_to\"],\n", + " exp_hash=wl.get_current_experiment_hash())\n", + " series = sorted(((step, val) for _, step, val, _ in history), key=lambda t: t[0])\n", + " values = [v for _, v in series]\n", + " label = _classify_loss_shape(values)\n", + " if label is None:\n", + " return -1\n", + " wl.set_categorical_tag([ctx.sample_id], \"loss_shape\", label)\n", + " return LOSS_SHAPE_CODES[label]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Train / eval steps" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def _run_signals(sig, outputs, labels, ids, preds):\n", + " bce_sample = sig[\"bce_sample\"](outputs, labels, batch_ids=ids, preds=preds)\n", + " dice_sample = sig[\"dice_sample\"](outputs, labels, batch_ids=ids)\n", + " sig[\"dice_instance\"](outputs, labels, batch_ids=ids)\n", + " sig[\"bce_instance\"](outputs, labels, batch_ids=ids)\n", + " avg = 0.5 * dice_sample + 0.5 * bce_sample\n", + " wl.save_signals({\"combined_bce_dice_per_sample\": avg}, ids)\n", + " return avg, dice_sample\n", + "\n", + "\n", + "def train_step(loader, model, optimizer, sig):\n", + " with guard_training_context:\n", + " inputs, ids, labels, _ = next(loader)\n", + " inputs = inputs.to(device)\n", + " labels = [[m.to(device) for m in insts] for insts in labels]\n", + " optimizer.zero_grad()\n", + " outputs = model(inputs)\n", + " preds = outputs.argmax(dim=1)\n", + " loss_per_sample, _ = _run_signals(sig, outputs, labels, ids, preds)\n", + " loss = loss_per_sample.mean()\n", + " loss.backward()\n", + " optimizer.step()\n", + " return float(loss.detach().cpu().item())\n", + "\n", + "\n", + "def evaluate(loader, model, sig, n_batches):\n", + " losses = dices = 0.0\n", + " with guard_testing_context, torch.no_grad():\n", + " for inputs, ids, labels, _ in loader:\n", + " inputs = inputs.to(device)\n", + " labels = [[m.to(device) for m in insts] for insts in labels]\n", + " outputs = model(inputs)\n", + " preds = outputs.argmax(dim=1)\n", + " loss_per_sample, dice_sample = _run_signals(sig, outputs, labels, ids, preds)\n", + " losses += torch.mean(loss_per_sample)\n", + " dices += torch.mean(dice_sample)\n", + " return float((losses / n_batches)), float((dices / n_batches) * 100.0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. (Optional) Expose the backend for the live UI\n", + "\n", + "Run this to open a raw-TCP [`bore`](https://github.com/ekzhang/bore) tunnel (`bore.pub`, no signup)\n", + "so a Weights Studio on your machine can connect. In Studio you'll see samples getting tagged with\n", + "their `loss_shape` **after step 505**." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, re, tarfile, threading, urllib.request, subprocess\n", + "\n", + "endpoint = None\n", + "if config[\"expose_ui\"]:\n", + " bore = os.path.join(os.getcwd(), \"bore\")\n", + " if not os.path.exists(bore):\n", + " BORE = \"v0.6.0\"\n", + " urllib.request.urlretrieve(\n", + " f\"https://github.com/ekzhang/bore/releases/download/{BORE}/bore-{BORE}-x86_64-unknown-linux-musl.tar.gz\",\n", + " \"bore.tar.gz\")\n", + " with tarfile.open(\"bore.tar.gz\") as t:\n", + " t.extractall()\n", + " os.chmod(bore, 0o755)\n", + " proc = subprocess.Popen([bore, \"local\", str(config[\"backend_port\"]), \"--to\", \"bore.pub\"],\n", + " stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)\n", + " for line in proc.stdout:\n", + " m = re.search(r\"bore\\.pub:(\\d+)\", line)\n", + " if m:\n", + " endpoint = f\"bore.pub:{m.group(1)}\"\n", + " break\n", + " threading.Thread(target=lambda: [None for _ in proc.stdout], daemon=True).start()\n", + " print(\"=\" * 60)\n", + " print(\"Backend exposed at:\", endpoint)\n", + " print(\"On your machine: weightslab ui launch && weightslab tunnel\", endpoint)\n", + " print(\"=\" * 60)\n", + "else:\n", + " print(\"expose_ui is False - no tunnel.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. Serve and train\n", + "\n", + "Watch the progress bar: before step 505 the `seg_loss_shape_classifier` stays silent; once the\n", + "warm-up gate opens it starts tagging samples by loss shape (visible in Studio and in the exported\n", + "dataframe)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "wl.serve(serving_grpc=config[\"serving_grpc\"])\n", + "wl.start_training(timeout=3)\n", + "\n", + "steps = config[\"training_steps_to_do\"]\n", + "eval_ratio = config[\"eval_full_to_train_steps_ratio\"]\n", + "export_ratio = config[\"write_export_ratio\"]\n", + "\n", + "test_loss = test_dice = None\n", + "pbar = tqdm(range(steps), desc=\"Training\")\n", + "for step in pbar:\n", + " age = model.get_age() if hasattr(model, \"get_age\") else step\n", + " tr_loss = train_step(train_loader, model, optimizer, train_sig)\n", + " if age > 0 and age % eval_ratio == 0:\n", + " test_loss, test_dice = evaluate(test_loader, model, test_sig, len(test_loader))\n", + " if age > 0 and age % export_ratio == 0:\n", + " wl.write_history(); wl.write_dataframe()\n", + " post = {\"loss\": f\"{tr_loss:.3f}\", \"shapes\": \"on\" if age >= config[\"shape_min_step\"] else \"warmup\"}\n", + " if test_dice is not None:\n", + " post[\"dice\"] = f\"{test_dice:.1f}%\"\n", + " pbar.set_postfix(post)\n", + "\n", + "wl.write_history(); wl.write_dataframe()\n", + "print(\"Done.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. Inspect the loss-shape tags\n", + "\n", + "After step 505 the classifier tags each sample. Export shows the `tag:loss_shape` column and the\n", + "numeric `seg_loss_shape_classifier` signal - group or sort by them in Studio to triage samples by\n", + "how their loss behaved." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import glob, pandas as pd\n", + "lg = wl.write_dataframe() # returns the written path\n", + "paths = sorted(glob.glob(os.path.join(os.path.dirname(lg), \"*dataframe*.json\")), key=os.path.getmtime)\n", + "if paths:\n", + " df = pd.read_json(paths[-1])\n", + " shape_cols = [c for c in df.columns if \"loss_shape\" in c.lower()]\n", + " print(\"shape-related columns:\", shape_cols)\n", + " display(df[shape_cols].head(30) if shape_cols else df.head())\n", + "else:\n", + " print(\"No export found yet.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## See it live in Weights Studio\n", + "\n", + "**Colab has no Docker daemon**, so run Studio on your own machine and point it at this backend with\n", + "the `bore.pub:` printed in Section 6:\n", + "\n", + "```bash\n", + "pip install weightslab\n", + "weightslab ui launch # Terminal 1 -> http://localhost:5173\n", + "weightslab tunnel bore.pub:12345 # Terminal 2 -> host:port from Section 6\n", + "```\n", + "\n", + "Group the sample grid by the `loss_shape` tag to see which samples plateaued, spiked, or never\n", + "learned - the decorated signal wrote those verdicts live, starting at step 505." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "
\n", + "Crafted by GrayboxTech - if WeightsLab helps you catch a bad label, drop us a star on GitHub.\n", + "
" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "name": "ws-segmentation-loss-shapes.ipynb", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/weightslab/examples/PyTorch/ws-classification/config.yaml b/weightslab/examples/PyTorch/wl-classification/config.yaml similarity index 100% rename from weightslab/examples/PyTorch/ws-classification/config.yaml rename to weightslab/examples/PyTorch/wl-classification/config.yaml diff --git a/weightslab/examples/PyTorch/ws-classification/main.py b/weightslab/examples/PyTorch/wl-classification/main.py similarity index 100% rename from weightslab/examples/PyTorch/ws-classification/main.py rename to weightslab/examples/PyTorch/wl-classification/main.py diff --git a/weightslab/examples/PyTorch/ws-clustering/config.yaml b/weightslab/examples/PyTorch/wl-clustering/config.yaml similarity index 100% rename from weightslab/examples/PyTorch/ws-clustering/config.yaml rename to weightslab/examples/PyTorch/wl-clustering/config.yaml diff --git a/weightslab/examples/PyTorch/ws-clustering/face/__init__.py b/weightslab/examples/PyTorch/wl-clustering/face/__init__.py similarity index 100% rename from weightslab/examples/PyTorch/ws-clustering/face/__init__.py rename to weightslab/examples/PyTorch/wl-clustering/face/__init__.py diff --git a/weightslab/examples/PyTorch/ws-clustering/face/data.py b/weightslab/examples/PyTorch/wl-clustering/face/data.py similarity index 100% rename from weightslab/examples/PyTorch/ws-clustering/face/data.py rename to weightslab/examples/PyTorch/wl-clustering/face/data.py diff --git a/weightslab/examples/PyTorch/ws-clustering/face/model.py b/weightslab/examples/PyTorch/wl-clustering/face/model.py similarity index 100% rename from weightslab/examples/PyTorch/ws-clustering/face/model.py rename to weightslab/examples/PyTorch/wl-clustering/face/model.py diff --git a/weightslab/examples/PyTorch/ws-clustering/face/signals.py b/weightslab/examples/PyTorch/wl-clustering/face/signals.py similarity index 100% rename from weightslab/examples/PyTorch/ws-clustering/face/signals.py rename to weightslab/examples/PyTorch/wl-clustering/face/signals.py diff --git a/weightslab/examples/PyTorch/ws-clustering/face/utils.py b/weightslab/examples/PyTorch/wl-clustering/face/utils.py similarity index 100% rename from weightslab/examples/PyTorch/ws-clustering/face/utils.py rename to weightslab/examples/PyTorch/wl-clustering/face/utils.py diff --git a/weightslab/examples/PyTorch/ws-clustering/main.py b/weightslab/examples/PyTorch/wl-clustering/main.py similarity index 100% rename from weightslab/examples/PyTorch/ws-clustering/main.py rename to weightslab/examples/PyTorch/wl-clustering/main.py diff --git a/weightslab/examples/PyTorch/ws-detection/README.md b/weightslab/examples/PyTorch/wl-detection/README.md similarity index 99% rename from weightslab/examples/PyTorch/ws-detection/README.md rename to weightslab/examples/PyTorch/wl-detection/README.md index ba8bddc6..3fee76a9 100644 --- a/weightslab/examples/PyTorch/ws-detection/README.md +++ b/weightslab/examples/PyTorch/wl-detection/README.md @@ -20,7 +20,7 @@ weightslab start example --det Or run it directly: ```bash -cd weightslab/examples/PyTorch/ws-detection +cd weightslab/examples/PyTorch/wl-detection pip install -r requirements.txt python main.py ``` diff --git a/weightslab/examples/PyTorch/ws-detection/config.yaml b/weightslab/examples/PyTorch/wl-detection/config.yaml similarity index 100% rename from weightslab/examples/PyTorch/ws-detection/config.yaml rename to weightslab/examples/PyTorch/wl-detection/config.yaml diff --git a/weightslab/examples/PyTorch/ws-detection/main.py b/weightslab/examples/PyTorch/wl-detection/main.py similarity index 99% rename from weightslab/examples/PyTorch/ws-detection/main.py rename to weightslab/examples/PyTorch/wl-detection/main.py index ff4b2030..6cb4d627 100644 --- a/weightslab/examples/PyTorch/ws-detection/main.py +++ b/weightslab/examples/PyTorch/wl-detection/main.py @@ -119,6 +119,8 @@ def test(loader, model, sig, device, grid_size, conf_thresh, test_loader_len): parameters.setdefault("freeze_backbone", True) parameters.setdefault("compute_natural_sort", True) + exp_name = parameters["experiment_name"] + # --- 2) Register hyperparameters --- exp_name = parameters["experiment_name"] wl.watch_or_edit( @@ -128,7 +130,6 @@ def test(loader, model, sig, device, grid_size, conf_thresh, test_loader_len): defaults=parameters, poll_interval=1.0, ) - num_classes = int(parameters["num_classes"]) image_size = int(parameters["image_size"]) grid_size = int(parameters["grid_size"]) diff --git a/weightslab/examples/PyTorch/ws-detection/utils/criterions.py b/weightslab/examples/PyTorch/wl-detection/utils/criterions.py similarity index 100% rename from weightslab/examples/PyTorch/ws-detection/utils/criterions.py rename to weightslab/examples/PyTorch/wl-detection/utils/criterions.py diff --git a/weightslab/examples/PyTorch/ws-detection/utils/data.py b/weightslab/examples/PyTorch/wl-detection/utils/data.py similarity index 100% rename from weightslab/examples/PyTorch/ws-detection/utils/data.py rename to weightslab/examples/PyTorch/wl-detection/utils/data.py diff --git a/weightslab/examples/PyTorch/ws-detection/utils/model.py b/weightslab/examples/PyTorch/wl-detection/utils/model.py similarity index 100% rename from weightslab/examples/PyTorch/ws-detection/utils/model.py rename to weightslab/examples/PyTorch/wl-detection/utils/model.py diff --git a/weightslab/examples/PyTorch/ws-generation/config.yaml b/weightslab/examples/PyTorch/wl-generation/config.yaml similarity index 100% rename from weightslab/examples/PyTorch/ws-generation/config.yaml rename to weightslab/examples/PyTorch/wl-generation/config.yaml diff --git a/weightslab/examples/PyTorch/ws-generation/main.py b/weightslab/examples/PyTorch/wl-generation/main.py similarity index 100% rename from weightslab/examples/PyTorch/ws-generation/main.py rename to weightslab/examples/PyTorch/wl-generation/main.py diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/0000f77c-6257be58.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/0000f77c-6257be58.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/0000f77c-6257be58.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/0000f77c-6257be58.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/000f8d37-d4c09a0f.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/000f8d37-d4c09a0f.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/000f8d37-d4c09a0f.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/000f8d37-d4c09a0f.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/00a0f008-a315437f.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/00a0f008-a315437f.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/00a0f008-a315437f.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/00a0f008-a315437f.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/00c12bd0-bb46e479.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/00c12bd0-bb46e479.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/00c12bd0-bb46e479.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/00c12bd0-bb46e479.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/00c29c52-f9524f1e.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/00c29c52-f9524f1e.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/00c29c52-f9524f1e.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/00c29c52-f9524f1e.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/00ce6f6d-50bbee62.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/00ce6f6d-50bbee62.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/00ce6f6d-50bbee62.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/00ce6f6d-50bbee62.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/00d1bafa-1b47b41c.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/00d1bafa-1b47b41c.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/00d1bafa-1b47b41c.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/00d1bafa-1b47b41c.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/00d7268f-fd4487be.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/00d7268f-fd4487be.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/00d7268f-fd4487be.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/00d7268f-fd4487be.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/00f0dd0f-5e9c9557.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/00f0dd0f-5e9c9557.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/00f0dd0f-5e9c9557.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/00f0dd0f-5e9c9557.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/0a0d7f4c-ac5c3c8f.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/0a0d7f4c-ac5c3c8f.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/0a0d7f4c-ac5c3c8f.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/0a0d7f4c-ac5c3c8f.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/0a1f4fce-f9cac880.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/0a1f4fce-f9cac880.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/0a1f4fce-f9cac880.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/0a1f4fce-f9cac880.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/0a3bb2d8-c195d91e.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/0a3bb2d8-c195d91e.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/train/0a3bb2d8-c195d91e.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/train/0a3bb2d8-c195d91e.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1cac6a7-04e33135.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1cac6a7-04e33135.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1cac6a7-04e33135.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1cac6a7-04e33135.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1ceb32e-3f481b43.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1ceb32e-3f481b43.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1ceb32e-3f481b43.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1ceb32e-3f481b43.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1d10d08-5b108225.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1d10d08-5b108225.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1d10d08-5b108225.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1d10d08-5b108225.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1d22449-15fb948f.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1d22449-15fb948f.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1d22449-15fb948f.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1d22449-15fb948f.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1d7b3ac-5af8623b.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1d7b3ac-5af8623b.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1d7b3ac-5af8623b.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1d7b3ac-5af8623b.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1dce572-c6a8cb5e.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1dce572-c6a8cb5e.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1dce572-c6a8cb5e.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1dce572-c6a8cb5e.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1e1a7b8-0aec80e8.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1e1a7b8-0aec80e8.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1e1a7b8-0aec80e8.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1e1a7b8-0aec80e8.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1e8ad72-c3c79240.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1e8ad72-c3c79240.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1e8ad72-c3c79240.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1e8ad72-c3c79240.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1ee702d-0ae1fc10.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1ee702d-0ae1fc10.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1ee702d-0ae1fc10.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1ee702d-0ae1fc10.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1f0efd9-37a14dda.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1f0efd9-37a14dda.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b1f0efd9-37a14dda.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b1f0efd9-37a14dda.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b2a0648b-d8e126bc.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b2a0648b-d8e126bc.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b2a0648b-d8e126bc.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b2a0648b-d8e126bc.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b2b70230-bad4ff6e.jpg b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b2b70230-bad4ff6e.jpg similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/images/val/b2b70230-bad4ff6e.jpg rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/images/val/b2b70230-bad4ff6e.jpg diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/0000f77c-6257be58.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/0000f77c-6257be58.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/0000f77c-6257be58.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/0000f77c-6257be58.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/000f8d37-d4c09a0f.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/000f8d37-d4c09a0f.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/000f8d37-d4c09a0f.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/000f8d37-d4c09a0f.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/00a0f008-a315437f.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/00a0f008-a315437f.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/00a0f008-a315437f.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/00a0f008-a315437f.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/00c12bd0-bb46e479.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/00c12bd0-bb46e479.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/00c12bd0-bb46e479.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/00c12bd0-bb46e479.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/00c29c52-f9524f1e.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/00c29c52-f9524f1e.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/00c29c52-f9524f1e.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/00c29c52-f9524f1e.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/00ce6f6d-50bbee62.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/00ce6f6d-50bbee62.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/00ce6f6d-50bbee62.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/00ce6f6d-50bbee62.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/00d1bafa-1b47b41c.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/00d1bafa-1b47b41c.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/00d1bafa-1b47b41c.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/00d1bafa-1b47b41c.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/00d7268f-fd4487be.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/00d7268f-fd4487be.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/00d7268f-fd4487be.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/00d7268f-fd4487be.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/00f0dd0f-5e9c9557.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/00f0dd0f-5e9c9557.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/00f0dd0f-5e9c9557.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/00f0dd0f-5e9c9557.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/0a0d7f4c-ac5c3c8f.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/0a0d7f4c-ac5c3c8f.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/0a0d7f4c-ac5c3c8f.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/0a0d7f4c-ac5c3c8f.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/0a1f4fce-f9cac880.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/0a1f4fce-f9cac880.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/0a1f4fce-f9cac880.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/0a1f4fce-f9cac880.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/0a3bb2d8-c195d91e.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/0a3bb2d8-c195d91e.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/train/0a3bb2d8-c195d91e.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/train/0a3bb2d8-c195d91e.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1cac6a7-04e33135.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1cac6a7-04e33135.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1cac6a7-04e33135.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1cac6a7-04e33135.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1ceb32e-3f481b43.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1ceb32e-3f481b43.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1ceb32e-3f481b43.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1ceb32e-3f481b43.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1d10d08-5b108225.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1d10d08-5b108225.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1d10d08-5b108225.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1d10d08-5b108225.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1d22449-15fb948f.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1d22449-15fb948f.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1d22449-15fb948f.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1d22449-15fb948f.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1d7b3ac-5af8623b.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1d7b3ac-5af8623b.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1d7b3ac-5af8623b.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1d7b3ac-5af8623b.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1dce572-c6a8cb5e.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1dce572-c6a8cb5e.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1dce572-c6a8cb5e.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1dce572-c6a8cb5e.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1e1a7b8-0aec80e8.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1e1a7b8-0aec80e8.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1e1a7b8-0aec80e8.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1e1a7b8-0aec80e8.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1e8ad72-c3c79240.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1e8ad72-c3c79240.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1e8ad72-c3c79240.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1e8ad72-c3c79240.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1ee702d-0ae1fc10.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1ee702d-0ae1fc10.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1ee702d-0ae1fc10.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1ee702d-0ae1fc10.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1f0efd9-37a14dda.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1f0efd9-37a14dda.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b1f0efd9-37a14dda.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b1f0efd9-37a14dda.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b2a0648b-d8e126bc.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b2a0648b-d8e126bc.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b2a0648b-d8e126bc.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b2a0648b-d8e126bc.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b2b70230-bad4ff6e.png b/weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b2b70230-bad4ff6e.png similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/BDD_subset/labels/val/b2b70230-bad4ff6e.png rename to weightslab/examples/PyTorch/wl-segmentation/BDD_subset/labels/val/b2b70230-bad4ff6e.png diff --git a/weightslab/examples/PyTorch/ws-segmentation/config.yaml b/weightslab/examples/PyTorch/wl-segmentation/config.yaml similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/config.yaml rename to weightslab/examples/PyTorch/wl-segmentation/config.yaml diff --git a/weightslab/examples/PyTorch/ws-segmentation/main.py b/weightslab/examples/PyTorch/wl-segmentation/main.py similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/main.py rename to weightslab/examples/PyTorch/wl-segmentation/main.py diff --git a/weightslab/examples/PyTorch/ws-segmentation/utils/criterions.py b/weightslab/examples/PyTorch/wl-segmentation/utils/criterions.py similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/utils/criterions.py rename to weightslab/examples/PyTorch/wl-segmentation/utils/criterions.py diff --git a/weightslab/examples/PyTorch/ws-segmentation/utils/data.py b/weightslab/examples/PyTorch/wl-segmentation/utils/data.py similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/utils/data.py rename to weightslab/examples/PyTorch/wl-segmentation/utils/data.py diff --git a/weightslab/examples/PyTorch/ws-segmentation/utils/model.py b/weightslab/examples/PyTorch/wl-segmentation/utils/model.py similarity index 100% rename from weightslab/examples/PyTorch/ws-segmentation/utils/model.py rename to weightslab/examples/PyTorch/wl-segmentation/utils/model.py diff --git a/weightslab/examples/Ultralytics/ws-detection/config.yaml b/weightslab/examples/Ultralytics/wl-detection/config.yaml similarity index 100% rename from weightslab/examples/Ultralytics/ws-detection/config.yaml rename to weightslab/examples/Ultralytics/wl-detection/config.yaml diff --git a/weightslab/examples/Ultralytics/ws-detection/main.py b/weightslab/examples/Ultralytics/wl-detection/main.py similarity index 100% rename from weightslab/examples/Ultralytics/ws-detection/main.py rename to weightslab/examples/Ultralytics/wl-detection/main.py diff --git a/weightslab/examples/Usecases/ws-2d-lidar-detection/README.md b/weightslab/examples/Usecases/wl-2d-lidar-detection/README.md similarity index 94% rename from weightslab/examples/Usecases/ws-2d-lidar-detection/README.md rename to weightslab/examples/Usecases/wl-2d-lidar-detection/README.md index 1925b9f4..46174c1a 100644 --- a/weightslab/examples/Usecases/ws-2d-lidar-detection/README.md +++ b/weightslab/examples/Usecases/wl-2d-lidar-detection/README.md @@ -1,12 +1,12 @@ # 2D LiDAR (laser-scan) Object Detection (Pillars2D-lite) -The 2D sibling of [`ws-3d-lidar-detection`](../ws-3d-lidar-detection/): object +The 2D sibling of [`wl-3d-lidar-detection`](../wl-3d-lidar-detection/): object detection on a **2D point cloud** (a single-layer laser scan / bird's-eye occupancy slice) instead of a 3D LiDAR scene. Same WeightsLab wiring and per-sample / per-instance signals — just with `z` and `yaw` dropped. ``` -ws-2d-lidar-detection/ +wl-2d-lidar-detection/ main.py # WL wiring + train/eval loop config.yaml # hyperparameters, plane range, loaders utils/ @@ -18,7 +18,7 @@ ws-2d-lidar-detection/ ## Quick start ```bash -cd weightslab/examples/Usecases/ws-2d-lidar-detection +cd weightslab/examples/Usecases/wl-2d-lidar-detection python main.py # or, from anywhere: weightslab start example --2d_det diff --git a/weightslab/examples/Usecases/ws-2d-lidar-detection/config.yaml b/weightslab/examples/Usecases/wl-2d-lidar-detection/config.yaml similarity index 100% rename from weightslab/examples/Usecases/ws-2d-lidar-detection/config.yaml rename to weightslab/examples/Usecases/wl-2d-lidar-detection/config.yaml diff --git a/weightslab/examples/Usecases/ws-2d-lidar-detection/main.py b/weightslab/examples/Usecases/wl-2d-lidar-detection/main.py similarity index 100% rename from weightslab/examples/Usecases/ws-2d-lidar-detection/main.py rename to weightslab/examples/Usecases/wl-2d-lidar-detection/main.py diff --git a/weightslab/examples/Usecases/ws-2d-lidar-detection/utils/criterions.py b/weightslab/examples/Usecases/wl-2d-lidar-detection/utils/criterions.py similarity index 100% rename from weightslab/examples/Usecases/ws-2d-lidar-detection/utils/criterions.py rename to weightslab/examples/Usecases/wl-2d-lidar-detection/utils/criterions.py diff --git a/weightslab/examples/Usecases/ws-2d-lidar-detection/utils/data.py b/weightslab/examples/Usecases/wl-2d-lidar-detection/utils/data.py similarity index 100% rename from weightslab/examples/Usecases/ws-2d-lidar-detection/utils/data.py rename to weightslab/examples/Usecases/wl-2d-lidar-detection/utils/data.py diff --git a/weightslab/examples/Usecases/ws-2d-lidar-detection/utils/model.py b/weightslab/examples/Usecases/wl-2d-lidar-detection/utils/model.py similarity index 100% rename from weightslab/examples/Usecases/ws-2d-lidar-detection/utils/model.py rename to weightslab/examples/Usecases/wl-2d-lidar-detection/utils/model.py diff --git a/weightslab/examples/Usecases/ws-3d-lidar-detection/README.md b/weightslab/examples/Usecases/wl-3d-lidar-detection/README.md similarity index 98% rename from weightslab/examples/Usecases/ws-3d-lidar-detection/README.md rename to weightslab/examples/Usecases/wl-3d-lidar-detection/README.md index 7694bfa8..4d266f14 100644 --- a/weightslab/examples/Usecases/ws-3d-lidar-detection/README.md +++ b/weightslab/examples/Usecases/wl-3d-lidar-detection/README.md @@ -6,8 +6,8 @@ with per-sample and per-instance signals flowing into the WeightsLab dashboards. ``` -ws-3d-lidar-detection/ - main.py # WL wiring + train/eval loop (mirrors PyTorch/ws-detection) +wl-3d-lidar-detection/ + main.py # WL wiring + train/eval loop (mirrors PyTorch/wl-detection) config.yaml # hyperparameters, ranges, loaders utils/ data.py # KITTI-format loader + synthetic scene fallback + collate @@ -18,7 +18,7 @@ ws-3d-lidar-detection/ ## Quick start ```bash -cd weightslab/examples/Usecases/ws-3d-lidar-detection +cd weightslab/examples/Usecases/wl-3d-lidar-detection python main.py ``` @@ -81,7 +81,7 @@ coordinates (`task_type = "detection_pointcloud"` — one task type covering 2D ## WeightsLab signals -Same pattern as `PyTorch/ws-detection`: +Same pattern as `PyTorch/wl-detection`: - `*_loss/sample` (`per_sample=True`) — the YOLO-in-BEV loss, one value per frame: localization (x, y, z) + log-size + sin/cos heading + class CE diff --git a/weightslab/examples/Usecases/ws-3d-lidar-detection/config.yaml b/weightslab/examples/Usecases/wl-3d-lidar-detection/config.yaml similarity index 100% rename from weightslab/examples/Usecases/ws-3d-lidar-detection/config.yaml rename to weightslab/examples/Usecases/wl-3d-lidar-detection/config.yaml diff --git a/weightslab/examples/Usecases/ws-3d-lidar-detection/main.py b/weightslab/examples/Usecases/wl-3d-lidar-detection/main.py similarity index 100% rename from weightslab/examples/Usecases/ws-3d-lidar-detection/main.py rename to weightslab/examples/Usecases/wl-3d-lidar-detection/main.py diff --git a/weightslab/examples/Usecases/ws-3d-lidar-detection/utils/criterions.py b/weightslab/examples/Usecases/wl-3d-lidar-detection/utils/criterions.py similarity index 100% rename from weightslab/examples/Usecases/ws-3d-lidar-detection/utils/criterions.py rename to weightslab/examples/Usecases/wl-3d-lidar-detection/utils/criterions.py diff --git a/weightslab/examples/Usecases/ws-3d-lidar-detection/utils/data.py b/weightslab/examples/Usecases/wl-3d-lidar-detection/utils/data.py similarity index 100% rename from weightslab/examples/Usecases/ws-3d-lidar-detection/utils/data.py rename to weightslab/examples/Usecases/wl-3d-lidar-detection/utils/data.py diff --git a/weightslab/examples/Usecases/ws-3d-lidar-detection/utils/kitti_download.py b/weightslab/examples/Usecases/wl-3d-lidar-detection/utils/kitti_download.py similarity index 100% rename from weightslab/examples/Usecases/ws-3d-lidar-detection/utils/kitti_download.py rename to weightslab/examples/Usecases/wl-3d-lidar-detection/utils/kitti_download.py diff --git a/weightslab/examples/Usecases/ws-3d-lidar-detection/utils/model.py b/weightslab/examples/Usecases/wl-3d-lidar-detection/utils/model.py similarity index 99% rename from weightslab/examples/Usecases/ws-3d-lidar-detection/utils/model.py rename to weightslab/examples/Usecases/wl-3d-lidar-detection/utils/model.py index 0af99b0d..1fc71d27 100644 --- a/weightslab/examples/Usecases/ws-3d-lidar-detection/utils/model.py +++ b/weightslab/examples/Usecases/wl-3d-lidar-detection/utils/model.py @@ -14,7 +14,7 @@ # (objectness, tx, ty, tz, log l, log w, log h, sin yaw, cos yaw, # class_logits...). # -# Encoding (BEV cell-relative, mirrors the 2D ws-detection example): +# Encoding (BEV cell-relative, mirrors the 2D wl-detection example): # * objectness = sigmoid(t_obj) -> P(box centered in cell) # * cx = x_min + (col + sigmoid(tx)) / S * range_x # * cy = y_min + (row + sigmoid(ty)) / S * range_y diff --git a/weightslab/examples/Usecases/wl-classification-signals_shape_classification/config.yaml b/weightslab/examples/Usecases/wl-classification-signals_shape_classification/config.yaml new file mode 100644 index 00000000..97f6d760 --- /dev/null +++ b/weightslab/examples/Usecases/wl-classification-signals_shape_classification/config.yaml @@ -0,0 +1,48 @@ +# ============================================================================= +# Configuration for the per-sample loss-shape classification example (MNIST) +# ============================================================================= +# Every value below can be overridden by the matching WL_STRESS_* / WL_* env +# var (see the env table below) so the example stays scriptable for stress runs. + +# Global +experiment_name: signals-mnist +device: auto # auto | cpu | cuda + +# Run parameters +epochs: 10 # env override: WL_STRESS_EPOCHS +out: /tmp/wl_stress # env override: WL_STRESS_OUT — wiped & recreated on start + # (holds data/, wl_logs/, metrics.jsonl, report.csv) +batch_size: 64 + +# Signals +loss_signal_name: loss_sample +shape_every: 1 # env override: WL_SHAPE_EVERY — throttle for + # sig/loss_shape (reads history, so it's costly). + # 5 = every step (full coverage); higher = cheaper. +min_step: 0 # min step to start computing + # sig/loss_shape (otherwise it will be NaN). + +# Serving +serving_grpc: true +serving_cli: true + +# Ledger / dataframe storage +ledger_flush_max_rows: 8192 +ledger_enable_h5_persistence: false +experiment_dump_to_train_steps_ratio: 10000000 +query_cache_maxsize: 2048 # env override: WL_QUERY_CACHE_MAXSIZE — LRU maxsize + # of the per-sample query cache the ledger uses when + # serving signals (larger = more cached, more memory). + +# Optimizer +optimizer: + lr: 0.01 + +# Data loaders +data: + train_loader: + batch_size: 64 + shuffle: true + test_loader: + batch_size: 256 + shuffle: false diff --git a/weightslab/examples/Usecases/wl-classification-signals_shape_classification/main.py b/weightslab/examples/Usecases/wl-classification-signals_shape_classification/main.py new file mode 100644 index 00000000..8c8de2c5 --- /dev/null +++ b/weightslab/examples/Usecases/wl-classification-signals_shape_classification/main.py @@ -0,0 +1,252 @@ +"""Per-sample signals on MNIST — readable, zero save_signals, with train + eval. + +Parameters live in ``config.yaml`` (loaded at startup). A handful of knobs can +also be overridden by environment variables for scripted stress runs. + +Per-step user code is just the watched loss. Everything else is a @wl.signal +(defined in ``utils/signals.py``): + entropy from ctx.logits when the loss fires + loss_norm reactive, from the logged loss + hardness reactive, from loss + entropy + loss_shape reactive, classifies each sample's loss trajectory (live signal, + not an end-of-run function). Reads history, so it's throttled by + shape_every (1 = every step, full coverage; higher = cheaper). + +Universal loss: the watched crit runs on the test split each epoch too, so test +samples get a loss trajectory and a shape as well. + +Env overrides (else the config.yaml value is used): + WL_STRESS_EPOCHS (int) number of training epochs. + WL_STRESS_OUT (str) output dir; wiped and recreated on start. Holds + data/, wl_logs/, metrics.jsonl and report.csv. + WL_SHAPE_EVERY (int) throttle for the sig/loss_shape signal, which + reads history and is therefore costly. 1 = compute every + step (full coverage); higher = every N steps (cheaper). + WL_QUERY_CACHE_MAXSIZE (int) backend tuning knob (read in + weightslab.backend.logger). Sets the LRU maxsize of the + per-sample query cache the ledger uses when serving + signals; larger values cache more distinct per-sample + queries at the cost of memory. +""" +import os, time, gc, json, shutil +import yaml +import torch +import numpy as np +import pandas as pd +import torch.nn as nn +import torch.optim as optim + +import weightslab as wl + +from collections import Counter + +from weightslab import guard_training_context, guard_testing_context + +from utils.model import SmallCNN +from utils.data import MNISTIdx +from utils.criterions import SHAPES +from utils.signals import register_signals + + +# ============================================================================= +# Configuration (config.yaml + env overrides, see module docstring) +# ============================================================================= +def load_config(): + """Load config.yaml next to this file and apply defaults for missing keys.""" + config_path = os.path.join(os.path.dirname(__file__), "config.yaml") + if os.path.exists(config_path): + with open(config_path, "r") as fh: + cfg = yaml.safe_load(fh) or {} + else: + cfg = {} + + cfg.setdefault("experiment_name", "signals-mnist") + cfg.setdefault("device", "auto") + cfg.setdefault("epochs", 10) + cfg.setdefault("out", "/tmp/wl_stress") + cfg.setdefault("batch_size", 64) + cfg.setdefault("loss_signal_name", "loss_sample") + cfg.setdefault("shape_every", 1) + cfg.setdefault("serving_grpc", False) + cfg.setdefault("serving_cli", False) + cfg.setdefault("ledger_flush_max_rows", 8192) + cfg.setdefault("ledger_enable_h5_persistence", False) + cfg.setdefault("experiment_dump_to_train_steps_ratio", 10_000_000) + cfg.setdefault("query_cache_maxsize", 2048) + cfg.setdefault("optimizer", {}).setdefault("lr", 0.01) + data = cfg.setdefault("data", {}) + data.setdefault("train_loader", {}).setdefault("batch_size", cfg["batch_size"]) + cfg["data"]["train_loader"].setdefault("shuffle", True) + data.setdefault("test_loader", {}).setdefault("batch_size", 256) + cfg["data"]["test_loader"].setdefault("shuffle", False) + + # Env overrides for scripted stress runs (env wins over the yaml value). + cfg["epochs"] = int(os.environ.get("WL_STRESS_EPOCHS", cfg["epochs"])) + cfg["out"] = os.environ.get("WL_STRESS_OUT", cfg["out"]) + cfg["shape_every"] = int(os.environ.get("WL_SHAPE_EVERY", cfg["shape_every"])) + cfg["min_step"] = int(os.environ.get("WL_MIN_STEP", cfg["min_step"])) + cfg["query_cache_maxsize"] = int( + os.environ.get("WL_QUERY_CACHE_MAXSIZE", cfg["query_cache_maxsize"])) + return cfg + + +def resolve_device(name): + if name == "auto": + return torch.device("cuda" if torch.cuda.is_available() else "cpu") + return torch.device(name) + + +# ============================================================================= +# Helpers +# ============================================================================= +def rss_gb(): + try: + for ln in open("/proc/self/status"): + if ln.startswith("VmRSS:"): + return int(ln.split()[1]) / (1024 * 1024) + except Exception: + return -1.0 + + +def log(msg): + print(msg, flush=True) + + +def vanilla_baseline(dev, batch, lr, sync): + """Plain-PyTorch step time (ms) for the WeightsLab overhead comparison.""" + vb = SmallCNN().to(dev); vo = optim.Adam(vb.parameters(), lr=lr); vc = nn.CrossEntropyLoss() + xb = torch.randn(batch, 1, 28, 28, device=dev); yb = torch.randint(0, 10, (batch,), device=dev) + for _ in range(10): + vo.zero_grad(); vc(vb(xb), yb).backward(); vo.step() + sync(); t0 = time.perf_counter() + for _ in range(50): + vo.zero_grad(); vc(vb(xb), yb).backward(); vo.step() + sync(); vanilla_ms = 1000 * (time.perf_counter() - t0) / 50 + del vb, vo; gc.collect() + return vanilla_ms + + +# ============================================================================= +# Train / Test loops (classification, using watcher-wrapped loaders) +# ============================================================================= +def train(loader, model, opt, crit, dev, sync): + """One training epoch. Returns the list of per-step wall times (ms). + + The only per-step call is the watched loss: it logs loss_sample and fires + the @wl.signal chain. No save_signals. + """ + ep_ms = [] + for img, ids, lab in loader: + img, lab = img.to(dev), lab.to(dev) + sync(); ts = time.perf_counter() + with guard_training_context: + opt.zero_grad() + logits = model(img) + crit(logits, lab, batch_ids=ids, preds=logits.argmax(1, keepdim=True)).mean().backward() + opt.step() + sync(); ep_ms.append(1000 * (time.perf_counter() - ts)) + return ep_ms + + +def test(test_loader, model, crit, dev): + """Universal loss: run the watched crit over the whole test split (one pass). + + Test samples get a loss trajectory and a shape too. + """ + with torch.no_grad(): + for tb in test_loader: + ti, tid, tl = tb[0].to(dev), tb[1], tb[2].to(dev) + with guard_testing_context: + tlg = model(ti) + crit(tlg, tl, batch_ids=tid, preds=tlg.argmax(1, keepdim=True)) + + +# ============================================================================= +# Main +# ============================================================================= +def main(): + # --- 1) Config: load, resolve device, prepare output dir --- + cfg = load_config() + LOSS = cfg["loss_signal_name"] + OUT = cfg["out"] + EPOCHS = cfg["epochs"] + SHAPE_EVERY = cfg["shape_every"] + SHAPE_MIN_STEP = cfg["min_step"] + BATCH = int(cfg["data"]["train_loader"]["batch_size"]) + LR = float(cfg["optimizer"]["lr"]) + # Backend reads this from the environment when it builds the query cache. + os.environ["WL_QUERY_CACHE_MAXSIZE"] = str(cfg["query_cache_maxsize"]) + + shutil.rmtree(OUT, ignore_errors=True) + os.makedirs(OUT + "/wl_logs", exist_ok=True) + dev = resolve_device(cfg["device"]) + torch.manual_seed(0) + metrics = open(OUT + "/metrics.jsonl", "w") + sync = (lambda: torch.cuda.synchronize()) if dev.type == "cuda" else (lambda: None) + + # --- 2) Vanilla baseline (plain PyTorch) for the overhead comparison --- + vanilla_ms = vanilla_baseline(dev, BATCH, LR, sync) + log(f"[run] vanilla baseline = {vanilla_ms:.2f} ms/step (dev {dev})") + + # --- 3) WeightsLab setup: both splits tracked, watched loss --- + hp = {"experiment_name": cfg["experiment_name"], "device": str(dev), + "root_log_dir": OUT + "/wl_logs", + "serving_grpc": cfg["serving_grpc"], "serving_cli": cfg["serving_cli"], + "ledger_flush_max_rows": cfg["ledger_flush_max_rows"], + "ledger_enable_h5_persistence": cfg["ledger_enable_h5_persistence"], + "experiment_dump_to_train_steps_ratio": cfg["experiment_dump_to_train_steps_ratio"], + "data": {"train_loader": {"batch_size": BATCH, + "shuffle": cfg["data"]["train_loader"]["shuffle"]}}} + wl.watch_or_edit(hp, flag="hyperparameters", defaults=hp) + train_ds = MNISTIdx(OUT + "/data", train=True, base=0) + test_ds = MNISTIdx(OUT + "/data", train=False, base=1_000_000) + model = wl.watch_or_edit(SmallCNN().to(dev), flag="model", device=dev) + opt = wl.watch_or_edit(optim.Adam(model.parameters(), lr=LR), flag="optimizer") + loader = wl.watch_or_edit(train_ds, flag="data", loader_name="train_loader", + batch_size=BATCH, + shuffle=cfg["data"]["train_loader"]["shuffle"], + is_training=True, preload_labels=True) + test_loader = wl.watch_or_edit(test_ds, flag="data", loader_name="test_loader", + batch_size=int(cfg["data"]["test_loader"]["batch_size"]), + shuffle=cfg["data"]["test_loader"]["shuffle"], + is_training=False, preload_labels=True) + crit = wl.watch_or_edit(nn.CrossEntropyLoss(reduction="none"), + flag="loss", signal_name=LOSS, per_sample=True, log=True) + + # --- 4) Per-sample signals (the @wl.signal chain, see utils/signals.py) --- + register_signals(LOSS, SHAPE_EVERY, SHAPE_MIN_STEP) + + # --- 5) Serve + launch training --- + wl.serve(serving_grpc=cfg["serving_grpc"], serving_cli=cfg["serving_cli"]) + wl.start_training(timeout=5) # Let WeightsLab Initialize + log(f"[run] tracked train={len(train_ds)} test={len(test_ds)} | shape_every={SHAPE_EVERY}") + + # --- 6) Train / eval loop --- + step_times, gstep, t_run = [], 0, time.perf_counter() + for ep in range(1, EPOCHS + 1): + ep_ms = train(loader, model, opt, crit, dev, sync) + gstep += len(ep_ms) + test(test_loader, model, crit, dev) + wl_ms = float(np.mean(ep_ms)); step_times += ep_ms + rec = {"epoch": ep, "gstep": gstep, "wl_ms": round(wl_ms, 2), "vanilla_ms": round(vanilla_ms, 2), + "rss_gb": round(rss_gb(), 2), "elapsed_s": round(time.perf_counter() - t_run, 1)} + metrics.write(json.dumps(rec) + "\n"); metrics.flush() + log(f"[run] ep {ep:3d}/{EPOCHS} | WL {wl_ms:6.2f} ms vs vanilla {vanilla_ms:.2f} " + f"= +{100*(wl_ms/vanilla_ms-1):.0f}% | RSS {rec['rss_gb']:.2f} GB | {rec['elapsed_s']:.0f}s") + + # --- 7) Report + summary --- + path = wl.write_dataframe(OUT + "/report.csv", format="csv", columns="signals") + df = pd.read_csv(path) + sc = [c for c in df.columns if c.endswith("sig/loss_shape")][0] + dist = Counter(SHAPES[int(v)] for v in df[sc].dropna() if v >= 0) + med = float(np.median(step_times)) + metrics.close() + log("\n[run] ===== SUMMARY =====") + log(f"[run] vanilla {vanilla_ms:.2f} ms | WL median {med:.2f} ms/step (+{100*(med/vanilla_ms-1):.0f}%)") + log(f"[run] report {len(df)} rows | loss_shape covered {df[sc].notna().sum()}/{len(df)}") + log(f"[run] shape distribution: {dict(dist)}") + log(f"[run] report -> {path}") + + +if __name__ == "__main__": + main() diff --git a/weightslab/examples/Usecases/wl-classification-signals_shape_classification/utils/criterions.py b/weightslab/examples/Usecases/wl-classification-signals_shape_classification/utils/criterions.py new file mode 100644 index 00000000..0837832a --- /dev/null +++ b/weightslab/examples/Usecases/wl-classification-signals_shape_classification/utils/criterions.py @@ -0,0 +1,36 @@ +# ============================================================================= +# Loss-shape classification +# ============================================================================= +# The watched criterion is a stock ``nn.CrossEntropyLoss(reduction="none")`` +# wrapped per-sample in main.py. This module holds the loss-*trajectory* +# classifier used by the ``sig/loss_shape`` signal: given each sample's loss +# history it labels the curve as one of ``SHAPES``. +import numpy as np + +SHAPES = ["monotonic", "plateaued", "Flat_high", "high_variance", "U_Shape", "Spiked"] + + +def classify_shape(values): + """Loss trajectory -> shape index (or -1 with < 5 points).""" + y = np.asarray(values, dtype=float) + if y.size < 5: + return -1 + n = y.size + rng = max(float(y.max() - y.min()), 1e-8) + drop = (float(y[0]) - float(y[-1])) / (abs(float(y[0])) + 1e-8) + cv = float(y.std()) / (abs(float(y.mean())) + 1e-8) + argmin = int(np.argmin(y)) + rebound = (float(y[-1]) - float(y.min())) / rng + tail = y[int(0.6 * n):] + tail_flat = float(tail.std()) / (abs(float(tail.mean())) + 1e-8) < 0.1 + if 0.2 * n < argmin < 0.8 * n and rebound > 0.3: + return SHAPES.index("U_Shape") + if drop > 0.4: + return SHAPES.index("monotonic") + if drop > 0.15 and tail_flat: + return SHAPES.index("plateaued") + if float(np.diff(y).max()) / rng > 0.5: + return SHAPES.index("Spiked") + if cv > 0.5: + return SHAPES.index("high_variance") + return SHAPES.index("Flat_high") diff --git a/weightslab/examples/Usecases/wl-classification-signals_shape_classification/utils/data.py b/weightslab/examples/Usecases/wl-classification-signals_shape_classification/utils/data.py new file mode 100644 index 00000000..7f23157d --- /dev/null +++ b/weightslab/examples/Usecases/wl-classification-signals_shape_classification/utils/data.py @@ -0,0 +1,23 @@ +# ============================================================================= +# MNIST dataset yielding per-sample uids for the shared WeightsLab ledger +# ============================================================================= +from torch.utils.data import Dataset +from torchvision import datasets, transforms + + +class MNISTIdx(Dataset): + """Yields (image, uid, label). uid namespaced by split so train/test don't + collide in the shared ledger; fast_get_label skips decode at ledger init.""" + def __init__(self, root, train, base): + self.m = datasets.MNIST(root, train=train, download=True, transform=None) + self.t = transforms.ToTensor(); self.base = base + + def __len__(self): + return len(self.m) + + def __getitem__(self, i): + img, lab = self.m[i] + return self.t(img), self.base + i, lab + + def fast_get_label(self, i): + return int(self.m.targets[i]) diff --git a/weightslab/examples/Usecases/wl-classification-signals_shape_classification/utils/model.py b/weightslab/examples/Usecases/wl-classification-signals_shape_classification/utils/model.py new file mode 100644 index 00000000..311585e7 --- /dev/null +++ b/weightslab/examples/Usecases/wl-classification-signals_shape_classification/utils/model.py @@ -0,0 +1,19 @@ +# ============================================================================= +# Small CNN classifier for MNIST +# ============================================================================= +# Two conv/pool blocks into a small MLP head producing 10 class logits. The +# ``input_shape`` attribute lets WeightsLab introspect the model. +import torch.nn as nn + + +class SmallCNN(nn.Module): + def __init__(self): + super().__init__() + self.input_shape = (1, 1, 28, 28) + self.net = nn.Sequential( + nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), + nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), + nn.Flatten(), nn.Linear(16 * 7 * 7, 64), nn.ReLU(), nn.Linear(64, 10)) + + def forward(self, x): + return self.net(x) diff --git a/weightslab/examples/Usecases/wl-classification-signals_shape_classification/utils/signals.py b/weightslab/examples/Usecases/wl-classification-signals_shape_classification/utils/signals.py new file mode 100644 index 00000000..696ceb10 --- /dev/null +++ b/weightslab/examples/Usecases/wl-classification-signals_shape_classification/utils/signals.py @@ -0,0 +1,48 @@ +# ============================================================================= +# Per-sample @wl.signal chain +# ============================================================================= +# Per-step user code is just the watched loss. Everything here is reactive: +# sig/entropy from the logits when the watched loss fires +# sig/loss_norm batch-normalized loss (loss / mean(loss)) +# sig/hardness loss * entropy +# sig/loss_shape classifies each sample's loss trajectory (live signal, not an +# end-of-run function). Reads history, so it's throttled by +# shape_every (1 = every step; higher = cheaper, sparser). +import numpy as np +import torch + +import weightslab as wl + +from utils.criterions import classify_shape + + +def register_signals(loss_name, shape_every, min_step: int = 0): + """Define and register the per-sample signal chain on the watched loss. + + Defining a ``@wl.signal`` registers it globally, so this must be called + before ``wl.serve`` / ``wl.start_training``. + + Args: + loss_name: name of the watched per-sample loss signal to subscribe to. + shape_every: compute sig/loss_shape every N steps (throttle). + min_step: minimum step to start computing sig/loss_shape. + """ + + @wl.signal(name="sig/entropy", subscribe_to=loss_name, batched=True) + def entropy(b): + p = torch.softmax(b.logits, 1) + return (-(p * (p + 1e-12).log()).sum(1)).detach().cpu().numpy() + + @wl.signal(name="sig/loss_norm", inputs=[loss_name], batched=True) + def loss_norm(b): + return b.inputs[loss_name] / (float(np.mean(b.inputs[loss_name])) + 1e-8) + + @wl.signal(name="sig/hardness", inputs=[loss_name, "sig/entropy"], batched=True) + def hardness(b): + return b.inputs[loss_name] * b.inputs["sig/entropy"] + + @wl.signal(name="sig/loss_shape", inputs=[loss_name], batched=True, + compute_every_n_steps=shape_every, min_step=min_step) + def loss_shape(b): + hist = b.history(loss_name) # {uid: [loss values in step order]} + return np.array([classify_shape(hist[s]) for s in b.sample_ids], dtype=float) diff --git a/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/README.md b/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/README.md deleted file mode 100644 index ba8bddc6..00000000 --- a/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/README.md +++ /dev/null @@ -1,102 +0,0 @@ -# WeightsLab — Object Detection (pure PyTorch) - -A small, fully-runnable **object detection** example wired into WeightsLab. It -trains a compact single-shot detector on the **Penn-Fudan Pedestrian** dataset -(~170 real photos, one class: `person`) and streams per-sample / per-instance -losses, IoU, and predicted bounding boxes to the WeightsLab UI. - -Everything here is plain PyTorch + torchvision — no detection framework -(no Ultralytics/Detectron). The only pretrained piece is an ImageNet backbone. - -## Quick start - -From a WeightsLab install, the one-liner (installs this example's -`requirements.txt`, then trains + serves until `Ctrl+C`): - -```bash -weightslab start example --det -``` - -Or run it directly: - -```bash -cd weightslab/examples/PyTorch/ws-detection -pip install -r requirements.txt -python main.py -``` - -The **first run downloads** the Penn-Fudan dataset (~50 MB, into `./data/`) and -the MobileNetV3-Small ImageNet weights (~10 MB, cached by torch). Then open the -UI (e.g. `http://localhost:5173`) to watch training. - -## What you'll see in the UI - -| Signal | Meaning | -| ----------------------- | ---------------------------------------------------- | -| `train_loss/sample` | Per-image training loss (the value being optimized) | -| `test_loss/sample` | Per-image validation loss | -| `train_iou/sample` | Mean IoU per training image | -| `test_iou/sample` | Mean IoU per validation image | -| `train_iou/instance` | IoU per **ground-truth box** `(sample_id, annotation_id)` | -| `test_iou/instance` | Same, on validation | - -Ground-truth and predicted **bounding boxes** are rendered as overlays on each -sample (the dataset and model declare `task_type = "detection"`). - -## How it works - -``` -utils/data.py PennFudanDetectionDataset — downloads Penn-Fudan, derives one - bbox per pedestrian from the instance masks, returns the WL - detection target [N, 6] = [x1, y1, x2, y2, class_id, conf] - normalized to [0, 1]. ImageNet-normalized model inputs. - `det_collate` keeps the variable box count as a per-sample list. - -utils/model.py SmallDetector — ImageNet-pretrained MobileNetV3-Small backbone - (frozen by default) + a small head that predicts ONE box per - cell on an S x S grid: (objectness, tx, ty, tw, th, class...). - `decode_grid` turns raw logits into xyxy boxes. - -utils/criterions.py PerSampleDetectionLoss — YOLO-style objectness + coordinate + - class loss, one differentiable scalar per sample (what WL - backprops). PerSampleIoU / PerInstanceIoU — IoU metrics. - decode_predictions — top-confidence boxes for the UI overlay. - -main.py Wires it all to WeightsLab: watch_or_edit(...) for the logger, - hyperparameters, data loaders, model, optimizer and the - loss/metric signals; serve(); start_training(); train/test loop. -``` - -The detector is genuinely learnable: on a small subset, mean IoU rises from -~0.39 to ~0.83 within ~60 steps. - -## Configuration (`config.yaml`) - -| Key | Default | Notes | -| ---------------------- | ------- | ----------------------------------------------------------- | -| `num_classes` | `1` | Penn-Fudan has one class (`person`). | -| `image_size` | `256` | Square model input (UI shows the original image). | -| `grid_size` | `8` | Detector predicts on an `8 x 8` cell grid. | -| `conf_thresh` | `0.3` | `objectness * class` threshold for displayed predictions. | -| `pretrained_backbone` | `true` | Load ImageNet weights for the MobileNetV3 backbone. | -| `freeze_backbone` | `true` | Train only the head (fast, less data-hungry). Set `false` to fine-tune the whole backbone once the head has warmed up. | -| `data.*.batch_size` | `8` | Per-loader batch size. | -| `data.*.max_samples` | `null` | Cap a split for quick runs (`null` = full split). | - -## Using your own dataset (e.g. traffic lights) - -The model, loss, metrics, `main.py`, and UI rendering are **dataset-agnostic** — -only `utils/data.py` and a couple of config values change: - -1. Write a `Dataset` whose `get_items(idx, ...)` returns - `(image_tensor, uid, target, metadata)`, where `target` is an - `[N, 6]` float array `[x1, y1, x2, y2, class_id, confidence]` **normalized to - `[0, 1]`** (ground-truth confidence = `1.0`). Set `self.task_type = "detection"`, - `self.num_classes`, `self.class_names`, and expose `self.images` (a list of - image paths) so the UI can show the raw image. -2. Reuse `det_collate` unchanged. -3. In `config.yaml`, set `num_classes` to your class count (e.g. `3` for - `red / yellow / green`) and update `class_names` in the dataset / model. - -That's it — multi-class works out of the box (the classification head is already -in the grid prediction; it's just trivial when `num_classes == 1`). diff --git a/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/config.yaml b/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/config.yaml deleted file mode 100644 index 26c6ae44..00000000 --- a/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/config.yaml +++ /dev/null @@ -1,50 +0,0 @@ -# Global configuration -device: auto -experiment_name: detection_pennfudan_usecase -training_steps_to_do: null # null = infinite training until manually stopped (behavior set by the user in main script) -# root_log_dir: # Empty to write in tmp directory, or specify a path to store logs and checkpoints - -checkpoint_manager: - load_config: false # Disable loading previous checkpoint configuration to allow for changes in the current config.yaml file between experiments. Otherwise changes will be replaced. - -# Initially compute natural sorting values, e.g., average intensity. More details in the README.md file. -compute_natural_sort: true - -# Experiment parameters -eval_full_to_train_steps_ratio: 5 -experiment_dump_to_train_steps_ratio: 5 -tqdm_display: true -is_training: false # Start training immediately or not - -# Serving -serving_grpc: true -serving_cli: true - -# Configure global dataframe storage -ledger_enable_h5_persistence: true -ledger_enable_flushing_threads: true -ledger_flush_max_rows: 100 -ledger_flush_interval: 60.0 - -# Model / task -num_classes: 1 # Penn-Fudan: single class (person) -image_size: 256 -grid_size: 8 # detector predicts on an 8x8 cell grid -conf_thresh: 0.3 # objectness*class confidence threshold for displayed predictions -pretrained_backbone: true # ImageNet-pretrained MobileNetV3-Small feature extractor -freeze_backbone: true # train only the detection head (faster, less data hungry) - -optimizer: - lr: 0.001 - -# Data (Penn-Fudan pedestrians; ~170 images downloaded on first run under data_root) -data_root: .\data -data: - train_loader: - batch_size: 8 - shuffle: true - max_samples: null # null = use the full training split - test_loader: - batch_size: 8 - shuffle: false - max_samples: null diff --git a/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/main.py b/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/main.py deleted file mode 100644 index ff4b2030..00000000 --- a/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/main.py +++ /dev/null @@ -1,340 +0,0 @@ -import os -import time -import numpy as np -import tqdm -import yaml -import torch -import logging -import tempfile -import itertools - -import weightslab as wl - -from torch import optim - - -from weightslab.components.global_monitoring import ( - guard_training_context, - guard_testing_context, -) - -from utils.data import PennFudanDetectionDataset, det_collate -from utils.model import SmallDetector -from utils.criterions import ( - PerSampleDetectionLoss, - PerSampleIoU, - PerInstanceIoU, - decode_predictions, -) - -# Setup loggers -logging.basicConfig(level=logging.ERROR) -logging.getLogger("PIL").setLevel(logging.INFO) - - -# ============================================================================= -# Train / Test loops (detection, using watcher-wrapped loaders) -# ============================================================================= - -def train(loader, model, optimizer, sig, device, grid_size, conf_thresh): - """Single training step using the tracked dataloader + watched loss. - - loader yields (inputs, ids, targets, metadata) because of the - DataSampleTrackingWrapper. `targets` is per sample a [N, 6] tensor of boxes - ([x1, y1, x2, y2, class_id, confidence]); see utils/data.det_collate. - """ - with guard_training_context: - (inputs, ids, targets, _) = next(loader) - inputs = inputs.to(device) - targets = [t.to(device) for t in targets] - - optimizer.zero_grad() - outputs = model(inputs) # [B, S, S, 5 + num_classes] - - # Decoded boxes for the UI overlay (detached — display only). - preds = decode_predictions(outputs.detach(), grid_size, conf_thresh=conf_thresh) - - # Per-sample detection loss (tracked, saved, and the value we backprop). - # `preds=` rides along so WL stores the predicted boxes for this batch. - loss_per_sample = sig["loss"](outputs, targets, batch_ids=ids, preds=preds) - - # Metrics: per-sample mean IoU + per-instance IoU (one value per box). - sig["iou_sample"](outputs, targets, batch_ids=ids) - sig["iou_instance"](outputs, targets, batch_ids=ids) - - loss = loss_per_sample.mean() - loss.backward() - optimizer.step() - - return float(loss.detach().cpu().item()) - - -def test(loader, model, sig, device, grid_size, conf_thresh, test_loader_len): - """Full evaluation pass over the val loader.""" - losses = 0.0 - ious = 0.0 - with guard_testing_context, torch.no_grad(): - for inputs, ids, targets, _ in loader: - inputs = inputs.to(device) - targets = [t.to(device) for t in targets] - - outputs = model(inputs) - preds = decode_predictions(outputs, grid_size, conf_thresh=conf_thresh) - - loss_per_sample = sig["loss"](outputs, targets, batch_ids=ids, preds=preds) - iou_per_sample = sig["iou_sample"](outputs, targets, batch_ids=ids) - sig["iou_instance"](outputs, targets, batch_ids=ids) - - losses += torch.mean(loss_per_sample) - ious += torch.mean(iou_per_sample) - - loss = float((losses / test_loader_len).detach().cpu().item()) - iou = float((ious / test_loader_len).detach().cpu().item()) - return loss, iou * 100.0 # Return mean IoU as percentage - - -# ============================================================================= -# Main -# ============================================================================= -if __name__ == "__main__": - # --- 1) Load hyperparameters from YAML (if present) --- - config_path = os.path.join(os.path.dirname(__file__), "config.yaml") - if os.path.exists(config_path): - with open(config_path, "r") as fh: - parameters = yaml.safe_load(fh) or {} - else: - parameters = {} - - # Defaults - parameters.setdefault("experiment_name", "pennfudan_detection") - parameters.setdefault("device", "auto") - parameters.setdefault("training_steps_to_do", 500) - parameters.setdefault("eval_full_to_train_steps_ratio", 50) - parameters.setdefault("number_of_workers", 4) - parameters.setdefault("num_classes", 1) # Penn-Fudan: single class (person) - parameters.setdefault("image_size", 256) - parameters.setdefault("grid_size", 8) - parameters.setdefault("conf_thresh", 0.3) - parameters.setdefault("pretrained_backbone", True) - parameters.setdefault("freeze_backbone", True) - parameters.setdefault("compute_natural_sort", True) - - # --- 2) Register hyperparameters --- - exp_name = parameters["experiment_name"] - wl.watch_or_edit( - parameters, - flag="hyperparameters", - name=exp_name, - defaults=parameters, - poll_interval=1.0, - ) - - num_classes = int(parameters["num_classes"]) - image_size = int(parameters["image_size"]) - grid_size = int(parameters["grid_size"]) - conf_thresh = float(parameters["conf_thresh"]) - - # --- 3) Device selection --- - if parameters.get("device", "auto") == "auto": - parameters["device"] = torch.device( - "cuda" if torch.cuda.is_available() else "cpu" - ) - device = parameters["device"] - - # --- 4) Logging directory --- - if not parameters.get("root_log_dir"): - tmp_dir = tempfile.mkdtemp() - parameters["root_log_dir"] = tmp_dir - print(f"No root_log_dir specified, using temporary directory: {parameters['root_log_dir']}") - os.makedirs(parameters["root_log_dir"], exist_ok=True) - log_dir = parameters["root_log_dir"] - max_steps = parameters["training_steps_to_do"] - eval_full_to_train_steps_ratio = parameters["eval_full_to_train_steps_ratio"] - verbose = parameters.get("verbose", True) - tqdm_display = parameters.get("tqdm_display", True) - - - # --- 5) Data (Penn-Fudan pedestrians, downloaded on first run) --- - default_data_root = os.path.abspath( - os.path.join(os.path.dirname(__file__), "data") - ) - data_root = parameters.get("data_root", default_data_root) - - train_cfg = parameters.get("data", {}).get("train_loader", {}) - test_cfg = parameters.get("data", {}).get("test_loader", {}) - - _train_dataset = PennFudanDetectionDataset( - root=data_root, - split="train", - num_classes=num_classes, - image_size=image_size, - max_samples=train_cfg.get("max_samples", None), - ) - _val_dataset = PennFudanDetectionDataset( - root=data_root, - split="val", - num_classes=num_classes, - image_size=image_size, - max_samples=test_cfg.get("max_samples", None), - ) - - train_loader = wl.watch_or_edit( - _train_dataset, - flag="data", - loader_name="train_loader", - batch_size=train_cfg.get("batch_size", 8), - shuffle=train_cfg.get("shuffle", True), - compute_hash=False, - is_training=True, - array_autoload_arrays=False, - array_return_proxies=True, - array_use_cache=True, - preload_labels=False, - collate_fn=det_collate, - ) - test_loader = wl.watch_or_edit( - _val_dataset, - flag="data", - loader_name="test_loader", - batch_size=test_cfg.get("batch_size", 8), - shuffle=test_cfg.get("shuffle", False), - compute_hash=False, - is_training=False, - array_autoload_arrays=False, - array_return_proxies=True, - array_use_cache=True, - preload_labels=True, - collate_fn=det_collate, - ) - - # --- 6) Model, optimizer, losses, metric --- - _model = SmallDetector( - in_channels=3, num_classes=num_classes, - image_size=image_size, grid_size=grid_size, - pretrained=bool(parameters["pretrained_backbone"]), - freeze_backbone=bool(parameters["freeze_backbone"]), - ).to(device) - model = wl.watch_or_edit( - _model, - flag="model", - device=device - ) - lr = parameters.get("optimizer", {}).get("lr", 1e-3) - # Only optimize trainable params (the head; backbone may be frozen). - trainable = [p for p in model.parameters() if p.requires_grad] - _optimizer = optim.Adam(trainable, lr=lr) - optimizer = wl.watch_or_edit( - _optimizer, - flag="optimizer", - ) - - # --- Detection loss (per sample) + IoU (per sample & per instance) signals --- - # per_sample=True auto-saves one value per sample; per_instance=True auto-saves - # one IoU per (sample_id, annotation_id), i.e. one per ground-truth box. - def _make_det_signals(split: str, weights=None) -> dict: - return { - "loss": wl.watch_or_edit( - PerSampleDetectionLoss(num_classes, grid_size, weights=weights), - flag="loss", - name=f"{split}_loss/sample", per_sample=True, log=True, - ), - "iou_sample": wl.watch_or_edit( - PerSampleIoU(num_classes, grid_size), flag="metric", - name=f"{split}_iou/sample", per_sample=True, log=True, - ), - "iou_instance": wl.watch_or_edit( - PerInstanceIoU(num_classes, grid_size), flag="metric", - name=f"{split}_iou/instance", per_instance=True, log=True, - ), - } - - # Class weights from ground-truth box counts (optional; balances rare shapes). - def compute_class_weights(dataset, num_classes, max_samples=200): - print("\n" + "=" * 60, flush=True) - print(f"Computing class weights for {num_classes} classes (max {max_samples} samples)...", flush=True) - class_counts = np.zeros(num_classes, dtype=np.float64) - num_samples = min(len(dataset), max_samples) - - for idx in tqdm.tqdm(range(num_samples), desc=" Analyzing Distribution"): - _, _, target, _ = dataset.get_items(idx, include_labels=True) - if target is None or len(target) == 0: - continue - for c in target[:, 4].astype(np.int64): - if 0 <= c < num_classes: - class_counts[c] += 1 - - class_counts = np.maximum(class_counts, 1) # Avoid div by zero - total = class_counts.sum() - class_weights = total / (num_classes * class_counts) - class_weights = class_weights / class_weights.mean() # Normalize - - print("\nClass distribution and weights:", flush=True) - for c in range(num_classes): - pct = (class_counts[c] / total) * 100 - print(f"Class {c} ({dataset.class_names[c]}): {pct:6.2f}% -> weight: {class_weights[c]:.3f}", flush=True) - print("=" * 60 + "\n", flush=True) - return torch.FloatTensor(class_weights).to(device) - - weights = compute_class_weights(_train_dataset, num_classes) - - train_sig = _make_det_signals("train", weights=weights) - test_sig = _make_det_signals("test", weights=weights) - - # --- 7) Start WeightsLab services --- - wl.serve( - serving_grpc=parameters.get("serving_grpc", True), - serving_cli=parameters.get("serving_cli", True), - ) - - print("=" * 60) - print(" STARTING PENN-FUDAN PEDESTRIAN DETECTION TRAINING") - print(f" Total steps: {max_steps}") - print(f" Evaluation every {eval_full_to_train_steps_ratio} steps") - print(f" Logs will be saved to: {log_dir}") - print(f" Data root: {data_root}") - print("=" * 60 + "\n") - - # ================ - # Training Loop - wl.start_training(timeout=3) # Blocks and keeps the main thread alive while background services run. Optionally set a timeout (seconds) to auto-stop. - - # ================ - train_range = tqdm.tqdm(itertools.count(), desc="Training") if tqdm_display else itertools.count() - test_loss, test_metric = None, None - start_time = time.time() - for train_step in train_range: - age = model.get_age() if hasattr(model, "get_age") else train_step - - # Train - train_loss = train(train_loader, model, optimizer, train_sig, device, grid_size, conf_thresh) - - # Test - if age == 0 or age % eval_full_to_train_steps_ratio == 0: - test_loader_len = len(test_loader) # Store length before wrapping with tqdm - test_loader_it = tqdm.tqdm(test_loader, desc="Evaluating") if tqdm_display else test_loader - test_loss, test_metric = test(test_loader_it, model, test_sig, device, grid_size, conf_thresh, test_loader_len) - - # Verbose - if verbose and not tqdm_display: - print( - "Training.. " + - f"Step {train_step} (Age {age}): " + - f"| Train Loss: {train_loss:.4f} " + - (f"| Test Loss: {test_loss:.4f} " if test_loss is not None else '') + - (f"| Test IoU: {test_metric:.2f}% " if test_metric is not None else '') - ) - elif tqdm_display: - train_range.set_description("Step") - train_range.set_postfix( - train_loss=f"{train_loss:.4f}", - test_loss=f"{test_loss:.4f}" if test_loss is not None else "N/A", - iou=f"{test_metric:.2f}%" if test_metric is not None else "N/A" - ) - - print("\n" + "=" * 60) - print(f" Training completed in {time.time() - start_time:.2f} seconds") - print(f" Logs saved to: {log_dir}") - print("=" * 60) - - # Keep the main thread alive to allow background serving threads to run - wl.keep_serving() diff --git a/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/utils/criterions.py b/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/utils/criterions.py deleted file mode 100644 index 7cd6c9c9..00000000 --- a/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/utils/criterions.py +++ /dev/null @@ -1,361 +0,0 @@ -import torch -import torch.nn as nn -import torch.nn.functional as F - -from .model import decode_grid - -import weightslab as wl -import numpy as np - - -# ============================================================================= -# Per-instance / per-sample detection criterions (YOLO-v1 style loss + IoU) -# ============================================================================= -# The detection dataset yields, per sample, a [N, 6] target tensor -# ``[x1, y1, x2, y2, class_id, confidence]`` (normalized to [0, 1]). Each GT box -# is assigned to the grid cell containing its center; that cell is "responsible" -# for predicting the box. -# -# * PerSampleDetectionLoss -> one differentiable loss scalar per sample ([B]), -# wrapped with ``per_sample=True`` (the value WL backprops + dashboards). -# * PerSampleIoU -> mean IoU over a sample's boxes ([B]), a metric. -# * PerInstanceIoU -> flat tensor of one IoU per GT box (sample-major -# order), wrapped with ``per_instance=True`` so WL auto-saves it at -# (sample_id, annotation_id). The ordering matches the per-sample target -# iteration, so the wrapper's auto ``batch_idx`` maps each value correctly. - -_EPS = 1e-6 -_LAMBDA_COORD = 5.0 -_LAMBDA_NOOBJ = 0.5 - - -def box_iou_xyxy(a, b): - """IoU between two aligned sets of xyxy boxes. a, b: [..., 4] -> [...].""" - x1 = torch.maximum(a[..., 0], b[..., 0]) - y1 = torch.maximum(a[..., 1], b[..., 1]) - x2 = torch.minimum(a[..., 2], b[..., 2]) - y2 = torch.minimum(a[..., 3], b[..., 3]) - - inter = (x2 - x1).clamp(min=0) * (y2 - y1).clamp(min=0) - area_a = (a[..., 2] - a[..., 0]).clamp(min=0) * (a[..., 3] - a[..., 1]).clamp(min=0) - area_b = (b[..., 2] - b[..., 0]).clamp(min=0) * (b[..., 3] - b[..., 1]).clamp(min=0) - union = area_a + area_b - inter + _EPS - return inter / union - - -def _responsible_cells(boxes, S): - """Map GT boxes -> their responsible (row, col) cell and center offsets. - - Args: - boxes: [N, 4] xyxy in [0, 1]. - S: grid size. - - Returns: - rows, cols: [N] long, the responsible cell indices. - off_x, off_y: [N] center offset within the cell, in [0, 1). - w, h: [N] box size as a fraction of the image. - """ - cx = (boxes[:, 0] + boxes[:, 2]) / 2 - cy = (boxes[:, 1] + boxes[:, 3]) / 2 - w = (boxes[:, 2] - boxes[:, 0]).clamp(_EPS, 1.0) - h = (boxes[:, 3] - boxes[:, 1]).clamp(_EPS, 1.0) - - cols = (cx * S).long().clamp(0, S - 1) - rows = (cy * S).long().clamp(0, S - 1) - off_x = (cx * S - cols).clamp(0, 1) - off_y = (cy * S - rows).clamp(0, 1) - return rows, cols, off_x, off_y, w, h - - -def _per_sample_loss(outputs, targets, num_classes, weights=None): - """YOLO-v1 style loss, returned as one scalar per sample ([B], with grad).""" - B, S = outputs.shape[0], outputs.shape[1] - device = outputs.device - - obj_logit = outputs[..., 0] # [B, S, S] - tx = torch.sigmoid(outputs[..., 1]) - ty = torch.sigmoid(outputs[..., 2]) - w_pred = torch.sigmoid(outputs[..., 3]) - h_pred = torch.sigmoid(outputs[..., 4]) - cls_logits = outputs[..., 5:] # [B, S, S, C] - - if weights is not None: - weights = torch.as_tensor(weights, device=device, dtype=outputs.dtype) - - losses = [] - for s in range(B): - tgt = targets[s] - tgt = torch.as_tensor(tgt, device=device, dtype=outputs.dtype) - if tgt.ndim == 1: - tgt = tgt.view(-1, 6) if tgt.numel() else tgt.view(0, 6) - - obj_target = torch.zeros((S, S), device=device, dtype=outputs.dtype) - - coord_loss = torch.zeros((), device=device) - class_loss = torch.zeros((), device=device) - - if tgt.numel() > 0: - boxes = tgt[:, :4] - cls_ids = tgt[:, 4].long().clamp(0, num_classes - 1) - rows, cols, off_x, off_y, gw, gh = _responsible_cells(boxes, S) - - obj_target[rows, cols] = 1.0 - - # Localization: center offset (linear) + size in sqrt space (YOLO trick - # so small-box errors weigh as much as large-box ones). - coord = ( - (tx[s, rows, cols] - off_x) ** 2 - + (ty[s, rows, cols] - off_y) ** 2 - + (torch.sqrt(w_pred[s, rows, cols] + _EPS) - torch.sqrt(gw + _EPS)) ** 2 - + (torch.sqrt(h_pred[s, rows, cols] + _EPS) - torch.sqrt(gh + _EPS)) ** 2 - ) - coord_loss = _LAMBDA_COORD * coord.sum() - - ce = F.cross_entropy( - cls_logits[s, rows, cols], cls_ids, reduction="none" - ) - if weights is not None: - ce = ce * weights[cls_ids] - class_loss = ce.sum() - - # Objectness BCE over the whole grid; down-weight the (many) empty cells. - obj_weight = torch.where( - obj_target > 0, - torch.ones_like(obj_target), - torch.full_like(obj_target, _LAMBDA_NOOBJ), - ) - obj_loss = ( - F.binary_cross_entropy_with_logits( - obj_logit[s], obj_target, reduction="none" - ) * obj_weight - ).sum() - - losses.append(coord_loss + class_loss + obj_loss) - - return torch.stack(losses) - - -def _per_box_iou(outputs, targets, grid_size): - """IoU of each GT box against its responsible cell's decoded prediction. - - Returns a list[B] of 1-D tensors (one IoU per box for that sample, in - annotation order). Detached — this is a metric, not a loss. - """ - boxes_grid, _, _ = decode_grid(outputs, grid_size) # [B, S, S, 4] - B = outputs.shape[0] - S = grid_size - device = outputs.device - - per_sample = [] - for s in range(B): - tgt = torch.as_tensor(targets[s], device=device, dtype=outputs.dtype) - if tgt.numel() == 0: - per_sample.append(torch.zeros(0, device=device)) - continue - if tgt.ndim == 1: - tgt = tgt.view(-1, 6) - - gt_boxes = tgt[:, :4] - rows, cols, _, _, _, _ = _responsible_cells(gt_boxes, S) - pred_boxes = boxes_grid[s, rows, cols] # [N, 4] - ious = box_iou_xyxy(pred_boxes, gt_boxes) # [N] - per_sample.append(ious.detach()) - - return per_sample - - -class PerSampleDetectionLoss(nn.Module): - """Total detection loss aggregated to one value per sample ([B]).""" - - def __init__(self, num_classes, grid_size, weights=None): - super().__init__() - self.num_classes = num_classes - self.grid_size = grid_size - self.register_buffer( - "weights", torch.tensor(weights) if weights is not None else None - ) - - def forward(self, outputs, targets): - return _per_sample_loss(outputs, targets, self.num_classes, weights=self.weights) - - -class PerSampleIoU(nn.Module): - """Mean IoU over a sample's boxes -> one value per sample ([B]).""" - - def __init__(self, num_classes, grid_size): - super().__init__() - self.num_classes = num_classes - self.grid_size = grid_size - - def forward(self, outputs, targets): - per_sample = _per_box_iou(outputs, targets, self.grid_size) - out = [ - v.mean() if v.numel() > 0 else torch.zeros((), device=outputs.device) - for v in per_sample - ] - return torch.stack(out).detach() - - -class PerInstanceIoU(nn.Module): - """IoU per GT box -> flat tensor [total_boxes] (sample-major order).""" - - def __init__(self, num_classes, grid_size): - super().__init__() - self.num_classes = num_classes - self.grid_size = grid_size - - def forward(self, outputs, targets): - per_sample = _per_box_iou(outputs, targets, self.grid_size) - flat = [v for s in per_sample for v in s] - if not flat: - return torch.zeros(0, device=outputs.device) - return torch.stack(flat).detach() - - -# ============================================================================= -# Inference-time decoding (for UI prediction overlays) -# ============================================================================= -def decode_predictions(outputs, grid_size, conf_thresh=0.3, max_det=10): - """Turn raw grid logits into a per-sample list of detected boxes. - - Returns list[B] of [M, 6] numpy-friendly tensors - ``[x1, y1, x2, y2, class_id, confidence]`` (kept on CPU, detached) — the - exact 6-column schema WL renders for detection predictions. - """ - boxes_grid, obj, cls_probs = decode_grid(outputs, grid_size) - B, S = outputs.shape[0], grid_size - - cls_conf, cls_id = cls_probs.max(dim=-1) # [B, S, S] - score = obj * cls_conf # combined confidence - - flat_boxes = boxes_grid.view(B, S * S, 4) - flat_score = score.view(B, S * S) - flat_cls = cls_id.view(B, S * S) - - results = [] - for s in range(B): - keep = flat_score[s] >= conf_thresh - if keep.sum() == 0: - results.append(torch.zeros((0, 6))) - continue - sc = flat_score[s][keep] - bx = flat_boxes[s][keep] - cl = flat_cls[s][keep].to(bx.dtype) - - # Keep the most confident detections (cheap top-k in place of full NMS). - order = torch.argsort(sc, descending=True)[:max_det] - det = torch.cat([bx[order], cl[order, None], sc[order, None]], dim=1) - results.append(det.detach().cpu()) - - return results - - - - -# ========================================================================= -# Custom subscribed signal: per-sample loss-shape classification -# ========================================================================= -# This is a *dynamic* WeightsLab signal. It subscribes to the per-sample -# classification loss "train/clsf_sample" and, every 25 optimisation steps, -# inspects each sample's full loss trajectory, classifies its *shape*, and -# writes the verdict back onto the sample as the categorical tag "loss_shape". -# -# The six shapes describe how a sample's loss evolved over training: -# monotonic -> loss steadily decreasing (model is learning it) -# plateaued -> dropped then leveled off still-high (stuck / hard sample) -# Flat_high -> never moved, stayed high (mislabel / unlearnable) -# high_variance -> noisy oscillation (ambiguous label) -# U_Shape -> learned then forgotten (catastrophic interference) -# Spiked -> sudden jump at some step (data/aug/version change) - -# Allowed values for the categorical tag, in display order. -LOSS_SHAPE_LABELS = [ - "monotonic", "plateaued", "Flat_high", - "high_variance", "U_Shape", "Spiked", -] - -# Numeric encoding returned by the signal so the verdict is also plottable -# per-sample (a tag is a string; a signal must be numeric). -LOSS_SHAPE_CODES = {label: i for i, label in enumerate(LOSS_SHAPE_LABELS)} - -# Minimum number of logged points before a trajectory can be classified. -_MIN_HISTORY = 5 - -# Get checkpoint manager -checkpoint_manager = ledgers.get_checkpoint_manager() - -# Declare the categorical tag written by the loss-shape classifier signal, -# so the UI shows all six choices even before any sample is tagged. Must be -# called after the dataloader is registered (the dataframe now exists). -wl.register_categorical_tag("loss_shape", LOSS_SHAPE_LABELS) - - -def _classify_loss_shape(values): - """Classify a per-sample loss trajectory into one of LOSS_SHAPE_LABELS. - - *values* is the loss series ordered by step. Returns a label string, or - ``None`` when there is not enough history yet to decide. All thresholds - are scale-invariant (expressed as fractions of the trajectory's own - range), so the same logic works regardless of the absolute loss scale. - These thresholds are illustrative — tune them for your own task. - """ - y = np.asarray(values, dtype=float) - if y.size < _MIN_HISTORY: - return None - - n = y.size - first, last = float(y[0]), float(y[-1]) - ymin, ymax = float(y.min()), float(y.max()) - rng = max(ymax - ymin, 1e-8) - mean = float(y.mean()) - - cv = float(y.std()) / (abs(mean) + 1e-8) # noisiness (coeff. of variation) - drop = (first - last) / (abs(first) + 1e-8) # net improvement, start -> end - argmin = int(np.argmin(y)) - rebound = (last - ymin) / rng # how far it climbed back from the trough - max_up_jump = float(np.diff(y).max()) / rng # largest single-step rise - - # Flat, recent tail (last 40% of the trajectory) is used to detect plateaus. - tail = y[int(0.6 * n):] - tail_flat = (float(tail.std()) / (abs(float(tail.mean())) + 1e-8)) < 0.1 - - # Order matters: most specific / most alarming shapes are checked first. - if max_up_jump > 0.5: # one big isolated jump up - return "Spiked" - if cv > 0.5: # globally noisy / oscillating - return "high_variance" - if 0.2 * n < argmin < 0.8 * n and rebound > 0.3: # dipped mid-run, then rose - return "U_Shape" - if drop > 0.4: # large, steady net decrease - return "monotonic" - if drop > 0.15 and tail_flat: # modest drop, then leveled high - return "plateaued" - return "Flat_high" # barely moved — never learned - - -@wl.signal( - name="train_loss_sample_shape_classifier", - subscribe_to="train_loss/sample", - compute_every_n_steps=25, - log=False, # side-effecting signal: we tag samples, no aggregate curve needed -) -def classify_loss_shape(ctx: wl.SignalContext) -> int: - """Dynamic signal fired per sample every 25 steps for "train_loss/sample". - - Pulls the sample's full loss history, classifies its shape, and tags the - sample with the categorical tag ``loss_shape``. Returns the numeric shape - code (or -1 when there is not enough history yet) so the verdict is also - available as a per-sample signal column. - """ - # Full per-sample trajectory of the subscribed metric, ordered by step. - history = wl.query_sample_history(ctx.sample_id, signal_name="train_loss/sample", exp_hash=checkpoint_manager.get_current_experiment_hash()) - series = sorted(((step, val) for _, step, val, _ in history), key=lambda t: t[0]) - values = [v for _, v in series] - - label = _classify_loss_shape(values) - if label is None: - return -1 - - # Persist the verdict as a categorical tag on this sample. - wl.set_categorical_tag([ctx.sample_id], "loss_shape", label) - return LOSS_SHAPE_CODES[label] diff --git a/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/utils/data.py b/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/utils/data.py deleted file mode 100644 index dbff4a02..00000000 --- a/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/utils/data.py +++ /dev/null @@ -1,226 +0,0 @@ -import os -import ssl -import zipfile -import urllib.request - -import numpy as np -import torch - -from torchvision import transforms - -from torch.utils.data import Dataset - -from PIL import Image - - -# ============================================================================= -# Penn-Fudan Pedestrian detection dataset -# ============================================================================= -# A small, real object-detection dataset (~170 photos, one class: "person"). -# It ships per-instance segmentation masks; we derive an axis-aligned bounding -# box per pedestrian from each mask. Downloaded + extracted on first use. -# -# On-disk layout after extraction: -# /PennFudanPed/ -# PNGImages/FudanPed00001.png ... -# PedMasks/FudanPed00001_mask.png ... # pixel value k = k-th pedestrian, 0 = bg -# -# WL renders detection targets/predictions from a per-sample [N, 6] array -# ``[x1, y1, x2, y2, class_id, confidence]`` normalized to [0, 1] (GT conf = 1.0) -# — see ``get_items`` below and ``data_service.py`` (task_type == "detection"). - -CLASS_NAMES = ["person"] - -_URL = "https://www.cis.upenn.edu/~jshi/ped_html/PennFudanPed.zip" - -# ImageNet statistics — the MobileNetV3 backbone is pretrained with these, so we -# normalize model inputs the same way. (The UI still shows the original PNG.) -IMAGENET_MEAN = [0.485, 0.456, 0.406] -IMAGENET_STD = [0.229, 0.224, 0.225] - - -def download_penn_fudan(root): - """Download + extract Penn-Fudan into /PennFudanPed (idempotent).""" - base = os.path.join(root, "PennFudanPed") - if os.path.isdir(os.path.join(base, "PNGImages")): - return base - - os.makedirs(root, exist_ok=True) - zip_path = os.path.join(root, "PennFudanPed.zip") - - if not os.path.exists(zip_path): - print(f"[data] Downloading Penn-Fudan dataset to {zip_path} ...", flush=True) - try: - urllib.request.urlretrieve(_URL, zip_path) - except Exception as e: - # Some corporate environments break TLS verification - retry unverified. - print(f"[data] TLS verification failed ({e}); retrying without verification.", flush=True) - ctx = ssl._create_unverified_context() - with urllib.request.urlopen(_URL, context=ctx) as resp, open(zip_path, "wb") as fh: - fh.write(resp.read()) - - print("[data] Extracting Penn-Fudan ...", flush=True) - with zipfile.ZipFile(zip_path) as zf: - zf.extractall(root) - return base - - -def _boxes_from_mask(mask_path): - """Derive one bbox per pedestrian from a Penn-Fudan instance mask. - - Returns (boxes_px [N, 4] int xyxy, height, width). Background (0) skipped. - """ - mask = np.array(Image.open(mask_path)) - h, w = mask.shape[:2] - obj_ids = np.unique(mask) - obj_ids = obj_ids[obj_ids != 0] # drop background - - boxes = [] - for oid in obj_ids: - ys, xs = np.where(mask == oid) - if xs.size == 0: - continue - x1, x2 = int(xs.min()), int(xs.max()) - y1, y2 = int(ys.min()), int(ys.max()) - if x2 > x1 and y2 > y1: - boxes.append([x1, y1, x2, y2]) - return np.asarray(boxes, dtype=np.float32).reshape(-1, 4), h, w - - -class PennFudanDetectionDataset(Dataset): - """Pedestrian bounding-box detection over the Penn-Fudan images. - - Args: - root: directory to download/extract the dataset into. - split: "train" or "val" (deterministic split of the 170 images). - image_size: square resize fed to the model. - val_fraction: fraction of images held out for validation. - max_samples: optional cap on the split size (for quick runs). - """ - - def __init__( - self, - root, - split="train", - num_classes=1, - image_size=256, - val_fraction=0.2, - max_samples=None, - ): - super().__init__() - self.root = root - self.split = split - self.num_classes = num_classes - self.image_size = image_size - # Explicit task type; bypasses WL's label-shape heuristic so bboxes are - # rendered as detection overlays (not mistaken for classification). - self.task_type = "detection" - self.class_names = CLASS_NAMES[:num_classes] - - base = download_penn_fudan(root) - img_dir = os.path.join(base, "PNGImages") - mask_dir = os.path.join(base, "PedMasks") - - all_imgs = sorted(f for f in os.listdir(img_dir) if f.lower().endswith(".png")) - - # Deterministic train/val split: every k-th image goes to val. - k = max(2, int(round(1.0 / max(val_fraction, 1e-6)))) - if split == "val": - selected = all_imgs[::k] - else: - val_set = set(all_imgs[::k]) - selected = [f for f in all_imgs if f not in val_set] - - selected = selected[:max_samples] if max_samples != None else selected - - self.images = [] - self.masks = [] - for fname in selected: - base_name, _ = os.path.splitext(fname) - mask_path = os.path.join(mask_dir, base_name + "_mask.png") - if os.path.exists(mask_path): - self.images.append(os.path.join(img_dir, fname)) - self.masks.append(mask_path) - - if len(self.images) == 0: - raise RuntimeError(f"No image/mask pairs found under {base}") - - self.image_transform = transforms.Compose( - [ - transforms.Resize((image_size, image_size), interpolation=Image.BILINEAR), - transforms.ToTensor(), - transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), - ] - ) - - def __len__(self): - return len(self.images) - - def _load_boxes(self, mask_path): - """Read mask → [N, 6] float32 = [x1, y1, x2, y2, cls=0, conf=1.0] (normalized).""" - boxes_px, h, w = _boxes_from_mask(mask_path) - if boxes_px.shape[0] == 0: - return np.zeros((0, 6), dtype=np.float32) - norm = boxes_px.copy() - norm[:, [0, 2]] /= float(w) - norm[:, [1, 3]] /= float(h) - n = norm.shape[0] - cls = np.zeros((n, 1), dtype=np.float32) # single class: person - conf = np.ones((n, 1), dtype=np.float32) - return np.concatenate([norm, cls, conf], axis=1).astype(np.float32) - - def __getitem__(self, idx): - """Returns (item, uid, target, metadata). - - - item: normalized image tensor [C, H, W] - - uid: unique sample id (string) - - target: [N, 6] float32 = [x1, y1, x2, y2, class_id, confidence] - - metadata: dict with source paths - """ - return self.get_items(idx, include_metadata=True, include_labels=True, include_images=True) - - def get_items(self, idx, include_metadata=False, include_labels=False, include_images=False): - img_path = self.images[idx] - mask_path = self.masks[idx] - uid = os.path.splitext(os.path.basename(img_path))[0] - - metadata = { - "img_path": img_path, - "mask_path": mask_path, - } if include_metadata else None - - img_t = None - if include_images: - img = Image.open(img_path).convert("RGB") - img_t = self.image_transform(img) - - target = None - if include_labels: - target = self._load_boxes(mask_path) - - return img_t, uid, target, metadata - - -def det_collate(batch): - """Collate WL per-sample tuples for object detection. - - A sample owns a variable number of boxes, so targets cannot be stacked. We - keep them as a Python list (one [N_i, 6] tensor per sample), exactly the - layout WL's per-instance helpers expect (``targets[s]`` iterates that - sample's boxes in annotation order). - - Returns: - images: FloatTensor [B, C, H, W] - ids: list[str] of length B - targets: list[B] of [N_i, 6] float tensors ([x1, y1, x2, y2, cls, conf]) - metas: list[B] of metadata dicts - """ - images = torch.stack([b[0] for b in batch], dim=0) - ids = [b[1] for b in batch] - targets = [ - torch.as_tensor(b[2], dtype=torch.float32) - if not isinstance(b[2], torch.Tensor) else b[2].float() - for b in batch - ] - metas = [b[3] if len(b) > 3 else None for b in batch] - return images, ids, targets, metas diff --git a/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/utils/model.py b/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/utils/model.py deleted file mode 100644 index ed204a66..00000000 --- a/weightslab/examples/Usecases/ws-loss_shapes_classification_per_sample/utils/model.py +++ /dev/null @@ -1,134 +0,0 @@ -# ============================================================================= -# Small single-shot grid detector on a pretrained MobileNetV3-Small backbone -# ============================================================================= -# A frozen/fine-tuned ImageNet-pretrained backbone extracts features; a tiny -# detection head turns them into an S x S grid where each cell predicts ONE box: -# (objectness, tx, ty, tw, th, class_logits...). -# -# Encoding (all coordinates normalized to the [0, 1] image frame): -# * objectness = sigmoid(t_obj) -> P(box present in this cell) -# * cx = (col + sigmoid(tx)) / S -> box center, x -# * cy = (row + sigmoid(ty)) / S -> box center, y -# * w = sigmoid(tw) -> box width (fraction of image) -# * h = sigmoid(th) -> box height (fraction of image) -# * class = softmax(class_logits) -# -# Raw forward output keeps logits (loss applies the activations); `decode` -# turns logits into xyxy boxes for metrics and UI rendering. -import torch -import torch.nn as nn - -from torchvision.models import mobilenet_v3_small, MobileNet_V3_Small_Weights - - -def decode_grid(outputs, grid_size): - """Decode raw grid logits -> per-cell xyxy boxes, objectness and class probs. - - Shared by the model (``SmallDetector.decode``) and the criterions so the - encoding lives in exactly one place. - - Args: - outputs: [B, S, S, 5 + num_classes] raw logits. - grid_size: S. - - Returns: - boxes: [B, S, S, 4] xyxy in [0, 1] - obj: [B, S, S] objectness probability - cls_probs: [B, S, S, num_classes] class probabilities - """ - B, S, _, _ = outputs.shape - device = outputs.device - - obj = torch.sigmoid(outputs[..., 0]) - tx = torch.sigmoid(outputs[..., 1]) - ty = torch.sigmoid(outputs[..., 2]) - w = torch.sigmoid(outputs[..., 3]) - h = torch.sigmoid(outputs[..., 4]) - cls_probs = torch.softmax(outputs[..., 5:], dim=-1) - - # Cell-origin grid (col=x, row=y). - cols = torch.arange(S, device=device).view(1, 1, S).expand(B, S, S) - rows = torch.arange(S, device=device).view(1, S, 1).expand(B, S, S) - - cx = (cols + tx) / S - cy = (rows + ty) / S - x1 = (cx - w / 2).clamp(0, 1) - y1 = (cy - h / 2).clamp(0, 1) - x2 = (cx + w / 2).clamp(0, 1) - y2 = (cy + h / 2).clamp(0, 1) - - boxes = torch.stack([x1, y1, x2, y2], dim=-1) - return boxes, obj, cls_probs - - -class SmallDetector(nn.Module): - def __init__( - self, - in_channels=3, - num_classes=1, - image_size=256, - grid_size=8, - pretrained=True, - freeze_backbone=True, - ): - super().__init__() - # For WeightsLab - self.task_type = "detection" - self.num_classes = num_classes - self.class_names = ["person"][:num_classes] - self.grid_size = grid_size - self.image_size = image_size - self.input_shape = (1, in_channels, image_size, image_size) - - # Channels per cell: objectness(1) + box(4) + class logits(num_classes) - self.preds_per_cell = 5 + num_classes - - # --- Pretrained backbone (ImageNet) --- - weights = MobileNet_V3_Small_Weights.IMAGENET1K_V1 if pretrained else None - backbone = mobilenet_v3_small(weights=weights) - self.backbone = backbone.features # [B, 576, H/32, W/32] - backbone_out_ch = 576 - - self.freeze_backbone = freeze_backbone - if freeze_backbone: - for p in self.backbone.parameters(): - p.requires_grad = False - - # --- Detection head (lightweight, trained from scratch) --- - self.neck = nn.Sequential( - nn.Conv2d(backbone_out_ch, 256, 3, padding=1, bias=False), - nn.BatchNorm2d(256), - nn.ReLU(inplace=True), - nn.Conv2d(256, 128, 3, padding=1, bias=False), - nn.BatchNorm2d(128), - nn.ReLU(inplace=True), - ) - self.head = nn.Conv2d(128, self.preds_per_cell, 1) - - def train(self, mode=True): - """Keep a frozen backbone in eval mode (so its BatchNorm stats stay fixed).""" - super().train(mode) - if self.freeze_backbone: - self.backbone.eval() - return self - - def forward(self, x): - """Returns raw logits [B, S, S, 5 + num_classes].""" - if self.freeze_backbone: - with torch.no_grad(): - feat = self.backbone(x) - else: - feat = self.backbone(x) - - feat = self.neck(feat) - out = self.head(feat) # [B, preds_per_cell, S', S'] - - # Resize feature grid to the configured grid_size. - if out.shape[-1] != self.grid_size or out.shape[-2] != self.grid_size: - out = nn.functional.adaptive_avg_pool2d(out, self.grid_size) - # [B, C, S, S] -> [B, S, S, C] - return out.permute(0, 2, 3, 1).contiguous() - - def decode(self, outputs): - """Decode raw logits -> per-cell xyxy boxes, objectness, class probs.""" - return decode_grid(outputs, self.grid_size) diff --git a/weightslab/proto/experiment_service.proto b/weightslab/proto/experiment_service.proto index 40961ff4..e4d394f0 100644 --- a/weightslab/proto/experiment_service.proto +++ b/weightslab/proto/experiment_service.proto @@ -69,6 +69,7 @@ message LoggerDataPoint { message GetLatestLoggerDataResponse { repeated LoggerDataPoint points = 1; // All points from all metrics + string weightslab_version = 2; // backend weightslab package version (shown in UI logo tooltip) } message Empty {} @@ -176,6 +177,13 @@ message SaveCheckpointOperation { bool save_optimizer = 2; // also persist optimizer state } +// Restarts the backend Python process. The process relies on the container's +// restart policy (e.g. `restart: unless-stopped`) to come back up, at which +// point it reloads its last saved checkpoint the same way it does on a fresh +// container start. +message RestartInstanceOperation { +} + message TrainerCommand { bool get_hyper_parameters = 4; bool get_interactive_layers = 5; @@ -189,6 +197,7 @@ message TrainerCommand { optional DenySamplesOperation remove_eval_from_denylist_operation = 12; optional PlotNoteOperation plot_note_operation = 13; optional SaveCheckpointOperation save_checkpoint_operation = 14; + optional RestartInstanceOperation restart_operation = 15; } message HyperParameterDesc { diff --git a/weightslab/proto/experiment_service_pb2.py b/weightslab/proto/experiment_service_pb2.py index 7c109dfa..e02bc82e 100644 --- a/weightslab/proto/experiment_service_pb2.py +++ b/weightslab/proto/experiment_service_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)weightslab/proto/experiment_service.proto\"\x89\x01\n\x1aGetLatestLoggerDataRequest\x12\x1c\n\x14request_full_history\x18\x01 \x01(\x08\x12\x12\n\nmax_points\x18\x02 \x01(\x05\x12\x17\n\x0f\x62reak_by_slices\x18\x03 \x01(\x08\x12\x0c\n\x04tags\x18\x04 \x03(\t\x12\x12\n\ngraph_name\x18\x05 \x01(\t\"\x81\x02\n\x0fLoggerDataPoint\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x11\n\tmodel_age\x18\x02 \x01(\x05\x12\x14\n\x0cmetric_value\x18\x03 \x01(\x02\x12\x17\n\x0f\x65xperiment_hash\x18\x04 \x01(\t\x12\x11\n\ttimestamp\x18\x05 \x01(\x03\x12\x11\n\tsample_id\x18\x06 \x01(\t\x12\x1c\n\x14is_evaluation_marker\x18\x07 \x01(\x08\x12\x12\n\nsplit_name\x18\x08 \x01(\t\x12\x17\n\x0f\x65valuation_tags\x18\t \x03(\t\x12\x12\n\npoint_note\x18\n \x01(\t\x12\x12\n\naudit_mode\x18\x0b \x01(\x08\"?\n\x1bGetLatestLoggerDataResponse\x12 \n\x06points\x18\x01 \x03(\x0b\x32\x10.LoggerDataPoint\"\x07\n\x05\x45mpty\"/\n\x08NeuronId\x12\x10\n\x08layer_id\x18\x01 \x01(\x05\x12\x11\n\tneuron_id\x18\x02 \x01(\x05\"\x91\x02\n\x0fWeightOperation\x12*\n\x07op_type\x18\x01 \x01(\x0e\x32\x14.WeightOperationTypeH\x00\x88\x01\x01\x12\x15\n\x08layer_id\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1d\n\nneuron_ids\x18\x03 \x03(\x0b\x32\t.NeuronId\x12\x16\n\x0eneurons_to_add\x18\t \x01(\x05\x12 \n\x18zerofy_from_incoming_ids\x18\x0b \x03(\x05\x12\x1c\n\x14zerofy_to_neuron_ids\x18\x0c \x03(\x05\x12+\n\x11zerofy_predicates\x18\r \x03(\x0e\x32\x10.ZerofyPredicateB\n\n\x08_op_typeB\x0b\n\t_layer_id\"_\n\x17WeightsOperationRequest\x12/\n\x10weight_operation\x18\x01 \x01(\x0b\x32\x10.WeightOperationH\x00\x88\x01\x01\x42\x13\n\x11_weight_operation\"<\n\x18WeightsOperationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xc1\x05\n\x0fHyperParameters\x12\x1c\n\x0f\x65xperiment_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12!\n\x14training_steps_to_do\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1a\n\rlearning_rate\x18\x03 \x01(\x02H\x02\x88\x01\x01\x12\x17\n\nbatch_size\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12 \n\x13\x66ull_eval_frequency\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12 \n\x13\x63heckpont_frequency\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x18\n\x0bis_training\x18\x07 \x01(\x08H\x06\x88\x01\x01\x12\x15\n\x08nb_steps\x18\x08 \x01(\x05H\x07\x88\x01\x01\x12\x19\n\x0c\x61uditor_mode\x18\t \x01(\x08H\x08\x88\x01\x01\x12\x1d\n\x10train_batch_size\x18\n \x01(\x05H\t\x88\x01\x01\x12\x1b\n\x0eval_batch_size\x18\x0b \x01(\x05H\n\x88\x01\x01\x12\x1c\n\x0ftest_batch_size\x18\x0c \x01(\x05H\x0b\x88\x01\x01\x12\x1c\n\x0f\x65valuation_mode\x18\r \x01(\x08H\x0c\x88\x01\x01\x12\x1e\n\x11\x65valuation_config\x18\x0e \x01(\tH\r\x88\x01\x01\x42\x12\n\x10_experiment_nameB\x17\n\x15_training_steps_to_doB\x10\n\x0e_learning_rateB\r\n\x0b_batch_sizeB\x16\n\x14_full_eval_frequencyB\x16\n\x14_checkpont_frequencyB\x0e\n\x0c_is_trainingB\x0b\n\t_nb_stepsB\x0f\n\r_auditor_modeB\x13\n\x11_train_batch_sizeB\x11\n\x0f_val_batch_sizeB\x12\n\x10_test_batch_sizeB\x12\n\x10_evaluation_modeB\x14\n\x12_evaluation_config\",\n\rMetricsStatus\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02\"~\n\rAnnotatStatus\x12\x0c\n\x04name\x18\x01 \x01(\t\x12.\n\x08metadata\x18\x02 \x03(\x0b\x32\x1c.AnnotatStatus.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\"\x90\x02\n\x10TrainingStatusEx\x12\x16\n\ttimestamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x1c\n\x0f\x65xperiment_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x16\n\tmodel_age\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12+\n\x0emetrics_status\x18\x04 \x01(\x0b\x32\x0e.MetricsStatusH\x03\x88\x01\x01\x12+\n\x0e\x61nnotat_status\x18\x05 \x01(\x0b\x32\x0e.AnnotatStatusH\x04\x88\x01\x01\x42\x0c\n\n_timestampB\x12\n\x10_experiment_nameB\x0c\n\n_model_ageB\x11\n\x0f_metrics_statusB\x11\n\x0f_annotat_status\"]\n\x15HyperParameterCommand\x12/\n\x10hyper_parameters\x18\x01 \x01(\x0b\x32\x10.HyperParametersH\x00\x88\x01\x01\x42\x13\n\x11_hyper_parameters\">\n\x14\x44\x65nySamplesOperation\x12\x12\n\nsample_ids\x18\x01 \x03(\t\x12\x12\n\naccumulate\x18\x02 \x01(\x08\"0\n\x17LoadCheckpointOperation\x12\x15\n\rcheckpoint_id\x18\x01 \x01(\x05\"b\n\x11PlotNoteOperation\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x17\n\x0f\x65xperiment_hash\x18\x02 \x01(\t\x12\x11\n\tmodel_age\x18\x03 \x01(\x05\x12\x0c\n\x04note\x18\x04 \x01(\t\"L\n\x17SaveCheckpointOperation\x12\x19\n\x11save_architecture\x18\x01 \x01(\x08\x12\x16\n\x0esave_optimizer\x18\x02 \x01(\x08\"\xbc\x07\n\x0eTrainerCommand\x12\x1c\n\x14get_hyper_parameters\x18\x04 \x01(\x08\x12\x1e\n\x16get_interactive_layers\x18\x05 \x01(\x08\x12\x1d\n\x10get_data_records\x18\x06 \x01(\tH\x00\x88\x01\x01\x12%\n\x18get_single_layer_info_id\x18\x08 \x01(\x05H\x01\x88\x01\x01\x12;\n\x16hyper_parameter_change\x18\x01 \x01(\x0b\x32\x16.HyperParameterCommandH\x02\x88\x01\x01\x12:\n\x16\x64\x65ny_samples_operation\x18\x07 \x01(\x0b\x32\x15.DenySamplesOperationH\x03\x88\x01\x01\x12?\n\x1b\x64\x65ny_eval_samples_operation\x18\n \x01(\x0b\x32\x15.DenySamplesOperationH\x04\x88\x01\x01\x12@\n\x19load_checkpoint_operation\x18\t \x01(\x0b\x32\x18.LoadCheckpointOperationH\x05\x88\x01\x01\x12\x42\n\x1eremove_from_denylist_operation\x18\x0b \x01(\x0b\x32\x15.DenySamplesOperationH\x06\x88\x01\x01\x12G\n#remove_eval_from_denylist_operation\x18\x0c \x01(\x0b\x32\x15.DenySamplesOperationH\x07\x88\x01\x01\x12\x34\n\x13plot_note_operation\x18\r \x01(\x0b\x32\x12.PlotNoteOperationH\x08\x88\x01\x01\x12@\n\x19save_checkpoint_operation\x18\x0e \x01(\x0b\x32\x18.SaveCheckpointOperationH\t\x88\x01\x01\x42\x13\n\x11_get_data_recordsB\x1b\n\x19_get_single_layer_info_idB\x19\n\x17_hyper_parameter_changeB\x19\n\x17_deny_samples_operationB\x1e\n\x1c_deny_eval_samples_operationB\x1c\n\x1a_load_checkpoint_operationB!\n\x1f_remove_from_denylist_operationB&\n$_remove_eval_from_denylist_operationB\x16\n\x14_plot_note_operationB\x1c\n\x1a_save_checkpoint_operation\"\x9d\x01\n\x12HyperParameterDesc\x12\r\n\x05label\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\x12\x1c\n\x0fnumerical_value\x18\x04 \x01(\x02H\x00\x88\x01\x01\x12\x19\n\x0cstring_value\x18\x05 \x01(\tH\x01\x88\x01\x01\x42\x12\n\x10_numerical_valueB\x0f\n\r_string_value\"\xf2\x02\n\x10NeuronStatistics\x12!\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronIdH\x00\x88\x01\x01\x12\x17\n\nneuron_age\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1f\n\x12train_trigger_rate\x18\x03 \x01(\x02H\x02\x88\x01\x01\x12\x1e\n\x11\x65val_trigger_rate\x18\x04 \x01(\x02H\x03\x88\x01\x01\x12\x1a\n\rlearning_rate\x18\x07 \x01(\x02H\x04\x88\x01\x01\x12\x36\n\x0bincoming_lr\x18\x08 \x03(\x0b\x32!.NeuronStatistics.IncomingLrEntry\x1a\x31\n\x0fIncomingLrEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\x42\x0c\n\n_neuron_idB\r\n\x0b_neuron_ageB\x15\n\x13_train_trigger_rateB\x14\n\x12_eval_trigger_rateB\x10\n\x0e_learning_rate\"\xf0\x02\n\x13LayerRepresentation\x12\x15\n\x08layer_id\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x17\n\nlayer_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x17\n\nlayer_type\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x1a\n\rneurons_count\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12#\n\x16incoming_neurons_count\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12\x18\n\x0bkernel_size\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x13\n\x06stride\x18\x07 \x01(\x05H\x06\x88\x01\x01\x12-\n\x12neurons_statistics\x18\n \x03(\x0b\x32\x11.NeuronStatisticsB\x0b\n\t_layer_idB\r\n\x0b_layer_nameB\r\n\x0b_layer_typeB\x10\n\x0e_neurons_countB\x19\n\x17_incoming_neurons_countB\x0e\n\x0c_kernel_sizeB\t\n\x07_stride\"H\n\x11\x41\x63tivationRequest\x12\x10\n\x08layer_id\x18\x01 \x01(\x05\x12\x11\n\tsample_id\x18\x02 \x01(\t\x12\x0e\n\x06origin\x18\x03 \x01(\t\"H\n\rActivationMap\x12\x11\n\tneuron_id\x18\x01 \x01(\x05\x12\x0e\n\x06values\x18\x02 \x03(\x02\x12\t\n\x01H\x18\x03 \x01(\x05\x12\t\n\x01W\x18\x04 \x01(\x05\"d\n\x12\x41\x63tivationResponse\x12\x12\n\nlayer_type\x18\x01 \x01(\t\x12\x15\n\rneurons_count\x18\x02 \x01(\x05\x12#\n\x0b\x61\x63tivations\x18\x03 \x03(\x0b\x32\x0e.ActivationMap\"\x93\x01\n\tTaskField\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x15\n\x0b\x66loat_value\x18\x02 \x01(\x02H\x00\x12\x13\n\tint_value\x18\x03 \x01(\x05H\x00\x12\x16\n\x0cstring_value\x18\x04 \x01(\tH\x00\x12\x15\n\x0b\x62ytes_value\x18\x05 \x01(\x0cH\x00\x12\x14\n\nbool_value\x18\x06 \x01(\x08H\x00\x42\x07\n\x05value\"\xcc\x02\n\x0eRecordMetadata\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x14\n\x0csample_label\x18\x02 \x03(\x05\x12\x19\n\x11sample_prediction\x18\x03 \x03(\x05\x12=\n\x10sample_last_loss\x18\x04 \x03(\x0b\x32#.RecordMetadata.SampleLastLossEntry\x12\x19\n\x11sample_encounters\x18\x05 \x01(\x05\x12\x18\n\x10sample_discarded\x18\x06 \x01(\x08\x12 \n\x0c\x65xtra_fields\x18\x07 \x03(\x0b\x32\n.TaskField\x12\x16\n\x0eprediction_raw\x18\t \x01(\x0c\x12\x11\n\ttask_type\x18\n \x01(\t\x1a\x35\n\x13SampleLastLossEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\"\x93\x01\n\x10SampleStatistics\x12\x13\n\x06origin\x18\x06 \x01(\tH\x00\x88\x01\x01\x12\x19\n\x0csample_count\x18\x07 \x01(\x05H\x01\x88\x01\x01\x12\x11\n\ttask_type\x18\t \x01(\t\x12 \n\x07records\x18\x08 \x03(\x0b\x32\x0f.RecordMetadataB\t\n\x07_originB\x0f\n\r_sample_count\"\xe6\x01\n\x0f\x43ommandResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x33\n\x16hyper_parameters_descs\x18\x03 \x03(\x0b\x32\x13.HyperParameterDesc\x12\x33\n\x15layer_representations\x18\x04 \x03(\x0b\x32\x14.LayerRepresentation\x12\x31\n\x11sample_statistics\x18\x05 \x01(\x0b\x32\x11.SampleStatisticsH\x00\x88\x01\x01\x42\x14\n\x12_sample_statistics\"U\n\rSampleRequest\x12\x16\n\tsample_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06origin\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x0c\n\n_sample_idB\t\n\x07_origin\"\xad\x02\n\x15SampleRequestResponse\x12\x16\n\tsample_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06origin\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x12\n\x05label\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12\x11\n\x04\x64\x61ta\x18\x04 \x01(\x0cH\x03\x88\x01\x01\x12\x1a\n\rerror_message\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x15\n\x08raw_data\x18\x06 \x01(\x0cH\x05\x88\x01\x01\x12\x11\n\x04mask\x18\x07 \x01(\x0cH\x06\x88\x01\x01\x12\x17\n\nprediction\x18\x08 \x01(\x0cH\x07\x88\x01\x01\x42\x0c\n\n_sample_idB\t\n\x07_originB\x08\n\x06_labelB\x07\n\x05_dataB\x10\n\x0e_error_messageB\x0b\n\t_raw_dataB\x07\n\x05_maskB\r\n\x0b_prediction\"\x92\x01\n\x12\x42\x61tchSampleRequest\x12\x12\n\nsample_ids\x18\x01 \x03(\t\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x19\n\x0cresize_width\x18\x03 \x01(\x05H\x00\x88\x01\x01\x12\x1a\n\rresize_height\x18\x04 \x01(\x05H\x01\x88\x01\x01\x42\x0f\n\r_resize_widthB\x10\n\x0e_resize_height\">\n\x13\x42\x61tchSampleResponse\x12\'\n\x07samples\x18\x01 \x03(\x0b\x32\x16.SampleRequestResponse\".\n\x0eWeightsRequest\x12\x1c\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronId\"\x9d\x02\n\x0fWeightsResponse\x12\x1c\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronId\x12\x17\n\nlayer_name\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x17\n\nlayer_type\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x10\n\x08incoming\x18\x04 \x01(\x05\x12\x10\n\x08outgoing\x18\x05 \x01(\x05\x12\x18\n\x0bkernel_size\x18\x06 \x01(\x05H\x02\x88\x01\x01\x12\x0f\n\x07weights\x18\x07 \x03(\x02\x12\x0f\n\x07success\x18\x0b \x01(\x08\x12\x1a\n\rerror_message\x18\x0c \x01(\tH\x03\x88\x01\x01\x42\r\n\x0b_layer_nameB\r\n\x0b_layer_typeB\x0e\n\x0c_kernel_sizeB\x10\n\x0e_error_message\"R\n\x10\x44\x61taQueryRequest\x12\r\n\x05query\x18\x01 \x01(\t\x12\x12\n\naccumulate\x18\x02 \x01(\x08\x12\x1b\n\x13is_natural_language\x18\x03 \x01(\x08\"5\n\x11\x43\x61tegoricalTagDef\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ncategories\x18\x02 \x03(\t\"\xa9\x02\n\x11\x44\x61taQueryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x1d\n\x15number_of_all_samples\x18\x03 \x01(\x05\x12%\n\x1dnumber_of_samples_in_the_loop\x18\x04 \x01(\x05\x12#\n\x1bnumber_of_discarded_samples\x18\x05 \x01(\x05\x12\x13\n\x0bunique_tags\x18\x06 \x03(\t\x12+\n\x11\x61gent_intent_type\x18\x07 \x01(\x0e\x32\x10.AgentIntentType\x12\x17\n\x0f\x61nalysis_result\x18\x08 \x01(\t\x12,\n\x10\x63\x61tegorical_tags\x18\t \x03(\x0b\x32\x12.CategoricalTagDef\"\xc2\x01\n\x12\x44\x61taSamplesRequest\x12\x13\n\x0bstart_index\x18\x01 \x01(\x05\x12\x13\n\x0brecords_cnt\x18\x02 \x01(\x05\x12 \n\x18include_transformed_data\x18\x03 \x01(\x08\x12\x18\n\x10include_raw_data\x18\x04 \x01(\x08\x12\x19\n\x11stats_to_retrieve\x18\x05 \x03(\t\x12\x14\n\x0cresize_width\x18\x06 \x01(\x05\x12\x15\n\rresize_height\x18\x07 \x01(\x05\"m\n\x08\x44\x61taStat\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\r\n\x05shape\x18\x03 \x03(\x05\x12\r\n\x05value\x18\x04 \x03(\x02\x12\x14\n\x0cvalue_string\x18\x05 \x01(\t\x12\x11\n\tthumbnail\x18\x06 \x01(\x0c\">\n\nDataRecord\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x1d\n\ndata_stats\x18\x02 \x03(\x0b\x32\t.DataStat\"Z\n\x13\x44\x61taSamplesResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12!\n\x0c\x64\x61ta_records\x18\x03 \x03(\x0b\x32\x0b.DataRecord\"C\n\x0fHistogramSubBar\x12\x0e\n\x06origin\x18\x01 \x01(\t\x12\x11\n\tdiscarded\x18\x02 \x01(\x08\x12\r\n\x05\x63ount\x18\x03 \x01(\x03\"h\n\x0cHistogramBin\x12\x0b\n\x03min\x18\x01 \x01(\x01\x12\x0b\n\x03max\x18\x02 \x01(\x01\x12\x0b\n\x03\x61vg\x18\x03 \x01(\x01\x12\r\n\x05\x63ount\x18\x04 \x01(\x03\x12\"\n\x08sub_bars\x18\x05 \x03(\x0b\x32\x10.HistogramSubBar\"[\n\x17\x43\x61tegoricalHistogramBar\x12\r\n\x05label\x18\x01 \x01(\t\x12\r\n\x05\x63ount\x18\x02 \x01(\x03\x12\"\n\x08sub_bars\x18\x03 \x03(\x0b\x32\x10.HistogramSubBar\"4\n\x10HistogramRequest\x12\x0e\n\x06\x63olumn\x18\x01 \x01(\t\x12\x10\n\x08max_bins\x18\x02 \x01(\x05\"\xb2\x01\n\x11HistogramResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\ntotal_rows\x18\x03 \x01(\x03\x12\x1b\n\x04\x62ins\x18\x04 \x03(\x0b\x32\r.HistogramBin\x12\x16\n\x0eis_categorical\x18\x05 \x01(\x08\x12\x32\n\x10\x63\x61tegorical_bars\x18\x06 \x03(\x0b\x32\x18.CategoricalHistogramBar\"W\n\x12GetMetaDataRequest\x12\x13\n\x0bstart_index\x18\x01 \x01(\x05\x12\x13\n\x0brecords_cnt\x18\x02 \x01(\x05\x12\x17\n\x0fmodal_sample_id\x18\x03 \x01(\t\"\x99\x01\n\x13GetMetaDataResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x1a\n\x12\x61ll_metadata_names\x18\x03 \x03(\t\x12!\n\x0cgrid_records\x18\x04 \x03(\x0b\x32\x0b.DataRecord\x12!\n\x0cmodal_record\x18\x05 \x01(\x0b\x32\x0b.DataRecord\"J\n\x11PointCloudRequest\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x12\n\nmax_points\x18\x03 \x01(\x05\"\xbf\x01\n\x0fPointCloudChunk\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\nnum_points\x18\x03 \x01(\x05\x12\x14\n\x0cnum_features\x18\x04 \x01(\x05\x12\x10\n\x08pc_range\x18\x05 \x03(\x02\x12\x0c\n\x04\x64\x61ta\x18\x06 \x01(\x0c\x12\x13\n\x0b\x63hunk_index\x18\x07 \x01(\x05\x12\x14\n\x0ctotal_chunks\x18\x08 \x01(\x05\x12\x15\n\rfeature_names\x18\t \x03(\t\"\xdc\x01\n\x10\x44\x61taEditsRequest\x12\x11\n\tstat_name\x18\x01 \x01(\t\x12\x13\n\x0b\x66loat_value\x18\x02 \x01(\x02\x12\x14\n\x0cstring_value\x18\x03 \x01(\t\x12\x12\n\nbool_value\x18\x04 \x01(\x08\x12\x1d\n\x04type\x18\x05 \x01(\x0e\x32\x0f.SampleEditType\x12\x13\n\x0bsamples_ids\x18\x06 \x03(\t\x12\x16\n\x0esample_origins\x18\x07 \x03(\t\x12\x16\n\x0eis_categorical\x18\x08 \x01(\x08\x12\x12\n\ncategories\x18\t \x03(\t\"5\n\x11\x44\x61taEditsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\":\n\x12\x44\x61taSplitsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x13\n\x0bsplit_names\x18\x02 \x03(\t\"9\n\x13\x41gentHealthResponse\x12\x11\n\tavailable\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"^\n\x16InitializeAgentRequest\x12\x0f\n\x07\x61pi_key\x18\x01 \x01(\t\x12$\n\x08provider\x18\x02 \x01(\x0e\x32\x12.AgentProviderType\x12\r\n\x05model\x18\x03 \x01(\t\";\n\x17InitializeAgentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"(\n\x17\x43hangeAgentModelRequest\x12\r\n\x05model\x18\x01 \x01(\t\"<\n\x18\x43hangeAgentModelResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x17\n\x15GetAgentModelsRequest\"J\n\x16GetAgentModelsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0e\n\x06models\x18\x02 \x03(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\"6\n\x12ResetAgentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"3\n\x18RestoreCheckpointRequest\x12\x17\n\x0f\x65xperiment_hash\x18\x01 \x01(\t\"=\n\x19RestoreCheckpointResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"R\n\x18TriggerEvaluationRequest\x12\x12\n\nsplit_name\x18\x01 \x01(\t\x12\x0c\n\x04tags\x18\x02 \x03(\t\x12\x14\n\x0cuse_full_set\x18\x03 \x01(\x08\"=\n\x19TriggerEvaluationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x1c\n\x1aGetEvaluationStatusRequest\"\x81\x01\n\x1bGetEvaluationStatusResponse\x12\x0e\n\x06status\x18\x01 \x01(\t\x12\x0f\n\x07\x63urrent\x18\x02 \x01(\x05\x12\r\n\x05total\x18\x03 \x01(\x05\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x12\x12\n\nsplit_name\x18\x06 \x01(\t\")\n\x17\x43\x61ncelEvaluationRequest\x12\x0e\n\x06reason\x18\x01 \x01(\t\"<\n\x18\x43\x61ncelEvaluationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t*d\n\x13WeightOperationType\x12\n\n\x06ZEROFY\x10\x00\x12\x10\n\x0cREINITIALIZE\x10\x01\x12\n\n\x06\x46REEZE\x10\x02\x12\x12\n\x0eREMOVE_NEURONS\x10\t\x12\x0f\n\x0b\x41\x44\x44_NEURONS\x10\n*o\n\x0fZerofyPredicate\x12\x19\n\x15ZEROFY_PREDICATE_NONE\x10\x00\x12 \n\x1cZEROFY_PREDICATE_WITH_FROZEN\x10\x01\x12\x1f\n\x1bZEROFY_PREDICATE_WITH_OLDER\x10\x02*M\n\x0f\x41gentIntentType\x12\x12\n\x0eINTENT_UNKNOWN\x10\x00\x12\x11\n\rINTENT_FILTER\x10\x01\x12\x13\n\x0fINTENT_ANALYSIS\x10\x02*I\n\x0eSampleEditType\x12\x11\n\rEDIT_OVERRIDE\x10\x00\x12\x13\n\x0f\x45\x44IT_ACCUMULATE\x10\x01\x12\x0f\n\x0b\x45\x44IT_REMOVE\x10\x02*,\n\x11\x41gentProviderType\x12\x17\n\x13PROVIDER_OPENROUTER\x10\x00\x32\xf5\n\n\x11\x45xperimentService\x12P\n\x13GetLatestLoggerData\x12\x1b.GetLatestLoggerDataRequest\x1a\x1c.GetLatestLoggerDataResponse\x12\x36\n\x11\x45xperimentCommand\x12\x0f.TrainerCommand\x1a\x10.CommandResponse\x12H\n\x11ManipulateWeights\x12\x18.WeightsOperationRequest\x1a\x19.WeightsOperationResponse\x12/\n\nGetWeights\x12\x0f.WeightsRequest\x1a\x10.WeightsResponse\x12\x39\n\x0eGetActivations\x12\x12.ActivationRequest\x1a\x13.ActivationResponse\x12\x37\n\nGetSamples\x12\x13.BatchSampleRequest\x1a\x14.BatchSampleResponse\x12\x37\n\x0e\x41pplyDataQuery\x12\x11.DataQueryRequest\x1a\x12.DataQueryResponse\x12;\n\x0eGetDataSamples\x12\x13.DataSamplesRequest\x1a\x14.DataSamplesResponse\x12\x35\n\x0cGetHistogram\x12\x11.HistogramRequest\x1a\x12.HistogramResponse\x12\x38\n\x0bGetMetaData\x12\x13.GetMetaDataRequest\x1a\x14.GetMetaDataResponse\x12\x37\n\rGetPointCloud\x12\x12.PointCloudRequest\x1a\x10.PointCloudChunk0\x01\x12\x37\n\x0e\x45\x64itDataSample\x12\x11.DataEditsRequest\x1a\x12.DataEditsResponse\x12,\n\rGetDataSplits\x12\x06.Empty\x1a\x13.DataSplitsResponse\x12\x30\n\x10\x43heckAgentHealth\x12\x06.Empty\x1a\x14.AgentHealthResponse\x12\x44\n\x0fInitializeAgent\x12\x17.InitializeAgentRequest\x1a\x18.InitializeAgentResponse\x12G\n\x10\x43hangeAgentModel\x12\x18.ChangeAgentModelRequest\x1a\x19.ChangeAgentModelResponse\x12\x41\n\x0eGetAgentModels\x12\x16.GetAgentModelsRequest\x1a\x17.GetAgentModelsResponse\x12)\n\nResetAgent\x12\x06.Empty\x1a\x13.ResetAgentResponse\x12J\n\x11RestoreCheckpoint\x12\x19.RestoreCheckpointRequest\x1a\x1a.RestoreCheckpointResponse\x12J\n\x11TriggerEvaluation\x12\x19.TriggerEvaluationRequest\x1a\x1a.TriggerEvaluationResponse\x12P\n\x13GetEvaluationStatus\x12\x1b.GetEvaluationStatusRequest\x1a\x1c.GetEvaluationStatusResponse\x12G\n\x10\x43\x61ncelEvaluation\x12\x18.CancelEvaluationRequest\x1a\x19.CancelEvaluationResponseb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)weightslab/proto/experiment_service.proto\"\x89\x01\n\x1aGetLatestLoggerDataRequest\x12\x1c\n\x14request_full_history\x18\x01 \x01(\x08\x12\x12\n\nmax_points\x18\x02 \x01(\x05\x12\x17\n\x0f\x62reak_by_slices\x18\x03 \x01(\x08\x12\x0c\n\x04tags\x18\x04 \x03(\t\x12\x12\n\ngraph_name\x18\x05 \x01(\t\"\x81\x02\n\x0fLoggerDataPoint\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x11\n\tmodel_age\x18\x02 \x01(\x05\x12\x14\n\x0cmetric_value\x18\x03 \x01(\x02\x12\x17\n\x0f\x65xperiment_hash\x18\x04 \x01(\t\x12\x11\n\ttimestamp\x18\x05 \x01(\x03\x12\x11\n\tsample_id\x18\x06 \x01(\t\x12\x1c\n\x14is_evaluation_marker\x18\x07 \x01(\x08\x12\x12\n\nsplit_name\x18\x08 \x01(\t\x12\x17\n\x0f\x65valuation_tags\x18\t \x03(\t\x12\x12\n\npoint_note\x18\n \x01(\t\x12\x12\n\naudit_mode\x18\x0b \x01(\x08\"[\n\x1bGetLatestLoggerDataResponse\x12 \n\x06points\x18\x01 \x03(\x0b\x32\x10.LoggerDataPoint\x12\x1a\n\x12weightslab_version\x18\x02 \x01(\t\"\x07\n\x05\x45mpty\"/\n\x08NeuronId\x12\x10\n\x08layer_id\x18\x01 \x01(\x05\x12\x11\n\tneuron_id\x18\x02 \x01(\x05\"\x91\x02\n\x0fWeightOperation\x12*\n\x07op_type\x18\x01 \x01(\x0e\x32\x14.WeightOperationTypeH\x00\x88\x01\x01\x12\x15\n\x08layer_id\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1d\n\nneuron_ids\x18\x03 \x03(\x0b\x32\t.NeuronId\x12\x16\n\x0eneurons_to_add\x18\t \x01(\x05\x12 \n\x18zerofy_from_incoming_ids\x18\x0b \x03(\x05\x12\x1c\n\x14zerofy_to_neuron_ids\x18\x0c \x03(\x05\x12+\n\x11zerofy_predicates\x18\r \x03(\x0e\x32\x10.ZerofyPredicateB\n\n\x08_op_typeB\x0b\n\t_layer_id\"_\n\x17WeightsOperationRequest\x12/\n\x10weight_operation\x18\x01 \x01(\x0b\x32\x10.WeightOperationH\x00\x88\x01\x01\x42\x13\n\x11_weight_operation\"<\n\x18WeightsOperationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xc1\x05\n\x0fHyperParameters\x12\x1c\n\x0f\x65xperiment_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12!\n\x14training_steps_to_do\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1a\n\rlearning_rate\x18\x03 \x01(\x02H\x02\x88\x01\x01\x12\x17\n\nbatch_size\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12 \n\x13\x66ull_eval_frequency\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12 \n\x13\x63heckpont_frequency\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x18\n\x0bis_training\x18\x07 \x01(\x08H\x06\x88\x01\x01\x12\x15\n\x08nb_steps\x18\x08 \x01(\x05H\x07\x88\x01\x01\x12\x19\n\x0c\x61uditor_mode\x18\t \x01(\x08H\x08\x88\x01\x01\x12\x1d\n\x10train_batch_size\x18\n \x01(\x05H\t\x88\x01\x01\x12\x1b\n\x0eval_batch_size\x18\x0b \x01(\x05H\n\x88\x01\x01\x12\x1c\n\x0ftest_batch_size\x18\x0c \x01(\x05H\x0b\x88\x01\x01\x12\x1c\n\x0f\x65valuation_mode\x18\r \x01(\x08H\x0c\x88\x01\x01\x12\x1e\n\x11\x65valuation_config\x18\x0e \x01(\tH\r\x88\x01\x01\x42\x12\n\x10_experiment_nameB\x17\n\x15_training_steps_to_doB\x10\n\x0e_learning_rateB\r\n\x0b_batch_sizeB\x16\n\x14_full_eval_frequencyB\x16\n\x14_checkpont_frequencyB\x0e\n\x0c_is_trainingB\x0b\n\t_nb_stepsB\x0f\n\r_auditor_modeB\x13\n\x11_train_batch_sizeB\x11\n\x0f_val_batch_sizeB\x12\n\x10_test_batch_sizeB\x12\n\x10_evaluation_modeB\x14\n\x12_evaluation_config\",\n\rMetricsStatus\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02\"~\n\rAnnotatStatus\x12\x0c\n\x04name\x18\x01 \x01(\t\x12.\n\x08metadata\x18\x02 \x03(\x0b\x32\x1c.AnnotatStatus.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\"\x90\x02\n\x10TrainingStatusEx\x12\x16\n\ttimestamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x1c\n\x0f\x65xperiment_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x16\n\tmodel_age\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12+\n\x0emetrics_status\x18\x04 \x01(\x0b\x32\x0e.MetricsStatusH\x03\x88\x01\x01\x12+\n\x0e\x61nnotat_status\x18\x05 \x01(\x0b\x32\x0e.AnnotatStatusH\x04\x88\x01\x01\x42\x0c\n\n_timestampB\x12\n\x10_experiment_nameB\x0c\n\n_model_ageB\x11\n\x0f_metrics_statusB\x11\n\x0f_annotat_status\"]\n\x15HyperParameterCommand\x12/\n\x10hyper_parameters\x18\x01 \x01(\x0b\x32\x10.HyperParametersH\x00\x88\x01\x01\x42\x13\n\x11_hyper_parameters\">\n\x14\x44\x65nySamplesOperation\x12\x12\n\nsample_ids\x18\x01 \x03(\t\x12\x12\n\naccumulate\x18\x02 \x01(\x08\"0\n\x17LoadCheckpointOperation\x12\x15\n\rcheckpoint_id\x18\x01 \x01(\x05\"b\n\x11PlotNoteOperation\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x17\n\x0f\x65xperiment_hash\x18\x02 \x01(\t\x12\x11\n\tmodel_age\x18\x03 \x01(\x05\x12\x0c\n\x04note\x18\x04 \x01(\t\"L\n\x17SaveCheckpointOperation\x12\x19\n\x11save_architecture\x18\x01 \x01(\x08\x12\x16\n\x0esave_optimizer\x18\x02 \x01(\x08\"\x1a\n\x18RestartInstanceOperation\"\x8d\x08\n\x0eTrainerCommand\x12\x1c\n\x14get_hyper_parameters\x18\x04 \x01(\x08\x12\x1e\n\x16get_interactive_layers\x18\x05 \x01(\x08\x12\x1d\n\x10get_data_records\x18\x06 \x01(\tH\x00\x88\x01\x01\x12%\n\x18get_single_layer_info_id\x18\x08 \x01(\x05H\x01\x88\x01\x01\x12;\n\x16hyper_parameter_change\x18\x01 \x01(\x0b\x32\x16.HyperParameterCommandH\x02\x88\x01\x01\x12:\n\x16\x64\x65ny_samples_operation\x18\x07 \x01(\x0b\x32\x15.DenySamplesOperationH\x03\x88\x01\x01\x12?\n\x1b\x64\x65ny_eval_samples_operation\x18\n \x01(\x0b\x32\x15.DenySamplesOperationH\x04\x88\x01\x01\x12@\n\x19load_checkpoint_operation\x18\t \x01(\x0b\x32\x18.LoadCheckpointOperationH\x05\x88\x01\x01\x12\x42\n\x1eremove_from_denylist_operation\x18\x0b \x01(\x0b\x32\x15.DenySamplesOperationH\x06\x88\x01\x01\x12G\n#remove_eval_from_denylist_operation\x18\x0c \x01(\x0b\x32\x15.DenySamplesOperationH\x07\x88\x01\x01\x12\x34\n\x13plot_note_operation\x18\r \x01(\x0b\x32\x12.PlotNoteOperationH\x08\x88\x01\x01\x12@\n\x19save_checkpoint_operation\x18\x0e \x01(\x0b\x32\x18.SaveCheckpointOperationH\t\x88\x01\x01\x12\x39\n\x11restart_operation\x18\x0f \x01(\x0b\x32\x19.RestartInstanceOperationH\n\x88\x01\x01\x42\x13\n\x11_get_data_recordsB\x1b\n\x19_get_single_layer_info_idB\x19\n\x17_hyper_parameter_changeB\x19\n\x17_deny_samples_operationB\x1e\n\x1c_deny_eval_samples_operationB\x1c\n\x1a_load_checkpoint_operationB!\n\x1f_remove_from_denylist_operationB&\n$_remove_eval_from_denylist_operationB\x16\n\x14_plot_note_operationB\x1c\n\x1a_save_checkpoint_operationB\x14\n\x12_restart_operation\"\x9d\x01\n\x12HyperParameterDesc\x12\r\n\x05label\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\x12\x1c\n\x0fnumerical_value\x18\x04 \x01(\x02H\x00\x88\x01\x01\x12\x19\n\x0cstring_value\x18\x05 \x01(\tH\x01\x88\x01\x01\x42\x12\n\x10_numerical_valueB\x0f\n\r_string_value\"\xf2\x02\n\x10NeuronStatistics\x12!\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronIdH\x00\x88\x01\x01\x12\x17\n\nneuron_age\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1f\n\x12train_trigger_rate\x18\x03 \x01(\x02H\x02\x88\x01\x01\x12\x1e\n\x11\x65val_trigger_rate\x18\x04 \x01(\x02H\x03\x88\x01\x01\x12\x1a\n\rlearning_rate\x18\x07 \x01(\x02H\x04\x88\x01\x01\x12\x36\n\x0bincoming_lr\x18\x08 \x03(\x0b\x32!.NeuronStatistics.IncomingLrEntry\x1a\x31\n\x0fIncomingLrEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\x42\x0c\n\n_neuron_idB\r\n\x0b_neuron_ageB\x15\n\x13_train_trigger_rateB\x14\n\x12_eval_trigger_rateB\x10\n\x0e_learning_rate\"\xf0\x02\n\x13LayerRepresentation\x12\x15\n\x08layer_id\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x17\n\nlayer_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x17\n\nlayer_type\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x1a\n\rneurons_count\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12#\n\x16incoming_neurons_count\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12\x18\n\x0bkernel_size\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x13\n\x06stride\x18\x07 \x01(\x05H\x06\x88\x01\x01\x12-\n\x12neurons_statistics\x18\n \x03(\x0b\x32\x11.NeuronStatisticsB\x0b\n\t_layer_idB\r\n\x0b_layer_nameB\r\n\x0b_layer_typeB\x10\n\x0e_neurons_countB\x19\n\x17_incoming_neurons_countB\x0e\n\x0c_kernel_sizeB\t\n\x07_stride\"H\n\x11\x41\x63tivationRequest\x12\x10\n\x08layer_id\x18\x01 \x01(\x05\x12\x11\n\tsample_id\x18\x02 \x01(\t\x12\x0e\n\x06origin\x18\x03 \x01(\t\"H\n\rActivationMap\x12\x11\n\tneuron_id\x18\x01 \x01(\x05\x12\x0e\n\x06values\x18\x02 \x03(\x02\x12\t\n\x01H\x18\x03 \x01(\x05\x12\t\n\x01W\x18\x04 \x01(\x05\"d\n\x12\x41\x63tivationResponse\x12\x12\n\nlayer_type\x18\x01 \x01(\t\x12\x15\n\rneurons_count\x18\x02 \x01(\x05\x12#\n\x0b\x61\x63tivations\x18\x03 \x03(\x0b\x32\x0e.ActivationMap\"\x93\x01\n\tTaskField\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x15\n\x0b\x66loat_value\x18\x02 \x01(\x02H\x00\x12\x13\n\tint_value\x18\x03 \x01(\x05H\x00\x12\x16\n\x0cstring_value\x18\x04 \x01(\tH\x00\x12\x15\n\x0b\x62ytes_value\x18\x05 \x01(\x0cH\x00\x12\x14\n\nbool_value\x18\x06 \x01(\x08H\x00\x42\x07\n\x05value\"\xcc\x02\n\x0eRecordMetadata\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x14\n\x0csample_label\x18\x02 \x03(\x05\x12\x19\n\x11sample_prediction\x18\x03 \x03(\x05\x12=\n\x10sample_last_loss\x18\x04 \x03(\x0b\x32#.RecordMetadata.SampleLastLossEntry\x12\x19\n\x11sample_encounters\x18\x05 \x01(\x05\x12\x18\n\x10sample_discarded\x18\x06 \x01(\x08\x12 \n\x0c\x65xtra_fields\x18\x07 \x03(\x0b\x32\n.TaskField\x12\x16\n\x0eprediction_raw\x18\t \x01(\x0c\x12\x11\n\ttask_type\x18\n \x01(\t\x1a\x35\n\x13SampleLastLossEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\"\x93\x01\n\x10SampleStatistics\x12\x13\n\x06origin\x18\x06 \x01(\tH\x00\x88\x01\x01\x12\x19\n\x0csample_count\x18\x07 \x01(\x05H\x01\x88\x01\x01\x12\x11\n\ttask_type\x18\t \x01(\t\x12 \n\x07records\x18\x08 \x03(\x0b\x32\x0f.RecordMetadataB\t\n\x07_originB\x0f\n\r_sample_count\"\xe6\x01\n\x0f\x43ommandResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x33\n\x16hyper_parameters_descs\x18\x03 \x03(\x0b\x32\x13.HyperParameterDesc\x12\x33\n\x15layer_representations\x18\x04 \x03(\x0b\x32\x14.LayerRepresentation\x12\x31\n\x11sample_statistics\x18\x05 \x01(\x0b\x32\x11.SampleStatisticsH\x00\x88\x01\x01\x42\x14\n\x12_sample_statistics\"U\n\rSampleRequest\x12\x16\n\tsample_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06origin\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x0c\n\n_sample_idB\t\n\x07_origin\"\xad\x02\n\x15SampleRequestResponse\x12\x16\n\tsample_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06origin\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x12\n\x05label\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12\x11\n\x04\x64\x61ta\x18\x04 \x01(\x0cH\x03\x88\x01\x01\x12\x1a\n\rerror_message\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x15\n\x08raw_data\x18\x06 \x01(\x0cH\x05\x88\x01\x01\x12\x11\n\x04mask\x18\x07 \x01(\x0cH\x06\x88\x01\x01\x12\x17\n\nprediction\x18\x08 \x01(\x0cH\x07\x88\x01\x01\x42\x0c\n\n_sample_idB\t\n\x07_originB\x08\n\x06_labelB\x07\n\x05_dataB\x10\n\x0e_error_messageB\x0b\n\t_raw_dataB\x07\n\x05_maskB\r\n\x0b_prediction\"\x92\x01\n\x12\x42\x61tchSampleRequest\x12\x12\n\nsample_ids\x18\x01 \x03(\t\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x19\n\x0cresize_width\x18\x03 \x01(\x05H\x00\x88\x01\x01\x12\x1a\n\rresize_height\x18\x04 \x01(\x05H\x01\x88\x01\x01\x42\x0f\n\r_resize_widthB\x10\n\x0e_resize_height\">\n\x13\x42\x61tchSampleResponse\x12\'\n\x07samples\x18\x01 \x03(\x0b\x32\x16.SampleRequestResponse\".\n\x0eWeightsRequest\x12\x1c\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronId\"\x9d\x02\n\x0fWeightsResponse\x12\x1c\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronId\x12\x17\n\nlayer_name\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x17\n\nlayer_type\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x10\n\x08incoming\x18\x04 \x01(\x05\x12\x10\n\x08outgoing\x18\x05 \x01(\x05\x12\x18\n\x0bkernel_size\x18\x06 \x01(\x05H\x02\x88\x01\x01\x12\x0f\n\x07weights\x18\x07 \x03(\x02\x12\x0f\n\x07success\x18\x0b \x01(\x08\x12\x1a\n\rerror_message\x18\x0c \x01(\tH\x03\x88\x01\x01\x42\r\n\x0b_layer_nameB\r\n\x0b_layer_typeB\x0e\n\x0c_kernel_sizeB\x10\n\x0e_error_message\"R\n\x10\x44\x61taQueryRequest\x12\r\n\x05query\x18\x01 \x01(\t\x12\x12\n\naccumulate\x18\x02 \x01(\x08\x12\x1b\n\x13is_natural_language\x18\x03 \x01(\x08\"5\n\x11\x43\x61tegoricalTagDef\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ncategories\x18\x02 \x03(\t\"\xa9\x02\n\x11\x44\x61taQueryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x1d\n\x15number_of_all_samples\x18\x03 \x01(\x05\x12%\n\x1dnumber_of_samples_in_the_loop\x18\x04 \x01(\x05\x12#\n\x1bnumber_of_discarded_samples\x18\x05 \x01(\x05\x12\x13\n\x0bunique_tags\x18\x06 \x03(\t\x12+\n\x11\x61gent_intent_type\x18\x07 \x01(\x0e\x32\x10.AgentIntentType\x12\x17\n\x0f\x61nalysis_result\x18\x08 \x01(\t\x12,\n\x10\x63\x61tegorical_tags\x18\t \x03(\x0b\x32\x12.CategoricalTagDef\"\xc2\x01\n\x12\x44\x61taSamplesRequest\x12\x13\n\x0bstart_index\x18\x01 \x01(\x05\x12\x13\n\x0brecords_cnt\x18\x02 \x01(\x05\x12 \n\x18include_transformed_data\x18\x03 \x01(\x08\x12\x18\n\x10include_raw_data\x18\x04 \x01(\x08\x12\x19\n\x11stats_to_retrieve\x18\x05 \x03(\t\x12\x14\n\x0cresize_width\x18\x06 \x01(\x05\x12\x15\n\rresize_height\x18\x07 \x01(\x05\"m\n\x08\x44\x61taStat\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\r\n\x05shape\x18\x03 \x03(\x05\x12\r\n\x05value\x18\x04 \x03(\x02\x12\x14\n\x0cvalue_string\x18\x05 \x01(\t\x12\x11\n\tthumbnail\x18\x06 \x01(\x0c\">\n\nDataRecord\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x1d\n\ndata_stats\x18\x02 \x03(\x0b\x32\t.DataStat\"Z\n\x13\x44\x61taSamplesResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12!\n\x0c\x64\x61ta_records\x18\x03 \x03(\x0b\x32\x0b.DataRecord\"C\n\x0fHistogramSubBar\x12\x0e\n\x06origin\x18\x01 \x01(\t\x12\x11\n\tdiscarded\x18\x02 \x01(\x08\x12\r\n\x05\x63ount\x18\x03 \x01(\x03\"h\n\x0cHistogramBin\x12\x0b\n\x03min\x18\x01 \x01(\x01\x12\x0b\n\x03max\x18\x02 \x01(\x01\x12\x0b\n\x03\x61vg\x18\x03 \x01(\x01\x12\r\n\x05\x63ount\x18\x04 \x01(\x03\x12\"\n\x08sub_bars\x18\x05 \x03(\x0b\x32\x10.HistogramSubBar\"[\n\x17\x43\x61tegoricalHistogramBar\x12\r\n\x05label\x18\x01 \x01(\t\x12\r\n\x05\x63ount\x18\x02 \x01(\x03\x12\"\n\x08sub_bars\x18\x03 \x03(\x0b\x32\x10.HistogramSubBar\"4\n\x10HistogramRequest\x12\x0e\n\x06\x63olumn\x18\x01 \x01(\t\x12\x10\n\x08max_bins\x18\x02 \x01(\x05\"\xb2\x01\n\x11HistogramResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\ntotal_rows\x18\x03 \x01(\x03\x12\x1b\n\x04\x62ins\x18\x04 \x03(\x0b\x32\r.HistogramBin\x12\x16\n\x0eis_categorical\x18\x05 \x01(\x08\x12\x32\n\x10\x63\x61tegorical_bars\x18\x06 \x03(\x0b\x32\x18.CategoricalHistogramBar\"W\n\x12GetMetaDataRequest\x12\x13\n\x0bstart_index\x18\x01 \x01(\x05\x12\x13\n\x0brecords_cnt\x18\x02 \x01(\x05\x12\x17\n\x0fmodal_sample_id\x18\x03 \x01(\t\"\x99\x01\n\x13GetMetaDataResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x1a\n\x12\x61ll_metadata_names\x18\x03 \x03(\t\x12!\n\x0cgrid_records\x18\x04 \x03(\x0b\x32\x0b.DataRecord\x12!\n\x0cmodal_record\x18\x05 \x01(\x0b\x32\x0b.DataRecord\"J\n\x11PointCloudRequest\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x12\n\nmax_points\x18\x03 \x01(\x05\"\xbf\x01\n\x0fPointCloudChunk\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\nnum_points\x18\x03 \x01(\x05\x12\x14\n\x0cnum_features\x18\x04 \x01(\x05\x12\x10\n\x08pc_range\x18\x05 \x03(\x02\x12\x0c\n\x04\x64\x61ta\x18\x06 \x01(\x0c\x12\x13\n\x0b\x63hunk_index\x18\x07 \x01(\x05\x12\x14\n\x0ctotal_chunks\x18\x08 \x01(\x05\x12\x15\n\rfeature_names\x18\t \x03(\t\"\xdc\x01\n\x10\x44\x61taEditsRequest\x12\x11\n\tstat_name\x18\x01 \x01(\t\x12\x13\n\x0b\x66loat_value\x18\x02 \x01(\x02\x12\x14\n\x0cstring_value\x18\x03 \x01(\t\x12\x12\n\nbool_value\x18\x04 \x01(\x08\x12\x1d\n\x04type\x18\x05 \x01(\x0e\x32\x0f.SampleEditType\x12\x13\n\x0bsamples_ids\x18\x06 \x03(\t\x12\x16\n\x0esample_origins\x18\x07 \x03(\t\x12\x16\n\x0eis_categorical\x18\x08 \x01(\x08\x12\x12\n\ncategories\x18\t \x03(\t\"5\n\x11\x44\x61taEditsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\":\n\x12\x44\x61taSplitsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x13\n\x0bsplit_names\x18\x02 \x03(\t\"9\n\x13\x41gentHealthResponse\x12\x11\n\tavailable\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"^\n\x16InitializeAgentRequest\x12\x0f\n\x07\x61pi_key\x18\x01 \x01(\t\x12$\n\x08provider\x18\x02 \x01(\x0e\x32\x12.AgentProviderType\x12\r\n\x05model\x18\x03 \x01(\t\";\n\x17InitializeAgentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"(\n\x17\x43hangeAgentModelRequest\x12\r\n\x05model\x18\x01 \x01(\t\"<\n\x18\x43hangeAgentModelResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x17\n\x15GetAgentModelsRequest\"J\n\x16GetAgentModelsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0e\n\x06models\x18\x02 \x03(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\"6\n\x12ResetAgentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"3\n\x18RestoreCheckpointRequest\x12\x17\n\x0f\x65xperiment_hash\x18\x01 \x01(\t\"=\n\x19RestoreCheckpointResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"R\n\x18TriggerEvaluationRequest\x12\x12\n\nsplit_name\x18\x01 \x01(\t\x12\x0c\n\x04tags\x18\x02 \x03(\t\x12\x14\n\x0cuse_full_set\x18\x03 \x01(\x08\"=\n\x19TriggerEvaluationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x1c\n\x1aGetEvaluationStatusRequest\"\x81\x01\n\x1bGetEvaluationStatusResponse\x12\x0e\n\x06status\x18\x01 \x01(\t\x12\x0f\n\x07\x63urrent\x18\x02 \x01(\x05\x12\r\n\x05total\x18\x03 \x01(\x05\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x12\x12\n\nsplit_name\x18\x06 \x01(\t\")\n\x17\x43\x61ncelEvaluationRequest\x12\x0e\n\x06reason\x18\x01 \x01(\t\"<\n\x18\x43\x61ncelEvaluationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t*d\n\x13WeightOperationType\x12\n\n\x06ZEROFY\x10\x00\x12\x10\n\x0cREINITIALIZE\x10\x01\x12\n\n\x06\x46REEZE\x10\x02\x12\x12\n\x0eREMOVE_NEURONS\x10\t\x12\x0f\n\x0b\x41\x44\x44_NEURONS\x10\n*o\n\x0fZerofyPredicate\x12\x19\n\x15ZEROFY_PREDICATE_NONE\x10\x00\x12 \n\x1cZEROFY_PREDICATE_WITH_FROZEN\x10\x01\x12\x1f\n\x1bZEROFY_PREDICATE_WITH_OLDER\x10\x02*M\n\x0f\x41gentIntentType\x12\x12\n\x0eINTENT_UNKNOWN\x10\x00\x12\x11\n\rINTENT_FILTER\x10\x01\x12\x13\n\x0fINTENT_ANALYSIS\x10\x02*I\n\x0eSampleEditType\x12\x11\n\rEDIT_OVERRIDE\x10\x00\x12\x13\n\x0f\x45\x44IT_ACCUMULATE\x10\x01\x12\x0f\n\x0b\x45\x44IT_REMOVE\x10\x02*,\n\x11\x41gentProviderType\x12\x17\n\x13PROVIDER_OPENROUTER\x10\x00\x32\xf5\n\n\x11\x45xperimentService\x12P\n\x13GetLatestLoggerData\x12\x1b.GetLatestLoggerDataRequest\x1a\x1c.GetLatestLoggerDataResponse\x12\x36\n\x11\x45xperimentCommand\x12\x0f.TrainerCommand\x1a\x10.CommandResponse\x12H\n\x11ManipulateWeights\x12\x18.WeightsOperationRequest\x1a\x19.WeightsOperationResponse\x12/\n\nGetWeights\x12\x0f.WeightsRequest\x1a\x10.WeightsResponse\x12\x39\n\x0eGetActivations\x12\x12.ActivationRequest\x1a\x13.ActivationResponse\x12\x37\n\nGetSamples\x12\x13.BatchSampleRequest\x1a\x14.BatchSampleResponse\x12\x37\n\x0e\x41pplyDataQuery\x12\x11.DataQueryRequest\x1a\x12.DataQueryResponse\x12;\n\x0eGetDataSamples\x12\x13.DataSamplesRequest\x1a\x14.DataSamplesResponse\x12\x35\n\x0cGetHistogram\x12\x11.HistogramRequest\x1a\x12.HistogramResponse\x12\x38\n\x0bGetMetaData\x12\x13.GetMetaDataRequest\x1a\x14.GetMetaDataResponse\x12\x37\n\rGetPointCloud\x12\x12.PointCloudRequest\x1a\x10.PointCloudChunk0\x01\x12\x37\n\x0e\x45\x64itDataSample\x12\x11.DataEditsRequest\x1a\x12.DataEditsResponse\x12,\n\rGetDataSplits\x12\x06.Empty\x1a\x13.DataSplitsResponse\x12\x30\n\x10\x43heckAgentHealth\x12\x06.Empty\x1a\x14.AgentHealthResponse\x12\x44\n\x0fInitializeAgent\x12\x17.InitializeAgentRequest\x1a\x18.InitializeAgentResponse\x12G\n\x10\x43hangeAgentModel\x12\x18.ChangeAgentModelRequest\x1a\x19.ChangeAgentModelResponse\x12\x41\n\x0eGetAgentModels\x12\x16.GetAgentModelsRequest\x1a\x17.GetAgentModelsResponse\x12)\n\nResetAgent\x12\x06.Empty\x1a\x13.ResetAgentResponse\x12J\n\x11RestoreCheckpoint\x12\x19.RestoreCheckpointRequest\x1a\x1a.RestoreCheckpointResponse\x12J\n\x11TriggerEvaluation\x12\x19.TriggerEvaluationRequest\x1a\x1a.TriggerEvaluationResponse\x12P\n\x13GetEvaluationStatus\x12\x1b.GetEvaluationStatusRequest\x1a\x1c.GetEvaluationStatusResponse\x12G\n\x10\x43\x61ncelEvaluation\x12\x18.CancelEvaluationRequest\x1a\x19.CancelEvaluationResponseb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -37,160 +37,162 @@ _globals['_NEURONSTATISTICS_INCOMINGLRENTRY']._serialized_options = b'8\001' _globals['_RECORDMETADATA_SAMPLELASTLOSSENTRY']._loaded_options = None _globals['_RECORDMETADATA_SAMPLELASTLOSSENTRY']._serialized_options = b'8\001' - _globals['_WEIGHTOPERATIONTYPE']._serialized_start=9734 - _globals['_WEIGHTOPERATIONTYPE']._serialized_end=9834 - _globals['_ZEROFYPREDICATE']._serialized_start=9836 - _globals['_ZEROFYPREDICATE']._serialized_end=9947 - _globals['_AGENTINTENTTYPE']._serialized_start=9949 - _globals['_AGENTINTENTTYPE']._serialized_end=10026 - _globals['_SAMPLEEDITTYPE']._serialized_start=10028 - _globals['_SAMPLEEDITTYPE']._serialized_end=10101 - _globals['_AGENTPROVIDERTYPE']._serialized_start=10103 - _globals['_AGENTPROVIDERTYPE']._serialized_end=10147 + _globals['_WEIGHTOPERATIONTYPE']._serialized_start=9871 + _globals['_WEIGHTOPERATIONTYPE']._serialized_end=9971 + _globals['_ZEROFYPREDICATE']._serialized_start=9973 + _globals['_ZEROFYPREDICATE']._serialized_end=10084 + _globals['_AGENTINTENTTYPE']._serialized_start=10086 + _globals['_AGENTINTENTTYPE']._serialized_end=10163 + _globals['_SAMPLEEDITTYPE']._serialized_start=10165 + _globals['_SAMPLEEDITTYPE']._serialized_end=10238 + _globals['_AGENTPROVIDERTYPE']._serialized_start=10240 + _globals['_AGENTPROVIDERTYPE']._serialized_end=10284 _globals['_GETLATESTLOGGERDATAREQUEST']._serialized_start=46 _globals['_GETLATESTLOGGERDATAREQUEST']._serialized_end=183 _globals['_LOGGERDATAPOINT']._serialized_start=186 _globals['_LOGGERDATAPOINT']._serialized_end=443 _globals['_GETLATESTLOGGERDATARESPONSE']._serialized_start=445 - _globals['_GETLATESTLOGGERDATARESPONSE']._serialized_end=508 - _globals['_EMPTY']._serialized_start=510 - _globals['_EMPTY']._serialized_end=517 - _globals['_NEURONID']._serialized_start=519 - _globals['_NEURONID']._serialized_end=566 - _globals['_WEIGHTOPERATION']._serialized_start=569 - _globals['_WEIGHTOPERATION']._serialized_end=842 - _globals['_WEIGHTSOPERATIONREQUEST']._serialized_start=844 - _globals['_WEIGHTSOPERATIONREQUEST']._serialized_end=939 - _globals['_WEIGHTSOPERATIONRESPONSE']._serialized_start=941 - _globals['_WEIGHTSOPERATIONRESPONSE']._serialized_end=1001 - _globals['_HYPERPARAMETERS']._serialized_start=1004 - _globals['_HYPERPARAMETERS']._serialized_end=1709 - _globals['_METRICSSTATUS']._serialized_start=1711 - _globals['_METRICSSTATUS']._serialized_end=1755 - _globals['_ANNOTATSTATUS']._serialized_start=1757 - _globals['_ANNOTATSTATUS']._serialized_end=1883 - _globals['_ANNOTATSTATUS_METADATAENTRY']._serialized_start=1836 - _globals['_ANNOTATSTATUS_METADATAENTRY']._serialized_end=1883 - _globals['_TRAININGSTATUSEX']._serialized_start=1886 - _globals['_TRAININGSTATUSEX']._serialized_end=2158 - _globals['_HYPERPARAMETERCOMMAND']._serialized_start=2160 - _globals['_HYPERPARAMETERCOMMAND']._serialized_end=2253 - _globals['_DENYSAMPLESOPERATION']._serialized_start=2255 - _globals['_DENYSAMPLESOPERATION']._serialized_end=2317 - _globals['_LOADCHECKPOINTOPERATION']._serialized_start=2319 - _globals['_LOADCHECKPOINTOPERATION']._serialized_end=2367 - _globals['_PLOTNOTEOPERATION']._serialized_start=2369 - _globals['_PLOTNOTEOPERATION']._serialized_end=2467 - _globals['_SAVECHECKPOINTOPERATION']._serialized_start=2469 - _globals['_SAVECHECKPOINTOPERATION']._serialized_end=2545 - _globals['_TRAINERCOMMAND']._serialized_start=2548 - _globals['_TRAINERCOMMAND']._serialized_end=3504 - _globals['_HYPERPARAMETERDESC']._serialized_start=3507 - _globals['_HYPERPARAMETERDESC']._serialized_end=3664 - _globals['_NEURONSTATISTICS']._serialized_start=3667 - _globals['_NEURONSTATISTICS']._serialized_end=4037 - _globals['_NEURONSTATISTICS_INCOMINGLRENTRY']._serialized_start=3896 - _globals['_NEURONSTATISTICS_INCOMINGLRENTRY']._serialized_end=3945 - _globals['_LAYERREPRESENTATION']._serialized_start=4040 - _globals['_LAYERREPRESENTATION']._serialized_end=4408 - _globals['_ACTIVATIONREQUEST']._serialized_start=4410 - _globals['_ACTIVATIONREQUEST']._serialized_end=4482 - _globals['_ACTIVATIONMAP']._serialized_start=4484 - _globals['_ACTIVATIONMAP']._serialized_end=4556 - _globals['_ACTIVATIONRESPONSE']._serialized_start=4558 - _globals['_ACTIVATIONRESPONSE']._serialized_end=4658 - _globals['_TASKFIELD']._serialized_start=4661 - _globals['_TASKFIELD']._serialized_end=4808 - _globals['_RECORDMETADATA']._serialized_start=4811 - _globals['_RECORDMETADATA']._serialized_end=5143 - _globals['_RECORDMETADATA_SAMPLELASTLOSSENTRY']._serialized_start=5090 - _globals['_RECORDMETADATA_SAMPLELASTLOSSENTRY']._serialized_end=5143 - _globals['_SAMPLESTATISTICS']._serialized_start=5146 - _globals['_SAMPLESTATISTICS']._serialized_end=5293 - _globals['_COMMANDRESPONSE']._serialized_start=5296 - _globals['_COMMANDRESPONSE']._serialized_end=5526 - _globals['_SAMPLEREQUEST']._serialized_start=5528 - _globals['_SAMPLEREQUEST']._serialized_end=5613 - _globals['_SAMPLEREQUESTRESPONSE']._serialized_start=5616 - _globals['_SAMPLEREQUESTRESPONSE']._serialized_end=5917 - _globals['_BATCHSAMPLEREQUEST']._serialized_start=5920 - _globals['_BATCHSAMPLEREQUEST']._serialized_end=6066 - _globals['_BATCHSAMPLERESPONSE']._serialized_start=6068 - _globals['_BATCHSAMPLERESPONSE']._serialized_end=6130 - _globals['_WEIGHTSREQUEST']._serialized_start=6132 - _globals['_WEIGHTSREQUEST']._serialized_end=6178 - _globals['_WEIGHTSRESPONSE']._serialized_start=6181 - _globals['_WEIGHTSRESPONSE']._serialized_end=6466 - _globals['_DATAQUERYREQUEST']._serialized_start=6468 - _globals['_DATAQUERYREQUEST']._serialized_end=6550 - _globals['_CATEGORICALTAGDEF']._serialized_start=6552 - _globals['_CATEGORICALTAGDEF']._serialized_end=6605 - _globals['_DATAQUERYRESPONSE']._serialized_start=6608 - _globals['_DATAQUERYRESPONSE']._serialized_end=6905 - _globals['_DATASAMPLESREQUEST']._serialized_start=6908 - _globals['_DATASAMPLESREQUEST']._serialized_end=7102 - _globals['_DATASTAT']._serialized_start=7104 - _globals['_DATASTAT']._serialized_end=7213 - _globals['_DATARECORD']._serialized_start=7215 - _globals['_DATARECORD']._serialized_end=7277 - _globals['_DATASAMPLESRESPONSE']._serialized_start=7279 - _globals['_DATASAMPLESRESPONSE']._serialized_end=7369 - _globals['_HISTOGRAMSUBBAR']._serialized_start=7371 - _globals['_HISTOGRAMSUBBAR']._serialized_end=7438 - _globals['_HISTOGRAMBIN']._serialized_start=7440 - _globals['_HISTOGRAMBIN']._serialized_end=7544 - _globals['_CATEGORICALHISTOGRAMBAR']._serialized_start=7546 - _globals['_CATEGORICALHISTOGRAMBAR']._serialized_end=7637 - _globals['_HISTOGRAMREQUEST']._serialized_start=7639 - _globals['_HISTOGRAMREQUEST']._serialized_end=7691 - _globals['_HISTOGRAMRESPONSE']._serialized_start=7694 - _globals['_HISTOGRAMRESPONSE']._serialized_end=7872 - _globals['_GETMETADATAREQUEST']._serialized_start=7874 - _globals['_GETMETADATAREQUEST']._serialized_end=7961 - _globals['_GETMETADATARESPONSE']._serialized_start=7964 - _globals['_GETMETADATARESPONSE']._serialized_end=8117 - _globals['_POINTCLOUDREQUEST']._serialized_start=8119 - _globals['_POINTCLOUDREQUEST']._serialized_end=8193 - _globals['_POINTCLOUDCHUNK']._serialized_start=8196 - _globals['_POINTCLOUDCHUNK']._serialized_end=8387 - _globals['_DATAEDITSREQUEST']._serialized_start=8390 - _globals['_DATAEDITSREQUEST']._serialized_end=8610 - _globals['_DATAEDITSRESPONSE']._serialized_start=8612 - _globals['_DATAEDITSRESPONSE']._serialized_end=8665 - _globals['_DATASPLITSRESPONSE']._serialized_start=8667 - _globals['_DATASPLITSRESPONSE']._serialized_end=8725 - _globals['_AGENTHEALTHRESPONSE']._serialized_start=8727 - _globals['_AGENTHEALTHRESPONSE']._serialized_end=8784 - _globals['_INITIALIZEAGENTREQUEST']._serialized_start=8786 - _globals['_INITIALIZEAGENTREQUEST']._serialized_end=8880 - _globals['_INITIALIZEAGENTRESPONSE']._serialized_start=8882 - _globals['_INITIALIZEAGENTRESPONSE']._serialized_end=8941 - _globals['_CHANGEAGENTMODELREQUEST']._serialized_start=8943 - _globals['_CHANGEAGENTMODELREQUEST']._serialized_end=8983 - _globals['_CHANGEAGENTMODELRESPONSE']._serialized_start=8985 - _globals['_CHANGEAGENTMODELRESPONSE']._serialized_end=9045 - _globals['_GETAGENTMODELSREQUEST']._serialized_start=9047 - _globals['_GETAGENTMODELSREQUEST']._serialized_end=9070 - _globals['_GETAGENTMODELSRESPONSE']._serialized_start=9072 - _globals['_GETAGENTMODELSRESPONSE']._serialized_end=9146 - _globals['_RESETAGENTRESPONSE']._serialized_start=9148 - _globals['_RESETAGENTRESPONSE']._serialized_end=9202 - _globals['_RESTORECHECKPOINTREQUEST']._serialized_start=9204 - _globals['_RESTORECHECKPOINTREQUEST']._serialized_end=9255 - _globals['_RESTORECHECKPOINTRESPONSE']._serialized_start=9257 - _globals['_RESTORECHECKPOINTRESPONSE']._serialized_end=9318 - _globals['_TRIGGEREVALUATIONREQUEST']._serialized_start=9320 - _globals['_TRIGGEREVALUATIONREQUEST']._serialized_end=9402 - _globals['_TRIGGEREVALUATIONRESPONSE']._serialized_start=9404 - _globals['_TRIGGEREVALUATIONRESPONSE']._serialized_end=9465 - _globals['_GETEVALUATIONSTATUSREQUEST']._serialized_start=9467 - _globals['_GETEVALUATIONSTATUSREQUEST']._serialized_end=9495 - _globals['_GETEVALUATIONSTATUSRESPONSE']._serialized_start=9498 - _globals['_GETEVALUATIONSTATUSRESPONSE']._serialized_end=9627 - _globals['_CANCELEVALUATIONREQUEST']._serialized_start=9629 - _globals['_CANCELEVALUATIONREQUEST']._serialized_end=9670 - _globals['_CANCELEVALUATIONRESPONSE']._serialized_start=9672 - _globals['_CANCELEVALUATIONRESPONSE']._serialized_end=9732 - _globals['_EXPERIMENTSERVICE']._serialized_start=10150 - _globals['_EXPERIMENTSERVICE']._serialized_end=11547 + _globals['_GETLATESTLOGGERDATARESPONSE']._serialized_end=536 + _globals['_EMPTY']._serialized_start=538 + _globals['_EMPTY']._serialized_end=545 + _globals['_NEURONID']._serialized_start=547 + _globals['_NEURONID']._serialized_end=594 + _globals['_WEIGHTOPERATION']._serialized_start=597 + _globals['_WEIGHTOPERATION']._serialized_end=870 + _globals['_WEIGHTSOPERATIONREQUEST']._serialized_start=872 + _globals['_WEIGHTSOPERATIONREQUEST']._serialized_end=967 + _globals['_WEIGHTSOPERATIONRESPONSE']._serialized_start=969 + _globals['_WEIGHTSOPERATIONRESPONSE']._serialized_end=1029 + _globals['_HYPERPARAMETERS']._serialized_start=1032 + _globals['_HYPERPARAMETERS']._serialized_end=1737 + _globals['_METRICSSTATUS']._serialized_start=1739 + _globals['_METRICSSTATUS']._serialized_end=1783 + _globals['_ANNOTATSTATUS']._serialized_start=1785 + _globals['_ANNOTATSTATUS']._serialized_end=1911 + _globals['_ANNOTATSTATUS_METADATAENTRY']._serialized_start=1864 + _globals['_ANNOTATSTATUS_METADATAENTRY']._serialized_end=1911 + _globals['_TRAININGSTATUSEX']._serialized_start=1914 + _globals['_TRAININGSTATUSEX']._serialized_end=2186 + _globals['_HYPERPARAMETERCOMMAND']._serialized_start=2188 + _globals['_HYPERPARAMETERCOMMAND']._serialized_end=2281 + _globals['_DENYSAMPLESOPERATION']._serialized_start=2283 + _globals['_DENYSAMPLESOPERATION']._serialized_end=2345 + _globals['_LOADCHECKPOINTOPERATION']._serialized_start=2347 + _globals['_LOADCHECKPOINTOPERATION']._serialized_end=2395 + _globals['_PLOTNOTEOPERATION']._serialized_start=2397 + _globals['_PLOTNOTEOPERATION']._serialized_end=2495 + _globals['_SAVECHECKPOINTOPERATION']._serialized_start=2497 + _globals['_SAVECHECKPOINTOPERATION']._serialized_end=2573 + _globals['_RESTARTINSTANCEOPERATION']._serialized_start=2575 + _globals['_RESTARTINSTANCEOPERATION']._serialized_end=2601 + _globals['_TRAINERCOMMAND']._serialized_start=2604 + _globals['_TRAINERCOMMAND']._serialized_end=3641 + _globals['_HYPERPARAMETERDESC']._serialized_start=3644 + _globals['_HYPERPARAMETERDESC']._serialized_end=3801 + _globals['_NEURONSTATISTICS']._serialized_start=3804 + _globals['_NEURONSTATISTICS']._serialized_end=4174 + _globals['_NEURONSTATISTICS_INCOMINGLRENTRY']._serialized_start=4033 + _globals['_NEURONSTATISTICS_INCOMINGLRENTRY']._serialized_end=4082 + _globals['_LAYERREPRESENTATION']._serialized_start=4177 + _globals['_LAYERREPRESENTATION']._serialized_end=4545 + _globals['_ACTIVATIONREQUEST']._serialized_start=4547 + _globals['_ACTIVATIONREQUEST']._serialized_end=4619 + _globals['_ACTIVATIONMAP']._serialized_start=4621 + _globals['_ACTIVATIONMAP']._serialized_end=4693 + _globals['_ACTIVATIONRESPONSE']._serialized_start=4695 + _globals['_ACTIVATIONRESPONSE']._serialized_end=4795 + _globals['_TASKFIELD']._serialized_start=4798 + _globals['_TASKFIELD']._serialized_end=4945 + _globals['_RECORDMETADATA']._serialized_start=4948 + _globals['_RECORDMETADATA']._serialized_end=5280 + _globals['_RECORDMETADATA_SAMPLELASTLOSSENTRY']._serialized_start=5227 + _globals['_RECORDMETADATA_SAMPLELASTLOSSENTRY']._serialized_end=5280 + _globals['_SAMPLESTATISTICS']._serialized_start=5283 + _globals['_SAMPLESTATISTICS']._serialized_end=5430 + _globals['_COMMANDRESPONSE']._serialized_start=5433 + _globals['_COMMANDRESPONSE']._serialized_end=5663 + _globals['_SAMPLEREQUEST']._serialized_start=5665 + _globals['_SAMPLEREQUEST']._serialized_end=5750 + _globals['_SAMPLEREQUESTRESPONSE']._serialized_start=5753 + _globals['_SAMPLEREQUESTRESPONSE']._serialized_end=6054 + _globals['_BATCHSAMPLEREQUEST']._serialized_start=6057 + _globals['_BATCHSAMPLEREQUEST']._serialized_end=6203 + _globals['_BATCHSAMPLERESPONSE']._serialized_start=6205 + _globals['_BATCHSAMPLERESPONSE']._serialized_end=6267 + _globals['_WEIGHTSREQUEST']._serialized_start=6269 + _globals['_WEIGHTSREQUEST']._serialized_end=6315 + _globals['_WEIGHTSRESPONSE']._serialized_start=6318 + _globals['_WEIGHTSRESPONSE']._serialized_end=6603 + _globals['_DATAQUERYREQUEST']._serialized_start=6605 + _globals['_DATAQUERYREQUEST']._serialized_end=6687 + _globals['_CATEGORICALTAGDEF']._serialized_start=6689 + _globals['_CATEGORICALTAGDEF']._serialized_end=6742 + _globals['_DATAQUERYRESPONSE']._serialized_start=6745 + _globals['_DATAQUERYRESPONSE']._serialized_end=7042 + _globals['_DATASAMPLESREQUEST']._serialized_start=7045 + _globals['_DATASAMPLESREQUEST']._serialized_end=7239 + _globals['_DATASTAT']._serialized_start=7241 + _globals['_DATASTAT']._serialized_end=7350 + _globals['_DATARECORD']._serialized_start=7352 + _globals['_DATARECORD']._serialized_end=7414 + _globals['_DATASAMPLESRESPONSE']._serialized_start=7416 + _globals['_DATASAMPLESRESPONSE']._serialized_end=7506 + _globals['_HISTOGRAMSUBBAR']._serialized_start=7508 + _globals['_HISTOGRAMSUBBAR']._serialized_end=7575 + _globals['_HISTOGRAMBIN']._serialized_start=7577 + _globals['_HISTOGRAMBIN']._serialized_end=7681 + _globals['_CATEGORICALHISTOGRAMBAR']._serialized_start=7683 + _globals['_CATEGORICALHISTOGRAMBAR']._serialized_end=7774 + _globals['_HISTOGRAMREQUEST']._serialized_start=7776 + _globals['_HISTOGRAMREQUEST']._serialized_end=7828 + _globals['_HISTOGRAMRESPONSE']._serialized_start=7831 + _globals['_HISTOGRAMRESPONSE']._serialized_end=8009 + _globals['_GETMETADATAREQUEST']._serialized_start=8011 + _globals['_GETMETADATAREQUEST']._serialized_end=8098 + _globals['_GETMETADATARESPONSE']._serialized_start=8101 + _globals['_GETMETADATARESPONSE']._serialized_end=8254 + _globals['_POINTCLOUDREQUEST']._serialized_start=8256 + _globals['_POINTCLOUDREQUEST']._serialized_end=8330 + _globals['_POINTCLOUDCHUNK']._serialized_start=8333 + _globals['_POINTCLOUDCHUNK']._serialized_end=8524 + _globals['_DATAEDITSREQUEST']._serialized_start=8527 + _globals['_DATAEDITSREQUEST']._serialized_end=8747 + _globals['_DATAEDITSRESPONSE']._serialized_start=8749 + _globals['_DATAEDITSRESPONSE']._serialized_end=8802 + _globals['_DATASPLITSRESPONSE']._serialized_start=8804 + _globals['_DATASPLITSRESPONSE']._serialized_end=8862 + _globals['_AGENTHEALTHRESPONSE']._serialized_start=8864 + _globals['_AGENTHEALTHRESPONSE']._serialized_end=8921 + _globals['_INITIALIZEAGENTREQUEST']._serialized_start=8923 + _globals['_INITIALIZEAGENTREQUEST']._serialized_end=9017 + _globals['_INITIALIZEAGENTRESPONSE']._serialized_start=9019 + _globals['_INITIALIZEAGENTRESPONSE']._serialized_end=9078 + _globals['_CHANGEAGENTMODELREQUEST']._serialized_start=9080 + _globals['_CHANGEAGENTMODELREQUEST']._serialized_end=9120 + _globals['_CHANGEAGENTMODELRESPONSE']._serialized_start=9122 + _globals['_CHANGEAGENTMODELRESPONSE']._serialized_end=9182 + _globals['_GETAGENTMODELSREQUEST']._serialized_start=9184 + _globals['_GETAGENTMODELSREQUEST']._serialized_end=9207 + _globals['_GETAGENTMODELSRESPONSE']._serialized_start=9209 + _globals['_GETAGENTMODELSRESPONSE']._serialized_end=9283 + _globals['_RESETAGENTRESPONSE']._serialized_start=9285 + _globals['_RESETAGENTRESPONSE']._serialized_end=9339 + _globals['_RESTORECHECKPOINTREQUEST']._serialized_start=9341 + _globals['_RESTORECHECKPOINTREQUEST']._serialized_end=9392 + _globals['_RESTORECHECKPOINTRESPONSE']._serialized_start=9394 + _globals['_RESTORECHECKPOINTRESPONSE']._serialized_end=9455 + _globals['_TRIGGEREVALUATIONREQUEST']._serialized_start=9457 + _globals['_TRIGGEREVALUATIONREQUEST']._serialized_end=9539 + _globals['_TRIGGEREVALUATIONRESPONSE']._serialized_start=9541 + _globals['_TRIGGEREVALUATIONRESPONSE']._serialized_end=9602 + _globals['_GETEVALUATIONSTATUSREQUEST']._serialized_start=9604 + _globals['_GETEVALUATIONSTATUSREQUEST']._serialized_end=9632 + _globals['_GETEVALUATIONSTATUSRESPONSE']._serialized_start=9635 + _globals['_GETEVALUATIONSTATUSRESPONSE']._serialized_end=9764 + _globals['_CANCELEVALUATIONREQUEST']._serialized_start=9766 + _globals['_CANCELEVALUATIONREQUEST']._serialized_end=9807 + _globals['_CANCELEVALUATIONRESPONSE']._serialized_start=9809 + _globals['_CANCELEVALUATIONRESPONSE']._serialized_end=9869 + _globals['_EXPERIMENTSERVICE']._serialized_start=10287 + _globals['_EXPERIMENTSERVICE']._serialized_end=11684 # @@protoc_insertion_point(module_scope) diff --git a/weightslab/src.py b/weightslab/src.py index aa5a39b0..952ebd8f 100644 --- a/weightslab/src.py +++ b/weightslab/src.py @@ -14,6 +14,7 @@ import tempfile import functools import threading +import queue as _queue import traceback import numpy as np import torch as th @@ -71,6 +72,231 @@ def _rebind_caller_local(original_obj: Any, new_obj: Any) -> None: DATAFRAME_M = None # Global registry for custom signals _REGISTERED_SIGNALS = {} +_REACTIVE_FIRED = {} # signal_name -> step it last fired at (per-step dedup) + + +def _gather_inputs_fresh(lg, inputs, ids, step, fresh_cache=None): + """``{name: (B,) array}`` if every input has a value at *step* for every id, + else ``None`` (value AT step, not latest — a lagging worker still gathers it). + Reads via ``query_per_sample_at_step`` (O(batch), cached). + + *fresh_cache*: in-memory values fired this pass — read them so the pass + persists once at the end, not per signal.""" + cols = {} + for inp in inputs: + if fresh_cache is not None and inp in fresh_cache: + cols[inp] = fresh_cache[inp] + continue + # A reactive-derived input lives only in fresh_cache this pass (it's not + # in the ledger at this step until the end-of-pass persist). If it's not + # there yet, skip — don't query the ledger (a guaranteed-miss flush). + _m = getattr(_REGISTERED_SIGNALS.get(inp), '_wl_signal_meta', {}) + if _m.get('inputs'): + return None + at = {int(sid): val for sid, val in lg.query_per_sample_at_step(inp, ids, step)} + for sid in ids: + if sid not in at: + return None + cols[inp] = np.array([at[sid] for sid in ids], dtype=float) + return cols + + +def _react_dependents(seed_names, batch_ids, step, origin='train'): + """Reactive signal firing. When the signals in *seed_names* were just logged, + fire any ``@wl.signal(inputs=[...])`` whose inputs are now ALL present at + *step* — order-independently, regardless of which input arrived last. Each + fired signal is itself a seed (chaining) and fires at most once per step. + Inputs are read from the logger, so ingested signals must be logged.""" + _warn_on_signal_cycles() # lazy, once/process — fires even with no wl.serve + if batch_ids is None: + return + try: + ids = [int(u) for u in (batch_ids.detach().cpu().numpy() + if hasattr(batch_ids, 'detach') else batch_ids)] + except Exception: + return + lg = get_logger() + if lg is None: + return + try: + df_proxy = get_dataframe() + except Exception: + df_proxy = None + + def dependents_of(nm): + for name, func in list(_REGISTERED_SIGNALS.items()): + meta = getattr(func, '_wl_signal_meta', {}) + inputs = meta.get('inputs') + if inputs and nm in inputs: + yield name, func, meta, inputs + + # Fired this pass; kept in-memory so chains read without a ledger round-trip + # and the whole pass persists in ONE save_signals (not one write per signal). + fired = {} # {name: (B,) array} + fired_log = {} # {name: log flag} + queue = list(seed_names) + while queue: + logged = queue.pop() + for name, func, meta, inputs in dependents_of(logged): + if _REACTIVE_FIRED.get(name) == step: + continue + every = meta.get('compute_every_n_steps', 1) or 1 + if step % every != 0: + continue + cols = _gather_inputs_fresh(lg, inputs, ids, step, fresh_cache=fired) + if cols is None: + continue # inputs not all fresh yet — a later log will re-trigger + _REACTIVE_FIRED[name] = step + try: + if meta.get('batched'): + bctx = BatchSignalContext(sample_ids=ids, subscribed_values=cols[inputs[0]], + logger=lg, dataframe=df_proxy, origin=origin, step=step, + inputs=cols) + res = func(bctx) + vals = res.tolist() if hasattr(res, 'tolist') else list(res) + else: + vals = [] + for i, sid in enumerate(ids): + ctx = SignalContext(sample_id=sid, subscribed_value=float(cols[inputs[0]][i]), + logger=lg, dataframe=df_proxy, origin=origin, step=step, + inputs={k: float(cols[k][i]) for k in cols}) + vals.append(func(ctx)) + vals = [float(x) for x in vals] + except StaleSignalError: + raise + except Exception as e: + logger.debug(f"reactive signal '{name}' failed: {e}") + continue + fired[name] = np.asarray(vals, dtype=float) + fired_log[name] = bool(meta.get('log', True)) + queue.append(name) + + # One persist for all signals fired this pass. step=step attributes values to + # the firing step (matters when the worker lags). _react=False: no re-dispatch. + if fired: + try: + _logged = {n: v.tolist() for n, v in fired.items() if fired_log.get(n, True)} + _unlogged = {n: v.tolist() for n, v in fired.items() if not fired_log.get(n, True)} + if _logged: + save_signals(signals=_logged, batch_ids=ids, step=step, log=True, _react=False) + if _unlogged: + save_signals(signals=_unlogged, batch_ids=ids, step=step, log=False, _react=False) + except Exception as e: + logger.debug(f"reactive batched persist failed: {e}") + + +def _detect_signal_cycles(): + """Cycles in the reactive ``inputs`` graph, each as a name list ending where + it began. Only edges to other registered signals count (base metrics are + leaves). A signal in a cycle never fires — surfacing it beats silent no-fire.""" + graph = {} + for name, func in _REGISTERED_SIGNALS.items(): + meta = getattr(func, '_wl_signal_meta', {}) + inputs = meta.get('inputs') or [] + graph[name] = [dep for dep in inputs if dep in _REGISTERED_SIGNALS] # depends-on edges + WHITE, GREY, BLACK = 0, 1, 2 + color = {n: WHITE for n in graph} + stack, cycles, seen = [], [], set() + + def dfs(n): + color[n] = GREY + stack.append(n) + for dep in graph.get(n, ()): + if color.get(dep) == GREY: # back-edge → cycle + cyc = stack[stack.index(dep):] + [dep] + key = frozenset(cyc) + if key not in seen: + seen.add(key) + cycles.append(cyc) + elif color.get(dep) == WHITE: + dfs(dep) + stack.pop() + color[n] = BLACK + + for n in graph: + if color[n] == WHITE: + dfs(n) + return cycles + + +_SIGNAL_CYCLE_CHECKED = False + + +def _warn_on_signal_cycles(force=False): + """Warn once per process for each reactive cycle. Fired from start_training + and the first dispatch (so it works headless). Warns, doesn't raise.""" + global _SIGNAL_CYCLE_CHECKED + if _SIGNAL_CYCLE_CHECKED and not force: + return + _SIGNAL_CYCLE_CHECKED = True + for cyc in _detect_signal_cycles(): + logger.warning( + "WeightsLab reactive signals: circular dependency %s — these signals " + "will NEVER fire (each waits on the other's value, which never becomes " + "fresh). Break the cycle in their @wl.signal(inputs=[...]).", + " -> ".join(cyc)) + + +# --- optional signal-worker thread: run reactive dispatch off the train thread - +_SIGNAL_WORKER = {"q": None, "thread": None} + + +def _signal_worker_enabled(): + try: + return bool(get_hyperparams().get("ledger_signal_worker", False)) + except Exception: + return False + + +def _ensure_signal_worker(): + if _SIGNAL_WORKER["thread"] is not None and _SIGNAL_WORKER["thread"].is_alive(): + return _SIGNAL_WORKER["q"] + q = _queue.Queue() + + def _worker(): + while True: + job = q.get() + if job is None: + q.task_done() + break + seed_names, ids, step, origin = job + try: + _react_dependents(seed_names, ids, step, origin) + except Exception as e: + logger.debug(f"signal worker job failed: {e}") + finally: + q.task_done() + + t = threading.Thread(target=_worker, name="WL-Signal-Worker", daemon=True) + t.start() + _SIGNAL_WORKER["q"] = q + _SIGNAL_WORKER["thread"] = t + return q + + +def _dispatch_or_enqueue(seed_names, batch_ids, step, origin): + """Reactive dispatch: inline on the train thread, or handed to the signal + worker thread when ``ledger_signal_worker`` is set. The seed signals are + already logged synchronously, so the worker only defers the derived compute + + persistence — the inputs it reads are already in the ledger.""" + if not _signal_worker_enabled(): + _react_dependents(seed_names, batch_ids, step, origin) + return + try: + ids = [int(u) for u in (batch_ids.detach().cpu().numpy() + if hasattr(batch_ids, 'detach') else batch_ids)] + except Exception: + return + _ensure_signal_worker().put((list(seed_names), ids, step, origin)) + + +def drain_signals(): + """Block until the signal-worker thread has processed all queued reactive + jobs. Called automatically by :func:`write_dataframe`; call it manually + before reading signals mid-run when ``ledger_signal_worker`` is enabled.""" + q = _SIGNAL_WORKER["q"] + if q is not None: + q.join() # Evaluation function registered via the @wl.eval_fn decorator. _REGISTERED_EVAL_FN: Optional[Any] = None @@ -78,18 +304,36 @@ def _rebind_caller_local(original_obj: Any, new_obj: Any) -> None: _EVAL_WORKER_THREAD: Optional[threading.Thread] = None +class StaleSignalError(RuntimeError): + """Raised when a signal ingests another signal that is not *fresh* — i.e. the + ingested signal has no value at the current step (it was not logged, or was + written after the trigger this step). See ``SignalContext.latest`` / + ``BatchSignalContext.latest`` (``require_fresh=True``) and + ``@wl.signal(ingests=[...])``.""" + pass + + class SignalContext: """ Unified context object for WeightsLab signals. Carries all available metadata for a single sample during computation. """ - def __init__(self, sample_id, dataframe, data=None, subscribed_value=None, logger=None, origin=None): + def __init__(self, sample_id, dataframe, data=None, subscribed_value=None, logger=None, origin=None, + step=None, logits=None, preds=None, targets=None, inputs=None): self.sample_id = sample_id self.dataframe = dataframe self.data = data self.subscribed_value = subscribed_value self.logger = logger self.origin = origin + self.step = step # step the trigger fired at (for freshness checks) + self.logits = logits # this sample's raw model output row (subscribe_to path) + self.preds = preds + self.targets = targets + # Current-step value of each declared @wl.signal(inputs=[...]) input for + # THIS sample, keyed by signal name: ``ctx.inputs["sig/entropy"]``. Empty + # for signals that don't declare inputs (e.g. the subscribe_to path). + self.inputs = inputs if inputs is not None else {} @property def image(self) -> Optional[np.ndarray]: @@ -163,6 +407,91 @@ def is_dynamic(self) -> bool: """True if running during training (triggered by a metric).""" return self.subscribed_value is not None + def latest(self, signal_name, default=float("nan"), require_fresh=False): + """Most recent value of ANOTHER signal for THIS sample. Lets a signal + ingest several other signals: read each with ``ctx.latest(name)`` and + combine. Returns *default* if the signal has no value yet. + + With ``require_fresh=True`` this raises if the ingested signal has no + value at the current step (``self.step``) — i.e. it was not logged, or + was written after (not before) the trigger this step. + """ + if self.logger is None: + return default + rows = self.logger.query_per_sample(signal_name, sample_ids=[self.sample_id]) + if require_fresh and (not rows or self.step is None or rows[-1][1] != self.step): + raise StaleSignalError( + f"ingested signal '{signal_name}' is not fresh at step {self.step} for " + f"sample {self.sample_id} (log it with log=True and write it BEFORE the " + f"subscribing signal fires).") + return rows[-1][2] if rows else default # rows ordered by seq -> last is newest + + +class BatchSignalContext: + """Batched context for a vectorized ``@wl.signal(batched=True)``. + + Instead of one :class:`SignalContext` per sample, a batched signal receives + the whole batch at once: ``sample_ids`` and ``subscribed_values`` are arrays + of length B, so the signal computes over all samples with vector ops and + returns one array of length B. It also exposes **batched** ledger reads + (:meth:`history` runs a single query for the whole batch instead of one query + per sample), which is where the large speed-ups come from. + """ + def __init__(self, sample_ids, subscribed_values, logger=None, dataframe=None, origin=None, + step=None, logits=None, preds=None, targets=None, inputs=None): + self.sample_ids = [int(s) for s in sample_ids] # length B + self.subscribed_values = np.asarray(subscribed_values, dtype=float) # (B,) + self.logger = logger + self.dataframe = dataframe + self.origin = origin + self.step = step # step the trigger fired at (for freshness checks) + # Raw model output for this batch (subscribe_to path): logit-derived + # signals compute from these, no save_signals. None on the inputs=[...] path. + self.logits = logits # (B, C) + self.preds = preds + self.targets = targets + # Current-step values of each declared @wl.signal(inputs=[...]) input for + # the whole batch, keyed by signal name: ``ctx.inputs["sig/entropy"]`` is a + # ``(B,)`` array aligned to ``sample_ids``. Populated by the reactive + # dispatch; empty for signals that don't declare inputs. + self.inputs = inputs if inputs is not None else {} + + def history(self, signal_name): + """Per-sample history of *signal_name* for every sample in the batch, in + a SINGLE ledger query. Returns ``{sample_id: [values in step order]}`` + (empty list for samples with no history yet).""" + out = {s: [] for s in self.sample_ids} + if self.logger is None: + return out + # query_per_sample accepts a list of ids -> one scan for the whole batch. + for sid, step, val, _ in self.logger.query_per_sample(signal_name, sample_ids=self.sample_ids): + out.setdefault(int(sid), []).append(val) # rows already ordered by seq (= step order) + return out + + def latest(self, signal_name, default=float("nan"), require_fresh=False): + """Most recent value of ANOTHER signal for each sample in the batch, in a + single batched query. ``(B,)`` array aligned to ``self.sample_ids``. Use + this to build a signal that ingests several other signals — call it once + per input signal, then combine the arrays with vector ops. + + With ``require_fresh=True`` this raises unless EVERY sample has a value of + *signal_name* at the current step (``self.step``) — catching a stale + ingest (input not logged, or written after the trigger this step). + """ + last = {} + if self.logger is not None: + for sid, step, val, _ in self.logger.query_per_sample(signal_name, sample_ids=self.sample_ids): + last[int(sid)] = (step, val) # rows ordered by seq -> last assignment wins + if require_fresh: + stale = [s for s in self.sample_ids + if s not in last or self.step is None or last[s][0] != self.step] + if stale: + raise StaleSignalError( + f"ingested signal '{signal_name}' is not fresh at step {self.step} for " + f"{len(stale)}/{len(self.sample_ids)} sample(s) (e.g. {stale[:3]}). Log it " + f"with log=True and write it BEFORE the subscribing signal fires.") + return np.array([last.get(s, (None, default))[1] for s in self.sample_ids], dtype=float) + # ##################################################################################################################### # WEIGHTSLAB INTERNAL FUNCTIONS FOR LOGGING, SIGNAL EXTRACTION, WRAPPING, ETC. (not typically called directly by users) @@ -273,9 +602,21 @@ def _extract_scalar_from_tensor(batch_scalar: th.Tensor | np.ndarray, out: th.Te batch_scalar = _tmp except Exception: pass - # Merged batch scalar with ids + # Merged batch scalar with ids. Vectorize the tensor->python + # conversion: one .tolist() (a single device sync) instead of a + # per-element .item() in a comprehension (B device syncs + B python + # calls). This is on the per-step hot path — profiling showed the + # per-element version was a top self-time cost. if isinstance(batch_scalar, (th.Tensor, np.ndarray)) and ids is not None and len(batch_scalar) == len(ids): - batch_scalar = {ids[i].item() if isinstance(ids, th.Tensor) else ids[i]: batch_scalar[i].item() for i in range(len(batch_scalar))} + _vals = (batch_scalar.detach().cpu().tolist() if isinstance(batch_scalar, th.Tensor) + else np.asarray(batch_scalar).tolist()) + if isinstance(ids, th.Tensor): + _keys = ids.detach().cpu().tolist() + elif isinstance(ids, np.ndarray): + _keys = ids.tolist() + else: + _keys = list(ids) + batch_scalar = dict(zip(_keys, _vals)) # 2. Otherwise fall back to extracting from 'out' elif out is not None: if isinstance(out, th.Tensor): @@ -466,6 +807,8 @@ def wrappered_fwd(original_forward, kwargs, reg_name, *a, **kw): # Extract torch function parameters _ = wl_kw.get('flag') + # Kept in-memory for ctx.logits (logit-derived signals); NOT persisted unless + # ledger_store_preds_raw is set (see the gated save_signals below). preds_raw = a[0] if len(a) > 0 else None # User parameters @@ -620,39 +963,87 @@ def wrappered_fwd(original_forward, kwargs, reg_name, *a, **kw): for name, func in subscribers: meta = getattr(func, '_wl_signal_meta', {}) compute_every = meta.get('compute_every_n_steps', 1) + min_step = meta.get('min_step', 0) + + # Warm-up gate: don't fire until we've reached min_step + # (e.g. a signal that needs enough per-sample history). + if current_step < min_step: + continue # Frequency Check if current_step % compute_every != 0: continue try: - batch_res = {} - for i, uid in enumerate(ids_np): - # Generic 'value' argument - val = float(val_vec[i]) - - # Unified Context Pattern - ctx = SignalContext( - sample_id=int(uid), - subscribed_value=val, - logger=ledgers.get_logger(), + _lg = ledgers.get_logger() + ingests = meta.get('ingests') or [] + if meta.get('batched'): + # Vectorized path: build ONE context for the whole + # batch and call the signal once. It returns a length-B + # array. Avoids B Python calls + B SignalContext allocs, + # and lets the signal do batched ledger reads. + bctx = BatchSignalContext( + sample_ids=[int(u) for u in ids_np], + subscribed_values=[float(v) for v in val_vec], + logger=_lg, dataframe=df_proxy, - origin=kwargs.get('origin', 'train') + origin=kwargs.get('origin', 'train'), + step=step, + logits=preds_raw.detach() if hasattr(preds_raw, 'detach') else preds_raw, + preds=preds.detach() if hasattr(preds, 'detach') else preds, + targets=targets.detach() if hasattr(targets, 'detach') else targets, ) - try: - res = func(ctx) # Compute per sample result with unified context - except TypeError: - # Fallback for legacy subscriber functions - res = func(sample_id=int(uid), value=val, dataframe=df_proxy) - - batch_res[uid] = res - signal_value = list(batch_res.values()) + # Enforce that declared ingested signals are fresh. + for dep in ingests: + bctx.latest(dep, require_fresh=True) + res = func(bctx) + res = res.tolist() if hasattr(res, 'tolist') else list(res) + signal_value = [float(x) for x in res] + else: + # Validate declared ingests once for the whole batch. + if ingests: + _vctx = BatchSignalContext( + sample_ids=[int(u) for u in ids_np], + subscribed_values=[float(v) for v in val_vec], + logger=_lg, step=step) + for dep in ingests: + _vctx.latest(dep, require_fresh=True) + batch_res = {} + for i, uid in enumerate(ids_np): + # Generic 'value' argument + val = float(val_vec[i]) + + # Unified Context Pattern + _lrow = preds_raw[i] if hasattr(preds_raw, '__getitem__') and preds_raw is not None else None + ctx = SignalContext( + sample_id=int(uid), + subscribed_value=val, + logger=_lg, + dataframe=df_proxy, + origin=kwargs.get('origin', 'train'), + step=step, + logits=_lrow.detach() if hasattr(_lrow, 'detach') else _lrow, + ) + try: + res = func(ctx) # Compute per sample result with unified context + except TypeError: + # Fallback for legacy subscriber functions + res = func(sample_id=int(uid), value=val, dataframe=df_proxy) + + batch_res[uid] = res + signal_value = list(batch_res.values()) dynamic_updates[name] = signal_value if dynamic_updates and meta.get('log', True): logger.debug(f"Dynamic updates computed for signal '{reg_name}': {list(dynamic_updates.keys())}") - _log_signal(sum(signal_value)/len(signal_value), signal_value, name, step=step, **kwargs) # Log custom subscribed signals + # Log per-sample keyed by id (dict), not a bare list — + # else it reaches the dataframe but not the logger's + # per_sample table, and reactive dependents can't read it. + _sv = {int(ids_np[i]): float(signal_value[i]) for i in range(len(ids_np))} + _log_signal(sum(signal_value)/len(signal_value), _sv, name, step=step, **kwargs) # Log custom subscribed signals + except StaleSignalError: + raise # correctness enforcement: surface, don't swallow except Exception as e: - logger.debug(f"Dynamic signal {name} failed: {e}") + logger.error(f"Dynamic signal {name} failed: {e}") pass # User function error, skip # Save statistics if requested and applicable. @@ -667,18 +1058,37 @@ def wrappered_fwd(original_forward, kwargs, reg_name, *a, **kw): signals.update(dynamic_updates) # Merge dynamic signals preds = detach_to_cpu(preds) - preds_raw = detach_to_cpu(preds_raw) + # preds_raw = detach_to_cpu(preds_raw) + + # Storing preds_raw (the (B,C) logits) every step is costly and only + # needed for FiftyOne/report drill-down — signals already read them via + # ctx.logits. Default off (dev disabled it); set the hyperparam to re-enable. + try: + _store_preds = bool(get_hyperparams().get('ledger_store_preds_raw', False)) + except Exception: + _store_preds = False # Enqueue signals and data save_signals( signals=signals, batch_ids=batch_ids, - preds_raw=preds_raw, + # preds_raw=preds_raw, preds=preds, targets=targets, log=False # Already logged above, no need to log again in save_signals; set to False to avoid duplicate logging if save_signals is called separately without logging ) + # Seed reactive dispatch with the watched metric AND subscribe_to results, so + # a chain like conf_r<-entropy<-logits fires without any user save_signals. + if not per_instance and batch_ids is not None: + try: + _seed = [reg_name] + [n for n in dynamic_updates.keys() if n != reg_name] + _dispatch_or_enqueue(_seed, batch_ids, step, origin or 'train') + except StaleSignalError: + raise + except Exception as e: + logger.debug(f"reactive dispatch after {reg_name} failed: {e}") + # Return the original output (dict for per-instance losses so caller can # use out['batch'] for backward, tensor for standard per-sample losses). return out_original if isinstance(out_original, dict) else out @@ -1068,36 +1478,106 @@ def start_training(timeout: int = None) -> None: timeout: Maximum number of seconds to keep running. If ``None``, runs until interrupted. """ + _warn_on_signal_cycles() # surface circular reactive-signal deps (warn, don't raise) if timeout is not None and isinstance(timeout, int) and timeout > 0: logger.info(f"Starting WeightsLab training mode with a timeout of {timeout} seconds.") time.sleep(timeout) pause_ctrl.resume() # Ensure we're not paused if start_training is called after serve + +def _running_in_notebook() -> bool: + """True when running inside a Jupyter notebook kernel or Google Colab. + + Distinguishes a notebook kernel (``ZMQInteractiveShell``) / Colab from a + plain script or a terminal IPython session, so we only nudge users who can't + reach a locally-launched Weights Studio without a tunnel. + """ + if "google.colab" in sys.modules: + return True + try: + from IPython import get_ipython + shell = get_ipython() + return shell is not None and shell.__class__.__name__ == "ZMQInteractiveShell" + except Exception: + return False + + def serve(serving_cli: bool = True, serving_grpc: bool = False, - spawn_cli_client: bool = False, **kwargs) -> None: + spawn_cli_client: bool = False, serving_bore: bool = False, + bore_port: int = None, **kwargs): """Start WeightsLab services. Args: serving_cli: Start the interactive CLI server. - serving_grpc: Start the gRPC server. + serving_grpc: Start the gRPC server. In a notebook/Colab, if this is on + but ``serving_bore`` is off, a warning suggests ``serving_bore=True`` + (the UI runs on your own machine and needs a tunnel to reach here). spawn_cli_client: When ``serving_cli`` is True, also open the interactive REPL in a new console window (the default, backward-compatible behavior). Set to ``False`` to start the CLI server *headless* — it still advertises its port, so you can attach on demand from any terminal with ``weightslab cli`` (or ``weightslab cli --port ``). + serving_bore: Expose the gRPC backend to the internet via a zero-signup + `bore `_ raw-TCP relay, so a Weights + Studio running on another machine can reach it (e.g. training in + Google Colab, UI on your laptop). Prints the public endpoint and the + two commands to run locally (``weightslab ui launch`` + + ``weightslab tunnel ``). Implies a gRPC backend. Uses the + shared public relay ``bore.pub`` — the random port is the only thing + guarding it, so keep it to non-sensitive demos. + bore_port: Port to expose when ``serving_bore`` is set. Defaults to the + gRPC port (``grpc_port`` kwarg, else ``$GRPC_BACKEND_PORT``, else + 50051). **kwargs: Extra server options passed to underlying backends (e.g. ``cli_host``, ``cli_port``, ``grpc_port``). + + Returns: + The public ``host:port`` bore endpoint string when ``serving_bore`` is + set and the tunnel came up, otherwise ``None``. """ if serving_grpc: grpc_serve(**kwargs) + # In a notebook/Colab the backend has no local Weights Studio to talk to + # (the UI runs on the user's own machine). Nudge them to open a tunnel. + if serving_grpc and not serving_bore and _running_in_notebook(): + logger.warning( + "Running in a notebook/Colab with serving_grpc=True but " + "serving_bore=False. Weights Studio runs on your OWN machine and " + "cannot reach this backend directly. Pass serving_bore=True to open " + "a tunnel and sync with the UI: wl.serve(serving_grpc=True, " + "serving_bore=True)." + ) + + bore_endpoint = None + if serving_bore: + from weightslab.tunnel import serve_bore + port = (bore_port + or kwargs.get("grpc_port") + or int(os.getenv("GRPC_BACKEND_PORT", 50051))) + if not serving_grpc: + logger.warning( + "serving_bore=True but serving_grpc=False — the tunnel will have " + "no gRPC backend to expose. Set serving_grpc=True." + ) + bore_endpoint = serve_bore(port=port) + if bore_endpoint: + print("=" * 60) + print(f" Backend exposed via bore at: {bore_endpoint}") + print(" On your local machine (Docker running), run:") + print(" weightslab ui launch") + print(f" weightslab tunnel {bore_endpoint}") + print("=" * 60) + if serving_cli: # The explicit parameter is the source of truth; drop any duplicate # spawn_client passed through kwargs so we don't pass it twice. kwargs.pop("spawn_client", None) cli_serve(spawn_client=spawn_cli_client, **kwargs) + return bore_endpoint + def keep_serving(timeout: int = None, release_gpu: bool = True) -> None: """Keep process alive while background WeightsLab services are running. @@ -1123,7 +1603,8 @@ def keep_serving(timeout: int = None, release_gpu: bool = True) -> None: logger.info("Shutting down WeightsLab services.") -def signal(name: str, subscribe_to: str = None, compute_every_n_steps: int = 1, **kwargs): +def signal(name: str, subscribe_to: str = None, compute_every_n_steps: int = 1, + min_step: int = 0, **kwargs): """ Decorator that registers a custom signal function. @@ -1135,6 +1616,12 @@ def signal(name: str, subscribe_to: str = None, compute_every_n_steps: int = 1, name: Public signal name. Defaults to decorated function name. subscribe_to: Optional signal/metric name this signal reacts to. compute_every_n_steps: Frequency for dynamic computation. + min_step: Minimum training step before a subscribed (dynamic) signal + starts firing. The signal is skipped while ``current_step < + min_step``. Defaults to ``0`` (fire from the start). Useful when a + signal needs enough history to be meaningful — e.g. a loss-shape + classifier that should only run once each sample has a trajectory + (``min_step=505``). **kwargs: Additional signal metadata stored in the ledger. Returns: @@ -1144,7 +1631,8 @@ def signal(name: str, subscribe_to: str = None, compute_every_n_steps: int = 1, @wl.signal(name="blue_pixels") def compute_blue(sample, **kwargs): ... - @wl.signal(name="weighted_loss", subscribe_to="train_loss", compute_every_n_steps=10) + @wl.signal(name="weighted_loss", subscribe_to="train_loss", + compute_every_n_steps=10, min_step=505) def compute_weighted(value, sample_id, **kwargs): ... """ def decorator(func): @@ -1157,6 +1645,14 @@ def decorator(func): func._wl_signal_meta = kwargs func._wl_signal_meta['subscribe_to'] = subscribe_to func._wl_signal_meta['compute_every_n_steps'] = compute_every_n_steps + # Reactive dependency set. ``inputs=[...]`` is the unified form: the signal + # fires when ALL its inputs are present at a step, order-independently + # (subsumes subscribe_to + ingests). A single-input signal is the alias + # ``inputs=["x"]`` == ``subscribe_to="x"``. Signals that use the legacy + # ``subscribe_to`` keyword instead stay on the legacy dispatch. + _inp = kwargs.get('inputs') + func._wl_signal_meta['inputs'] = list(_inp) if _inp else None + func._wl_signal_meta['min_step'] = min_step func._wl_signal_name = reg_name return func @@ -1602,7 +2098,8 @@ def save_signals( targets: th.Tensor | np.ndarray | dict = None, preds: th.Tensor | np.ndarray | dict = None, step: int | None = None, - log: bool = False + log: bool = False, + _react: bool = True, ): """Save **per-sample** statistics to the tracked dataset. @@ -1666,8 +2163,10 @@ def save_signals( if DATAFRAME_M is None: DATAFRAME_M = get_dataframe() - # Get current model step - step = _get_step(step=step) + # Honor an explicit step (as documented); only infer from the model when it + # is omitted. Reactive backfill on the signal-worker thread depends on this — + # it logs derived values at the job's step, not the live (ahead) model step. + step = step if step is not None else _get_step() # Log if requested for each signals if log: @@ -1699,7 +2198,17 @@ def normalize(x): if x is None: return None if isinstance(x, list) and isinstance(x[0], list): - return [np.max(np.array([to_numpy(t) for t in row]), axis=0) for row in x] + rows = [[to_numpy(t) for t in row] for row in x] + # A sample can legitimately have zero instances (e.g. no tracked + # class visible in frame); fall back to an all-background mask + # shaped like its neighbors instead of reducing an empty array. + shape = next((r[0].shape for r in rows if r), None) + dtype = next((r[0].dtype for r in rows if r), np.uint16) + return [ + np.max(np.array(row), axis=0) if row + else np.zeros(shape, dtype=dtype) if shape is not None else np.zeros(0, dtype=dtype) + for row in rows + ] elif isinstance(x, list): return [to_numpy(t) for t in x] if isinstance(x, th.Tensor): @@ -1750,6 +2259,18 @@ def expand_dim(x): step=step ) + # Reactive signals: these just-logged signals may satisfy an inputs=[...] + # signal. Only logged (queryable) signals can be inputs. _react=False on the + # reactive persist path prevents re-entrant dispatch. + if _react and log and isinstance(signals, dict): + try: + _dispatch_or_enqueue(list(signals.keys()), batch_ids, step, + get_active_origin() or 'train') + except StaleSignalError: + raise + except Exception as e: + logger.debug(f"reactive dispatch after save_signals failed: {e}") + def save_instance_signals( signals: dict, @@ -3190,6 +3711,7 @@ def write_history( experiment_hash: str | None = None, sample_id=None, instance_id=None, + verbose: bool = False, ) -> str: """Dump signal history to *path* as JSON or CSV. @@ -3266,6 +3788,10 @@ def write_history( sample_id, instance_id, ) + # Progress lines are INFO only when verbose; otherwise DEBUG (quiet by + # default so periodic exports don't spam a training log). + _log = logger.info if verbose else logger.debug + _lg = get_logger() if _lg is None: logger.warning( @@ -3287,7 +3813,7 @@ def write_history( logger.debug("write_history: failed to resolve root_log_dir (%s); " "falling back to current directory.", _e) path = "." - logger.info("write_history: no path given, using output directory %r.", path) + _log("write_history: no path given, using output directory %r.", path) fmt = format.lower().strip() @@ -3332,7 +3858,7 @@ def write_history( write_sample = _type in ("all", "sample") write_instance = _type in ("all", "instance") - logger.info( + _log( "write_history: resolved filters → type=%r, experiment_hash=%s, " "graph_name=%s, sample_id=%s, instance_id=%s", _type, @@ -3357,10 +3883,10 @@ def write_history( ) _phash = _hashlib.md5(str(_params_key).encode()).hexdigest()[:8] path = _os.path.join(path, f"{_phash}_history.{fmt}") - logger.info("write_history: auto-generated filename %r (params hash " - "%s).", _os.path.basename(path), _phash) + _log("write_history: auto-generated filename %r (params hash " + "%s).", _os.path.basename(path), _phash) _os.makedirs(_os.path.dirname(_os.path.abspath(path)), exist_ok=True) - logger.info("write_history: output file → %s", _os.path.abspath(path)) + _log("write_history: output file → %s", _os.path.abspath(path)) global_rows: list = [] sample_rows: list = [] @@ -3474,7 +4000,7 @@ def write_history( ) _total = len(global_rows) + len(sample_rows) + len(instance_rows) - logger.info( + _log( "write_history: wrote %d row(s) (global=%d, sample=%d, instance=%d) " "as %s to %s", _total, len(global_rows), len(sample_rows), len(instance_rows), @@ -3491,15 +4017,122 @@ def write_history( return path +LOSS_SHAPES = ["monotonic", "plateaued", "Flat_high", "high_variance", "U_Shape", "Spiked"] + + +def trajectory_stats(values): + """Scale-invariant summary stats of one sample's trajectory — the reusable + feature layer. Returns a dict (or ``None`` with < 2 points) so you can build + a custom classifier on top without re-deriving the features:: + + def my_clf(v): + s = wl.trajectory_stats(v) + return None if s is None else ("fast" if s["drop"] > 0.6 else "slow") + """ + y = np.asarray(values, dtype=float) + if y.size < 2: + return None + n = y.size + rng = max(float(y.max() - y.min()), 1e-8) + tail = y[int(0.6 * n):] + return { + "n": n, + "first": float(y[0]), "last": float(y[-1]), + "drop": (float(y[0]) - float(y[-1])) / (abs(float(y[0])) + 1e-8), + "cv": float(y.std()) / (abs(float(y.mean())) + 1e-8), + "argmin_frac": float(np.argmin(y)) / n, + "rebound": (float(y[-1]) - float(y.min())) / rng, + "max_up_jump": float(np.diff(y).max()) / rng, + "tail_cv": float(tail.std()) / (abs(float(tail.mean())) + 1e-8), + } + + +def classify_loss_shape(values, min_points=5, drop_learned=0.4, drop_plateau=0.15, + tail_flat_cv=0.1, spike_jump=0.5, noisy_cv=0.5, u_rebound=0.3): + """Default classifier -> one of ``LOSS_SHAPES`` (or ``None`` with < + *min_points*). Built on :func:`trajectory_stats` (reuse those features + for your own rules) with every threshold a named, tunable param. Override the + whole thing by passing your own ``classifier`` to :func:`write_signal_shapes`.""" + s = trajectory_stats(values) + if s is None or s["n"] < min_points: + return None + if 0.2 < s["argmin_frac"] < 0.8 and s["rebound"] > u_rebound: + return "U_Shape" + if s["drop"] > drop_learned: + return "monotonic" + if s["drop"] > drop_plateau and s["tail_cv"] < tail_flat_cv: + return "plateaued" + if s["max_up_jump"] > spike_jump: + return "Spiked" + if s["cv"] > noisy_cv: + return "high_variance" + return "Flat_high" + + +def write_signal_shapes(signal_name, tag_name=None, classifier=None): + """Reusable engine: classify every sample's trajectory of *signal_name* into + a categorical tag and return the ``{label: count}`` distribution. Works for + ANY per-sample signal — loss, accuracy, a second loss, any metric. Reads the + full history once (cheap end-of-report reduction). *classifier* (trajectory + -> label|None) defaults to the loss-shaped one (for a decreasing metric); + pass your own for e.g. accuracy (increasing). *tag_name* defaults to + ``'_shape'``.""" + clf = classifier or classify_loss_shape + if tag_name is None: + tag_name = signal_name.split("/")[-1] + "_shape" + series = {} + for sid, step, val, _ in query_signal_history(signal_name): + series.setdefault(sid, []).append((step, val)) + by_label = {} + for sid, pts in series.items(): + label = clf([v for _, v in sorted(pts)]) + if label is not None: + by_label.setdefault(label, []).append(sid) + for label, sids in by_label.items(): + set_categorical_tag(sids, tag_name, label) + return {k: len(v) for k, v in by_label.items()} + + +def write_loss_shapes(loss_signal="loss_sample", classifier=None): + """Convenience wrapper over :func:`write_signal_shapes` for the loss signal + (tag ``loss_shape``, default loss classifier).""" + return write_signal_shapes(loss_signal, tag_name="loss_shape", classifier=classifier) + + +def enable_loss_shape_signal(loss_signal="loss_sample", name="sig/loss_shape", + every=1, classifier=None): + """Register a LIVE per-step ``@wl.signal`` that classifies each sample's + loss-trajectory-so-far into an int-coded shape (index into ``LOSS_SHAPES``, + ``-1`` before there's enough history), updated every *every* steps. + + This is the live counterpart to :func:`write_loss_shapes` (report-time). It's + heavier — it reads history on every fire — so throttle with *every* or prefer + the report-time path for a definitive, full-coverage tag.""" + clf = classifier or classify_loss_shape + + @signal(name=name, inputs=[loss_signal], batched=True, compute_every_n_steps=every) + def _live_shape(b): + hist = b.history(loss_signal) + return np.array([(LOSS_SHAPES.index(lbl) if (lbl := clf(hist[s])) is not None else -1) + for s in b.sample_ids], dtype=float) + return _live_shape + + def write_dataframe( path: str | None = None, format: str = "json", columns=None, sample_id=None, instance_id=None, + verbose: bool = False, + loss_shape_signal: str | None = None, ) -> str: """Dump the WeightsLab sample dataframe to *path* as JSON or CSV. + When *loss_shape_signal* is set (e.g. ``"loss_sample"``), the default loss- + shape classifier runs first (tags each sample + logs a distribution summary), + so a report written every N steps carries an up-to-date shape summary. + Parameters ---------- path : str, optional @@ -3567,12 +4200,27 @@ def write_dataframe( import os as _os import hashlib as _hashlib + # If reactive signals run on the worker thread, process every queued job + # before reading, so the report includes the latest steps' derived signals. + drain_signals() + + # Default report summary: tag each sample's loss shape + log the distribution. + if loss_shape_signal is not None: + try: + dist = write_loss_shapes(loss_shape_signal) + logger.info("[WeightsLab] loss-shape summary (%s): %s", loss_shape_signal, dist) + except Exception as e: + logger.debug("loss-shape summary skipped: %s", e) + logger.debug( "write_dataframe called: path=%r, format=%r, columns=%r, " "sample_id=%r, instance_id=%r", path, format, columns, sample_id, instance_id, ) + # INFO only when verbose; DEBUG otherwise (quiet periodic exports). + _log = logger.info if verbose else logger.debug + _dm = get_dataframe() if _dm is None: logger.warning( @@ -3592,7 +4240,7 @@ def write_dataframe( logger.debug("write_dataframe: failed to resolve root_log_dir (%s); " "falling back to current directory.", _e) path = "." - logger.info("write_dataframe: no path given, using output directory %r.", path) + _log("write_dataframe: no path given, using output directory %r.", path) fmt = format.lower().strip() @@ -3611,7 +4259,7 @@ def write_dataframe( if columns is not None and not (isinstance(columns, str) and columns.lower() == "all"): _col_filter = [columns] if isinstance(columns, str) else list(columns) - logger.info( + _log( "write_dataframe: resolved filters → columns=%s, sample_id=%s, instance_id=%s", _col_filter if _col_filter is not None else "", _sid_filter if _sid_filter is not None else "", @@ -3629,10 +4277,10 @@ def write_dataframe( ) _phash = _hashlib.md5(str(_params_key).encode()).hexdigest()[:8] path = _os.path.join(path, f"{_phash}_dataframe.{fmt}") - logger.info("write_dataframe: auto-generated filename %r (params hash %s).", - _os.path.basename(path), _phash) + _log("write_dataframe: auto-generated filename %r (params hash %s).", + _os.path.basename(path), _phash) _os.makedirs(_os.path.dirname(_os.path.abspath(path)), exist_ok=True) - logger.info("write_dataframe: output file → %s", _os.path.abspath(path)) + _log("write_dataframe: output file → %s", _os.path.abspath(path)) # Flush pending buffer to H5 before reading try: diff --git a/weightslab/trainer/services/agent/intent_prompt.py b/weightslab/trainer/services/agent/intent_prompt.py index a9fe7bba..663a91d3 100644 --- a/weightslab/trainer/services/agent/intent_prompt.py +++ b/weightslab/trainer/services/agent/intent_prompt.py @@ -74,13 +74,10 @@ - "average train loss over training below 0.2" → `signal_history('train_loss','mean') < 0.2` Use it exactly like a normal column expression (create a tag, discard, or keep on it — see Ex39). A sample with no recorded history yields NaN (comparisons are False), so it's safely excluded. 11. **Hyperparameter tuning (`action_name="set_hyperparam"`)**: to change a training hyperparameter, emit a `kind="action"` step with `action_name="set_hyperparam"` and `action_params={{"param": , "op": <"set"|"scale">, "value": }}`. - - `param`: prefer a semantic name — `"batch_size"`, `"learning_rate"`, `"dump_ratio"` (the model-dump/checkpoint ratio), `"eval_ratio"` (the evaluation ratio) — or an exact dotted config path (e.g. `"data.train_loader.batch_size"`). The backend resolves the semantic name to the real config key. - - Absolute change ("set X to N", "change X to N") → `op="set"`, `value=N`. - Relative change ("increase X by 10%", "decrease by 20%") → `op="scale"`, `value` = the multiplier (10% increase → `1.1`; 20% decrease → `0.8`). Compute the multiplier yourself. - See Ex45–Ex48. --- -## 4. SCHEMA RULES (STRICT) - **Primary Goal**: `ui_manipulation` (grid changes), `data_analysis` (answers), `action` (external), `model_management` (model_info/model_action), `out_of_scope`. - **Atomic Operations**: - `conditions`: List of dicts with keys "column", "op", "value". Operators: `==, !=, >, <, >=, <=, contains, in, max, min`. diff --git a/weightslab/trainer/services/experiment_service.py b/weightslab/trainer/services/experiment_service.py index 2a4a66d7..3de658a5 100644 --- a/weightslab/trainer/services/experiment_service.py +++ b/weightslab/trainer/services/experiment_service.py @@ -727,9 +727,28 @@ def _handle_save_checkpoint(self, save_op, components, context): self._log_audit("checkpoint_save", "success", audit_details) return pb2.CommandResponse(success=True, message=msg) + def _handle_restart_instance(self): + self._log_audit("restart_instance", "success", {}) + logger.warning( + "[ExperimentCommand] Restart requested via UI; exiting process so the " + "container's restart policy brings it back up and reloads the last checkpoint." + ) + + def _delayed_exit(): + # Give the gRPC response a moment to flush to the client before the + # process dies. + time.sleep(0.5) + os._exit(0) + + threading.Thread(target=_delayed_exit, daemon=True).start() + return pb2.CommandResponse(success=True, message="Restart requested. The instance will restart shortly.") + # Training & hyperparameter commands # ------------------------------------------------------------------------- def ExperimentCommand(self, request, context): + if request.HasField("restart_operation"): + return self._handle_restart_instance() + self._ctx.ensure_components() components = self._ctx.components diff --git a/weightslab/trainer/trainer_services.py b/weightslab/trainer/trainer_services.py index 0400c434..030b31e7 100644 --- a/weightslab/trainer/trainer_services.py +++ b/weightslab/trainer/trainer_services.py @@ -32,6 +32,25 @@ def _is_truthy(value: str | None) -> bool: return str(value or "").strip().lower() in {"1", "true", "yes", "on"} +_WEIGHTSLAB_VERSION: str | None = None + + +def _weightslab_version() -> str: + """Backend weightslab package version, resolved once and cached. + + Sent to the UI (via GetLatestLoggerDataResponse) so the studio can show the + backend version alongside the UI version in the logo tooltip. + """ + global _WEIGHTSLAB_VERSION + if _WEIGHTSLAB_VERSION is None: + try: + from weightslab import __version__ as v + _WEIGHTSLAB_VERSION = str(v or "") + except Exception: + _WEIGHTSLAB_VERSION = "" + return _WEIGHTSLAB_VERSION + + def _load_auth_tokens() -> set[str]: """Load accepted bearer/API-key tokens from env vars.""" raw_values: list[str] = [] @@ -408,7 +427,14 @@ def ResetAgent(self, request, context): # ------------------------------------------------------------------------- def GetLatestLoggerData(self, request, context): logger.debug(f"ExperimentServiceServicer.GetLatestLoggerData({request})") - return self._exp_service.GetLatestLoggerData(request, context) + response = self._exp_service.GetLatestLoggerData(request, context) + # Advertise the backend version to the UI (shown in the logo tooltip + # next to the UI version). Best-effort: never fail the poll over this. + try: + response.weightslab_version = _weightslab_version() + except Exception: + pass + return response # ------------------------------------------------------------------------- # Training & hyperparameter commands diff --git a/weightslab/tunnel.py b/weightslab/tunnel.py new file mode 100644 index 00000000..f86c6cb8 --- /dev/null +++ b/weightslab/tunnel.py @@ -0,0 +1,349 @@ +"""Raw-TCP tunnel client for Weights Studio. + +Exposes a *remote* gRPC training backend — e.g. a Google Colab run sitting behind +a raw-TCP tunnel — on a **local** port, so the bundled Weights Studio Docker +stack connects to it as if it were local. The stack's Envoy proxy dials +``localhost:50051`` (see the ``grpc_service`` upstream in the bundled envoy +config), so making the remote backend appear there needs no change to the UI at +all: just run ``weightslab ui launch`` and this tunnel side by side. + +Why a *raw* byte forwarder (no protocol parsing): the browser speaks gRPC-Web to +Envoy, and Envoy speaks native HTTP/2 gRPC to its upstream. Those HTTP/2 frames +must pass through byte-for-byte — anything that re-frames them (an HTTP/1 proxy, +a gRPC-Web tunnel) breaks the connection. So this shuttles bytes both ways and +leaves the protocol untouched. The matching remote tunnel must likewise be raw +TCP — ``bore local 50051 --to bore.pub`` (zero-signup) or ``ngrok tcp 50051`` +(needs a card on the free tier) — and the backend must run **plaintext** (the +default ``weightslab ui launch`` — no ``--certs``) so no TLS terminates mid-path. +""" + +import logging +import os +import re +import socket +import subprocess +import sys +import threading + +logger = logging.getLogger(__name__) + +# Env var read as the default ENDPOINT so a bare ``weightslab tunnel`` works +# once it is exported (e.g. the notebook can print `export WEIGHTSLAB_TUNNEL_ENDPOINT=...`). +_ENDPOINT_ENV_VAR = "WEIGHTSLAB_TUNNEL_ENDPOINT" + +# `bore` (https://github.com/ekzhang/bore) — the zero-signup raw-TCP relay used +# by the ``serving_bore`` path of ``wl.serve`` to expose a training backend +# (e.g. from Colab) to the internet so a local Weights Studio can reach it. +_BORE_VERSION = "v0.6.0" +_BORE_RELAY = "bore.pub" +# Keep spawned bore processes referenced so they are not garbage-collected +# (which would close their pipes and drop the tunnel) while the kernel lives. +_BORE_PROCS = [] + +# The local port the bundled Envoy upstream dials (GRPC_BACKEND_PORT default). +# Binding here makes a remote backend look local to the UI stack. +DEFAULT_LISTEN_PORT = 50051 + +# Size of the per-read buffer for the byte pump. +_BUF = 65536 + +# Scheme prefixes we tolerate on an endpoint string (ngrok prints ``tcp://...``). +_STRIP_SCHEMES = ("tcp://", "grpc://", "grpcs://", "http://", "https://") + + +def _default_listen_host() -> str: + """Interface to bind so the UI's Envoy container can reach the tunnel. + + Docker Desktop (Windows/macOS) routes ``host.docker.internal`` to host + loopback, so ``127.0.0.1`` is reachable *and* stays private to this machine. + On Linux the compose ``host-gateway`` resolves to the docker bridge IP, which + cannot reach a loopback-only listener — so bind all interfaces there. + """ + if sys.platform in ("win32", "darwin"): + return "127.0.0.1" + return "0.0.0.0" + + +def _parse_endpoint(endpoint: str, default_port=None): + """Parse ``[scheme://]host[:port]`` into ``(host, port)``. + + ``default_port`` is used when the endpoint has no ``:port`` (e.g. the user + passed ``--remote-port`` separately). Raises ``ValueError`` if neither is + available. + """ + ep = endpoint.strip() + for scheme in _STRIP_SCHEMES: + if ep.lower().startswith(scheme): + ep = ep[len(scheme):] + break + ep = ep.rstrip("/") + if ":" in ep: + host, _, port = ep.rpartition(":") + if not host: + raise ValueError(f"Endpoint '{endpoint}' is missing a host") + try: + return host, int(port) + except ValueError: + raise ValueError(f"Endpoint '{endpoint}' has a non-numeric port '{port}'") + if default_port is None: + raise ValueError( + f"No port in endpoint '{endpoint}' and no --remote-port given" + ) + return ep, int(default_port) + + +def _pipe(src: socket.socket, dst: socket.socket) -> None: + """Copy bytes from ``src`` to ``dst`` until either side closes.""" + try: + while True: + data = src.recv(_BUF) + if not data: + break + dst.sendall(data) + except OSError: + # Peer reset / half-open close — normal on connection teardown. + pass + finally: + # Unblock the paired pump so both sockets tear down together. + for s in (src, dst): + try: + s.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + +def _handle(client: socket.socket, remote_addr) -> None: + """Dial the remote for one accepted client and pump both directions. + + The remote is re-resolved per connection (via ``create_connection``) so a + tunnel endpoint whose DNS/IP changes is picked up without a restart. + """ + try: + remote = socket.create_connection(remote_addr, timeout=10) + except OSError as exc: + logger.warning( + f"Could not reach remote {remote_addr[0]}:{remote_addr[1]}: {exc}" + ) + try: + client.close() + except OSError: + pass + return + remote.settimeout(None) + client.settimeout(None) + threading.Thread(target=_pipe, args=(client, remote), daemon=True).start() + threading.Thread(target=_pipe, args=(remote, client), daemon=True).start() + + +def run_tunnel(remote_host: str, remote_port: int, + listen_host: str = None, + listen_port: int = DEFAULT_LISTEN_PORT) -> int: + """Forward ``listen_host:listen_port`` (local) to ``remote_host:remote_port``. + + Blocks until Ctrl+C. Returns a process exit code (0 on clean stop, 1 if the + local port could not be bound). + """ + listen_host = listen_host or _default_listen_host() + remote_addr = (remote_host, remote_port) + + # Best-effort reachability probe so the user gets immediate feedback instead + # of a silent hang when the Colab cell / ngrok tunnel isn't up yet. + try: + probe = socket.create_connection(remote_addr, timeout=8) + probe.close() + logger.info(f" Remote backend reachable at {remote_host}:{remote_port}") + except OSError as exc: + logger.warning( + f"Remote {remote_host}:{remote_port} not reachable yet ({exc}). " + "Starting anyway — is the Colab training cell running with the tunnel " + "(e.g. bore) up?" + ) + + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + srv.bind((listen_host, listen_port)) + except OSError as exc: + logger.error( + f"Cannot bind {listen_host}:{listen_port}: {exc}. " + "Another process (a local backend, or a previous tunnel) may already " + "hold it — stop it or pass a different --listen-port." + ) + srv.close() + return 1 + srv.listen(128) + + logger.info("=" * 60) + logger.info(f" Tunnel up: {listen_host}:{listen_port} -> {remote_host}:{remote_port}") + logger.info(" If the UI isn't running yet, in another terminal: weightslab ui launch") + logger.info(" Then open http://localhost:5173") + logger.info(" Ctrl+C to stop the tunnel.") + logger.info("=" * 60) + + try: + while True: + client, peer = srv.accept() + logger.debug(f"New connection from {peer[0]}:{peer[1]}") + _handle(client, remote_addr) + except KeyboardInterrupt: + logger.info("Tunnel stopped.") + return 0 + finally: + srv.close() + + +def tunnel_connect(args) -> None: + """`weightslab tunnel [ENDPOINT] [opts]` handler. Exits the process. + + ENDPOINT is optional: when omitted it falls back to the + ``WEIGHTSLAB_TUNNEL_ENDPOINT`` env var, so a bare ``weightslab tunnel`` + works once that is set. Everything else (local port 50051, auto listen + host) already defaults. + """ + endpoint = getattr(args, "endpoint", None) or os.environ.get(_ENDPOINT_ENV_VAR) + if not endpoint: + logger.error( + "No endpoint given. Pass it as an argument or set " + f"{_ENDPOINT_ENV_VAR}.\n" + " e.g. weightslab tunnel bore.pub:12345\n" + f" or export {_ENDPOINT_ENV_VAR}=bore.pub:12345 && weightslab tunnel" + ) + sys.exit(2) + + remote_port_arg = getattr(args, "remote_port", None) + try: + host, port = _parse_endpoint(endpoint, default_port=remote_port_arg) + except ValueError as exc: + logger.error(str(exc)) + sys.exit(2) + + listen_host = getattr(args, "listen_host", None) + listen_port = getattr(args, "listen_port", None) or DEFAULT_LISTEN_PORT + rc = run_tunnel(host, port, listen_host=listen_host, listen_port=listen_port) + sys.exit(rc or 0) + + +# --------------------------------------------------------------------------- +# Server side: expose a LOCAL backend port to the internet via a bore relay. +# This is the counterpart of the client above — it runs on the training host +# (e.g. Colab) and is what ``wl.serve(serving_bore=True)`` drives. +# --------------------------------------------------------------------------- + +def _bore_asset(version: str): + """Return ``(asset_filename, archive_kind)`` for the running platform.""" + import platform + + system = platform.system().lower() + machine = platform.machine().lower() + arch = {"x86_64": "x86_64", "amd64": "x86_64", + "aarch64": "aarch64", "arm64": "aarch64"}.get(machine) + if system == "linux" and arch: + return f"bore-{version}-{arch}-unknown-linux-musl.tar.gz", "tar" + if system == "darwin" and arch: + return f"bore-{version}-{arch}-apple-darwin.tar.gz", "tar" + if system == "windows": + return f"bore-{version}-x86_64-pc-windows-msvc.zip", "zip" + raise RuntimeError( + f"No prebuilt bore binary for {system}/{machine}. " + "Install bore manually (https://github.com/ekzhang/bore) and put it on PATH." + ) + + +def _ensure_bore(version: str = _BORE_VERSION) -> str: + """Return a path to a runnable ``bore`` binary, downloading it if needed. + + Prefers a ``bore`` already on PATH; otherwise downloads the release asset + for this platform into ``~/.weightslab/bin`` and caches it there. + """ + import shutil + from pathlib import Path + + on_path = shutil.which("bore") + if on_path: + return on_path + + cache = Path.home() / ".weightslab" / "bin" + cache.mkdir(parents=True, exist_ok=True) + exe = cache / ("bore.exe" if sys.platform == "win32" else "bore") + if exe.exists(): + return str(exe) + + import tarfile + import tempfile + import urllib.request + import zipfile + + asset, kind = _bore_asset(version) + url = f"https://github.com/ekzhang/bore/releases/download/{version}/{asset}" + logger.info(f"Downloading bore {version} ({asset})...") + tmp = Path(tempfile.mkdtemp()) + archive = tmp / asset + urllib.request.urlretrieve(url, archive) + if kind == "tar": + with tarfile.open(archive) as t: + t.extractall(tmp) + else: + with zipfile.ZipFile(archive) as z: + z.extractall(tmp) + + found = None + for p in tmp.rglob("bore*"): + if p.is_file() and p.suffix in ("", ".exe"): + found = p + break + if found is None: + raise RuntimeError(f"bore binary not found inside {asset}") + shutil.copy2(found, exe) + if sys.platform != "win32": + os.chmod(exe, 0o755) + return str(exe) + + +def serve_bore(port: int = DEFAULT_LISTEN_PORT, relay: str = _BORE_RELAY, + version: str = _BORE_VERSION, timeout: float = 30.0): + """Expose local ``port`` to the internet over a raw-TCP bore relay. + + Downloads the bore client if needed, starts ``bore local --to + `` in the background, and returns the public endpoint string (e.g. + ``"bore.pub:53984"``), or ``None`` if no endpoint was reported within + ``timeout`` seconds. Non-blocking beyond that initial handshake — the tunnel + keeps running in the background for the life of the process. + + Security note: the public relay (``bore.pub``) is shared; the random remote + port is the only thing guarding the endpoint. Fine for a demo, not for + sensitive data. + """ + try: + bore = _ensure_bore(version) + except Exception as exc: # download / platform failure — non-fatal for serve() + logger.warning(f"Could not set up bore tunnel: {exc}") + return None + + proc = subprocess.Popen( + [bore, "local", str(port), "--to", relay], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, + ) + _BORE_PROCS.append(proc) + + result = {"endpoint": None} + found = threading.Event() + relay_re = re.compile(re.escape(relay) + r":(\d+)") + + def _read(): + # One reader thread both captures the endpoint and keeps draining the + # pipe afterwards, so it never fills and stalls the tunnel. + for line in proc.stdout: + if result["endpoint"] is None: + m = relay_re.search(line) + if m: + result["endpoint"] = f"{relay}:{m.group(1)}" + found.set() + + threading.Thread(target=_read, daemon=True).start() + found.wait(timeout) + if result["endpoint"] is None: + logger.warning( + f"bore did not report an endpoint within {timeout:.0f}s " + "(is the relay reachable?)." + ) + return result["endpoint"] diff --git a/weightslab/ui_docker_bridge.py b/weightslab/ui_docker_bridge.py index 5fa795e0..6ec8745d 100644 --- a/weightslab/ui_docker_bridge.py +++ b/weightslab/ui_docker_bridge.py @@ -12,6 +12,7 @@ from pathlib import Path from weightslab.security import CertAuthManager +from weightslab.tunnel import DEFAULT_LISTEN_PORT logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') logger = logging.getLogger(__name__) @@ -172,6 +173,17 @@ def _banner() -> str: --port PORT connect to a specific CLI port --host HOST connect to a specific host (default: localhost) + tunnel Forward a REMOTE gRPC backend (e.g. a Colab run + behind `ngrok tcp 50051`) to a LOCAL port so the UI + stack — whose Envoy dials localhost:50051 — reaches + it. Run alongside `weightslab ui launch`. Raw TCP, so + the backend must be plaintext (default launch). + ENDPOINT remote host:port (e.g. bore.pub:12345) + (default: $WEIGHTSLAB_TUNNEL_ENDPOINT) + --listen-port N local port to expose (default 50051) + --listen-host H interface to bind (default: auto) + --remote-port N remote port, if not in ENDPOINT + examples: weightslab se # one-time secure setup (then export WEIGHTSLAB_CERTS_DIR) weightslab se --force-certs # regenerate the certs @@ -186,6 +198,7 @@ def _banner() -> str: weightslab start example --2d_det # run the 2D LiDAR detection demo weightslab cli # connect a terminal to the running experiment weightslab cli --port 60000 # connect to a specific CLI port + weightslab tunnel bore.pub:12345 # expose a remote (Colab) backend at localhost:50051 for the UI """ @@ -993,22 +1006,22 @@ def ui_secure_environment(args): logger.warning(f" (Windows) setx WEIGHTSLAB_CERTS_DIR \"{manager.certs_dir}\"") -# Bundled PyTorch examples, keyed by the CLI flag (e.g. --cls -> ws-classification). +# Bundled PyTorch examples, keyed by the CLI flag (e.g. --cls -> wl-classification). # Each value is (directory name under examples/PyTorch, human-readable label). # kind -> (dir_name, label, category) where category is the examples/ subfolder. _EXAMPLES = { - "cls": ("ws-classification", "classification", "PyTorch"), - "seg": ("ws-segmentation", "segmentation", "PyTorch"), - "det": ("ws-detection", "detection", "PyTorch"), - "clus": ("ws-clustering", "clustering", "PyTorch"), - "gen": ("ws-generation", "generation", "PyTorch"), - "3d_det": ("ws-3d-lidar-detection", "3D LiDAR detection", "Usecases"), - "2d_det": ("ws-2d-lidar-detection", "2D LiDAR detection", "Usecases"), + "cls": ("wl-classification", "classification", "PyTorch"), + "seg": ("wl-segmentation", "segmentation", "PyTorch"), + "det": ("wl-detection", "detection", "PyTorch"), + "clus": ("wl-clustering", "clustering", "PyTorch"), + "gen": ("wl-generation", "generation", "PyTorch"), + "3d_det": ("wl-3d-lidar-detection", "3D LiDAR detection", "Usecases"), + "2d_det": ("wl-2d-lidar-detection", "2D LiDAR detection", "Usecases"), } _DEFAULT_EXAMPLE = "cls" -def _get_example_dir(name: str = "ws-classification", category: str = "PyTorch") -> Path: +def _get_example_dir(name: str = "wl-classification", category: str = "PyTorch") -> Path: """Path to a bundled example directory (under examples//).""" return Path(__file__).parent / 'examples' / category / name @@ -1141,7 +1154,7 @@ def _build_parser() -> argparse.ArgumentParser: ) # metavar lists only the documented commands; the `example` alias is accepted # but intentionally omitted here (and help=SUPPRESS'd below) so it stays hidden. - sub = parser.add_subparsers(dest="command", metavar="{se,ui,start,cli,help}") + sub = parser.add_subparsers(dest="command", metavar="{se,ui,start,cli,tunnel,help}") # weightslab se [--force-certs] [certs_dir] se_parser = sub.add_parser("se", help="Set up the secure environment (TLS certs + gRPC auth token)") @@ -1171,6 +1184,25 @@ def _build_parser() -> argparse.ArgumentParser: cli_parser.add_argument('--host', default=None, help='CLI server host (default: localhost)') + # weightslab tunnel ENDPOINT [--listen-port N] [--listen-host H] [--remote-port N] + tunnel_parser = sub.add_parser( + "tunnel", + help="Forward a remote gRPC backend (e.g. a Colab run via `ngrok tcp 50051`) " + "to a local port for the UI") + tunnel_parser.add_argument( + 'endpoint', nargs='?', default=None, + help="Remote backend endpoint host:port (e.g. bore.pub:12345). " + "A tcp:// prefix is accepted. Default: $WEIGHTSLAB_TUNNEL_ENDPOINT.") + tunnel_parser.add_argument( + '--listen-port', '-p', type=int, default=DEFAULT_LISTEN_PORT, + help=f"Local port to expose (default: {DEFAULT_LISTEN_PORT} — the port the UI's Envoy dials)") + tunnel_parser.add_argument( + '--listen-host', default=None, + help="Local interface to bind (default: 127.0.0.1 on Windows/macOS, 0.0.0.0 on Linux)") + tunnel_parser.add_argument( + '--remote-port', type=int, default=None, + help="Remote port, if not included in ENDPOINT") + # weightslab start example [--cls|--seg|--clus|--gen] start_parser = sub.add_parser("start", help="Start a bundled WeightsLab resource") start_sub = start_parser.add_subparsers(dest="start_target") @@ -1201,6 +1233,9 @@ def main(): parser.print_help() elif args.command == "cli": cli_connect(args) + elif args.command == "tunnel": + from weightslab.tunnel import tunnel_connect + tunnel_connect(args) elif args.command == "se": ui_secure_environment(args) elif args.command == "ui":