diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 875736b..3202216 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,43 +1,3 @@ -# SPDX-FileCopyrightText: 2026 bartzbeielstein -# -# SPDX-License-Identifier: AGPL-3.0-or-later -# -# ============================================================================= -# .github/workflows/ci.yml — Continuous Integration (Optimised v2) -# ============================================================================= -# -# Performance optimisations (cumulative with v1): -# 1. Concurrency groups → auto-cancel superseded runs on same branch -# 2. Path-based filtering → skip tests entirely for docs-only changes -# 3. 8-shard matrix → 127 test files ÷ 8 ≈ 16 files per shard -# 4. pytest-xdist (-n auto) → each shard uses all available cores (~4×) -# 5. CPU-only PyTorch → ~800 MB smaller install, 2-3 min saved -# 6. Full .venv caching → near-instant installs on cache hit -# 7. Ruff-only lint → single tool replaces black + isort + ruff -# 8. Job-level timeouts → prevent stuck tests from burning hours -# 9. Pytest --timeout → per-test 5-minute guard via pytest-timeout -# 10. Consolidated quality job → lint + security in one job, one venv -# -# Jobs: -# changes — detect which paths changed (gate for test/quality) -# reuse — REUSE licence compliance (always) -# test — pytest + coverage upload, 8 shards (skip for docs-only) -# coverage — merge shard reports & upload to Codecov -# quality — ruff + bandit (skip for docs-only, non-blocking) -# ============================================================================= -# -# Jobs: -# reuse — REUSE licence compliance (PR + push) -# test — pytest + coverage upload (PR + push, Python 3.13) -# lint — black / isort / ruff formatting checks (non-blocking) -# security — safety + bandit vulnerability scans (non-blocking) -# -# Note: Linear-history enforcement (no merge commits) is handled by GitHub's -# built-in "Require linear history" branch-protection rule, which is faster, -# more reliable, and blocks the merge button before CI even starts. -# The previous `no-merge-commits` CI job has been removed. -# ============================================================================= - name: CI Tests on: @@ -50,224 +10,146 @@ on: - main - develop -# Cancel in-flight runs on the same branch/PR — the single biggest time saver -# when multiple pushes happen in quick succession. +# Principle of Least Privilege: Base permissions are read-only +permissions: read-all + +# Cancel stale runs when a new push arrives on the same branch or PR. concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: - contents: read - -env: - PYTHON_VERSION: "3.13" - # PyTorch CPU-only index is configured in pyproject.toml [tool.uv.sources] - jobs: - - # --------------------------------------------------------------------------- - # Detect changed paths to gate downstream jobs - # --------------------------------------------------------------------------- - changes: - name: Detect Changes - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - outputs: - code: ${{ steps.filter.outputs.code }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Check changed paths - uses: dorny/paths-filter@9d7afb8d214ad99e78fbd4247752c4caed2b6e4c # v4.0.0 - id: filter - with: - filters: | - code: - - 'src/**' - - 'tests/**' - - 'pyproject.toml' - - 'uv.lock' - - '.github/workflows/ci.yml' - - # --------------------------------------------------------------------------- - # REUSE licence compliance (always runs — fast, no deps) - # --------------------------------------------------------------------------- reuse: name: REUSE Compliance runs-on: ubuntu-latest permissions: contents: read + steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up uv - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@e58605a9b6da7c637471fab8847a5e5a6b8df081 # v5.2.2 - name: Run reuse lint - run: uv tool run reuse lint + run: | + uv tool run reuse lint - # --------------------------------------------------------------------------- - # Test matrix — 8 shards, each running pytest-xdist on all available cores - # --------------------------------------------------------------------------- test: - name: "Test (shard ${{ matrix.shard }}/8)" + name: Test on Python ${{ matrix.python-version }} runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.code == 'true' - timeout-minutes: 90 permissions: contents: read + id-token: write strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4, 5, 6, 7, 8] + python-version: ["3.13"] steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - python-version: ${{ env.PYTHON_VERSION }} + fetch-depth: 0 - - name: Set up uv - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - enable-cache: true - cache-dependency-glob: "uv.lock" + python-version: ${{ matrix.python-version }} - # Cache the full .venv so subsequent runs skip dependency resolution - - name: Cache virtual environment - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 - with: - path: .venv - key: venv-test-${{ runner.os }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('uv.lock') }} - restore-keys: | - venv-test-${{ runner.os }}-py${{ env.PYTHON_VERSION }}- + - name: Set up uv + uses: astral-sh/setup-uv@e58605a9b6da7c637471fab8847a5e5a6b8df081 # v5.2.2 - name: Install dependencies - run: uv sync --all-extras --group dev - - # Simple modulo-based file assignment — no pytest --collect-only overhead - # (saves 30–60 s per shard × 8 shards ≈ 4–8 min of wasted collection time). - - name: Select tests for shard ${{ matrix.shard }} - id: shard run: | - shard=${{ matrix.shard }} - num_shards=8 - - mapfile -t all_files < <(ls tests/test_*.py 2>/dev/null | sort) - shard_files=() - for i in "${!all_files[@]}"; do - if (( (i % num_shards) + 1 == shard )); then - shard_files+=("${all_files[$i]}") - fi - done - - if [ ${#shard_files[@]} -eq 0 ]; then - echo "files=" >> "$GITHUB_OUTPUT" - echo "skip=true" >> "$GITHUB_OUTPUT" - echo "::notice::Shard ${shard}: no test files assigned" - else - echo "files=${shard_files[*]}" >> "$GITHUB_OUTPUT" - echo "skip=false" >> "$GITHUB_OUTPUT" - echo "::notice::Shard ${shard}: ${#shard_files[@]} test files" - fi + uv pip install --system -e ".[dev]" - - name: Run pytest (shard ${{ matrix.shard }}) - if: steps.shard.outputs.skip != 'true' + - name: Run pytest run: | - uv run pytest ${{ steps.shard.outputs.files }} \ - -n auto \ - --dist loadfile \ - --tb=short \ - --timeout=900 \ - --cov=src/spotoptim \ - --cov-report=xml:coverage-shard${{ matrix.shard }}.xml \ - --cov-report=term + uv run pytest tests/ -v -n auto --tb=short --cov=src/spotoptim --cov-branch --cov-report=xml --cov-report=term - - name: Upload shard coverage - if: steps.shard.outputs.skip != 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - name: Upload results to Codecov + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 with: - name: coverage-shard-${{ matrix.shard }} - path: coverage-shard${{ matrix.shard }}.xml - retention-days: 1 + token: ${{ secrets.CODECOV_TOKEN }} + slug: sequential-parameter-optimization/spotoptim + files: ./coverage.xml + fail_ci_if_error: false - # --------------------------------------------------------------------------- - # Merge coverage from all shards and upload to Codecov - # --------------------------------------------------------------------------- - coverage: - name: Upload Coverage + lint: + name: Code Quality runs-on: ubuntu-latest - needs: test - if: always() && needs.test.result == 'success' - permissions: - contents: read steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Download all coverage artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - pattern: coverage-shard-* - merge-multiple: true + python-version: "3.13" - - name: Upload coverage to Codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 - with: - files: coverage-shard1.xml,coverage-shard2.xml,coverage-shard3.xml,coverage-shard4.xml,coverage-shard5.xml,coverage-shard6.xml,coverage-shard7.xml,coverage-shard8.xml - fail_ci_if_error: false + - name: Set up uv + uses: astral-sh/setup-uv@e58605a9b6da7c637471fab8847a5e5a6b8df081 # v5.2.2 + + - name: Install dependencies + run: | + uv pip install --system -e ".[dev]" + + - name: Check formatting with black + run: | + black --check src/ tests/ + continue-on-error: true + + - name: Check imports with isort + run: | + isort --check-only src/ tests/ + continue-on-error: true + + - name: Lint with ruff + run: | + ruff check src/ tests/ + continue-on-error: true - # --------------------------------------------------------------------------- - # Code quality + security (consolidated, non-blocking) - # --------------------------------------------------------------------------- - quality: - name: Code Quality & Security + security: + name: Security Scan runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.code == 'true' - timeout-minutes: 10 permissions: contents: read + security-events: write steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ env.PYTHON_VERSION }} + python-version: "3.13" - name: Set up uv - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 - with: - enable-cache: true - cache-dependency-glob: "uv.lock" + uses: astral-sh/setup-uv@e58605a9b6da7c637471fab8847a5e5a6b8df081 # v5.2.2 - # Lint/security only needs dev tools, not the full project with torch - - name: Install lint tools only - run: uv sync --group dev --no-install-project + - name: Install dependencies + run: | + uv pip install --system -e ".[dev]" - - name: Check formatting (ruff format) - run: uv run ruff format --check src/ tests/ + - name: Check dependencies with safety + run: | + safety check --json continue-on-error: true - - name: Lint (ruff check) - run: uv run ruff check src/ tests/ + - name: Security scan with bandit + run: | + bandit -r src/spotoptim/ -f sarif -o bandit-report.sarif continue-on-error: true - - name: Static security analysis (bandit) - # --exit-zero: exit 0 even when issues are found so the step stays green. - # Findings are still printed to the log; this job is advisory only. - run: uv run bandit -r src/spotoptim/ --exit-zero + - name: Upload bandit report to security tab + if: always() + uses: github/codeql-action/upload-sarif@c6f931105cb2c34c8f901cc885ba1e2e259cf745 # v4.34.0 + with: + sarif_file: bandit-report.sarif + category: bandit continue-on-error: true diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 101f001..7e5e175 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,35 +1,17 @@ # SPDX-FileCopyrightText: 2026 bartzbeielstein # -# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-License-Identifier: GPL-2.0-or-later # # ============================================================================= -# .github/workflows/docs.yml — Documentation Build & Deploy (Optimised v2) +# .github/workflows/docs.yml — Documentation Build & Deploy # ============================================================================= -# -# Performance optimisations (cumulative with v1): -# 1. Concurrency groups → cancel stale doc builds on same branch -# 2. Quarto _freeze/ cache → skip re-executing unchanged notebooks -# 3. Broader restore-keys → partial cache hits still save time -# 4. Full .venv caching → near-instant installs on cache hit -# 5. CPU-only PyTorch → avoids 2 GB CUDA download -# 6. Removed `quarto check` → saves ~30s, not needed for builds -# 7. Path filters → only trigger when docs/code actually change -# 8. Job-level timeout → prevents runaway notebook execution -# 9. Split build/deploy → deploy is instant; build can be parallelised -# # Pipeline: -# build-docs: -# 1. Checkout (full history for cross-refs) -# 2. Set up Python + uv (with venv cache) -# 3. Install Python dependencies (CPU-only torch) -# 4. Install Quarto CLI (pinned) -# 5. Restore _freeze/ cache from prior runs -# 6. Generate API reference with quartodoc -# 7. Render Quarto site (incremental, using freeze) -# 8. Upload _site artifact -# deploy (main only): -# 9. Download _site artifact -# 10. Deploy to gh-pages +# 1. Checkout +# 2. Set up Python + uv +# 3. Install Python dependencies +# 4. Install Quarto CLI (PINNED) +# 5. Generate API reference with quartodoc +# 6. Render Quarto site & publish to rh-pages # ============================================================================= name: Documentation @@ -37,139 +19,77 @@ name: Documentation on: push: branches: - - main - - develop - paths: - - 'docs/**' - - '_quarto.yml' - - 'src/spotoptim/**' - - 'pyproject.toml' - - '.github/workflows/docs.yml' - pull_request: - branches: - - main + - main # Production docs only; develop pushes are not deployed. paths: - - 'docs/**' + - '**/*.qmd' - '_quarto.yml' - 'src/spotoptim/**' - 'pyproject.toml' workflow_dispatch: -# Cancel in-flight doc builds on the same branch -concurrency: - group: docs-${{ github.ref }} - cancel-in-progress: true +permissions: read-all -permissions: - contents: read +# Prevent concurrent deployments from overwriting each other on gh-pages. +# A docs build can take several minutes; let it finish rather than cancel. +concurrency: + group: docs-deploy + cancel-in-progress: false env: + # ⚠️ Pinned to match production safety standards. QUARTO_VERSION: "1.8.27" PYTHON_VERSION: "3.13" - # PyTorch CPU-only index is configured in pyproject.toml [tool.uv.sources] jobs: - build-docs: - name: Build Documentation + deploy-docs: + name: Build & Deploy Documentation runs-on: ubuntu-latest - timeout-minutes: 60 permissions: - contents: read + contents: write # push access via gh-pages steps: - name: Checkout (full history) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2 with: - fetch-depth: 0 + fetch-depth: 0 # Full history ensures cross-referencing interlinks work safely - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5.3.0 with: python-version: ${{ env.PYTHON_VERSION }} - name: Set up uv - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 - with: - enable-cache: true - cache-dependency-glob: "uv.lock" - - # Cache the full .venv to avoid rebuilding torch/xgboost/scipy - - name: Cache virtual environment - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 - with: - path: .venv - key: venv-docs-${{ runner.os }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('uv.lock') }} - restore-keys: | - venv-docs-${{ runner.os }}-py${{ env.PYTHON_VERSION }}- + uses: astral-sh/setup-uv@e58605a9b6da7c637471fab8847a5e5a6b8df081 # v5.2.2 - name: Install Python dependencies - run: uv sync --all-extras --group dev + run: | + # Use pyproject.toml [dev] and docs extras to install quartodoc safely + uv sync --group dev --extra docs - name: Install Quarto CLI ${{ env.QUARTO_VERSION }} uses: quarto-dev/quarto-actions/setup@8a96df13519ee81fd526f2dfca5962811136661b # v2.1.2 with: version: ${{ env.QUARTO_VERSION }} - # Restore Quarto _freeze/ cache (executed notebook outputs). - # This is the single biggest performance win for docs: unchanged - # notebooks are not re-executed, saving potentially hours of compute. - # Use a two-tier key: exact match on content, fallback to any prior freeze. - - name: Restore Quarto freeze cache - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 - with: - path: _freeze - key: quarto-freeze-${{ runner.os }}-${{ hashFiles('docs/**/*.qmd', 'src/spotoptim/**/*.py') }} - restore-keys: | - quarto-freeze-${{ runner.os }}- + - name: Verify Quarto installation + run: quarto check - name: Generate API reference with quartodoc + # Rebuild dynamically instead of keeping autogenerated `.qmd` tracked into git run: | uv run python docs/quartodoc_build.py uv run quartodoc interlinks - # Render WITHOUT --no-cache so Quarto uses _freeze/ for unchanged pages - - name: Render Quarto site - run: uv run quarto render - - # Upload _site as artifact for the deploy job (and for PR preview) - - name: Upload site artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: docs-site - path: _site - retention-days: 3 - - # --------------------------------------------------------------------------- - # Deploy — only from main branch, downloads pre-built site - # --------------------------------------------------------------------------- - deploy: - name: Deploy to GitHub Pages - runs-on: ubuntu-latest - needs: build-docs - if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Install Quarto CLI ${{ env.QUARTO_VERSION }} - uses: quarto-dev/quarto-actions/setup@8a96df13519ee81fd526f2dfca5962811136661b # v2.1.2 - with: - version: ${{ env.QUARTO_VERSION }} - - - name: Download site artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: docs-site - path: _site + - name: Render Quarto site + run: uv run quarto render --no-cache - name: Deploy to GitHub Pages uses: quarto-dev/quarto-actions/publish@8a96df13519ee81fd526f2dfca5962811136661b # v2.1.2 with: target: gh-pages path: _site - render: "false" + render: "false" # Handled in prior step env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f07703..272f081 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,105 +1,207 @@ -# SPDX-FileCopyrightText: 2026 bartzbeielstein -# -# SPDX-License-Identifier: AGPL-3.0-or-later -# -# ============================================================================= -# .github/workflows/release.yml — Semantic Release on push to main -# ============================================================================= -# -# Triggered on every push to main (typically via merged PR from develop). -# semantic-release inspects commit messages and only creates a release when -# it finds feat:, fix:, perf:, or other configured types. -# -# Requirements: -# - Repository secret SEMANTIC_RELEASE_TOKEN: a PAT with Contents: Read -# and write on this repository. Passed to actions/checkout so git push -# and the GitHub API both work automatically. -# - PyPI Trusted Publisher configured for this repository (OIDC, no token). -# ============================================================================= - name: Release on: push: - branches: [main] - workflow_dispatch: + branches: + - main + - develop + +# Principle of Least Privilege: Base permissions are read-only +permissions: read-all +# Serialize releases per-branch; never cancel an in-progress release. concurrency: - group: release + group: release-${{ github.ref }} cancel-in-progress: false -permissions: - contents: read - jobs: release: - name: Semantic Release + name: Create Release runs-on: ubuntu-latest - timeout-minutes: 15 - # Skip release commits to prevent infinite loops - if: "!contains(github.event.head_commit.message, 'chore(release)')" permissions: contents: write issues: write pull-requests: write id-token: write + if: "!contains(github.event.head_commit.message, 'chore(release)')" steps: - # The token: parameter does two things: - # 1. Configures git credentials so semantic-release can push tags/commits - # 2. Authenticates the GitHub API calls semantic-release makes - # This is the standard, battle-tested approach — no manual credential setup. - - name: Checkout - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - token: ${{ secrets.SEMANTIC_RELEASE_TOKEN }} + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.13" - name: Set up uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - cache-dependency-glob: "uv.lock" + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - - name: Install build tools - run: uv sync --no-install-project --group dev + - name: Install dependencies + run: | + uv pip install --system build + uv pip install --system -e ".[dev,docs]" + + - name: Run tests + run: | + uv run pytest tests/ -v -n auto + + - name: Verify package name in pyproject.toml + run: | + grep -q "name = \"spotoptim\"" pyproject.toml || (echo "ERROR: Package name mismatch in pyproject.toml" && exit 1) + echo "✓ Package name verified: spotoptim" + + - name: Build distribution packages + run: | + rm -rf dist/ build/ + uv build + echo "Build artifacts created:" + ls -lah dist/ - - name: Set up Node.js - uses: actions/setup-node@v4 + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 - - name: Install semantic-release - run: | - npm install -g semantic-release@23 \ - @semantic-release/git@10 \ - @semantic-release/changelog@6 \ - @semantic-release/exec@6 \ + - name: Semantic Release + uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6.0.0 + with: + extra_plugins: | + @semantic-release/git@10 + @semantic-release/changelog@6 + @semantic-release/exec@6 conventional-changelog-conventionalcommits@7 - - - name: Run semantic-release env: GITHUB_TOKEN: ${{ secrets.SEMANTIC_RELEASE_TOKEN }} - GH_TOKEN: ${{ secrets.SEMANTIC_RELEASE_TOKEN }} - run: npx semantic-release - - - name: Check if release generated dist/ - id: check_dist - run: | - if [ -d "dist" ]; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - name: Publish to PyPI - if: steps.check_dist.outputs.exists == 'true' - uses: pypa/gh-action-pypi-publish@release/v1 + if: success() + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 with: packages-dir: dist/ skip-existing: true + # Note: No token needed with Trusted Publishers (OIDC) + + - name: Install Quarto CLI + if: success() + uses: quarto-dev/quarto-actions/setup@8a96df13519ee81fd526f2dfca5962811136661b # v2.2.0 + with: + version: "1.7.29" + + - name: Generate API reference stubs + if: success() + run: | + uv run --extra docs python docs/quartodoc_build.py + uv run --extra docs quartodoc interlinks + + - name: Build documentation + if: success() + run: | + uv run quarto render --no-cache + + - name: Setup Git Credentials for Publish + if: success() + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git + + - name: Deploy documentation + if: success() + uses: quarto-dev/quarto-actions/publish@8a96df13519ee81fd526f2dfca5962811136661b # v2.2.0 + with: + target: gh-pages + path: _site + render: "false" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify PyPI Release + if: success() + run: | + echo "Release completed successfully!" + echo "Verifying package on PyPI..." + for i in {1..30}; do + if python -c "import urllib.request; urllib.request.urlopen('https://pypi.org/project/spotoptim/')" 2>/dev/null; then + echo "✓ Package found on PyPI" + break + fi + if [ $i -lt 30 ]; then + echo "Waiting for PyPI to sync (attempt $i/30)..." + sleep 10 + fi + done + + # --------------------------------------------------------------------------- + # Back-merge: keep develop in sync after every main release + # --------------------------------------------------------------------------- + # Why: semantic-release commits CHANGELOG.md + pyproject.toml back to main. + # Without this job, develop diverges and the next develop→main PR conflicts. + # Strategy: use the built-in GITHUB_TOKEN (no extra secret needed) to open a + # PR via the pre-installed `gh` CLI. If branch-protection requires a review + # the PR waits; otherwise it can be auto-merged by the repo owner. The step + # is idempotent: if main is already merged into develop, or a PR already + # exists, it exits gracefully. + back-merge: + name: Back-merge main → develop + runs-on: ubuntu-latest + needs: release + # Only needed after production releases on main, not for rc on develop. + if: github.ref == 'refs/heads/main' + permissions: + contents: write # required to push/read branches + pull-requests: write # required to open the PR + + steps: + - name: Checkout (full history) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + # Use the default GITHUB_TOKEN; gh CLI picks it up via GH_TOKEN below. + + - name: Check whether a back-merge is needed + id: sync-check + run: | + git fetch origin develop + AHEAD=$(git rev-list --count origin/develop..HEAD) + echo "main is ${AHEAD} commit(s) ahead of develop" + echo "needed=${AHEAD}" >> "$GITHUB_OUTPUT" + + - name: Open back-merge PR (main → develop) + if: steps.sync-check.outputs.needed != '0' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + + # Idempotency: skip if an open PR already targets develop from main. + EXISTING=$(gh pr list \ + --base develop --head main \ + --state open \ + --json number \ + --jq '.[0].number' 2>/dev/null || true) + + if [ -n "$EXISTING" ]; then + echo "Back-merge PR #${EXISTING} already open. Nothing to do." + exit 0 + fi + + gh pr create \ + --base develop \ + --head main \ + --title "chore: back-merge release ${VERSION} (main → develop)" \ + --body "$(cat <<'EOF' + Automated back-merge created by the Release workflow. + + Release ${VERSION} committed updated files (CHANGELOG.md, pyproject.toml) + back to main. This PR syncs those changes into develop so the next + develop → main pull request has no conflicts. + + Merge this PR as soon as convenient. + EOF + )" + + echo "✓ Back-merge PR created for release ${VERSION}" diff --git a/.github/workflows/sync-develop.yml b/.github/workflows/sync-develop.yml deleted file mode 100644 index 3557004..0000000 --- a/.github/workflows/sync-develop.yml +++ /dev/null @@ -1,100 +0,0 @@ -# SPDX-FileCopyrightText: 2026 bartzbeielstein -# -# SPDX-License-Identifier: AGPL-3.0-or-later -# -# ============================================================================= -# .github/workflows/sync-develop.yml — Auto-sync main → develop -# ============================================================================= -# -# WHY THIS EXISTS -# --------------- -# develop → main PRs use squash-merge, which collapses all develop commits -# into a single new commit on main. From that moment, main and develop share -# no common ancestor for the files that were changed, so git treats every -# subsequent PR as a three-way conflict on those lines — even when the same -# person made all the changes. -# -# This workflow eliminates that by merging main back into develop immediately -# after every push to main, re-establishing the common ancestor before -# divergence can accumulate. -# -# TRIGGERS -# -------- -# push to main — handles PR merges and Dependabot updates instantly. -# schedule (06:00 UTC daily) — fallback for pushes that carry [skip ci] -# in their commit message (e.g. semantic-release chore -# commits), which GitHub silently skips all workflow -# triggers for. -# workflow_dispatch — manual trigger for on-demand syncs. -# -# CONFLICT BEHAVIOUR -# ------------------ -# If a genuine merge conflict exists (real code divergence, not a squash -# artefact), the job exits non-zero with instructions. Resolve manually: -# git fetch origin && git merge origin/main -# ============================================================================= - -name: Sync main → develop - -on: - push: - branches: [main] - schedule: - - cron: '0 6 * * *' - workflow_dispatch: - -# Never cancel a sync mid-flight — a partial merge left dangling is worse -# than waiting for the previous run to finish. -concurrency: - group: sync-develop - cancel-in-progress: false - -permissions: - contents: write - -jobs: - sync: - name: Merge main into develop - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout develop - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: develop - fetch-depth: 0 - # Explicit token so git push origin develop is authorised. - # Pushes via GITHUB_TOKEN intentionally do NOT re-trigger workflows - # on develop (GitHub's built-in loop prevention) — correct behaviour - # here because the incoming code already passed CI on main. - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Configure Git identity - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Check whether a sync is needed - id: check - run: | - git fetch origin main - behind=$(git rev-list --count HEAD..origin/main) - echo "behind=${behind}" >> "$GITHUB_OUTPUT" - if [ "${behind}" -eq 0 ]; then - echo "::notice::develop is already up-to-date with main — nothing to do" - else - echo "::notice::develop is ${behind} commit(s) behind main — syncing now" - fi - - - name: Merge main into develop - if: steps.check.outputs.behind != '0' - run: | - git merge origin/main \ - -m "chore: sync main into develop [skip ci]" \ - || { - echo "::error::Merge conflict detected — manual resolution required." - echo "::error::Run locally: git fetch origin && git merge origin/main" - exit 1 - } - git push origin develop diff --git a/docs/optimize_parallel.qmd b/docs/optimize_parallel.qmd index cefda30..0ac7183 100644 --- a/docs/optimize_parallel.qmd +++ b/docs/optimize_parallel.qmd @@ -164,9 +164,9 @@ The `_surrogate_lock` (a `threading.Lock`) is used in both configurations to ser concurrent surrogate reads and refits. ```{python} -from spotoptim.SpotOptim import _is_gil_disabled +from spotoptim.SpotOptim import is_gil_disabled -result = _is_gil_disabled() +result = is_gil_disabled() print(f"GIL disabled: {result}") assert isinstance(result, bool) print("GIL detection check passed.") @@ -324,19 +324,19 @@ print("initial postprocessing check passed.") --- -## Step 8 — First Surrogate Fit (`_fit_scheduler()`) +## Step 8 — First Surrogate Fit (`fit_scheduler()`) ```python # No lock needed — no search threads active yet -self._fit_scheduler() +self.fit_scheduler() ``` After the initial postprocessing, the surrogate model is fitted to the complete initial design for the first time. No surrogate lock is acquired here because the `search_pool` has not yet been populated: -this is the only point in the parallel path where `_fit_scheduler()` is called without +this is the only point in the parallel path where `fit_scheduler()` is called without holding `_surrogate_lock`. -`_fit_scheduler()` selects the most recent `window_size` training points according to +`fit_scheduler()` selects the most recent `window_size` training points according to `selection_method`, fits the surrogate, and prepares it for acquisition-function queries. When a list of surrogates was specified at construction, one is chosen probabilistically @@ -614,7 +614,7 @@ print("future routing check passed.") --- -## Step 15 — Batch Evaluation Processing (`update_success_rate()`, `_update_storage_steady()`, `_fit_scheduler()`) +## Step 15 — Batch Evaluation Processing (`update_success_rate()`, `_update_storage_steady()`, `fit_scheduler()`) ```python for xi, yi in zip(X_done, y_done): @@ -622,7 +622,7 @@ for xi, yi in zip(X_done, y_done): self._update_storage_steady(xi, yi) self.n_iter_ += 1 with _surrogate_lock: - self._fit_scheduler() + self.fit_scheduler() ``` When a batch evaluation completes successfully, the main thread processes every point in @@ -633,7 +633,7 @@ For each point, `update_success_rate()` records whether the new value improves o `best_y_` if an improvement is found, and synchronises `min_y` and `min_X`. `n_iter_` is incremented once per point so that it reflects the total number of post-initial-design evaluations. -After all points in the batch have been stored, `_fit_scheduler()` is called once under +After all points in the batch have been stored, `fit_scheduler()` is called once under `_surrogate_lock`, refitting the surrogate on the updated training window. Batching the refit in this way — one call per batch rather than one call per point — improves efficiency when `eval_batch_size > 1` and ensures that in-flight search @@ -712,14 +712,14 @@ print("termination check passed.") | 5 | Phase 1 submission | Store injected points directly; submit remaining `n_to_submit` points to `eval_pool` | | 6 | `_update_storage_steady()` | Collect initial results; append each valid `(x, y)` to storage | | 7 | `_init_tensorboard()`, `update_stats()`, `get_best_xy_initial_design()` | Log initial design; compute statistics; identify initial best | -| 8 | `_fit_scheduler()` | First surrogate fit (no lock needed, no search threads active) | +| 8 | `fit_scheduler()` | First surrogate fit (no lock needed, no search threads active) | | 9 | `optimize_steady_state()` while loop | Main loop: iterate until budget or time exhausted | | 10 | `_batch_ready()` | Check whether `pending_cands` should be flushed | | 11 | `_flush_batch()` | Dispatch all pending candidates as one batch eval to `eval_pool` | | 12 | `_thread_search_task()` slot fill | Submit up to `n_jobs` search tasks under budget guard | | 13 | `suggest_next_infill_point()` | Optimise acquisition under `_surrogate_lock` to propose candidate | | 14 | `wait(FIRST_COMPLETED)` routing | Block until any future completes; route by `"search"` or `"batch_eval"` tag | -| 15 | `update_success_rate()`, `_update_storage_steady()`, `_fit_scheduler()` | Process batch result; update storage and best; refit surrogate under lock | +| 15 | `update_success_rate()`, `_update_storage_steady()`, `fit_scheduler()` | Process batch result; update storage and best; refit surrogate under lock | | 16 | Return `"FINISHED"` + `OptimizeResult` | Assemble and return final result | : Complete Parallel Run Summary {#tbl-par} diff --git a/docs/optimize_seq.qmd b/docs/optimize_seq.qmd index 9a5e7bb..fa0382f 100644 --- a/docs/optimize_seq.qmd +++ b/docs/optimize_seq.qmd @@ -60,7 +60,7 @@ print("dispatch check passed.") ```python X0, y0 = self._initialize_run(X0, y0_known) -X0, y0, n_evaluated = self.rm_NA_values(X0, y0) +X0, y0, n_evaluated = self.rm_initial_design_NA_values(X0, y0) self.check_size_initial_design(y0, n_evaluated) self.init_storage(X0, y0) self._zero_success_count = 0 @@ -137,7 +137,7 @@ print("_initialize_run check passed.") --- -## Step 4 — Filtering Invalid Evaluations (`rm_NA_values()`) +## Step 4 — Filtering Invalid Evaluations (`rm_initial_design_NA_values()`) ```python finite_mask = np.isfinite(y0) @@ -164,13 +164,13 @@ from spotoptim.function import sphere opt = SpotOptim(fun=sphere, bounds=[(-5, 5), (-5, 5)], n_initial=5) X0 = np.array([[1.0, 2.0], [3.0, 4.0], [0.0, 0.0]]) y0 = np.array([5.0, np.nan, 0.0]) -X0_clean, y0_clean, n_original = opt.rm_NA_values(X0, y0) +X0_clean, y0_clean, n_original = opt.rm_initial_design_NA_values(X0, y0) assert X0_clean.shape == (2, 2) assert len(y0_clean) == 2 assert n_original == 3 assert np.all(np.isfinite(y0_clean)) print(f"1 NaN removed; {len(y0_clean)} of {n_original} points retained.") -print("rm_NA_values check passed.") +print("rm_initial_design_NA_values check passed.") ``` --- @@ -354,7 +354,7 @@ print("get_best_xy_initial_design check passed.") while len(self.y_) < effective_max_iter and \ time.time() < timeout_start + max_time * 60: self.n_iter_ += 1 - self._fit_scheduler() + self.fit_scheduler() X_ocba = self.apply_ocba() x_next = self.suggest_next_infill_point() x_next_repeated = self.update_repeats_infill_points(x_next) @@ -398,15 +398,15 @@ print("_run_sequential_loop check passed.") --- -## Step 11 — Surrogate Fitting (`_fit_scheduler()`) +## Step 11 — Surrogate Fitting (`fit_scheduler()`) ```python -self._fit_scheduler() +self.fit_scheduler() ``` At the start of each iteration, the surrogate model is refitted to the current training window. -`_fit_scheduler()` selects the most recent `window_size` observations according to +`fit_scheduler()` selects the most recent `window_size` observations according to `selection_method` (default `"distant"`) and calls the surrogate's `fit()` method. When a list of surrogates was supplied at construction, one surrogate is chosen probabilistically according to `prob_surrogate` before fitting, and per-surrogate @@ -422,7 +422,7 @@ result = opt.optimize() print(f"window_size : {opt.window_size}") print(f"evaluations : {result.nfev}") assert opt.window_size == 10 -print("_fit_scheduler check passed.") +print("fit_scheduler check passed.") ``` --- @@ -705,14 +705,14 @@ print("determine_termination check passed.") | 1 | `execute_optimization_run()` | Dispatch to sequential or parallel path | | 2 | `optimize_sequential_run()` | Sequential orchestrator | | 3 | `_initialize_run()` | Seed RNG, generate and evaluate initial design | -| 4 | `rm_NA_values()` | Remove NaN/inf from initial evaluations | +| 4 | `rm_initial_design_NA_values()` | Remove NaN/inf from initial evaluations | | 5 | `check_size_initial_design()` | Validate minimum initial design size | | 6 | `init_storage()` | Initialise `X_`, `y_`, `n_iter_` | | 7 | `update_stats()` | Compute `min_y`, `min_X`, `counter` | | 8 | `_init_tensorboard()` | Log initial design to TensorBoard | | 9 | `get_best_xy_initial_design()` | Identify initial `best_x_`, `best_y_` | | 10 | `_run_sequential_loop()` | Main iteration loop (Steps 11–20 per iteration) | -| 11 | `_fit_scheduler()` | Fit surrogate to current training window | +| 11 | `fit_scheduler()` | Fit surrogate to current training window | | 12 | `apply_ocba()` | Schedule OCBA re-evaluations (noisy problems only) | | 13 | `suggest_next_infill_point()` | Optimise acquisition to propose candidate | | 14 | `update_repeats_infill_points()` | Replicate candidate for noisy evaluation | diff --git a/docs/spotoptim_class.qmd b/docs/spotoptim_class.qmd index 0474180..f1f908a 100644 --- a/docs/spotoptim_class.qmd +++ b/docs/spotoptim_class.qmd @@ -41,12 +41,17 @@ description: "Structure of the Methods" * generate_initial_design() * curate_initial_design() * validate_x0() +* check_size_initial_design() +* get_best_xy_initial_design() +* init_surrogate() -### TASK_Surrogate: +### TASK_FIT: -* init_surrogate() -* _fit_surrogate() -* _fit_scheduler() +* _fit_scheduler() -> fit_scheduler() +* _fit_surrogate() -> fit_surrogate() +* _selection_dispatcher() -> fit_selection_dispatcher() +* select_distant_points() -> fit_select_distant_points() +* select_best_cluster() -> fit_select_best_cluster() ### TASK_PREDICT: @@ -70,7 +75,6 @@ description: "Structure of the Methods" * _try_fallback_strategy() * get_shape() * optimize_acquisition_func() -* _optimize_run_task() ### TASK_OPTIM_SEQ: @@ -106,11 +110,6 @@ description: "Structure of the Methods" * get_ocba() * get_ocba_X() -### TASK_SUBSET: - -* select_distant_points() -* select_best_cluster() -* _selection_dispatcher() ### TASK_SELECT: @@ -146,4 +145,94 @@ description: "Structure of the Methods" * get_stars() -## The Surrogate-model-based Optimization Process \ No newline at end of file +## The Surrogate-model-based Optimization Process + +In the following, we will consider the `optimize` method as the main entry point for the surrogate-model-based optimization process. `optimize` calls the dispatcher for the sequential and the parallel (steady-state) optimization runs, which are the main optimization loops. We consider the sequential run, which is started via the method `optimize_sequential_run`. We list all the calls to `self.*` methods that are made in the sequential optimization loop and enumerate the dependencies. Every time, a self-method is called, the numbering goes down by one level. If the method is finished, we go back up one level. +This results in a tree-like structure of method calls, which we will use to improve (refactor) the structure of the code in the `SpotOptim` class in @SpotOptim.py. + +1. _initialize_run() +1.1. set_seed() +1.2. get_initial_design() +1.2.1. generate_initial_design() +1.2.1.1. lhs_sampler.random() +1.2.1.2. repair_non_numeric() +1.3. curate_initial_design() +1.4. evaluate_function() +1.4.1. to_all_dim() +1.4.2. inverse_transform_X() +1.4.2.1. inverse_transform_value() +1.4.3. map_to_factor_values() +1.4.4. fun() +1.4.5. mo2so() +1.4.5.1. get_shape() +1.4.5.2. store_mo() +2. rm_initial_design_NA_values() +3. check_size_initial_design() +4. init_storage() +4.1. inverse_transform_X() +4.1.1. inverse_transform_value() +5. update_stats() +5.1. aggregate_mean_var() *(only for noisy functions)* +6. _init_tensorboard() +6.1. _write_tensorboard_hparams() *(for each initial point)* +6.2. _write_tensorboard_scalars() +7. get_best_xy_initial_design() +8. _run_sequential_loop() *(loop while evaluations < max_iter and time < max_time)* +8.1. fit_scheduler() +8.1.1. transform_X() +8.1.1.1. transform_value() +8.1.2. fit_surrogate() +8.1.2.1. fit_selection_dispatcher() *(only if n_points > max_surrogate_points)* +8.1.2.1.1. fit_select_distant_points() or fit_select_best_cluster() +8.1.2.2. surrogate.fit() +8.2. apply_ocba() *(only for noisy functions with ocba_delta > 0)* +8.2.1. get_ocba_X() +8.2.1.1. get_ocba() +8.3. suggest_next_infill_point() +8.3.1. _try_optimizer_candidates() +8.3.1.1. optimize_acquisition_func() +8.3.1.1.1. _optimize_acquisition_tricands() *(if acquisition_optimizer == "tricands")* +8.3.1.1.1.1. _acquisition_function() +8.3.1.1.1.1.1. _predict_with_uncertainty() +8.3.1.1.2. _optimize_acquisition_de() *(if acquisition_optimizer == "differential_evolution")* +8.3.1.1.2.1. transform_X() +8.3.1.1.2.2. _acquisition_function() +8.3.1.1.2.2.1. _predict_with_uncertainty() +8.3.1.1.2.3. _prepare_de_kwargs() +8.3.1.1.3. _optimize_acquisition_scipy() *(otherwise)* +8.3.1.1.3.1. _acquisition_function() +8.3.1.1.3.1.1. _predict_with_uncertainty() +8.3.1.2. transform_X() +8.3.1.3. repair_non_numeric() +8.3.1.4. select_new() +8.3.2. _try_fallback_strategy() *(if not enough candidates found)* +8.3.2.1. _handle_acquisition_failure() +8.3.2.1.1. lhs_sampler.random() +8.3.2.1.2. repair_non_numeric() +8.3.2.2. select_new() +8.4. update_repeats_infill_points() +8.5. evaluate_function() *(see 1.4 for sub-calls)* +8.6. _handle_NA_new_points() +8.6.1. apply_penalty_NA() *(if penalty=True)* +8.6.2. remove_nan() +8.7. update_success_rate() +8.8. update_storage() +8.8.1. inverse_transform_X() +8.8.1.1. inverse_transform_value() +8.9. update_stats() +8.9.1. aggregate_mean_var() *(only for noisy functions)* +8.10. _write_tensorboard_hparams() *(if tb_writer is not None, for each new point)* +8.11. _write_tensorboard_scalars() *(if tb_writer is not None)* +8.12. _update_best_main_loop() +8.12.1. inverse_transform_X() +8.12.1.1. inverse_transform_value() +--- *end of loop* --- +9. to_all_dim() *(if red_dim)* +10. determine_termination() +11. _close_tensorboard_writer() +12. map_to_factor_values() *(if factor variables present)* + +## The Surrogate-model-based Initialization Process + +In the following, we will consider the `__init__` method as the starting point before the `optimize` method is called. We list all the calls to `self.*` methods that are made in the `__init__` and enumerate the dependencies. Every time, a self-method is called, the numbering goes down by one level. If the method is finished, we go back up one level. +This results in a tree-like structure of method calls, which we will use to improve (refactor) the structure of the code in the `SpotOptim` class in @SpotOptim.py. \ No newline at end of file diff --git a/reorder_spotoptim.py b/reorder_spotoptim.py index dab6c93..56c9e24 100644 --- a/reorder_spotoptim.py +++ b/reorder_spotoptim.py @@ -12,8 +12,8 @@ "Configuration & Helpers": ["set_seed", "detect_var_type", "modify_bounds_based_on_var_type", "handle_default_var_trans", "process_factor_bounds", "get_best_hyperparameters", "repair_non_numeric", "reinitialize_components", "init_surrogate", "get_pickle_safe_optimizer"], "Dimension Reduction": ["setup_dimension_reduction", "to_red_dim", "to_all_dim"], "Variable Transformation": ["transform_value", "inverse_transform_value", "transform_X", "inverse_transform_X", "transform_bounds", "map_to_factor_values"], - "Initial Design": ["get_initial_design", "generate_initial_design", "curate_initial_design", "rm_NA_values", "validate_x0", "check_size_initial_design", "get_best_xy_initial_design", "update_repeats_infill_points", "remove_nan"], - "Surrogate & Acquisition": ["_fit_surrogate", "_fit_scheduler", "_predict_with_uncertainty", "_acquisition_function", "_optimize_acquisition_tricands", "_optimize_acquisition_de", "_optimize_acquisition_scipy", "_try_optimizer_candidates", "_handle_acquisition_failure", "_try_fallback_strategy", "get_shape", "store_mo", "mo2so", "get_ranks", "get_ocba", "get_ocba_X", "evaluate_function", "select_distant_points", "select_best_cluster", "_selection_dispatcher", "select_new", "acquisition", "optimize_acquisition_func"], + "Initial Design": ["get_initial_design", "generate_initial_design", "curate_initial_design", "rm_initial_design_NA_values", "validate_x0", "check_size_initial_design", "get_best_xy_initial_design", "update_repeats_infill_points", "remove_nan"], + "Surrogate & Acquisition": ["fit_surrogate", "fit_scheduler", "_predict_with_uncertainty", "_acquisition_function", "_optimize_acquisition_tricands", "_optimize_acquisition_de", "_optimize_acquisition_scipy", "_try_optimizer_candidates", "_handle_acquisition_failure", "_try_fallback_strategy", "get_shape", "store_mo", "mo2so", "get_ranks", "get_ocba", "get_ocba_X", "evaluate_function", "fit_select_distant_points", "fit_select_best_cluster", "fit_selection_dispatcher", "select_new", "acquisition", "optimize_acquisition_func"], "Optimization Loop": ["optimize", "_optimize_single_run", "suggest_next_infill_point", "_handle_NA_new_points", "_update_best_main_loop", "determine_termination", "apply_ocba", "apply_penalty_NA"], "Storage & Statistics": ["init_storage", "update_storage", "update_stats", "update_success_rate", "get_success_rate", "aggregate_mean_var"], "Results & Analysis": ["save_result", "load_result", "save_experiment", "load_experiment", "get_result_filename", "get_experiment_filename", "print_results", "print_best", "get_results_table", "get_design_table", "gen_design_table", "get_importance", "sensitivity_spearman", "get_stars"], diff --git a/src/spotoptim/SpotOptim.py b/src/spotoptim/SpotOptim.py index c90b5ee..2254aa9 100644 --- a/src/spotoptim/SpotOptim.py +++ b/src/spotoptim/SpotOptim.py @@ -1916,8 +1916,150 @@ def map_to_factor_values(self, X: np.ndarray) -> np.ndarray: # * validate_x0() # * check_size_initial_design() # * get_best_xy_initial_design() + # * init_surrogate() # ==================== + def init_surrogate(self) -> None: + """Initialize or configure the surrogate model for optimization. Handles three surrogate configurations: + * List of surrogates: sets up multi-surrogate selection with probability weights and per-surrogate `max_surrogate_points`. + * None (default): creates a `GaussianProcessRegressor` with a + `ConstantKernel * Matern(nu=2.5)` kernel, 100 optimizer restarts, + and `normalize_y=True`. + * User-provided surrogate: accepted as-is; internal bookkeeping + attributes (`_max_surrogate_points_list`, + `_active_max_surrogate_points`) are still initialised. + After this method returns the following attributes are set: + * `self.surrogate` — the active surrogate model. + * `self._surrogates_list` — `list | None`. + * `self._prob_surrogate` — normalised selection probabilities or `None`. + * `self._max_surrogate_points_list` — per-surrogate point caps or `None`. + * `self._active_max_surrogate_points` — active cap. + + Raises: + ValueError: If the surrogate list is empty. + ValueError: If 'prob_surrogate' length does not match the surrogate list length. + ValueError: If 'max_surrogate_points' list length does not match the surrogate list length. + + Returns: + None + + Examples: + ```{python} + import numpy as np + from spotoptim import SpotOptim + # Default surrogate (GaussianProcessRegressor) + opt = SpotOptim( + fun=lambda X: np.sum(X**2, axis=1), + bounds=[(-5, 5), (-5, 5)], + n_initial=5, + ) + print(type(opt.surrogate).__name__) + ``` + + ```{python} + import numpy as np + from spotoptim import SpotOptim + from sklearn.ensemble import RandomForestRegressor + # User-provided surrogate + rf = RandomForestRegressor(n_estimators=50, random_state=42) + opt = SpotOptim( + fun=lambda X: np.sum(X**2, axis=1), + bounds=[(-5, 5), (-5, 5)], + n_initial=5, + surrogate=rf, + ) + print(type(opt.surrogate).__name__) + ``` + + ```{python} + import numpy as np + from spotoptim import SpotOptim + from sklearn.ensemble import RandomForestRegressor + from sklearn.gaussian_process import GaussianProcessRegressor + # List of surrogates with selection probabilities + surrogates = [GaussianProcessRegressor(), RandomForestRegressor()] + opt = SpotOptim( + fun=lambda X: np.sum(X**2, axis=1), + bounds=[(-5, 5), (-5, 5)], + n_initial=5, + surrogate=surrogates, + prob_surrogate=[0.7, 0.3], + ) + print(opt._prob_surrogate) + print([type(s).__name__ for s in opt._surrogates_list]) + ``` + """ + self._surrogates_list = None + self._prob_surrogate = None + + if isinstance(self.surrogate, list): + self._surrogates_list = self.surrogate + if not self._surrogates_list: + raise ValueError("Surrogate list cannot be empty.") + + # Handle probabilities + if self.config.prob_surrogate is None: + # Uniform probability + n = len(self._surrogates_list) + self._prob_surrogate = [1.0 / n] * n + else: + probs = self.config.prob_surrogate + if len(probs) != len(self._surrogates_list): + raise ValueError( + f"Length of prob_surrogate ({len(probs)}) must match " + f"number of surrogates ({len(self._surrogates_list)})." + ) + # Normalize probabilities + total = sum(probs) + if not np.isclose(total, 1.0) and total > 0: + self._prob_surrogate = [p / total for p in probs] + else: + self._prob_surrogate = probs + + # Handle max_surrogate_points list + self._max_surrogate_points_list = None + if isinstance(self.config.max_surrogate_points, list): + if len(self.config.max_surrogate_points) != len(self._surrogates_list): + raise ValueError( + f"Length of max_surrogate_points ({len(self.config.max_surrogate_points)}) " + f"must match number of surrogates ({len(self._surrogates_list)})." + ) + self._max_surrogate_points_list = self.config.max_surrogate_points + else: + # If int or None, broadcast to list for easier indexing + self._max_surrogate_points_list = [ + self.config.max_surrogate_points + ] * len(self._surrogates_list) + + # Set initial surrogate and max points + self.surrogate = self._surrogates_list[0] + self._active_max_surrogate_points = self._max_surrogate_points_list[0] + + elif self.surrogate is None: + # Default single surrogate case + self._max_surrogate_points_list = None + self._active_max_surrogate_points = self.config.max_surrogate_points + + kernel = ConstantKernel(1.0, (1e-2, 1e12)) * Matern( + length_scale=1.0, length_scale_bounds=(1e-4, 1e2), nu=2.5 + ) + + # Determine optimizer for GPR + optimizer = "fmin_l_bfgs_b" # Default used by sklearn + if self.config.acquisition_optimizer_kwargs is not None: + optimizer = partial( + gpr_minimize_wrapper, **self.config.acquisition_optimizer_kwargs + ) + + self.surrogate = GaussianProcessRegressor( + kernel=kernel, + n_restarts_optimizer=100, + normalize_y=True, + random_state=self.seed, + optimizer=optimizer, + ) + + def get_initial_design(self, X0: Optional[np.ndarray] = None) -> np.ndarray: """Generate or process initial design points. Ensures that design points are in internal (transformed and reduced) scale. @@ -2255,155 +2397,18 @@ def check_point(pt): # ==================== - # TASK_Surrogate: - # * init_surrogate() - # * _fit_surrogate() - # *_fit_scheduler() + # TASK_FIT: + # *fit_scheduler() + # * fit_surrogate() + # * fit_select_distant_points() + # * fit_select_best_cluster() + # * fit_selection_dispatcher() # ==================== - def init_surrogate(self) -> None: - """Initialize or configure the surrogate model for optimization. Handles three surrogate configurations: - * List of surrogates: sets up multi-surrogate selection with probability weights and per-surrogate `max_surrogate_points`. - * None (default): creates a `GaussianProcessRegressor` with a - `ConstantKernel * Matern(nu=2.5)` kernel, 100 optimizer restarts, - and `normalize_y=True`. - * User-provided surrogate: accepted as-is; internal bookkeeping - attributes (`_max_surrogate_points_list`, - `_active_max_surrogate_points`) are still initialised. - After this method returns the following attributes are set: - * `self.surrogate` — the active surrogate model. - * `self._surrogates_list` — `list | None`. - * `self._prob_surrogate` — normalised selection probabilities or `None`. - * `self._max_surrogate_points_list` — per-surrogate point caps or `None`. - * `self._active_max_surrogate_points` — active cap. - - Raises: - ValueError: If the surrogate list is empty. - ValueError: If 'prob_surrogate' length does not match the surrogate list length. - ValueError: If 'max_surrogate_points' list length does not match the surrogate list length. - - Returns: - None - - Examples: - ```{python} - import numpy as np - from spotoptim import SpotOptim - # Default surrogate (GaussianProcessRegressor) - opt = SpotOptim( - fun=lambda X: np.sum(X**2, axis=1), - bounds=[(-5, 5), (-5, 5)], - n_initial=5, - ) - print(type(opt.surrogate).__name__) - ``` - - ```{python} - import numpy as np - from spotoptim import SpotOptim - from sklearn.ensemble import RandomForestRegressor - # User-provided surrogate - rf = RandomForestRegressor(n_estimators=50, random_state=42) - opt = SpotOptim( - fun=lambda X: np.sum(X**2, axis=1), - bounds=[(-5, 5), (-5, 5)], - n_initial=5, - surrogate=rf, - ) - print(type(opt.surrogate).__name__) - ``` - - ```{python} - import numpy as np - from spotoptim import SpotOptim - from sklearn.ensemble import RandomForestRegressor - from sklearn.gaussian_process import GaussianProcessRegressor - # List of surrogates with selection probabilities - surrogates = [GaussianProcessRegressor(), RandomForestRegressor()] - opt = SpotOptim( - fun=lambda X: np.sum(X**2, axis=1), - bounds=[(-5, 5), (-5, 5)], - n_initial=5, - surrogate=surrogates, - prob_surrogate=[0.7, 0.3], - ) - print(opt._prob_surrogate) - print([type(s).__name__ for s in opt._surrogates_list]) - ``` - """ - self._surrogates_list = None - self._prob_surrogate = None - - if isinstance(self.surrogate, list): - self._surrogates_list = self.surrogate - if not self._surrogates_list: - raise ValueError("Surrogate list cannot be empty.") - # Handle probabilities - if self.config.prob_surrogate is None: - # Uniform probability - n = len(self._surrogates_list) - self._prob_surrogate = [1.0 / n] * n - else: - probs = self.config.prob_surrogate - if len(probs) != len(self._surrogates_list): - raise ValueError( - f"Length of prob_surrogate ({len(probs)}) must match " - f"number of surrogates ({len(self._surrogates_list)})." - ) - # Normalize probabilities - total = sum(probs) - if not np.isclose(total, 1.0) and total > 0: - self._prob_surrogate = [p / total for p in probs] - else: - self._prob_surrogate = probs - - # Handle max_surrogate_points list - self._max_surrogate_points_list = None - if isinstance(self.config.max_surrogate_points, list): - if len(self.config.max_surrogate_points) != len(self._surrogates_list): - raise ValueError( - f"Length of max_surrogate_points ({len(self.config.max_surrogate_points)}) " - f"must match number of surrogates ({len(self._surrogates_list)})." - ) - self._max_surrogate_points_list = self.config.max_surrogate_points - else: - # If int or None, broadcast to list for easier indexing - self._max_surrogate_points_list = [ - self.config.max_surrogate_points - ] * len(self._surrogates_list) - - # Set initial surrogate and max points - self.surrogate = self._surrogates_list[0] - self._active_max_surrogate_points = self._max_surrogate_points_list[0] - - elif self.surrogate is None: - # Default single surrogate case - self._max_surrogate_points_list = None - self._active_max_surrogate_points = self.config.max_surrogate_points - - kernel = ConstantKernel(1.0, (1e-2, 1e12)) * Matern( - length_scale=1.0, length_scale_bounds=(1e-4, 1e2), nu=2.5 - ) - - # Determine optimizer for GPR - optimizer = "fmin_l_bfgs_b" # Default used by sklearn - if self.config.acquisition_optimizer_kwargs is not None: - optimizer = partial( - gpr_minimize_wrapper, **self.config.acquisition_optimizer_kwargs - ) - - self.surrogate = GaussianProcessRegressor( - kernel=kernel, - n_restarts_optimizer=100, - normalize_y=True, - random_state=self.seed, - optimizer=optimizer, - ) - - def _fit_surrogate(self, X: np.ndarray, y: np.ndarray) -> None: + def fit_surrogate(self, X: np.ndarray, y: np.ndarray) -> None: """Fit surrogate model to data. - Used by _fit_scheduler() to fit the surrogate model. + Used by fit_scheduler() to fit the surrogate model. If the number of points exceeds `self.max_surrogate_points`, a subset of points is selected using the selection dispatcher. @@ -2427,7 +2432,7 @@ def _fit_surrogate(self, X: np.ndarray, y: np.ndarray) -> None: ... surrogate=GaussianProcessRegressor()) >>> X = np.random.rand(50, 2) >>> y = np.random.rand(50) - >>> opt._fit_surrogate(X, y) + >>> opt.fit_surrogate(X, y) >>> # Surrogate is now fitted """ X_fit = X @@ -2437,79 +2442,225 @@ def _fit_surrogate(self, X: np.ndarray, y: np.ndarray) -> None: # Resolve active max points max_k = getattr(self, "_active_max_surrogate_points", self.max_surrogate_points) - if max_k is not None and X.shape[0] > max_k: - if self.verbose: - print( - f"Selecting subset of {max_k} points " - f"from {X.shape[0]} total points for surrogate fitting." - ) - X_fit, y_fit = self._selection_dispatcher(X, y) + if max_k is not None and X.shape[0] > max_k: + if self.verbose: + print( + f"Selecting subset of {max_k} points " + f"from {X.shape[0]} total points for surrogate fitting." + ) + X_fit, y_fit = self.fit_selection_dispatcher(X, y) + + self.surrogate.fit(X_fit, y_fit) + + def fit_scheduler(self) -> None: + """Fit surrogate model using appropriate data based on noise handling. + This method selects the appropriate training data for surrogate fitting: + * For noisy functions (repeats_surrogate > 1): Uses mean_X and mean_y (aggregated values) + * For deterministic functions: Uses X_ and y_ (all evaluated points) + The data is transformed to internal scale before fitting the surrogate. + + Returns: + None + + Examples: + >>> import numpy as np + >>> from spotoptim import SpotOptim + >>> from sklearn.gaussian_process import GaussianProcessRegressor + >>> # Deterministic function + >>> def sphere(X): + ... X = np.atleast_2d(X) + ... return np.sum(X**2, axis=1) + >>> opt = SpotOptim( + ... fun=sphere, + ... bounds=[(-5, 5), (-5, 5)], + ... surrogate=GaussianProcessRegressor(), + ... n_initial=5 + ... ) + >>> # Simulate optimization state + >>> opt.X_ = np.array([[1, 2], [0, 0], [2, 1]]) + >>> opt.y_ = np.array([5.0, 0.0, 5.0]) + >>> opt.fit_scheduler() + >>> # Surrogate fitted with X_ and y_ + >>> + >>> # Noisy function + >>> def sphere(X): + ... X = np.atleast_2d(X) + ... return np.sum(X**2, axis=1) + >>> opt_noise = SpotOptim( + ... fun=sphere, + ... bounds=[(-5, 5), (-5, 5)], + ... surrogate=GaussianProcessRegressor(), + ... n_initial=5, + ... repeats_initial=3, + ... ) + >>> # Simulate noisy optimization state + >>> opt_noise.mean_X = np.array([[1, 2], [0, 0]]) + >>> opt_noise.mean_y = np.array([5.0, 0.0]) + >>> opt_noise.fit_scheduler() + >>> # Surrogate fitted with mean_X and mean_y + """ + # Fit surrogate (use mean_y if noise, otherwise y_) + # Transform X to internal scale for surrogate fitting + + # Handle multi-surrogate selection + if getattr(self, "_surrogates_list", None) is not None: + idx = self.rng.choice(len(self._surrogates_list), p=self._prob_surrogate) + self.surrogate = self._surrogates_list[idx] + # Update active max surrogate points + self._active_max_surrogate_points = self._max_surrogate_points_list[idx] + + if (self.repeats_initial > 1) or (self.repeats_surrogate > 1): + X_for_surrogate = self.transform_X(self.mean_X) + self.fit_surrogate(X_for_surrogate, self.mean_y) + else: + X_for_surrogate = self.transform_X(self.X_) + self.fit_surrogate(X_for_surrogate, self.y_) + + + def fit_select_distant_points( + self, X: np.ndarray, y: np.ndarray, k: int + ) -> Tuple[np.ndarray, np.ndarray]: + """Selects k points that are distant from each other using K-means clustering. + This method performs K-means clustering to find k clusters, then selects + the point closest to each cluster center. This ensures a space-filling + subset of points for surrogate model training. + + Args: + X (ndarray): Design points, shape (n_samples, n_features). + y (ndarray): Function values at X, shape (n_samples,). + k (int): Number of points to select. + + Returns: + tuple: A tuple containing: + * selected_X (ndarray): Selected design points, shape (k, n_features). + * selected_y (ndarray): Function values at selected points, shape (k,). + + Examples: + ```{python} + import numpy as np + from spotoptim import SpotOptim + opt = SpotOptim(fun=lambda X: np.sum(X**2, axis=1), + bounds=[(-5, 5), (-5, 5)], + max_surrogate_points=5) + X = np.random.rand(100, 2) + y = np.random.rand(100) + X_sel, y_sel = opt.fit_select_distant_points(X, y, 5) + print(X_sel.shape) + ``` + """ + # Perform k-means clustering + kmeans = KMeans(n_clusters=k, random_state=0, n_init="auto").fit(X) + + # Find the closest point to each cluster center + selected_indices = [] + for center in kmeans.cluster_centers_: + distances = np.linalg.norm(X - center, axis=1) + closest_idx = np.argmin(distances) + selected_indices.append(closest_idx) + + selected_indices = np.array(selected_indices) + return X[selected_indices], y[selected_indices] + + def fit_select_best_cluster( + self, X: np.ndarray, y: np.ndarray, k: int + ) -> Tuple[np.ndarray, np.ndarray]: + """Selects all points from the cluster with the smallest mean y value. + This method performs K-means clustering and selects all points from the + cluster whose center corresponds to the best (smallest) mean objective + function value. + + Args: + X (ndarray): Design points, shape (n_samples, n_features). + y (ndarray): Function values at X, shape (n_samples,). + k (int): Number of clusters. + + Returns: + tuple: A tuple containing: + * selected_X (ndarray): Selected design points from best cluster, shape (m, n_features). + * selected_y (ndarray): Function values at selected points, shape (m,). + + Examples: + ```{python} + import numpy as np + from spotoptim import SpotOptim + opt = SpotOptim(fun=lambda X: np.sum(X**2, axis=1), + bounds=[(-5, 5), (-5, 5)], + max_surrogate_points=5, + selection_method='best') + X = np.random.rand(100, 2) + y = np.random.rand(100) + X_sel, y_sel = opt.fit_select_best_cluster(X, y, 5) + print(f"X_sel.shape: {X_sel.shape}") + print(f"y_sel.shape: {y_sel.shape}") + ``` + """ + # Perform k-means clustering + kmeans = KMeans(n_clusters=k, random_state=0, n_init="auto").fit(X) + labels = kmeans.labels_ + + # Compute mean y for each cluster + cluster_means = [] + for cluster_idx in range(k): + cluster_y = y[labels == cluster_idx] + if len(cluster_y) == 0: + cluster_means.append(np.inf) + else: + cluster_means.append(np.mean(cluster_y)) + + # Find cluster with smallest mean y + best_cluster = np.argmin(cluster_means) + + # Select all points from the best cluster + mask = labels == best_cluster + return X[mask], y[mask] - self.surrogate.fit(X_fit, y_fit) + def fit_selection_dispatcher( + self, X: np.ndarray, y: np.ndarray + ) -> Tuple[np.ndarray, np.ndarray]: + """Dispatcher for selection methods. + Depending on the value of `self.selection_method`, this method calls + the appropriate selection function to choose a subset of points for + surrogate model training when the total number of points exceeds + `self.max_surrogate_points`. - def _fit_scheduler(self) -> None: - """Fit surrogate model using appropriate data based on noise handling. - This method selects the appropriate training data for surrogate fitting: - * For noisy functions (repeats_surrogate > 1): Uses mean_X and mean_y (aggregated values) - * For deterministic functions: Uses X_ and y_ (all evaluated points) - The data is transformed to internal scale before fitting the surrogate. + Args: + X (ndarray): Design points, shape (n_samples, n_features). + y (ndarray): Function values at X, shape (n_samples,). Returns: - None + tuple: A tuple containing: + * selected_X (ndarray): Selected design points. + * selected_y (ndarray): Function values at selected points. Examples: - >>> import numpy as np - >>> from spotoptim import SpotOptim - >>> from sklearn.gaussian_process import GaussianProcessRegressor - >>> # Deterministic function - >>> def sphere(X): - ... X = np.atleast_2d(X) - ... return np.sum(X**2, axis=1) - >>> opt = SpotOptim( - ... fun=sphere, - ... bounds=[(-5, 5), (-5, 5)], - ... surrogate=GaussianProcessRegressor(), - ... n_initial=5 - ... ) - >>> # Simulate optimization state - >>> opt.X_ = np.array([[1, 2], [0, 0], [2, 1]]) - >>> opt.y_ = np.array([5.0, 0.0, 5.0]) - >>> opt._fit_scheduler() - >>> # Surrogate fitted with X_ and y_ - >>> - >>> # Noisy function - >>> def sphere(X): - ... X = np.atleast_2d(X) - ... return np.sum(X**2, axis=1) - >>> opt_noise = SpotOptim( - ... fun=sphere, - ... bounds=[(-5, 5), (-5, 5)], - ... surrogate=GaussianProcessRegressor(), - ... n_initial=5, - ... repeats_initial=3, - ... ) - >>> # Simulate noisy optimization state - >>> opt_noise.mean_X = np.array([[1, 2], [0, 0]]) - >>> opt_noise.mean_y = np.array([5.0, 0.0]) - >>> opt_noise._fit_scheduler() - >>> # Surrogate fitted with mean_X and mean_y + ```{python} + import numpy as np + from spotoptim import SpotOptim + opt = SpotOptim(fun=lambda X: np.sum(X**2, axis=1), + bounds=[(-5, 5), (-5, 5)], + max_surrogate_points=5) + X = np.random.rand(100, 2) + y = np.random.rand(100) + X_sel, y_sel = opt.fit_selection_dispatcher(X, y) + print(X_sel.shape[0] <= 5) + ``` """ - # Fit surrogate (use mean_y if noise, otherwise y_) - # Transform X to internal scale for surrogate fitting + # Resolve active max points + max_k = getattr(self, "_active_max_surrogate_points", self.max_surrogate_points) - # Handle multi-surrogate selection - if getattr(self, "_surrogates_list", None) is not None: - idx = self.rng.choice(len(self._surrogates_list), p=self._prob_surrogate) - self.surrogate = self._surrogates_list[idx] - # Update active max surrogate points - self._active_max_surrogate_points = self._max_surrogate_points_list[idx] + if max_k is None: + return X, y - if (self.repeats_initial > 1) or (self.repeats_surrogate > 1): - X_for_surrogate = self.transform_X(self.mean_X) - self._fit_surrogate(X_for_surrogate, self.mean_y) + if self.selection_method == "distant": + return self.fit_select_distant_points(X=X, y=y, k=max_k) + elif self.selection_method == "best": + return self.fit_select_best_cluster(X=X, y=y, k=max_k) else: - X_for_surrogate = self.transform_X(self.X_) - self._fit_surrogate(X_for_surrogate, self.y_) + # If no valid selection method, return all points + return X, y + + + # ==================== # TASK_PREDICT: @@ -2542,7 +2693,7 @@ def _predict_with_uncertainty(self, X: np.ndarray) -> Tuple[np.ndarray, np.ndarr ... ) >>> X_train = np.array([[0, 0], [1, 1], [2, 2]]) >>> y_train = np.array([0, 2, 8]) - >>> opt._fit_surrogate(X_train, y_train) + >>> opt.fit_surrogate(X_train, y_train) >>> X_test = np.array([[1.5, 1.5], [3.0, 3.0]]) >>> preds, stds = opt._predict_with_uncertainty(X_test) >>> print("Predictions:", preds) @@ -2595,7 +2746,7 @@ def _acquisition_function(self, x: np.ndarray) -> np.ndarray: ... ) >>> X_train = np.array([[0, 0], [1, 1], [2, 2]]) >>> y_train = np.array([0, 2, 8]) - >>> opt._fit_surrogate(X_train, y_train) + >>> opt.fit_surrogate(X_train, y_train) >>> x_eval = np.array([1.5, 1.5]) >>> acq_value = opt._acquisition_function(x_eval) >>> print("Acquisition function value:", acq_value) @@ -2642,6 +2793,7 @@ def _acquisition_function(self, x: np.ndarray) -> np.ndarray: # ==================== # TASK_OPTIM: # * optimize() + # * execute_optimization_run() # * evaluate_function() # * _optimize_acquisition_tricands() # * _prepare_de_kwargs() @@ -2653,8 +2805,6 @@ def _acquisition_function(self, x: np.ndarray) -> np.ndarray: # * _try_fallback_strategy() # * get_shape() # * optimize_acquisition_func() - # * _optimize_run_task() - # * execute_optimization_run() # ==================== def optimize(self, X0: Optional[np.ndarray] = None) -> OptimizeResult: @@ -3253,7 +3403,7 @@ def _optimize_acquisition_scipy(self) -> np.ndarray: >>> >>> # Fit the surrogate model manually >>> # Note: this is normally handled inside optimize() - >>> optimizer._fit_surrogate(X, y) + >>> optimizer.fit_surrogate(X, y) >>> >>> # Optimize the acquisition function using scipy's minimize >>> x_next = optimizer._optimize_acquisition_scipy() @@ -3621,49 +3771,6 @@ def sphere(X): else: return self._optimize_acquisition_scipy() - - def _optimize_run_task( - self, - seed: int, - timeout_start: float, - X0: Optional[np.ndarray], - y0_known_val: Optional[float], - max_iter_override: Optional[int], - shared_best_y=None, # Accept shared value - shared_lock=None, # Accept shared lock - ) -> Tuple[str, OptimizeResult]: - """Helper to run a single optimization task with a specific seed. Calls _optimize_single_run. - - Args: - seed (int): Seed for this run. - timeout_start (float): Start time for timeout. - X0 (Optional[np.ndarray]): Initial design points in Natural Space, shape (n_initial, n_features). - y0_known_val (Optional[float]): Known best value for initial design. - max_iter_override (Optional[int]): Override for maximum number of iterations. - shared_best_y (Optional[float]): Shared best value for parallel runs. - shared_lock (Optional[Lock]): Shared lock for parallel runs. - - Returns: - Tuple[str, OptimizeResult]: Tuple containing status and optimization result. - """ - # Set the seed for this run - self.seed = seed - self.set_seed() - - # Re-initialize LHS sampler with new seed to ensure diversity in initial design - if hasattr(self, "n_dim"): - self.lhs_sampler = LatinHypercube(d=self.n_dim, rng=self.seed) - - return self._optimize_single_run( - timeout_start, - X0, - y0_known=y0_known_val, - max_iter_override=max_iter_override, - shared_best_y=shared_best_y, - shared_lock=shared_lock, - ) - - # ==================== # TASK_OPTIM_SEQ: # * determine_termination() @@ -4052,7 +4159,7 @@ def _run_sequential_loop( self.n_iter_ += 1 # Fit surrogate (use mean_y if noise, otherwise y_) - self._fit_scheduler() + self.fit_scheduler() # Apply OCBA for noisy functions X_ocba = self.apply_ocba() @@ -4852,7 +4959,7 @@ def optimize_steady_state( # Lock that serialises surrogate access: # - search threads call suggest_next_infill_point() under the lock - # - the main thread calls _fit_scheduler() under the lock after each eval + # - the main thread calls fit_scheduler() under the lock after each eval # This prevents a surrogate refit from racing with an in-flight search. _surrogate_lock = threading.Lock() @@ -4968,7 +5075,7 @@ def _thread_batch_eval_task(X_batch): print( f"Initial design evaluated. Fitting surrogate... (Data size: {len(self.y_)})" ) - self._fit_scheduler() + self.fit_scheduler() # --- Phase 2: Steady State Loop --- if self.verbose: @@ -5116,7 +5223,7 @@ def _batch_ready() -> bool: # held under the lock so in-flight search threads # do not read a partially-updated model. with _surrogate_lock: - self._fit_scheduler() + self.fit_scheduler() except Exception as e: _future_n_pts.pop(fut, None) @@ -5496,154 +5603,6 @@ def get_ocba_X( else: return None - # ==================== - # TASK_SUBSET: - # * select_distant_points() - # * select_best_cluster() - # * _selection_dispatcher() - # ==================== - - def select_distant_points( - self, X: np.ndarray, y: np.ndarray, k: int - ) -> Tuple[np.ndarray, np.ndarray]: - """Selects k points that are distant from each other using K-means clustering. - This method performs K-means clustering to find k clusters, then selects - the point closest to each cluster center. This ensures a space-filling - subset of points for surrogate model training. - - Args: - X (ndarray): Design points, shape (n_samples, n_features). - y (ndarray): Function values at X, shape (n_samples,). - k (int): Number of points to select. - - Returns: - tuple: A tuple containing: - * selected_X (ndarray): Selected design points, shape (k, n_features). - * selected_y (ndarray): Function values at selected points, shape (k,). - - Examples: - ```{python} - import numpy as np - from spotoptim import SpotOptim - opt = SpotOptim(fun=lambda X: np.sum(X**2, axis=1), - bounds=[(-5, 5), (-5, 5)], - max_surrogate_points=5) - X = np.random.rand(100, 2) - y = np.random.rand(100) - X_sel, y_sel = opt.select_distant_points(X, y, 5) - print(X_sel.shape) - ``` - """ - # Perform k-means clustering - kmeans = KMeans(n_clusters=k, random_state=0, n_init="auto").fit(X) - - # Find the closest point to each cluster center - selected_indices = [] - for center in kmeans.cluster_centers_: - distances = np.linalg.norm(X - center, axis=1) - closest_idx = np.argmin(distances) - selected_indices.append(closest_idx) - - selected_indices = np.array(selected_indices) - return X[selected_indices], y[selected_indices] - - def select_best_cluster( - self, X: np.ndarray, y: np.ndarray, k: int - ) -> Tuple[np.ndarray, np.ndarray]: - """Selects all points from the cluster with the smallest mean y value. - This method performs K-means clustering and selects all points from the - cluster whose center corresponds to the best (smallest) mean objective - function value. - - Args: - X (ndarray): Design points, shape (n_samples, n_features). - y (ndarray): Function values at X, shape (n_samples,). - k (int): Number of clusters. - - Returns: - tuple: A tuple containing: - * selected_X (ndarray): Selected design points from best cluster, shape (m, n_features). - * selected_y (ndarray): Function values at selected points, shape (m,). - - Examples: - ```{python} - import numpy as np - from spotoptim import SpotOptim - opt = SpotOptim(fun=lambda X: np.sum(X**2, axis=1), - bounds=[(-5, 5), (-5, 5)], - max_surrogate_points=5, - selection_method='best') - X = np.random.rand(100, 2) - y = np.random.rand(100) - X_sel, y_sel = opt.select_best_cluster(X, y, 5) - print(f"X_sel.shape: {X_sel.shape}") - print(f"y_sel.shape: {y_sel.shape}") - ``` - """ - # Perform k-means clustering - kmeans = KMeans(n_clusters=k, random_state=0, n_init="auto").fit(X) - labels = kmeans.labels_ - - # Compute mean y for each cluster - cluster_means = [] - for cluster_idx in range(k): - cluster_y = y[labels == cluster_idx] - if len(cluster_y) == 0: - cluster_means.append(np.inf) - else: - cluster_means.append(np.mean(cluster_y)) - - # Find cluster with smallest mean y - best_cluster = np.argmin(cluster_means) - - # Select all points from the best cluster - mask = labels == best_cluster - return X[mask], y[mask] - - def _selection_dispatcher( - self, X: np.ndarray, y: np.ndarray - ) -> Tuple[np.ndarray, np.ndarray]: - """Dispatcher for selection methods. - Depending on the value of `self.selection_method`, this method calls - the appropriate selection function to choose a subset of points for - surrogate model training when the total number of points exceeds - `self.max_surrogate_points`. - - Args: - X (ndarray): Design points, shape (n_samples, n_features). - y (ndarray): Function values at X, shape (n_samples,). - - Returns: - tuple: A tuple containing: - * selected_X (ndarray): Selected design points. - * selected_y (ndarray): Function values at selected points. - - Examples: - ```{python} - import numpy as np - from spotoptim import SpotOptim - opt = SpotOptim(fun=lambda X: np.sum(X**2, axis=1), - bounds=[(-5, 5), (-5, 5)], - max_surrogate_points=5) - X = np.random.rand(100, 2) - y = np.random.rand(100) - X_sel, y_sel = opt._selection_dispatcher(X, y) - print(X_sel.shape[0] <= 5) - ``` - """ - # Resolve active max points - max_k = getattr(self, "_active_max_surrogate_points", self.max_surrogate_points) - - if max_k is None: - return X, y - - if self.selection_method == "distant": - return self.select_distant_points(X=X, y=y, k=max_k) - elif self.selection_method == "best": - return self.select_best_cluster(X=X, y=y, k=max_k) - else: - # If no valid selection method, return all points - return X, y # ==================== # TASK_SELECT: @@ -5737,7 +5696,7 @@ def sphere(X): np.random.seed(0) opt.X_ = np.random.rand(10, 2) opt.y_ = np.random.rand(10) - opt._fit_surrogate(opt.X_, opt.y_) + opt.fit_surrogate(opt.X_, opt.y_) x_next = opt.suggest_next_infill_point() x_next.shape ``` diff --git a/src/spotoptim/utils/parallel.py b/src/spotoptim/utils/parallel.py index e1b9c0d..bcf201a 100644 --- a/src/spotoptim/utils/parallel.py +++ b/src/spotoptim/utils/parallel.py @@ -125,7 +125,7 @@ def remote_search_task(pickled_optimizer): Args: pickled_optimizer (bytes): A pickled SpotOptim instance that has been initialized with data - and a fitted surrogate model (via X_, y_, and _fit_surrogate). + and a fitted surrogate model (via X_, y_, and fit_surrogate). Returns: ndarray or Exception: The suggested next infill point(s) as an array of shape (n_infill_points, n_features), @@ -154,7 +154,7 @@ def remote_search_task(pickled_optimizer): np.random.seed(0) opt.X_ = np.random.rand(10, 2) * 10 - 5 opt.y_ = np.sum(opt.X_**2, axis=1) - opt._fit_surrogate(opt.X_, opt.y_) + opt.fit_surrogate(opt.X_, opt.y_) # Use the function pickled_optimizer = dill.dumps(opt) diff --git a/tests/test_max_surrogate_points_list.py b/tests/test_max_surrogate_points_list.py index be2f746..5c23eaa 100644 --- a/tests/test_max_surrogate_points_list.py +++ b/tests/test_max_surrogate_points_list.py @@ -52,7 +52,7 @@ def test_max_surrogate_points_switching(): ) # We can't easily force switch without mocking RNG or running many iters. - # Instead, we can manually trigger _fit_scheduler behavior logic or inspect internal list. + # Instead, we can manually trigger fit_scheduler behavior logic or inspect internal list. assert opt._max_surrogate_points_list == [10, 20] # Initial state (first surrogate) @@ -61,10 +61,10 @@ def test_max_surrogate_points_switching(): # Manually switch to second surrogate opt.surrogate = surrogates[1] - # Update active manually (mimicking _fit_scheduler) to verify intended behavior would work - # Or actually call _fit_scheduler with mocked probability? + # Update active manually (mimicking fit_scheduler) to verify intended behavior would work + # Or actually call fit_scheduler with mocked probability? - # Let's rely on _fit_scheduler property: it uses self.rng. + # Let's rely on fit_scheduler property: it uses self.rng. # We can mock self.rng.choice class MockRNG: @@ -77,8 +77,8 @@ def choice(self, n, p=None): opt.X_ = np.array([[0.5]]) opt.y_ = np.array([0.5]) - # Call _fit_scheduler - opt._fit_scheduler() + # Call fit_scheduler + opt.fit_scheduler() assert opt.surrogate == surrogates[1] assert opt._active_max_surrogate_points == 20 @@ -94,7 +94,7 @@ def test_single_surrogate_behavior(): X = np.random.rand(20, 1) y = np.random.rand(20) - # Mock _selection_dispatcher to verify it gets called + # Mock fit_selection_dispatcher to verify it gets called called = False def mock_dispatcher(X, y): @@ -102,7 +102,7 @@ def mock_dispatcher(X, y): called = True return X[:15], y[:15] - opt._selection_dispatcher = mock_dispatcher - opt._fit_surrogate(X, y) + opt.fit_selection_dispatcher = mock_dispatcher + opt.fit_surrogate(X, y) assert called diff --git a/tests/test_multi_surrogate.py b/tests/test_multi_surrogate.py index 4680f0c..6c57aa8 100644 --- a/tests/test_multi_surrogate.py +++ b/tests/test_multi_surrogate.py @@ -102,7 +102,7 @@ def test_selection_logic_deterministic(): # Also verify s1 was never fitted? (Well, SpotOptim fits self.surrogate. # If s1 was never selected, it might not be fitted unless initially fitted?) # SpotOptim init sets self.surrogate = list[0]. But first fit happens in loop? - # Actually, `optimize()` does `_fit_scheduler()` inside loop. + # Actually, `optimize()` does `fit_scheduler()` inside loop. # Initial design evaluation doesn't trigger fit. First fit is after N_initial. # So s1 (idx 0) might be set initially but if first selection picks s2, s1.fit is never called. @@ -127,16 +127,16 @@ def test_reproducibility_of_sequence(): ) # Collect sequence of surrogates used - # We can mock _fit_surrogate to record selection + # We can mock fit_surrogate to record selection sequence1 = [] - original_fit = opt1._fit_surrogate + original_fit = opt1.fit_surrogate def fit_hook(X, y): sequence1.append(opt1.surrogate.name) return original_fit(X, y) - opt1._fit_surrogate = fit_hook + opt1.fit_surrogate = fit_hook opt1.optimize() # Run 2 @@ -150,13 +150,13 @@ def fit_hook(X, y): ) sequence2 = [] - original_fit2 = opt2._fit_surrogate + original_fit2 = opt2.fit_surrogate def fit_hook2(X, y): sequence2.append(opt2.surrogate.name) return original_fit2(X, y) - opt2._fit_surrogate = fit_hook2 + opt2.fit_surrogate = fit_hook2 opt2.optimize() assert sequence1 == sequence2 @@ -178,13 +178,13 @@ def test_integration_loop_alternating(): ) names = set() - original_fit = opt._fit_surrogate + original_fit = opt.fit_surrogate def fit_hook(X, y): names.add(opt.surrogate.name) return original_fit(X, y) - opt._fit_surrogate = fit_hook + opt.fit_surrogate = fit_hook opt.optimize() # With enough iterations and 50/50, both should be selected diff --git a/tests/test_point_selection.py b/tests/test_point_selection.py index 612457d..0830789 100644 --- a/tests/test_point_selection.py +++ b/tests/test_point_selection.py @@ -16,8 +16,8 @@ def sphere(X): return np.sum(X**2, axis=1) -def test_select_distant_points_basic(): - """Test basic functionality of select_distant_points method.""" +def test_fit_select_distant_points_basic(): + """Test basic functionality of fit_select_distant_points method.""" optimizer = SpotOptim( fun=sphere, bounds=[(-5, 5), (-5, 5)], @@ -31,7 +31,7 @@ def test_select_distant_points_basic(): y = np.array([1, 2, 3, 4, 5]) k = 3 - selected_X, selected_y = optimizer.select_distant_points(X, y, k) + selected_X, selected_y = optimizer.fit_select_distant_points(X, y, k) # Check shapes assert selected_X.shape == ( @@ -47,8 +47,8 @@ def test_select_distant_points_basic(): ), "Selected point not in original set" -def test_select_best_cluster_basic(): - """Test basic functionality of select_best_cluster method.""" +def test_fit_select_best_cluster_basic(): + """Test basic functionality of fit_select_best_cluster method.""" optimizer = SpotOptim( fun=sphere, bounds=[(-5, 5), (-5, 5)], @@ -62,7 +62,7 @@ def test_select_best_cluster_basic(): y = np.array([1, 1.1, 1.2, 10, 10.1]) k = 2 - selected_X, selected_y = optimizer.select_best_cluster(X, y, k) + selected_X, selected_y = optimizer.fit_select_best_cluster(X, y, k) # Check that we got points back assert selected_X.shape[0] > 0, "No points selected" @@ -78,7 +78,7 @@ def test_select_best_cluster_basic(): assert np.mean(selected_y) < 5, "Expected best cluster with smaller y values" -def test_selection_dispatcher_distant(): +def test_fit_selection_dispatcher_distant(): """Test selection dispatcher with 'distant' method.""" optimizer = SpotOptim( fun=sphere, @@ -91,13 +91,13 @@ def test_selection_dispatcher_distant(): X = np.random.rand(20, 2) y = np.random.rand(20) - selected_X, selected_y = optimizer._selection_dispatcher(X, y) + selected_X, selected_y = optimizer.fit_selection_dispatcher(X, y) assert selected_X.shape == (3, 2), "Expected 3 points selected" assert selected_y.shape == (3,), "Expected 3 y values selected" -def test_selection_dispatcher_best(): +def test_fit_selection_dispatcher_best(): """Test selection dispatcher with 'best' method.""" optimizer = SpotOptim( fun=sphere, @@ -110,14 +110,14 @@ def test_selection_dispatcher_best(): X = np.random.rand(20, 2) y = np.random.rand(20) - selected_X, selected_y = optimizer._selection_dispatcher(X, y) + selected_X, selected_y = optimizer.fit_selection_dispatcher(X, y) # Should return points from best cluster (not necessarily exactly 3) assert selected_X.shape[0] > 0, "Expected some points selected" assert selected_y.shape[0] > 0, "Expected some y values selected" -def test_selection_dispatcher_no_limit(): +def test_fit_selection_dispatcher_no_limit(): """Test selection dispatcher when max_surrogate_points is None.""" optimizer = SpotOptim( fun=sphere, @@ -130,7 +130,7 @@ def test_selection_dispatcher_no_limit(): X = np.random.rand(20, 2) y = np.random.rand(20) - selected_X, selected_y = optimizer._selection_dispatcher(X, y) + selected_X, selected_y = optimizer.fit_selection_dispatcher(X, y) # Should return all points assert selected_X.shape == X.shape, "Expected all points returned" @@ -164,7 +164,7 @@ def test_fit_surrogate_with_selection(): y_all = np.concatenate([y, y_extra]) # Fit surrogate - should trigger selection - optimizer._fit_surrogate(X_all, y_all) + optimizer.fit_surrogate(X_all, y_all) # Verify surrogate is fitted X_test = np.array([[0, 0]]) @@ -230,7 +230,7 @@ def test_too_few_points_for_clustering(): # Should raise an error from KMeans with pytest.raises(ValueError): - optimizer.select_distant_points(X, y, k) + optimizer.fit_select_distant_points(X, y, k) def test_identical_points_handling(): @@ -247,7 +247,7 @@ def test_identical_points_handling(): X = np.array([[1, 1], [1, 1], [2, 2], [3, 3], [4, 4]]) y = np.array([1, 1, 2, 3, 4]) - selected_X, selected_y = optimizer.select_distant_points(X, y, 3) + selected_X, selected_y = optimizer.fit_select_distant_points(X, y, 3) assert selected_X.shape == (3, 2), "Expected 3 points selected" assert selected_y.shape == (3,), "Expected 3 y values selected" @@ -270,7 +270,7 @@ def test_verbose_output(capsys): X = np.random.rand(15, 2) * 10 - 5 y = optimizer.evaluate_function(X) - optimizer._fit_surrogate(X, y) + optimizer.fit_surrogate(X, y) captured = capsys.readouterr() assert ( diff --git a/tests/test_refactored_optimize.py b/tests/test_refactored_optimize.py index 2826d60..0110247 100644 --- a/tests/test_refactored_optimize.py +++ b/tests/test_refactored_optimize.py @@ -147,7 +147,7 @@ def testoptimize_sequential_run_calls_init_tensorboard(self, spot_optim): spot_optim._initialize_run = MagicMock( return_value=(np.zeros((5, 2)), np.zeros(5)) ) - spot_optim.rm_NA_values = MagicMock( + spot_optim.rm_initial_design_NA_values = MagicMock( return_value=(np.zeros((5, 2)), np.zeros(5), 5) ) spot_optim.check_size_initial_design = MagicMock() diff --git a/tests/test_remote_search_task_example.py b/tests/test_remote_search_task_example.py index c01fcf7..d6b0d65 100644 --- a/tests/test_remote_search_task_example.py +++ b/tests/test_remote_search_task_example.py @@ -28,7 +28,7 @@ def test_remote_search_task_basic_example(): np.random.seed(0) opt.X_ = np.random.rand(10, 2) * 10 - 5 # Scale to bounds [-5, 5] opt.y_ = np.sum(opt.X_**2, axis=1) - opt._fit_surrogate(opt.X_, opt.y_) + opt.fit_surrogate(opt.X_, opt.y_) pickled_optimizer = dill.dumps(opt) x_new = remote_search_task(pickled_optimizer) @@ -59,7 +59,7 @@ def test_remote_search_task_1d_problem(): np.random.seed(42) opt.X_ = np.random.rand(8, 1) * 20 - 10 # Scale to bounds [-10, 10] opt.y_ = (opt.X_**2).ravel() - opt._fit_surrogate(opt.X_, opt.y_) + opt.fit_surrogate(opt.X_, opt.y_) pickled_optimizer = dill.dumps(opt) x_new = remote_search_task(pickled_optimizer) @@ -84,7 +84,7 @@ def test_remote_search_task_5d_problem(): np.random.seed(123) opt.X_ = np.random.rand(12, 5) * 6 - 3 # Scale to bounds [-3, 3] opt.y_ = np.sum(opt.X_**2, axis=1) - opt._fit_surrogate(opt.X_, opt.y_) + opt.fit_surrogate(opt.X_, opt.y_) pickled_optimizer = dill.dumps(opt) x_new = remote_search_task(pickled_optimizer) @@ -119,7 +119,7 @@ def rosenbrock(X): np.random.seed(99) opt.X_ = np.random.rand(10, 2) * 4 - 2 # Scale to bounds [-2, 2] opt.y_ = rosenbrock(opt.X_) - opt._fit_surrogate(opt.X_, opt.y_) + opt.fit_surrogate(opt.X_, opt.y_) pickled_optimizer = dill.dumps(opt) x_new = remote_search_task(pickled_optimizer) @@ -146,7 +146,7 @@ def test_remote_search_task_with_acquisition_ei(): np.random.seed(777) opt.X_ = np.random.rand(10, 2) * 10 - 5 # Scale to bounds [-5, 5] opt.y_ = np.sum(opt.X_**2, axis=1) - opt._fit_surrogate(opt.X_, opt.y_) + opt.fit_surrogate(opt.X_, opt.y_) pickled_optimizer = dill.dumps(opt) x_new = remote_search_task(pickled_optimizer) @@ -173,7 +173,7 @@ def test_remote_search_task_different_seeds(): np.random.seed(seed) opt.X_ = np.random.rand(10, 2) * 10 - 5 # Scale to bounds [-5, 5] opt.y_ = np.sum(opt.X_**2, axis=1) - opt._fit_surrogate(opt.X_, opt.y_) + opt.fit_surrogate(opt.X_, opt.y_) pickled_optimizer = dill.dumps(opt) x_new = remote_search_task(pickled_optimizer) @@ -220,7 +220,7 @@ def test_remote_search_task_preserves_optimizer_state(): np.random.seed(42) opt.X_ = np.random.rand(10, 2) * 10 - 5 # Scale to bounds [-5, 5] opt.y_ = np.sum(opt.X_**2, axis=1) - opt._fit_surrogate(opt.X_, opt.y_) + opt.fit_surrogate(opt.X_, opt.y_) # Store original seed original_seed = opt.seed diff --git a/tests/test_reproducibility_comprehensive.py b/tests/test_reproducibility_comprehensive.py index c95084f..6c9d82d 100644 --- a/tests/test_reproducibility_comprehensive.py +++ b/tests/test_reproducibility_comprehensive.py @@ -267,12 +267,12 @@ def test_surrogate_training_reproducibility(self): # Instance 1 opt1 = SpotOptim(fun=lambda x: x, bounds=[(0, 1)], seed=seed) - opt1._fit_surrogate(X, y) + opt1.fit_surrogate(X, y) pred1_mu, pred1_sigma = opt1._predict_with_uncertainty(X) # Instance 2 opt2 = SpotOptim(fun=lambda x: x, bounds=[(0, 1)], seed=seed) - opt2._fit_surrogate(X, y) + opt2.fit_surrogate(X, y) pred2_mu, pred2_sigma = opt2._predict_with_uncertainty(X) np.testing.assert_array_equal(pred1_mu, pred2_mu) diff --git a/tests/test_suggest_next_infill_point_example.py b/tests/test_suggest_next_infill_point_example.py index fa70b2b..fef5419 100644 --- a/tests/test_suggest_next_infill_point_example.py +++ b/tests/test_suggest_next_infill_point_example.py @@ -19,7 +19,7 @@ def test_suggest_next_infill_point_example(): np.random.seed(0) opt.X_ = np.random.rand(10, 2) opt.y_ = np.random.rand(10) - opt._fit_surrogate(opt.X_, opt.y_) + opt.fit_surrogate(opt.X_, opt.y_) x_next = opt.suggest_next_infill_point() diff --git a/tests/test_thread_pool_search.py b/tests/test_thread_pool_search.py index 8d3cdb6..78b1923 100644 --- a/tests/test_thread_pool_search.py +++ b/tests/test_thread_pool_search.py @@ -230,7 +230,7 @@ def test_remote_search_task_returns_array(self): np.random.seed(0) opt.X_ = np.random.uniform(-5, 5, (8, 2)) opt.y_ = sphere(opt.X_) - opt._fit_surrogate(opt.X_, opt.y_) + opt.fit_surrogate(opt.X_, opt.y_) pickled = dill.dumps(opt) result = remote_search_task(pickled) @@ -262,7 +262,7 @@ def test_concurrent_suggest_calls_do_not_crash(self): X_init = np.random.default_rng(0).uniform(-5, 5, (8, 2)) opt.X_ = X_init opt.y_ = sphere(X_init) - opt._fit_surrogate(opt.X_, opt.y_) + opt.fit_surrogate(opt.X_, opt.y_) lock = threading.Lock() errors = []