From 3cbc262c89862b56e4acddf8e4bd8147dc07797f Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 6 Aug 2026 18:33:01 -0700 Subject: [PATCH 01/13] setup mixin class to handle checkpointing features --- xopt/generators/checkpoints.py | 123 ++++++++++++++++++++++ xopt/tests/generators/test_checkpoints.py | 84 +++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 xopt/generators/checkpoints.py create mode 100644 xopt/tests/generators/test_checkpoints.py diff --git a/xopt/generators/checkpoints.py b/xopt/generators/checkpoints.py new file mode 100644 index 00000000..d234fcb7 --- /dev/null +++ b/xopt/generators/checkpoints.py @@ -0,0 +1,123 @@ +from datetime import datetime +from pydantic import BaseModel, Field, model_validator +import json +import os + +from ..vocs import VOCS + + +class CheckpointMixin(BaseModel): + """ + Mix-in class adding checkpoint saving and loading to a generator. + + Checkpoints are written to a "checkpoints" subdirectory of a caller-supplied + directory, with the VOCS object written alongside it as "vocs.txt". + + The host class must provide a ``vocs`` attribute and a ``to_json`` method. + Writing of the checkpoint file is the responsibility of the concrete class + as it will be implementation dependent. + + Parameters + ---------- + checkpoint_file : str, optional + Path to checkpoint file to load from. If provided, the generator will be + initialized from the checkpoint state. User-specified parameters will + override checkpoint values. + """ + + checkpoint_file: str | None = Field( + None, description="Path to checkpoint file to load from", exclude=True + ) + + @staticmethod + def _load_checkpoint_data(fname: str) -> dict: + """ + Internal function to load generator data from checkpoint file as well as VOCS object. + + Parameters + ---------- + fname : str + Path to the checkpoint file + + Returns + ------- + dict + Dictionary containing VOCS and checkpoint data + """ + # Load the VOCS object + vocs_fname = os.path.join(os.path.dirname(fname), "../vocs.txt") + if not os.path.exists(vocs_fname): + raise ValueError( + f'Could not load VOCS file at "{vocs_fname}". Complete generator ' + "output directory is required for loading from checkpoint." + ) + + with open(vocs_fname) as f: + vocs = VOCS(**json.load(f)) + + # Load the checkpoint + with open(fname) as f: + checkpoint_data = json.load(f) + + return {"vocs": vocs, **checkpoint_data} + + @model_validator(mode="before") + @classmethod + def load_from_checkpoint(cls, values): + """ + Load from checkpoint file if checkpoint_file is provided. + """ + # Case when a checkpoint file has been supplied + if isinstance(values, dict) and "checkpoint_file" in values: + checkpoint_file = values.pop("checkpoint_file") + if checkpoint_file is not None: + # Load checkpoint data + checkpoint_data = cls._load_checkpoint_data(checkpoint_file) + + # Merge with user data precedence + merged_data = {**checkpoint_data, **values} + return merged_data + + # No checkpoint + return values + + def _save_checkpoint(self, path: str | os.PathLike) -> str: + """ + Write the VOCS object and a checkpoint of the generator state to disk. + + Parameters + ---------- + path : str or os.PathLike + Directory into which "vocs.txt" and the "checkpoints" subdirectory + containing the checkpoint file are written. + + Returns + ------- + str + Path to the checkpoint file which was written. + """ + # Set up the output directory and write the VOCS object needed to reload + checkpoint_dir = os.path.join(path, "checkpoints") + os.makedirs(checkpoint_dir, exist_ok=True) + with open(os.path.join(path, "vocs.txt"), "w") as f: + json.dump(self.vocs.model_dump(), f) + + # Create a base filename + base_checkpoint_filename = datetime.now().strftime("%Y%m%d_%H%M%S") + checkpoint_path = os.path.join( + checkpoint_dir, f"{base_checkpoint_filename}_1.txt" + ) + + # Check if file exists and increment counter until we find a free filename + counter = 2 + while os.path.exists(checkpoint_path): + checkpoint_path = os.path.join( + checkpoint_dir, f"{base_checkpoint_filename}_{counter}.txt" + ) + counter += 1 + + # Now we have a unique filename + with open(checkpoint_path, "w") as f: + f.write(self.to_json()) + + return checkpoint_path diff --git a/xopt/tests/generators/test_checkpoints.py b/xopt/tests/generators/test_checkpoints.py new file mode 100644 index 00000000..3f0726cb --- /dev/null +++ b/xopt/tests/generators/test_checkpoints.py @@ -0,0 +1,84 @@ +from copy import deepcopy +from datetime import datetime +import json +import os + +import pytest + +from xopt.generators.checkpoints import CheckpointMixin +from xopt.generators.random import RandomGenerator +from xopt.resources.testing import TEST_VOCS_BASE +from xopt.vocs import VOCS + + +class CheckpointingRandomGenerator(CheckpointMixin, RandomGenerator): + """Minimal host for the checkpoint mixin with a field to round trip.""" + + counter: int = 0 + + +def parse_checkpoint_filename(filename: str) -> tuple[datetime, int]: + """Split a checkpoint filename into its timestamp and deduplication index.""" + base, index = filename.rsplit("_", 1) + return datetime.strptime(base, "%Y%m%d_%H%M%S"), int(index.split(".")[0]) + + +def test_save_checkpoint_layout(tmp_path): + generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) + checkpoint_path = generator._save_checkpoint(tmp_path) + + # VOCS is written to the parent directory and the checkpoint into "checkpoints" + vocs_path = tmp_path / "vocs.txt" + assert vocs_path.is_file() + assert os.path.dirname(checkpoint_path) == str(tmp_path / "checkpoints") + assert os.listdir(tmp_path / "checkpoints") == [os.path.basename(checkpoint_path)] + + # Filename follows the timestamp plus deduplication index scheme + _, index = parse_checkpoint_filename(os.path.basename(checkpoint_path)) + assert index == 1 + + # Both files hold valid JSON and the VOCS object round trips + with open(checkpoint_path) as f: + assert "counter" in json.load(f) + with open(vocs_path) as f: + assert VOCS(**json.load(f)) == generator.vocs + + +def test_checkpoint_round_trip(tmp_path): + generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE), counter=17) + checkpoint_path = generator._save_checkpoint(tmp_path) + + # VOCS comes from the checkpoint output directory, not from the user + reloaded = CheckpointingRandomGenerator(checkpoint_file=checkpoint_path) + assert reloaded.counter == 17 + assert reloaded.vocs == generator.vocs + + # The path used to load is not carried into the reloaded generator's state + assert reloaded.checkpoint_file is None + assert "checkpoint_file" not in json.loads(reloaded.to_json()) + + +def test_checkpoint_user_values_take_precedence(tmp_path): + generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE), counter=17) + checkpoint_path = generator._save_checkpoint(tmp_path) + + reloaded = CheckpointingRandomGenerator(checkpoint_file=checkpoint_path, counter=99) + assert reloaded.counter == 99 + + +def test_save_checkpoint_avoids_overwriting(tmp_path): + generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) + first = generator._save_checkpoint(tmp_path) + second = generator._save_checkpoint(tmp_path) + + assert first != second + assert len(os.listdir(tmp_path / "checkpoints")) == 2 + + +def test_load_checkpoint_missing_vocs(tmp_path): + generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) + checkpoint_path = generator._save_checkpoint(tmp_path) + os.remove(tmp_path / "vocs.txt") + + with pytest.raises(ValueError, match="Could not load VOCS file"): + CheckpointingRandomGenerator(checkpoint_file=checkpoint_path) From d59f14c0a5b016021829d9dbdf4acc05d5645d61 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 6 Aug 2026 18:37:06 -0700 Subject: [PATCH 02/13] refactor `NSGA2Generator` to use new `CheckpointMixin` --- xopt/generators/ga/nsga2.py | 94 ++----------------------------------- 1 file changed, 4 insertions(+), 90 deletions(-) diff --git a/xopt/generators/ga/nsga2.py b/xopt/generators/ga/nsga2.py index ba716ccc..da8aa13b 100644 --- a/xopt/generators/ga/nsga2.py +++ b/xopt/generators/ga/nsga2.py @@ -1,4 +1,3 @@ -from datetime import datetime from itertools import chain from pydantic import Field, Discriminator, model_validator from typing import Annotated @@ -14,6 +13,7 @@ from ...errors import DataError from ...generator import StateOwner from ...vocs import VOCS +from ..checkpoints import CheckpointMixin from ..deduplicated import DeduplicatedGeneratorBase from ..utils import fast_dominated_argsort from .operators import ( @@ -312,7 +312,7 @@ def generate_candidates_from_population( ######################################################################################################################## -class NSGA2Generator(DeduplicatedGeneratorBase, StateOwner): +class NSGA2Generator(CheckpointMixin, DeduplicatedGeneratorBase, StateOwner): """ Non-dominated Sorting Genetic Algorithm II (NSGA-II) generator. Implements the NSGA-II algorithm for multi-objective optimization as described in [1]. This generator accomdates user selected mutation @@ -373,11 +373,6 @@ class NSGA2Generator(DeduplicatedGeneratorBase, StateOwner): supports_constraints: bool = True supports_single_objective: bool = True - # Checkpoint loading - checkpoint_file: str | None = Field( - None, description="Path to checkpoint file to load from", exclude=True - ) - population_size: int = Field(50, description="Population size") crossover_operator: Annotated[ ( @@ -436,58 +431,6 @@ def model_post_init(self, context): self._logger = logging.getLogger(f"{__name__}.NSGA2Generator.{id(self)}") self._logger.setLevel(self.log_level) - @staticmethod - def _load_checkpoint_data(fname: str) -> dict: - """ - Internal function to load generator data from checkpoint file as well as VOCS object. - - Parameters - ---------- - fname : str - Path to the checkpoint file - - Returns - ------- - dict - Dictionary containing VOCS and checkpoint data - """ - # Load the VOCS object - vocs_fname = os.path.join(os.path.dirname(fname), "../vocs.txt") - if not os.path.exists(vocs_fname): - raise ValueError( - f'Could not load VOCS file at "{vocs_fname}". Complete NSGA2Generator ' - "output directory is required for loading from checkpoint." - ) - - with open(vocs_fname) as f: - vocs = VOCS(**json.load(f)) - - # Load the checkpoint - with open(fname) as f: - checkpoint_data = json.load(f) - - return {"vocs": vocs, **checkpoint_data} - - @model_validator(mode="before") - @classmethod - def load_from_checkpoint(cls, values): - """ - Load from checkpoint file if checkpoint_file is provided. - """ - # Case when a checkpoint file has been supplied - if isinstance(values, dict) and "checkpoint_file" in values: - checkpoint_file = values.pop("checkpoint_file") - if checkpoint_file is not None: - # Load checkpoint data - checkpoint_data = cls._load_checkpoint_data(checkpoint_file) - - # Merge with user data precedence - merged_data = {**checkpoint_data, **values} - return merged_data - - # No checkpoint - return values - @model_validator(mode="after") def vocs_compatible(self): """ @@ -682,37 +625,8 @@ def add_data(self, new_data: pd.DataFrame): if self.checkpoint_freq > 0 and ( self.n_generations % self.checkpoint_freq == 0 ): - self._save_checkpoint() - - def _save_checkpoint(self): - # Confirm we are ready to save checkpoint - if self.output_dir is None: - raise ValueError("Cannot save checkpoint without an output directory") - self.ensure_output_dir_setup() - - # Create a base filename - os.makedirs(os.path.join(self.output_dir, "checkpoints"), exist_ok=True) - base_checkpoint_filename = datetime.now().strftime("%Y%m%d_%H%M%S") - checkpoint_path = os.path.join( - self.output_dir, - "checkpoints", - f"{base_checkpoint_filename}_1.txt", - ) - - # Check if file exists and increment counter until we find a free filename - counter = 2 - while os.path.exists(checkpoint_path): - checkpoint_path = os.path.join( - self.output_dir, - "checkpoints", - f"{base_checkpoint_filename}_{counter}.txt", - ) - counter += 1 - - # Now we have a unique filename - with open(checkpoint_path, "w") as f: - f.write(self.to_json()) - self._logger.debug(f'saved checkpoint file "{checkpoint_path}"') + checkpoint_path = self._save_checkpoint(self.output_dir) + self._logger.debug(f'saved checkpoint file "{checkpoint_path}"') def set_data(self, data): self.data = data From 36b38ea907a9bbedc072152e2e686e10e24cbf5c Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 6 Aug 2026 18:42:43 -0700 Subject: [PATCH 03/13] don't write `vocs.txt` from main generator --- docs/examples/ga/nsga2/nsga2_python.ipynb | 6 +++--- docs/examples/ga/nsga2/yaml_interface/index.md | 2 +- docs/examples/ga/nsga2/yaml_interface/nsga2_yaml.ipynb | 2 +- xopt/generators/ga/nsga2.py | 3 --- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/docs/examples/ga/nsga2/nsga2_python.ipynb b/docs/examples/ga/nsga2/nsga2_python.ipynb index 08d5ee2a..96bc4282 100644 --- a/docs/examples/ga/nsga2/nsga2_python.ipynb +++ b/docs/examples/ga/nsga2/nsga2_python.ipynb @@ -202,7 +202,7 @@ "\n", "The output files are the following.\n", " - `data.csv`: All data evaluated during the optimization\n", - " - `vocs.txt`: The VOCS object so that the objectives, constraints, decision variables are retained alongside the data\n", + " - `vocs.txt`: The VOCS object so that the objectives, constraints, decision variables are retained alongside the data. Written as each checkopint is emitted\n", " - `populations.csv`: Each population is written here with a column `xopt_generation` to distinguish which generation the row belongs to\n", " - `checkpoints`: This generator periodically saves its full state to timestamped files in this directory\n", " - `log.txt`: Log output from the generator is recorded to this file\n", @@ -357,7 +357,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "xopt-dev", "language": "python", "name": "python3" }, @@ -371,7 +371,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.10" + "version": "3.12.0" } }, "nbformat": 4, diff --git a/docs/examples/ga/nsga2/yaml_interface/index.md b/docs/examples/ga/nsga2/yaml_interface/index.md index 2e293e6d..9c0592c4 100644 --- a/docs/examples/ga/nsga2/yaml_interface/index.md +++ b/docs/examples/ga/nsga2/yaml_interface/index.md @@ -255,7 +255,7 @@ Navigate to the output directory and observe the files there. - `populations.csv`: Each completed population is recorded to this file - `data.csv`: Contains all evaluated inviduals - `log.txt`: A record of all log messages the genreator emitted during its run -- `vocs.txt`: A copy of the variable, objectives, and constraints (VOCs) definitions +- `vocs.txt`: A copy of the variable, objectives, and constraints (VOCs) definitions, written as each checkpoint is emitted. - `checkpoints/`: This directory contains checkpoint files which are used with the `checkpoint_file` key of the generator to restart an optimization. diff --git a/docs/examples/ga/nsga2/yaml_interface/nsga2_yaml.ipynb b/docs/examples/ga/nsga2/yaml_interface/nsga2_yaml.ipynb index 1f0635d3..063be280 100644 --- a/docs/examples/ga/nsga2/yaml_interface/nsga2_yaml.ipynb +++ b/docs/examples/ga/nsga2/yaml_interface/nsga2_yaml.ipynb @@ -52,7 +52,7 @@ "- `populations.csv`: Each completed population is recorded to this file\n", "- `data.csv`: Contains all evaluated inviduals\n", "- `log.txt`: A record of all log messages the genreator emitted during its run\n", - "- `vocs.txt`: A copy of the variable, objectives, and constraints (VOCs) definitions\n", + "- `vocs.txt`: A copy of the variable, objectives, and constraints (VOCs) definitions, written as each checkpoint is emitted\n", "- `checkpoints/`: This directory contains checkpoint files which are used with the `checkpoint_file` key of the generator to restart an optimization." ] }, diff --git a/xopt/generators/ga/nsga2.py b/xopt/generators/ga/nsga2.py index da8aa13b..402fd46a 100644 --- a/xopt/generators/ga/nsga2.py +++ b/xopt/generators/ga/nsga2.py @@ -1,7 +1,6 @@ from itertools import chain from pydantic import Field, Discriminator, model_validator from typing import Annotated -import json import logging import numpy as np import os @@ -593,8 +592,6 @@ def add_data(self, new_data: pd.DataFrame): # Save all Xopt data self.data.to_csv(os.path.join(self.output_dir, "data.csv"), index=False) - with open(os.path.join(self.output_dir, "vocs.txt"), "w") as f: - json.dump(self.vocs.dict(), f) # Construct the DataFrame for this population pop_df = pd.DataFrame(self.pop) From 15c163a8680e937cdd16128dd8a1c32da5141da0 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 6 Aug 2026 19:34:02 -0700 Subject: [PATCH 04/13] we don't need to actually save vocs.txt anymore --- docs/examples/ga/nsga2/nsga2_python.ipynb | 18 +----- .../examples/ga/nsga2/yaml_interface/index.md | 1 - .../ga/nsga2/yaml_interface/nsga2_yaml.ipynb | 1 - xopt/generators/checkpoints.py | 31 +++++----- xopt/tests/generators/ga/test_nsga2.py | 6 -- xopt/tests/generators/test_checkpoints.py | 56 ++++++++++++++++--- 6 files changed, 65 insertions(+), 48 deletions(-) diff --git a/docs/examples/ga/nsga2/nsga2_python.ipynb b/docs/examples/ga/nsga2/nsga2_python.ipynb index 96bc4282..89576db4 100644 --- a/docs/examples/ga/nsga2/nsga2_python.ipynb +++ b/docs/examples/ga/nsga2/nsga2_python.ipynb @@ -14,7 +14,6 @@ "metadata": {}, "outputs": [], "source": [ - "import json\n", "import logging\n", "import matplotlib.pyplot as plt\n", "import os\n", @@ -28,7 +27,7 @@ " SimulatedBinaryCrossover,\n", ")\n", "from xopt.resources.test_functions.zdt import construct_zdt\n", - "from xopt import Xopt, Evaluator, VOCS" + "from xopt import Xopt, Evaluator" ] }, { @@ -202,7 +201,6 @@ "\n", "The output files are the following.\n", " - `data.csv`: All data evaluated during the optimization\n", - " - `vocs.txt`: The VOCS object so that the objectives, constraints, decision variables are retained alongside the data. Written as each checkopint is emitted\n", " - `populations.csv`: Each population is written here with a column `xopt_generation` to distinguish which generation the row belongs to\n", " - `checkpoints`: This generator periodically saves its full state to timestamped files in this directory\n", " - `log.txt`: Log output from the generator is recorded to this file\n", @@ -288,20 +286,6 @@ "df.head()" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Read the VOCS object back in. This can be used for data analysis / restarting optimizations\n", - "with open(os.path.join(my_xopt.generator.output_dir, \"vocs.txt\")) as f:\n", - " vocs_from_file = VOCS(**json.load(f))\n", - "\n", - "# Show the objectives\n", - "vocs_from_file.objectives" - ] - }, { "cell_type": "code", "execution_count": null, diff --git a/docs/examples/ga/nsga2/yaml_interface/index.md b/docs/examples/ga/nsga2/yaml_interface/index.md index 9c0592c4..91d4ff82 100644 --- a/docs/examples/ga/nsga2/yaml_interface/index.md +++ b/docs/examples/ga/nsga2/yaml_interface/index.md @@ -255,7 +255,6 @@ Navigate to the output directory and observe the files there. - `populations.csv`: Each completed population is recorded to this file - `data.csv`: Contains all evaluated inviduals - `log.txt`: A record of all log messages the genreator emitted during its run -- `vocs.txt`: A copy of the variable, objectives, and constraints (VOCs) definitions, written as each checkpoint is emitted. - `checkpoints/`: This directory contains checkpoint files which are used with the `checkpoint_file` key of the generator to restart an optimization. diff --git a/docs/examples/ga/nsga2/yaml_interface/nsga2_yaml.ipynb b/docs/examples/ga/nsga2/yaml_interface/nsga2_yaml.ipynb index 063be280..63da9e81 100644 --- a/docs/examples/ga/nsga2/yaml_interface/nsga2_yaml.ipynb +++ b/docs/examples/ga/nsga2/yaml_interface/nsga2_yaml.ipynb @@ -52,7 +52,6 @@ "- `populations.csv`: Each completed population is recorded to this file\n", "- `data.csv`: Contains all evaluated inviduals\n", "- `log.txt`: A record of all log messages the genreator emitted during its run\n", - "- `vocs.txt`: A copy of the variable, objectives, and constraints (VOCs) definitions, written as each checkpoint is emitted\n", "- `checkpoints/`: This directory contains checkpoint files which are used with the `checkpoint_file` key of the generator to restart an optimization." ] }, diff --git a/xopt/generators/checkpoints.py b/xopt/generators/checkpoints.py index d234fcb7..1d4d519c 100644 --- a/xopt/generators/checkpoints.py +++ b/xopt/generators/checkpoints.py @@ -11,7 +11,9 @@ class CheckpointMixin(BaseModel): Mix-in class adding checkpoint saving and loading to a generator. Checkpoints are written to a "checkpoints" subdirectory of a caller-supplied - directory, with the VOCS object written alongside it as "vocs.txt". + directory. The VOCS object is serialized into the checkpoint itself. Legacy + checkpoints which predate this instead carry the VOCS object in a "vocs.txt" + file beside the checkpoint directory and are still supported when loading. The host class must provide a ``vocs`` attribute and a ``to_json`` method. Writing of the checkpoint file is the responsibility of the concrete class @@ -44,21 +46,24 @@ def _load_checkpoint_data(fname: str) -> dict: dict Dictionary containing VOCS and checkpoint data """ - # Load the VOCS object + # Load the checkpoint + with open(fname) as f: + checkpoint_data = json.load(f) + + if "vocs" in checkpoint_data: + return checkpoint_data + + # Legacy checkpoints w/o VOCS vocs_fname = os.path.join(os.path.dirname(fname), "../vocs.txt") if not os.path.exists(vocs_fname): raise ValueError( - f'Could not load VOCS file at "{vocs_fname}". Complete generator ' - "output directory is required for loading from checkpoint." + f'Checkpoint "{fname}" does not contain a VOCS object and no ' + f'VOCS file was found at "{vocs_fname}".' ) with open(vocs_fname) as f: vocs = VOCS(**json.load(f)) - # Load the checkpoint - with open(fname) as f: - checkpoint_data = json.load(f) - return {"vocs": vocs, **checkpoint_data} @model_validator(mode="before") @@ -83,24 +88,22 @@ def load_from_checkpoint(cls, values): def _save_checkpoint(self, path: str | os.PathLike) -> str: """ - Write the VOCS object and a checkpoint of the generator state to disk. + Write a checkpoint of the generator state to disk. Parameters ---------- path : str or os.PathLike - Directory into which "vocs.txt" and the "checkpoints" subdirectory - containing the checkpoint file are written. + Directory into which the "checkpoints" subdirectory containing the + checkpoint file is written. Returns ------- str Path to the checkpoint file which was written. """ - # Set up the output directory and write the VOCS object needed to reload + # Set up the output directory checkpoint_dir = os.path.join(path, "checkpoints") os.makedirs(checkpoint_dir, exist_ok=True) - with open(os.path.join(path, "vocs.txt"), "w") as f: - json.dump(self.vocs.model_dump(), f) # Create a base filename base_checkpoint_filename = datetime.now().strftime("%Y%m%d_%H%M%S") diff --git a/xopt/tests/generators/ga/test_nsga2.py b/xopt/tests/generators/ga/test_nsga2.py index 635d93dc..850b819d 100644 --- a/xopt/tests/generators/ga/test_nsga2.py +++ b/xopt/tests/generators/ga/test_nsga2.py @@ -76,7 +76,6 @@ def test_nsga2_output_data(): # Verify that the data files are created assert os.path.exists(os.path.join(output_dir, "data.csv")) assert os.path.exists(os.path.join(output_dir, "populations.csv")) - assert os.path.exists(os.path.join(output_dir, "vocs.txt")) assert os.path.exists(os.path.join(output_dir, "log.txt")) # Read the data file and check its contents @@ -110,11 +109,6 @@ def test_nsga2_output_data(): # Check that the populations file contains the expected columns assert "xopt_generation" in pop_df.columns - # Check that the VOCS file contains valid JSON - with open(os.path.join(output_dir, "vocs.txt"), "r") as f: - vocs_dict = json.load(f) - VOCS(**vocs_dict) - # Verify that the log file exists and has content with open(os.path.join(output_dir, "log.txt"), "r") as f: log_content = f.read() diff --git a/xopt/tests/generators/test_checkpoints.py b/xopt/tests/generators/test_checkpoints.py index 3f0726cb..0b2a3091 100644 --- a/xopt/tests/generators/test_checkpoints.py +++ b/xopt/tests/generators/test_checkpoints.py @@ -23,13 +23,25 @@ def parse_checkpoint_filename(filename: str) -> tuple[datetime, int]: return datetime.strptime(base, "%Y%m%d_%H%M%S"), int(index.split(".")[0]) +def make_legacy_checkpoint(checkpoint_path: str) -> None: + """Rewrite a checkpoint into the legacy layout with VOCS held in "vocs.txt".""" + with open(checkpoint_path) as f: + checkpoint_data = json.load(f) + vocs = checkpoint_data.pop("vocs") + + with open(checkpoint_path, "w") as f: + json.dump(checkpoint_data, f) + legacy_dir = os.path.dirname(os.path.dirname(checkpoint_path)) + with open(os.path.join(legacy_dir, "vocs.txt"), "w") as f: + json.dump(vocs, f) + + def test_save_checkpoint_layout(tmp_path): generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) checkpoint_path = generator._save_checkpoint(tmp_path) - # VOCS is written to the parent directory and the checkpoint into "checkpoints" - vocs_path = tmp_path / "vocs.txt" - assert vocs_path.is_file() + # Only the "checkpoints" subdirectory is created, no separate VOCS file + assert os.listdir(tmp_path) == ["checkpoints"] assert os.path.dirname(checkpoint_path) == str(tmp_path / "checkpoints") assert os.listdir(tmp_path / "checkpoints") == [os.path.basename(checkpoint_path)] @@ -37,11 +49,11 @@ def test_save_checkpoint_layout(tmp_path): _, index = parse_checkpoint_filename(os.path.basename(checkpoint_path)) assert index == 1 - # Both files hold valid JSON and the VOCS object round trips + # The checkpoint holds valid JSON and carries the VOCS object itself with open(checkpoint_path) as f: - assert "counter" in json.load(f) - with open(vocs_path) as f: - assert VOCS(**json.load(f)) == generator.vocs + checkpoint_data = json.load(f) + assert "counter" in checkpoint_data + assert VOCS(**checkpoint_data["vocs"]) == generator.vocs def test_checkpoint_round_trip(tmp_path): @@ -75,10 +87,36 @@ def test_save_checkpoint_avoids_overwriting(tmp_path): assert len(os.listdir(tmp_path / "checkpoints")) == 2 -def test_load_checkpoint_missing_vocs(tmp_path): +def test_load_legacy_checkpoint(tmp_path): + generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE), counter=17) + checkpoint_path = generator._save_checkpoint(tmp_path) + make_legacy_checkpoint(checkpoint_path) + + # VOCS is recovered from "vocs.txt" since the checkpoint does not carry it + reloaded = CheckpointingRandomGenerator(checkpoint_file=checkpoint_path) + assert reloaded.counter == 17 + assert reloaded.vocs == generator.vocs + + +def test_load_legacy_checkpoint_missing_vocs_file(tmp_path): generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) checkpoint_path = generator._save_checkpoint(tmp_path) + make_legacy_checkpoint(checkpoint_path) os.remove(tmp_path / "vocs.txt") - with pytest.raises(ValueError, match="Could not load VOCS file"): + with pytest.raises(ValueError, match="does not contain a VOCS object"): CheckpointingRandomGenerator(checkpoint_file=checkpoint_path) + + +def test_embedded_vocs_preferred_over_legacy_file(tmp_path): + generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) + checkpoint_path = generator._save_checkpoint(tmp_path) + + # A stale legacy file beside a modern checkpoint must be ignored + stale_vocs = deepcopy(TEST_VOCS_BASE) + stale_vocs.variables.pop(next(iter(stale_vocs.variables))) + with open(tmp_path / "vocs.txt", "w") as f: + json.dump(stale_vocs.model_dump(), f) + + reloaded = CheckpointingRandomGenerator(checkpoint_file=checkpoint_path) + assert reloaded.vocs == generator.vocs From 26b32a154cee428beb624aa421320ce1de3f5004 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 6 Aug 2026 19:38:22 -0700 Subject: [PATCH 05/13] have save_checkpoints point to actual checkpoints directory --- xopt/generators/checkpoints.py | 21 +++++++++------------ xopt/generators/ga/nsga2.py | 4 +++- xopt/tests/generators/test_checkpoints.py | 22 +++++++++++----------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/xopt/generators/checkpoints.py b/xopt/generators/checkpoints.py index 1d4d519c..c189e836 100644 --- a/xopt/generators/checkpoints.py +++ b/xopt/generators/checkpoints.py @@ -10,10 +10,10 @@ class CheckpointMixin(BaseModel): """ Mix-in class adding checkpoint saving and loading to a generator. - Checkpoints are written to a "checkpoints" subdirectory of a caller-supplied - directory. The VOCS object is serialized into the checkpoint itself. Legacy - checkpoints which predate this instead carry the VOCS object in a "vocs.txt" - file beside the checkpoint directory and are still supported when loading. + Checkpoints are written to a caller-supplied directory. The VOCS object is + serialized into the checkpoint itself. Legacy checkpoints which predate this + instead carry the VOCS object in a "vocs.txt" file one level above the + directory holding the checkpoints and are still supported when loading. The host class must provide a ``vocs`` attribute and a ``to_json`` method. Writing of the checkpoint file is the responsibility of the concrete class @@ -93,8 +93,8 @@ def _save_checkpoint(self, path: str | os.PathLike) -> str: Parameters ---------- path : str or os.PathLike - Directory into which the "checkpoints" subdirectory containing the - checkpoint file is written. + Directory into which the checkpoint file is written. Created if it + does not already exist. Returns ------- @@ -102,20 +102,17 @@ def _save_checkpoint(self, path: str | os.PathLike) -> str: Path to the checkpoint file which was written. """ # Set up the output directory - checkpoint_dir = os.path.join(path, "checkpoints") - os.makedirs(checkpoint_dir, exist_ok=True) + os.makedirs(path, exist_ok=True) # Create a base filename base_checkpoint_filename = datetime.now().strftime("%Y%m%d_%H%M%S") - checkpoint_path = os.path.join( - checkpoint_dir, f"{base_checkpoint_filename}_1.txt" - ) + checkpoint_path = os.path.join(path, f"{base_checkpoint_filename}_1.txt") # Check if file exists and increment counter until we find a free filename counter = 2 while os.path.exists(checkpoint_path): checkpoint_path = os.path.join( - checkpoint_dir, f"{base_checkpoint_filename}_{counter}.txt" + path, f"{base_checkpoint_filename}_{counter}.txt" ) counter += 1 diff --git a/xopt/generators/ga/nsga2.py b/xopt/generators/ga/nsga2.py index 402fd46a..e38f3b60 100644 --- a/xopt/generators/ga/nsga2.py +++ b/xopt/generators/ga/nsga2.py @@ -622,7 +622,9 @@ def add_data(self, new_data: pd.DataFrame): if self.checkpoint_freq > 0 and ( self.n_generations % self.checkpoint_freq == 0 ): - checkpoint_path = self._save_checkpoint(self.output_dir) + checkpoint_path = self._save_checkpoint( + os.path.join(self.output_dir, "checkpoints") + ) self._logger.debug(f'saved checkpoint file "{checkpoint_path}"') def set_data(self, data): diff --git a/xopt/tests/generators/test_checkpoints.py b/xopt/tests/generators/test_checkpoints.py index 0b2a3091..38034320 100644 --- a/xopt/tests/generators/test_checkpoints.py +++ b/xopt/tests/generators/test_checkpoints.py @@ -38,12 +38,12 @@ def make_legacy_checkpoint(checkpoint_path: str) -> None: def test_save_checkpoint_layout(tmp_path): generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) - checkpoint_path = generator._save_checkpoint(tmp_path) + checkpoint_path = generator._save_checkpoint(tmp_path / "checkpoints") - # Only the "checkpoints" subdirectory is created, no separate VOCS file - assert os.listdir(tmp_path) == ["checkpoints"] + # The checkpoint is written directly into the supplied directory assert os.path.dirname(checkpoint_path) == str(tmp_path / "checkpoints") assert os.listdir(tmp_path / "checkpoints") == [os.path.basename(checkpoint_path)] + assert os.listdir(tmp_path) == ["checkpoints"] # Filename follows the timestamp plus deduplication index scheme _, index = parse_checkpoint_filename(os.path.basename(checkpoint_path)) @@ -58,9 +58,9 @@ def test_save_checkpoint_layout(tmp_path): def test_checkpoint_round_trip(tmp_path): generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE), counter=17) - checkpoint_path = generator._save_checkpoint(tmp_path) + checkpoint_path = generator._save_checkpoint(tmp_path / "checkpoints") - # VOCS comes from the checkpoint output directory, not from the user + # VOCS comes from the checkpoint itself, not from the user reloaded = CheckpointingRandomGenerator(checkpoint_file=checkpoint_path) assert reloaded.counter == 17 assert reloaded.vocs == generator.vocs @@ -72,7 +72,7 @@ def test_checkpoint_round_trip(tmp_path): def test_checkpoint_user_values_take_precedence(tmp_path): generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE), counter=17) - checkpoint_path = generator._save_checkpoint(tmp_path) + checkpoint_path = generator._save_checkpoint(tmp_path / "checkpoints") reloaded = CheckpointingRandomGenerator(checkpoint_file=checkpoint_path, counter=99) assert reloaded.counter == 99 @@ -80,8 +80,8 @@ def test_checkpoint_user_values_take_precedence(tmp_path): def test_save_checkpoint_avoids_overwriting(tmp_path): generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) - first = generator._save_checkpoint(tmp_path) - second = generator._save_checkpoint(tmp_path) + first = generator._save_checkpoint(tmp_path / "checkpoints") + second = generator._save_checkpoint(tmp_path / "checkpoints") assert first != second assert len(os.listdir(tmp_path / "checkpoints")) == 2 @@ -89,7 +89,7 @@ def test_save_checkpoint_avoids_overwriting(tmp_path): def test_load_legacy_checkpoint(tmp_path): generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE), counter=17) - checkpoint_path = generator._save_checkpoint(tmp_path) + checkpoint_path = generator._save_checkpoint(tmp_path / "checkpoints") make_legacy_checkpoint(checkpoint_path) # VOCS is recovered from "vocs.txt" since the checkpoint does not carry it @@ -100,7 +100,7 @@ def test_load_legacy_checkpoint(tmp_path): def test_load_legacy_checkpoint_missing_vocs_file(tmp_path): generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) - checkpoint_path = generator._save_checkpoint(tmp_path) + checkpoint_path = generator._save_checkpoint(tmp_path / "checkpoints") make_legacy_checkpoint(checkpoint_path) os.remove(tmp_path / "vocs.txt") @@ -110,7 +110,7 @@ def test_load_legacy_checkpoint_missing_vocs_file(tmp_path): def test_embedded_vocs_preferred_over_legacy_file(tmp_path): generator = CheckpointingRandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) - checkpoint_path = generator._save_checkpoint(tmp_path) + checkpoint_path = generator._save_checkpoint(tmp_path / "checkpoints") # A stale legacy file beside a modern checkpoint must be ignored stale_vocs = deepcopy(TEST_VOCS_BASE) From 7d88308fa07d9459c0c1a6365c92efd4988a76d6 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 6 Aug 2026 20:15:13 -0700 Subject: [PATCH 06/13] setup common output class --- xopt/generators/ga/outputs.py | 99 +++++++++++++++ xopt/tests/generators/ga/test_outputs.py | 147 +++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 xopt/generators/ga/outputs.py create mode 100644 xopt/tests/generators/ga/test_outputs.py diff --git a/xopt/generators/ga/outputs.py b/xopt/generators/ga/outputs.py new file mode 100644 index 00000000..f2ae5a21 --- /dev/null +++ b/xopt/generators/ga/outputs.py @@ -0,0 +1,99 @@ +import os +import pandas as pd + +from ...vocs import VOCS + +POPULATION_METADATA_COLUMNS = [ + "xopt_generation", + "xopt_candidate_idx", + "xopt_runtime", + "xopt_error", +] + + +class GAOutputs: + """ + File output for genetic algorithm generators. + + Owns the layout of the output directory and writes the evaluated data and each + completed population to disk. The directory is resolved and created when this + object is constructed. + """ + + def __init__(self, output_dir: str): + """ + Parameters + ---------- + output_dir : str + Requested directory for output. If it already exists and is not empty, a + numeric suffix is appended to avoid overwriting previous data. The path + actually used is available as the `output_dir` attribute. + """ + self.requested_output_dir = output_dir + + # Check if directory exists and do collision avoidance + counter = 2 + self.output_dir = output_dir + while os.path.exists(self.output_dir) and os.listdir(self.output_dir): + self.output_dir = f"{output_dir}_{counter}" + counter += 1 + + os.makedirs(self.output_dir, exist_ok=True) + + @property + def data_path(self) -> str: + """Path of the file holding every evaluated individual.""" + return os.path.join(self.output_dir, "data.csv") + + @property + def population_path(self) -> str: + """Path of the file holding each completed population.""" + return os.path.join(self.output_dir, "populations.csv") + + @property + def checkpoint_dir(self) -> str: + """Directory into which checkpoint files are written.""" + return os.path.join(self.output_dir, "checkpoints") + + @property + def log_path(self) -> str: + """Path of the log file.""" + return os.path.join(self.output_dir, "log.txt") + + def register_generation( + self, + generation_index: int, + population: list[dict], + data: pd.DataFrame, + vocs: VOCS, + ) -> None: + """ + Write a completed generation to disk. + + Parameters + ---------- + generation_index : int + Index recorded in the "xopt_generation" column of the population file. + population : list of dict + The individuals making up the completed population. + data : pd.DataFrame + All data evaluated so far. Overwrites the data file. + vocs : VOCS + Used to normalize the columns of the population file. + """ + # Save all Xopt data + data.to_csv(self.data_path, index=False) + + # Construct the DataFrame for this population + pop_df = pd.DataFrame(population) + pop_df["xopt_generation"] = generation_index + + # Normalize the columns in the DataFrame + # Avoid schema changing part way through optimization so we can write CSV in append mode + pop_df = pop_df.reindex(columns=vocs.all_names + POPULATION_METADATA_COLUMNS) + + # Write population DataFrame to file + csv_path = self.population_path + pop_df.to_csv( + csv_path, index=False, mode="a", header=not os.path.isfile(csv_path) + ) diff --git a/xopt/tests/generators/ga/test_outputs.py b/xopt/tests/generators/ga/test_outputs.py new file mode 100644 index 00000000..b48eef27 --- /dev/null +++ b/xopt/tests/generators/ga/test_outputs.py @@ -0,0 +1,147 @@ +import os + +import pandas as pd + +from xopt.generators.ga.outputs import GAOutputs +from xopt.resources.test_functions.tnk import tnk_vocs + + +def make_population( + size: int, generation: int, extra: dict | None = None +) -> list[dict]: + """Build a population of individuals carrying all VOCS and metadata columns.""" + population = [] + for idx in range(size): + individual = { + "x1": 0.1 * idx, + "x2": 0.2 * idx, + "y1": 1.0 * idx, + "y2": 2.0 * idx, + "c1": -1.0, + "c2": -1.0, + "xopt_candidate_idx": generation * size + idx, + "xopt_runtime": 0.1, + "xopt_error": False, + } + if extra is not None: + individual.update(extra) + population.append(individual) + return population + + +def make_data(n_rows: int) -> pd.DataFrame: + return pd.DataFrame( + { + "x1": [0.1 * i for i in range(n_rows)], + "x2": [0.2 * i for i in range(n_rows)], + "xopt_candidate_idx": list(range(n_rows)), + "xopt_parent_generation": [0] * n_rows, + } + ) + + +def test_creates_missing_directory(tmp_path): + requested = str(tmp_path / "run") + outputs = GAOutputs(requested) + + assert outputs.output_dir == requested + assert outputs.requested_output_dir == requested + assert os.path.isdir(requested) + + +def test_existing_empty_directory_is_not_renamed(tmp_path): + # A directory which exists but holds nothing is reused as-is. Tests which hand + # the generator a TemporaryDirectory depend on this. + requested = str(tmp_path / "run") + os.makedirs(requested) + + outputs = GAOutputs(requested) + assert outputs.output_dir == requested + + +def test_non_empty_directory_is_renamed(tmp_path): + requested = str(tmp_path / "run") + os.makedirs(requested) + (tmp_path / "run" / "data.csv").write_text("existing\n") + + first = GAOutputs(requested) + assert first.output_dir == f"{requested}_2" + assert first.requested_output_dir == requested + assert os.path.isdir(f"{requested}_2") + + # The original directory is left untouched + assert (tmp_path / "run" / "data.csv").read_text() == "existing\n" + + # A second collision steps to the next suffix + (tmp_path / "run_2" / "data.csv").write_text("existing\n") + second = GAOutputs(requested) + assert second.output_dir == f"{requested}_3" + + +def test_paths_resolve_under_resolved_directory(tmp_path): + requested = str(tmp_path / "run") + os.makedirs(requested) + (tmp_path / "run" / "data.csv").write_text("existing\n") + + outputs = GAOutputs(requested) + resolved = f"{requested}_2" + assert outputs.data_path == os.path.join(resolved, "data.csv") + assert outputs.population_path == os.path.join(resolved, "populations.csv") + assert outputs.checkpoint_dir == os.path.join(resolved, "checkpoints") + assert outputs.log_path == os.path.join(resolved, "log.txt") + + +def test_register_generation_writes_both_files(tmp_path): + outputs = GAOutputs(str(tmp_path / "run")) + outputs.register_generation(1, make_population(4, 0), make_data(8), tnk_vocs) + + data_df = pd.read_csv(outputs.data_path) + assert len(data_df) == 8 + + pop_df = pd.read_csv(outputs.population_path) + assert len(pop_df) == 4 + assert (pop_df["xopt_generation"] == 1).all() + assert list(pop_df.columns) == tnk_vocs.all_names + [ + "xopt_generation", + "xopt_candidate_idx", + "xopt_runtime", + "xopt_error", + ] + + +def test_data_overwritten_while_populations_accumulate(tmp_path): + outputs = GAOutputs(str(tmp_path / "run")) + outputs.register_generation(1, make_population(4, 0), make_data(4), tnk_vocs) + outputs.register_generation(2, make_population(4, 1), make_data(8), tnk_vocs) + + # data.csv is a full overwrite, so it reflects only the latest call + assert len(pd.read_csv(outputs.data_path)) == 8 + + # populations.csv is appended and carries exactly one header line + pop_df = pd.read_csv(outputs.population_path) + assert len(pop_df) == 8 + assert sorted(pop_df["xopt_generation"].unique()) == [1, 2] + with open(outputs.population_path) as f: + header_count = sum(1 for line in f if line.startswith("x1,")) + assert header_count == 1 + + +def test_register_generation_normalizes_changing_schema(tmp_path): + outputs = GAOutputs(str(tmp_path / "run")) + outputs.register_generation(1, make_population(4, 0), make_data(4), tnk_vocs) + + # A later generation gaining an extra key must not shift the appended columns + outputs.register_generation( + 2, make_population(4, 1, extra={"obs1": 3.0}), make_data(8), tnk_vocs + ) + + sparse = make_population(4, 2) + for individual in sparse: + del individual["xopt_runtime"] + outputs.register_generation(3, sparse, make_data(12), tnk_vocs) + + pop_df = pd.read_csv(outputs.population_path) + assert len(pop_df) == 12 + assert "obs1" not in pop_df.columns + assert pop_df[pop_df["xopt_generation"] == 3]["xopt_runtime"].isna().all() + assert pop_df[pop_df["xopt_generation"] == 2]["xopt_runtime"].notna().all() From d7920e7f0ab53e72772b8c02bb1795c39cbedda1 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 6 Aug 2026 20:19:46 -0700 Subject: [PATCH 07/13] refactor nsga2generator to use new output class --- xopt/generators/ga/nsga2.py | 63 +++++++++++-------------------------- 1 file changed, 18 insertions(+), 45 deletions(-) diff --git a/xopt/generators/ga/nsga2.py b/xopt/generators/ga/nsga2.py index e38f3b60..bba4f03f 100644 --- a/xopt/generators/ga/nsga2.py +++ b/xopt/generators/ga/nsga2.py @@ -3,7 +3,6 @@ from typing import Annotated import logging import numpy as np -import os import pandas as pd import time import warnings @@ -15,6 +14,7 @@ from ..checkpoints import CheckpointMixin from ..deduplicated import DeduplicatedGeneratorBase from ..utils import fast_dominated_argsort +from .outputs import GAOutputs from .operators import ( PolynomialMutation, DummyMutation, @@ -395,8 +395,8 @@ class NSGA2Generator(CheckpointMixin, DeduplicatedGeneratorBase, StateOwner): log_level: int = Field( logging.INFO, description="Log message level output to log.txt" ) - _output_dir_setup: bool = ( - False # Used in initializing the directory. PLEASE DO NOT CHANGE + _outputs: GAOutputs | None = ( + None # Set once the output directory is resolved. PLEASE DO NOT CHANGE ) _logger: logging.Logger | None = None @@ -590,27 +590,9 @@ def add_data(self, new_data: pd.DataFrame): if self.output_dir is not None: save_start_t = time.perf_counter() - # Save all Xopt data - self.data.to_csv(os.path.join(self.output_dir, "data.csv"), index=False) - - # Construct the DataFrame for this population - pop_df = pd.DataFrame(self.pop) - pop_df["xopt_generation"] = self.n_generations - - # Normalize the columns in the DataFrame - # Avoid schema changing part way through optimization so we can write CSV in append mode - columns = self.vocs.all_names + [ - "xopt_generation", - "xopt_candidate_idx", - "xopt_runtime", - "xopt_error", - ] - pop_df = pop_df.reindex(columns=columns) - - # Write population DataFrame to file - csv_path = os.path.join(self.output_dir, "populations.csv") - pop_df.to_csv( - csv_path, index=False, mode="a", header=not os.path.isfile(csv_path) + # Write the evaluated data and this population to disk + self._outputs.register_generation( + self.n_generations, self.pop, self.data, self.vocs ) # Log some things @@ -623,7 +605,7 @@ def add_data(self, new_data: pd.DataFrame): self.n_generations % self.checkpoint_freq == 0 ): checkpoint_path = self._save_checkpoint( - os.path.join(self.output_dir, "checkpoints") + self._outputs.checkpoint_dir ) self._logger.debug(f'saved checkpoint file "{checkpoint_path}"') @@ -643,29 +625,20 @@ def __str__(self) -> str: return self.__repr__() def ensure_output_dir_setup(self): - if (self.output_dir is None) or self._output_dir_setup: + if (self.output_dir is None) or (self._outputs is not None): return - # Check if directory exists and do collision avoidance - counter = 2 - output_dir_dedup = self.output_dir - while os.path.exists(output_dir_dedup) and os.listdir(output_dir_dedup): - output_dir_dedup = f"{self.output_dir}_{counter}" - counter += 1 - self._logger.info( - f'detected existing output_dir "{self.output_dir}" and corrected ' - f'to "{output_dir_dedup}" to avoid overwriting' - ) - self.output_dir = output_dir_dedup - - # We are now setup - self._output_dir_setup = True - - # Setup the directory - os.makedirs(self.output_dir, exist_ok=True) + # Resolve and create the output directory + self._outputs = GAOutputs(self.output_dir) + if self._outputs.output_dir != self.output_dir: + self._logger.info( + f'detected existing output_dir "{self.output_dir}" and corrected ' + f'to "{self._outputs.output_dir}" to avoid overwriting' + ) + self.output_dir = self._outputs.output_dir # Set up file logging - log_file_path = os.path.join(self.output_dir, "log.txt") + log_file_path = self._outputs.log_path file_handler = logging.FileHandler(log_file_path, mode="w") file_handler.setLevel(self.log_level) @@ -683,7 +656,7 @@ def close_log_file(self): """ Closes out the log file (if used) """ - if self.output_dir is not None and self._output_dir_setup: + if self._outputs is not None: # Remove all handlers from the logger for handler in list(self._logger.handlers): if isinstance(handler, logging.FileHandler): From 106004936f7f353222d693b4a30208de4972a8bb Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 6 Aug 2026 20:29:38 -0700 Subject: [PATCH 08/13] minor cleanup --- xopt/generators/ga/nsga2.py | 24 +++++++++++++++--------- xopt/tests/generators/ga/test_nsga2.py | 4 ++-- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/xopt/generators/ga/nsga2.py b/xopt/generators/ga/nsga2.py index bba4f03f..f242f530 100644 --- a/xopt/generators/ga/nsga2.py +++ b/xopt/generators/ga/nsga2.py @@ -484,7 +484,7 @@ def data_in_bounds(self, data: dict) -> bool: ) def _generate(self, n_candidates: int) -> list[dict]: - self.ensure_output_dir_setup() + self.get_output() start_t = time.perf_counter() # If we have a population create children, otherwise generate randomly sampled points @@ -525,7 +525,7 @@ def _generate(self, n_candidates: int) -> list[dict]: return candidates def add_data(self, new_data: pd.DataFrame): - self.ensure_output_dir_setup() + output = self.get_output() # Validate data is at least compatible with selection / genetic operators vocs_names = ( @@ -587,11 +587,11 @@ def add_data(self, new_data: pd.DataFrame): self.n_generations += 1 # Save the history file - if self.output_dir is not None: + if output is not None: save_start_t = time.perf_counter() # Write the evaluated data and this population to disk - self._outputs.register_generation( + output.register_generation( self.n_generations, self.pop, self.data, self.vocs ) @@ -604,9 +604,7 @@ def add_data(self, new_data: pd.DataFrame): if self.checkpoint_freq > 0 and ( self.n_generations % self.checkpoint_freq == 0 ): - checkpoint_path = self._save_checkpoint( - self._outputs.checkpoint_dir - ) + checkpoint_path = self._save_checkpoint(output.checkpoint_dir) self._logger.debug(f'saved checkpoint file "{checkpoint_path}"') def set_data(self, data): @@ -624,9 +622,15 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.__repr__() - def ensure_output_dir_setup(self): + def get_output(self) -> GAOutputs | None: + """ + Returns the object handling file output, or None if no output directory was set. + + The output directory is resolved and created on the first call. Note that this + means no files are touched until the generator is actually used. + """ if (self.output_dir is None) or (self._outputs is not None): - return + return self._outputs # Resolve and create the output directory self._outputs = GAOutputs(self.output_dir) @@ -652,6 +656,8 @@ def ensure_output_dir_setup(self): self._logger.addHandler(file_handler) self._logger.info(f"routing log output to file: {log_file_path}") + return self._outputs + def close_log_file(self): """ Closes out the log file (if used) diff --git a/xopt/tests/generators/ga/test_nsga2.py b/xopt/tests/generators/ga/test_nsga2.py index 850b819d..6a7b3e1b 100644 --- a/xopt/tests/generators/ga/test_nsga2.py +++ b/xopt/tests/generators/ga/test_nsga2.py @@ -218,7 +218,7 @@ def nsga2_optimization_with_checkpoint(): ) # Hack to avoid log error on windows: "The process cannot access the file because it is being used by another process" - generator.ensure_output_dir_setup() + generator.get_output() generator.close_log_file() # Run a few optimization steps @@ -888,7 +888,7 @@ def test_nsga2_output_inhomogenous_data(): ) # Hack to avoid log error on windows: "The process cannot access the file because it is being used by another process" - generator.ensure_output_dir_setup() + generator.get_output() generator.close_log_file() # Run a few optimization steps From f7650a06b7f9188ba503b09cbd175c41860f63fb Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Sat, 8 Aug 2026 01:14:10 -0700 Subject: [PATCH 09/13] working on base class --- xopt/generators/__init__.py | 5 + xopt/generators/ga/base.py | 127 +++++++++++++++++++++++ xopt/generators/ga/nsga2.py | 97 +---------------- xopt/generators/ga/outputs.py | 108 ++++++++++++++++--- xopt/tests/generators/ga/test_outputs.py | 116 +++++++++++++++++++-- 5 files changed, 333 insertions(+), 120 deletions(-) create mode 100644 xopt/generators/ga/base.py diff --git a/xopt/generators/__init__.py b/xopt/generators/__init__.py index 776ed8d3..651803e9 100644 --- a/xopt/generators/__init__.py +++ b/xopt/generators/__init__.py @@ -171,6 +171,11 @@ def get_generator_defaults( # handles everything else defaults[k] = v.default + # Computed fields are settable options too (eg. NSGA2Generator's output_dir, which + # is owned by a separate object) but carry no declared default + for k in generator_class.model_computed_fields: + defaults.setdefault(k, None) + return defaults diff --git a/xopt/generators/ga/base.py b/xopt/generators/ga/base.py new file mode 100644 index 00000000..dd6f7e8c --- /dev/null +++ b/xopt/generators/ga/base.py @@ -0,0 +1,127 @@ +from pydantic import Field, PrivateAttr, computed_field, model_validator +from typing import Any +import logging +import time + +from ..checkpoints import CheckpointMixin +from ..deduplicated import DeduplicatedGeneratorBase +from .outputs import GAOutputs + + +class GAGeneratorBase(CheckpointMixin, DeduplicatedGeneratorBase): + """ + Base class for genetic algorithm generators which write output and checkpoints. + + Handles the output directory, log file, and periodic checkpointing on behalf of + subclasses. Subclasses call `end_generation` once each time a generation is + completed and everything else is taken care of. + + Parameters + ---------- + output_dir : str, optional + Directory to save algorithm state and population history, or None to write + nothing. If the directory already contains data, a number is appended to + avoid overwriting it. + checkpoint_freq : int, default=1 + Frequency (in generations) at which checkpoints are saved. Set to -1 to + disable checkpointing. + log_level : int + Level of log messages written to "log.txt". + """ + + checkpoint_freq: int = Field( + 1, + description="How often (in generations) to save checkpoints (set to -1 to disable)", + ) + log_level: int = Field( + logging.INFO, description="Log message level output to log.txt" + ) + _outputs: GAOutputs | None = PrivateAttr(default=None) + + @model_validator(mode="wrap") + @classmethod + def _build_outputs(cls, data: Any, handler): + """ + Hand ownership of "output_dir" to the GAOutputs object. + + The checkpoint is merged here rather than being left to the inherited + "before" validator, which pydantic would run inside this one. Doing it first + means "output_dir" is taken from the fully merged data, and consuming + "checkpoint_file" leaves the inherited validator with nothing to do. + """ + output_dir = None + if isinstance(data, dict): + data = cls.load_from_checkpoint(dict(data)) + output_dir = data.pop("output_dir", None) + + instance = handler(data) + + # Assigning to any field re-runs this validator, which must not discard the + # object already holding the output directory and log file + if instance._outputs is None: + instance._outputs = GAOutputs( + output_dir, + f"{type(instance).__module__}.{type(instance).__name__}", + instance.log_level, + ) + instance._logger = instance._outputs.logger + return instance + + @computed_field + @property + def output_dir(self) -> str | None: + """Directory output is written to, or None if output is disabled.""" + return self._outputs.output_dir + + @output_dir.setter + def output_dir(self, value: str | None) -> None: + self._outputs.output_dir = value + + def get_output(self) -> GAOutputs: + """ + Returns the object handling file output and logging. + + The output directory is created on the first call, so nothing is written to + disk until the generator is actually used. + """ + requested = self._outputs.prepare() + if requested is not None: + self._logger.info( + f'detected existing output_dir "{requested}" and corrected ' + f'to "{self._outputs.output_dir}" to avoid overwriting' + ) + return self._outputs + + def end_generation(self, generation_index: int, population: list[dict]) -> None: + """ + Record a completed generation, writing output and checkpoints as configured. + + Parameters + ---------- + generation_index : int + Index of the generation which was just completed. + population : list of dict + The individuals making up the completed population. + """ + output = self.get_output() + if not output.enabled: + return + + # Write the evaluated data and this population to disk + save_start_t = time.perf_counter() + output.register_generation(generation_index, population, self.data, self.vocs) + self._logger.debug( + f'saved optimization data to "{output.output_dir}" ' + f"in {1000 * (time.perf_counter() - save_start_t):.2f}ms" + ) + + # Save a checkpoint if one is due + if self.checkpoint_freq > 0 and (generation_index % self.checkpoint_freq == 0): + checkpoint_path = self._save_checkpoint(output.checkpoint_dir) + self._logger.debug(f'saved checkpoint file "{checkpoint_path}"') + + def close_log_file(self): + """ + Closes out the log file (if used) + """ + self._outputs.close_log() diff --git a/xopt/generators/ga/nsga2.py b/xopt/generators/ga/nsga2.py index f242f530..4edf4c34 100644 --- a/xopt/generators/ga/nsga2.py +++ b/xopt/generators/ga/nsga2.py @@ -1,7 +1,6 @@ from itertools import chain from pydantic import Field, Discriminator, model_validator from typing import Annotated -import logging import numpy as np import pandas as pd import time @@ -11,10 +10,8 @@ from ...errors import DataError from ...generator import StateOwner from ...vocs import VOCS -from ..checkpoints import CheckpointMixin -from ..deduplicated import DeduplicatedGeneratorBase from ..utils import fast_dominated_argsort -from .outputs import GAOutputs +from .base import GAGeneratorBase from .operators import ( PolynomialMutation, DummyMutation, @@ -311,7 +308,7 @@ def generate_candidates_from_population( ######################################################################################################################## -class NSGA2Generator(CheckpointMixin, DeduplicatedGeneratorBase, StateOwner): +class NSGA2Generator(GAGeneratorBase, StateOwner): """ Non-dominated Sorting Genetic Algorithm II (NSGA-II) generator. Implements the NSGA-II algorithm for multi-objective optimization as described in [1]. This generator accomdates user selected mutation @@ -386,20 +383,6 @@ class NSGA2Generator(CheckpointMixin, DeduplicatedGeneratorBase, StateOwner): Discriminator("name"), ] = PolynomialMutation() - # Output options - output_dir: str | None = None - checkpoint_freq: int = Field( - 1, - description="How often (in generations) to save checkpoints (set to -1 to disable)", - ) - log_level: int = Field( - logging.INFO, description="Log message level output to log.txt" - ) - _outputs: GAOutputs | None = ( - None # Set once the output directory is resolved. PLEASE DO NOT CHANGE - ) - _logger: logging.Logger | None = None - # Metadata fevals: int = Field( 0, @@ -425,11 +408,6 @@ class NSGA2Generator(CheckpointMixin, DeduplicatedGeneratorBase, StateOwner): pop: list[dict] = Field(default=[]) child: list[dict] = Field(default=[]) - def model_post_init(self, context): - # Get a unique logger per object - self._logger = logging.getLogger(f"{__name__}.NSGA2Generator.{id(self)}") - self._logger.setLevel(self.log_level) - @model_validator(mode="after") def vocs_compatible(self): """ @@ -525,7 +503,7 @@ def _generate(self, n_candidates: int) -> list[dict]: return candidates def add_data(self, new_data: pd.DataFrame): - output = self.get_output() + self.get_output() # Validate data is at least compatible with selection / genetic operators vocs_names = ( @@ -586,26 +564,8 @@ def add_data(self, new_data: pd.DataFrame): self.child = self.child[self.population_size :] self.n_generations += 1 - # Save the history file - if output is not None: - save_start_t = time.perf_counter() - - # Write the evaluated data and this population to disk - output.register_generation( - self.n_generations, self.pop, self.data, self.vocs - ) - - # Log some things - self._logger.debug( - f'saved optimization data to "{self.output_dir}" ' - f"in {1000 * (time.perf_counter() - save_start_t):.2f}ms" - ) - - if self.checkpoint_freq > 0 and ( - self.n_generations % self.checkpoint_freq == 0 - ): - checkpoint_path = self._save_checkpoint(output.checkpoint_dir) - self._logger.debug(f'saved checkpoint file "{checkpoint_path}"') + # Write output files and save a checkpoint if one is due + self.end_generation(self.n_generations, self.pop) def set_data(self, data): self.data = data @@ -621,50 +581,3 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.__repr__() - - def get_output(self) -> GAOutputs | None: - """ - Returns the object handling file output, or None if no output directory was set. - - The output directory is resolved and created on the first call. Note that this - means no files are touched until the generator is actually used. - """ - if (self.output_dir is None) or (self._outputs is not None): - return self._outputs - - # Resolve and create the output directory - self._outputs = GAOutputs(self.output_dir) - if self._outputs.output_dir != self.output_dir: - self._logger.info( - f'detected existing output_dir "{self.output_dir}" and corrected ' - f'to "{self._outputs.output_dir}" to avoid overwriting' - ) - self.output_dir = self._outputs.output_dir - - # Set up file logging - log_file_path = self._outputs.log_path - file_handler = logging.FileHandler(log_file_path, mode="w") - file_handler.setLevel(self.log_level) - - # Use the same format as the default logger - formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - ) - file_handler.setFormatter(formatter) - - # Add the file handler to the logger - self._logger.addHandler(file_handler) - self._logger.info(f"routing log output to file: {log_file_path}") - - return self._outputs - - def close_log_file(self): - """ - Closes out the log file (if used) - """ - if self._outputs is not None: - # Remove all handlers from the logger - for handler in list(self._logger.handlers): - if isinstance(handler, logging.FileHandler): - handler.close() - self._logger.removeHandler(handler) diff --git a/xopt/generators/ga/outputs.py b/xopt/generators/ga/outputs.py index f2ae5a21..9c96e762 100644 --- a/xopt/generators/ga/outputs.py +++ b/xopt/generators/ga/outputs.py @@ -1,3 +1,4 @@ +import logging import os import pandas as pd @@ -13,32 +14,55 @@ class GAOutputs: """ - File output for genetic algorithm generators. + File output and logging for genetic algorithm generators. - Owns the layout of the output directory and writes the evaluated data and each - completed population to disk. The directory is resolved and created when this - object is constructed. + Owns the output directory and the logger used by its owner. Construction is + cheap: the directory is not resolved or created until `prepare` is called, so + merely building or deserializing a generator never touches the filesystem. """ - def __init__(self, output_dir: str): + def __init__( + self, + output_dir: str | None, + logger_name: str, + log_level: int = logging.INFO, + ): """ Parameters ---------- - output_dir : str - Requested directory for output. If it already exists and is not empty, a - numeric suffix is appended to avoid overwriting previous data. The path - actually used is available as the `output_dir` attribute. + output_dir : str, optional + Directory for output, or None to disable file output. If it already + exists and is not empty, a numeric suffix is appended on `prepare` to + avoid overwriting previous data. + logger_name : str + Name the logger is created beneath. Records propagate to this logger, + so it should name the owner's module for log configuration to work as + users expect. + log_level : int + Level applied to the logger and to the log file. """ - self.requested_output_dir = output_dir - - # Check if directory exists and do collision avoidance - counter = 2 self.output_dir = output_dir - while os.path.exists(self.output_dir) and os.listdir(self.output_dir): - self.output_dir = f"{output_dir}_{counter}" - counter += 1 + self.log_level = log_level + self._prepared = False - os.makedirs(self.output_dir, exist_ok=True) + self._logger = logging.getLogger(f"{logger_name}.{id(self)}") + self._logger.setLevel(log_level) + + def __setattr__(self, name, value): + # Pointing at a new directory requires setting that directory up again + if name == "output_dir" and getattr(self, "_prepared", False): + self._prepared = False + super().__setattr__(name, value) + + @property + def logger(self) -> logging.Logger: + """Logger for the owner to write to. Also writes to the log file once prepared.""" + return self._logger + + @property + def enabled(self) -> bool: + """Whether output is written to disk at all.""" + return self.output_dir is not None @property def data_path(self) -> str: @@ -60,6 +84,52 @@ def log_path(self) -> str: """Path of the log file.""" return os.path.join(self.output_dir, "log.txt") + def prepare(self) -> str | None: + """ + Resolve and create the output directory and begin logging to file. + + Repeated calls do nothing. If the requested directory already holds data, a + numeric suffix is appended and `output_dir` is updated to the path used. + + Returns + ------- + str or None + The directory which was requested, if it differed from the one used, and + None otherwise. Lets the caller report the correction. + """ + if self._prepared or not self.enabled: + return None + + # Check if directory exists and do collision avoidance + requested = self.output_dir + counter = 2 + while os.path.exists(self.output_dir) and os.listdir(self.output_dir): + self.output_dir = f"{requested}_{counter}" + counter += 1 + + os.makedirs(self.output_dir, exist_ok=True) + self._prepared = True + + # Route log output to a file inside the output directory + file_handler = logging.FileHandler(self.log_path, mode="w") + file_handler.setLevel(self.log_level) + file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + ) + self._logger.addHandler(file_handler) + self._logger.info(f"routing log output to file: {self.log_path}") + + return requested if self.output_dir != requested else None + + def close_log(self): + """ + Close out the log file, if one was opened. + """ + for handler in list(self._logger.handlers): + if isinstance(handler, logging.FileHandler): + handler.close() + self._logger.removeHandler(handler) + def register_generation( self, generation_index: int, @@ -81,6 +151,10 @@ def register_generation( vocs : VOCS Used to normalize the columns of the population file. """ + if not self.enabled: + return + self.prepare() + # Save all Xopt data data.to_csv(self.data_path, index=False) diff --git a/xopt/tests/generators/ga/test_outputs.py b/xopt/tests/generators/ga/test_outputs.py index b48eef27..116e147a 100644 --- a/xopt/tests/generators/ga/test_outputs.py +++ b/xopt/tests/generators/ga/test_outputs.py @@ -1,10 +1,14 @@ +import logging import os import pandas as pd +import pytest from xopt.generators.ga.outputs import GAOutputs from xopt.resources.test_functions.tnk import tnk_vocs +LOGGER_NAME = "xopt.tests.generators.ga.test_outputs" + def make_population( size: int, generation: int, extra: dict | None = None @@ -40,14 +44,29 @@ def make_data(n_rows: int) -> pd.DataFrame: ) -def test_creates_missing_directory(tmp_path): +def test_construction_touches_nothing(tmp_path): + # Building the object must not create anything. Generators are constructed and + # deserialized freely, and each round trip would otherwise leave a directory. + requested = str(tmp_path / "run") + outputs = GAOutputs(requested, LOGGER_NAME) + + assert outputs.output_dir == requested + assert outputs.enabled + assert os.listdir(tmp_path) == [] + + +def test_prepare_creates_directory_and_is_idempotent(tmp_path): requested = str(tmp_path / "run") - outputs = GAOutputs(requested) + outputs = GAOutputs(requested, LOGGER_NAME) + assert outputs.prepare() is None assert outputs.output_dir == requested - assert outputs.requested_output_dir == requested assert os.path.isdir(requested) + # A second call changes nothing + assert outputs.prepare() is None + assert outputs.output_dir == requested + def test_existing_empty_directory_is_not_renamed(tmp_path): # A directory which exists but holds nothing is reused as-is. Tests which hand @@ -55,7 +74,8 @@ def test_existing_empty_directory_is_not_renamed(tmp_path): requested = str(tmp_path / "run") os.makedirs(requested) - outputs = GAOutputs(requested) + outputs = GAOutputs(requested, LOGGER_NAME) + assert outputs.prepare() is None assert outputs.output_dir == requested @@ -64,9 +84,9 @@ def test_non_empty_directory_is_renamed(tmp_path): os.makedirs(requested) (tmp_path / "run" / "data.csv").write_text("existing\n") - first = GAOutputs(requested) + first = GAOutputs(requested, LOGGER_NAME) + assert first.prepare() == requested assert first.output_dir == f"{requested}_2" - assert first.requested_output_dir == requested assert os.path.isdir(f"{requested}_2") # The original directory is left untouched @@ -74,16 +94,38 @@ def test_non_empty_directory_is_renamed(tmp_path): # A second collision steps to the next suffix (tmp_path / "run_2" / "data.csv").write_text("existing\n") - second = GAOutputs(requested) + second = GAOutputs(requested, LOGGER_NAME) + second.prepare() assert second.output_dir == f"{requested}_3" +def test_assigning_output_dir_reprepares(tmp_path): + outputs = GAOutputs(str(tmp_path / "first"), LOGGER_NAME) + outputs.prepare() + + outputs.output_dir = str(tmp_path / "second") + assert outputs.prepare() is None + assert os.path.isdir(tmp_path / "second") + + +def test_disabled_outputs(tmp_path): + outputs = GAOutputs(None, LOGGER_NAME) + + assert not outputs.enabled + assert outputs.prepare() is None + + # Registering a generation is a no-op rather than an error + outputs.register_generation(1, make_population(4, 0), make_data(4), tnk_vocs) + assert os.listdir(tmp_path) == [] + + def test_paths_resolve_under_resolved_directory(tmp_path): requested = str(tmp_path / "run") os.makedirs(requested) (tmp_path / "run" / "data.csv").write_text("existing\n") - outputs = GAOutputs(requested) + outputs = GAOutputs(requested, LOGGER_NAME) + outputs.prepare() resolved = f"{requested}_2" assert outputs.data_path == os.path.join(resolved, "data.csv") assert outputs.population_path == os.path.join(resolved, "populations.csv") @@ -92,7 +134,7 @@ def test_paths_resolve_under_resolved_directory(tmp_path): def test_register_generation_writes_both_files(tmp_path): - outputs = GAOutputs(str(tmp_path / "run")) + outputs = GAOutputs(str(tmp_path / "run"), LOGGER_NAME) outputs.register_generation(1, make_population(4, 0), make_data(8), tnk_vocs) data_df = pd.read_csv(outputs.data_path) @@ -110,7 +152,7 @@ def test_register_generation_writes_both_files(tmp_path): def test_data_overwritten_while_populations_accumulate(tmp_path): - outputs = GAOutputs(str(tmp_path / "run")) + outputs = GAOutputs(str(tmp_path / "run"), LOGGER_NAME) outputs.register_generation(1, make_population(4, 0), make_data(4), tnk_vocs) outputs.register_generation(2, make_population(4, 1), make_data(8), tnk_vocs) @@ -127,7 +169,7 @@ def test_data_overwritten_while_populations_accumulate(tmp_path): def test_register_generation_normalizes_changing_schema(tmp_path): - outputs = GAOutputs(str(tmp_path / "run")) + outputs = GAOutputs(str(tmp_path / "run"), LOGGER_NAME) outputs.register_generation(1, make_population(4, 0), make_data(4), tnk_vocs) # A later generation gaining an extra key must not shift the appended columns @@ -145,3 +187,55 @@ def test_register_generation_normalizes_changing_schema(tmp_path): assert "obs1" not in pop_df.columns assert pop_df[pop_df["xopt_generation"] == 3]["xopt_runtime"].isna().all() assert pop_df[pop_df["xopt_generation"] == 2]["xopt_runtime"].notna().all() + + +class RecordingHandler(logging.Handler): + """Captures messages so propagation to the parent logger can be checked.""" + + def __init__(self): + super().__init__() + self.messages = [] + + def emit(self, record): + self.messages.append(record.getMessage()) + + +@pytest.fixture +def parent_logger(): + """The logger GAOutputs loggers are created beneath, with a capturing handler.""" + logger = logging.getLogger(LOGGER_NAME) + handler = RecordingHandler() + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + yield handler + logger.removeHandler(handler) + + +def test_logger_propagates_without_output(parent_logger): + outputs = GAOutputs(None, LOGGER_NAME, log_level=logging.DEBUG) + + outputs.logger.info("no output configured") + assert "no output configured" in parent_logger.messages + + +def test_logger_writes_to_file_and_propagates(tmp_path, parent_logger): + outputs = GAOutputs(str(tmp_path / "run"), LOGGER_NAME, log_level=logging.DEBUG) + outputs.prepare() + outputs.logger.info("after prepare") + outputs.close_log() + + # Reaches the parent logger + assert "after prepare" in parent_logger.messages + + # ... and the log file + with open(outputs.log_path) as f: + assert "after prepare" in f.read() + + +def test_close_log_removes_handlers(tmp_path): + outputs = GAOutputs(str(tmp_path / "run"), LOGGER_NAME) + outputs.prepare() + assert outputs.logger.handlers + + outputs.close_log() + assert not outputs.logger.handlers From 3082951badba1a92e8539c18c51199ffbe6f0365 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Sat, 8 Aug 2026 01:39:10 -0700 Subject: [PATCH 10/13] simplify; don't use separate output class, inherit instead --- xopt/generators/__init__.py | 5 - xopt/generators/ga/base.py | 141 +++++++------ xopt/generators/ga/nsga2.py | 4 +- xopt/generators/ga/outputs.py | 173 ---------------- xopt/tests/generators/ga/test_base.py | 245 +++++++++++++++++++++++ xopt/tests/generators/ga/test_nsga2.py | 4 +- xopt/tests/generators/ga/test_outputs.py | 241 ---------------------- 7 files changed, 333 insertions(+), 480 deletions(-) delete mode 100644 xopt/generators/ga/outputs.py create mode 100644 xopt/tests/generators/ga/test_base.py delete mode 100644 xopt/tests/generators/ga/test_outputs.py diff --git a/xopt/generators/__init__.py b/xopt/generators/__init__.py index 651803e9..776ed8d3 100644 --- a/xopt/generators/__init__.py +++ b/xopt/generators/__init__.py @@ -171,11 +171,6 @@ def get_generator_defaults( # handles everything else defaults[k] = v.default - # Computed fields are settable options too (eg. NSGA2Generator's output_dir, which - # is owned by a separate object) but carry no declared default - for k in generator_class.model_computed_fields: - defaults.setdefault(k, None) - return defaults diff --git a/xopt/generators/ga/base.py b/xopt/generators/ga/base.py index dd6f7e8c..d60dd179 100644 --- a/xopt/generators/ga/base.py +++ b/xopt/generators/ga/base.py @@ -1,11 +1,18 @@ -from pydantic import Field, PrivateAttr, computed_field, model_validator -from typing import Any +from pydantic import Field import logging +import os +import pandas as pd import time from ..checkpoints import CheckpointMixin from ..deduplicated import DeduplicatedGeneratorBase -from .outputs import GAOutputs + +POPULATION_METADATA_COLUMNS = [ + "xopt_generation", + "xopt_candidate_idx", + "xopt_runtime", + "xopt_error", +] class GAGeneratorBase(CheckpointMixin, DeduplicatedGeneratorBase): @@ -16,6 +23,9 @@ class GAGeneratorBase(CheckpointMixin, DeduplicatedGeneratorBase): subclasses. Subclasses call `end_generation` once each time a generation is completed and everything else is taken care of. + Nothing is written to disk until the generator is used, so building or + deserializing one never touches the filesystem. + Parameters ---------- output_dir : str, optional @@ -29,6 +39,7 @@ class GAGeneratorBase(CheckpointMixin, DeduplicatedGeneratorBase): Level of log messages written to "log.txt". """ + output_dir: str | None = None checkpoint_freq: int = Field( 1, description="How often (in generations) to save checkpoints (set to -1 to disable)", @@ -36,61 +47,56 @@ class GAGeneratorBase(CheckpointMixin, DeduplicatedGeneratorBase): log_level: int = Field( logging.INFO, description="Log message level output to log.txt" ) - _outputs: GAOutputs | None = PrivateAttr(default=None) + _output_prepared: bool = ( + False # Whether the output directory has been resolved and created + ) - @model_validator(mode="wrap") - @classmethod - def _build_outputs(cls, data: Any, handler): - """ - Hand ownership of "output_dir" to the GAOutputs object. + def model_post_init(self, context): + # Get a unique logger per object. Naming it after the concrete class keeps + # records propagating through that class's module logger. + self._logger = logging.getLogger( + f"{type(self).__module__}.{type(self).__name__}.{id(self)}" + ) + self._logger.setLevel(self.log_level) - The checkpoint is merged here rather than being left to the inherited - "before" validator, which pydantic would run inside this one. Doing it first - means "output_dir" is taken from the fully merged data, and consuming - "checkpoint_file" leaves the inherited validator with nothing to do. + def _prepare_output(self) -> None: """ - output_dir = None - if isinstance(data, dict): - data = cls.load_from_checkpoint(dict(data)) - output_dir = data.pop("output_dir", None) - - instance = handler(data) - - # Assigning to any field re-runs this validator, which must not discard the - # object already holding the output directory and log file - if instance._outputs is None: - instance._outputs = GAOutputs( - output_dir, - f"{type(instance).__module__}.{type(instance).__name__}", - instance.log_level, - ) - instance._logger = instance._outputs.logger - return instance - - @computed_field - @property - def output_dir(self) -> str | None: - """Directory output is written to, or None if output is disabled.""" - return self._outputs.output_dir - - @output_dir.setter - def output_dir(self, value: str | None) -> None: - self._outputs.output_dir = value + Resolve and create the output directory and begin logging to file. - def get_output(self) -> GAOutputs: + Repeated calls do nothing. If the requested directory already holds data, a + number is appended and `output_dir` is updated to the path actually used. """ - Returns the object handling file output and logging. + if (self.output_dir is None) or self._output_prepared: + return - The output directory is created on the first call, so nothing is written to - disk until the generator is actually used. - """ - requested = self._outputs.prepare() - if requested is not None: + # Check if directory exists and do collision avoidance. Resolve into a local + # so the field is only assigned once, since assignment revalidates the model. + requested = self.output_dir + counter = 2 + output_dir = requested + while os.path.exists(output_dir) and os.listdir(output_dir): + output_dir = f"{requested}_{counter}" + counter += 1 + if output_dir != requested: self._logger.info( f'detected existing output_dir "{requested}" and corrected ' - f'to "{self._outputs.output_dir}" to avoid overwriting' + f'to "{output_dir}" to avoid overwriting' ) - return self._outputs + self.output_dir = output_dir + + # We are now setup + os.makedirs(self.output_dir, exist_ok=True) + self._output_prepared = True + + # Set up file logging + log_file_path = os.path.join(self.output_dir, "log.txt") + file_handler = logging.FileHandler(log_file_path, mode="w") + file_handler.setLevel(self.log_level) + file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + ) + self._logger.addHandler(file_handler) + self._logger.info(f"routing log output to file: {log_file_path}") def end_generation(self, generation_index: int, population: list[dict]) -> None: """ @@ -103,25 +109,46 @@ def end_generation(self, generation_index: int, population: list[dict]) -> None: population : list of dict The individuals making up the completed population. """ - output = self.get_output() - if not output.enabled: + self._prepare_output() + if self.output_dir is None: return - - # Write the evaluated data and this population to disk save_start_t = time.perf_counter() - output.register_generation(generation_index, population, self.data, self.vocs) + + # Save all Xopt data + self.data.to_csv(os.path.join(self.output_dir, "data.csv"), index=False) + + # Construct the DataFrame for this population + pop_df = pd.DataFrame(population) + pop_df["xopt_generation"] = generation_index + + # Normalize the columns in the DataFrame + # Avoid schema changing part way through optimization so we can write CSV in append mode + pop_df = pop_df.reindex( + columns=self.vocs.all_names + POPULATION_METADATA_COLUMNS + ) + + # Write population DataFrame to file + csv_path = os.path.join(self.output_dir, "populations.csv") + pop_df.to_csv( + csv_path, index=False, mode="a", header=not os.path.isfile(csv_path) + ) self._logger.debug( - f'saved optimization data to "{output.output_dir}" ' + f'saved optimization data to "{self.output_dir}" ' f"in {1000 * (time.perf_counter() - save_start_t):.2f}ms" ) # Save a checkpoint if one is due if self.checkpoint_freq > 0 and (generation_index % self.checkpoint_freq == 0): - checkpoint_path = self._save_checkpoint(output.checkpoint_dir) + checkpoint_path = self._save_checkpoint( + os.path.join(self.output_dir, "checkpoints") + ) self._logger.debug(f'saved checkpoint file "{checkpoint_path}"') def close_log_file(self): """ Closes out the log file (if used) """ - self._outputs.close_log() + for handler in list(self._logger.handlers): + if isinstance(handler, logging.FileHandler): + handler.close() + self._logger.removeHandler(handler) diff --git a/xopt/generators/ga/nsga2.py b/xopt/generators/ga/nsga2.py index 4edf4c34..0566ca98 100644 --- a/xopt/generators/ga/nsga2.py +++ b/xopt/generators/ga/nsga2.py @@ -462,7 +462,7 @@ def data_in_bounds(self, data: dict) -> bool: ) def _generate(self, n_candidates: int) -> list[dict]: - self.get_output() + self._prepare_output() start_t = time.perf_counter() # If we have a population create children, otherwise generate randomly sampled points @@ -503,7 +503,7 @@ def _generate(self, n_candidates: int) -> list[dict]: return candidates def add_data(self, new_data: pd.DataFrame): - self.get_output() + self._prepare_output() # Validate data is at least compatible with selection / genetic operators vocs_names = ( diff --git a/xopt/generators/ga/outputs.py b/xopt/generators/ga/outputs.py deleted file mode 100644 index 9c96e762..00000000 --- a/xopt/generators/ga/outputs.py +++ /dev/null @@ -1,173 +0,0 @@ -import logging -import os -import pandas as pd - -from ...vocs import VOCS - -POPULATION_METADATA_COLUMNS = [ - "xopt_generation", - "xopt_candidate_idx", - "xopt_runtime", - "xopt_error", -] - - -class GAOutputs: - """ - File output and logging for genetic algorithm generators. - - Owns the output directory and the logger used by its owner. Construction is - cheap: the directory is not resolved or created until `prepare` is called, so - merely building or deserializing a generator never touches the filesystem. - """ - - def __init__( - self, - output_dir: str | None, - logger_name: str, - log_level: int = logging.INFO, - ): - """ - Parameters - ---------- - output_dir : str, optional - Directory for output, or None to disable file output. If it already - exists and is not empty, a numeric suffix is appended on `prepare` to - avoid overwriting previous data. - logger_name : str - Name the logger is created beneath. Records propagate to this logger, - so it should name the owner's module for log configuration to work as - users expect. - log_level : int - Level applied to the logger and to the log file. - """ - self.output_dir = output_dir - self.log_level = log_level - self._prepared = False - - self._logger = logging.getLogger(f"{logger_name}.{id(self)}") - self._logger.setLevel(log_level) - - def __setattr__(self, name, value): - # Pointing at a new directory requires setting that directory up again - if name == "output_dir" and getattr(self, "_prepared", False): - self._prepared = False - super().__setattr__(name, value) - - @property - def logger(self) -> logging.Logger: - """Logger for the owner to write to. Also writes to the log file once prepared.""" - return self._logger - - @property - def enabled(self) -> bool: - """Whether output is written to disk at all.""" - return self.output_dir is not None - - @property - def data_path(self) -> str: - """Path of the file holding every evaluated individual.""" - return os.path.join(self.output_dir, "data.csv") - - @property - def population_path(self) -> str: - """Path of the file holding each completed population.""" - return os.path.join(self.output_dir, "populations.csv") - - @property - def checkpoint_dir(self) -> str: - """Directory into which checkpoint files are written.""" - return os.path.join(self.output_dir, "checkpoints") - - @property - def log_path(self) -> str: - """Path of the log file.""" - return os.path.join(self.output_dir, "log.txt") - - def prepare(self) -> str | None: - """ - Resolve and create the output directory and begin logging to file. - - Repeated calls do nothing. If the requested directory already holds data, a - numeric suffix is appended and `output_dir` is updated to the path used. - - Returns - ------- - str or None - The directory which was requested, if it differed from the one used, and - None otherwise. Lets the caller report the correction. - """ - if self._prepared or not self.enabled: - return None - - # Check if directory exists and do collision avoidance - requested = self.output_dir - counter = 2 - while os.path.exists(self.output_dir) and os.listdir(self.output_dir): - self.output_dir = f"{requested}_{counter}" - counter += 1 - - os.makedirs(self.output_dir, exist_ok=True) - self._prepared = True - - # Route log output to a file inside the output directory - file_handler = logging.FileHandler(self.log_path, mode="w") - file_handler.setLevel(self.log_level) - file_handler.setFormatter( - logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") - ) - self._logger.addHandler(file_handler) - self._logger.info(f"routing log output to file: {self.log_path}") - - return requested if self.output_dir != requested else None - - def close_log(self): - """ - Close out the log file, if one was opened. - """ - for handler in list(self._logger.handlers): - if isinstance(handler, logging.FileHandler): - handler.close() - self._logger.removeHandler(handler) - - def register_generation( - self, - generation_index: int, - population: list[dict], - data: pd.DataFrame, - vocs: VOCS, - ) -> None: - """ - Write a completed generation to disk. - - Parameters - ---------- - generation_index : int - Index recorded in the "xopt_generation" column of the population file. - population : list of dict - The individuals making up the completed population. - data : pd.DataFrame - All data evaluated so far. Overwrites the data file. - vocs : VOCS - Used to normalize the columns of the population file. - """ - if not self.enabled: - return - self.prepare() - - # Save all Xopt data - data.to_csv(self.data_path, index=False) - - # Construct the DataFrame for this population - pop_df = pd.DataFrame(population) - pop_df["xopt_generation"] = generation_index - - # Normalize the columns in the DataFrame - # Avoid schema changing part way through optimization so we can write CSV in append mode - pop_df = pop_df.reindex(columns=vocs.all_names + POPULATION_METADATA_COLUMNS) - - # Write population DataFrame to file - csv_path = self.population_path - pop_df.to_csv( - csv_path, index=False, mode="a", header=not os.path.isfile(csv_path) - ) diff --git a/xopt/tests/generators/ga/test_base.py b/xopt/tests/generators/ga/test_base.py new file mode 100644 index 00000000..557c49de --- /dev/null +++ b/xopt/tests/generators/ga/test_base.py @@ -0,0 +1,245 @@ +import json +import logging +import os + +import pandas as pd +import pytest + +from xopt.generators.ga.base import GAGeneratorBase +from xopt.resources.test_functions.tnk import tnk_vocs + + +class OutputTestGenerator(GAGeneratorBase): + """Minimal concrete generator for exercising the base class output behavior.""" + + name = "ga_base_test" + supports_single_objective: bool = True + supports_multi_objective: bool = True + supports_constraints: bool = True + + def _generate(self, n_candidates: int) -> list[dict]: + return [] + + +class RecordingHandler(logging.Handler): + """Captures messages so propagation to the module logger can be checked.""" + + def __init__(self): + super().__init__() + self.messages = [] + + def emit(self, record): + self.messages.append(record.getMessage()) + + +@pytest.fixture +def module_logger(): + """Handler on the logger records propagate to, named after the concrete class.""" + logger = logging.getLogger(OutputTestGenerator.__module__) + handler = RecordingHandler() + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + yield handler + logger.removeHandler(handler) + + +def make_generator(output_dir, **kwargs): + return OutputTestGenerator( + vocs=tnk_vocs, + output_dir=None if output_dir is None else str(output_dir), + log_level=logging.DEBUG, + **kwargs, + ) + + +def make_population( + size: int, generation: int, extra: dict | None = None +) -> list[dict]: + """Build a population of individuals carrying all VOCS and metadata columns.""" + population = [] + for idx in range(size): + individual = { + "x1": 0.1 * idx, + "x2": 0.2 * idx, + "y1": 1.0 * idx, + "y2": 2.0 * idx, + "c1": -1.0, + "c2": -1.0, + "xopt_candidate_idx": generation * size + idx, + "xopt_runtime": 0.1, + "xopt_error": False, + } + if extra is not None: + individual.update(extra) + population.append(individual) + return population + + +def make_data(n_rows: int) -> pd.DataFrame: + return pd.DataFrame( + { + "x1": [0.1 * i for i in range(n_rows)], + "x2": [0.2 * i for i in range(n_rows)], + "xopt_candidate_idx": list(range(n_rows)), + "xopt_parent_generation": [0] * n_rows, + } + ) + + +def run_generation(generator, index, size=4, extra=None, n_data=None): + """Feed the generator a completed generation.""" + generator.data = make_data(n_data if n_data is not None else index * size) + generator.end_generation(index, make_population(size, index - 1, extra)) + + +def test_construction_touches_nothing(tmp_path): + # Generators are built and deserialized freely, so neither may create anything + requested = tmp_path / "run" + generator = make_generator(requested) + OutputTestGenerator.model_validate(json.loads(generator.to_json())) + + assert generator.output_dir == str(requested) + assert os.listdir(tmp_path) == [] + + +def test_prepare_output_creates_directory_and_is_idempotent(tmp_path): + requested = tmp_path / "run" + generator = make_generator(requested) + + generator._prepare_output() + assert generator.output_dir == str(requested) + assert os.path.isdir(requested) + + generator._prepare_output() + assert generator.output_dir == str(requested) + generator.close_log_file() + + +def test_existing_empty_directory_is_not_renamed(tmp_path, module_logger): + # A directory which exists but holds nothing is reused as-is. Tests which hand + # the generator a TemporaryDirectory depend on this. + requested = tmp_path / "run" + os.makedirs(requested) + + generator = make_generator(requested) + generator._prepare_output() + + assert generator.output_dir == str(requested) + assert not any("corrected" in m for m in module_logger.messages) + generator.close_log_file() + + +def test_non_empty_directory_is_renamed(tmp_path, module_logger): + requested = tmp_path / "run" + os.makedirs(requested) + (requested / "data.csv").write_text("existing\n") + + first = make_generator(requested) + first._prepare_output() + assert first.output_dir == f"{requested}_2" + assert os.path.isdir(f"{requested}_2") + assert any("corrected" in m for m in module_logger.messages) + + # The original directory is left untouched + assert (requested / "data.csv").read_text() == "existing\n" + first.close_log_file() + + # A second collision steps to the next suffix + (tmp_path / "run_2" / "data.csv").write_text("existing\n") + second = make_generator(requested) + second._prepare_output() + assert second.output_dir == f"{requested}_3" + second.close_log_file() + + +def test_end_generation_writes_both_files(tmp_path): + generator = make_generator(tmp_path / "run") + run_generation(generator, 1, n_data=8) + + assert len(pd.read_csv(os.path.join(generator.output_dir, "data.csv"))) == 8 + + pop_df = pd.read_csv(os.path.join(generator.output_dir, "populations.csv")) + assert len(pop_df) == 4 + assert (pop_df["xopt_generation"] == 1).all() + assert list(pop_df.columns) == tnk_vocs.all_names + [ + "xopt_generation", + "xopt_candidate_idx", + "xopt_runtime", + "xopt_error", + ] + generator.close_log_file() + + +def test_data_overwritten_while_populations_accumulate(tmp_path): + generator = make_generator(tmp_path / "run") + run_generation(generator, 1, n_data=4) + run_generation(generator, 2, n_data=8) + + # data.csv is a full overwrite, so it reflects only the latest generation + assert len(pd.read_csv(os.path.join(generator.output_dir, "data.csv"))) == 8 + + # populations.csv is appended and carries exactly one header line + population_path = os.path.join(generator.output_dir, "populations.csv") + pop_df = pd.read_csv(population_path) + assert len(pop_df) == 8 + assert sorted(pop_df["xopt_generation"].unique()) == [1, 2] + with open(population_path) as f: + assert sum(1 for line in f if line.startswith("x1,")) == 1 + generator.close_log_file() + + +def test_end_generation_normalizes_changing_schema(tmp_path): + generator = make_generator(tmp_path / "run") + run_generation(generator, 1) + + # A later generation gaining an extra key must not shift the appended columns + run_generation(generator, 2, extra={"obs1": 3.0}) + + # ... nor may one missing a metadata key + generator.data = make_data(12) + sparse = make_population(4, 2) + for individual in sparse: + del individual["xopt_runtime"] + generator.end_generation(3, sparse) + + pop_df = pd.read_csv(os.path.join(generator.output_dir, "populations.csv")) + assert len(pop_df) == 12 + assert "obs1" not in pop_df.columns + assert pop_df[pop_df["xopt_generation"] == 3]["xopt_runtime"].isna().all() + assert pop_df[pop_df["xopt_generation"] == 2]["xopt_runtime"].notna().all() + generator.close_log_file() + + +@pytest.mark.parametrize("checkpoint_freq, expected", [(1, 4), (2, 2), (-1, 0)]) +def test_checkpoint_frequency(tmp_path, checkpoint_freq, expected): + generator = make_generator(tmp_path / "run", checkpoint_freq=checkpoint_freq) + for index in range(1, 5): + run_generation(generator, index) + + checkpoint_dir = os.path.join(generator.output_dir, "checkpoints") + written = len(os.listdir(checkpoint_dir)) if os.path.isdir(checkpoint_dir) else 0 + assert written == expected + generator.close_log_file() + + +def test_no_output_dir(tmp_path, module_logger): + generator = make_generator(None) + run_generation(generator, 1) + + # Nothing written, but the generator still logs to the module logger + assert os.listdir(tmp_path) == [] + generator._logger.info("still logging") + assert "still logging" in module_logger.messages + + +def test_log_file_receives_records_and_closes(tmp_path, module_logger): + generator = make_generator(tmp_path / "run") + generator._prepare_output() + generator._logger.info("after prepare") + + assert "after prepare" in module_logger.messages + generator.close_log_file() + + with open(os.path.join(generator.output_dir, "log.txt")) as f: + assert "after prepare" in f.read() + assert not generator._logger.handlers diff --git a/xopt/tests/generators/ga/test_nsga2.py b/xopt/tests/generators/ga/test_nsga2.py index 6a7b3e1b..8cd753f4 100644 --- a/xopt/tests/generators/ga/test_nsga2.py +++ b/xopt/tests/generators/ga/test_nsga2.py @@ -218,7 +218,7 @@ def nsga2_optimization_with_checkpoint(): ) # Hack to avoid log error on windows: "The process cannot access the file because it is being used by another process" - generator.get_output() + generator._prepare_output() generator.close_log_file() # Run a few optimization steps @@ -888,7 +888,7 @@ def test_nsga2_output_inhomogenous_data(): ) # Hack to avoid log error on windows: "The process cannot access the file because it is being used by another process" - generator.get_output() + generator._prepare_output() generator.close_log_file() # Run a few optimization steps diff --git a/xopt/tests/generators/ga/test_outputs.py b/xopt/tests/generators/ga/test_outputs.py deleted file mode 100644 index 116e147a..00000000 --- a/xopt/tests/generators/ga/test_outputs.py +++ /dev/null @@ -1,241 +0,0 @@ -import logging -import os - -import pandas as pd -import pytest - -from xopt.generators.ga.outputs import GAOutputs -from xopt.resources.test_functions.tnk import tnk_vocs - -LOGGER_NAME = "xopt.tests.generators.ga.test_outputs" - - -def make_population( - size: int, generation: int, extra: dict | None = None -) -> list[dict]: - """Build a population of individuals carrying all VOCS and metadata columns.""" - population = [] - for idx in range(size): - individual = { - "x1": 0.1 * idx, - "x2": 0.2 * idx, - "y1": 1.0 * idx, - "y2": 2.0 * idx, - "c1": -1.0, - "c2": -1.0, - "xopt_candidate_idx": generation * size + idx, - "xopt_runtime": 0.1, - "xopt_error": False, - } - if extra is not None: - individual.update(extra) - population.append(individual) - return population - - -def make_data(n_rows: int) -> pd.DataFrame: - return pd.DataFrame( - { - "x1": [0.1 * i for i in range(n_rows)], - "x2": [0.2 * i for i in range(n_rows)], - "xopt_candidate_idx": list(range(n_rows)), - "xopt_parent_generation": [0] * n_rows, - } - ) - - -def test_construction_touches_nothing(tmp_path): - # Building the object must not create anything. Generators are constructed and - # deserialized freely, and each round trip would otherwise leave a directory. - requested = str(tmp_path / "run") - outputs = GAOutputs(requested, LOGGER_NAME) - - assert outputs.output_dir == requested - assert outputs.enabled - assert os.listdir(tmp_path) == [] - - -def test_prepare_creates_directory_and_is_idempotent(tmp_path): - requested = str(tmp_path / "run") - outputs = GAOutputs(requested, LOGGER_NAME) - - assert outputs.prepare() is None - assert outputs.output_dir == requested - assert os.path.isdir(requested) - - # A second call changes nothing - assert outputs.prepare() is None - assert outputs.output_dir == requested - - -def test_existing_empty_directory_is_not_renamed(tmp_path): - # A directory which exists but holds nothing is reused as-is. Tests which hand - # the generator a TemporaryDirectory depend on this. - requested = str(tmp_path / "run") - os.makedirs(requested) - - outputs = GAOutputs(requested, LOGGER_NAME) - assert outputs.prepare() is None - assert outputs.output_dir == requested - - -def test_non_empty_directory_is_renamed(tmp_path): - requested = str(tmp_path / "run") - os.makedirs(requested) - (tmp_path / "run" / "data.csv").write_text("existing\n") - - first = GAOutputs(requested, LOGGER_NAME) - assert first.prepare() == requested - assert first.output_dir == f"{requested}_2" - assert os.path.isdir(f"{requested}_2") - - # The original directory is left untouched - assert (tmp_path / "run" / "data.csv").read_text() == "existing\n" - - # A second collision steps to the next suffix - (tmp_path / "run_2" / "data.csv").write_text("existing\n") - second = GAOutputs(requested, LOGGER_NAME) - second.prepare() - assert second.output_dir == f"{requested}_3" - - -def test_assigning_output_dir_reprepares(tmp_path): - outputs = GAOutputs(str(tmp_path / "first"), LOGGER_NAME) - outputs.prepare() - - outputs.output_dir = str(tmp_path / "second") - assert outputs.prepare() is None - assert os.path.isdir(tmp_path / "second") - - -def test_disabled_outputs(tmp_path): - outputs = GAOutputs(None, LOGGER_NAME) - - assert not outputs.enabled - assert outputs.prepare() is None - - # Registering a generation is a no-op rather than an error - outputs.register_generation(1, make_population(4, 0), make_data(4), tnk_vocs) - assert os.listdir(tmp_path) == [] - - -def test_paths_resolve_under_resolved_directory(tmp_path): - requested = str(tmp_path / "run") - os.makedirs(requested) - (tmp_path / "run" / "data.csv").write_text("existing\n") - - outputs = GAOutputs(requested, LOGGER_NAME) - outputs.prepare() - resolved = f"{requested}_2" - assert outputs.data_path == os.path.join(resolved, "data.csv") - assert outputs.population_path == os.path.join(resolved, "populations.csv") - assert outputs.checkpoint_dir == os.path.join(resolved, "checkpoints") - assert outputs.log_path == os.path.join(resolved, "log.txt") - - -def test_register_generation_writes_both_files(tmp_path): - outputs = GAOutputs(str(tmp_path / "run"), LOGGER_NAME) - outputs.register_generation(1, make_population(4, 0), make_data(8), tnk_vocs) - - data_df = pd.read_csv(outputs.data_path) - assert len(data_df) == 8 - - pop_df = pd.read_csv(outputs.population_path) - assert len(pop_df) == 4 - assert (pop_df["xopt_generation"] == 1).all() - assert list(pop_df.columns) == tnk_vocs.all_names + [ - "xopt_generation", - "xopt_candidate_idx", - "xopt_runtime", - "xopt_error", - ] - - -def test_data_overwritten_while_populations_accumulate(tmp_path): - outputs = GAOutputs(str(tmp_path / "run"), LOGGER_NAME) - outputs.register_generation(1, make_population(4, 0), make_data(4), tnk_vocs) - outputs.register_generation(2, make_population(4, 1), make_data(8), tnk_vocs) - - # data.csv is a full overwrite, so it reflects only the latest call - assert len(pd.read_csv(outputs.data_path)) == 8 - - # populations.csv is appended and carries exactly one header line - pop_df = pd.read_csv(outputs.population_path) - assert len(pop_df) == 8 - assert sorted(pop_df["xopt_generation"].unique()) == [1, 2] - with open(outputs.population_path) as f: - header_count = sum(1 for line in f if line.startswith("x1,")) - assert header_count == 1 - - -def test_register_generation_normalizes_changing_schema(tmp_path): - outputs = GAOutputs(str(tmp_path / "run"), LOGGER_NAME) - outputs.register_generation(1, make_population(4, 0), make_data(4), tnk_vocs) - - # A later generation gaining an extra key must not shift the appended columns - outputs.register_generation( - 2, make_population(4, 1, extra={"obs1": 3.0}), make_data(8), tnk_vocs - ) - - sparse = make_population(4, 2) - for individual in sparse: - del individual["xopt_runtime"] - outputs.register_generation(3, sparse, make_data(12), tnk_vocs) - - pop_df = pd.read_csv(outputs.population_path) - assert len(pop_df) == 12 - assert "obs1" not in pop_df.columns - assert pop_df[pop_df["xopt_generation"] == 3]["xopt_runtime"].isna().all() - assert pop_df[pop_df["xopt_generation"] == 2]["xopt_runtime"].notna().all() - - -class RecordingHandler(logging.Handler): - """Captures messages so propagation to the parent logger can be checked.""" - - def __init__(self): - super().__init__() - self.messages = [] - - def emit(self, record): - self.messages.append(record.getMessage()) - - -@pytest.fixture -def parent_logger(): - """The logger GAOutputs loggers are created beneath, with a capturing handler.""" - logger = logging.getLogger(LOGGER_NAME) - handler = RecordingHandler() - logger.addHandler(handler) - logger.setLevel(logging.DEBUG) - yield handler - logger.removeHandler(handler) - - -def test_logger_propagates_without_output(parent_logger): - outputs = GAOutputs(None, LOGGER_NAME, log_level=logging.DEBUG) - - outputs.logger.info("no output configured") - assert "no output configured" in parent_logger.messages - - -def test_logger_writes_to_file_and_propagates(tmp_path, parent_logger): - outputs = GAOutputs(str(tmp_path / "run"), LOGGER_NAME, log_level=logging.DEBUG) - outputs.prepare() - outputs.logger.info("after prepare") - outputs.close_log() - - # Reaches the parent logger - assert "after prepare" in parent_logger.messages - - # ... and the log file - with open(outputs.log_path) as f: - assert "after prepare" in f.read() - - -def test_close_log_removes_handlers(tmp_path): - outputs = GAOutputs(str(tmp_path / "run"), LOGGER_NAME) - outputs.prepare() - assert outputs.logger.handlers - - outputs.close_log() - assert not outputs.logger.handlers From d41993a3cc99e12529ee5aa0b2c49eb2eef35d93 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Tue, 11 Aug 2026 16:27:08 -0700 Subject: [PATCH 11/13] allow os.PathLike --- xopt/generators/ga/base.py | 16 ++++++++++++---- xopt/generators/ga/nsga2.py | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/xopt/generators/ga/base.py b/xopt/generators/ga/base.py index d60dd179..550ab613 100644 --- a/xopt/generators/ga/base.py +++ b/xopt/generators/ga/base.py @@ -1,4 +1,4 @@ -from pydantic import Field +from pydantic import Field, field_validator import logging import os import pandas as pd @@ -28,10 +28,10 @@ class GAGeneratorBase(CheckpointMixin, DeduplicatedGeneratorBase): Parameters ---------- - output_dir : str, optional + output_dir : str or os.PathLike, optional Directory to save algorithm state and population history, or None to write - nothing. If the directory already contains data, a number is appended to - avoid overwriting it. + nothing. Stored as a string. If the directory already contains data, a number + is appended to avoid overwriting it. checkpoint_freq : int, default=1 Frequency (in generations) at which checkpoints are saved. Set to -1 to disable checkpointing. @@ -51,6 +51,14 @@ class GAGeneratorBase(CheckpointMixin, DeduplicatedGeneratorBase): False # Whether the output directory has been resolved and created ) + @field_validator("output_dir", mode="before") + @classmethod + def validate_output_dir(cls, value): + """Accept any os.PathLike, storing it as a string.""" + if isinstance(value, os.PathLike): + return os.fspath(value) + return value + def model_post_init(self, context): # Get a unique logger per object. Naming it after the concrete class keeps # records propagating through that class's module logger. diff --git a/xopt/generators/ga/nsga2.py b/xopt/generators/ga/nsga2.py index 0566ca98..5e35092e 100644 --- a/xopt/generators/ga/nsga2.py +++ b/xopt/generators/ga/nsga2.py @@ -327,7 +327,7 @@ class NSGA2Generator(GAGeneratorBase, StateOwner): Operator used to perform crossover between parent solutions. mutation_operator : PolynomialMutation or DummyMutation, default=PolynomialMutation() Operator used to perform mutation on offspring solutions. - output_dir : str, optional + output_dir : str or os.PathLike, optional Directory to save algorithm state and population history. checkpoint_freq : int, default=1 Frequency (in generations) at which to save checkpoints. From 95c3bd6eef7a8ade0e4c06a81a1d6ee90f65ab69 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Tue, 11 Aug 2026 16:31:55 -0700 Subject: [PATCH 12/13] use environment variables and home char in output directory --- xopt/generators/ga/base.py | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/xopt/generators/ga/base.py b/xopt/generators/ga/base.py index 550ab613..90b84544 100644 --- a/xopt/generators/ga/base.py +++ b/xopt/generators/ga/base.py @@ -30,13 +30,20 @@ class GAGeneratorBase(CheckpointMixin, DeduplicatedGeneratorBase): ---------- output_dir : str or os.PathLike, optional Directory to save algorithm state and population history, or None to write - nothing. Stored as a string. If the directory already contains data, a number - is appended to avoid overwriting it. + nothing. Stored as a string, unexpanded; environment variables and "~" are + expanded when the path is used. If the directory already contains data, a + number is appended to avoid overwriting it. checkpoint_freq : int, default=1 Frequency (in generations) at which checkpoints are saved. Set to -1 to disable checkpointing. log_level : int Level of log messages written to "log.txt". + + Attributes + ---------- + expanded_output_dir : str or None + `output_dir` with environment variables and "~" expanded. All file writes go + here. """ output_dir: str | None = None @@ -59,6 +66,13 @@ def validate_output_dir(cls, value): return os.fspath(value) return value + @property + def expanded_output_dir(self) -> str | None: + """Output directory with environment variables and "~" expanded.""" + if self.output_dir is None: + return None + return os.path.expanduser(os.path.expandvars(self.output_dir)) + def model_post_init(self, context): # Get a unique logger per object. Naming it after the concrete class keeps # records propagating through that class's module logger. @@ -79,11 +93,14 @@ def _prepare_output(self) -> None: # Check if directory exists and do collision avoidance. Resolve into a local # so the field is only assigned once, since assignment revalidates the model. + # Suffixes are applied to the unexpanded path, but tested against the expanded one. requested = self.output_dir counter = 2 output_dir = requested - while os.path.exists(output_dir) and os.listdir(output_dir): + expanded = self.expanded_output_dir + while os.path.exists(expanded) and os.listdir(expanded): output_dir = f"{requested}_{counter}" + expanded = os.path.expanduser(os.path.expandvars(output_dir)) counter += 1 if output_dir != requested: self._logger.info( @@ -93,11 +110,11 @@ def _prepare_output(self) -> None: self.output_dir = output_dir # We are now setup - os.makedirs(self.output_dir, exist_ok=True) + os.makedirs(self.expanded_output_dir, exist_ok=True) self._output_prepared = True # Set up file logging - log_file_path = os.path.join(self.output_dir, "log.txt") + log_file_path = os.path.join(self.expanded_output_dir, "log.txt") file_handler = logging.FileHandler(log_file_path, mode="w") file_handler.setLevel(self.log_level) file_handler.setFormatter( @@ -120,10 +137,11 @@ def end_generation(self, generation_index: int, population: list[dict]) -> None: self._prepare_output() if self.output_dir is None: return + output_dir = self.expanded_output_dir save_start_t = time.perf_counter() # Save all Xopt data - self.data.to_csv(os.path.join(self.output_dir, "data.csv"), index=False) + self.data.to_csv(os.path.join(output_dir, "data.csv"), index=False) # Construct the DataFrame for this population pop_df = pd.DataFrame(population) @@ -136,19 +154,19 @@ def end_generation(self, generation_index: int, population: list[dict]) -> None: ) # Write population DataFrame to file - csv_path = os.path.join(self.output_dir, "populations.csv") + csv_path = os.path.join(output_dir, "populations.csv") pop_df.to_csv( csv_path, index=False, mode="a", header=not os.path.isfile(csv_path) ) self._logger.debug( - f'saved optimization data to "{self.output_dir}" ' + f'saved optimization data to "{output_dir}" ' f"in {1000 * (time.perf_counter() - save_start_t):.2f}ms" ) # Save a checkpoint if one is due if self.checkpoint_freq > 0 and (generation_index % self.checkpoint_freq == 0): checkpoint_path = self._save_checkpoint( - os.path.join(self.output_dir, "checkpoints") + os.path.join(output_dir, "checkpoints") ) self._logger.debug(f'saved checkpoint file "{checkpoint_path}"') From 78891c6c7db35f7d9f4550fd6c8e40668cc03caa Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 13 Aug 2026 23:17:40 -0700 Subject: [PATCH 13/13] add `vocs.txt` back --- docs/examples/ga/nsga2/nsga2_python.ipynb | 1 + docs/examples/ga/nsga2/yaml_interface/index.md | 1 + docs/examples/ga/nsga2/yaml_interface/nsga2_yaml.ipynb | 1 + xopt/generators/ga/base.py | 6 ++++++ 4 files changed, 9 insertions(+) diff --git a/docs/examples/ga/nsga2/nsga2_python.ipynb b/docs/examples/ga/nsga2/nsga2_python.ipynb index 89576db4..83aeafdf 100644 --- a/docs/examples/ga/nsga2/nsga2_python.ipynb +++ b/docs/examples/ga/nsga2/nsga2_python.ipynb @@ -202,6 +202,7 @@ "The output files are the following.\n", " - `data.csv`: All data evaluated during the optimization\n", " - `populations.csv`: Each population is written here with a column `xopt_generation` to distinguish which generation the row belongs to\n", + " - `vocs.txt`: The VOCS object so that the objectives, constraints, and decision variables are retained alongside the data\n", " - `checkpoints`: This generator periodically saves its full state to timestamped files in this directory\n", " - `log.txt`: Log output from the generator is recorded to this file\n", "\n", diff --git a/docs/examples/ga/nsga2/yaml_interface/index.md b/docs/examples/ga/nsga2/yaml_interface/index.md index 91d4ff82..2e293e6d 100644 --- a/docs/examples/ga/nsga2/yaml_interface/index.md +++ b/docs/examples/ga/nsga2/yaml_interface/index.md @@ -255,6 +255,7 @@ Navigate to the output directory and observe the files there. - `populations.csv`: Each completed population is recorded to this file - `data.csv`: Contains all evaluated inviduals - `log.txt`: A record of all log messages the genreator emitted during its run +- `vocs.txt`: A copy of the variable, objectives, and constraints (VOCs) definitions - `checkpoints/`: This directory contains checkpoint files which are used with the `checkpoint_file` key of the generator to restart an optimization. diff --git a/docs/examples/ga/nsga2/yaml_interface/nsga2_yaml.ipynb b/docs/examples/ga/nsga2/yaml_interface/nsga2_yaml.ipynb index 63da9e81..1f0635d3 100644 --- a/docs/examples/ga/nsga2/yaml_interface/nsga2_yaml.ipynb +++ b/docs/examples/ga/nsga2/yaml_interface/nsga2_yaml.ipynb @@ -52,6 +52,7 @@ "- `populations.csv`: Each completed population is recorded to this file\n", "- `data.csv`: Contains all evaluated inviduals\n", "- `log.txt`: A record of all log messages the genreator emitted during its run\n", + "- `vocs.txt`: A copy of the variable, objectives, and constraints (VOCs) definitions\n", "- `checkpoints/`: This directory contains checkpoint files which are used with the `checkpoint_file` key of the generator to restart an optimization." ] }, diff --git a/xopt/generators/ga/base.py b/xopt/generators/ga/base.py index 90b84544..8c96b295 100644 --- a/xopt/generators/ga/base.py +++ b/xopt/generators/ga/base.py @@ -123,6 +123,12 @@ def _prepare_output(self) -> None: self._logger.addHandler(file_handler) self._logger.info(f"routing log output to file: {log_file_path}") + # Record the problem definition alongside the data + # Note: this is necessary to include in output for users running analysis on the results + # ie to plot Pareto front, you need to know the names and direction of the objectives + with open(os.path.join(self.expanded_output_dir, "vocs.txt"), "w") as f: + f.write(self.vocs.model_dump_json()) + def end_generation(self, generation_index: int, population: list[dict]) -> None: """ Record a completed generation, writing output and checkpoints as configured.