diff --git a/docs/examples/basic/checkpointing_and_restarts.ipynb b/docs/examples/basic/checkpointing_and_restarts.ipynb index 30750324f..b122b40b1 100644 --- a/docs/examples/basic/checkpointing_and_restarts.ipynb +++ b/docs/examples/basic/checkpointing_and_restarts.ipynb @@ -10,8 +10,10 @@ }, "source": [ "# Checkpointing and Restarts\n", - "If `dump_file` is provided Xopt will save the data and the Xopt configuration in a\n", - "yaml file. This can be used directly to create a new Xopt object." + "If `xopt_dump_file` is provided Xopt will save the data and the Xopt configuration\n", + "in a yaml file. This can be used directly to create a new Xopt object. If\n", + "`data_dump_file` is provided Xopt will additionally write the evaluation data to a\n", + "csv file." ] }, { @@ -30,7 +32,7 @@ "\n", "# Make a proper input file.\n", "YAML = \"\"\"\n", - "dump_file: dump.yml\n", + "xopt_dump_file: dump.yml\n", "generator:\n", " name: random\n", " vocs:\n", diff --git a/xopt/base.py b/xopt/base.py index 0e1bc03bb..485070f5d 100644 --- a/xopt/base.py +++ b/xopt/base.py @@ -1,5 +1,6 @@ import json import logging +import os from copy import deepcopy from typing import Any, Optional, Union @@ -40,6 +41,11 @@ logger = logging.getLogger(__name__) +DUMP_FILE_RENAME_MESSAGE = ( + "The attribute `dump_file` in `Xopt` has been renamed to `xopt_dump_file`. " + "Support for the old name will be removed in later versions of the Xopt library." +) + class Xopt(XoptBaseModel): """ @@ -59,9 +65,13 @@ class Xopt(XoptBaseModel): strict : bool, optional A flag indicating whether exceptions raised during evaluation should stop the optimization process. - dump_file : str, optional + xopt_dump_file : str, optional An optional file path for dumping attributes of the xopt object and the - results of evaluations. + results of evaluations. Environment variables and `~` are expanded when + dumping. + data_dump_file : str, optional + An optional file path for dumping the evaluation data as a CSV file. + Environment variables and `~` are expanded when dumping. data : DataFrame, optional An optional DataFrame object for storing internal data related to the optimization process. @@ -98,6 +108,8 @@ class Xopt(XoptBaseModel): Serializes the Xopt configuration to a YAML string. dump(file: str = None, **kwargs) Dumps the Xopt configuration to a specified file. + dump_data() + Dumps the evaluation data to the file given by `data_dump_file`. dict(**kwargs) -> dict Provides a custom dictionary representation of the Xopt configuration. json(**kwargs) -> str @@ -115,8 +127,11 @@ class Xopt(XoptBaseModel): description="flag to indicate if exceptions raised during evaluation " "should stop Xopt", ) - dump_file: Optional[str] = Field( - None, description="file to dump the results of the evaluations" + xopt_dump_file: Optional[str] = Field( + None, description="file to dump the serialized Xopt object to" + ) + data_dump_file: Optional[str] = Field( + None, description="file to dump the evaluation data to as CSV" ) data: Optional[DataFrame] = Field(None, description="internal DataFrame object") serialize_torch: bool = Field( @@ -243,6 +258,35 @@ def max_evaluations_legacy(cls, data: Any): ) return data + @model_validator(mode="before") + @classmethod + def dump_file_legacy(cls, data: Any): + """ + Handle backward compatibility: convert the old dump_file parameter to + xopt_dump_file. + """ + if isinstance(data, dict) and "dump_file" in data: + warnings.warn(DUMP_FILE_RENAME_MESSAGE, DeprecationWarning, stacklevel=2) + dump_file = data.pop("dump_file") + if dump_file is not None: + if data.get("xopt_dump_file") is not None: + raise ValueError( + "Cannot specify both 'dump_file' and 'xopt_dump_file'. " + "Use 'xopt_dump_file' instead." + ) + data["xopt_dump_file"] = dump_file + return data + + @property + def dump_file(self) -> Optional[str]: + warnings.warn(DUMP_FILE_RENAME_MESSAGE, DeprecationWarning, stacklevel=2) + return self.xopt_dump_file + + @dump_file.setter + def dump_file(self, value: Optional[str]): + warnings.warn(DUMP_FILE_RENAME_MESSAGE, DeprecationWarning, stacklevel=2) + self.xopt_dump_file = value + @property def n_data(self) -> int: if self.data is None: @@ -440,9 +484,11 @@ def evaluate_data( self.add_data(output_data) - # dump data to file if specified - if self.dump_file is not None: + # dump to file(s) if specified + if self.xopt_dump_file is not None: self.dump() + if self.data_dump_file is not None: + self.dump_data() return output_data @@ -624,31 +670,58 @@ def yaml(self, **kwargs): def dump(self, file: str = None, **kwargs): """ - Dump data to a file. + Dump the Xopt configuration and data to a YAML file. + + Environment variables and `~` in the path are expanded. Parameters ---------- file : str, optional The path to the file where the Xopt configuration will be dumped. + Defaults to the `xopt_dump_file` attribute. **kwargs Additional keyword arguments for customizing the dump. Raises ------ ValueError - If no dump file is specified via argument or in the `dump_file` attribute. + If no dump file is specified via argument or in the `xopt_dump_file` + attribute. """ - fname = file if file is not None else self.dump_file + fname = file if file is not None else self.xopt_dump_file if fname is None: raise ValueError( - "no dump file specified via argument or in `dump_file` attribute" + "no dump file specified via argument or in `xopt_dump_file` attribute" ) - else: - with open(fname, "w") as f: - f.write(self.yaml(**kwargs)) - logger.debug(f"Dumped state to YAML file: {fname}") + + fname = os.path.expanduser(os.path.expandvars(fname)) + with open(fname, "w") as f: + f.write(self.yaml(**kwargs)) + logger.debug(f"Dumped state to YAML file: {fname}") + + def dump_data(self): + """ + Dump the evaluation data to the CSV file given by `data_dump_file`. + + Environment variables and `~` in the path are expanded. + + Raises + ------ + ValueError + If no data dump file is specified in the `data_dump_file` attribute. + + """ + if self.data_dump_file is None: + raise ValueError( + "no data dump file specified in `data_dump_file` attribute" + ) + + fname = os.path.expanduser(os.path.expandvars(self.data_dump_file)) + data = self.data if self.data is not None else pd.DataFrame() + data.to_csv(fname, index_label="xopt_index") + logger.debug(f"Dumped data to CSV file: {fname}") def dict(self, **kwargs) -> dict: """ diff --git a/xopt/tests/generators/bayesian/test_high_level.py b/xopt/tests/generators/bayesian/test_high_level.py index 617b9b775..0ceb52446 100644 --- a/xopt/tests/generators/bayesian/test_high_level.py +++ b/xopt/tests/generators/bayesian/test_high_level.py @@ -90,7 +90,7 @@ def test_mobo(self): def test_restart_torch_inline_serialization(self): YAML = """ - dump_file: dump_inline.yml + xopt_dump_file: dump_inline.yml serialize_torch: True serialize_inline: True @@ -136,7 +136,7 @@ def test_restart_torch_inline_serialization(self): def test_restart_torch_serialization(self): YAML = """ - dump_file: dump.yml + xopt_dump_file: dump.yml serialize_torch: True generator: @@ -179,7 +179,7 @@ def test_restart_torch_serialization(self): def test_restart(self): YAML = """ - dump_file: dump.yml + xopt_dump_file: dump.yml generator: name: mobo reference_point: {y1: 1.5, y2: 1.5} diff --git a/xopt/tests/generators/bayesian/test_turbo.py b/xopt/tests/generators/bayesian/test_turbo.py index ee0208e3e..82bc3c6ef 100644 --- a/xopt/tests/generators/bayesian/test_turbo.py +++ b/xopt/tests/generators/bayesian/test_turbo.py @@ -489,7 +489,7 @@ def test_serialization(self): X = Xopt( evaluator=evaluator, generator=generator, - dump_file="dump.yml", + xopt_dump_file="dump.yml", ) yaml_str = X.yaml() diff --git a/xopt/tests/generators/ga/test_cnsga.py b/xopt/tests/generators/ga/test_cnsga.py index aea06c678..e58611bbb 100644 --- a/xopt/tests/generators/ga/test_cnsga.py +++ b/xopt/tests/generators/ga/test_cnsga.py @@ -83,7 +83,7 @@ def test_cnsga_from_yaml(): stopping_condition: name: MaxEvaluationsCondition max_evaluations: 10 - dump_file: null + xopt_dump_file: null data: null generator: name: cnsga @@ -120,7 +120,7 @@ def test_cnsga_no_constraints(): stopping_condition: name: MaxEvaluationsCondition max_evaluations: 10 - dump_file: null + xopt_dump_file: null data: null generator: name: cnsga diff --git a/xopt/tests/test_xopt.py b/xopt/tests/test_xopt.py index 0bdf43e14..ae0b4d6d8 100644 --- a/xopt/tests/test_xopt.py +++ b/xopt/tests/test_xopt.py @@ -54,7 +54,7 @@ def dummy(x): # init with yaml YAML = """ - dump_file: null + xopt_dump_file: null data: null evaluator: function: xopt.resources.test_functions.tnk.evaluate_TNK @@ -107,7 +107,7 @@ def test_legacy_vocs_yaml(self): """ # init with yaml YAML = """ - dump_file: null + xopt_dump_file: null data: null evaluator: function: xopt.resources.test_functions.tnk.evaluate_TNK @@ -151,7 +151,7 @@ def test_legacy_vocs_duplicate_yaml(self): """ # init with yaml YAML = """ - dump_file: null + xopt_dump_file: null data: null evaluator: function: xopt.resources.test_functions.tnk.evaluate_TNK @@ -492,7 +492,7 @@ def test_dump_w_exploded_cols(self): generator=generator, evaluator=evaluator, ) - X.dump_file = "test_checkpointing.yaml" + X.xopt_dump_file = "test_checkpointing.yaml" # test case with exploded data data = pd.DataFrame( @@ -525,7 +525,7 @@ def test_checkpointing(self): generator=generator, evaluator=evaluator, ) - X.dump_file = "test_checkpointing.yaml" + X.xopt_dump_file = "test_checkpointing.yaml" X.step() @@ -533,13 +533,132 @@ def test_checkpointing(self): X.step() # try to load the state from nothing - X2 = Xopt.from_file(X.dump_file) + X2 = Xopt.from_file(X.xopt_dump_file) assert len(X2.data) == 6 assert isinstance(X2.generator, RandomGenerator) assert isinstance(X2.evaluator, Evaluator) assert X.vocs == X2.vocs + def test_dump_file_legacy_name(self): + evaluator = Evaluator(function=xtest_callable) + generator = RandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) + + with pytest.warns(DeprecationWarning, match="renamed to `xopt_dump_file`"): + X = Xopt( + generator=generator, + evaluator=evaluator, + dump_file="test_checkpointing.yaml", + ) + assert X.xopt_dump_file == "test_checkpointing.yaml" + + YAML = """ + dump_file: test_checkpointing.yaml + evaluator: + function: xopt.resources.test_functions.tnk.evaluate_TNK + + generator: + name: random + vocs: + variables: + x1: [0, 3.14159] + x2: [0, 3.14159] + objectives: {y1: MINIMIZE} + """ + with pytest.warns(DeprecationWarning, match="renamed to `xopt_dump_file`"): + X = Xopt(YAML) + assert X.xopt_dump_file == "test_checkpointing.yaml" + + def test_dump_file_legacy_property(self): + evaluator = Evaluator(function=xtest_callable) + generator = RandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) + + X = Xopt( + generator=generator, + evaluator=evaluator, + xopt_dump_file="test_checkpointing.yaml", + ) + + # Reading through the old name warns and gives the current value + with pytest.warns(DeprecationWarning, match="renamed to `xopt_dump_file`"): + assert X.dump_file == "test_checkpointing.yaml" + + # Assigning through the old name warns and writes through + with pytest.warns(DeprecationWarning, match="renamed to `xopt_dump_file`"): + X.dump_file = "other_checkpointing.yaml" + assert X.xopt_dump_file == "other_checkpointing.yaml" + + def test_dump_file_legacy_conflict(self): + evaluator = Evaluator(function=xtest_callable) + generator = RandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) + + with pytest.raises(ValidationError): + Xopt( + generator=generator, + evaluator=evaluator, + dump_file="legacy.yaml", + xopt_dump_file="test_checkpointing.yaml", + ) + + def test_data_dump_file(self, tmp_path): + evaluator = Evaluator(function=xtest_callable) + generator = RandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) + + data_dump_file = str(tmp_path / "data.csv") + X = Xopt( + generator=generator, + evaluator=evaluator, + data_dump_file=data_dump_file, + ) + X.random_evaluate(3) + + assert os.path.exists(data_dump_file) + dumped_data = pd.read_csv(data_dump_file, index_col="xopt_index") + pd.testing.assert_frame_equal( + dumped_data, X.data, check_dtype=False, check_names=False + ) + + # dumping directly writes the same data + os.remove(data_dump_file) + X.dump_data() + dumped_data = pd.read_csv(data_dump_file, index_col="xopt_index") + pd.testing.assert_frame_equal( + dumped_data, X.data, check_dtype=False, check_names=False + ) + + def test_dump_file_expandvars(self, tmp_path, monkeypatch): + evaluator = Evaluator(function=xtest_callable) + generator = RandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) + + monkeypatch.setenv("XOPT_TEST_DUMP_DIR", str(tmp_path)) + X = Xopt( + generator=generator, + evaluator=evaluator, + xopt_dump_file="$XOPT_TEST_DUMP_DIR/dump.yml", + data_dump_file="$XOPT_TEST_DUMP_DIR/data.csv", + ) + X.random_evaluate(1) + + assert os.path.exists(tmp_path / "dump.yml") + assert os.path.exists(tmp_path / "data.csv") + + def test_dump_without_files(self, tmp_path): + evaluator = Evaluator(function=xtest_callable) + generator = RandomGenerator(vocs=deepcopy(TEST_VOCS_BASE)) + + X = Xopt(generator=generator, evaluator=evaluator) + + # no dump files specified, evaluation should not write anything or raise + X.random_evaluate(1) + assert not list(tmp_path.iterdir()) + + # dumping explicitly requires a destination + with pytest.raises(ValueError): + X.dump() + + with pytest.raises(ValueError): + X.dump_data() + def test_random_evaluate(self): evaluator = Evaluator(function=xtest_callable) generator = RandomGenerator(vocs=deepcopy(TEST_VOCS_BASE))