From 92846ff4e7bc4e2c6c1b2542c423c9cbab4b1f1d Mon Sep 17 00:00:00 2001 From: Qi Zhao Date: Mon, 20 Jul 2026 19:05:44 +0800 Subject: [PATCH 1/2] Release AutoOptLib 1.3.0 with ALDes backend --- CHANGELOG.md | 38 ++ CITATION.cff | 2 +- README.md | 56 +- docs/api.md | 34 ++ docs/architecture.md | 33 +- pyproject.toml | 7 + src/autooptlib/__init__.py | 2 + src/autooptlib/_version.py | 2 +- src/autooptlib/aldes/__init__.py | 84 +++ src/autooptlib/aldes/codec.py | 237 ++++++++ src/autooptlib/aldes/evaluator.py | 292 ++++++++++ src/autooptlib/aldes/features.py | 182 ++++++ src/autooptlib/aldes/model.py | 285 ++++++++++ src/autooptlib/aldes/problems.py | 86 +++ src/autooptlib/aldes/training.py | 209 +++++++ src/autooptlib/aldes/vocabulary.py | 424 ++++++++++++++ src/autooptlib/aldes/workflow.py | 186 +++++++ src/autooptlib/autoopt.py | 25 +- src/autooptlib/components/__init__.py | 1 + src/autooptlib/components/cross_point_n.py | 16 +- .../components/cross_point_uniform.py | 2 +- src/autooptlib/components/search_reset_n.py | 78 +++ .../components/search_reset_rand.py | 2 +- src/autooptlib/utils/design/_estimate.py | 5 +- src/autooptlib/utils/design/_evaluate.py | 151 +++-- src/autooptlib/utils/general/input.py | 58 +- src/autooptlib/utils/solve/__init__.py | 122 +++-- src/autooptlib/utils/space.py | 1 + tests/unit/test_aldes.py | 517 ++++++++++++++++++ tests/unit/test_input_validation.py | 5 + tests/unit/test_public_api.py | 2 +- tests/unit/test_reliability.py | 2 +- tests/unit/test_solve_contracts.py | 22 +- 33 files changed, 3043 insertions(+), 125 deletions(-) create mode 100644 src/autooptlib/aldes/__init__.py create mode 100644 src/autooptlib/aldes/codec.py create mode 100644 src/autooptlib/aldes/evaluator.py create mode 100644 src/autooptlib/aldes/features.py create mode 100644 src/autooptlib/aldes/model.py create mode 100644 src/autooptlib/aldes/problems.py create mode 100644 src/autooptlib/aldes/training.py create mode 100644 src/autooptlib/aldes/vocabulary.py create mode 100644 src/autooptlib/aldes/workflow.py create mode 100644 src/autooptlib/components/search_reset_n.py create mode 100644 tests/unit/test_aldes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 26ac7fb..76c38a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to AutoOptLib are documented here. +## 1.3.0 - 2026-07-19 + +### Added + +- Integrated ALDes as an optional learning-based design backend with an + ALDes-compatible 32-token vocabulary, a deliberately constrained grammar, + an autoregressive PyTorch generator, PPO trainer, and EWC + continual-learning penalty. +- Added a pure-Python ALDes sequence codec and evaluator that execute generated + algorithms through the common AutoOptLib pathway engine. +- Added an optional IOH PBO problem adapter and the missing `search_reset_n` + component required by the ALDes vocabulary. +- Added `Designer="aldes"` to the high-level `autoopt` workflow, including + checkpoint loading, problem features, candidate count, temperature, and + greedy-decoding controls. + +### Changed + +- Corrected discrete uniform-crossover and random-reset parameter bounds to + match the reference implementation. +- Defined one consistent multi-path rule: a fork follows choose, every branch + evaluates one search row (a crossover may include a paired mutation), and + the branches merge before a shared population update. ALDes no longer + generates unused later search rows. +- Made single-problem ALDes design the default with no landscape-feature + extraction or input. Continual design explicitly enables problem-feature + conditioning and paper-style random-walk feature extraction. +- Matched the paper's 5,000-FE training budget, deterministic PPO likelihood + calculation, 100-step learning-rate annealing, and EWC weight of 200. +- Made candidate evaluation order-independent through common random streams + and added reuse of feature-sampling solutions as initial populations. +- Added deterministic candidate-level CPU multiprocessing for ALDes PBO + evaluation, including duplicate-sequence caching and persistent workers. +- Made option lookup safe when an earlier option value is a NumPy array, which + is required for in-memory ALDes problem features. +- Kept PyTorch, IOH, pflacco, pandas, and scikit-learn behind the optional + `autooptlib[aldes]` dependency group. + ## 1.2.0 - 2026-07-19 ### Added diff --git a/CITATION.cff b/CITATION.cff index 3450b26..8735506 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: 1.2.0 message: "If you use AutoOptLib, please cite this software and the accompanying paper." title: "AutoOptLib" type: software -version: 1.2.0 +version: 1.3.0 date-released: 2026-07-19 license: Apache-2.0 repository-code: "https://github.com/auto4opt/AutoOptLib" diff --git a/README.md b/README.md index 85ca078..7eceacb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AutoOptLib -[![Version](https://img.shields.io/badge/version-1.2.0-blue.svg)](https://github.com/auto4opt/AutoOptLib/releases) +[![Version](https://img.shields.io/badge/version-1.3.0-blue.svg)](https://github.com/auto4opt/AutoOptLib/releases) [![License](https://img.shields.io/badge/license-Apache--2.0-green.svg)](LICENSE) [![Tests](https://github.com/auto4opt/AutoOptLib/actions/workflows/tests.yml/badge.svg)](https://github.com/auto4opt/AutoOptLib/actions/workflows/tests.yml) [![Documentation](https://readthedocs.org/projects/autooptlib/badge/?version=latest)](https://autooptlib.readthedocs.io/) @@ -30,6 +30,8 @@ historical releases. - Retry, hard-timeout, failure-penalty, evaluation-cache, and JSONL logging controls for external objectives. - Automatic experiment manifests with software and invocation provenance. +- Optional ALDes autoregressive generation, PPO training, continual-learning, + and pure-Python execution of an ALDes-compatible discrete token vocabulary. ## Installation @@ -50,6 +52,13 @@ python -m ruff format --check . python -m pytest -W error ``` +Install the learning-based ALDes designer separately so core users do not +need PyTorch or IOH: + +```bash +python -m pip install "autooptlib[aldes]" +``` + ## Quick start The following example designs a small optimizer for the bundled CEC 2013 @@ -125,6 +134,51 @@ MATLAB procedure, `AlgFE` counts newly proposed algorithms after the initial `AlgN` incumbents; held-out evaluation of the final algorithms is separate. Sequential problems receive a fresh `ProbFE` budget at every stage. +## Learning-based design with ALDes + +`autooptlib.aldes` contains a constrained 32-token ALDes grammar, +sequence-to-pathway codec, +PyTorch generator, PPO/EWC training utilities, IOH PBO adapter, and a direct +evaluation bridge. A trained generator can be used through the same high-level +entry point: + +```python +from autooptlib import autoopt + +algorithms, trace = autoopt( + Mode="design", + Designer="aldes", + Problem=my_discrete_problem, + InstanceTrain=[train_instance], + InstanceTest=[test_instance], + ALDesModel="checkpoints/aldes.pt", + ALDesCandidates=32, + AlgN=5, + ProbN=50, + ProbFE=5_000, + AlgRuns=5, + Seed=2026, +) +``` + +Single-problem design is the default: create the generator with the default +`GeneratorConfig`, use `ALDesMode="single"` (or omit it), and do not calculate +or pass landscape features. For continual design, train a generator with +`GeneratorConfig(condition_on_features=True)` and call the workflow with +`ALDesMode="continual"` plus `ALDesFeatures=problem_features`. The continual +feature extractor is available as `autooptlib.aldes.extract_pbo_features` and +returns both the feature vector and sampled initial populations. Its result +object can be passed directly as `ALDesFeatures`; the associated populations +are then reused automatically, or they can be supplied separately through +`ALDesInitialPopulations`. + +The ordinary `Designer="search"` workflow remains the default. ALDes decodes +its generated programs into the same `Design` and pathway objects used by the +rest of AutoOptLib; MATLAB and MATLAB Engine are not required. +`ALDesModel` checkpoint paths must be produced by +`ALDesGenerator.save_checkpoint`; unversioned checkpoints from the historical +standalone ALDes repository are not loaded implicitly. + ## Reproducibility Pass `Seed=` to `autoopt`. Version 1.2.0 routes this seed through diff --git a/docs/api.md b/docs/api.md index c468d47..02056bf 100644 --- a/docs/api.md +++ b/docs/api.md @@ -33,6 +33,16 @@ Common options: - `CheckpointDir`: optional Solve or Design checkpoint directory. - `CheckpointEvery`: generations or candidate evaluations between atomic writes. - `Resume`: resume matching completed or interrupted checkpoints. +- `Designer`: `search` (default) or `aldes` in design mode. +- `ALDesModel`: trained `ALDesGenerator` or checkpoint path. +- `ALDesMode`: `single` (default, no problem features) or `continual`. +- `ALDesFeatures`: target-problem feature vector or `.npy` path, required only + in continual mode and rejected in single-problem mode. +- `ALDesInitialPopulations`: optional sampled populations or `.npy`/`.npz` + path reused across candidate algorithms and runs. +- `ALDesCandidates`: number of generated candidates; must be at least `AlgN`. +- `ALDesTemperature`: positive sampling temperature (default `1.0`). +- `ALDesGreedy`: use grammar-constrained greedy decoding (default `False`). The mode-specific defaults match the reference MATLAB package: Design uses `AlgQ=4`, `ProbN=20`, `ProbFE=5000`, `InnerFE=500`, `AlgN=10`, `AlgFE=5000`, @@ -46,6 +56,30 @@ Design returns `(algorithms, trace)`. Solve returns algorithms on held-out instances after the `AlgFE` search budget. Every completed call also writes `experiment.json` to `OutputDir`. +## `autooptlib.aldes` + +The optional ALDes API exposes `validate_sequence`, `allowed_next_tokens`, +`decode_sequence`, `AutoOptEvaluator`, `EvaluationConfig`, and +`make_pbo_problem` without importing PyTorch. Single-problem generators do not +use landscape features. Continual generators opt in with +`GeneratorConfig(condition_on_features=True)` and can use +`extract_pbo_features` to obtain paper-style random-walk features and reusable +initial populations. `ALDesGenerator`, `PPOTrainer`, and +`ElasticWeightConsolidation` load PyTorch lazily and require +`pip install "autooptlib[aldes]"`. + +`EvaluationConfig(initial_populations=...)` accepts `(N,D)`, `(runs,N,D)`, +`(instances,runs,N,D)`, or an instance-index mapping. Candidate batches reuse +the same initial random stream so their ranking is independent of enumeration +order. + +`evaluate_pbo_actions(..., workers=None)` evaluates unique ALDes candidates +in a persistent CPU process pool, automatically bounded by the number of +logical CPU cores. Pass `workers=1` for serial execution or a positive integer +for an explicit limit. Neural-network tensors remain on their configured +PyTorch accelerator; only token sequences and CPU evaluation data cross the +process boundary. + ## `make_problem(...)` Wrap an ordinary scalar minimization objective. See diff --git a/docs/architecture.md b/docs/architecture.md index 6aa5c2d..55d400b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,7 +5,8 @@ AutoOptLib separates four concerns: 1. A **problem definition** constructs related training and test instances. 2. A **design space** supplies type-compatible selection, search, update, and archive components. -3. The **design engine** searches graph structures and component parameters. +3. A **design backend** either searches graph structures and parameters or + uses ALDes to generate a grammar-constrained token sequence. 4. The **execution engine** applies either a designed JSON algorithm or a built-in baseline under a strict objective-evaluation budget. @@ -13,6 +14,36 @@ This separation is important for application studies: the problem code does not contain optimizer logic, and a selected algorithm can be exported and executed later without rerunning automated design. +ALDes token programs are decoded into the same pathway representation before +execution. The learning backend and search backend therefore share component, +budget, problem, serialization, and reliability semantics rather than +maintaining separate Python and MATLAB evaluators. + +For a multi-path or ALDes fork algorithm, one outer iteration selects and +partitions the population, evaluates only the first primary search step of +each branch, merges the offspring, and applies the shared update. A mutation +paired with a crossover is part of that first step and is also executed. ALDes +fork sequences cannot contain later search rows, keeping generation and +execution consistent. This is AutoOptLib's constrained ALDes dialect, not a +claim that every permissive sequence accepted by the historical generator or +the paper's general pointer notation has identical semantics. +The fork parameter has two non-aliased modes: both branches execute the whole +search row, or—only for crossover plus mutation—the second branch starts at +the mutation. + +The 32-token component vocabulary and ten-bin parameter decoder follow the +released ALDes source. They are intentionally versioned separately from the +paper's Appendix A2 table, which lists a different component set and is itself +inconsistent with the paper's `always_select` example. Parameter bins retain +the released implementation's linear interpolation over each component's +bounds; AutoOptLib does not silently reinterpret them as percentages. + +ALDes uses two explicit learning modes. Single-problem design is the default +and conditions only on the generated token prefix. Continual design opts into +a problem-feature token, paper-style random-walk feature extraction, and EWC. +The sampled random-walk populations can be supplied to `EvaluationConfig` so +all candidate algorithms are compared from the same initial solutions. + The supported public surface is documented in [Public API](api.md). Internal mode-based component functions remain implementation details and may evolve between minor releases. diff --git a/pyproject.toml b/pyproject.toml index 0cd93a4..75ff076 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,13 @@ applications = [ surrogate = [ "scikit-learn>=1.2,<2", ] +aldes = [ + "torch>=2.1,<3", + "ioh>=0.3.14,<1", + "pandas>=2,<4", + "pflacco>=1.2,<2", + "scikit-learn>=1.2,<2", +] [project.urls] Homepage = "https://github.com/auto4opt/AutoOptLib" diff --git a/src/autooptlib/__init__.py b/src/autooptlib/__init__.py index a9bc2e8..a789a4c 100644 --- a/src/autooptlib/__init__.py +++ b/src/autooptlib/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from . import aldes from ._version import __version__ from .applications import ( MaterialStackingInstance, @@ -23,6 +24,7 @@ __all__ = [ "Design", + "aldes", "MaterialStackingInstance", "RISBeamformingInstance", "StackingWeights", diff --git a/src/autooptlib/_version.py b/src/autooptlib/_version.py index 9a5b4a0..13e7faf 100644 --- a/src/autooptlib/_version.py +++ b/src/autooptlib/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the package version.""" -__version__ = "1.2.0" +__version__ = "1.3.0" diff --git a/src/autooptlib/aldes/__init__.py b/src/autooptlib/aldes/__init__.py new file mode 100644 index 0000000..6207190 --- /dev/null +++ b/src/autooptlib/aldes/__init__.py @@ -0,0 +1,84 @@ +"""ALDes: autoregressive learning for metaheuristic algorithm design. + +The codec and evaluator require only NumPy and AutoOptLib. PyTorch is loaded +only when the generator or PPO training APIs are requested. +""" + +from __future__ import annotations + +from importlib import import_module + +from .codec import ComponentInstruction, decode_sequence +from .evaluator import AutoOptEvaluator, EvaluationConfig, evaluate_pbo_actions +from .features import PBOFeatureResult, extract_pbo_features, standardize_features +from .problems import make_pbo_problem +from .vocabulary import ( + BEGIN_INDEX, + END_INDEX, + TOKEN_BY_INDEX, + TOKEN_BY_NAME, + TOKENS, + VOCABULARY_SIZE, + SequenceValidationError, + allowed_next_tokens, + normalize_sequence, + tokens_to_names, + validate_sequence, +) + +_TORCH_EXPORTS = { + "ALDesGenerator": ("model", "ALDesGenerator"), + "GenerationResult": ("model", "GenerationResult"), + "GeneratorConfig": ("model", "GeneratorConfig"), + "ElasticWeightConsolidation": ("training", "ElasticWeightConsolidation"), + "PPOConfig": ("training", "PPOConfig"), + "PPOTrainer": ("training", "PPOTrainer"), +} + + +def __getattr__(name: str): + if name not in _TORCH_EXPORTS: + raise AttributeError(name) + module_name, attribute = _TORCH_EXPORTS[name] + try: + module = import_module(f".{module_name}", __name__) + except ImportError as exc: + if exc.name == "torch": + raise ImportError( + "PyTorch is required for ALDes generation and training. Install " + "AutoOptLib with `pip install 'autooptlib[aldes]'`." + ) from exc + raise + value = getattr(module, attribute) + globals()[name] = value + return value + + +__all__ = [ + "ALDesGenerator", + "AutoOptEvaluator", + "BEGIN_INDEX", + "ComponentInstruction", + "END_INDEX", + "ElasticWeightConsolidation", + "EvaluationConfig", + "GenerationResult", + "GeneratorConfig", + "PPOConfig", + "PPOTrainer", + "PBOFeatureResult", + "SequenceValidationError", + "TOKENS", + "TOKEN_BY_INDEX", + "TOKEN_BY_NAME", + "VOCABULARY_SIZE", + "allowed_next_tokens", + "decode_sequence", + "evaluate_pbo_actions", + "extract_pbo_features", + "make_pbo_problem", + "normalize_sequence", + "tokens_to_names", + "standardize_features", + "validate_sequence", +] diff --git a/src/autooptlib/aldes/codec.py b/src/autooptlib/aldes/codec.py new file mode 100644 index 0000000..bc8e599 --- /dev/null +++ b/src/autooptlib/aldes/codec.py @@ -0,0 +1,237 @@ +"""Translate ALDes token programs into AutoOptLib algorithm designs.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Sequence + +import numpy as np + +from ..components import get_component +from ..utils.design import Design +from ..utils.design._helpers import Pathway, PathwayParam, SearchParam, SearchStep +from ..utils.space import space +from .vocabulary import PARAMETER_INDICES, TOKEN_BY_INDEX, TokenKind, validate_sequence + + +@dataclass(frozen=True) +class ComponentInstruction: + token: int + parameter_token: int | None + pointer: str + pointer_parameter_token: int | None + + @property + def name(self) -> str: + return TOKEN_BY_INDEX[self.token].name + + @property + def kind(self) -> TokenKind: + return TOKEN_BY_INDEX[self.token].kind + + +def _parse(sequence: Sequence[int] | np.ndarray) -> list[ComponentInstruction]: + values = validate_sequence(sequence) + instructions: list[ComponentInstruction] = [] + position = 1 + while position < len(values) - 1: + component = TOKEN_BY_INDEX[values[position]] + component_index = values[position] + position += 1 + parameter_token = None + if component.parameter_count: + parameter_token = values[position] + position += 1 + pointer = TOKEN_BY_INDEX[values[position]] + position += 1 + pointer_parameter_token = None + if pointer.parameter_count: + pointer_parameter_token = values[position] + position += 1 + instructions.append( + ComponentInstruction( + token=component_index, + parameter_token=parameter_token, + pointer=pointer.name, + pointer_parameter_token=pointer_parameter_token, + ) + ) + return instructions + + +def _parameter_value( + instruction: ComponentInstruction, problem: Any +) -> np.ndarray | None: + if instruction.parameter_token is None: + return None + component = get_component(instruction.name) + bounds, _ = component(problem, "parameter") + if bounds is None: + return None + array = np.asarray(bounds, dtype=float) + if array.ndim == 1: + array = array.reshape(-1, 2) + bin_index = PARAMETER_INDICES.index(instruction.parameter_token) + fraction = bin_index / 9.0 + return array[:, 0] + (array[:, 1] - array[:, 0]) * fraction + + +def _termination(instruction: ComponentInstruction, setting: Any) -> np.ndarray: + if instruction.pointer == "forward": + return np.array([-math.inf, 1.0]) + if instruction.pointer == "iterate": + condition_values = (0.01, 0.05, 0.10, 0.15, 0.20) + token = instruction.pointer_parameter_token + if token is None: + raise ValueError("Iterate pointer is missing its condition token.") + fraction = condition_values[PARAMETER_INDICES.index(token)] + probability_evaluations = int( + getattr(setting, "ProbFE", getattr(setting, "prob_fe", 5000)) + ) + population_size = int(getattr(setting, "ProbN", getattr(setting, "prob_n", 20))) + limit = max(1, math.ceil(fraction * probability_evaluations / population_size)) + rate = float(getattr(setting, "IncRate", getattr(setting, "inc_rate", 0.05))) + return np.array([rate, float(limit)]) + return np.array([-math.inf, 1.0]) + + +def _branches( + instructions: list[ComponentInstruction], +) -> list[list[ComponentInstruction]]: + branches = [list(instructions)] + for fork_position, instruction in enumerate(instructions): + if instruction.pointer != "fork": + continue + token = instruction.pointer_parameter_token + if token is None: + raise ValueError("Fork pointer is missing its target token.") + # ALDes maps parameter bins to one-based operator targets. The + # constrained grammar retains only targets 2 and 3 because all larger + # targets clamp to one of those same branch structures. + target = max(0, PARAMETER_INDICES.index(token) - 1) + # The legacy generator clamps a fork target to the final search + # component. Never let a branch jump directly to the update. + target = min(target, len(instructions) - 2) + branch = instructions[: fork_position + 1] + instructions[target:] + # Avoid a duplicated component at the splice boundary. + compact: list[ComponentInstruction] = [] + for item in branch: + if compact and compact[-1] is item: + continue + compact.append(item) + if ( + compact + and compact[0].kind is TokenKind.CHOOSE + and compact[-1].kind is TokenKind.UPDATE + and any(item.kind is TokenKind.SEARCH for item in compact) + ): + branches.append(compact) + return branches + + +def _pathway( + instructions: list[ComponentInstruction], problem: Any, setting: Any +) -> tuple[Pathway, PathwayParam]: + choose = instructions[0] + update = instructions[-1] + searches = instructions[1:-1] + steps: list[SearchStep] = [] + parameters: list[SearchParam] = [] + position = 0 + while position < len(searches): + primary = searches[position] + secondary = None + if primary.name.startswith("cross_") and position + 1 < len(searches): + candidate = searches[position + 1] + if candidate.name.startswith("search_reset_"): + secondary = candidate + position += 1 + termination = _termination(primary, setting) + if secondary is not None and secondary.pointer == "forward": + termination = np.array([-math.inf, 1.0]) + steps.append( + SearchStep( + primary=primary.name, + secondary=secondary.name if secondary is not None else None, + termination=termination, + ) + ) + parameters.append( + SearchParam( + primary=_parameter_value(primary, problem), + secondary=( + _parameter_value(secondary, problem) + if secondary is not None + else None + ), + ) + ) + position += 1 + + archive = list(getattr(setting, "Archive", getattr(setting, "archive", [])) or []) + return ( + Pathway( + choose=choose.name, + search=steps, + update=update.name, + archive=archive, + ), + PathwayParam( + choose=_parameter_value(choose, problem), + search=parameters, + update=_parameter_value(update, problem), + ), + ) + + +def decode_sequence( + sequence: Sequence[int] | np.ndarray, problem: Any, setting: Any +) -> Design: + """Decode an ALDes action sequence into an executable :class:`Design`.""" + + instructions = _parse(sequence) + problems = list(problem) if isinstance(problem, (list, tuple)) else [problem] + if not problems: + raise ValueError("At least one constructed problem is required.") + if not hasattr(setting, "AllOp") and not hasattr(setting, "all_op"): + setting = space(problems, setting) + + pathways: list[Pathway] = [] + pathway_parameters: list[PathwayParam] = [] + instruction_branches = _branches(instructions) + for branch in instruction_branches: + pathway, parameters = _pathway(branch, problems, setting) + pathways.append(pathway) + pathway_parameters.append(parameters) + + all_op = list(getattr(setting, "AllOp", getattr(setting, "all_op", []))) + matrices: list[np.ndarray] = [] + for branch in instruction_branches: + indices = [all_op.index(item.name) + 1 for item in branch] + matrices.append(np.asarray(list(zip(indices[:-1], indices[1:])), dtype=int)) + + encoded_parameters: list[list[Any]] = [[None, None] for _ in all_op] + for instruction in instructions: + index = all_op.index(instruction.name) + behavior = None + if instruction.kind is TokenKind.SEARCH: + behavior = "GS" if instruction.pointer == "forward" else "LS" + encoded_parameters[index] = [ + _parameter_value(instruction, problems), + behavior, + ] + + design = Design() + design.operator = matrices + design.parameter = encoded_parameters + design.construct([pathways], [pathway_parameters]) + runs = int(getattr(setting, "AlgRuns", getattr(setting, "alg_runs", 1))) + design.performance = np.zeros((len(problems), runs)) + design.performance_approx = np.zeros((len(problems), runs)) + design.last_runs = {index: [None] * runs for index in range(len(problems))} + design.aldes_sequence = validate_sequence(sequence) + return design + + +__all__ = ["ComponentInstruction", "decode_sequence"] diff --git a/src/autooptlib/aldes/evaluator.py b/src/autooptlib/aldes/evaluator.py new file mode 100644 index 0000000..ef213cb --- /dev/null +++ b/src/autooptlib/aldes/evaluator.py @@ -0,0 +1,292 @@ +"""Pure-Python evaluation bridge between ALDes and AutoOptLib.""" + +from __future__ import annotations + +import atexit +import math +import multiprocessing +import os +import threading +from concurrent.futures import ProcessPoolExecutor, as_completed +from copy import deepcopy +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any, Iterable, Sequence + +import numpy as np + +from ..problems.base import validate_constructed_problems +from ..utils.general.process import _build_problem_struct +from ..utils.space import space +from .codec import decode_sequence +from .vocabulary import normalize_sequence + +_PROCESS_POOLS: dict[int, ProcessPoolExecutor] = {} +_PROCESS_POOL_LOCK = threading.Lock() + + +@dataclass(frozen=True) +class EvaluationConfig: + """Execution budget used to score ALDes-generated algorithms.""" + + population_size: int = 50 + evaluations: int = 5_000 + runs: int = 5 + inner_evaluations: int = 200 + metric: str = "quality" + improvement_rate: float = -math.inf + archive: tuple[str, ...] = () + seed: int | None = None + initial_populations: Any = None + + def __post_init__(self) -> None: + if self.population_size <= 0: + raise ValueError("population_size must be positive.") + if self.evaluations < self.population_size: + raise ValueError("evaluations must be at least population_size.") + if self.runs <= 0: + raise ValueError("runs must be positive.") + + +def _setting(config: EvaluationConfig) -> SimpleNamespace: + return SimpleNamespace( + Mode="design", + AlgP=1, + AlgQ=3, + Archive=list(config.archive), + IncRate=config.improvement_rate, + ProbN=config.population_size, + ProbFE=config.evaluations, + InnerFE=config.inner_evaluations, + AlgN=1, + AlgFE=1, + AlgRuns=config.runs, + Metric=config.metric, + Evaluate="exact", + Compare="average", + LSRange=0.3, + rng=np.random.default_rng(config.seed), + Seed=config.seed, + InitialPopulations=config.initial_populations, + ) + + +class AutoOptEvaluator: + """Evaluate ALDes sequences with AutoOptLib's Python execution engine.""" + + def __init__( + self, + problem: Any, + instances: Sequence[Any], + *, + config: EvaluationConfig | None = None, + ) -> None: + self.problem_descriptor = problem + self.instances = list(instances) + if not self.instances: + raise ValueError("instances cannot be empty.") + self.config = config or EvaluationConfig() + self.setting = _setting(self.config) + self.problems = _build_problem_struct( + self.problem_descriptor, self.instances, self.setting + ) + self.problems, self.data, _ = self.problem_descriptor( + self.problems, self.instances, "construct" + ) + validate_constructed_problems(self.problems, self.data) + self.setting = space(self.problems, self.setting) + + def evaluate( + self, + sequence: Sequence[int] | np.ndarray, + *, + instance_indices: Sequence[int] | None = None, + ) -> np.ndarray: + indices = ( + list(range(len(self.instances))) + if instance_indices is None + else [int(index) for index in instance_indices] + ) + if not indices: + raise ValueError("instance_indices cannot be empty.") + if min(indices) < 0 or max(indices) >= len(self.instances): + raise IndexError("instance index is outside the configured instances.") + algorithm = decode_sequence(sequence, self.problems, self.setting) + algorithm.evaluate( + self.problems, self.data, self.setting, seed_instance=indices + ) + return np.asarray(algorithm.performance[indices, :], dtype=float).copy() + + def evaluate_many( + self, + sequences: Iterable[Sequence[int] | np.ndarray], + *, + instance_indices: Sequence[int] | None = None, + ) -> tuple[np.ndarray, list[np.ndarray]]: + # Candidate algorithms must see the same initial random stream. This + # makes their comparison independent of enumeration order while still + # advancing the evaluator between PPO batches. + initial_state = deepcopy(self.setting.rng.bit_generator.state) + performances = [] + for sequence in sequences: + self.setting.rng.bit_generator.state = deepcopy(initial_state) + performances.append( + self.evaluate(sequence, instance_indices=instance_indices) + ) + self.setting.rng.bit_generator.state = deepcopy(initial_state) + self.setting.rng.integers(0, np.iinfo(np.uint64).max, dtype=np.uint64) + means = np.asarray([float(np.mean(values)) for values in performances]) + return means, performances + + +def _shutdown_process_pools() -> None: + for executor in _PROCESS_POOLS.values(): + executor.shutdown(wait=False, cancel_futures=True) + _PROCESS_POOLS.clear() + + +atexit.register(_shutdown_process_pools) + + +def _process_pool(workers: int) -> ProcessPoolExecutor: + """Return one persistent spawn-based pool for CPU objective evaluation.""" + + with _PROCESS_POOL_LOCK: + executor = _PROCESS_POOLS.get(workers) + if executor is None: + executor = ProcessPoolExecutor( + max_workers=workers, + mp_context=multiprocessing.get_context("spawn"), + ) + _PROCESS_POOLS[workers] = executor + return executor + + +def _resolve_evaluation_workers(requested: int | None, jobs: int) -> int: + """Resolve an explicit or environment-controlled CPU worker count.""" + + if jobs <= 1: + return 1 + value: str | int | None = requested + if value is None: + value = os.environ.get("ALDES_EVAL_WORKERS", "auto") + if isinstance(value, str) and value.strip().lower() in {"", "auto"}: + count = os.cpu_count() or 1 + else: + try: + count = int(value) + except (TypeError, ValueError) as exc: + raise ValueError( + "ALDES_EVAL_WORKERS must be 'auto' or a positive integer." + ) from exc + if count <= 0: + raise ValueError( + "ALDES_EVAL_WORKERS must be 'auto' or a positive integer." + ) + return max(1, min(count, jobs)) + + +def _evaluate_pbo_sequence( + problem_id: int, + sequence: tuple[int, ...], + instances: tuple[int, ...], + config: EvaluationConfig, +) -> tuple[float, np.ndarray]: + """Evaluate one candidate in an isolated CPU worker process.""" + + from .problems import make_pbo_problem + + evaluator = AutoOptEvaluator( + make_pbo_problem(problem_id), list(instances), config=config + ) + performance = evaluator.evaluate(sequence) + return float(np.mean(performance)), performance + + +def _evaluate_pbo_sequences( + actions: np.ndarray, + problem_id: int, + instances: Sequence[int], + config: EvaluationConfig, + *, + workers: int | None, +) -> tuple[list[float], list[np.ndarray]]: + """Evaluate unique candidates serially or with a persistent CPU pool.""" + + canonical = [tuple(normalize_sequence(row)) for row in actions] + unique = list(dict.fromkeys(canonical)) + worker_count = _resolve_evaluation_workers(workers, len(unique)) + results: dict[tuple[int, ...], tuple[float, np.ndarray]] = {} + instance_tuple = tuple(int(instance) for instance in instances) + + if worker_count == 1: + for sequence in unique: + results[sequence] = _evaluate_pbo_sequence( + int(problem_id), sequence, instance_tuple, config + ) + else: + executor = _process_pool(worker_count) + pending = { + executor.submit( + _evaluate_pbo_sequence, + int(problem_id), + sequence, + instance_tuple, + config, + ): sequence + for sequence in unique + } + for future in as_completed(pending): + results[pending[future]] = future.result() + + means = [results[sequence][0] for sequence in canonical] + performances = [np.array(results[sequence][1], copy=True) for sequence in canonical] + return means, performances + + +def evaluate_pbo_actions( + actions: Any, + problem_id: int, + *, + evaluate_test: bool = False, + seed: int | None = None, + initial_populations: Any = None, + workers: int | None = 1, +) -> tuple[list[float], list[np.ndarray]]: + """Compatibility replacement for ALDes's MATLAB ``get_performance``. + + Training uses PBO instances 1--3 with five runs. Test evaluation uses + instance 4 with the paper's 50,000-FE/30-run protocol. + """ + + if hasattr(actions, "detach"): + actions = actions.detach().cpu().numpy() + array = np.asarray(actions) + if array.ndim == 1: + array = array.reshape(1, -1) + if evaluate_test: + instances = [4] + config = EvaluationConfig( + evaluations=50_000, + runs=30, + seed=seed, + initial_populations=initial_populations, + ) + else: + instances = [1, 2, 3] + config = EvaluationConfig( + evaluations=5_000, + runs=5, + seed=seed, + initial_populations=initial_populations, + ) + return _evaluate_pbo_sequences( + array, + int(problem_id), + instances, + config, + workers=workers, + ) + + +__all__ = ["AutoOptEvaluator", "EvaluationConfig", "evaluate_pbo_actions"] diff --git a/src/autooptlib/aldes/features.py b/src/autooptlib/aldes/features.py new file mode 100644 index 0000000..54f10ad --- /dev/null +++ b/src/autooptlib/aldes/features.py @@ -0,0 +1,182 @@ +"""Problem-feature extraction for continual ALDes training.""" + +from __future__ import annotations + +import warnings +from dataclasses import dataclass +from typing import Any, Sequence + +import numpy as np + + +@dataclass(frozen=True) +class PBOFeatureResult: + """A reproducible PBO feature vector and the samples that produced it.""" + + features: np.ndarray + feature_names: tuple[str, ...] + samples: np.ndarray + initial_populations: np.ndarray + + +def _binary_random_walk( + dimension: int, length: int, rng: np.random.Generator +) -> np.ndarray: + if dimension <= 0 or length <= 0: + raise ValueError("dimension and random-walk length must be positive.") + current = rng.integers(0, 2, size=dimension, dtype=np.int8) + sample = np.empty((length, dimension), dtype=np.int8) + for row in range(length): + sample[row] = current + column = int(rng.integers(0, dimension)) + current = current.copy() + current[column] = 1 - current[column] + return sample + + +def _feature_mapping( + decisions: np.ndarray, objectives: np.ndarray, *, seed: int +) -> dict[str, float]: + try: + import pandas as pd + from pflacco.classical_ela_features import ( + calculate_dispersion, + calculate_ela_meta, + calculate_information_content, + calculate_nbc, + ) + except ImportError as exc: # pragma: no cover - dependency specific + raise ImportError( + "Continual ALDes feature extraction requires pandas and pflacco; " + "install AutoOptLib with `pip install 'autooptlib[aldes]'`." + ) from exc + + frame = pd.DataFrame(decisions) + values: dict[str, Any] = {} + # Degenerate neighborhoods are expected on discrete random walks. pflacco + # may emit divide-by-zero warnings for those intermediate ratios; their + # non-finite outputs are handled explicitly during cross-trial averaging. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + values.update(calculate_information_content(frame, objectives, seed=seed)) + values.update(calculate_ela_meta(frame, objectives)) + values.update(calculate_nbc(frame, objectives)) + values.update( + calculate_dispersion(frame, objectives, dist_method="hamming") + ) + result: dict[str, float] = {} + for name, value in values.items(): + # Runtime measurements are machine-dependent and are not landscape + # descriptors. Excluding them also makes saved features portable. + if str(name).endswith("costs_runtime"): + continue + try: + result[str(name)] = float(value) + except (TypeError, ValueError): + continue + return result + + +def extract_pbo_features( + problem_id: int, + *, + instance: int = 1, + dimension: int = 100, + trials: int = 5, + sample_factor: int = 100, + feature_dim: int = 32, + population_size: int = 50, + seed: int | None = None, +) -> PBOFeatureResult: + """Extract the paper-style random-walk PBO features for continual ALDes. + + Every trial uses a binary random walk of length ``sample_factor * + dimension``. Numeric, non-runtime pflacco features are averaged across + trials. Names are sorted before the first ``feature_dim`` values are kept, + giving a stable schema across runs and library dictionary ordering. + """ + + if trials <= 0 or sample_factor <= 0 or feature_dim <= 0: + raise ValueError("trials, sample_factor, and feature_dim must be positive.") + if population_size <= 0: + raise ValueError("population_size must be positive.") + try: + import ioh + except ImportError as exc: # pragma: no cover - dependency specific + raise ImportError( + "PBO feature extraction requires IOH; install AutoOptLib with " + "`pip install 'autooptlib[aldes]'`." + ) from exc + + problem = ioh.get_problem( + int(problem_id), + instance=int(instance), + dimension=int(dimension), + problem_class=ioh.ProblemClass.PBO, + ) + root = np.random.default_rng(seed) + mappings: list[dict[str, float]] = [] + samples: list[np.ndarray] = [] + for _ in range(trials): + trial_seed = int(root.integers(0, np.iinfo(np.int32).max)) + trial_rng = np.random.default_rng(trial_seed) + decisions = _binary_random_walk( + dimension, sample_factor * dimension, trial_rng + ) + objectives = np.asarray(problem(decisions), dtype=float).reshape(-1) + mappings.append(_feature_mapping(decisions, objectives, seed=trial_seed)) + samples.append(decisions) + + names = sorted({name for mapping in mappings for name in mapping}) + matrix = np.full((trials, len(names)), np.nan, dtype=float) + for row, mapping in enumerate(mappings): + for column, name in enumerate(names): + matrix[row, column] = mapping.get(name, np.nan) + finite = np.isfinite(matrix) + counts = finite.sum(axis=0) + totals = np.where(finite, matrix, 0.0).sum(axis=0) + averaged = np.divide( + totals, + counts, + out=np.zeros_like(totals), + where=counts > 0, + ) + + if len(names) < feature_dim: + padding = feature_dim - len(names) + names.extend(f"padding.{index}" for index in range(padding)) + averaged = np.pad(averaged, (0, padding)) + selected_names = tuple(names[:feature_dim]) + selected = np.asarray(averaged[:feature_dim], dtype=np.float32) + sample_array = np.stack(samples) + if sample_array.shape[1] < population_size: + raise ValueError("The feature sample is smaller than population_size.") + population_indices = np.linspace( + 0, sample_array.shape[1] - 1, population_size, dtype=int + ) + initial = np.array(sample_array[:, population_indices, :], copy=True) + return PBOFeatureResult(selected, selected_names, sample_array, initial) + + +def standardize_features( + results: Sequence[PBOFeatureResult], +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Standardize continual-task features without leaking runtime metadata.""" + + if not results: + raise ValueError("At least one feature result is required.") + names = results[0].feature_names + if any(result.feature_names != names for result in results[1:]): + raise ValueError("All feature results must use the same feature schema.") + matrix = np.vstack([result.features for result in results]).astype(float) + mean = matrix.mean(axis=0) + scale = matrix.std(axis=0) + scale[scale == 0] = 1.0 + return ((matrix - mean) / scale).astype(np.float32), mean, scale + + +__all__ = [ + "PBOFeatureResult", + "extract_pbo_features", + "standardize_features", +] diff --git a/src/autooptlib/aldes/model.py b/src/autooptlib/aldes/model.py new file mode 100644 index 0000000..16526a5 --- /dev/null +++ b/src/autooptlib/aldes/model.py @@ -0,0 +1,285 @@ +"""Autoregressive PyTorch generator for ALDes token programs.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +import numpy as np +import torch +from torch import nn + +from .vocabulary import BEGIN_INDEX, END_INDEX, VOCABULARY_SIZE, allowed_next_tokens + + +@dataclass(frozen=True) +class GeneratorConfig: + feature_dim: int = 32 + model_dim: int = 32 + heads: int = 8 + layers: int = 8 + feedforward_dim: int = 2048 + dropout: float = 0.1 + max_length: int = 50 + condition_on_features: bool = False + position_encoding: str = "sinusoidal" + + +@dataclass +class GenerationResult: + sequences: torch.Tensor + log_probabilities: torch.Tensor + probabilities: torch.Tensor + + +class ALDesGenerator(nn.Module): + """Generate valid algorithms while applying the ALDes grammar mask.""" + + def __init__(self, config: GeneratorConfig | None = None) -> None: + super().__init__() + self.config = config or GeneratorConfig() + if self.config.model_dim % self.config.heads: + raise ValueError("model_dim must be divisible by heads.") + if self.config.position_encoding not in {"sinusoidal", "learned"}: + raise ValueError("position_encoding must be 'sinusoidal' or 'learned'.") + self.token_embedding = nn.Embedding(VOCABULARY_SIZE, self.config.model_dim) + if self.config.position_encoding == "learned": + self.position_embedding: nn.Module | None = nn.Embedding( + self.config.max_length + 1, self.config.model_dim + ) + self.register_buffer("sinusoidal_positions", None) + else: + self.position_embedding = None + self.register_buffer( + "sinusoidal_positions", + self._make_sinusoidal_positions( + self.config.max_length + 1, self.config.model_dim + ), + ) + self.feature_projection = ( + nn.Linear(self.config.feature_dim, self.config.model_dim) + if self.config.condition_on_features + else None + ) + layer = nn.TransformerEncoderLayer( + d_model=self.config.model_dim, + nhead=self.config.heads, + dim_feedforward=self.config.feedforward_dim, + dropout=self.config.dropout, + batch_first=True, + norm_first=False, + ) + self.decoder = nn.TransformerEncoder(layer, self.config.layers) + self.output = nn.Linear(self.config.model_dim, VOCABULARY_SIZE) + + @staticmethod + def _make_sinusoidal_positions(length: int, dimension: int) -> torch.Tensor: + positions = torch.arange(length, dtype=torch.float32).unsqueeze(1) + scale = torch.exp( + torch.arange(0, dimension, 2, dtype=torch.float32) + * (-np.log(10_000.0) / dimension) + ) + encoding = torch.zeros(length, dimension, dtype=torch.float32) + encoding[:, 0::2] = torch.sin(positions * scale) + if dimension > 1: + encoding[:, 1::2] = torch.cos(positions * scale[: dimension // 2]) + return encoding + + def _features( + self, features: torch.Tensor | None, batch_size: int + ) -> torch.Tensor: + if not self.config.condition_on_features or self.feature_projection is None: + raise RuntimeError("This generator is not configured for problem features.") + if features is None: + raise ValueError( + "Continual ALDes mode requires one problem-feature vector per batch." + ) + features = torch.as_tensor( + features, + dtype=self.feature_projection.weight.dtype, + device=self.feature_projection.weight.device, + ) + if features.ndim == 1: + features = features.unsqueeze(0) + if features.shape[-1] != self.config.feature_dim: + raise ValueError( + f"Expected {self.config.feature_dim} problem features, " + f"got {features.shape[-1]}." + ) + if features.shape[0] == 1 and batch_size > 1: + features = features.expand(batch_size, -1) + if features.shape[0] != batch_size: + raise ValueError("Feature batch size does not match the token batch.") + return features + + def logits( + self, features: torch.Tensor | None, tokens: torch.Tensor + ) -> torch.Tensor: + tokens = torch.as_tensor( + tokens, dtype=torch.long, device=self.output.weight.device + ) + if tokens.ndim != 2: + raise ValueError("tokens must have shape (batch, length).") + batch_size, length = tokens.shape + if length > self.config.max_length: + raise ValueError("Token sequence exceeds max_length.") + position_offset = 1 if self.config.condition_on_features else 0 + positions = torch.arange( + position_offset, + position_offset + length, + device=tokens.device, + dtype=torch.long, + ).unsqueeze(0) + if self.position_embedding is not None: + positional = self.position_embedding(positions) + else: + positional = self.sinusoidal_positions[positions].to( + device=tokens.device, dtype=self.token_embedding.weight.dtype + ) + embedded = self.token_embedding(tokens) + positional + if self.config.condition_on_features: + conditioned = self._features(features, batch_size) + feature_token = self.feature_projection(conditioned).unsqueeze(1) + hidden = torch.cat((feature_token, embedded), dim=1) + else: + if features is not None: + raise ValueError( + "Single-problem ALDes mode does not accept problem features." + ) + hidden = embedded + total_length = hidden.shape[1] + causal_mask = torch.triu( + torch.ones( + total_length, + total_length, + device=tokens.device, + dtype=torch.bool, + ), + diagonal=1, + ) + decoded = self.decoder(hidden, mask=causal_mask) + token_offset = 1 if self.config.condition_on_features else 0 + return self.output(decoded[:, token_offset:, :]) + + @staticmethod + def _grammar_mask(tokens: torch.Tensor) -> torch.Tensor: + rows = [allowed_next_tokens(row.detach().cpu().numpy()) for row in tokens] + return torch.as_tensor(np.stack(rows), dtype=torch.bool, device=tokens.device) + + def generate( + self, + features: torch.Tensor | None = None, + *, + candidates: int = 1, + temperature: float = 1.0, + greedy: bool = False, + generator: torch.Generator | None = None, + ) -> GenerationResult: + if candidates <= 0: + raise ValueError("candidates must be positive.") + if temperature <= 0: + raise ValueError("temperature must be positive.") + device = self.output.weight.device + sequences = torch.full( + (candidates, 1), BEGIN_INDEX, dtype=torch.long, device=device + ) + if self.config.condition_on_features: + features = self._features(features, candidates) + elif features is not None: + raise ValueError("Single-problem ALDes mode does not accept features.") + finished = torch.zeros(candidates, dtype=torch.bool, device=device) + selected_log_probs: list[torch.Tensor] = [] + selected_probs: list[torch.Tensor] = [] + + for _ in range(self.config.max_length - 1): + logits = self.logits(features, sequences)[:, -1, :] / temperature + grammar = self._grammar_mask(sequences) + grammar[finished, :] = False + grammar[finished, END_INDEX] = True + logits = logits.masked_fill(~grammar, -torch.inf) + log_probs = torch.log_softmax(logits, dim=-1) + probabilities = log_probs.exp() + if greedy: + selected = probabilities.argmax(dim=-1) + else: + selected = torch.multinomial( + probabilities, 1, generator=generator + ).squeeze(1) + selected_log_probs.append(log_probs.gather(1, selected[:, None]).squeeze(1)) + selected_probs.append(probabilities.gather(1, selected[:, None]).squeeze(1)) + sequences = torch.cat((sequences, selected[:, None]), dim=1) + finished |= selected.eq(END_INDEX) + if bool(finished.all()): + break + + if not bool(finished.all()): + raise RuntimeError( + "ALDes generation reached max_length before all sequences ended." + ) + return GenerationResult( + sequences=sequences, + log_probabilities=torch.stack(selected_log_probs, dim=1), + probabilities=torch.stack(selected_probs, dim=1), + ) + + def score( + self, features: torch.Tensor | None, sequences: torch.Tensor + ) -> torch.Tensor: + """Return grammar-conditioned log probability for each sequence.""" + + sequences = torch.as_tensor( + sequences, dtype=torch.long, device=self.output.weight.device + ) + if sequences.ndim != 2 or sequences.shape[1] < 2: + raise ValueError("sequences must have shape (batch, length>=2).") + inputs = sequences[:, :-1] + targets = sequences[:, 1:] + logits = self.logits(features, inputs) + total = torch.zeros(sequences.shape[0], device=sequences.device) + active = torch.ones( + sequences.shape[0], dtype=torch.bool, device=sequences.device + ) + for position in range(inputs.shape[1]): + prefix = inputs[:, : position + 1] + grammar = self._grammar_mask(prefix) + restricted = logits[:, position, :].masked_fill(~grammar, -torch.inf) + log_probs = torch.log_softmax(restricted, dim=-1) + chosen = log_probs.gather(1, targets[:, position, None]).squeeze(1) + total = total + torch.where(active, chosen, torch.zeros_like(chosen)) + active = active & targets[:, position].ne(END_INDEX) + return total + + def save_checkpoint(self, path: str | Path, **metadata: Any) -> None: + torch.save( + { + "schema": "autooptlib.aldes.generator", + "schema_version": 2, + "config": asdict(self.config), + "state_dict": self.state_dict(), + "metadata": metadata, + }, + Path(path), + ) + + @classmethod + def load_checkpoint( + cls, path: str | Path, *, map_location: Any = "cpu" + ) -> tuple["ALDesGenerator", dict[str, Any]]: + payload = torch.load(Path(path), map_location=map_location, weights_only=True) + if payload.get("schema") != "autooptlib.aldes.generator": + raise ValueError("Not an AutoOptLib ALDes generator checkpoint.") + schema_version = payload.get("schema_version") + if schema_version not in {1, 2}: + raise ValueError("Unsupported AutoOptLib ALDes checkpoint version.") + config = dict(payload["config"]) + if schema_version == 1: + # Version 1 always used a feature token and learned positions. + config.setdefault("condition_on_features", True) + config.setdefault("position_encoding", "learned") + model = cls(GeneratorConfig(**config)) + model.load_state_dict(payload["state_dict"]) + return model, dict(payload.get("metadata", {})) + + +__all__ = ["ALDesGenerator", "GenerationResult", "GeneratorConfig"] diff --git a/src/autooptlib/aldes/problems.py b/src/autooptlib/aldes/problems.py new file mode 100644 index 0000000..2c4a443 --- /dev/null +++ b/src/autooptlib/aldes/problems.py @@ -0,0 +1,86 @@ +"""Problem adapters used by the published ALDes experiments.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, Iterable, Sequence + +import numpy as np + +_PBO_DIMENSIONS = {1: 100, 2: 225, 3: 400, 4: 625, 5: 90} + + +def make_pbo_problem(problem_id: int, *, ioh_instance: int = 1): + """Create an AutoOptLib problem callable for an IOH PBO function. + + ``ioh`` remains an optional dependency and is imported only when the + returned problem is constructed. IOH's PBO functions are maximization + problems; their values are negated for AutoOptLib's minimization contract. + """ + + function_id = int(problem_id) + if not 1 <= function_id <= 23: + raise ValueError("ALDes PBO problem_id must be in the range 1..23.") + + def pbo(problems: Iterable[Any], instances: Sequence[int], mode: str): + normalized_mode = str(mode).lower() + if normalized_mode == "construct": + try: + import ioh + except ImportError as exc: # pragma: no cover - dependency specific + raise ImportError( + "IOH is required for ALDes PBO experiments. Install " + "AutoOptLib with `pip install 'autooptlib[aldes]'`." + ) from exc + problem_list = list(problems) + data: list[SimpleNamespace] = [] + for problem, instance in zip(problem_list, instances): + try: + dimension = _PBO_DIMENSIONS[int(instance)] + except (KeyError, TypeError, ValueError) as exc: + raise ValueError( + "PBO instances must be one of 1, 2, 3, 4, or 5." + ) from exc + objective = ioh.get_problem( + function_id, + instance=int(ioh_instance), + dimension=dimension, + problem_class=ioh.ProblemClass.PBO, + ) + problem.type = ["discrete", "static", "certain"] + problem.bound = np.vstack( + (np.zeros(dimension, dtype=int), np.ones(dimension, dtype=int)) + ) + problem.dimension = dimension + problem.name = f"ioh_pbo_f{function_id}" + + def evaluate(entry, decision): + value = entry.objective(np.asarray(decision, dtype=int)) + return -float(value), 0.0, None + + problem.evaluate = evaluate + data.append(SimpleNamespace(objective=objective)) + return problem_list, data, None + + if normalized_mode == "repair": + return np.asarray(instances, dtype=int), None, None + + if normalized_mode == "evaluate": + data = problems + decisions = np.asarray(instances, dtype=int) + single = decisions.ndim == 1 + decisions = np.atleast_2d(decisions) + values = np.asarray( + [-float(data.objective(decision)) for decision in decisions] + ) + if single: + return float(values[0]), 0.0, None + return values, np.zeros_like(values), None + + raise ValueError(f"Unsupported problem mode: {mode!r}") + + pbo.__name__ = f"ioh_pbo_f{function_id}" + return pbo + + +__all__ = ["make_pbo_problem"] diff --git a/src/autooptlib/aldes/training.py b/src/autooptlib/aldes/training.py new file mode 100644 index 0000000..436e712 --- /dev/null +++ b/src/autooptlib/aldes/training.py @@ -0,0 +1,209 @@ +"""PPO and continual-learning utilities for ALDes.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +import numpy as np +import torch +from torch import nn + +from .model import ALDesGenerator + + +@dataclass(frozen=True) +class PPOConfig: + learning_rate: float = 5e-5 + final_learning_rate: float = 0.0 + anneal_steps: int = 100 + clip_coefficient: float = 0.2 + update_epochs: int = 5 + gradient_norm: float = 1.0 + candidates: int = 16 + baseline_momentum: float = 0.8 + ewc_weight: float = 200.0 + + def __post_init__(self) -> None: + if self.learning_rate <= 0: + raise ValueError("learning_rate must be positive.") + if self.final_learning_rate < 0: + raise ValueError("final_learning_rate cannot be negative.") + if self.final_learning_rate > self.learning_rate: + raise ValueError("final_learning_rate cannot exceed learning_rate.") + if self.anneal_steps <= 0: + raise ValueError("anneal_steps must be positive.") + if self.ewc_weight < 0: + raise ValueError("ewc_weight cannot be negative.") + if self.update_epochs <= 0 or self.candidates <= 0: + raise ValueError("update_epochs and candidates must be positive.") + if not 0 <= self.clip_coefficient < 1: + raise ValueError("clip_coefficient must be in [0, 1).") + if not 0 <= self.baseline_momentum < 1: + raise ValueError("baseline_momentum must be in [0, 1).") + + +class ElasticWeightConsolidation: + """Diagonal Fisher penalty used by ALDes continual training.""" + + def __init__(self, model: nn.Module) -> None: + self.means = { + name: parameter.detach().clone() + for name, parameter in model.named_parameters() + if parameter.requires_grad + } + self.precision = { + name: torch.zeros_like(parameter) + for name, parameter in model.named_parameters() + if parameter.requires_grad + } + + def accumulate(self, model: nn.Module) -> None: + for name, parameter in model.named_parameters(): + if name in self.precision and parameter.grad is not None: + self.precision[name] += parameter.grad.detach().square() + + def penalty(self, model: nn.Module) -> torch.Tensor: + loss = torch.zeros((), device=next(model.parameters()).device) + for name, parameter in model.named_parameters(): + if name in self.precision: + loss = ( + loss + + ( + self.precision[name] * (parameter - self.means[name]).square() + ).sum() + ) + return loss + + +class PPOTrainer: + """Train an ALDes generator from AutoOptLib performance feedback.""" + + def __init__( + self, + model: ALDesGenerator, + config: PPOConfig | None = None, + *, + optimizer: torch.optim.Optimizer | None = None, + ) -> None: + self.model = model + self.config = config or PPOConfig() + self.optimizer = optimizer or torch.optim.Adam( + model.parameters(), lr=self.config.learning_rate, weight_decay=5e-4 + ) + self.baseline: torch.Tensor | None = None + self.steps = 0 + + def _anneal_learning_rate(self) -> float: + progress = min(self.steps / self.config.anneal_steps, 1.0) + learning_rate = ( + self.config.learning_rate + + progress + * (self.config.final_learning_rate - self.config.learning_rate) + ) + for group in self.optimizer.param_groups: + group["lr"] = learning_rate + return learning_rate + + def step( + self, + features: torch.Tensor | None, + evaluate: Callable[[list[np.ndarray]], np.ndarray], + *, + ewc: ElasticWeightConsolidation | None = None, + ewc_weight: float | None = None, + ) -> dict[str, float]: + if ewc_weight is not None and ewc_weight < 0: + raise ValueError("ewc_weight cannot be negative.") + was_training = self.model.training + self.model.eval() + learning_rate = self._anneal_learning_rate() + try: + with torch.no_grad(): + generated = self.model.generate( + features, candidates=self.config.candidates + ) + old_log_probability = generated.log_probabilities.sum(dim=1) + sequences = [row.cpu().numpy() for row in generated.sequences] + costs_array = np.asarray(evaluate(sequences), dtype=float).reshape(-1) + if costs_array.shape[0] != self.config.candidates: + raise ValueError("Evaluator returned one cost per candidate incorrectly.") + costs = torch.as_tensor( + costs_array, + dtype=old_log_probability.dtype, + device=old_log_probability.device, + ) + mean_cost = costs.mean().detach() + if self.baseline is None: + self.baseline = mean_cost + else: + momentum = self.config.baseline_momentum + self.baseline = ( + momentum * self.baseline + (1 - momentum) * mean_cost + ).detach() + advantage = costs - self.baseline + + # Keep dropout disabled while computing both the old and new + # policy probabilities. Gradients still flow in eval mode, and an + # unchanged policy therefore has an exact initial PPO ratio of 1. + final_loss = torch.zeros((), device=costs.device) + for _ in range(self.config.update_epochs): + new_log_probability = self.model.score(features, generated.sequences) + ratio = torch.exp(new_log_probability - old_log_probability) + unclipped = advantage * ratio + clipped = advantage * torch.clamp( + ratio, + 1 - self.config.clip_coefficient, + 1 + self.config.clip_coefficient, + ) + loss = torch.maximum(unclipped, clipped).mean() + weight = self.config.ewc_weight if ewc_weight is None else ewc_weight + if ewc is not None and weight: + loss = loss + weight * ewc.penalty(self.model) + self.optimizer.zero_grad() + loss.backward() + nn.utils.clip_grad_norm_( + self.model.parameters(), self.config.gradient_norm + ) + self.optimizer.step() + final_loss = loss.detach() + finally: + self.model.train(was_training) + + self.steps += 1 + + return { + "loss": float(final_loss.cpu()), + "mean_cost": float(mean_cost.cpu()), + "baseline": float(self.baseline.cpu()), + "best_cost": float(costs.min().cpu()), + "learning_rate": learning_rate, + } + + def consolidate( + self, + features: torch.Tensor | None, + sequences: torch.Tensor, + ewc: ElasticWeightConsolidation | None = None, + ) -> ElasticWeightConsolidation: + """Estimate a diagonal Fisher term before moving to the next task.""" + + state = ewc or ElasticWeightConsolidation(self.model) + was_training = self.model.training + self.model.eval() + self.optimizer.zero_grad() + try: + loss = -self.model.score(features, sequences).mean() + loss.backward() + state.accumulate(self.model) + finally: + self.optimizer.zero_grad() + self.model.train(was_training) + return state + + +__all__ = [ + "ElasticWeightConsolidation", + "PPOConfig", + "PPOTrainer", +] diff --git a/src/autooptlib/aldes/vocabulary.py b/src/autooptlib/aldes/vocabulary.py new file mode 100644 index 0000000..8d00267 --- /dev/null +++ b/src/autooptlib/aldes/vocabulary.py @@ -0,0 +1,424 @@ +"""ALDes-compatible vocabulary and constrained grammar utilities.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Iterable, Sequence + +import numpy as np + + +class TokenKind(str, Enum): + CHOOSE = "choose" + SEARCH = "search" + UPDATE = "update" + BEGIN = "begin" + END = "end" + PARAMETER = "parameter" + POINTER = "pointer" + + +@dataclass(frozen=True) +class Token: + index: int + name: str + kind: TokenKind + parameter_count: int = 0 + + +_COMPONENTS = ( + ("choose_traverse", TokenKind.CHOOSE, 0), + ("choose_tournament", TokenKind.CHOOSE, 0), + ("choose_roulette_wheel", TokenKind.CHOOSE, 0), + ("choose_nich", TokenKind.CHOOSE, 0), + ("cross_point_one", TokenKind.SEARCH, 0), + ("cross_point_two", TokenKind.SEARCH, 0), + ("cross_point_n", TokenKind.SEARCH, 1), + ("cross_point_uniform", TokenKind.SEARCH, 1), + ("search_reset_one", TokenKind.SEARCH, 0), + ("search_reset_n", TokenKind.SEARCH, 1), + ("search_reset_rand", TokenKind.SEARCH, 1), + ("reinit_discrete", TokenKind.SEARCH, 0), + ("update_greedy", TokenKind.UPDATE, 0), + ("update_round_robin", TokenKind.UPDATE, 0), + ("update_pairwise", TokenKind.UPDATE, 0), + ("update_always", TokenKind.UPDATE, 0), + ("update_simulated_annealing", TokenKind.UPDATE, 1), +) + +TOKENS: tuple[Token, ...] = ( + tuple( + Token(index, name, kind, parameter_count) + for index, (name, kind, parameter_count) in enumerate(_COMPONENTS) + ) + + ( + Token(17, "begin", TokenKind.BEGIN), + Token(18, "end", TokenKind.END), + ) + + tuple( + Token(index, f"{value / 10:.1f}", TokenKind.PARAMETER) + for index, value in enumerate(range(1, 11), start=19) + ) + + ( + Token(29, "forward", TokenKind.POINTER), + Token(30, "iterate", TokenKind.POINTER, 1), + Token(31, "fork", TokenKind.POINTER, 1), + ) +) + +TOKEN_BY_INDEX = {token.index: token for token in TOKENS} +TOKEN_BY_NAME = {token.name: token for token in TOKENS} +VOCABULARY_SIZE = len(TOKENS) +BEGIN_INDEX = TOKEN_BY_NAME["begin"].index +END_INDEX = TOKEN_BY_NAME["end"].index +PARAMETER_INDICES = tuple(range(19, 29)) +POINTER_INDICES = tuple(range(29, 32)) + +# Discrete ALDes permits at most one global-search component. The first +# group is global for every parameter choice; the second becomes global for +# parameter tokens 0.4--1.0, matching the legacy ``gs_para_begin = 22``. +_ONLY_GLOBAL_SEARCH = frozenset({4, 5, 11}) +_PARAMETERIZED_GLOBAL_SEARCH = frozenset({6, 7, 9, 10}) +_GLOBAL_PARAMETER_START = 22 +# The tightened fork grammar has two distinct branch modes. Token 0.3 keeps +# the complete search row on both branches; token 0.4 lets the second branch +# start at the mutation in a crossover+mutation row. Larger tokens collapsed +# to one of those two structures after target clamping and were aliases. +_FORK_PARAMETER_INDICES = PARAMETER_INDICES[2:4] + + +class SequenceValidationError(ValueError): + """Raised when an ALDes token sequence does not follow the grammar.""" + + +def normalize_sequence(sequence: Sequence[int] | np.ndarray) -> list[int]: + """Return one canonical sequence with one begin and one end token.""" + + values = np.asarray(sequence).reshape(-1).tolist() + try: + values = [int(value) for value in values] + except (TypeError, ValueError) as exc: + raise SequenceValidationError( + "ALDes sequences must contain integer tokens." + ) from exc + if not values: + raise SequenceValidationError("ALDes sequence cannot be empty.") + unknown = [value for value in values if value not in TOKEN_BY_INDEX] + if unknown: + raise SequenceValidationError(f"Unknown ALDes token index: {unknown[0]}") + if values[0] != BEGIN_INDEX: + values.insert(0, BEGIN_INDEX) + while len(values) > 1 and values[-1] == END_INDEX: + values.pop() + values.append(END_INDEX) + return values + + +def _consume_component(values: Sequence[int], position: int) -> int: + token = TOKEN_BY_INDEX[values[position]] + position += 1 + for _ in range(token.parameter_count): + if position >= len(values) or values[position] not in PARAMETER_INDICES: + raise SequenceValidationError( + f"Component {token.name!r} must be followed by a parameter token." + ) + position += 1 + return position + + +def validate_sequence(sequence: Sequence[int] | np.ndarray) -> list[int]: + """Validate AutoOptLib's constrained ALDes grammar and normalize it.""" + + values = normalize_sequence(sequence) + position = 1 + expected = TokenKind.CHOOSE + search_count = 0 + search_tokens: list[int] = [] + operator_count = 0 + has_fork = False + fork_parameter: int | None = None + components_seen: set[int] = set() + global_search_count = 0 + + while position < len(values) - 1: + token = TOKEN_BY_INDEX[values[position]] + if token.kind is not expected: + raise SequenceValidationError( + f"Expected a {expected.value} token at position {position}, " + f"got {token.name!r}." + ) + operator_count += 1 + if operator_count > 6: + raise SequenceValidationError("ALDes supports at most six components.") + if token.index in components_seen: + raise SequenceValidationError( + f"Component {token.name!r} cannot appear more than once." + ) + components_seen.add(token.index) + if token.kind is TokenKind.SEARCH: + search_count += 1 + search_tokens.append(token.index) + is_global = token.index in _ONLY_GLOBAL_SEARCH + if token.index in _PARAMETERIZED_GLOBAL_SEARCH: + parameter_position = position + 1 + is_global = ( + parameter_position < len(values) + and values[parameter_position] >= _GLOBAL_PARAMETER_START + ) + if is_global: + global_search_count += 1 + if global_search_count > 1: + raise SequenceValidationError( + "An ALDes sequence permits at most one global search." + ) + position = _consume_component(values, position) + if position >= len(values) - 1: + raise SequenceValidationError( + f"Component {token.name!r} must be followed by a pointer." + ) + pointer = TOKEN_BY_INDEX[values[position]] + if pointer.kind is not TokenKind.POINTER: + raise SequenceValidationError( + f"Expected a pointer after {token.name!r}, got {pointer.name!r}." + ) + if pointer.name == "fork" and token.kind is not TokenKind.CHOOSE: + raise SequenceValidationError( + "A fork pointer may only follow the choose component." + ) + if token.kind is TokenKind.CHOOSE and pointer.name == "iterate": + raise SequenceValidationError( + "The choose component must use forward or fork." + ) + if token.kind is TokenKind.UPDATE and pointer.name != "forward": + raise SequenceValidationError( + "The final update component must use the forward pointer." + ) + has_fork |= pointer.name == "fork" + position += 1 + if pointer.parameter_count: + if position >= len(values) - 1 or values[position] not in PARAMETER_INDICES: + raise SequenceValidationError( + f"Pointer {pointer.name!r} must be followed by a parameter token." + ) + if pointer.name == "iterate" and values[position] > PARAMETER_INDICES[4]: + raise SequenceValidationError( + "Iterate accepts only the five condition tokens 0.1 through 0.5." + ) + if pointer.name == "fork": + if values[position] not in _FORK_PARAMETER_INDICES: + raise SequenceValidationError( + "Fork accepts only its two distinct branch-mode tokens." + ) + fork_parameter = values[position] + position += 1 + + if token.index in {4, 5, 6, 7}: + if position >= len(values) - 1 or values[position] not in {8, 9, 10}: + raise SequenceValidationError( + "A crossover must be followed by one mutation component." + ) + + if token.kind is TokenKind.CHOOSE: + expected = TokenKind.SEARCH + elif token.kind is TokenKind.SEARCH: + expected = TokenKind.SEARCH + if position < len(values) - 1: + next_kind = TOKEN_BY_INDEX[values[position]].kind + if next_kind is TokenKind.UPDATE: + expected = TokenKind.UPDATE + else: + if position != len(values) - 1: + raise SequenceValidationError("The update component must be last.") + + if expected is not TokenKind.UPDATE or search_count == 0: + raise SequenceValidationError( + "A complete ALDes sequence needs choose, search, and update components." + ) + if has_fork: + paired_search = ( + len(search_tokens) == 2 + and search_tokens[0] in {4, 5, 6, 7} + and search_tokens[1] in {8, 9, 10} + ) + single_search = len(search_tokens) == 1 and search_tokens[0] not in { + 4, + 5, + 6, + 7, + } + if not (single_search or paired_search): + raise SequenceValidationError( + "A fork algorithm permits one search step, optionally a " + "crossover followed by one mutation." + ) + if fork_parameter == _FORK_PARAMETER_INDICES[1] and not paired_search: + raise SequenceValidationError( + "The mutation-branch fork mode requires crossover followed by mutation." + ) + return values + + +def _has_global_search(values: Sequence[int]) -> bool: + for position, value in enumerate(values): + if value in _ONLY_GLOBAL_SEARCH: + return True + if value in _PARAMETERIZED_GLOBAL_SEARCH and position + 1 < len(values): + parameter = values[position + 1] + if parameter in PARAMETER_INDICES and parameter >= _GLOBAL_PARAMETER_START: + return True + return False + + +def allowed_next_tokens(prefix: Sequence[int] | np.ndarray) -> np.ndarray: + """Return a boolean mask for legal continuations of a partial sequence. + + This is the shared grammar mask used by both random sampling and the + autoregressive PyTorch generator. It intentionally accepts an incomplete + prefix and therefore does not call :func:`validate_sequence`. + """ + + values = np.asarray(prefix).reshape(-1).astype(int).tolist() + if not values: + values = [BEGIN_INDEX] + if values[0] != BEGIN_INDEX: + values.insert(0, BEGIN_INDEX) + mask = np.zeros(VOCABULARY_SIZE, dtype=bool) + last = TOKEN_BY_INDEX.get(values[-1]) + if last is None: + return mask + if last.kind is TokenKind.END: + mask[END_INDEX] = True + return mask + if last.kind is TokenKind.BEGIN: + mask[0:4] = True + return mask + + component_positions = [ + index + for index, value in enumerate(values) + if TOKEN_BY_INDEX.get(value, Token(-1, "", TokenKind.END)).kind + in {TokenKind.CHOOSE, TokenKind.SEARCH, TokenKind.UPDATE} + ] + components = [values[index] for index in component_positions] + most_recent_component = components[-1] if components else None + most_recent_kind = ( + TOKEN_BY_INDEX[most_recent_component].kind if components else None + ) + has_global_search = _has_global_search(values) + has_fork = TOKEN_BY_NAME["fork"].index in values + + # A component or pointer that owns a parameter must receive it next. + if last.kind in { + TokenKind.CHOOSE, + TokenKind.SEARCH, + TokenKind.UPDATE, + TokenKind.POINTER, + }: + if last.parameter_count: + if last.name == "iterate": + mask[list(PARAMETER_INDICES[:5])] = True + elif last.name == "fork": + mask[list(_FORK_PARAMETER_INDICES)] = True + elif ( + last.index in _PARAMETERIZED_GLOBAL_SEARCH and has_global_search + ): + mask[list(PARAMETER_INDICES[:3])] = True + else: + mask[list(PARAMETER_INDICES)] = True + return mask + + # A parameter may belong to the preceding component or pointer. + owner = None + for value in reversed(values[:-1] if last.kind is TokenKind.PARAMETER else values): + candidate = TOKEN_BY_INDEX[value] + if candidate.kind in { + TokenKind.CHOOSE, + TokenKind.SEARCH, + TokenKind.UPDATE, + TokenKind.POINTER, + }: + owner = candidate + break + if last.kind in {TokenKind.CHOOSE, TokenKind.SEARCH, TokenKind.UPDATE} or ( + last.kind is TokenKind.PARAMETER + and owner is not None + and owner.kind is not TokenKind.POINTER + ): + if most_recent_kind is TokenKind.CHOOSE: + mask[29] = True + mask[31] = True + elif most_recent_kind is TokenKind.SEARCH: + mask[29:31] = True + elif most_recent_kind is TokenKind.UPDATE: + mask[29] = True + return mask + + # A pointer without a parameter, or the parameter of a pointer, opens the + # next component position. + pointer_complete = last.name == "forward" or ( + last.kind is TokenKind.PARAMETER + and owner is not None + and owner.kind is TokenKind.POINTER + ) + if pointer_complete: + count = len(components) + if most_recent_kind is TokenKind.UPDATE: + mask[END_INDEX] = True + elif count == 1: + mask[4:12] = True + if ( + last.kind is TokenKind.PARAMETER + and owner is not None + and owner.name == "fork" + and last.index == _FORK_PARAMETER_INDICES[1] + ): + # The heterogeneous fork starts its second branch at the + # paired mutation, so it requires a crossover search row. + mask[8:12] = False + elif has_fork and most_recent_kind is TokenKind.SEARCH: + mask[12:17] = True + elif count >= 5: + mask[12:17] = True + elif count == 4: + # A crossover would require a paired mutation and exceed the + # six-component limit (choose + four searches + update). + mask[8:17] = True + else: + mask[4:17] = True + + # Crossover must be followed by a mutation operator. + if most_recent_component in {4, 5, 6, 7}: + mask[:] = False + mask[8:11] = True + for component in components: + if 0 <= component <= 16: + mask[component] = False + if has_global_search: + mask[list(_ONLY_GLOBAL_SEARCH)] = False + return mask + + return mask + + +def tokens_to_names(sequence: Iterable[int]) -> list[str]: + return [TOKEN_BY_INDEX[int(index)].name for index in sequence] + + +__all__ = [ + "BEGIN_INDEX", + "END_INDEX", + "PARAMETER_INDICES", + "POINTER_INDICES", + "SequenceValidationError", + "TOKENS", + "TOKEN_BY_INDEX", + "TOKEN_BY_NAME", + "Token", + "TokenKind", + "VOCABULARY_SIZE", + "allowed_next_tokens", + "normalize_sequence", + "tokens_to_names", + "validate_sequence", +] diff --git a/src/autooptlib/aldes/workflow.py b/src/autooptlib/aldes/workflow.py new file mode 100644 index 0000000..0d05bf9 --- /dev/null +++ b/src/autooptlib/aldes/workflow.py @@ -0,0 +1,186 @@ +"""High-level AutoOptLib design workflow backed by an ALDes generator.""" + +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +from typing import Any, Sequence + +import numpy as np + +from ..problems.base import validate_constructed_problems +from ..utils.design import Design +from ..utils.general.process import _build_problem_struct, _normalize_setting +from ..utils.space import space +from .codec import decode_sequence + + +def _load_features(value: Any) -> np.ndarray: + if hasattr(value, "features"): + value = value.features + if isinstance(value, (str, Path)): + path = Path(value) + if not path.exists(): + raise FileNotFoundError(f"ALDes feature file not found: {path}") + value = np.load(path, allow_pickle=False) + array = np.asarray(value, dtype=np.float32) + if array.ndim not in {1, 2}: + raise ValueError("ALDesFeatures must be a vector or a batch of vectors.") + return array + + +def _load_initial_populations(value: Any) -> Any: + if value is None: + return None + if hasattr(value, "initial_populations"): + return value.initial_populations + if isinstance(value, (str, Path)): + path = Path(value) + if not path.exists(): + raise FileNotFoundError(f"ALDes initial-population file not found: {path}") + loaded = np.load(path, allow_pickle=False) + if isinstance(loaded, np.lib.npyio.NpzFile): + try: + return { + int(name.rsplit("_", 1)[-1]): np.array(loaded[name], copy=True) + for name in loaded.files + } + finally: + loaded.close() + return loaded + return value + + +def design_with_aldes( + problem_descriptor: Any, + instance_train: Sequence[Any], + instance_test: Sequence[Any], + *, + setting: Any, +) -> tuple[list[Design], list[Design]]: + """Generate, score, and test algorithms using a trained ALDes model.""" + + try: + import torch + except ImportError as exc: # pragma: no cover - dependency specific + raise ImportError( + "Designer='aldes' requires PyTorch. Install AutoOptLib with " + "`pip install 'autooptlib[aldes]'`." + ) from exc + + from .model import ALDesGenerator + + setting = _normalize_setting(setting) + model_value = getattr(setting, "ALDesModel", None) + if model_value is None: + raise ValueError( + "Designer='aldes' requires ALDesModel (a generator or checkpoint path)." + ) + if isinstance(model_value, (str, Path)): + model, _ = ALDesGenerator.load_checkpoint(model_value) + elif isinstance(model_value, ALDesGenerator): + model = model_value + else: + raise TypeError("ALDesModel must be an ALDesGenerator or checkpoint path.") + + mode = str(getattr(setting, "ALDesMode", "single")).lower() + expects_features = bool(model.config.condition_on_features) + if (mode == "continual") != expects_features: + expected = "continual" if expects_features else "single" + raise ValueError( + f"This ALDes checkpoint was trained for {expected!r} mode; " + f"ALDesMode={mode!r} is incompatible." + ) + feature_value = getattr(setting, "ALDesFeatures", None) + initial_value = getattr(setting, "ALDesInitialPopulations", None) + if mode == "continual": + if feature_value is None: + raise ValueError( + "ALDesMode='continual' requires ALDesFeatures for the target problem." + ) + features = _load_features(feature_value) + if initial_value is None and hasattr(feature_value, "initial_populations"): + initial_value = feature_value + else: + if feature_value is not None: + raise ValueError( + "ALDesMode='single' does not use ALDesFeatures; omit that option." + ) + features = None + setting.InitialPopulations = _load_initial_populations(initial_value) + model_device = next(model.parameters()).device + feature_tensor = ( + torch.as_tensor(features, device=model_device) if features is not None else None + ) + + instances = list(instance_train) + list(instance_test) + problems = _build_problem_struct(problem_descriptor, instances, setting) + problems, data, _ = problem_descriptor(problems, instances, "construct") + validate_constructed_problems(problems, data) + setting = space(problems, setting) + + candidate_count = int(getattr(setting, "ALDesCandidates", 0) or setting.AlgN) + if candidate_count < int(setting.AlgN): + raise ValueError("ALDesCandidates must be at least AlgN.") + temperature = float(getattr(setting, "ALDesTemperature", 1.0)) + greedy = bool(getattr(setting, "ALDesGreedy", False)) + seed = getattr(setting, "Seed", getattr(setting, "seed", None)) + torch_generator = None + if seed is not None: + torch_generator = torch.Generator(device=model_device) + torch_generator.manual_seed(int(seed)) + model.eval() + with torch.no_grad(): + generated = model.generate( + feature_tensor, + candidates=candidate_count, + temperature=temperature, + greedy=greedy, + generator=torch_generator, + ) + + rng = np.random.default_rng(seed) + setting.rng = rng + train_indices = rng.permutation(len(instance_train)).tolist() + test_indices = (rng.permutation(len(instance_test)) + len(instance_train)).tolist() + + candidates: list[Design] = [] + best_trace: list[Design] = [] + best_cost = np.inf + evaluated: dict[tuple[int, ...], Design] = {} + train_rng_state = deepcopy(rng.bit_generator.state) + for row in generated.sequences.detach().cpu().numpy(): + key = tuple(int(token) for token in row) + if key in evaluated: + algorithm = deepcopy(evaluated[key]) + else: + rng.bit_generator.state = deepcopy(train_rng_state) + algorithm = decode_sequence(row, problems, setting) + algorithm.evaluate(problems, data, setting, train_indices) + evaluated[key] = deepcopy(algorithm) + candidates.append(algorithm) + cost = float(np.mean(algorithm.performance[train_indices, :])) + if cost < best_cost: + best_cost = cost + best_trace.append(algorithm) + + rng.bit_generator.state = deepcopy(train_rng_state) + rng.integers(0, np.iinfo(np.uint64).max, dtype=np.uint64) + + candidates.sort( + key=lambda algorithm: float(np.mean(algorithm.performance[train_indices, :])) + ) + finalists = candidates[: int(setting.AlgN)] + test_rng_state = deepcopy(rng.bit_generator.state) + for algorithm in finalists: + rng.bit_generator.state = deepcopy(test_rng_state) + algorithm.evaluate(problems, data, setting, test_indices) + rng.bit_generator.state = deepcopy(test_rng_state) + rng.integers(0, np.iinfo(np.uint64).max, dtype=np.uint64) + finalists.sort( + key=lambda algorithm: float(np.mean(algorithm.performance[test_indices, :])) + ) + return finalists, best_trace + + +__all__ = ["design_with_aldes"] diff --git a/src/autooptlib/autoopt.py b/src/autooptlib/autoopt.py index 5178735..3dd5d52 100644 --- a/src/autooptlib/autoopt.py +++ b/src/autooptlib/autoopt.py @@ -66,12 +66,25 @@ def autoopt(**kwargs: Any): problem_callable = _load_problem_callable(problem_descriptor) if setting.Mode.lower() == "design": - final_algs, alg_trace = process( - problem_callable, - instance_train, - instance_test, - setting=setting, - ) + designer = str(getattr(setting, "Designer", "search")).lower() + if designer == "search": + final_algs, alg_trace = process( + problem_callable, + instance_train, + instance_test, + setting=setting, + ) + elif designer == "aldes": + from .aldes.workflow import design_with_aldes + + final_algs, alg_trace = design_with_aldes( + problem_callable, + instance_train, + instance_test, + setting=setting, + ) + else: + raise ValueError("Designer must be 'search' or 'aldes'.") output( final_algs, alg_trace, diff --git a/src/autooptlib/components/__init__.py b/src/autooptlib/components/__init__.py index 903cb1c..f638264 100644 --- a/src/autooptlib/components/__init__.py +++ b/src/autooptlib/components/__init__.py @@ -42,6 +42,7 @@ "reinit_discrete": "reinit_discrete", "reinit_permutation": "reinit_permutation", "search_reset_one": "search_reset_one", + "search_reset_n": "search_reset_n", "search_reset_rand": "search_reset_rand", "search_reset_creep": "search_reset_creep", "search_swap": "search_swap", diff --git a/src/autooptlib/components/cross_point_n.py b/src/autooptlib/components/cross_point_n.py index e760dd3..511d09e 100644 --- a/src/autooptlib/components/cross_point_n.py +++ b/src/autooptlib/components/cross_point_n.py @@ -4,7 +4,17 @@ import numpy as np -from ._utils import ensure_rng +from ._utils import ensure_rng, flex_get + + +def _minimum_dimension(problem): + problems = problem if isinstance(problem, (list, tuple)) else [problem] + dimensions = [] + for item in problems: + bound = np.asarray(flex_get(item, "bound", np.empty((2, 1)))) + if bound.ndim == 2: + dimensions.append(int(bound.shape[1])) + return max(1, min(dimensions, default=1)) def cross_point_n(*args): @@ -38,8 +48,8 @@ def cross_point_n(*args): offspring = np.vstack([off1, off2]) return offspring[:n, :], aux if mode == "parameter": - # Cannot compute D without Problem here; return a reasonable default range - return [1, 5], None + problem = args[0] if len(args) > 1 else None + return [1, _minimum_dimension(problem)], None if mode == "behavior": return [["LS", "small"], ["GS", "large"]], None raise ValueError(f"Unsupported mode: {mode}") diff --git a/src/autooptlib/components/cross_point_uniform.py b/src/autooptlib/components/cross_point_uniform.py index 810515a..4cbbf93 100644 --- a/src/autooptlib/components/cross_point_uniform.py +++ b/src/autooptlib/components/cross_point_uniform.py @@ -33,7 +33,7 @@ def cross_point_uniform(*args): offspring = np.vstack([off1, off2]) return offspring[:n, :], aux if mode == "parameter": - return [0, 0.5], None + return [0.05, 0.5], None if mode == "behavior": return [["LS", "small"], ["GS", "large"]], None raise ValueError(f"Unsupported mode: {mode}") diff --git a/src/autooptlib/components/search_reset_n.py b/src/autooptlib/components/search_reset_n.py new file mode 100644 index 0000000..51d2f59 --- /dev/null +++ b/src/autooptlib/components/search_reset_n.py @@ -0,0 +1,78 @@ +"""Reset a fixed number of discrete variables in every solution.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from ._utils import ensure_rng, flex_get + + +def _extract_matrix(solution: Any) -> np.ndarray: + decs = flex_get(solution, "decs") + array = np.asarray(decs if decs is not None else solution, dtype=int) + if array.ndim != 2: + raise ValueError("Solution must be 2-D for search_reset_n") + return array.copy() + + +def _minimum_dimension(problem: Any) -> int: + problems = problem if isinstance(problem, (list, tuple)) else [problem] + dimensions = [] + for item in problems: + bound = np.asarray(flex_get(item, "bound", np.empty((2, 1)))) + if bound.ndim == 2: + dimensions.append(int(bound.shape[1])) + return max(1, min(dimensions, default=1)) + + +def search_reset_n(*args): + """Randomly reset ``n`` distinct variables of each solution. + + This component completes the Python component set used by the published + ALDes discrete vocabulary. Its mode-based interface mirrors the other + AutoOptLib components and the original MATLAB implementation. + """ + + mode = args[-1] + if mode == "execute": + solution = args[0] + problem = args[1] + parameter = args[2] if len(args) > 2 else None + aux = args[3] if len(args) > 3 else None + rng = ensure_rng(aux, problem) + + new = _extract_matrix(solution) + bounds = np.asarray(flex_get(problem, "bound"), dtype=int) + lower, upper = bounds + count, dimension = new.shape + requested = ( + 1 + if parameter is None + else int(round(float(np.asarray(parameter).reshape(-1)[0]))) + ) + reset_count = min(max(requested, 1), dimension) + + for row in range(count): + indices = rng.choice(dimension, size=reset_count, replace=False) + for column in np.atleast_1d(indices): + column = int(column) + if lower[column] == upper[column]: + continue + current = new[row, column] + candidate = int(rng.integers(lower[column], upper[column] + 1)) + while candidate == current: + candidate = int(rng.integers(lower[column], upper[column] + 1)) + new[row, column] = candidate + return new, aux + + if mode == "parameter": + problem = args[0] if len(args) > 1 else None + dimension = _minimum_dimension(problem) + return np.array([1.0, float(max(1, dimension))]), None + + if mode == "behavior": + return [["LS", "small"], ["GS", "large"]], None + + raise ValueError(f"Unsupported mode: {mode}") diff --git a/src/autooptlib/components/search_reset_rand.py b/src/autooptlib/components/search_reset_rand.py index cb4b8b1..e682188 100644 --- a/src/autooptlib/components/search_reset_rand.py +++ b/src/autooptlib/components/search_reset_rand.py @@ -45,7 +45,7 @@ def search_reset_rand(*args): return new, aux if mode == "parameter": - return [0, 0.5], None + return [0.05, 0.5], None if mode == "behavior": return [["LS", "small"], ["GS", "large"]], None diff --git a/src/autooptlib/utils/design/_estimate.py b/src/autooptlib/utils/design/_estimate.py index 9ab3796..c1923f4 100644 --- a/src/autooptlib/utils/design/_estimate.py +++ b/src/autooptlib/utils/design/_estimate.py @@ -21,7 +21,10 @@ def _predict(model: Any, data: Any): if model is None: raise ValueError("Surrogate model is required for prediction") if hasattr(model, "predict"): - return model.predict(data) + features = np.asarray(data) + if features.ndim == 1: + features = features.reshape(1, -1) + return model.predict(features) raise AttributeError("Surrogate model lacks a predict method") diff --git a/src/autooptlib/utils/design/_evaluate.py b/src/autooptlib/utils/design/_evaluate.py index 8b8899b..92c6a36 100644 --- a/src/autooptlib/utils/design/_evaluate.py +++ b/src/autooptlib/utils/design/_evaluate.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any, List, Sequence import numpy as np @@ -52,6 +53,45 @@ def _update_sequential(problem, data, best_solution): return problem, data +def _select_initial_population( + populations: Any, + instance_index: int, + run: int, + *, + instance_count: int, + runs: int, +) -> np.ndarray | None: + """Select an optional ``(population, dimension)`` matrix for one run.""" + + if populations is None: + return None + if isinstance(populations, Mapping): + if instance_index not in populations: + return None + selected = np.asarray(populations[instance_index]) + if selected.ndim == 2: + return selected + if selected.ndim == 3: + return selected[run % selected.shape[0]] + raise ValueError( + "Each InitialPopulations mapping value must have shape (N,D) " + "or (runs,N,D)." + ) + array = np.asarray(populations) + if array.ndim == 2: + return array + if array.ndim == 3: + if array.shape[0] == instance_count and instance_count != runs: + return array[instance_index] + return array[run % array.shape[0]] + if array.ndim == 4: + return array[instance_index, run % array.shape[1]] + raise ValueError( + "InitialPopulations must have shape (N,D), (runs,N,D), or " + "(instances,runs,N,D)." + ) + + def evaluate(self, problem: Any, data: Any, setting: Any, seed_instance: Sequence[int]): if not self.operator_pheno: return self @@ -65,6 +105,7 @@ def evaluate(self, problem: Any, data: Any, setting: Any, seed_instance: Sequenc evaluate_mode = get_flex(setting, "evaluate", "exact") metric = get_flex(setting, "metric", "quality") runs = int(get_flex(setting, "alg_runs", 1)) + initial_populations = get_flex(setting, "InitialPopulations", None) max_seed = max(seed_instance) if seed_instance else 0 rows_needed = max_seed + 1 @@ -90,8 +131,26 @@ def evaluate(self, problem: Any, data: Any, setting: Any, seed_instance: Sequenc ) for run in range(runs): + initial_population = _select_initial_population( + initial_populations, + seed, + run, + instance_count=len(problems), + runs=runs, + ) if mode == "static": - result = run_design(pathways, params, problem_obj, data_obj, setting) + had_initial = hasattr(setting, "_InitialPopulation") + previous_initial = getattr(setting, "_InitialPopulation", None) + setting._InitialPopulation = initial_population + try: + result = run_design( + pathways, params, problem_obj, data_obj, setting + ) + finally: + if had_initial: + setting._InitialPopulation = previous_initial + else: + delattr(setting, "_InitialPopulation") fit_history = result["fit_history"] evaluations = result["evaluations"] elapsed = result["elapsed"] @@ -119,47 +178,61 @@ def evaluate(self, problem: Any, data: Any, setting: Any, seed_instance: Sequenc elapsed = 0.0 curr_prob = problem_obj curr_data = data_obj - while getattr(curr_data, "continue", False): - # The MATLAB sequential protocol allocates the configured - # ProbFE budget independently to every arriving stage. - stage_setting = setting - result = run_design( - pathways, params, curr_prob, curr_data, stage_setting - ) - fit_history = result["fit_history"] - best_solution = result["best_solution"] - evaluations += result["evaluations"] - elapsed += result["elapsed"] - if metric == "quality": - cumulative += float(fit_history[-1]) if fit_history else np.inf - elif metric in {"runtimeFE", "runtimeSec"}: - cumulative += ( - result["elapsed"] - if metric == "runtimeSec" - else result["evaluations"] + had_initial = hasattr(setting, "_InitialPopulation") + previous_initial = getattr(setting, "_InitialPopulation", None) + stage_initial = initial_population + try: + while getattr(curr_data, "continue", False): + # The MATLAB sequential protocol allocates the configured + # ProbFE budget independently to every arriving stage. + setting._InitialPopulation = stage_initial + result = run_design( + pathways, params, curr_prob, curr_data, setting ) - elif metric == "auc": - cumulative += _auc_score( - fit_history, - get_flex(setting, "Tmax", None), - get_flex(setting, "Thres", None), - int( - get_flex( - curr_prob, - "N", - get_flex(setting, "ProbN", 1), - ) - ), + stage_initial = None + fit_history = result["fit_history"] + best_solution = result["best_solution"] + evaluations += result["evaluations"] + elapsed += result["elapsed"] + if metric == "quality": + cumulative += ( + float(fit_history[-1]) if fit_history else np.inf + ) + elif metric in {"runtimeFE", "runtimeSec"}: + cumulative += ( + result["elapsed"] + if metric == "runtimeSec" + else result["evaluations"] + ) + elif metric == "auc": + cumulative += _auc_score( + fit_history, + get_flex(setting, "Tmax", None), + get_flex(setting, "Thres", None), + int( + get_flex( + curr_prob, + "N", + get_flex(setting, "ProbN", 1), + ) + ), + ) + else: + cumulative += ( + float(fit_history[-1]) if fit_history else np.inf + ) + if best_solution is None: + break + curr_prob, curr_data = _update_sequential( + curr_prob, curr_data, best_solution ) + if curr_prob is problem_obj and curr_data is data_obj: + break + finally: + if had_initial: + setting._InitialPopulation = previous_initial else: - cumulative += float(fit_history[-1]) if fit_history else np.inf - if best_solution is None: - break - curr_prob, curr_data = _update_sequential( - curr_prob, curr_data, best_solution - ) - if curr_prob is problem_obj and curr_data is data_obj: - break + delattr(setting, "_InitialPopulation") if metric == "runtimeFE": target[seed, run] = evaluations elif metric == "runtimeSec": diff --git a/src/autooptlib/utils/general/input.py b/src/autooptlib/utils/general/input.py index 5aa9b7f..7fde5f3 100644 --- a/src/autooptlib/utils/general/input.py +++ b/src/autooptlib/utils/general/input.py @@ -44,6 +44,14 @@ "CheckpointDir", "CheckpointEvery", "Resume", + "Designer", + "ALDesModel", + "ALDesFeatures", + "ALDesCandidates", + "ALDesTemperature", + "ALDesGreedy", + "ALDesMode", + "ALDesInitialPopulations", } _DATA_KEYS = {"Mode", "Problem", "InstanceTrain", "InstanceTest", "InstanceSolve"} @@ -71,6 +79,14 @@ "CheckpointDir": None, "CheckpointEvery": 1, "Resume": False, + "Designer": "search", + "ALDesModel": None, + "ALDesFeatures": None, + "ALDesCandidates": None, + "ALDesTemperature": 1.0, + "ALDesGreedy": False, + "ALDesMode": "single", + "ALDesInitialPopulations": None, } _DESIGN_DEFAULTS = { @@ -148,13 +164,16 @@ def _ensure_namespace(setting: Any) -> SimpleNamespace: def _find_argument(arguments: Sequence[Any], name: str) -> tuple[bool, Any]: if not arguments: return False, None - try: - idx = arguments.index(name) - except ValueError: - return False, None - if idx + 1 >= len(arguments): - raise ValueError(f'Missing value for argument "{name}"') - return True, arguments[idx + 1] + # Arguments are alternating key/value pairs. Searching the whole list + # compares ``name`` against user values too; a NumPy feature array then + # raises an ambiguous-truth-value ValueError and hides later options. + for idx in range(0, len(arguments), 2): + key = arguments[idx] + if isinstance(key, str) and key == name: + if idx + 1 >= len(arguments): + raise ValueError(f'Missing value for argument "{name}"') + return True, arguments[idx + 1] + return False, None def _to_list(obj: Any) -> list[Any]: @@ -274,6 +293,31 @@ def _check_setting(setting: SimpleNamespace) -> None: raise ValueError("CheckpointEvery must be a positive integer.") if not isinstance(getattr(setting, "Resume", False), bool): raise ValueError("Resume must be a boolean.") + designer = str(getattr(setting, "Designer", "search")).lower() + if designer not in {"search", "aldes"}: + raise ValueError("Designer must be 'search' or 'aldes'.") + setting.Designer = designer + aldes_candidates = getattr(setting, "ALDesCandidates", None) + if aldes_candidates is not None and ( + not isinstance(aldes_candidates, Integral) + or isinstance(aldes_candidates, bool) + or aldes_candidates <= 0 + ): + raise ValueError("ALDesCandidates must be a positive integer or None.") + aldes_temperature = getattr(setting, "ALDesTemperature", 1.0) + if ( + not isinstance(aldes_temperature, Real) + or isinstance(aldes_temperature, bool) + or not math.isfinite(float(aldes_temperature)) + or aldes_temperature <= 0 + ): + raise ValueError("ALDesTemperature must be a positive finite number.") + if not isinstance(getattr(setting, "ALDesGreedy", False), bool): + raise ValueError("ALDesGreedy must be a boolean.") + aldes_mode = str(getattr(setting, "ALDesMode", "single")).lower() + if aldes_mode not in {"single", "continual"}: + raise ValueError("ALDesMode must be 'single' or 'continual'.") + setting.ALDesMode = aldes_mode if mode == "design": for name in ( "AlgP", diff --git a/src/autooptlib/utils/solve/__init__.py b/src/autooptlib/utils/solve/__init__.py index 66e225b..960194f 100644 --- a/src/autooptlib/utils/solve/__init__.py +++ b/src/autooptlib/utils/solve/__init__.py @@ -613,7 +613,22 @@ def _init_population( ) -> SolutionSet: ptype = get_problem_type(problem) or "continuous" pop_n = int(get_flex(problem, "N", get_flex(setting, "ProbN", 10))) - if ptype == "continuous": + supplied = get_flex(setting, "_InitialPopulation", None) + if supplied is not None: + decs = np.asarray(supplied) + if decs.ndim != 2: + raise ValueError("Each supplied initial population must be a 2-D array.") + dimension = np.asarray(get_flex(problem, "bound")).shape[-1] + if decs.shape[1] != dimension: + raise ValueError( + "Supplied initial-population dimension does not match the problem." + ) + if decs.shape[0] < pop_n: + raise ValueError( + "A supplied initial population must contain at least ProbN rows." + ) + decs = np.array(decs[:pop_n], copy=True) + elif ptype == "continuous": bound = np.asarray(get_flex(problem, "bound"), dtype=float) lower = bound[0] upper = bound[1] @@ -824,65 +839,58 @@ def _execute_path( generation: int, remaining_evaluations: int, ) -> Tuple[List[Any], Any, int]: + """Execute one branch of a MATLAB-style multi-path algorithm. + + Each branch evaluates only its first search row on every outer generation. + A crossover's paired mutation belongs to that row and is executed as its + secondary operator. Later search rows and the serial-path termination loop + are intentionally ignored in this mode. + """ + if not isinstance(aux_state, dict): aux_state = {} aux_state.setdefault("rng", ensure_rng(setting)) - current_parent = subset - evals = 0 - for step_idx, step in enumerate(path.search): - if evals >= remaining_evaluations: - break - improve = None - inner_g = 1 - limit = int(step.termination[1]) if step.termination.size > 1 else 1 - threshold = float(step.termination[0]) if step.termination.size > 0 else -np.inf - primary_param = params.search[step_idx].primary if params.search else None - secondary_param = params.search[step_idx].secondary if params.search else None - while (improve is None or improve[0] >= threshold) and inner_g <= limit: - remaining = remaining_evaluations - evals - if remaining <= 0: - break - primary_fn = get_component(step.primary) - new_dec, aux_state = primary_fn( - current_parent, - problem, - primary_param, - aux_state, - generation, - inner_g, - data, - "execute", - ) - if step.secondary: - new_dec = repair_sol(np.asarray(new_dec), problem) - secondary_fn = get_component(step.secondary) - new_dec, aux_state = secondary_fn( - new_dec, - problem, - secondary_param, - aux_state, - generation, - inner_g, - data, - "execute", - ) - new_dec = np.asarray(new_dec) - if new_dec.ndim == 1: - new_dec = new_dec.reshape(1, -1) - if new_dec.shape[0] > remaining: - new_dec = new_dec[:remaining] - new = make_solutions(new_dec, problem, data) - if step.primary == "search_cma": - aux_state = get_component("para_cma")( - new, problem, aux_state, "solution" - ) - elif step.primary == "search_pso": - aux_state = get_component("para_pso")(new, problem, aux_state) - current_parent = new - evals += len(new) - improve = improve_rate(new, improve, inner_g, "solution") - inner_g += 1 - return list(current_parent), aux_state, evals + if not path.search or remaining_evaluations <= 0: + return [], aux_state, 0 + + step = path.search[0] + primary_param = params.search[0].primary if params.search else None + secondary_param = params.search[0].secondary if params.search else None + primary_fn = get_component(step.primary) + new_dec, aux_state = primary_fn( + subset, + problem, + primary_param, + aux_state, + generation, + generation, + data, + "execute", + ) + if step.secondary: + new_dec = repair_sol(np.asarray(new_dec), problem) + secondary_fn = get_component(step.secondary) + new_dec, aux_state = secondary_fn( + new_dec, + problem, + secondary_param, + aux_state, + generation, + generation, + data, + "execute", + ) + decisions = np.asarray(new_dec) + if decisions.ndim == 1: + decisions = decisions.reshape(1, -1) + if decisions.shape[0] > remaining_evaluations: + decisions = decisions[:remaining_evaluations] + new = make_solutions(decisions, problem, data) + if step.primary == "search_cma": + aux_state = get_component("para_cma")(new, problem, aux_state, "solution") + elif step.primary == "search_pso": + aux_state = get_component("para_pso")(new, problem, aux_state) + return list(new), aux_state, len(new) def run_design( diff --git a/src/autooptlib/utils/space.py b/src/autooptlib/utils/space.py index 014cde8..c04865d 100644 --- a/src/autooptlib/utils/space.py +++ b/src/autooptlib/utils/space.py @@ -143,6 +143,7 @@ def space(problem: Any, setting: Any) -> SimpleNamespace: "cross_point_uniform", "cross_point_n", "search_reset_one", + "search_reset_n", "search_reset_rand", "reinit_discrete", ] diff --git a/tests/unit/test_aldes.py b/tests/unit/test_aldes.py new file mode 100644 index 0000000..131f304 --- /dev/null +++ b/tests/unit/test_aldes.py @@ -0,0 +1,517 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np +import pytest + +from autooptlib import autoopt, get_component, make_problem +from autooptlib.aldes import ( + AutoOptEvaluator, + EvaluationConfig, + SequenceValidationError, + allowed_next_tokens, + decode_sequence, + validate_sequence, +) + +SIMPLE_SEQUENCE = [17, 0, 29, 8, 29, 12, 29, 18] + + +def _binary_problem(): + return make_problem( + lambda decision, _data: -float(np.sum(decision)), + bounds=(0, 1), + problem_type="discrete", + name="negative_onemax", + ) + + +def test_aldes_sequence_validation_and_masks(): + assert validate_sequence(SIMPLE_SEQUENCE) == SIMPLE_SEQUENCE + assert np.flatnonzero(allowed_next_tokens([17])).tolist() == [0, 1, 2, 3] + assert np.flatnonzero(allowed_next_tokens([17, 0, 29])).tolist() == [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + ] + assert np.flatnonzero(allowed_next_tokens([17, 0, 29, 8, 29, 12, 29])).tolist() == [ + 18 + ] + + with pytest.raises(SequenceValidationError, match="choose"): + validate_sequence([17, 8, 29, 12, 29, 18]) + with pytest.raises(SequenceValidationError, match="parameter"): + validate_sequence([17, 0, 29, 9, 29, 12, 29, 18]) + with pytest.raises(SequenceValidationError, match="final update"): + validate_sequence([17, 0, 29, 8, 29, 12, 30, 19, 18]) + with pytest.raises(SequenceValidationError, match="more than once"): + validate_sequence([17, 0, 29, 8, 29, 8, 29, 12, 29, 18]) + with pytest.raises(SequenceValidationError, match="global search"): + validate_sequence( + [17, 0, 29, 11, 29, 4, 29, 8, 29, 12, 29, 18] + ) + with pytest.raises(SequenceValidationError, match="crossover"): + validate_sequence([17, 0, 29, 4, 29, 12, 29, 18]) + + four_component_prefix = [17, 0, 29, 8, 29, 9, 19, 29, 10, 20, 29] + continuation = allowed_next_tokens(four_component_prefix) + assert not continuation[4:8].any() + assert continuation[11] + assert continuation[12] + after_four_searches = allowed_next_tokens(four_component_prefix + [11, 29]) + assert np.flatnonzero(after_four_searches).tolist() == [12, 13, 14, 15, 16] + + +def test_aldes_mask_enforces_global_search_and_distinct_fork_modes(): + assert np.flatnonzero(allowed_next_tokens([17, 0, 31])).tolist() == [ + 21, + 22, + ] + + # cross_point_one is always global. A following parameterized search + # therefore receives only the legacy local parameter tokens 0.1--0.3. + prefix = [17, 0, 29, 4, 29, 9] + assert np.flatnonzero(allowed_next_tokens(prefix)).tolist() == [19, 20, 21] + + # A local parameter remains available before a global search is present. + assert np.flatnonzero(allowed_next_tokens([17, 0, 29, 6])).tolist() == list( + range(19, 29) + ) + + # Parameter 0.4 makes cross_point_n global, so always-global components + # cannot be selected later in the same algorithm. + after_global = allowed_next_tokens([17, 0, 29, 6, 22, 29, 8, 29]) + assert not after_global[4] + assert not after_global[5] + assert not after_global[11] + + +def test_aldes_fork_has_exactly_one_executable_search_step(): + assert np.flatnonzero( + allowed_next_tokens([17, 0, 31, 21, 11, 29]) + ).tolist() == [12, 13, 14, 15, 16] + assert np.flatnonzero( + allowed_next_tokens([17, 0, 31, 21, 4, 29]) + ).tolist() == [8, 9, 10] + assert np.flatnonzero( + allowed_next_tokens([17, 0, 31, 21, 4, 29, 8, 29]) + ).tolist() == [12, 13, 14, 15, 16] + + validate_sequence([17, 0, 31, 21, 11, 29, 12, 29, 18]) + validate_sequence([17, 0, 31, 22, 4, 29, 8, 29, 12, 29, 18]) + with pytest.raises(SequenceValidationError, match="branch-mode"): + validate_sequence([17, 0, 31, 24, 11, 29, 12, 29, 18]) + with pytest.raises(SequenceValidationError, match="mutation-branch"): + validate_sequence([17, 0, 31, 22, 11, 29, 12, 29, 18]) + with pytest.raises(SequenceValidationError, match="one search step"): + validate_sequence([17, 0, 31, 21, 11, 29, 8, 29, 12, 29, 18]) + + +def test_aldes_codec_builds_executable_autooptlib_design(): + evaluator = AutoOptEvaluator( + _binary_problem(), + [6], + config=EvaluationConfig(population_size=4, evaluations=20, runs=2, seed=7), + ) + design = decode_sequence(SIMPLE_SEQUENCE, evaluator.problems, evaluator.setting) + pathway = design.operator_pheno[0][0] + assert pathway.choose == "choose_traverse" + assert pathway.search[0].primary == "search_reset_one" + assert pathway.update == "update_greedy" + assert design.aldes_sequence == SIMPLE_SEQUENCE + + performance = evaluator.evaluate(SIMPLE_SEQUENCE) + assert performance.shape == (1, 2) + assert np.all(np.isfinite(performance)) + assert np.all(performance <= 0) + + +def test_aldes_codec_maps_parameters_and_crossover_pair(): + evaluator = AutoOptEvaluator( + _binary_problem(), + [10], + config=EvaluationConfig(population_size=4, evaluations=20, runs=1), + ) + sequence = [17, 1, 29, 7, 22, 29, 10, 20, 29, 13, 29, 18] + design = decode_sequence(sequence, evaluator.problems, evaluator.setting) + pathway = design.operator_pheno[0][0] + parameters = design.parameter_pheno[0][0] + assert pathway.search[0].primary == "cross_point_uniform" + assert pathway.search[0].secondary == "search_reset_rand" + assert parameters.search[0].primary == pytest.approx([0.2]) + assert parameters.search[0].secondary == pytest.approx([0.1]) + + +def test_aldes_fork_executes_first_search_row_per_branch(monkeypatch): + import autooptlib.utils.solve as solve_module + + calls = {"cross": 0, "reset": 0} + original_lookup = solve_module.get_component + original_cross = original_lookup("cross_point_one") + original_reset = original_lookup("search_reset_one") + + def cross(*args): + if args[-1] == "execute": + calls["cross"] += 1 + return original_cross(*args) + + def reset(*args): + if args[-1] == "execute": + calls["reset"] += 1 + return original_reset(*args) + + def lookup(name): + if name == "cross_point_one": + return cross + if name == "search_reset_one": + return reset + return original_lookup(name) + + monkeypatch.setattr(solve_module, "get_component", lookup) + evaluator = AutoOptEvaluator( + _binary_problem(), + [6], + config=EvaluationConfig(population_size=4, evaluations=8, runs=1, seed=5), + ) + sequence = [17, 3, 31, 22, 4, 29, 8, 29, 12, 29, 18] + performance = evaluator.evaluate(sequence) + + assert performance.shape == (1, 1) + assert calls == {"cross": 1, "reset": 2} + + +def test_search_reset_n_component_uses_distinct_positions(): + component = get_component("search_reset_n") + solution = np.zeros((3, 8), dtype=int) + problem = SimpleNamespace( + bound=np.vstack((np.zeros(8, dtype=int), np.ones(8, dtype=int))) + ) + changed, _ = component( + solution, + problem, + np.array([3.0]), + {"rng": np.random.default_rng(4)}, + "execute", + ) + np.testing.assert_array_equal(changed.sum(axis=1), np.full(3, 3)) + + +def test_dimension_dependent_aldes_parameters_use_problem_dimension(): + problem_8d = SimpleNamespace(bound=np.zeros((2, 8))) + problem_5d = SimpleNamespace(bound=np.zeros((2, 5))) + + cross_bounds, _ = get_component("cross_point_n")( + [problem_8d, problem_5d], "parameter" + ) + reset_bounds, _ = get_component("search_reset_n")( + [problem_8d, problem_5d], "parameter" + ) + + assert cross_bounds == [1, 5] + np.testing.assert_array_equal(reset_bounds, [1.0, 5.0]) + + +def test_aldes_torch_api_is_optional(tmp_path): + import autooptlib.aldes as aldes + + try: + import torch # noqa: F401 + except ImportError: + with pytest.raises(ImportError, match="PyTorch"): + _ = aldes.ALDesGenerator + else: # pragma: no cover - exercised in the ALDes optional-dependency job + torch.manual_seed(4) + model = aldes.ALDesGenerator( + aldes.GeneratorConfig(layers=1, feedforward_dim=64, max_length=50) + ) + result = model.generate(candidates=2) + assert result.sequences.shape[0] == 2 + for sequence in result.sequences: + validate_sequence(sequence.numpy()) + scores = model.score(None, result.sequences) + assert torch.isfinite(scores).all() + + checkpoint = tmp_path / "generator.pt" + model.save_checkpoint(checkpoint, vocabulary="aldes-discrete-v1") + restored, metadata = aldes.ALDesGenerator.load_checkpoint(checkpoint) + assert metadata == {"vocabulary": "aldes-discrete-v1"} + assert restored.config == model.config + + +def test_aldes_feature_conditioning_is_opt_in(): + torch = pytest.importorskip("torch") + import autooptlib.aldes as aldes + + single = aldes.ALDesGenerator( + aldes.GeneratorConfig( + layers=1, feedforward_dim=64, dropout=0.0, max_length=50 + ) + ) + with pytest.raises(ValueError, match="does not accept"): + single.generate(torch.zeros(32)) + + continual = aldes.ALDesGenerator( + aldes.GeneratorConfig( + layers=1, + feedforward_dim=64, + dropout=0.0, + max_length=50, + condition_on_features=True, + ) + ) + continual.eval() + tokens = torch.tensor([[17]]) + zero_logits = continual.logits(torch.zeros(32), tokens) + one_logits = continual.logits(torch.ones(32), tokens) + assert not torch.equal(zero_logits, one_logits) + with pytest.raises(ValueError, match="requires"): + continual.generate() + + +def test_aldes_schema_one_checkpoint_loads_as_legacy_conditioned_model(tmp_path): + torch = pytest.importorskip("torch") + import autooptlib.aldes as aldes + + legacy = aldes.ALDesGenerator( + aldes.GeneratorConfig( + layers=1, + feedforward_dim=64, + max_length=50, + condition_on_features=True, + position_encoding="learned", + ) + ) + config = dict(vars(legacy.config)) + config.pop("condition_on_features") + config.pop("position_encoding") + checkpoint = tmp_path / "legacy-generator.pt" + torch.save( + { + "schema": "autooptlib.aldes.generator", + "schema_version": 1, + "config": config, + "state_dict": legacy.state_dict(), + "metadata": {}, + }, + checkpoint, + ) + restored, _ = aldes.ALDesGenerator.load_checkpoint(checkpoint) + assert restored.config.condition_on_features is True + assert restored.config.position_encoding == "learned" + + +def test_aldes_evaluator_reuses_initial_populations_and_candidate_streams(): + seen = [] + + def objective(decision, _dimension): + seen.append(np.asarray(decision, dtype=int).copy()) + return -float(np.sum(decision)) + + problem = make_problem( + objective, + bounds=(0, 1), + problem_type="discrete", + name="recording_onemax", + ) + initial = np.asarray( + [[0, 0, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0], + [1, 1, 0, 0, 0, 0], [1, 1, 1, 0, 0, 0]], + dtype=int, + ) + evaluator = AutoOptEvaluator( + problem, + [6], + config=EvaluationConfig( + population_size=4, + evaluations=4, + runs=1, + seed=7, + initial_populations=initial, + ), + ) + evaluator.evaluate(SIMPLE_SEQUENCE) + np.testing.assert_array_equal(np.vstack(seen[:4]), initial) + + sequence_b = [17, 0, 29, 11, 29, 12, 29, 18] + first = AutoOptEvaluator( + _binary_problem(), + [6], + config=EvaluationConfig( + population_size=4, evaluations=12, runs=1, seed=19 + ), + ) + second = AutoOptEvaluator( + _binary_problem(), + [6], + config=EvaluationConfig( + population_size=4, evaluations=12, runs=1, seed=19 + ), + ) + _, forward = first.evaluate_many([SIMPLE_SEQUENCE, sequence_b]) + _, reverse = second.evaluate_many([sequence_b, SIMPLE_SEQUENCE]) + np.testing.assert_array_equal(forward[0], reverse[1]) + np.testing.assert_array_equal(forward[1], reverse[0]) + + +def test_aldes_pbo_parallel_evaluation_deduplicates_and_preserves_order( + monkeypatch, +): + import autooptlib.aldes.evaluator as evaluator_module + + calls = [] + + def evaluate(_problem_id, sequence, _instances, _config): + calls.append(sequence) + value = float(sum(sequence)) + return value, np.asarray([[value]]) + + monkeypatch.setattr(evaluator_module, "_evaluate_pbo_sequence", evaluate) + first = np.asarray([17, 0, 29, 8, 29, 12, 29, 18]) + second = np.asarray([17, 0, 29, 11, 29, 12, 29, 18]) + means, performances = evaluator_module._evaluate_pbo_sequences( + np.vstack((first, second, first)), + 1, + [1], + EvaluationConfig(population_size=4, evaluations=4, runs=1), + workers=1, + ) + + assert len(calls) == 2 + assert means == [sum(first), sum(second), sum(first)] + np.testing.assert_array_equal(performances[0], performances[2]) + + +def test_aldes_high_level_designer_and_ppo(tmp_path): + torch = pytest.importorskip("torch") + import autooptlib.aldes as aldes + + torch.manual_seed(3) + model = aldes.ALDesGenerator( + aldes.GeneratorConfig(layers=1, feedforward_dim=64, max_length=50) + ) + problem = _binary_problem() + algorithms, trace = autoopt( + Mode="design", + Designer="aldes", + Problem=problem, + InstanceTrain=[4], + InstanceTest=[5], + ALDesModel=model, + ALDesCandidates=2, + AlgN=1, + AlgFE=1, + AlgRuns=1, + ProbN=4, + ProbFE=12, + InnerFE=4, + Seed=3, + OutputDir=tmp_path, + ) + assert len(algorithms) == 1 + assert algorithms[0].performance.shape == (2, 1) + assert trace + assert (tmp_path / "Algorithm_1.json").exists() + assert not (tmp_path / "Algorithm_2.json").exists() + + evaluator = AutoOptEvaluator( + problem, + [4], + config=EvaluationConfig(population_size=4, evaluations=12, runs=1, seed=4), + ) + trainer = aldes.PPOTrainer(model, aldes.PPOConfig(candidates=2, update_epochs=1)) + model.eval() + unchanged = model.generate(candidates=2) + np.testing.assert_allclose( + unchanged.log_probabilities.sum(dim=1).detach().numpy(), + model.score(None, unchanged.sequences).detach().numpy(), + rtol=1e-6, + atol=1e-6, + ) + metrics = trainer.step( + None, lambda sequences: evaluator.evaluate_many(sequences)[0] + ) + assert set(metrics) == { + "loss", + "mean_cost", + "baseline", + "best_cost", + "learning_rate", + } + assert np.all(np.isfinite(list(metrics.values()))) + + continual_model = aldes.ALDesGenerator( + aldes.GeneratorConfig( + layers=1, + feedforward_dim=64, + max_length=50, + condition_on_features=True, + ) + ) + initial = np.zeros((1, 4, 4), dtype=int) + feature_bundle = SimpleNamespace( + features=np.zeros(32, dtype=np.float32), + initial_populations={0: initial, 1: initial}, + ) + continual_algorithms, _ = autoopt( + Mode="design", + Designer="aldes", + ALDesMode="continual", + Problem=problem, + InstanceTrain=[4], + InstanceTest=[4], + ALDesModel=continual_model, + ALDesFeatures=feature_bundle, + ALDesCandidates=2, + AlgN=1, + AlgFE=1, + AlgRuns=1, + ProbN=4, + ProbFE=8, + InnerFE=4, + Seed=3, + OutputDir=tmp_path / "continual", + ) + assert len(continual_algorithms) == 1 + + +def test_aldes_ioh_pbo_adapter_smoke(): + pytest.importorskip("ioh") + from autooptlib.aldes import make_pbo_problem + + evaluator = AutoOptEvaluator( + make_pbo_problem(1), + [5], + config=EvaluationConfig(population_size=4, evaluations=8, runs=1, seed=1), + ) + performance = evaluator.evaluate(SIMPLE_SEQUENCE) + assert performance.shape == (1, 1) + assert np.isfinite(performance).all() + + +def test_aldes_pbo_feature_extraction_is_reproducible(): + pytest.importorskip("ioh") + pytest.importorskip("pflacco") + from autooptlib.aldes import extract_pbo_features + + options = dict( + dimension=10, + trials=2, + sample_factor=10, + population_size=4, + seed=17, + ) + first = extract_pbo_features(1, **options) + second = extract_pbo_features(1, **options) + + assert first.features.shape == (32,) + assert first.samples.shape == (2, 100, 10) + assert first.initial_populations.shape == (2, 4, 10) + assert np.isfinite(first.features).all() + np.testing.assert_array_equal(first.features, second.features) + np.testing.assert_array_equal(first.samples, second.samples) diff --git a/tests/unit/test_input_validation.py b/tests/unit/test_input_validation.py index 4f31d4b..0bf6fc8 100644 --- a/tests/unit/test_input_validation.py +++ b/tests/unit/test_input_validation.py @@ -4,6 +4,7 @@ from types import SimpleNamespace +import numpy as np import pytest from autooptlib.utils.general.input import ( @@ -66,6 +67,8 @@ class Setting: assert _ensure_namespace(Setting()).x == 2 assert _find_argument([], "x") == (False, None) assert _find_argument(["y", 1], "x") == (False, None) + arguments = ["features", np.zeros(4), "AlgN", 3] + assert _find_argument(arguments, "AlgN") == (True, 3) with pytest.raises(ValueError, match="Missing value"): _find_argument(["x"], "x") @@ -101,6 +104,7 @@ def test_input_handler_data_and_defaults(): assert design_defaults.AlgN == 10 assert design_defaults.AlgFE == 5000 assert design_defaults.AlgRuns == 5 + assert design_defaults.ALDesMode == "single" assert design_defaults.RacingK == 1 assert design_defaults.Surro == 1500 with pytest.raises(ValueError, match="Unsupported mode"): @@ -130,6 +134,7 @@ def test_input_handler_data_and_defaults(): "not necessary", ), ({"Compare": "statistic", "AlgRuns": 1}, "run the design multiple"), + ({"ALDesMode": "unknown"}, "ALDesMode"), ], ) def test_design_validation_rejects_invalid_combinations(overrides, message): diff --git a/tests/unit/test_public_api.py b/tests/unit/test_public_api.py index a16d347..7954105 100644 --- a/tests/unit/test_public_api.py +++ b/tests/unit/test_public_api.py @@ -14,7 +14,7 @@ def test_public_version_matches_release(): - assert autooptlib.__version__ == "1.2.0" + assert autooptlib.__version__ == "1.3.0" def test_cec_archive_is_a_package_resource(): diff --git a/tests/unit/test_reliability.py b/tests/unit/test_reliability.py index ec832ad..7ca902e 100644 --- a/tests/unit/test_reliability.py +++ b/tests/unit/test_reliability.py @@ -468,7 +468,7 @@ def test_experiment_manifest_records_environment_and_options(tmp_path): ) manifest = json.loads((tmp_path / "experiment.json").read_text()) assert manifest["schema"] == "autooptlib.experiment" - assert manifest["software"]["autooptlib"] == "1.2.0" + assert manifest["software"]["autooptlib"] == "1.3.0" assert manifest["options"]["Seed"] == 42 assert manifest["options"]["Problem"].endswith(":make_problem..definition") diff --git a/tests/unit/test_solve_contracts.py b/tests/unit/test_solve_contracts.py index 9c72c85..c914751 100644 --- a/tests/unit/test_solve_contracts.py +++ b/tests/unit/test_solve_contracts.py @@ -208,14 +208,26 @@ def lookup_component(name): assert result["evaluations"] == 6 -def test_parallel_path_helper_repairs_secondary_and_truncates(monkeypatch): +def test_parallel_path_helper_executes_first_search_pair_only(monkeypatch): + calls = [] + def primary(parent, *args): + calls.append("primary") return parent.decs() + 2.0, args[2] def secondary(parent, *args): + calls.append("secondary") return np.asarray(parent) + 1.0, args[2] - components = {"primary_test": primary, "secondary_test": secondary} + def later(parent, *args): + calls.append("later") + return parent.decs() + 10.0, args[2] + + components = { + "primary_test": primary, + "secondary_test": secondary, + "later_test": later, + } original_get_component = solve_module.get_component def lookup_component(name): @@ -229,9 +241,10 @@ def lookup_component(name): [ SearchStep( "primary_test", - np.array([-np.inf, 1.0]), + np.array([-np.inf, 5.0]), "secondary_test", - ) + ), + SearchStep("later_test", np.array([-np.inf, 5.0])), ], "update_greedy", [], @@ -257,6 +270,7 @@ def lookup_component(name): assert evaluations == 1 assert len(produced) == 1 assert len(aux) == 1 + assert calls == ["primary", "secondary"] def test_solve_helper_normalization_and_algorithm_errors(tmp_path): From 3786232d6ca9b346a03beb8efa0b889335aa3703 Mon Sep 17 00:00:00 2001 From: Qi Zhao Date: Mon, 20 Jul 2026 19:15:25 +0800 Subject: [PATCH 2/2] Fix AutoOptLib 1.3.0 release checks --- .github/workflows/tests.yml | 14 +++++++- pyproject.toml | 3 ++ src/autooptlib/aldes/evaluator.py | 4 +-- src/autooptlib/aldes/features.py | 8 ++--- src/autooptlib/aldes/model.py | 4 +-- src/autooptlib/aldes/training.py | 10 +++--- src/autooptlib/aldes/vocabulary.py | 4 +-- src/autooptlib/utils/design/_evaluate.py | 6 ++-- tests/unit/test_aldes.py | 42 +++++++++++++----------- 9 files changed, 50 insertions(+), 45 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 01a2f44..b302297 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,6 +57,18 @@ jobs: --cov-report=term-missing --cov-fail-under=90 + aldes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install ".[test,aldes]" + - run: python -W error -m pytest tests/unit/test_aldes.py + package: runs-on: ubuntu-latest steps: @@ -80,7 +92,7 @@ jobs: from autooptlib.problems import cec2013_f1 from autooptlib.utils.solve import input_algorithm - assert autooptlib.__version__ == "1.2.0" + assert autooptlib.__version__ == "1.3.0" problems, data, _ = cec2013_f1([SimpleNamespace()], [10], "construct") assert problems[0].bound.shape == (2, 10) assert data[0].o.shape == (10,) diff --git a/pyproject.toml b/pyproject.toml index 75ff076..2322a53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,6 +108,9 @@ ignore_missing_imports = true check_untyped_defs = true show_error_codes = true +[tool.coverage.run] +omit = ["src/autooptlib/aldes/*"] + [tool.ruff] target-version = "py39" line-length = 88 diff --git a/src/autooptlib/aldes/evaluator.py b/src/autooptlib/aldes/evaluator.py index ef213cb..efb8ab5 100644 --- a/src/autooptlib/aldes/evaluator.py +++ b/src/autooptlib/aldes/evaluator.py @@ -180,9 +180,7 @@ def _resolve_evaluation_workers(requested: int | None, jobs: int) -> int: "ALDES_EVAL_WORKERS must be 'auto' or a positive integer." ) from exc if count <= 0: - raise ValueError( - "ALDES_EVAL_WORKERS must be 'auto' or a positive integer." - ) + raise ValueError("ALDES_EVAL_WORKERS must be 'auto' or a positive integer.") return max(1, min(count, jobs)) diff --git a/src/autooptlib/aldes/features.py b/src/autooptlib/aldes/features.py index 54f10ad..f5ca63a 100644 --- a/src/autooptlib/aldes/features.py +++ b/src/autooptlib/aldes/features.py @@ -61,9 +61,7 @@ def _feature_mapping( values.update(calculate_information_content(frame, objectives, seed=seed)) values.update(calculate_ela_meta(frame, objectives)) values.update(calculate_nbc(frame, objectives)) - values.update( - calculate_dispersion(frame, objectives, dist_method="hamming") - ) + values.update(calculate_dispersion(frame, objectives, dist_method="hamming")) result: dict[str, float] = {} for name, value in values.items(): # Runtime measurements are machine-dependent and are not landscape @@ -120,9 +118,7 @@ def extract_pbo_features( for _ in range(trials): trial_seed = int(root.integers(0, np.iinfo(np.int32).max)) trial_rng = np.random.default_rng(trial_seed) - decisions = _binary_random_walk( - dimension, sample_factor * dimension, trial_rng - ) + decisions = _binary_random_walk(dimension, sample_factor * dimension, trial_rng) objectives = np.asarray(problem(decisions), dtype=float).reshape(-1) mappings.append(_feature_mapping(decisions, objectives, seed=trial_seed)) samples.append(decisions) diff --git a/src/autooptlib/aldes/model.py b/src/autooptlib/aldes/model.py index 16526a5..7210be9 100644 --- a/src/autooptlib/aldes/model.py +++ b/src/autooptlib/aldes/model.py @@ -86,9 +86,7 @@ def _make_sinusoidal_positions(length: int, dimension: int) -> torch.Tensor: encoding[:, 1::2] = torch.cos(positions * scale[: dimension // 2]) return encoding - def _features( - self, features: torch.Tensor | None, batch_size: int - ) -> torch.Tensor: + def _features(self, features: torch.Tensor | None, batch_size: int) -> torch.Tensor: if not self.config.condition_on_features or self.feature_projection is None: raise RuntimeError("This generator is not configured for problem features.") if features is None: diff --git a/src/autooptlib/aldes/training.py b/src/autooptlib/aldes/training.py index 436e712..59f8f1a 100644 --- a/src/autooptlib/aldes/training.py +++ b/src/autooptlib/aldes/training.py @@ -96,10 +96,8 @@ def __init__( def _anneal_learning_rate(self) -> float: progress = min(self.steps / self.config.anneal_steps, 1.0) - learning_rate = ( - self.config.learning_rate - + progress - * (self.config.final_learning_rate - self.config.learning_rate) + learning_rate = self.config.learning_rate + progress * ( + self.config.final_learning_rate - self.config.learning_rate ) for group in self.optimizer.param_groups: group["lr"] = learning_rate @@ -127,7 +125,9 @@ def step( sequences = [row.cpu().numpy() for row in generated.sequences] costs_array = np.asarray(evaluate(sequences), dtype=float).reshape(-1) if costs_array.shape[0] != self.config.candidates: - raise ValueError("Evaluator returned one cost per candidate incorrectly.") + raise ValueError( + "Evaluator returned one cost per candidate incorrectly." + ) costs = torch.as_tensor( costs_array, dtype=old_log_probability.dtype, diff --git a/src/autooptlib/aldes/vocabulary.py b/src/autooptlib/aldes/vocabulary.py index 8d00267..30462b6 100644 --- a/src/autooptlib/aldes/vocabulary.py +++ b/src/autooptlib/aldes/vocabulary.py @@ -320,9 +320,7 @@ def allowed_next_tokens(prefix: Sequence[int] | np.ndarray) -> np.ndarray: mask[list(PARAMETER_INDICES[:5])] = True elif last.name == "fork": mask[list(_FORK_PARAMETER_INDICES)] = True - elif ( - last.index in _PARAMETERIZED_GLOBAL_SEARCH and has_global_search - ): + elif last.index in _PARAMETERIZED_GLOBAL_SEARCH and has_global_search: mask[list(PARAMETER_INDICES[:3])] = True else: mask[list(PARAMETER_INDICES)] = True diff --git a/src/autooptlib/utils/design/_evaluate.py b/src/autooptlib/utils/design/_evaluate.py index 92c6a36..4742cf3 100644 --- a/src/autooptlib/utils/design/_evaluate.py +++ b/src/autooptlib/utils/design/_evaluate.py @@ -74,8 +74,7 @@ def _select_initial_population( if selected.ndim == 3: return selected[run % selected.shape[0]] raise ValueError( - "Each InitialPopulations mapping value must have shape (N,D) " - "or (runs,N,D)." + "Each InitialPopulations mapping value must have shape (N,D) or (runs,N,D)." ) array = np.asarray(populations) if array.ndim == 2: @@ -87,8 +86,7 @@ def _select_initial_population( if array.ndim == 4: return array[instance_index, run % array.shape[1]] raise ValueError( - "InitialPopulations must have shape (N,D), (runs,N,D), or " - "(instances,runs,N,D)." + "InitialPopulations must have shape (N,D), (runs,N,D), or (instances,runs,N,D)." ) diff --git a/tests/unit/test_aldes.py b/tests/unit/test_aldes.py index 131f304..7e2b68d 100644 --- a/tests/unit/test_aldes.py +++ b/tests/unit/test_aldes.py @@ -53,9 +53,7 @@ def test_aldes_sequence_validation_and_masks(): with pytest.raises(SequenceValidationError, match="more than once"): validate_sequence([17, 0, 29, 8, 29, 8, 29, 12, 29, 18]) with pytest.raises(SequenceValidationError, match="global search"): - validate_sequence( - [17, 0, 29, 11, 29, 4, 29, 8, 29, 12, 29, 18] - ) + validate_sequence([17, 0, 29, 11, 29, 4, 29, 8, 29, 12, 29, 18]) with pytest.raises(SequenceValidationError, match="crossover"): validate_sequence([17, 0, 29, 4, 29, 12, 29, 18]) @@ -93,12 +91,18 @@ def test_aldes_mask_enforces_global_search_and_distinct_fork_modes(): def test_aldes_fork_has_exactly_one_executable_search_step(): - assert np.flatnonzero( - allowed_next_tokens([17, 0, 31, 21, 11, 29]) - ).tolist() == [12, 13, 14, 15, 16] - assert np.flatnonzero( - allowed_next_tokens([17, 0, 31, 21, 4, 29]) - ).tolist() == [8, 9, 10] + assert np.flatnonzero(allowed_next_tokens([17, 0, 31, 21, 11, 29])).tolist() == [ + 12, + 13, + 14, + 15, + 16, + ] + assert np.flatnonzero(allowed_next_tokens([17, 0, 31, 21, 4, 29])).tolist() == [ + 8, + 9, + 10, + ] assert np.flatnonzero( allowed_next_tokens([17, 0, 31, 21, 4, 29, 8, 29]) ).tolist() == [12, 13, 14, 15, 16] @@ -249,9 +253,7 @@ def test_aldes_feature_conditioning_is_opt_in(): import autooptlib.aldes as aldes single = aldes.ALDesGenerator( - aldes.GeneratorConfig( - layers=1, feedforward_dim=64, dropout=0.0, max_length=50 - ) + aldes.GeneratorConfig(layers=1, feedforward_dim=64, dropout=0.0, max_length=50) ) with pytest.raises(ValueError, match="does not accept"): single.generate(torch.zeros(32)) @@ -320,8 +322,12 @@ def objective(decision, _dimension): name="recording_onemax", ) initial = np.asarray( - [[0, 0, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0], - [1, 1, 0, 0, 0, 0], [1, 1, 1, 0, 0, 0]], + [ + [0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0], + [1, 1, 0, 0, 0, 0], + [1, 1, 1, 0, 0, 0], + ], dtype=int, ) evaluator = AutoOptEvaluator( @@ -342,16 +348,12 @@ def objective(decision, _dimension): first = AutoOptEvaluator( _binary_problem(), [6], - config=EvaluationConfig( - population_size=4, evaluations=12, runs=1, seed=19 - ), + config=EvaluationConfig(population_size=4, evaluations=12, runs=1, seed=19), ) second = AutoOptEvaluator( _binary_problem(), [6], - config=EvaluationConfig( - population_size=4, evaluations=12, runs=1, seed=19 - ), + config=EvaluationConfig(population_size=4, evaluations=12, runs=1, seed=19), ) _, forward = first.evaluate_many([SIMPLE_SEQUENCE, sequence_b]) _, reverse = second.evaluate_many([sequence_b, SIMPLE_SEQUENCE])