From ec666c57dac99e1fe748f2109f7383baf4bcd39b Mon Sep 17 00:00:00 2001 From: Brian Pondi Date: Fri, 5 Jun 2026 16:06:49 +0200 Subject: [PATCH] Harden Docker torch CI builds, add save_ml_model return_model, and refresh LPS ML demos. Retry torch Lantern install with CPU binaries and GHA build cache; let save_ml_model return the model for in-graph chaining; update demo notebooks and outputs. --- .dockerignore | 1 + .github/workflows/docker-compose-workflow.yml | 7 + docker/Dockerfile | 12 +- docker/install-torch.R | 33 +++++ .../01_ml_api_eo_data_cubes.ipynb | 115 ++++++++-------- inst/demo-lps-2025/05_ml_tuning_random.ipynb | 130 +++++++++++++----- .../SENTINEL-2_MSI_20LMR_class_2022-01-05.tif | Bin 1109 -> 1375 bytes .../data/outputs/job-results.json | 2 +- .../data/outputs_random/job-results.json | 2 +- inst/ml/processes.R | 53 ++++++- inst/ml/processes/save_ml_model.json | 25 +++- 11 files changed, 270 insertions(+), 110 deletions(-) create mode 100644 docker/install-torch.R diff --git a/.dockerignore b/.dockerignore index e13b2e4..bd23912 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,7 @@ docker/* !docker/plumber.R !docker/server.R !docker/verify-r-deps.R +!docker/install-torch.R tests/ *.Rproj *.md diff --git a/.github/workflows/docker-compose-workflow.yml b/.github/workflows/docker-compose-workflow.yml index 54990e9..97f7980 100644 --- a/.github/workflows/docker-compose-workflow.yml +++ b/.github/workflows/docker-compose-workflow.yml @@ -7,10 +7,14 @@ on: jobs: test: runs-on: ubuntu-22.04 + timeout-minutes: 360 steps: - name: Check out code uses: actions/checkout@v6 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - name: Login to Docker Hub uses: docker/login-action@v4 with: @@ -22,8 +26,11 @@ jobs: with: context: . file: ./docker/Dockerfile + platforms: linux/amd64 push: true tags: brianpondi/openeocraft:latest + cache-from: type=gha + cache-to: type=gha,mode=max - name: Run docker compose run: docker compose up -d diff --git a/docker/Dockerfile b/docker/Dockerfile index 65901e4..7567646 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -41,11 +41,13 @@ RUN R -e "install.packages(c('devtools'), repos='https://cloud.r-project.org')" RUN R -e "install.packages(c('gdalcubes','plumber','useful','s2','sf','rstac','geojsonsf', 'jsonlite', 'base64enc', 'ids', 'callr' , 'sits'), repos='https://cloud.r-project.org')" # Install torch's Lantern library (required for deep learning models like TempCNN). -# TORCH_INSTALL=1 auto-confirms the interactive prompt. -# The second R call fails the build immediately if install_torch() left Lantern -# absent — without it, install failures are silent and only surface at runtime. -RUN TORCH_INSTALL=1 R -e "torch::install_torch()" && \ - R -e "t <- torch::torch_tensor(1L); stopifnot(as.numeric(t) == 1L); message('Lantern OK')" +# TORCH_INSTALL=1 auto-confirms the interactive prompt; CUDA=cpu avoids GPU binary +# detection on build hosts without NVIDIA drivers. Retries + long timeout help CI +# survive slow downloads (~170 MB libtorch as of torch 0.17). +ENV TORCH_INSTALL=1 +ENV CUDA=cpu +COPY docker/install-torch.R /tmp/install-torch.R +RUN Rscript /tmp/install-torch.R # create directories diff --git a/docker/install-torch.R b/docker/install-torch.R new file mode 100644 index 0000000..0ffc10c --- /dev/null +++ b/docker/install-torch.R @@ -0,0 +1,33 @@ +#!/usr/bin/env Rscript +# Install torch Lantern during Docker build with retries for flaky CI downloads. + +Sys.setenv(TORCH_INSTALL = "1", CUDA = "cpu") + +if (!requireNamespace("torch", quietly = TRUE)) { + install.packages("torch", repos = "https://cloud.r-project.org") +} + +max_attempts <- 3L +ok <- FALSE +for (attempt in seq_len(max_attempts)) { + ok <- tryCatch( + { + torch::install_torch(timeout = 1800) + t <- torch::torch_tensor(1L) + stopifnot(as.numeric(t) == 1L) + TRUE + }, + error = function(e) { + message("install_torch attempt ", attempt, " failed: ", conditionMessage(e)) + FALSE + } + ) + if (ok) break + if (attempt < max_attempts) Sys.sleep(60) +} + +if (!ok) { + stop("install_torch failed after ", max_attempts, " attempts", call. = FALSE) +} + +message("Lantern OK") diff --git a/inst/demo-lps-2025/01_ml_api_eo_data_cubes.ipynb b/inst/demo-lps-2025/01_ml_api_eo_data_cubes.ipynb index fd5b3ce..53c973c 100644 --- a/inst/demo-lps-2025/01_ml_api_eo_data_cubes.ipynb +++ b/inst/demo-lps-2025/01_ml_api_eo_data_cubes.ipynb @@ -22,7 +22,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 23, "id": "3843b5af", "metadata": {}, "outputs": [], @@ -32,7 +32,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 24, "id": "71be7901", "metadata": {}, "outputs": [], @@ -42,7 +42,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 25, "id": "91320354", "metadata": {}, "outputs": [ @@ -52,7 +52,7 @@ "" ] }, - "execution_count": 3, + "execution_count": 25, "metadata": {}, "output_type": "execute_result" } @@ -63,7 +63,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 26, "id": "5fb3d8b7", "metadata": {}, "outputs": [ @@ -79,7 +79,7 @@ " 'cdse-sentinel-2-l2a']" ] }, - "execution_count": 4, + "execution_count": 26, "metadata": {}, "output_type": "execute_result" } @@ -98,7 +98,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 27, "id": "b538a852", "metadata": {}, "outputs": [ @@ -182,7 +182,7 @@ " 'cloud']}}}" ] }, - "execution_count": 5, + "execution_count": 27, "metadata": {}, "output_type": "execute_result" } @@ -193,7 +193,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 28, "id": "5908f1f7", "metadata": {}, "outputs": [ @@ -277,7 +277,7 @@ " 'cloud']}}}" ] }, - "execution_count": 6, + "execution_count": 28, "metadata": {}, "output_type": "execute_result" } @@ -298,7 +298,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 29, "id": "bdefeff4", "metadata": {}, "outputs": [ @@ -361,7 +361,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 30, "id": "f9a9acb5", "metadata": {}, "outputs": [ @@ -416,7 +416,7 @@ " 'rel': 'about'}]}" ] }, - "execution_count": 8, + "execution_count": 30, "metadata": {}, "output_type": "execute_result" } @@ -427,7 +427,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 31, "id": "9e1c9a33", "metadata": {}, "outputs": [ @@ -558,7 +558,7 @@ " 'rel': 'about'}]}" ] }, - "execution_count": 9, + "execution_count": 31, "metadata": {}, "output_type": "execute_result" } @@ -594,7 +594,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 32, "id": "3b8e084b", "metadata": {}, "outputs": [], @@ -610,7 +610,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 33, "id": "db29bbdf", "metadata": {}, "outputs": [], @@ -637,7 +637,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 34, "id": "cc555722", "metadata": {}, "outputs": [], @@ -662,7 +662,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 35, "id": "823e6ba9", "metadata": {}, "outputs": [], @@ -690,7 +690,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 36, "id": "91a5405a", "metadata": {}, "outputs": [], @@ -703,42 +703,42 @@ }, { "cell_type": "markdown", - "id": "58a86897", + "id": "9c0ecc76", "metadata": {}, "source": [ - "## Make Predictions\n", + "## Save the Model\n", "\n", - "Apply the trained model to make predictions on new data.\n" + "Save the trained model for future use" ] }, { "cell_type": "code", - "execution_count": 15, - "id": "7bfa9f13", + "execution_count": 37, + "id": "652b6cb2", "metadata": {}, "outputs": [], "source": [ - "datacube = tempcnn_model.predict(datacube)" + "tempcnn_model = tempcnn_model.save_ml_model(name =\"tempcnn_rondonia_2022\", return_model = True)" ] }, { "cell_type": "markdown", - "id": "cebaa30c", + "id": "58a86897", "metadata": {}, "source": [ - "## Save the Model\n", + "## Make Predictions\n", "\n", - "Save the trained model for future use." + "Apply the trained model to make predictions on new data.\n" ] }, { "cell_type": "code", - "execution_count": 16, - "id": "5db36f29", + "execution_count": 38, + "id": "7bfa9f13", "metadata": {}, "outputs": [], "source": [ - "saved_model = tempcnn_model.save_ml_model(name =\"tempcnn_rondonia_2022\")" + "datacube = tempcnn_model.predict(datacube)" ] }, { @@ -753,7 +753,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 39, "id": "d7c4de9a", "metadata": {}, "outputs": [], @@ -765,7 +765,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 40, "id": "d56dbc8a", "metadata": {}, "outputs": [ @@ -788,15 +788,15 @@ " }\n", " \n", " \n", - " \n", + " \n", " \n", " " ], "text/plain": [ - "" + "" ] }, - "execution_count": 18, + "execution_count": 40, "metadata": {}, "output_type": "execute_result" } @@ -807,7 +807,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 41, "id": "8ca118fb", "metadata": {}, "outputs": [], @@ -820,7 +820,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 42, "id": "57b33b17", "metadata": {}, "outputs": [ @@ -828,24 +828,21 @@ "name": "stdout", "output_type": "stream", "text": [ - "0:00:00 Job '52e671809e04155ac235a3fefc5e7985': send 'start'\n", - "0:00:03 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:00:08 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:00:14 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:00:22 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:00:31 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:00:44 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:00:59 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:01:18 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:01:42 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:02:12 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:02:49 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:03:36 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:04:34 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:05:34 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:06:34 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:07:34 Job '52e671809e04155ac235a3fefc5e7985': running (progress N/A)\n", - "0:08:34 Job '52e671809e04155ac235a3fefc5e7985': finished (progress N/A)\n" + "0:00:00 Job '92958c2545b379cc79335e4f42c8822f': send 'start'\n", + "0:00:04 Job '92958c2545b379cc79335e4f42c8822f': running (progress N/A)\n", + "0:00:09 Job '92958c2545b379cc79335e4f42c8822f': running (progress N/A)\n", + "0:00:15 Job '92958c2545b379cc79335e4f42c8822f': running (progress N/A)\n", + "0:00:23 Job '92958c2545b379cc79335e4f42c8822f': running (progress N/A)\n", + "0:00:32 Job '92958c2545b379cc79335e4f42c8822f': running (progress N/A)\n", + "0:00:45 Job '92958c2545b379cc79335e4f42c8822f': running (progress N/A)\n", + "0:01:00 Job '92958c2545b379cc79335e4f42c8822f': running (progress N/A)\n", + "0:01:19 Job '92958c2545b379cc79335e4f42c8822f': running (progress N/A)\n", + "0:01:43 Job '92958c2545b379cc79335e4f42c8822f': running (progress N/A)\n", + "0:02:13 Job '92958c2545b379cc79335e4f42c8822f': running (progress N/A)\n", + "0:02:50 Job '92958c2545b379cc79335e4f42c8822f': running (progress N/A)\n", + "0:03:37 Job '92958c2545b379cc79335e4f42c8822f': running (progress N/A)\n", + "0:04:35 Job '92958c2545b379cc79335e4f42c8822f': running (progress N/A)\n", + "0:05:35 Job '92958c2545b379cc79335e4f42c8822f': finished (progress N/A)\n" ] } ], @@ -856,7 +853,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 43, "id": "4a07e0f2", "metadata": {}, "outputs": [ @@ -867,7 +864,7 @@ " PosixPath('data/outputs/job-results.json')]" ] }, - "execution_count": 21, + "execution_count": 43, "metadata": {}, "output_type": "execute_result" } diff --git a/inst/demo-lps-2025/05_ml_tuning_random.ipynb b/inst/demo-lps-2025/05_ml_tuning_random.ipynb index df8087f..2849159 100644 --- a/inst/demo-lps-2025/05_ml_tuning_random.ipynb +++ b/inst/demo-lps-2025/05_ml_tuning_random.ipynb @@ -9,9 +9,22 @@ "\n", "This notebook demonstrates hyperparameter tuning using `ml_tune_random`, which samples from parameter distributions rather than exhausting all combinations.\n", "\n", - "Random search is more efficient than grid search for large search spaces, especially when some hyperparameters are more important than others. Cost is roughly `n_iter` full trainings — keep `n_iter` and `epochs` ranges small for quick demos.\n", + "For exhaustive search, see `04_ml_tuning_grid.ipynb`.\n", "\n", - "The process returns a **trained model** (best params, full-data fit); runs are logged in `tuning_results.json`. Use **`save_ml_model`** after tuning to persist the model and attach the tuning trace as an extra STAC `data` asset when that file is present." + "Random search is more efficient than grid search for large search spaces. Each sampled combination runs a **full TempCNN training** for scoring, then the process **re-trains on the full sample set** with the best params. Cost is roughly:\n", + "\n", + "`n_iter × time_one_training + time_final_fit`\n", + "\n", + "Keep `n_iter`, `epochs` ranges, and `verbose` small for quick demos. Deep models may **fall back to CPU** if GPU memory is tight, which increases wall time.\n", + "\n", + "## Process outputs\n", + "\n", + "| Step | Returns / writes |\n", + "| ---- | ---------------- |\n", + "| **`ml_tune_random`** | Trained **ml-model** (best hyperparameters, full-data refit); writes `tuning_results.json` |\n", + "| **`save_ml_model`** (result node) | Job result **`true`/`false`**; writes `models/.rds`, STAC item, and attaches `tuning_results.json` as an extra STAC `data` asset when present |\n", + "\n", + "`job.get_results().download_files()` downloads **`tuning_results.json`** (search trace). The saved **`.rds` model** is on the backend under the job files path — see the download section below." ] }, { @@ -64,13 +77,15 @@ "## Parameter Distributions\n", "\n", "Random search supports several distribution types:\n", - "- **Arrays** - uniform sampling from discrete values\n", - "- **`uniform`** - continuous uniform between min and max\n", - "- **`log_uniform`** - log-uniform distribution (good for learning rates spanning orders of magnitude)\n", - "- **`int_uniform`** - integer uniform distribution\n", - "- **`choice`** - explicit discrete choices from a values array\n", + "- **Arrays** — uniform sampling from discrete values\n", + "- **`uniform`** — continuous uniform between min and max\n", + "- **`log_uniform`** — log-uniform distribution (good for learning rates spanning orders of magnitude)\n", + "- **`int_uniform`** — integer uniform distribution\n", + "- **`choice`** — explicit discrete choices from a values array\n", + "\n", + "**Important for deep learning models:** Always include `epochs` in your parameter distributions. If omitted, the model uses its default (e.g., 150 for TempCNN), which can be very slow — especially when the system falls back to CPU.\n", "\n", - "**Important for deep learning models:** Always include `epochs` in your parameter distributions. If omitted, the model uses its default (e.g., 150 for TempCNN), which can be very slow - especially when the system falls back to CPU due to GPU memory constraints." + "**Hold-out validation:** With `cv: 0` (single split, faster than k-fold), scoring uses `validation_split` (default **0.2** — 20% of samples held out). Set it explicitly in the process graph if you need a different fraction." ] }, { @@ -103,32 +118,35 @@ "name": "stdout", "output_type": "stream", "text": [ - "0:00:00 Job 'a43dfcac908606db2739adb190d53403': send 'start'\n", - "0:00:03 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:00:08 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:00:14 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:00:22 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:00:31 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:00:44 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:00:59 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:01:18 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:01:42 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:02:12 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:02:49 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:03:35 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:04:34 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:05:34 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:06:34 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:07:34 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:08:34 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:09:34 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:10:34 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:11:34 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:12:34 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:13:34 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:14:34 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:15:34 Job 'a43dfcac908606db2739adb190d53403': running (progress N/A)\n", - "0:16:34 Job 'a43dfcac908606db2739adb190d53403': finished (progress N/A)\n" + "0:00:00 Job '312cf908482d1a71c09904414d0e8047': send 'start'\n", + "0:00:04 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:00:09 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:00:15 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:00:23 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:00:33 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:00:45 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:01:01 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:01:20 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:01:43 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:02:13 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:02:51 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:03:37 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:04:35 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:05:35 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:06:35 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:07:35 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:08:35 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:09:35 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:10:35 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:11:35 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:12:35 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:13:35 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:14:35 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:15:35 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:16:35 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:17:35 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:18:36 Job '312cf908482d1a71c09904414d0e8047': running (progress N/A)\n", + "0:19:36 Job '312cf908482d1a71c09904414d0e8047': finished (progress N/A)\n" ] } ], @@ -150,10 +168,11 @@ " \"training_data\": training_set,\n", " \"target\": \"label\",\n", " \"parameters\": param_distributions,\n", - " \"n_iter\": 2, # Number of random samples to evaluate (keep low for testing)\n", + " \"n_iter\": 2, # random samples to evaluate (keep low for testing)\n", " \"scoring\": \"accuracy\",\n", - " \"cv\": 0,\n", - " \"seed\": 42\n", + " \"cv\": 0, # single hold-out split (faster than k-fold)\n", + " \"validation_split\": 0.2,\n", + " \"seed\": 42,\n", " },\n", " },\n", " \"save1\": {\n", @@ -161,6 +180,7 @@ " \"arguments\": {\n", " \"data\": {\"from_node\": \"tune1\"},\n", " \"name\": \"tempcnn-tuned-random\",\n", + " # return_model defaults to false — correct when save is the job result node\n", " },\n", " \"result\": True,\n", " },\n", @@ -175,6 +195,22 @@ "results = job.get_results()" ] }, + { + "cell_type": "markdown", + "id": "955ad3e4", + "metadata": {}, + "source": [ + "## Download results\n", + "\n", + "`download_files()` fetches job STAC assets — here **`tuning_results.json`** (all sampled runs and scores).\n", + "\n", + "The persisted model is written separately as `models/tempcnn-tuned-random.rds` plus a STAC item on the backend. Fetch it via the job files URL (replace `` and auth as needed):\n", + "\n", + "`http://127.0.0.1:8000/files/jobs//models/tempcnn-tuned-random.rds`\n", + "\n", + "Or load later with `connection.load_ml_model()` / `load_stac_ml` depending on your client version." + ] + }, { "cell_type": "code", "execution_count": 6, @@ -196,6 +232,26 @@ "source": [ "results.download_files(\"data/outputs_random/\")" ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "55d2fe37", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Job id: 312cf908482d1a71c09904414d0e8047\n", + "Model file: http://127.0.0.1:8000/files/jobs/312cf908482d1a71c09904414d0e8047/models/tempcnn-tuned-random.rds\n" + ] + } + ], + "source": [ + "print(f\"Job id: {job.job_id}\")\n", + "print(f\"Model file: http://127.0.0.1:8000/files/jobs/{job.job_id}/models/tempcnn-tuned-random.rds\")" + ] } ], "metadata": { diff --git a/inst/demo-lps-2025/data/outputs/SENTINEL-2_MSI_20LMR_class_2022-01-05.tif b/inst/demo-lps-2025/data/outputs/SENTINEL-2_MSI_20LMR_class_2022-01-05.tif index 0bb19e9f2da569d8e16d63d640b905df09dd9ef3..8de540c165209c9b5adc08090fe0455f24ce04e2 100644 GIT binary patch delta 842 zcmV-Q1GW6s2;T~z0SPe!00000002{wkrT5V0T=;)3>g5%<31XoB`GupKij|Q|NsC0 z|NsC0{}7SA|Np=36fXch05t$}BB5{9$wY)Ob#AkHU$8D zWi0vv9w^6{&1QiBp2S5Ll7WCqI6L|=D2SQnjtLf1Dvye z>--unNO^!ZeCV&_0lZcL5bYo(Zv2;uo-*VC^;Q^G03a3olJT&N>zf;8 z@%3N-$_LItFbgGXK$PODj0h;%WDG^WC9Ed{!I@Q2wwA7*kM7-KV(v&Y2%g z_It;SfA6CQFc@<0xq)tI$0kHkVMM0{>aBZ6$^&*%e7vij07eiv&pd$R1a$I$N5c16 zF}>OUBLFm+`1*!Z*(4CqN&t)H0Y?J_2mveV8UIrrWPqt*Byg`|^5Mr+r#yg@fUGI$ z6Q~UbNH>!Qn1umj8E{C+^M?%d0IL`(x5Pxt1F%AQaP$&@(JFdCJYfU~DsMdxSW?j} zIY4H1#X%s;4PdZD^MAj7f1CS;?3e^ap0SPAp00000002{wkrT5V0T=;)ybu6F4;mJ*rI5x=LaT;h7=~dO zhGB4pSnSzO0Yi+y;9l)wJdD*<(07_X4LwbDO}Cn+=}Nl_Lz|{!{V5cD0-aW5v>N%u zQ5@%5Kp=Dn22-Xu3J;m3On`tgL7p-)ugPn*h5Uu1eAX*dUxk3vr~n5iNplbb5s@UN zZ4#h=a4?o8F^-}b2tkk{0z!x}0wOR%LI@EQVf~%~{hPrg519M>fZl^2fd0kpX?B3N z&DS5kEyq~E0hgms63GJ;)<0mGz-NgC+s?+976bH8=W4%f_#fXjKue$n!ExZT_@DLz zv=0D12?H+oMQ>~1v>5mW0lZ7(0l;582-rV=^X&n2l@R13pi+K$fY$oozxyM5JoW7* zA8cOh@&J`103=49Z3U_Lt}kyn;F})cn&rSVxjX<4MSg<}=bySO4+usM8+hUtfI;4Z z1H%I45=!8(OuL@9=?;KT+^!SYLXZEY9dI~rY({2CTmum5=9YlE()aP=_z3XiVSxHKtV<^80Uj9uR*s!r?*y1ac?L13u?&8&Ku} z6$%u5fK0J%1#o_3017qcIm2E28swxU`J7C{x@86`UIXc6U{DQ^M*&)9JyN_5kP6a7 MQ78fc5C8!H02uuAq5uE@ diff --git a/inst/demo-lps-2025/data/outputs/job-results.json b/inst/demo-lps-2025/data/outputs/job-results.json index 9bebacf..17dbc2f 100644 --- a/inst/demo-lps-2025/data/outputs/job-results.json +++ b/inst/demo-lps-2025/data/outputs/job-results.json @@ -1 +1 @@ -{"openeo:status": "running", "type": "Collection", "stac_version": "1.0.0", "id": "52e671809e04155ac235a3fefc5e7985", "title": "Deforestation Prediction in Rondonia", "description": "Using TempCNN model to predict deforestation in Rondonia", "license": "various", "extent": [], "links": [], "assets": {"SENTINEL-2_MSI_20LMR_class_2022-01-05.tif": {"href": "http://127.0.0.1:8000/files/jobs/52e671809e04155ac235a3fefc5e7985/SENTINEL-2_MSI_20LMR_class_2022-01-05.tif?token=YnJpYW4=", "type": {}, "roles": ["data"]}}} \ No newline at end of file +{"openeo:status": "running", "type": "Collection", "stac_version": "1.0.0", "id": "92958c2545b379cc79335e4f42c8822f", "title": "Deforestation Prediction in Rondonia", "description": "Using TempCNN model to predict deforestation in Rondonia", "license": "various", "extent": [], "links": [], "assets": {"SENTINEL-2_MSI_20LMR_class_2022-01-05.tif": {"href": "http://127.0.0.1:8000/files/jobs/92958c2545b379cc79335e4f42c8822f/SENTINEL-2_MSI_20LMR_class_2022-01-05.tif?token=YnJpYW4=", "type": {}, "roles": ["data"]}}} \ No newline at end of file diff --git a/inst/demo-lps-2025/data/outputs_random/job-results.json b/inst/demo-lps-2025/data/outputs_random/job-results.json index 460b946..a2a430e 100644 --- a/inst/demo-lps-2025/data/outputs_random/job-results.json +++ b/inst/demo-lps-2025/data/outputs_random/job-results.json @@ -1 +1 @@ -{"openeo:status": "running", "type": "Collection", "stac_version": "1.0.0", "id": "a43dfcac908606db2739adb190d53403", "title": "TempCNN hyperparameter tuning (random search)", "description": "Random search for TempCNN hyperparameters", "license": "various", "extent": [], "links": [], "assets": {"tuning_results": {"href": "http://127.0.0.1:8000/files/jobs/a43dfcac908606db2739adb190d53403/tuning_results.json?token=YnJpYW4=", "type": "application/json", "roles": ["data"]}}} \ No newline at end of file +{"openeo:status": "running", "type": "Collection", "stac_version": "1.0.0", "id": "312cf908482d1a71c09904414d0e8047", "title": "TempCNN hyperparameter tuning (random search)", "description": "Random search for TempCNN hyperparameters", "license": "various", "extent": [], "links": [], "assets": {"tuning_results": {"href": "http://127.0.0.1:8000/files/jobs/312cf908482d1a71c09904414d0e8047/tuning_results.json?token=YnJpYW4=", "type": "application/json", "roles": ["data"]}}} \ No newline at end of file diff --git a/inst/ml/processes.R b/inst/ml/processes.R index 285ebf9..64c483e 100644 --- a/inst/ml/processes.R +++ b/inst/ml/processes.R @@ -280,6 +280,36 @@ stop(err, call. = FALSE) } +# Coerce openEO / JSON boolean arguments to a strict logical scalar. +.openeocraft_as_bool <- function(x, default = FALSE) { + if (base::is.null(x)) { + return(default) + } + if (base::is.logical(x) && base::length(x) == 1L) { + return(base::isTRUE(x)) + } + if (base::is.character(x) && base::length(x) == 1L) { + normalized <- base::tolower(base::trimws(x)) + if (normalized %in% base::c("true", "t", "1", "yes")) { + return(TRUE) + } + if (normalized %in% base::c("false", "f", "0", "no")) { + return(FALSE) + } + } + if (base::is.numeric(x) && base::length(x) == 1L) { + return(x != 0) + } + default +} + +# Detect untrained openEO model specs (mlm_class_* lists), not sits_train output. +.openeocraft_is_ml_model_spec <- function(x) { + base::is.list(x) && + "train" %in% base::names(x) && + base::is.function(x$train) +} + # Wrap sits_train with start/finish messages and elapsed wall time. # # sits/torch often drives txtProgressBar to stderr; openEO job logs may show long @@ -1863,6 +1893,7 @@ ml_fit <- function(model, training_set, target = "label") { training_obj, ml_args_for_hyperparameters = NULL ) + trained_model } # Classify a cube with a trained model and apply label layer (openEO). @@ -3540,17 +3571,19 @@ import_ml_model <- function(name, folder) { # Args: # data: Trained model with optional mlm_* attributes from ml_fit. # name: Model id / filename stem; options: MLM overrides and optional code_asset. +# return_model: If TRUE, return `data` after save (for in-graph chaining); else TRUE/FALSE. # # Returns: -# TRUE on success; FALSE on non-HTTP runtime errors inside tryCatch. +# TRUE/FALSE when return_model is FALSE; input model when return_model is TRUE and save succeeds. # # Raises: # openeocraft::api_stop on validation; rethrows api errors from inner tryCatch. # #* @openeo-process -save_ml_model <- function(data, name, options = list()) { +save_ml_model <- function(data, name, options = list(), return_model = FALSE) { base::message("[save_ml_model] START") base::on.exit(base::message("[save_ml_model] END")) + return_model <- .openeocraft_as_bool(return_model, default = FALSE) # Input validation if (!base::is.character(name) || base::length(name) != 1 || base::nchar(name) == 0) { @@ -3561,6 +3594,15 @@ save_ml_model <- function(data, name, options = list()) { if (base::is.null(data)) { openeocraft::api_stop(400, "Parameter 'data' must be a non-null model object") } + if (.openeocraft_is_ml_model_spec(data)) { + openeocraft::api_stop( + 400, + paste0( + "Parameter 'data' must be a trained model from ml_fit(), ", + "not an untrained model specification" + ) + ) + } # --- Infer MLM metadata from model attributes (set by ml_fit) --- # Task: inferred from the mlm_class_*/mlm_regr_* function that created the model @@ -3990,6 +4032,13 @@ save_ml_model <- function(data, name, options = list()) { auto_unbox = TRUE ) + if (return_model) { + saved_model <- base::readRDS(file) + base::attr(saved_model, "mlm:save_name") <- name + base::attr(saved_model, "mlm:save_href") <- model_href + return(saved_model) + } + return(TRUE) }, error = function(e) { diff --git a/inst/ml/processes/save_ml_model.json b/inst/ml/processes/save_ml_model.json index df87c66..0baa432 100644 --- a/inst/ml/processes/save_ml_model.json +++ b/inst/ml/processes/save_ml_model.json @@ -1,7 +1,7 @@ { "id": "save_ml_model", "summary": "Save a ML model", - "description": "Saves a machine learning model as part of a batch job.\n\nThe model will be accompanied by a separate STAC Item that implements the [mlm-model extension](https://github.com/stac-extensions/mlm).\n\nIf `tuning_results.json` is present in the job directory (e.g. after `ml_tune_grid` or `ml_tune_random` in the same job), it is copied alongside the model and registered as an additional STAC asset with role `data`. That asset is ordinary STAC metadata and is not defined by the MLM extension.", + "description": "Saves a machine learning model as part of a batch job.\n\nThe model will be accompanied by a separate STAC Item that implements the [mlm-model extension](https://github.com/stac-extensions/mlm).\n\nIf `tuning_results.json` is present in the job directory (e.g. after `ml_tune_grid` or `ml_tune_random` in the same job), it is copied alongside the model and registered as an additional STAC asset with role `data`. That asset is ordinary STAC metadata and is not defined by the MLM extension.\n\nSet ``return_model`` to ``true`` when ``save_ml_model`` is followed by processes such as ``ml_predict()`` in the same process graph. The default (``false``) returns a boolean success flag, which is appropriate when saving is the job result.", "categories": [ "machine learning", "import" @@ -32,13 +32,28 @@ }, "default": {}, "optional": true + }, + { + "name": "return_model", + "description": "If ``true``, return the input model after a successful save so it can be chained (for example with ``ml_predict()``) in the same process graph. If ``false`` (default), return ``true`` on success or ``false`` on failure.", + "schema": { + "type": "boolean" + }, + "default": false, + "optional": true } ], "returns": { - "description": "Returns `false` if the process failed to store the model, `true` otherwise.", - "schema": { - "type": "boolean" - } + "description": "If ``return_model`` is ``false`` (default), returns ``true`` on success or ``false`` on failure. If ``return_model`` is ``true``, returns the saved ml-model on success.", + "schema": [ + { + "type": "boolean" + }, + { + "type": "object", + "subtype": "ml-model" + } + ] }, "links": [ {